Skip to content
Open
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
4 changes: 4 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-cd-pretool-check.sh --claude"
},
{
"type": "command",
"command": "\"\"/bin/fm-continuity-pretool-check.sh"
}
]
},
Expand Down
135 changes: 135 additions & 0 deletions bin/fm-continuity-command-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env node
// Narrow shell classifier for the Claude watcher-continuity PreToolUse gate.
//
// 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
// 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", "fm-watch-checkpoint.sh"]);

function parseArguments(argv) {
const result = { command: "", root: "" };
for (let index = 0; index < argv.length; index += 1) {
const name = argv[index];
if (name !== "--command" && name !== "--root") throw new Error(`unknown argument: ${name}`);
if (index + 1 >= argv.length) throw new Error(`${name} requires a value`);
result[name.slice(2)] = argv[index + 1];
index += 1;
}
return result;
}

function basename(value) {
return value.split("/").filter(Boolean).at(-1) || value;
}

function fleetScript(value, root) {
const normalized = path.normalize(value);
const name = basename(normalized);
if (!/^fm-[A-Za-z0-9._-]+\.sh$/.test(name)) return "";
const relative = `bin/${name}`;
if (normalized === relative || normalized === path.join(root, relative) || normalized.endsWith(`/${relative}`)) return name;
return "";
}

function literalShellPayload(position) {
if (!position.command || !["sh", "bash", "zsh"].includes(basename(position.command.value))) return null;
const words = position.words;
let noExecute = false;
for (let index = position.index + 1; index < words.length; index += 1) {
const word = words[index];
if (/^-[A-Za-z]*n[A-Za-z]*$/.test(word.value)) noExecute = true;
if (/^-[A-Za-z]*c[A-Za-z]*$/.test(word.value)) {
const payload = words[index + 1];
if (!payload || !payload.literal || payload.subs.length > 0) return null;
return { kind: noExecute ? "none" : "command", value: payload.value };
}
if (/^[-+]O$/.test(word.value)) {
index += 1;
continue;
}
if (word.value === "--" || /^[-+]/.test(word.value)) continue;
return { kind: noExecute ? "none" : "script", value: word.value };
}
return { kind: noExecute ? "none" : "stdin", value: "" };
}

function literalEvalPayload(position) {
if (!position.command || basename(position.command.value) !== "eval") return "";
const payloads = position.words.slice(position.index + 1);
if (payloads.length === 0 || payloads.some((payload) => !payload.literal || payload.subs.length > 0)) return "";
return payloads.map((payload) => payload.value).join(" ");
}

