vallum 0.8.10

Security boundary between AI coding agents and your shell — redacts secrets, neutralizes prompt injection, sanitizes untrusted terminal output, audits every command.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
# Vallum Security: Threat Model

This document describes what Vallum protects, by which mechanism, at what
strength — and what it explicitly does **not** guarantee. It complements the
[README](README.md), which covers usage and configuration.

## Trust boundaries

Vallum sits between an AI coding agent and your shell. Everything a child
process writes to stdout/stderr is **untrusted input**: it may contain
secrets, adversarial text aimed at the model, or marker forgeries. The
Vallum binary and its config file (`~/.vallum/config.toml`) are trusted; the
agent's *commands* are executed as given — Vallum is not a sandbox (see
non-guarantees).

```mermaid
flowchart LR
    A[AI agent] -- command --> V[Vallum]
    V -- spawn --> C[Child process / shell]
    C -- untrusted stdout+stderr --> V
    V -- scrubbed, wrapped output --> A
    V -. sanitized log + stats, 0600 .-> L[(~/.vallum)]
```

## What Vallum protects — and how strongly

Strength vocabulary used below:

- **Structural** — holds by construction; does not depend on pattern quality.
- **Best-effort** — pattern/heuristic based; raises the cost of an attack and
  catches common cases, with known gaps listed in the sections below.
- **Not a security control** — a side benefit, never load-bearing.

| Threat | Protection | Mechanism | Strength |
|---|---|---|---|
| Secrets in terminal output | Redacted before the model or the sanitized log sees them | Known-format patterns (provider keys, connection-string passwords, `.env` assignments) + context-gated entropy detection | Best-effort |
| Secrets in command names/args | Redacted in the audit log, `stats.jsonl`, and `--json` output | The same scrubber applied to cmd/args before any sink | Best-effort |
| Prompt injection in command output / logs | Trigger line neutralized, or whole output blocked | Multilingual pattern families; whole-line consumption; opt-in `--strict` fail-closed | Best-effort |
| Invisible/bidi control chars in output | Removed before the model sees them | Fixed-set zero-width/BOM/bidi strip (`normalize`) | Structural (for the listed code points) |
| Forged `[UNTRUSTED TERMINAL OUTPUT]` markers | Output cannot close the wrapper early or smuggle "trusted" text | Marker defang after all content transforms; exactly one wrapper survives | **Structural** |
| Log exposure | Secrets don't reach disk by default | Raw (unredacted) log **off by default**; sanitized log and stats written `0600`; cmd/args redacted even in the raw log header | Structural defaults |
| Noisy output / token bloat | Compressed before reaching the model | Command optimizers, truncation, whitespace collapse | Not a security control |
| Accidental / dangerous command execution | Command is prompted on (`Ask`) or refused (`Deny`) before it runs | Pre-exec guardrail: plain-text pattern rules over the joined command line → Allow/Ask/Deny (default on, built-ins all `Ask`) | Best-effort |

Detection strength is measured, not asserted: see
[`evals/report.md`](evals/report.md) for precision/recall over the committed
`evals/corpus/`, including the honest list of known misses.

## Per-threat detail and known gaps

### Secrets in terminal output

Two layers run on every command's output (and on command names/args before
they are logged):

1. **Format patterns** — OpenAI `sk-`/`sk-proj-`, Anthropic `sk-ant-…`,
   GitHub `ghp_`/`github_pat_`, GitLab `glpat-`, Slack `xox*`, AWS `AKIA…`,
   Google `AIza…`, Stripe `sk_live_`/`rk_live_`, SendGrid `SG.…`, Twilio
   `SK…`, npm `npm_…`, PyPI `pypi-…`, Hugging Face `hf_…`, Slack incoming
   webhooks, Discord/Telegram bot tokens, DigitalOcean `dop_v1_…`, Shopify
   `shp*_…`, Azure Storage `AccountKey=…`, Sentry DSNs, age secret keys
   (`AGE-SECRET-KEY-1…`), Google OAuth (`ya29.`/`GOCSPX-`), Stripe webhook
   secrets (`whsec_`), GitHub OAuth/app tokens (`gho_`/`ghu_`/`ghs_`/`ghr_`),
   New Relic keys (`NRAK-`/…), Supabase `sbp_…`, Doppler `dp.st.…`, Linear
   `lin_api_…`, Figma `figd_…`, Postman `PMAK-…`, Databricks `dapi…`, JWTs
   (both `Bearer`-prefixed and bare `eyJ….eyJ….sig`), PEM private keys,
   connection-string passwords, and uppercase `.env`-style assignments.
   Extensible via `[scrubber] extra_secret_patterns`.
