Skip to content

feat(bin): give unattended pi and claude workers one command perimeter - #2509

Open
Kallas95 wants to merge 14 commits into
kunchenguid:mainfrom
Kallas95:fm/fm-garde-pretool-ouvriers-pi
Open

feat(bin): give unattended pi and claude workers one command perimeter#2509
Kallas95 wants to merge 14 commits into
kunchenguid:mainfrom
Kallas95:fm/fm-garde-pretool-ouvriers-pi

Conversation

@Kallas95

Copy link
Copy Markdown

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_call on 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:

  1. A Pi worker is refused: sudo, ssh, scp, rsync, chmod, git config --global, git rebase, git push to master/main, and reading or copying a file whose basename is exactly .env.
  2. The perimeter is defined in one place only; the Pi guard and the Claude firewall cannot diverge silently. The PR must explain how that uniqueness is guaranteed.
  3. A Pi worker launched without an active guard is refused or loudly flagged, never silently permissive.
  4. Colocated tests covering at minimum: one refusal, one legitimate pass, and the case where the guard is absent.

Scope note stated explicitly by the requester: git push origin HEAD remains 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:

  • The single owner is the new bin/fm-worker-command-policy.mjs. It deliberately imports Lexer/splitProgram/commandPosition from bin/fm-arm-command-policy.mjs, which the repo already designates as the sole owner of firstmate's shell classification (the sibling cd-guard does the same), rather than duplicating shell lexing or matching raw regex prefixes the way the Claude firewall does. That is why pipeline stages, subshells, wrappers, assignments prefixes, and inline sh -c payloads are all caught.
  • bin/fm-worker-pretool-check.sh is the single transport to that owner, modeled on the existing bin/fm-cd-pretool-check.sh, and already speaks the Claude, Codex, Grok, Cursor, Pi, and OpenCode payload and response shapes so a future worker harness needs a wiring line rather than a second copy of the rules.
  • The uniqueness guarantee is structural, not documentary: bin/fm-spawn.sh writes BOTH application points per task and neither restates a rule - the Pi per-task extension gains a 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.
  • The captain's personal hook at ~/.claude/hooks/pre-tool-use-firewall.mjs lives outside this repository, so it could not be edited from an isolated task worktree; it remains a personal layer. The PR is expected to say so and to note it can be reduced to a delegator pointing at bin/fm-worker-pretool-check.sh --claude.
  • This guard FAILS CLOSED, deliberately unlike bin/fm-arm-pretool-check.sh and bin/fm-cd-pretool-check.sh, which fail open. Those guard a supervised primary against agent mistakes, where a false block costs more than a missed one; this one is a perimeter around an unattended worker. So a missing node, missing jq, absent policy owner, unreadable payload, or invalid policy response all deny; fm-spawn refuses to launch a Pi or Claude worker whose guard runtime is missing; and a Pi extension whose transport disappeared after launch blocks every tool call with a loud reason.
  • Unparseable shell syntax denies ONLY when the raw text mentions a perimeter command (reason code unclassifiable-perimeter-command); unparseable syntax mentioning none of them still allows, so the guard never blocks work it has no opinion about.
  • .env is matched on the exact basename, per the acceptance criterion, and deliberately NOT on the Claude firewall's broader /.env(.|$)/ pattern, because firstmate itself tracks ordinary files such as config/x-mode.env that workers legitimately read.
  • Deliberate boundary: the policy classifies the command a worker submits and does NOT open and re-classify the bodies of scripts that command would run, unlike the Claude firewall which does. Re-classifying script bodies blocks this repo's own test suite, which legitimately changes fixture modes, and every project's build scripts. This boundary is documented rather than silently taken.
  • The guard is wired for crewmates and scouts only, not for --secondmate spawns: a secondmate is a firstmate instance with its own supervised posture rather than an unattended worker. Firstmate's own primary session is likewise untouched.
  • docs/worker-command-guard.md is the maintainer-architecture contract, registered in docs/documentation-audiences.json and cross-referenced from docs/arm-pretool-check.md and docs/turnend-guard.md.
  • tests/fm-worker-command-guard.test.sh drives real behavior: a 38-case deny/allow matrix across five harness entry forms, the file-path perimeter in both payload shapes, every fail-closed path, and both live application points - a real bin/fm-spawn.sh run whose generated Pi extension is driven in a plain Node host, and the recorded Claude hook command fed a real payload. It asserts no implementation-source bytes.

