Custosworking name

For developers who let a coding agent work with real keys

Let your agent work until 07:00. Not a minute longer.

Your agent uses your keys by name. Custos adds them on the way out. You approve in an app built for your phone, and the grant ends when you said.

Proof of concept Strongest in service mode → iPhone/Android run pending

Your agentnames a key, never holds it

handle

Custos daemonon the agent's machine: adds the key, scrubs the answer

value injected

The APIgets the key over HTTPS

sealed requestsigned grant

Our relaycarries both, sealed: can't read them

sealed requestsigned grant

Your phonea passkey signs yes, until a time you pick

Only the daemon and the API ever hold the value. In service mode the daemon runs as its own OS user, so even a hijacked agent can't read the key; in dev mode it guards against mistakes. The relay can't read a request or your answer as long as the approver app's code is honest when it loads. Limits →

Real CLI · real approver app · one recorded run hero run 23 Oct, 09:33 CEST · every screen cropped, the whole one tap away

[22:01 laptop] granted until 2026-10-24 07:00 CEST, 200 uses
dev@laptop:~/work$ custos request staging-api --until 07:00 --uses 200 --reason 'Overnight migration run: backfill the orders table'
Request TL__yqH1cU-vvwvLNd_VIg  fingerprint: nectar bamboo shadow
Type this number on your phone: 089 592
request TL__yqH1cU-vvwvLNd_VIg
granted until 2026-10-24 07:00 CEST, 200 uses
[exit 0]
  1. [22:01 laptop] bedtime: one approval

    dev@laptop:~/work$ custos request staging-api --until 07:00 --uses 200 --reason 'Overnight migration run: backfill the orders table'
    Request TL__yqH1cU-vvwvLNd_VIg  fingerprint: nectar bamboo shadow
    Type this number on your phone: 089 592
    request TL__yqH1cU-vvwvLNd_VIg
    granted until 2026-10-24 07:00 CEST, 200 uses
    [exit 0]

    089 592 on the terminal = 089 592 typed on the phone

  2. [03:12 laptop] inside the lease: no question asked

    dev@laptop:~/work$ custos http staging-api GET https://api.staging.example/migrations/3 --reason 'check migration 3'
    HTTP 200 · 5 redaction(s)
    ⋯ 1 line left out: 308-character JSON echo of the request; the key in it reads «custos:staging-api»
    [exit 0]
  3. [07:01 laptop] after 07:00: it waits for you

    dev@laptop:~/work$ custos http staging-api GET https://api.staging.example/migrations/4 --reason 'check migration 4' --wait 0
    Request BmyfGXpA8En_52q2VCv2_A  fingerprint: dune ember kayak
    custos: needs_approval: needs approval: approve the request on your phone (request BmyfGXpA8En_52q2VCv2_A, fingerprint: dune ember kayak)
    [exit 3]
Show the full transcript
⋯ cropped: [22:01 laptop] custos handles, its 2 output lines and [exit 0]
[22:01 laptop]one approval at bedtime
dev@laptop:~/work$ custos request staging-api --until 07:00 --uses 200 --reason 'Overnight migration run: backfill the orders table'
Request TL__yqH1cU-vvwvLNd_VIg  fingerprint: nectar bamboo shadow
Type this number on your phone: 089 592
request TL__yqH1cU-vvwvLNd_VIg
granted until 2026-10-24 07:00 CEST, 200 uses
[exit 0]
[22:01 laptop]
dev@laptop:~/work$ custos http staging-api GET https://api.staging.example/migrations/1 --reason 'check migration 1'
HTTP 200 · 5 redaction(s)
{"body_b64":"","headers":[{"base64":"aWRlbnRpdHk=","hex":"6964656e74697479","name":"Accept-Encoding","value":"identity"},{"base64":"QmVhcmVyIH«custos:staging-api»","hex":"42656172657220«custos:staging-api»","name":"Authorization","value":"Bearer «custos:staging-api»"}],"method":"GET","path":"/migrations/1"}the key, replaced in raw, base64 and hex
[exit 0]
⋯ cropped: [22:01 laptop] custos http staging-api GET https://api.staging.example/migrations/2, its 2 output lines and [exit 0]
[03:12 laptop]inside the lease: reads go through; a write waits for you by rule
dev@laptop:~/work$ custos http staging-api GET https://api.staging.example/migrations/3 --reason 'check migration 3'
HTTP 200 · 5 redaction(s)
⋯ cropped: 308-character JSON echo of the request, as above
[exit 0]
[03:12 laptop]
dev@laptop:~/work$ custos http staging-api POST https://api.staging.example/deploys -d '{}' --reason 'deploy the migrated schema' --wait 0
Request MXClipIMCuZ5LqdJjQUR2A  fingerprint: beach moss lobster
custos: needs_approval: needs approval: approve the request on your phone (request MXClipIMCuZ5LqdJjQUR2A, fingerprint: beach moss lobster)
[exit 3]
[07:01 laptop]after 07:00: nothing without a new approval
dev@laptop:~/work$ custos http staging-api GET https://api.staging.example/migrations/4 --reason 'check migration 4' --wait 0
Request BmyfGXpA8En_52q2VCv2_A  fingerprint: dune ember kayak
custos: needs_approval: needs approval: approve the request on your phone (request BmyfGXpA8En_52q2VCv2_A, fingerprint: dune ember kayak)
[exit 3]
⋯ cropped: [07:01 laptop] custos status, its 27 output lines and [exit 0]
The approver app on the phone, as captured in the same run: Night shift until 07:00 selected, "Up to 200 calls to api.staging.example until 07:00", and the number 089 592 typed into "Type the number shown on laptop's terminal", above Hold to approve and Deny.

