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).
This commit is contained in:
Claude Code
2026-08-19 06:41:04 +01:00
commit 8ada367bd0
12 changed files with 6301 additions and 0 deletions
+629
View File
@@ -0,0 +1,629 @@
# 07 — System Ops: Filesystem, Base de Dados, WP-CLI, Security Scanner, Performance Analyzer
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
19-08-2026. Cruzado com `docs/00-ARQUITECTURA.md` (arquitectura geral, cadeia de arranque,
`emcp_tools_register_ability()`) e `skill://emcp-tools` (postura de segurança ao vivo nos 3
sites do ecossistema, mecanismo do deny-list incremental).
**Estes são os grupos de MAIOR RISCO do plugin.** Todas as 6 tools de filesystem que mutam
estado, todas as 6 de base de dados que mutam estado, e as 4 de WP-CLI (incluindo as duas de
só leitura, `get-wp-cli-job`/`list-wp-cli-jobs`) fazem parte dos 131 slugs desligados por
omissão em `emanuelalmeida.pt`/`starter.descomplicar.pt` (ver `skill://emcp-tools` §2.1/§2.2).
Security Scanner e Performance Analyzer são as únicas duas ferramentas deste documento que ficam
**activas por omissão** — são estritamente de leitura, nunca escrevem nada.
Todos os grupos deste documento registam-se **sempre** (não dependem de Elementor activo, nem de
nenhum módulo opcional) — são chamados directamente em `EMCP_Tools_Ability_Registrar::register_groups()`,
fora de qualquer bloco condicional `if ( class_exists(...) )` ou `if ( $elementor_active )`. A
única coisa que os torna "inúteis" num site com config por omissão é estarem no deny-list
aplicado por `EMCP_Tools_Plugin::filter_disabled_tools()` (ver doc 00 §3).
---
## 1. Filesystem
**Classe de abilities:** `EMCP_Tools_Filesystem_Abilities`
(`includes/abilities/class-filesystem-abilities.php`)
**Condição de registo:** sempre activo (chamado sem guarda em `register_groups()`).
**`permission_callback` (todas as 6 tools):** `current_user_can( 'manage_options' )` — mesmo as
de leitura, porque `read-file`/`search-files` podem expor segredos de config de outros
ficheiros do site.
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|---|---|---|---|
| `read-file` | `path` (string, **required**), `offset` (int, 1-based), `limit` (int) | Lê um ficheiro dentro de `ABSPATH`. Limite de 5 MB (`MAX_READ_BYTES`); recusa binários (devolve `{binary:true}` em vez do conteúdo); recusa `wp-config.php` (`is_read_protected`); suporta slice por linhas via `offset`/`limit`. | readonly / não destrutivo |
| `list-directory` | `path` (opt, default raiz), `recursive` (bool, profundidade máx. 5, cap 2000 entradas) | Lista entradas (nome/path/tipo/size/mtime) de um directório dentro de `ABSPATH`. | readonly |
| `search-files` | `query` (string, **required**), `path` (opt), `extensions` (array de string), `max_results` (int, default 200, tecto 500) | Grep de substring (case-sensitive) recursivo por uma árvore; ignora ficheiros >5 MB e `wp-config.php`; devolve `{file, line, text}` capado a 300 chars por match. | readonly |
| `write-file` | `path`, `content` (ambos **required**) | Cria ou sobrescreve um ficheiro. Faz backup do existente primeiro; recusa `wp-config.php`/`.htaccess`; limite 5 MB (`MAX_WRITE_BYTES`); requer `writes_allowed()` (capability `edit_files` + `!DISALLOW_FILE_EDIT`); invalida OPcache se `.php`; grava no change ledger unificado. | **não readonly, destrutivo** — **desligado por omissão** |
| `edit-file` | `path`, `old_string`, `new_string` (**required**), `replace_all` (bool) | Substituição exacta de string (`old_string` deve corresponder exactamente uma vez, a menos que `replace_all`). Backup, mesmo gate de escrita, mesma protecção de ficheiros, mesmo registo no ledger. | **destrutivo** — **desligado por omissão** |
| `delete-file` | `path` (**required**), `confirm` (bool) | Apaga um ficheiro. Exige `confirm:true`; backup antes de apagar; mesmo gate de escrita e protecção. | **destrutivo** — **desligado por omissão** |
### Guard: `EMCP_Tools_Filesystem_Guard`
Ficheiro: `includes/class-filesystem-guard.php`. Comentário do próprio ficheiro: *"This is the
security boundary for the filesystem tools. resolve_path() is the one chokepoint that makes
'inside the WordPress install only' true."*
Constantes: `MAX_READ_BYTES = 5242880` (5 MB), `MAX_WRITE_BYTES = 5242880` (5 MB),
`BACKUP_DIR = 'emcp-fs-backups'`.
- **`resolve_path( string $path, ?string $root = null )`** — o chokepoint único. `$root`
por omissão `ABSPATH` (parâmetro só existe para testes). Rejeita path vazio ou com byte NUL.
Detecta se é absoluto (começa por `/`, `\`, ou `C:\`-like via regex). Constrói o candidato
(absoluto tal-e-qual, ou `rtrim($root) . '/' . ltrim($path)`). Faz `realpath()`; se o próprio
alvo não existir ainda (caso de escrita nova), resolve o **directório pai** com `realpath()`
e reconstrói `parent/basename`. Compara o prefixo do caminho resolvido contra
`realpath($root)`: tem de ser **exactamente igual** ou começar por
`root . DIRECTORY_SEPARATOR` — nunca um simples `strpos`, para evitar que
`/var/www/site-evil` passe por prefixo de `/var/www/site`. Devolve `WP_Error('outside_root')`
se escapar.
- **`is_protected( string $abs )`** — lista de basenames **escrita/eliminação**-protegidos:
`wp-config.php`, `.htaccess` (case-insensitive), filtrável via `emcp_tools_fs_protected_paths`.
- **`is_read_protected( string $abs )`** — lista de basenames **leitura**-protegidos: **só**
`wp-config.php` — `.htaccess` fica de fora porque não é um segredo, comentário explícito no
código: *"wp-config.php carries the DB credentials and auth salts; .htaccess is not a secret
so it stays readable."* Filtrável via `emcp_tools_fs_read_protected_paths` — um admin pode
adicionar `.env`, ficheiros de chave, etc. **Nota importante:** as duas listas são
deliberadamente diferentes (escrita ⊃ leitura) — não é o mesmo array reutilizado.
- **`backup_name( rel, timestamp )`** — pure: nome de ficheiro sanitizado
`<timestamp>-<path-com-/-substituído-por-\->`, caracteres fora de `A-Za-z0-9._-` viram `-`.
- **`is_utf8( content )`** — pure: byte NUL ou falha em `preg_match('//u', $content)` → binário.
- **`check_writes( can_edit_files, disallow_file_edit )`** — pure: combina a capability
`edit_files` com a constante `DISALLOW_FILE_EDIT`.
- **`writes_allowed()`** — wrapper live: `current_user_can('edit_files') && !(defined(DISALLOW_FILE_EDIT) && DISALLOW_FILE_EDIT)`.
- **`to_relative( abs )`** — inverso de `resolve_path` para display/log (relativo a `ABSPATH`,
slashes normalizados).
- **`backup( abs )`** — copia o ficheiro-alvo para
`wp-content/uploads/emcp-fs-backups/<timestamp>-<path-flat>` **antes** de qualquer
write/edit/delete. Cria `.htaccess` (`Require all denied`) + `index.html` vazio no directório
de backups na primeira utilização (bloqueia acesso web directo aos backups). Devolve `''`
quando o ficheiro-fonte ainda não existe (é uma criação, não um overwrite) — nesse caso o
rollback ficheiro-a-ficheiro é do tipo `'file-create'` em vez de `'file-backup'`.
- **`log()`** — `@deprecated 3.10.0`, no-op. Auditoria foi migrada para o change ledger unificado
(`EMCP_Tools_Change_Log`/`EMCP_Tools_Change_Recorder::record_file()`, documentado no doc 05) —
`class-filesystem-abilities.php::record_fs_change()` monta a entrada do ledger com `rollback`
do tipo `file-backup`/`file-create` e chama o recorder directamente.
Efeito colateral extra em cada escrita/edição/eliminação de `.php`: `invalidate_php_opcache()`
chama `opcache_invalidate($abs, true)` — sem isto, o pedido seguinte podia executar bytecode
cached obsoleto em vez do ficheiro recém-alterado.
**Storage:** backups em `wp-content/uploads/emcp-fs-backups/` (protegido de acesso web via
`.htaccess`). Registo de mudanças no change ledger unificado (ver doc 05) — nenhum log próprio
separado.
---
## 2. Base de dados directa
**Classe de abilities:** `EMCP_Tools_Database_Abilities`
(`includes/abilities/class-database-abilities.php`)
**Condição de registo:** sempre activo.
**`permission_callback` (todas as 6 tools):** `current_user_can( 'manage_options' )`.
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|---|---|---|---|
| `list-tables` | (sem input) | Lista tabelas via `information_schema.TABLES` — nome, `table_rows` estimado, tamanho em bytes (`data_length + index_length`). | readonly |
| `describe-table` | `table` (string, **required**) | Valida o nome contra `EMCP_Tools_Database_Guard::valid_table()` e corre `DESCRIBE`; devolve colunas/tipos/keys. | readonly |
| `query` | `sql` (string, **required**), `limit` (int, default/tecto `MAX_ROWS=1000`) | Corre SQL de leitura validado por `is_read_only_sql()`. Recusa também leitura das tabelas `users`/`usermeta` mesmo em modo `SELECT` (`query_touches_protected`), apontando para `list-users`/`get-user` em vez disso. | readonly |
| `insert-row` | `table`, `data` (objecto) — ambos **required** | `$wpdb->insert()` parametrizado. Recusa tabelas protegidas. Regista no ledger (`rollback.type = db-before-image`, `op = insert`). | **destrutivo** — **desligado por omissão** |
| `update-rows` | `table`, `data`, `where` (todos objecto) — **required** | Exige `where` não-vazio (nunca um UPDATE sem condição). Captura `before_image()` (snapshot das linhas afectadas, cap 500) antes de `$wpdb->update()`. Recusa tabelas protegidas. | **destrutivo** — **desligado por omissão** |
| `delete-rows` | `table`, `where` (**required**), `confirm` (bool) | Exige `confirm:true` + `where` não-vazio. `before_image()` antes de `$wpdb->delete()`. Recusa tabelas protegidas. | **destrutivo** — **desligado por omissão** |
### Guard: `EMCP_Tools_Database_Guard`
Ficheiro: `includes/class-database-guard.php`. Comentário: *"is_read_only_sql() is the safety
boundary for the flexible read path."* Constantes: `MAX_ROWS = 1000`, `BEFORE_IMAGE_CAP = 500`.
- **`normalize_sql( string $sql )`** — pure, scanner char-a-char (não regex — evita
problemas de backtracking/ReDoS em SQL longo). Substitui todo o comentário (`--`, `#`,
`/* */`) por um espaço, todo o literal de string (`'...'`/`"..."`, com escapes de backslash e
duplicação de quote reconhecidos) por `''`, e todo o identificador entre backticks por
`` `` ``. Não trata `/*! ... */` (comentários executáveis do MySQL) — esses são rejeitados
**antes** mesmo de chamar `normalize_sql`.
- **`is_read_only_sql( string $sql )`** — a gate real, em cadeia:
1. Se `$sql` contém `/*!` → rejeita de imediato. Comentário: *"MySQL executes the body of
/*! ... *​/ executable comments, so we cannot safely strip-and-trust."*
2. `normalize_sql()` + `trim()`.
3. Multi-statement: qualquer `;` que não seja o único carácter final (depois de `rtrim`)
→ rejeita.
4. Vectores de acesso a ficheiros — regex
`/\b(into\s+outfile|into\s+dumpfile|load_file\s*\(|load\s+data\b)/i`. Nota deliberada no
código: **sem `\b` a fechar** — porque `load_file(` termina em `(`, e `(` seguido de outro
não-word-char não tem word boundary; um `\b` final deixaria passar `LOAD_FILE` por engano.
5. Primeira palavra tem de ser uma de `SELECT/SHOW/DESCRIBE/DESC/EXPLAIN/WITH`.
6. Denylist da frase inteira: `INSERT|UPDATE|DELETE|REPLACE|MERGE|DROP|TRUNCATE|ALTER|CREATE|RENAME|GRANT|REVOKE|HANDLER|CALL|LOCK|UNLOCK|PREPARE|EXECUTE|INTO` — como comentários/literais já
foram removidos por `normalize_sql`, isto só apanha keywords reais (não texto dentro de
uma string).
- **`valid_table( string $table )`** — nomes de tabela **não podem ser parametrizados** em SQL
(`?`/`%s` só serve para valores), por isso resolve contra `SHOW TABLES` ao vivo e devolve o
nome real exacto (preserva case) ou `WP_Error('unknown_table')`.
- **`table_is_protected`/`is_protected( $table )`** — tabelas protegidas por omissão:
`$wpdb->users`, `$wpdb->usermeta`, filtrável via `emcp_tools_db_protected_tables`.
- **`query_touches_tables`/`query_touches_protected( $sql )`** — o análogo do lado da leitura:
remove backticks, normaliza (comentários/strings fora), e testa se algum nome de tabela
protegida aparece como identificador real com word boundaries (`wp_users_backup` **não**
corresponde a `wp_users`). Aplicado ao tool `query` para bloquear leitura directa de
password hashes/tokens de sessão via `SELECT * FROM wp_users`.
- **`before_image( $table, $where )`** — `SELECT *` com condições de igualdade AND
(parametrizado via `$wpdb->prepare`), `LIMIT 500`, chamado antes de update/delete.
- **`log()`** — `@deprecated 3.10.0`, no-op; ledger via `EMCP_Tools_Change_Recorder::record_db()`.
**Storage:** nenhum armazenamento próprio — escreve directamente nas tabelas alvo via `$wpdb`;
before-images e rollback vão para o change ledger unificado (blob store para snapshots grandes,
ver doc 05).
---
## 3. WP-CLI (execução + jobs assíncronos)
**Classe de abilities:** `EMCP_Tools_WPCLI_Abilities` (`includes/abilities/class-wpcli-abilities.php`)
**Condição de registo:** sempre activo. **Todas as 4 tools deste grupo fazem parte dos 131
slugs desligados por omissão** (`skill://emcp-tools` §2.1) — incluindo as duas de puro leitura
(`get-wp-cli-job`/`list-wp-cli-jobs`), ao contrário de outros grupos onde read/write têm gates
separadas. Provavelmente porque estas duas só fazem sentido em conjunto com `run`/`dispatch`.
**`permission_callback` (todas as 4):** `current_user_can( 'manage_options' )`.
Comentário do cabeçalho do ficheiro, citado por ser exactamente o resumo de risco correcto:
*"Risk notice. WP-CLI is powerful and, via the shell path, is effectively command execution.
The tool is confined by a command blocklist (no eval, eval-file, shell, raw db query, config
writes, package install, or arbitrary PHP flags), ships disabled-by-default, is admin-gated, and
audit-logs runs."*
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|---|---|---|---|
| `run-wp-cli` | `command` (string, **required**, sem `wp` inicial), `timeout` (int, default 60, tecto 300) | Corre um comando WP-CLI síncrono. Devolve stdout/stderr/exit_code. Executa **in-process** (`WP_CLI::runcommand`) se este pedido já corre dentro de um processo WP-CLI (transporte stdio), ou via **shell** (`proc_open` com um binário `wp` configurado) se ligado por HTTP. | **destrutivo** (por defeito de anotação — pode invocar qualquer subcomando não bloqueado) — **desligado por omissão** |
| `dispatch-wp-cli` | `command` (**required**), `timeout` (int, default 900, conselho até 86400) | Corre como job **detached** em background (para migrações/bulk tasks longas). Requer o caminho shell disponível — não é possível fazer detach de um comando in-process. Devolve `job_id`. | **destrutivo** — **desligado por omissão** |
| `get-wp-cli-job` | `job_id` (string, **required**) | Devolve estado do job (running/completed/failed), exit_code, stdout/stderr (tail capado). | readonly (anotação) — **mesmo assim desligado por omissão** |
| `list-wp-cli-jobs` | (sem input) | Lista jobs recentes (metadata apenas, sem stdout/stderr completos). | readonly (anotação) — **mesmo assim desligado por omissão** |
Cada execução (`run` e `dispatch`) é registada no change ledger, mas **não é reversível** —
comentário explícito no código: *"not reversible — commands have no before-image"*.
### Validator: `EMCP_Tools_WPCLI_Validator`
Ficheiro: `includes/wpcli/class-wpcli-validator.php`. **A gate de segurança do grupo inteiro.**
Comentário do cabeçalho: *"Args are always passed to the runner as an array (never interpolated
into a shell string), so metacharacters in values are inert; this validator blocks the command
surface that would let an operator run arbitrary PHP, raw SQL, or arbitrary shell."*
- **`BLOCKED_COMMANDS`** = `eval`, `eval-file`, `shell`, `server` — comandos WP-CLI que dão
execução de PHP arbitrário, shell interactivo, ou arrancam um servidor web embutido.
- **`BLOCKED_SUBCOMMANDS`** (pares `comando subcomando`) = `db query`, `db cli`, `db import`,
`db export`, `db reset`, `db drop`, `db clean`, `config set`, `config delete`, `config edit`,
`package install`, `package update`, `package uninstall`, `cli update`, `cli cmd-dump`,
`cli info`. Note-se: `db query`/`db cli`/etc. é redundante em parte com a gate própria da
ability `database` (§2), mas é defesa em profundidade — um agente não pode contornar o guard
de SQL via WP-CLI.
- **`BLOCKED_FLAG_PREFIXES`** = `--exec`, `--require` (carregam PHP arbitrário), `--path`,
`--ssh`, `--http` (retargeting do WP-CLI para outro install/servidor — poderia escapar do
site actual), `--prompt` (ficaria pendurado à espera de input interactivo), `--user=0`.
- **`validate( $command )`**: `trim()`; rejeita `\r`/`\n` (anti-injecção de linha); remove
prefixo `"wp "` tolerado; tokeniza; varre TODOS os tokens contra os prefixos de flag
bloqueados (`stripos`, case-insensitive, por prefixo — não exact-match); identifica a
"command word" = primeiro token não-flag (não começa por `-`) e o subcommand = segundo token
não-flag; rejeita se o comando isolado ou o par comando+subcomando estiverem nas listas.
Todas as três listas são filtráveis (`emcp_tools_wpcli_blocked_commands`,
`_blocked_subcommands`, `_blocked_flags`).
- **`tokenize( $command )`**: tokenizador consciente de aspas (single/double quotes). Fora de
aspas, um backslash é **literal** (friendly para paths Windows tipo `C:\wp\wp-cli.phar`).
Dentro de aspas duplas, só `\"` e `\\` são escapes reconhecidos; dentro de aspas simples,
nada é escapado. Devolve `WP_Error('wpcli_unterminated_quote')` se uma aspa não fechar.
### Runner: `EMCP_Tools_WPCLI_Runner`
Ficheiro: `includes/wpcli/class-wpcli-runner.php`. `OUTPUT_CAP = 262144` (256 KB) por stream,
truncado com `"…[output truncated]"`.
- **`is_cli_context()`** — `defined('WP_CLI') && WP_CLI && class_exists('\WP_CLI')` — true se
este pedido já corre dentro de um processo WP-CLI (o caso normal deste ecossistema, que liga
via SSH+STDIO a `wp mcp-adapter serve`, ver doc 00 e `skill://emcp-tools`).
- **`base_command()`** — o binário `wp` configurado, ordem de prioridade: constante
`EMCP_TOOLS_WPCLI_COMMAND` > option `emcp_tools_wpcli_command` > filtro
`emcp_tools_wpcli_command`.
- **`shell_available()`** — `proc_open` existe, `base_command()` não vazio, e `proc_open` não
está em `disable_functions` do php.ini.
- **`run( $command, $timeout=60 )`** — valida via `Validator::validate()`; se `is_cli_context()`
→ `run_in_process()` usa `WP_CLI::runcommand($cmd, ['return'=>'all','exit_error'=>false,'launch'=>false,'parse'=>false])`
— corre **dentro do mesmo processo PHP**, sem `fork`/`exec`. Senão, se `shell_available()` →
`run_shell()`.
- **`run_shell()`** — `proc_open($argv_array, ...)`. Comentário do código, crucial:
*"PHP 7.4+: an array command is executed WITHOUT a shell — arguments are passed verbatim, so
no metacharacter can be interpreted."* `$argv = base_argv() + tokens + ['--path=' . ABSPATH, '--no-color']`.
Poll não-bloqueante (`stream_set_blocking(false)`), timeout com `proc_terminate($proc, 9)`
(SIGKILL) se ultrapassar deadline (`timed_out=true`, `exit_code=124`), drena pipes no fim.
### Jobs assíncronos: `EMCP_Tools_WPCLI_Jobs`
Ficheiro: `includes/wpcli/class-wpcli-jobs.php`. `KEEP = 50` job dirs mantidos (mais antigos são
apagados por `prune()`, por `filemtime`).
- **`dir()`** — `wp-content/uploads/emcp-wpcli-jobs/`, criado + protegido na primeira utilização
com `.htaccess` (`Require all denied\nDeny from all`) + `index.php` silencioso.
- **Estrutura por job** (`<id>/`, `$id = gmdate('Ymd-His') . '-' . substr(md5(uniqid()),0,6)`):
- `meta.json` — `{id, command, timeout, status, created, started, finished, exit_code, user}`;
`status` transita `queued → running → (completed|failed)`.
- `stdout.log` / `stderr.log` — streams capturados.
- `run.sh` (POSIX) ou `run.bat` (Windows) — **launcher gerado**, com o comando completo já
montado via `escapeshellarg()` por token (`implode(' ', array_map('escapeshellarg', $argv))`)
— a escaping fica de fora do `proc_open`/`popen` de spawn.
- `exit_code` — ficheiro escrito **pelo launcher** quando o comando termina — é a fonte de
verdade para o estado terminal (não um polling do processo pai).
- **`dispatch( $command, $timeout=900 )`** — requer `shell_available()` (impossível fazer
detach de algo já in-process); valida; `prune()`; grava `meta.json` inicial; `spawn()` lança
o launcher **detached** — POSIX: `proc_open(['sh','-c', 'nohup sh run.sh > /dev/null 2>&1 &'], ...)`;
Windows: `popen('cmd /c start /B "" cmd /c run.bat', 'r')`. O processo pai **não espera** —
retorna `job_id` de imediato com `status='running'`.
- **`get( $id )`** — sanitiza `$id` (regex `[^a-z0-9-]` removido); lê `meta.json`; deriva o
estado terminal do ficheiro `exit_code` se existir (`0 = completed`, `!=0 = failed`); devolve
`tail()` do stdout/stderr (capado a `OUTPUT_CAP`, prefixo `"…[output truncated]"` — mostra o
**fim** do log, não o início).
- **`all()`** — todos os jobs (`glob(GLOB_ONLYDIR)`), ordenados por `created` desc, sem
stdout/stderr completos.
- **`spawn()`** — vale a pena ler: gera o `run.sh`/`run.bat` completo primeiro (incluindo
redirecção de stdout/stderr/exit_code), depois só lança um shell trivial que executa esse
ficheiro — separa completamente a lógica de "o que corre" da lógica de "como fica detached".
**Storage:** `wp-content/uploads/emcp-wpcli-jobs/<job-id>/` (protegido de acesso web).
---
## 4. Security & Malware Scanner
**Classe de abilities:** `EMCP_Tools_Security_Abilities` (`includes/abilities/class-security-abilities.php`)
**Condição de registo:** sempre activo. **`scan-security` está ACTIVA por omissão** (não faz
parte do deny-list — a única categoria deste documento com essa distinção, junto com
`analyze-performance`).
**`permission_callback`:** `current_user_can( 'manage_options' )`.
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|---|---|---|---|
| `scan-security` | `checks` (array de enum `malware`/`integrity`/`hardening`/`software`, opt — omitir corre as 4), `deep` (bool, default false), `max_files` (int, default 2000, tecto 20000), `max_seconds` (int, default 20, tecto 120) | Corre até 4 audits e devolve `{summary:{score 0-100, grade A-F, counts}, sections:{malware,integrity,hardening,software}, scan_meta, top_recommendations}`. `deep=false` cobre só `uploads/` + plugins activos + tema activo; `deep=true` cobre toda a `wp-content/` (mais lento). | **readonly, destructive=false, idempotent=true — activa por omissão** |
### Orchestrator: `EMCP_Tools_Security_Scanner`
Ficheiro: `includes/security/class-security-scanner.php`.
`CRITICAL_WEIGHT=20`, `WARNING_WEIGHT=5`, `CATEGORY_CRIT_CAP=60` (o penalty de criticals
**satura a 60 por categoria** — impede que uma categoria sozinha com muitos criticals leve o
score a zero), `TOP_RECS=8`.
- **Construção LAZY dos 4 audits** — só instanciados na primeira `scan()` que de facto precisa
deles. Decisão de design explícita (comentário completo citado em §4.1 abaixo, ligado ao
**issue #100**): registar a tool **não pode** instanciar o motor de audit, porque o registo de
abilities corre em **cada** carregamento de página de admin e **cada** pedido REST.
- **`resolve_checks( $requested )`** — pure: normaliza para o subset válido em ordem canónica;
vazio/tudo-inválido → todos os 4.
- **`scan( $input )`** — corre os checks pedidos, agrega findings, `summarize()`.
- **`summarize( $findings )`** — pure: conta por `status` (critical/warning/pass/info); penalty
**por categoria** — `cat_crit_pen[cat] = min(60, soma_de_20_por_cada_critical_nessa_categoria)`;
`score = 100 - soma(penalties_por_categoria) - (nº_warnings * 5)`, clamp `[0,100]`;
`grade` A(≥90)/B(≥80)/C(≥70)/D(≥60)/F(resto).
- **`group_by_category`** — agrupa em 4 secções fixas.
- **`rank_recommendations`** — críticos primeiro, depois warnings, corta a 8, formato
`"[label] recomendação"`.
### Value object: `EMCP_Tools_Security_Finding`
Ficheiro: `includes/security/class-security-finding.php`. Uma única factory pure:
`make( id, category, label, status, value, message, recommendation='' )` → array uniforme.
`status` ∈ `pass|warning|critical|info`; `recommendation` deve ser não-vazio quando `status !=
'pass'`.
### 4.1 Audit — Malware (`EMCP_Tools_Security_Malware_Audit`)
Ficheiro: `includes/security/class-security-malware-audit.php`.
`MAX_FILE_BYTES=2MB` (ficheiros maiores são saltados), `MAX_LINE_BYTES=64KB` (cap por linha
alimentada às regex — guarda anti-ReDoS/backtrack-limit; o comentário explica: linhas longas
fazem `preg_match` devolver `false` silenciosamente ao atingir `pcre.backtrack_limit`, mascarando
um hit real), `MAX_FILES=2000`/`CEILING=20000`, `TIME_BUDGET=20s`/`CEILING=120s`,
`SNIPPET_LEN=120`, `MAX_FINDINGS_PER_FILE=5`.
- **`scan_code( code, relpath, in_uploads )`** — pure: corre 5 regras de assinatura linha a
linha (ver abaixo); nunca devolve mais de 5 achados por ficheiro; o `value` de cada finding é
`{location: "path:line", snippet}` — **nunca o conteúdo completo do ficheiro**.
- **`is_misplaced_php( relpath )`** — PHP executável dentro de `uploads/` (extensões
`php/phtml/php3-7/phps/pht`).
- **`is_trivial_php( code )`** — pure, usa `token_get_all()` para distinguir um
`index.php` "Silence is golden" (só `T_OPEN_TAG`/`CLOSE_TAG`/`WHITESPACE`/`COMMENT`/`DOC_COMMENT`)
de PHP com código real — evita falsos positivos em guardas de directório vazias.
- **`is_excluded( relpath, prefixes )`** — exclui a própria pasta de instalação do plugin (via
`EMCP_TOOLS_DIR`) + o directório sandbox gerido (`EMCP_Tools_Sandbox_Paths::relative_base()`),
para o scanner não se auto-detectar como malware.
- **`run( deep, max_files, max_seconds )`** — `scan_roots(deep)`: `false` →
`uploads/` + plugins **activos** + tema activo/pai; `true` → toda a `wp-content/`. Percorre com
`RecursiveIteratorIterator` + `FOLLOW_SYMLINKS`, mas **valida que o caminho resolvido
continua dentro de `ABSPATH`** (bloqueia escape de symlink). Ficheiro PHP executável sob
`uploads/` com código real (não trivial) gera um achado crítico dedicado
`malware_uploads_php` **antes** de correr as 5 regras normais.
**🏆 O achado mais importante deste ficheiro (citação literal, comentário do autor):**
As assinaturas de malware (`eval`, `assert`, `create_function`, `base64_decode`, `gzinflate`,
`system`, `exec`, `shell_exec`, `c99shell`, `r57shell`, `b374k`, `phpspy`, `WSO`, etc.) **NÃO
estão escritas de forma literal no ficheiro-fonte** — estão fragmentadas em
`signature_tokens()` como concatenações (`'ev' . 'al'`), reunidas em runtime via
`expand()`/`strtr()`. Comentário do autor no código:
> *"This class is a malware scanner, so its rules have to name the exact functions and
> webshell handles that host-level scanners (Imunify360, maldet, Wordfence, ModSecurity) hunt
> for. Spelled out intact, this file reads as a c99-style webshell and gets quarantined or
> zeroed in place. `require_once` then still succeeds (the path exists) but the class is never
> declared, which used to fatal every wp-admin page and REST request (issue #100)."*
> *"Splitting the tokens means no intact signature ever sits on disk. The patterns compiled
> below are byte-identical to the originals, so detection behaviour is unchanged. Keep any new
> signature split the same way."*
Este é o motivo directo por trás **tanto** da construção lazy no orchestrator (§4, issue #100)
**como** desta fragmentação de tokens: um scanner de malware do **próprio host** identificava
literalmente este ficheiro como um webshell e colocava-o em quarentena/zerava-o, partindo o site
inteiro (a classe deixava de existir mas o `require_once` continuava a "ter sucesso"
silenciosamente — o registo de abilities engolia a excepção mas o servidor MCP ficava sem esta
tool).
**As 5 regras de assinatura:**
1. `malware_eval_obfuscation` (critical) — `eval`/`assert`/`create_function` envolvendo um
decoder (`base64_decode`/`gzinflate`/`gzuncompress`/`str_rot13`/`strrev`/`convert_uudecode`).
2. `malware_request_eval` (critical) — `eval`/`assert`/`system`/`exec`/`passthru`/
`shell_exec`/`popen`/`proc_open` recebendo directamente `$_GET`/`POST`/`REQUEST`/`COOKIE`/`SERVER`
— o backdoor RCE clássico.
3. `malware_command_exec` (warning; **critical se `in_uploads`**) — qualquer chamada de
`shell_exec`/`passthru`/`proc_open`/`popen`/`system`/`exec` isolada.
4. `malware_webshell_marker` (critical) — strings de webshells conhecidos
(`FilesMan`, `c99shell`, `r57shell`, `b374k`, `phpspy`, `WSO<versão>shell`).
5. `malware_long_base64` (warning) — blob de 260+ chars base64-like (payload escondido).
### 4.2 Audit — Integrity (`EMCP_Tools_Security_Integrity_Audit`)
Ficheiro: `includes/security/class-security-integrity-audit.php`.
- **`diff( checksums, hasher )`** — pure: compara o manifesto de checksums oficial do
wordpress.org (md5 por ficheiro core-relative) contra o hash real via `hash_equals()`
(timing-safe); ficheiro em falta → `warning('integrity_missing')`; hash não bate →
`critical('integrity_modified')`.
- **`run()`** — usa a função core `get_core_checksums($wp_version, $locale)`
(`wp-admin/includes/update.php`); **exclui tudo em `wp-content/`** (não faz parte dos
checksums oficiais de core). Se a API estiver inacessível (offline) devolve **um único**
finding `info` com `api.ok=false` — degrada graciosamente em vez de falhar todo o scan.
### 4.3 Audit — Hardening (`EMCP_Tools_Security_Hardening_Audit`)
Ficheiro: `includes/security/class-security-hardening-audit.php`. `FETCH_TIMEOUT=8s`.
7 checks, cada `evaluate_*()` pure, `run()` gathers live + **UM** loopback GET reutilizado para
dois checks (headers + generator meta):
| Check | Pass | Warning/Critical |
|---|---|---|
| `harden_file_edit` | `DISALLOW_FILE_EDIT` definido true | editor de ficheiros do admin ligado |
| `harden_debug_display` | `WP_DEBUG_DISPLAY` off | `warning` se on em produção; `info` noutro ambiente |
| `harden_admin_user` | sem user `admin` | `username_exists('admin')` |
| `harden_xmlrpc` | XML-RPC desligado | `xmlrpc.php` existe + filtro `xmlrpc_enabled` true |
| `harden_https` | scheme de `home_url()` é `https` | qualquer outro |
| `harden_security_headers` | X-Frame-Options + X-Content-Type-Options + Strict-Transport-Security + Content-Security-Policy todos presentes | falta pelo menos um (via 1 loopback GET a `home_url('/')`) |
| `harden_version_disclosure` | sem `readme.html` nem meta `generator` no HTML | qualquer um presente |
### 4.4 Audit — Software (`EMCP_Tools_Security_Software_Audit`)
Ficheiro: `includes/security/class-security-software-audit.php`.
`MAX_ABANDONED_LOOKUPS=30` (orçamento de chamadas `plugins_api` **ao vivo** por scan),
`ABANDONED_CACHE_TTL=43200` (12h, transient por-slug).
Checks: core desactualizado, plugins/temas desactualizados (um finding por item), plugins
inactivos (contagem — `info`, não `warning`), **plugins abandonados/removidos do directório
wordpress.org** — via `plugins_api('plugin_information', ['slug'=>$slug])` e o campo
`$info->closed`. O resultado (`closed`/`open`) fica em cache num transient
`emcp_sec_abandoned_<md5(slug)>` durante 12h; um `WP_Error` (plugin premium não no wp.org, ou API
em baixo) **não é marcado nem colocado em cache** — retenta no próximo scan. Slugs já em cache
não contam para o orçamento de 30 chamadas ao vivo — só chamadas realmente feitas são limitadas.
---
## 5. Performance Analyzer
**Classe de abilities:** `EMCP_Tools_Performance_Abilities` (`includes/abilities/class-performance-abilities.php`)
**Condição de registo:** sempre activo. **`analyze-performance` está ACTIVA por omissão**
(igual a `scan-security`).
**`permission_callback`:** `current_user_can( 'manage_options' )`.
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|---|---|---|---|
| `analyze-performance` | `url` (uri, opt — página deste site, hosts externos rejeitados), `post_id` (int, opt — ignorado se `url` definido), `include_page_fetch` (bool, default true — `false` corre só server/DB), `deep_assets` (bool, **reservado, ainda não implementado**) | Sem `url`/`post_id` analisa a frontpage. Devolve `{target, summary{score,grade,counts}, sections, page_fetch, top_recommendations}`. | **readonly, destructive=false, idempotent=true — activa por omissão** |
### Orchestrator: `EMCP_Tools_Performance_Analyzer`
Ficheiro: `includes/performance/class-performance-analyzer.php`.
`CRITICAL_WEIGHT=15`, `WARNING_WEIGHT=4`, `TOP_RECS=8` (pesos diferentes do Security Scanner —
mais leve, **sem** cap por categoria).
- **`analyze( $input )`** — `resolve_target()` primeiro (`url`|`post_id`|frontpage); corre
**sempre** o server audit; corre o page audit condicionalmente
(`include_page_fetch`); combina findings; `summarize()` + `group_by_category()`.
- **`resolve_target( $input )`** — valida que `url` está no **mesmo host** que `home_url()` via
`validate_same_host()` (pure) — protecção anti-SSRF/anti-scan-de-outro-site logo à entrada.
- **`summarize( $findings )`** — pure: `score = 100 - (critical*15) - (warning*4)`, **sem** cap
por categoria (diferente do Security Scanner); grade A-F igual.
- **`group_by_category`** — 5 secções fixas: `server`, `database`, `config`, `page`, `assets`.
### Value object: `EMCP_Tools_Performance_Finding`
Ficheiro: `includes/performance/class-performance-finding.php`. Idêntico em forma ao
`Security_Finding` — factory pure `make(id, category, label, status, value, message, recommendation='')`.
### 5.1 Audit — Server (`EMCP_Tools_Performance_Server_Audit`)
Ficheiro: `includes/performance/class-performance-server-audit.php`. **Todo in-process, sem
HTTP.** `MIN_MEMORY_BYTES=128MB`, `AUTOLOAD_WARN=1MB`/`CRIT=3MB`, `PLUGIN_WARN_COUNT=40`,
`REVISIONS_WARN_COUNT=1000`, `TOP_TABLES=5`, `TOP_AUTOLOAD_OPTIONS=5`.
11 checks pure+live: **versão PHP** (≥8.2 pass, ≥8.0 warning, senão critical), **memory_limit**
(≥128MB pass, `-1`=ilimitado=pass, senão warning), **OPcache** activo, **object cache
persistente** (`wp_using_ext_object_cache()`), **biblioteca de imagem** (Imagick ou GD),
**WP_DEBUG** em produção (warning) vs outros ambientes (info), **contagem de plugins activos**
(>40 → warning), **revisões de posts** (>1000 → warning, `COUNT(*) WHERE post_type='revision'`),
**backlog de cron** overdue >5min (via `_get_cron_array()`), **tamanho de opções autoload**
(`SUM(LENGTH(option_value)) WHERE autoload IN ('yes','on','auto')` — nota: cobre as **3
variantes** de valor da coluna `autoload`, não só `'yes'`; devolve top 5 maiores), **tamanho da
base de dados** (`SUM(data_length+index_length)` de `information_schema.TABLES`, top 5 tabelas
maiores).
### 5.2 Audit — Page (`EMCP_Tools_Performance_Page_Audit`)
Ficheiro: `includes/performance/class-performance-page-audit.php`. **1 loopback HTTP fetch +
parsing DOM. Não executa JS** — análise de HTML/headers, não Core Web Vitals reais.
`FETCH_TIMEOUT=10s`, `MAX_HTML_BYTES=2MB` (cap de parsing), `MAX_REDIRECTS=3`,
`RESPONSE_WARN_MS=800`, `HTML_WARN_BYTES=500KB`, `RENDER_BLOCK_WARN=5`.
- **`fetch( $url, $timeout=10 )`** — segue redirects **manualmente** (`redirection=0` em cada
`wp_remote_get`, loop até `MAX_REDIRECTS`) **em vez de** delegar no `redirection` nativo do
`wp_remote_get`. Motivo: cada hop precisa de ser revalidado contra o host de origem
(`safe_redirect_target()`) — um redirect para outro host é **recusado**, não seguido.
- **`safe_redirect_target( location, current_url, origin_host )`** — resolve `Location`
relativo contra o URL actual (scheme+host), depois testa se o host de destino ==
`origin_host` (case-insensitive); devolve `''` se sair do host. **Guarda anti-SSRF
explícita**: um atacante não pode fazer o site "chutar" outro host arbitrário através de um
redirect 301/302 configurado maliciosamente numa página que este tool analisa.
- **`analyze( $fetched, $deep_assets )`** — pure. Se o fetch falhou → 1 finding `warning` e
degrada graciosamente (server/DB continuam reportados normalmente). Se OK: `http_status`
(200=pass), `response_time` (>800ms=warning), `html_size` (>500KB=warning), `compression`
(gzip/br em `content-encoding`), `cache_headers` (Cache-Control/Expires/X-Cache presentes),
depois `parse_dom()` com `DOMDocument` (`libxml_use_internal_errors` para engolir HTML
malformado) + `asset_findings()`: **render-blocking** (`<link rel=stylesheet>` no `<head>` +
`<script>` síncrono no `<head>` sem `async`/`defer`, warning se >5), **asset_counts** (info),
**image_lazy_loading** (contagem de `<img>` sem `loading="lazy"`, info), **third_party**
(domínios de `<link>`/`<script src>` diferentes do host analisado, info).
**Storage:** nenhum armazenamento próprio para nenhum dos dois audits — tudo calculado ao vivo e
devolvido na resposta; nada persistido em disco/BD (excepto os transients de cache do audit de
software do Security Scanner, que é um documento diferente — §4.4).
---
## Blueprint para réplica
### Copiar quase 1:1 (código já correcto, difícil de reproduzir sem reintroduzir bugs)
1. **`EMCP_Tools_Filesystem_Guard::resolve_path()`** — o chokepoint de confinamento a `ABSPATH`.
A lógica de comparação de prefixo (`===` OU `strpos($real, $root . DIRECTORY_SEPARATOR) === 0`,
nunca um `strpos` simples) evita a classe de bugs "`/var/www/site-evil` passa como prefixo de
`/var/www/site`". O tratamento de caminhos que ainda não existem (resolver o **pai** com
`realpath` para permitir criar um ficheiro novo) é subtil e vale a pena copiar tal-e-qual.
2. **A distinção `is_protected` (write) vs `is_read_protected` (read)** — não são a mesma lista.
`.htaccess` pode ser lido mas não escrito/apagado; `wp-config.php` não pode nem sequer ser
lido. Uma réplica que use uma única lista "protected" para tudo está a proteger a menos (deixa
ler segredos) ou a mais (bloqueia leitura de `.htaccess`, que é inofensiva e útil de auditar).
3. **Padrão "backup antes de escrever, directório de backups bloqueado a acesso web"** —
`.htaccess Require all denied` + `index.html` vazio no directório de backups, gerado on-demand
na primeira escrita. Simples e eficaz; copiar tal-e-qual.
4. **`EMCP_Tools_Database_Guard::normalize_sql()` + `is_read_only_sql()`** — o scanner
char-a-char que neutraliza comentários/strings/identificadores antes de testar keywords é
sofisticado e já tem dois bugs de "quase-passou" documentados directamente nos comentários do
código (o truque `/*!` do MySQL, e o cuidado de não pôr `\b` a fechar no regex de
`LOAD_FILE(`). Reimplementar do zero arrisca reintroduzir exactamente estes dois bugs já
corrigidos aqui.
5. **`valid_table()` a resolver contra `SHOW TABLES` ao vivo** em vez de confiar no input —
nomes de tabela não podem ser parametrizados em SQL preparado, por isso a única forma segura
de os validar é confirmá-los contra uma lista real.
6. **`EMCP_Tools_WPCLI_Validator`** — a combinação de blocklist de comandos/subcomandos/flags +
tokenizador consciente de aspas (com as regras específicas de escaping por tipo de aspa, e o
comportamento "backslash literal fora de aspas" para paths Windows) é bem pensada e testada.
Copiar a estrutura (3 listas filtráveis + tokenizer) quase tal-e-qual.
7. **O princípio "argv array, nunca string interpolada"** em `WPCLI_Runner::run_shell()` e
`WPCLI_Jobs::spawn()` — mesmo quando o `spawn()` monta uma string via
`implode(' ', array_map('escapeshellarg', $argv))` para o ficheiro launcher, cada TOKEN
individual passa primeiro por `escapeshellarg()`; nunca há concatenação directa de input do
utilizador numa string de shell. Esta é a regra arquitectural mais importante de todo este
documento — qualquer tool nova que precise de invocar um processo externo deve seguir o mesmo
padrão.
8. **A técnica de fragmentação de assinaturas do malware scanner** (`signature_tokens()` +
`expand()`) — o achado mais interessante de todo o ficheiro: se uma réplica alguma vez
escrever o seu próprio scanner de malware, tem de aplicar a mesma técnica ou arrisca ser
confundida com o próprio malware que procura, por scanners de host (Imunify360, maldet,
Wordfence). Ver citação completa em §4.1.
9. **`Performance_Page_Audit::fetch()` / `safe_redirect_target()`** — seguir redirects
manualmente em vez de delegar no parâmetro `redirection` do `wp_remote_get`, revalidando o
host em cada hop. Este padrão é reutilizável para qualquer futura tool "analisar um URL deste
site" (ex. um crawler de sitemap) — nunca confiar que o WordPress core segue redirects de
forma segura para este caso de uso.
### Simplificar
1. **O caminho "shell" completo de WP-CLI (binário `wp` configurável via option/constante,
`proc_open` HTTP, jobs em background detached)** é infraestrutura pesada que só é necessária
quando o servidor MCP é alcançado por HTTP puro (sem contexto WP-CLI). **Neste ecossistema, os
3 servidores MCP já ligam via SSH+STDIO a `wp mcp-adapter serve` (ver doc 00 e
`skill://emcp-tools`)** — ou seja, `is_cli_context()` é **sempre verdadeiro** e o caminho
`run_in_process()` cobre 100% da utilização real. Uma réplica focada neste padrão de
deployment pode **eliminar inteiramente** o caminho shell + jobs detached (`WPCLI_Runner::run_shell`,
toda a classe `WPCLI_Jobs`) e ficar só com `WP_CLI::runcommand(..., launch=false)` — muito
menos superfície de ataque e código para manter, sem perder funcionalidade no cenário real de
uso.
2. **`Security_Finding`/`Performance_Finding`** são factories triviais de um único método —
copiar tal-e-qual sem redesenho, não há aqui nada a simplificar mais.
3. O cap `CATEGORY_CRIT_CAP=60` por categoria no Security Scanner é razoável mas arbitrário —
uma réplica pode adoptar o mesmo valor sem re-derivar a heurística, ou simplesmente usar o
scoring mais simples do Performance Analyzer (sem cap) se a diferenciação por categoria não
for um requisito.
### Deixar de fora (ou adiar)
1. **`deep_assets` no Performance Analyzer** — está no schema como "reserved", nunca chegou a ser
implementado neste build. Não vale a pena reservar espaço para uma feature que o próprio
vendor não implementou em 12 versões (`since 3.0.0`).
### Gotchas não óbvios a não esquecer
- **Invalidação de OPcache após escrever/apagar `.php`** (`opcache_invalidate($abs, true)`) — é
fácil esquecer este passo numa réplica e ter bugs "a alteração não teve efeito" reportados como
falsos negativos de teste.
- **O deny-list incremental (doc 00 §8) é a camada EXTERNA de defesa; os guards deste documento
são a camada INTERNA.** Mesmo que uma réplica não implemente um deny-list configurável, TODAS
as gates internas aqui documentadas (confinamento de path, validação de SQL, blocklist de
WP-CLI, `confirm:true` obrigatório em deletes) devem ser mantidas — são a última linha de
defesa se o deny-list for mal configurado (ver o achado crítico de `descomplicar.pt` no
`skill://emcp-tools` §8.2, onde o deny-list externo falhou completamente e só as gates internas
— que continuam a existir mas não bastam sozinhas quando a tool está "activa" — teriam impedido
o pior).
- **`get-wp-cli-job`/`list-wp-cli-jobs` são readonly e mesmo assim ficam desligados por omissão
em bloco com `run`/`dispatch`** — é uma escolha deliberada de UX/segurança (não faz sentido dar
acesso a resultados de jobs sem dar acesso a criá-los), mas quebra o padrão "read sempre
activo, write desligado" que domina o resto do plugin. Documentar esta excepção explicitamente
numa réplica para não ser "corrigida" por engano.
- **A construção lazy dos audits de segurança não é só uma optimização de performance — é uma
medida de resiliência anti-fatal-error** (issue #100). Qualquer réplica que registe abilities em
cada `wp_abilities_api_init` (que corre em TODO pedido admin/REST) deve tratar a construção de
qualquer dependência pesada/frágil da mesma forma: lazy, e sempre com um `try/catch` a envolver
o registo global (ver `EMCP_Tools_Ability_Registrar::register_all()` no doc 00, que já faz
exactamente isto ao nível do registrador inteiro).
---
## Fonte
Leitura directa (19-08-2026) de:
`includes/abilities/class-filesystem-abilities.php`,
`includes/class-filesystem-guard.php`,
`includes/abilities/class-database-abilities.php`,
`includes/class-database-guard.php`,
`includes/abilities/class-wpcli-abilities.php`,
`includes/wpcli/class-wpcli-runner.php`,
`includes/wpcli/class-wpcli-validator.php`,
`includes/wpcli/class-wpcli-jobs.php`,
`includes/abilities/class-security-abilities.php`,
`includes/security/class-security-scanner.php`,
`includes/security/class-security-malware-audit.php`,
`includes/security/class-security-hardening-audit.php`,
`includes/security/class-security-software-audit.php`,
`includes/security/class-security-integrity-audit.php`,
`includes/security/class-security-finding.php`,
`includes/abilities/class-performance-abilities.php`,
`includes/performance/class-performance-analyzer.php`,
`includes/performance/class-performance-server-audit.php`,
`includes/performance/class-performance-page-audit.php`,
`includes/performance/class-performance-finding.php`.
Cruzado com `includes/abilities/class-ability-registrar.php` (condições de registo — todos os 5
grupos deste documento registam-se sem guarda condicional, fora do bloco `if ($elementor_active)`)
e com `docs/00-ARQUITECTURA.md` + `skill://emcp-tools` (contexto de arquitectura geral e postura
de deny-list ao vivo nos 3 sites do ecossistema).