2. **Context-gated entropy detection** (default on; `[scrubber] entropy =
   false` disables) — a value is masked only when it is the value of a
   credential-ish assignment (key containing
   pass/pwd/secret/token/key/auth/cred/session) AND it is ≥16 chars with
   high Shannon entropy. Bare tokens — commit SHAs, UUIDs, base64 blobs in
   logs — are never candidates.

**Known gaps**

- Unknown-format secrets *outside* any assignment context are not caught
  (catching them was rejected as a high-false-positive design).
- Secrets split across lines, or encoded so that measured entropy drops.
- Low-entropy passwords and pure-decimal secrets are exempt by design (the
  alternative would redact ordinary IDs and phone numbers).
- A secret the agent itself typed into a command is already known to the
  model; Vallum still keeps it out of the audit log, stats, and JSON output.

### Prompt injection in output and logs

Pattern families ("ignore previous instructions", "you are now…", "reveal
your system prompt", DAN/persona jailbreaks, injected `Assistant:`/`System:`
turns, plus escalation banners ("IMPORTANT!!! ignore…" / "strictly adhere to
the following instruction"), covert-action markers ("do not tell the user",
"hide this from the user"), data-exfiltration imperatives (send/email a
credential-ish object to an external address/URL), forged chat-template /
tool-call scaffolding (`<|im_start|>system`, `[INST]`, `<<SYS>>`,
`<tool_call>`, Alpaca `### Instruction:` headers), and invisible-markup
injections (an instruction hidden via `display:none`/`opacity:0` CSS)) are
detected across English,
Turkish, Spanish, German, French, Chinese, Japanese, Korean, Russian, Arabic,
Portuguese, Italian, Dutch, and Hindi (coverage varies by family), resist
line-splitting, and neutralize the whole trigger line (trigger + payload) with
`[POTENTIAL INJECTION NEUTRALIZED]`. With `--strict` (or `[security]
strict`), any detection replaces the entire output body with
`[OUTPUT BLOCKED: prompt injection detected]` while preserving the child
exit code. The sanitized log only ever receives post-neutralization text. The injection scanner runs before secret redaction within the scrub stage, so a secret pattern can no longer delete a trigger word and hide an injection.
Before matching, output is normalized: zero-width, BOM, and bidi control
characters are stripped from the output entirely, and the scanner matches a
per-line shadow (compatibility/decomposition + combining-mark strip +
lowercase + curated confusable folding) so homoglyph, full-width, and
diacritic evasions are seen through. The despaced ignore-family and
reveal-family also catch no-space concatenation
(`ignoreallpreviousinstructions`, `revealyoursystemprompt`). This normalization
relies on the `unicode-normalization` crate (NFKD/NFKC) plus a curated,
dependency-free confusable table.

**Known gaps**

- The escalation/covert-action/exfiltration/scaffolding/hidden-markup families
  are English-only so far (the override/reveal families are multilingual).
- Invisible-markup detection is line-local: an instruction hidden across
  multiple lines of un-minified HTML (hiding technique and directive on
  different lines) is not correlated.
- Confusables outside the curated table (the table targets ASCII-Latin
  look-alikes of the keyword alphabets, not the full Unicode confusable set).
- Leetspeak digit substitution (`1gn0re`) — not folded.
- `Assistant:`/`System:` turns starting mid-line rather than at line start.
- Turn lines with fewer than three natural-language words after the colon.
- Reveal-family phrases without a possessive or system-directed qualifier.

### Forged untrusted-output markers

Every result is wrapped in `[UNTRUSTED TERMINAL OUTPUT START/END]` markers.
Defanging runs after all other content transforms, so no output content —
including text produced by the optimizers or the scrubber itself — can fake
an early END marker and smuggle "trusted" text past the boundary. Exactly
one wrapper survives (property-tested). The wrapper's token cost is constant
and size-independent by design.

