# 03 — Contrato do wrapper SSH no CT 107 (e no CT de dev)

Baseado em PLANO.md (arquitetura + secção Segurança) e no procedimento manual já em produção na skill `/inserir-funcionario` (`SKILL.md`, secção "Procedimento de prod").

**Fase 4 (23/09/2026): implementado de facto em dev, este documento passa a refletir o real, não só o desenho.**
Diferenças relevantes face ao desenho original deste documento:
- `ficha_pdf` deixou de ser "A CONFIRMAR": corre `ficha_pdf.php` (novo, ao lado de `inserir.php` na release), que usa `funcoes/ficha_funcionario_render.php` + wkhtmltopdf (instalado em dev via pacote `wkhtmltox` estático — não existe no repositório apt do Debian 13) e devolve o PDF em base64 (limite 10 MB).
- `email`: `--ignora-modo-teste` só é passado ao `fichas_email.php` quando o alvo é `prod`; em dev, o wrapper confirma primeiro `configuracoes_globais.email.modo_teste=1` na própria BD antes de sequer tentar enviar, e recusa (exit 2) se não estiver ativo — nunca arrisca enviar ao destinatário real por engano.
- Nova ação `consulta.alvo` (chave de consulta): devolve o alvo real deste wrapper, lido de `/opt/assistente/etc/alvo`, com correspondência **exata** a `dev`/`prod` (nunca um default otimista) — o CT assistente confirma isto antes de qualquer apply/pdf/email "em DEV", porque a flag local `WRAPPER_APPLY_ATIVO=dev` não prova por si só a que Sabichão a chave/host configurados chegam de facto.
- Idempotência do `apply`: ver secção 7 revista — não é só "ficheiro com a chave", é um lock por PID (nunca só por tempo) mais uma marca `INCERTO` para quando o resultado não pôde ser persistido.

## 1. Utilizador técnico e chaves

- Utilizador técnico dedicado no CT 107, ex. `assistente` (nome exato **A CONFIRMAR** — o plano não fixa o username, só o desenho).
- **Duas chaves distintas**, cada uma num `authorized_keys` próprio (ou entradas separadas na mesma):
  - **Chave de consulta**: só permite ações de leitura (`consulta.*`, `dry_run`).
  - **Chave de apply**: permite `apply`, `verify`, `ficha_pdf`, `email` — só existe em prod a partir da Fase 5. O CT de dev nunca a tem.
- Cada entrada em `authorized_keys`:
  ```
  from="<IP do CT assistente>",command="/caminho/wrapper.php",no-pty,no-agent-forwarding,no-X11-forwarding,no-port-forwarding ssh-ed25519 AAAA... assistente@ct-dev-ou-prod
  ```
- `sudoers` restrito só ao necessário para o wrapper correr o que precisa como `www-data` (ex. `assistente ALL=(www-data) NOPASSWD: /usr/bin/php /caminho/wrapper.php`) — sem shell genérico.
- `inserir.php`/`portal_lookup.php`/`fichas_email.php` instalados como **release versionada, root-owned** — o utilizador técnico não pode alterá-los.

## 2. Ações suportadas

