feat(bin): give unattended pi and claude workers one command perimeter - #2509
Open
Kallas95 wants to merge 14 commits into
Open
feat(bin): give unattended pi and claude workers one command perimeter#2509Kallas95 wants to merge 14 commits into
Kallas95 wants to merge 14 commits into
Conversation
…Claude A crewmate or scout runs with its harness's approvals disabled, so nothing stood between a Pi worker and privilege escalation, remote access and transfer, host file modes, the captain's global git config, history rewriting, a push onto master/main, or a live .env. Claude workers were covered only by a host-local hook that Pi has no equivalent for. bin/fm-worker-command-policy.mjs now owns that perimeter, once. It reuses the shell classifier exported by bin/fm-arm-command-policy.mjs rather than re-lexing, and classifies command positions instead of matching raw prefixes, so a pipeline stage, a subshell, a wrapper, or an inline shell payload is caught while pushing the task branch and ordinary work stay untouched. bin/fm-worker-pretool-check.sh is the only route to that owner, and fm-spawn wires both application points per task: Pi through the per-task extension's tool_call handler, Claude through the per-task settings PreToolUse hook. Neither restates a rule, so the two cannot drift apart. The guard fails closed, unlike the primary-side seatbelts: an unusable classifier, an unreadable payload, or unparseable syntax naming a perimeter command denies rather than allowing, fm-spawn refuses to launch a worker whose guard runtime is missing, and an extension whose transport vanished blocks every tool call with a loud reason. Boundary kept deliberate: the guard classifies the submitted command, not the bodies of scripts it runs, and .env matches the exact basename so tracked files such as config/x-mode.env stay readable.
…earch patterns as paths
… document guard boundaries
…e closed reader sets
…ll cluster residue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Extend to Pi workers a guard equivalent to the pre-tool-use firewall that Claude workers already benefit from.
Established empirically by a prior investigation (2026-08-16): from a Pi worker, reading a file whose basename is exactly .env SUCCEEDS. The cause is that the firewall is the PreToolUse hook ~/.claude/hooks/pre-tool-use-firewall.mjs registered in ~/.claude/settings.json, so it is specific to Claude Code and has no Pi equivalent. What is therefore no longer refused to a Pi worker: sudo, ssh, scp, rsync, chmod, git config --global, git rebase, git push to master/main, and reading or copying a .env. The risk radius widened suddenly because workers were switched onto Ollama, hence onto Pi, on 2026-08-16; before that, Pi workers were rare.
Known technical lever, established by the same report, not to be reinvented: Pi exposes auto-discovered extensions, and bin/fm-spawn.sh already passes a per-task extension to pi and pi-signed workers via
-e state/<id>.pi-ext.ts. The attachment point already exists; there is no new mechanism to build, only a guard to plug into it.Bounded research required before coding, inside this same task: (1) what interception surface Pi really offers to REFUSE a tool call before execution - the harness-adapters skill documents for pi a block through
tool.execute.before/tool_callon the watcher-arm guard side, so verify whether that same point serves a general denylist; (2) read ~/.claude/hooks/pre-tool-use-firewall.mjs as the SOURCE OF TRUTH for the perimeter to refuse.Non-negotiable design requirement: the perimeter must be defined in ONE PLACE ONLY. The Pi guard and the Claude firewall must share that single definition, with two application points. Rewriting the list twice is a failure of this task, because the two copies would diverge and the guard would become a false sense of security. The failure mode must be refusal, never a silent pass: a Pi worker launched without the guard active must be refused at startup, or flagged loudly - never start silently unprotected.
Acceptance criteria:
Scope note stated explicitly by the requester:
git push origin HEADremains the authorized way to push. The rule refuses pushing TO master/main, not pushing as such. This legitimate case must not break.This task changes firstmate's shared tracked material, so the firstmate-coding-guidelines skill applies: one owner per contract, no duplicated restatement, knowledge routed to its most specific owner, one sentence per line in tracked Markdown, plain dash instead of em dash, bin/*.sh shellcheck-clean under bin/fm-lint.sh, tests colocated in tests/ named .test.sh, and tests must exercise behavior through an executable or public interface and never assert implementation-source bytes.
Decisions and tradeoffs made while doing the work, which a reviewer reading only the diff would not know:
sh -cpayloads are all caught.pi.on("tool_call")handler returning {block:true}, and the Claude per-task .claude/settings.local.json gains a PreToolUse hook - and both call the same transport. A test proves it as behavior by removing the shared policy owner and asserting that BOTH application points change verdict together.What Changed
bin/fm-worker-command-policy.mjsas the single owner of the unattended-worker perimeter (sudo;ssh/scp/rsync;chmod;git config --global;git rebaseand rebasinggit pull;git pushresolving tomaster/mainplus--all/--mirror; reading or copying a file whose basename is exactly.env), reached only through the newbin/fm-worker-pretool-check.shtransport. The policy imports the tokenizer and command-position analysis frombin/fm-arm-command-policy.mjs(which now exportsSHELL_RESERVED_WORDS) instead of duplicating shell lexing, so pipeline stages, subshells, substitutions, compound bodies, carried payloads (find -exec,xargs), inlinesh -c/evalpayloads and sourcing builtins are all classified. Pushing the task branch, includinggit push origin HEAD, stays allowed.bin/fm-spawn.shfor crewmate and scout spawns: the Pi per-task extension gains api.on("tool_call")handler returning{block: true, reason}, and the Claude per-task.claude/settings.local.jsongains aPreToolUsehook with a"*"matcher. Neither restates a rule; both call the same transport, which is what keeps them from diverging, and a test proves it by removing the policy owner and asserting both live verdicts change together. The guard fails closed: missingnode/jq, an absent policy owner, an unreadable payload or an invalid policy response all deny;fm-spawn.shrefuses to launch aclaude/pi/pi-signedworker whose guard runtime is missing; a Pi extension whose transport disappeared after launch blocks every call with a loud reason; and a spawn onto any unwired harness warns that the worker starts with no perimeter.--secondmatespawns are deliberately not wired.docs/worker-command-guard.md(perimeter, threat model, what the guard holds, named non-exhaustive boundaries), registered it indocs/documentation-audiences.jsonasmaintainer-architecture, and cross-referenced it fromdocs/arm-pretool-check.mdanddocs/turnend-guard.md.tests/fm-worker-command-guard.test.shdrives real behavior: a deny/allow matrix across five harness entry forms, the file-path perimeter in both payload shapes, every fail-closed path, both live application points via a realfm-spawn.shrun, and the guard-absent case.Note: the captain's personal
~/.claude/hooks/pre-tool-use-firewall.mjslives outside this repository and is untouched; it remains a personal layer and can be reduced to a delegator callingbin/fm-worker-pretool-check.sh --claude.Risk Assessment
Testing
J'ai exercé la suite colocaliséetests/fm-worker-command-guard.test.sh(169 cas de matrice sur 5 formes d'entrée harness, périmètre file-path, chemins fail-closed, refus au lancement, deux points d'application), puis sept suites voisines touchant le classifieur shell partagé et les artefacts générés parfm-spawn.sh- tout passe sans échec. Pour la preuve produit, j'ai spawné un worker Pi réel avecbin/fm-spawn.sh, vérifié que la ligne de lancementpiporte bien-e <state>/<id>.pi-ext.ts, puis piloté l'extension générée par le handlertool_callque Pi appelle : les dix formes du critère 1 sont refusées avec leur code de raison, y compris la lecture d'un.envpar l'outilreadnatif de Pi, tandis quegit push origin HEADet le travail ordinaire passent ; la même chose est rejouée sur le point Claude par sa commandePreToolUseréellement enregistrée. Le transcript reproduit d'abord la faille sur le commit de base (l'extension d'alors n'enregistre aucun handlertool_call), montre l'unicité du périmètre en neutralisant l'unique propriétaire - les deux points basculent ensemble - et couvre les trois issues sans garde (transport disparu, runtime manquant au spawn, harness non câblé). Le changement n'a aucune surface UI rendue : l'expérience utilisateur finale est le refus d'un appel d'outil, capturé comme transcript CLI plutôt que comme capture d'écran. Seule limite : Pi est piloté dans un hôte Node nu plutôt que par une session Pi avec modèle vivant, mais le contrat{block: true}est celui déclaré par le paquet Pi installé et déjà vérifié en direct selondocs/cd-guard.md. L'arbre de travail est resté propre.Evidence: Transcript E2E - un worker Pi réel refuse le périmètre, avant/après
Source: Transcript E2E - un worker Pi réel refuse le périmètre, avant/après
0. LA FAILLE, REPRODUITE SUR LE COMMIT DE BASE BEFORE (base commit) Pi worker, read tool on /srv/app/.env: unguarded (this worker registers no tool_call handler) BEFORE (base commit) Pi worker, bash tool running sudo: unguarded (this worker registers no tool_call handler) 1. APRES: worker Pi reel spawne par bin/fm-spawn.sh the pi launch this spawn actually issued carries it: pi' -e '<state>/guard-evidence-pi.pi-ext.ts' 2. CRITERE 1, PAR LES OUTILS PROPRES DU WORKER PI EXPECTED TOOL FIELD TOOL CALL VERDICT REFUSE bash command sudo rm -rf / REFUSED [privilege-escalation] REFUSE bash command ssh build-host uptime REFUSED [remote-transfer] REFUSE bash command scp secrets.tar host:/tmp REFUSED [remote-transfer] REFUSE bash command rsync -a . host:/srv REFUSED [remote-transfer] REFUSE bash command chmod +x deploy.sh REFUSED [permission-change] REFUSE bash command git config --global user.email x@y.z REFUSED [global-git-config] REFUSE bash command git rebase -i HEAD~3 REFUSED [history-rewrite] REFUSE bash command git push origin main REFUSED [protected-branch-push] REFUSE bash command git push origin master REFUSED [protected-branch-push] REFUSE bash command git push --force origin refs/heads/master REFUSED [protected-branch-push] REFUSE bash command cat .env REFUSED [dotenv-access] REFUSE bash command cp .env /tmp/stolen REFUSED [dotenv-access] REFUSE read path /srv/app/.env REFUSED [dotenv-access] REFUSE grep path /srv/app/.env REFUSED [dotenv-access] ALLOW bash command git push origin HEAD allowed ALLOW bash command git push -u origin fm/task-branch allowed ALLOW bash command git commit -m "fix: thing" allowed ALLOW bash command git config user.email x@y.z allowed ALLOW bash command npm test allowed ALLOW bash command cat README.md allowed ALLOW bash command cat config/x-mode.env allowed ALLOW read path config/x-mode.env allowed ALLOW write path /srv/app/.env allowed ALLOW edit path /srv/app/.env allowed mismatches: 0 3. MEME PERIMETRE PAR LE HOOK PreToolUse DU WORKER CLAUDE (matcher "*") {"tool_name":"Bash",...{"command":"sudo id"}} REFUSED (exit 2) [privilege-escalation] {"tool_name":"Read",...{"file_path":"/srv/app/.env"}} REFUSED (exit 2) [dotenv-access] {"tool_name":"Bash",...{"command":"git push origin main"}} REFUSED (exit 2) [protected-branch-push] {"tool_name":"Bash",...{"command":"git push origin HEAD"}} allowed 4. UN SEUL PROPRIETAIRE DU PERIMETRE with bin/fm-worker-command-policy.mjs present: pi -> [remote-transfer] claude -> [remote-transfer] after removing that single owner: pi -> [worker-guard-unavailable] claude -> [worker-guard-unavailable] 5. AUCUN PASSAGE SILENCIEUX SANS GARDE Pi worker whose guard transport was removed, ordinary tool call: block [worker-guard-unavailable] the firstmate worker command guard is missing at .../bin/fm-worker-pretool-check.sh, so this worker has no command perimeter. fm-spawn.sh launching a Pi worker whose guard runtime is missing: exit 1: error: the worker command guard policy owner is missing at .../bin/fm-worker-command-policy.mjs; a pi worker must not launch without it per-task extension written: no fm-spawn.sh launching a worker on a harness with no application point: WARNING: no worker command guard is wired for the 'opencode' harness. WARNING: this ship worker starts with NO command perimeter - sudo, ssh, scp, rsync, chmod, git config --global, git rebase, a push to master/main, and reading a .env are all unrefused for it.Evidence: Pilote de preuve E2E (rejouable)
Source: Pilote de preuve E2E (rejouable)
Evidence: Matrice d'acceptation pilotée par le script de preuve
Source: Matrice d'acceptation pilotée par le script de preuve
Evidence: Journal du backend terminal - la commande de lancement pi porte bien -e <extension>
Source: Journal du backend terminal - la commande de lancement pi porte bien -e <extension>
Evidence: Sonde de lancement ayant produit ce journal
Source: Sonde de lancement ayant produit ce journal
Evidence: Suite colocalisée - resultat
Pipeline
Updates from git push no-mistakes
... (10 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)
🔧 Fix: state guard boundary list as non-exhaustive; name closed reader sets
2 issues (1 warning, 1 info) still open:
bin/fm-worker-command-policy.mjs:356- Les deux chemins de charge utile inline divergent, et la doc décrit les deux comme un seul comportement.evalPayloadrenvoie "" dès qu'un mot n'est pas littéral (ligne 360), ce qui bascule sur le replimentionsPerimeteret refuse ;shellPayloadprend la valeur brute du mot et la classe telle quelle, quel que soit son caractère littéral. Vérifié en exécutant le propriétaire, dans les DEUX sens. Sur-refus côté eval, sur du travail ordinaire :eval "git -C $DIR log -1"-> deny unclassifiable-perimeter-command alors quebash -c "git -C $DIR log -1"-> allow ;eval "cd $DIR && git status"-> deny alors que la même charge viabash -c-> allow ;eval "git commit -m \"$MSG\""-> deny ;eval "git push origin $BRANCH"-> deny, alors queeval "git push origin HEAD"-> allow. Sous-refus côté sh -c :sh -c "$PREFIX chmod +x a"-> allow etbash -c "$RUN cat .env"-> allow, alors queeval "$PREFIX chmod +x a"eteval "$RUN cat .env"dénient tous deux ; si la variable est vide, la commande du périmètre s'exécute bel et bien. docs/worker-command-guard.md:79 affirme pourtant : « An inline shell or eval payload is classified whenever it is literal, in any option spelling; when it is assembled at runtime instead, a node that still mentions a perimeter command is refused rather than allowed. » C'est vrai deevalet faux desh -c, et la moitié vraie refuse du travail légitime. La matrice n'exerce que des charges utiles entièrement littérales (A31, A54-A56, D85-D91), ce qui explique que l'écart passe. Le point à trancher est un comportement produit et appartient à l'auteur : soit alignerevalsurshellPayload(classer le texte concaténé même non littéral, ce qui supprime les quatre faux refus ci-dessus et rend la deuxième clause de la ligne 79 inexacte, à réécrire), soit alignershellPayloadsureval(mais alorsbash -c "cd $DIR && git status"se met à refuser, ce qui contredit le principe de docs/worker-command-guard.md:95). Dans les deux cas, la ligne 79 doit finir par décrire ce que le code fait, et les formes vérifiées ci-dessus méritent d'entrer dans la matrice.bin/fm-worker-command-policy.mjs:496- Le rescan de suffixes ajouté pour la valeur séparée d'une option de mot réservé classe TOUS les opérandes restants comme des commandes, pas seulement celui qui peut se retrouver en position de commande, d'où un refus de travail ordinaire. Vérifié en exécutant le propriétaire :time -p grep -rn ssh src/-> deny remote-transfer alors quegrep -rn ssh src/-> allow ; de mêmetime -p rg chmod .-> deny permission-change,time -p find . -name ssh-> deny,for f in a; do time -p grep -l ssh $f; done-> deny. Aucune de ces commandes n'exécute la commande du périmètre : le mot est un motif ou un argument. Ce n'est pas le cas d'ambiguïté que la règle « refuser plutôt qu'autoriser » vise, puisque la commande EST résolue (grep,find) ; le rescan s'exécute quand même. Le défaut qu'il corrige laisse toujours la vraie commande dans l'opérande IMMÉDIATEMENT suivant le mot résolu (time -a -o log cat .envrésoutlogpuiscat), donc restreindre le rescan à ce seul opérande, en s'arrêtant s'il commence par un tiret, suffit : j'ai vérifié que les six cas D71-D76 continuent tous de dénier avec cette règle plus étroite, tandis quetime -p grep -rn ssh src/redevient autorisé. Portée limitée aux formestime <option> <commande> <argument-du-périmètre>, et le worker reçoit une raison explicite, d'où la sévérité basse.🔧 Fix: classify every inline payload through one shared path
2 issues (1 warning, 1 info) still open:
bin/fm-worker-command-policy.mjs:501- Régression introduite par e6d5725 : le rétrécissement du rescan à « l'opérande immédiatement après le mot de commande résolu » rouvre la forme que le round 8 avait fermée. Quand une option de mot réservé prend sa valeur dans un token séparé ET qu'une autre option suit cette valeur, le mot résolu (la valeur) est suivi d'une OPTION, doncnext && !isOption(next.value)est faux et plus rien n'est classé - il n'existe aucun repli. Vérifié en exécutant le propriétaire, et comparé au commit précédent f622d64 :time -o log -a cat .env-> allow (f622d64 : deny dotenv-access),time -o log -p chmod +x a-> allow (deny),time -f %e -p chmod +x a-> allow (deny),/usr/bin/time -o log -a chmod +x a-> allow,for f in a; do time -o log -a chmod +x $f; done-> allow,if true; then time -o log -a ssh host; fi-> allow ; alors que les ordres inverses D71-D76 (time -a -o log cat .env) dénient toujours. Deux conséquences. (1) Le critère d'acceptation 1 (chmod, lecture d'un .env, ssh) est contourné par une commande dont tous les mots sont littéraux, donc hors des trois frontières documentées. (2) docs/worker-command-guard.md:56 revendique toujours « a command behindtime, with or without the keyword's own options », ce qui est maintenant faux - exactement l'écart doc/code que le demandeur a exigé deux fois de fermer d'un côté ou de l'autre. Le point à trancher appartient à l'auteur, parce que les deux exigences se contredisent : le repli structurel demandé au round 7 (« refuser quand un mot réservé et des options ont été retirés et que le nœud mentionne un marqueur ») refait dénier A64time -p grep -rn ssh src/, que le round 9 vient explicitement d'autoriser, et rien dans la forme ne distinguelog(valeur d'option) degrep(vraie commande) sans une table d'options par outil, que le round 6 a interdite. Deux résolutions honnêtes : soit accepter le faux refus sur la famille motif et rétablir le repli, soit accepter ce résidu, corriger la ligne 56 pour ne plus revendiquer les options du mot-clé, et le nommer dans la section des frontières délibérées sous la règle de non-exhaustivité déjà écrite. La plausibilité pratique est faible (il fauttime -o <fichier> <autre option>devant la commande du périmètre), mais l'écart entre la revendication et le comportement, lui, est réel.bin/fm-worker-command-policy.mjs:500- La nouvelle clause!position.command.literalétend le rescan à TOUT nœud dont le mot de commande est une expansion, ce qui ferme bien le sous-refus visé ($PREFIX chmod +x a-> deny) mais crée l'image miroir du faux refus corrigé ce même round. Vérifié en exécutant le propriétaire :$GREP ssh src/-> deny remote-transfer et${RG:-rg} chmod .-> deny permission-change, alors quegrep ssh src/,rg chmod .ettime -p rg chmod .(cas A65 ajouté ce round) sont tous autorisés. L'exclusion PATTERN_READING_COMMANDS ne peut pas s'appliquer ici puisque le nom de la commande est justement inconnu, donc l'asymétrie est inhérente à la règle. Aucune correction recommandée : docs/worker-command-guard.md:81 énonce déjà la règle et sa justification (« that is what runs if the expansion is empty »), la direction du sur-refus est celle que la règle d'ambiguïté impose, et les formes touchées sont rares - un worker écrit son outil de recherche en littéral. Signalé parce que le demandeur a explicitement pesé le coût quotidien d'un faux refus ce round, et que cette famille-là subsiste au même endroit du code. Un balayage de 28 commandes de travail ordinaire n'a produit aucun autre faux refus.🔧 Fix: state real timing-keyword coverage; name its residue
1 warning still open:
bin/fm-worker-command-policy.mjs:346- La branchexargsde carriedPrograms pousse CHAQUE suffixe non-option comme commande candidate, donc l'ARGUMENT d'une commande portée déjà résolue est reclassé comme une commande et refuse du travail ordinaire. Vérifié en exécutant le propriétaire :git ls-files | xargs grep -l ssh-> deny remote-transfer,find . -name "*.md" | xargs grep -n chmod-> deny permission-change,xargs -a list.txt grep -l sudo-> deny privilege-escalation, alors que les formes équivalentesgrep -rn ssh src/,rg chmod .et surtoutfind . -type f -name "*.sh" -exec grep -l ssh {} +(même charge portée, mais find délimite sa charge exactement) sont toutes autorisées. Chercher les références à ssh ou chmod dans un dépôt viaxargs grepest du travail ordinaire, et la raison renvoyée (« ssh, scp, and rsync are forbidden ») décrit une action que le worker n'a pas demandée. C'est exactement la forme que le round 9 a explicitement corrigée pour le rescan du mot-clé de timing (A64time -p grep -rn ssh src/est autorisé grâce à l'exclusion PATTERN_READING_COMMANDS) ; la même exclusion n'a jamais été appliquée ici. Le commentaire des lignes 330-336 justifie le balayage large par l'ambiguïté de la frontière des options de xargs (« An option value that happens to name a perimeter command is then refused »), ce qui ne couvre pas ce cas : la commande portée EST résolue (grep), et le mot du périmètre est son motif. Cela contredit aussi le principe énoncé en docs/worker-command-guard.md:106 (« the guard never blocks work it has no opinion about »). La matrice n'a aucun cas allow dexargsportant une commande à motif (A30 estxargs wc -l), ce qui explique que le trou passe. Deux résolutions possibles et le choix appartient à l'auteur, car le refus excessif est ici la direction assumée par le commentaire : soit sauter, pour un candidat qui se résout en membre de PATTERN_READING_COMMANDS, l'opérande que readTargets écarte déjà comme motif - j'ai vérifié que D53, D54 et D55 continuent tous de dénier sous cette règle plus étroite tandis que les trois formes ci-dessus redeviennent autorisées - soit nommer ce refus excessif dans la section des frontières, qui ne décrit aujourd'hui que des manques, jamais un excès.🔧 Fix: stop classifying an xargs-carried search pattern as a command
2 warnings still open:
bin/fm-worker-command-policy.mjs:233-isRebasingPullne reconnaît que-risolé,--rebaseet--rebase=<valeur>, alors que git parse-options accepte le groupage court :git pull -qrrejoue bel et bien la branche. Vérifié DEUX fois. (a) En exécutant le propriétaire :git pull -qr origin main-> allow,git pull -rq origin main-> allow,git pull -fr origin main-> allow,git pull -kr-> allow, alors quegit pull -q -r origin main-> deny history-rewrite. (b) Avec git lui-même, dans un dépôt jetable (up/down, un commit divergent de chaque côté) :git pull -qr origin masterproduitlocal1 upstream1 baseavec 0 commit de fusion et un sha réécrit pour local1 - c'est un rebase, pas une fusion.git pull -hconfirme-r, --[no-]rebase[=(false|true|merges|interactive)].Le critère d'acceptation 1 exige le refus de
git rebase, et l'action refusée se produit réellement. Aucune des frontières documentées ne couvre ce cas : ni lanceur non modélisé, ni chemin produit à l'exécution, ni corps de script --qrest un opérande littéral de la commande soumise. docs/worker-command-guard.md:35 revendique par ailleurs «git rebase, andgit pull --rebasewhich replays the same way », sans réserve sur l'orthographe, donc l'écart doc/code que le demandeur a exigé de fermer d'un côté ou de l'autre à chaque round est rouvert ici.C'est exactement la classe que ce changement a déjà fermée deux fois : le cluster court
cp -rt/mv -ftau round 2 (TARGET_DIRECTORY_SHORT) et le cluster courtsh -cx/sh -xcau round 8 (shellPayload). Seule la branchepullde classifyGit n'a jamais reçu le même traitement, et les cas D81-D84 n'exercent que-risolé et les formes longues, ce qui explique que le trou passe.Le demandeur a gelé le classifieur au round 11 (« The classifier is otherwise closed »), donc le choix lui appartient : soit reconnaître un cluster court contenant
rdansisRebasingPull(attention à ne pas capter la valeur accolée de-s/-X/-S, qui prennent un argument), soit nommer ce résidu dans la section des frontières sous la règle de non-exhaustivité déjà écrite et corriger la ligne 35 pour ne plus revendiquer la couverture sans réserve. Ne pas le fermer est défendable ; laisser la doc le revendiquer ne l'est pas.bin/fm-worker-command-policy.mjs:358- Le correctif du round 11 ne couvre que le motif POSITIONNEL, donc le même faux refus subsiste dès que le motif est fourni par-e/--regexpdans un token séparé.readOperandsne renvoiepatternIndexque lorsquepatternIsPositionalest vrai ; quand une option porte le motif, elle renvoiepatternIndex: -1et la bouclexargsreclasse le token du motif comme commande candidate.Vérifié en exécutant le propriétaire :
git ls-files | xargs grep -e ssh-> deny remote-transfer,git ls-files | xargs egrep -e chmod-> deny permission-change, alors que la forme directegrep -e ssh src/-> allow, la forme accoléexargs grep --regexp=ssh-> allow, et la forme que le round 11 vient de corrigerxargs grep -l ssh-> allow. Le verdict dépend donc de l'orthographe de l'option, pour une recherche qui n'ouvre aucun fichier et n'exécute aucune commande du périmètre - la raison renvoyée (« ssh, scp, and rsync are forbidden ») décrit une action que le worker n'a pas demandée.C'est le même faux refus de travail ordinaire que le round 11 a été chargé de corriger, et l'instruction donnée était générale : « skip the operand that the pattern-target logic already excludes as the pattern ».
readOperandsEXCLUT déjà cet opérande (il le consomme viaargs[index += 1]et ne le pousse pas danspaths), elle ne l'expose simplement pas. Correctif : faire renvoyer parreadOperandsl'index du token porteur du motif dans les DEUX cas - positionnel et porté par option - et laisser la branchexargsle sauter comme elle le fait aujourd'hui. Aucun cas déniant n'est touché :xargs grep -f .env .reste refusé (-fnomme un fichier, pas un motif), D53-D55 aussi. Ajouter les deux formes vérifiées ci-dessus à la matrice, à côté de A69-A71.Remarque secondaire, même code, direction inverse et plausibilité quasi nulle : une VALEUR d'option de xargs qui nomme une commande de la famille motif fait sauter le token suivant, donc
xargs -d grep chmod +x a-> allow. Aucune valeur d'option réaliste (-a,-d,-I,-n,-E) ne s'appelle grep, sed ou awk ; signalé pour que le correctif ne l'aggrave pas.🔧 Fix: skip option-carried search patterns; document pull cluster residue
1 info still open:
docs/worker-command-guard.md:86- Un opérande glob dont l'expansion contient un fichier de secrets passe, dans les deux directions du périmètre. Vérifié en exécutant le propriétaire :cat .env*-> allow,source .env*-> allow,grep KEY .env*-> allow,cp .env* /tmp/x-> allow etmv .env? /tmp/-> allow, alors que les formes littéralescat .env,source .envetcp .env /tmp/xdénient toutes. La cause est saine et assumée :isDotenvPathcompare le basename littéral, et.env*n'est pas.env.Ce n'est PAS un écart doc/code : la classe est déjà énoncée en docs/worker-command-guard.md:87 (« The policy reads literal operands, so a path or a program the command only receives while running is invisible to it »), et un glob est bien un chemin que la commande ne reçoit qu'à l'exécution. La section porte de plus la règle de non-exhaustivité posée au round 9.
Signalé seulement parce que les exemples vérifiés de cette classe ne nomment que la forme
find -name .env -execet la variable (sh -c "$CMD"), alors quecat .env*est vraisemblablement l'orthographe la plus spontanée chez un worker qui divague (« montre-moi les fichiers env »), plus courte et plus naturelle que les deux exemples cités. Une ligne d'exemple supplémentaire dans cette même section suffirait ; refuser en code n'est pas recommandé, puisque le classifieur est gelé et qu'un refus sur préfixe rouvrirait le faux refus de.env.exampleque le matching exact existe précisément pour éviter.✅ **Test** - passed
✅ No issues found.
bin/fm-test-run.sh tests/fm-worker-command-guard.test.sh- suite colocalisée complète : 169 cas de matrice x 5 formes d'entrée harness, périmètre file-path, chemins fail-closed, refus au spawn, deux points d'application vivants (16 checks, 0 échec)bin/fm-test-run.sh tests/fm-arm-pretool-check.test.sh tests/fm-busy-adapter-wiring.test.sh tests/fm-calm-pi-extension.test.sh tests/fm-pi-watch-extension.test.sh tests/fm-spawn-dispatch-profile.test.sh- régression ciblée sur les consommateurs debin/fm-arm-command-policy.mjs(mot-clés réservés extraits) et des artefacts générés parfm-spawn.sh(total=5, failed=0)bin/fm-test-run.sh tests/fm-cd-pretool-check.test.sh tests/fm-subagent-pretool-check.test.sh- gardes soeurs partageant le même classifieur shell (total=2, failed=0)Vérification manuelle E2E :FM_EVIDENCE_REPO=$PWD FM_EVIDENCE_BASE_ROOT=/tmp/fm-base-bin bash worker-guard-evidence.sh- spawn réel d'un worker Pi viabin/fm-spawn.sh, chargement de l'extensionstate/<id>.pi-ext.tsgénérée dans un hôte Node nu, et déclenchement du handlertool_callque Pi appelle, un appel d'outil par critère d'acceptationReproduction avant/après : extraction du commit de base viagit archive e518906 bin AGENTS.md, spawn Pi avec ceFM_ROOT, puis même pilotage - l'extension de base n'enregistre aucun handlertool_call(lecture.envetsudonon gardés)Vérification manuelle que l'extension est attachée au lancement réel : journalisation dessend-keysdu backend terminal, la ligne de lancement portepi' -e '<state>/<id>.pi-ext.ts'Vérification manuelle du point d'application Claude : extraction de la commandePreToolUseréellement enregistrée dans.claude/settings.local.jsongénéré (matcher*), puis alimentation avec de vrais payloadsBash/ReadVérification manuelle de l'unicité du périmètre : suppression debin/fm-worker-command-policy.mjsdans une copie deFM_ROOT, les deux points d'application basculent ensemble sur[worker-guard-unavailable]Vérification manuelle des verdicts par nom d'outil Pi :bin/fm-worker-pretool-check.sh --tool <read|grep|find|ls|edit|write|bash|Read|Write|Edit> --path /srv/app/.envVérification du contrat réel de Pi dans@earendil-works/pi-coding-agentinstallé :ToolCallEventResult.blockdocumenté « Block tool execution », et schémas d'entrée des outils (pathpour read/grep/find/ls/edit/write,commandpour bash)docs/scripts.md:44- docs/scripts.md, the bin/ toolbelt inventory, gains no row for bin/fm-worker-pretool-check.sh or bin/fm-worker-command-policy.mjs. Left alone deliberately: that table is already non-exhaustive and omits the closest sibling pair (fm-cd-pretool-check.sh, fm-cd-command-policy.mjs) plus about thirty other scripts, so adding only this change's two rows would be inconsistent, and completing the table is a separate consolidation outside this change's scope.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.