**Dependency, not gap:** the wrapper only helps if the agent is prompted to
treat wrapped content as untrusted data. Vallum cannot force the model to
respect it.

### Logs

Raw (unredacted) body logging is **opt-in and off by default**. The
sanitized log and `stats.jsonl` are created with `0600` permissions on Unix.
Command names and arguments are scrubbed before they appear in *any* log
header, stats record, or JSON output.

### Noisy output / token bloat

Optimizers, truncation, and whitespace collapse compress what reaches the
model. This is a cost feature, **not a security control**: summarization
never hides error lines, and the scrub → wrap stages run on every command
whether or not an optimizer fired.

### Dangerous command execution (guardrail)

Before a command runs, Vallum evaluates the **joined command line** against a
narrow set of pattern rules and returns Allow / Ask / Deny (most-severe-wins).
The guardrail is on by default; every built-in rule is `Ask`, so a matched
command prompts for confirmation rather than being blocked outright. Built-ins
cover `rm -rf` on a root/home/system path, `curl … | sh`, remote-fetch-and-exec,
`dd`/redirect/`mkfs` to a block device, the classic fork bomb, recursive
`chmod 777`, reading private keys / credential files / `/etc/shadow`,
`git push --force`, `find -delete`/`shred`/`truncate`/`xargs rm` on sensitive
targets, reverse shells (`/dev/tcp`, `nc -e`, `socat exec:`), `git clean -f`,
and recursive `chown` on a system path. Users can add `ask`/`deny` rules under
`[policy]`.

**Enforcement points.** The same Allow/Ask/Deny decision core is reached from
five call sites: direct `vallum run`, and four native pre-exec hooks — Claude
Code `PreToolUse`, Cursor `beforeShellExecution`, Gemini CLI `BeforeTool`, and
Codex CLI `PreToolUse`. Only the Claude Code hook rewrites the command through
`vallum run` (full capture/scrub/optimize/wrap pipeline); the Cursor, Gemini
CLI, and Codex CLI hooks gate the command (Allow/Ask/Deny) but do not rewrite
it or run it through the output pipeline. Two principles hold across all four
hooks:

- **P1 — Allow never overrides the agent.** On Cursor, Gemini CLI, and Codex
  CLI, an Allow verdict emits *no decision at all*, so whatever native
  approval flow the agent itself has still runs; Vallum's hook only ever adds
  friction on top, never removes it.
- **P2 — Ask fails closed where there is no native ask.** Claude Code and
  Cursor expose a native "ask the user" decision; Gemini CLI and Codex CLI do
  not. On those two, an `Ask` verdict is enforced as a **Deny**, with a reason
  that names the escape hatches — run it yourself with
  `vallum run -- bash -c '<cmd>'` (the same `bash -c` wrapping the Claude hook
  applies, so a piped/compound command like `curl … | sh` stays gated as one
  unit instead of splitting across your own shell), or turn the rule off: for
  a built-in, `[policy] disabled = ["<rule>"]`; for a user-defined rule
  (named `user:<pattern>`, which `[policy] disabled` cannot touch), edit or
  remove the matching `[[policy.rules]]` entry in your config instead.
  Emitting no decision instead would silently become *allow* under an
  agent's auto-approve mode, which is unacceptable for a security control.

Every Ask/Deny verdict, on any of the five call sites, is still recorded
(redacted) to `policy.log`, and the line now names which surface produced it:
`agent=claude|cursor|gemini|codex|direct`.

**Strength: best-effort.** This raises the cost of an *accidental* destructive
command and gives the model (or you) a confirmation checkpoint — it is **not a
sandbox**.

**Known gaps**

- Matching is **plain-text over the command string** — no shell parsing, no
  path resolution, no variable/alias expansion. A determined author can evade a
  rule by obfuscation (unusual quoting, indirection, alternate tools,
  base64-then-decode). The built-ins target the *common, obvious* forms.
- Rules are deliberately **narrow to preserve precision** (a committed
  benign-command gate keeps them from firing on legitimate commands), so they
  under-match rather than risk nagging — they are not an exhaustive catalog of
  dangerous commands.
