Skip to content

An approval channel, so an escalation is no longer a dead end - #18

Merged
phyxan merged 1 commit into
mainfrom
approvals
Oct 7, 2026
Merged

phyxan merged 1 commit into
mainfrom
approvals

Conversation

@phyxan

@phyxan phyxan commented Sep 26, 2026

Copy link
Copy Markdown
Member

A decision that needed a person could only be refused, with a name attached. Workable for an assistant somebody is watching, useless for anything running on its own, which is most of what a guard is for.

The loop

agent-passport guard --policy treasury.json --ledger spend.jsonl \
  --approvals approvals.jsonl -- npx -y @stripe/mcp

The agent gets a refusal with a reference:

Waiting for a person: stripe.create_charge
2,500 USD is above the 1,000 USD threshold, so a person must confirm before commitment.
Request 4ad06d81 is recorded. Someone can answer it with:
  agent-passport approve 4ad06d81 --approvals approvals.jsonl
This call was not sent to the server.
$ agent-passport approvals --approvals approvals.jsonl

4ad06d81  WAITING
  stripe.create_charge  2,500 USD
  on cus_7
  amount.above-human-threshold
  asked 2026-09-26 18:35:31, window closes 2026-09-27 02:35:31
  {"amount":250000,"customer":"cus_7"}

Answer it, and the same call goes through next time.

What an approval is worth

One exact call. What ties an approval to a call is the decision digest, taken over the subject, the authority and the request and over nothing else. It is the same digest that already stopped an action being swapped after approval, reused here. Change the amount by a penny, the customer, or any argument, and the approval stops matching.

Once. Spent when the call actually runs.

Until the window closes. The policy's slaHours. A decline is final.

A waiting request carries the arguments in full, which a receipt deliberately does not. Nobody can approve what they cannot see, and this file is the operator's rather than something handed to a counterparty.

Who may answer

Worth being blunt: an agent that can run a shell can also run agent-passport approve. A policy that lists approvers takes only answers signed by those keys:

"approvers": [{ "keyId": "approver-2026", "alg": "ed25519", "publicKey": "..." }]

An unsigned answer, one signed by a key the policy does not name, and one edited after signing are all refused. Without that list anything in the file counts, which is safe only where the agent cannot write to it, and the docs say that rather than implying the feature is stronger than it is.

The Claude Code hook honours an approval that already exists but opens none of its own, because Claude Code can put the question to the person directly.

Verification

8 new tests driving the real CLI, 129 in total. Harness and end-to-end scenario green. Mutation-tested seven ways, each reddening exactly its case:

Break Result
Skip the digest comparison "one exact call" red
Spend an approval twice "good once" red
Reopen a decline "stays declined" red
Accept an unsigned answer "approvers" red
Never verify the signature "edited afterwards" red
Accept a key the policy does not name "approvers" red
Never mark an approval used "good once" red

🤖 Generated with Claude Code

A decision that needed a person could only be refused, with a name attached.
That is workable for an assistant somebody is watching and useless for anything
running on its own, which is most of what a guard is for.

Now a refused call leaves a request behind, somebody answers it out of band,
and the same call goes through next time. The guard writes the request and
refuses with a reference; agent-passport approvals shows what is waiting;
approve or decline answers it.

What ties an approval to a call is the decision digest, which is taken over the
subject, the authority and the request and over nothing else. It is the same
digest that already stopped an action being swapped after approval, reused. So
an approval buys one exact call: change the amount by a penny, the customer, or
any argument, and it stops matching. It is spent when the call actually runs,
and it lapses with the policy's response window. A decline is final.

A waiting request carries the arguments in full, which a receipt deliberately
does not. Nobody can approve what they cannot see, and this file is the
operator's rather than something handed to a counterparty.

Then the part worth saying plainly: an agent that can run a shell can also run
the approve command. A policy that lists `approvers` takes only answers signed
by those keys, and refuses an unsigned one, one signed by a key it does not
name, and one edited after signing. Without that list anything in the file
counts, which is safe only where the agent cannot write to it, and the docs say
so rather than implying the feature is stronger than it is.

The Claude Code hook honours an approval that already exists but opens none of
its own, because Claude Code can put the question to the person directly and
asking twice helps nobody.

Verified with 8 tests driving the real CLI, including that an approval for one
call does not cover the same call with a different amount, customer or
argument. Mutation-tested seven ways: skipping the digest comparison, spending
an approval twice, reopening a decline, accepting an unsigned answer, never
verifying the signature, accepting an unnamed key, and never marking an
approval used. Each reddens exactly its case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@phyxan
phyxan merged commit b43a045 into main Oct 7, 2026
4 checks passed
@phyxan
phyxan deleted the approvals branch October 7, 2026 17:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant