Skip to content

feat(security): écriture path-scopée pour les presets audit-output (#177) - #220

Draft
swoofer wants to merge 6 commits into
mainfrom
fix/177
Draft

feat(security): écriture path-scopée pour les presets audit-output (#177)#220
swoofer wants to merge 6 commits into
mainfrom
fix/177

Conversation

@swoofer

@swoofer swoofer commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Le compteur (P1)

gardien tente d'écrire src/x.ts ET AUDIT.mdAUDIT.md passe, src/x.ts refusé ; essaim solo gardien produit AUDIT.md sur disque (non-régression #34), 0 autre fichier modifié. Closes #177.

Le problème (moitié restante de #1)

gardien (écrit AUDIT.md) et phare-* (écrivent tmp/audit/*) ont read-only-mode ET audit-output → « lecture seule SAUF ces chemins ». Ils gardaient Write+Bash complet : sur workspace: shared + -p ., ils pouvaient écrire n'importe quel source de l'utilisateur. --allowedTools est par-outil, pas par-chemin, et --dangerously-skip-permissions ignore settings.json → le seul mécanisme de path-scope est un hook PreToolUse.

La solution

  • Hook PreToolUse scripts/audit_output_guard.sh émis par behaviors/audit-output.yaml avec params.paths en args : autorise Write/Edit/NotebookEdit uniquement sur les chemins d'audit, refuse ailleurs. Deny via JSON stdout + exit 0 (le wrapper promptweave || exit 1 avalerait un exit 2). Fail-closed (jq absent / stdin vide / chemin non résoluble), traversal-safe (resolve_physical + refus symlink).
  • Tool-lock (bridge tri-état presetIsAuditOutputaudit_output → agent-loop) : Bash + MultiEdit + nested agents retirés via disallowedTools — les vecteurs d'écriture que le hook ne couvre pas (matcher = Edit|Write|NotebookEdit). MultiEdit fermé après revue (bypass sous skip-permissions).
  • check-interrupt déprécié retiré des presets audit-output : il écrivait sur stdout et aurait pollué le JSON du deny.

Note de faisabilité

Vérifié : livrable sur promptweave 0.5.1 (le buffer stdin y est déjà, assemble.js:97 ; l'état vulnérable était 0.5.0). Pas de dépendance à 0.5.2.

Tests falsifiables

  • tests/audit_output_guard.test.sh (bridgé vitest, skip si jq absent) : allow AUDIT.md / deny src/x.ts / deny traversal / fail-closed.
  • tests/unit/audit_output_chain.test.ts : la vraie chaîne assemblée émet le hook avec les chemins.
  • tests/unit/read-only-enforcement.test.ts : wiring audit_output, tool-lock contient Bash/MultiEdit/nested et PAS Write/Edit.

Hook vérifié à la main : deny src/x.ts + traversal + stdin vide, allow AUDIT.md — tous via JSON stdout+exit 0. 922 tests verts, build OK.

🤖 Generated with Claude Code

swoofer and others added 6 commits August 31, 2026 22:52
)

Suite de #1. Le verrou read_only (#176) protège les LECTEURS PURS ; gardien
(AUDIT.md) et phare-* (tmp/audit/*) ont read-only-mode ET audit-output —
« lecture seule SAUF ces chemins » — mais gardaient Write+Bash complet : sur
workspace: shared + -p ., ils pouvaient écrire N'IMPORTE quel fichier source,
pas seulement leur livrable. L'autre moitié du trou de #1. Chantier #10.

Sous --dangerously-skip-permissions l'allowlist d'outils est indicative ; deux
mécanismes verrouillent :

1. HOOK PreToolUse (scripts/audit_output_guard.sh), émis par
   behaviors/audit-output.yaml, assemblé/câblé par la chaîne existante. Il
   autorise Write/Edit/NotebookEdit UNIQUEMENT sur les chemins d'audit déclarés
   (params.paths), refuse ailleurs. Résistant à la traversée : résout le plus
   long préfixe existant (pwd -P → .. et symlinks résolus), rejette tout ..
   restant, compare des chemins ABSOLUS. Le REFUS passe par un JSON de décision
   sur stdout + exit 0 (le wrapper promptweave `|| exit 1` masquerait un exit 2)
   — format vérifié contre un vrai claude.

2. Bash retiré côté agent-loop (disallowedForAuditOutput = Bash + nested), car
   le matcher PreToolUse ne cible qu'Edit|Write|NotebookEdit : sans ça,
   `bash -c 'echo > src/x'` contournerait le path-scope. Write/Edit RESTENT
   (le hook les scope). Câblé via presetIsAuditOutput -> agent.audit_output ->
   AgentLoopConfig.auditOutput, aux deux sites d'envoi (send + interrupt).

Acceptance #177 prouvée bout-en-bout (vrai claude, câblage de prod) : gardien
écrit AUDIT.md (passe, non-régression #34) mais src/pwn.ts est REFUSÉ. Tests :
13 cas du guard (autorise AUDIT.md/tmp-audit-absent ; refuse src/traversée/
absolu/préfixe-trompeur), disallow audit-output (Bash oui, Write non), wiring
(gardien audit_output=true, émet le hook avec ses chemins).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ne (revue #177)

Revue adverse sécurité (12 findings). Les 2 critiques rendaient le guard INERTE
en prod ; le test d'acceptation initial était invalide (guard en ISOLATION, pas
la chaîne assemblée).

- CRITIQUE stdin drainé : dans le hook pre-tool-use assemblé, activity-tracking
  (order 50) fait `INPUT=$(cat)` et vide le pipe avant le guard (order 60), qui
  lisait EOF -> autorisait tout. Corrigé EN AMONT dans promptweave >= 0.5.2
  (assembleHooks bufferise stdin et le redonne à chaque hook). ^0.5.1 couvre 0.5.2.
- CRITIQUE fail-open jq : sans jq (défaut Windows/Git-Bash), le guard autorisait
  tout. Désormais FAIL-CLOSED : jq absent OU stdin vide -> deny littéral (sans jq).
- HAUT symlink : la feuille cible n'était pas résolue ; une cible symlink est
  désormais refusée (une écriture la suivrait hors scope).

Test d'acceptation REFAIT via la VRAIE chaîne assemblée (audit_output_chain.test.ts) :
AUDIT.md autorisé, src/x.ts refusé — ce test ÉCHOUE sur promptweave 0.5.1 (stdin
drainé -> fail-closed refuse AUDIT.md), donc il verrouille la dépendance 0.5.2.
Plus 3 cas fail-closed dans le .test.sh du guard.

Reste en suivi (findings mineurs, #<suivi>) : chemin d'audit à espace (word-split ;
aucun preset concerné), casse NTFS (faux-refus, direction sûre), gap `essaim solo`
(pas de skip-permissions ni hook ; l'issue scopait solo en non-régression).

REQUIS pour activer en prod : publier @swoofer/promptweave 0.5.2 puis `pnpm install`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…y non pollué (re-revue #177)

2e revue sécurité : une FUITE HAUTE résiduelle. Le hook pre-tool-use assemblé
enchaîne check-interrupt (order 10), activity-tracking (50), puis le guard (60),
partageant UN stdout. Sur interrupt en attente, check_interrupt émet une bannière
MULTI-LIGNES (blank lines + détails de thread) AVANT le deny JSON du guard. Reproduit
contre un vrai claude : le stdout n'est plus du JSON valide -> le parse échoue ->
le deny est perdu -> l'écriture hors scope PASSE. La mitigation « guard en dernier »
ne couvrait que le texte APRÈS le guard, pas celui d'avant.

- check-interrupt est DÉPRÉCIÉ (les interrupts passent par le push MQTT de
  l'agent-loop) : on le retire des 6 presets audit-output (gardien, phare-*).
  Reste alors activity-tracking (stdout SILENCIEUX -> /dev/null) puis le guard :
  le deny JSON est le stdout UNIQUE et propre. Vérifié contre un vrai claude +
  parse JSON.
- Tests : la chaîne assemblée rend un deny PARSABLE (JSON.parse) ; le hook
  pre-tool-use de gardien ne contient plus check_interrupt.

Ferme les 3 confirmés de la re-revue (tous la même pollution de stdout). Les 3
critiques du 1er tour (stdin-drain, fail-open jq, symlink) sont confirmés clos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e revue #177)

Revue de faisabilité (workflow) : MultiEdit était un vecteur d'écriture NON
couvert — absent du matcher PreToolUse (Edit|Write|NotebookEdit), du case du hook
audit_output_guard.sh, ET du disallow audit-output. Sous
--dangerously-skip-permissions l'allowlist (CODE_TOOLS, sans MultiEdit) est du
théâtre : le modèle pouvait appeler MultiEdit pour écrire hors des chemins
d'audit. Ajouté au tool-lock DISALLOWED_FOR_AUDIT_OUTPUT (redondant avec Edit,
que le hook path-scope — le retirer ne coûte rien). Assertion ajoutée.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nerte sur Linux (#177)

CI ubuntu : « audit_output_guard.sh: Permission denied ». Le wrapper de hook
généré par promptweave invoque le script DIRECTEMENT (\"$BCE_SCRIPTS_DIR/x.sh\"),
pas via `bash`. Commité en 100644 (sans bit exec), il n'est pas exécutable sur
POSIX → le hook de décision ne produit AUCUN deny JSON → l'écriture hors scope
serait AUTORISÉE en silence. L'enforcement #177 était donc inerte sur Linux
(non détecté sur Windows/Git-Bash, qui ignore le bit exec). Mode passé à 100755.

Note : les autres scripts/*.sh sont aussi 100644 (hooks non-décisionnels →
échec avalé par `|| true`, no-op silencieux sur Linux) — bug pré-existant traité
à part.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(pas d'exécution)

CI : le chain-test échouait sur les DEUX plateformes en exécutant le wrapper bash
multi-script assemblé via spawnSync — non portable :
- ubuntu : le test concaténait stdout+stderr puis JSON.parse ; pre_track_activity.sh
  (100644, pré-existant) émettait « Permission denied » sur stderr → parse cassé.
- windows : Git-Bash livrait un stdin vide au guard à travers le wrapper.
- Sa prémisse était fausse (il prétendait que 0.5.1 draine stdin / exige 0.5.2 ;
  or 0.5.1 a déjà le buffer __BCE_HOOK_STDIN).

Le comportement allow/deny du guard reste couvert, portablement, par
tests/audit_output_guard.test.sh (exécute le guard directement, vert sur ubuntu
ET windows). Ce test asserte désormais la COMPOSITION du wrapper — ce qui garantit
la délivrance du deny : buffer stdin redonné AU guard (anti-drain), guard invoqué
`|| true` (deny via stdout, pas exit code), check-interrupt absent. Portable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@swoofer
swoofer marked this pull request as draft September 1, 2026 03:21
@swoofer

swoofer commented Sep 1, 2026

Copy link
Copy Markdown
Owner Author

⚠️ Gaté sur la publication de promptweave 0.5.2 — ne pas merger en l'état

Vérifié : le 0.5.1 publié sur npm n'a PAS le buffer stdin (npm pack @swoofer/promptweave@0.5.1 ⇒ 0 occurrence de __BCE_HOOK_STDIN). Sur 0.5.1, activity-tracking (order 50) draine stdin avant le guard (order 60) → le guard lit EOF → fail-closed deny sur TOUT, y compris AUDIT.md → presets cassés. La CI le prouve (le chain-test échoue faute de buffer). Le buffer est le commit e46b835 de promptweave, présent en 0.5.2 (non publié).

(Un verdict local antérieur « livrable sur 0.5.1 » était faux : le store .pnpm local était contaminé par un build de la source 0.5.2 ; la CI, qui fetch le vrai tarball, fait foi.)

Débloquer

cd C:/Users/gagno/projet/promptweave && npm publish   # 0.5.2

puis bump @swoofer/promptweave (package.json + lockfile) dans essaim.

À traiter avant merge (revue sécurité — aucun bypass critique, mais)

  • F1 (décision produit) : bloquer Bash casse les 5 presets phareaudit-specialist.yaml impose wc -l (LOC exact), Read tronque à 2000 lignes. gardien épargné (pas d'audit-specialist). Options : retirer le mandat wc -l (LOC via Read), OU calculer le LOC en pré-pass, OU réduire Ecriture path-scopee pour les presets audit-output (gardien, phare) sous skip-permissions #177 à gardien. Ne PAS réautoriser Bash (écrit où il veut → défait le path-scope).
  • C1 (sécu latente) : for allowed in $arg globe (bash pathname expansion) — inoffensif tant que params.paths est littéral, mais un glob futur = bypass. Fix : set -f.
  • F2 : comparaison casse-sensible → sur-refus sous Windows (jamais un bypass). Fix : compare insensible à la casse sur win32.

Ce qui est sain et prêt : hook audit_output_guard.sh (+x, deny via JSON stdout, fail-closed, traversal-safe), tool-lock Bash+MultiEdit+nested (atteint les 2 sites d'envoi, en union), check-interrupt retiré, tests. Le deny est bien délivré (le || true du wrapper n'agit que sur l'exit code).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ecriture path-scopee pour les presets audit-output (gardien, phare) sous skip-permissions

1 participant