Files
emcp-tools-mapping/docs/06-SANDBOX-CUSTOM-CODE.md
Claude Code 8ada367bd0 docs: mapeamento completo do EMCP Tools (11 docs, ~6070 linhas)
Especificação funcional do plugin emcp-tools v3.12.1 (msrbuilds/elementor-mcp,
GPL-2.0-or-later) via leitura directa do código-fonte em emanuelalmeida.pt.

- 00: arquitectura (bootstrap, ability registrar, dispatcher, MCP adapter)
- 01: Elementor classico (paginas, layout, widgets, templates, globals)
- 02: Elementor Atomic v4 + Gutenberg
- 03: WordPress core (conteudo, media, settings, temas)
- 04: Themer (CPT, condicoes, render, PHP templates)
- 05: Redirects + change ledger unificado (rollback)
- 06: Sandbox PHP snippets + custom widgets
- 07: Filesystem/DB/WP-CLI/Security/Performance (maior risco)
- 08: Integracoes terceiros (ACF, Meta Box, forms, SEO)
- 09: Stock images + Cloud + OAuth
- 10: Sistema de modulos + inventario Pro-only (30 classes)
- INDEX: sintese, sequencia de construcao, tabela de risco

Produzido por 10 subagentes code-explorer em paralelo + revisao cruzada de
consistencia. Cada doc inclui blueprint de replica (copiar/simplificar/omitir).
2026-08-19 06:41:04 +01:00

35 KiB

06 — Sandbox de PHP Snippets e Infraestrutura de Widgets/Blocos Custom

Este documento mapeia o subsistema "Sandbox" do EMCP Tools: a funcionalidade Free de PHP Snippets criados por agentes de IA com aprovação humana obrigatória, o contrato de export/import cross-artefacto (EMCP_Tools_Sandbox_Artifact), a infraestrutura de caminhos partilhada, e o Widget Builder (Pro) — cuja parte MCP está ausente desta build Free, deixando apenas a camada de armazenamento/admin acessível a partir do wp-admin. Documenta-se também class-mcpb-builder.php, cujo nome sugere parentesco com o Widget/Block Builder mas que é, na realidade, uma feature totalmente distinta (gerador de bundle Claude Desktop .mcpb).


1. Ability: EMCP_Tools_PHP_Snippet_Abilities

Ficheiro: includes/abilities/class-php-snippet-abilities.php

Condição de registo (class-ability-registrar.php, linhas ~416-421):

// PHP Snippet abilities (Sandbox) — free, capability-gated, no Elementor.
if ( class_exists( 'EMCP_Tools_PHP_Snippet_Abilities' ) ) {
    $php_snippets = new EMCP_Tools_PHP_Snippet_Abilities();
    $php_snippets->register();
    ...
}

A classe existe incondicionalmente na build Free — não há gate de Pro nem de Elementor. O gate real é feito por-tool via permission_callback, apoiado em duas capabilities WordPress nativas: manage_options (leitura) e manage_options + unfiltered_html (escrita — as mesmas capabilities que já permitem a um utilizador editar código de plugin).

Ability Input (resumo) O que faz Permission callback readonly / destructive
validate-php-snippet code (string, obrigatório) Verifica estaticamente o código SEM o guardar nem correr: confirma que faz parse e depois corre o scanner de segurança (execução de código, shell, escritas de ficheiro, rede, ofuscação, SQL destrutivo). Devolve relatório {valid, safe, parse_error, findings[]}. Pensado para iterar antes de create-php-snippet. check_read_permission → manage_options readonly=true, destructive=false, idempotent=true
create-php-snippet title?, code (obrigatório), context? (enum shortcode|hook|both), hook?, priority? Cria o snippet como DRAFT INACTIVO. Nunca corre. Valida primeiro; rejeita com invalid_php (erro de parse) ou unsafe_php (finding crítico) devolvendo o relatório de validação para o agente corrigir. check_edit_permission → manage_options AND unfiltered_html readonly=false, destructive=false, idempotent=false
update-php-snippet snippet_id (obrigatório) + campos de create-php-snippet (parciais) Actualiza código/config. Re-valida (mesmas rejeições). Se o snippet já estiver activo, recompila o executável em disco; se a recompilação falhar, degrada automaticamente para draft. A activação continua a exigir um passo humano separado. check_edit_permission readonly=false, destructive=false, idempotent=false
get-php-snippet snippet_id (obrigatório) Devolve o registo completo: código, status (draft/active), contexto de execução, shortcode gerado, e o último relatório de validação. check_read_permission readonly=true, idempotent=true
list-php-snippets status? (enum active|draft|any) Lista snippets com status, contexto e shortcode. Devolve {count, snippets[]}. check_read_permission readonly=true, idempotent=true
delete-php-snippet snippet_id (obrigatório) Apaga permanentemente o snippet (post CPT + ficheiro sandbox, se existir). check_edit_permission readonly=false, destructive=true, idempotent=false

