Docs
Counterseal documentation
Three things, one service. Approve: irreversible calls wait for your passkey. Record: every decision becomes a receipt in a public log anyone can verify. Prove who: an agent signs its own requests, so a stolen token alone is useless.
Quickstart
- Create an account at /app with a passkey. No password or email.
- Sign in the CLI. It shows a code; open /app#device, type the code, check where the sign-in started, and approve with your passkey. Never type a code someone sent you:
npx counterseal login
- Vault a key. Paste it once; it is encrypted in the vault and never saved on your machine:
npx counterseal add github prod
- Mint a token for your agent (or do it in the app under Agent tokens):
npx counterseal creds # find the key id npx counterseal token <key-id> --label coding-agent
- Connect your agent. Add the MCP server to your agent's config (see below).
counterseal; leashcli, the product's earlier name, keeps working as an alias that runs it; it is the same service./app, /v1, /p) with a permanent redirect (301) to the same path here and serves nothing itself. Point agents and command lines at counterseal.gautamkhosla.com: leashcli 0.1.1 does not follow redirects. Passkeys made on the earlier address do not work here; make a new account.Concepts
| Term | What it is |
|---|---|
| Vault | Where your real API keys live, encrypted. Keys go in and never come out. |
| Agent token | An lsh_ token tied to one vaulted key, with a policy and an expiry (7 days by default, up to 90; one minted from the command line ends with its sign-in). This is all your agent ever sees. |
| Irreversible map | A versioned list, per provider, of calls that can't be undone. Calls on it are held. |
| Hold | A call the broker stopped, or a command a coding agent's guard asked about, waiting on you to approve or deny. |
| Guard key | An lsg_ key the guard on a coding agent's machine uses to ask for approvals. It can ask and read the answer; it can never approve. |
| Approval | Your passkey signing off on one held request. It lets that exact request through once, within ten minutes. |
| Audit log | Your private, append-only, hash-chained record of every write, hold and decision on your account. |
| Receipt | A public entry in the log for one decision: method and provider host, the decision, the rule, the time, and the hash of your audit entry. Never the path or the body. |
| Agent key | An Ed25519 key pair your agent holds. You register the public half; tokens bound to it only work on requests it signed. |
Connecting your agent (MCP)
npx counterseal mcp runs a local MCP server that gives your agent one request tool, leash_request, that can call every provider in LEASH_TOKENS. Add it to any MCP client's server config. List several providers separated by commas:
{
"mcpServers": {
"leash": {
"command": "npx",
"args": ["-y", "counterseal", "mcp"],
"env": { "LEASH_TOKENS": "github=lsh_aaa,stripe=lsh_bbb" }
}
}
}
The tool tells the agent that held calls need a human, so a well-behaved agent stops and gives you the approval link instead of looking for another way in. For an API that takes a form instead of JSON (Stripe), the tool has a form field next to body: {"charge": "ch_...", "amount": 2500} is sent as application/x-www-form-urlencoded (from leashcli 0.3.0).
Calling through the proxy
Any agent, script or SDK can call the broker directly. Swap the provider's base URL for the proxy and the real key for your token:
https://counterseal.gautamkhosla.com/p/<provider>/<path> Authorization: Bearer lsh_...
curl https://counterseal.gautamkhosla.com/p/github/repos/acme/app/pulls \ -H "Authorization: Bearer lsh_..."
The broker checks the call, swaps your token for the real key, forwards it to the provider and returns the response with an x-leash-decision header (allowed or approved). Upstream cookies, CORS grants and browser policies are dropped, the answer is marked no-store with a sandboxing CSP, and an answer over 10 MB is refused. Redirects are never followed.
When a call is held
A held call returns HTTP 428:
{
"error": "held_for_approval",
"hold_id": "4f2a...",
"approve_url": "https://counterseal.gautamkhosla.com/app#hold=4f2a...",
"rule": "rw.mutation",
"message": "Counterseal held this request because it cannot be undone..."
}
Open the link, check the method, host, path, reason and the request preview, and approve with your passkey. Then retry the exact same request once. Turn on phone alerts in the app, or run npx counterseal watch for a terminal bell and link for every new hold (leashcli 0.1.1 calls leash.gautamkhosla.com, which now only redirects, and it does not follow redirects).
Approvals for a coding agent's commands
Some irreversible actions never pass through an API the broker holds a key for: terraform destroy, railway volume delete, gh repo delete, prisma migrate reset, DROP TABLE through psql, git push --force. For those, a guard on the agent's machine asks the Approvals API with a guard key and waits for your passkey:
curl -s https://counterseal.gautamkhosla.com/v1/approvals \
-H "authorization: Bearer lsg_..." -H 'content-type: application/json' \
-d '{"kind":"shell","agent":"claude-code","rule":"sh.terraform-destroy",
"why":"Destroys every resource in the state.","summary":"terraform destroy",
"detail":"terraform destroy -auto-approve","context":{"cwd":"infra"}}'
# 202 {"id":"...","status":"pending","hash":"...","approve_url":"...","expires_at":...}
curl -s "https://counterseal.gautamkhosla.com/v1/approvals/<id>?wait=20" -H "authorization: Bearer lsg_..."
# {"status":"approved"} or "denied" (with "deny_reason"), "expired", "pending"
curl -s https://counterseal.gautamkhosla.com/v1/approvals/<id>/use \
-H "authorization: Bearer lsg_..." -H 'content-type: application/json' -d '{"hash":"<hash>"}'
# 200: run it now, once
- One exact command, once. An approval covers that command with its agent and folder name, once, within 10 minutes. Asking again for the same command while it waits does not send a second alert.
- Deny with a reason. The guard hands your reason back to the agent (200 characters at most), so "not today, use staging" reaches it.
- Guard keys only ask. A guard key can ask and read its own approvals; approving always needs your passkey in the app. Make one under Account, Guard keys, or from a signed-in CLI, where it ends with that sign-in. Each key can ask for 60 new approvals an hour, with 20 waiting at a time.
- A seatbelt, not a sandbox. The guard runs on the agent's machine and matches a map of commands; an agent that edits its hook settings or wraps a command in a script is not stopped by it. Commands not on the map are left to your agent's own permission settings. For a hard stop, give your agent a Counterseal token instead of the real key.
- Receipts. A guard decision's public receipt says only
RUN claude-code(orCALL mcpfor a tool call), the decision, the rule and the map version. Never the command, folder or key label.
Fields: kind (shell or tool), agent (claude-code, codex, cursor, copilot, gemini, antigravity or mcp), rule, why (up to 300 characters), summary (up to 200), detail (the exact command or canonical tool call, up to 4,096), optional context (cwd, the folder's name only; session) and map (the guard's map version). The full contract is in docs/PROTOCOL.md.
Guard your coding agent
counterseal guard puts a hook in front of your coding agent's shell commands. A command on the shell map waits for your passkey; every other command is left to the agent's own permission settings. It is in this repository (CLI 0.3.0, coming to npm), not on npm yet: until then run node cli/leash.mjs guard ... from a clone.
npx counterseal login # type its code at /app#device, check where it started, approve npx counterseal guard install # finds Claude Code, Codex and Cursor; or --agents claude-code,codex,cursor npx counterseal guard check -- terraform destroy # what the map says, offline npx counterseal guard status # what is installed, and whether the key works
Claude Code plugin. The same hook as a plugin, from this repository's marketplace file (no form):
claude plugin marketplace add GautamTalksDev/counterseal claude plugin install counterseal@counterseal npx counterseal guard install --agents none # makes the key the plugin's hook uses
The plugin needs node on your PATH and runs its own pinned copy of the guard, never npx. The plugin sets the hook to block the command if the hook itself crashes or times out; Claude Code 2.1.295 or later honours that, and older versions let such a command through. Claude Code may say one plugin option is not set: it is only for the optional MCP server and can be left empty. If you use the plugin, guard install adds no second hook to Claude Code (two would run, and the second would deny a command you approved).
What happens
- The agent runs
terraform destroy -auto-approve. The hook reads it, finds the rule, and asks the Approvals API (above) with the command, the rule, why, and the name of the folder. - The command waits (up to 9 minutes by default). Your phone shows the exact command; you approve with a passkey, or deny with a reason.
- Approved: the hook uses the approval once and the command runs. Denied: the agent is told, in quotes, that its human said no and why. Nobody answers: the agent is told to ask you, and asking again is the same hold.
Agents whose hook cannot wait (or if you set mode to retry) get the denial at once with the link, and run exactly the same command again after you approve it.
On a team that requires two seals, a held command stays pending until two different people have approved it, each with their own passkey. The hook keeps waiting, prints how far it is (for example "1 of 2 approvals so far") and, if the time runs out, tells the agent that a second person has to approve it too. A guard key can be revoked only by the person who made it or by an owner.
What is held (shell map 2026-10-10.1)
| Rule | Holds |
|---|---|
sh.terraform-destroy | terraform or tofu: destroy, apply -destroy, workspace delete (also terragrunt) |
sh.pulumi-destroy | pulumi: destroy, stack rm, state delete |
sh.iac-destroy | cdk, cdktf, sst, serverless, sam, amplify: destroy, remove, delete |
sh.railway | railway: delete, down, volume delete, environment delete |
sh.fly | fly: apps destroy, volumes destroy, machine destroy |
sh.vercel | vercel: remove, rm, project rm, env rm, domains rm, alias rm |
sh.wrangler | wrangler: delete, d1 delete, r2 bucket delete, kv namespace delete, d1 execute --remote with SQL that is not a plain read |
sh.supabase | supabase: db reset (local too), projects delete, db push, migration repair |
sh.neon | neonctl: projects delete, branches delete, restore or reset |
sh.prisma | prisma: migrate reset, db push --accept-data-loss or --force-reset, db execute, any --shadow-database-url |
sh.drizzle | drizzle-kit: push, drop |
sh.sql-cli | psql, mysql, mariadb, sqlite3, turso and other SQL clients: DROP, TRUNCATE, DELETE or UPDATE without WHERE, ALTER ... DROP, SQL from a file; any write to a database on another machine |
sh.db-reset | dropdb, rails db:drop and db:reset, manage.py flush, artisan migrate:fresh, redis-cli FLUSHALL, pg_restore --clean and other tools that empty or drop a database |
sh.git-force | git push with --force, -f, --force-with-lease, a +refspec, --mirror, --delete or :branch |
sh.git-history | git filter-branch, filter-repo, reflog expire, gc --prune=now, prune, update-ref -d |
sh.git-discard | git reset --hard, clean -f, checkout or restore of the whole tree, checkout --force |
sh.gh | gh: repo delete, archive, rename, edit --visibility, release delete, secret set or delete, any delete, api -X DELETE, PATCH or PUT on the GitHub map |
sh.k8s | kubectl delete, drain, replace --force, apply --prune; helm uninstall or delete; argocd app delete |
sh.docker | docker: volume rm or prune, system prune, container prune, rm -v, rm of every container, compose down -v |
sh.cloud | aws: delete-*, terminate-*, remove-*, deregister-*, s3 rm --recursive, s3 rb; gcloud, az, doctl, heroku, firebase and other cloud CLIs: delete, destroy |
sh.stripe | stripe CLI: refunds create, payouts create, transfers create, subscriptions cancel, any delete, post or delete on the Stripe map |
sh.npm | npm, pnpm, yarn: unpublish, deprecate, owner rm, dist-tag rm, access revoke, token revoke |
sh.rm | rm -r on /, ~, the project root, .git, a variable that may be empty or a path outside the project; find -delete; shred; rsync --delete; chmod or chown -R on /; crontab -r |
sh.disk | dd of=/dev/..., mkfs, wipefs, blkdiscard, diskutil erase, and writes to a disk device |
sh.curl-api | curl, wget, httpie with a write method to a host on the API map, matched against that provider's rules |
sh.dynamic-program | a program name built at run time (a variable or a substitution) in a command that contains destroy, delete, drop, truncate, reset, force or rm |
sh.decode-exec | decoded text (base64, xxd, gunzip, openssl) piped into a shell, an interpreter or eval; held without being read |
The guard reads commands the way a shell does: quotes, escapes, ; && |, groups, loops, here-documents, $(...), brace expansion, simple variables on the same line, cd, and these wrappers: sudo, env, nohup, time, xargs, find -exec, sh -c and bash -lc, eval, ssh, docker exec, kubectl exec, npx and its relatives, and the scripts in the project's package.json. MCP tool calls (matcher mcp__.*) are held when the tool name says it deletes, drops, sends, pays or deploys, or when the call carries SQL that is not a plain read; a policy file ~/.config/leash/mcp/<server>.json can add hold and allow patterns. Tool annotations such as readOnlyHint are never trusted.
What it does not see
- Aliases and shell functions defined outside the command line, and variables set in an earlier command.
- The contents of script files (
bash deploy.sh,./run.sh,make), even one the same command line writes, a script downloaded and piped into a shell (curl ... | sh), and code in other languages (python -c,node -e,perl -e). The shell map is for what a shell is asked to run. - PowerShell and
cmd. On Windows, use Git Bash or WSL. - Encoded payloads: a decoder piped into a shell is held, but the payload is not decoded or classified.
- Globs stay unexpanded, symlinks are not followed, and shells nested more than three deep are scanned piece by piece.
- A command it cannot parse is scanned piece by piece. If that finds nothing it passes; set
"unreadable": "hold"to hold it. - An agent that edits its own hook settings, the guard's files or its config, or runs the same effect through a script, is not stopped.
counterseal guard statusnotices a changed script. For a hard stop, give the agent a Counterseal token instead of the real key.
If something goes wrong
By default the guard fails closed: if the server cannot be reached, answers something unexpected, or the key is no longer valid, a command on the map is denied with the fix, and every other command runs as before. A hook request times out after 10 seconds. Set "onUnavailable": "allow" in ~/.config/leash/guard.json to let commands on the map through when the server cannot be reached; a revoked key, a rate limit and Freeze still deny. When the account is frozen the server answers 423 to asking, waiting and using: the agent is told your human froze the account, not to retry and not to look for another way, and a wait already in progress learns of it when its current long poll ends (20 seconds at most). A key made by counterseal guard install ends with that command line sign-in (7 days at most): make a lasting one in the app (Account, Guard keys) and run counterseal guard install --paste-key.
Setting in guard.json | Default | Meaning |
|---|---|---|
onUnavailable | deny | deny or allow, when the server cannot be reached |
waitSeconds | 540 | how long the hook waits for the passkey (at most 590; Claude Code's hook limit is set to 660) |
mode | wait | per agent: wait holds the command at the hook; retry denies at once and the agent runs it again after approval |
disableRules | none | rule ids to leave out, for example ["sh.git-discard"] (an agent that can edit this file can do the same) |
unreadable | allow | what to do with a command that cannot be parsed and has nothing on the map in it |
cursorPassThrough | allow | Cursor's hook must answer for every command: allow or ask |
Which agents
| Agent | How | Status |
|---|---|---|
| Claude Code | plugin, or a PreToolUse hook in ~/.claude/settings.json (Bash and mcp__.*) | Follows the documented hook format; the plugin passes claude plugin validate --strict and installs from the marketplace file with Claude Code 2.1.293. Not yet seen firing in a live session. |
| Codex | ~/.codex/hooks.json, Bash only. Open /hooks once to trust it. | Written to the documented format. Not tested in Codex yet. |
| Cursor | ~/.cursor/hooks.json (beforeShellExecution, beforeMCPExecution, fail closed) | Written to the documented format. Not tested in Cursor yet. Whether Cursor still asks you about a command the hook answers allow to is not documented; cursorPassThrough: "ask" is the careful choice. |
| Copilot CLI, Gemini CLI, Antigravity | Not supported yet: their hooks have not been tested with the guard. | |
What leaves your machine: the command text with credentials removed (connection strings, bearer tokens, key-shaped values, PASSWORD= style variables), the rule, why, the name of the folder (not its path) and the session id. Held commands are shown on your devices and may appear on the lock screen.
Deploy gates for GitHub Actions
Hold any GitHub Actions job until you approve it with your passkey. GitHub's own deployment approvals (required reviewers, wait timers, custom protection rules) are available only for public repositories on the Free, Pro and Team plans, according to GitHub's documentation (read 9 Oct 2026). A gate does not use them: the job's step sends the OIDC token GitHub signs for it, Counterseal checks the signature and what the token says about the run, and your phone shows the repository, workflow, commit, run and who started it, from that token.
permissions:
id-token: write # lets the job ask GitHub for the token the gate checks
contents: read
steps:
- uses: ./.github/actions/counterseal-gate # action/deploy-gate, copied into your repository
with:
gate: gate_0123456789abcdef01234567 # from the app, Deploy gates; not a secret
summary: deploy ${{ github.sha }} to production
timeout-minutes: 15
- Make the gate first. In the app, Deploy gates, New gate (it asks for your passkey): the repository, its workflow files and the branches or tags it deploys from; optionally environments and triggers. Nothing is accepted by default, pull requests are never offered, and a gate cannot be edited (make a new one and revoke the old).
- One run, once. The approval is bound to GitHub's repository and owner ids, run, attempt, commit, workflow, ref and environment. The step fails if you deny, if nobody decides within
timeout-minutes, or if the run is not what the gate accepts. A cancelled job withdraws its request. - The first approved run binds the gate to the repository's GitHub ids, so a repository deleted and re-created under the same name cannot use it.
- No approval, no key. A gate can name a key in your vault and a policy. The job then has no deploy key until you approve; on approval it gets a Counterseal token for that key (15 minutes, under the policy; the real key never leaves the vault) in the step's
tokenoutput. - A pause the job asks for. A person who can edit the workflow file can remove the step. Protect the file (branch protection, CODEOWNERS), or use the key option.
- Receipts. A gate decision's public receipt says only
DEPLOY github.com, the decision and the rule; never the repository unless you chose that for the gate. - Not yet run against real GitHub tokens. The action is in the Counterseal repository (
action/deploy-gate), not on the Marketplace, and its tests use a stand-in that signs like GitHub.
Usage, what is checked, limits and errors: docs/DEPLOY_GATE.md. The contract is in docs/PROTOCOL.md.
Any MCP server behind Counterseal
counterseal mcp wrap sits between your MCP client and a local (stdio) MCP server. Destructive tool calls wait for your passkey, and the server's tool definitions are pinned, so a tool the server changes after you approved it is hidden until you look. It is in the repository and not yet published (CLI 0.3.0). Put it in front of the server's own command:
{ "mcpServers": { "supabase": {
"command": "npx",
"args": ["-y", "counterseal", "mcp", "wrap", "--name", "supabase", "--",
"npx", "-y", "@supabase/mcp-server-supabase@latest"],
"env": { "LEASH_GUARD_KEY": "lsg_..." } } } }
- What is held. A tool whose name says it deletes, sends, pays, deploys, drops, resets or forces something; a tool that runs a command; a SQL argument that is not one plain read; anything your policy file lists. Reads go straight through.
- How it works. The wrapper answers a held call at once, with the approval link. You approve with your passkey; the agent calls the same tool with the same arguments again and it goes through, once. Other arguments are another approval. Public receipts say only
CALL mcp, the decision, the rule and the map version. - Pinning. The first tool list your client asks for is pinned (trust on first use), with mcp-pin's published tool definition hash. A changed or new tool, or changed instructions, never reach the model: the tool shows as blocked, calls to it are held, and
counterseal mcp pin --name supabaseshows you what changed before you accept it.counterseal mcp statusandcounterseal mcp unpinshow and forget the pin. - Policy file
~/.config/leash/mcp/<name>.json:holdandallow(tool name patterns; allow never lifts the SQL rule),sqlArgs,trustAnnotationsandrepin(terminalorpasskey). A typo stops the wrapper before it starts. - Needs a guard key (Account, Guard keys) in
LEASH_GUARD_KEYorguard.json. Without one, or with no network, a revoked key or a frozen account, a held call is refused and the agent is told why; it never runs. The server you wrap does not get the key. - A seatbelt, not a sandbox. It runs on the agent's machine and sees only what the client sends through it. An agent that starts the server another way, edits your MCP configuration or the pin and policy files, or calls a tool the lists do not name is not stopped. Names and arguments are matched against lists, not understood. Prompts, resources and tool results are not checked, and remote (HTTP) servers are not supported. For a hard stop, give your agent a Counterseal token instead of the real key.
Token policies
Each token can carry a policy that narrows what its key can do. A policy can never widen it.
{
"allow": [{ "method": "GET", "path": "/repos/acme/**" }],
"deny": [{ "path": "/orgs/**" }],
"hold": [{ "method": "POST", "path": "/repos/*/*/releases" }],
"default": "allow",
"readOnly": false,
"perMinute": 120,
"unattendedIrreversible": []
}
| Field | Meaning |
|---|---|
allow | If present, only these calls pass. |
deny | Always refused with 403. Checked first. |
hold | Held for approval even if the map doesn't require it. |
default | allow, hold or deny for calls that match no rule. With an allow list, allow refuses what is not on it. |
readOnly | Only GET and HEAD. |
perMinute | Rate limit for this token, 1 to 600. Default 120. |
unattendedIrreversible | Irreversible calls allowed without approval. Can only be set with your passkey, in the app. |
limits | The breaker: writesPerHour (1 to 5000) and repeatsPer5Min (2 to 100). Any session may set it. See Brakes. |
money | Money caps for Stripe refunds and payouts: currency, perCall, perDay. Can only be set with your passkey. See Brakes. |
A rule is { "method": "GET", "path": "/glob" }. * matches one path segment, ** any depth, {a,b} alternatives. Paths are relative to the provider's API base. Evaluation order: deny, readOnly, the irreversible map, hold, allow, default.
Recipes
Read-only access to one GitHub org:
{ "readOnly": true, "allow": [{ "path": "/repos/acme/**" }, { "path": "/orgs/acme/**" }] }
Hold every write to Stripe, not just the money-moving ones:
{ "allow": [{ "method": "GET", "path": "/v1/**" }], "default": "hold" }
Brakes: money caps, a breaker and Freeze
Three ways to keep an agent small. They cover calls that go through Counterseal, not LLM tokens, and not SQL sent straight over a database connection.
Money caps
Let a support agent refund small amounts without asking and wait for your passkey above that. Set money when you make the token with your passkey (in the app: Create with passkey). Amounts are whole numbers of the currency's smallest unit, as Stripe counts them: 5000 is 50.00 USD, 5,000 JPY or 50.000 KWD.
{ "money": { "currency": "usd", "perCall": 5000, "perDay": 20000 } }
A POST /v1/refunds or POST /v1/payouts passes without asking when it is a plain form, its amount is a whole number written the usual way, it is in that currency, it is at most perCall, and the day's total (UTC) stays at most perDay. For a refund, Counterseal asks Stripe, with your key, what currency the charge is in and what is left of it; a refund without amount counts as everything left. Anything else is held, with the arithmetic in the preview and the amount in large type:
| Rule id | Held because |
|---|---|
brake.money-unreadable | The amount or currency cannot be read with certainty (a repeated, escaped or oddly spelled parameter, a raw semicolon or space in the body, a query string, a JSON body, an amount that is not a plain whole number, a charge Stripe did not answer for). |
brake.money-currency | The call is in another currency than the caps. |
brake.money-per-call | Over perCall. |
brake.money-per-day | Today's total plus this call would be over perDay. |
A call that passes is recorded with the rule st.money:capped; its amount is in your audit log and never in the public receipt. Transfers, charges, top-ups, confirms and captures, and anything not spelled exactly /v1/refunds or /v1/payouts, stay held. What you approve is yours and is not counted against the day. When Stripe refuses a call (a 4xx) the amount is handed back; after a 5xx or no answer it stays counted.
Breaker
{ "limits": { "writesPerHour": 200, "repeatsPer5Min": 3 } }
Any session may set limits. A write is any request that is not a GET or HEAD. When a token makes more than writesPerHour writes in an hour, or sends the same exact request (method, path, query, content type and body) more than repeatsPer5Min times in 5 minutes, it trips: that write and every later one is held with rule brake.tripped until you reset the token with your passkey (Agent tokens, Reset), and reads keep passing. Your devices are told once. A token with neither limit costs nothing extra.
Freeze
The Freeze bar at the top of the app, leash freeze, or the Freeze button on a phone alert stops every token of the account at once: each call answers 423 account_frozen, a coding agent's guard is stopped too, approvals nobody used yet are void, and approving waits. Any signed-in session can freeze. Only your passkey, in the app, unfreezes.
Teams: the two-person seal
In the repository, not yet on the live service. An account can have several people, each with their own passkey, and a held call or command can need two of them.
- Invite. In the app, Team, an owner makes an invite with their passkey: a role, who it is for, and how long it works (2 hours by default, 24 at most). The link works once. Send it privately; it shows the person your account, your name and the role before they make their own passkey. Every member's devices are told when someone joins.
- Roles. Owner (the team, the seals needed, the vault, deleting the account), approver (seals held actions), member (makes tokens and keys for their agents, cannot seal), viewer (reads, can deny). Counterseal checks the role on every request. A command line sign-in gets the role of the person who approved it.
- Two seals. With two seals, a held action goes ahead only when two different owners or approvers have each approved it with their own passkey. The card shows "1 of 2 seals" and who sealed. One person counts once, whatever their passkeys or browsers. You can also require that the person whose token or guard key asked is not one of them. Anyone can deny: one no ends it.
- Lowering waits. Raising the seals needed applies at once, to waiting actions too. Lowering them, or letting the person who asked count, applies 24 hours later, tells every member's devices and shows a banner in the app; any owner or approver can cancel it. While two seals are needed, a new approver's seal counts 24 hours after they join, and pre-approvals and money caps are paused.
- Leaving. Removing someone signs them out everywhere, removes their passkeys and alert devices, revokes the tokens and keys they made, drops their seals and voids approvals they helped give that are not yet used, at once. Making someone a viewer revokes what they made. Only its maker or an owner can revoke a token or key. Anyone can leave. The last owner cannot be removed.
- Public receipts of an approval that needed two seals say
"quorum": 2, "votes": 2inmeta, never who. - Break glass. When the second approver cannot be reached, an owner or approver can open a window of 5 to 60 minutes with a passkey. While it is open, what Counterseal would hold for the tokens and guard keys you pick goes through, every call it lets through is on the record and its public receipt says
breakglass, and every member and channel is told at once. It cannot be extended, three a day at most, and any member can close it. It never lifts a freeze, a tripped breaker or a money cap, and never applies to deploy gates. - Slack, Discord and Google Chat. An owner can connect a channel with a passkey (the webhook address is kept encrypted and not shown again). It is told when something is held or part-sealed, when break glass opens, and when the team changes: who asked, the rule, how many seals and the link; never the path, command, body, amount or a name. Only a passkey in the app approves.
Two people who agree to act together are not stopped: two seals mean two passkeys of two people on your team.
Providers and the map
The irreversible map covers 10 APIs: GitHub, Cloudflare, Railway, Stripe, Supabase, Vercel, Neon, Fly.io, Resend and Shopify. Management APIs only; SQL sent straight over a database connection string never passes through Counterseal. The current irreversible map, with the reason for every rule, is on the homepage and at GET /v1/meta. Every DELETE is held on GitHub, Cloudflare, Stripe, Supabase, Vercel, Neon, Fly.io and Resend, and every Railway mutation is held unless every field is a redeploy or restart. Vercel, Neon and Fly.io hold production promotions and rollbacks, restores that overwrite data, secret and token changes, and access grants; Resend holds batch sends, broadcasts and any email to more than one recipient. Shopify is brokered through its Admin GraphQL endpoint only, at the store address you give when you add the key (your-store.myshopify.com, checked strictly), and holds mutations that delete, move money, cancel, change gift cards or customer data, or cannot be read. Also held: force pushes, repo transfers, Stripe refunds and payouts, Cloudflare purges of everything, a whole host or a URL prefix, and any SQL on Supabase or D1 that is not a single plain read (SELECT, WITH ... SELECT, EXPLAIN without ANALYZE, SHOW), including Supabase migrations and D1 imports. Paths are matched in one canonical form (no empty segments, no ; path parameters, escapes of plain letters decoded, one trailing slash dropped) and that canonical path is sent upstream. Rules match it without regard to case, the map also ignores a format suffix such as .json, and a HEAD counts as a GET. A body the broker cannot read one way on a route with a body rule is held: a watched parameter that appears twice, in both the body and the query string, or as a case variant, and JSON with repeated keys. A request that carries a method override (_method) is held on every provider and cannot be pre-approved.
Receipts: the public log
If it was recorded, you can prove it. When receipts are on, every decision the broker makes on a write becomes an entry in a public, append-only Merkle log (RFC 6962), sealed by a signed checkpoint (C2SP signed note, Ed25519): an allowed write, a hold, an approval, a denial, and the approved call going through. Allowed reads (GET and HEAD) are not recorded; a read that is held or denied is.
A broker receipt holds only this, and it is public and permanent:
{ "v": 2, "source": "broker", "submitter": "<salted commitment>", "received_at": 1791300000000,
"receipt": { "agent": "broker", "action": "DELETE api.github.com", "decision": "held",
"parent": "<SHA-256 of your audit entry>", "ts": 1791300000000,
"meta": { "map": "2026-10-09.1", "rule": "gh.delete", "agent_key": true } } }
No path, query string, body, key, token, account id, name, email or IP address is ever in a receipt. parent is the hash of the matching entry in your private audit log, so you can open any receipt later with your audit log and prove exactly what happened, which agent key made the call (agent_key says one did) and which passkey approved it (passkey). source: "broker" sits outside the receipt and can only be written by the broker itself, so nobody can forge a broker receipt through the receipts API.
- On or off. New accounts start with receipts on. Accounts made before receipts existed keep them off until you turn them on in the app. Turning them off needs a fresh passkey check and is itself written to your audit log.
- Limits. 10,000 receipts per account per month, 2,000 per account per UTC day, and 10,000 appends per UTC day for the whole log (it runs on Cloudflare's free plan). When a limit is reached the broker still decides and still writes your audit log; the receipt is skipped and counted as skipped in the app.
- Your bundles. The app lists your receipts and downloads a bundle for each (the entry, its inclusion proof and a signed checkpoint). A bundle verifies offline, with no account and no network. Your copy carries an opening (account id, key id and salt) that proves the entry is yours; downloads leave it out unless you keep it, and you should not publish bundles that carry it.
Agents logging their own actions
An agent can also record what it did, with a receipts API key (lsr_, made in the app under Receipts):
curl -s https://counterseal.gautamkhosla.com/v1/receipts \
-H "authorization: Bearer lsr_..." -H 'content-type: application/json' \
-d '{"receipt":{"agent":"deploy-agent","action":"deploy.prod v2.4.1","decision":"executed"}}'
Fields: agent and action (required, up to 80 characters), subject (up to 120), decision (allowed, denied, held, approved, executed, failed), input_sha256, output_sha256, parent, run (up to 80), ts (Unix ms), meta (up to 8 keys, values up to 120 characters). No control, bidi or invisible characters; unknown fields are refused. Up to 25 receipts per call, or 12 for a key that requires signatures. Everything you send is public: send hashes, not content. These entries carry no source field, so verifiers tell them apart from the broker's own.
Signed receipts. Bind a receipts key to an Ed25519 signer (one of your agent keys, or any key with a proof of possession) and the key only accepts receipts that signer signed over their canonical JSON, each with a ts within 10 minutes of the server clock, and each signature once.
Verifying a receipt
Open Verify a receipt and paste or open a bundle: it is checked in your browser against this log's key. Or offline with the SDK in this repository (sdk/receipts.mjs, zero dependencies):
node sdk/receipts.mjs verify bundle.json --key "<verifier key from GET /v1/log/key>"
Pin the verifier key: fetch GET /v1/log/key once and keep it with your auditors. A bundle's own log field is only a hint. Any two checkpoints can be linked with a consistency proof, so a log that rewrote history would be caught by anyone who kept an old checkpoint.
Agent keys: prove who
Give each agent its own Ed25519 key. You register the public half with a proof of possession; the private half never leaves the agent's machine. A token bound to the key then only works on requests that key signed, so a token copied from a log, a config file or a screen is useless on its own, and every decision on a signed request records which agent key made it.
npx counterseal agent-key agent-key.json --label laptop-agent # makes the key (file mode 600) and registers it npx counterseal token <key-id> --agent-key <agent-key-id> # a token bound to it
Then run the MCP server of leashcli 0.3.0 with LEASH_AGENT_KEY=agent-key.json next to LEASH_TOKENS; it signs every call. (0.3.0 is in the repository and not yet on npm: npx counterseal still runs 0.1.1, which cannot sign, so a bound token refuses its calls.) You can also make a key in the app (it is generated in your browser and downloaded; only the public half is sent). The CLI commands for agent keys arrive in CLI 0.3.0; until it is published, use the app or the signer in cli/sign.mjs.
The signature is an RFC 9421 HTTP Message Signature with this profile:
Signature-Input: sig1=("@method" "@target-uri" "content-digest" "content-type");created=1791300000;expires=1791300060;keyid="<JWK thumbprint>";alg="ed25519";nonce="<random>";tag="leash-pop"
Signature: sig1=:<base64 Ed25519 signature>:
Content-Digest: sha-256=:<base64 SHA-256 of the body>:
@methodand@target-uriare always covered, andcontent-digest(RFC 9530) andcontent-typewhenever there is a body. Only those plus@authority,@pathand@querymay be covered.- The window from
createdtoexpiresis 5 minutes at most. A nonce is required and accepted once per key. keyidis the RFC 7638 thumbprint of the registered key (RFC 8037 for Ed25519).- A bound token never falls back to unsigned requests. A Web Bot Auth signature (tag
web-bot-auth) is not accepted here. - Up to 20 active agent keys per account; a key belongs to one account. Revoking a key (web app only) stops every token bound to it at once. Small-order and non-canonical keys are refused.
Web Bot Auth checker
The free signature checker checks a Web Bot Auth request (draft-ietf-webbotauth-httpsig-protocol on RFC 9421) in your browser: paste the headers, fetch or paste the agent's key directory, and see whether the signature is valid. To verify on your own server, use the verifier in this repository (sdk/verifier.mjs, local by default, with SSRF-safe directory fetching and pluggable nonce stores).
CLI
| Command | What it does |
|---|---|
leash login | Sign in this machine for 7 days. You type the code it shows in the app and approve with your passkey. Tokens it mints end with the sign-in; revoke it in the app under Account. |
leash logout | End the session on the server (0.3.0) and forget it on this machine. Tokens it minted keep working until they end or you revoke the sign-in in the app. |
leash add <provider> [label] | Vault a key. Prompts for it; encrypted in the vault, never saved on your machine. |
leash creds | List vaulted keys (id, provider, label, last four). |
leash token <key-id> [--label x] [--ttl hours] [--policy file.json] | Mint an agent token. 0.3.0 adds --agent-key <id>. |
leash tokens | List active tokens. |
leash revoke <token-id> | Revoke a token immediately. |
leash freeze | Stop every agent token on the account at once. Only your passkey, in the app, unfreezes. From 0.3.0. |
leash holds | List calls waiting for you. |
leash watch | Print and ring for each new hold. |
leash mcp | Run the MCP server for your agent. |
leash agent-key [file], leash agent-keys | Make and register an agent key; list them. From 0.3.0. |
Run any command with npx counterseal, or install it globally with npm i -g leashcli.
API
Base URL https://counterseal.gautamkhosla.com. Account endpoints use your session (web cookie or CLI bearer). Web writes need the same Origin and an x-leash: 1 header. Endpoints marked passkey or web only work from the web app.
| Endpoint | Purpose |
|---|---|
GET /v1/meta | Providers and the irreversible map. Public. |
GET /v1/me | Your account, and your receipts numbers (on, used this month, today, skipped, quotas). |
GET, POST /v1/credentials | List or add vaulted keys. |
DELETE /v1/credentials/:id | Delete a key. Its tokens stop working. Web only. |
GET, POST /v1/tokens | List or mint agent tokens. agentKeyId binds the token to an agent key. |
POST /v1/tokens/passkey/begin, /finish | Mint a token with pre-approved irreversible operations. Passkey. |
DELETE /v1/tokens/:id | Revoke a token. |
POST /v1/tokens/:id/reset/begin, /finish | Reset a tripped breaker (409 not_tripped if it has not tripped). Passkey. Web only. |
POST /v1/me/freeze | Freeze the account: every lsh_ token is answered 423. Any signed-in session, the command line included. GET /v1/me shows account.frozen_at. |
POST /v1/me/unfreeze/begin, /finish | End a freeze (409 not_frozen if it is not frozen). Passkey. Web only. |
GET /v1/holds | Calls and commands waiting for you, each with its kind (proxy, shell, tool, deploy) and a preview of what it would do (for a call: query string, SQL or GraphQL, body, at most 2 KB; for a command: the whole command). A held Stripe refund or payout has a headline with its amount. |
POST /v1/holds/:id/approve/begin, /finish | Approve a hold (on a team, add your seal: the answer says approved, seals and needs). Passkey. Owners and approvers. |
GET /v1/team | The people, their roles, the seals needed and a waiting change, open invites, and who you are. |
POST /v1/team/invites/begin, /finish, DELETE /v1/team/invites/:id | Make an invite ({role, label, hours}; owner, passkey) or revoke one. |
POST /v1/invites/lookup, /begin, /finish | The invite page: what the invite is, then join with a new passkey. The invite's token goes in the body. |
POST /v1/team/members/:id/role/begin, /finish, /remove/begin, /finish | Change someone's role (owner) or remove them (owner, or yourself to leave). Passkey. |
POST /v1/team/quorum/begin, /finish, /quorum/cancel/begin, /finish | Set the seals needed ({quorum: 1 or 2, selfApprove}; owner, passkey), or cancel a waiting lowering (owner or approver, passkey). |
POST /v1/holds/:id/deny | Deny a hold. Optional {"reason"} (200 characters), handed back to a guard. |
GET, POST /v1/guard/keys, DELETE /v1/guard/keys/:id | Guard keys (lsg_, shown once). Revoking one closes its open approvals. |
POST /v1/approvals, GET /v1/approvals/:id?wait=, POST /v1/approvals/:id/use | Ask for, wait for (up to 20 s) and use an approval for a coding agent's command. Bearer lsg_ guard key. |
GET /v1/gates, POST /v1/gates/passkey/begin, /finish, DELETE /v1/gates/:id | Deploy gates: list, make (passkey, web only; a member or more) and revoke (its maker or an owner; it closes the gate's waiting requests and revokes the tokens it handed out). On a team, a deploy request needs as many different approvers as the quorum. |
POST /v1/gate/request, GET /v1/gate/status/:id?wait=, POST /v1/gate/cancel/:id | A GitHub Actions job asks for, waits for (up to 20 s) and withdraws a deploy approval. The first is authenticated by GitHub's OIDC token, the others by the poll secret (gps_) it returns. Used by action/deploy-gate. |
GET /v1/audit, GET /v1/audit/verify | Read your audit log (with the receipt index of each sealed entry); check the hash chain, 100 entries a request (?after=<next_after> until done). |
GET /v1/agent-keys, POST /v1/agent-keys/begin, POST /v1/agent-keys | List agent keys; register one with a proof of possession over the text from begin. |
DELETE /v1/agent-keys/:id | Revoke an agent key. Web only. |
POST /v1/me/receipts/on, POST /v1/me/receipts/off/begin, /finish | Turn receipts on (web) or off (passkey). |
GET, POST /v1/keys, POST /v1/keys/signer/begin, DELETE /v1/keys/:id | Receipts API keys, with an optional signer. |
POST /v1/receipts | Record receipts. Bearer lsr_ key. |
GET /v1/my/receipts, GET /v1/my/receipts/:index/bundle | Your receipts with openings; a fresh bundle for one. |
GET /v1/log/checkpoint, /v1/log/key, /v1/log/proof/inclusion?index=&size=, /v1/log/proof/consistency?first=&second=, /v1/log/entries?start=&count=, /v1/stats | The public log. Anyone, any origin (CORS open), no account. |
GET /v1/tools/directory?agent= | Fetch an agent's Web Bot Auth key directory for the checker. Public, 10 a minute per network. |
GET /v1/passkeys, POST /v1/passkeys/:id/remove/begin, /finish | Your passkeys; remove one with a passkey check, which also signs out your other browsers (web only, never the last). |
POST /v1/device/start, POST /v1/device/poll | Command line sign-in (RFC 8628 device code): a code to type at /app#device, valid 5 minutes, 10 starts an hour per network; poll every interval seconds. |
POST /v1/device/lookup, /v1/device/deny, /v1/device/approve/begin, /finish | In the app: where a sign-in started, then deny it, or approve it with a passkey. Web only. |
GET /v1/sessions, DELETE /v1/sessions/:id | Command line sign-ins of the last 7 days; revoke one and every token, agent key and receipts key it made, even after it signed out (revoking is web only). |
GET /v1/alerts | What a phone alert shows: waiting holds (a held refund or payout has a headline with its amount), command line sign-ins and passkeys added in the last 15 minutes, and frozen_at. |
GET /v1/push/key, POST /v1/push/subscribe, /unsubscribe, /test | Phone and desktop alerts (Web Push, no payload), up to 10 devices. Web only. |
POST /v1/me/delete/begin, /finish | Delete your account and everything linked to it (public log entries stay, unlinked). Passkey. Finish body {"confirm":"delete", challengeId, credential}. Web only. |
ANY /p/:provider/* | The agent proxy. Bearer lsh_ token (and the agent's signature when the token is bound). |
Errors
| Status | Error | Meaning |
|---|---|---|
| 401 | leash_token_required, token_invalid | Missing, expired or revoked token. |
| 401 | agent_signature_required, bad_agent_signature, agent_signature_replayed, agent_key_revoked | The token is bound to an agent key and the request was not signed by it, was signed wrongly (the reason says how), reused a nonce, or the key was revoked. |
| 401 | api_key_required, api_key_invalid | Missing, revoked or expired receipts API key. |
| 401 | guard_key_required, guard_key_invalid | Missing, revoked or ended guard key (a key made by a command line sign-in ends with it). |
| 401 | oidc_required, oidc_malformed, oidc_alg, oidc_bad_signature, oidc_issuer, oidc_audience, oidc_expired, oidc_not_yet_valid, oidc_lifetime, oidc_claims, oidc_replayed, oidc_unknown_kid | A deploy gate's request without a valid GitHub OIDC token: missing (the job lacks id-token: write), not signed by GitHub, made for another audience, expired, a claim missing or odd, or already used. The message says which. |
| 403 | gate_refused | The run is not what the gate accepts. reason is repository, workflow, ref, environment or event. |
| 503 | oidc_keys_unavailable | GitHub's signing keys could not be fetched; the action retries. |
| 403 | denied | You denied this command; deny_reason carries your reason. |
| 409 | hash_mismatch, not_approved, already_used | The approval is for a different command, is still waiting, or was used. |
| 410 | expired | The approval was not used within 10 minutes, or nobody decided within 30. Ask again. |
| 402 | quota, daily_quota | Your monthly or daily receipts are used up. Nothing was recorded. |
| 403 | leash_denied | A policy deny rule, readOnly, or the allow list or default blocked the call. |
| 403 | wrong_provider | The token belongs to a different provider. |
| 403 | passkey_required | Needs the web app and your passkey. |
| 403 | role_required, own_request, seal_not_yet | On a team: your role does not allow it (or the token or key is someone else's: only its maker or an owner revokes it); your own token or guard key asked and someone else must seal it; or you joined or became an approver less than 24 hours ago while one person cannot approve alone (two seals, or self-approval off), so your seal, an unfreeze or a breaker reset by you does not count yet. |
| 409 | already_sealed, quorum_blocks_preapproval, last_owner, not_enough_approvers | You sealed this already; pre-approvals and money caps wait while two seals are needed; the account needs an owner; the seals needed need that many owners or approvers. |
| 400 | invite_invalid | The invite was used, has expired, was revoked, or its owner is no longer an owner. |
| 403 | csrf | Web write without the right Origin or x-leash header. |
| 409 | replay, key_taken, signer_taken | A signed receipt sent twice; a public key registered to another account. |
| 409 | too_many_credentials, too_many_tokens, too_many_keys, too_many_agent_keys, too_many_guard_keys, too_many_devices | A ceiling per account: 50 vault keys, 200 live tokens, 20 receipts keys, 20 agent keys, 20 guard keys, 10 alert devices. |
| 413 | too_large | Request body over the limit. |
| 409 | not_frozen, not_tripped | An unfreeze or a reset for something that is not frozen or tripped. |
| 423 | account_frozen | The owner froze the account: every agent token is stopped until they unfreeze it with a passkey. Stop and tell your human; do not retry. Approving is refused with the same error while frozen. |
| 428 | held_for_approval | Irreversible, or a brake (the rule starts with brake.): over a money cap, unreadable, or the token's breaker tripped. Approve, then retry once. |
| 429 | slow_down | Too many requests. The message says when to retry. |
| 429 | too_many_holds | 20 requests from this token or guard key (or 100 on the account) are already waiting for approval; nothing new is held or alerted until a human decides. |
| 503 | log_at_capacity | The public log reached its daily capacity. Nothing was recorded; try after 00:00 UTC. |