the approver app at 22:01, same run cropped

Crop of the approver app's screen in the same run: "Up to 200 calls to api.staging.example until 07:00. Writes to POST api.staging.example/ will wait for you and fail overnight." Uses 200. The number 089 592 typed into "Type the number shown on laptop's terminal", above Hold to approve.
emulated phone: desktop Chromium at Pixel 7 size, test passkey · whole screen
  1. 22:41 without Custos
  2. 22:41 with Custos
  3. 02:14 your call
  4. 07:00 the log
A separate recorded night with its own names (laptop, staging-api); the story below is the Ledgerline night. The night from 22:00 to 07:00 ran in 48 s on one clock that jumps forward; api.staging.example is a local fake echo. Dev mode: this shows the flow, not the OS-user boundary (that is the Linux run).
How it was recordedThe real CLI on a laptop (dev mode) and the real approver app in an emulated phone screen (desktop Chromium at Pixel 7 size, test passkey; not yet run on a real phone). The relay, the daemons and the phone shared that clock; on it the lease ran out at 07:00 by itself. The phone grants only when the typed number is the terminal's: a wrong number was refused in the same run. Each block above runs from its command to its exit code as captured; a line left out inside a block is marked ⋯ where it stood, and the blocks between are in the full transcript. On phones the 22:01 block folds to its result line; it opens whole.
hero run 23 Oct, 09:33 CEST · manifest sha256 3761f7eb5e2d… · tree 33d8870d

22:41 · without Custos

Nobody attacked anything. The agent was debugging.

It took five tries. On the fifth, your key landed in the chat.1

session 7f3a2c · claude-code · devbox-03 · ssh simulated Written by us (no Custos), modelled on Upstream's 2026-10-20 incident1 (6 attempts there, shortened to 5 here) and claude-code #44868.2 The key is fake.
  1. You

    Login to the Ledgerline sandbox fails with 401. Find out why, fix it, open a PR. I'm off to bed.

  2. Agent · attempts 1–4

    22:43–22:55: curl → 401, printenv, Read(.env) → blocked by your deny rule
    Agent ran: curl -s -H "Authorization: Bearer $LEDGERLINE_KEY" https://sandbox.ledgerline.example/v1/session
    Output: {"error":"unauthorized"}
    Agent ran: printenv | grep LEDGER
    Output: LEDGERLINE_ENV=sandbox
    Agent used tool: Read(.env)
    Output: Blocked by your deny rule.
  3. Agent · attempt 5

    The read tool is blocked. Searching the file instead.

    Agent ran: grep -n LEDGERLINE_LIVE .env
    Output: 3:LEDGERLINE_LIVE=ll_live_7Q2fDEMOdemoDEMOdemo0000

    Sent to the model ×1 The value is now part of the conversation. Your deny rule blocked one tool; the shell is another door.3

22:59 → 23:40 · the next 13 turns One leak is not one event.

The harness sends the whole conversation back every turn, and your key rides along.1

