Skip to content

feat: block crewmate secret-value exposure - #3

Merged
elixlabssolutions merged 4 commits into
mainfrom
fm/fm-vault-guard-g1
Jul 31, 2026
Merged

elixlabssolutions merged 4 commits into
mainfrom
fm/fm-vault-guard-g1

Conversation

@elixlabssolutions

@elixlabssolutions elixlabssolutions commented Jul 31, 2026 •

Copy link
Copy Markdown

Intent

Make secret-value exposure by crewmates structurally impossible after the 2026-07-30 incident where a crewmate's first Infisical command printed secret VALUES into its session transcript (the brief said names-only; instruction-following failed where enforcement did not exist; the captain directed that rotation must never again be the remedy). This is mechanical PreToolUse enforcement, not instructions: a vault-command guard denies every value-printing infisical form before it executes, plus a sanctioned names-only listing wrapper.

Deliberate design decisions a diff reviewer should not flag as mistakes:
(1) The guard follows the existing sibling-seatbelt pattern exactly: bin/fm-vault-command-policy.mjs is the policy owner and imports the shared shell classifier from bin/fm-arm-command-policy.mjs per the one-owner rule; four generic execution-sink helpers (shellInvocation, evalPayload, shellHeredocPayloads, shellHereStringPayloads) were newly EXPORTED from the arm policy (additive, zero behavior change) so the sibling never duplicates shell lexing.
(2) Policy is fail-closed by design: ALLOW only infisical run (injection form), login/init/help, --help/-h/--version before any -- terminator, and command -v/-V existence queries; DENY every other subcommand, run children that dump the injected env (env/printenv/set/export/declare/typeset, echo/printf carrying a dollar sign, sh -c payloads reaching those, cobra glued -cpayload flags, builtin prefixes), and ANY unclassifiable shell carrying the infisical token (lexer errors, if/for/while/case/time grammar, dynamic payloads or command words, unresolved wrapper options, indirect executors like xargs/watch/nice). Conservative false-positive denies (e.g. a dynamic $VAR command word plus the token elsewhere on the line, or infisical --domain D login with a separate-value pre-subcommand flag) are accepted on purpose; the deny message names both sanctioned paths so agents self-correct.
(3) Token matching is case-insensitive end to end (policy regex/basename plus a bracket-pattern bash prefilter) because macOS's default case-insensitive filesystem executes 'Infisical secrets' as the real binary; the prefilter stays pure-bash, zero-fork, bash-3.2 compatible.
(4) Transport mirrors the cd-guard byte-for-byte where possible: fail-OPEN on broken transport (missing jq/node/policy, malformed stdin) so a broken hook never denies every shell command; fail-CLOSED semantics live in the policy owner; deliberately NO environment scoping unlike the cd-guard because printing a secret is wrong everywhere the guard is installed.
(5) bin/fm-secrets-names.sh is the only sanctioned listing path: invokes infisical export --format json --silent, structurally parses with jq accepting exactly two known shapes (array of key-objects, flat object), prints NOTHING and exits non-zero on any other shape or CLI failure - no raw-output fallback ever; requires explicit --projectId and --env; values transit its internal pipe (the CLI has no names-only fetch) but never its output.
(6) Install matrix: crewmate/scout worktrees are project repos without firstmate's bin, so fm-spawn bakes the ABSOLUTE checker path into per-task hooks (claude settings.local.json gains PreToolUse next to the existing Stop hook; opencode gets a second plugin file; pi's spawn-written state extension gains a tool_call handler in the same file so no extra -e flag; codex gets a worktree .codex/hooks.json AND the crewmate launch template deliberately adds --dangerously-bypass-hook-trust so that hook loads without a trust dialog - risk-equivalent to the --dangerously-bypass-approvals-and-sandbox the unattended crewmate already runs with; grok gets a firstmate-owned GLOBAL hook gated on the existing .fm-grok-turnend workspace pointer because grok project hooks require folder trust - deliberately NO token registry unlike the turn-end hook since nothing pointer-derived is written). The tracked primary configs (.claude/settings.json, .codex/hooks.json, new .grok/hooks/fm-primary-vault-check.json, new .opencode/plugins/fm-primary-vault-check.js, .pi/extensions/fm-primary-turnend-guard.ts) cover the primary and every secondmate home on all five verified harnesses.
(7) Known gaps are documented in docs/vault-guard.md rather than silently skipped: a project that tracks its own .codex/hooks.json is left untouched (spawn warns loudly, that one task runs unguarded at the hook layer); codex interactive-TUI hook loading is wired-but-unverified (exec mode verified live); a deleted last-spawning home leaves grok's baked checker path dead and the hook fails open until the next spawn; the wrapper's JSON shapes are pinned against infisical 0.43.84 and abort closed on drift.
(8) AGENTS.md is deliberately UNCHANGED per the firstmate-coding-guidelines knowledge-placement decision tree: mechanics live in the two script headers, reference/incident/validation live in docs/vault-guard.md, and the deny messages teach the sanctioned paths reactively - no inline stub is needed.
(9) The codex secondmate launch template is deliberately unchanged (no hook-trust flag): its tracked hooks.json entry loads under whatever hook trust the home already has, exactly like the pre-existing arm/cd entries there.
(10) Verification recorded dated in docs/vault-guard.md: live claude 2.1.220 scratch crewmate session (real allow: infisical --version executed; real deny: infisical secrets blocked with vault-secret-print and the chained touch sentinel never ran) and live codex-cli 0.144.4 exec session with the exact spawn-generated hooks.json (same allow/deny/sentinel results); pinned shellcheck lint green via bin/fm-lint.sh; all 74 test suites green including the new tests/fm-vault-guard.test.sh (60-case matrix x 5 harness entry forms with per-row reason codes, transport fail-open, prefilter fast path, wrapper output shapes, per-harness spawn-install against a fake tmux backend, tracked wiring).

