diff --git a/.claude/settings.json b/.claude/settings.json index 6bbd714d727..696b7f8056b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -11,6 +11,10 @@ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-cd-pretool-check.sh --claude" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-vault-pretool-check.sh --claude" } ] } diff --git a/.codex/hooks.json b/.codex/hooks.json index 0ed18a74af0..38847225139 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -13,6 +13,11 @@ "type": "command", "command": "bash -lc 'payload=$(cat 2>/dev/null || true); [ -n \"$payload\" ] || exit 0; command -v jq >/dev/null 2>&1 || exit 0; root=$(pwd -P) || exit 0; [ -x \"$root/bin/fm-cd-pretool-check.sh\" ] || exit 0; [ -f \"$root/AGENTS.md\" ] || exit 0; [ -f \"$root/.codex/hooks.json\" ] || exit 0; jq -e \"any(.hooks.PreToolUse[]?.hooks[]?.command?; type == \\\"string\\\" and contains(\\\"fm-cd-pretool-check.sh\\\"))\" \"$root/.codex/hooks.json\" >/dev/null 2>&1 || exit 0; printf \"%s\" \"$payload\" | \"$root/bin/fm-cd-pretool-check.sh\"'", "timeout": 10 + }, + { + "type": "command", + "command": "bash -lc 'payload=$(cat 2>/dev/null || true); [ -n \"$payload\" ] || exit 0; command -v jq >/dev/null 2>&1 || exit 0; root=$(pwd -P) || exit 0; [ -x \"$root/bin/fm-vault-pretool-check.sh\" ] || exit 0; [ -f \"$root/AGENTS.md\" ] || exit 0; [ -f \"$root/.codex/hooks.json\" ] || exit 0; jq -e \"any(.hooks.PreToolUse[]?.hooks[]?.command?; type == \\\"string\\\" and contains(\\\"fm-vault-pretool-check.sh\\\"))\" \"$root/.codex/hooks.json\" >/dev/null 2>&1 || exit 0; printf \"%s\" \"$payload\" | \"$root/bin/fm-vault-pretool-check.sh\"'", + "timeout": 10 } ] } diff --git a/.grok/hooks/fm-primary-vault-check.json b/.grok/hooks/fm-primary-vault-check.json new file mode 100644 index 00000000000..e18bab8ebae --- /dev/null +++ b/.grok/hooks/fm-primary-vault-check.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash -lc '[ -n \"${GROK_WORKSPACE_ROOT:-}\" ] || exit 0; exec \"${GROK_WORKSPACE_ROOT:-}/bin/fm-vault-pretool-check.sh\"'", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/.opencode/plugins/fm-primary-vault-check.js b/.opencode/plugins/fm-primary-vault-check.js new file mode 100644 index 00000000000..667117a9eae --- /dev/null +++ b/.opencode/plugins/fm-primary-vault-check.js @@ -0,0 +1,64 @@ +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { spawn } from "node:child_process"; + +// PreToolUse seatbelt for OpenCode: the vault guard +// (bin/fm-vault-pretool-check.sh, docs/vault-guard.md) denies infisical +// commands that would print secret VALUES into the transcript. This plugin is +// the primary/secondmate-home transport; crewmate worktrees get a spawn-written +// copy with the checker path baked in (bin/fm-spawn.sh). tool.execute.before +// blocks by throwing, exactly like the sibling arm and cd plugins. Outside a +// firstmate home the checker path does not exist, so runProcess resolves code 0 +// and the plugin is inert by construction. + +function runProcess(command, args) { + return new Promise((resolvePromise) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", () => resolvePromise({ code: 0, stdout: "", stderr: "" })); + child.on("close", (code) => resolvePromise({ code: code ?? 0, stdout, stderr })); + }); +} + +async function resolveRoot(anchor) { + if (!anchor) return ""; + const result = await runProcess("git", ["-C", anchor, "rev-parse", "--show-toplevel"]); + const root = result.stdout.trim(); + if (result.code === 0 && root) return root; + try { + return realpathSync(anchor); + } catch { + return resolve(anchor); + } +} + +export const FmPrimaryVaultCheck = async ({ directory, worktree }) => { + const root = worktree ? (() => { + try { + return realpathSync(worktree); + } catch { + return resolve(worktree); + } + })() : await resolveRoot(directory); + + return { + "tool.execute.before": async (input, output) => { + if (!root || input?.tool !== "bash") return; + const command = output?.args?.command; + if (!command || typeof command !== "string") return; + + const result = await runProcess(`${root}/bin/fm-vault-pretool-check.sh`, ["--command", command]); + if (result.code !== 2) return; + + const reason = result.stderr.trim() || "denied by the vault-guard PreToolUse seatbelt"; + throw new Error(reason); + }, + }; +}; diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index fe90145a196..89e0679acc1 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -71,7 +71,8 @@ function runGuard(): Promise<{ code: number; stderr: string }> { } // PreToolUse seatbelts (bin/fm-arm-pretool-check.sh, docs/arm-pretool-check.md; -// bin/fm-cd-pretool-check.sh, docs/cd-guard.md). Both piggyback on this same +// bin/fm-cd-pretool-check.sh, docs/cd-guard.md; bin/fm-vault-pretool-check.sh, +// docs/vault-guard.md). All piggyback on this same // extension file rather than separate ones so no extra Pi -e flag is needed at // launch - the primary already loads this file for the turn-end guard, and // pi.on("tool_call", ...) can block (verified 2026-07-09 against pi 0.80.5: @@ -99,6 +100,10 @@ function runCdCheck(command: string): Promise<{ code: number; stderr: string }> return runChecker("fm-cd-pretool-check.sh", command); } +function runVaultCheck(command: string): Promise<{ code: number; stderr: string }> { + return runChecker("fm-vault-pretool-check.sh", command); +} + export default function (pi: ExtensionAPI) { pi.on?.("session_start", () => { markLoaded(); @@ -112,6 +117,10 @@ export default function (pi: ExtensionAPI) { if (cdResult.code === 2) { return { block: true, reason: cdResult.stderr.trim() || "denied by the cd-guard PreToolUse seatbelt" }; } + const vaultResult = await runVaultCheck(command); + if (vaultResult.code === 2) { + return { block: true, reason: vaultResult.stderr.trim() || "denied by the vault-guard PreToolUse seatbelt" }; + } const result = await runPretoolCheck(command); if (result.code !== 2) return {}; return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" }; diff --git a/bin/fm-arm-command-policy.mjs b/bin/fm-arm-command-policy.mjs index 846965fa9a5..10102e20a4e 100755 --- a/bin/fm-arm-command-policy.mjs +++ b/bin/fm-arm-command-policy.mjs @@ -6,12 +6,15 @@ // 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. +// The tokenizer, command-position analysis, and generic execution-sink helpers +// (Lexer, splitProgram, commandPosition, shellInvocation, evalPayload, +// shellHeredocPayloads, shellHereStringPayloads) are exported so the sibling +// cd-guard policy (bin/fm-cd-command-policy.mjs) and vault-guard policy +// (bin/fm-vault-command-policy.mjs) reuse the same proven parser instead of +// duplicating shell lexing; see docs/cd-guard.md and docs/vault-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"; @@ -216,7 +219,7 @@ export class Lexer { if (redirection.value === "<<" || redirection.value === "<<-") this.expectHeredoc = { token, stripTabs: redirection.value === "<<-" }; continue; } - if (char === "(" || char === "{") { + if (char === "(" || (char === "{" && /\s/.test(this.source[this.index + 1] || ""))) { const close = char === "(" ? ")" : "}"; const balanced = extractBalanced(this.source, this.index + 1, char, close); if (!balanced) { @@ -614,7 +617,7 @@ function hasUnclassifiableProtectedExpansion(word, root) { return /(?:^|\/)fm-watch/.test(word.value); } -function shellInvocation(position) { +export function shellInvocation(position) { if (!position.command) return null; const name = basename(position.command.value); if (!["sh", "bash", "zsh"].includes(name)) return null; @@ -636,13 +639,13 @@ function shellInvocation(position) { return { kind: "stdin", payload: null }; } -function shellHeredocPayloads(tokens, position) { +export 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) { +export function shellHereStringPayloads(tokens, position) { if (shellInvocation(position)?.kind !== "stdin") return []; const payloads = []; for (let i = 0; i < tokens.length; i += 1) { @@ -659,7 +662,7 @@ function sourcedScript(position) { return position.words[position.index + 1] || null; } -function evalPayload(position) { +export 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; diff --git a/bin/fm-secrets-names.sh b/bin/fm-secrets-names.sh new file mode 100755 index 00000000000..28dd8611824 --- /dev/null +++ b/bin/fm-secrets-names.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# fm-secrets-names.sh - the ONLY sanctioned way to list Infisical secret NAMES. +# +# Incident 2026-07-30: a crewmate listed secret VALUES into its session +# transcript with a raw infisical listing command. bin/fm-vault-pretool-check.sh +# now denies every value-printing infisical form; this wrapper is the sanctioned +# replacement for the one legitimate need those forms served - discovering what +# secrets exist. See docs/vault-guard.md for the full contract. +# +# Contract: +# - Prints secret NAMES only, one per line, to stdout. Never a value. +# - Structurally strips, never filters: it fetches machine-readable JSON from +# the Infisical CLI and emits only the name fields the parser proves are +# names. It never passes CLI stdout through. +# - Fail closed: if the CLI fails, jq is missing, or the JSON does not match a +# known export shape exactly, it prints NOTHING on stdout and exits +# non-zero. There is no raw-output fallback path. +# - The CLI's stderr (auth errors, tips) passes through untouched; Infisical +# writes secret material to stdout only, and stdout is never passed through. +# +# Usage: +# fm-secrets-names.sh --projectId --env [--path ] +# +# Both --projectId and --env are required so a listing is always explicit about +# what it lists; --path narrows to a folder (Infisical's default is /). +# Authentication is ambient (infisical login or INFISICAL_TOKEN), exactly as +# for any other infisical invocation. +set -u + +usage() { + sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//' +} + +PROJECT_ID="" +ENV_SLUG="" +FOLDER="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --projectId) + [ "$#" -gt 1 ] || { echo "error: --projectId requires a value" >&2; exit 2; } + PROJECT_ID=$2 + shift 2 + ;; + --projectId=*) + PROJECT_ID=${1#--projectId=} + shift + ;; + --env) + [ "$#" -gt 1 ] || { echo "error: --env requires a value" >&2; exit 2; } + ENV_SLUG=$2 + shift 2 + ;; + --env=*) + ENV_SLUG=${1#--env=} + shift + ;; + --path) + [ "$#" -gt 1 ] || { echo "error: --path requires a value" >&2; exit 2; } + FOLDER=$2 + shift 2 + ;; + --path=*) + FOLDER=${1#--path=} + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1 (this wrapper lists names only and takes --projectId, --env, --path)" >&2 + usage >&2 + exit 2 + ;; + esac +done + +[ -n "$PROJECT_ID" ] || { echo "error: --projectId is required" >&2; usage >&2; exit 2; } +[ -n "$ENV_SLUG" ] || { echo "error: --env is required" >&2; usage >&2; exit 2; } + +command -v infisical >/dev/null 2>&1 || { echo "error: infisical CLI not found on PATH" >&2; exit 3; } +command -v jq >/dev/null 2>&1 || { echo "error: jq not found on PATH; refusing to print anything without a structural parse" >&2; exit 3; } + +EXPORT_ARGS=(export --projectId "$PROJECT_ID" --env "$ENV_SLUG" --format json --silent) +[ -z "$FOLDER" ] || EXPORT_ARGS+=(--path "$FOLDER") + +# CLI stdout is captured and NEVER printed; only jq-proven name fields are. +if ! RAW=$(infisical "${EXPORT_ARGS[@]}"); then + echo "error: infisical export failed; nothing printed" >&2 + exit 3 +fi + +# Accept exactly the two machine shapes infisical export --format json is known +# to emit, and nothing else: +# - an array of objects that each carry a string .key (name field), +# - a flat object whose values are all strings, whose keys are the secret names. +# Any other shape aborts with no stdout at all. jq output is captured first and +# printed only after a fully successful parse, so a mid-stream jq error can +# never leave partial output behind. +if ! NAMES=$(printf '%s' "$RAW" | jq -r ' + if type == "array" then + if all(.[]; type == "object" and (.key | type == "string")) then .[].key + else error("unrecognized element shape") end + elif type == "object" then + if all(.[]; type == "string") then keys_unsorted[] + else error("unrecognized object shape") end + else error("unrecognized document shape") end +' 2>/dev/null); then + echo "error: infisical export output did not match a known JSON shape; refusing to print anything" >&2 + exit 3 +fi + +[ -z "$NAMES" ] || printf '%s\n' "$NAMES" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index a4ec0890cee..1066ec8dbc5 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -326,7 +326,12 @@ launch_template() { if [ "$kind" = secondmate ]; then printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(cat __BRIEF__)"' else - printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c "notify=[\"bash\",\"-c\",\"touch __TURNEND__\"]" "$(cat __BRIEF__)"' + # --dangerously-bypass-hook-trust lets the worktree .codex/hooks.json + # written below (the vault guard) load without a trust dialog; without + # it the hook file would be inert or wedge the pane on a trust prompt. + # Risk-equivalent to the existing approvals/sandbox bypass this + # unattended crewmate already runs with (docs/vault-guard.md). + printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust -c "notify=[\"bash\",\"-c\",\"touch __TURNEND__\"]" "$(cat __BRIEF__)"' fi ;; opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(cat __BRIEF__)"' ;; @@ -882,12 +887,16 @@ exclude_path() { mkdir -p "$(dirname "$EXCL")" grep -qxF "$rel" "$EXCL" 2>/dev/null || echo "$rel" >> "$EXCL" } +# The vault guard (docs/vault-guard.md) is installed alongside each harness's +# turn-end hook: an absolute path to this firstmate's checker, because a task +# worktree is a project repo that does not contain firstmate's bin/. +VAULT_CHECK="$FM_ROOT/bin/fm-vault-pretool-check.sh" if [ "$KIND" != secondmate ]; then case "$HARNESS" in claude*) mkdir -p "$WT/.claude" cat > "$WT/.claude/settings.local.json" < ({ }) EOF exclude_path '.opencode/plugins/fm-turn-end.js' + cat > "$WT/.opencode/plugins/fm-vault-guard.js" < ({ + "tool.execute.before": async (input, output) => { + if (input?.tool !== "bash") return; + const command = output?.args?.command; + if (!command || typeof command !== "string") return; + const result = await new Promise((resolvePromise) => { + const child = spawn("$VAULT_CHECK", ["--command", command], { stdio: ["ignore", "ignore", "pipe"] }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); + child.on("error", () => resolvePromise({ code: 0, stderr: "" })); + child.on("close", (code) => resolvePromise({ code: code ?? 0, stderr })); + }); + if (result.code !== 2) return; + throw new Error(result.stderr.trim() || "denied by the vault-guard PreToolUse seatbelt"); + }, +}); +EOF + exclude_path '.opencode/plugins/fm-vault-guard.js' ;; pi*) # Written OUTSIDE the worktree: pi's project-trust gate fires on any extension # loaded from inside the project (verified live), but an explicit -e path # elsewhere loads without a dialog. Lives in state/, cleaned by teardown. cat > "$STATE/$ID.pi-ext.ts" < execFile("touch", ["$TURNEND"])); + pi.on("tool_call", async (event: any) => { + if (event.type !== "tool_call" || event.toolName !== "bash") return {}; + const command = String((event.input as { command?: unknown })?.command ?? ""); + if (!command) return {}; + const result = await new Promise<{ code: number; stderr: string }>((resolveResult) => { + const child = spawn("$VAULT_CHECK", ["--command", command], { stdio: ["ignore", "ignore", "pipe"] }); + let stderr = ""; + child.stderr.on("data", (chunk: any) => { stderr += chunk.toString(); }); + child.on("error", () => resolveResult({ code: 0, stderr: "" })); + child.on("close", (code: number | null) => resolveResult({ code: code ?? 0, stderr })); + }); + if (result.code !== 2) return {}; + return { block: true, reason: result.stderr.trim() || "denied by the vault-guard PreToolUse seatbelt" }; + }); } EOF ;; codex*) - # codex: turn-end rides the launch command via -c notify=[...] and __TURNEND__. + # codex: turn-end rides the launch command via -c notify=[...] and + # __TURNEND__. The vault guard needs a worktree .codex/hooks.json (codex + # loads hooks from the cwd), which the launch template's + # --dangerously-bypass-hook-trust lets load without a trust dialog. A + # project that tracks its own .codex/hooks.json is left untouched - + # overwriting a tracked file would dirty the worktree - and the uncovered + # task is warned loudly (docs/vault-guard.md owns this gap). + if [ -e "$WT/.codex/hooks.json" ]; then + echo "warning: $WT/.codex/hooks.json already exists (project-tracked?); vault guard NOT installed for this codex task - see docs/vault-guard.md" >&2 + else + mkdir -p "$WT/.codex" + # shellcheck disable=SC2016 # single quotes are deliberate: $payload/$c expand when codex runs the hook, not here + vault_hook_body='payload=$(cat 2>/dev/null || true); [ -n "$payload" ] || exit 0; c='"$(shell_quote "$VAULT_CHECK")"'; [ -x "$c" ] || exit 0; printf "%s" "$payload" | "$c"' + printf '{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"%s","timeout":10}]}]}}\n' \ + "$(json_escape "bash -lc $(shell_quote "$vault_hook_body")")" > "$WT/.codex/hooks.json" + exclude_path '.codex/hooks.json' + fi ;; grok*) # grok fires a Stop hook at every turn boundary (verified, grok 0.2.73), the @@ -968,6 +1032,28 @@ EOF printf '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"%s"}]}]}}\n' "$hook_command" > "$GROK_HOOKS_DIR/fm-turn-end.json" printf 'token=%s\n' "${auth_file##*/}" > "$WT/.fm-grok-turnend" exclude_path '.fm-grok-turnend' + # Vault guard (docs/vault-guard.md): grok crewmate worktrees never hold + # hook trust, so like the turn-end hook this rides a firstmate-owned + # GLOBAL hook. It is a guarded no-op for every non-firstmate grok + # session: it fires only when the current workspace holds the + # .fm-grok-turnend crewmate pointer written above. Unlike the turn-end + # hook it needs no token registry - it writes nothing pointer-derived, + # only classifies the stdin payload through the baked firstmate checker. + sq_vault_check=$(shell_quote "$VAULT_CHECK") + cat > "$GROK_HOOKS_DIR/fm-vault-guard.sh" < "$GROK_HOOKS_DIR/fm-vault-guard.json" ;; esac fi diff --git a/bin/fm-vault-command-policy.mjs b/bin/fm-vault-command-policy.mjs new file mode 100755 index 00000000000..8d32c25ed41 --- /dev/null +++ b/bin/fm-vault-command-policy.mjs @@ -0,0 +1,575 @@ +#!/usr/bin/env node +// Semantic policy for the vault-guard: can a shell command print secret VALUES +// from the Infisical vault into the session transcript? +// +// Incident 2026-07-30: a crewmate's first Infisical command listed secret +// values into its session transcript; the brief said names-only, and +// instruction-following failed where enforcement did not exist. This policy is +// the enforcement: it allows only the value-safe infisical forms and denies +// every value-printing or unclassifiable one, fail closed. The harness +// transport lives in bin/fm-vault-pretool-check.sh; the full contract and +// validation record live in docs/vault-guard.md. +// +// Allowed forms (everything else that executes infisical denies): +// - infisical run ... -- , infisical run --command '': the +// injection form - secrets go to a child process env, never to output - +// EXCEPT when the child is a known env-dump shape (env/printenv/set/ +// export/declare/typeset, an echo/printf carrying a $, or a shell payload +// that reaches one of those). +// - infisical login / infisical init / infisical help. +// - --help, -h, or --version before any `--` terminator. +// - bin/fm-secrets-names.sh (the sanctioned names-only listing wrapper) is +// allowed by construction: it never matches the infisical token, so it +// never reaches a deny path here or in the transport prefilter. +// +// The shell tokenizer, command-position analysis, and execution-sink helpers +// 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 positions only. +// +// Fail-closed contract (the deliberate difference from the cd-guard): syntax +// this classifier cannot prove safe - a lexer error, unsupported compound +// grammar, an unresolved wrapper option, a dynamic (non-literal) execution +// payload, or a dynamic command word - denies whenever the raw command also +// carries the infisical token, exactly as the watcher-arm guard fails closed +// on unclassifiable protected commands. Opaque dataflow that never carries the +// token (bash -c "$WHOLE_COMMAND" with no infisical bytes anywhere) remains +// out of scope by the same agent-mistake threat model. + +import { + Lexer, + splitProgram, + commandPosition, + shellInvocation, + evalPayload, + shellHeredocPayloads, + shellHereStringPayloads, +} from "./fm-arm-command-policy.mjs"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const REASONS = { + "vault-secret-print": + "this infisical command can print secret VALUES into the session transcript. Sanctioned paths: bin/fm-secrets-names.sh --projectId --env lists secret NAMES only; infisical run [flags] -- injects secrets into a child process env without printing them.", + "vault-run-dump": + "this infisical run child command would dump the injected secret environment into the transcript. Run the real workload command under infisical run instead; list secret NAMES with bin/fm-secrets-names.sh.", + "unclassifiable-vault-command": + "unsupported, malformed, or dynamic shell syntax carries an infisical command that cannot be safely classified. Use a plain infisical run [flags] -- to inject secrets, or bin/fm-secrets-names.sh --projectId --env to list secret names.", +}; + +// Child commands that print the environment they run in. `set`, `export`, +// `declare`, and `typeset` are shell builtins that dump the environment when +// bare, so they matter inside `sh -c` payloads and are matched in direct child +// position too (fail closed - they can never be a real workload binary). +const DUMP_COMMANDS = new Set(["env", "printenv", "set", "export", "declare", "typeset"]); +const ECHO_COMMANDS = new Set(["echo", "printf"]); +// Commands that execute their argument words, which command-position analysis +// alone would treat as data. Denied whenever an argument carries the infisical +// token: the sanctioned forms never need an indirect executor. +const EXEC_FORWARDERS = new Set([ + "xargs", + "parallel", + "watch", + "nice", + "ionice", + "stdbuf", + "setsid", + "doas", + "script", + "caffeinate", + "chronic", + "unbuffer", + "hyperfine", +]); +const FORWARDER_OPTIONS = { + nice: { + shortNoArgument: new Set(), + shortTakesArgument: new Set(["n"]), + longNoArgument: new Set(["help", "version"]), + longTakesArgument: new Set(["adjustment"]), + }, + watch: { + shortNoArgument: new Set(["b", "c", "e", "g", "q", "t", "w"]), + shortTakesArgument: new Set(["n"]), + longNoArgument: new Set(["beep", "color", "chgexit", "errexit", "exec", "no-rerun", "no-title", "precise", "quiet", "title"]), + longTakesArgument: new Set(["interval"]), + }, + stdbuf: { + shortNoArgument: new Set(), + shortTakesArgument: new Set(["i", "o", "e"]), + longNoArgument: new Set(["help", "version"]), + longTakesArgument: new Set(["input", "output", "error"]), + }, + xargs: { + shortNoArgument: new Set(["0", "r", "t", "x"]), + shortTakesArgument: new Set(["E", "e", "I", "i", "J", "L", "l", "n", "P", "p", "s"]), + longNoArgument: new Set(["exit", "no-run-if-empty", "null", "verbose", "help", "version"]), + longTakesArgument: new Set(["eof", "replace", "delimiter", "max-lines", "max-args", "max-procs", "max-chars"]), + }, +}; +const UNSUPPORTED_KEYWORDS = new Set([ + "if", + "then", + "else", + "elif", + "fi", + "for", + "while", + "until", + "case", + "esac", + "do", + "done", + "function", + "time", + "coproc", +]); +const MAX_DEPTH = 12; + +function basename(value) { + return value.split("/").filter(Boolean).at(-1) || value; +} + +// macOS's default filesystem is case-insensitive, so `Infisical secrets` +// executes the real binary; every token match here is therefore +// case-insensitive (the transport prefilter mirrors this). +function isInfisicalName(value) { + return basename(value).toLowerCase() === "infisical"; +} + +// Raw-byte token test used only to gate the fail-closed fallbacks; it mirrors +// the transport prefilter's cheap byte strip so an escape- or quote-split +// token (infi\sical, in"fisical") still counts as a mention. Positive +// classification of executed positions works on cooked words and does not use +// this. +function mentionsInfisical(text) { + return /infisical/i.test(String(text).replace(/[\\'"\r\n]/g, "")); +} + +function dynamicWord(word) { + return Boolean(word) && (!word.literal || word.subs.length > 0); +} + +// `command -v infisical` is an existence query, not an execution (the same +// carve-out the cd-guard makes for `command -v cd`). +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 deny(code) { + return { deny: code }; +} + +function combine(target, nested, source) { + if (nested.deny) { + target.deny = target.deny || nested.deny; + return; + } + // Nested trouble matters only when the nested source can actually carry an + // infisical command (the same gating the arm guard applies to nested + // payload errors). + if ((nested.unsupported || nested.dynamic) && mentionsInfisical(source)) { + target.unsupported = true; + } +} + +function forwarderChild(name, words, index) { + const options = FORWARDER_OPTIONS[name]; + let next = index; + while (words[next]) { + const value = words[next].value; + if (value === "--") return { words: words.slice(next + 1) }; + if (!value.startsWith("-") || value === "-") return { words: words.slice(next) }; + if (!options) return { unresolved: true }; + if (value.startsWith("--")) { + const equals = value.indexOf("="); + const option = value.slice(2, equals === -1 ? undefined : equals); + if (options.longNoArgument.has(option)) { + if (equals !== -1) return { unresolved: true }; + next += 1; + continue; + } + if (!options.longTakesArgument.has(option)) return { unresolved: true }; + if (equals !== -1) { + next += 1; + continue; + } + if (!words[next + 1]) return { unresolved: true }; + next += 2; + continue; + } + let consumedArgument = false; + for (let offset = 1; offset < value.length; offset += 1) { + const option = value[offset]; + if (options.shortNoArgument.has(option)) continue; + if (!options.shortTakesArgument.has(option)) return { unresolved: true }; + if (offset + 1 === value.length && !words[next + 1]) return { unresolved: true }; + next += offset + 1 === value.length ? 2 : 1; + consumedArgument = true; + break; + } + if (!consumedArgument) next += 1; + } + return { words: [] }; +} + +// --- infisical invocation classification ------------------------------------ + +function classifyInfisical(position, tokens, depth) { + const args = position.words.slice(position.index + 1); + const terminator = args.findIndex((word) => word.value === "--"); + const pre = terminator === -1 ? args : args.slice(0, terminator); + if (pre.some((word) => word.value === "--help" || word.value === "-h" || word.value === "--version")) { + return {}; + } + const subIndex = pre.findIndex((word) => !word.value.startsWith("-")); + if (subIndex === -1) return deny("vault-secret-print"); + const sub = pre[subIndex]; + if (dynamicWord(sub)) return deny("unclassifiable-vault-command"); + if (sub.value === "login" || sub.value === "init" || sub.value === "help") return {}; + if (sub.value !== "run") return deny("vault-secret-print"); + return classifyRun(position, tokens, args, subIndex, terminator, depth); +} + +function classifyRun(position, tokens, args, subIndex, terminator, depth) { + const result = {}; + // --command payloads are full shell command strings executed with the + // injected environment; classify each literal payload as a dump-context + // program, and fail closed on a dynamic one. + const pre = terminator === -1 ? args : args.slice(0, terminator); + for (let i = subIndex + 1; i < pre.length; i += 1) { + const word = pre[i]; + let payloadWord = null; + let inlinePrefix = 0; + if (word.value === "--command" || word.value === "-c") { + payloadWord = pre[i + 1] || null; + i += 1; + } else if (word.value.startsWith("--command=")) { + payloadWord = word; + inlinePrefix = "--command=".length; + } else if (word.value.startsWith("-c") && word.value.length > 2 && !word.value.startsWith("--")) { + // Cobra accepts the glued short form: -c'printenv' cooks to -cprintenv, + // and -c=x means the same payload. + payloadWord = word; + inlinePrefix = word.value.startsWith("-c=") ? 3 : 2; + } + if (!payloadWord) continue; + if (dynamicWord(payloadWord)) return deny("unclassifiable-vault-command"); + const payload = payloadWord.value.slice(inlinePrefix); + const nested = classifyDumpProgram(payload, depth + 1); + if (nested.deny) return nested; + combine(result, nested, payload); + } + let childWords = []; + if (terminator !== -1) { + childWords = args.slice(terminator + 1); + } else { + // Bare-word child form (infisical run ): the first non-flag word + // after `run` starts the child. A separate-value flag (--env dev) misreads + // its value as the child; that costs at worst a conservative deny, never a + // missed dump. + for (let i = subIndex + 1; i < args.length; i += 1) { + if (!args[i].value.startsWith("-")) { + childWords = args.slice(i); + break; + } + } + } + if (childWords.length > 0) { + const nested = classifyChild(childWords, tokens, depth); + if (nested.deny) return nested; + combine(result, nested, childWords.map((word) => word.value).join(" ")); + } + if (result.unsupported && !result.deny) return deny("unclassifiable-vault-command"); + return result; +} + +// Classify the command a `infisical run` child position executes. `tokens` is +// the enclosing node's token list so a heredoc feeding a stdin-mode shell +// child is still visible. +function classifyChild(childWords, tokens, depth) { + if (depth > MAX_DEPTH) return deny("unclassifiable-vault-command"); + const position = commandPosition(childWords); + if (position.unresolvedWrapperOption) return deny("unclassifiable-vault-command"); + const result = {}; + for (const payload of position.wrapperPayloads) { + const nested = classifyDumpProgram(payload, depth + 1); + if (nested.deny) return nested; + combine(result, nested, payload); + } + let command = position.command; + let commandIndex = position.index; + if (!command) { + // commandPosition consumes `env` as a wrapper; with no command after it, + // bare `env` (or `env FOO=1`) prints the whole injected environment. + if (position.wrappers.includes("env")) return deny("vault-run-dump"); + return result; + } + // `builtin` is not a commandPosition wrapper; skip it so `builtin export` + // inside a shell payload still hits the dump check. + while (command && basename(command.value) === "builtin") { + commandIndex += 1; + command = position.words[commandIndex]; + } + if (!command) return result; + if (dynamicWord(command)) return deny("unclassifiable-vault-command"); + const name = basename(command.value).toLowerCase(); + if (DUMP_COMMANDS.has(name)) return deny("vault-run-dump"); + if (ECHO_COMMANDS.has(name)) { + const echoed = position.words.slice(commandIndex + 1); + if (echoed.some((word) => dynamicWord(word) || word.value.includes("$"))) { + return deny("vault-run-dump"); + } + return result; + } + if (name === "infisical") { + if (hasCommandQueryPrefix(position)) return result; + const nested = classifyInfisical(position, tokens, depth + 1); + if (nested.deny) return nested; + combine(result, nested, position.words.map((word) => word.value).join(" ")); + return result; + } + if (name === "sh" || name === "bash" || name === "zsh") { + const shell = shellInvocation(position); + // A case-variant shell name (SH) or a builtin-prefixed one defeats the + // case-sensitive shared shellInvocation; in dump context that is + // unclassifiable, so fail closed. + if (!shell) return deny("unclassifiable-vault-command"); + if (shell.kind === "command") { + if (!shell.payload) return result; + if (dynamicWord(shell.payload)) return deny("unclassifiable-vault-command"); + const nested = classifyDumpProgram(shell.payload.value, depth + 1); + if (nested.deny) return nested; + combine(result, nested, shell.payload.value); + return result; + } + if (shell.kind === "script") { + // An opaque script file cannot be classified; fail closed only when its + // name carries the token, otherwise it is the workload's own business. + if (shell.payload && mentionsInfisical(shell.payload.value)) return deny("unclassifiable-vault-command"); + return result; + } + for (const body of [...shellHeredocPayloads(tokens, position), ...shellHereStringPayloads(tokens, position)]) { + const nested = classifyDumpProgram(body, depth + 1); + if (nested.deny) return nested; + combine(result, nested, body); + } + return result; + } + if (name === "eval") { + const payload = evalPayload(position); + if (payload === null) return deny("unclassifiable-vault-command"); + const nested = classifyDumpProgram(payload, depth + 1); + if (nested.deny) return nested; + combine(result, nested, payload); + return result; + } + if (EXEC_FORWARDERS.has(name)) { + const forwarded = forwarderChild(name, position.words, commandIndex + 1); + if (forwarded.unresolved) return deny("unclassifiable-vault-command"); + if (forwarded.words.length > 0) return classifyChild(forwarded.words, tokens, depth + 1); + return result; + } + return result; +} + +// A program that runs WITH secrets injected into its environment (an +// `infisical run --command` payload, a shell child's -c payload, a heredoc fed +// to a stdin-mode shell child). Inside this context there is no +// mention-gating: anything unclassifiable denies, because the environment it +// runs in is already the protected material. +function classifyDumpProgram(source, depth) { + if (depth > MAX_DEPTH) return deny("unclassifiable-vault-command"); + const lexed = new Lexer(source).tokenize(); + if (lexed.error) return deny("unclassifiable-vault-command"); + const { nodes } = splitProgram(lexed.tokens); + for (const tokens of nodes) { + const position = commandPosition(tokens); + const firstName = basename(position.words[0]?.value || ""); + if (UNSUPPORTED_KEYWORDS.has(firstName)) return deny("unclassifiable-vault-command"); + for (const token of tokens) { + if (token.type === "group") { + const nested = classifyDumpProgram(token.content, depth + 1); + if (nested.deny) return nested; + } + if (token.type === "word") { + for (const substitution of token.subs) { + const nested = classifyDumpProgram(substitution.content, depth + 1); + if (nested.deny) return nested; + } + } + } + const nested = classifyChild(position.words, tokens, depth + 1); + if (nested.deny) return nested; + if (nested.unsupported || nested.dynamic) return deny("unclassifiable-vault-command"); + } + return {}; +} + +// --- top-level program classification --------------------------------------- + +function classifyProgram(source, depth) { + const result = {}; + if (depth > MAX_DEPTH) { + result.unsupported = true; + return result; + } + const lexed = new Lexer(source).tokenize(); + if (lexed.error) { + result.unsupported = true; + return result; + } + const { nodes } = splitProgram(lexed.tokens); + for (const tokens of nodes) { + const position = commandPosition(tokens); + const firstName = basename(position.words[0]?.value || ""); + if (UNSUPPORTED_KEYWORDS.has(firstName)) result.unsupported = true; + if (position.unresolvedWrapperOption) result.unsupported = true; + for (const payload of position.wrapperPayloads) { + combine(result, classifyProgram(payload, depth + 1), payload); + if (result.deny) return result; + } + for (const token of tokens) { + if (token.type === "group") { + combine(result, classifyProgram(token.content, depth + 1), token.content); + if (result.deny) return result; + } + if (token.type === "word") { + for (const substitution of token.subs) { + combine(result, classifyProgram(substitution.content, depth + 1), substitution.content); + if (result.deny) return result; + } + } + } + const command = position.command; + if (!command) continue; + const name = basename(command.value).toLowerCase(); + if (name === "sh" || name === "bash" || name === "zsh") { + const shell = shellInvocation(position); + if (!shell) { + // A case-variant shell name (SH) defeats the case-sensitive shared + // shellInvocation; treat as unsupported so a token-carrying command + // still fails closed. + result.unsupported = true; + } else if (shell.kind === "command" && shell.payload) { + if (dynamicWord(shell.payload)) { + result.dynamic = true; + } else { + combine(result, classifyProgram(shell.payload.value, depth + 1), shell.payload.value); + if (result.deny) return result; + } + } else if (shell.kind === "script") { + if (shell.payload && mentionsInfisical(shell.payload.value)) result.unsupported = true; + } else if (shell.kind === "stdin") { + for (const body of [...shellHeredocPayloads(tokens, position), ...shellHereStringPayloads(tokens, position)]) { + combine(result, classifyProgram(body, depth + 1), body); + if (result.deny) return result; + } + } + } else if (name === "eval") { + const payload = evalPayload(position); + if (payload === null) { + result.dynamic = true; + } else { + combine(result, classifyProgram(payload, depth + 1), payload); + if (result.deny) return result; + } + } else if (isInfisicalName(command.value)) { + if (!hasCommandQueryPrefix(position)) { + const nested = classifyInfisical(position, tokens, depth); + if (nested.deny) { + result.deny = nested.deny; + return result; + } + if (nested.unsupported) result.unsupported = true; + } + } else if (EXEC_FORWARDERS.has(name)) { + if (position.words.some((word) => mentionsInfisical(word.value))) { + result.deny = "unclassifiable-vault-command"; + return result; + } + } else if (dynamicWord(command)) { + // A command word this classifier cannot resolve ($X, $(pick-tool)); + // matters only when the raw command also carries the token. + result.dynamic = true; + } + } + return result; +} + +function decision(command) { + const result = classifyProgram(command, 0); + if (result.deny) { + return { decision: "deny", code: result.deny, reason: REASONS[result.deny] }; + } + if ((result.unsupported || result.dynamic) && mentionsInfisical(command)) { + return { + decision: "deny", + code: "unclassifiable-vault-command", + reason: REASONS["unclassifiable-vault-command"], + }; + } + 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-vault-pretool-check.sh b/bin/fm-vault-pretool-check.sh new file mode 100755 index 00000000000..d6f7a27049a --- /dev/null +++ b/bin/fm-vault-pretool-check.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Stable PreToolUse transport for the vault-guard command policy. +# +# Incident 2026-07-30: a crewmate's first Infisical command printed secret +# VALUES into its session transcript. This seatbelt makes that structurally +# impossible: it denies every value-printing infisical form before it runs, in +# every crewmate, scout, secondmate, and primary session it is installed into. +# bin/fm-vault-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 acquires the harness +# payload, invokes that policy, and renders the established harness-specific +# responses. It never executes, sources, evaluates, or expands the command. +# See docs/vault-guard.md for the complete contract, the per-harness install +# mechanism, and the validation record. +# +# Usage: +# | bin/fm-vault-pretool-check.sh +# bin/fm-vault-pretool-check.sh --command '' [--claude] +# +# Stdin mode extracts .toolInput.command for Grok or .tool_input.command for +# Claude and Codex. CLI mode is used by OpenCode and Pi after their adapters +# extract the exact command string. +# +# Exit/output contract (identical shape to bin/fm-arm-pretool-check.sh): +# ALLOW - exit 0 and no output. +# DENY - exit 2, a Claude-shaped deny object on stderr, and a Grok-shaped +# deny object on stdout unless --claude was supplied. +# FAIL OPEN - malformed or empty stdin, missing jq for stdin transport, +# missing Node or policy owner, or an invalid policy response. +# +# Unlike the cd-guard there is no environment scoping: printing a secret is +# wrong everywhere this guard is installed, so the guard fires in any +# directory. Semantic fail-closed behavior (malformed or dynamic shell around +# the infisical token denies) is the policy owner's, not this transport's. +# +# Claude requires stdout to remain empty on deny. +# Codex blocks on exit 2 and displays stderr. +# Grok consumes the stdout decision object. +# OpenCode and Pi consume exit 2 plus stderr. +set -u + +CMD="" +CMD_SET=0 +CLAUDE_MODE=0 + +usage() { + cat <<'EOF' +Usage: fm-vault-pretool-check.sh [--command ] [--claude] + +With no --command, reads a PreToolUse-style JSON payload on stdin (Grok +toolInput.command, or Claude/Codex tool_input.command). +Exits 0 to allow and 2 to deny a value-printing infisical command. +The deny reason is written to stderr, with a Grok decision object on stdout +unless --claude is supplied. +Malformed transport and an unavailable classifier runtime fail open; malformed +shell around the infisical token fails closed inside the policy owner. +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) + CLAUDE_MODE=1 + 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 '(.toolInput.command // .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 the infisical token, so ordinary quoted or escaped +# fragments cannot hide a deniable vault command 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 the token 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'/} +# The bracket pattern is the case-insensitive form of *infisical*: macOS's +# default filesystem resolves `Infisical` to the real binary, and the policy +# owner matches the token case-insensitively, so the prefilter must too (a +# lowercase-only test would fast-allow a capitalized invocation past the +# policy). Pure-bash pattern, no fork, macOS bash 3.2 compatible. +case "$CMD" in + *"\$'"*|*'$"'*) ;; + *) + case "$PREFILTER" in + *[Ii][Nn][Ff][Ii][Ss][Ii][Cc][Aa][Ll]*) ;; + *) 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 + +POLICY="$FM_ROOT/bin/fm-vault-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 +[ "$CLAUDE_MODE" -eq 1 ] || printf '{"decision":"deny","reason":"%s"}\n' "$ESCAPED" +exit 2 diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md index 14fdf714aa9..8370e82b2ab 100644 --- a/docs/arm-pretool-check.md +++ b/docs/arm-pretool-check.md @@ -4,7 +4,7 @@ This document is the authoritative human-readable contract for the watcher arm P `bin/fm-arm-command-policy.mjs` is the single semantic owner. `bin/fm-arm-pretool-check.sh` is only the stable harness transport and output renderer. The tracked harness adapters forward command text without classifying it. -`bin/fm-arm-command-policy.mjs` is also the sole owner of firstmate's shell classification: it exports the tokenizer and command-position analysis, which the sibling cd-guard seatbelt (`bin/fm-cd-pretool-check.sh`, `docs/cd-guard.md`) reuses instead of duplicating shell lexing. +`bin/fm-arm-command-policy.mjs` is also the sole owner of firstmate's shell classification: it exports the tokenizer, command-position analysis, and generic execution-sink helpers, which the sibling cd-guard seatbelt (`bin/fm-cd-pretool-check.sh`, `docs/cd-guard.md`) and vault-guard seatbelt (`bin/fm-vault-pretool-check.sh`, `docs/vault-guard.md`) reuse instead of duplicating shell lexing. ## Purpose and boundary diff --git a/docs/cd-guard.md b/docs/cd-guard.md index e324bfeb4d3..8c39c4edadd 100644 --- a/docs/cd-guard.md +++ b/docs/cd-guard.md @@ -5,8 +5,8 @@ This document is the authoritative human-readable contract for the cd-guard PreT `bin/fm-cd-pretool-check.sh` is the stable harness transport, primary-checkout scope, and output renderer. The tracked harness adapters forward command text without classifying it. -It is the third member of a family of primary-session guards that share the same cross-harness 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`, `docs/turnend-guard.md`). +It shares cross-harness hook machinery with the watcher-arm and vault PreToolUse seatbelts and the turn-end supervision guard: +the watcher-arm seatbelt (`bin/fm-arm-pretool-check.sh`, `docs/arm-pretool-check.md`), the vault-guard seatbelt (`bin/fm-vault-pretool-check.sh`, `docs/vault-guard.md`), and the turn-end supervision guard (`bin/fm-turnend-guard.sh`, `docs/turnend-guard.md`). ## Purpose and boundary @@ -118,7 +118,7 @@ The cd-guard never duplicates shell lexing; it adds only the cd-specific decisio | OpenCode | `.opencode/plugins/fm-primary-cd-check.js` `tool.execute.before` | Throws, which surfaces as the failed tool result. | | Pi | `.pi/extensions/fm-primary-turnend-guard.ts` `tool_call` handler | Returns `{block: true}`; piggybacks on the already-loaded primary extension so no extra `-e` flag is needed. | -Each harness runs the cd-guard alongside the watcher-arm seatbelt; the two are independent checks, and either deny blocks the command. +Each harness runs the cd-guard alongside the watcher-arm and vault-guard seatbelts; the three are independent checks, and any deny blocks the command. Every shell variable reference in the Grok hook command carries an inline default (`${GROK_WORKSPACE_ROOT:-}`) because Grok expands the raw hook command before `bash -lc` runs it, the same requirement documented in `docs/arm-pretool-check.md`. ## Automated validation diff --git a/docs/scripts.md b/docs/scripts.md index db420026ebd..aa2c46c1ded 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -23,6 +23,9 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm | `fm-turnend-guard-grok.sh` | Grok Stop-hook adapter for the primary turn-end guard | | `fm-arm-pretool-check.sh` | Stable PreToolUse transport for the watcher-arm command policy (docs/arm-pretool-check.md) | | `fm-arm-command-policy.mjs` | Semantic owner of the watcher-arm PreToolUse policy (docs/arm-pretool-check.md) | +| `fm-vault-pretool-check.sh` | Stable PreToolUse transport for the vault-guard command policy (docs/vault-guard.md) | +| `fm-vault-command-policy.mjs` | Semantic owner of the vault-guard PreToolUse policy (docs/vault-guard.md) | +| `fm-secrets-names.sh` | Fail-closed wrapper that lists Infisical secret names only (docs/vault-guard.md) | | `fm-supervision-instructions.sh` | Render the session-start primary-harness supervision block or the one-line repair instruction | | `fm-home-seed.sh` | Transactionally provision a secondmate home and maintain `data/secondmates.md` | | `fm-spawn.sh` | Spawn crewmates, scouts, `id=repo` batches, and secondmates on the resolved harness and runtime backend | diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 3444171615d..33e78d4daff 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -3,7 +3,7 @@ This is the authoritative contract for the "no turn ends blind" primary guard referenced from AGENTS.md section 8. The shared predicate lives in `bin/fm-turnend-guard.sh`. Harness-specific tracked hook files only adapt each verified harness's real turn-end mechanism to that shared predicate. -Two related but separate PreToolUse seatbelts deny a bad command shape before it runs rather than detecting a blind turn end afterward: the watcher-arm seatbelt (`bin/fm-arm-pretool-check.sh`, `docs/arm-pretool-check.md`) and the cd-guard (`bin/fm-cd-pretool-check.sh`, `docs/cd-guard.md`). +Three related but separate PreToolUse seatbelts deny a bad command shape before it runs rather than detecting a blind turn end afterward: the watcher-arm seatbelt (`bin/fm-arm-pretool-check.sh`, `docs/arm-pretool-check.md`), the cd-guard (`bin/fm-cd-pretool-check.sh`, `docs/cd-guard.md`), and the vault-guard seatbelt (`bin/fm-vault-pretool-check.sh`, `docs/vault-guard.md`). Each seatbelt's own document defines its scope; they do not share the turn-end guard's marker-aware primary detection. ## Gap Closed diff --git a/docs/vault-guard.md b/docs/vault-guard.md new file mode 100644 index 00000000000..4cfb20620ad --- /dev/null +++ b/docs/vault-guard.md @@ -0,0 +1,116 @@ +# Vault-guard PreToolUse seatbelt + +This document is the authoritative human-readable contract for the vault-guard PreToolUse seatbelt. +`bin/fm-vault-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-vault-pretool-check.sh` is the stable harness transport and output renderer. +`bin/fm-secrets-names.sh` is the sanctioned names-only listing wrapper; its header owns its exact flag and parse contract. + +## Incident and purpose + +2026-07-30: a crewmate's first Infisical command listed secret VALUES into its local session transcript - the listing flag printed both names and values. +The brief said names-only; instruction-following failed where enforcement did not exist, and the remedy was secret rotation. +This seatbelt makes that failure structurally impossible: every value-printing infisical form is denied mechanically before it executes, in every crewmate, scout, secondmate, and primary session, so rotation is never again the remedy for an agent printing a secret. + +## Policy + +The policy is fail-closed: only provably value-safe forms are allowed, and shell the classifier cannot prove safe denies whenever the infisical token is present. + +| Command shape | Decision | Reason code | +| --- | --- | --- | +| `infisical run [flags] -- ` / `--command ''` (injection form) | allow | | +| `infisical login`, `infisical init`, `infisical help` | allow | | +| `--help`, `-h`, or `--version` before any `--` terminator | allow | | +| `command -v infisical` / `command -V infisical` (existence query, the cd-guard's carve-out) | allow | | +| `bin/fm-secrets-names.sh ...` (names-only wrapper) | allow (by construction: it never matches the infisical token) | | +| Any other subcommand (`secrets`, `export`, `get`, ...), bare `infisical`, or a flags-only invocation | deny | `vault-secret-print` | +| A `run` child that dumps the injected env: `env`/`printenv`/`set`/`export`/`declare`/`typeset` (through resolved option-bearing wrappers and forwarders such as `nice -n 1`, `stdbuf -oL`, `watch -n 5`, and `xargs -I {}` too), an `echo`/`printf` carrying a `$`, or a shell/`eval` payload reaching one of those | deny | `vault-run-dump` | +| Executed infisical inside substitutions, subshells, literal `sh -c`/`eval` payloads, or heredocs fed to a stdin-mode shell | classified recursively, same rules as above | | +| Unclassifiable with the token present: lexer errors, unsupported compound grammar (`if`/`for`/`while`/`case`/`time`/...), dynamic (non-literal) payloads or command words, unresolved wrapper or forwarder options, or an indirect executor (`xargs`, `watch`, `nice`, ...) whose arguments carry the token | deny | `unclassifiable-vault-command` | + +Token matching is case-insensitive end to end (prefilter and policy), because macOS's default case-insensitive filesystem executes `Infisical secrets` as the real binary. +The run-child dump check also resolves cobra's glued short form (`-cprintenv`, `-c=x`) and skips `builtin` prefixes, so `sh -c 'builtin export'` under `run` still denies. +Data mentions stay data: quoted arguments (`echo "infisical secrets"`), grep patterns, comments, and heredoc bodies fed to non-shell commands never make the outer command relevant. +Any `--command` string payload of `run` is itself classified as a program running with the injected environment, where ALL unclassifiable syntax denies (no token-gating - the environment it runs in is already the protected material). +The deny reasons name the two sanctioned paths so the denied agent can self-correct: `bin/fm-secrets-names.sh --projectId --env ` for names, `infisical run [flags] -- ` for injection. + +Out of scope, by the same agent-mistake threat model as the arm and cd guards: opaque dynamic dataflow that never carries the token (`bash -c "$WHOLE_COMMAND"`), value dumping by an arbitrary workload binary the child runs (`node -e 'console.log(process.env)'`), and deliberate byte-level obfuscation beyond the classifier's static quote decoding. +The transport prefilter and its quoting-decoder markers mirror the cd-guard's strict-superset contract; both scripts' headers own the exact transport and fail-open rules. + +## Transport and fail-open vs fail-closed + +`bin/fm-vault-pretool-check.sh` accepts the same entry forms as the sibling guards: stdin JSON at `.tool_input.command` (Claude/Codex) or `.toolInput.command` (Grok), `--command ` (OpenCode/Pi), and `--claude` for Claude's stderr-only deny requirement. +Transport failure - malformed or empty stdin, missing `jq`, missing Node or the policy file - fails open, exactly like the arm guard, so a broken hook never denies every shell command. +Semantic failure - malformed, unsupported, or dynamic shell that carries the infisical token - fails closed inside the policy owner, exactly like the arm guard's `unclassifiable-protected-command`. +Unlike the cd-guard there is no environment scoping: the guard fires wherever it is installed, because printing a secret is wrong everywhere. + +## The names-only wrapper + +`bin/fm-secrets-names.sh --projectId --env [--path ]` is the only sanctioned listing tool. +It invokes `infisical export --format json --silent`, captures stdout, and structurally strips to name fields: an array of `{key: ...}` objects yields each `.key`, a flat object with only string values yields its keys, and ANY other shape aborts with nothing printed and a non-zero exit. +There is no raw-output fallback path; a parse failure prints nothing. +Secret values transit the wrapper's internal pipe (the CLI has no names-only fetch) but never its output. + +## Install matrix (who gets the guard, and how) + +Primary firstmate and every secondmate home run from this repo, so the tracked adapter configs cover them; crewmate/scout worktrees are project repos, so `bin/fm-spawn.sh` installs a per-task hook with the absolute checker path baked in, alongside each harness's existing turn-end integration. + +| Harness | Primary + secondmate home (tracked) | Crewmate/scout worktree (spawn-written) | +| --- | --- | --- | +| Claude | `.claude/settings.json` PreToolUse entry | `.claude/settings.local.json` gains a PreToolUse entry next to the Stop hook | +| Codex | `.codex/hooks.json` self-verifying PreToolUse entry | `.codex/hooks.json` written into the worktree (git-excluded); the crewmate launch template adds `--dangerously-bypass-hook-trust` so it loads without a trust dialog | +| Grok | `.grok/hooks/fm-primary-vault-check.json` project hook (loads under the home's existing folder trust) | firstmate-owned GLOBAL hook `~/.grok/hooks/fm-vault-guard.{sh,json}`, inert unless the workspace holds the `.fm-grok-turnend` crewmate pointer | +| OpenCode | `.opencode/plugins/fm-primary-vault-check.js` | `.opencode/plugins/fm-vault-guard.js` written into the worktree (git-excluded) | +| Pi | `.pi/extensions/fm-primary-turnend-guard.ts` runs the vault check in its `tool_call` handler | the spawn-written `state/.pi-ext.ts` gains a `tool_call` handler next to the turn-end handler | + +Grok quirk (owned by `docs/arm-pretool-check.md`): every shell variable in a grok hook command carries an inline default such as `${GROK_WORKSPACE_ROOT:-}`. +The grok global vault hook needs no token registry, unlike the turn-end hook: it writes nothing pointer-derived, only classifies the stdin payload through the checker path baked in by the last spawning firstmate home, and fails open if that path is gone. + +### Known gaps + +- Codex crewmates: a project that tracks its own `.codex/hooks.json` is left untouched (overwriting a tracked file would dirty the worktree); `fm-spawn` warns loudly and that one task runs unguarded on the hook layer. +- Codex crewmates: the spawn-shaped worktree `hooks.json` plus `--dangerously-bypass-hook-trust` is validated in `codex exec` mode (this document's record below); interactive-TUI hook loading rides the same general CLI flag but has not itself been observed live, so treat it as wired-but-unverified until a live codex crewmate exercises it. +- Codex secondmate launches do not pass the hook-trust bypass flag; the tracked hooks.json entry loads under whatever hook trust the home already has, exactly like the pre-existing arm and cd entries there. +- Grok crewmates on a machine whose last-spawning firstmate home was deleted: the baked checker path dies and the global hook exits 0 (fail open) until the next grok spawn rewrites it. +- The wrapper's `infisical export --format json` shapes were pinned against infisical 0.43.84; an upstream format change makes the wrapper abort (fail closed), never pass values through. + +## Automated validation + +`tests/fm-vault-guard.test.sh` owns the acceptance matrix: deny/allow rows through Codex-, Claude-, Grok-, OpenCode-, and Pi-shaped entry forms, fail-open transport behavior, the prefilter fast path, policy CLI contract, wrapper output-shape rows (names only, nothing on parse failure), spawn-install rows per harness against a fake tmux backend, and tracked-config wiring rows. + +Run: + +```sh +bash -n bin/fm-vault-pretool-check.sh bin/fm-secrets-names.sh +node --check bin/fm-vault-command-policy.mjs +tests/fm-vault-guard.test.sh +bin/fm-lint.sh +``` + +## Live validation record, 2026-07-30 + +Validation ran in a git-initialized scratch project (crewmate-worktree shape) under this task's implementation worktree, with Claude Code 2.1.220 and infisical 0.43.84. +The scratch `.claude/settings.local.json` carried exactly the hook fm-spawn writes - the absolute checker path plus `--claude`: + +```json +{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"'/bin/fm-vault-pretool-check.sh' --claude"}]}]}} +``` + +The harness launch was `claude -p "$PROMPT" --dangerously-skip-permissions --output-format text`, with the prompt instructing two exact shell commands as separate Bash tool calls: + +```sh +infisical --version +infisical secrets; touch deny-executed.sentinel +``` + +Observed: the first command executed and printed `infisical version 0.43.84` (real allow). +The second was blocked before execution; the session reported this exact hook error: + +```text +PreToolUse:Bash hook error: ['/bin/fm-vault-pretool-check.sh' --claude]: {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"},"systemMessage":"[vault-secret-print] this infisical command can print secret VALUES into the session transcript. Sanctioned paths: bin/fm-secrets-names.sh --projectId --env lists secret NAMES only; infisical run [flags] -- injects secrets into a child process env without printing them."} +``` + +The session's final line was `RESULT: cmd1=ran cmd2=blocked`, and `deny-executed.sentinel` did not exist afterward: the deny stopped the entire compound line, so the chained `touch` never ran. +The tracked primary wiring was additionally observed live during implementation: the implementing agent's own claude session, running in a firstmate worktree with the updated `.claude/settings.json`, had a compound command carrying the infisical token in a quoted payload plus a `"$VAR"/...` dynamic command word denied with `[unclassifiable-vault-command]` - the fail-closed dynamic rule firing through the real tracked-config path. + +The same scratch shape was repeated for codex (codex-cli 0.144.4) with a worktree `.codex/hooks.json` generated by the exact fm-spawn code path, launched as `codex exec --dangerously-bypass-hook-trust --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check "$PROMPT"` with the same two commands. +Observed: `hook: PreToolUse Completed` then `infisical version 0.43.84` for the first command; `hook: PreToolUse Blocked` for the second with `Command blocked by PreToolUse hook:` carrying the same `[vault-secret-print]` deny object; the final line was `RESULT: cmd1=ran cmd2=blocked` and the sentinel did not exist. diff --git a/tests/fm-vault-guard.test.sh b/tests/fm-vault-guard.test.sh new file mode 100755 index 00000000000..11663ad0c59 --- /dev/null +++ b/tests/fm-vault-guard.test.sh @@ -0,0 +1,641 @@ +#!/usr/bin/env bash +# shellcheck disable=SC1091,SC2016 +# Behavior tests for the vault-guard PreToolUse seatbelt (docs/vault-guard.md). +# +# bin/fm-vault-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-vault-pretool-check.sh is the stable transport driving all five harness +# entry forms; bin/fm-secrets-names.sh is the sanctioned names-only wrapper. +# This suite proves the decision matrix with per-row reason codes, the +# harness-output shaping, the fail-open transport behavior, the prefilter fast +# path, the wrapper's structural names-only output contract, the per-task spawn +# installation for every verified harness against a fake tmux backend, and the +# tracked primary/secondmate-home wiring. No harness is spawned; live claude +# evidence lives in docs/vault-guard.md. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +fm_git_identity fmtest fmtest@example.invalid +TMP_ROOT=$(fm_test_tmproot fm-vault-guard) + +CHECK="$ROOT/bin/fm-vault-pretool-check.sh" +POLICY="$ROOT/bin/fm-vault-command-policy.mjs" +WRAPPER="$ROOT/bin/fm-secrets-names.sh" +SPAWN="$ROOT/bin/fm-spawn.sh" + +# --- full cross-harness acceptance matrix ---------------------------------- + +MATRIX_IDS=() +MATRIX_EXPECTED=() +MATRIX_COMMANDS=() + +matrix_case() { + MATRIX_IDS+=("$1") + MATRIX_EXPECTED+=("$2") + MATRIX_COMMANDS+=("$3") +} + +# DENY vault-secret-print: value-printing infisical forms. +matrix_case P01 vault-secret-print 'infisical secrets' +matrix_case P02 vault-secret-print 'infisical export --env=prod' +matrix_case P03 vault-secret-print 'infisical secrets get DB_URL --plain' +matrix_case P04 vault-secret-print 'infisical' +matrix_case P05 vault-secret-print 'sudo infisical secrets' +matrix_case P06 vault-secret-print '/opt/homebrew/bin/infisical secrets' +matrix_case P07 vault-secret-print 'X=$(infisical secrets get A)' +matrix_case P08 vault-secret-print 'sh -c "infisical export"' +matrix_case P09 vault-secret-print 'eval "infisical secrets"' +matrix_case P10 vault-secret-print '(infisical export) > /tmp/x' +matrix_case P11 vault-secret-print 'infisical run -- infisical secrets' +matrix_case P12 vault-secret-print 'echo before; infisical secrets | tee out' +matrix_case P13 vault-secret-print 'infisical secrets --env dev' + +# DENY vault-run-dump: an allowed run whose child dumps the injected env. +matrix_case R01 vault-run-dump 'infisical run -- env' +matrix_case R02 vault-run-dump 'infisical run -- printenv' +matrix_case R03 vault-run-dump 'infisical run -- set' +matrix_case R04 vault-run-dump 'infisical run --env dev -- printenv' +matrix_case R05 vault-run-dump 'infisical run -- sudo env' +matrix_case R06 vault-run-dump 'infisical run -- xargs env' +matrix_case R07 vault-run-dump "infisical run -- sh -c 'echo \$DATABASE_URL'" +matrix_case R08 vault-run-dump "infisical run -- sh -c 'export'" +matrix_case R09 vault-run-dump 'infisical run --command "printenv"' +matrix_case R10 vault-run-dump 'infisical run -- echo $HOME' +matrix_case R11 vault-run-dump "infisical run -- echo '\$FOO'" +matrix_case R12 vault-run-dump "infisical run --command 'sh -c \"printenv\"'" +matrix_case R13 vault-run-dump 'infisical run -cprintenv' +matrix_case R14 vault-run-dump 'infisical run -- builtin export' +matrix_case R15 vault-run-dump 'infisical run -- sh -c "builtin export"' +matrix_case R16 vault-run-dump 'infisical run -- nice -n 1 printenv' +matrix_case R17 vault-run-dump 'infisical run -- nice -n 1 env FOO=1 printenv' +matrix_case R18 vault-run-dump 'infisical run -- stdbuf -oL printenv' +matrix_case R19 vault-run-dump 'infisical run -- xargs -I {} printenv' + +# DENY unclassifiable-vault-command: fail closed around the token. +matrix_case U01 unclassifiable-vault-command 'xargs infisical secrets' +matrix_case U02 unclassifiable-vault-command 'for f in 1; do infisical secrets; done' +matrix_case U03 unclassifiable-vault-command 'infisical secrets "unclosed' +matrix_case U04 unclassifiable-vault-command 'VAR="infisical secrets"; bash -c "$VAR"' +matrix_case U05 unclassifiable-vault-command 'watch infisical secrets' +matrix_case U06 unclassifiable-vault-command 'nice infisical export' +matrix_case U07 unclassifiable-vault-command 'infisical $SUB' +matrix_case U08 unclassifiable-vault-command 'infisical run --command "$CMD"' +matrix_case U09 unclassifiable-vault-command 'time infisical secrets' +matrix_case U10 unclassifiable-vault-command 'infisical run -- SH -c "printenv"' +matrix_case U11 unclassifiable-vault-command 'infisical run -- nice --unknown printenv' + +# DENY, case variance: macOS's default filesystem executes these for real. +matrix_case C01 vault-secret-print 'Infisical secrets' +matrix_case C02 vault-secret-print 'INFISICAL export' + +# ALLOW: the injection form, setup forms, the wrapper, and data mentions. +matrix_case A01 allow 'infisical run --env=dev -- npm run dev' +matrix_case A02 allow 'infisical run --env dev -- node server.js' +matrix_case A03 allow 'infisical run -c "npm start"' +matrix_case A04 allow 'infisical run --command "npm install && npm test"' +matrix_case A05 allow 'infisical run -- env node server.js' +matrix_case A06 allow 'infisical run -- echo hello' +matrix_case A07 allow 'infisical login' +matrix_case A08 allow 'infisical init' +matrix_case A09 allow 'infisical --help' +matrix_case A10 allow 'infisical --version' +matrix_case A11 allow 'infisical secrets --help' +matrix_case A12 allow 'bin/fm-secrets-names.sh --projectId x --env dev' +matrix_case A13 allow 'echo "infisical secrets"' +matrix_case A14 allow 'grep -rn infisical docs/' +matrix_case A15 allow 'bash -c "infisical run -- npm start"' +matrix_case A16 allow 'git status' +matrix_case A17 allow 'npm install infisical-sdk' +matrix_case A18 allow 'infisical run --watch -- npm run dev' +matrix_case A19 allow 'command -v infisical' +matrix_case A20 allow 'Infisical run -- npm start' +matrix_case A21 allow 'infisical run -- watch -n 5 npm test' + +MATRIX_TMP=$(mktemp -d "${TMPDIR:-/tmp}/fm-vault-policy-matrix.XXXXXX") +FM_TEST_CLEANUP_DIRS+=("$MATRIX_TMP") + +run_matrix_entry() { + local id=$1 expected=$2 entry=$3 cmd=$4 payload out_file err_file rc + out_file="$MATRIX_TMP/$id-$entry.out" + err_file="$MATRIX_TMP/$id-$entry.err" + + case "$entry" in + codex) + payload=$(jq -cn --arg command "$cmd" '{tool_name:"Bash",tool_input:{command:$command}}') + printf '%s' "$payload" | "$CHECK" >"$out_file" 2>"$err_file" + rc=$? + ;; + claude) + payload=$(jq -cn --arg command "$cmd" '{tool_name:"Bash",tool_input:{command:$command}}') + printf '%s' "$payload" | "$CHECK" --claude >"$out_file" 2>"$err_file" + rc=$? + ;; + grok) + payload=$(jq -cn --arg command "$cmd" '{toolName:"run_terminal_command",toolInput:{command:$command}}') + printf '%s' "$payload" | "$CHECK" >"$out_file" 2>"$err_file" + rc=$? + ;; + opencode|pi) + "$CHECK" --command "$cmd" >"$out_file" 2>"$err_file" + rc=$? + ;; + *) + fail "unknown matrix entry form: $entry" + ;; + esac + + if [ "$expected" = allow ]; then + [ "$rc" -eq 0 ] || fail "$id via $entry must allow, got exit $rc: $(cat "$err_file")" + [ ! -s "$out_file" ] || fail "$id via $entry allow must leave stdout empty: $(cat "$out_file")" + [ ! -s "$err_file" ] || fail "$id via $entry allow must leave stderr empty: $(cat "$err_file")" + return + fi + + [ "$rc" -eq 2 ] || fail "$id via $entry must deny, got exit $rc" + jq -e --arg code "$expected" '.hookSpecificOutput.permissionDecision == "deny" and (.systemMessage | test("\\[" + $code + "\\]"))' "$err_file" >/dev/null 2>&1 \ + || fail "$id via $entry deny must carry the $expected reason code on stderr: $(cat "$err_file")" + if [ "$entry" = claude ]; then + [ ! -s "$out_file" ] || fail "$id via claude deny must leave stdout empty: $(cat "$out_file")" + elif [ "$entry" = grok ]; then + jq -e '.decision == "deny"' "$out_file" >/dev/null 2>&1 \ + || fail "$id via grok deny must carry decision=deny on stdout: $(cat "$out_file")" + fi +} + +test_full_acceptance_matrix() { + local i entry + for ((i = 0; i < ${#MATRIX_IDS[@]}; i++)); do + for entry in codex claude grok opencode pi; do + run_matrix_entry "${MATRIX_IDS[$i]}" "${MATRIX_EXPECTED[$i]}" "$entry" "${MATRIX_COMMANDS[$i]}" + done + done + pass "vault-guard acceptance matrix: ${#MATRIX_IDS[@]} cases x 5 harness entry forms, block/allow and reason codes all correct" +} + +# --- fail-open transport behavior ------------------------------------------ + +test_fail_open_empty_stdin() { + local out rc + out=$("$CHECK" < /dev/null 2>&1); rc=$? + expect_code 0 "$rc" "transport must exit 0 on empty stdin" + [ -z "$out" ] || fail "transport produced output on empty stdin: $out" + pass "vault-guard: fails open on empty stdin" +} + +test_fail_open_unparseable_json() { + local out rc + out=$(printf 'not json at all' | "$CHECK" 2>&1); rc=$? + expect_code 0 "$rc" "transport must exit 0 on unparseable stdin JSON" + [ -z "$out" ] || fail "transport produced output on unparseable JSON: $out" + pass "vault-guard: fails open on unparseable stdin JSON" +} + +test_fail_open_missing_node() { + local fakebin tool tool_path out rc + fakebin=$(fm_fakebin "$TMP_ROOT/nonode") + for tool in bash sh dirname cat printf sed tr jq; do + tool_path=$(command -v "$tool") || continue + ln -s "$tool_path" "$fakebin/$tool" + done + # node deliberately absent from this PATH. + out=$(PATH="$fakebin" "$CHECK" --command 'infisical secrets' 2>&1); rc=$? + expect_code 0 "$rc" "transport must fail open when node is unavailable" + [ -z "$out" ] || fail "transport produced output without node: $out" + pass "vault-guard: fails open (never blocks) when node is missing" +} + +test_fail_open_missing_jq_on_stdin() { + local fakebin tool tool_path out rc + fakebin=$(fm_fakebin "$TMP_ROOT/nojq") + for tool in bash sh dirname cat printf sed tr node; do + tool_path=$(command -v "$tool") || continue + ln -s "$tool_path" "$fakebin/$tool" + done + # jq deliberately absent: the stdin transport cannot extract the command. + out=$(printf '{"tool_input":{"command":"infisical secrets"}}' | PATH="$fakebin" "$CHECK" 2>&1); rc=$? + expect_code 0 "$rc" "stdin transport must fail open when jq is unavailable" + [ -z "$out" ] || fail "transport produced output without jq on the stdin path: $out" + pass "vault-guard: fails open on the stdin path when jq is missing" +} + +# --- prefilter fast path ---------------------------------------------------- + +test_prefilter_skips_node_without_infisical_substring() { + local fakebin marker tool tool_path out rc + fakebin=$(fm_fakebin "$TMP_ROOT/prefilter-fake") + marker="$TMP_ROOT/prefilter-node-called" + for tool in bash sh dirname cat printf sed tr jq; do + tool_path=$(command -v "$tool") || continue + ln -s "$tool_path" "$fakebin/$tool" + done + cat > "$fakebin/node" < "$marker" +exit 0 +EOF + chmod +x "$fakebin/node" + # No infisical substring: the prefilter must fast-allow before the policy + # runtime is ever consulted. + out=$(PATH="$fakebin" "$CHECK" --command 'bin/fm-secrets-names.sh --projectId x --env dev' 2>&1); rc=$? + expect_code 0 "$rc" "prefilter must fast-allow a command with no infisical substring" + [ -z "$out" ] || fail "prefilter fast-allow produced output: $out" + [ ! -e "$marker" ] || fail "prefilter fast-allow still invoked the node policy owner" + pass "vault-guard: prefilter fast-allows (skips node) when no infisical substring is present" +} + +test_prefilter_delegates_quote_split_token() { + local out rc + out=$("$CHECK" --command 'in"fisical" secrets' 2>&1); rc=$? + expect_code 2 "$rc" "a quote-split infisical token must still reach the policy and deny" + assert_contains "$out" '[vault-secret-print]' "quote-split deny must carry the reason code" + pass "vault-guard: prefilter strict superset delegates quote-split tokens" +} + +# --- policy CLI contract ---------------------------------------------------- + +test_policy_cli_direct() { + [ "$(node "$POLICY" --command 'infisical secrets' | cut -f1)" = deny ] \ + || fail "policy CLI must deny a bare infisical secrets" + [ "$(node "$POLICY" --command 'infisical run -- npm start')" = allow ] \ + || fail "policy CLI must allow the injection form" + [ "$(node "$POLICY" --command 'infisical run -- env' | cut -f2)" = vault-run-dump ] \ + || fail "policy CLI must code a run env dump as vault-run-dump" + [ "$(node "$POLICY")" = allow ] \ + || fail "policy CLI must allow when no command is supplied" + pass "vault-guard: fm-vault-command-policy.mjs CLI honors the deny/allow output contract" +} + +# --- names-only wrapper ----------------------------------------------------- + +make_wrapper_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/infisical" <<'EOF' +#!/usr/bin/env bash +case "${FAKE_SHAPE:-array}" in + array) printf '[{"key":"DB_URL","value":"postgres://secret-value-1"},{"key":"API_KEY","value":"sk-secret-value-2"}]' ;; + object) printf '{"DB_URL":"postgres://secret-value-1","API_KEY":"sk-secret-value-2"}' ;; + empty-array) printf '[]' ;; + empty-object) printf '{}' ;; + mixed) printf '[{"key":"GOOD","value":"v"},{"noKey":true}]' ;; + nested) printf '{"secrets":[{"key":"DB_URL","value":"secret-value-1"}],"meta":{}}' ;; + array-value) printf '{"DB_URL":[]}' ;; + object-value) printf '{"DB_URL":{}}' ;; + number-value) printf '{"DB_URL":1}' ;; + null-value) printf '{"DB_URL":null}' ;; + garbage) printf 'DB_URL=secret-value-1\nAPI_KEY=secret-value-2\n' ;; + fail) echo "auth error" >&2; exit 1 ;; +esac +EOF + chmod +x "$fakebin/infisical" + printf '%s\n' "$fakebin" +} + +test_wrapper_names_only_array_shape() { + local fakebin out rc + fakebin=$(make_wrapper_fakebin "$TMP_ROOT/wrapper-array") + out=$(PATH="$fakebin:$PATH" "$WRAPPER" --projectId p1 --env dev 2>/dev/null); rc=$? + expect_code 0 "$rc" "wrapper must succeed on the array export shape" + [ "$out" = 'DB_URL +API_KEY' ] || fail "wrapper array output must be names only, got: $out" + case "$out" in *secret-value*) fail "wrapper array output leaked a value" ;; esac + pass "fm-secrets-names.sh: array shape yields names only" +} + +test_wrapper_names_only_object_shape() { + local fakebin out rc + fakebin=$(make_wrapper_fakebin "$TMP_ROOT/wrapper-object") + out=$(FAKE_SHAPE=object PATH="$fakebin:$PATH" "$WRAPPER" --projectId p1 --env dev 2>/dev/null); rc=$? + expect_code 0 "$rc" "wrapper must succeed on the flat object export shape" + [ "$out" = 'DB_URL +API_KEY' ] || fail "wrapper object output must be names only, got: $out" + case "$out" in *secret-value*) fail "wrapper object output leaked a value" ;; esac + pass "fm-secrets-names.sh: flat object shape yields names only" +} + +test_wrapper_allows_empty_known_shapes() { + local fakebin out rc shape + for shape in empty-array empty-object; do + fakebin=$(make_wrapper_fakebin "$TMP_ROOT/wrapper-$shape") + out=$(FAKE_SHAPE=$shape PATH="$fakebin:$PATH" "$WRAPPER" --projectId p1 --env dev 2>/dev/null); rc=$? + expect_code 0 "$rc" "wrapper must accept the empty $shape export shape" + [ -z "$out" ] || fail "wrapper empty $shape output must be empty, got: $out" + done + pass "fm-secrets-names.sh: empty known shapes succeed without output" +} + +test_wrapper_fails_closed_on_unknown_shape() { + local fakebin out rc shape + for shape in mixed nested array-value object-value number-value null-value garbage; do + fakebin=$(make_wrapper_fakebin "$TMP_ROOT/wrapper-$shape") + out=$(FAKE_SHAPE=$shape PATH="$fakebin:$PATH" "$WRAPPER" --projectId p1 --env dev 2>/dev/null); rc=$? + [ "$rc" -ne 0 ] || fail "wrapper must exit non-zero on the $shape shape" + [ -z "$out" ] || fail "wrapper must print NOTHING on the $shape shape, got: $out" + done + pass "fm-secrets-names.sh: unrecognized shapes print nothing and exit non-zero (no raw fallback)" +} + +test_wrapper_fails_closed_on_cli_failure() { + local fakebin out rc + fakebin=$(make_wrapper_fakebin "$TMP_ROOT/wrapper-fail") + out=$(FAKE_SHAPE=fail PATH="$fakebin:$PATH" "$WRAPPER" --projectId p1 --env dev 2>/dev/null); rc=$? + [ "$rc" -ne 0 ] || fail "wrapper must exit non-zero when infisical fails" + [ -z "$out" ] || fail "wrapper must print nothing on stdout when infisical fails, got: $out" + pass "fm-secrets-names.sh: CLI failure prints nothing and exits non-zero" +} + +test_wrapper_requires_project_and_env() { + local out rc + out=$("$WRAPPER" --projectId p1 2>&1); rc=$? + expect_code 2 "$rc" "wrapper must refuse a missing --env" + assert_contains "$out" '--env is required' "wrapper must name the missing flag" + out=$("$WRAPPER" --env dev 2>&1); rc=$? + expect_code 2 "$rc" "wrapper must refuse a missing --projectId" + out=$("$WRAPPER" --projectId p1 --env dev --plain 2>&1); rc=$? + expect_code 2 "$rc" "wrapper must refuse unknown arguments" + assert_contains "$out" 'unknown argument' "wrapper must reject value-ish flags it does not own" + pass "fm-secrets-names.sh: requires --projectId and --env, rejects unknown flags" +} + +# --- spawn installation per harness ----------------------------------------- + +make_spawn_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows) exit 0 ;; + has-session|new-session|new-window|kill-window) exit 0 ;; + send-keys) + if [ -n "${FM_FAKE_LAUNCH_LOG:-}" ]; then + prev= + for a in "$@"; do + if [ "$prev" = "-l" ]; then + printf '%s\n' "$a" >> "$FM_FAKE_LAUNCH_LOG" + fi + prev=$a + done + fi + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse + printf '%s\n' "$fakebin" +} + +make_spawn_case() { + local name=$1 harness=$2 id=$3 case_dir home proj wt fakebin launchlog + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + launchlog="$case_dir/launch.log" + fakebin=$(make_spawn_fakebin "$case_dir/fake") + mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config" + printf '%s\n' "$harness" > "$home/config/crew-harness" + fm_git_worktree "$proj" "$wt" "wt-$name" + touch "$home/state/.last-watcher-beat" + mkdir -p "$home/data/$id" + printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md" + printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin|$launchlog" +} + +read_case_record() { + IFS='|' read -r CASE_DIR HOME_DIR PROJ_DIR WT_DIR FAKEBIN_DIR LAUNCH_LOG < "$launchlog" + FM_ROOT_OVERRIDE='' FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ + FM_FAKE_LAUNCH_LOG="$launchlog" GROK_HOME="$home/grok-home" PATH="$fakebin:$PATH" \ + "$SPAWN" "$@" 2>&1 +} + +test_spawn_installs_claude_vault_hook() { + local rec id out status settings + id=vault-claude-z1 + rec=$(make_spawn_case vault-claude claude "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "claude spawn should succeed: $out" + settings="$WT_DIR/.claude/settings.local.json" + assert_present "$settings" "claude spawn must write settings.local.json" + jq -e '.hooks.PreToolUse[0].matcher == "Bash"' "$settings" >/dev/null \ + || fail "claude settings.local.json must carry a PreToolUse Bash matcher" + jq -e --arg c "$ROOT/bin/fm-vault-pretool-check.sh" '[.hooks.PreToolUse[0].hooks[].command | select(contains($c) and contains("--claude"))] | length == 1' "$settings" >/dev/null \ + || fail "claude vault hook must invoke the absolute checker path with --claude: $(cat "$settings")" + jq -e '[.hooks.Stop[0].hooks[].command | select(contains("turn-ended"))] | length == 1' "$settings" >/dev/null \ + || fail "claude vault hook must not displace the turn-end Stop hook" + pass "fm-spawn: claude crewmate gets the vault PreToolUse hook alongside the Stop hook" +} + +test_spawn_installs_codex_vault_hook() { + local rec id out status hooks launch + id=vault-codex-z2 + rec=$(make_spawn_case vault-codex codex "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "codex spawn should succeed: $out" + hooks="$WT_DIR/.codex/hooks.json" + assert_present "$hooks" "codex spawn must write a worktree hooks.json" + jq -e --arg c "$ROOT/bin/fm-vault-pretool-check.sh" '[.hooks.PreToolUse[0].hooks[].command | select(contains($c))] | length == 1' "$hooks" >/dev/null \ + || fail "codex worktree hooks.json must invoke the absolute checker path: $(cat "$hooks")" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" '--dangerously-bypass-hook-trust' \ + "codex crewmate launch must bypass hook trust so the worktree hook loads" + git -C "$WT_DIR" check-ignore -q .codex/hooks.json \ + || fail "codex worktree hooks.json must be excluded from git's view" + pass "fm-spawn: codex crewmate gets a worktree vault hooks.json plus the hook-trust launch flag" +} + +test_spawn_skips_codex_hook_when_project_tracks_one() { + local rec id out status + id=vault-codex-skip-z3 + rec=$(make_spawn_case vault-codex-skip codex "$id") + read_case_record "$rec" + mkdir -p "$WT_DIR/.codex" + printf '{"hooks":{}}\n' > "$WT_DIR/.codex/hooks.json" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "codex spawn should still succeed when hooks.json pre-exists: $out" + assert_contains "$out" 'vault guard NOT installed' "pre-existing hooks.json must be warned loudly" + [ "$(cat "$WT_DIR/.codex/hooks.json")" = '{"hooks":{}}' ] \ + || fail "a pre-existing (project-tracked) hooks.json must be left untouched" + pass "fm-spawn: codex crewmate never clobbers a pre-existing hooks.json and warns about the gap" +} + +test_spawn_installs_opencode_vault_plugin() { + local rec id out status plugin + id=vault-opencode-z4 + rec=$(make_spawn_case vault-opencode opencode "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "opencode spawn should succeed: $out" + plugin="$WT_DIR/.opencode/plugins/fm-vault-guard.js" + assert_present "$plugin" "opencode spawn must write the vault plugin" + assert_contains "$(cat "$plugin")" 'tool.execute.before' "opencode vault plugin must run before tool execution" + assert_contains "$(cat "$plugin")" "$ROOT/bin/fm-vault-pretool-check.sh" "opencode vault plugin must bake the absolute checker path" + assert_contains "$(cat "$plugin")" 'throw new Error' "opencode vault plugin must block by throwing" + node --check "$plugin" 2>/dev/null || fail "opencode vault plugin must be valid JS" + assert_present "$WT_DIR/.opencode/plugins/fm-turn-end.js" "the turn-end plugin must still be written" + pass "fm-spawn: opencode crewmate gets the vault plugin alongside the turn-end plugin" +} + +test_spawn_installs_pi_vault_handler() { + local rec id out status ext + id=vault-pi-z5 + rec=$(make_spawn_case vault-pi pi "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "pi spawn should succeed: $out" + ext="$HOME_DIR/state/$id.pi-ext.ts" + assert_present "$ext" "pi spawn must write the state extension" + assert_contains "$(cat "$ext")" 'tool_call' "pi extension must handle tool_call" + assert_contains "$(cat "$ext")" "$ROOT/bin/fm-vault-pretool-check.sh" "pi extension must bake the absolute checker path" + assert_contains "$(cat "$ext")" 'block: true' "pi extension must block on a checker exit 2" + assert_contains "$(cat "$ext")" 'turn_end' "pi extension must keep the turn-end handler" + pass "fm-spawn: pi crewmate extension gains the vault tool_call handler next to turn-end" +} + +test_spawn_installs_grok_vault_hook() { + local rec id out status hook config deny_payload rc workspace + id=vault-grok-z6 + rec=$(make_spawn_case vault-grok grok "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "grok spawn should succeed: $out" + hook="$HOME_DIR/grok-home/hooks/fm-vault-guard.sh" + config="$HOME_DIR/grok-home/hooks/fm-vault-guard.json" + assert_present "$hook" "grok spawn must write the global vault hook script" + assert_present "$config" "grok spawn must write the global vault hook config" + jq -e '.hooks.PreToolUse[0].hooks[0].command | contains("fm-vault-guard.sh")' "$config" >/dev/null \ + || fail "grok vault hook config must invoke the global hook script" + assert_contains "$(cat "$hook")" '.fm-grok-turnend' "grok vault hook must gate on the crewmate pointer" + deny_payload='{"toolInput":{"command":"infisical secrets"}}' + # Without the pointer the hook must be inert even for a deniable payload. + workspace="$CASE_DIR/grok-nonfm" + mkdir -p "$workspace" + out=$(printf '%s' "$deny_payload" | GROK_WORKSPACE_ROOT="$workspace" bash "$hook" 2>&1); rc=$? + expect_code 0 "$rc" "grok vault hook must be inert without the crewmate pointer" + [ -z "$out" ] || fail "grok vault hook produced output outside a crewmate workspace: $out" + # With the pointer (the spawned worktree) the same payload must deny. + out=$(printf '%s' "$deny_payload" | GROK_WORKSPACE_ROOT="$WT_DIR" bash "$hook" 2>/dev/null); rc=$? + expect_code 2 "$rc" "grok vault hook must deny a secrets listing inside a crewmate workspace" + printf '%s' "$out" | jq -e '.decision == "deny"' >/dev/null \ + || fail "grok vault hook deny must carry the grok stdout decision object: $out" + pass "fm-spawn: grok crewmate gets the pointer-gated global vault hook (inert elsewhere, denies in-workspace)" +} + +# --- tracked primary/secondmate-home wiring --------------------------------- + +test_claude_wiring() { + local settings + settings="$ROOT/.claude/settings.json" + jq -e '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-vault-pretool-check.sh") and contains("--claude") and contains("CLAUDE_PROJECT_DIR"))] | length == 1' "$settings" >/dev/null \ + || fail "claude PreToolUse must invoke fm-vault-pretool-check.sh with CLAUDE_PROJECT_DIR and --claude" + jq -e '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-arm-pretool-check.sh"))] | length == 1' "$settings" >/dev/null \ + || fail "claude vault hook must not displace the watcher-arm hook" + jq -e '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-cd-pretool-check.sh"))] | length == 1' "$settings" >/dev/null \ + || fail "claude vault hook must not displace the cd-guard hook" + pass ".claude/settings.json: PreToolUse invokes the vault guard alongside the arm and cd guards" +} + +test_codex_wiring() { + local settings command + settings="$ROOT/.codex/hooks.json" + command=$(jq -r '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-vault-pretool-check.sh"))][0] // empty' "$settings") + [ -n "$command" ] || fail "codex PreToolUse must invoke fm-vault-pretool-check.sh" + assert_contains "$command" 'pwd -P' "codex vault hook must anchor from the hook process working directory" + jq -e '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-arm-pretool-check.sh"))] | length == 1' "$settings" >/dev/null \ + || fail "codex vault hook must not displace the watcher-arm hook" + pass ".codex/hooks.json: PreToolUse invokes the vault guard alongside the arm and cd guards" +} + +test_grok_wiring() { + local settings command + settings="$ROOT/.grok/hooks/fm-primary-vault-check.json" + [ -f "$settings" ] || fail "tracked grok vault hook config is missing" + command=$(jq -r '.hooks.PreToolUse[0].hooks[0].command // empty' "$settings") + [ -n "$command" ] || fail "grok vault hook command is missing" + assert_contains "$command" 'fm-vault-pretool-check.sh' "grok vault hook must invoke the vault guard" + assert_contains "$command" '${GROK_WORKSPACE_ROOT:-}' "grok vault hook must default-guard the workspace var" + pass ".grok primary vault hook: PreToolUse invokes the vault guard" +} + +test_opencode_wiring() { + local plugin content + plugin="$ROOT/.opencode/plugins/fm-primary-vault-check.js" + [ -f "$plugin" ] || fail "tracked OpenCode vault plugin is missing" + content=$(cat "$plugin") + assert_contains "$content" 'tool.execute.before' "OpenCode vault plugin must run before tool execution" + assert_contains "$content" 'fm-vault-pretool-check.sh' "OpenCode vault plugin must invoke the vault guard" + assert_contains "$content" 'throw new Error' "OpenCode vault plugin must block by throwing" + pass ".opencode vault plugin: tool.execute.before invokes the vault guard and blocks by throwing" +} + +test_pi_wiring() { + local ext content + ext="$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" + content=$(cat "$ext") + assert_contains "$content" 'runVaultCheck(command)' "pi extension must run the vault check in tool_call" + assert_contains "$content" 'fm-vault-pretool-check.sh' "pi extension must invoke the vault-guard owner" + assert_contains "$content" 'runPretoolCheck(command)' "pi extension must keep running the watcher-arm check" + assert_contains "$content" 'runCdCheck(command)' "pi extension must keep running the cd check" + pass ".pi primary extension: tool_call runs the vault guard alongside the arm and cd checks" +} + +test_scripts_are_shellcheck_clean() { + shellcheck "$ROOT/bin/fm-vault-pretool-check.sh" "$ROOT/bin/fm-secrets-names.sh" >/dev/null 2>&1 \ + || fail "vault-guard bin scripts are not shellcheck-clean" + pass "bin/fm-vault-pretool-check.sh and bin/fm-secrets-names.sh are shellcheck-clean" +} + +test_full_acceptance_matrix +test_fail_open_empty_stdin +test_fail_open_unparseable_json +test_fail_open_missing_node +test_fail_open_missing_jq_on_stdin +test_prefilter_skips_node_without_infisical_substring +test_prefilter_delegates_quote_split_token +test_policy_cli_direct +test_wrapper_names_only_array_shape +test_wrapper_names_only_object_shape +test_wrapper_allows_empty_known_shapes +test_wrapper_fails_closed_on_unknown_shape +test_wrapper_fails_closed_on_cli_failure +test_wrapper_requires_project_and_env +test_spawn_installs_claude_vault_hook +test_spawn_installs_codex_vault_hook +test_spawn_skips_codex_hook_when_project_tracks_one +test_spawn_installs_opencode_vault_plugin +test_spawn_installs_pi_vault_handler +test_spawn_installs_grok_vault_hook +test_claude_wiring +test_codex_wiring +test_grok_wiring +test_opencode_wiring +test_pi_wiring +test_scripts_are_shellcheck_clean