- **Path-aware matching is deferred:** the guardrail cannot tell
  `rm -rf ./build` from `rm -rf /` beyond the literal patterns encoded today.
- **Codex CLI does not intercept every shell call.** Per Codex's own hooks
  documentation: "This doesn't intercept all shell calls yet, only the simple
  ones. The newer `unified_exec` mechanism allows richer streaming
  stdin/stdout handling of shell, but interception is incomplete. Similarly,
  this doesn't intercept `WebSearch` or other non-shell, non-MCP tool calls."
  A command Codex never routes through the hook reaches the shell with no
  Vallum verdict at all — logged or otherwise. See the README's
  [Multi-agent guardrail]README.md#multi-agent-guardrail section for the
  source link.
- **Codex CLI skips untrusted hooks silently (fail-open until trusted).**
  Codex only runs a hook after its exact definition has been reviewed and
  trusted once (Codex TUI review flow, or `--dangerously-bypass-hook-trust`
  for automation); an installed-but-untrusted hook is skipped with no warning
  and gated commands execute unguarded. This trust state lives inside Codex
  and is invisible to Vallum — `vallum install-hook --agent codex` succeeding
  and `vallum doctor` reporting "installed" do **not** imply enforcement.
  Hook-trust handling in `codex exec` was fixed in codex-cli 0.141.0
  (openai/codex#26434); on 0.139 the hook never fired at all in our tests.
  Enforcement verified live on codex-cli 0.142.5 (2026-07-08). After
  installing (or upgrading Codex), verify with a known-Ask command and expect
  a deny.
- **A hook entry that invokes bare `vallum` no-ops silently.** Bare
  `vallum` (no subcommand) prints a welcome screen and exits 0, so a
  hand-edited hook entry missing the `hook` subcommand does not fail
  closed — the agent treats the output as informational and runs the
  command ungated. (Before the welcome screen existed it exited 2, which
  blocked every command — a loud misconfiguration.) `vallum install-hook`
  never writes this shape, and `vallum doctor` reports it as "not
  installed". After hand-editing any hook config, verify with a known-Ask
  command and expect a prompt or deny.
- **TUI-headed commands are evaluated but never rewritten.** Commands whose
  first word is `vim`, `vi`, `nano`, `less`, `more`, `top`, `htop`, `tmux`,
  or `screen` go through the same policy evaluation as everything else —
  `less /etc/shadow` asks (Claude Code, Cursor) or is denied with
  instructions (Gemini CLI, Codex CLI). What the TUI list changes is the
  *rewrite*: a clean Allow passes the command through untouched, and an
  approved Ask on Claude Code runs the original command directly — in both
  cases the interactive TTY is preserved and the command's *output* does not
  go through Vallum's sanitization pipeline (it never did for any
  passed-through command). Direct `vallum run` is unaffected.
- Setting `security.guardrail = false` disables the layer entirely (Vallum then
  behaves exactly as it did before it existed).

### Guardrail wrapper coverage

The pre-exec guardrail matches against the raw command **and** a bounded set of
precision-safe views that surface a wrapped or encoded inner command:

- POSIX shell `-c` arguments (`bash`/`sh`/`zsh`/`dash`/`ksh`), bare or bundled
  (`bash -c '…'`, `bash -xc '…'`), including wrapper prefixes
  (`sudo`/`env`/`timeout`/`FOO=bar bash -c '…'`) and nesting up to depth 3
- `eval` arguments
- `base64 -d` / `--decode` payloads (decoded and re-checked)
- `$IFS` / `${IFS}` token-separator splitting
- word-internal quote splitting (`r'm'`), escaped spaces (`rm\ -rf`), and the
  same splitting applied to the interpreter verb itself (`\bash -c '…'`,
  `b''ash -c '…'`)
- whole-token quote / backslash removal, brace-list expansion (`/{bin,etc}`),
  and trailing-`/.` collapse on the command before matching, so a rule's anchor
  token cannot be hidden by quoting the path (`rm -rf "/"`), quoting a flag
  (`chmod "-R" 777 /`), escaping it (`rm -rf \/`), brace-listing it
  (`rm -rf /{bin,etc,usr}`), or writing `/.`. A quoted span that contains
  whitespace (`echo "rm -rf /"`) is never unquoted, so a benign mention stays
  benign
