diff --git a/.agents/skills/plan-issue/ui-design-html.md b/.agents/skills/plan-issue/ui-design-html.md index afab3edd..dad37c76 100644 --- a/.agents/skills/plan-issue/ui-design-html.md +++ b/.agents/skills/plan-issue/ui-design-html.md @@ -19,6 +19,7 @@ Mesma doutrina para os dois papéis (fonte única — os prompts dos agentes apo - **Criticar:** o `designer` relê a **implementação renderizada** (screenshots do app) contra o artefato aprovado e devolve **lista numerada de ajustes concretos** — hierarquia (o CTA primário domina?), contraste, tipografia, espaçamento, mobile, acessibilidade. **A referência é o alvo — nunca a critique.** - **Visão nativa (fail-closed):** leia screenshots, prints e referências **direto com a tool Read**; nunca peça ao humano para descrever o que você pode ver. Se a leitura da imagem falhar (modelo da sessão sem visão), **pare** e peça a troca de modelo via `/models` antes de julgar a tela; **nunca descreva o que não viu**. - **Tier degradado:** o `designer-degraded` cria/estende/critica, mas **marca todo output `DEGRADED`** e **nunca certifica** — exige sign-off humano explícito. `DEGRADED` não é design aprovado. +- **Escrita só no artefato (file tools + bash):** os dois agentes têm `permission` fail-closed — `edit`/`write`/`patch` negados fora de `docs/plans/-ui-design*` e o **bash** nega os vetores de escrita enumerados (redirecionamento, `sed -i`, `tee`, shells/interpretadores) fora do artefato; o resto do shell fica em `ask`. Não tente contornar por shell (o guard cobre os vetores enumerados, não é sandbox de SO) — leia/inspecione à vontade (`cat`, `sed -n`, `git diff`) e grave o artefato pelas file tools. ## O que o design DEVE ter (fidelidade mínima) diff --git a/.opencode/agent/designer-degraded.md b/.opencode/agent/designer-degraded.md index e4b31787..fd3366db 100644 --- a/.opencode/agent/designer-degraded.md +++ b/.opencode/agent/designer-degraded.md @@ -6,6 +6,39 @@ permission: edit: '*': deny 'docs/plans/*-ui-design*': allow + bash: + '*': ask + '*>*': deny + 'sed *-i*': deny + 'tee *': deny + 'cp *': deny + 'mv *': deny + 'rm *': deny + 'mkdir *': deny + 'touch *': deny + 'truncate *': deny + 'dd *': deny + 'install *': deny + 'ln *': deny + 'chmod *': deny + 'chown *': deny + 'bash *': deny + 'sh *': deny + 'zsh *': deny + 'python*': deny + 'node *': deny + 'bun *': deny + 'deno *': deny + 'perl *': deny + 'ruby *': deny + 'find *-exec*': deny + 'find *-delete*': deny + 'xargs *': deny + 'git checkout*': deny + 'git restore*': deny + 'git apply*': deny + '* > docs/plans/*-ui-design*': allow + 'prettier --write docs/plans/*-ui-design*': allow --- # Persona: Designer degradado do Teqo (tier de fallback) diff --git a/.opencode/agent/designer.md b/.opencode/agent/designer.md index d3f3f12d..25d17f75 100644 --- a/.opencode/agent/designer.md +++ b/.opencode/agent/designer.md @@ -7,6 +7,39 @@ permission: edit: '*': deny 'docs/plans/*-ui-design*': allow + bash: + '*': ask + '*>*': deny + 'sed *-i*': deny + 'tee *': deny + 'cp *': deny + 'mv *': deny + 'rm *': deny + 'mkdir *': deny + 'touch *': deny + 'truncate *': deny + 'dd *': deny + 'install *': deny + 'ln *': deny + 'chmod *': deny + 'chown *': deny + 'bash *': deny + 'sh *': deny + 'zsh *': deny + 'python*': deny + 'node *': deny + 'bun *': deny + 'deno *': deny + 'perl *': deny + 'ruby *': deny + 'find *-exec*': deny + 'find *-delete*': deny + 'xargs *': deny + 'git checkout*': deny + 'git restore*': deny + 'git apply*': deny + '* > docs/plans/*-ui-design*': allow + 'prettier --write docs/plans/*-ui-design*': allow --- # Persona: Designer de referência do Teqo diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 3d4fabbb..342fb64d 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -21,7 +21,7 @@ main → push dispara deploy.yml sozinho (ou workflow_dispatch manual) → prefl **Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` (GitHub) → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = Issues do GitHub (`github.com/fsolla/teqo/issues`). -**Design hi-fi (OPS113):** item que muda UI passa pelo agente `designer` (`model: openai/gpt-5.6-sol`; crítica contestada/pixel-critical em `openai/gpt-6-astra`, mesmo agente via `/models`); a doutrina `.agents/skills/plan-issue/ui-design-html.md` é a fonte única do artefato `docs/plans/-ui-design.html` (+ `-ui-design-assets/*.svg`), que é a fonte de verdade do port — o implementador porta classe-a-classe. Indisponibilidade/quota desce a ladder — `opencode-go/deepseek-v4.1-flash` (agente `designer-degraded`) → `deepseek/deepseek-flash` (inline pelo orquestrador) — sempre gravando o tier no PR (`Design tier:`); tier degradado marca `DEGRADED`, não certifica e para em sign-off humano (triggers no work-issue: OPS115). +**Design hi-fi (OPS113):** item que muda UI passa pelo agente `designer` (`model: openai/gpt-5.6-sol`; crítica contestada/pixel-critical em `openai/gpt-6-astra`, mesmo agente via `/models`); a doutrina `.agents/skills/plan-issue/ui-design-html.md` é a fonte única do artefato `docs/plans/-ui-design.html` (+ `-ui-design-assets/*.svg`), que é a fonte de verdade do port — o implementador porta classe-a-classe. Indisponibilidade/quota desce a ladder — `opencode-go/deepseek-v4.1-flash` (agente `designer-degraded`) → `deepseek/deepseek-flash` (inline pelo orquestrador) — sempre gravando o tier no PR (`Design tier:`); tier degradado marca `DEGRADED`, não certifica e para em sign-off humano (triggers no work-issue: OPS115). Ambos têm `permission` fail-closed: `edit` negado fora de `docs/plans/*-ui-design*` e `bash` com os **vetores de escrita enumerados** negados (redirecionamento, `sed -i`, `tee`, shells/interpretadores — OPS117), preservando leitura e o `prettier --write` do artefato; o resto do shell fica em `ask` (o guard cobre os vetores, não é sandbox de SO). - **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **e2e local afetado** (OPS72, discricionário: roda os e2e que criou + os da mesma superfície — `pnpm test:e2e:affected`; ver skill) → **`pnpm push`** (origin = GitHub) → PR via `node scripts/github-pr.mjs` **Ready** (nunca draft) com `Closes #N` → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do GitHub** (`enablePullRequestAutoMerge`, rebase) — o servidor mergea quando o required check `CI (PR) / checks` fica verde. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false` (o default draft da tool **não** vale neste repo) — o merge continua pelo safety net. - **Só humano:** aprovar o environment `production` no run (o único gate de publicação — OPS104 nunca auto-aprova; OPS107: aprove só o run escolhido e rejeite os demais — runs não aprovados não publicam nada), envs de produção no homeserver (`~/stack/teqo-1313.env`, fora do repo — o deploy as lê via BuildKit secrets), `GITHUB_TOKEN` local (PAT) para os scripts GitHub, secrets no repo GitHub, `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`). diff --git a/docs/changelog/2026-09-16-ops117.md b/docs/changelog/2026-09-16-ops117.md new file mode 100644 index 00000000..d4e4eb46 --- /dev/null +++ b/docs/changelog/2026-09-16-ops117.md @@ -0,0 +1 @@ +**OPS117 — Guard de escrita dos agentes de design além dos file tools (bash) (2026-09-16):** fecha o resíduo declarado no OPS113 — os agentes `designer` e `designer-degraded` eram fail-closed nos file tools (`edit`/`write`/`patch`), mas a escrita via **bash** (redirecionamento, `sed -i`, `tee`) escapava do gate. Ambos os frontmatters ganham o mesmo bloco `permission.bash`: `'*': ask` + deny dos vetores de escrita (`*>*`/`>>`, editores in-place `sed`, mutadores `cp/mv/rm/mkdir/touch/truncate/dd/install/ln/chmod/chown/tee`, shells/interpretadores `bash/sh/zsh/python/node/bun/deno/perl/ruby`, `find -exec/-delete`, `xargs`, `git checkout/restore/apply`) e, **por último** (`last-match-wins`), o allow do artefato (`* > docs/plans/*-ui-design*`, `prettier --write docs/plans/*-ui-design*`). Evidência do mecanismo: o tool bash do opencode 1.18.31 resolve `permission.bash` pelo source tree-sitter do comando e, em redirecionamento, casa o `redirected_statement` inteiro **incluindo o caminho de destino**; smoke em subprocesso `opencode run --auto --agent designer` provou `echo x > src/…` negado (arquivo ausente), artefato permitido, `sed -i`/`tee`/`printf >`/`bash -c`/`python3 -c` negados e `cat` (leitura) não bloqueada. Novo `tests/unit/opencodeAgents.unit.spec.ts` (anti-drift: os dois frontmatters têm o bloco `bash` idêntico, o allow do artefato é a última regra e o `edit` fail-closed do OPS113 segue intacto). 1 nota na doutrina `.agents/skills/plan-issue/ui-design-html.md` e 1 frase em `docs/AGENT-OPS.md`. Resíduo honesto declarado: não é sandbox de SO — sob `--auto` um vetor exótico fora da deny-list ainda poderia passar; sem migration, sem schema e sem `src/`. Verificação: `opencode debug agent` (ruleset resolvido) + smoke subprocesso, `pnpm gate:fast`; Closes #1063. diff --git a/docs/plans/ops117-guard-bash-agentes-design-impl.md b/docs/plans/ops117-guard-bash-agentes-design-impl.md new file mode 100644 index 00000000..ebc8a5ae --- /dev/null +++ b/docs/plans/ops117-guard-bash-agentes-design-impl.md @@ -0,0 +1,145 @@ +# Impl: OPS117 — Guard de escrita dos agentes de design além dos file tools (bash) + +Status: entregue (2026-09-16) +Atualizado em: 2026-09-16 +Issue: #1063 +Intenção: docs/plans/ops117-guard-bash-agentes-design.md +Appetite restante: herdado (~0,5 dia eng; um outcome verificável) + +## Leitura da intenção + +- **Outcome:** a escrita em disco via bash pelos agentes `designer` e `designer-degraded` fora de `docs/plans/-ui-design*` falha fechado (negada); gravar o próprio artefato (`.html` + `-ui-design-assets/*.svg`) segue permitido; leitura (`cat`/`sed -n`) não é bloqueada; nada de sandbox de SO nem agente twin; nada em `src/`. +- **O que NÃO negociar:** output só no artefato; leitura livre; sem sandbox de SO; sem twin de agente; sem tocar `src/`/schema; o gate de file tools do OPS113 permanece. +- **O que reavaliar:** a hipótese da intenção ("`permission.bash` casa comando, não caminho — a regra por caminho pode não existir") **já foi resolvida pela evidência**: com redirecionamento o glob casa o `redirected_statement` inteiro, **incluindo o destino do redirect**, logo uma regra de artefato por caminho é viável (e `last-match-wins` permite o allow depois do deny). O risco "semântica por caminho não existir" está morto. + +## Abordagem recomendada + +```mermaid +flowchart LR + INT["Intenção OPS117: bash fora do artefato"] --> A["F1 · permission.bash declarativa\ndefault ask + deny-list\n+ allow do artefato (último)"] + A --> AG["F1 · designer.md +\ndesigner-degraded.md\n(blocos idênticos)"] + AG --> T["F2 · teste unit anti-drift\nfrontmatters idênticos"] + AG --> D["F2 · ui-design-html.md (1 nota)\n+ AGENT-OPS.md:24 (1 frase)"] + T --> V["F3 · smoke subprocesso\n+ opencode debug agent\ngate:fast · diff sem src/"] + D --> V +``` + +**Opções consideradas:** A | B | C +**Recomendação:** A — `permission.bash` declarativa por agente: default `ask` + deny-list curada dos vetores de escrita + allow explícito do artefato por último. É config pura em 2 frontmatters já existentes (o `permission.edit` fail-closed do OPS113 fica intocado), cabe no appetite e tem evidência empírica de que o match por caminho no redirect funciona (`echo x > docs/plans/foo-ui-design.html` → allow; `echo x > src/a.txt` → deny, arquivo ausente). +**Rejeitadas:** B — plugin `.opencode/plugins/*.ts` (`tool.execute.before` + `throw`): o input do hook é `{tool, sessionID, callID}` e **não traz o agente** (`@opencode-ai/plugin@1.18.31`), escopar aos dois agentes exigiria cache frágil `sessionID→agent` via `chat.params`; infra nova versionada, lint/prettier em `.ts` e complexidade >> appetite; C — default-deny com allowlist no bash: bloquearia leitura (`cat`, `git diff`, `sed -n`), fora de escopo explícito da intenção. + +### Decisões de engenharia (com rejeitadas) + +1. **Forma do guard.** + - Opções: A) declarativa `permission.bash` por agente | B) plugin com hook | C) default-deny + allowlist. + - Recomendação: **A**. Bloco **idêntico** nos dois frontmatters, sob o `permission` existente: + ```yaml + permission: + edit: # intocado (OPS113) + '*': deny + 'docs/plans/*-ui-design*': allow + bash: + '*': ask + '*>*': deny + 'sed *-i*': deny + 'tee *': deny + 'cp *': deny + 'mv *': deny + 'rm *': deny + 'mkdir *': deny + 'touch *': deny + 'truncate *': deny + 'dd *': deny + 'install *': deny + 'ln *': deny + 'chmod *': deny + 'chown *': deny + 'bash *': deny + 'sh *': deny + 'zsh *': deny + 'python*': deny + 'node *': deny + 'bun *': deny + 'deno *': deny + 'perl *': deny + 'ruby *': deny + 'find *-exec*': deny + 'find *-delete*': deny + 'xargs *': deny + 'git checkout*': deny + 'git restore*': deny + 'git apply*': deny + '* > docs/plans/*-ui-design*': allow + 'prettier --write docs/plans/*-ui-design*': allow + ``` + - **Curadoria (29 deny + 1 catch-all + 2 allow) em 5 classes**, cada linha justificada por um vetor concreto: (1) redirecionamento `*>*` cobre `>` e `>>`; (2) editores in-place `sed` (`sed *-i*` casa `-i` e `--in-place`); (3) mutadores de arquivo (`cp/mv/rm/mkdir/touch/truncate/dd/install/ln/chmod/chown/tee`); (4) interpretadores/shells que reabrem a escrita por dentro (`bash/sh/zsh/python/node/bun/deno/perl/ruby`, evidência `bash -c` e `python3 -c`); (5) destrutivos de git (`checkout/restore/apply`) e `find -exec/-delete`/`xargs`. **Fora da lista (permanecem `ask`, auto-aprovados em `--auto` e legíveis num run interativo):** leitura/inspeção (`cat`, `sed -n`, `ls`, `rg`, `git diff/status/log`), `<` (input), peers de pipeline (o parser checa cada comando). O `'*': ask` fica explícito (baseline do ruleset e merge com o global `bash: ask`; regras do agente vêm depois ⇒ agente vence). + - Rejeitadas: B/C acima; e a tentação de deny-list exaustiva (infla manutenção para um risco de ferramenta de design — o resíduo é declarado, não esgotado). + +2. **Teste unit anti-drift (barato, incluído).** + - Opções: A) `tests/unit/opencodeAgents.unit.spec.ts` lê os dois `.md` e exige o bloco `bash` idêntico | B) sem teste (confiança no review). + - Recomendação: **A** — o custo da divergência (um agente guardado, o outro vazando) é exatamente o anti-goal; o precedente `opencodeCommands.unit.spec.ts` já lê `.opencode/*.md` sem app/DB. Extrai o sub-bloco `bash:` do frontmatter por regex (sem dependência de YAML) e afirma: existe nos dois, é **idêntico**, contém `'*': ask`, `'*>*': deny` e o allow `* > docs/plans/*-ui-design*`; o bloco `edit` segue fail-closed (`'*': deny` + allow do artefato). Roda no `test:unit` do `gate:fast`. + - Rejeitadas: B — drift silencioso entre dois arquivos gêmeos é o modo de falha mais provável e mais grave; o teste é a única prova contínua. + +3. **Docs (toque mínimo, sem twinar).** + - Opções: A) 1 nota em `ui-design-html.md` + 1 frase em `docs/AGENT-OPS.md:24` | B) só a doutrina | C) nova seção H2 no AGENT-OPS. + - Recomendação: **A**. A doutrina é a dona do contrato do artefato ("o bash dos agentes é guardado; escrita fora do artefato é negada") e o parágrafo OPS113 do AGENT-OPS (entrada operacional) passa a dizer que a `permission` cobre **file tools + bash**. Uma frase em cada; nenhuma seção nova. + - Rejeitadas: B — deixa o doc operacional mentindo (diz só file tools); C — cerimônia para uma frase. + +### Componentes / mudanças + +- **`.opencode/agent/designer.md`** / **`.opencode/agent/designer-degraded.md`**: adicionar o bloco `bash` da decisão 1 sob o `permission` (edit intocado); textos dos inegociáveis já citam "não tente contornar por shell" — sem reescrita. +- **`tests/unit/opencodeAgents.unit.spec.ts`** (novo): anti-drift dos dois frontmatters (decisão 2); sem app/DB (fs puro). +- **`.agents/skills/plan-issue/ui-design-html.md`** e **`docs/AGENT-OPS.md`**: 1 frase cada (decisão 3). +- **`docs/changelog/2026-09-16-ops117.md`** (novo): linha única no formato vigente. +- **Migration:** sem migration. **Access / Consent:** N/A. **UI:** Impeccable A — N/A sem UI de produto; nenhum arquivo em `src/` é tocado. + +## Fases verificáveis + +1. **F1 — Guard declarativo** (~40%): bloco `bash` idêntico nos dois agentes; `opencode debug agent designer` (subprocesso novo — config não é hot-reload) mostra o ruleset resolvido (`*>* deny`, artifact allow). +2. **F2 — Teste + docs** (~30%): `tests/unit/opencodeAgents.unit.spec.ts` verde; 1 frase em `ui-design-html.md` e em `AGENT-OPS.md:24`; changelog. +3. **F3 — Verificação** (~30%): smokes em subprocesso; `pnpm exec prettier --check` nos tocados; `pnpm gate:fast`; `git diff --stat` sem `src/`; `pnpm push` → PR `Closes #1063`. + +## Rabbit holes / Não escopo (engenharia) + +- Plugin/hook (decisão 1B), default-deny com allowlist (1C), sandbox de SO/container, bloquear leitura, agente twin, mexer no allowlist de file tools do OPS113, qualquer coisa em `src/`/schema/Consent. +- Exaustão da deny-list (vetores exóticos ficam no resíduo declarado) e ressuscitar `design-vision`/renderizador de PNG. +- Não tocar consumidores OPS114/OPS116 nem outros `.opencode/agent/*.md`. + +## Riscos e mitigação + +- **Resíduo honesto:** não é sandbox de SO; sob `--auto`, um vetor de escrita exótico fora da deny-list poderia passar. Mitigação: deny-list curada + proibição no prompt + supervisão humana; declarado no PR (não prometer mais que o entregue). +- **Falso-deny do artefato** (allow depois do deny, `last-match-wins`): o smoke prova `echo x > docs/plans/-ui-design.html` permitido; se falhar, corrigir o padrão antes do merge (fail-closed). +- **Drift entre os dois frontmatters:** teste anti-drift (decisão 2). +- **Merge com o global `bash: ask`:** regras do agente vêm depois ⇒ vencem; validado pelo `opencode debug agent`. +- **Prettier/eslint em `.ts` novo e em `.opencode/`:** rodar `prettier --write` nos tocados; `.opencode/` não é ignorado (`.prettierignore` ignora `.agents/`, não `.opencode/`). +- **Disponibilidade do modelo do designer no smoke** (`openai/gpt-5.6-sol`): se indisponível, rodar o smoke do tier degradado (via task) ou o harness isolado equivalente, registrando o tier no PR. +- **Lixo de smoke no diff:** limpar `docs/plans/ops117-ui-design-smoke.html` e `src/ops117-smoke.txt` antes do commit. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto: escrita via bash fora de `docs/plans/*-ui-design*` negada nos dois agentes; artefato gravável; leitura não bloqueada; sem sandbox/twin/`src/`. +- [x] Invariantes AGENTS/engineering-standards: sem tocar `src/`/migrations; edita o dono (frontmatter/permission existentes, doutrina existente) sem twinar; identificadores em inglês, texto de prompt/admin em pt-BR. +- [x] Testes de domínio previstos: `tests/unit/opencodeAgents.unit.spec.ts` (anti-drift dos frontmatters) + smoke de permissão em subprocesso como evidência no PR; sem int (nenhum access/write path de app muda). + +### Simplify (2026-09-16) + +- `'sed -i*'` era subsumida por `'sed *-i*'` (`*` → `.*`) — removida nos dois agentes; smoke re-provou o deny de `sed -i` com a regra única. +- Teste anti-drift passou a pinar o ruleset COMPLETO (não só comparar os dois arquivos): erosão simultânea de uma regra nos dois agentes também falha o build. +- Copy de `docs/AGENT-OPS.md` e da doutrina precisada para "vetores de escrita enumerados" (o guard não é sandbox de SO; o baseline é `ask`). + +### Triagem pós-simplify (2026-09-16) + +- **Já resolvido no simplify (não reabrir):** linha em branco dupla no plano (prettier), teste anti-drift pinando o ruleset completo (erosão simultânea), `'sed -i*'` redundante removida, copy "vetores de escrita enumerados / não é sandbox", `bash` em backticks no AGENT-OPS. +- **Absorvido:** status do plano `em execução` → `entregue`, coerente com os aceites `[x]`. +- **Descartados:** `designer-campanha-solla.md` sem `permission` — é persona designer **e implementador** (constrói a página de campanha; precisa escrever, inclusive em `src/`), fora da família OPS113/117; um guard fail-closed o quebraria (não é débito). Resíduo do `--auto` sob vetor exótico fora da deny-list — limitação aceita e já declarada, não é sandbox de SO. +- **Défer com gatilho:** nenhum. + +### Verificação executada (2026-09-16) + +- `opencode debug agent designer` / `designer-degraded`: 33 regras `bash` idênticas (32 do agente + o `ask` global), `edit` fail-closed intacto. +- Smoke `opencode run --auto --agent designer`: `echo x > src/ops117-smoke.txt` → `pattern=*>* action=deny` (arquivo ausente); `echo ok > docs/plans/ops117-ui-design-smoke.html` → allow do artefato (arquivo criado); `sed -i` (`sed *-i*`)/`tee`/`printf >`/`bash -c`/`python3 -c` negados; `cat`/`ls` (leitura) não bloqueados. `designer-degraded` via task: mesmo deny. Artefatos de smoke removidos. +- `pnpm gate:fast` verde (lint + typecheck + 3383 unit). + +--- + +Self-score decision-quality: 5/5 — (1) as decisões caras têm opções e rejeitadas (forma A/B/C, curadoria da deny-list, teste, docs); (2) cabe no appetite: 2 frontmatters + 1 spec + 2 frases de doc + changelog, sem código de app; (3) rabbit holes nomeados (plugin, allowlist default-deny, sandbox, exaustão da lista); (4) depth check: reusa o `permission`/precedente de frontmatter do OPS113 e o padrão de spec do `opencodeCommands`, sem criar módulo/helper/twin; (5) o outcome da intenção fica intacto — a engenharia só escolheu a forma declarativa e declarou o resíduo honestamente. diff --git a/tests/unit/opencodeAgents.unit.spec.ts b/tests/unit/opencodeAgents.unit.spec.ts new file mode 100644 index 00000000..54e6d717 --- /dev/null +++ b/tests/unit/opencodeAgents.unit.spec.ts @@ -0,0 +1,92 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// OPS117 guard: the two design agents must carry the SAME `permission.bash` +// ruleset. They are two files by design (the degraded agent is a distinct +// `mode: subagent` identity), so nothing but this spec stops one of them from +// drifting while the other keeps the guard — the exact anti-goal (`designer` +// guarded, `designer-degraded` leaking a shell write). +// +// The expected array is pinned on purpose: comparing the two files only would +// let a rule disappear from BOTH at once. Here the contract is the literal +// ruleset, so eroding the guard fails the build too. +// +// The spec parses the YAML frontmatter line-by-line (no YAML dependency): a +// block is the 4-space-indented run under ` :`. Order matters — bash +// rules resolve last-match-wins, so the artifact allow sits at the end. + +const repoRoot = process.cwd() +const agents = ['designer', 'designer-degraded'] as const + +// Keep in sync with `.opencode/agent/designer.md` and `designer-degraded.md`. +const expectedBash = [ + `'*': ask`, + `'*>*': deny`, + `'sed *-i*': deny`, + `'tee *': deny`, + `'cp *': deny`, + `'mv *': deny`, + `'rm *': deny`, + `'mkdir *': deny`, + `'touch *': deny`, + `'truncate *': deny`, + `'dd *': deny`, + `'install *': deny`, + `'ln *': deny`, + `'chmod *': deny`, + `'chown *': deny`, + `'bash *': deny`, + `'sh *': deny`, + `'zsh *': deny`, + `'python*': deny`, + `'node *': deny`, + `'bun *': deny`, + `'deno *': deny`, + `'perl *': deny`, + `'ruby *': deny`, + `'find *-exec*': deny`, + `'find *-delete*': deny`, + `'xargs *': deny`, + `'git checkout*': deny`, + `'git restore*': deny`, + `'git apply*': deny`, + `'* > docs/plans/*-ui-design*': allow`, + `'prettier --write docs/plans/*-ui-design*': allow`, +] + +const frontmatter = (agent: string) => { + const content = readFileSync(resolve(repoRoot, `.opencode/agent/${agent}.md`), 'utf8') + const match = content.match(/^---\n([\s\S]*?)\n---/) + expect(match, `${agent}: frontmatter must be delimited by ---`).not.toBeNull() + return match![1] +} + +const block = (agent: string, key: 'bash' | 'edit') => { + const lines = frontmatter(agent).split('\n') + const start = lines.findIndex((line) => line === ` ${key}:`) + expect(start, `${agent}: permission.${key} block must exist`).toBeGreaterThanOrEqual(0) + + const rules: string[] = [] + for (const line of lines.slice(start + 1)) { + if (line.trim() === '') continue + if (!line.startsWith(' ')) break + rules.push(line.trim()) + } + return rules +} + +describe('design agents share the OPS117 bash write guard', () => { + it.each(agents)('%s declares the pinned bash ruleset (default ask, writes denied)', (agent) => { + const rules = block(agent, 'bash') + + // Default is `ask` (never silent), the artifact is the last match, and the + // enumerated write vectors — redirect, in-place sed, tee, mutators, nested + // shells/interpreters, find -exec/-delete, xargs, destructive git — deny. + expect(rules).toEqual(expectedBash) + }) + + it.each(agents)('%s keeps the file-tool gate fail-closed (OPS113 intact)', (agent) => { + expect(block(agent, 'edit')).toEqual([`'*': deny`, `'docs/plans/*-ui-design*': allow`]) + }) +})