What Changed

  • Added bin/fm-worker-command-policy.mjs as the single owner of the unattended-worker perimeter (sudo; ssh/scp/rsync; chmod; git config --global; git rebase and rebasing git pull; git push resolving to master/main plus --all/--mirror; reading or copying a file whose basename is exactly .env), reached only through the new bin/fm-worker-pretool-check.sh transport. The policy imports the tokenizer and command-position analysis from bin/fm-arm-command-policy.mjs (which now exports SHELL_RESERVED_WORDS) instead of duplicating shell lexing, so pipeline stages, subshells, substitutions, compound bodies, carried payloads (find -exec, xargs), inline sh -c/eval payloads and sourcing builtins are all classified. Pushing the task branch, including git push origin HEAD, stays allowed.
  • Wired two application points per task in bin/fm-spawn.sh for crewmate and scout spawns: the Pi per-task extension gains a pi.on("tool_call") handler returning {block: true, reason}, and the Claude per-task .claude/settings.local.json gains a PreToolUse hook 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: missing node/jq, an absent policy owner, an unreadable payload or an invalid policy response all deny; fm-spawn.sh refuses to launch a claude/pi/pi-signed worker 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. --secondmate spawns are deliberately not wired.
  • Documented the contract in docs/worker-command-guard.md (perimeter, threat model, what the guard holds, named non-exhaustive boundaries), registered it in docs/documentation-audiences.json as maintainer-architecture, and cross-referenced it from docs/arm-pretool-check.md and docs/turnend-guard.md. tests/fm-worker-command-guard.test.sh drives 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 real fm-spawn.sh run, and the guard-absent case.

Note: the captain's personal ~/.claude/hooks/pre-tool-use-firewall.mjs lives outside this repository and is untouched; it remains a personal layer and can be reduced to a delegator calling bin/fm-worker-pretool-check.sh --claude.

Risk Assessment

⚠️ Medium: Changement de sécurité large qui intercepte chaque appel d'outil de worker et échoue fermé, mais vérifié en profondeur - matrice de 169 cas x 5 formes d'entrée, deux points d'application pilotés en vrai, et chacun des 35 exemples concrets de la doc confirmé exact contre le propriétaire - avec des résidus connus explicitement documentés et acceptés par le demandeur, donc sûr à fusionner avec ces résidus en suivi.

Testing