function collectExecutedFleetScripts(command, root, depth = 0) {
if (depth > 12) return [];
const lexed = new Lexer(command).tokenize();
if (lexed.error) return [];
const scripts = [];
const program = splitProgram(lexed.tokens);

for (const tokens of program.nodes) {
const position = commandPosition(tokens);
const direct = fleetScript(position.command?.value || "", root);
if (direct) scripts.push(direct);

for (const token of tokens) {
if (token.type === "group") scripts.push(...collectExecutedFleetScripts(token.content, root, depth + 1));
if (token.type !== "word") continue;
for (const substitution of token.subs) {
scripts.push(...collectExecutedFleetScripts(substitution.content, root, depth + 1));
}
}

const shell = literalShellPayload(position);
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 (shell?.kind === "stdin") {
for (const token of tokens) {
if (token.type === "redir" && token.fd === 0 && typeof token.heredoc === "string") {
scripts.push(...collectExecutedFleetScripts(token.heredoc, root, depth + 1));
}
}
}

const sourced = position.command && [".", "source"].includes(position.command.value)
? fleetScript(position.words[position.index + 1]?.value || "", root)
: "";
if (sourced) scripts.push(sourced);
const evaluated = literalEvalPayload(position);
if (evaluated) scripts.push(...collectExecutedFleetScripts(evaluated, root, depth + 1));
}

return scripts;
}

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: "" };
}

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`);
}

const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : "";
if (invokedPath === fileURLToPath(import.meta.url)) {
try {
main();
} catch {
process.exitCode = 0;
}
}
102 changes: 102 additions & 0 deletions bin/fm-continuity-pretool-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# 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.
#
# 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
# weaken the Stop hook.
#
# Input is Claude PreToolUse JSON on stdin. Tests may pass --command directly.
# Malformed transport, missing jq/Node, a missing classifier, or classifier
# failure all fail open. A deny writes Claude's hook decision to stderr only and
# exits 2.
set -u

COMMAND=
COMMAND_SET=0

usage() {
cat <<'EOF'
Usage: fm-continuity-pretool-check.sh [--command <shell-command>]

Reads Claude PreToolUse JSON from stdin unless --command is supplied.
Exits 0 to allow. Exits 2 with a Claude deny object on stderr only when an
unhealthy primary tries to execute a non-recovery firstmate fleet script.
EOF
}

while [ "$#" -gt 0 ]; do
case "$1" in
--command)
[ "$#" -gt 1 ] || { echo "error: --command requires a value" >&2; exit 2; }
COMMAND=$2
COMMAND_SET=1
shift 2
;;
--command=*)
COMMAND=${1#--command=}
COMMAND_SET=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "error: unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done

if [ "$COMMAND_SET" -eq 0 ]; then
PAYLOAD=$(cat 2>/dev/null || true)
[ -n "$PAYLOAD" ] || exit 0
command -v jq >/dev/null 2>&1 || exit 0
COMMAND=$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null) || exit 0
fi
[ -n "$COMMAND" ] || exit 0

SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P) || exit 0
FM_ROOT=${FM_ROOT_OVERRIDE:-$(CDPATH='' cd -- "$SCRIPT_DIR/.." 2>/dev/null && pwd -P)}
FM_HOME=${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}
STATE=${FM_STATE_OVERRIDE:-$FM_HOME/state}
WATCH="$SCRIPT_DIR/fm-watch.sh"
POLICY="$SCRIPT_DIR/fm-continuity-command-policy.mjs"

# shellcheck source=bin/fm-supervision-lib.sh
. "$SCRIPT_DIR/fm-supervision-lib.sh"
# shellcheck source=bin/fm-primary-scope-lib.sh
. "$SCRIPT_DIR/fm-primary-scope-lib.sh"
# shellcheck source=bin/fm-wake-lib.sh
. "$SCRIPT_DIR/fm-wake-lib.sh"

fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0
fm_supervision_status "$STATE" "${FM_GUARD_GRACE:-300}"
[ "$FM_SUP_IN_FLIGHT" -gt 0 ] || exit 0
LOCK_PID=$(cat "$STATE/.watch.lock/pid" 2>/dev/null || true)
if fm_pid_alive "$LOCK_PID" && fm_watcher_lock_matches_pid "$STATE" "$WATCH" "$LOCK_PID" "$FM_HOME"; then
exit 0
fi

command -v node >/dev/null 2>&1 || exit 0
[ -f "$POLICY" ] || exit 0
CLASSIFICATION=$(node "$POLICY" --command "$COMMAND" --root "$FM_ROOT" 2>/dev/null) || exit 0
case "$CLASSIFICATION" in
deny*) ;;
*) exit 0 ;;
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 (Claude/Grok) or run bin/fm-watch-checkpoint.sh (Copilot/Codex) before running other fleet commands (blocked: $BLOCKED_SCRIPT)"
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
120 changes: 120 additions & 0 deletions tests/fm-continuity-pretool-check.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Behavior tests for Claude's narrowly scoped watcher-continuity PreToolUse gate.
set -u

# shellcheck source=tests/lib.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"

CHECK="$ROOT/bin/fm-continuity-pretool-check.sh"
WATCH="$ROOT/bin/fm-watch.sh"
TMP_ROOT=$(fm_test_tmproot fm-continuity-pretool-tests)
PRIMARY="$TMP_ROOT/primary"
STATE="$PRIMARY/state"
OUT="$TMP_ROOT/out"
ERR="$TMP_ROOT/err"

mkdir -p "$PRIMARY/bin" "$STATE"
printf '# fixture\n' > "$PRIMARY/AGENTS.md"
git -C "$PRIMARY" init -q

run_command() {
local command=$1 rc=0
: > "$OUT"
: > "$ERR"
FM_ROOT_OVERRIDE="$PRIMARY" FM_HOME="$PRIMARY" FM_STATE_OVERRIDE="$STATE" \
"$CHECK" --command "$command" > "$OUT" 2> "$ERR" || rc=$?
return "$rc"
}

expect_allow() {
local label=$1 command=$2 rc=0
run_command "$command" || rc=$?
[ "$rc" -eq 0 ] || fail "$label must allow, got exit $rc: $(cat "$ERR")"
[ ! -s "$OUT" ] || fail "$label allow wrote stdout: $(cat "$OUT")"
[ ! -s "$ERR" ] || fail "$label allow wrote stderr: $(cat "$ERR")"
}

expect_deny() {
local label=$1 command=$2 blocked=$3 rc=0 expected 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 (Claude/Grok) or run bin/fm-watch-checkpoint.sh (Copilot/Codex) before running other fleet commands (blocked: $blocked)"
actual=$(jq -r '.systemMessage' "$ERR")
[ "$actual" = "$expected" ] || fail "$label recovery guidance changed: $actual"
}

test_gate_scope_and_recovery_exceptions() {
expect_allow "idle fleet command" 'bin/fm-crew-state.sh task'
printf 'project=fixture\n' > "$STATE/task.meta"

expect_allow "ordinary shell command" 'git status --short'
expect_allow "fleet-script text as data" "rg -n 'bin/fm-send.sh' docs"
expect_allow "wake drain recovery" 'bin/fm-wake-drain.sh'
expect_allow "watch arm recovery" 'bin/fm-watch-arm.sh'
expect_allow "watch checkpoint recovery" 'bin/fm-watch-checkpoint.sh --seconds 180'
expect_allow "drain then arm recovery" 'bin/fm-wake-drain.sh; bin/fm-watch-arm.sh'
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'
pass "continuity gate allows recovery and ordinary commands but denies only other fleet execution"
}

test_live_lock_allows_fleet_command_even_with_stale_beacon() {
local holder identity rc=0
sleep 300 &
holder=$!
identity=$(FM_STATE_OVERRIDE="$STATE" bash -c '. "$1"; fm_pid_identity "$2"' _ "$ROOT/bin/fm-wake-lib.sh" "$holder") \
|| fail "could not identify live continuity fixture"
mkdir -p "$STATE/.watch.lock"
printf '%s\n' "$holder" > "$STATE/.watch.lock/pid"
printf '%s\n' "$PRIMARY" > "$STATE/.watch.lock/fm-home"
printf '%s\n' "$WATCH" > "$STATE/.watch.lock/watcher-path"
printf '%s\n' "$identity" > "$STATE/.watch.lock/pid-identity"
touch -t 200001010000 "$STATE/.last-watcher-beat"

run_command 'bin/fm-crew-state.sh task' || rc=$?
kill "$holder" 2>/dev/null || true
wait "$holder" 2>/dev/null || true
[ "$rc" -eq 0 ] || fail "identity-matched live lock must allow fleet command even when its beacon is stale"
[ ! -s "$ERR" ] || fail "live-lock allow wrote stderr: $(cat "$ERR")"
pass "continuity gate classifies the lock by live PID identity rather than beacon age"
}

test_child_worktree_and_malformed_input_fail_open() {
local child="$TMP_ROOT/child" rc=0
rm -rf "$STATE/.watch.lock"
git -C "$PRIMARY" config user.name fixture
git -C "$PRIMARY" config user.email [email protected]
git -C "$PRIMARY" add AGENTS.md
git -C "$PRIMARY" commit -qm fixture
git -C "$PRIMARY" worktree add -q -b fixture-child "$child"
mkdir -p "$child/bin" "$child/state"
FM_ROOT_OVERRIDE="$child" FM_HOME="$child" FM_STATE_OVERRIDE="$child/state" \
"$CHECK" --command 'bin/fm-send.sh task hi' > "$OUT" 2> "$ERR" || rc=$?
[ "$rc" -eq 0 ] || fail "linked child worktree must be out of continuity-gate scope"

expect_allow "malformed dynamic shell" "bin/fm-send.sh 'unterminated"
printf '%s' '{not-json' | FM_ROOT_OVERRIDE="$PRIMARY" FM_HOME="$PRIMARY" FM_STATE_OVERRIDE="$STATE" \
"$CHECK" > "$OUT" 2> "$ERR" || rc=$?
[ "$rc" -eq 0 ] || fail "malformed Claude transport must fail open"
pass "continuity gate excludes child worktrees and fails open on opaque input"
}

test_claude_hook_registration_preserves_stop_backstop() {
jq -e '
[.hooks.PreToolUse[] | select(.matcher == "Bash") | .hooks[].command]
| any(contains("fm-continuity-pretool-check.sh"))
' "$ROOT/.claude/settings.json" >/dev/null || fail "Claude settings omit the continuity PreToolUse hook"
jq -e '
.hooks.Stop == [{"hooks":[{"type":"command","command":"[ -z \"${GROK_AGENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh --claude"},{"type":"command","command":"[ -z \"${GROK_AGENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-claude-stop-autoarm.sh","asyncRewake":true,"timeout":28800}]}]
' "$ROOT/.claude/settings.json" >/dev/null || fail "Claude Stop turn-end backstop changed"
pass "Claude wires the continuity gate while preserving the existing Stop backstop byte-for-byte"
}

test_gate_scope_and_recovery_exceptions
test_live_lock_allows_fleet_command_even_with_stale_beacon
test_child_worktree_and_malformed_input_fail_open
test_claude_hook_registration_preserves_stop_backstop
Loading