Files
emcp-tools-mapping/docs/06-SANDBOX-CUSTOM-CODE.md
T
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

337 lines
35 KiB
Markdown

# 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: `<?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 `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.