Vault

Secrets by reference: the agent writes a vault reference, the firewall puts the value in only where it may go

Edit on GitHub

The vault keeps credentials and sensitive personal or financial values (API tokens, OAuth tokens, a card, an IBAN) in your operating system's secret store, records where each one may go, and makes the firewall treat every stored value as a credential wherever it appears.

The agent writes a reference such as vault://github/token or vault://bank/primary#iban where the value would go. The firewall puts the value in only at the moment an allowed action runs, only for destinations that secret is bound to, and scrubs it back to the reference in everything the model reads.

Built: the stores, the signed index, bindings, the commands below, the data-loss rule for stored values (FW-VAULT-EGRESS), the doctor checks, and substitution through the MCP gateway (every client) and through the hooks for shell commands where the agent can rewrite tool input (Claude Code). Upstream OAuth tokens from mcp login live in the vault. launchsafe-firewall run --env NAME=vault://... -- <command> is built with run. Not built: vault import --from-config and vault unlock or lock.

Commands

CommandWhoDoes
vault add <name> [--kind K] [--fields a,b] [--confirm always or never]You (terminal, approval passphrase)Reads each field with echo off, stores it, records its fingerprint, re-signs the index
vault allow <name> --host H [--allow-in-url] [--allow-http], --mcp S[:tool[:arg]] or --process "<argv>"YouAdds a binding
vault revoke <name> <n>YouRemoves binding n (as vault list numbers them)
vault rotate <name>YouReplaces every field's value and fingerprint; bindings stay
vault remove <name>You (and a typed "yes")Deletes the values and the entry
vault list [--json] (also vault)Anyone, agents includedNames, kinds, fields, stores, bindings; never a value or a fingerprint

The changing commands refuse to run from a script or from inside an agent, and the firewall denies them (FW-TAMPER) when an agent tries. They need an approval passphrase (approver init): it unlocks the key that signs the index.

Names are 1 to 4 parts of lower-case letters, digits, _ and -, joined by / (github/token, bank/primary). A reference is vault://<name> or vault://<name>#<field>, and it must start on a word boundary.

Kinds and their default fields: credential and oauth (value), card (number, expiry, cvc, name), iban (iban, bic, holder), national_id and personal (value).

Where values are stored

StoreWhenProtection from programs running as you
macOS KeychainmacOS--confirm always (the default for card, iban, national_id, personal) creates the item with an empty access list, so every read shows the keychain dialog. --confirm never (the default for credential and oauth) trusts /usr/bin/security, so any program running as you can read it through that tool. Never click "Always Allow" on that dialog
Linux Secret ServiceLinux with a D-Bus session busNone per application: once your login keyring is unlocked, any program on the session bus can read it. Run the agent in its OS sandbox (no bus socket)
Encrypted file (vault/store.enc)Linux without a Secret Service, only with "vault": { "fileFallback": true } in your user policy (off by default)AES-256-GCM per value under a random data key, wrapped with a key from scrypt over the approval passphrase. Against backups and accidental reads only

On macOS the value is written with /usr/bin/security -i, the command on standard input, so it is never in argv or ps. On Linux it uses secret-tool store, the value on standard input. Helpers run only from fixed root-owned system paths, never through PATH. approver rotate re-signs the index with the new key and re-wraps the file store's data key.

The signed index

vault/index.json lists each secret's name, kind, fields, store, confirmation setting, bindings and per-field fingerprints (length, two rolling hashes and a SHA-256 prefix; never the value). It is signed with the approver key and verified against the key the hook command pins.

If the signature does not verify, no secret is bound to anything, the values are still recognised and denied everywhere, and doctor fails. If the index cannot be read at all, no stored value could be recognised, so it is not treated as "no vault". In both cases, while the vault is on, every send and every fetch that carries data asks (FW-VAULT-INDEX) until the index is repaired. A change is never built on an index that does not verify, so a forged binding cannot be laundered by the next vault allow.

Bindings live only in the signed index, never in a policy file, so no repository can bind a secret to its own host.

BindingMatches
--host api.github.comThat exact host, over https; *.example.com matches its subdomains (not the apex). A value inside the URL itself also needs --allow-in-url; plain http needs --allow-http
--mcp linear[:create_*[:headers.token]]Calls to that MCP server, and the tool pattern when given
--process "npm publish"A program and arguments that run --env may give the secret to; * only as a whole last word, never as the program

A stored value is always a credential (FW-VAULT-EGRESS)

Any outbound payload (a request body or URL, MCP arguments, a message) that contains a stored value, in the clear or base64, base32, hex, URL or DNS-label encoded, going to a destination that secret is not bound to, is denied. The rule ignores dataAllowHosts and sendAllowHosts: the per-secret binding is the narrower statement. It applies in every session, trusted or not, and is notified like other data blocks. This covers a value that reached the agent some other way, such as a .env file it read. Fields shorter than 12 characters (a CVC, an expiry date) cannot be fingerprinted; vault add says which.