J'ai exercé la suite colocalisée tests/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 par fm-spawn.sh - tout passe sans échec. Pour la preuve produit, j'ai spawné un worker Pi réel avec bin/fm-spawn.sh, vérifié que la ligne de lancement pi porte bien -e &lt;state&gt;/&lt;id&gt;.pi-ext.ts, puis piloté l'extension générée par le handler tool_call que Pi appelle : les dix formes du critère 1 sont refusées avec leur code de raison, y compris la lecture d'un .env par l'outil read natif de Pi, tandis que git push origin HEAD et le travail ordinaire passent ; la même chose est rejouée sur le point Claude par sa commande PreToolUse réellement enregistrée. Le transcript reproduit d'abord la faille sur le commit de base (l'extension d'alors n'enregistre aucun handler tool_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 selon docs/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.


--------------------------------------------------------------------------------
0. The reported hole, reproduced against the BASE commit's fm-spawn.sh
--------------------------------------------------------------------------------
  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. AFTER: a real Pi worker spawned by this branch's bin/fm-spawn.sh
--------------------------------------------------------------------------------
  spawn exit: 0
  per-task Pi extension written: guard-evidence-pi.pi-ext.ts
  the pi launch this spawn actually issued carries it:
    pi' -e '/var/folders/5q/snmnm0_926ddy6xlmggzx1g80000gn/T//fm-worker-guard-evidence.kgNjpO/pi-live/home/state/guard-evidence-pi.pi-ext.ts'

--------------------------------------------------------------------------------
2. Acceptance criterion 1, through that Pi worker's own tools
--------------------------------------------------------------------------------
  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. The SAME perimeter through the Claude worker's own PreToolUse hook
--------------------------------------------------------------------------------
  registered matcher: *
  registered command: test -x '/Users/max/.no-mistakes/worktrees/0e7dd249bdb3/01M0661VCBQHY15G5DFADMST6B/bin/fm-worker-pretool-check.sh' || { printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"},"systemMessage":"[worker-guard-unavailable] the firstmate worker command guard is missing at /Users/max/.no-mistakes/worktrees/0e7dd249bdb3/01M0661VCBQHY15G5DFADMST6B/bin/fm-worker-pretool-check.sh, so this worker has no command perimeter. Report this to firstmate; do not work around it."}' >&2; exit 2; }; exec '/Users/max/.no-mistakes/worktrees/0e7dd249bdb3/01M0661VCBQHY15G5DFADMST6B/bin/fm-worker-pretool-check.sh' --claude

  {"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. One perimeter owner: neutralize it and BOTH points change together
--------------------------------------------------------------------------------
  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. A worker without an active guard is refused, never silently permissive
--------------------------------------------------------------------------------
  Pi worker whose guard transport was removed, ordinary tool call:
    block [worker-guard-unavailable] the firstmate worker command guard is missing at /var/folders/5q/snmnm0_926ddy6xlmggzx1g80000gn/T//fm-worker-guard-evidence.kgNjpO/guard-gone/fmroot/bin/fm-worker-pretool-check.sh, so this worker has no command perimeter. Report this to firstmate; do not work around it.

  fm-spawn.sh launching a Pi worker whose guard runtime is missing:
    exit 1: error: the worker command guard policy owner is missing at /var/folders/5q/snmnm0_926ddy6xlmggzx1g80000gn/T//fm-worker-guard-evidence.kgNjpO/preflight/fmroot/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:
    exit 0
    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. See docs/worker-command-guard.md.

--------------------------------------------------------------------------------
RESULT: 0 mismatch(es) against perimeter-cases.tsv
--------------------------------------------------------------------------------
Evidence: Pilote de preuve E2E (rejouable)

Source: Pilote de preuve E2E (rejouable)

#!/usr/bin/env bash
# End-to-end evidence driver for the worker command guard.
#
# Spawns a REAL Pi worker with bin/fm-spawn.sh, then drives the per-task Pi
# extension that spawn generated through the same tool_call handler Pi itself
# calls, one acceptance-criterion tool call at a time, with Pi's own tool names
# (bash, read, grep, write, edit) and Pi's own input fields (command, path).
# Then the same for the Claude application point through its recorded PreToolUse
# hook command, the one-owner proof, and the guard-absent outcomes.
#
# Section 0 reproduces the reported hole against the BASE commit's spawn.
# Perimeter cases live in perimeter-cases.tsv beside this file.
set -u

EVID_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
REPO=${FM_EVIDENCE_REPO:?set FM_EVIDENCE_REPO to the worktree root}
BASE_ROOT=${FM_EVIDENCE_BASE_ROOT:-}
CASES="$EVID_DIR/perimeter-cases.tsv"

# shellcheck source=/dev/null
. "$REPO/tests/lib.sh"

fm_git_identity fmtest fmtest@example.invalid
TMP_ROOT=$(fm_test_tmproot fm-worker-guard-evidence)

hr() { printf '%s\n' "--------------------------------------------------------------------------------"; }
title() { printf '\n'; hr; printf '%s\n' "$1"; hr; }

make_fakebin() {  # <dir>
  local dir=$1 fakebin staged
  fakebin=$(fm_fakebin "$dir")
  staged="$dir/tmux.staged"
  cat > "$staged" <<'SH'
#!/usr/bin/env bash
set -u
[ -z "${FM_TMUX_LOG:-}" ] || printf '%s\n' "$*" >> "$FM_TMUX_LOG"
case "$*" in
  *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;;
esac
case "${1:-}" in
  display-message) printf 'firstmate\n'; exit 0 ;;
  list-windows) exit 0 ;;
esac
exit 0
SH
  install -m 755 "$staged" "$fakebin/tmux"
  fm_fake_exit0 "$fakebin" treehouse pi claude opencode
  printf '%s\n' "$fakebin"
}

# make_case <name> <harness> <id> <fmroot: real|copy|<path>>
make_case() {
  local name=$1 harness=$2 id=$3 mode=$4 case_dir home proj wt fakebin fmroot
  case_dir="$TMP_ROOT/$name"
  home="$case_dir/home"; proj="$case_dir/project"; wt="$case_dir/wt"
  fakebin=$(make_fakebin "$case_dir/fake")
  case "$mode" in
    real) fmroot="$REPO" ;;
    copy)
      fmroot="$case_dir/fmroot"
      mkdir -p "$fmroot"
      cp -R "$REPO/bin" "$fmroot/bin"
      cp "$REPO/AGENTS.md" "$fmroot/AGENTS.md"
      ;;
    *) fmroot="$mode" ;;
  esac
  mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config"
  printf '%s\n' "$harness" > "$home/config/crew-harness"
  fm_git_worktree "$proj" "$wt" "wt-$name"
  touch "$home/state/.last-watcher-beat"
  mkdir -p "$home/data/$id"
  printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md"
  printf '%s|%s|%s|%s|%s\n' "$home" "$proj" "$wt" "$fakebin" "$fmroot"
}

