尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证A local credential proxy for coding agents: the agent sends a placeholder and never holds the secret. Free and open source from ioLite Labs.
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
Your coding agent never holds a credential.
Free and open source: a gift to the community from ioLite Labs .
A local credential proxy for coding agents. The agent sends a placeholder; the daemon replaces it with the real secret, but only at the exact place that credential is ever injected, only for a destination the grant allows, and only while the placeholder has uses left. The response comes back scrubbed. The agent never holds a credential.查看完整 README ↗A local credential proxy for coding agents. The agent sends a placeholder; the daemon replaces it with the real secret, but only at the exact place that credential is ever injected, only for a destination the grant allows, and only while the placeholder has uses left. The response comes back scrubbed. The agent never holds a credential.
agent agent-vault daemon upstream
│ Authorization: Bearer av1.7f2x…c4d9tz │
├──────────────────────────────▶ check session, grant, site, budget │
│ consume one use │
│ substitute at the declared site ─────▶ Authorization: Bearer ghp_real…
│ │
│ {"login":"frozencrow", "token_used":"av1.7f2x…"} ◀── scrub ─────────┤ {"token_used":"ghp_real…"}
◀──────────────────────────────┤ │
This repository is the M0/M1 working core of the v1 build spec, and is ready to use for what it implements: the mechanism works end to end, it is tested on Node 20.10 through 24 on Linux and macOS, and everything four independent adversarial reviews found has been fixed with a test behind it — see the changelog for what they found. What is not built is listed under Honest limits, and the biggest items are the language and storage choices the spec makes differently. Read that section before you point it at a credential that matters.
Requires Node 20.10 or newer. CI runs the suite on Linux and macOS on Node 20.10, 20, 22 and 24, with the security, property and mutation suites as separate jobs so a failure names what broke. No dependencies to install.
node demo/demo.js
The demo starts a real daemon and a stand-in upstream, then walks through: a successful call, an upstream echoing the token back, a prompt-injection attempt, the same attempt base64-encoded, an attempt to send the placeholder elsewhere, an approval, budget exhaustion, and the audit chain. It ends by confirming the real token reached the upstream and nothing else.
npm test # core and security suites
npm run test:mutation
The changelog records what the four independent reviews found, with the reproductions: CHANGELOG.md.
The mutation suite checks the tests against themselves by disabling critical protections in turn and requiring a test to notice. It also fails when a mutation anchor drifts after a refactor, so a moved check cannot quietly escape coverage.
agent-vault setup # shows the plan, then runs the installer for you
agent-vault daemon # only needed in --dev mode; otherwise it is a service
# store a credential; the value is read from the terminal, never from argv
agent-vault cred add gh --kind github
agent-vault session create --cred gh --paths "/repos/frozencrow/**"
Run either of those without arguments and you get a wizard instead of a flag list.
A session asks you before every write by default (--approval on-write). To let an agent write without asking, say so, and prove it is you:
agent-vault session create --cred gh --methods GET,POST --approval auto # asks for your passphrase, every time
agent-vault session create --cred gh --approval auto # read-only: asks for nothing extra
agent-vault session create --cred gh --infinite # no expiry; asks for your passphrase
agent-vault session create --cred gh,openai-key # one session, two credentials
auto with a write method, and --infinite, each take you out of the loop for as long as the session lives, so creating one needs your passphrase (or fingerprint, in the UI) for that one operation — the five-minute confirmation window does not count — and is refused on a vault with no human factor enrolled. An agent cannot ask for either one; you can add them when approving its request: agent-vault approve sr_… --approval auto. A read-only auto session is unaffected, because on-write would never ask for GET, HEAD or OPTIONS either. An infinite session has no idle expiry; it lasts until revoked, shortened to a finite lifetime, or ended by panic. It cannot be created as remote, and its token and placeholders are refused on every non-loopback listener, including MCP and preflight.
After installing, confirm the boundary is real. This should be refused:
cat /var/db/agent-vault/vault/vault.json
Three ways in, in the order to try them.
MCP over stdio. The vault writes itself into the agent's own config file. It merges rather than overwrites, keeps a backup, and running it twice changes nothing.
agent-vault mcp install --agent claude-code
--agent takes claude-code, cursor, gemini or codex; --scope project|user picks which config file; --print shows the block without writing it. The agent gains nine tools — vault_status, vault_list_creds, vault_get_placeholder, vault_http, vault_explain_denial, vault_request_session, vault_approval_status, vault_describe_access and vault_preflight — and no token is written to the config, because the bridge finds the session from the state file, which the CLI and the web UI both write.
MCP over HTTP, for an agent somewhere else:
agent-vault mcp url # endpoint and header
agent-vault mcp install --agent cursor --transport http
As a custom connector. Claude Desktop and Claude Code refuse a connector URL that is not https, with no exemption for loopback — so even an endpoint that never leaves this machine needs a certificate. The daemon generates one and keeps the private key behind its own uid; what crosses the socket is the public certificate, which is also its own CA:
agent-vault tls setup
agent-vault listen add mcp-tls --address 127.0.0.1:7443 --surfaces mcp --tls managed
agent-vault mcp install --agent claude-code --transport http --tls
A self-signed certificate is refused until the client is told to trust it, and Node does not read the macOS keychain by default, so set this in the environment the client launches with:
export NODE_EXTRA_CA_CERTS="$HOME/.agent-vault/agent-vault-ca.pem"
That listener serves only the mcp surface, so a connector cannot reach the credential proxy through it. A listener on a network address must carry TLS — plaintext there would put session tokens and substituted credentials on the wire, and is refused rather than warned about.
Environment variables, for anything that can make an HTTP request:
eval "$(agent-vault env)"
curl "$AGENT_VAULT_URL/p/gh/user" -H "Authorization: Bearer $AGENT_VAULT_PLACEHOLDER"
An env file, for tools that read their own variable names:
agent-vault env --out .env --map GH_TOKEN=gh --profile-vars
agent-vault exec --map GH_TOKEN=gh -- gh pr list # the same, for one process, no file
The file holds placeholders and loopback gateway URLs — never a credential and never the session token, which is checked again just before the file is opened. It is written 0600, is never overwritten without --force (or a yes at the prompt), and a symlink at that path is refused rather than followed. --profile-vars adds OPENAI_API_KEY / OPENAI_BASE_URL and the like for every credential in the session whose SDK reads them; --map-file F takes one NAME=SPEC per line. When a session holds two credentials for the same host, use /p/<credential>/: /t/ picks the first grant for the host.
The API key the agent itself runs on is usually the most valuable secret on the machine, and every major SDK reads its base URL from the environment, so no code changes:
agent-vault cred add anthropic-key --kind anthropic
eval "$(agent-vault env)"
export ANTHROPIC_BASE_URL="$AGENT_VAULT_URL/p/anthropic-key"
export ANTHROPIC_API_KEY="$AGENT_VAULT_PLACEHOLDER"
OPENAI_BASE_URL / OPENAI_API_KEY and GOOGLE_GEMINI_BASE_URL / GEMINI_API_KEY work the same way. Those profiles already refuse the admin and organization paths that would let a key mint more keys, even under a grant of /**.
agent-vault docs # or: docs how | start | connect | models | locking | safety | refused | commands
That opens the docs inside the web UI, where they are written against your actual install — your port, your credential names — so the commands are yours to paste. The command workflows below cover policy, daily operation and recovery.
Start with a short session and only the methods and paths the task needs:
agent-vault session create --cred gh --methods GET,POST --paths '/repos/my-org/**' \
--approval on-write --ttl 2 --budget 100 --uses 4 \
--rate 60/min --concurrency 2 --quota 1000/day --grant-ttl 30
agent-vault session list
agent-vault session show # current session; or give a session id
agent-vault session show <sid> --markdown
agent-vault report --since 7d --markdown
agent-vault session revoke <sid>
--ttl on create is hours; --grant-ttl is minutes and expires each grant
independently (AV_GRANT_EXPIRED), without ending other grants. --uses sets
placeholder uses, while --budget limits requests. ph next <cred> [--uses N]
issues a successor placeholder. session show describes effective access,
remaining budgets, limits, spend, expiry and activity; report reviews access
across sessions and credentials. Activity comes from the available audit log:
read any incomplete-history note before treating it as a full review.
These policy flags also work on templates and human session-request approval:
| Flag | Meaning |
|---|---|
--methods GET,POST, --paths '/repos/**', --deny-paths '/admin/**' | Allowed methods and paths, with explicit denials. |
--approval each|on-write|first-use|auto | Human approval for every call, writes, first use, or none. Default: on-write. |
--budget N, --rate 60/min, --concurrency N, --quota 1000/day | Request budget, request rate, simultaneous requests and rolling quota. Rate units: second/minute/hour; quota windows: hour/day. |
--route 'GET:/repos/**' (repeatable) | Bind a method to a path; separate routes are alternatives within a layer. |
--payload-file F, --graphql query | JSON payload rules and permitted GraphQL operation types. |
--redirects N | Follow at most 0–5 hops; default 0. Every hop is checked again. |
--spend-tokens N, --spend-usd X | Model token or dollar spend ceiling. Unknown models under a dollar ceiling are refused (spend_unpriced); token ceilings need no price table. |
--grant-ttl MIN | Grant lifetime in minutes, capped by the session lifetime. |
--label TEXT, --grants-file F | Session label, or a JSON multi-grant specification. |
Payload files contain a JSON rule set, for example:
{"json":{"/channel":{"required":true,"one_of":["reviews"]}},"query_strict":true,"query":{},"max_body_bytes":65536}
Available rule groups are json (JSON pointers), form, query, and graphql;
constraints include required, absent, one_of, numeric min/max, and
max_length. --graphql query,mutation sets the allowed operation types and
checks the whole document, including fragments and batches; persisted queries
without a document are refused. Checks see placeholders before substitution, so
do not constrain credential injection sites. --grants-file instead takes a
JSON array of per-credential grant specs and cannot be combined with --cred;
put each grant's limits in that file.
Policies intersect with profile ceilings and every enclosing layer: a new flag
cannot grant access a profile forbids. Invalid policy fails closed; budgets are
non-negative integers (canonical decimal integer strings are also accepted),
and a zero budget cannot create a usable session. Routes and payload rules from
different layers must all pass. Spend ceilings reserve before forwarding and
reconcile with reported usage; they are vault limits, not a provider billing ledger.
agent-vault template save repo-review --cred gh --methods GET --paths '/repos/my-org/**'
agent-vault template save custom --from-file session-spec.json
agent-vault template list
agent-vault template show repo-review
agent-vault session create --template repo-review --budget 20
agent-vault template rm repo-review
agent-vault approve sr_… --methods GET --paths '/user' --grant-ttl 10
A narrowing the CLI cannot read — an unknown or misspelled flag, a flag with no
value, a number that is not one, --routes or --payload that is not JSON —
exits 2 and sends nothing. It is never approved as the agent asked. The same
holds for session create, session narrow and template save: a flag they
do not know is refused rather than ignored. There is no --remote flag: the
CLI creates sessions over the local socket, so session create --remote is an
unknown flag and exits 2.
Template overrides only narrow. An agent can propose a template with
vault_request_session; approval uses the saved snapshot rather than silently
substituting a later edit. Templates create no session by themselves. Agents cannot add approval or infinite-lifetime overrides to a proposal. A saved
template may contain those owner-selected settings; approving an elevated template
request still requires a fresh human proof.
agent-vault session narrow <sid> --cred gh --methods GET --budget 10 --ttl 15
agent-vault session narrow --approval each --redirects 0 --grant-ttl 5
Narrowing works immediately, needs no daemon presence proof, and cannot widen
access. Omit the id to use the current session and --cred to narrow all its
grants. It accepts the policy limits above; here --ttl is minutes and only
shortens the session (including making an infinite one finite). Redirects can
only be narrowed to 0. --uses is not a narrowable limit, so session narrow --uses exits 2 and sends nothing. A narrow must specify a change.
agent-vault preflight --cred gh --method POST --path /repos/my-org/project/issues \
--query 'state=open' --header 'Content-Type: application/json' --body-file issue.json
Preflight reports allow, hold or deny and the checks behind that prediction.
You do not pass the placeholder: the request is judged as the agent would send
it, with one at the credential's site, as vault_preflight does. A placeholder
you do pass is checked where you put it. (Called directly, POST /v1/preflight
assumes one only when the body says "assume_placeholder": true.)
It sends nothing upstream, mints no placeholder, creates no approval, spends no
budget or rate capacity, and does not extend the session or trigger tripwires.
It records a preflight audit event and is limited to 10 calls/second/session and
1 MiB bodies. It is advisory: a real request checks everything again. Exit codes
are 0 for allow, 10 for hold, 6 for deny and 9 without a session.
vault_preflight provides the same prediction to MCP agents;
vault_describe_access shows their current effective access without returning
secrets, tokens or placeholders, consuming capacity, or writing an audit record.
Requests with ambiguous interpretations are refused as request_ambiguous.
Query parameters split on both & and ;; names are decoded once. Query, form,
multipart and top-level JSON names cannot be empty, contain controls or non-ASCII,
or have leading/trailing whitespace. Parameter names must start with an ASCII
letter, digit, _, -, . or $; JSON keys are exempt from that first-character
rule. Matching also accounts for PHP-style space/dot folding and bracket bases.
Array parameters are lists, and every element must satisfy any single-value rule
on their base name. Method overrides, duplicate headers and unrepresentable
queries are also checked; redirects receive the same checks. A caller-supplied
Host that disagrees with the URL is refused (host_mismatch).
agent-vault cred list
agent-vault cred add gh --kind github --expires 2027-01-31
agent-vault cred expiry gh 2027-02-28 # or: none
agent-vault cred test gh # or: cred test --all
agent-vault cred rotate gh --retire-hours 24 # hidden terminal prompt for new value
agent-vault cred rotate gh --field token --revoke --expires 2027-02-28
Rotation substitutes the new value immediately. By default existing grants keep
working; --revoke revokes them. Both modes deny pending and granted-but-unspent
approvals for that credential. The old value remains scrubbed for the retirement
window (default 24 hours, 0–168 allowed) and is never injected again. Rotation
clears the old expiry unless --expires YYYY-MM-DD|none sets it explicitly.
Expiry and observed health are warnings, not a local expiry enforcement rule;
status and doctor surface them. cred test [<slug>|--all] uses the connector's
read-only probe, can contact the provider, and is throttled; a failing probe exits
12, while throttled or quarantined probes exit 6. A kind with no probe (such as
http) exits 7 when named, and --all lists it as skip without failing.
agent-vault cred scrub gh
agent-vault cred scrub gh --add 'acme_[A-Za-z0-9]{20,40}' --sample 'acme_01234567890123456789'
agent-vault cred scrub gh --rm sp_…
agent-vault cred pin gh --host api.github.com --live
agent-vault cred pin gh --host api.github.com --add 'sha256/…' # repeat --add for overlap
agent-vault cred pin gh --host api.github.com --rm 'sha256/…' # or: --clear
Scrub patterns add response redaction across the vault, including streams and
error details; they never weaken built-in scrubbing. Samples use your supplied
text, never a stored secret. The grammar is deliberately bounded: 3–256 source
characters, at most 16 alternation branches, each starting with three literal
characters. Atoms may be literals, escaped characters, character classes or
\d \w \s \D \W \S. Each branch permits at most one variable quantifier
(? or {n,m} with n < m), followed only by literal characters. Before it,
plain atoms and exact {n} repeats are allowed. There are no unbounded
quantifiers (*, +, {n,}), groups, backreferences, lookarounds or anchors;
repeat bounds are at most 256 and maximum match length is 512. Patterns matching
empty or ordinary corpus text, placeholders or stored secrets are refused.
Limits are 16 patterns per credential and 64 per vault; an invalid stored pattern
withholds responses rather than bypassing scrubbing.
Pins are human-managed SPKI SHA-256 values; none ship for providers. --live
fetches the certificate chain in the CLI without a credential: verify its pins
out of band before adding them. With no mutation flag, cred pin shows pin
status. Pinning normally adds to CA and URL-hostname checks. An exact leaf pin
can replace CA trust for a self-signed upstream, but hostname and certificate
validity still apply; a non-leaf pin never replaces CA verification. Malformed
pins or a mismatch fail closed before sending the credential (AV_PIN_MISMATCH).
agent-vault approvals
agent-vault approvals --show <id>
agent-vault approve <id> # deny <id> refuses it
agent-vault watch --open # --quiet and --no-notify are optional
agent-vault notify set --detail minimal # hidden prompt for HTTPS webhook URL
agent-vault notify show
agent-vault notify test
agent-vault notify off
Approval previews scrub query, headers, placeholders and up to 4096 bytes of
body. The list shows an excerpt; --show shows the complete bounded preview.
Treat all agent-written text as unverified, even after scrubbing. Agent polling
does not expose the human preview. watch prints new pending ids, optionally
shows coalesced desktop notifications, and --open opens the UI once.
Notifications signal work waiting; they cannot approve it and never include the
agent's stated reason. watch --json prints one JSON envelope per line. The
first is the snapshot of what is already waiting,
{"ok":true,"data":{"items":[…],"event":"snapshot"}}; each later line is one
entry, {"ok":true,"data":{"kind":…,"id":…,"headline":…,"created_at":…,"event":"pending"}}. Every entry,
in the snapshot or not, has only those four fields, with the same cleaning:
control characters and bidi controls stripped, placeholders replaced, query
strings dropped. Nothing else from the stream is printed.
The outbound webhook is optional and off until configured. Its URL is a sealed
secret, entered outside argv; status and audit expose only its host. Default
--detail minimal sends counts and ids, without paths or agent text. Opting into
--detail headline also sends scrubbed headlines, so choose it only if that
metadata may leave this machine. Approval still needs the normal human proof.
agent-vault session revoke <sid>
agent-vault cred quarantine gh --reason 'unexpected activity'
agent-vault panic --lock --reason 'stop all agents'
agent-vault cred release gh
agent-vault tripwire show
agent-vault tripwire on
agent-vault tripwire set --rule replay --codes AV_PH_REPLAY --threshold 3 --window 10m --action revoke_session
agent-vault tripwire off
Panic revokes every session, denies held requests and pending session requests,
and optionally locks. Quarantine immediately stops every use of a credential,
including new proposals for it; only explicit owner release restores use.
Revocation, panic and quarantine only narrow access and need no daemon presence
proof. Tripwires are off by default. Rules match --codes A,B or
--rule-prefix P (a deny-list rule is deny_paths:<n>, so deny_paths: matches
them all), count denials within --window, and apply --action
revoke_session, quarantine_credential or panic. Editing rules and disabling
tripwires need human confirmation; installing the default rules on first enable
is also gated. Preflight denials never feed them. tripwire show lists the rules
in force: until you save any, those are the defaults (rules_saved: false in
--json), and tripwire set changes one of them.
agent-vault backup export --out vault-backup.json # --force permits replacement
agent-vault backup inspect vault-backup.json
agent-vault backup restore vault-backup.json --on-conflict skip
agent-vault audit tail --limit 20
agent-vault audit export --since-seq 0 --out audit.jsonl # --force; --follow to keep appending
agent-vault audit witness --out witness.jsonl
agent-vault audit verify --witness witness.jsonl
Export asks for a separate recovery passphrase of at least 12 characters twice,
then requires fresh human proof even within a confirmation window. Keep that
recovery passphrase separately. Bundle output is encrypted, mode 0600, and
refuses existing files without --force or confirmation; symlinks are refused.
Inspect decrypts metadata without returning values. Restore offers conflict
modes skip (default), rename (-restored suffixes), or fail (refuse before
writing), and asks for confirmation on a TTY; --json or no TTY skips that
question, not the presence gate.
Backups carry current credential values, sites, scrub patterns, pins, quarantine, expiry metadata and templates. They exclude retired values, health, sessions, grants, placeholders, approvals, session requests, listeners, factors, vault-key wraps, audit history and settings (including webhook configuration). Restore creates no session: every agent needs a new one. Quarantine survives restore, including any quarantine already on the target credential or renamed copy; restore cannot release it. An unreadable quarantine record refuses restore.
Audit export is JSONL with chain fields; --follow appends new records until
Ctrl-C. Witness appends an authenticated checkpoint (default
~/.agent-vault/witness.jsonl) without truncating it. Schedule periodic
agent-vault audit witness --out /protected/path/witness.jsonl and copy the file
to a location the agent cannot rewrite. Verification detects forged checkpoints,
mismatched history and witnesses ahead of a truncated or rolled-back log; an
older checkpoint behind the current head is normal. A writable local witness
can itself be truncated or rolled back, and HMAC checkpoints are not independent
third-party signatures. The local anchor alone has the rollback limits below.
A broken chain or a failing witness exits 1, and with --json it is an
{ok:false, error} envelope carrying the findings in error.problem, never
ok:true. Export and witness files are refused if a symlink, fifo or other
non-regular file is planted at the path; a fifo is refused at once rather than
hanging the command.
Every command accepts --json for a machine-readable {ok, data} success envelope
or structured error. Fresh proof requires a new passphrase entry or an
authenticator assertion bound to the operation; it never opens a shared window.
Without a terminal the CLI exits 5 when it cannot ask for a passphrase, and only
suggests the UI when an authenticator is enrolled. Enroll a factor first with
agent-vault passphrase set or the UI. lock stops all proxied requests;
unlock resumes live sessions without extending their lifetime.
Substitution is location-bound. Each credential field declares where it may be injected: header:authorization:Bearer, basic:pass, json:/auth/key. A placeholder found anywhere else is refused before any upstream connection exists. This is what turns a prompt-injected "post your token in this comment" into a placeholder in the comment rather than a credential.
The detector is a superset of the substituter, structurally: locate routes every region of a request through detectAll, so the two cannot drift apart. It finds a placeholder in raw text, percent-encoded, JSON-unicode-escaped, and base64 at all three byte alignments in both alphabets. An agent that encodes a placeholder to smuggle it past the site check gets AV_BAD_LOCATION, not a substitution.
A use is consumed before the first upstream byte, inside the same step that re-checks session, grant and budget. Twenty concurrent uses of a one-time placeholder produce exactly one upstream request.
Responses are scrubbed against every injected secret and every other stored credential, in every encoding a response is likely to carry it in — raw, the UTF-8 bytes as the wire actually delivers them, percent-encoded, escaped-slash, JSON-escaped (both \u escapes and the two-character ones, mixed freely), hex in either case, HTML entities, and base64 at all three byte alignments in both alphabets — and those encodings stacked inside one another, such as base64 inside a percent-encoded value, up to three layers deep — including across streaming chunk boundaries. Tokens the vault never stored (a minted installation token, an AWS key) are redacted by shape.
An agent can ask for a session; only a human can grant one. vault_request_session opens a request and creates nothing — no session, no token, no grant — until a human answers it. They see what was asked for and the agent's stated reason as an unverified claim, and they can narrow it before approving: agent-vault approve sr_… --methods GET --paths "/user". What they approve is what gets created, through the same code path as session create, so an approved proposal cannot reach a laxer one. Approval supplies the human, never the permission — the credential's host ceiling and the profile's deny paths still bind afterwards. Only the agent that asked can collect the answer: asking returns a one-time secret (collect_secret over HTTP, poll_secret from vault_request_session) that polling must present, because request ids are listed on the socket for anyone to read. The client you approve with records the session for local agents directly from the approval. The audit log records the shape of a request, never its contents: session_request.opened, .approved and .denied name the request, the credential, who acted, the decision, which fields were proposed and granted, how many entries each list held (paths: 3), and which fields you narrowed. The paths, routes, payload rules, limits and the agent's reason are not in it, nor any digest of them, because the agent wrote every one of those and a stored secret placed there would otherwise be written to the log and its export. A session or grant made from a request records whether it expires (has_expiry), not when; session show and the page read the end from the live session. placeholder.issued and placeholder.refunded do not record the use ceiling. A bulk decision (pending.decided_many) lists only ids the daemon minted; an id it has no request for is counted (unknown: 1), never written. A gateway request's record names the path rule and route that let it through by their place in the grant (path_rule: 0, route_rule: 1), and a deny-list denial names its rule deny_paths:<n>, never a pattern's text: an agent can propose a pattern, and a narrowing can add a deny pattern. A payload refusal names its rule the same way, payload:<kind>:<n>:<constraint>, by the key's place in the rule set (or, under query_strict, the parameter's place in the request). Denials and other failures are recorded as codes, never as sentences: a denial row has reason_code, rule, the method (one of the standard methods, or OTHER) and the scrubbed path, and no detail or ; a refused Touch ID confirmation records with one of , , , , , , or ; a fault records its error class (, ) and a listener that cannot bind records its system code. The sentences quote what the request carried, so they go to the caller and stay out of the log. keeps the sentence for each recent denial in memory, for the session that was denied, for an hour or the last 1000 denials, and answers with the code and rule alone () after that or after the daemon restarts; the code and rule come from that request's own record, however much has been logged since. A caller with no session gets the code and rule of a no-session denial, never its sentence, because every such caller looks the same. A streamed response records and its encoding as one of the encodings the gateway decodes (or ), never the upstream's header text. A record says whether the session has a label (), not what it says. Text an agent wrote is kept only in the fields ending , with stored secrets and placeholders replaced and cut to 200 characters. That replacement matches a secret exactly as stored, so an agent that re-encodes one (base64, splitting it) gets it past this one cleaning: a stated limit of those fields.
Sessions that remove the human need the human, every time. A session whose writes go through without asking (approval: auto or approval: first-use with any method beyond GET, HEAD and OPTIONS — first-use asks once, and that one answer, even for a harmless GET, lets every later write through), or that never expires, is elevated. Creating one, approving an agent's request into one, or a bulk decision containing one needs a proof made for that one operation — the passphrase sent with the request, or an authenticator signature over it — and the shared confirmation window, which admits every caller on the socket while it is open, is never enough. With no human factor enrolled it is refused outright. An agent cannot propose either setting: a request that names one is refused, not trimmed. An approval mode the daemon does not know is refused too, where it used to mean auto.
Approvals are bound to the request hash. A write held for a human executes exactly once however many times the client retries; later resends replay the stored response instead of producing a second side effect.
The audit log is hash-chained, and its end is anchored. Editing or removing a record breaks verification and audit verify names the record. The chain alone says nothing about the end of the log — cut the last fifty lines off and what remains verifies perfectly, which is the only edit worth making, since the records anyone would want gone are always the most recent. So the head sequence and hash are also kept in a small file beside the log, authenticated under the same key, and the daemon compares them at every start: a log that comes back short, or without its anchor, gets that fact written permanently into the chain it continues with. A missing anchor means one of two things, and telling them apart is the point: the vault itself records that it keeps an anchor, in a different file, so removing audit.jsonl.head does not remove the knowledge that there was one. On a vault known to keep one, its absence is tampering. On a vault written before anchors existed there is no evidence either way, and that is reported as an unproven extent rather than a broken chain — a check that always fails on every upgrade is a check nobody reads. doctor checks it, so nothing depends on a human thinking to. No credential value, full placeholder, body or query string ever enters it. The path does, scrubbed: without it a grant of /repos/frozencrow/** recorded the same glob every time, so the log could say a credential had been used forty times and not which forty things it had been used on. The query string stays out, because that is where credentials actually appear in a URL. Its key is derived from the vault master key and is dropped when the vault locks, so a locked vault genuinely cannot read its own log — audit tail answers AV_LOCKED rather than pretending otherwise.
The control API is a Unix socket and never binds a network address. Everything that creates or widens capability lives there. A listen entry naming the control or database surface on a network address is refused at creation, loudly and audibly, rather than silently narrowed.
On a single-user dev install it is mode 0600. On a system install the daemon runs as _agentvault and the socket is 0660 owned by a group the human's account is in, which is the whole point: your account can reach the socket without being able to read the vault. That also means anything running as you can reach it, which is why every operation there that widens capability sits behind a human factor — and why three separate gaps in that gate were worth finding.
agent-vault ui # prints a single-use link and opens it
Its views cover Overview, Credentials, Sessions, Approvals and Audit, plus the Docs page that agent-vault docs opens directly.
The link uses localhost, not 127.0.0.1, and arriving by IP redirects. That is
not cosmetic: WebAuthn requires the relying party to be a registrable domain, and
an IP literal is not one, so the browser refuses the enrollment outright with an
invalid-domain error. localhost is both a domain and a secure context over
plain http, which is what makes the platform authenticator available without a
certificate.
A browser page is the one surface an agent can drive most easily, through automation or screenshots. So the UI is built on the assumption that it will be reached, and three things make reaching it worth very little.
Nothing secret is ever on the page. There is no endpoint that returns a credential value, so a screenshot, a DOM read or a full dump of the API yields slugs, fingerprints, hosts and grant shapes. A test asserts that no route anywhere returns a stored value.
The link works once. The first browser to open it claims the session and the token is burned. If something else claimed it first, your own browser is refused and tells you so, which turns a silent theft into a visible failure.
Every change needs a fingerprint. This is the part worth explaining, because it is the only one that actually holds.
You cannot tell a human from an automated browser by asking the browser.
navigator.webdriver is a boolean the automation can set. event.isTrusted is
true for input injected over the DevTools protocol. Mouse movement can be
synthesised, and a screenshot reads the screen better than the DOM does. Every
signal is forgeable by something with control of the browser.
So the gate is not a test the visitor passes. It is a signature the visitor cannot produce. Adding a credential, creating or revoking a session, approving a request and locking the vault each require a WebAuthn assertion from a platform authenticator enrolled on first use. The private key is in the Secure Enclave, it is bound to this origin, and it only signs after the platform verifies a human by fingerprint or device password. An automated browser can click Approve and will sit at a dialog that never resolves.
The challenge is derived from the operation itself, SHA-256(nonce ‖ operation),
so a signature obtained for one action cannot execute another. The daemon
recomputes the operation from the request it actually received and compares, so
the signed thing and the executed thing cannot drift apart.
The daemon mints that challenge and verifies that signature, not the UI process. This matters more than it first looks: the UI server runs as your account, which is the account an agent already has, so anything it merely asserts about a human being present is worth nothing. For the three operations that widen what an agent can do — adding a credential, removing one, creating a session — the control socket checks the assertion against the credential it enrolled and against the operation in the request it received. A hostile page that shows one action and submits another is caught on the far side of the socket, where it cannot have edited the judge.
A virtual authenticator, which the DevTools protocol can install, does not help: it can only mint a new credential, and the daemon accepts assertions only from the credential id it enrolled, checked against that stored public key. Copying the credential id does not help either, because the id is public and the key is not. Both are tested.
What remains. The platform's dialog says who is asking, not what for. An agent that triggers an action at the moment you were expecting a different one can borrow your touch. The page shows the operation and the audit log records it, which is detection rather than prevention for that last gap.
Enrolling is the one change a fingerprint cannot authorise, because the fingerprint being enrolled is the thing in question. Whoever enrols can sign every later change, so on a vault with a passphrase the daemon asks for the passphrase once, at enrollment, and never again for that device. Without that rule the gate is circular: an agent that reached the socket could install its own key and then have that key authorise everything else. On a vault with no passphrase, enrollment is trust-on-first-use — whoever is at the browser when you first enrol is who gets enrolled — and either way it is audited loudly.
src/core/ placeholder grammar, detector, substitution, scrubber, policy, canonical hashing
src/store/ encrypted vault, key hierarchy, hash-chained audit log
src/daemon/ request pipeline, HTTP gateway, control API, MCP (stdio + Streamable HTTP)
src/connectors/ built-in profiles (github, slack, anthropic, openai, google-genai, http, postgres)
src/ui/ the web UI server, its page, and WebAuthn verification
src/cli/ the command-line app
bin/ the CLI, the service entry point, and the privileged installer
test/core/ unit and property tests for the pure core
test/security/ the attacks an agent would actually try, and what must happen
demo/ an end-to-end run against a real upstream
src/core has no I/O and no platform code. It is the part worth reading first and the part worth fuzzing.
What this defends against, and what it does not. Stated plainly because a credential tool that is vague here is worse than none: it manufactures confidence.
The attacker this is built for is an agent running as you. It has your shell, your files, your environment, and it can reach the control socket. It has read every line of this repository. Against that attacker:
agent-vault doctor proves it by trying.test/security/control-gate.test.js walks the whole surface
and states which side of that line each route is on.audit verify names the record if anything
was edited or cut from the end.What it does not defend against:
control.widening_ungated. Set a passphrase.sudo. The installer is JavaScript in a directory you own, so
the first install runs code an agent could have edited beforehand. After that
the root-owned copy is used. It now refuses a source tree containing symlinks,
which previously would have made that one-time window permanent.Audited, but never by anyone independent of the project. Four separate
adversarial reviews — the crypto and storage layer, the request pipeline, the
control surface and MCP, and the privileged installer — went through this code
without being told what had already been fixed, and were asked for
reproductions rather than opinions. They returned three findings rated
critical, twelve rated high, and a long tail below that. Every one that
survived my own re-verification is fixed, and each carries a test that fails
without the fix; several are in the mutation inventory. The reproductions are
worth reading: a gzipped event stream defeated the response scrubber
completely, the installer resolved chown through the caller's PATH while
running as root, and an agent could enrol its own authenticator on a fresh
vault and lock the owner out permanently.
Two of those reviews also found false positives in fixes I had made hours earlier — a check that refused ordinary JSON bodies, and a missing anchor reported as tampering on every upgrade — which is the clearest argument available that review from outside one's own head finds things review from inside it does not.
It has not had a paid audit by a security firm. Very little open-source security software has one before its first release, and treating that as a release gate would be a standard this project applies to nothing else — so it is recorded here as what it is: an assurance this artifact does not carry, for you to weigh, rather than a defect. What it does carry is above.
This build is the mechanism, not the hardened deployment. Each gap below is real and none of them is hidden by the code.
The privileged install is new, and has only ever run on macOS. sudo agent-vault-setup.js install creates the _agentvault system user, a root-owned tree, a 0700 vault owned by that user, and a launchd or systemd unit. After it runs, your own account cannot read the vault — the boundary the whole design rests on. agent-vault doctor checks it by trying, and on a real install it holds: the vault is owned by the service uid, mode 0700, and every file in it, including the TLS private key, refuses a read from the human's account. It has never been run on Linux. Run it with --dry-run first; it prints every change it would make.
The installer itself is trust-on-first-use. It is JavaScript in a directory you own, so the first sudo runs code an agent could have edited beforehand. The spec's answer is a signed native binary and that is not built.
agent-vault setup runs unprivileged, prints the dry-run plan and the absolute path of the one program root will execute, then asks before spawning sudo. After a successful install it uses the root-owned copy of the installer instead, which an agent cannot edit, so only the very first install carries this risk. Running the whole CLI under sudo is a different and worse thing, and is refused: it would put every code path in the package, and whichever interpreter is first on root's PATH, inside a root process.
Dev mode has no boundary at all. agent-vault setup --dev keeps the vault in your home directory, where anything running as you can read it. It exists for a quick try and says so loudly.
The daemon is JavaScript, not Rust. The spec chose Rust for three reasons this build cannot satisfy: zeroizable memory (V8 strings cannot be wiped), peer-credential syscalls (Node has no public API for SO_PEERCRED), and running from a root-owned path with no interpreter the human owns. Everything language-independent, the grammar, the pipeline, the policy semantics, the wire behaviour, is implemented and tested here and ports directly.
Presence on the CLI is a passphrase; in the UI it is hardware. Either satisfies the gate. Run agent-vault passphrase set and every capability-widening action on the control socket — adding or removing a credential, creating a session, issuing a placeholder, approving or denying a held request, binding a listener — requires it, opening a five-minute window so you are not asked on every call. An enrolled authenticator counts too: the daemon mints the challenge and verifies the assertion itself, so a signature collected by the web UI satisfies the socket without the UI being trusted to vouch for it.
The window is daemon-wide: while it is open it admits any caller on the socket, not only the command you typed the passphrase for. So every operation it admits is recorded by name (presence.window_used), locking closes it, and it does not cover the changes that decide who can prove presence at all — enrolling or replacing the authenticator, adding or removing Touch ID unlock, and trusting another web UI origin (POST /v1/ui/origins, agent-vault ui --allow-origin). Those need the passphrase sent with the request itself, or a signature from the authenticator already enrolled.
The daemon decides which web UI origins it accepts signatures from: http://localhost:7412 by default, where agent-vault ui listens unless given --port. The relying party id localhost is shared by every port on the machine, so the origin is what tells the real page from a look-alike an agent serves elsewhere; it used to be whatever origin the caller minting the challenge named. One exposure remains: the Touch ID unlock secret comes from the authenticator's PRF, which depends on the relying party and not the origin, so a tap on a look-alike page served on another localhost port hands that page the unlock secret. The daemon never sees that tap. Only tap for this vault on the page agent-vault ui opened.
Attempts are charged for: five free, then each further failure doubles the wait, up to thirty seconds, and the throttle is audited. That bounds online guessing, and it bounds something else — the check is scryptSync, which runs on the event loop, so every attempt is ~190ms during which the daemon answers nothing at all. A loop of wrong passphrases on the control socket used to be a stall of the whole vault, gateway included, at no cost to the caller. The Rust port moves the derivation off the loop; the throttle is what makes that a performance change rather than a security one.
The throttle counts attempts per daemon, not per caller, because on a Unix socket there is nobody to count separately. So an agent can deliberately fail five times and make you wait. Two things bound that: an attempt refused during a backoff does not extend it, so an agent cannot hold the window open by retrying, and the backoff caps at thirty seconds. Your worst case is half a minute and a race for the next attempt, not a lockout, and it is in the audit log.
The passphrase's honest limit (N3) is that a swapped CLI or an agent-owned terminal could phish it as you type. Hardware presence over the socket would remove that, and the pieces now exist — the daemon already verifies assertions — but the CLI has no way to collect one, so it tells you to confirm in the UI instead.
With neither factor enrolled the socket is open and every widening call is audited as control.widening_ungated. That is the documented state of a vault with no human secret. Set a passphrase.
Once a vault has a human factor it keeps one. agent-vault passphrase remove is refused with last_factor while the passphrase is the only factor (enrol a presence authenticator in the web UI first) and while Touch ID unlock depends on it for recovery (turn that off first). A replacement presence authenticator is stored only if the daemon could verify a signature from it, an ES256 key with a real credential id; anything else is refused with presence_key, and the key already enrolled stays.
There is one window the gate cannot close, and it is the first one. The daemon creates the vault unattended, so a fresh install has no factor, and a gate has nothing to check against until a factor exists. Setting the first passphrase is therefore ungated by construction — whoever reaches the control socket first sets it, and from then on holds the factor the gate enforces. If an agent is already running as you when you install, it can set a passphrase before you do and the vault is its vault, not yours. This is not a bug that a check inside the daemon can fix: the state precedes the thing that would do the checking. What the code does instead is refuse to let you sit in it quietly — agent-vault status says so in plain words and agent-vault doctor fails the human factor enrolled check until you fix it. Set the passphrase as the next thing you do after installing, before you point an agent at the socket.
Touch ID can unlock the vault. In the web UI, once a passphrase is set, Enable Touch ID unlock adds a fingerprint as an unlock factor. It uses the WebAuthn PRF extension, which returns a stable secret from the Secure Enclave that wraps the vault key — a cryptographic factor, not just a presence tap, so a locked vault genuinely cannot be opened without either the fingerprint or the passphrase. The passphrase stays as the mandatory recovery factor (lose the authenticator and it still opens the vault), which is why one is required before Touch ID can be added: on a vault with no passphrase the daemon refuses with factor_order and the page says to run agent-vault passphrase set first. PRF support varies by browser; where it is unavailable the UI says so and the passphrase still works.
Scrubbing has a fixed reach. The response scrubber recognises a secret re-encoded by the schemes it knows — JSON string escapes (\uXXXX and the two-character ones), percent-encoding, base64 and base64url, and hex in either case — and peels them in any order up to three nested layers deep, redacting the secret wherever one of those stacks hides it. The peeling is bounded: nested decoding is held to a budget of bytes and of intermediate views, sized so an ordinary response stays well within it, and a response whose nesting cannot be finished inside that budget is refused as AV_UNSCANNABLE rather than forwarded part-scanned — a buffered body is discarded, an event stream is cut. It does not claim to reverse what it does not recognise: a secret that was encrypted, run through a custom or unlisted encoding, or nested more than three layers deep is not guaranteed to be caught.
Crypto substitutions. ChaCha20-Poly1305 with a 96-bit nonce instead of XChaCha20's 192, and scrypt instead of Argon2id, because Node has neither without a native dependency. Per-field keys keep each key's record count far below the birthday bound, so the nonce size is not a practical problem at this scale, but it is not what the spec specifies.
The audit log's anchor has two limits. The head anchor and the stamp in vault.json that says "this vault keeps an anchor" catch a truncated or deleted log, but both live in the vault directory. Someone who can write there can save a copy of the anchor and later put it back with the log cut to match (a rollback), or delete the stamp along with the anchor so the vault looks older than anchoring; both verify clean. Binding the stamp cryptographically would mean re-wrapping the vault key, and a rollback needs a high-water mark somewhere the attacker cannot write, which the local anchor alone does not provide. Keep an external witness as described above to detect rollback past its checkpoint. Separately, the log and its anchor are not fsynced on every record — that would cost every proxied request several flushes — so a power cut (not a crash; the anchor is replaced by rename) can leave the anchor ahead of the last line, which reads as truncation.
Storage is a JSON document, not SQLite. Same entity model, same invariants, atomic rewrites. Every write rewrites the whole document, so the placeholder ledger is bounded rather than allowed to grow, and the two halves are written differently: key material and credentials are fsynced, the placeholder ledger is not. The rename is atomic either way, so the file is never torn — the most a power cut costs there is one placeholder issuance, which the agent simply asks for again. It will not hold up under concurrent daemons or a large vault.
Not implemented at all: the Postgres, MySQL, MongoDB, SSH and SMTP connectors, mutual TLS and client certificates for network listeners, CONNECT mode, GitHub App token minting, and request-body streaming — a request body over 16 MiB is refused rather than streamed, because a body that cannot be scanned for placeholders cannot be forwarded. The stream_bodies policy field exists and intersects correctly; nothing reads it yet.
The Postgres profile exists as a design; the L4 proxy that would carry it does not. Storing a credential against it is now refused at creation. It used to succeed: you got a fingerprint, a placeholder and a suggested curl, and the HTTP path stood ready to send a database password as URL userinfo to a Postgres port — while you believed the credential was being protected. A profile without a proxy is a plan, not a feature, and it no longer accepts secrets.
Listeners bind and can serve TLS, and record client_auth: bearer, which is what they actually enforce. --allow-cidr is enforced on the raw connection (it used to be stored and ignored), a list that cannot be parsed is refused when the listener is added, and listen rm stops a bound listener at once. listen add still takes effect at the next daemon start. They previously recorded mtls-required, a field that claimed a protection the code did not have. A network listener is therefore protected by TLS and a session token, not by a client certificate. Certificate generation shells out to openssl rather than minting the certificate in process.
The code is the spec's design, built. Where building it taught something, the code is right and the spec entry is noted here:
AV-Replayed: true.AV-Redacted counts occurrences, not patterns. More useful to an agent trying to understand what came back.credentialHostCeiling / confineHosts), the secret injected into a request is scrubbed at any length, only a coarse size bucket is exposed, and widening operations are gated behind the passphrase window above. The exfiltration reproductions live in test/security/exfil.test.js.cpSync keeps symbolic links as links. So an agent could plant src/store/vault.js -> /tmp/agent-owned.js, and after the install the daemon — running as _agentvault, holding the vault key — would load its own code through a path the agent still owned and could rewrite at any time. That turns the documented one-time trust-on-first-use window into ongoing control of the daemon, with no race to win: plant once, edit forever. It defeats the uid boundary the whole design rests on. The installer now refuses a source tree containing any symlink, naming the path and where it points, and dereferences the copy as well. Found by auditing the only code in the project that runs as root.Email [email protected] with agent-vault security in the subject, rather than
opening a public issue. SECURITY.md says what is in scope and
what is documented-rather-than-accidental, and why.
Apache-2.0.
hintpresence.deniedclient_data_typeoriginchallengesignaturecredentialmalformedsizeotherdaemon.uncaughtdaemon.handler_errorvault_explain_denialdetail: nullcontent_type: text/event-streamothersession.createdhas_label_untrustedclient_auth: mtls-required was a field that lied. Listeners recorded it and nothing enforced it; mutual TLS is not implemented. They now record client_auth: bearer, which is what they actually check. A network listener is protected by TLS and a session token, and the README says so.POST /v1/approvals was not gated, so an agent whose call was stopped for human approval reached the control socket, granted itself, resent, and collected the credential. approval: each — what a careful operator sets — was therefore worth nothing against anything running as that account. Granting now needs the human, and so does denying: a deny of an approval that had already run returned its cached response, av-placeholder-next included, so it did not only narrow. Found by auditing every mutating route at once rather than one at a time, which also turned up three more: binding a listener, driving the WebAuthn signature counter up to disable the presence gate permanently, and forcing a certificate regeneration that breaks every client's trust anchor.none wrap, so on a vault that had none, anything reaching the socket could choose a phrase and keep the vault. Setting the first one now needs whatever factor is enrolled; changing an existing one still needs the current phrase, which is proof enough on its own.detectAll and intersect found nothing, which is worth recording too. 60,000 cases across ten encodings — percent, double-percent, JSON unicode escapes, and base64 at every alignment including embedded in a larger blob — with no misses. 24,000 policy intersections produced no case where combining layers allowed something a single layer refused, and no order-dependence. 60,000 mutated paths (dot segments, doubled slashes, query strings, single and double percent-escapes) produced no deny-list escape. Those properties are now in test/core/detect-fuzz.test.js.\u-escaped form is six times the secret's length, so it was reachable on any streamed response: exactly where an LLM token stream lives. The cut now moves forward past a complete encoding, while shape patterns like ghp_... stay held because they may still be growing. test/core/scrub-fuzz.test.js keeps it honest.POST /v1/sessions is behind the presence gate and refuses. But POST /v1/placeholders on the control socket was not gated, and with no sid it picked whichever session happened to be active. So an agent running as the human borrowed that session's grant, minted a placeholder, and used it as the sole carrier on loopback (which is allowed there, by design). The real credential reached the upstream with no passphrase at any point. Issuing a placeholder mints a fresh authorization to spend a credential, so it is now a widening operation behind the same gate, and it will not silently borrow: with more than one active session it refuses and names them. The reproduction is in test/security/exfil.test.js.audit tail dereferenced a null handle and reported an internal error; status reported audit records 0, which reads as "your audit trail was wiped" at exactly the wrong moment. Both now answer honestly. In the same pass, ten mutating methods that write to the audit log gained an unlock guard: listen rm on a locked vault deleted the entry, persisted it, then failed to record it, and a change that cannot be audited must not happen.listen add validated an entry, stored it and reported success, and nothing ever listened — which also meant req.listener was always undefined, so every remote rule in the pipeline was unreachable code. They bind now, with optional TLS, and serve only the surfaces the entry declares. Addresses are validated when the listener is added rather than at bind time — --address 127.0.0.1 with no port was accepted, reported success, and then silently never appeared. test/security/listener.test.js covers it.Authorization: Bearer avs1... is the documented way to authenticate, and that header was forwarded verbatim: each API the vault proxied for received a live vault capability and logged it. Found by making a real call to the Gemini API, which refuses requests carrying two credentials and said so; a more permissive upstream would have accepted it silently. Any header still carrying a session token after substitution is now consumed, like AV-Session. test/security/carrier.test.js covers it.test/security/widening.test.js covers both factors and the operation-swap attempt.lock() wiped the master key but left the AuditLog handle alive, so a key derived from it stayed in the daemon's memory for the life of the process — locking was not quite the zeroization it claimed. The handle is now dropped with the key, which also turned a null dereference on audit tail (reported as an internal error) into a plain AV_LOCKED. test/security/locked.test.js covers what a locked daemon answers.