What Changed

  • Add a vault command policy and PreToolUse guard that block Infisical commands capable of printing injected secret values.
  • Add a strictly parsed, names-only Infisical export wrapper with no raw-output fallback.
  • Wire the guard into primary and spawned crewmate flows across supported harnesses, with documentation and coverage for policy, transport, and installation paths.

Risk Assessment

⚠️ Medium: The prior bypasses are fixed on inspection, but this remains a broad security-sensitive hook change across all supported harnesses and the documented interactive Codex loading gap is still unverified.

Testing

The reported baseline full suite, the focused 66-case-by-five-harness acceptance suite, direct adapter payload checks, a live Codex PreToolUse session, and names-only wrapper output all passed; evidence shows the unsafe compound command never executed its sentinel and no worktree artifacts were left behind.

Evidence: Focused vault-guard acceptance test transcript
ok - vault-guard acceptance matrix: 66 cases x 5 harness entry forms, block/allow and reason codes all correct
ok - vault-guard: fails open on empty stdin
ok - vault-guard: fails open on unparseable stdin JSON
ok - vault-guard: fails open (never blocks) when node is missing
ok - vault-guard: fails open on the stdin path when jq is missing
ok - vault-guard: prefilter fast-allows (skips node) when no infisical substring is present
ok - vault-guard: prefilter strict superset delegates quote-split tokens
ok - vault-guard: fm-vault-command-policy.mjs CLI honors the deny/allow output contract
ok - fm-secrets-names.sh: array shape yields names only
ok - fm-secrets-names.sh: flat object shape yields names only
ok - fm-secrets-names.sh: empty known shapes succeed without output
ok - fm-secrets-names.sh: unrecognized shapes print nothing and exit non-zero (no raw fallback)
ok - fm-secrets-names.sh: CLI failure prints nothing and exits non-zero
ok - fm-secrets-names.sh: requires --projectId and --env, rejects unknown flags
ok - fm-spawn: claude crewmate gets the vault PreToolUse hook alongside the Stop hook
ok - fm-spawn: codex crewmate gets a worktree vault hooks.json plus the hook-trust launch flag
ok - fm-spawn: codex crewmate never clobbers a pre-existing hooks.json and warns about the gap
ok - fm-spawn: opencode crewmate gets the vault plugin alongside the turn-end plugin
ok - fm-spawn: pi crewmate extension gains the vault tool_call handler next to turn-end
ok - fm-spawn: grok crewmate gets the pointer-gated global vault hook (inert elsewhere, denies in-workspace)
ok - .claude/settings.json: PreToolUse invokes the vault guard alongside the arm and cd guards
ok - .codex/hooks.json: PreToolUse invokes the vault guard alongside the arm and cd guards
ok - .grok primary vault hook: PreToolUse invokes the vault guard
ok - .opencode vault plugin: tool.execute.before invokes the vault guard and blocks by throwing
ok - .pi primary extension: tool_call runs the vault guard alongside the arm and cd checks
ok - bin/fm-vault-pretool-check.sh and bin/fm-secrets-names.sh are shellcheck-clean
Evidence: Live Codex hook end-to-end transcript
Reading additional input from stdin...
OpenAI Codex v0.144.4
--------
workdir: /private/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T/no-mistakes-evidence/01KYTSGVGWFPT8JSZXP6ES20T1/codex-hook-e2e
model: gpt-5.6-terra
provider: openai
approval: never
sandbox: danger-full-access
reasoning effort: high
reasoning summaries: none
session id: 019fb5b0-36ae-7462-8af4-602668dbbf8b
--------
user
Run exactly these two shell commands as separate Bash tool calls, in order.
Do not inspect files, do not edit files, and do not substitute alternatives.