run_spawn() {  # <home> <wt> <fakebin> <fmroot> <spawn-args...>
  local home=$1 wt=$2 fakebin=$3 fmroot=$4
  shift 4
  set -- "$@" --mode no-mistakes --yolo off
  FM_ROOT_OVERRIDE="$fmroot" FM_HOME="$home" \
    FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \
    FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \
    FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \
    PATH="$fakebin:$PATH" \
    "$fmroot/bin/fm-spawn.sh" "$@" 2>&1
}

# drive_pi <ext> <toolName> <tool-json>: load the generated extension in a plain
# Node host and fire one tool_call through the very handler Pi calls.
# Prints allow | block<TAB>reason | unguarded (no tool_call handler at all).
drive_pi() {
  EXT_PATH="$1" TOOL_NAME="$2" TOOL_INPUT="$3" node --input-type=module 2>&1 <<'EOF'
import { pathToFileURL } from "node:url";
const mod = await import(pathToFileURL(process.env.EXT_PATH).href);
const handlers = {};
mod.default({ on: (name, fn) => { handlers[name] = fn; } });
if (typeof handlers["tool_call"] !== "function") {
  process.stdout.write("unguarded (this worker registers no tool_call handler)");
} else {
  const r = await handlers["tool_call"]({
    type: "tool_call", toolName: process.env.TOOL_NAME,
    input: JSON.parse(process.env.TOOL_INPUT),
  });
  if (r && r.block) process.stdout.write("block\t" + String(r.reason || ""));
  else process.stdout.write("allow");
}
EOF
}

reason_code() { printf '%s' "$1" | grep -o '\[[a-z-]*\]' | head -1; }

# --- 0. the reported hole, against the BASE commit --------------------------

if [ -n "$BASE_ROOT" ]; then
  title "0. The reported hole, reproduced against the BASE commit's fm-spawn.sh"
  IFS='|' read -r Z_HOME Z_PROJ Z_WT Z_FAKEBIN Z_FMROOT <<EOF
$(make_case base-commit pi guard-evidence-base "$BASE_ROOT")
EOF
  run_spawn "$Z_HOME" "$Z_WT" "$Z_FAKEBIN" "$Z_FMROOT" guard-evidence-base "$Z_PROJ" >/dev/null
  Z_EXT="$Z_HOME/state/guard-evidence-base.pi-ext.ts"
  printf '  BEFORE (base commit) Pi worker, read tool on /srv/app/.env:\n'
  printf '    %s\n' "$(drive_pi "$Z_EXT" read '{"path":"/srv/app/.env"}')"
  printf '  BEFORE (base commit) Pi worker, bash tool running sudo:\n'
  printf '    %s\n' "$(drive_pi "$Z_EXT" bash '{"command":"sudo rm -rf /"}')"
fi

# --- 1. live Pi worker ------------------------------------------------------

IFS='|' read -r HOME_DIR PROJ_DIR WT_DIR FAKEBIN FMROOT <<EOF
$(make_case pi-live pi guard-evidence-pi real)
EOF

title "1. AFTER: a real Pi worker spawned by this branch's bin/fm-spawn.sh"
TMUX_LOG="$TMP_ROOT/pi-live-tmux.log"
FM_TMUX_LOG="$TMUX_LOG" run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN" "$FMROOT" guard-evidence-pi "$PROJ_DIR" >/dev/null
printf '  spawn exit: %s\n' "$?"
EXT="$HOME_DIR/state/guard-evidence-pi.pi-ext.ts"
printf '  per-task Pi extension written: %s\n' "$(basename "$EXT")"
printf '  the pi launch this spawn actually issued carries it:\n'
printf '    %s\n' "$(grep -o -- "pi' -e '[^']*pi-ext.ts'" "$TMUX_LOG" | head -1)"

title "2. Acceptance criterion 1, through that Pi worker's own tools"
printf '  %-8s %-6s %-8s %-42s %s\n' EXPECTED TOOL FIELD 'TOOL CALL' 'VERDICT'
FAILURES=0
while IFS=$'\t' read -r expected tool field value; do
  [ -n "${expected:-}" ] || continue
  json=$(jq -cn --arg k "$field" --arg v "$value" '{($k):$v}')
  out=$(drive_pi "$EXT" "$tool" "$json")
  case "$out" in
    block*) verdict="REFUSED $(reason_code "$out")"; actual=REFUSE ;;
    allow)  verdict="allowed"; actual=ALLOW ;;
    *)      verdict="UNEXPECTED: $out"; actual=? ;;
  esac
  printf '  %-8s %-6s %-8s %-42s %s\n' "$expected" "$tool" "$field" "$value" "$verdict"
  [ "$actual" = "$expected" ] || { FAILURES=$((FAILURES + 1)); printf '  !! MISMATCH on: %s\n' "$value"; }
