Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions bin/fm-continuity-command-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
//
// The shared Lexer, program splitter, and command-position resolver remain owned
// by fm-arm-command-policy.mjs. This policy only identifies executed firstmate
// fleet scripts and divides them into recovery commands (wake drain and watcher
// arm) versus every other bin/fm-*.sh command. Unparseable or opaque dynamic
// fleet scripts and divides them into recovery commands (wake drain, watcher
// arm, and fail-closed teardown) versus every other bin/fm-*.sh command. Unparseable or opaque dynamic
// commands fail open so this gate can never become a blanket shell block.

import path from "node:path";
import { fileURLToPath } from "node:url";
import { Lexer, commandPosition, splitProgram } from "./fm-arm-command-policy.mjs";

const RECOVERY_SCRIPTS = new Set(["fm-wake-drain.sh", "fm-watch-arm.sh"]);
const RECOVERY_SCRIPTS = new Set(["fm-wake-drain.sh", "fm-watch-arm.sh", "fm-teardown.sh"]);

function parseArguments(argv) {
const result = { command: "", root: "" };
Expand Down Expand Up @@ -55,7 +55,7 @@ function literalShellPayload(position) {
continue;
}
if (word.value === "--" || /^[-+]/.test(word.value)) continue;
return { kind: noExecute ? "none" : "script", value: word.value };
return { kind: noExecute ? "none" : "script", value: word.value, index };
}
return { kind: noExecute ? "none" : "stdin", value: "" };
}
Expand All @@ -67,6 +67,13 @@ function literalEvalPayload(position) {
return payloads.map((payload) => payload.value).join(" ");
}

function fleetScriptRecord(name, position, scriptIndex) {
const argumentsAfterScript = position.words.slice(scriptIndex + 1);
const unsafeTeardown = name === "fm-teardown.sh"
&& argumentsAfterScript.some((argument) => argument.value === "--force" || !argument.literal);
Comment on lines +72 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject brace and glob expansion in teardown arguments

Captain, when a teardown argument uses brace or glob expansion, the lexer sets unquotedExpansion but leaves literal true, so this check allows commands such as bin/fm-teardown.sh {task,--force}. Bash expands that command to the arguments task and --force, and fm-teardown.sh:106 treats the second argument as force mode, bypassing the dirty, unlanded-work, and scout-report safeguards that this recovery gate is intended to protect; include argument.unquotedExpansion in the unsafe classification.

Useful? React with 👍 / 👎.

return { name, unsafeTeardown };
}