- ANSI-C / locale `$'…'` / `$"…"` quotes are de-prefixed (`bash -c $'rm -rf /'`
  unwraps), path-qualified interpreters are matched by basename (`/bin/bash -c
  '…'`, `curl … | /usr/bin/sh`), and `source`/`.` executing a process
  substitution (`source <(curl …)`) is recognized
- newlines are treated as command separators, so a print-led first line does
  not mask an interpreter on the next line

**Known limitation (by design):** the guardrail is defense-in-depth, not a
shell sandbox. Distinguishing a safe sink (`echo "rm -rf /"`, which only prints
the string) from an executed context in *every* case would require full
shell-verb sink analysis, which would cost precision. Several classes can still
get through: **non-shell interpreters** are not unwrapped — a payload passed to
`python -c`, `perl -e`, or `node -e` is opaque to the matcher; the interpreter
name can be **assembled from a variable** (`X=rm; $X -rf /`); it can be hidden
inside a **command substitution** that is not split out (`echo $(bash -c 'rm -rf
/')`, `` `bash -c '…'` ``, `$(printf …)`); or introduced by a **command
separator glued to the surrounding words** so the wrapped interpreter reads as
one token (`echo x;bash -c 'rm -rf /'`, where `x;bash` is not split). A literal
command **fed to a shell through a pipe or here-string** rather than a `-c`
argument (`echo 'rm -rf /' | sh`, `bash <<< 'rm -rf /'`, `… | xargs bash -c`) is
likewise opaque — the dangerous text is data on the interpreter's stdin, not a
matchable argument — as is a wrapped command hidden inside a single combined
token (`env -S "bash -c 'rm -rf /'"`). Built-in rules are `Ask`, not `Deny`, and
the guardrail is one layer — not a guarantee.

### Agent-config self-protection

Vallum defends the agent config/hook files it manages with two complementary
checks, both reusing the existing engines (no new detection logic):

- **Write guardrail (`write_agent_config`):** Asks before a shell write-verb
  (redirect, `tee`, `dd of=`, `sed -i`, `cp`/`mv`/`install` to a destination)
  targets a protected config/hook file (`.claude/settings.json`,
  `.claude/settings.local.json`, `.cursor/hooks.json`, `.codex/hooks.json`,
  `.codex/config.toml`, `.gemini/settings.json`, `.mcp.json`). Reads and
  copies *from* these files never fire. It does **not** see non-shell writers
  (`python -c 'open(...,"w")'`), a variable-assembled path, or a `cp` whose
  destination is not the final token — fail-safe, same scope as the rest of
  the guardrail. Vallum's own `install-hook` writes via Rust `std::fs`, so it
  never triggers.
- **Doctor hook-audit:** `vallum doctor` inspects the four JSON agent hook
  configs (user-level), lists foreign hook commands — any it did not install —
  as a Warn, and fails (``, non-zero exit) on one matching a dangerous
  guardrail pattern (curl-pipe-shell, reverse shell, …). Displayed commands
  are redacted through the secret scrubber first. It is a static,
  pattern-based, pull check (the user must run `vallum doctor`); it does not
  watch files, cover project-level Claude settings, or audit `.mcp.json`
  server definitions (that is `vallum mcp scan`).

### Tamper-evident audit log (policy.log hash chain)

Every Ask/Deny block appended to `policy.log` carries a `Chain:` line —
`SHA256(previous_hash ++ block_body)`, genesis-anchored. `vallum log verify`
recomputes the chain and reports the first break; `vallum doctor` runs the
same check (`log-chain`, broken → ✗ non-zero exit). Appends are serialized
with an exclusive `flock`, entries are single-lined so logged commands cannot
inject fake chain/delimiter lines, and the payload is already redacted by the
secret scrubber before hashing.

Honest limits:

- **Tail truncation is invisible to the chain alone** — deleting the last N
  blocks leaves a self-consistent shorter chain. `vallum log verify` prints
  the head hash on every successful verify; store it outside the machine (password
  manager, another host) and pass it back via `--expect-head` to catch
  truncation and full-file rewrites. Absence alone is not treated as tamper
  evidence (a machine that has never logged a verdict has no chain), but
  `--expect-head` against a missing log **does** fail (exit 20) — full
  deletion is the easiest truncation, and the anchor exists to catch it.
- An attacker with write access and knowledge of the scheme can **recompute
  the whole chain** (there is no secret key — a same-host key would be
  readable by the same attacker). The chain is tamper-*evident* against
  in-place edits, not tamper-*proof*; the external anchor closes the gap.
- Blocks written before this feature (no `Chain:` line) are legacy:
  unverifiable and outside chain coverage.

### Blast-radius circuit breaker

The guardrail rules gate one command at a time; the circuit breaker watches
the *rate*. Five Ask/Deny verdicts inside 60 seconds (defaults; `[security]`
tunable) trip a machine-wide lock: every subsequent command — including ones
the guardrail would allow — is denied for the cooldown (300s) or until
`vallum unlock`. The verdict that trips the breaker is returned unchanged;
lockdown starts with the next command. Trips are logged to the hash-chained
`policy.log` (`rule_name = circuit_breaker`) — when `[audit] sanitized_enabled`
is on (the default); with it off the breaker still trips and locks, but writes
no forensic line. `vallum`-headed commands keep
their pass-through so `vallum unlock` stays reachable — a wrapped
`vallum run` still re-enters the policy (and the trip check) in the child
process.

Honest limits:

- The state file (`~/.vallum/logs/breaker.state`) shares the config file's
  trust boundary: an attacker with filesystem write access can delete or
  edit it. The breaker is a brake on runaway **agent behavior**, not an
  on-host tamper-proof control.