done < "$CASES"
printf '\n  mismatches: %s\n' "$FAILURES"

# --- 3. the Claude application point ----------------------------------------

IFS='|' read -r C_HOME C_PROJ C_WT C_FAKEBIN C_FMROOT <<EOF
$(make_case claude-live claude guard-evidence-claude real)
EOF
run_spawn "$C_HOME" "$C_WT" "$C_FAKEBIN" "$C_FMROOT" guard-evidence-claude "$C_PROJ" >/dev/null
SETTINGS="$C_WT/.claude/settings.local.json"
HOOK=$(jq -r '(.hooks.PreToolUse // [])[0].hooks[0].command // empty' "$SETTINGS")

title "3. The SAME perimeter through the Claude worker's own PreToolUse hook"
printf '  registered matcher: %s\n' "$(jq -r '(.hooks.PreToolUse // [])[0].matcher' "$SETTINGS")"
printf '  registered command: %s\n\n' "$HOOK"
claude_call() {  # <payload>
  local out rc
  out=$(printf '%s' "$1" | bash -c "$HOOK" 2>&1); rc=$?
  if [ "$rc" -eq 0 ]; then printf 'allowed\n'
  else printf 'REFUSED (exit %s) %s\n' "$rc" "$(reason_code "$out")"; fi
}
printf '  %-62s %s\n' '{"tool_name":"Bash",...{"command":"sudo id"}}' "$(claude_call '{"tool_name":"Bash","tool_input":{"command":"sudo id"}}')"
printf '  %-62s %s\n' '{"tool_name":"Read",...{"file_path":"/srv/app/.env"}}' "$(claude_call '{"tool_name":"Read","tool_input":{"file_path":"/srv/app/.env"}}')"
printf '  %-62s %s\n' '{"tool_name":"Bash",...{"command":"git push origin main"}}' "$(claude_call '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}')"
printf '  %-62s %s\n' '{"tool_name":"Bash",...{"command":"git push origin HEAD"}}' "$(claude_call '{"tool_name":"Bash","tool_input":{"command":"git push origin HEAD"}}')"

# --- 4. one perimeter owner, two application points -------------------------

title "4. One perimeter owner: neutralize it and BOTH points change together"
IFS='|' read -r B_HOME B_PROJ B_WT B_FAKEBIN B_FMROOT <<EOF
$(make_case both-points pi guard-evidence-both copy)
EOF
run_spawn "$B_HOME" "$B_WT" "$B_FAKEBIN" "$B_FMROOT" guard-evidence-both "$B_PROJ" >/dev/null
B_EXT="$B_HOME/state/guard-evidence-both.pi-ext.ts"
B_CHECK="$B_FMROOT/bin/fm-worker-pretool-check.sh"
printf '  with bin/fm-worker-command-policy.mjs present:\n'
printf '    pi     -> %s\n' "$(reason_code "$(drive_pi "$B_EXT" bash '{"command":"ssh host"}')")"
printf '    claude -> %s\n' "$(reason_code "$(printf '%s' '{"tool_name":"Bash","tool_input":{"command":"ssh host"}}' | "$B_CHECK" --claude 2>&1)")"
rm -f "$B_FMROOT/bin/fm-worker-command-policy.mjs"
printf '  after removing that single owner:\n'
printf '    pi     -> %s\n' "$(reason_code "$(drive_pi "$B_EXT" bash '{"command":"ssh host"}')")"
printf '    claude -> %s\n' "$(reason_code "$(printf '%s' '{"tool_name":"Bash","tool_input":{"command":"ssh host"}}' | "$B_CHECK" --claude 2>&1)")"

# --- 5. no silent pass when the guard is absent -----------------------------

title "5. A worker without an active guard is refused, never silently permissive"
IFS='|' read -r M_HOME M_PROJ M_WT M_FAKEBIN M_FMROOT <<EOF
$(make_case guard-gone pi guard-evidence-gone copy)
EOF
run_spawn "$M_HOME" "$M_WT" "$M_FAKEBIN" "$M_FMROOT" guard-evidence-gone "$M_PROJ" >/dev/null
M_EXT="$M_HOME/state/guard-evidence-gone.pi-ext.ts"
rm -f "$M_FMROOT/bin/fm-worker-pretool-check.sh"
printf '  Pi worker whose guard transport was removed, ordinary tool call:\n'
printf '    %s\n' "$(drive_pi "$M_EXT" bash '{"command":"echo ordinary work"}' | tr '\t' ' ')"

