Essay An Approval Is a Hash Not a Yes - joeylking/agent-runtime GitHub Wiki
An approval is a hash, not a yes
An agent built on agent-runtime asks for actions, and a policy written in ordinary code decides whether each one runs. One of the policy's answers is "ask a person first". The run then pauses until an operator, the person responsible for the agent, approves or rejects the request. This essay is about what that approval is, because a careless design turns it into a yes that can be spent on something nobody read.
Yes to what?
The simplest approval is a flag on the run: approved, true. The trouble is the time between asking and acting. The operator may decide an hour or a day later. Meanwhile the request can be re-planned, the stored row can be edited, the policy can change, or the tool can be upgraded. A flag says someone agreed. It does not say to what.
agent-runtime stores an approval as a row holding four things
(approval.go):
- the kind, a name the policy chooses, such as
publication; - the capability, what the operator would be granting;
- the presentation, what the operator is shown;
- the exact request: the run and step it belongs to, the tool's full registered specification (name, description, input schema, kind of effect, timeout), and the arguments.
The row carries a SHA-256 hash over all four, computed from a canonical
JSON encoding with sorted keys. That encoder is written out by hand in
hash.go rather than left to Go's encoding/json,
because stored hashes must still verify after a Go release changes how
the standard library escapes or formats something. Golden tests pin it
byte for byte to the hashes already stored
(TestContentHash_Golden,
TestCanonicalJSON_MatchesEncodingJSON). A value
that will not encode is an error, never a stand-in hash
(TestApprovalHash_ErrorsRatherThanSubstituting).
The paused run is a row in a SQLite file, so the process can exit and the
operator can decide the next day. Approving recomputes the hash from the
stored fields before accepting the decision, so a row whose request was
edited behind the runtime's back is refused
(TestApproval_TamperedRowRefused). Approving
does not continue the run. A separate Resume executes exactly the
recorded request and carries on
(TestApproval_ApproveThenResumeExecutesRecordedRequest).
A consumer uses the capability and presentation to bind what matters to
it. repo-steward's publication approval names the frozen proposal and its
own hash in the capability, and shows the branch, commit, and files in the
presentation
(internal/policy/policy.go).
A changed proposal produces a different approval hash, so an old approval
can never authorize it. The runtime's NeedApproval helper builds such a
decision, and both fields enter the hash
(TestNeedApproval_PausesAndBindsWhatWasShown).
Bound to what was shown
A hash on the row catches a request edited without its hash. It did not,
at first, protect the decision itself. The operator command,
agentrt approve, read the approval, printed it, waited for the operator
to type yes, and then approved whatever the row held at that moment. The
hash contains no secret, so anyone able to write the row could replace the
request and store a hash that verified. The security review reproduced
exactly that: it changed the approval while the command was waiting for
the operator's answer, and the yes was applied to a request the operator
never saw.
The fix binds the decision to what was displayed. ApproveShown and
RejectShown take the hash of the approval the front end printed. They
compare it with the stored hash inside the transaction that records the
decision, and the database update is conditional on that hash, so nothing
can change the row between the check and the write. A mismatch returns
ErrApprovalChanged, nothing is decided, and the command asks the operator
to look again
(TestDecide_BindsToTheHashActuallyShown,
TestApproval_ShownDecisionIsAtomic).
The same rule holds wherever an approval is decided away from the
database. The reference webhook channel, for a chat bot or a browser,
requires every decision to carry the hash its caller was shown, and
refuses a stale one with nothing decided
(TestHandler_HashBindsAgainstAConcurrentChange,
TestHandler_StaleHashIsRefusedAndNothingDecided).
Both consumers now decide this way: repo-steward's approve, reject, and
cancel, and casework's browser approval.
Checked again on resume
An approval is not a standing permission. Resume
(resume.go) treats a grant as one input among several
and checks the rest before anything executes:
- Only the step's latest approval counts. A step that paused again
has a newer approval, and an older grant is spent. While the newest one
is pending,
ResumereturnsErrRunStateand changes nothing, however often it is called (TestResume_OnlyTheLatestApprovalOfAStepGrants,TestResume_RepausedStepDoesNotPileUpApprovals). - The hash is verified again.
- The request is rebuilt from the tool as registered now, and its
arguments are checked against the tool's current schema. Arguments that
no longer fit end the step as an invalid decision, and the agent
decides again
(
TestResume_ApprovedArgumentsMeetTheCurrentSchema). - The policy is asked again, while the run is still waiting. The
request executes only if the policy now allows it, or asks for exactly
the approval that was granted, meaning the same hash. Any other answer
pauses the run again with a new approval
(
TestApproval_ResumeRepausesWhenPolicyWantsDifferentApproval). Because step 3 rebuilt the request, a tool whose description, schema, or timeout changed since the pause no longer matches the old hash (loop.go).
The move out of waiting, the policy's new decision, and the tool's start
commit in one transaction, so a crash between them cannot drop the
approved request
(TestResume_CrashAfterResumeKeepsTheApprovedRequest).
The move succeeds only if the run is still waiting, so of two processes
resuming the same approved run, one executes the request and the other gets ErrRunState
(TestResume_ConcurrentResumeExecutesOnce).
Approvals expire
Two limits bound how old an approval may be. Limits.ApprovalTTL bounds
how long one may wait for a decision; an approval past it cancels the run
as approval_expired the next time anyone touches it
(TestApproval_ExpiryCancelsRun).
Limits.GrantTTL bounds how long a granted approval may wait for
Resume, measured from the decision. A stale grant cancels the run with
nothing executed
(TestResume_GrantNotResumedWithinTheTTLExpires),
and the first limit alone never expires a grant
(TestResume_ApprovalTTLAloneNeverExpiresAGrant).
GrantTTL is off by default. casework, which is private, leaves it off
and instead has its tool re-check the specific approval, its expiry, and
every precondition before anything leaves the process
(internal/tools/act.go in casework).
What the hash deliberately leaves out
The approval binds what the model asked for. For a tool from an MCP
server, the operator can fix a parameter's value in configuration: the
parameter is removed from the schema the model sees and injected into
every call after the runtime has validated the model's arguments. That
fixed value is outside the approval hash on purpose. It is the operator's
own setting, not part of the request, and the operator already knows it
(mcp/load.go,
TestFixed_RemovedFromTheSchemaAndInjectedAtCall).
What keeps this honest is that a call carrying a value for a fixed
parameter fails rather than being silently overridden. Otherwise the
approval could show one value while the server received another
(TestFixed_ACallThatSetsAFixedParameterFails).
The hash also leaves out who decided and the note they wrote, which are recorded beside it, and the code behind the tool's name.
What this does not claim
- The hash is not a signature. It has no secret, so anyone who can write
the database file can forge an approval or approve directly. That is why
the runtime creates the file readable and writable by its owner only and
refuses one that others can write
(
SECURITY.md). The hash catches corruption and code that mutates a record after it was fixed, not a local attacker. - The name recorded as the approver is a label given by the caller, not a verified identity.
- An approval binds a request. It does not make the action safe, and the world can change between the decision and the execution. A tool that depends on outside state should check it again when it runs, as casework's does.
- A change to an operator's fixed MCP parameter between the approval and the resume is not caught by the hash.
This essay is also in the repository as docs/essays/02-an-approval-is-a-hash-not-a-yes.md. The index of essays lists the others.