# 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 // 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): ```php // 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)`):** ```php 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: `` — 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 ` *"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` (` 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 `kind`s (`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()`: ```php 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: ```php // 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.