IFS='|' read -r P_HOME P_PROJ P_WT P_FAKEBIN P_FMROOT <<EOF
$(make_case preflight pi guard-evidence-preflight copy)
EOF
rm -f "$P_FMROOT/bin/fm-worker-command-policy.mjs"
PRE_OUT=$(run_spawn "$P_HOME" "$P_WT" "$P_FAKEBIN" "$P_FMROOT" guard-evidence-preflight "$P_PROJ"); PRE_RC=$?
printf '\n  fm-spawn.sh launching a Pi worker whose guard runtime is missing:\n'
printf '    exit %s: %s\n' "$PRE_RC" "$PRE_OUT"
printf '    per-task extension written: %s\n' \
  "$([ -f "$P_HOME/state/guard-evidence-preflight.pi-ext.ts" ] && echo yes || echo no)"

IFS='|' read -r U_HOME U_PROJ U_WT U_FAKEBIN U_FMROOT <<EOF
$(make_case unwired opencode guard-evidence-unwired copy)
EOF
UNWIRED=$(run_spawn "$U_HOME" "$U_WT" "$U_FAKEBIN" "$U_FMROOT" guard-evidence-unwired "$U_PROJ"); U_RC=$?
printf '\n  fm-spawn.sh launching a worker on a harness with no application point:\n'
printf '    exit %s\n' "$U_RC"
printf '%s\n' "$UNWIRED" | grep WARNING | sed 's/^/    /'

printf '\n'
hr
printf 'RESULT: %s mismatch(es) against perimeter-cases.tsv\n' "$FAILURES"
hr
exit "$FAILURES"
Evidence: Matrice d'acceptation pilotée par le script de preuve

Source: Matrice d'acceptation pilotée par le script de preuve

REFUSE	bash	command	sudo rm -rf /
REFUSE	bash	command	ssh build-host uptime
REFUSE	bash	command	scp secrets.tar host:/tmp
REFUSE	bash	command	rsync -a . host:/srv
REFUSE	bash	command	chmod +x deploy.sh
REFUSE	bash	command	git config --global user.email x@y.z
REFUSE	bash	command	git rebase -i HEAD~3
REFUSE	bash	command	git push origin main
REFUSE	bash	command	git push origin master
REFUSE	bash	command	git push --force origin refs/heads/master
REFUSE	bash	command	cat .env
REFUSE	bash	command	cp .env /tmp/stolen
REFUSE	read	path	/srv/app/.env
REFUSE	grep	path	/srv/app/.env
ALLOW	bash	command	git push origin HEAD
ALLOW	bash	command	git push -u origin fm/task-branch
ALLOW	bash	command	git commit -m "fix: thing"
ALLOW	bash	command	git config user.email x@y.z
ALLOW	bash	command	npm test
ALLOW	bash	command	cat README.md
ALLOW	bash	command	cat config/x-mode.env
ALLOW	read	path	config/x-mode.env
ALLOW	write	path	/srv/app/.env
ALLOW	edit	path	/srv/app/.env
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>

display-message -p #S
list-windows -t firstmate -F #{window_name}
new-window -dP -F #{window_id} -t firstmate: -n fm-probe -c /var/folders/5q/snmnm0_926ddy6xlmggzx1g80000gn/T/fm-guard-probe.s1jfgf/project
set-window-option -t  automatic-rename off
set-window-option -t  allow-rename off
send-keys -t firstmate:fm-probe treehouse get Enter
display-message -p -t firstmate:fm-probe #{pane_current_path}
display-message -p -t firstmate:fm-probe #{pane_current_path}
send-keys -t firstmate:fm-probe export GOTMPDIR=/tmp/fm-probe/gotmp Enter
send-keys -t firstmate:fm-probe -l env -u CURSOR_AGENT -u CURSOR_INVOKED_AS FM_PI_HARNESS=pi '/var/folders/5q/snmnm0_926ddy6xlmggzx1g80000gn/T//fm-guard-probe.s1jfgf/fake/fakebin/pi' -e '/var/folders/5q/snmnm0_926ddy6xlmggzx1g80000gn/T//fm-guard-probe.s1jfgf/home/state/probe.pi-ext.ts' "$('/Users/max/.no-mistakes/worktrees/0e7dd249bdb3/01M0661VCBQHY15G5DFADMST6B/bin/fm-operational-input.sh' encode launch-brief < '/var/folders/5q/snmnm0_926ddy6xlmggzx1g80000gn/T//fm-guard-probe.s1jfgf/home/data/probe/brief.md')"
display-message -p -t firstmate:fm-probe #{pane_id}
send-keys -t firstmate:fm-probe Enter
Evidence: Sonde de lancement ayant produit ce journal