Decisão de design central (citação directa do topo do ficheiro): "Lets an AI agent author, validate, read, and manage PHP snippets — but NEVER run them. There is intentionally no 'activate' tool: a snippet created via MCP is an inactive draft until a human administrator reviews it and activates it in the Sandbox admin screen."

Helper normalize_write_result(): transforma um WP_Error de rejeição de validação (invalid_php/unsafe_php) numa resposta estruturada {success:false, reason, validation} — em vez de um erro opaco, o agente recebe o relatório de findings completo para poder corrigir o código e tentar de novo. Em sucesso, acrescenta sempre uma note a lembrar que o draft precisa de aprovação de um administrador.


2. Ability: EMCP_Tools_Sandbox_Cloud_Abilities

Ficheiro: includes/abilities/class-sandbox-cloud-abilities.php

Condição de registo (class-ability-registrar.php, linhas ~429-434):

// Sandbox cloud export/import (free; operates over the bundle contract).
if ( class_exists( 'EMCP_Tools_Sandbox_Cloud_Abilities' ) ) {
    $cloud = new EMCP_Tools_Sandbox_Cloud_Abilities();
    $cloud->register();
    ...
}

Sempre registada na build Free (ao contrário do Widget Builder, esta classe não se autoguarda por Pro). O gate por-kind acontece dentro de resolve_artifact(), não na classe.

Ability Input (resumo) O que faz Permission callback readonly / destructive
export-sandbox-artifact kind (enum block|widget|snippet, obrigatório), id (int, obrigatório) Exporta um artefacto de sandbox como bundle portátil, verificado por checksum, pronto para partilha/sincronização cloud. Devolve {bundle: object}. current_user_can('manage_options') readonly=true, destructive=false, idempotent=true
import-sandbox-artifact bundle (object, obrigatório — produzido por export-sandbox-artifact) Importa um bundle como um novo draft local. O bundle é validado (versão de schema, checksum) antes de qualquer escrita. Devolve {id: int}. current_user_can('manage_options') readonly=false, destructive=false, idempotent=false

Resolução de artefacto (resolve_artifact($kind)):

switch ( $kind ) {
    case 'block':   return class_exists('EMCP_Tools_Block_Store') ? EMCP_Tools_Block_Store::instance() : null; // Pro-only, ausente nesta build
    case 'widget':  return new EMCP_Tools_Widget_Bundle_Adapter(); // sempre disponível
    case 'snippet': return new EMCP_Tools_Snippet_Bundle_Adapter(); // sempre disponível
    default:        return null;
}

Quando resolve_artifact() devolve null para um kind válido (block), a ability devolve um WP_Error pro_required em vez de fatal — falha limpa. Nota importante: mesmo com kind=widget resolúvel (o adapter existe sempre), a operação real de import ainda pode falhar internamente com WP_Error('forbidden', ...) dentro de EMCP_Tools_Widget_Store::create() se a licença Pro não estiver activa — o gate real vive na store, não no resolver.


3. Serviços de suporte

3.1 EMCP_Tools_PHP_Snippet_Store — includes/class-php-snippet-store.php

Storage: CPT privado emcp_php_snippet (public=false, show_in_rest=false, capability_type=page, map_meta_cap=true).

Meta keys: _emcp_snippet_code (código raw, wp_slash()-eado), _emcp_snippet_context, _emcp_snippet_hook, _emcp_snippet_priority, _emcp_snippet_validation (JSON do relatório), _emcp_snippet_hash (sha256 do ficheiro compilado), _emcp_snippet_error.

Post status = flag de activação: publish = activo, draft = inactivo. Este é o único mecanismo de "ligar/desligar" um snippet.

Ficheiro em disco: wp-content/emcp-sandbox/snippets/{id}.php — só existe enquanto o snippet está activo. Um draft não tem artefacto executável em disco. O ficheiro é gerado por write_executable(): o código é despido de tags PHP (strip_tags()), embrulhado numa função única (emcp_php_snippet_{id}()), e o resultado final passa por um token_get_all(..., TOKEN_PARSE) de segurança (guarda final antes de escrever).

Manifest: wp-content/emcp-sandbox/snippets-manifest.json — array de {post_id, func, php_path, hash, context, hook, priority} apenas dos snippets publish. Reconstruído (rebuild_manifest()) após qualquer create/update/set_status/delete/mark_error.