A stored value also counts as a secret elsewhere: tool output that shows one makes the session sensitive and remembers its fingerprint, and a file written with one is a secret file (publishing it later needs you).

The keychain and keyring tools naming the firewall's service, and lookups that do not say which service they mean (so they would reach the vault's items too), are denied from inside an agent (FW-TAMPER): the store is the firewall's data.

Substitution

A reference only matters where it would leave the machine: in a request's URL or body, a shell command that sends data, or MCP arguments. Anywhere else (a file the agent writes, a comment) it is just text: nothing is substituted and no rule applies.

RuleWhenOutcome
FW-VAULT-UNBOUNDThe reference names no stored secret or a field it lacks; the destination is not one the secret is bound to; the value would go into a URL without --allow-in-url; plain http without --allow-http, or a local address when the bound host is not itself local; the index does not verify; MCP arguments with more than 1000 references, or nested deeper than 64 levelsDeny ("vault://github/token may only be used with host api.github.com")
FW-VAULT-UNSUPPORTEDA position the firewall cannot fill without the agent seeing the value: a WebFetch URL or search query; single quotes, $'...', backticks, a command or parameter substitution or a here-document in a shell command; a reference given to anything but curl or wget named as such; a variable or substitution anywhere in that curl or wget command; an agent that cannot rewrite tool input; an MCP server not behind the gateway; an OAuth token anywhere but the gateway's Authorization headerDeny, with the run --env hint
FW-VAULT-OPTIONThe curl or wget command that would get the value carries an option that is not on the allowlist, saves the response to a file in a command that carries any reference, uses a URL not written out as http(s)://host/..., or has a prefix assignment other than LANG, LANGUAGE, LC_*, TZ, NO_COLOR, TERM, COLUMNSDeny, naming the option, with the run --env hint
FW-VAULT-APPROVEA kind in vault.approveKinds (cards, IBANs, national ids, personal values); any substitution in an untrusted session; a keychain item stored with per-use confirmation in a session no one is watchingApprove: asked, or queued when unattended; the approval covers that exact action once

Every other rule judges the action exactly as if the reference were text, so a bound substitution in an untrusted session still needs FW-SEND's approval. The question names the reference, the kind and the destination, never the value. Values are read from the store only after the final verdict is allow; nothing is read for a denied or held action. A value that cannot be read (a locked store, a missing item) denies the action: it is never sent with the reference in place of the value.

Where substitution works, per agent

PathClaude CodeCodexGemini CLICursorClaude Desktop, VS Code, other MCP clients
MCP arguments through the gateway (answers scrubbed)Yes (hook mode)Yes (hook mode)Yes (hook mode)Yes (hook mode)Yes (full mode)
A reference in a shell command (hook rewrite)Yes: a ticketed runRefused (FW-VAULT-UNSUPPORTED)RefusedRefusedNot applicable
Second scrub of tool output by the hookYesNo output replacementNo output replacementNo output replacementThe gateway rewrites answers
run --env typed by the agentRefused (FW-TAMPER): the person runs itRefused, sameRefused, sameRefused, sameNot applicable

vault.shellRewrite: "off" refuses the shell rewrite everywhere.

The hook rewrite (Claude Code)

A Bash command such as

curl -H "Authorization: Bearer vault://github/token" https://api.github.com/user

is allowed (when bound) with its input rewritten to a single-use ticketed run:

launchsafe-firewall run --ticket <T> --env __LSV_1=vault://github/token -- /bin/sh -c 'curl -H "Authorization: Bearer ${__LSV_1}" https://api.github.com/user'

Only the curl or wget command that carries the references gets the values. Each such simple command is replaced, where it stands, by its own ticketed run; everything around it (pipes, && lists, redirections) stays in the agent's shell as written and reads only the filtered output.

  • The values exist only in that one process's environment: no other command of the agent's runs with them, and a command that names __LSV_ is refused before anything runs.
  • The program is named by its absolute path, found in the PATH the hook itself was started with or the system directories. Not found: denied.
  • curl -q (wget --no-config) comes first, so a .curlrc or .wgetrc the agent wrote cannot add trace or log options.
  • The rewrite rebuilds the command from literal words, so it carries no variable or substitution that could be computed at run time. Two sending commands get two tickets.
  • wget also gets --max-redirect=0. curl follows no redirects without -L, which is refused.
  • The ticketed run drops, from the environment it inherits, every variable that changes routing, name resolution, TLS trust or config (the *_proxy family, HOSTALIASES, LOCALDOMAIN, SSLKEYLOGFILE, CURL_CA_BUNDLE, SSL_CERT_FILE, CURL_HOME, WGETRC, XDG_CONFIG_HOME, LD_*, DYLD_*).

The ticket is stored by the hook with a hash of the exact --env pairs and argv, the references, the session and an expiry (vault.ticketSeconds, ten minutes for an asked action). run takes it atomically and refuses (exit 77, logged as vault_denied) when it is unknown, already used, expired, or issued for another command.