PATH='/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T/no-mistakes-evidence/01KYTSGVGWFPT8JSZXP6ES20T1/codex-hook-e2e/bin':$PATH infisical --version
PATH='/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T/no-mistakes-evidence/01KYTSGVGWFPT8JSZXP6ES20T1/codex-hook-e2e/bin':$PATH infisical secrets; touch '/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T/no-mistakes-evidence/01KYTSGVGWFPT8JSZXP6ES20T1/codex-hook-e2e/deny-executed.sentinel'

Then respond with one line saying whether each command executed or was blocked.
warning: `--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation.
warning: `--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation.
hook: SessionStart
hook: SessionStart
hook: SessionStart
hook: SessionStart Completed
hook: SessionStart Completed
hook: SessionStart Completed
codex
Running the two requested Bash commands in order.
hook: PreToolUse
hook: PreToolUse Completed
exec
/bin/bash -c "PATH='/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T/no-mistakes-evidence/01KYTSGVGWFPT8JSZXP6ES20T1/codex-hook-e2e/bin':"'$PATH infisical --version' in /private/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T/no-mistakes-evidence/01KYTSGVGWFPT8JSZXP6ES20T1/codex-hook-e2e
 succeeded in 230ms:
infisical version 0.43.84-test

hook: PreToolUse
2026-07-31T01:01:05.662305Z ERROR codex_core::tools::router: error=Command blocked by PreToolUse hook: {"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 <id> --env <slug> lists secret NAMES only; infisical run [flags] -- <cmd> injects secrets into a child process env without printing them."}. Command: PATH='/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T/no-mistakes-evidence/01KYTSGVGWFPT8JSZXP6ES20T1/codex-hook-e2e/bin':$PATH infisical secrets; touch '/var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T/no-mistakes-evidence/01KYTSGVGWFPT8JSZXP6ES20T1/codex-hook-e2e/deny-executed.sentinel'
hook: PreToolUse Blocked
codex
First command executed; second command was blocked.
tokens used
14,550
First command executed; second command was blocked.
ASSERTION: denied sentinel absent
Evidence: Names-only wrapper output
DATABASE_URL
API_TOKEN
ASSERTION: wrapper emitted only expected names; fixture values never reached stdout
Evidence: Configured Claude and Codex adapter deny transcript
Vault PreToolUse end-to-end adapter transcript
The command below is supplied only as hook input. It is never evaluated by this script.

[claude]
requested Bash command: infisical secrets; touch /var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T/no-mistakes-evidence/01KYTSGVGWFPT8JSZXP6ES20T1/claude-deny-executed.sentinel
hook exit: 2 (2 means PreToolUse deny)
sentinel exists after hook: NO - the requested compound command was not run
adapter stdout:
adapter stderr:
  {"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 <id> --env <slug> lists secret NAMES only; infisical run [flags] -- <cmd> injects secrets into a child process env without printing them."}

[codex]
requested Bash command: infisical secrets; touch /var/folders/_4/q0rlygb56rs6y6xp9sbql5240000gn/T/no-mistakes-evidence/01KYTSGVGWFPT8JSZXP6ES20T1/codex-deny-executed.sentinel
hook exit: 2 (2 means PreToolUse deny)
sentinel exists after hook: NO - the requested compound command was not run
adapter stdout:
  {"decision":"deny","reason":"[vault-secret-print] this infisical command can print secret VALUES into the session transcript. Sanctioned paths: bin/fm-secrets-names.sh --projectId <id> --env <slug> lists secret NAMES only; infisical run [flags] -- <cmd> injects secrets into a child process env without printing them."}