In each of the next 13 turns, line 3 with the key is sent to the model again: 14 times in total.

  1. Edits config/ledgerline.ts to read the sandbox key

  2. npm test: 212 passed, 3 failed

  3. Fixes the webhook fixture

  4. 23:12–23:37: 9 more turns, each resending line 3 (not shown here)

  5. Done. Suggests you rotate the key it printed at 22:58

14× to the model provider, plus the transcript on devbox-03, which stays after you rotate. Of 1,708 credential leaks found in 520 of 17,022 sampled agent skills, 73.5% came this way: debug output fed back into the model's context.4

Five sensible fixes, each guarding the wrong door

Five sensible fixes. Each guards the wrong door.

  • CLAUDE.md: NEVER read .envadvice2
  • "deny": ["Read(.env)"]one door3
  • .gitignore: .envwrong stage5
  • op run (Touch ID)wrong room6
  • az login (all subscriptions)no question

Every fix asks the agent to behave. The other way: leave it nothing to misbehave with.

Check it yourself

Press ⌘ F (or Ctrl F) and search forTap Find it for me, or use Find in Page, and search for ll_live_

Above the rewind6

Below the rewind?see after the rewind

1 printed + 4 resends shown + 1 in this box = 6

The model got the key 14 times; this page shows 4 of the 13 resends. Both passages are written by us; the count is of what this page renders.

Rewind to 22:41

Same agent. Same night. Same bug. This time Custos sits between the agent and your keys.

22:41 again · with Custos

Your agent can use the key. It can't read it.

Against a hijacked agent: in service mode. Against an agent's mistake: in either mode. Where that holds ↓

It names a key. The Custos daemon, running as its own OS user, adds the value to the outgoing HTTPS request, then scrubs it from the answer as a safety net.

What one approval releases

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.

Where that holds: service mode and dev mode
Where that holds
The Custos daemon runsAn agent's mistakeA hijacked agent
as its own OS user service mode; the agent's user can't become rootvalue isn't handed to the agent; echoes scrubbed, best effortvalue never enters the agent's process
as your user dev mode, a laptop; or an agent with sudo, docker or rootvalue isn't handed to the agent; echoes scrubbed, best effortnot protected: it can read the daemon's keys

In both modes an agent can still use a key for anything its rules allow until the grant ends, including minting a new key. Limits and spec ids →

How service mode was tested: two real users on Linux

11 of 11 permission tests passed as root, 26 Oct, 21:23 CET, daemon tree c11917d3. A walkthrough (23 Oct, 09:30 CEST): 22 ok · 21 refused by the OS-user boundary · 2 policy refusals · 1 not exercised (no sudo in the image) · 0 failed. Read the transcript.

line 31
uid=999(custos) gid=998(custos) groups=998(custos)
uid=1000(dev) gid=1000(dev) groups=1000(dev),999(custos-clients)
⋯ cropped
line 451
   # Daemon memory and environment:
dev@devbox$ cat /proc/1354/environ
cat: /proc/1354/environ: Permission denied
[exit 1]
⋯ cropped
line 467
dev@devbox$ kill -STOP 1354
bash: line 1: kill: (1354) - Operation not permitted
[exit 1]

In this story devbox-03 runs service mode. In the proof-of-concept run behind the story, that mode is simulated in one process, so the story is not evidence of the user boundary; the Linux run above is. Setup is manual for now: the systemd unit the CLI prints lacks the agent's user id, so it doesn't start the daemon; start it by hand as the walkthrough does.

