Files
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

630 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).