function collectExecutedFleetScripts(command, root, depth = 0) {
if (depth > 12) return [];
const lexed = new Lexer(command).tokenize();
Expand All @@ -77,7 +84,7 @@ function collectExecutedFleetScripts(command, root, depth = 0) {
for (const tokens of program.nodes) {
const position = commandPosition(tokens);
const direct = fleetScript(position.command?.value || "", root);
if (direct) scripts.push(direct);
if (direct) scripts.push(fleetScriptRecord(direct, position, position.index));

for (const token of tokens) {
if (token.type === "group") scripts.push(...collectExecutedFleetScripts(token.content, root, depth + 1));
Expand All @@ -91,7 +98,7 @@ function collectExecutedFleetScripts(command, root, depth = 0) {
if (shell?.kind === "command") scripts.push(...collectExecutedFleetScripts(shell.value, root, depth + 1));
if (shell?.kind === "script") {
const script = fleetScript(shell.value, root);
if (script) scripts.push(script);
if (script) scripts.push(fleetScriptRecord(script, position, shell.index));
}
if (shell?.kind === "stdin") {
for (const token of tokens) {
Expand All @@ -101,10 +108,11 @@ function collectExecutedFleetScripts(command, root, depth = 0) {
}
}

const sourcedIndex = position.index + 1;
const sourced = position.command && [".", "source"].includes(position.command.value)
? fleetScript(position.words[position.index + 1]?.value || "", root)
? fleetScript(position.words[sourcedIndex]?.value || "", root)
: "";
if (sourced) scripts.push(sourced);
if (sourced) scripts.push(fleetScriptRecord(sourced, position, sourcedIndex));
const evaluated = literalEvalPayload(position);
if (evaluated) scripts.push(...collectExecutedFleetScripts(evaluated, root, depth + 1));
}
Expand All @@ -114,15 +122,17 @@ function collectExecutedFleetScripts(command, root, depth = 0) {

export function classifyContinuityCommand(command, root) {
const scripts = collectExecutedFleetScripts(command, root);
const blocked = scripts.find((script) => !RECOVERY_SCRIPTS.has(script));
return blocked ? { decision: "deny", script: blocked } : { decision: "allow", script: "" };
const blocked = scripts.find(({ name, unsafeTeardown }) => !RECOVERY_SCRIPTS.has(name) || unsafeTeardown);
if (!blocked) return { decision: "allow", script: "" };
const code = blocked.unsafeTeardown ? "unsafe-teardown" : "other-fleet";
return { decision: "deny", script: blocked.name, code };
}

function main() {
const args = parseArguments(process.argv.slice(2));
if (!args.command || !args.root) return;
const result = classifyContinuityCommand(args.command, args.root);
if (result.decision === "deny") process.stdout.write(`deny\t${result.script}\n`);
if (result.decision === "deny") process.stdout.write(`deny\t${result.script}\t${result.code}\n`);
}

const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : "";
Expand Down
25 changes: 18 additions & 7 deletions bin/fm-continuity-pretool-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
# Claude primary watcher-continuity PreToolUse gate.
#
# This hook is deliberately narrow. It denies only an executed bin/fm-*.sh fleet
# command other than bin/fm-wake-drain.sh or bin/fm-watch-arm.sh, and only when
# the active primary home has task metadata in flight but no identity-matched
# live watcher holds the home lock. Ordinary shell commands, recovery commands,
# healthy supervision, fleet-idle homes, and child worktrees are always allowed.
# command other than bin/fm-wake-drain.sh, bin/fm-watch-arm.sh, or the
# independently fail-closed bin/fm-teardown.sh, and only when the active primary
# home has task metadata in flight but no identity-matched live watcher holds the
# home lock. Ordinary shell commands, recovery commands, healthy supervision,
# fleet-idle homes, and child worktrees are always allowed.
#
# The existing turn-end guard remains the unchanged final backstop. This gate
# closes the long-turn gap before another fleet mutation, but does not replace or
Expand Down Expand Up @@ -94,9 +95,19 @@ case "$CLASSIFICATION" in
esac

TAB=$(printf '\t')
BLOCKED_SCRIPT=${CLASSIFICATION#*"$TAB"}
[ -n "$BLOCKED_SCRIPT" ] && [ "$BLOCKED_SCRIPT" != "$CLASSIFICATION" ] || exit 0
REASON="[watcher-continuity] tasks are in flight and no live watcher holds this home lock; run bin/fm-wake-drain.sh, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: $BLOCKED_SCRIPT)"
REST=${CLASSIFICATION#*"$TAB"}
[ -n "$REST" ] && [ "$REST" != "$CLASSIFICATION" ] || exit 0
BLOCKED_SCRIPT=${REST%%"$TAB"*}
REASON_CODE=${REST#*"$TAB"}
[ "$REASON_CODE" != "$REST" ] || REASON_CODE=""
case "$REASON_CODE" in
unsafe-teardown)
REASON="[watcher-continuity] tasks are in flight and no live watcher holds this home lock; during recovery only the ordinary literal bin/fm-teardown.sh is allowed, so drop --force and any shell-expanded arguments and retry the literal invocation (blocked: $BLOCKED_SCRIPT)"
;;
*)
REASON="[watcher-continuity] tasks are in flight and no live watcher holds this home lock; drain wakes with bin/fm-wake-drain.sh, use fail-closed bin/fm-teardown.sh for completed tasks when needed, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: $BLOCKED_SCRIPT)"
;;
esac
ESCAPED=$(printf '%s' "$REASON" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr '\n' ' ')
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"},"systemMessage":"%s"}\n' "$ESCAPED" >&2
exit 2
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ That block owns the live wait shape for the running primary harness: Claude and
On `attached` it stays live across identity-matched successors, and an unexplained clean child close either attaches to a verified healthy successor or becomes the typed nonzero `watcher: FAILED - cycle ended without an actionable reason` result.
The arm layer records one bounded lifecycle row per observed cycle in `state/.watch-cycle-exits.log`; `state/.watch-triage.log` remains exclusively the absorbed-wake debug log.
Pi and OpenCode verify session-lock ownership and launch one singleton successor from their child-close handlers before delivering an actionable wake prompt, with bounded exponential retry for failed restoration.
Claude keeps its tracked background-task protocol and adds a narrow PreToolUse continuity gate that allows drain and arm recovery while refusing only other fleet commands when tasks are in flight and no identity-matched live watcher holds the home lock.
Claude keeps its tracked background-task protocol and adds a narrow PreToolUse continuity gate that allows drain, arm recovery, and fail-closed teardown while refusing only other fleet commands when tasks are in flight and no identity-matched live watcher holds the home lock.
The existing turn-end guard is unchanged and remains the final backstop for all five harness protocols.
Its `--restart` mode signals only the watcher recorded in the current home's `state/.watch.lock`, so restarting one home cannot kill sibling secondmate watchers.
A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if the primary checkout is tangled, or if tasks are in flight and that watcher stops running or queued wakes are waiting to be drained.
Expand Down
6 changes: 3 additions & 3 deletions docs/arm-pretool-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ This policy is not a post-arm liveness guarantee.

Claude also registers `bin/fm-continuity-pretool-check.sh` for Bash PreToolUse events.
This is a separate, tightly bounded recovery gate rather than another watcher-shape policy.
It runs only in a primary home, and it denies only an executed `bin/fm-*.sh` command other than `bin/fm-wake-drain.sh` or `bin/fm-watch-arm.sh` when task metadata is in flight and no identity-matched live watcher holds that home's lock.
Ordinary shell commands, fleet-script names used as data, all commands in an idle fleet, child worktrees, wake drain, and watcher arm remain allowed.
The exact denial tells Claude to run `bin/fm-wake-drain.sh` and then re-arm through a tracked Claude background task before retrying the blocked fleet command.
It runs only in a primary home, and it denies only an executed `bin/fm-*.sh` command other than `bin/fm-wake-drain.sh`, `bin/fm-watch-arm.sh`, or the ordinary literal `bin/fm-teardown.sh` when task metadata is in flight and no identity-matched live watcher holds that home's lock.
Ordinary shell commands, fleet-script names used as data, all commands in an idle fleet, child worktrees, wake drain, watcher arm, and ordinary literal teardown remain allowed.
The denial gives Claude reason-specific recovery guidance — drain, re-arm via a tracked Claude background task, and use fail-closed `bin/fm-teardown.sh` for completed tasks — per the contract in [`watcher-continuity.md`](watcher-continuity.md).
`bin/fm-continuity-command-policy.mjs` reuses this document's shell lexer and command-position analysis but owns the recovery-versus-other-fleet classification.
Malformed transport or opaque dynamic syntax fails open so this narrow gate cannot become a blanket Bash block.
The existing `bin/fm-turnend-guard.sh` Stop integration is unchanged and remains the final backstop.
Expand Down
2 changes: 1 addition & 1 deletion docs/supervision-protocols/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ When this session owns supervision and away mode is not active:
7. Failure or missing cycle only: treat any `watcher: FAILED ...` result as an alarm and repair it before ending the turn.
8. Ordinary wake: when the background task completes with `signal:`, `stale:`, `check:`, or `heartbeat`, drain queued wakes, then start exactly one fresh background task before running other fleet commands to handle the wake.
Do not invent a wake from an attach-status line alone; drain and act only on real wake records or a real watcher reason line.
9. The continuity PreToolUse gate allows wake drain and watcher arm recovery, and refuses only other `bin/fm-*.sh` fleet commands while tasks are in flight and no identity-matched live watcher holds the home lock.
9. The continuity PreToolUse gate allows wake drain, watcher arm recovery, and fail-closed teardown, and refuses only other `bin/fm-*.sh` fleet commands while tasks are in flight and no identity-matched live watcher holds the home lock.
10. The existing turn-end guard remains unchanged as the final backstop and is not replaced by this command gate.
11. Recovery only: if a forced restart is genuinely needed, run `bin/fm-watch-arm.sh --restart` through the same Claude background task mechanism.
12. Do not send idle progress while the watcher is parked.
Expand Down
3 changes: 2 additions & 1 deletion docs/watcher-continuity.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ After the configured retry bound is exhausted, it delivers the original wake wit
This is deliberate Option B ordering: the fleet is protected before the model handles the wake whenever restoration succeeds, but the model is never left blind when it does not.

Claude retains its native tracked background-task completion path.
Its new PreToolUse continuity gate allows wake drain and arm recovery but refuses only other fleet commands while tasks are in flight and no identity-matched live watcher holds the home lock.
Its new PreToolUse continuity gate allows wake drain, arm recovery, and independently fail-closed teardown, but refuses other fleet commands while tasks are in flight and no identity-matched live watcher holds the home lock.
Allowing an ordinary literal teardown prevents a terminal wake from creating a recovery circle: forced or dynamically constructed teardown remains blocked, ordinary teardown itself still refuses dirty, unlanded, incomplete-scout, and unresolved-decision cases, and the turn-end guard continues to require supervision for any tasks left in flight.
Codex retains its bounded foreground checkpoint protocol.
Grok retains its tracked background-task notification protocol.
No adapter starts a replacement with shell `&`.
Expand Down
2 changes: 1 addition & 1 deletion tests/fm-claude-continuity-live-e2e.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ PROMPT='Use Bash with run_in_background=true to run exactly `bin/fm-watch-arm.sh
[ -f "$HOME_DIR/state/claude-arm-ran" ] || fail "Claude did not run the tracked background arm fixture"
[ -f "$HOME_DIR/state/claude-drain-ran" ] || fail "Claude continuity gate blocked the allowed wake drain"
[ ! -f "$HOME_DIR/state/claude-forbidden-ran" ] || fail "Claude continuity gate allowed an unrelated fleet command"
GUIDANCE='[watcher-continuity] tasks are in flight and no live watcher holds this home lock; run bin/fm-wake-drain.sh, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: fm-crew-state.sh)'
GUIDANCE='[watcher-continuity] tasks are in flight and no live watcher holds this home lock; drain wakes with bin/fm-wake-drain.sh, use fail-closed bin/fm-teardown.sh for completed tasks when needed, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: fm-crew-state.sh)'
grep -F "$GUIDANCE" "$TRANSCRIPT" >/dev/null || fail "Claude transcript omitted the exact continuity recovery guidance"

printf 'ok - Claude %s live E2E refused only the post-completion fleet command with exact re-arm guidance\n' "$CLAUDE_VERSION"
10 changes: 8 additions & 2 deletions tests/fm-continuity-pretool-check.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ expect_allow() {
}

expect_deny() {
local label=$1 command=$2 blocked=$3 rc=0 expected actual
local label=$1 command=$2 blocked=$3 expected=${4:-} rc=0 actual
run_command "$command" || rc=$?
[ "$rc" -eq 2 ] || fail "$label must deny with exit 2, got $rc"
[ ! -s "$OUT" ] || fail "$label deny wrote stdout: $(cat "$OUT")"
jq -e '.hookSpecificOutput.hookEventName == "PreToolUse" and .hookSpecificOutput.permissionDecision == "deny"' "$ERR" >/dev/null 2>&1 \
|| fail "$label deny omitted Claude's permission decision: $(cat "$ERR")"
expected="[watcher-continuity] tasks are in flight and no live watcher holds this home lock; run bin/fm-wake-drain.sh, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: $blocked)"
[ -n "$expected" ] || expected="[watcher-continuity] tasks are in flight and no live watcher holds this home lock; drain wakes with bin/fm-wake-drain.sh, use fail-closed bin/fm-teardown.sh for completed tasks when needed, then re-arm with bin/fm-watch-arm.sh as a tracked Claude background task before running other fleet commands (blocked: $blocked)"
actual=$(jq -r '.systemMessage' "$ERR")
[ "$actual" = "$expected" ] || fail "$label recovery guidance changed: $actual"
}
Expand All @@ -55,6 +55,12 @@ test_gate_scope_and_recovery_exceptions() {
expect_allow "wake drain recovery" 'bin/fm-wake-drain.sh'
expect_allow "watch arm recovery" 'bin/fm-watch-arm.sh'
expect_allow "drain then arm recovery" 'bin/fm-wake-drain.sh; bin/fm-watch-arm.sh'
expect_allow "fail-closed teardown recovery" 'bin/fm-teardown.sh task'
unsafe_teardown_reason='[watcher-continuity] tasks are in flight and no live watcher holds this home lock; during recovery only the ordinary literal bin/fm-teardown.sh is allowed, so drop --force and any shell-expanded arguments and retry the literal invocation (blocked: fm-teardown.sh)'
expect_deny "forced teardown is not recovery" 'bin/fm-teardown.sh task --force' 'fm-teardown.sh' "$unsafe_teardown_reason"
expect_deny "nested forced teardown is not recovery" "bash -lc 'bin/fm-teardown.sh task --force'" 'fm-teardown.sh' "$unsafe_teardown_reason"
# shellcheck disable=SC2016 # single quotes are deliberate: "$TEARDOWN_MODE" is literal test data (an unsafe shell-expanded arg the gate must deny), not an expansion here
expect_deny "dynamic teardown mode is not recovery" 'bin/fm-teardown.sh task "$TEARDOWN_MODE"' 'fm-teardown.sh' "$unsafe_teardown_reason"
expect_deny "unrelated fleet command" 'bin/fm-crew-state.sh task' 'fm-crew-state.sh'
expect_deny "recovery bundled with unrelated fleet command" 'bin/fm-wake-drain.sh; bin/fm-send.sh task hi' 'fm-send.sh'
expect_deny "literal nested fleet command" "bash -lc 'bin/fm-bootstrap.sh'" 'fm-bootstrap.sh'
Expand Down