Security
Security
Counterseal holds other people's production keys and runs a public log. This is the honest version of how it protects them, and where it stops.
The model
Agents are powerful and they make mistakes. Prompt rules ("never delete the database") live inside the model and can be ignored, forgotten or injected away. Counterseal moves the rules outside the model:
- The agent never holds a real key. It holds an
lsh_token that only works through the broker, only for one provider, only within its policy, and only until it expires or you revoke it. - Irreversible calls are held. The broker checks every call against a versioned map of operations that cannot be undone. Those wait for a human.
- Only a passkey can release a hold. Not the agent, not the CLI, not an API key.
- Every decision is on the record in your hash-chained audit log, and, with receipts on, in a public Merkle log anyone can verify.
- An agent can prove who it is. A token bound to an agent key only works on requests that key signed.
How keys are protected
- Envelope encryption. Each secret gets its own random 256-bit data key and is encrypted with AES-256-GCM. The data key is encrypted (wrapped) with a master key that lives in Cloudflare's secret store, not in the database.
- Bound to its row. Each ciphertext is bound to its account, row and provider as authenticated data. A ciphertext copied into another account or row fails to decrypt.
- Write only. No endpoint returns a stored key. You see the last four characters, nothing more.
- Injected at the edge. The key is decrypted only for the instant the broker forwards a permitted call, added to that one upstream request, and never logged.
- Fixed destinations. Each provider has a fixed upstream host. Paths are checked for traversal and encoded separators. Redirects are never followed, so a key can't be bounced somewhere else.
How approvals work
- Your agent makes an irreversible call. The broker stops it and returns
428 held_for_approvalwith a link. - You open the link (or the app, or the phone alert) and see the exact method, host, path, the rule that matched, why, and a preview of the request itself: the SQL, the GraphQL or the body.
- You approve with your passkey: Face ID, Touch ID, Windows Hello or a security key. The check happens on your device; the broker only verifies a signature.
- The approval covers that exact request, identified by a hash of its method, path, query, content type and body, once, for ten minutes. A different request, or the same one twice, is held again.
- A token can have at most 20 requests waiting and an account 100, and held requests wake your devices at most once a minute, so a flood of requests cannot wear you down into approving one.
A coding agent's commands
- Guard keys only ask. The guard on the agent's machine holds a guard key that can ask for an approval and read the answer. Approving always needs your passkey in the app. A key made by a command line sign-in ends with it, and revoking it closes its open approvals.
- One exact command, once. An approval is bound to a hash of the exact command, the agent and the folder's name, used once, within ten minutes. The card shows the whole command, with hidden and control characters made visible.
- No flood of alerts. Each guard key can ask for 60 new approvals an hour, with 20 waiting; asking again for the same command sends no new alert, and alerts go out at most once a minute.
- A seatbelt, not a sandbox. The guard runs on the agent's machine. An agent that edits its hook settings or wraps a command in a script is not stopped by it. Only keys kept in the vault are enforced by the server, so for a hard stop give your agent a Counterseal token instead of the real key.
Two people, two passkeys (teams, not yet live)
- Each person counts once. A team can require two seals on every held action. A seal belongs to the person whose passkey made it, so a second passkey, a second browser or two taps at the same moment still count once, and the approval happens in the same database transaction as the last seal.
- Roles are checked by the server. A passkey proves who is there; what they may do (owner, approver, member, viewer) is checked on every request. Only owners and approvers can seal, and you can require that the person whose agent asked is not one of them.
- Protection is never lowered at once. Raising the seals needed applies immediately. Lowering them takes 24 hours, wakes every member's devices and can be cancelled; meanwhile no one person can pre-approve or set money caps, and a new approver's seal counts only after 24 hours.
- Invites are one-time and short-lived. Made by an owner with a passkey, stored only as a hash, single use, 2 hours by default, sent only in the link's fragment; the page shows who invites you to what before anything is made, and the team is told when someone joins.
- Removal is total and immediate. Sessions, passkeys, alert devices, the keys and tokens they made, and their seals, in one step. The last owner can never be removed.
- What it does not stop: two people who agree to act together.
A GitHub Actions deploy
- GitHub signs who is asking. A deploy gate holds a job until you approve with your passkey. The job sends the OIDC token GitHub signs for it. Counterseal pins the algorithm (RS256), takes the key only from GitHub's own key set, checks the signature before it reads any claim, and checks the issuer, an audience that names the gate, the times (60 seconds of skew) and that the token was not used before.
- Only what you listed. A gate accepts one repository, the workflow files, branches or tags, environments and triggers you list when you make it (with your passkey; a command line session cannot). Nothing is accepted by default, and pull requests are never offered. A run that is not what the gate accepts is refused before anything is written or anyone is alerted, and learns nothing about the gate's repository.
- One run, once, bound to the repository's ids. The approval covers the repository and owner ids, run, attempt, commit, workflow, ref and environment named in the token, and is spent once. The first approved run binds the gate to GitHub's ids, so a repository deleted and re-created under the same name cannot use it.
- No approval, no key (optional). A gate can hand the job a 15 minute Counterseal token for a vaulted key only after you approve; before that the job has no deploy key, and the real key never leaves the vault.
- A pause the job asks for. A person who can edit the workflow can remove the step. Protect the workflow file, or use the key option. Every step of an approved job shares the job's identity.
- Not yet run against real GitHub. The checks follow GitHub's documentation and are tested with a stand-in that signs like GitHub.
An MCP server behind counterseal mcp wrap
- Held, then approved exactly. A destructive tool call is answered at once as held and asked for with a guard key; after your passkey the same call with the same arguments runs once. A failure, a missing key, no network or a frozen account never lets it through.
- Pinned definitions. A tool the server changes after you pinned it, a new tool and changed instructions never reach the model; they are shown to you, with hidden characters made visible, before you accept them.
- One reading only. A message that could be read two ways (a repeated name, a name in another case, an id written twice) is not passed on in either direction.
- A seatbelt, not a sandbox. It runs on the agent's machine as the same user and matches tool names and arguments against lists. An agent that starts the server another way or edits your MCP configuration is not stopped by it.
Money caps, the breaker and Freeze
- Small refunds flow, big ones wait. A token made with your passkey can let a Stripe refund or payout through without asking when it is under a per-call and a per-day cap, in one currency. Only those two calls can pass; every other money call stays held.
- Amounts are read the way the map reads them. The amount comes from a plain form read by the same strict parser that holds repeated and disguised parameters. A repeated, escaped or oddly spelled amount, a query string, a JSON body, a currency in another spelling, or an amount that is not a plain whole number is held, never guessed. A refund's currency and what is left come from Stripe, with your key.
- The day's total cannot be raced. It is reserved by one conditional statement, so two calls at once cannot both fit under a cap they would exceed together. A refusal from Stripe hands the amount back; a server error or no answer keeps it counted.
- A breaker for loops. A token with limits trips when it makes too many writes in an hour or repeats one exact request; its writes then wait for you and your devices are told once. Reads still pass.
- Freeze stops everything, and only a passkey ends it. Any signed-in session can freeze (stopping is always safe); every call of every token then answers 423 and nothing is approved. Setting caps, resetting a breaker and unfreezing need your passkey, never a command line session.
- Where it stops. These cover calls through Counterseal. They do not cap LLM tokens or SQL sent over a database connection, a day is the UTC calendar day (a total can reach twice the cap across midnight), and calls already in flight when you freeze finish.
How the public log is protected
- Append only, one writer. The log is an RFC 6962 Merkle tree in one Durable Object, so appends are strictly in order and the tree cannot fork; each append is written in one storage transaction, so a failure leaves nothing half written.
- Signed checkpoints. Every append returns a C2SP signed note (Ed25519) over the tree size and root, and an inclusion proof. Any two checkpoints can be linked with a consistency proof. The log refuses to sign under a different name or key than the one it started with.
- Broker receipts cannot be forged. Only the broker writes entries with
source: "broker"; the receipts API fills only the inner receipt and refuses unknown fields. - Nothing secret goes public. Broker receipts carry the method, the provider host, the decision, the rule, the time and a hash of your audit entry; never a path, body, key, token, name, email, IP address or account id. Each entry's submitter is a salted commitment, so that field does not link entries to an account or to each other.
- Signed receipts. A receipts key bound to a signer accepts only receipts that signer signed, with a
tswithin 10 minutes, each signature once (also across keys); small-order and non-canonical keys and signatures are refused, and binding needs a proof of possession over a text that names the log and the account. - Limits. 10,000 receipts per account per month, 2,000 per account per day, 10,000 appends per day for the whole log, reserved before appending; a refused batch records and counts nothing. When a limit is hit, the broker still decides; the receipt is skipped and counted.
How agent keys work
- Proof of possession. Registering a key needs a signature over a fresh single-use challenge naming this server and your account. Private keys are refused. A key belongs to one account.
- Every request signed. A bound token needs an RFC 9421 signature (tag
leash-pop) covering the method, the full URL and, with a body, its SHA-256 digest and content type; a window of 5 minutes at most; a nonce accepted once. Without it the call is refused before the policy, before the token's rate limit, and before anything reaches the provider. - No downgrade. A bound token never accepts an unsigned request, and a Web Bot Auth signature is not accepted in its place. The proof text, receipts, checkpoints and request signatures each start differently, so a signature made for one can never pass as another.
- On the record. Your audit entry names the agent key behind each signed call; the public receipt says a registered key signed it.
One host
- One origin, one RP ID. Every passkey ceremony and every write check uses counterseal.gautamkhosla.com and nothing else; a client never names an RP ID, and a challenge only works on the host that issued it.
- Host-only cookies, same-origin writes. Sessions are
__Host-cookies; a write is accepted only with an Origin of counterseal.gautamkhosla.com. - The retired address. leash.gautamkhosla.com answers every request with a 301 to the same path on counterseal.gautamkhosla.com (with HSTS,
nosniff, no referrer and a deny-all CSP) and serves, signs in and proxies nothing.
Signing in the command line
- The code is typed, never linked.
counterseal loginshows a code and a plain address. You type the code in the app yourself; the app never takes a code from a link and warns when a link tries. To phish a sign-in, someone has to get you to type a stranger's code by hand, on a page that tells you not to. - You see what you grant, and from where. Before your passkey, the app shows what the command line will be able to do, when the sign-in started, and the country and network it started from next to yours, with a warning when they differ. "No, deny it" comes first.
- Small and short. A code works once, for 5 minutes. A command line session lasts 7 days, and a token, receipts key or guard key it makes ends with it. Sign-in starts (10 an hour per network), code lookups (10 per 10 minutes per account) and approvals (10 an hour per account) are limited.
- Seen and undone. Every sign-in is in your audit log and wakes your devices with "A command line was signed in"; one tap revokes it and every token, agent key, receipts key and guard key it made, even after it signed itself out.
Controls, mapped to OWASP
The full self-assessment against OWASP ASVS 5.0, the Top 10 2025, the API Security Top 10 2023, the Top 10 for LLM Applications 2025 and the Top 10 for Agentic Applications 2026, with what is not met yet, is on the security standards page. Self-assessed, not certified.
| Area | Control | OWASP |
|---|---|---|
| Sign-in | Passkeys only (WebAuthn, user verification required, origin and RP ID checked, single-use 5-minute challenges, signature counters); removing a passkey needs a fresh passkey check and signs out your other browsers | A07 |
| Sessions | __Host- cookie, HttpOnly, Secure, SameSite=Strict, 12 h at most and 1 h without an action of yours; CLI sessions are bearer tokens stored only as SHA-256 hashes, last 7 days, and can be revoked in the app with everything they made | A07 |
| CSRF | Web writes require the same Origin and an x-leash: 1 header; CORS only on the public log's read endpoints | A01 |
| Authorization | Every query is scoped by account; another account's holds, tokens, keys, agent keys and receipts return 404 (tested) | A01, API1 |
| Privilege separation | CLI sessions can mint and revoke tokens, deny holds, make and revoke guard keys, revoke deploy gates, freeze the account, and make agent keys and receipts keys, but cannot approve holds, make a deploy gate, pre-approve irreversible operations, set money caps, reset a tripped breaker, unfreeze, delete vaulted keys, revoke agent keys, turn public receipts on or off, add alert devices, remove passkeys, or approve or revoke command line sign-ins | A01, A06 |
| Secrets | Envelope encryption with AAD per row; no API returns a secret; tokens, sessions and receipts keys stored as hashes | A04 |
| Injection | All SQL is parameterized; request bodies are size-capped while streaming and parsed as JSON only | A05 |
| SSRF | Upstream hosts fixed per provider; no redirects; push endpoints limited to known push services; the checker's directory fetch only reaches https public host names on port 443, the fixed well-known path, no redirects, 5 s, 64 KB | A01, API7 |
| Rate limits | Per token (policy, default 120 a minute), per account, per IP (IPv6 by /64, salted hash), per receipts key; receipts quotas and a global daily cap; at most 20 waiting holds per token and 100 per account; ceilings per account (50 vault keys, 200 live tokens, 20 receipts keys, 20 agent keys, 10 alert devices); policy globs limited so they cannot backtrack | API4 |
| Headers | Strict CSP with Trusted Types, no innerHTML anywhere, HSTS, COOP, CORP, X-Frame-Options: DENY, Permissions-Policy, self-hosted fonts, no third-party requests | A02 |
| Supply chain | Zero runtime dependencies in the Worker, the CLI and the SDK; GitHub Actions pinned by SHA | A03, A08 |
| Integrity | Hash-chained audit log with a verify endpoint; RFC 6962 log with signed checkpoints; approvals bound to the exact request | A08 |
| Logging | Security events (bad tokens, signatures, CSRF, replays, rate limits) and errors are written only to the live log stream, with the code, method and redacted path: never IPs, keys, cookies, tokens or bodies. Workers Logs are off, so none of it is stored | A09 |
| Errors | Clients get a generic 500 that leaks nothing; a broken log never blocks a broker decision | A10 |
| Agents | Excessive agency is the threat this exists for: irreversible calls are held outside the model; the MCP tool tells the agent not to route around holds | LLM06 |
What Counterseal does not protect against
- A human approving something they shouldn't. The approval screen shows exactly what will happen and why. Read it.
- Approving a command line sign-in someone sent you. The screen shows where it started and asks whether you ran it; the alert and one-tap revoke limit the damage, and the session cannot approve holds, but a person who approves anyway has signed that command line in. Country and network checks can be fooled by a VPN.
- An agent getting around its guard. The coding-agent guard runs on the agent's machine and matches a map of commands. It does not stop an agent that edits its hook settings, wraps a command in a script, or runs something the map does not know.
- A workflow that drops its gate. A deploy gate is a pause the job asks for: a person who can edit the workflow file can remove the step. Protect the file, or hand the deploy key out through the gate so there is nothing to deploy with before an approval.
- An over-scoped key used outside the broker. Rotate the keys you give the vault and keep them only there.
- Calls the map doesn't know are irreversible. The map is conservative: every DELETE is held on GitHub, Cloudflare, Stripe, Supabase, Vercel, Neon, Fly.io and Resend, every Railway mutation except redeploy and restart, and SQL unless it is plainly read-only. Management APIs only: SQL sent straight over a database connection string never passes through Counterseal. Add
holdrules to your policy for anything else you care about. - A compromised agent machine. An agent key on a machine an attacker controls signs for the attacker. Revoke the key; the bound tokens stop at once.
- Claims in reported receipts. A receipt an agent sends proves it was recorded, when, and (if signed) by which key; not that what it says is true. Broker receipts are the broker's own record.
- The log operator. One key signs the checkpoints. A log that showed two different histories would be caught by anyone comparing checkpoints, but nothing yet prevents it; independent witness cosigning is on the roadmap.
- A compromised Cloudflare account. The master key and the log key live in Cloudflare's secret store.
Report a vulnerability
Email security@gautamkhosla.com. Please don't open a public issue. You'll hear back within 7 days. Good-faith research is welcome; don't access other people's data or degrade the service. Our security.txt has the same details.