The curl and wget option allowlist

A value travels only in a request to the binding's own host, and curl or wget never write it to disk or to the output. So a curl or wget command that carries a reference is rewritten only when every option is on this list; anything else is FW-VAULT-OPTION, naming the option. Long options are matched by their full name only. Short options may be clustered (-sSf, -XPOST).

  • curl: -s, -S, -f, --fail-with-body, --no-progress-meter, --compressed, -i, --show-headers, -I, -X, -H (may carry a reference; -H @file is refused), -d and its --data* and --json forms (may carry a reference; @file is fine, a reference after @ is refused), -u, -G (moves the data into the URL, so a reference there needs --allow-in-url), -A, -m, --connect-timeout, --retry, -w (only %{name} from a fixed list of transfer variables, never %output{}, %header{}, %{url}, %{json} or @file), --url.
  • wget: -q, -nv, --content-on-error, --no-config, -S, --method, --header, --post-data, --body-data, --user, --password, --http-user, --http-password, -U, -T, -t, --max-redirect=0.
  • Saved responses: -o, -O and wget's -O are refused in a command that carries any vault reference, unless the target is - (stdout) or /dev/null. A saved response is not filtered, and a server that echoes the request would write the value into the file. Plain wget (which saves a file named after the URL) is refused in such a command.
  • Refused with a reason of their own: -L, --resolve, --connect-to, -x and --proxy, --preproxy, --socks*, --doh-url, --dns-servers, --unix-socket, --interface, -v, --trace, --trace-ascii, -K, --variable, -k, --next; wget -i, -e, -d, --no-check-certificate, -r, -m.

A reference may appear only in a header, the data, the credentials or a URL. Every URL must be written out as https://host[:port]/... (or http:// with --allow-http), with no user info, no globbing in the host and no backslash, and every URL of the command must be the bound host.

Output scrubbing

What the model reads is scrubbed back to the reference, by the gateway for MCP and by PostToolUse for Claude Code (every fingerprinted value in the clear; output in which a value is still detectable is replaced by a withheld note). Dumps (curl --trace -, hexdump -C, xxd) are judged whole, so a value split over rows, or spelled as spaced hex, is still found. Fields shorter than 12 characters have no fingerprint, so only the gateway, which knows the value for the call in flight, can scrub them.

Upstream OAuth tokens

mcp login stores the tokens as vault://mcp/<server> (kind oauth, bound mcp:<server>) when it can sign the entry. The gateway sends them only as the upstream Authorization header, and refreshes them in the store unattended. See MCP gateway.

At session start

With vault.listInContext, the agent is told which references exist and where each may go, names and destinations only.

Audit

Entries in the signed decision log never hold a value or a fingerprint: vault_change (add, allow, revoke, remove, rotate, re-sign after approver rotate, refresh for an OAuth token), vault_use (session, reference, kind, how, destination, binding, action hash, verdict), vault_denied (the rule or the problem, such as a store that could not be read) and vault_scrub (a gateway answer withheld because a vaulted value could not be scrubbed). Queue items keep the references, never values; notifications are fixed text.

Policy

KeyDefaultA project file mayUser or managed only
vault.enabledtrueNothing (a repository must not switch off your vault)Set false
vault.confirmDefault.<kind>always for card, iban, national_id, personal; never for credential, oauthSet "always"Set "never"
vault.fileFallbackfalseSet falseSet true
vault.approveKindscard, iban, national_id, personalAdd kindsRemove kinds
vault.approveWhenUntrustedtrueSet trueSet false
vault.shellRewrite"auto"Set "off"Either
vault.ticketSeconds30Lower it (5 minimum)Raise it (up to 120)
vault.listInContexttrueSet falseEither

These keys belong to schema version 3 and are still read from a version 2 file, with the same meaning.

Doctor

doctor reports whether the index verifies against your approval key; how many secrets and bindings there are, and which secrets are bound to nothing; entries whose store is not available on this machine; values missing from the macOS Keychain (checked without reading them, so no dialog); and the weaker stores and financial or personal items stored without per-use confirmation. It is not part of the protection score.

Honest limits

  • A bound host that echoes a request back can return the value. What comes back is filtered in the clear and in the encodings above, not in arbitrary transformations. Redirects are never followed with a value.
  • The host check is on the name: the system's own resolver decides the address. A bound name that resolves somewhere unexpected (your DNS, your /etc/hosts) gets the value. A host binding ignores the port and a trailing dot.
  • Only curl and wget get values from a shell command. For other programs, a person binds the secret to the command (vault allow <name> --process) and runs it with run --env.

The vault keeps secrets out of what the agent reads and limits where actions the firewall sees can send them. It is not a hardware boundary: a program running as you, outside the sandbox, that the firewall cannot see into can read your keychain (on Linux always; on macOS only for secrets stored without per-use confirmation). Run the firewall with the agent's sandbox.

On this page