session ledgerline-401 · claude-code · devbox-03 · ssh · custos-mcp PoC output, scripted session story run on tree d041dbac, not gated The agent's turns are written by us; every tool result and Custos line is the proof of concept's output (devbox-03's service mode simulated in one process).
  1. Agent · 22:42 → 22:46

    22:42–22:45: list_secrets, a refused call (egress_denied), grep .env
    Agent used tool: list_secrets
    Output, names only: ledgerline-liveprodevery useGET api.ledgerline.example /v1/ledgerline-testdevleaseGET sandbox.ledgerline.example /v1/github-tokendevleaseGET api.github.com / · POST api.github.com /repos/acme/ledgerline/pulls
    Agent used tool: http_request custos:ledgerline-live GET https://sandbox.ledgerline.example/v1/session
    Output: egress_denied: operation matches no egress rule

    Requests sent to Ledgerline: 0. In Custos, a key goes only to the hosts, methods and path prefixes its policy allows: ledgerline-live's names only GET api.ledgerline.example /v1/. That refusal is the bug: the app sends the live key to the sandbox.

    Agent ran: grep -n LEDGERLINE .env
    Output: 2:LEDGERLINE_TEST=custos:ledgerline-test
    3:LEDGERLINE_LIVE=custos:ledgerline-live
    Agent used tool: request_access custos:ledgerline-test custos:github-token · lease until 07:00 · 500 uses

    Your phone · 22:46:40 · Night shift replica

    Up to 200 calls to sandbox.ledgerline.example, api.github.com until 07:00. The agent asked for 500 uses; you approved 200.

    Output: status granted · uses_left 200
  2. Agent · 22:58 · the first call with the right key works

    Agent used tool: http_request custos:ledgerline-test GET https://sandbox.ledgerline.example/v1/session
    Whose view of this call
    Output as the model reads it: status 200 · redactions 1
    [debug] auth header: Bearer «custos:ledgerline-test»

    The sandbox echoed the key in a debug line. Custos replaced it before the agent saw the answer.

    What sandbox.ledgerline.example received: GET /v1/session
    Authorization: Bearer ll_test_••••••••••••••••••••••••

    The Custos daemon added the header on devbox-03. The value never entered the agent's process. Masked here too: what matters is where it was.

  3. Agent

    Agent used tool: http_request custos:github-token POST https://api.github.com/repos/acme/ledgerline/pulls
    Output: status 201 · PR #212 open

    Fixed. PR #212 is open. No key appeared in this session.

Search again, same eight charactersAbove the rewind: 6. Below it, the same work: 0.

22:46 → 07:00

Say yes once, at 22:46. Then sleep.

Inside the grant, the agent works without asking. The Custos daemon on devbox-03 holds it to the window and the use limit. Anything outside it waits for you.

People approve 93% of the permission prompts they see.7 So Custos asks less often, and only about what's new.

  1. grant by your phone · ledgerline-test, github-token · until 07:00 · up to 200 calls
  2. 6 calls until 01:30 · all succeeded (HTTP 200 OK, 201 Created) ✓ · grant: 6 of 200 used
    1. GET sandbox.ledgerline.example/v1/session · ledgerline-test · HTTP 200
    2. POST api.github.com/repos/acme/ledgerline/pulls · github-token · HTTP 201
    3. GET sandbox.ledgerline.example/v1/payments · ledgerline-test · HTTP 200
    4. GET sandbox.ledgerline.example/v1/refunds · ledgerline-test · HTTP 200
    5. GET api.github.com/repos/acme/ledgerline/commits/fix-401/check-runs · github-token · HTTP 200
    6. GET sandbox.ledgerline.example/v1/webhooks/events · ledgerline-test · HTTP 200
  3. Webhook signatures fail only in prod. Checking the last 14 events against prod; this key is outside tonight's grant.
    http_request GET api.ledgerline.example/v1/webhooks/events?limit=14 · ledgerline-live → needs_approval · fingerprint: jaguar orchid award · waiting for you 3 words: they catch a relay swapping in another request from this host, but only if you compare them with the words in the agent's session; on your phone alone they check nothing. A relay can pick among the host's other pending requests (at most 2) and re-sign each up to 5 times: about a dozen candidates against 16.8 M word triples, roughly 1 in 1.4 million per request. No number to type on this path. They say nothing about the agent itself.

02:14 · your phone · your call

Real screen from the hero's recorded run: a different request, the same flow cropped

The approver app's real request screen: "devbox wants payments-api", expires in 9 min 59 s; banners "Production or per-use secret" and "This host was enrolled less than 24 h ago"; Verified: devbox, service mode, its host words, payments-api prod · every use, GET api.payments.example/, request words aspen board prism.
Show the decision: the number typed, Hold to approve The same request after the number was typed: Agent says "Check yesterday's failed charges before the standup"; Your decision: Once · 10 min selected, 15 min and 1 h disabled ("tier 2: once only, at most 10 min"); 906 044 typed into "Type the number shown on devbox's terminal"; Hold to approve; Deny; This wasn't me.
Its own labels: devbox asked for payments-api (words aspen board prism), approved once for 10 minutes. Why the warnings: that run's host was enrolled a minute before, and its request came from the plain CLI, outside any Custos session, so the app says the grant is usable by any program on devbox without one. The story's devbox-03, enrolled long before and asking through its agent's session, would show neither. Whole screen →
story branch What if you… (this page's control, not the app)
Approved · signed 02:14:52 Denied · signed 02:14:52
The same screen with this story's values (a replica)

Replica: the app's request screen, filled with this story's values

One approver: if this phone is lost, stolen with its passcode, or its website data is cleared, you cannot revoke it or recover the account — lock each host with custosd lock. Add a second approver

devbox-03 wants ledgerline-live

expires in 9 min 59 s

Production or per-use secret

Verified

devbox-03 service mode

clockcarpetciderblade host words: which machine

ledgerline-live prod · every use

GET api.ledgerline.example/v1/

GET api.ledgerline.example

matches egress rule GET api.ledgerline.example/v1/

This approval works only for this exact request

Usable only by the Custos session that made this request on devbox-03 †

Request jaguarorchidaward request words: which call

Reported by the host, not verified 5 rows
Program
claude → landing-fixture †
Executable
/usr/local/bin/claude reported by the client process
Folder
/home/dev/ledgerline reported by the client process
Session
“ledgerline-401” a label reported by the host; any program running as the host user that obtains the token could use it
Host time zone
Europe/Zurich

Agent says:

“Webhook signatures fail only in prod. Checking the last 14 events against prod; this key is outside tonight's grant.”

Path chosen by the agent

/v1/webhooks/events?limit=14

Your decision

Once · 10 min15 mintier 2: once only, at most 10 min1 htier 2: once only, at most 10 minCustom

  1. If you don't answerThe request expires at 02:24:09. The agent skips the prod check and notes it in PR #212.
  2. If you approvegrant · once · until 02:24:52 · 1 use · signed by your phone
    02:15:03 GET api.ledgerline.example/v1/webhooks/events · 200 ✓ · «custos:ledgerline-live» ×1
    next call → needs_approval: the grant is used up
  3. If you denydenial by your phone · The agent skips the prod check and leaves a TODO in PR #212.
  4. 4 calls until 06:31 · all succeeded (HTTP 200 OK) ✓ · grant: 10 of 200 used
    1. GET sandbox.ledgerline.example/v1/session · ledgerline-test · HTTP 200
    2. GET api.github.com/repos/acme/ledgerline/commits/fix-401/check-runs · github-token · HTTP 200
    3. GET sandbox.ledgerline.example/v1/payments · ledgerline-test · HTTP 200
    4. GET sandbox.ledgerline.example/v1/session · ledgerline-test · HTTP 200
  5. grant ends · devbox-03 holds no grant
    07:00:30 GET sandbox.ledgerline.example/v1/session → needs_approval: a new request, with new words: elbow radish brook
About the 02:14 screen: the replica, who can approve, push

The replica is not a capture: the approver PWA's request screen (request.ts), its zones and strings, filled with this run's values. † Program reads claude → landing-fixture because the run hosted the MCP server inside its own program (scripts/fixture-rs); installed, the server runs as the custos-mcp binary, as in the recorded Claude Code run (Program: claude → custos-mcp). The screen above it and the hero are real captures.

† A session is a label an honest host keeps, not proof of which process used the key (ADR 0035). The real screen above shows the unbound case: "Usable by any program on devbox that runs without a Custos session".

Nothing on devbox-03 could have approved this: every approval needs a user-verified passkey (Face ID, fingerprint or passcode) from an approver you enrolled. Approver keys are software keys in the browser, and a thief who knows your phone's passcode passes this check (N26). Here a one-second hold stands in for it, and you can change your mind; in Custos a decision is final, signed and logged.

Host words (4) say which machine: fixed when you enrolled it. Request words (3) say which call: new for each request, and the agent reads you the same three.

Proof of concept: with notifications on, a content-free ping says a request is waiting; you open the app to see it.

02:14, as our relay stored it

Our relay carries the question and your answer. It can't read the question, or the key inside your answer.

The relay's whole copy of the 02:14 request, from the proof of concept's run of this night. One assumption remains: the approver app's code is honest when it loads. Self-host it, or run it apart from the relay.

You approvedYour answer is entry 18, 8,567 bytes. The relay reads that it is a grant, its window, 02:14:52 → 02:24:52, and its limit of 1 use. The key share for devbox-03 inside it, 1,168 bytes, stays sealed.

Nobody answeredThe relay keeps this request in its log for good, sealed. It could be answered until 02:24:09. Had you approved, the relay would read the grant's window and use limit, not the key share inside.

You deniedYour answer is a signed denial entry. The relay reads that it is a denial of this request (by its hash), when you signed it, and its code: a plain no, “This wasn't me”, or a wrong number. It carries no key share.

The relay's whole copy: 9,778 bytes. It can read 300.

  • 300 Bmetadata the relay reads: ids, times, sizes, the kind of entry
  • 7,962 Bpublic keys and signatures: readable, no secret in them
  • 1,516 Bsealed to your account: the reason, the URL, the agent, the scope
To scale: each band's width is its share of the 9,778 bytes. Every byte, one pixel each, band by band →
Show what our relay reads, and what it can't

Our relay reads

  • ids, kinds, times, and sizes; sizes aren't padded yet, so they hint at how long a reason or URL is
  • each device's role (host or approver), label like devbox-03, operating system, and isolation: service mode or not
  • how often each key id is used, and by which host
  • whether you approved or denied, and when; a denial's code: a plain no, “This wasn't me”, or a wrong number
  • each grant's window and use limit, so a once-only approval is recognisable
  • with notifications on, which push service each approver uses (Apple, Google, Mozilla, Microsoft); that push service learns when a request is waiting, never what

Our relay can't read

  • key names, reasons, URLs, the agent's details and the requested scope
  • the key share inside a grant: sealed to the request's one-time key
  • the value: stored only encrypted, under a key it never holds
  • today, that rests on X-Wing (ML-KEM-768 and X25519): the relay keeps the sealed copies, so if both are ever broken, kept copies open (N18, ADR 0034)
  • signatures are ML-DSA-65 + Ed25519, but the passkey (WebAuthn) check is classical ES256 (N27), and a value sent to an API is only as post-quantum as that API's TLS (N30)

The full list is in the spec: what the relay sees and the metadata it keeps. Padding and sealed labels are planned. How the two key halves meet

07:00 · the morning after

Every key your agent used, and why it said it needed it.

Who, what, when, which machine, and the agent's words, cleaned of hidden characters. Every entry is signed by the device that caused it and hash-chained to the one before; custos audit verify checks both. Rows marked derived are read from an entry's own times, not signed.

The Overnight

devbox-03 · session ledgerline-401Thu 22 → Fri 23 Oct 2026Audit → Report, on your phonethis page's rendering of the run's audit log

Audit log for the night, including your 02:14 decision
TimeEntryKeySigned byReason
22:46:40grantledgerline-test, github-token · until 07:00 · 200 callsyour phoneFix the sandbox login, run the sandbox checks, open a PR. Working overnight.
22:58 → 06:31use ×10ledgerline-test, github-tokendevbox-03
  • Retry the login with the sandbox key
  • Open the PR with the fix
  • +8 more
02:14:09requestledgerline-live · oncedevbox-03Webhook signatures fail only in prod. Checking the last 14 events against prod; this key is outside tonight's grant.
02:24:09If you don't answerexpired derivedledgerline-livenot an entryNo answer in 10 minutes. Read from the request's own expiry, not a new entry.
02:14:52If you approvegrantledgerline-live · 1 use · until 02:24:52you, on your phone (verified)You approved it at 02:14.
02:15:03useledgerline-live · 1 of 1devbox-03GET api.ledgerline.example/v1/webhooks/events · 200 · redacted ×1
02:14:52If you denydenialledgerline-liveyou, on your phoneYou said no at 02:14. The agent left a TODO.
$ custos audit verify
⋯ cropped: 1 line (log size, head, fingerprint)
chain verified · frozen false · locked false
⋯ cropped: 1 line (the fork-check hint)
A relay that shows your devices different histories is caught only when you run the check on that host: type the phone's 4 check words into custosd verify, which locks the host on a mismatch. Until then it can keep that host on an older history; the app prompts the check after a reducing change. A compromised host can still under-report its own uses.

Works with what you have

Keep your vault. Custos stands in front of it.

Your team's keys stay where they are; the agent just stops holding standing access to them. Both vault bridges are built and tested against stand-ins, not yet against a live account.

Stores

  • 1Passwordread-only service accounttested against a stub op CLI; no live account yet
  • Azure Key Vaultone vault, read accesstested against an emulator (Lowkey Vault) and a fake Entra
  • .envnames, no values

Agents

  • Claude Code8 sessions used a key · shell off
  • Codex · Cursor · Gemini CLIuntested
  • Scripts (plain CLI)tested

The stores, the agent clients and the CLI, with the proof of concept's own output →

Evidence

Check the claims, not the copy.

Every product claim on this page is tagged and tied to named tests of the proof of concept, or to a recorded run: 18 claims. 2 are only partly proven, and say so.

Open the security review every claim with all its tests, 12 limits, the comparison, the files

2,984 Rust tests, 0 failed 100% of 66,486 lines 65 fuzz targets 18 claims, each tied to tests or a run gate 26 Oct, 21:05 CET · tree 5b643f83
  • clean dependency audit (cargo-deny)
  • 11/11 two-user permission tests, as root on Linux
  • 21 refusals by the OS-user boundary in a two-user walkthrough, 0 failed the transcript's summary says 24 refused: 2 of those are policy refusals and 1 was not run older code
  • 12/12 Claude Code calls got the key back redacted Key absent from the model transcript in every session that used it; Claude Code's own session files were not found, so they were not checked. Run with its shell off, approvals scripted, dev mode; recorded, not gated (the runs). Redaction is a safety net, not a boundary; in service mode the value never enters the agent's process.

gate 26 Oct, 21:05 CET · tree 5b643f83 · poc/scripts/gate-json.mjs. That is the code as it is now. Walkthrough transcript · gate.json · claims and tests

Who else does this

The nearest products on four questions, from the competitor matrix (vendor docs read 2026-10-23)
ProductKey out of the modelApprove remotelyEnds on a clockVendor blind to values
Custos PoC yes partly yes partly
1Password yes no partly partly
Bitwarden partly partly partly yes
Infisical yes partly partly no
OneCLI yes yes no partly
Clawvisor yes yes partly no

● yes · ◐ partly · ○ no. Where they are ahead: they ship, and some are audited. The full comparison, with sources and notes →

What it doesn't do, in short

  • 07:00 is the host's clock. An honest host's daemon keeps it, not cryptography.
  • An allowed API can mint a new key. A lease to api.github.com that allows POST /repos/*/*/keys outlives 07:00. The app flags known minting paths in red; keep write rules narrow (the story's token may only POST …/pulls).
  • Dev mode guards against accidents only. Against a hijacked agent, use service mode, set up by hand for now.
  • Not yet on a real phone, not yet audited. All 12 limits, with spec ids →

Design partners wanted: teams running coding agents with production keys on remote boxes or CI (contact: owner input; until then, join the waitlist).

For investors stage, market, next milestones, team

Why now

28.65 M new hardcoded secrets on public GitHub in 2025, up 34%.5 More numbers and what users say →

~12 M developers whose coding agents touch real keys in 2026 (5–23 M) U

  1. TAM 2028, bottom-up U$420M–$5.7Bdevelopers whose agents touch real keys × an assumed $60–180 a seat-year
  2. TAM 2028, top-down U$470M–$1.7Bcoding-agent spend (Gartner) × today's security attach rate
  3. SAM 2028 U~$1.2Bdevelopers in organisations under 500 people × $120
  4. SOM, year-3 ARR U$6M–$12M50–100 k paid seats, 0.5–1 % of SAM seats
U = our estimate, unverified; the method and every input are in the docs' market file (research/09). Log scale. The $120 a seat-year inside them is a sizing assumption, not a price. Expansion in our plan: team grants and audit, then compliance evidence and company-wide policy; later SSH keys and database passwords.
Wedge
Teams that run coding agents with production keys on remote boxes or in CI, where a fingerprint prompt on the laptop can't reach. Service mode is set up by hand today.
Stage
Proof of concept with a recorded gate, not audited, no users. Round and use of funds: owner input
Next
  1. a run on a real iPhone and Android phone
  2. a Claude Code session with its shell on, in service mode, a person approving
  3. the printed service unit starts the daemon
Model
Proposed, not decided: free for local use, paid when it crosses devices, people or time. owner input
Team
  • Name · role · relevant prior work
  • Name · role · relevant prior work
owner input

Objections, answered

What a skeptic asks first.

Can't the agent just print its own environment?

Its own, yes, and there is no key in it. The Custos daemon runs as its own OS user and adds the key to the outgoing HTTPS request; the value never enters the agent's process or output. As a safety net it scrubs every answer: the exact value, its base64, hex, URL and JSON forms, and any run of 12 or more of its characters. That scrub is accident prevention, not a boundary (N5).

On a laptop in dev mode, where the daemon runs as your own user, this guards against accidents, not against a prompt-injected agent. Use service mode for that; in the proof of concept you set it up by hand (see the limits).

One door stays open in both modes: a value you type into the custos CLI on the agent's machine passes through a process the agent's user can trace. Type high-value secrets on your phone instead (N19).

What if a prompt injection asks for approval?

Then your phone shows that request: the host and the full path the key would go to, unshortened, and the reason in the agent's words, cleaned of hidden characters (with a badge when any were removed). You decide, and an approval can narrow a request, never widen it. In the recorded Claude Code run, an instruction planted in a URL was on the phone word for word.

And whatever you approve, a key goes only to the hosts, methods and path prefixes its policy allows, which you set per key. At 22:42 above, that rule is what caught the bug. An allowed API that can mint keys is the gap: keep write rules narrow (N6).

One thing to know: anyone you read an approver's enrollment code to becomes an approver, an AI agent included. The proof of concept can't yet tell a phone's passkey from a software one (attestation checks are planned), so type that code only into the Custos app on your other phone.

Isn't this what 1Password or Infisical already do?

Close cousins, and good ones. Keeping a key out of the context is now common: Infisical, OneCLI, 1Password's Environments and others do it in some form. Approving from a phone is not new either: OneCLI (a Slack card) and Clawvisor (Telegram, push) ship it. The comparison shows who does what, with sources, including where they are ahead: they ship, and some are audited.

What's different is narrower. In Custos, 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 goes through a relay that can't read the request, the value or the key share, as long as the approver app's code is honest when it loads. The approval is signed into a chained log with the agent's reason. OneCLI and Clawvisor approve actions through a vendor cloud that holds or decrypts the secrets; 1Password's Credential Broker keeps one of its two keys in an enclave it runs.8 And 1Password's own users have asked for remote approval since February; the request was still open on 2026-10-22.6

Custos is built to sit in front of 1Password (tested with a stub of its CLI so far). Keep using it.

Does your cloud ever see a value?

Our relay stores ciphertext it can't open, and it can't sign a grant. The picture above is its real copy of your 02:14 request. It does see that you answered, when, each grant's window and use limit, and how often each key id is used (the full list is above).

The honest limits: whoever serves the approver app could change its code, so self-host it or run it apart from the relay (N9). A compromised machine can stay quiet about its own uses. And a relay that shows your devices different histories is caught only when you run the check on that host (type the phone's 4 check words into custosd verify); until then it can keep that host on an older history (N17).

The three request words the agent reads you guard against one attack: a relay that shows your phone a different, validly signed request from the same host. It can pick among the host's other pending requests (at most 2) and re-sign each up to 5 times, so it has about a dozen candidates against 16.8 million word triples (three bytes of the request's hash, spec §3; ADR 0050): roughly 1 in 1.4 million per request. And only if you compare them with the words in the agent's session; on your phone alone they check nothing. Where the terminal shows a 6-digit number to type, that number binds the approval to the request (P4); the 02:14 request in the story comes over MCP, with no number, so there the words are the only check. The words come through the agent, so they don't tell you whether the agent is honest (ADR 0035); the host, path and reason do.

Adding an approver passes your account secret through our relay, sealed to the new device with X-Wing (ML-KEM-768 + X25519); an honest relay deletes it after 10 minutes. A hostile relay can keep it, along with the sealed key halves in the log: if both halves of X-Wing are 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 (harvest now, decrypt later; N18, ADR 0034).

The 07:00 cut-off is kept by an honest host's daemon, not by cryptography, and it uses devbox-03's clock. Someone who controls both our relay and the host's time server could stretch a grant by as much as they skew that clock (N2).

Push, a lost phone, which agents, audits, cost: more answers on the security review →

P.S.

P.S. The key from last month may still be in your history. Count tonight:

grep -rlE '[A-Z0-9_]*(KEY|TOKEN|SECRET|LIVE|PASSWORD)[A-Z0-9_]*\\?"?[[:space:]]*[:=][[:space:]]*\\?"?[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[abprs]-[A-Za-z0-9-]{10,}|Bearer [A-Za-z0-9._~+/-]{20,}' ~/.claude/projects ~/.codex/sessions 2>/dev/null | wc -l

Each file it counts is a Claude Code or Codex session with a key-like value in it (some false positives; for a thorough scan, gitleaks dir ~/.claude/projects). We can't take those back. We can keep the next one out.