| Ação | Chave exigida | Efeito | Idempotente |
|---|---|---|---|
| `consulta.zonas` | consulta | `SELECT zona FROM zona WHERE estado='Ativo'` | Sim (leitura) |
| `consulta.reserva` | consulta | `SELECT id_processo, admissao FROM processo WHERE nif=? AND estado='Reservado'` | Sim |
| `consulta.funcionario` | consulta | `SELECT nome, estado FROM funcionarios WHERE nif=?` (deteção de re-admissão/erro) | Sim |
| `consulta.ficha` | consulta | Colunas do manifesto de `funcionarios` (nome, mai, ncc, niss, filho_de, nascimento, validade_cc, especialidades, val_vig/spr/ard/are/outro, morada, iban, contacto, naturalidade, concelho, distrito, nacionalidade, val_rc, estado_civil, ndependentes, habilitacoes, zona1/2, local_servico, nhoras, mec, estado) + id_processo/admissao/estado/email do último `processo`, por NIF — `REPLACE(nif,' ','')` dos dois lados (prod tem NIFs com espaços gravados). Usada por `fichas:validar`/`Cruzamentos::ficha()` para comparar `valor_ocr` contra `valor_sabichao` (docs/validacao-fase2.md, 22/09/2026) | Sim |
| `consulta.portal_lookup` | consulta | Corre `portal_lookup.php --nif=... --nascimento=...` (API + fallback cache local) | Sim |
| `consulta.portal_por_nome` | consulta | 2º nível do lookup do portal (24/09/2026, caso real: NIF errado escrito no portal, checksum inválido — `consulta.portal_lookup` dá `not_found` nos dois níveis, mas a submissão existe na cache local com o mesmo nome+nascimento). SQL direto a `portal_submissions` (`sync_status='synced'`, filtrado por `data_nascimento`), sem passar pela API do portal (não tem pesquisa por nome); compara o nome em PHP, case/accent-insensitive (`normalizar_nome_ci`), nunca na query. Devolve no máx. 3 `candidatos`, cada um com `id`, `remote_id`, `nif_checksum_ok` (mod-11 do NIF da própria submissão) e os mesmos campos `mapeado`/`morada_original_so_conferencia` do `consulta.portal_lookup`. **Só no repo — nunca instalada** (nem CT 100 dev, nem prod) até o utilizador pedir | Sim |
| `consulta.alvo` | consulta | Devolve `{"alvo":"dev"\|"prod"\|"desconhecido"}`, lido de `/opt/assistente/etc/alvo` com correspondência exata (Fase 4) | Sim |
| `dry_run` | consulta | Corre `inserir.php --manifesto=... --confirmo-prod` (sem `--apply`) | Sim |
| `apply` | apply | Corre `inserir.php --manifesto=... --apply --confirmo-prod [--mark-concluded]` | **Não** — protegido por chave de idempotência + lock por PID, não por retry automático (secção 7) |
| `verify` | consulta ou apply | SELECTs de verificação pós-inserção (mesmos que o `inserir.php` já faz internamente, reexecutáveis para reconciliação) | Sim |
| `ficha_pdf` | apply | Gera o PDF da ficha (`ficha_pdf.php` novo → `funcoes/ficha_funcionario_render.php` + wkhtmltopdf) e devolve os bytes em base64 (limite 10 MB) — implementado na Fase 4 | Sim (mesmo caso, mesmo resultado) |
| `email` | apply | Corre `fichas_email.php --ids=... --enviar`; `--ignora-modo-teste` só em prod (Fase 4: em dev confirma primeiro `configuracoes_globais.email.modo_teste=1`, senão recusa) | **Não** — cada chamada envia; a app não deve repetir sem confirmação (`email_enviando_em` do lado do CT assistente) |

Nota: atribuir `dry_run` à chave de consulta é uma escolha de desenho deste documento — o `inserir.php` sem `--apply` não escreve nada, coerente com "consulta". **A CONFIRMAR** se o utilizador quer mesmo o dry-run acessível sem a chave de apply, ou se prefere que só a chave de apply o corra (mais simples de raciocinar sobre, menos granular).

## 3. Formato de entrada (JSON por stdin)

```json
{
  "acao": "apply",
  "caso_id": 1234,
  "chave_idempotencia": "caso-1234-v3",
  "manifesto": { "...": "objeto conforme 01-MANIFESTO-SCHEMA.json" },
  "mark_concluded": false
}
```

- `acao`: obrigatório, uma das da tabela acima.
- `caso_id`: id do caso na app, só para correlação em log — nunca usado para autorizar nada (a autorização vem da chave SSH usada, não do payload).
- `chave_idempotencia`: obrigatória em `apply` — o wrapper regista as chaves já processadas (ficheiro ou tabela local no CT 107) e recusa repetir um `apply` com a mesma chave, devolvendo o resultado anterior em vez de reexecutar.
- `manifesto`: objeto completo, validado pelo próprio `inserir.php` (schema/comprimentos) — o wrapper não precisa de o revalidar, mas pode rejeitar antes de gastar uma ligação SSH nova se o JSON for claramente inválido.
- Para `consulta.*`: payload mínimo com os parâmetros da consulta (`nif`, `admissao`, `nascimento`, etc.), sem manifesto.

## 4. Formato de saída (JSON por stdout, sem dados sensíveis em stderr)

```json
{
  "ok": true,
  "acao": "apply",
  "exit_code": 0,
  "resultado": { "...": "resumo sanitizado, sem password nem PII desnecessária" },
  "avisos": ["..."]
}
```

Em erro:
```json
{
  "ok": false,
  "acao": "apply",
  "exit_code": 1,
  "erro": "mensagem sanitizada (nunca a password, nunca stack trace com PII)"
}
```

Regra explícita do plano: **stderr nunca contém dados sensíveis**. O wrapper deve:
- Capturar stdout/stderr do `inserir.php`/`portal_lookup.php`/`fichas_email.php` internamente.
- Nunca ecoar a password de BD (resolvida sempre dentro do próprio comando, como no procedimento manual — `grep` inline ao `addclientback.php`, nunca gravada em ficheiro nem impressa).
- Devolver ao CT assistente só o necessário para o relatório à RH — nunca o output bruto dos scripts internos sem filtragem.

## 5. Exit codes