- The window is machine-global, not per-agent-session: agents running in
  parallel share the counter (deliberate — the blast radius being limited
  is the machine's).
- Counting is best-effort: if the state file cannot be written, verdicts
  still stand but the breaker may undercount.

### Approval tokens (hook → `vallum run`)

In hook mode the Claude codec is the single point of policy enforcement: it
evaluates a command once, and on approval re-wraps it as
`vallum run --approval-token <hmac> -- bash -c '<original>'`. The token is an
HMAC-SHA256 over the exact command line, keyed by a machine-local secret
(`~/.vallum/logs/approval.secret`, mode 0600, generated on first use). `vallum
run` recomputes the HMAC and skips re-evaluation only when the token matches.

This prevents an agent from asserting its own bypass: a forged or injected
`vallum run --approval-token … -- …` cannot produce a valid token without the
secret, so it is re-gated by the guardrail (and the circuit breaker).

**Boundary (honest):** the token stops an agent from *forging* a bypass — it
cannot compute a valid HMAC for a command without the secret. It is **not** a
defense against an agent that can *read* the secret: a process running as the
same user with no sandbox can read `approval.secret` and mint valid tokens, and
Vallum does not defend against that, because on the same machine there is no
cryptographic boundary between your files and a process you launched. Confining
the agent so it cannot read arbitrary files is the operating-system sandbox's
job (roadmap item #1), not the guardrail's.

The token is bound to one exact command, so observing a rewritten command never
helps forge a *different* one — but the two rewrite paths differ in what a
replay of the *same* token buys:

- For an **Allowed** command, replaying its token only re-runs a command that
  was allowed anyway — harmless.
- For an **Ask** command, the hook mints the token *before* the user decides
  (so an approved Ask can run without being double-gated and failing closed).
  Replaying that token would skip the user's prompt, so this path's safety
  relies on the agent never seeing the rewritten command: Claude Code does not
  surface a `PreToolUse` hook's `updatedInput` back to the model. Treat that as
  defense-in-depth, not a cryptographic guarantee.

## MCP configuration scanning

`vallum mcp scan` is a **static, read-only** check over MCP server
configuration files. It discovers the well-known config locations (plus any
explicit path arguments), parses the server definitions, and runs three
existing engines over them: the secret scrubber on each server's `env` values,
the guardrail policy engine on each server's launch command, and the injection
detector on any tool-description text written into the config. It connects to
no server, launches no process, and modifies no file.

**What it catches:** plaintext credentials embedded in a server's `env`, a
server whose launch command matches a dangerous pattern (`curl … | sh` and the
guardrail's other rules), and prompt-injection phrasing in descriptions stored
in the config. Exit codes are `0` clean, `10` warning-class findings, `20`
high-severity (only reachable with a user-defined `deny` rule — every built-in
rule is `Ask`, i.e. warning-class), and `125` for a usage/read error.

**What it does NOT cover, stated plainly:**

- **Runtime tool descriptions are invisible to it.** Most MCP servers supply
  their tool list and descriptions at connection time via `tools/list`, not in
  the config file. A static scan sees only descriptions literally written into
  the config; the classic tool-poisoning payload delivered by a live server is
  out of scope until a future live-introspection mode is added.
- **It is pattern-based.** The injection check inherits the injection
  detector's measured recall, and the launch-command check inherits the
  guardrail's precision-safe matching and its documented wrapper limitations —
  it is a speed bump, not a proof.
- **It connects to nothing, so it cannot see behavior.** A server that is
  benign in its config but malicious at runtime (a "rug pull"), or whose risk
  lives in code rather than metadata, is not something a static config scan can
  detect.
- **It only looks where it knows to look** — the built-in well-known config
  locations plus paths you pass explicitly. A server configured somewhere else
  is not scanned unless you name its file.
- **Within a scanned file, it reads only top-level server maps** (`mcpServers`
  / `servers`, or Codex's `[mcp_servers]`). Servers nested elsewhere — for
  example project-scoped entries under `projects.<dir>.mcpServers` in
  `~/.claude.json` — are not parsed in this version, so a file can be
  discovered and read yet still have servers the scan does not see.

## Supply-chain integrity

Release binaries are built in GitHub Actions by the `dist` pipeline. Each
artifact carries a **SHA-256 checksum** (`*.sha256`, plus a combined
`sha256.sum`) and a **GitHub build-provenance attestation** binding the binary
to the exact source commit and release workflow. Verify a download before
trusting it:

```bash
gh attestation verify ./vallum --repo kahramanemir/Vallum
```

**Known gap:** macOS binaries are **not** Apple-notarized or Developer-ID
signed in this release line, so Gatekeeper may quarantine a binary extracted
from a direct `.tar.xz` download (Homebrew and the shell installer are
unaffected). Code signing / notarization is deferred.

## What Vallum does NOT guarantee

- **It is not a sandbox.** The guardrail prompts on (`Ask`) or refuses
  (`Deny`) a *narrow, pattern-matched* set of dangerous commands — best-effort
  and evadable by obfuscation (see the guardrail section). It is not an
  allowlist and does not otherwise restrict what a command does; anything it
  does not match runs as given, and it can be turned off entirely
  (`security.guardrail = false`).
- **It cannot make the model obey.** Treating wrapped output as untrusted is
  prompt-side discipline; keep your agent instructed accordingly.
- **It covers only what flows through it.** The Claude Code hook rewrites
  Bash tool calls through the full output pipeline; the Cursor, Gemini CLI,
  and Codex CLI hooks gate commands but never see or rewrite their output.
  On every agent, file reads, web fetches, and other non-shell tools bypass
  Vallum entirely — and on Codex CLI specifically, even some shell calls do
  (see the guardrail's known gaps above).
- **TUI commands bypass the output pipeline.** The hook does not rewrite
  `vim`/`vi`/`nano`/`less`/`more`/`top`/`htop`/`tmux`/`screen` (to preserve
  their TTY interaction), but they are still policy-gated beforehand.
- **An enabled raw log is unredacted by definition** (its header cmd/args
  are still scrubbed). Leave `raw_enabled = false` unless actively debugging.
- **Best-effort layers have gaps** — see the per-threat lists above. Treat
  all terminal output as untrusted regardless of Vallum.
- **Resource caps protect Vallum, not your system.** The 10 MiB capture cap
  and the timeout bound Vallum's capture path; they do not constrain what
  the child process does to the machine.

## Reporting

Found a gap or bypass? Open an issue on the repository, or use GitHub's
private security advisories for sensitive findings.