Source: Sonde de lancement ayant produit ce journal

#!/usr/bin/env bash
# Throwaway probe: what does the spawn actually send to the terminal backend?
set -u
EVID_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
REPO=${FM_EVIDENCE_REPO:?}
# shellcheck source=/dev/null
. "$REPO/tests/lib.sh"
fm_git_identity fmtest fmtest@example.invalid
TMP_ROOT=$(fm_test_tmproot fm-guard-probe)
FAKE="$TMP_ROOT/fake"
fakebin=$(fm_fakebin "$FAKE")
cat > "$TMP_ROOT/tmux.staged" <<'SH'
#!/usr/bin/env bash
set -u
[ -z "${FM_TMUX_LOG:-}" ] || printf '%s\n' "$*" >> "$FM_TMUX_LOG"
case "$*" in
  *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;;
esac
case "${1:-}" in
  display-message) printf 'firstmate\n'; exit 0 ;;
  list-windows) exit 0 ;;
esac
exit 0
SH
install -m 755 "$TMP_ROOT/tmux.staged" "$fakebin/tmux"
fm_fake_exit0 "$fakebin" treehouse pi claude opencode
home="$TMP_ROOT/home"; proj="$TMP_ROOT/project"; wt="$TMP_ROOT/wt"
mkdir -p "$home/data/probe" "$home/projects" "$home/state" "$home/config"
printf 'pi\n' > "$home/config/crew-harness"
fm_git_worktree "$proj" "$wt" wt-probe
touch "$home/state/.last-watcher-beat"
printf 'brief\n' > "$home/data/probe/brief.md"
LOG="$TMP_ROOT/tmux.log"
FM_TMUX_LOG="$LOG" FM_ROOT_OVERRIDE="$REPO" FM_HOME="$home" \
  FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \
  FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \
  FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \
  PATH="$fakebin:$PATH" \
  "$REPO/bin/fm-spawn.sh" probe "$proj" --mode no-mistakes --yolo off >/dev/null 2>&1
echo "=== tmux log lines mentioning pi-ext ==="
grep -n 'pi-ext' "$LOG" | head -5
echo "=== state files ==="
ls "$home/state" | head -20
echo "=== any state file mentioning pi-ext ==="
grep -rln 'pi-ext' "$home/state" | head -5
cp "$LOG" "$EVID_DIR/probe-tmux.log" 2>/dev/null || true
Evidence: Suite colocalisée - resultat
FM_TEST_BEGIN tests/fm-worker-command-guard.test.sh
ok - worker guard matrix: 169 cases x 5 harness entry forms, deny/allow all correct
ok - worker guard: .env is refused to every reader through both payload shapes, and neighbours and writers are not
ok - worker guard: a live Pi per-task extension refuses the perimeter and keeps ordinary work
ok - worker guard: a Pi worker whose guard is absent is refused, whether it vanished before or after load
ok - worker guard: fm-spawn refuses to launch a Pi worker whose guard runtime is missing
ok - worker guard: the Claude per-task registration covers every tool and applies the same perimeter
ok - worker guard: both application points follow one perimeter owner and cannot diverge
FM_TEST_SUMMARY total=1 failed=0 skipped_gate=0

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.)

⚠️ **Review** - 1 info