| Exit code | Significado |
|---|---|
| 0 | Sucesso (consulta devolveu dados, dry-run sem erros, apply com verificação pós-inserção toda OK) |
| 1 | Falha (argumento inválido, manifesto rejeitado, apply com `PROBLEMA` na verificação pós-inserção — mesmo comportamento do `inserir.php` atual) |
| 2 | **A CONFIRMAR** — reservado para falhas de geração/validação específicas de `ficha_pdf`/`email` (ex. `fichas_email.php` já usa exit 2 para "falha de geração/validação do lote" e exit 3 para "falha de envio" — o wrapper deve propagar esses códigos, não os achatar todos a 1) |
| 3 | Falha de envio de email (propagado de `fichas_email.php`) |

O wrapper deve propagar o exit code real do script subjacente sempre que possível, e não inventar um código genérico — importante para a reconciliação automática no CT assistente.

## 6. Limites

- Tamanho do manifesto JSON: limite razoável (ex. 256 KB) — um manifesto de ficha nunca deveria aproximar-se disto; serve só para rejeitar payloads absurdos antes de os processar.
- Timeout do comando SSH: o CT assistente deve assumir timeout e tratar como falha técnica reconciliável por `verify`, nunca como sucesso silencioso (mesma lógica do `inserir.php`: "Apply sem retry automático: timeout SSH → reconciliar por SELECT").
- Concorrência: o wrapper não impõe concorrência 1 por si (ao contrário do serviço leitor no mini) — a exclusão é feita pela fila do CT assistente (um caso de cada vez no motor), mas o wrapper deve ser são perante dois pedidos simultâneos para o mesmo `caso_id`/`chave_idempotencia` (o segundo deve ser recusado ou devolver o resultado do primeiro, nunca correr em paralelo sobre a mesma reserva).

## 7. Idempotência (revisto na Fase 4, depois de 4 rondas de revisão do Codex)

- Cada `apply` tem uma `chave_idempotencia` única: `hash(manifesto_hash + '|aprovacao-' + id_da_aprovacao)` — a mesma aprovação produz sempre a mesma chave, um clique repetido ou um retry manual depois de um timeout nunca gera uma chave nova.
- Registo: ficheiro `/var/lib/assistente/apply/<chave>.json` (0600, `O_EXCL`) com o resultado — decisão 4 do `07-DECISOES-FASE0.md`, sem SQLite.
- **Lock de execução** (`<chave>.lock`), adquirido ANTES de chamar `inserir.php` e só libertado depois de o resultado ficar gravado — sem isto havia uma janela real entre "SSH local expira" e "o processo remoto continua a correr" em que uma 2ª tentativa podia reaplicar. Reclamar o lock é decidido pelo **PID do processo dono**, nunca só pela idade do ficheiro (`posix_kill($pid, 0)` — um apply genuinamente lento, ex. BD lenta, não pode ser "roubado" só por o lock parecer velho); a idade (`LOCK_OBSOLETO_SEGUNDOS`, 3600s) só decide quando nem sequer há um PID legível no lock (formato antigo/corrompido).
- Depois de adquirir o lock, o wrapper **relê o registo outra vez** antes de correr `inserir.php` — fecha a janela entre a 1ª leitura (topo da função) e a aquisição do lock, onde uma execução concorrente podia ter terminado e gravado o resultado entretanto.
- Se `inserir.php` correu mas o registo não pôde ser gravado (disco cheio, permissões), o lock é reescrito como marca **`INCERTO`** — nunca reclamável automaticamente (nem por PID morto, nem por idade); só a remoção manual do ficheiro `.lock`, depois de confirmar o resultado real por `verify`/`consulta.ficha`, volta a desbloquear essa chave. O CT assistente recebe `"incerto": true` e mantém o caso em `a_aplicar` (nunca `erro_tecnico`, que a reconciliação já não alcançaria).
- `ficha_pdf` e `verify` são naturalmente idempotentes (leitura/render, sem efeito lateral persistente) e não precisam de chave.
- `email` **não é idempotente** — cada chamada envia de facto. Do lado do CT assistente, o mutex é feito a um nível acima (coluna `fichas_casos.email_enviando_em`, não no wrapper): a app garante que só um pedido de cada vez chama a ação `email` para o mesmo caso, e qualquer exceção depois de invocar o wrapper (não só timeout) fica marcada como resultado incerto — nunca reverte a marca de "enviado" às cegas.

## 8. Como a password é resolvida localmente

Réplica exata da receita já em produção na skill manual (`SKILL.md`, "Procedimento de prod"), dentro do próprio wrapper, nunca fora dele:

```bash
P=$(grep -m1 -oP "mysqli_connect\('localhost','segunor','\K[^']+" /var/www/sabichao/addclientback.php)
DB_HOST=localhost DB_USER=segunor DB_NAME=segunor DB_PASS="$P" \
  SABICHAO_REPO=/var/www/sabichao SABICHAO_URL=https://app.segunor.pt \
  php /caminho/inserir.php --manifesto=... --apply --confirmo-prod
```

Regras que se mantêm (já validadas na skill manual, herdadas aqui):
- Nunca fazer `grep`/`cat` "a descoberto" ao `addclientback.php` fora deste uso interno inline.
- Nunca escrever a password num ficheiro no CT 107.
- Nunca ecoar `$P` em stdout/stderr do wrapper — só é usada dentro da variável de ambiente do processo filho.
- O `.env` do webroot de prod não é legível pelo utilizador técnico (0640, `www-data:www-data`) — chamadas que precisem do token do portal (`portal_lookup` nível API, `mark-concluded`, `fichas_email.php`) correm via `sudo -u www-data`, restrito pelo `sudoers` do ponto 1.

## 9. Esboço do wrapper (bash + PHP 7.3)

Wrapper fino em bash que valida a ação, isola o payload num ficheiiro temporário `0700`, e invoca um dispatcher PHP 7.3 compatível com o resto do repo:

```bash
#!/usr/bin/env bash
# /caminho/wrapper.sh — invocado por sshd via authorized_keys "command="
set -euo pipefail
umask 077
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cat > "$TMPDIR/payload.json"
php /caminho/wrapper.php "$TMPDIR/payload.json"
```

```php
<?php
// wrapper.php — dispatcher PHP 7.3, root-owned, chamado pelo wrapper.sh.
// Lê o JSON de entrada, valida a ação, chama o script certo, imprime JSON sanitizado.

$payload = json_decode(file_get_contents($argv[1]), true);
if (!is_array($payload) || empty($payload['acao'])) {
    echo json_encode(['ok' => false, 'erro' => 'payload invalido']);
    exit(1);
}
$acoesPermitidas = ['consulta.zonas', 'consulta.reserva', 'consulta.funcionario',
    'consulta.portal_lookup', 'dry_run', 'apply', 'verify', 'ficha_pdf', 'email'];
if (!in_array($payload['acao'], $acoesPermitidas, true)) {
    echo json_encode(['ok' => false, 'erro' => 'acao desconhecida']);
    exit(1);
}

// Idempotência do apply — A CONFIRMAR mecanismo exato de persistência (ficheiro vs SQLite)
if ($payload['acao'] === 'apply') {
    $chave = $payload['chave_idempotencia'] ?? null;
    if (!$chave) {
        echo json_encode(['ok' => false, 'erro' => 'chave_idempotencia obrigatoria em apply']);
        exit(1);
    }
    $registo = '/caminho/estado/aplicados.json'; // ou tabela dedicada
    $ja = ler_registo_idempotencia($registo, $chave);
    if ($ja !== null) {
        echo json_encode($ja);
        exit($ja['exit_code']);
    }
}

$p = resolver_password_prod(); // grep inline ao addclientback.php, nunca ecoado

switch ($payload['acao']) {
    case 'dry_run':
        $r = correr_inserir($payload['manifesto'], false, $p);
        break;
    case 'apply':
        $r = correr_inserir($payload['manifesto'], true, $p, $payload['mark_concluded'] ?? false);
        gravar_registo_idempotencia($registo, $payload['chave_idempotencia'], $r);
        break;
    case 'consulta.portal_lookup':
        $r = correr_portal_lookup($payload['nif'], $payload['nascimento'], $p);
        break;
    case 'ficha_pdf':
        $r = gerar_ficha_pdf($payload['caso_id'], $p);
        break;
    case 'email':
        $r = correr_fichas_email($payload['ids'], $p);
        break;
    // ... consulta.zonas, consulta.reserva, consulta.funcionario, verify
    default:
        $r = ['ok' => false, 'erro' => 'nao implementado'];
}

echo json_encode(sanitizar_saida($r), JSON_UNESCAPED_UNICODE);
exit($r['exit_code'] ?? ($r['ok'] ? 0 : 1));
```

O esboço acima é ilustrativo (nomes de funções por implementar na Fase 1/4) — não é código a copiar diretamente, serve para fixar a forma do contrato antes de escrever o wrapper real.

Resolvido (ver `07-DECISOES-FASE0.md` e a secção 7 revista acima):
- Username: `assistente`.
- Persistência: ficheiro `/var/lib/assistente/apply/<chave>.json`, sem SQLite.
- `dry_run` fica na chave de consulta.
- Limite do payload: 256 KB (`assistente-wrapper`, `head -c`).

A CONFIRMAR (fora do âmbito da Fase 4, só relevante para a Fase 5/prod):
- Chave de apply de prod só é gerada e instalada na Fase 5, com aprovação explícita.