adapter stderr:
  {"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 <id> --env <slug> lists secret NAMES only; infisical run [flags] -- <cmd> injects secrets into a child process env without printing them."}

RESULT: both configured adapters denied the value-printing form before the sentinel could run.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed ✅
  • 🚨 bin/fm-vault-command-policy.mjs:314 - Forwarder options bypass the dump check. infisical run -- nice -n 1 printenv recurses from 1 rather than the real child printenv, so it is allowed and then prints the injected environment. This contradicts the required denial of indirect executors such as nice.
  • 🚨 bin/fm-secrets-names.sh:104 - Any JSON object is accepted as the flat export shape. For example, an upstream shape such as {&#34;secrets&#34;:[...],&#34;meta&#34;:...} exits successfully and prints secrets and meta, rather than aborting. The required contract permits exactly a flat object or key-object array and requires unknown shapes to print nothing and fail.

🔧 Fix: harden vault guard parsing
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • command -v tmux >/dev/null || { echo "tmux is required for e2e tests" >&2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo "== $t =="; bash "$t" || rc=1; done; exit "$rc"
  • Baseline configured command was reported successful before this validation: command -v tmux &gt;/dev/null || { echo &#34;tmux is required for e2e tests&#34; &gt;&amp;2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo &#34;== $t ==&#34;; bash &#34;$t&#34; || rc=1; done; exit &#34;$rc&#34;
  • bash tests/fm-vault-guard.test.sh
  • Live isolated hook check: codex exec --dangerously-bypass-hook-trust --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check … with a worktree-shaped .codex/hooks.json
  • PATH=<fixture-bin> bin/fm-secrets-names.sh --projectId vault-test-project --env test
  • Direct Claude-shaped and Codex-shaped payload checks through the tracked hook commands, verifying exit 2, remediation text, and absent sentinels
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…ult guard)

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 adds mechanical, fail-closed enforcement at the PreToolUse layer:

- bin/fm-vault-command-policy.mjs: policy owner reusing the shared shell
  classifier; allows only the injection form (infisical run), login/init,
  help/version, denies every value-printing form, run children that dump
  the injected env, and unclassifiable shell around the infisical token.
- bin/fm-vault-pretool-check.sh: harness transport, same shape and
  fail-open transport contract as the sibling arm/cd seatbelts.
- bin/fm-secrets-names.sh: the only sanctioned listing path - structural
  JSON parse to name fields, nothing printed on any parse failure.
- bin/fm-spawn.sh: installs the guard for every crewmate/scout on every
  verified harness (claude settings.local.json, codex worktree hooks.json
  plus --dangerously-bypass-hook-trust, opencode plugin, pi extension
  handler, grok pointer-gated global hook).
- Tracked primary configs gain the same guard, covering the primary and
  every secondmate home on all five harnesses.
- bin/fm-arm-command-policy.mjs: exports its generic execution-sink
  helpers so the new sibling never duplicates shell lexing.
- docs/vault-guard.md: policy table, install matrix, known gaps, and the
  dated live claude + codex validation records (real allow, real deny,
  sentinel proof that denied compound lines never execute).
- tests/fm-vault-guard.test.sh: 52-case matrix across five harness entry
  forms with per-row reason codes, transport fail-open, prefilter,
  wrapper output-shape, per-harness spawn-install, and wiring tests.
Four holes closed after attacking the initial policy:

- Cobra glued short flags: infisical run -cprintenv (and -c=x) now
  resolves the payload and denies the dump, instead of reading it as an
  opaque flag and allowing.
- command -v/-V infisical is an existence query, not an execution; it is
  now allowed (the cd-guard's exact carve-out).
- builtin prefixes inside run children (sh -c 'builtin export') no
  longer slip the dump check; case-variant shell names (SH -c) fail
  closed in dump context and mention-gated at top level.
- Token matching is case-insensitive end to end (policy regex/basename,
  transport bracket-pattern prefilter): macOS's default case-insensitive
  filesystem executes Infisical secrets as the real binary.

Matrix grows to 60 cases x 5 entry forms; lint stays green.
@elixlabssolutions
elixlabssolutions merged commit 9265ecd into main Jul 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant