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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Expand Down
5 changes: 5 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
Expand Down
16 changes: 16 additions & 0 deletions .grok/hooks/fm-primary-vault-check.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
]
}
}
64 changes: 64 additions & 0 deletions .opencode/plugins/fm-primary-vault-check.js
Original file line number Diff line number Diff line change
@@ -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);
},
};
};
11 changes: 10 additions & 1 deletion .pi/extensions/fm-primary-turnend-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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();
Expand All @@ -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" };
Expand Down
25 changes: 14 additions & 11 deletions bin/fm-arm-command-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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;
Expand Down
114 changes: 114 additions & 0 deletions bin/fm-secrets-names.sh
Original file line number Diff line number Diff line change
@@ -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 <id> --env <slug> [--path <folder>]
#
# 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"
Loading