Permissões:

  • can_edit(): manage_options AND unfiltered_html
  • can_read(): manage_options

Fluxo CRUD:

  • create_draft() — sempre cria em draft; valida primeiro e rejeita com invalid_php/unsafe_php (WP_Error com o relatório em error_data['validation']).
  • update() — re-valida; se o snippet já estava activo, chama write_executable() de novo; se a escrita falhar, degrada automaticamente para draft e regista o erro.
  • set_status('active'|'draft') — é o portão de aprovação humana. Nunca é chamado pelas MCP abilities (só pelo handler AJAX do admin). Ao activar: re-valida (bloqueia com activation_blocked se inválido/inseguro), escreve o executável, muda para publish. Ao desactivar: apaga o ficheiro, apaga o hash, muda para draft.
  • mark_error() — chamado pelo loader quando um snippet crasha em runtime: desactiva automaticamente, regista o erro, reconstrói o manifest.
  • uninstall_cleanup() — apaga todos os posts + o manifest.json no desinstalar do plugin.

3.2 EMCP_Tools_PHP_Snippet_Loader — includes/class-php-snippet-loader.php

Corre em plugins_loaded, regista o shortcode [emcp_snippet id="N"] e carrega os snippets activos.

load() é manifest-only (nunca faz scan de directório). Para cada entrada do manifest:

  1. Path containment: 0 !== strpos(normalize(path), normalize(sandbox)) → salta (defende contra manifest envenenado).
  2. Tamper guard: recalcula hash('sha256', file_get_contents($path)) e compara com o hash registado → salta se não bater certo.
  3. include_once do ficheiro — isto só define a função, não executa código do utilizador.
  4. Se context for hook/both, faz add_action($hook, closure, $priority) que despoleta run_on_hook().

Execução:

  • render_shortcode() — só corre se context for shortcode/both; captura output via ob_start(); se a função devolver string/numérico, é concatenado ao output do buffer.
  • run_on_hook() — corre a função directamente no hook (output vai inline, ex.: wp_footer).

Isolamento de falhas — camada dupla:

  1. try { ... } catch (\Throwable $e) em cada execução.
  2. register_shutdown_function como rede de segurança para os fatais que um try/catch não apanha (E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR) — se um snippet crasha (mesmo em erro de parse do próprio ficheiro incluído), mark_error() desactiva-o automaticamente para a request seguinte recuperar. Um snippet mau não consegue white-screenar o site de forma persistente.

3.3 EMCP_Tools_PHP_Snippet_Validator — includes/class-php-snippet-validator.php — SECÇÃO CRÍTICA

Duas camadas de validação:

Camada 1 — PARSE. O código é embrulhado exactamente como vai correr: <?php function __emcp_snippet_validate() { CODE\n} — e passa por token_get_all($wrapped, TOKEN_PARSE). Um \ParseError/\Throwable aqui devolve valid=false com a mensagem de erro, sem sequer chegar ao scan de segurança.

Camada 2 — SECURITY SCAN. Percorre os tokens significativos (whitespace/comments removidos) e aplica regras por token e por vizinhança (prev/next).

Bloqueio CRÍTICO por nome de função (mapa severity → reason, bloqueia create/activate):

Categoria Funções
Execução de código arbitrário eval, assert, create_function
Shell / processo exec, system, shell_exec, passthru, proc_open, popen, pcntl_exec, expect_popen
Invocação dinâmica (bypassa este próprio check) call_user_func, call_user_func_array, forward_static_call, forward_static_call_array, func_get_args
Escritas/apagamentos de ficheiro file_put_contents, fwrite, fputs, fputcsv, ftruncate, unlink, rmdir, rename, copy, mkdir, chmod, chown, chgrp, symlink, link, move_uploaded_file
Rede curl_init, curl_exec, curl_setopt, fsockopen, pfsockopen, stream_socket_client, socket_create, socket_connect
Decoders de ofuscação (sinal nº1 de malware) base64_decode, gzinflate, gzuncompress, gzdecode, str_rot13, convert_uudecode, hex2bin
Runtime/ambiente dl, putenv, ini_set, ini_alter, apache_setenv, virtual, set_error_handler, register_shutdown_function, register_tick_function, extract

