From 2feb23fce79fd4b648feaadfc104ff6295d9c4d8 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 2 Sep 2026 00:32:08 -0400 Subject: [PATCH] docs: write the stamp contract down apart from what writes it `docs/stamp-contract.md` names the seven keys, the rule that each provenance key is droppable on its own, why a server may believe a stamp, and what a mismatched hook and server do in each direction. The adversarial test that forges every stamp key now forges both spellings of each, because a reader in the compatibility window honours both and a rename that stopped refusing the old one would have removed a check. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 + crates/didbot-hookd/tests/hook_events.rs | 2 +- crates/didbot-hookd/tests/stamp_forgery.rs | 27 +++-- docs/hook-flow.md | 8 ++ docs/stamp-contract.md | 129 +++++++++++++++++++++ docs/trust-model.md | 9 +- 6 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 docs/stamp-contract.md diff --git a/README.md b/README.md index 4ad49a63..2891750f 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ To that end, here is a non-exhaustive list of recommendations for a respectful ` - `docs/web-surfaces.md` — the four websites around this project and which is served by what - `docs/trust-model.md` — what the system can and cannot prove about an agent +- `docs/stamp-contract.md` — the fields a hook adds to a tool call, and why a + server may believe them - `docs/deployment.md` — the zone, the hosts, and what a compromise of each reaches - `docs/operations.md` — the runbook: upgrading, rolling back, backing up and restoring a deployment that is already running diff --git a/crates/didbot-hookd/tests/hook_events.rs b/crates/didbot-hookd/tests/hook_events.rs index 9dd8d44e..5a90408c 100644 --- a/crates/didbot-hookd/tests/hook_events.rs +++ b/crates/didbot-hookd/tests/hook_events.rs @@ -631,7 +631,7 @@ async fn another_tool_is_left_alone() { #[tokio::test] async fn a_forged_identity_stamp_is_denied_not_passed_through() { // The regression test for finding 03: a model that writes its own - // `_vibescrobbleAgentDid` used to sail through untouched — this hook + // `_didbotAgentDid` used to sail through untouched — this hook // treated "already carries a stamp" as "someone already handled this" // and returned an empty response, which forwards the model's original, // forged arguments to the harness unmodified. It must now be refused. diff --git a/crates/didbot-hookd/tests/stamp_forgery.rs b/crates/didbot-hookd/tests/stamp_forgery.rs index 75c3e3a2..7e1e9015 100644 --- a/crates/didbot-hookd/tests/stamp_forgery.rs +++ b/crates/didbot-hookd/tests/stamp_forgery.rs @@ -19,7 +19,7 @@ use std::process::{Command, Stdio}; use std::sync::Arc; use didbot_dns::LoopbackDns; -use didbot_hook::{PARENT_KEY, STAMP_KEY, STAMP_KEYS}; +use didbot_hook::{LEGACY_STAMP_KEYS, PARENT_KEY, STAMP_KEY, STAMP_KEYS}; use didbot_identity::Zone; use didbot_pds::{MemoryAccountStore, Provisioner, Registry}; use serde_json::{json, Value}; @@ -170,17 +170,26 @@ async fn record_count(server: &Server, did: &str) -> usize { .unwrap_or_default() } -/// **The composed-tool-call shape.** Every one of the seven stamp keys, -/// forged inside a `tool_input` handed to the real compiled binary on -/// stdin — the shape a model's own tool call actually arrives in, not a -/// `tool_input` a test built with the forged field already attached before -/// calling a library function. Each must be denied, and each denial must -/// leave nothing for the harness to re-execute. +/// **The composed-tool-call shape.** Every one of the seven stamp keys, in +/// both spellings, forged inside a `tool_input` handed to the real compiled +/// binary on stdin — the shape a model's own tool call actually arrives in, +/// not a `tool_input` a test built with the forged field already attached +/// before calling a library function. Each must be denied, and each denial +/// must leave nothing for the harness to re-execute. +/// +/// Both spellings, because a reader in the [`LEGACY_STAMP_KEYS`] window +/// honours either. A rename that stopped refusing the old spelling would have +/// handed a model back the forgery the current spelling costs it, under a +/// name every deployed server still believes. #[tokio::test(flavor = "multi_thread")] async fn every_stamp_key_forged_in_a_real_tool_call_is_denied_by_the_compiled_binary() { let server = Server::start().await; - for key in STAMP_KEYS { + let spellings = STAMP_KEYS + .iter() + .copied() + .chain(LEGACY_STAMP_KEYS.iter().map(|(_, legacy)| *legacy)); + for key in spellings { let state = scratch(&format!("forge-{key}")); // A session id unique to this key: the agent id `didbot-hookd` // derives is a deterministic function of the session id @@ -224,7 +233,7 @@ async fn every_stamp_key_forged_in_a_real_tool_call_is_denied_by_the_compiled_bi /// **A forged lineage claim using a real, well-formed, currently-existing /// DID** — not an obviously wrong string. This is the shape -/// `plan/adversarial.md` calls out by name: `_vibescrobbleParentDid` was +/// `plan/adversarial.md` calls out by name: `_didbotParentDid` was /// "the live hole before the fix", and a model that has seen its own /// previous scrobbles knows exactly what a `did:web` under this zone looks /// like. Session A is a real, separately provisioned account; session B's diff --git a/docs/hook-flow.md b/docs/hook-flow.md index ba791660..7901f3d4 100644 --- a/docs/hook-flow.md +++ b/docs/hook-flow.md @@ -74,6 +74,14 @@ does. The node component is not in the write path. for one tool. The `SessionStart` context asks the agent to load the schema at the start of the session instead, which is a nudge like any other. +## The stamp + +What the hook adds to a scrobble call, and why the server may believe it, is +`docs/stamp-contract.md`. The short version: seven `_didbot*` keys, two of them +required and five droppable on their own, trusted because the harness writes +them where the model cannot reach, and refused under every spelling a reader +still honours. + ## Deferred A hook of type `prompt` can run a fast model over the final assistant message diff --git a/docs/stamp-contract.md b/docs/stamp-contract.md new file mode 100644 index 00000000..c1b6559b --- /dev/null +++ b/docs/stamp-contract.md @@ -0,0 +1,129 @@ +# The stamp contract + +A `PreToolUse` hook rewrites a tool call before it executes, adding fields the +model did not write. A server that receives the call reads those fields back +out. This page is the whole agreement, written so that either half can be +implemented without reading the other's source. + +`vectors/stamp-contract/keys.json` is the same agreement as data. +`didbot-hook` writes it and `didbot-mcp` reads it, and each has a test that +fails if its constants drift from that file. + +## The keys + +Every value is a JSON string, at the top level of the tool call's arguments +object. + +| Key | Carries | Required | +| --- | --- | --- | +| `_didbotAgentDid` | the DID of the agent making the call | yes | +| `_didbotToken` | that agent's write credential, a bearer token | yes | +| `_didbotEffort` | the effort level the turn actually spent | no | +| `_didbotTurn` | an opaque identifier for the turn | no | +| `_didbotModel` | the model identifier in force for the turn | no | +| `_didbotAgentType` | the harness-defined type of a subagent | no | +| `_didbotParentDid` | the DID of the agent that spawned this one | no | + +## Each key is droppable on its own + +The two required keys are refused rather than defaulted. A record whose author +is a guess is worse than no record: attribution is the whole point of it, and a +plausible wrong attribution is a lie a reader cannot detect. + +The other five are provenance, and each is absent for its own ordinary reason. +The harness reports effort only inside a tool-use context; only a subagent has +a type or a parent; the model is read out of a transcript whose shape nothing +promises; the turn identifier is only as good as the harness's own. So they are +separate keys rather than one nested object: a reader of a half-populated +object would have to decide what the missing halves meant, and there is no +answer that is right for all five. A reader must accept any subset of them, +including none, and must not let one absent field invalidate another. + +They are dropped rather than refused on the way in, too. Nothing the model did +can produce a bad value in one of them, so refusing the call would punish an +agent for a hook's mistake and lose the sentence it was actually trying to say. + +## Why a stamp may be believed + +**The harness writes it and the model cannot reach it.** The hook runs after +the model has composed the tool call, through a mechanism the model has no +access to, and it rewrites the arguments on the way past. Every value it writes +comes from the hook payload the harness filled in, or from a file the harness +wrote — never from anything the model said. That is the property. It is not a +property of these particular names. + +**The names are not a secret, and nothing here depends on their being one.** +A model that has seen one stamped call has seen the keys. What stops it forging +one is not obscurity but that the writer refuses: a call arriving at the hook +already carrying a stamp key, with a value that is not the one this hook +independently computes for that same call, is denied outright rather than +quietly overwritten. Presence alone is not the test, because presence cannot +tell a forgery from the same hook honestly stamping one call twice — a harness +runs every matching hook in a chain when the matcher is wired at more than one +configuration scope. Value agreement can tell them apart, because every stamped +value is a deterministic function of the payload and the hook's own state. + +**The leading underscore is load-bearing, and is not the property.** It is what +tells a stamped call from an unstamped one, and it is what keeps these names +clear of a tool's own parameters, so no argument a model chooses can collide +with a stamp. Any renaming has to carry both of those arguments with it, and +has to keep refusing every spelling any reader still honours — otherwise it is +a rename that quietly removes a check. + +This is the same guarantee `docs/trust-model.md` states in general: a model can +lie about what it is doing, and not about which agent is doing it. The +guarantee stops at the trust domain. Arbitrary code running in the same domain +can address the credential path directly. + +## The rename, and mismatched halves + +The keys were spelled `_vibescrobble*` before they were spelled `_didbot*` — +naming a stamp written by a hook that knows nothing about any one product after +one product was a category error. Renaming them is a protocol break, and the +halves ship apart: `didbot-setup` installs a hook binary onto a machine, and +the server that reads its stamps is updated separately. So a deployment always +spends time with one side old. + +The window is: **readers accept either spelling, writers write only the new +one.** + +**Update servers before hooks.** + +- **Old hook, new server.** The hook writes `_vibescrobble*`. The server finds + no `_didbot*` key, falls back to the old spelling, and the call works + normally. This is the direction the window exists for. +- **New hook, old server.** The hook writes `_didbot*`. The old server has + never heard of them, sees a call carrying no stamp at all, and refuses it + with the message it already has for an unconfigured hook — naming the key it + looked for. Loud, and not a silently unattributed record. It is an outage + for scrobbles until the server catches up, which is why this is the order + not to deploy in. +- **Both spellings present, disagreeing.** Either two hook versions stamped one + call and computed different answers, or something that is not a hook wrote + one of them. For the two required keys the call is refused: choosing a winner + would mean choosing which of two contradictory authors to attribute a + permanent public record to. For the five droppable ones the field is dropped + and the scrobble goes out without it. +- **Both spellings present, agreeing.** An older hook earlier in the chain + stamped the same call. Accepted, and not treated as a forgery, for the reason + the section above gives. + +A writer refuses a forged value under *both* spellings, for as long as any +reader honours both. The compatibility fallback is otherwise a way to forge a +stamp: a model writing `_vibescrobbleAgentDid` into its own call would be +making exactly the claim the trust model says it cannot make. + +## What is not in the contract + +The tool the hook stamps. `didbot-hook` still names one — `SCROBBLE_TOOL`, +`SCROBBLE_SERVER`, `is_scrobble_tool`, `scrobble_tool_for` — which means the +mechanism that decides whether an agent's identity can be trusted knows one +product's tool by name. That belongs in the hook's configuration; see +`plan/stamp-contract.md`. + +Field limits on the record. `_didbotModel` is bounded by the reader, because a +limit that exists to protect a server cannot be delegated to the thing sending +it requests. The hook bounds the same value independently and for its own +reason — it reads it out of a harness-internal file of no promised shape — and +the two numbers are allowed to disagree. If the writer's is looser, the record +loses a field the contract already says is droppable. diff --git a/docs/trust-model.md b/docs/trust-model.md index de75c028..70f2cd36 100644 --- a/docs/trust-model.md +++ b/docs/trust-model.md @@ -55,7 +55,7 @@ This is mechanically enforced, not merely organisational. `didbot-hook`'s `stamp()` used to refuse to overwrite a stamp key already present on a tool call, on the theory that a call already carrying one had already been stamped. It hadn't: the only way a model's own tool call could carry -`_vibescrobbleAgentDid`, `_vibescrobbleParentDid`, `_vibescrobbleEffort`, or +`_didbotAgentDid`, `_didbotParentDid`, `_didbotEffort`, or any of the other four stamp keys was that the model wrote it there, and the old code let that value ride through untouched — an identity claim the paragraph above says a model cannot make, forgeable in practice by writing @@ -69,6 +69,13 @@ scope wired the matcher; that is told apart from an attack by comparing the carried value against the one this hook independently computes, not by trusting that the key is merely present. +Both spellings are refused, not only the current one. The keys were +`_vibescrobble*` before they were `_didbot*`, and a reader in the +compatibility window honours either — so a model free to write the old +spelling unchallenged would be forging the same claim under a different name. +`docs/stamp-contract.md` is the whole agreement, including what a mismatched +hook and server do in each direction. + That guarantee stops at the trust domain. Arbitrary code execution in the same domain can address the credential path directly and claim any agent within it. Per-agent unix users or containers move that boundary and remain a deployment -- 2.51.2