diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md new file mode 100644 index 00000000000..2353427e531 --- /dev/null +++ b/.agents/skills/bearings/SKILL.md @@ -0,0 +1,73 @@ +--- +name: bearings +description: Generate a "pick up where I left off" status report from firstmate's live fleet state. Use when the captain invokes /bearings or asks for a bearings report, morning brief, status report, catch-up, "where did I leave off", or "what's in the works". Reads bounded local fleet state cheaply, optionally checks open PRs when asked, writes a dated report to data/status-report-.md, and surfaces a concise version in chat; it is read-mostly and never tears down, merges, or mutates task state. +user-invocable: true +--- + +# bearings + +Generate a "pick up where I left off" report from the fleet's live state, so the captain can resume in one read after a break, a night, or a context reset. +The deliverable is a dated markdown file plus a concise chat summary. +This skill is read-mostly. +It reads fleet state and writes exactly one report file. +It never tears down a task, merges a PR, dispatches new work, or mutates any task state as a side effect of producing the brief; those belong to the captain's explicit word and the normal task lifecycle. + +## What it does + +1. **Gather live fleet state with one deterministic command.** + Run `bin/fm-bearings-snapshot.sh` and read its compact output. + It is the single bounded, deterministic source for this report and renders TOON by default. + Do not hand-probe the snapshot schema and do not make ad-hoc `gh`/`gh-axi` calls to assemble fleet facts; this command already assembles them. + The command's header and `--help` output own its exact fields, bounds, opt-ins, and output contract. + When the captain asks to include PRs, use the command's live-PR opt-in (`--include-prs`); otherwise keep the default local-only read. + If the command is unavailable, fall back to `bin/fm-fleet-snapshot.sh --json` and `bin/fm-crew-state.sh `; never infer current state from a raw `tail` of `state/.status`, which is append-only wake-event history whose last line goes stale. + A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived; until then it stays queued with the reason. + +2. **Compose the detailed report file around the four-section spine.** + The gather step is deterministic; your judgment is scoped to the last mile - ranking the command's facts by what matters right now and writing the scannable prose. + The report uses the same four sections as the chat (see the contract below), in the same order, each always present, and adds the detail the chat omits: + - **Title** - `# Bearings - `, followed by two or three sentences framing where things stand. + - **Captain's Call** - every open decision relayed verbatim with its options, plus each PR ready to merge and each needed credential or login, every PR with the full `https://...` URL, never a bare `#number`. + - **Recently Landed** - merged PRs and completed scouts since the last report, across the main fleet and every registered secondmate home. + - **Underway** - each live direct report making progress, with its current state, and the plans / pickup pointers worth reopening (`data//report.md` files). + - **Charted Next** - queued or gated next work, with each item's blocker or date reason. + +3. **Write the dated report file, then surface the four-section digest in chat.** + - Write the full report to `data/status-report-.md` using today's date. + This is the required artifact; it lives in gitignored `data/`. + If today's file already exists, delete it first, then create a new file from scratch. + - The chat response is the concise four-section digest defined below: materially shorter than the report file, and it links to that file for the full picture. + - For a richer review surface, optionally offer a `lavish-axi` board when the report has enough structure to deserve one, but the markdown file is the required artifact and the four-section chat digest is the required minimum. + +## Chat-response contract + +This skill is the one owner of the `/bearings` chat-response format. +Every `/bearings` chat response renders EXACTLY these four sections, in THIS order, and nothing else structural: + +1. **Captain's Call** - ONLY items that need the captain's own action now: a decision to make, a PR to approve or merge, a credential or login to provide, or a blocker only the captain can clear. + Empty-state: "Nothing needs your action right now." +2. **Recently Landed** - work completed since the prior report: merged PRs and completed scouts, across the main fleet and every registered secondmate home. + Empty-state: "Nothing has landed since your last report." +3. **Underway** - live work progressing on its own, one line of current state per direct report. + Empty-state: "Nothing is underway." +4. **Charted Next** - queued or gated work waiting on the fleet or a date, never on the captain. + Empty-state: "Nothing is queued." + +Rules that keep the contract unambiguous: + +- Every section ALWAYS renders, even when empty, with its short empty-state sentence; never omit a section. +- The four buckets are mutually exclusive, so every item is forced into exactly one: needs-your-action is Captain's Call, done is Recently Landed, self-progressing is Underway, not-yet-started is Charted Next. +- The strict boundary keeps action-free items OUT of Captain's Call: a working or validating task, a queued item blocked on another task or a date, landed work, a completed scout's report pointer, and a bare recorded PR with no merge-ready signal each belong to one of the other three sections, never Captain's Call. +- The chat carries one scannable line per item, each PR as the full `https://...` URL; the verbatim decisions, plans, full gate reasons, and evidence live only in the report file, which the chat links to, so the chat stays materially shorter than that file. + +## Tone and content rules + +- This report is a private, captain-facing internal artifact that lives in gitignored `data/`, so unlike normal captain chat it MAY reference task ids, PR URLs, and repo names - the captain works with these directly and needs them to resume; keep it organized and scannable, not a raw dump. +- Every PR reference is a full `https://...` URL, never a bare `#number`; a shorthand `#number` is fine only as a back-reference after the full URL has already appeared in the same report. +- Never include secret values; the report is an operational artifact, but it is still subject to the same security rules that govern everything else in this fleet. + +## Supervision discipline + +This skill is read-mostly and changes no fleet state. +Do not tear down a task, merge a PR, dispatch queued work, or mutate any `state/` or `data/` file other than the single report file as a side effect of generating the brief. +If the state you read suggests an action - a PR ready to merge, a queued item whose gate has arrived, a needs-decision finding - name it in its section (a captain action under "Captain's Call", queued or gated work under "Charted Next") and let the captain decide, rather than taking the action from inside this skill. diff --git a/.claude/settings.json b/.claude/settings.json index a70ee43e499..6bbd714d727 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,20 @@ { "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-arm-pretool-check.sh --claude" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-cd-pretool-check.sh --claude" + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/AGENTS.md b/AGENTS.md index 3652c50de40..8a5429704fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history captain.md captain's curated personal preferences and working style - approval posture, communication style, research and delivery habits; LOCAL, gitignored; compact rewrite-and-prune counterpart to shared AGENTS.md; canonical harness-portable home, even if harness memory mirrors it as a recall cache learnings.md fleet-local operational learnings (script sharp edges, harness quirks, recurring false alarms and their causes); LOCAL, gitignored; dated, evidence-backed, curated rewrite-and-prune style; the /stow skill sweeps a session's uncaptured knowledge into it + status-report-.md dated "pick up where I left off" fleet report; LOCAL, gitignored; written by the /bearings skill from bin/fm-bearings-snapshot.sh when the captain asks for a status/catch-up/morning brief projects.md thin fleet navigation registry: one line per project under projects/ with name, delivery mode, and a one-line description. It is firstmate-private, not a project knowledge dump; fm-project-mode.sh parses it (section 6) secondmates.md secondmate routing table: one line per persistent domain supervisor, with a natural-language scope, non-exclusive project clone list, and home path; fm-home-seed.sh maintains it and validates unique ids, unique homes, and non-overlapping home paths (section 6) /brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate @@ -489,6 +490,7 @@ After a background watcher exits with `signal`, `stale`, `check`, or `heartbeat` Never end a turn with tasks in flight and no live watcher cycle. If a forced restart is ever genuinely needed, use `bin/fm-watch-arm.sh --restart`, which stops only THIS home's watcher (the pid recorded in this home's `state/.watch.lock`) and starts a fresh one. Never `pkill -f bin/fm-watch.sh`: that pattern matches every firstmate home's watcher, including secondmate homes that run the same script, so a broad pkill from one home kills sibling homes' watchers. +A Claude PreToolUse guard (`bin/fm-arm-pretool-check.sh`) enforces this: it denies a broad `pkill -f fm-watch` and any non-standalone watcher arm (bundled, piped, redirected, backgrounded) before it runs, and a sibling cd-guard (`bin/fm-cd-pretool-check.sh`) denies a persistent top-level `cd` in the primary checkout; both are Claude-only seatbelts wired in `.claude/settings.json` (see `docs/arm-pretool-check.md`, `docs/cd-guard.md`). Waiting on the watcher is intentionally silent. After arming it, do not send idle progress updates to the captain; wait until it returns `signal`, `stale`, `check`, or `heartbeat`, unless the captain asks for status. Empty polls, elapsed waiting time, and "still no change" are tool bookkeeping, not conversational progress. diff --git a/bin/fm-arm-command-policy.mjs b/bin/fm-arm-command-policy.mjs new file mode 100755 index 00000000000..a68c7f7910b --- /dev/null +++ b/bin/fm-arm-command-policy.mjs @@ -0,0 +1,958 @@ +#!/usr/bin/env node +// Semantic policy for watcher arm shell commands. +// +// Fork note (Claude-only): upstream also protects a Codex-only +// bin/fm-watch-checkpoint.sh. This fork has no checkpoint concept - Claude arms +// the watcher as a tracked background task and is re-invoked on its exit - so +// the "checkpoint" protected identity is dropped here; only "arm" (valid +// standalone) and "watch" (forbidden direct) remain. +// +// This parser is deliberately narrow. +// It recognizes executed command positions without evaluating, expanding, +// sourcing, or running any byte of the submitted command. +// +// This file is the sole owner of firstmate's shell command classification. +// The tokenizer and command-position analysis (Lexer, splitProgram, +// commandPosition) are exported so the sibling cd-guard policy +// (bin/fm-cd-command-policy.mjs) reuses the same proven parser instead of +// duplicating shell lexing; see docs/cd-guard.md. The watcher-arm decision +// procedure below stays private to this file. The CLI entry point at the bottom +// runs only when this module is invoked directly, never on import. + +import path from "node:path"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const REASONS = { + "watcher-background": "a protected watcher command cannot run in an asynchronous shell list or through nohup/disown", + "watcher-pipeline": "a protected watcher command must not participate in a pipeline", + "watcher-redirection": "a protected watcher command must not use shell redirection", + "watcher-bundled": "a protected watcher command must be the sole final command after approved setup nodes", + "watcher-nested": "a protected watcher command must not run through a wrapper, substitution, or compound command", + "broad-watcher-kill": "a broad process kill targeting the firstmate watcher is forbidden", + "unclassifiable-protected-command": "unsupported or malformed shell syntax contains a protected watcher command", + "watcher-direct": "bin/fm-watch.sh must not be run directly; arm the watcher with bin/fm-watch-arm.sh instead", +}; + +function parseArguments(argv) { + const result = { command: "", root: "", home: "" }; + for (let i = 0; i < argv.length; i += 1) { + const name = argv[i]; + if (name === "--command" || name === "--root" || name === "--home") { + if (i + 1 >= argv.length) throw new Error(`${name} requires a value`); + result[name.slice(2)] = argv[i + 1]; + i += 1; + continue; + } + throw new Error(`unknown argument: ${name}`); + } + return result; +} + +function rawMentionsProtected(command) { + return /(?:^|[/\s'"`(])fm-watch(?:-arm)?\.sh\b/.test(normalizeLineContinuations(command)); +} + +function rawMentionsBroadKill(command) { + const normalized = normalizeLineContinuations(command); + return /fm-watch/.test(normalized) && /\b(?:pkill|kill)\b/.test(normalized); +} + +function normalizeLineContinuations(source) { + return source.replace(/\\\r?\n/g, ""); +} + +function basename(value) { + return value.split("/").filter(Boolean).at(-1) || value; +} + +function extractBalanced(source, start, open, close) { + let depth = 1; + let quote = ""; + let escaped = false; + for (let i = start; i < source.length; i += 1) { + const char = source[i]; + if (escaped) { + escaped = false; + continue; + } + if (quote === "'") { + if (char === "'") quote = ""; + continue; + } + if (quote === '"') { + if (char === "\\") { + escaped = true; + } else if (char === '"') { + quote = ""; + } + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === "'" || char === '"') { + quote = char; + continue; + } + if (char === open) depth += 1; + if (char === close) { + depth -= 1; + if (depth === 0) return { content: source.slice(start, i), next: i + 1 }; + } + } + return null; +} + +function extractBackticks(source, start) { + let escaped = false; + for (let i = start; i < source.length; i += 1) { + const char = source[i]; + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === "`") return { content: source.slice(start, i), next: i + 1 }; + } + return null; +} + +function decodeAnsiCQuoted(source, start) { + let index = start + 2; + let value = ""; + while (index < source.length) { + const char = source[index]; + if (char === "'") return { value, next: index + 1 }; + if (char !== "\\") { + value += char; + index += 1; + continue; + } + if (index + 1 >= source.length) return null; + const escape = source[index + 1]; + index += 2; + const simple = { a: "\u0007", b: "\b", e: "\u001b", E: "\u001b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v", "\\": "\\", "'": "'", '"': '"', "?": "?" }; + if (Object.hasOwn(simple, escape)) { + value += simple[escape]; + continue; + } + if (/[0-7]/.test(escape)) { + let digits = escape; + while (digits.length < 3 && /[0-7]/.test(source[index] || "")) { + digits += source[index]; + index += 1; + } + value += String.fromCodePoint(Number.parseInt(digits, 8)); + continue; + } + if (escape === "x") { + let digits = ""; + while (digits.length < 2 && /[0-9A-Fa-f]/.test(source[index] || "")) { + digits += source[index]; + index += 1; + } + value += digits ? String.fromCodePoint(Number.parseInt(digits, 16)) : "\\x"; + continue; + } + if (escape === "u" || escape === "U") { + const length = escape === "u" ? 4 : 8; + const digits = source.slice(index, index + length); + if (digits.length === length && /^[0-9A-Fa-f]+$/.test(digits)) { + const codePoint = Number.parseInt(digits, 16); + try { + value += String.fromCodePoint(codePoint); + index += length; + continue; + } catch {} + } + value += `\\${escape}`; + continue; + } + if (escape === "c" && index < source.length) { + value += String.fromCodePoint(source.codePointAt(index) & 31); + index += 1; + continue; + } + value += `\\${escape}`; + } + return null; +} + +export class Lexer { + constructor(source) { + this.source = source; + this.index = 0; + this.error = ""; + this.tokens = []; + this.pendingHeredocs = []; + this.expectHeredoc = null; + } + + tokenize() { + while (this.index < this.source.length && !this.error) { + const char = this.source[this.index]; + if (char === " " || char === "\t" || char === "\r") { + this.index += 1; + continue; + } + if (char === "#") { + this.skipComment(); + continue; + } + if (char === "\n") { + this.tokens.push({ type: "op", value: "newline" }); + this.index += 1; + if (this.pendingHeredocs.length > 0) this.skipHeredocBodies(); + continue; + } + const control = this.readControlOperator(); + if (control) { + this.tokens.push({ type: "op", value: control }); + continue; + } + const redirection = this.readRedirection(); + if (redirection) { + const token = { type: "redir", value: redirection.value, inlineTarget: redirection.inlineTarget, fd: redirection.fd }; + this.tokens.push(token); + if (redirection.value === "<<" || redirection.value === "<<-") this.expectHeredoc = { token, stripTabs: redirection.value === "<<-" }; + continue; + } + if (char === "(" || char === "{") { + const close = char === "(" ? ")" : "}"; + const balanced = extractBalanced(this.source, this.index + 1, char, close); + if (!balanced) { + this.error = `unclosed ${char}`; + break; + } + this.tokens.push({ type: "group", kind: char === "(" ? "subshell" : "brace", content: balanced.content }); + this.index = balanced.next; + continue; + } + const word = this.readWord(); + if (!word) { + this.error = `unsupported token at byte ${this.index}`; + break; + } + this.tokens.push(word); + if (this.expectHeredoc) { + this.pendingHeredocs.push({ delimiter: word.value, stripTabs: this.expectHeredoc.stripTabs, token: this.expectHeredoc.token }); + this.expectHeredoc = null; + } + } + if (this.expectHeredoc) this.error = "missing heredoc delimiter"; + return { tokens: this.tokens, error: this.error }; + } + + skipComment() { + while (this.index < this.source.length && this.source[this.index] !== "\n") this.index += 1; + } + + skipHeredocBodies() { + for (const heredoc of this.pendingHeredocs) { + let found = false; + let body = ""; + while (this.index < this.source.length) { + const end = this.source.indexOf("\n", this.index); + const lineEnd = end === -1 ? this.source.length : end; + const line = this.source.slice(this.index, lineEnd); + const comparable = heredoc.stripTabs ? line.replace(/^\t+/, "") : line; + this.index = end === -1 ? this.source.length : end + 1; + if (comparable === heredoc.delimiter) { + found = true; + break; + } + body += comparable; + if (end !== -1) body += "\n"; + } + if (!found) { + this.error = "unclosed heredoc"; + break; + } + heredoc.token.heredoc = body; + } + this.pendingHeredocs = []; + } + + readControlOperator() { + for (const operator of ["&&", "||", "|&", ";;", ";", "&", "|"]) { + if (this.source.startsWith(operator, this.index)) { + this.index += operator.length; + return operator; + } + } + return ""; + } + + readRedirection() { + const remaining = this.source.slice(this.index); + const match = remaining.match(/^(\d+)?(<<<|<<-|<<|>>|<>|>&|<&|>|<)(?:&?[0-9-]+)?/); + if (!match) return ""; + this.index += match[0].length; + const inlineTarget = /(?:>&|<&)[0-9-]+$/.test(match[0]); + let normalized = match[0].replace(/^\d+/, ""); + if (inlineTarget) normalized = normalized.replace(/[0-9-]+$/, ""); + const fd = match[1] === undefined ? (match[2].startsWith("<") ? 0 : 1) : Number(match[1]); + return { value: normalized, inlineTarget, fd }; + } + + readWord() { + const word = { type: "word", value: "", literal: true, subs: [], quoted: false, unquotedExpansion: false }; + let consumed = false; + while (this.index < this.source.length) { + const char = this.source[this.index]; + if (/\s/.test(char) || ";&|<>()".includes(char)) break; + if (char === "#" && !consumed) break; + consumed = true; + if (char === "'") { + word.quoted = true; + const end = this.source.indexOf("'", this.index + 1); + if (end === -1) { + this.error = "unclosed single quote"; + return null; + } + word.value += this.source.slice(this.index + 1, end); + this.index = end + 1; + continue; + } + if (char === '"') { + word.quoted = true; + if (!this.readDoubleQuoted(word)) return null; + continue; + } + if (char === "\\") { + if (this.index + 1 >= this.source.length) { + this.error = "trailing escape"; + return null; + } + if (this.source[this.index + 1] === "\n") { + this.index += 2; + continue; + } + word.value += this.source[this.index + 1]; + this.index += 2; + continue; + } + if (this.source.startsWith("$'", this.index)) { + const ansi = decodeAnsiCQuoted(this.source, this.index); + if (!ansi) { + this.error = "unclosed ANSI-C quote"; + return null; + } + word.quoted = true; + word.value += ansi.value; + this.index = ansi.next; + continue; + } + if (this.source.startsWith('$"', this.index)) { + word.quoted = true; + this.index += 1; + if (!this.readDoubleQuoted(word)) return null; + continue; + } + if (this.source.startsWith("$(", this.index)) { + const balanced = extractBalanced(this.source, this.index + 2, "(", ")"); + if (!balanced) { + this.error = "unclosed command substitution"; + return null; + } + word.subs.push({ kind: "command", content: balanced.content }); + word.literal = false; + this.index = balanced.next; + continue; + } + if ((char === "<" || char === ">") && this.source[this.index + 1] === "(") { + const balanced = extractBalanced(this.source, this.index + 2, "(", ")"); + if (!balanced) { + this.error = "unclosed process substitution"; + return null; + } + word.subs.push({ kind: "process", content: balanced.content }); + word.literal = false; + this.index = balanced.next; + continue; + } + if (char === "`") { + const backticks = extractBackticks(this.source, this.index + 1); + if (!backticks) { + this.error = "unclosed backtick substitution"; + return null; + } + word.subs.push({ kind: "command", content: backticks.content }); + word.literal = false; + this.index = backticks.next; + continue; + } + if (char === "$") word.literal = false; + if ("*?[]{}".includes(char)) word.unquotedExpansion = true; + word.value += char; + this.index += 1; + } + return consumed ? word : null; + } + + readDoubleQuoted(word) { + this.index += 1; + while (this.index < this.source.length) { + const char = this.source[this.index]; + if (char === '"') { + this.index += 1; + return true; + } + if (char === "\\") { + if (this.index + 1 >= this.source.length) break; + if (this.source[this.index + 1] === "\n") { + this.index += 2; + continue; + } + word.value += this.source[this.index + 1]; + this.index += 2; + continue; + } + if (this.source.startsWith("$(", this.index)) { + const balanced = extractBalanced(this.source, this.index + 2, "(", ")"); + if (!balanced) break; + word.subs.push({ kind: "command", content: balanced.content }); + word.literal = false; + this.index = balanced.next; + continue; + } + if (char === "`") { + const backticks = extractBackticks(this.source, this.index + 1); + if (!backticks) break; + word.subs.push({ kind: "command", content: backticks.content }); + word.literal = false; + this.index = backticks.next; + continue; + } + if (char === "$") word.literal = false; + word.value += char; + this.index += 1; + } + this.error = "unclosed double quote"; + return false; + } +} + +export function splitProgram(tokens) { + const nodes = []; + const separators = []; + let current = []; + for (const token of tokens) { + if (token.type === "op") { + if (current.length > 0) { + nodes.push(current); + current = []; + separators.push(token.value); + } else if (token.value !== "newline") { + separators.push(token.value); + } + continue; + } + current.push(token); + } + if (current.length > 0) nodes.push(current); + while (separators.length >= nodes.length && separators.at(-1) === "newline") separators.pop(); + return { nodes, separators }; +} + +function isAssignment(value) { + return /^[A-Za-z_][A-Za-z0-9_]*=/.test(value); +} + +function wordsInNode(tokens) { + const words = []; + let skipRedirectionTarget = false; + for (const token of tokens) { + if (token.type === "redir") { + skipRedirectionTarget = !token.inlineTarget; + continue; + } + if (skipRedirectionTarget && token.type === "word") { + skipRedirectionTarget = false; + continue; + } + if (token.type === "word") words.push(token); + } + return words; +} + +const WRAPPER_OPTIONS = { + command: { noArgument: new Set(["p", "v", "V"]), takesArgument: new Set() }, + env: { noArgument: new Set(["0", "i", "P", "v"]), takesArgument: new Set(["a", "C", "S", "u"]) }, + exec: { noArgument: new Set(["c", "l"]), takesArgument: new Set(["a"]) }, + nohup: { noArgument: new Set(), takesArgument: new Set() }, + sudo: { noArgument: new Set(["A", "B", "b", "E", "e", "H", "i", "K", "k", "l", "N", "n", "P", "S", "s", "v", "V"]), takesArgument: new Set(["C", "D", "g", "h", "p", "r", "R", "t", "T", "u", "U"]) }, + timeout: { noArgument: new Set(["f", "p", "v"]), takesArgument: new Set(["k", "s"]) }, +}; + +const WRAPPER_LONG_OPTIONS = { + command: { noArgument: new Set(["help", "version"]), takesArgument: new Set() }, + env: { noArgument: new Set(["ignore-environment", "null", "help", "version"]), takesArgument: new Set(["argv0", "block-signal", "chdir", "default-signal", "ignore-signal", "split-string", "unset"]) }, + exec: { noArgument: new Set(), takesArgument: new Set() }, + nohup: { noArgument: new Set(["help", "version"]), takesArgument: new Set() }, + sudo: { noArgument: new Set(["askpass", "background", "bell", "edit", "help", "login", "non-interactive", "preserve-env", "preserve-groups", "remove-timestamp", "reset-timestamp", "set-home", "shell", "stdin", "validate", "version"]), takesArgument: new Set(["chdir", "chroot", "close-from", "command-timeout", "group", "host", "other-user", "prompt", "role", "type", "user"]) }, + timeout: { noArgument: new Set(["foreground", "preserve-status", "verbose", "help", "version"]), takesArgument: new Set(["kill-after", "signal"]) }, +}; + +function consumeWrapperOptions(name, words, index) { + const optionOwner = name === "gtimeout" ? "timeout" : name; + const short = WRAPPER_OPTIONS[optionOwner]; + const long = WRAPPER_LONG_OPTIONS[optionOwner]; + const embeddedPayloads = []; + let next = index; + while (words[next]) { + const value = words[next].value; + if (value === "--") return { index: next + 1, unresolved: false, embeddedPayloads }; + if (!value.startsWith("-") || value === "-") return { index: next, unresolved: false, embeddedPayloads }; + if (value.startsWith("--")) { + const equals = value.indexOf("="); + const option = value.slice(2, equals === -1 ? undefined : equals); + if (long.noArgument.has(option)) { + next += 1; + continue; + } + if (!long.takesArgument.has(option)) return { index: next, unresolved: true, embeddedPayloads }; + if (equals !== -1) { + if (name === "env" && option === "split-string") embeddedPayloads.push(value.slice(equals + 1)); + next += 1; + continue; + } + if (!words[next + 1]) return { index: next, unresolved: true, embeddedPayloads }; + if (name === "env" && option === "split-string") embeddedPayloads.push(words[next + 1].value); + next += 2; + continue; + } + let consumedArgument = false; + for (let offset = 1; offset < value.length; offset += 1) { + const option = value[offset]; + if (short.noArgument.has(option)) continue; + if (!short.takesArgument.has(option)) return { index: next, unresolved: true, embeddedPayloads }; + if (offset + 1 === value.length) { + if (!words[next + 1]) return { index: next, unresolved: true, embeddedPayloads }; + if (name === "env" && option === "S") embeddedPayloads.push(words[next + 1].value); + next += 2; + } else { + if (name === "env" && option === "S") embeddedPayloads.push(value.slice(offset + 1)); + next += 1; + } + consumedArgument = true; + break; + } + if (!consumedArgument) next += 1; + } + return { index: next, unresolved: false, embeddedPayloads }; +} + +export function commandPosition(tokens) { + const words = wordsInNode(tokens); + let index = 0; + while (index < words.length && isAssignment(words[index].value)) index += 1; + const prefixAssignments = index; + const wrappers = []; + let unresolvedWrapperOption = false; + const wrapperPayloads = []; + let command = words[index]; + while (command) { + const name = basename(command.value); + if (name === "exec" || name === "command" || name === "sudo" || name === "nohup") { + wrappers.push(name); + const options = consumeWrapperOptions(name, words, index + 1); + unresolvedWrapperOption ||= options.unresolved; + wrapperPayloads.push(...options.embeddedPayloads); + index = options.index; + command = words[index]; + continue; + } + if (name === "env") { + wrappers.push(name); + const options = consumeWrapperOptions(name, words, index + 1); + unresolvedWrapperOption ||= options.unresolved; + wrapperPayloads.push(...options.embeddedPayloads); + index = options.index; + while (words[index] && (words[index].value.startsWith("-") || isAssignment(words[index].value))) index += 1; + command = words[index]; + continue; + } + if (name === "timeout" || name === "gtimeout") { + wrappers.push(name); + const options = consumeWrapperOptions(name, words, index + 1); + unresolvedWrapperOption ||= options.unresolved; + index = options.index; + if (!words[index]) { + unresolvedWrapperOption = true; + command = undefined; + break; + } + index += 1; + command = words[index]; + if (!command) { + unresolvedWrapperOption = true; + break; + } + continue; + } + break; + } + return { words, index, command, wrappers, prefixAssignments, unresolvedWrapperOption, wrapperPayloads }; +} + +const PROTECTED_SCRIPTS = [ + { relative: "bin/fm-watch-arm.sh", kind: "arm" }, + { relative: "bin/fm-watch.sh", kind: "watch" }, +]; + +function protectedIdentity(value, root) { + const normalized = path.normalize(value); + for (const { relative, kind } of PROTECTED_SCRIPTS) { + if (normalized === relative || normalized === path.join(root, relative) || normalized.endsWith(`/${relative}`)) return kind; + } + return ""; +} + +function hasUnclassifiableProtectedExpansion(word, root) { + if (!word?.unquotedExpansion || protectedIdentity(word.value, root)) return false; + return /(?:^|\/)fm-watch/.test(word.value); +} + +function shellInvocation(position) { + if (!position.command) return null; + const name = basename(position.command.value); + if (!["sh", "bash", "zsh"].includes(name)) return null; + const words = position.words; + for (let i = position.index + 1; i < words.length; i += 1) { + const option = words[i]; + if (/^-[A-Za-z]*c[A-Za-z]*$/.test(option.value)) { + let payloadIndex = i + 1; + if (words[payloadIndex]?.value === "--") payloadIndex += 1; + return { kind: "command", payload: words[payloadIndex] || null }; + } + if (/^[-+]O$/.test(option.value)) { + i += 1; + continue; + } + if (option.value === "--" || /^[-+]/.test(option.value)) continue; + return { kind: "script", payload: option }; + } + return { kind: "stdin", payload: null }; +} + +function shellHeredocPayloads(tokens, position) { + if (shellInvocation(position)?.kind !== "stdin") return []; + const heredocs = tokens.filter((token) => token.type === "redir" && token.fd === 0 && typeof token.heredoc === "string"); + return heredocs.length === 0 ? [] : [heredocs.at(-1).heredoc]; +} + +function shellHereStringPayloads(tokens, position) { + if (shellInvocation(position)?.kind !== "stdin") return []; + const payloads = []; + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token.type !== "redir" || token.value !== "<<<" || token.fd !== 0) continue; + const payload = tokens[i + 1]; + if (payload?.type === "word" && payload.literal && payload.subs.length === 0) payloads.push(payload.value); + } + return payloads; +} + +function sourcedScript(position) { + if (!position.command || ![".", "source"].includes(position.command.value)) return null; + return position.words[position.index + 1] || null; +} + +function evalPayload(position) { + if (!position.command || basename(position.command.value) !== "eval") return null; + const payloads = position.words.slice(position.index + 1); + if (payloads.length === 0 || payloads.some((payload) => !payload.literal || payload.subs.length > 0)) return null; + return payloads.map((payload) => payload.value).join(" "); +} + +function wordReferencesAny(word, names) { + if (!word || names.size === 0) return false; + for (const match of word.value.matchAll(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g)) { + if (names.has(match[1] || match[2])) return true; + } + return false; +} + +function hasDynamicExecutionPayload(position, context) { + if (!position.command) return false; + const name = basename(position.command.value); + if (["sh", "bash", "zsh"].includes(name)) { + for (let i = position.index + 1; i < position.words.length; i += 1) { + if (!/^-[A-Za-z]*c[A-Za-z]*$/.test(position.words[i].value)) continue; + const payload = position.words[i + 1]; + return Boolean(payload && (!payload.literal || payload.subs.length > 0) && wordReferencesAny(payload, context.protectedVariables)); + } + } + if (name === "eval") { + return position.words.slice(position.index + 1).some((payload) => (!payload.literal || payload.subs.length > 0) && wordReferencesAny(payload, context.protectedVariables)); + } + return false; +} + +function assignmentName(word) { + const match = word.value.match(/^([A-Za-z_][A-Za-z0-9_]*)=/); + return match ? match[1] : ""; +} + +function contextWithAssignments(context, words) { + const protectedVariables = new Set(context.protectedVariables || []); + const watcherPatterns = new Set(context.watcherPatterns || []); + const watcherPids = new Set(context.watcherPids || []); + for (const word of words) { + const name = assignmentName(word); + if (!name) continue; + const value = word.value.slice(word.value.indexOf("=") + 1); + if (rawMentionsProtected(value) || wordReferencesAny(word, protectedVariables)) protectedVariables.add(name); + else protectedVariables.delete(name); + if (/fm-watch/.test(value) || wordReferencesAny(word, watcherPatterns)) watcherPatterns.add(name); + else watcherPatterns.delete(name); + if (wordReferencesAny(word, watcherPids)) watcherPids.add(name); + else watcherPids.delete(name); + } + return { ...context, protectedVariables, watcherPatterns, watcherPids }; +} + +function nodeHasRedirection(tokens) { + return tokens.some((token) => token.type === "redir"); +} + +function nodeHasUnsafeSubstitution(tokens) { + return tokens.some((token) => token.type === "word" && token.subs.length > 0); +} + +function isWatcherPgrep(position, context) { + if (!position.command || basename(position.command.value) !== "pgrep") return false; + return position.words.slice(position.index + 1).some((word) => /(?:^|\/)fm-watch(?:\.sh)?\b/.test(word.value) || wordReferencesAny(word, context.watcherPatterns)); +} + +function analyzeProgram(command, context, depth = 0) { + if (depth > 12) { + return { error: "recursion limit", protectedFound: rawMentionsProtected(command), broadKill: rawMentionsBroadKill(command), pgrepWatcher: false, watcherPids: new Set() }; + } + const lexed = new Lexer(command).tokenize(); + if (lexed.error) { + return { error: lexed.error, protectedFound: rawMentionsProtected(command), broadKill: rawMentionsBroadKill(command), pgrepWatcher: false, watcherPids: new Set() }; + } + const program = splitProgram(lexed.tokens); + const nodeInfos = []; + let nestedProtected = false; + let broadKill = false; + let pgrepWatcher = false; + let unsupported = false; + let activeContext = { + ...context, + protectedVariables: new Set(context.protectedVariables || []), + watcherPatterns: new Set(context.watcherPatterns || []), + watcherPids: new Set(context.watcherPids || []), + }; + let unclassifiableProtected = false; + + for (const tokens of program.nodes) { + const position = commandPosition(tokens); + const nodeContext = contextWithAssignments(activeContext, position.words); + const firstName = basename(position.words[0]?.value || ""); + if (["if", "then", "else", "elif", "fi", "for", "while", "until", "case", "esac", "do", "done", "function", "time", "coproc"].includes(firstName)) { + unsupported = true; + } + + let nodeNestedProtected = false; + let nodePgrepWatcher = false; + const substitutionResults = new Map(); + for (const payload of position.wrapperPayloads) { + const nested = analyzeProgram(payload, nodeContext, depth + 1); + nodeNestedProtected ||= nested.protectedFound; + broadKill ||= nested.broadKill; + nodePgrepWatcher ||= nested.pgrepWatcher; + if (nested.error && rawMentionsProtected(payload)) unsupported = true; + } + for (const token of tokens) { + if (token.type === "group") { + const nested = analyzeProgram(token.content, nodeContext, depth + 1); + nodeNestedProtected ||= nested.protectedFound; + broadKill ||= nested.broadKill; + nodePgrepWatcher ||= nested.pgrepWatcher; + if (nested.error && rawMentionsProtected(token.content)) unsupported = true; + } + if (token.type === "word") { + for (const substitution of token.subs) { + const nested = analyzeProgram(substitution.content, nodeContext, depth + 1); + substitutionResults.set(substitution, nested); + nodeNestedProtected ||= nested.protectedFound; + broadKill ||= nested.broadKill; + nodePgrepWatcher ||= nested.pgrepWatcher; + if (nested.error && rawMentionsProtected(substitution.content)) unsupported = true; + } + } + } + + const shell = shellInvocation(position); + const shellPayload = shell?.kind === "command" ? shell.payload : null; + const shellScript = shell?.kind === "script" ? shell.payload : null; + const sourceScript = sourcedScript(position); + const literalEvalPayload = evalPayload(position); + const heredocPayloads = shellHeredocPayloads(tokens, position); + const hereStringPayloads = shellHereStringPayloads(tokens, position); + for (const script of [shellScript, sourceScript]) { + if (!script) continue; + nodeNestedProtected ||= Boolean(protectedIdentity(script.value, context.root)) || wordReferencesAny(script, nodeContext.protectedVariables); + unclassifiableProtected ||= hasUnclassifiableProtectedExpansion(script, context.root); + } + if (shellPayload && (!shellPayload.literal || shellPayload.subs.length > 0)) { + if (wordReferencesAny(shellPayload, nodeContext.protectedVariables)) nodeNestedProtected = true; + } else if (shellPayload) { + const nested = analyzeProgram(shellPayload.value, nodeContext, depth + 1); + nodeNestedProtected ||= nested.protectedFound; + broadKill ||= nested.broadKill; + nodePgrepWatcher ||= nested.pgrepWatcher; + if (nested.error && rawMentionsProtected(shellPayload.value)) unsupported = true; + } + for (const payload of [literalEvalPayload, ...heredocPayloads, ...hereStringPayloads]) { + if (payload === null) continue; + const nested = analyzeProgram(payload, nodeContext, depth + 1); + nodeNestedProtected ||= nested.protectedFound; + broadKill ||= nested.broadKill; + nodePgrepWatcher ||= nested.pgrepWatcher; + if (nested.error && rawMentionsProtected(payload)) unsupported = true; + } + + const executable = position.command?.value || ""; + const protectedKind = protectedIdentity(executable, context.root); + if (hasUnclassifiableProtectedExpansion(position.command, context.root)) unclassifiableProtected = true; + const commandName = basename(executable); + const args = position.words.slice(position.index + 1); + if (commandName === "pkill" && args.some((word) => /fm-watch/.test(word.value) || wordReferencesAny(word, nodeContext.watcherPatterns))) broadKill = true; + if (commandName === "kill" && (nodePgrepWatcher || args.some((word) => wordReferencesAny(word, nodeContext.watcherPids)))) broadKill = true; + if (isWatcherPgrep(position, nodeContext)) pgrepWatcher = true; + if (hasDynamicExecutionPayload(position, nodeContext) || wordReferencesAny(position.command, nodeContext.protectedVariables)) nodeNestedProtected = true; + for (const word of position.words) { + const name = assignmentName(word); + if (!name) continue; + if (word.subs.some((substitution) => substitutionResults.get(substitution)?.pgrepWatcher)) nodeContext.watcherPids.add(name); + } + pgrepWatcher ||= nodePgrepWatcher; + nestedProtected ||= nodeNestedProtected; + activeContext = nodeContext; + if (position.unresolvedWrapperOption) unsupported = true; + nodeInfos.push({ + tokens, + position, + protectedKind, + nestedProtected: nodeNestedProtected, + redirection: nodeHasRedirection(tokens), + substitution: nodeHasUnsafeSubstitution(tokens), + }); + } + + const directProtected = nodeInfos.some((info) => Boolean(info.protectedKind)); + const protectedFound = directProtected || nestedProtected || unclassifiableProtected; + if (unclassifiableProtected) unsupported = true; + const broadKillFound = broadKill || (unsupported && rawMentionsBroadKill(command)); + if (unsupported && (protectedFound || rawMentionsProtected(command) || broadKillFound)) { + return { error: "unsupported compound grammar", protectedFound: true, broadKill: broadKillFound, pgrepWatcher, watcherPids: activeContext.watcherPids, program, nodeInfos }; + } + return { error: "", protectedFound, directProtected, nestedProtected, broadKill: broadKillFound, pgrepWatcher, watcherPids: activeContext.watcherPids, program, nodeInfos }; +} + +function ordinaryWordsOnly(tokens) { + return tokens.every((token) => token.type === "word" && token.subs.length === 0); +} + +// Fork note (Claude-only): upstream also blesses `source config/x-mode.env` (and +// its `[ -f ... ]` guard) as a setup node before the arm, an X-mode startup form. +// This fork has no X-mode and no source-a-config-before-arming pattern, so that +// blessing is dropped - only bare `cd ` and `export VAR=val` setup nodes +// remain (tighter than upstream). The context param is retained for call parity. +// eslint-disable-next-line no-unused-vars +function setupKind(info, context) { + const { tokens, position } = info; + if (!ordinaryWordsOnly(tokens) || position.prefixAssignments > 0 || position.wrappers.length > 0) return ""; + const values = position.words.map((word) => word.value); + if (values[0] === "cd" && values.length === 2) return "cd"; + if (values[0] === "export" && values.length === 2 && isAssignment(values[1])) return "export"; + return ""; +} + +function finalProtectedAllowed(info) { + if (!info.protectedKind || info.protectedKind === "watch" || info.redirection || info.substitution) return false; + if (!ordinaryWordsOnly(info.tokens) || info.position.prefixAssignments > 0) return false; + const wrappers = info.position.wrappers; + return wrappers.length === 0 || (wrappers.length === 1 && wrappers[0] === "exec"); +} + +function blessedProgram(analysis, context) { + const { nodeInfos } = analysis; + const separators = analysis.program.separators; + if (nodeInfos.length === 0 || separators.some((separator) => ![";", "newline", "&&"].includes(separator))) return false; + if (!finalProtectedAllowed(nodeInfos.at(-1))) return false; + if (nodeInfos.slice(0, -1).some((info) => info.protectedKind || info.nestedProtected)) return false; + + const setup = nodeInfos.slice(0, -1).map((info) => setupKind(info, context)); + if (setup.some((kind) => !kind)) return false; + return true; +} + +function decision(command, root, home) { + const context = { root: path.normalize(root), home: path.normalize(home), protectedVariables: new Set(), watcherPatterns: new Set(), watcherPids: new Set() }; + const analysis = analyzeProgram(command, context); + if (analysis.broadKill) return deny("broad-watcher-kill"); + if (analysis.error && analysis.protectedFound) return deny("unclassifiable-protected-command"); + if (!analysis.protectedFound) return { decision: "allow" }; + if (analysis.nodeInfos?.some((info) => info.protectedKind === "watch")) return deny("watcher-direct"); + if (analysis.nestedProtected) return deny("watcher-nested"); + + const separators = analysis.program.separators; + if (separators.includes("&") || analysis.nodeInfos.some((info) => info.position.wrappers.includes("nohup")) || analysis.nodeInfos.some((info) => basename(info.position.words[0]?.value || "") === "disown")) { + return deny("watcher-background"); + } + if (separators.includes("|") || separators.includes("|&")) return deny("watcher-pipeline"); + if (analysis.nodeInfos.some((info) => info.redirection)) return deny("watcher-redirection"); + if (analysis.nodeInfos.some((info) => info.substitution)) return deny("watcher-nested"); + if (blessedProgram(analysis, context)) return { decision: "allow" }; + if (analysis.nodeInfos.some((info) => info.position.prefixAssignments > 0 || info.position.wrappers.some((wrapper) => wrapper !== "exec"))) { + return deny("watcher-nested"); + } + return deny("watcher-bundled"); +} + +function deny(code) { + return { decision: "deny", code, reason: REASONS[code] }; +} + +// Run the CLI only when invoked directly (node fm-arm-command-policy.mjs ...), +// never when imported by a sibling policy such as bin/fm-cd-command-policy.mjs. +function invokedDirectly() { + const entry = process.argv[1]; + if (!entry) return false; + const self = fileURLToPath(import.meta.url); + try { + return realpathSync(entry) === realpathSync(self); + } catch { + return entry === self; + } +} + +if (invokedDirectly()) { + try { + const args = parseArguments(process.argv.slice(2)); + if (!args.root || !args.home) throw new Error("--root and --home are required"); + if (!args.command) { + process.stdout.write("allow\n"); + } else { + const result = decision(args.command, args.root, args.home); + if (result.decision === "allow") { + process.stdout.write("allow\n"); + } else { + process.stdout.write(`deny\t${result.code}\t${result.reason}\n`); + } + } + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/bin/fm-arm-pretool-check.sh b/bin/fm-arm-pretool-check.sh new file mode 100755 index 00000000000..849a4f83969 --- /dev/null +++ b/bin/fm-arm-pretool-check.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# Claude PreToolUse transport for the watcher-arm command policy. +# +# A firstmate must arm the watcher as a standalone verified harness call, never +# bundled into a compound command, piped, redirected, backgrounded, nested in a +# wrapper/substitution, run directly as bin/fm-watch.sh, or broad-killed with +# `pkill -f fm-watch`. bin/fm-arm-command-policy.mjs is the sole owner of shell +# classification, the blessed setup tree, and deny reason codes. This wrapper +# only acquires the Claude PreToolUse payload, discovers the active roots, +# invokes that policy, and renders the deny response. It never executes, +# sources, evaluates, or expands the submitted command. +# See docs/arm-pretool-check.md for the complete contract and validation record. +# +# This fork is Claude-only, so the wrapper speaks only Claude's PreToolUse +# contract (upstream firstmate multiplexes Grok/Codex/OpenCode/Pi transports +# here, and also protects a Codex-only fm-watch-checkpoint.sh; neither applies). +# +# Usage: +# | bin/fm-arm-pretool-check.sh [--claude] +# bin/fm-arm-pretool-check.sh --command '' # CLI mode (tests) +# +# Stdin mode extracts .tool_input.command from the Claude PreToolUse payload. +# --claude is accepted for hook-command compatibility and is a no-op (Claude is +# the only rendering). --background is accepted for transport parity and is not +# itself a policy signal. +# +# Exit/output contract: +# ALLOW - exit 0 and no output. +# DENY - exit 2 with a Claude-shaped deny object on stderr (stdout stays empty, +# which Claude requires on deny). +# FAIL OPEN - malformed or empty stdin, missing jq for stdin transport, +# missing Node or policy owner, or an invalid policy response. +set -u + +CMD="" +CMD_SET=0 + +usage() { + cat <<'EOF' +Usage: fm-arm-pretool-check.sh [--command ] [--background true|false] [--claude] + +With no --command, reads a Claude PreToolUse JSON payload on stdin +(.tool_input.command). +Exits 0 to allow and 2 to deny. +The deny reason is written to stderr as a Claude PreToolUse deny object. +Malformed transport and an unavailable classifier runtime fail open. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --command) + [ "$#" -gt 1 ] || { echo "error: --command requires a value" >&2; exit 2; } + CMD=$2 + CMD_SET=1 + shift 2 + ;; + --command=*) + CMD=${1#--command=} + CMD_SET=1 + shift + ;; + --background) + # Accepted for transport parity; harness-native tracked background + # execution is not itself a policy signal. + [ "$#" -gt 1 ] || { echo "error: --background requires a value" >&2; exit 2; } + shift 2 + ;; + --background=*) + shift + ;; + --claude) + # Accepted for hook-command compatibility; Claude is the only rendering. + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [ "$CMD_SET" -eq 0 ]; then + PAYLOAD=$(cat 2>/dev/null || true) + [ -n "$PAYLOAD" ] || exit 0 + command -v jq >/dev/null 2>&1 || exit 0 + CMD=$(printf '%s' "$PAYLOAD" | jq -r '(.tool_input.command // empty)' 2>/dev/null) || exit 0 + [ -n "$CMD" ] || exit 0 +fi + +[ -n "$CMD" ] || exit 0 + +# Strict-superset prefilter (transport only; owns zero classification semantics). +# Every protected watcher execution and every broad watcher kill resolves to the +# fm-watch byte sequence AFTER the classifier's byte normalization, so a command +# that cannot contain fm-watch even after that normalization can never be a +# deniable watcher command and is fast-allowed without the Node policy owner. +# We mirror the classifier's cheapest byte transforms here (drop line- +# continuation and escape backslashes, quotes, and newlines) so obfuscated +# protected paths such as fm-watc\h-arm.sh or fm-"watch"-arm.sh still +# delegate. Stripping only these non-alphanumeric bytes can never destroy an +# existing fm-watch run. +# +# The fast path may allow ONLY when BOTH hold: (a) the stripped/normalized text +# lacks the fm-watch watcher substring, AND (b) the raw command carries no +# quoting-decoder marker - a $ immediately followed by a single quote (ANSI-C +# $'...') or a double quote (bash locale $"..."), both of which the classifier +# decodes and can therefore reconstruct fm-watch from bytes this cheap byte +# strip cannot. This marker set is COUPLED to the classifier's decoder set in +# bin/fm-arm-command-policy.mjs: adding any new quote/expansion form the +# classifier decodes REQUIRES extending this marker set in the same change, or +# the prefilter stops being a strict superset. Otherwise the command always +# delegates to the classifier - the single owner of every decision. +PREFILTER=$CMD +PREFILTER=${PREFILTER//\\/} +PREFILTER=${PREFILTER//\"/} +PREFILTER=${PREFILTER//\'/} +PREFILTER=${PREFILTER//$'\n'/} +PREFILTER=${PREFILTER//$'\r'/} +case "$CMD" in + *"\$'"*|*'$"'*) ;; + *) + case "$PREFILTER" in + *fm-watch*) ;; + *) exit 0 ;; + esac + ;; +esac + +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P) || exit 0 +ROOT=$(CDPATH='' cd -- "$SCRIPT_DIR/.." 2>/dev/null && pwd -P) || exit 0 +ACTIVE_HOME=${FM_HOME:-$ROOT} +POLICY="$ROOT/bin/fm-arm-command-policy.mjs" + +command -v node >/dev/null 2>&1 || exit 0 +[ -f "$POLICY" ] || exit 0 + +POLICY_OUTPUT=$(node "$POLICY" --command "$CMD" --root "$ROOT" --home "$ACTIVE_HOME" 2>/dev/null) || exit 0 +[ -n "$POLICY_OUTPUT" ] || exit 0 + +TAB=$(printf '\t') +DECISION=${POLICY_OUTPUT%%"$TAB"*} +[ "$DECISION" = "deny" ] || exit 0 +REST=${POLICY_OUTPUT#*"$TAB"} +[ "$REST" != "$POLICY_OUTPUT" ] || exit 0 +CODE=${REST%%"$TAB"*} +REASON=${REST#*"$TAB"} +[ -n "$CODE" ] && [ -n "$REASON" ] && [ "$REASON" != "$REST" ] || exit 0 + +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr '\n' ' ' +} + +DETAIL="[$CODE] $REASON" +ESCAPED=$(json_escape "$DETAIL") +printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"},"systemMessage":"%s"}\n' "$ESCAPED" >&2 +exit 2 diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh new file mode 100755 index 00000000000..433d14d64bd --- /dev/null +++ b/bin/fm-bearings-snapshot.sh @@ -0,0 +1,395 @@ +#!/usr/bin/env bash +# fm-bearings-snapshot.sh - compact, bounded, TOON-by-default bearings projection. +# +# A thin wrapper OVER the canonical bin/fm-fleet-snapshot.sh. It does not parse +# fleet state itself: it shells out to `fm-fleet-snapshot.sh --json`, projects that +# complete structured contract down to the small set of fields a "pick up where I +# left off" read needs, and renders TOON at the output boundary. The internal data +# model stays JSON (`--json` prints it verbatim); TOON is the default agent-facing +# format per the AXI standard, and TOON/JSON are parity representations of the same +# projected model. The projection is view-specific: it DROPS fields from the bearings +# output, it never removes them from - or otherwise weakens - the canonical snapshot, +# which stays complete. +# +# LOCAL-ONLY by default: a normal invocation makes ZERO GitHub/network/auth calls. +# It MAY surface PR URLs already recorded locally in task meta (recorded_prs), but it +# performs no live discovery or checks. Live PR discovery/checks happen ONLY under +# --include-prs, which is the sole path that touches the network; all gh coupling +# lives in that branch and never in the canonical snapshot. The default output states +# explicitly (the prs: line and the omitted[] surfaces) what was not requested, so an +# absence is never ambiguous. +# +# This wrapper consumes the canonical snapshot's hints.open_decisions field. +# fm-classify-lib.sh owns the durable keyed-decision contract. +# +# The landed section merges this home's Done with the canonical snapshot's +# secondmate_landed roll-up (fm-fleet-snapshot.sh), so merges a secondmate managed - +# recorded in ITS OWN backlog, never the main one - are visible. It stays bounded by +# a per-home cap and an overall cap, with omitted[] disclosure of both and of any +# secondmate home whose backlog was unreadable; no GitHub/network call is involved. +# +# Flags: +# (default) compact projection, TOON, local-only +# --json the same projected model as JSON (machine/debug; parity form) +# --include-prs ALSO do live open-PR discovery + checks (the only network path) +# --fields opt in to dropped surfaces: bodies,paths,actions,endpoints +# --all-in-flight include every in-flight task +# --all-decisions include every open decision +# --all-landed include every landed record from every home (default: bounded) +# --all-reports include the full scout-report inventory (default: relevant only) +# --all-queued include superseded/held queued items (default: dropped) +# --all-recorded-prs include every locally recorded PR +# --all-unhealthy include every unhealthy endpoint +# --all-pr-repos query every discovered repository under --include-prs +# -h,--help usage +# +# Output contract: `fm-bearings.v1`. Read-only; no locks, no mutation, no reports. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FLEET="$SCRIPT_DIR/fm-fleet-snapshot.sh" + +# Bounds (overridable for tests / large fleets). +FM_BEARINGS_LANDED=${FM_BEARINGS_LANDED:-6} +FM_BEARINGS_LANDED_PER_HOME=${FM_BEARINGS_LANDED_PER_HOME:-$FM_BEARINGS_LANDED} +FM_BEARINGS_IN_FLIGHT=${FM_BEARINGS_IN_FLIGHT:-20} +FM_BEARINGS_DECISIONS=${FM_BEARINGS_DECISIONS:-20} +FM_BEARINGS_GATES=${FM_BEARINGS_GATES:-20} +FM_BEARINGS_REPORTS=${FM_BEARINGS_REPORTS:-20} +FM_BEARINGS_RECORDED_PRS=${FM_BEARINGS_RECORDED_PRS:-20} +FM_BEARINGS_UNHEALTHY=${FM_BEARINGS_UNHEALTHY:-20} +FM_BEARINGS_PR_REPOS=${FM_BEARINGS_PR_REPOS:-10} +FM_BEARINGS_PR_LIMIT=${FM_BEARINGS_PR_LIMIT:-20} +FM_BEARINGS_PR_TIMEOUT=${FM_BEARINGS_PR_TIMEOUT:-20} +case "$FM_BEARINGS_PR_TIMEOUT" in ''|*[!0-9]*|0) FM_BEARINGS_PR_TIMEOUT=20 ;; esac +validate_bound() { # + case "$2" in ''|*[!0-9]*|0) echo "fm-bearings-snapshot: $1 must be a positive integer" >&2; exit 2 ;; esac +} +validate_bound FM_BEARINGS_LANDED "$FM_BEARINGS_LANDED" +validate_bound FM_BEARINGS_LANDED_PER_HOME "$FM_BEARINGS_LANDED_PER_HOME" +validate_bound FM_BEARINGS_IN_FLIGHT "$FM_BEARINGS_IN_FLIGHT" +validate_bound FM_BEARINGS_DECISIONS "$FM_BEARINGS_DECISIONS" +validate_bound FM_BEARINGS_GATES "$FM_BEARINGS_GATES" +validate_bound FM_BEARINGS_REPORTS "$FM_BEARINGS_REPORTS" +validate_bound FM_BEARINGS_RECORDED_PRS "$FM_BEARINGS_RECORDED_PRS" +validate_bound FM_BEARINGS_UNHEALTHY "$FM_BEARINGS_UNHEALTHY" +validate_bound FM_BEARINGS_PR_REPOS "$FM_BEARINGS_PR_REPOS" +validate_bound FM_BEARINGS_PR_LIMIT "$FM_BEARINGS_PR_LIMIT" + +usage() { + cat <<'EOF' +usage: fm-bearings-snapshot.sh [--json] [--include-prs] [--fields ] + [--all-in-flight] [--all-decisions] [--all-landed] + [--all-reports] [--all-queued] + [--all-recorded-prs] [--all-unhealthy] + [--all-pr-repos] + +Compact bearings projection over fm-fleet-snapshot.sh. TOON by default. +Default is LOCAL-ONLY (no network); --include-prs is the only path that fetches. + +Default fields: schema, home, generated, prs, in_flight{id,kind,state,doing}, + decisions_open{id,key,verb,summary}, landed{id,what,artifact}, + gates{id,title,blocked_by,reason}, reports{id,path}, recorded_prs{id,url}, + unhealthy_endpoints{...} (only when non-empty), omitted{surface,reveal}. +landed merges this home's Done with registered secondmate homes' Done, bounded by + a per-home cap (FM_BEARINGS_LANDED_PER_HOME) and an overall cap (FM_BEARINGS_LANDED), + with omitted[] disclosure; --all-landed reveals the full set. +Opt-in surfaces: --fields bodies|paths|actions|endpoints, --all-in-flight, + --all-decisions, --all-landed, --all-reports, --all-queued, --all-recorded-prs, + --all-unhealthy, --all-pr-repos, --include-prs (adds candidate_prs). +Raise FM_BEARINGS_PR_LIMIT to expand per-repository open-PR results. +EOF +} + +FORMAT=toon +INCLUDE_PRS=0 +ALL_REPORTS=0 +ALL_QUEUED=0 +ALL_IN_FLIGHT=0 +ALL_DECISIONS=0 +ALL_LANDED=0 +ALL_RECORDED_PRS=0 +ALL_UNHEALTHY=0 +ALL_PR_REPOS=0 +FIELDS="" +while [ $# -gt 0 ]; do + case "$1" in + --json) FORMAT=json ;; + --include-prs) INCLUDE_PRS=1 ;; + --all-reports) ALL_REPORTS=1 ;; + --all-queued) ALL_QUEUED=1 ;; + --all-in-flight) ALL_IN_FLIGHT=1 ;; + --all-decisions) ALL_DECISIONS=1 ;; + --all-landed) ALL_LANDED=1 ;; + --all-recorded-prs) ALL_RECORDED_PRS=1 ;; + --all-unhealthy) ALL_UNHEALTHY=1 ;; + --all-pr-repos) ALL_PR_REPOS=1 ;; + --fields) shift; FIELDS=${1:-} ;; + --fields=*) FIELDS=${1#--fields=} ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac + shift +done + +command -v jq >/dev/null 2>&1 || { echo "fm-bearings-snapshot: jq not found" >&2; exit 1; } + +if [ "$ALL_LANDED" = 1 ]; then + SNAP=$(FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=0 "$FLEET" --json) || exit $? +else + SNAP=$("$FLEET" --json) || exit $? +fi +HOME_LABEL=$(printf '%s' "$SNAP" | jq -er '.fm_home | strings | split("/") | (.[-2:] | join("/"))') \ + || { echo "fm-bearings-snapshot: invalid canonical snapshot" >&2; exit 1; } +NOW=${FM_BEARINGS_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} + +# --- optional live PR enrichment (the ONLY network path) -------------------- +PR_STATUS='not_requested (run: /bearings include PRs)' +CANDIDATE_PRS='[]' +PR_REPOS_TOTAL=0 +PR_REPOS_SHOWN=0 +PR_ROWS_CAPPED=0 +PR_ROWS_MIN_TOTAL=0 + +# Parse owner/repo from an https or ssh GitHub remote/PR URL; empty if not GitHub. +repo_slug() { # + printf '%s' "$1" | sed -n 's#.*github\.com[:/]\([^/]*/[^/]*\)#\1#p' | sed 's#\.git$##; s#/pull/.*$##; s#/$##' +} + +# Bounded gh call; prints stdout, non-zero on timeout/failure. gh only. +gh_bounded() { # + if command -v timeout >/dev/null 2>&1; then + GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 timeout "$FM_BEARINGS_PR_TIMEOUT" gh "$@" + elif command -v gtimeout >/dev/null 2>&1; then + GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 gtimeout "$FM_BEARINGS_PR_TIMEOUT" gh "$@" + elif command -v perl >/dev/null 2>&1; then + GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 perl -e 'my $t = shift; my $pid = fork; die "fork failed" unless defined $pid; if (!$pid) { setpgrp(0, 0); exec @ARGV } local $SIG{ALRM} = sub { kill "TERM", -$pid; select undef, undef, undef, 0.2; kill "KILL", -$pid; exit 124 }; alarm $t; waitpid $pid, 0; exit($? >> 8)' "$FM_BEARINGS_PR_TIMEOUT" gh "$@" + else + return 124 + fi +} + +if [ "$INCLUDE_PRS" = 1 ]; then + if ! command -v gh >/dev/null 2>&1; then + PR_STATUS='unavailable (gh not found)' + else + # Candidate repos: recorded pr= URLs plus live worktree origins. Deduped. + repos="" + while IFS= read -r u; do + [ -n "$u" ] || continue + s=$(repo_slug "$u"); [ -n "$s" ] || continue + case " $repos " in *" $s "*) : ;; *) repos="$repos $s" ;; esac + done </dev/null) || continue + s=$(repo_slug "$u"); [ -n "$s" ] || continue + case " $repos " in *" $s "*) : ;; *) repos="$repos $s" ;; esac + done </dev/null) \ + || { nwarn=$((nwarn + 1)); continue; } + [ -n "$out" ] || out='[]' + repo_result=$(printf '%s' "$out" | jq --arg repo "$repo" --argjson limit "$FM_BEARINGS_PR_LIMIT" ' + [ .[] | { + num:(.number|tostring), + repo:$repo, + task:(if (.headRefName // "" | startswith("fm/")) then (.headRefName | ltrimstr("fm/")) else "-" end), + url:(.url // "-"), + review:(.reviewDecision // "none"), + mergeable:(.mergeable // "UNKNOWN"), + checks:( + (.statusCheckRollup // []) as $c + | if ($c|length) == 0 then "none" + elif any($c[]; (.conclusion // .state // "") as $s | ($s=="FAILURE" or $s=="ERROR" or $s=="TIMED_OUT" or $s=="CANCELLED" or $s=="ACTION_REQUIRED")) then "failing" + elif any($c[]; ((.status // "") != "COMPLETED") and ((.state // "") != "SUCCESS")) then "pending" + else "passing" end) + } ] as $rows | {returned:($rows | length), rows:$rows[:$limit]}') || { nwarn=$((nwarn + 1)); continue; } + returned=$(printf '%s' "$repo_result" | jq '.returned') + repo_rows=$(printf '%s' "$repo_result" | jq '.rows') + cnt=$(printf '%s' "$repo_rows" | jq 'length') + [ "$returned" -gt "$FM_BEARINGS_PR_LIMIT" ] && ncapped=$((ncapped + 1)) + npr=$((npr + cnt)) + rows=$(jq -n --argjson a "$rows" --argjson b "$repo_rows" '$a + $b') + done + PR_REPOS_SHOWN=$nrepos + PR_ROWS_CAPPED=$ncapped + PR_ROWS_MIN_TOTAL=$((npr + ncapped)) + CANDIDATE_PRS=$rows + warnnote="" + [ "$nwarn" -gt 0 ] && warnnote="; ${nwarn} repo(s) unavailable" + cappednote="" + [ "$ncapped" -gt 0 ] && cappednote="; ${npr} shown, at least ${PR_ROWS_MIN_TOTAL} open; capped in ${ncapped} repo(s)" + if [ "$ncapped" -gt 0 ]; then + PR_STATUS="checked (${nrepos} repos${cappednote}${warnnote})" + else + PR_STATUS="checked (${nrepos} repos, ${npr} open${warnnote})" + fi + fi +fi + +# --- projection: canonical snapshot -> fm-bearings.v1 model (JSON) ---------- +MODEL=$(printf '%s' "$SNAP" | jq \ + --arg home "$HOME_LABEL" \ + --arg now "$NOW" \ + --arg prs "$PR_STATUS" \ + --arg fields "$FIELDS" \ + --argjson landed_n "$FM_BEARINGS_LANDED" \ + --argjson landed_per_home_n "$FM_BEARINGS_LANDED_PER_HOME" \ + --argjson in_flight_n "$FM_BEARINGS_IN_FLIGHT" \ + --argjson decisions_n "$FM_BEARINGS_DECISIONS" \ + --argjson gates_n "$FM_BEARINGS_GATES" \ + --argjson reports_n "$FM_BEARINGS_REPORTS" \ + --argjson recorded_prs_n "$FM_BEARINGS_RECORDED_PRS" \ + --argjson unhealthy_n "$FM_BEARINGS_UNHEALTHY" \ + --argjson include_prs "$INCLUDE_PRS" \ + --argjson all_in_flight "$ALL_IN_FLIGHT" \ + --argjson all_decisions "$ALL_DECISIONS" \ + --argjson all_landed "$ALL_LANDED" \ + --argjson all_reports "$ALL_REPORTS" \ + --argjson all_queued "$ALL_QUEUED" \ + --argjson all_recorded_prs "$ALL_RECORDED_PRS" \ + --argjson all_unhealthy "$ALL_UNHEALTHY" \ + --argjson pr_repos_total "$PR_REPOS_TOTAL" \ + --argjson pr_repos_shown "$PR_REPOS_SHOWN" \ + --argjson pr_rows_capped "$PR_ROWS_CAPPED" \ + --argjson pr_rows_min_total "$PR_ROWS_MIN_TOTAL" \ + --argjson candidate_prs "$CANDIDATE_PRS" ' + def trunc($n): if . == null then null else + (tostring | gsub("\\s+"; " ") | if (length > $n) then (.[:$n] + "…") else . end) end; + ($fields | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(. != ""))) as $fl + | (($fl | index("bodies")) != null) as $f_bodies + | (($fl | index("paths")) != null) as $f_paths + | (($fl | index("actions")) != null) as $f_actions + | (($fl | index("endpoints")) != null) as $f_endpoints + | ([ .backlog.records[] | select(.state == "done" and .structured) + | {id, title, pr_url, report_path, local_note, completion, home:"(main)", home_id:"(main)"} ]) as $main_done + | ((.secondmate_landed.records) // []) as $mate_done + | ($main_done + $mate_done) as $all_landed_rows + | ([ $all_landed_rows | group_by(.home_id)[] + | sort_by([(.completion.date // ""), .id]) | reverse + | (if $all_landed == 1 then . else .[:$landed_per_home_n] end) ] | add // []) as $per_home_capped + | ([ $all_landed_rows | group_by(.home_id)[] | select(length > $landed_per_home_n) ] | length) as $home_cap_dropped + | ($per_home_capped | sort_by([(.completion.date // ""), .id]) | reverse) as $landed_sorted + | (if $all_landed == 1 then $landed_sorted else $landed_sorted[:$landed_n] end) as $done + | ($done | map(.id)) as $done_ids + | (.tasks | map(.id)) as $live_ids + | ($live_ids + $done_ids) as $rel_ids + | ([ .tasks[] + | select(.endpoint.exists == false or .endpoint.agent_alive == "dead") + | {id, backend, target:(.endpoint.target // "-"), exists:.endpoint.exists, agent:.endpoint.agent_alive} ]) as $unhealthy_all + | ([ .tasks[] | { + id, kind, + state: .current_state.state, + doing: ((.current_state.detail // "") as $d + | (if $d != "" then $d else (.hints.last_event_text // "") end) | trunc(90)) + } ]) as $in_flight_all + | ([ .tasks[] as $t | ($t.hints.open_decisions // [])[] + | {id:$t.id, key, verb, summary:(.summary | trunc(90))} ]) as $decisions_all + | ([ .backlog.records[] + | select(.state == "queued" and .structured) + | select(($all_queued == 1) + or (((.body_excerpt // "") | test("SUPERSEDED|NOT REQUIRED|NOT-REQUIRED|DEFERRED"; "i")) | not)) + | {id, title:(.title | trunc(60)), blocked_by:(.blocked_by // "-"), + reason:((.blocked_reason // "-") | trunc(40))} ]) as $gates_all + | ([ .scout_reports[] + | . as $r + | select(($all_reports == 1) or (($rel_ids | index($r.id)) != null)) + | {id, path} ]) as $reports_all + | ([ .tasks[] | select(.pr.url != null and .pr.source == "meta") | {id, url:.pr.url} ]) as $recorded_prs_all + | . as $snap + | { + schema: "fm-bearings.v1", + home: $home, + generated: $now, + prs: $prs, + in_flight: (if $all_in_flight == 1 then $in_flight_all else $in_flight_all[:$in_flight_n] end), + decisions_open: (if $all_decisions == 1 then $decisions_all else $decisions_all[:$decisions_n] end), + landed: ($done | map({id, what:(.title | trunc(70)), + artifact:(.pr_url // .report_path // .local_note // "-")})), + gates: (if $all_queued == 1 then $gates_all else $gates_all[:$gates_n] end), + reports: (if $all_reports == 1 then $reports_all else $reports_all[:$reports_n] end), + recorded_prs: (if $all_recorded_prs == 1 then $recorded_prs_all else $recorded_prs_all[:$recorded_prs_n] end) + } + | . + (if ($unhealthy_all | length) > 0 then + {unhealthy_endpoints:(if $all_unhealthy == 1 then $unhealthy_all else $unhealthy_all[:$unhealthy_n] end)} + else {} end) + | . + (if $include_prs == 1 then {candidate_prs:$candidate_prs} else {} end) + | . + (if $f_bodies then {bodies:[ $snap.backlog.records[] | select(.structured and (.state == "queued" or .state == "done")) | {id, body:((.body_excerpt // .raw // "-") | trunc(200))} ]} else {} end) + | . + (if $f_paths then {paths:[ $snap.tasks[] | {id, worktree:(.paths.worktree.path // "-"), home:(.paths.home.path // "-"), status:.paths.status_log.path, report:.paths.report.path} ]} else {} end) + | . + (if $f_actions then {actions:[ $snap.tasks[] | {id, watch:(.actions.watch // .actions.send // "-"), steer:(.actions.steer // .actions.send // "-")} ]} else {} end) + | . + (if $f_endpoints then {endpoints:[ $snap.tasks[] | {id, backend, target:(.endpoint.target // "-"), exists:.endpoint.exists, agent:.endpoint.agent_alive} ]} else {} end) + | . + {omitted: ( + [ (if $f_bodies then empty else {surface:"backlog item bodies", reveal:"--fields bodies"} end), + (if $f_paths then empty else {surface:"task paths", reveal:"--fields paths"} end), + (if $f_actions then empty else {surface:"watch/steer actions", reveal:"--fields actions"} end), + (if $f_endpoints then empty else {surface:"healthy endpoint detail", reveal:"--fields endpoints"} end), + (if $all_reports == 1 then empty else {surface:"full scout-report inventory", reveal:"--all-reports"} end), + (if $all_queued == 1 then empty else {surface:"superseded/held queued items", reveal:"--all-queued"} end), + (if $all_landed == 0 and ($per_home_capped | length) > ($done | length) then {surface:("landed showing \($done | length) of \($per_home_capped | length)" + (($done | map(.home_id) | unique | map(select(. != "(main)")) | length) as $k | if $k > 0 then " (incl. \($k) secondmate home(s))" else "" end)), reveal:"--all-landed"} else empty end), + (if $all_landed == 0 and $home_cap_dropped > 0 then {surface:("landed per-home capped at \($landed_per_home_n) for \($home_cap_dropped) home(s)"), reveal:"--all-landed"} else empty end), + (if (($snap.secondmate_landed.unreadable // []) | length) > 0 then {surface:("secondmate home(s) with unreadable backlog: \(($snap.secondmate_landed.unreadable // []) | length)"), reveal:"inspect the listed secondmate home backlogs"} else empty end), + (if $all_landed == 0 and (($snap.secondmate_landed.truncated // []) | length) > 0 then {surface:("secondmate home Done capped at the snapshot layer for \(($snap.secondmate_landed.truncated // []) | length) home(s)"), reveal:"--all-landed"} else empty end), + (if $all_in_flight == 0 and ($in_flight_all | length) > $in_flight_n then {surface:("in_flight showing \($in_flight_n) of \($in_flight_all | length)"), reveal:"--all-in-flight"} else empty end), + (if $all_decisions == 0 and ($decisions_all | length) > $decisions_n then {surface:("decisions_open showing \($decisions_n) of \($decisions_all | length)"), reveal:"--all-decisions"} else empty end), + (if $all_queued == 0 and ($gates_all | length) > $gates_n then {surface:("gates showing \($gates_n) of \($gates_all | length)"), reveal:"--all-queued"} else empty end), + (if $all_reports == 0 and ($reports_all | length) > $reports_n then {surface:("reports showing \($reports_n) of \($reports_all | length)"), reveal:"--all-reports"} else empty end), + (if $all_recorded_prs == 0 and ($recorded_prs_all | length) > $recorded_prs_n then {surface:("recorded_prs showing \($recorded_prs_n) of \($recorded_prs_all | length)"), reveal:"--all-recorded-prs"} else empty end), + (if $all_unhealthy == 0 and ($unhealthy_all | length) > $unhealthy_n then {surface:("unhealthy_endpoints showing \($unhealthy_n) of \($unhealthy_all | length)"), reveal:"--all-unhealthy"} else empty end), + (if $include_prs == 1 and $pr_repos_total > $pr_repos_shown then {surface:("PR repositories showing \($pr_repos_shown) of \($pr_repos_total)"), reveal:"--all-pr-repos"} else empty end), + (if $include_prs == 1 and $pr_rows_capped > 0 then {surface:("candidate_prs showing \($candidate_prs | length) of at least \($pr_rows_min_total); capped in \($pr_rows_capped) repo(s)"), reveal:"raise FM_BEARINGS_PR_LIMIT"} else empty end), + (if $include_prs == 1 then empty else {surface:"live PR discovery + checks", reveal:"--include-prs"} end) ]) } +') || { echo "fm-bearings-snapshot: projection failed" >&2; exit 1; } + +if [ "$FORMAT" = json ]; then + printf '%s\n' "$MODEL" + exit 0 +fi + +# --- TOON renderer (output boundary; parity with the JSON model) ------------ +# The model is a flat object of scalar fields plus arrays of uniform scalar +# objects, so the encoder only needs object scalars, the tabular array form +# (key[N]{fields}: + comma rows at +2 indent), and the empty-array form (key: []), +# per the TOON spec. Quoting follows the spec exactly. +TOON=$(printf '%s\n' "$MODEL" | jq -r ' + def q: + tostring + | if (. == "") + or test("^\\s|\\s$") + or (. == "true" or . == "false" or . == "null") + or test("^-?[0-9]+(\\.[0-9]+)?([eE][+-]?[0-9]+)?$") + or test("[:\"\\\\\\[\\]{},]") + or test("[[:cntrl:]]") + or test("^-") + then "\"" + (gsub("\\\\"; "\\\\") | gsub("\""; "\\\"") | gsub("\n"; "\\n") | gsub("\r"; "\\r") | gsub("\t"; "\\t")) + "\"" + else . end; + def scal: + if . == null then "null" + elif type == "boolean" then (if . then "true" else "false" end) + elif type == "number" then tostring + else q end; + def emit($k; $v): + if ($v | type) == "array" then + if ($v | length) == 0 then "\($k): []" + else + ($v[0] | keys_unsorted) as $ks + | ( "\($k)[\($v | length)]{\($ks | map(q) | join(","))}:", + ($v[] as $row | " " + ([ $ks[] as $kk | ($row[$kk] | scal) ] | join(","))) ) + end + else "\($k): " + ($v | scal) + end; + [ to_entries[] | emit(.key; .value) ] | join("\n") +') || { echo "fm-bearings-snapshot: TOON rendering failed" >&2; exit 1; } +printf '%s\n' "$TOON" diff --git a/bin/fm-cd-command-policy.mjs b/bin/fm-cd-command-policy.mjs new file mode 100755 index 00000000000..41adcc612bf --- /dev/null +++ b/bin/fm-cd-command-policy.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +// Semantic policy for the cd-guard: does a shell command persistently change the +// PRIMARY firstmate shell's own working directory? +// +// A stray persistent top-level `cd projects/` silently relocates the +// primary shell, so the next firstmate-owned command (a backlog write, an +// fm-* lifecycle call, tasks-axi) runs inside a project clone instead of the +// home. This policy blocks exactly that class of command; the environmental +// scoping to the real primary checkout lives in the bin/fm-cd-pretool-check.sh +// transport, not here. See docs/cd-guard.md for the full contract. +// +// The shell tokenizer and command-position analysis are imported from +// bin/fm-arm-command-policy.mjs, the sole owner of firstmate's shell +// classification, so this guard never duplicates shell lexing. This policy +// never evaluates, expands, sources, or runs any byte of the submitted command; +// it inspects lexical command positions only. + +import { Lexer, splitProgram, commandPosition } from "./fm-arm-command-policy.mjs"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const REASONS = { + "persistent-cd": + "a persistent top-level directory change in the primary firstmate checkout is blocked; it would move the shell out of the home so a later firstmate-owned command runs inside a project clone. Reach the target without moving the shell - use git -C or an absolute path on the command itself - or scope the cd to a subshell like (cd && ...).", +}; + +// Directory-changing builtins that mutate the calling shell's own cwd. +const CD_BUILTINS = new Set(["cd", "pushd", "popd"]); + +// Wrappers that fork or exec a child before reaching the builtin, so a cd behind +// them never persists to the parent shell (and generally just fails, since cd is +// a builtin with no external program). `command` is deliberately NOT here: it +// runs the builtin in the current shell, so `command cd x` still persists. +const FORKING_WRAPPERS = new Set(["env", "sudo", "nohup", "timeout", "gtimeout", "exec"]); + +function isPipe(separator) { + return separator === "|" || separator === "|&"; +} + +// A top-level command-list node persists its cwd change to the parent shell +// unless it runs in a subshell context: backgrounded with a trailing `&`, or a +// stage of a pipeline (bash runs every pipeline stage in a subshell). +function nodePersists(separators, index) { + if (separators[index] === "&") return false; + if (isPipe(separators[index]) || isPipe(separators[index - 1])) return false; + return true; +} + +function deny(code) { + return { decision: "deny", code, reason: REASONS[code] }; +} + +function hasPathQualifiedCommandPrefix(position) { + return position.words + .slice(position.prefixAssignments, position.index) + .some((word) => word.value.includes("/") && word.value.split("/").at(-1) === "command"); +} + +function hasCommandQueryPrefix(position) { + let commandPrefix = false; + for (const word of position.words.slice(position.prefixAssignments, position.index)) { + if (word.value === "command") { + commandPrefix = true; + continue; + } + if (commandPrefix && /^-[^-]*[vV]/.test(word.value)) return true; + } + return false; +} + +function decision(command) { + const lexed = new Lexer(command).tokenize(); + // Fail open on syntax this classifier cannot tokenize. The cd-guard's threat + // model is agent mistakes - an accidental bare `cd projects/foo` always + // tokenizes - so we prioritize zero false blocks over catching malformed or + // deliberately obfuscated bypasses, which stay out of scope by design. + if (lexed.error) return { decision: "allow" }; + + const { nodes, separators } = splitProgram(lexed.tokens); + for (let index = 0; index < nodes.length; index += 1) { + if (!nodePersists(separators, index)) continue; + // commandPosition ignores subshell/brace groups, quoted data, comments, and + // substitutions (they contribute no top-level command word), and skips + // leading assignments and wrappers to find the executed command word. + const position = commandPosition(nodes[index]); + if (hasPathQualifiedCommandPrefix(position)) continue; + if (hasCommandQueryPrefix(position)) continue; + let command = position.command; + let wordIndex = position.index; + while (command && (command.value === "builtin" || command.value === "command")) { + wordIndex += 1; + command = position.words[wordIndex]; + } + if (!command) continue; + if (!CD_BUILTINS.has(command.value)) continue; + if (position.wrappers.some((wrapper) => FORKING_WRAPPERS.has(wrapper))) continue; + return deny("persistent-cd"); + } + return { decision: "allow" }; +} + +function parseArguments(argv) { + const result = { command: "", commandSet: false }; + for (let i = 0; i < argv.length; i += 1) { + const name = argv[i]; + if (name === "--command") { + if (i + 1 >= argv.length) throw new Error("--command requires a value"); + result.command = argv[i + 1]; + result.commandSet = true; + i += 1; + continue; + } + if (name.startsWith("--command=")) { + result.command = name.slice("--command=".length); + result.commandSet = true; + continue; + } + throw new Error(`unknown argument: ${name}`); + } + return result; +} + +function invokedDirectly() { + const entry = process.argv[1]; + if (!entry) return false; + const self = fileURLToPath(import.meta.url); + try { + return realpathSync(entry) === realpathSync(self); + } catch { + return entry === self; + } +} + +if (invokedDirectly()) { + try { + const args = parseArguments(process.argv.slice(2)); + if (!args.commandSet || !args.command) { + process.stdout.write("allow\n"); + } else { + const result = decision(args.command); + if (result.decision === "allow") { + process.stdout.write("allow\n"); + } else { + process.stdout.write(`deny\t${result.code}\t${result.reason}\n`); + } + } + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} + +export { decision }; diff --git a/bin/fm-cd-pretool-check.sh b/bin/fm-cd-pretool-check.sh new file mode 100755 index 00000000000..e4e0d322030 --- /dev/null +++ b/bin/fm-cd-pretool-check.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# Claude PreToolUse transport for the cd-guard command policy. +# +# A stray persistent top-level `cd projects/` in the PRIMARY firstmate +# shell silently relocates the shell, so a later firstmate-owned command (a +# backlog write, an fm-* lifecycle call, tasks-axi) runs inside a project clone +# instead of the home. This seatbelt denies such a command before it runs. +# bin/fm-cd-command-policy.mjs is the sole owner of the block/allow decision; it +# reuses the shell classifier owned by bin/fm-arm-command-policy.mjs. This +# wrapper only scopes the guard to the real primary checkout, acquires the +# Claude PreToolUse payload, invokes that policy, and renders the deny response. +# It never executes, sources, evaluates, or expands the command. +# See docs/cd-guard.md for the complete contract and validation record. +# +# This fork is Claude-only, so the wrapper speaks only Claude's PreToolUse +# contract (upstream firstmate multiplexes Grok/Codex/OpenCode/Pi transports +# here; none of those harnesses run in this fork). +# +# Usage: +# | bin/fm-cd-pretool-check.sh [--claude] +# bin/fm-cd-pretool-check.sh --command '' # CLI mode (tests) +# +# Stdin mode extracts .tool_input.command from the Claude PreToolUse payload. +# --claude is accepted for hook-command compatibility and is a no-op (Claude is +# the only rendering). +# +# Exit/output contract: +# ALLOW - exit 0 and no output. +# DENY - exit 2 with a Claude-shaped deny object on stderr (stdout stays empty, +# which Claude requires on deny). +# INERT - not the real primary checkout (a crewmate/scout task worktree or a +# non-firstmate repo): exit 0 with no output, exactly like ALLOW. +# FAIL OPEN - malformed or empty stdin, missing jq for stdin transport, +# missing Node or policy owner, or an invalid policy response. +set -u + +CMD="" +CMD_SET=0 + +usage() { + cat <<'EOF' +Usage: fm-cd-pretool-check.sh [--command ] [--claude] + +With no --command, reads a Claude PreToolUse JSON payload on stdin +(.tool_input.command). +Fires only in the real primary firstmate checkout; it is a silent no-op in a +crewmate/scout task worktree or any non-firstmate repo. +Exits 0 to allow and 2 to deny a persistent top-level cwd change. +The deny reason is written to stderr as a Claude PreToolUse deny object. +Malformed transport and an unavailable classifier runtime fail open. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --command) + [ "$#" -gt 1 ] || { echo "error: --command requires a value" >&2; exit 2; } + CMD=$2 + CMD_SET=1 + shift 2 + ;; + --command=*) + CMD=${1#--command=} + CMD_SET=1 + shift + ;; + --claude) + # Accepted for hook-command compatibility; Claude is the only rendering. + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [ "$CMD_SET" -eq 0 ]; then + PAYLOAD=$(cat 2>/dev/null || true) + [ -n "$PAYLOAD" ] || exit 0 + command -v jq >/dev/null 2>&1 || exit 0 + CMD=$(printf '%s' "$PAYLOAD" | jq -r '(.tool_input.command // empty)' 2>/dev/null) || exit 0 +fi + +[ -n "$CMD" ] || exit 0 + +# Strict-superset prefilter (transport only; owns zero classification +# semantics). Strip syntax bytes that the classifier joins within a shell word +# before looking for cd/pushd/popd, so ordinary quoted or escaped fragments +# cannot hide a deniable cwd change from the policy owner. A quoting-decoder +# marker - a $ immediately followed by a single quote (ANSI-C $'...') or a double +# quote (bash locale $"...") - delegates too, because the classifier decodes +# those and can reconstruct cd from bytes this substring test cannot see. This +# marker set is COUPLED to the classifier's decoder set in +# bin/fm-arm-command-policy.mjs: adding any new quote/expansion form the +# classifier decodes REQUIRES extending it here in the same change, or the +# prefilter stops being a strict superset. Deliberate deeper obfuscation is out +# of scope by the same agent-mistake threat model the policy uses. +PREFILTER=$CMD +PREFILTER=${PREFILTER//\\/} +PREFILTER=${PREFILTER//\"/} +PREFILTER=${PREFILTER//\'/} +PREFILTER=${PREFILTER//$'\n'/} +PREFILTER=${PREFILTER//$'\r'/} +case "$CMD" in + *"\$'"*|*'$"'*) ;; + *) + case "$PREFILTER" in + *cd*|*pushd*|*popd*) ;; + *) exit 0 ;; + esac + ;; +esac + +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)} || exit 0 + +# Scope to a plain, non-worktree firstmate checkout, where git-dir equals +# git-common-dir. A crewmate/scout task worktree - the shape bin/fm-spawn.sh +# always hands out - is a linked git worktree where the two differ. This guard +# does not inspect .fm-secondmate-home, so it applies in a git-cloned secondmate +# home but remains inert when the secondmate home is itself a linked worktree. +# Any failure to confirm the checkout is inert (exit 0), never a block, so a +# broken environment never denies a shell command. +[ -f "$FM_ROOT/AGENTS.md" ] || exit 0 +[ -d "$FM_ROOT/bin" ] || exit 0 +command -v git >/dev/null 2>&1 || exit 0 +GIT_DIR=$(git -C "$FM_ROOT" rev-parse --git-dir 2>/dev/null) || exit 0 +GIT_COMMON_DIR=$(git -C "$FM_ROOT" rev-parse --git-common-dir 2>/dev/null) || exit 0 +[ "$GIT_DIR" = "$GIT_COMMON_DIR" ] || exit 0 + +POLICY="$FM_ROOT/bin/fm-cd-command-policy.mjs" +command -v node >/dev/null 2>&1 || exit 0 +[ -f "$POLICY" ] || exit 0 + +POLICY_OUTPUT=$(node "$POLICY" --command "$CMD" 2>/dev/null) || exit 0 +[ -n "$POLICY_OUTPUT" ] || exit 0 + +TAB=$(printf '\t') +DECISION=${POLICY_OUTPUT%%"$TAB"*} +[ "$DECISION" = "deny" ] || exit 0 +REST=${POLICY_OUTPUT#*"$TAB"} +[ "$REST" != "$POLICY_OUTPUT" ] || exit 0 +CODE=${REST%%"$TAB"*} +REASON=${REST#*"$TAB"} +[ -n "$CODE" ] && [ -n "$REASON" ] && [ "$REASON" != "$REST" ] || exit 0 + +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr '\n' ' ' +} + +DETAIL="[$CODE] $REASON" +ESCAPED=$(json_escape "$DETAIL") +printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"},"systemMessage":"%s"}\n' "$ESCAPED" >&2 +exit 2 diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh new file mode 100755 index 00000000000..f06bff14da9 --- /dev/null +++ b/bin/fm-fleet-snapshot.sh @@ -0,0 +1,552 @@ +#!/usr/bin/env bash +# fm-fleet-snapshot.sh - read-only structured fleet snapshot. +# +# Output contract: `--json` prints one object with schema +# `fm-fleet-snapshot.v1`. +# The command is read-only: it does not acquire the session lock, drain wakes, +# arm watchers, mutate backlog state, or write reports. +# +# Top-level fields: +# schema: stable schema id. +# fm_home: resolved operational home. +# roots: resolved root/config/data/state/projects directories. +# backlog: {path,present,records[]} where records are ordered as written in +# data/backlog.md and cover In flight, Queued, and Done. +# Canonical tasks-axi rows are structured; free-form non-empty lines in +# those sections are preserved as unstructured records. +# tasks[]: one row per state/.meta, sorted by id. +# current_state is parsed from bin/fm-crew-state.sh and preserves +# state, source, detail, and raw line separately. +# paths.status_log.last_event is historical wake-event data only, never +# current state. +# hints.open_decisions is the keyed open-decision set returned by +# fm-classify-lib.sh's authoritative status_open_decisions fold and reconciled +# against current_state; hints.pending_decision and hints.blocked_event are +# booleans derived from that set. +# endpoint.exists is the cheap backend endpoint-presence read. +# endpoint.agent_alive is populated for secondmates only, where it is useful +# return-channel supervision data; other tasks use "not_checked". +# scout_reports[]: present data//report.md pointers. +# secondmate_landed: {records[],truncated[],unreadable[]} - a bounded, read-only +# roll-up of DONE backlog records from this home's registered secondmate homes, +# so landed-work views see merges a secondmate managed (recorded in ITS OWN +# backlog, not the main one). Per-home count is capped; homes with an existing +# but unparseable backlog are disclosed in unreadable[]. Home paths come from the +# one secondmate-home enumerator (meta home= with data/secondmates.md fallback). +# secondmate_guidance: return-channel action note for renderers and bearings. +# +# Compatibility: JSON is the primary machine-readable surface. +# Human views must render this output instead of parsing state files again. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" +PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" +BACKLOG="$DATA/backlog.md" + +# The fork's fm-backend.sh is a CLI (it dispatches on $@ when sourced), and its +# fm_backend_* abstraction does not exist here, so the snapshot uses the herdr +# primitives directly instead of sourcing it. +# shellcheck source=bin/fm-herdr-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-herdr-lib.sh" # fm_herdr_pane_exists for the endpoint-liveness read +# shellcheck source=bin/fm-classify-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-classify-lib.sh" +# shellcheck source=bin/fm-ff-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-ff-lib.sh" # live_secondmate_meta_records: the one secondmate-home enumerator + +usage() { + cat <<'EOF' +usage: fm-fleet-snapshot.sh --json + +Print a read-only structured snapshot of the firstmate fleet. +JSON is the stable machine-readable output contract. +EOF +} + +case "${1:---json}" in + --json) ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; +esac + +command -v jq >/dev/null 2>&1 || { echo "fm-fleet-snapshot: jq not found" >&2; exit 1; } + +bool_json() { + if [ "$1" = 1 ]; then printf 'true'; else printf 'false'; fi +} + +path_present_json() { # + local present=0 + [ -e "$1" ] && present=1 + jq -n --arg path "$1" --argjson present "$(bool_json "$present")" \ + '{path:$path,present:$present}' +} + +meta_value() { # + grep "^$2=" "$1" 2>/dev/null | tail -1 | cut -d= -f2- || true +} + +# Fork-native status parsing. Upstream ships these in fm-classify-lib.sh; this +# fork's classify-lib does not, and the fork's crewmates do not emit [key=...] +# decision markers, so open decisions are tracked simply as the last unresolved +# needs-decision/blocked line, cleared by a later resolving verb. +status_line_verb() { # -> leading verb word + local v=${1%%:*} + v=${v#"${v%%[![:space:]]*}"} + v=${v%"${v##*[![:space:]]}"} + printf '%s' "$v" +} +status_line_note() { # -> text after the first colon, trimmed + case "$1" in + *:*) local n=${1#*:}; printf '%s' "${n#"${n%%[![:space:]]*}"}" ;; + *) printf '%s' "$1" ;; + esac +} +status_open_decisions() { # -> TSV(key,verb,note) of the open decision, if any + local f=$1 line verb last_verb='' last_note='' + [ -f "$f" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + case "${line//[[:space:]]/}" in '') continue ;; esac + verb=$(status_line_verb "$line") + case "$verb" in + needs-decision|blocked) last_verb=$verb; last_note=$(status_line_note "$line") ;; + done|failed|working|checks-passed|passed|checks|running|fixing|ci) last_verb=''; last_note='' ;; + esac + done < "$f" + [ -n "$last_verb" ] && printf '%s\t%s\t%s\n' "$last_note" "$last_verb" "$last_note" +} + +last_nonempty_line() { # + [ -f "$1" ] || return 1 + grep -v '^[[:space:]]*$' "$1" 2>/dev/null | tail -1 +} + +crew_state_json() { # + local id=$1 raw rest state source detail sep + raw=$( + FM_ROOT_OVERRIDE="$FM_ROOT" \ + FM_HOME="$FM_HOME" \ + FM_STATE_OVERRIDE="$STATE" \ + FM_DATA_OVERRIDE="$DATA" \ + FM_PROJECTS_OVERRIDE="$PROJECTS" \ + FM_CONFIG_OVERRIDE="$CONFIG" \ + "$SCRIPT_DIR/fm-crew-state.sh" "$id" 2>/dev/null || true + ) + raw=$(printf '%s\n' "$raw" | head -1) + sep=' · ' + state=unknown + source=none + detail= + case "$raw" in + state:\ *"$sep"source:\ *) + rest=${raw#state: } + state=${rest%%"$sep"source: *} + rest=${rest#*"$sep"source: } + case "$rest" in + *"$sep"*) source=${rest%%"$sep"*}; detail=${rest#*"$sep"} ;; + *) source=$rest ;; + esac + ;; + esac + jq -n --arg raw "$raw" --arg state "$state" --arg source "$source" --arg detail "$detail" \ + '{state:$state,source:$source,detail:$detail,raw:$raw}' +} + +status_event_json() { # + local log=$1 present=0 raw='' verb='' note='' + if [ -f "$log" ]; then + present=1 + raw=$(last_nonempty_line "$log" || true) + verb=$(status_line_verb "$raw") + note=$(status_line_note "$raw") + fi + jq -n \ + --arg path "$log" \ + --arg raw "$raw" \ + --arg verb "$verb" \ + --arg note "$note" \ + --argjson present "$(bool_json "$present")" \ + '{path:$path,present:$present,kind:"event_history",last_event:{state:$verb,note:$note,raw:$raw}}' +} + +first_pr_url_in_file() { # + [ -f "$1" ] || return 1 + grep -Eo 'https?://[^[:space:])"]+/pull/[0-9]+' "$1" 2>/dev/null | head -1 +} + +backlog_json() { # [] - defaults to this home's $BACKLOG + local backlog=${1:-$BACKLOG} + if [ ! -f "$backlog" ]; then + jq -n --arg path "$backlog" '{path:$path,present:false,records:[]}' + return 0 + fi + + # shellcheck disable=SC2094 + jq -Rn --arg path "$backlog" ' + def trim: gsub("^[[:space:]]+|[[:space:]]+$"; ""); + def section_state: + if . == "In flight" then "in_flight" + elif . == "Queued" then "queued" + elif . == "Done" then "done" + else null end; + def cap($rest; $re): + (((($rest | capture($re)?) // {}) | .v) // null) as $v + | if $v == null then null else ($v | trim) end; + def metadata($rest; $key): + cap($rest; ".*(?:\\(|,[[:space:]]*)" + $key + ":[[:space:]]*(?[^,)]*)"); + def metadata_word($rest; $key): + cap($rest; ".*(?:\\(|,[[:space:]]*)" + $key + "[[:space:]]+(?[^,)]*)"); + def url_pattern: "https?://[^[:space:])\"<>]+"; + def wrapped_url_pattern: "?"; + def links($rest): [$rest | scan(url_pattern)]; + def strip_trailing_metadata: + reduce range(0; 20) as $_ (.; + sub("[[:space:]]*\\([[:space:]]*(?:(?:repo|kind|priority):[[:space:]]*[^)]*|(?:since|merged|reported|done)[[:space:]]+[^)]*)[[:space:]]*\\)[[:space:]]*$"; "")); + def strip_title_artifacts: + sub("[[:space:]]+-[[:space:]]+data/[^[:space:])]+/report\\.md$"; "") + | sub("[[:space:]]+data/[^[:space:])]+/report\\.md$"; "") + | sub("[[:space:]]+-[[:space:]]+local main$"; "") + | sub("[[:space:]]+local main$"; "") + | sub("[[:space:]]+-[[:space:]]*$"; ""); + def clean_title: + strip_trailing_metadata + | strip_title_artifacts + | gsub("[[:space:]]+"; " ") + | trim; + def title_of($rest): + $rest + | gsub(wrapped_url_pattern; "") + | sub("[[:space:]]*blocked-by:[[:space:]]+[^[:space:])]+[[:space:]]+-[[:space:]]+.*$"; "") + | gsub("[[:space:]]*blocked-by:[[:space:]]+[^[:space:]]+"; "") + | clean_title; + def blocked_reason($rest): + cap($rest; ".*blocked-by:[[:space:]]*[^[:space:])]+[[:space:]]+-[[:space:]]*(?.*)$") as $reason + | if $reason == null then null + else ($reason | clean_title | if . == "" then null else . end) + end; + def local_note($rest): + cap(($rest | strip_trailing_metadata); ".*(?:^|[[:space:]]+-[[:space:]]+|[[:space:]])(?local main)$"); + def completion($rest): + (metadata_word($rest; "merged")) as $merged + | (metadata_word($rest; "reported")) as $reported + | (metadata_word($rest; "done")) as $done + | if $merged != null then {verb:"merged",date:$merged} + elif $reported != null then {verb:"reported",date:$reported} + elif $done != null then {verb:"done",date:$done} + else {verb:null,date:null} end; + def row_match($line): + (($line | capture("^[-*][[:space:]]+\\[(?[ xX])\\][[:space:]]+(?[^[:space:]]+)[[:space:]]+-[[:space:]]+(?.*)$")?) // + (($line | capture("^[-*][[:space:]]+\\*\\*(?[^*]+)\\*\\*[[:space:]]+-[[:space:]]+(?.*)$")?) + | if . == null then null else . + {check:" "} end)); + def structured_row($line): + ($line | test("^[-*][[:space:]]+\\[[ xX]\\][[:space:]]+[^[:space:]]+[[:space:]]+-[[:space:]]+")) + or ($line | test("^[-*][[:space:]]+\\*\\*[^*]+\\*\\*[[:space:]]+-[[:space:]]+")); + def parse_row($line; $section; $order): + row_match($line) as $m + | if $m == null then + {order:$order,state:$section,structured:false,id:null,raw:$line,body_lines:[],body_excerpt:null} + else + ($m.rest) as $rest + | {order:$order, + state:$section, + structured:true, + id:($m.id | trim), + checked:($m.check | test("[xX]")), + title:title_of($rest), + repo:metadata($rest; "repo"), + kind:metadata($rest; "kind"), + priority:metadata($rest; "priority"), + blocked_by:cap($rest; ".*blocked-by:[[:space:]]*(?[^[:space:])]+).*"), + blocked_reason:blocked_reason($rest), + since:metadata_word($rest; "since"), + merged:metadata_word($rest; "merged"), + reported:metadata_word($rest; "reported"), + done:metadata_word($rest; "done"), + completion:completion($rest), + links:links($rest), + pr_url:((links($rest) | map(select(test("/pull/[0-9]+"))) | .[0]) // null), + report_path:cap($rest; ".*(?data/[^[:space:])]+/report\\.md).*"), + local_note:local_note($rest), + raw:$line, + body_lines:[], + body_excerpt:null} + end; + reduce inputs as $line + ({path:$path,present:true,records:[],section:null,order:0}; + if ($line | test("^##[[:space:]]+")) then + .section = (($line | sub("^##[[:space:]]+";"") | trim) | section_state) + elif .section == null or ($line | trim) == "" then + . + elif structured_row($line) then + .order += 1 + | .records += [parse_row($line; .section; .order)] + elif ((.records | length) > 0 and (.records[-1].structured == true) and ($line | test("^[[:space:]]+"))) then + ($line | trim) as $body + | if $body == "" then . + else .records[-1].body_lines += [$body] end + else + .order += 1 + | .records += [{order:.order,state:.section,structured:false,id:null,raw:$line,body_lines:[],body_excerpt:null}] + end) + | .records |= map( + if (.body_lines | length) > 0 then + .body_excerpt = ((.body_lines | join(" "))[:240]) + else . end) + | del(.section,.order) + ' < "$backlog" +} + +task_json_lines() { + local meta id kind harness mode yolo project worktree home projects backend target status_log report_path + local pr pr_source event_json current_json endpoint_exists agent_alive meta_json status_json report_json worktree_json home_json + local last_event_raw current_state current_source pending_decision blocked_event report_present=0 pr_from_status + local open_decisions_tsv open_decisions_json + + for meta in "$STATE"/*.meta; do + [ -e "$meta" ] || continue + id=$(basename "$meta" .meta) + kind=$(meta_value "$meta" kind) + [ -n "$kind" ] || kind=ship + harness=$(meta_value "$meta" harness) + mode=$(meta_value "$meta" mode) + yolo=$(meta_value "$meta" yolo) + project=$(meta_value "$meta" project) + worktree=$(meta_value "$meta" worktree) + home=$(meta_value "$meta" home) + projects=$(meta_value "$meta" projects) + backend=herdr + target=$(meta_value "$meta" handle) + status_log="$STATE/$id.status" + report_path="$DATA/$id/report.md" + pr=$(meta_value "$meta" pr) + pr_source=meta + if [ -z "$pr" ]; then + pr_from_status=$(first_pr_url_in_file "$status_log" || true) + pr=$pr_from_status + pr_source=status_event + fi + if [ -z "$pr" ]; then + pr_source=absent + fi + + current_json=$(crew_state_json "$id") + event_json=$(status_event_json "$status_log") + last_event_raw=$(printf '%s' "$event_json" | jq -r '.last_event.raw // ""') + current_state=$(printf '%s' "$current_json" | jq -r '.state // ""') + current_source=$(printf '%s' "$current_json" | jq -r '.source // ""') + + # Durable keyed open-decision set: fold the WHOLE status stream + # (fm-classify-lib.sh's status_open_decisions) so a later unrelated event can + # never mask a still-open captain decision. The set is derived purely from the + # keyed fold - never from report bodies or decision-like prose - and then + # reconciled against the crew LIFECYCLE, which only clears a stale decision the + # crew has provably moved past. Two lifecycle signals clear it, neither of which + # reads any report content: + # - a live activity read (run-step or busy pane) that is working/done, so a + # crew that resumed past a gate is not still reported as parked; and + # - a TERMINAL done/failed state on a single-owner task (scout or ship), whose + # deliverable is its report or PR, so a COMPLETED scout surfaces only as a + # report POINTER, never as a reopened pending decision. + # Secondmates are excluded from lifecycle clearing: they are persistent and + # multiplex many concerns onto one stream, so activity on one concern must + # never clear another concern's keyed decision. A parked/blocked state, or a + # non-authoritative status-log/none read on a still-live task, keeps the fold's + # open decision surfacing. + open_decisions_tsv=$(status_open_decisions "$status_log") + if [ "$kind" != secondmate ] && \ + { { { [ "$current_source" = run-step ] || [ "$current_source" = pane ]; } \ + && [ "$current_state" != parked ] && [ "$current_state" != blocked ]; } \ + || { [ "$current_state" = "done" ] || [ "$current_state" = "failed" ]; }; }; then + open_decisions_tsv="" + fi + open_decisions_json=$(printf '%s' "$open_decisions_tsv" | jq -R -s ' + [ splits("\n") | select(length > 0) + | (capture("^(?[^\t]*)\t(?[^\t]*)\t(?.*)$")?) + | select(. != null) ]') + pending_decision=$(printf '%s' "$open_decisions_json" | jq 'if any(.[]; .verb == "needs-decision") then 1 else 0 end') + blocked_event=$(printf '%s' "$open_decisions_json" | jq 'if any(.[]; .verb == "blocked") then 1 else 0 end') + + endpoint_exists=null + if [ -n "$target" ]; then + if fm_herdr_pane_exists "$target" >/dev/null 2>&1; then + endpoint_exists=true + else + endpoint_exists=false + fi + fi + agent_alive=not_checked + if [ "$kind" = secondmate ] && [ -n "$target" ]; then + if fm_herdr_pane_exists "$target" >/dev/null 2>&1; then agent_alive=alive; else agent_alive=dead; fi + fi + + [ -f "$report_path" ] && report_present=1 || report_present=0 + meta_json=$(path_present_json "$meta") + status_json=$event_json + report_json=$(path_present_json "$report_path") + if [ -n "$worktree" ]; then worktree_json=$(path_present_json "$worktree"); else worktree_json=$(jq -n '{path:null,present:false}'); fi + if [ -n "$home" ]; then home_json=$(path_present_json "$home"); else home_json=$(jq -n '{path:null,present:false}'); fi + + jq -n \ + --arg id "$id" \ + --arg kind "$kind" \ + --arg harness "$harness" \ + --arg mode "$mode" \ + --arg yolo "$yolo" \ + --arg project "$project" \ + --arg worktree "$worktree" \ + --arg home "$home" \ + --arg projects "$projects" \ + --arg backend "$backend" \ + --arg target "$target" \ + --arg pr "$pr" \ + --arg pr_source "$pr_source" \ + --arg agent_alive "$agent_alive" \ + --arg last_event_raw "$last_event_raw" \ + --argjson current_state "$current_json" \ + --argjson meta_path "$meta_json" \ + --argjson status_log "$status_json" \ + --argjson report "$report_json" \ + --argjson worktree_path "$worktree_json" \ + --argjson home_path "$home_json" \ + --argjson endpoint_exists "$endpoint_exists" \ + --argjson open_decisions "$open_decisions_json" \ + --argjson pending_decision "$(bool_json "$pending_decision")" \ + --argjson blocked_event "$(bool_json "$blocked_event")" \ + --argjson report_present "$(bool_json "$report_present")" \ + '{ + id:$id, + kind:$kind, + harness:($harness // ""), + mode:($mode // ""), + yolo:($yolo // ""), + project:($project // ""), + backend:$backend, + paths:{ + meta:$meta_path, + status_log:$status_log, + worktree:$worktree_path, + home:$home_path, + report:$report + }, + secondmate_projects:($projects | if . == "" then [] else split(",") | map(gsub("^[[:space:]]+|[[:space:]]+$"; "")) | map(select(. != "")) end), + current_state:$current_state, + endpoint:{target:($target | if . == "" then null else . end),exists:$endpoint_exists,agent_alive:$agent_alive}, + pr:{url:($pr | if . == "" then null else . end),source:$pr_source}, + hints:{ + pending_decision:$pending_decision, + blocked_event:$blocked_event, + open_decisions:$open_decisions, + scout_report_present:$report_present, + last_event_text:$last_event_raw + }, + actions:( + if $kind == "secondmate" then + {send:"bin/fm-send.sh fm-\($id) \u0027\u0027", + watch:"read status/doc return channel; do not routinely fm-peek a secondmate for answers", + return_channel_note:"Secondmate answers come back through status/doc paths after a marked fm-send request."} + else + {watch:"bin/fm-peek.sh fm-\($id)", + steer:"bin/fm-send.sh fm-\($id) \u0027\u0027", + return_channel_note:null} + end) + }' + done | jq -s 'sort_by(.id)' +} + +# Bounded, deterministic roll-up of DONE records from this home's registered +# secondmate homes. A merge a secondmate managed is recorded in ITS OWN backlog, +# never the main one, so landed-work views miss it without this. Reuses the single +# backlog parser (backlog_json) against each home's data/backlog.md - a pure Markdown +# read, no per-task crew-state and no network - and the one secondmate-home enumerator +# (fm-ff-lib.sh's live_secondmate_meta_records: meta home= with data/secondmates.md +# fallback). Per-home Done is capped here by default so the canonical snapshot stays +# bounded; a cap of 0 explicitly lifts that bound for an expanding caller. Bearings +# applies its own tighter view caps and omitted[] disclosure. A home with no backlog +# file yet contributes nothing and is NOT flagged +# (a fresh secondmate is normal); only an existing backlog that fails to parse is +# reported unreadable. Records are sorted most-recent-first by completion date, id. +FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=${FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME:-10} +case "$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" in ''|*[!0-9]*) FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=10 ;; esac +secondmate_landed_json() { + local reg="$DATA/secondmates.md" id home backlog bj rows n + local records='[]' truncated='[]' unreadable='[]' + while IFS='|' read -r id home _; do + [ -n "$id" ] || continue + [ -n "$home" ] || continue + backlog="$home/data/backlog.md" + [ -f "$backlog" ] || continue + bj=$(backlog_json "$backlog") \ + || { unreadable=$(jq -n --argjson a "$unreadable" --arg h "$home" '$a + [$h]'); continue; } + rows=$(printf '%s' "$bj" | jq --arg home "$home" --arg id "$id" ' + [ .records[] | select(.state == "done" and .structured) + | {id, title, pr_url, report_path, local_note, completion, home:$home, home_id:$id} ] + | sort_by([(.completion.date // ""), .id]) | reverse') \ + || { unreadable=$(jq -n --argjson a "$unreadable" --arg h "$home" '$a + [$h]'); continue; } + n=$(printf '%s' "$rows" | jq 'length') + if [ "$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" -gt 0 ] \ + && [ "$n" -gt "$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" ]; then + truncated=$(jq -n --argjson a "$truncated" --arg h "$home" '$a + [$h]') + fi + records=$(jq -n --argjson a "$records" --argjson b "$rows" \ + --argjson cap "$FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME" \ + '$a + (if $cap == 0 then $b else $b[:$cap] end)') + done <&2; exit 2 ;; +esac + +command -v jq >/dev/null 2>&1 || { echo "fm-fleet-view: jq not found" >&2; exit 1; } + +SNAPSHOT=$("$SCRIPT_DIR/fm-fleet-snapshot.sh" --json) || exit $? + +printf '%s\n' "$SNAPSHOT" | jq -r ' + def dash($v): if $v == null or $v == "" then "-" else $v end; + def endpoint_exists($t): + if $t.endpoint.exists == null then "unknown" + elif $t.endpoint.exists then "present" + else "absent" end; + def endpoint_of($t): + if $t.kind == "secondmate" then "\(endpoint_exists($t)) / \($t.endpoint.agent_alive)" + else endpoint_exists($t) end; + def artifact($t): + if $t.pr.url != null then $t.pr.url + elif $t.paths.report.present then $t.paths.report.path + else "-" end; + def path_of($t): + if $t.paths.home.present then $t.paths.home.path + elif $t.paths.home.path != null then $t.paths.home.path + " (absent)" + elif $t.paths.worktree.present then $t.paths.worktree.path + elif $t.paths.worktree.path != null then $t.paths.worktree.path + " (absent)" + else "-" end; + def action_of($t): + if $t.kind == "secondmate" then "\($t.actions.send) - \($t.actions.watch)" + else $t.actions.watch end; + def task_row($t): + "| \($t.id) | \($t.current_state.state) / \($t.current_state.source) | \($t.kind) | \(dash($t.backlog.repo // $t.project)) | \($t.backend) | \(endpoint_of($t)) | \(artifact($t)) | \(path_of($t)) | \(action_of($t)) |"; + def blocker($r): + if ($r.blocked_by // "") == "" then "-" + elif ($r.blocked_reason // "") == "" then $r.blocked_by + else "\($r.blocked_by) - \($r.blocked_reason)" end; + def backlog_row($r): + "| \($r.id // "-") | \(dash($r.title // $r.raw)) | \(dash($r.repo)) | \(dash($r.kind)) | \(blocker($r)) | \(dash($r.pr_url // $r.report_path // $r.local_note)) |"; + + "# Fleet View", + "", + "Schema: \(.schema)", + "Home: \(.fm_home)", + "", + "## In Flight", + (if (.tasks | length) == 0 then + "No live task metadata found." + else + "| ID | Current | Kind | Repo/Project | Backend | Endpoint | Artifact | Path | Watch / return channel |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", + (.tasks[] | task_row(.)) + end), + "", + "## Queued", + (if ([.backlog.records[]? | select(.state == "queued")] | length) == 0 then + "No queued backlog records found." + else + "| ID | Title | Repo | Kind | Blocked By | Artifact |", + "| --- | --- | --- | --- | --- | --- |", + (.backlog.records[] | select(.state == "queued") | backlog_row(.)) + end), + "", + "## Done", + (if ([.backlog.records[]? | select(.state == "done")] | length) == 0 then + "No done backlog records found." + else + "| ID | Title | Repo | Kind | Blocked By | Artifact |", + "| --- | --- | --- | --- | --- | --- |", + (.backlog.records[] | select(.state == "done") | backlog_row(.)) + end), + "", + "## Secondmates", + .secondmate_guidance.note +' diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md new file mode 100644 index 00000000000..49bc38a7768 --- /dev/null +++ b/docs/arm-pretool-check.md @@ -0,0 +1,60 @@ +# watcher-arm PreToolUse seatbelt + +This document is the authoritative human-readable contract for the watcher-arm PreToolUse seatbelt. +`bin/fm-arm-command-policy.mjs` is the single decision owner and the sole owner of firstmate's shell command classification. +`bin/fm-arm-pretool-check.sh` is the Claude PreToolUse transport and output renderer. +This fork is Claude-only, so the transport speaks only Claude's PreToolUse contract; upstream firstmate multiplexes several harness transports here. + +It is a sibling of the cd-guard (`bin/fm-cd-pretool-check.sh`, `docs/cd-guard.md`), which imports this file's shell tokenizer instead of duplicating shell lexing. + +## Purpose and boundary + +The watcher is firstmate's supervision backbone (AGENTS.md section 8): it must be armed as a standalone, harness-tracked background task via `bin/fm-watch-arm.sh`, never bundled into a compound command, and it must never be broad-killed with `pkill -f fm-watch`. +A watcher that is armed inside a pipeline, a substitution, or an `&`-list, or that is killed by a pattern that matches sibling homes' watchers, silently breaks supervision. +The seatbelt denies those command shapes before they run. + +This guard classifies shell command positions only; it never evaluates, expands, sources, or runs any byte of the submitted command. +Its threat model is agent mistakes, not a deliberately obfuscated bypass. +It fails **closed** on an unclassifiable command that still mentions a protected watcher script (unlike the cd-guard, which fails open), because a missed bad arm breaks supervision silently. + +### Fork note: no checkpoint + +Upstream firstmate also protects a Codex-only `bin/fm-watch-checkpoint.sh`, a bounded foreground watcher checkpoint for harnesses that cannot rely on background-task completion to wake the model. +This fork is Claude-only, and Claude arms the watcher as a tracked background task and is re-invoked on its exit, so there is no checkpoint concept. +The classifier therefore recognizes only two protected identities: `bin/fm-watch-arm.sh` (`arm`, a valid standalone) and `bin/fm-watch.sh` (`watch`, which must never be run directly). + +## Deny reasons + +The policy denies a protected watcher command that escapes a clean standalone call, each with a distinct reason code: + +- `watcher-background` - the arm runs in an asynchronous list or through `nohup`/`disown` (`arm &`, `nohup arm`, `arm & disown`). +- `watcher-pipeline` - the arm participates in a pipeline (`arm | cat`, `arm 2>&1 | head`). +- `watcher-redirection` - the arm uses shell redirection (`arm >/tmp/out`). +- `watcher-bundled` - the arm is not the sole final command after approved setup nodes (`echo x; arm`, `true && arm`, `arm; echo y`). +- `watcher-nested` - the arm runs through a wrapper, substitution, process substitution, or `sh -c`/`bash -c` payload (`$(arm)`, `<(arm)`, `bash -lc 'arm &'`, an indirected `"$WATCHER" &`). +- `broad-watcher-kill` - a broad process kill targets the watcher (`pkill -f /bin/fm-watch.sh`, `sudo pkill ...`, `kill "$(pgrep -f ...)"`), including behind `command`/`sudo`/an absolute path. +- `watcher-direct` - `bin/fm-watch.sh` is run directly instead of arming with `bin/fm-watch-arm.sh`. +- `unclassifiable-protected-command` - unsupported or malformed shell syntax still mentions a protected watcher script (fail-closed). + +## Allow: standalone arm, blessed setup, and mere mentions + +The guard **allows**: + +- A standalone verified arm: `bin/fm-watch-arm.sh`, `./bin/fm-watch-arm.sh --restart`, `exec bin/fm-watch-arm.sh`. +- A standalone arm preceded only by approved setup nodes connected with `;`, newline, or `&&`, where the arm is the sole final command: a `cd ` and an `export VAR=val`. + Upstream additionally blesses a `source config/x-mode.env` setup node (an X-mode startup form); this fork has no X-mode and no source-a-config-before-arming pattern, so only `cd` and `export` setup nodes are blessed here (tighter than upstream). +- Any command that merely mentions a protected script as data: a quoted reference (`rg -n 'fm-watch-arm.sh &' docs`, `git grep '...'`), a comment (`echo ok # arm &`), a `printf`/`echo` payload, or a search over docs and tests. +- Any command with no `fm-watch` token at all (fast-allowed by the prefilter before the Node policy runs). + +## Transport, wiring, and fail-open + +`bin/fm-arm-pretool-check.sh` is wired as a Claude `PreToolUse` hook on the `Bash` matcher in the tracked `.claude/settings.json`, invoked as `... fm-arm-pretool-check.sh --claude`, ahead of the cd-guard hook. +In stdin mode it reads `.tool_input.command` from the Claude PreToolUse payload; a `--command ` CLI mode drives the tests, and `--background` is accepted for transport parity as a no-op. +On a deny it writes a Claude deny object (`hookSpecificOutput.permissionDecision = "deny"`) to stderr and exits 2, keeping stdout empty. +A strict-superset prefilter fast-allows any command whose text cannot contain `fm-watch` even after byte normalization; the marker set that forces delegation to the classifier is coupled to the classifier's decoder set. +Transport uncertainty fails open (missing `jq`, missing Node, a missing policy owner, an invalid policy response); the classifier itself fails closed on an unclassifiable protected command. + +## Tests + +`tests/fm-arm-pretool-check.test.sh` drives a representative decision matrix (every deny reason plus the blessed-setup and reference allows) through the Claude CLI, plus reason-code mapping, the stdin transport, the fail-open path, the prefilter fast path, and the shared-tokenizer export the cd-guard imports. +The classifier is ported verbatim from upstream, so its exhaustive adversarial coverage is inherited. diff --git a/docs/cd-guard.md b/docs/cd-guard.md new file mode 100644 index 00000000000..04b06223b30 --- /dev/null +++ b/docs/cd-guard.md @@ -0,0 +1,71 @@ +# cd-guard PreToolUse seatbelt + +This document is the authoritative human-readable contract for the cd-guard PreToolUse seatbelt. +`bin/fm-cd-command-policy.mjs` is the single decision owner. +`bin/fm-cd-pretool-check.sh` is the Claude PreToolUse transport, primary-checkout scope, and output renderer. +This fork is Claude-only, so the transport speaks only Claude's PreToolUse contract; upstream firstmate multiplexes several harness transports here. + +It is one of a family of primary-session guards that share the same hook machinery: +the watcher-arm PreToolUse seatbelt (`bin/fm-arm-pretool-check.sh`, `docs/arm-pretool-check.md`) and the turn-end supervision guard (`bin/fm-turnend-guard.sh`). + +## Purpose and boundary + +The primary firstmate shell persists its working directory across tool calls. +A stray persistent top-level `cd projects/` therefore silently relocates the shell, so the next firstmate-owned command - a backlog write, an `fm-*` lifecycle call, `tasks-axi` - runs inside a project clone instead of the home. +The seatbelt denies exactly that command shape - a cwd change that persists to the primary shell - before it runs. + +This guard is not a general sandbox. +It classifies shell command positions only; it never evaluates, expands, sources, or runs any byte of the submitted command. +Its threat model is agent mistakes, the same as the watcher-arm seatbelt: an accidental bare `cd projects/foo`, not a deliberately obfuscated bypass. + +## Scope: plain firstmate checkouts only + +The guard fires only in a plain firstmate checkout where git-dir equals git-common-dir. +It is a silent no-op (exit 0, no output) everywhere else, so it never interferes with a crewmate or scout that legitimately works inside its own herdr task worktree. + +A plain, non-worktree checkout has `git rev-parse --git-dir` equal to `git rev-parse --git-common-dir`. +A crewmate or scout task worktree - the shape `bin/fm-spawn.sh` always hands out - is a linked git worktree where the two differ, so the guard is inert there. +The checkout must also carry `AGENTS.md` and `bin/`, and any failure to confirm the primary is treated as inert, never as a block. + +The cd-guard does not inspect `.fm-secondmate-home`. +It therefore applies in a secondmate home whose git-dir equals git-common-dir; a secondmate home that is itself a linked herdr worktree is inert under the same linked-worktree test, as are secondmate child crew and scout worktrees. + +## Block vs allow + +The discriminator is persistence to the parent shell's cwd, not the mere presence of the token `cd`. + +The guard **blocks** a `cd`, `pushd`, or `popd` builtin that runs in an executed top-level position in the parent shell, because such a command persistently changes the primary shell's own working directory. +This covers a bare `cd projects/foo`, `cd ..`, `cd`, `cd -`, an absolute `cd /some/path` (still a persistent relocation of the parent shell), `pushd `, `popd`, a leading-assignment form such as `X=1 cd foo`, quoted or escaped command-word fragments that cook to a bare builtin (`"cd" x`, `c'd' x`, `c\d x`, an ANSI-C `$'\143d' x`), and any list form where the builtin runs in the parent shell (`cd x && cmd`, `cmd; cd x`, `cmd || cd x`, `command cd x`, `command -p cd x`, `command -- cd x`, `builtin cd x`, `command builtin cd x`, `cd x >/dev/null`, and newline-separated lists). + +The guard **allows** everything else, including these safe scoped forms that must never be blocked: + +- A command that reaches a target without changing the shell's own cwd: `git -C ...`, `make -C ...`, `env -C ...`, or an absolute path on the command itself. +- A directory change that does not persist to the parent shell: a subshell `(cd x && ...)`, a `bash -c 'cd ...'` / `sh -c` / `zsh -c` payload, a `find ... -execdir` runner, a pipeline stage (`cd x | cmd`), or a backgrounded `cd x &`. +- A `cd` behind a forking or exec'ing wrapper (`env`, `sudo`, `nohup`, `timeout`, `gtimeout`, `exec`), which runs in a child and never persists. +- A path-qualified external command named `cd`, `command`, or `builtin` (`./cd`, `/usr/bin/cd`, `./command`, `./builtin`), because it runs as a child process and cannot change the parent shell's cwd. +- A `command` query such as `command -v cd`, `command -V cd`, or a clustered `command -pv cd`, because it reports command resolution without executing the named builtin. +- The token `cd` appearing as data: quoted text (`echo "cd projects/foo"`), a comment, a substring of another word (`cdk`, `abcd`), a `printf` payload, or any later argument word. + +An absolute-path `cd` is blocked on purpose: the ALLOW carve-out for absolute paths is for commands that address a target by absolute path, not for `cd`, which relocates the shell itself regardless of its argument. +Blocking a top-level `cd` is safe in the strong sense: the guard's steady state is "always at the home", so a return-to-home `cd` is redundant rather than necessary, and the block never causes a wrong-directory write. + +### Accepted non-goals + +Consistent with the agent-mistake threat model, the guard deliberately does not chase every obfuscated bypass: + +- A `cd` reconstructed by a command substitution (`$(echo c)d x`) or hidden inside a brace group (`{ cd x; }`) is not blocked; a false block on brace expansion `{cd,foo}` would be worse than the missed exotic bypass. +- Malformed or untokenizable syntax fails open (allow). Unlike the watcher-arm seatbelt, which fails closed on unclassifiable protected commands, the cd-guard prioritizes zero false blocks over catching a malformed bypass, because a blocked backlog write is a correctness hazard while a missed exotic `cd` is only the pre-existing status quo. + +## Transport, wiring, and fail-open + +`bin/fm-cd-pretool-check.sh` is wired as a Claude `PreToolUse` hook on the `Bash` matcher in the tracked `.claude/settings.json`, invoked as `... fm-cd-pretool-check.sh --claude`. +In stdin mode it reads `.tool_input.command` from the Claude PreToolUse payload; a `--command ` CLI mode drives the tests. +On a block it writes a Claude deny object (`hookSpecificOutput.permissionDecision = "deny"`) to stderr and exits 2, keeping stdout empty, which Claude requires on deny. +A strict-superset prefilter fast-allows any command whose text cannot contain `cd`/`pushd`/`popd` even after byte normalization, so the Node policy owner runs only for candidate commands. +Every uncertainty fails open (exit 0, allow): malformed or empty stdin, missing `jq`, missing Node, a missing policy owner, an invalid policy response, or a non-primary checkout. + +`bin/fm-cd-command-policy.mjs` imports the shell tokenizer and command-position analysis (`Lexer`, `splitProgram`, `commandPosition`) from `bin/fm-arm-command-policy.mjs`, the sole owner of firstmate's shell classification, so the cd-guard never duplicates shell lexing. + +## Tests + +`tests/fm-cd-pretool-check.test.sh` drives the full block/allow decision matrix through the Claude CLI, plus the stdin transport, the primary-checkout scoping (a linked worktree is inert), the fail-open path, and the prefilter fast path. diff --git a/docs/scripts.md b/docs/scripts.md index 065f39de859..4ddca3b22b0 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -6,6 +6,9 @@ Read each script's header comment before first use. | Script | Description | | ------------------------ | ------------------------------------------------------------------------------------------------------------------- | | `fm-fleet-sync.sh` | Fetch clones, fast-forward safe default-branch states, self-heal clean detached ancestor drift, report unsafe drift as `STUCK:`, and safely prune branches whose remote is gone | +| `fm-fleet-snapshot.sh` | Read-only structured (`--json`, schema `fm-fleet-snapshot.v1`) snapshot of the whole fleet: backlog, one row per task meta with reconciled current state and herdr endpoint presence, open-decision hints, scout reports, and a secondmate-landed roll-up; no locks, wakes, or mutations | +| `fm-fleet-view.sh` | Human renderer over `fm-fleet-snapshot.sh --json` (a fleet table); `--json` passes the underlying snapshot through | +| `fm-bearings-snapshot.sh` | Compact TOON projection of the canonical snapshot for a "pick up where I left off" read; LOCAL-ONLY by default (zero network), with `--include-prs` as the sole opt-in that touches GitHub; feeds the `bearings` skill (`/bearings`) | | `fm-ff-lib.sh` | Shared fast-forward machinery for the spawn-time secondmate sync | | `fm-backlog-handoff.sh` | Move already-judged in-scope queued backlog items from the main home into a seeded secondmate home | | `fm-brief.sh` | Scaffold a ship brief, a report-only scout brief with `--scout`, or a secondmate charter with `--secondmate` | @@ -13,6 +16,10 @@ Read each script's header comment before first use. | `fm-guard.sh` | Warn when tasks are in flight but queued wakes are pending or the watcher is down; also alarm on a worktree tangle (primary checkout on a feature branch) | | `fm-tangle-lib.sh` | Shared classifier for the worktree-tangle guard: a named non-default branch in the primary checkout | | `fm-turnend-guard.sh` | Claude Code Stop hook (tracked `.claude/settings.json`): blocks a primary turn end, once per turn, when tasks are in flight with no fresh watcher beacon (or a dead afk daemon) | +| `fm-cd-pretool-check.sh` | Claude PreToolUse hook: denies a persistent top-level `cd`/`pushd`/`popd` in the real primary checkout before it relocates the shell; inert in a linked worktree, fails open on any uncertainty (`docs/cd-guard.md`) | +| `fm-cd-command-policy.mjs` | Sole block/allow decision owner for the cd-guard; reuses the shared shell classifier from `fm-arm-command-policy.mjs` | +| `fm-arm-pretool-check.sh` | Claude PreToolUse hook: denies a watcher arm that is not a standalone verified call (bundled, piped, redirected, backgrounded, nested) and a broad `pkill -f fm-watch`; fails closed on an unclassifiable protected command (`docs/arm-pretool-check.md`) | +| `fm-arm-command-policy.mjs` | Sole owner of firstmate's shell command classification and the watcher-arm decision procedure; exports the tokenizer the cd-guard imports | | `fm-home-seed.sh` | Provision a secondmate home transactionally (a herdr worktree of the repo with `-`), clone projects, initialize gates, and maintain `data/secondmates.md` | | `fm-session-start.sh` | One-command session start: lock, diagnostics, wake drain (or read-only report), full context + fleet digest, and the watcher next step | | `fm-spawn.sh` | Spawn one task, several `id=repo` pairs in one batch, or a persistent secondmate with `--secondmate`; records task kind; `--model`/`--effort` set the Claude launch profile, enforced as the dispatch backstop while `config/crew-dispatch.json` is active | diff --git a/tests/fm-arm-pretool-check.test.sh b/tests/fm-arm-pretool-check.test.sh new file mode 100755 index 00000000000..8e7e40225bb --- /dev/null +++ b/tests/fm-arm-pretool-check.test.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +# Behavior tests for the watcher-arm Claude PreToolUse seatbelt +# (docs/arm-pretool-check.md). +# +# bin/fm-arm-command-policy.mjs is the sole owner of firstmate's shell command +# classification and the watcher-arm decision procedure; it also exports the +# tokenizer the cd-guard imports. bin/fm-arm-pretool-check.sh is the Claude +# transport. This fork is Claude-only (no Codex checkpoint concept), so the suite +# drives the Claude entry forms and covers each deny reason, the blessed setup +# tree, reference/quoted allows, the fail-open transport, and the prefilter fast +# path. No harness or watcher is spawned. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ARM="$ROOT/bin/fm-arm-pretool-check.sh" +export GIT_AUTHOR_NAME=fmtest GIT_AUTHOR_EMAIL=fmtest@example.invalid +export GIT_COMMITTER_NAME=fmtest GIT_COMMITTER_EMAIL=fmtest@example.invalid + +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } + +TMP= +cleanup() { [ -n "${TMP:-}" ] && rm -rf "$TMP"; } +trap cleanup EXIT +TMP=$(mktemp -d "${TMPDIR:-/tmp}/fm-arm-pretool.XXXXXX") + +# --- decision matrix (representative; the verbatim classifier's exhaustive +# adversarial coverage is inherited from upstream #483) -------------------- + +IDS=(); EXPECT=(); CMDS=() +mc() { IDS+=("$1"); EXPECT+=("$2"); CMDS+=("$3"); } + +# ALLOW: a standalone verified arm, after blessed setup nodes, or a mere mention. +mc A01 allow 'bin/fm-watch-arm.sh' +mc A02 allow './bin/fm-watch-arm.sh --restart' +mc A03 allow 'exec bin/fm-watch-arm.sh' +mc A04 allow 'cd /tmp && exec bin/fm-watch-arm.sh' +mc A05 allow 'export FM_HOME=/tmp; bin/fm-watch-arm.sh' +mc A06 allow 'export FM_HOME=/tmp && bin/fm-watch-arm.sh' +mc A07 allow 'cd /tmp; exec bin/fm-watch-arm.sh' +mc A08 allow "rg -n 'fm-watch-arm.sh &' docs" +mc A09 allow "echo 'pkill -f fm-watch'" +mc A10 allow 'echo ok # bin/fm-watch-arm.sh &' +mc A11 allow "git grep 'fm-watch-arm.sh && echo bad'" +mc A12 allow 'ls -la' +mc A13 allow 'git status' +mc A14 allow "printf '%s\\n' 'bin/fm-watch-arm.sh &'" + +# DENY: every way a protected watcher command can escape a standalone call. +mc D01 deny 'bin/fm-watch-arm.sh &' # watcher-background +mc D02 deny 'nohup bin/fm-watch-arm.sh' # watcher-background +mc D03 deny 'bin/fm-watch-arm.sh & disown' # watcher-background +mc D04 deny '(bin/fm-watch-arm.sh) &' # watcher-nested/background +mc D05 deny 'bin/fm-watch-arm.sh | cat' # watcher-pipeline +mc D06 deny 'bin/fm-watch-arm.sh 2>&1 | head -2' # watcher-pipeline +mc D07 deny 'bin/fm-watch-arm.sh >/tmp/out' # watcher-redirection +mc D08 deny 'echo before; bin/fm-watch-arm.sh' # watcher-bundled +mc D09 deny 'true && bin/fm-watch-arm.sh' # watcher-bundled +mc D10 deny 'bin/fm-watch-arm.sh; echo after' # watcher-bundled +mc D11 deny '$(bin/fm-watch-arm.sh)' # watcher-nested (substitution) +mc D12 deny 'cat <(bin/fm-watch-arm.sh)' # watcher-nested (process sub) +mc D13 deny "bash -lc 'bin/fm-watch-arm.sh &'" # watcher-nested (shell -c) +mc D14 deny 'bin/fm-watch.sh' # watcher-direct +mc D15 deny "pkill -f '/bin/fm-watch.sh'" # broad-watcher-kill +mc D16 deny "command pkill -f '/bin/fm-watch.sh'" # broad-watcher-kill +mc D17 deny "sudo pkill -f '/bin/fm-watch.sh'" # broad-watcher-kill +mc D18 deny 'kill "$(pgrep -f '\''/bin/fm-watch.sh'\'')"' # broad-watcher-kill +mc D19 deny 'bin/fm-"watch-arm.sh" &' # obfuscated quoting +mc D20 deny "WATCHER='bin/fm-watch-arm.sh'; \"\$WATCHER\" &" # var-indirect +mc D21 deny "bash -c \$'bin/fm-watch-arm.sh &'" # ANSI-C decoded + +test_decision_matrix() { + local i cmd want out rc bad=0 + for i in "${!IDS[@]}"; do + cmd=${CMDS[$i]}; want=${EXPECT[$i]} + out=$("$ARM" --command "$cmd" 2>&1); rc=$? + if [ "$want" = deny ]; then + { [ "$rc" -eq 2 ] && printf '%s' "$out" | grep -q '"permissionDecision":"deny"'; } \ + || { printf ' %s expected deny, got rc=%s out=%s\n' "${IDS[$i]}" "$rc" "$out" >&2; bad=1; } + else + { [ "$rc" -eq 0 ] && [ -z "$out" ]; } \ + || { printf ' %s expected allow, got rc=%s out=%s\n' "${IDS[$i]}" "$rc" "$out" >&2; bad=1; } + fi + done + [ "$bad" -eq 0 ] || fail "arm decision matrix had mismatches" + pass "arm-guard decision matrix: ${#IDS[@]} cases (14 allow + 21 deny) via the Claude CLI" +} + +test_reason_codes() { + local out + out=$("$ARM" --command 'bin/fm-watch.sh' 2>&1) + printf '%s' "$out" | grep -q 'watcher-direct' || fail "arm: fm-watch.sh direct must map to watcher-direct ($out)" + out=$("$ARM" --command "pkill -f '/bin/fm-watch.sh'" 2>&1) + printf '%s' "$out" | grep -q 'broad-watcher-kill' || fail "arm: pkill must map to broad-watcher-kill ($out)" + out=$("$ARM" --command 'bin/fm-watch-arm.sh &' 2>&1) + printf '%s' "$out" | grep -q 'watcher-background' || fail "arm: backgrounded arm must map to watcher-background ($out)" + pass "arm-guard reason codes: watcher-direct, broad-watcher-kill, watcher-background map through" +} + +test_stdin_transport() { + local out rc + out=$(printf '{"tool_input":{"command":"pkill -f fm-watch"}}' | "$ARM" --claude 2>&1); rc=$? + { [ "$rc" -eq 2 ] && printf '%s' "$out" | grep -q '"permissionDecision":"deny"'; } \ + || fail "arm stdin: a deniable payload must exit 2 with a Claude deny object (rc=$rc)" + out=$(printf '{"tool_input":{"command":"bin/fm-watch-arm.sh"}}' | "$ARM" --claude 2>&1); rc=$? + { [ "$rc" -eq 0 ] && [ -z "$out" ]; } || fail "arm stdin: a standalone arm must exit 0 silently (rc=$rc)" + out=$(printf 'not json' | "$ARM" --claude 2>&1); rc=$? + { [ "$rc" -eq 0 ] && [ -z "$out" ]; } || fail "arm stdin: malformed payload must fail open (rc=$rc)" + pass "arm-guard stdin transport: deny object on exit 2, silent allow, malformed fails open" +} + +test_fail_open_missing_policy() { + local dir out rc + dir="$TMP/nopolicy"; mkdir -p "$dir/bin" + cp "$ROOT/bin/fm-arm-pretool-check.sh" "$dir/bin/fm-arm-pretool-check.sh" + chmod +x "$dir/bin/fm-arm-pretool-check.sh" # policy .mjs deliberately absent + out=$("$dir/bin/fm-arm-pretool-check.sh" --command 'bin/fm-watch-arm.sh &' 2>&1); rc=$? + { [ "$rc" -eq 0 ] && [ -z "$out" ]; } \ + || fail "arm: a missing policy owner must fail open (allow), not error (rc=$rc out=$out)" + pass "arm-guard fail-open: a missing policy .mjs allows instead of blocking" +} + +test_prefilter_fast_allow() { + local out rc + out=$("$ARM" --command 'tasks-axi ready && ls -la' 2>&1); rc=$? + { [ "$rc" -eq 0 ] && [ -z "$out" ]; } || fail "arm prefilter: an fm-watch-free command must fast-allow (rc=$rc)" + pass "arm-guard prefilter: a command with no fm-watch substring fast-allows" +} + +test_classifier_exports() { + # The cd-guard imports Lexer/splitProgram/commandPosition from this classifier; + # exercising the cd policy end-to-end proves the exports resolve. + local out + out=$(node "$ROOT/bin/fm-cd-command-policy.mjs" --command 'cd projects/foo' 2>&1) + printf '%s' "$out" | grep -q 'persistent-cd' \ + || fail "arm classifier exports (Lexer/splitProgram/commandPosition) must load into the cd policy ($out)" + pass "arm-command-policy exports the shared tokenizer the cd-guard imports" +} + +test_decision_matrix +test_reason_codes +test_stdin_transport +test_fail_open_missing_policy +test_prefilter_fast_allow +test_classifier_exports diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh new file mode 100644 index 00000000000..9bf2e8f7c4f --- /dev/null +++ b/tests/fm-bearings-snapshot.test.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Behavior tests for the bearings projection (bin/fm-bearings-snapshot.sh), the +# "pick up where I left off" view over the canonical fleet snapshot. +# +# It projects fm-fleet-snapshot.sh down to the fields a catch-up read needs and +# is LOCAL-ONLY by default (zero network/gh). This suite proves the projected +# schema, the local-only default (a stubbed gh is never invoked), and the landed +# roll-up from the Done backlog. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BEARINGS="$ROOT/bin/fm-bearings-snapshot.sh" + +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } + +command -v jq >/dev/null 2>&1 || { echo "1..0 # skip jq not found"; exit 0; } + +TMP= +cleanup() { [ -n "${TMP:-}" ] && rm -rf "$TMP"; } +trap cleanup EXIT +TMP=$(mktemp -d "${TMPDIR:-/tmp}/fm-bearings.XXXXXX") + +make_home() { + local home=$1 + mkdir -p "$home/state" "$home/data" + cat > "$home/data/backlog.md" <<'MD' +## In flight + +## Queued + +## Done +- [x] shipped-a1 - the earlier change - https://github.com/leo1oel/yourapp/pull/12 (merged 2026-07-09) +- [x] scouted-b2 - the investigation - data/scouted-b2/report.md (reported 2026-07-08) +MD + printf '%s\n' "$home" +} + +# A gh stub that records any invocation, to prove the local-only default path +# never touches the network. +make_gh_marker_bin() { + local dir=$1 marker=$2 gb + gb="$dir/ghbin"; mkdir -p "$gb" + cat > "$gb/gh" <> '$marker' +exit 0 +SH + chmod +x "$gb/gh" + printf '%s\n' "$gb" +} + +run_bearings() { # [ASSIGN...] -- + local home=$1; shift + local assigns=() ; while [ "$1" != "--" ]; do assigns+=("$1"); shift; done; shift + env "FM_HOME=$home" "FM_STATE_OVERRIDE=$home/state" "FM_DATA_OVERRIDE=$home/data" \ + "FM_CONFIG_OVERRIDE=$home/config" "FM_PROJECTS_OVERRIDE=$home/projects" "${assigns[@]}" \ + "$BEARINGS" "$@" 2>&1 +} + +test_projection_schema() { + local home out + home=$(make_home "$TMP/home-a") + out=$(run_bearings "$home" -- --json) || fail "bearings --json failed: $out" + printf '%s' "$out" | jq -e '.schema == "fm-bearings.v1"' >/dev/null \ + || fail "bearings schema wrong: $(printf '%s' "$out" | head -3)" + printf '%s' "$out" | jq -e 'has("in_flight") and has("landed") and has("gates") and has("prs")' >/dev/null \ + || fail "bearings projection missing a required surface" + pass "bearings-snapshot: projects the fm-bearings.v1 schema with the catch-up surfaces" +} + +test_local_only_default() { + local home marker gb out + home=$(make_home "$TMP/home-b") + marker="$TMP/gh-called" + gb=$(make_gh_marker_bin "$TMP/ghmark" "$marker") + out=$(run_bearings "$home" "PATH=$gb:$PATH" -- --json) || fail "bearings failed: $out" + [ ! -e "$marker" ] || fail "the default bearings snapshot invoked gh (must be local-only): $(cat "$marker")" + printf '%s' "$out" | jq -e '.prs | test("not_requested")' >/dev/null \ + || fail "the local-only default must state prs were not requested" + pass "bearings-snapshot: local-only by default - no gh/network call, prs marked not_requested" +} + +test_landed_rollup() { + local home out + home=$(make_home "$TMP/home-c") + out=$(run_bearings "$home" -- --json) || fail "bearings failed: $out" + printf '%s' "$out" | jq -e '.landed | map(.id) | index("shipped-a1")' >/dev/null \ + || fail "a merged Done PR must appear in landed[]" + printf '%s' "$out" | jq -e '.landed | map(.id) | index("scouted-b2")' >/dev/null \ + || fail "a completed Done scout must appear in landed[]" + pass "bearings-snapshot: rolls up merged PRs and completed scouts from the Done backlog" +} + +test_projection_schema +test_local_only_default +test_landed_rollup diff --git a/tests/fm-cd-pretool-check.test.sh b/tests/fm-cd-pretool-check.test.sh new file mode 100755 index 00000000000..f83197a22c4 --- /dev/null +++ b/tests/fm-cd-pretool-check.test.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +# Behavior tests for the cd-guard Claude PreToolUse seatbelt (docs/cd-guard.md). +# +# bin/fm-cd-command-policy.mjs is the single owner of the block/allow decision; +# it reuses the shell classifier owned by bin/fm-arm-command-policy.mjs. +# bin/fm-cd-pretool-check.sh is the Claude transport: it scopes the guard to the +# real primary checkout, then reads the PreToolUse payload. This fork is +# Claude-only, so the suite drives the Claude entry forms (CLI --command and +# stdin --claude); it proves the full decision matrix, primary-checkout scoping +# (a linked crewmate/scout worktree is inert), the fail-open transport, and the +# prefilter fast path. No harness is spawned. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export GIT_AUTHOR_NAME=fmtest GIT_AUTHOR_EMAIL=fmtest@example.invalid +export GIT_COMMITTER_NAME=fmtest GIT_COMMITTER_EMAIL=fmtest@example.invalid + +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } + +TMP= +cleanup() { [ -n "${TMP:-}" ] && rm -rf "$TMP"; } +trap cleanup EXIT +TMP=$(mktemp -d "${TMPDIR:-/tmp}/fm-cd-pretool.XXXXXX") + +DENY_MSG='persistent-cd' + +# Copy the transport plus BOTH policy files into a fixture bin/ (cd-command-policy +# imports the shared classifier from arm-command-policy). Copy, not symlink, so +# the transport resolves FM_ROOT to the fixture and scopes against its git dirs. +install_cd_scripts() { + local dir=$1 + mkdir -p "$dir/bin" + cp "$ROOT/bin/fm-cd-pretool-check.sh" "$dir/bin/fm-cd-pretool-check.sh" + cp "$ROOT/bin/fm-cd-command-policy.mjs" "$dir/bin/fm-cd-command-policy.mjs" + cp "$ROOT/bin/fm-arm-command-policy.mjs" "$dir/bin/fm-arm-command-policy.mjs" + chmod +x "$dir/bin/fm-cd-pretool-check.sh" +} + +# A primary-shaped checkout: plain (non-worktree) git repo, git-dir == git-common-dir. +make_primary_fixture() { + local dir=$1 + git init -q "$dir" + git -C "$dir" commit -q --allow-empty -m init + : > "$dir/AGENTS.md" + install_cd_scripts "$dir" + printf '%s\n' "$dir" +} + +# A genuine linked git worktree - the shape bin/fm-spawn.sh hands crewmate/scout +# tasks. git-dir and git-common-dir differ, so the guard must be inert. +make_child_worktree_fixture() { + local base=$1 dir=$2 + git -C "$base" worktree add -q --detach "$dir" >/dev/null 2>&1 + : > "$dir/AGENTS.md" + install_cd_scripts "$dir" + printf '%s\n' "$dir" +} + +PRIMARY=$(make_primary_fixture "$TMP/primary") +CHECK="$PRIMARY/bin/fm-cd-pretool-check.sh" + +# --- decision matrix (ported from upstream #483, driven via the Claude CLI) --- + +IDS=(); EXPECT=(); CMDS=() +mc() { IDS+=("$1"); EXPECT+=("$2"); CMDS+=("$3"); } + +# BLOCK: a persistent top-level cwd change in the parent shell. +mc B01 deny 'cd projects/foo' +mc B02 deny 'cd ..' +mc B03 deny 'cd' +mc B04 deny 'cd -' +mc B05 deny 'cd /abs/path' +mc B06 deny 'pushd projects/foo' +mc B07 deny 'popd' +mc B08 deny 'X=1 cd projects/foo' +mc B09 deny 'cd projects/foo && tasks-axi add x' +mc B10 deny 'echo before; cd projects/foo' +mc B11 deny 'true && cd projects/foo' +mc B12 deny 'tasks-axi done x || cd projects/foo' +mc B13 deny 'cd "projects/foo"' +mc B14 deny '"cd" projects/foo' +mc B15 deny 'sleep 1 & cd projects/foo' +mc B16 deny 'command cd projects/foo' +mc B17 deny 'cd projects/foo >/dev/null' +mc B18 deny $'cd projects/foo\necho done' +mc B19 deny "\$'\\143d' projects/foo" +mc B20 deny "c'd' projects/foo" +mc B21 deny 'c"d" projects/foo' +mc B22 deny 'c\d projects/foo' +mc B23 deny 'builtin cd projects/foo' +mc B24 deny 'command builtin cd projects/foo' +mc B25 deny 'builtin command cd projects/foo' +mc B26 deny 'command -p cd projects/foo' +mc B27 deny 'command -- cd projects/foo' + +# ALLOW: reaches a dir without moving the parent shell, or is not a cd at all. +mc A01 allow 'git -C projects/foo status' +mc A02 allow 'cat /abs/path/file' +mc A03 allow 'ls projects/foo' +mc A04 allow 'echo "cd projects/foo"' +mc A05 allow 'grep cd file' +mc A06 allow '(cd projects/foo && pwd)' +mc A07 allow "bash -c 'cd projects/foo'" +mc A08 allow 'env -C projects/foo make' +mc A09 allow 'make -C projects/foo build' +mc A10 allow 'find . -execdir cd {} \;' +mc A11 allow 'cd projects/foo | cat' +mc A12 allow 'cat foo | cd bar' +mc A13 allow 'cd projects/foo &' +mc A14 allow 'abcd project' +mc A15 allow 'cdk deploy' +mc A16 allow 'env cd projects/foo' +mc A17 allow 'sudo cd projects/foo' +mc A18 allow 'x=$(cd foo && pwd)' +mc A19 allow 'dirs' +mc A20 allow "echo 'pushd x'" +mc A21 allow 'git checkout main' +mc A22 allow "sh -c 'cd projects/foo && ls'" +mc A23 allow "printf '%s\\n' 'cd projects/foo'" +mc A24 allow 'ls -la' +mc A25 allow './cd projects/foo' +mc A26 allow '/tmp/cd projects/foo' +mc A27 allow '/usr/bin/cd projects/foo' +mc A28 allow './builtin cd projects/foo' +mc A29 allow 'c\d\ projects/foo' +mc A30 allow './command cd projects/foo' +mc A31 allow '/usr/bin/command cd projects/foo' +mc A32 allow '/tmp/builtin cd projects/foo' +mc A33 allow 'command -v cd' +mc A34 allow 'command -V cd' +mc A35 allow 'command -pv cd' +mc A36 allow 'command -vp cd' + +test_decision_matrix() { + local i cmd want out rc bad=0 + for i in "${!IDS[@]}"; do + cmd=${CMDS[$i]}; want=${EXPECT[$i]} + out=$("$CHECK" --command "$cmd" 2>&1); rc=$? + if [ "$want" = deny ]; then + { [ "$rc" -eq 2 ] && printf '%s' "$out" | grep -q "$DENY_MSG"; } \ + || { printf ' %s expected deny, got rc=%s out=%s\n' "${IDS[$i]}" "$rc" "$out" >&2; bad=1; } + else + { [ "$rc" -eq 0 ] && [ -z "$out" ]; } \ + || { printf ' %s expected allow, got rc=%s out=%s\n' "${IDS[$i]}" "$rc" "$out" >&2; bad=1; } + fi + done + [ "$bad" -eq 0 ] || fail "cd decision matrix had mismatches" + pass "cd-guard decision matrix: ${#IDS[@]} cases (27 deny + 36 allow) via the Claude CLI" +} + +test_stdin_transport() { + local out rc + out=$(printf '{"tool_input":{"command":"cd projects/foo && rm x"}}' | "$CHECK" --claude 2>&1); rc=$? + { [ "$rc" -eq 2 ] && printf '%s' "$out" | grep -q '"permissionDecision":"deny"'; } \ + || fail "cd-guard stdin: a deniable payload must exit 2 with a Claude deny object (rc=$rc)" + out=$(printf '{"tool_input":{"command":"git -C projects/foo status"}}' | "$CHECK" --claude 2>&1); rc=$? + { [ "$rc" -eq 0 ] && [ -z "$out" ]; } || fail "cd-guard stdin: an allowed payload must exit 0 silently (rc=$rc)" + out=$(printf 'not json' | "$CHECK" --claude 2>&1); rc=$? + { [ "$rc" -eq 0 ] && [ -z "$out" ]; } || fail "cd-guard stdin: malformed payload must fail open (rc=$rc)" + pass "cd-guard stdin transport: deny object on exit 2, silent allow, malformed fails open" +} + +test_primary_scoping() { + local wt out rc + wt=$(make_child_worktree_fixture "$PRIMARY" "$TMP/child-wt") + # The SAME deniable command is INERT from a linked worktree (git-dir != common). + out=$("$wt/bin/fm-cd-pretool-check.sh" --command 'cd projects/foo' 2>&1); rc=$? + { [ "$rc" -eq 0 ] && [ -z "$out" ]; } \ + || fail "cd-guard: a linked child worktree must be inert, not deny (rc=$rc out=$out)" + # And still denies from the primary checkout (control). + out=$("$CHECK" --command 'cd projects/foo' 2>&1); rc=$? + [ "$rc" -eq 2 ] || fail "cd-guard: the primary checkout must still deny (rc=$rc)" + pass "cd-guard scoping: denies in the primary checkout, inert in a linked crew/scout worktree" +} + +test_fail_open_missing_policy() { + local dir out rc + dir="$TMP/nopolicy" + git init -q "$dir"; git -C "$dir" commit -q --allow-empty -m init + : > "$dir/AGENTS.md"; mkdir -p "$dir/bin" + cp "$ROOT/bin/fm-cd-pretool-check.sh" "$dir/bin/fm-cd-pretool-check.sh" + chmod +x "$dir/bin/fm-cd-pretool-check.sh" # policy .mjs deliberately absent + out=$("$dir/bin/fm-cd-pretool-check.sh" --command 'cd projects/foo' 2>&1); rc=$? + { [ "$rc" -eq 0 ] && [ -z "$out" ]; } \ + || fail "cd-guard: a missing policy owner must fail open (allow), not error (rc=$rc out=$out)" + pass "cd-guard fail-open: a missing policy .mjs allows instead of blocking" +} + +test_prefilter_fast_allow() { + local out rc + # No cd/pushd/popd substring: the prefilter fast-allows before invoking Node. + out=$("$CHECK" --command 'ls -la && tasks-axi ready' 2>&1); rc=$? + { [ "$rc" -eq 0 ] && [ -z "$out" ]; } || fail "cd-guard prefilter: a cd-free command must fast-allow (rc=$rc)" + pass "cd-guard prefilter: a command with no cd/pushd/popd fast-allows" +} + +test_decision_matrix +test_stdin_transport +test_primary_scoping +test_fail_open_missing_policy +test_prefilter_fast_allow diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh new file mode 100644 index 00000000000..37aa3f21e93 --- /dev/null +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# Behavior tests for the read-only fleet snapshot (bin/fm-fleet-snapshot.sh) and +# its human renderer (bin/fm-fleet-view.sh). +# +# The snapshot is the fork-adapted foundation of bearings: it reads meta with a +# plain grep helper, derives endpoint presence from fm_herdr_pane_exists, and +# tracks open decisions with a last-unresolved-line model (the fork has no +# [key=...] decision markers). This suite proves the JSON schema, backlog +# parsing, a task row's kind/endpoint/current_state, the open-decision hint and +# its lifecycle clearing, and that the view renders the snapshot. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SNAPSHOT="$ROOT/bin/fm-fleet-snapshot.sh" +VIEW="$ROOT/bin/fm-fleet-view.sh" +export GIT_AUTHOR_NAME=fmtest GIT_AUTHOR_EMAIL=fmtest@example.invalid +export GIT_COMMITTER_NAME=fmtest GIT_COMMITTER_EMAIL=fmtest@example.invalid + +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } + +command -v jq >/dev/null 2>&1 || { echo "1..0 # skip jq not found"; exit 0; } + +TMP= +cleanup() { [ -n "${TMP:-}" ] && rm -rf "$TMP"; } +trap cleanup EXIT +TMP=$(mktemp -d "${TMPDIR:-/tmp}/fm-fleet-snapshot.XXXXXX") + +# A fixture home with a crafted backlog and no task metas: exercises the +# backend-free path (schema, roots, backlog parsing) against the REAL snapshot. +make_home() { + local home=$1 + mkdir -p "$home/state" "$home/data" + cat > "$home/data/backlog.md" <<'MD' +## In flight +- [ ] fix-login-k3 - wire the login guard (repo: yourapp, since 2026-07-10) + +## Queued +- [ ] add-tests-q7 - cover the guard (repo: yourapp) blocked-by: fix-login-k3 - waits on the guard + +## Done +- [x] old-ship-a1 - earlier change - https://github.com/leo1oel/yourapp/pull/12 (merged 2026-07-09) +MD + printf '%s\n' "$home" +} + +run_snapshot() { # [ASSIGN...] + local home=$1; shift + env "FM_HOME=$home" "FM_STATE_OVERRIDE=$home/state" "FM_DATA_OVERRIDE=$home/data" \ + "FM_CONFIG_OVERRIDE=$home/config" "FM_PROJECTS_OVERRIDE=$home/projects" "$@" \ + "$SNAPSHOT" --json 2>&1 +} + +test_schema_and_backlog() { + local home out + home=$(make_home "$TMP/home-a") + out=$(run_snapshot "$home") || fail "snapshot exited non-zero: $out" + printf '%s' "$out" | jq -e '.schema == "fm-fleet-snapshot.v1"' >/dev/null \ + || fail "snapshot schema wrong: $(printf '%s' "$out" | head -3)" + # Backlog records preserve section state and metadata. + printf '%s' "$out" | jq -e '.backlog.records | map(.state) | (index("in_flight") and index("queued") and index("done"))' >/dev/null \ + || fail "backlog sections not all parsed" + printf '%s' "$out" | jq -e '.backlog.records[] | select(.state=="queued") | .blocked_by == "fix-login-k3"' >/dev/null \ + || fail "queued blocked_by not parsed" + printf '%s' "$out" | jq -e '.backlog.records[] | select(.state=="done") | .links[0] | test("/pull/12")' >/dev/null \ + || fail "done PR link not parsed" + pass "fleet-snapshot: schema fm-fleet-snapshot.v1 and In flight/Queued/Done backlog parsing" +} + +# A fake bin/ with the REAL snapshot + its libs + a stub fm-crew-state, plus a +# stub herdr on PATH, so a task row's endpoint and current_state are +# deterministic without a live backend. +make_fake_bin() { + local dir=$1 crew_state=$2 fb + fb="$dir/bin"; mkdir -p "$fb" + cp "$SNAPSHOT" "$fb/fm-fleet-snapshot.sh" + cp "$ROOT/bin/fm-herdr-lib.sh" "$ROOT/bin/fm-classify-lib.sh" "$ROOT/bin/fm-ff-lib.sh" "$fb/" + chmod +x "$fb/fm-fleet-snapshot.sh" + cat > "$fb/fm-crew-state.sh" <` exits 0 only for the known live handle. +make_fake_herdr() { + local dir=$1 live=$2 hb + hb="$dir/herdrbin"; mkdir -p "$hb" + cat > "$hb/herdr" < "$home/data/backlog.md" + # A ship task whose pane is live and whose status log ends on an unresolved + # needs-decision (crew-state is a non-authoritative none read, so the snapshot + # must NOT clear the open decision). + printf 'handle=%%7\nkind=ship\nmode=no-mistakes\nproject=%s/proj\n' "$home" > "$home/state/pick-x2.meta" + printf 'working: started\nneeds-decision: choose A or B\n' > "$home/state/pick-x2.status" + fb=$(make_fake_bin "$TMP/fake-b" 'state: unknown · source: none · no run') + hb=$(make_fake_herdr "$TMP/fake-b" '%7') + out=$(env "FM_HOME=$home" "FM_STATE_OVERRIDE=$home/state" "FM_DATA_OVERRIDE=$home/data" \ + "FM_CONFIG_OVERRIDE=$home/config" "FM_PROJECTS_OVERRIDE=$home/projects" \ + "PATH=$hb:$PATH" "$fb/fm-fleet-snapshot.sh" --json 2>&1) || fail "snapshot failed: $out" + printf '%s' "$out" | jq -e '.tasks | length == 1' >/dev/null || fail "expected one task row: $out" + printf '%s' "$out" | jq -e '.tasks[0].kind == "ship"' >/dev/null || fail "task kind wrong" + printf '%s' "$out" | jq -e '.tasks[0].endpoint.exists == true' >/dev/null \ + || fail "endpoint.exists should be true for a live pane" + printf '%s' "$out" | jq -e '.tasks[0].hints.pending_decision == true' >/dev/null \ + || fail "an unresolved needs-decision must set hints.pending_decision" + pass "fleet-snapshot: task row records kind, live endpoint, and an unresolved open decision" +} + +test_open_decision_cleared_when_resumed() { + local home fb hb out + home="$TMP/home-c"; mkdir -p "$home/state" "$home/data" + : > "$home/data/backlog.md" + printf 'handle=%%7\nkind=ship\nmode=no-mistakes\n' > "$home/state/pick-x2.meta" + printf 'needs-decision: choose A or B\n' > "$home/state/pick-x2.status" + # crew-state now authoritatively working via run-step: the stale needs-decision + # log line must be cleared (the crew provably resumed past the gate). + fb=$(make_fake_bin "$TMP/fake-c" 'state: working · source: run-step · fixing') + hb=$(make_fake_herdr "$TMP/fake-c" '%7') + out=$(env "FM_HOME=$home" "FM_STATE_OVERRIDE=$home/state" "FM_DATA_OVERRIDE=$home/data" \ + "FM_CONFIG_OVERRIDE=$home/config" "FM_PROJECTS_OVERRIDE=$home/projects" \ + "PATH=$hb:$PATH" "$fb/fm-fleet-snapshot.sh" --json 2>&1) || fail "snapshot failed: $out" + printf '%s' "$out" | jq -e '.tasks[0].hints.pending_decision == false' >/dev/null \ + || fail "a resumed run-step working state must clear the stale needs-decision" + pass "fleet-snapshot: a provably-resumed task clears its stale open decision" +} + +test_fleet_view_renders() { + local home out + home=$(make_home "$TMP/home-v") + out=$(env "FM_HOME=$home" "FM_STATE_OVERRIDE=$home/state" "FM_DATA_OVERRIDE=$home/data" \ + "FM_CONFIG_OVERRIDE=$home/config" "FM_PROJECTS_OVERRIDE=$home/projects" \ + "$VIEW" 2>&1) || fail "fleet-view failed: $out" + printf '%s' "$out" | grep -q '# Fleet View' || fail "fleet-view missing title" + printf '%s' "$out" | grep -q '## Queued' || fail "fleet-view missing Queued section" + pass "fleet-view: renders the snapshot as a human fleet table" +} + +test_schema_and_backlog +test_task_row_and_open_decision +test_open_decision_cleared_when_resumed +test_fleet_view_renders