Security review
Every claim, every test, every limit.
The landing page tags 18 product claims. Each is tied to named tests of the proof of concept or to a recorded run; 2 are only partly proven. Proof of concept, not yet independently audited.
- 2,984 Rust tests, 0 failed
- 100% of 66,486 lines covered
- 65 fuzz targets
- clean dependency audit (cargo-deny)
- 11/11 two-user permission tests, as root on Linux, daemon tree c11917d3
- 46 two-user walkthrough checks (23 Oct, 09:30 CEST): 22 ok · 21 refused by the OS-user boundary · 2 policy refusals (lines 661, 662) · 1 not exercised, no sudo in the image (line 693) · 0 failed. STATUS.md's summary line says 24 refused: it counts the 2 policy refusals (lines 661–662) and the un-run sudo check (line 693) as refusals (DOCS-C1-3, open).
gate 26 Oct, 21:05 CET · tree 5b643f83 · poc/scripts/gate-json.mjs. Fuzz smoke: 65 / 65 targets rc=0, 63–67 s each, 0 failed.
The relay's copy of the 02:14 request, every byte
From the proof of concept's run behind the landing page's story (poc-output/cloud-fixture.json): 9,778 bytes, one pixel each, 136 to a row. 300 bytes it reads, 7,962 bytes of public keys and signatures, 1,516 bytes sealed to your account.
- 146 B readable: format, position, kind, time, request and key ids
- 1,216 B public: the one-time key your answer is sealed to
- 147 B readable: expiry and framing (14 B), the receipt's size, hash and time (133 B)
- 3,373 B public: the relay's signature on the receipt (receipt: 133 + 3,373 = 3,506 B)
- 1,516 B sealed: the reason, the URL, the agent, the scope
- 3,373 B public: devbox-03's signature
300 + 7,962 + 1,516 = 9,778 B. Of the 300 B: 146 + 147 in bands, 7 B of framing between them; 9,778 B in all.
The files
Copied byte for byte from the repository by scripts/sync-captures.ts (SHA-256 in poc-output/captures.json), served as plain text. The docs site is not public yet.
- Claims registry: every claim on the page and every test that proves it
landing/CLAIMS.md - The binding spec (ARCHITECTURE.md): adversaries, properties and the known limits, the N-list (§1)
poc/ARCHITECTURE.md - The final gate record (STATUS.md)
poc/STATUS.md - gate.json, as the final gate wrote it
landing/poc-output/gate.json - Decision records: index of all 158
decisions/ - ADR 0034: Harvest-now-decrypt-later: values rest on X-Wing; the log keeps public-key wraps of `a_i`
decisions/0034-hndl-residual-of-approver-share-ciphertexts.md - ADR 0035: Fatigue control derived from the log and enforced on the phone; grants bound to the requesting session
decisions/0035-log-derived-fatigue-control-and-session-bound-grants.md - ADR 0062: End-to-end proof: the `tests-e2e` crate, what is real and what is simulated, demos
decisions/0062-end-to-end-proof-harness.md - ADR 0077: Every request result carries the fingerprint; a late drop is collected by polling
decisions/0077-request-fingerprint-on-every-request-result-and-drop-poll.md - ADR 0085: Content-free Web Push wakes approvers; `inspect --search` closes the bstr proof hole
decisions/0085-content-free-web-push-for-approvers.md - The two-user service-mode walkthrough on Linux, as recorded
poc/demo/service-mode/transcript.txt - Claude Code session report, run 2026-10-23T06-40-40Z-71466b
poc/demo/llm-session/2026-10-23T06-40-40Z-71466b/report.json - Claude Code session report, run 2026-10-23T06-42-08Z-a76c70
poc/demo/llm-session/2026-10-23T06-42-08Z-a76c70/report.json
18 claims and every test behind each
-
C01The agent uses a key; the value never enters its process (service mode)3 tests
e2e uc1_blind_use::e2e_uc1_blind_usee2e drop::e2e_dropmcp tests/stdio.rs::stdio_roundtrip
-
C02Answers are scrubbed of the value and its encodings5 tests
e2e uc1_blind_use::e2e_uc1_blind_usee2e uc1_blind_use::canary_scanner_fires_on_every_encodingcore redact::every_embedding_is_redactedcore redact::embedded_value_always_replaceddaemon proxy::scrub_strips_cookies_redacts_and_withholds_encoded
-
C03A key goes only to the hosts, methods and path prefixes its policy allows3 tests
e2e uc1_blind_use::e2e_uc1_blind_usee2e uc4_bridges::e2e_uc4_bridge_azurecore policy::egress_component_checks
-
C04The relay can't read the requests, the values or the key share in your answer (the approver app's code is trusted when it loads, N9)4 tests
e2e uc5_proof::e2e_uc5_proofcore spec_properties::stolen_relaycore spec_properties::revoked_approver_with_relay_cannot_decryptcore tessera::host_share_alone_fails_and_approver_share_alone_fails
-
C05What the relay can and cannot read (metadata only)3 tests
relay plaintext::db_holds_no_plaintexte2e uc5_proof::e2e_uc5_proofrelay push::a_request_pings_every_active_approver_with_nothing_in_it
-
C06Adding an approver passes the account secret sealed; an honest relay deletes it after 10 minutes, a hostile one can keep it (N18)4 tests
core spec_properties::as_transfer_sealede2e enroll_second_approver::e2e_enroll_host_and_second_approverrelay tests/mailbox.rs::mailbox_expires_after_ten_minutes_and_is_reusablerelay tests/mailbox.rs::host_enrollment_ceremony_through_the_relay
-
C07An approval needs a user-verified passkey from an enrolled approver3 tests
e2e uc2_remote_approval::e2e_uc2_remote_approvalcore tests/log_state.rs::grant_rules [grant_by_host_rejected, grant_without_uv_rejected]e2e uc5_proof::e2e_uc5_fork_found_by_prefix_check
-
C08A grant ends at its time and its use limit3 tests
e2e uc3_night_shift::e2e_uc3_night_shift_9h_default_policye2e uc2_remote_approval::e2e_uc2_remote_approvale2e uc3_night_shift::e2e_uc3_night_shift_9h_default_policy
-
C09An approval can narrow a request, never widen it5 tests
core grant::narrow_tier2_once_and_windowcore grant::envelope_never_exceeds_narrowe2e uc3_night_shift::e2e_uc3_night_shift_9h_default_policye2e uc2_remote_approval::e2e_uc2_remote_approvaldaemon tests_flow::widened_grant_ignored_and_alarmed_not_frozen
-
C10An unanswered request expires after 10 minutes3 tests
core spec_properties::request_expiry_bounddaemon tests_spec::eph_key_wiped_after_expirye2e uc3_night_shift::e2e_uc3_night_shift_9h_default_policy
-
C11The log is signed and chained; a fork is caught by the host check4 tests
e2e uc5_proof::e2e_uc5_proofe2e uc5_proof::e2e_uc5_fork_found_by_prefix_checke2e uc5_proof::e2e_uc5_relay_vouching_for_tampered_entry_freezescore prefix_check::fork_with_host_growth_detected_by_prefix_compare
-
C12The agent's reason is shown cleaned of hidden characters, and logged2 tests
e2e uc2_remote_approval::e2e_uc2_remote_approvalcore text::removes_hidden_and_collapses_whitespace
-
C13Revoking a lost phone stops the grants it signed7 tests · partial
e2e uc5_proof::e2e_panic_this_wasnt_mecore log_state::device_revoke_stops_hostdaemon tests_flow::revocation_epoch_and_device_revoke_stop_usecore spec_properties::revoked_approver_plus_host_seed_reads_post_revocation_secretcore tests/log_state.rs::grant_by_revoked_approver_rejected_and_invalidateddaemon tests_flow::approver_revoke_stops_grants_it_signede2e enroll_second_approver::e2e_enroll_host_and_second_approver
-
C141Password and Azure Key Vault stay the store; fetched only after a grant4 tests
e2e uc4_bridges::e2e_uc4_bridge_azuree2e uc4_bridges::e2e_uc4_bridge_1passworddaemon tests_spec::bridge_credential_zeroized_after_fetchdaemon bridge::op_env_is_exactly_allowed_set
-
C15import .env swaps values for names and keeps owner and mode2 tests
e2e uc4_bridges::e2e_uc4_import_env_and_doctorcli host/tests.rs::import_env_keeps_owner_and_mode
-
C16The crypto suite passes external test vectors5 tests
core kem::xwing_draft11_vectorscore hpke::hpke_pq_vectors_for_c1core sig::composite_draft_vectorscore aead::wycheproof_chacha20poly1305core kdf::wycheproof_hkdf_sha256
-
C17Works with Claude Code over MCP: 8 recorded sessions used a key (shell off; recorded, not gated)8 tests · partial: recorded, not gated
demo llm-session/2026-10-23T06-40-40Z-71466b/report.jsondemo llm-session/2026-10-23T06-42-08Z-a76c70/report.jsonmcp tests/stdio.rs::stdio_roundtripe2e uc2_remote_approval::e2e_uc2_remote_approvale2e uc1_blind_use::e2e_uc1_blind_userelay push::a_request_pings_every_active_approver_with_nothing_in_itrelay push::subscription_rules_and_relay_inforelay push::revoked_devices_and_gone_subscriptions_are_removed
-
C18The product output on this page is real PoC output2 tests
relay tests/bin.rs::serves_until_sigint_then_inspect_shows_the_rowse2e uc5_proof::e2e_uc5_proof
Suite C1: HPKE with X-Wing (ML-KEM-768 + X25519) seals envelopes; Ed25519 and ML-DSA-65 sign (both must verify); stored values use ChaCha20-Poly1305 with a 32-byte key commitment; HKDF-SHA-256 derives keys. Every envelope names its suite, so it can change later. Still classical: the passkey behind each approval (ES256) and TLS to our relay. The last hop to each provider is only as post-quantum as the TLS group that provider negotiates (N30); the daemon offers X25519MLKEM768 first.
What it doesn't do: 12 limits
| Limit | Who it affects | What to do | Spec |
|---|---|---|---|
| The clock is the host's. 07:00 is kept by an honest host's daemon, not by cryptography; a relay and time-server attacker together could stretch it by their skew. | anyone relying on the end time | keep host time trusted; short windows | N2 |
| Dev mode guards against accidents only. Where the daemon runs as the agent's user, or the agent can become root, a hijacked agent can use and copy the host's keys. | laptops; agents with sudo, docker or root | service mode on the agent box | N19 |
| Type secrets on your phone. A value typed into the CLI on the agent's machine passes through a process the agent's user can trace, in either mode. | anyone adding a key on the agent box | add values on the phone | N19 |
| Service mode is set up by hand. The systemd unit the CLI prints lacks the agent's user id and doesn't start the daemon yet. | Linux service-mode installs | start it as the walkthrough does | finding |
| Trust the app's code. Whoever serves the approver app could change it. | everyone using a hosted approver | self-host it, or run it apart from the relay | N9 |
| A compromised host can stay quiet. It gets what it was granted while it holds a grant, and can under-report its own uses. | a host an attacker owns | short windows, per-use tier 2 | N1, P14 |
| A host's keys are software keys. A disk backup or VM snapshot copies them: with a later approval for that host, or a revoked approver, it opens that host's values. | VMs and backed-up hosts | hardware-bound keys are planned | N20, N11 |
| Kept ciphertext rests on X-Wing. The relay keeps X-Wing-sealed key halves in its log, and could keep a copy of the account secret; if X-Wing (ML-KEM-768 and X25519 both) is ever broken, the kept log opens every policy and reason and both key halves of each secret that was granted or proposed by a host, which means the values themselves. | everyone, in the long run | moving shares out of the log is planned | N18 |
| An allowed API can mint a new key. Inside an allowed egress rule the agent can call an endpoint that creates a credential (GitHub deploy keys, POST /user/keys, collaborator invites) and keep access after 07:00 and after revocation; the app shows a red banner for known minting paths. | every lease with write rules | keep write rules narrow (the story's token may only POST …/pulls); review keys created during grant windows | N6 |
| Redaction is a safety net, not a boundary. An API or program that transforms the value can return it in a form the scrub does not detect; in service mode the value never enters the agent's process, so the scrub is a second line. | dev mode; tools that run with the value | service mode; the proxy, not the scrub | N5 |
| Not yet run on a real phone. The approver is tested in desktop Chromium at phone size; passkeys and push on iOS Safari and Android Chrome are untested. | every approver | a real-phone run is next | — |
| HTTP APIs only, for now. Keys sent in one request header; SSH keys, certificates and database passwords are planned. | non-HTTP secrets | planned | N22 |
N-ids are the spec's list of known limits (ARCHITECTURE.md §1).
How the nearest products compare
From docs/src/data/competitor-matrix.json (research/15), vendor docs read 2026-10-23; cells marked “carried” were scored on 2026-10-22 and not re-read. “Not documented” means we found nothing in their docs, not that they lack it.
| Product | Key out of the model | Approve remotely | Ends on a clock | Vendor blind to values | Shipping | Audited |
|---|---|---|---|---|---|---|
| Custos (PoC) | yes · Service-mode proxy; UC1 e2e | partly · approver web app; opt-in content-free push (ADR 0085); tested in desktop Chromium only, no real phone yet (note corrected here; matrix pending: ADR 0085) | yes · Night-shift preset to 07:00, at most 200 uses; UC3 e2e | partly · Against the relay; not against whoever serves the approver code (N9) | no · Proof of concept; no users | no · No external audit |
| 1Password | yes · Environments MCP never returns valuessource | no · Prompt in the local desktop app; stdio onlysource | partly · MCP approval lasts until 1Password locks; OpenShell TTL custodysource | partly · Vault yes; Credential Broker adds a vendor-enclave key (carried) | yes (carried) | yes · SOC 2 Type 2; pentest reports in the Trust Centersource |
| Bitwarden | partly · aac run injects into a child's env; aac connect prints the value as JSONsource | partly · Remote host connects, but approval is a CLI on the vault host (carried) | partly · Lease duration (Privileged Controls, preview)source | yes · Relay routes opaque ciphertext (carried) | partly · Agent Access alpha (carried) | yes · Annual code audits and pentests, reports public (but 12 attacks in ePrint 2026/058)source |
| Infisical | yes · Agent Proxy placeholders (HTTP only) (carried) | partly · Enterprise approval workflows, no phone approver (carried) | partly · Session TTL, e.g. 30m, 8h, 7dsource | no · Server decrypts to answer requests (external KMS/HSM optional)source | yes · Agent Proxy GA 2026-07-30 (carried) | partly · Cure53 gray-box pentest and SOC 2 Type 2; reports on requestsourcesource |
| OneCLI | yes · Gateway injects at the network layersource | yes · Approve/Deny card in Slack or the dashboard; approves actions, not a key release; the vendor cloud and Slack see the requestsource | no · Listed under 'Coming soon'source | partly · Own store: server-side AES-256-GCM; 1Password values only in gateway memory for 60 ssourcesource | yes · 3.6k stars, YC S26 (carried) | not documented · Not documented: no audit, SOC 2 or pentest found in the docs or sitesource |
| Clawvisor | yes · Injected server-sidesource | yes · Dashboard, Telegram, push; QR pairing (carried) | partly · Task expires_in_seconds (carried) | no · Managed cloud stores and injects; self-host keeps it in your networksource | partly · Small project, 283 stars (carried) | not documented · Not documented: the FAQ points to the public source; no audit foundsource |
What Custos combines, in the proof of concept today: each approval releases the keys you name, each only to the hosts, methods and path prefixes its policy allows (set on your phone, per key), until a set time and a use limit. A key you mark production (tier 2, the default for prod) is released for one use within 10 minutes; when the agent asks through the proxy (http_request), that use is bound to the exact method, URL and body it showed you; it travels through a relay that can't read the request, the value or the key share; grants end on the daemon's clock; the log keeps the agent's reasons, signed. It is built to sit in front of 1Password and Azure Key Vault, tested so far against a stub op CLI and an emulator (Lowkey Vault).
Where the others are ahead, by the same matrix: approve a remote / headless agent from a phone (OneCLI and Clawvisor ship phone-reachable approval today; our PoC has opt-in push but has not run on a real phone); shipping, generally available product; independent security audit or attestation.
The stores, the agent clients and the CLI
The CLI output below is the proof of concept's own, from the run behind the landing page's story (poc-output/cloud-fixture.json).
- 1Password
op://<vault-id>/<item-id>/<field>Read through a read-only service account. Its token stays with the Custos daemon, which fetches a value only after a grant. On Linux hosts; on macOS in dev mode. Tested with a stub
oprun as a real child process; a live service account has not been run yet. - Azure Key Vault
vault kv-prod · secret ledgerline-liveA service principal with read access to one vault.
custos bridge setup azureprints the az commands. It doesn't run them. Tested against Lowkey Vault (a Key Vault emulator) and a fake Entra, not a real tenant. - .env
LEDGERLINE_LIVE=custos:ledgerline-liveThe only .env your agent can read holds names. Rotate the old values: copies may survive on disk.
- Your agents
Agent clients and their test status Claude Code 8 sessions used a key · shell off Real Claude Code 2.1.276 sessions against the real MCP server, with only its MCP tools on: in the 8 that used a key, the key never reached the model. With its shell on: not yet recorded. Setup: one line (the last command in the terminal below). Codex · Cursor · Gemini CLI untested Stdio MCP clients too, so they should work the same way. Not run yet. Scripts (plain CLI) tested An agent on a remote box, end to end, through the CLI. Agents on macOS and Linux, local or over SSH. The Custos daemon compiles for Windows; no Windows agent client yet. You approve in the approver web app.
Type high-value secrets on your phone. A value typed into the custos CLI on the agent's machine passes through a process the agent's user can trace, in either mode (N19).
Show the CLI: doctor, import .env, a bridge, the one-line agent setup
$ custos doctor [standing_access] environment variable OP_SERVICE_ACCOUNT_TOKEN is set fix: unset it in agent shells; store it with `custos secret add` or a bridge [standing_access] ~/.azure/msal_token_cache.json: Azure CLI login cache fix: run `az logout` in agent shells; use `custos bridge add azure` [standing_access] ./.env holds values for LEDGERLINE_TEST, LEDGERLINE_LIVE, GITHUB_TOKEN fix: run `custos import .env --env <env>` … 8 findings in all
$ custos import .env --env dev LEDGERLINE_TEST → custos:ledgerline-test (proposed BLO7KLvyx6H9lQifp6dBaA) LEDGERLINE_LIVE → custos:ledgerline-live (proposed MmWomCdrCZZ6FT2s_MHkMA) GITHUB_TOKEN → custos:github-token (proposed N3S7mtB-h0Lz6D7Dl_UtUw) rewrote .env (3 value(s) → handles; owner and mode kept) Values were copied into Custos. The old file contents may survive on disk (copy-on-write, SSD, snapshots, backups). Rotate these values at their provider, then adopt the proposals on your phone. $ custos bridge add azure --tenant ledgerline.example --client-id 1111…5555 --ref kv-prod/ledgerline-live --name kv-prod-reader --env prod Client secret of the service principal (input hidden): proposed Azure credential 2etb_KotVTWxDe3fz3UvtQ: adopt it on your phone (Secrets → Proposed by hosts) and set the handles' egress rules. Remove standing access from agent terminals: `az logout` (custos doctor checks). $ claude mcp add custos -- "$(command -v custos-mcp)"
The recorded Claude Code sessions
10 sessions in 2 runs (23 Oct, 08:40–08:43 CEST), Claude Code 2.1.276 with claude-sonnet-5-5, Claude Code's own shell and file tools off, the real custos-mcp server. 8 sessions used the key, 12 calls in all, with 12 approvals; every approval and denial was made by the run's Playwright script in the approver app (desktop Chromium), not by a person. Both runs passed: 52 per-session checks and 10 file scans. The key was absent from the model transcript (the stream-json record, 32 forms checked) in every session; Claude Code's own session files were not found, so 2 of the scans checked nothing (the harness should fail when a required scan finds no file). Recorded, not gated: the runs did not record their code tree.
- 0times the key showed up: 32 encodings searched in the transcripts and in the daemon's and the relay's files; the relay's database searched in 5 forms
- 12calls with the key, each approved in the approver app by a test script (desktop Chromium)
- 0substitutions outside the approved header: a key name put into a URL or an X-Copy header (tried in 2 sessions) came back from the echo as plain text
- 2requests for "a copy of the key", denied by the same script
What this doesn't show. Approvals were scripted; no person, no phone. Claude Code's shell and file tools were off, so Custos's tools were the model's only way to the key: this shows the redaction and the tools that never return a value, not the shell door the landing page's story leaks through. Redaction is a safety net, not a boundary (N5); in service mode the value never enters the agent's process at all. One laptop in dev mode, one model. In 2 of the 10 sessions the model's own safeguards stopped before any call. Reports: run 06:40:40Z · run 06:42:08Z
Why now: numbers and voices
More questions
Will my phone tell me when the agent asks?
If you turn on notifications, it tells you that something is waiting, and no more. Settings → Notifications in the approver app subscribes the phone to an empty Web Push: an iPhone or iPad from iOS 16.4 as a Home Screen app, or Chrome, Android, Firefox and Edge. The ping carries no request details: you open the app to see the request and decide. Apple, Google, Mozilla or Microsoft learn when a ping is sent, not what for. Push is off when the relay runs without a push contact, and delivery through Apple's and Google's services is not yet tested on a phone. Without notifications, the approver app shows a request while it is open. A request nobody answers expires after 10 minutes. That is why Custos asks per grant, not per call: "staging, these hosts and paths, until 07:00" is one approval at bedtime, and 93% of permission prompts get approved anyway.7 The Custos daemon enforces the grant; tokens a provider issues also expire on their own.
What if I lose my phone?
Revoke it from a second approver. Every grant it signed stops at each host within a minute of the revocation reaching our relay, or the host pauses. A relay that holds it back can stretch that to 3 minutes. A relay that hides or refuses it: the app tells you to run the host check, which locks a host that missed it. Enroll that second approver on day one; with one, the app warns you on every screen. A thief who knows your passcode can approve until you revoke (N26).
A revoked approver still holds today's account secret, so secrets created later stay exposed to it until replacing the account secret is built. Planned: a printed recovery kit, with no password.
Which agents and systems?
Claude Code: run for real, 10 recorded sessions with Claude Code 2.1.276 and the real custos-mcp server, 8 of which used a key, with Claude Code's own shell and file tools off (above). Not yet recorded: Claude Code with its shell on, which is where service mode matters; that boundary is tested with two real OS users on Linux, without Claude Code. Scripts use a plain CLI, tested end to end. Codex, Cursor and Gemini CLI are stdio MCP clients too, so they should work; we haven't run them yet. Agents on macOS and Linux, local or over SSH. The Custos daemon compiles for Windows; there is no Windows agent client yet.
You approve in the approver web app, with a passkey: Face ID, a fingerprint or your passcode. It is tested in desktop Chromium; no run on an iPhone or Android phone yet. Today it covers HTTP APIs that take the key in one header. SSH keys, certificates and database passwords are planned.
Is it audited? Is it open source?
Not audited yet, and we won't pretend otherwise. The spec with its threat model, the index of decision records and every claim with its tests are here as plain text. The licence for the code isn't decided yet.
I'm one developer. Isn't this overkill?
Maybe. Not an agent story, just what one leaked key can cost: one team reported $82,314.44 in charges from a single compromised Google Cloud key, over two days. Their normal month: $180.10 If your agents never touch a real key, you don't need us.
What does it cost?
Nothing yet: the proof of concept isn't for sale. Proposed, not decided: free for local use, paid when it crosses devices, people or time. Prices aren't set; that is an owner decision (the proposal).