🔧 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. evalPayload renvoie "" dès qu'un mot n'est pas littéral (ligne 360), ce qui bascule sur le repli mentionsPerimeter et refuse ; shellPayload prend 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 &#34;git -C $DIR log -1&#34; -> deny unclassifiable-perimeter-command alors que bash -c &#34;git -C $DIR log -1&#34; -> allow ; eval &#34;cd $DIR &amp;&amp; git status&#34; -> deny alors que la même charge via bash -c -> allow ; eval &#34;git commit -m \&#34;$MSG\&#34;&#34; -> deny ; eval &#34;git push origin $BRANCH&#34; -> deny, alors que eval &#34;git push origin HEAD&#34; -> allow. Sous-refus côté sh -c : sh -c &#34;$PREFIX chmod +x a&#34; -> allow et bash -c &#34;$RUN cat .env&#34; -> allow, alors que eval &#34;$PREFIX chmod +x a&#34; et eval &#34;$RUN cat .env&#34; 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 de eval et faux de sh -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 aligner eval sur shellPayload (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 aligner shellPayload sur eval (mais alors bash -c &#34;cd $DIR &amp;&amp; git status&#34; 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 que grep -rn ssh src/ -> allow ; de même time -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 .env résout log puis cat), 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 que time -p grep -rn ssh src/ redevient autorisé. Portée limitée aux formes time &lt;option&gt; &lt;commande&gt; &lt;argument-du-périmètre&gt;, 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, donc next &amp;&amp; !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 behind time, 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 A64 time -p grep -rn ssh src/, que le round 9 vient explicitement d'autoriser, et rien dans la forme ne distingue log (valeur d'option) de grep (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 faut time -o &lt;fichier&gt; &lt;autre option&gt; 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 que grep ssh src/, rg chmod . et time -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 branche xargs de 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 &#34;*.md&#34; | xargs grep -n chmod -> deny permission-change, xargs -a list.txt grep -l sudo -> deny privilege-escalation, alors que les formes équivalentes grep -rn ssh src/, rg chmod . et surtout find . -type f -name &#34;*.sh&#34; -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 via xargs grep est 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 (A64 time -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 de xargs portant une commande à motif (A30 est xargs 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 - isRebasingPull ne reconnaît que -r isolé, --rebase et --rebase=&lt;valeur&gt;, alors que git parse-options accepte le groupage court : git pull -qr rejoue 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 que git 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 master produit local1 upstream1 base avec 0 commit de fusion et un sha réécrit pour local1 - c'est un rebase, pas une fusion. git pull -h confirme -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 - -qr est un opérande littéral de la commande soumise. docs/worker-command-guard.md:35 revendique par ailleurs « git rebase, and git pull --rebase which 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 -ft au round 2 (TARGET_DIRECTORY_SHORT) et le cluster court sh -cx / sh -xc au round 8 (shellPayload). Seule la branche pull de classifyGit n'a jamais reçu le même traitement, et les cas D81-D84 n'exercent que -r isolé 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 r dans isRebasingPull (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/--regexp dans un token séparé. readOperands ne renvoie patternIndex que lorsque patternIsPositional est vrai ; quand une option porte le motif, elle renvoie patternIndex: -1 et la boucle xargs reclasse 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 directe grep -e ssh src/ -> allow, la forme accolée xargs grep --regexp=ssh -> allow, et la forme que le round 11 vient de corriger xargs 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 ». readOperands EXCLUT déjà cet opérande (il le consomme via args[index += 1] et ne le pousse pas dans paths), elle ne l'expose simplement pas. Correctif : faire renvoyer par readOperands l'index du token porteur du motif dans les DEUX cas - positionnel et porté par option - et laisser la branche xargs le sauter comme elle le fait aujourd'hui. Aucun cas déniant n'est touché : xargs grep -f .env . reste refusé (-f nomme 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 et mv .env? /tmp/ -> allow, alors que les formes littérales cat .env, source .env et cp .env /tmp/x dénient toutes. La cause est saine et assumée : isDotenvPath compare 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 -exec et la variable (sh -c &#34;$CMD&#34;), alors que cat .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.example que 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 de bin/fm-arm-command-policy.mjs (mot-clés réservés extraits) et des artefacts générés par fm-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 via bin/fm-spawn.sh, chargement de l'extension state/&lt;id&gt;.pi-ext.ts générée dans un hôte Node nu, et déclenchement du handler tool_call que Pi appelle, un appel d'outil par critère d'acceptation
  • Reproduction avant/après : extraction du commit de base via git archive e518906 bin AGENTS.md, spawn Pi avec ce FM_ROOT, puis même pilotage - l'extension de base n'enregistre aucun handler tool_call (lecture .env et sudo non gardés)
  • Vérification manuelle que l'extension est attachée au lancement réel : journalisation des send-keys du backend terminal, la ligne de lancement porte pi&#39; -e &#39;&lt;state&gt;/&lt;id&gt;.pi-ext.ts&#39;
  • Vérification manuelle du point d'application Claude : extraction de la commande PreToolUse réellement enregistrée dans .claude/settings.local.json généré (matcher *), puis alimentation avec de vrais payloads Bash/Read
  • Vérification manuelle de l'unicité du périmètre : suppression de bin/fm-worker-command-policy.mjs dans une copie de FM_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 &lt;read|grep|find|ls|edit|write|bash|Read|Write|Edit&gt; --path /srv/app/.env
  • Vérification du contrat réel de Pi dans @earendil-works/pi-coding-agent installé : ToolCallEventResult.block documenté « Block tool execution », et schémas d'entrée des outils (path pour read/grep/find/ls/edit/write, command pour bash)
⚠️ **Document** - 1 info
  • ℹ️ 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.

…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.
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.

1 participant