Bloqueio CRÍTICO por construto de linguagem (análise de tokens, não lista de nomes):

  • Backtick shell execution — `...`
  • include/include_once/require/require_once — bloqueia sempre, mesmo estático ("loads and runs another PHP file")
  • T_EVAL — construto de linguagem eval, além da função
  • Chamada de função por variável — $var(...) — "Calls a function named by a variable (bypasses static checks)"
  • Instanciação dinâmica — new $var — classe escolhida em runtime
  • Reflection/Closure factories — new ReflectionFunction/ReflectionMethod/ReflectionClass/ReflectionObject/Closure
  • Tag de fecho PHP embutida — ?> — bloqueia porque permitiria "escapar" do wrapper para output HTML cru
  • SQL destrutivo dentro de string literal — regex /\b(DROP|TRUNCATE|ALTER)\s+(TABLE|DATABASE)\b/i ou /\bDELETE\s+FROM\b/i

Apenas AVISO (warning, não bloqueia; fica visível ao revisor humano na UI):

  • Leitura de ficheiros: fopen, file_get_contents, readfile, fread, fgets, scandir, glob, opendir
  • define, header, setcookie, error_reporting
  • WordPress sensível: update_option, delete_option, add_option, wp_mail, wp_delete_post, wp_delete_user, wp_insert_user, wp_update_user, switch_theme, activate_plugin, deactivate_plugins, do_action
  • Callbacks dinâmicos — o vector clássico de bypass da lista crítica acima (ex.: array_map('system', $_GET['cmds']) evitaria a checagem directa de system): array_map, array_filter, array_walk, array_walk_recursive, array_reduce, usort, uasort, uksort, ob_start, preg_replace_callback, preg_replace_callback_array, set_exception_handler, iterator_apply
  • die/exit (T_EXIT)
  • Variável variável ($$x)
  • Supressão de erro (@)
  • Superglobais: $_GET, $_POST, $_REQUEST, $_FILES, $_COOKIE, $_SERVER, $_ENV, $GLOBALS
  • Definição de function/class/trait/interface dentro do snippet (risco de redeclaração fatal em re-execuções)

strip_tags()

Remove um único <?php (ou <?=/<?) inicial via regex, para aceitar código com ou sem tags de abertura — usado tanto na validação como na compilação final.

Postura de segurança declarada (citação directa do topo do ficheiro):

"IMPORTANT — this is a GUARDRAIL, not a guarantee. PHP is expressive enough to hide intent (variable functions, decoded strings, reflection), so static analysis cannot prove arbitrary code is safe. The real safety boundary is the capability gate (manage_options + unfiltered_html) plus the human approval step: an AI can create a DRAFT and run the validator, but only an admin can activate a snippet so it actually executes."

Esta mensagem é reforçada literalmente na UI de administração (admin/views/sandbox/snippets.php): "The validator blocks obviously dangerous code (...), but static analysis is a guardrail, not a guarantee, only activate code you have read and trust. Activation is the approval step; AI can only create inactive drafts."

3.4 EMCP_Tools_Sandbox_Paths — includes/sandbox/class-sandbox-paths.php

Centraliza todos os caminhos de sandbox (usado por snippets, widgets, blocks Pro, theme-php).

  • Base dir: wp-content/emcp-sandbox (nome filtrável via emcp_tools_sandbox_folder, caminho absoluto filtrável via emcp_tools_sandbox_dir, URL via emcp_tools_sandbox_url).
  • Localização legada: wp-content/uploads/emcp-widgets — antes de a v3.7 introduzir a pasta única sob wp-content/, os artefactos viviam dispersos sob uploads.
  • maybe_migrate() — migração automática one-time, corre no bootstrap (plugins_loaded), antes dos loaders (init). Tenta rename(); se falhar (device diferente), faz copy_tree() + rmdir_tree() como fallback. Guarda o flag em option('emcp_tools_sandbox_location') para nunca repetir. Insight de design: os manifests guardam caminhos relativos + hashes de conteúdo — por isso a migração nunca precisa de reconstruir um único manifest, todos os hashes continuam válidos contra a nova base.
  • harden() — escreve um index.php de silêncio + um .htaccess que bloqueia execução directa de .php (<FilesMatch "\.php$"> Require all denied) mas continua a permitir servir .css/.js estáticos.
  • guard_subdir() — garante index.php de silêncio em cada subpasta nova.
  • relative_base() — caminho relativo a ABSPATH, usado pelo scanner de malware (ver skill://emcp-tools, já auditada) para excluir o próprio PHP sandboxado do plugin da verificação de malware.

3.5 EMCP_Tools_Sandbox_Bundle — includes/sandbox/class-sandbox-bundle.php

Envelope de portabilidade partilhado por todos os kinds (block, widget, snippet).

  • SCHEMA_VERSION = 1, KINDS = ['block', 'widget', 'snippet'].
  • build() — monta {schema_version, kind, uuid, meta, spec, assets, version, updated_at, checksum}.
  • checksum() — ksort($assets) seguido de sha256(wp_json_encode($assets)) — determinístico, prefixado sha256:.
  • validate() — valida schema_version (1..SCHEMA_VERSION), kind conhecido, presença de todas as chaves obrigatórias, assets é array, e recalcula o checksum e compara — devolve WP_Error('bundle_checksum', ...) ("tampered or corrupt") se não bater certo.

3.6 EMCP_Tools_Sandbox_Store (abstract) — includes/sandbox/class-sandbox-store.php

Classe-base comum para stores tipo-artefacto, implementa EMCP_Tools_Sandbox_Artifact parcialmente.

  • Meta keys partilhadas: _emcp_uuid, _emcp_origin, _emcp_remote_id, _emcp_sync_state, _emcp_version, _emcp_updated_at.
  • ensure_uuid()/uuid() — gera UUID4 uma vez, persiste.
  • bump_version() — incrementa versão, marca sync_state='dirty', regista updated_at.
  • sync_meta() — pacote de metadados de sincronização cloud.
  • artifact_dir()/artifact_url() — caminho e URL por artefacto (útil para enfileirar assets próprios de um artefacto, ex. script de editor de um bloco, cujo URL o resolver file: de block.json do WordPress não consegue calcular para uma sandbox fora de um plugin/tema).
  • Helpers I/O partilhados: write_file/read_file/delete_file/rmdir_recursive (com invalidação de opcache em escritas .php).

Nota de arquitectura: apesar de existir, esta classe abstract não é usada pelo EMCP_Tools_Widget_Store — que reimplementa manualmente os mesmos padrões (write_file/read_file/rmdir_recursive/sync-like meta). É consumida pelo Block Store (Pro, ausente desta build). A explicação plausível: o Widget Store é @since 1.9.0 (anterior), esta classe sandbox/ é claramente da geração @since 3.7.0 dos adapters cloud — dívida técnica de evolução do produto, não um erro.

3.7 Adapters — class-snippet-bundle-adapter.php e class-widget-bundle-adapter.php

Ambos implementam EMCP_Tools_Sandbox_Artifact sem tocar no store subjacente — thin adapters puros.

EMCP_Tools_Snippet_Bundle_Adapter EMCP_Tools_Widget_Bundle_Adapter
assets() {code.php: código raw NÃO compilado} — deliberadamente não o executável envolvido em função (esse é maquinaria local do store, re-derivada em cada import) {widget.php, style.css?, script.js?} — os ficheiros gerados (compilados)
to_bundle() Monta via EMCP_Tools_Sandbox_Bundle::build('snippet', ...) Monta via EMCP_Tools_Sandbox_Bundle::build('widget', ...), usando EMCP_Tools_Widget_Store::get_spec() como spec
apply_bundle() Chama EMCP_Tools_PHP_Snippet_Store::create_draft() — sempre novo draft, nunca activa Chama EMCP_Tools_Widget_Store::create($spec, false) — $active=false explícito, mesmo princípio

Ambos gravam _emcp_uuid = bundle.uuid e _emcp_origin = 'imported' no post recém-criado. O portão de aprovação humana é preservado mesmo no fluxo de import — importar um bundle nunca substitui a necessidade de um administrador activar o resultado.

3.8 interface EMCP_Tools_Sandbox_Artifact — includes/sandbox/interface-sandbox-artifact.php

Contrato mínimo, consumido por EMCP_Tools_Sandbox_Cloud_Abilities::resolve_artifact():

interface EMCP_Tools_Sandbox_Artifact {
    public function kind(): string;
    public function uuid( int $id ): string;
    public function to_bundle( int $id );          // array|WP_Error
    public function apply_bundle( array $bundle );  // int|WP_Error (novo id local)
    public function checksum( int $id ): string;
    public function sync_meta( int $id ): array;
}

3.9 EMCP_Tools_Widget_Store — includes/class-widget-store.php (839 linhas na tarefa)

CONFIRMAÇÃO DIRECTA: este ficheiro existe na build Free (lido na íntegra via SSH). O que não existe é includes/abilities/class-widget-builder-abilities.php — ausente da listagem de includes/abilities/ neste servidor. O registrar só instancia condicionalmente:

// Widget Builder (Pro; self-guards on license).
if ( class_exists( 'EMCP_Tools_Widget_Builder_Abilities' ) ) {
    $widget_builder = new EMCP_Tools_Widget_Builder_Abilities();
    $widget_builder->register();
    ...
}

Como o ficheiro dessa classe não existe, class_exists() é sempre false nesta build → create-custom-widget, create-custom-block e afins NÃO estão registadas como MCP abilities. "Self-guards on license" refere-se ao comportamento se o overlay Pro privado estivesse presente: a própria classe verificaria a licença dentro de si. Aqui nem chega a ser definida.

Também confirmado ausente: includes/class-widget-generator.php (o compilador spec→PHP) e includes/class-block-store.php — nenhum dos dois consta da listagem de includes/ desta build. Isto confirma explicitamente a nota da própria class-sandbox-cloud-abilities.php: "the 'block' kind is Pro-only via the resolver: EMCP_Tools_Block_Store is absent on free sites".

Duas camadas de gate independentes para a funcionalidade real (mesmo hipotetizando que a classe de abilities existisse):

  1. Gate de licença Freemius (soft — a classe corre, devolve false): user_has_access() → emcp_tools_fs()->can_use_premium_code() && current_user_can('manage_options').
  2. Gate de código ausente (hard): write_widget() verifica class_exists('EMCP_Tools_Widget_Generator') — o compilador spec→PHP, ficheiro Pro-only ausente desta árvore. Se ausente, devolve WP_Error('emcp_pro_required', 'Widget Builder requires EMCP Pro.') mesmo que user_has_access() fosse true.

O que a classe faz (arquitectura, documentada mesmo sem poder correr nesta build):

  • CPT privado emcp_widget. Meta: _emcp_spec (JSON regenerável, fonte-de-verdade), _emcp_widget_name, _emcp_class_name, _emcp_php_hash, _emcp_css_hash, _emcp_js_hash, _emcp_last_error.
  • Post status = activação: publish = activo (carregado no Elementor), draft = inactivo.
  • Storage: wp-content/emcp-sandbox/widgets/{id}/widget.php (+ style.css/script.js opcionais).
  • Nunca escreve no tema, no core ou noutros plugins — sandbox isolada, tal como os snippets (declarado no cabeçalho do ficheiro).
  • create(spec, active=true): insere o post primeiro (para o ID poder semear nomes únicos de classe EMCP_Widget_{id}/widget emcp_custom_{id}), depois write_widget(), e se $active chama safeguard_active() antes de aparecer no manifest.
  • write_widget(): chama EMCP_Tools_Widget_Generator::generate($spec, $class_name, $widget_name, $handles) — compila a spec estruturada em PHP. O agente de IA nunca escreve PHP cru, só a spec JSON — confirmado pelo texto da própria UI admin: "these widgets are PHP compiled by this plugin from an AI-supplied spec (the AI never writes raw PHP). Output is escaped by control type."
  • Defesa de injecção nos assets estáticos: CSS/JS aceitam ficheiros completos opcionais, mas com preg_replace('/<\?(?:php|=)?/i', '', $out) — garante que um .css/.js gerado nunca pode ser executado como PHP mesmo com short_open_tag activo num servidor mal configurado.
  • runtime_validate() — safeguard notável: instancia a classe gerada e chama $instance->get_stack() (força init_controls() → register_controls(), exactamente o que o editor Elementor faz), antes de deixar o widget ir para produção. Se rebentar, é apanhado aqui em vez de dar white-screen no painel do editor Elementor. Chamado tanto em create() como em set_status('active').
  • safeguard_active(): se a validação runtime falhar, degrada automaticamente para draft e regista o erro — nunca deixa algo quebrado ficar "activo".
  • mark_error(): chamado pelo loader em runtime (paralelo ao snippet loader) quando um widget crasha após já ter sido carregado.
  • Mesmo padrão manifest-only (rebuild_manifest()/read_manifest()) dos snippets.
  • uninstall_cleanup(): apaga todos os posts e faz rmdir_recursive(self::sandbox_dir()) — a árvore sandbox inteira, não só widgets/. Note-se que isto é diferente do PHP_Snippet_Store::uninstall_cleanup(), que só apaga os seus próprios ficheiros individuais — risco arquitectural a evitar numa réplica se vários subsistemas partilharem a mesma raiz de sandbox (aqui não causa dano porque o desinstalar do plugin apaga tudo de uma vez, mas é frágil).

3.10 EMCP_Tools_Widget_Loader — includes/class-widget-loader.php (bónus — runtime companion do Widget Store)

Não foi pedido explicitamente na lista de ficheiros, mas foi lido para fechar a compreensão do ciclo de vida completo do widget (paralelo directo ao PHP_Snippet_Loader).

  • has_access(): mesmo gate Freemius do Store — "The whole loader is Pro-gated: on a free/unlicensed site nothing is loaded" (comentário do próprio autor).
  • Regista categoria Elementor "Custom (EMCP)" (slug emcp-custom) só se has_access().
  • register_widgets(): manifest-only, tamper guard sha256, path-containment guard — padrão idêntico ao snippet loader.
  • register_assets(): wp_register_style/wp_register_script por widget com handle emcp-widget-{id}-style/-script, versionados pelo hash do ficheiro (cache-busting automático em cada regeneração).
  • Mesma rede de segurança de shutdown handler + isolamento de fatais que o snippet loader.

3.11 EMCP_Tools_Mcpb_Builder — includes/admin/class-mcpb-builder.php — NÃO é "Widget/Block Builder"

Desambiguação explícita, confirmada por leitura directa do ficheiro: apesar de o nome "MCP Builder" sugerir parentesco com o Widget/Block Builder, esta é uma feature completamente distinta e não relacionada com o sandbox de código custom. Constrói um bundle .mcpb (formato Claude Desktop Extension) que instala um servidor MCP standalone que faz proxy para o REST API do WordPress — é o mecanismo para ligar o Claude Desktop directamente a este site sem passar por um MCP host remoto.

  • build_manifest(): gera manifest.json versão MCPB 0.3. O name é único por site, derivado do host (host_slug()), porque "Claude Desktop identifies extensions by the manifest name (not the filename or display_name), so the name must be unique per site or a second install replaces the first" — comentário que referencia um bug real (#86) já reportado.
  • mcp_config.args usa ${__dirname}/server/index.js (variável de substituição MCPB) em vez de caminho relativo — comentário do autor explica: "Claude Desktop does not cd into the extracted bundle dir before running node, so a relative path resolves against the wrong CWD and Node throws 'Cannot find module' → instant 'Server disconnected'".
  • build_zip(): lê bin/mcp-proxy.mjs (fonte ESM, testável com node --test), converte para CJS self-contained via regex simples (troca import ... from 'node:X' por require('X'), remove export), e embute as credenciais (WP_URL, WP_USERNAME, WP_APP_PASSWORD) como overrides de process.env no topo do ficheiro — para funcionar mesmo que o host Claude Desktop não injecte mcp_config.env.
  • validate_server_js(): sanity check pós-build — confirma presença dos 4 require() esperados e ausência de qualquer import/export ESM residual; falha cedo em vez de distribuir um bundle partido.
  • Disparado pelo admin-post emcp_tools_download_mcpb (NONCE_DOWNLOAD_MCPB, visto em includes/admin/class-admin.php), a partir do separador "Connection" das definições do plugin — nada a ver com a página Sandbox.

4. Blueprint para réplica

Copiar quase 1:1

  1. O validador em 3 camadas (parse → scan de tokens → classificação critical/warning) — é o coração da segurança deste subsistema e está bem pensado: cobre tanto nomes de função óbvios como vectores de bypass (chamada dinâmica, callbacks). A lista de funções críticas está bem pesquisada; copiar quase literalmente.
  2. O padrão "manifest-only loading + tamper guard sha256 + path containment check" — usado tanto no snippet loader como no widget loader. É elegante e evita I/O desnecessário em cada request (nunca faz scan de directório).
  3. O padrão "shutdown handler + auto-deactivate on fatal" — garante que um snippet/widget mau nunca consegue white-screenar o site de forma persistente (recupera na request seguinte). Detalhe fácil de esquecer numa reescrita ingénua.
  4. O gate de duas camadas para escrita de PHP (create só pode produzir draft; activate é acção humana separada, nunca exposta via MCP) — é a decisão de design mais importante deste subsistema todo e deve ser preservada tal e qual: "There is intentionally no 'activate' tool" no MCP.
  5. O envelope de bundle (EMCP_Tools_Sandbox_Bundle) com checksum determinístico (ksort + sha256) — simples e eficaz para portabilidade/import-export entre sites.
  6. A defesa de injecção de tag PHP em ficheiros CSS/JS gerados (preg_replace('/<\?(?:php|=)?/i', '', $out)) — detalhe fácil de esquecer que evita um vector de RCE se short_open_tag estiver activo no servidor de destino.
  7. runtime_validate() do Widget Store — instanciar e forçar get_stack() antes de activar, para nunca deixar código gerado partido chegar ao editor Elementor.

Simplificar

  • A dualidade EMCP_Tools_Sandbox_Store (abstract, @since 3.7.0) vs a implementação manual duplicada em EMCP_Tools_Widget_Store (@since 1.9.0) é dívida técnica visível — ambas fazem o mesmo (write_file/read_file/rmdir_recursive/sync-meta) com código quase idêntico. Numa reescrita, fazer o Widget Store herdar de Sandbox_Store desde o início.
  • A migração legada uploads/emcp-widgets → wp-content/emcp-sandbox (EMCP_Tools_Sandbox_Paths::maybe_migrate) só é necessária porque o produto original mudou de local a meio da vida. Numa réplica de raiz, ir directo para wp-content/{slug}-sandbox sem essa bagagem.

Deixar de fora ou adiar

  • O MCPB Builder (Claude Desktop .mcpb) é uma feature auxiliar completamente ortogonal ao sandbox — só implementar se o objectivo for também suportar o cliente Claude Desktop nativo além de MCP hosts remotos. Não é "custom code sandbox", é "distribuição de credenciais de ligação".
  • O sistema cloud/marketplace (Save to Cloud/Publish/View on Marketplace, visto nas views admin como EMCP_Tools_Admin::render_sandbox_cloud_actions()/render_cloud_library()) claramente pertence a outro documento (provavelmente o de integrações cloud/OAuth) — não aprofundado aqui, apenas mencionado como consumidor do contrato EMCP_Tools_Sandbox_Artifact.

Gotchas não óbvios (citações directas)

  • "Claude Desktop identifies extensions by the manifest name (not the filename or display_name), so the name must be unique per site or a second install replaces the first" — referenciando o bug #86 relatado.
  • "Claude Desktop does not cd into the extracted bundle dir before running node, so a relative path resolves against the wrong CWD" — motivo de usar ${__dirname}.
  • "PHP is expressive enough to hide intent (variable functions, decoded strings, reflection), so static analysis cannot prove arbitrary code is safe" — o autor é honesto sobre os limites do validador, não vende segurança que não tem.
  • EMCP_Tools_Widget_Store::uninstall_cleanup() apaga sandbox_dir() inteiro (não só widgets/) — cuidado ao portar esta lógica se outros artefactos (snippets, blocks) partilharem a mesma raiz de sandbox; podia apagar dados de outros subsistemas por engano num desinstalar parcial (aqui não acontece porque o plugin desinstala tudo de uma vez, mas é um risco arquitectural a ter em conta numa réplica com desinstalação modular).
  • A lista de funções warning inclui explicitamente callbacks dinâmicos (array_map, usort, preg_replace_callback, etc.) precisamente porque são o vector clássico para contornar a lista critical de chamada directa (ex.: array_map('system', $input)) — um detalhe de segurança sofisticado que vale a pena preservar em qualquer réplica do validador.

5. Fonte

Todos os ficheiros lidos via ssh://server/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/:

  • includes/abilities/class-php-snippet-abilities.php
  • includes/class-php-snippet-store.php
  • includes/class-php-snippet-loader.php
  • includes/class-php-snippet-validator.php
  • includes/abilities/class-sandbox-cloud-abilities.php
  • includes/sandbox/class-sandbox-paths.php
  • includes/sandbox/class-snippet-bundle-adapter.php
  • includes/sandbox/class-sandbox-bundle.php
  • includes/sandbox/class-sandbox-store.php
  • includes/sandbox/class-widget-bundle-adapter.php
  • includes/sandbox/interface-sandbox-artifact.php
  • includes/class-widget-store.php
  • includes/admin/class-mcpb-builder.php
  • includes/class-widget-loader.php (bónus, consultado para confirmar o runtime companion do Widget Store)
  • includes/admin/views/sandbox/widgets.php (confirmação da UI Pro-gated do Widget Builder)
  • includes/admin/views/sandbox/blocks.php (confirmação de que EMCP_Tools_Block_Store também é Pro-only/ausente)
  • includes/admin/views/sandbox/snippets.php (confirmação da UI de aprovação humana dos snippets)
  • includes/abilities/class-ability-registrar.php (grep pontual, linhas ~416-434 e ~549-554, gating exacto dos grupos)
  • includes/admin/class-admin.php (grep pontual: admin-post handle_download_mcpb + ajax handlers de toggle/delete de widget/block)
  • includes/abilities/class-custom-code-abilities.php (verificação rápida — confirmar que é feature distinta: Elementor Custom CSS/JS/Code Snippets, não faz parte deste subsistema)
  • Listagens de directório (via read em modo directório): includes/abilities/, includes/, includes/admin/, includes/admin/views/sandbox/ — usadas para confirmar a ausência de class-widget-builder-abilities.php, class-widget-generator.php e class-block-store.php nesta build Free.