Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
23 kB · 521 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522//! Dispatch: one hook payload in, one hook response out.//!//! [`handle`] is the whole behaviour of the binary. It is deliberately//! infallible — see the crate documentation — so every branch here ends in a//! `HookOutput`, and the only trace of a failure is a line on stderr.
use std::sync::LazyLock;
use didbot_hook::{ is_scrobble_tool, lineage, stamp_agent_type, stamp_conflict, stamp_effort, stamp_identity, stamp_model, stamp_parent, stamp_token, stamp_turn, transcript, AgentKey, CommonInput, HookOutput, HookSpecificOutput, PreToolUseInput, AGENT_TYPE_KEY, EFFORT_KEY, HARNESS, MODEL_KEY, PARENT_KEY, SCROBBLE_INSTRUCTIONS, STAMP_KEY, TOKEN_KEY, TURN_KEY,};
use crate::agent_id::derive_agent_id;use crate::config::Config;use crate::diag;use crate::pds::{PdsClient, WireRegistration};use crate::state::{HookState, StateStore};
/// Handles one hook payload.////// `payload` is the raw JSON the harness wrote to stdin. Anything unexpected —/// an event this crate does not act on, a payload that will not parse, a/// personal data server that is not running — produces an empty successful/// response.pub async fn handle(config: &Config, payload: &str) -> HookOutput { let common: CommonInput = match serde_json::from_str(payload) { Ok(common) => common, Err(err) => { diag(format_args!("cannot parse hook payload: {err}")); return HookOutput::default(); } };
// Before anything reads `config.pds_url`: on a machine running more than // one personal data server, which one this session belongs to is a // property of where it is working. let config = &config.resolved_for(std::path::Path::new(&common.cwd));
let store = StateStore::new(&config.state_path); let key = AgentKey::from_input(&common);
match common.hook_event_name.as_str() { // Both starts are the same operation. A subagent is a separate agent // with its own DID, and `AgentKey` already carries the difference. "SessionStart" | "SubagentStart" => { provision(config, &store, &key, &common).await; nudge(&common.hook_event_name) } "PreToolUse" => pre_tool_use(config, payload, &store, &key).await, "SubagentStop" => { teardown(config, &store, &key).await; HookOutput::default() } "SessionEnd" => { teardown_session(config, &store, &key.session_id).await; HookOutput::default() } _ => HookOutput::default(), }}
/// Puts the scrobble tool in front of the agent at the start of its life.////// This was written once, removed as redundant with the tool description, and/// then restored on evidence: with a good description and no nudge, two real/// sessions provisioned accounts and made zero scrobbles between them. A tool/// description answers "when would I use this" for a model already considering/// the tool. It does not raise the subject.////// So the two are not duplicates of each other. The description carries the/// trigger conditions and travels to any harness; this carries the fact that/// the tool exists at all, at the one moment the agent is deciding how to/// work. Keep them consistent, and do not delete this one because it repeats/// the other — that is what happened last time.fn nudge(event: &str) -> HookOutput { HookOutput { hook_specific_output: Some(HookSpecificOutput { additional_context: Some(SCROBBLE_NUDGE.clone()), ..HookSpecificOutput::for_event(event) }), ..HookOutput::default() }}
/// The text handed to an agent at the start of its life.////// This is [`SCROBBLE_INSTRUCTIONS`] verbatim, followed by the one thing the/// tool description cannot say. A description is read from a loaded schema; an/// agent whose harness has listed the tool without loading its schema is/// exactly the agent that will never read it. So that sentence belongs here/// and only here, and the rest is quoted rather than restated, because a/// restatement is a second copy and a second copy drifts.////// The trailing sentence exists because a harness may list a tool by name/// without loading its schema, to keep the system prompt small. An agent in/// that state cannot call the tool without a round trip first, and reaches for/// it at the moment it has something to say — the moment it is least inclined/// to spend a turn on setup. Paying that cost at the start of the session is/// the difference between a nudge that can be acted on and one that cannot.static SCROBBLE_NUDGE: LazyLock<String> = LazyLock::new(|| { format!( "{} If the tool is listed but its schema is not loaded yet, load it \ now rather than at the moment you first want to scrobble: in Claude \ Code that is `ToolSearch` with `select:mcp__vibescrobble__scrobble`.", *SCROBBLE_INSTRUCTIONS )});
/// Provisions an account for `key` and remembers its DID.////// An agent that already has a DID is left alone. `SessionStart` fires again/// on resume and on clear, and minting a second account each time would leak/// accounts and change the identity of a session mid-flight.async fn provision(config: &Config, store: &StateStore, key: &AgentKey, common: &CommonInput) { let state = store.load(); if state.usable(key, &config.pds_url) { diag(format_args!( "{} already provisioned as {}", key.local_handle(), state.did(key).unwrap_or_default() )); return; }
let profile = profile_facts(&state, common); let agent_id = derive_agent_id(key); match PdsClient::new(config.clone()) .provision(&agent_id, profile) .await { Ok(provisioned) => { let did = provisioned.did; store.update(|state| { state.insert(key, did.clone(), &config.pds_url); state.set_token(key, provisioned.agent_token.clone()); }); diag(format_args!("provisioned {} as {did}", key.local_handle())); } Err(err) => { // The session continues unstamped. The MCP server refuses // unstamped scrobbles, which is the visible symptom. diag(format_args!( "not provisioning {}: {err}", key.local_handle() )); } }}
/// Stamps a scrobble call with the acting agent's DID.////// Returns an empty response for any other tool and for an agent that could/// not be given an account; neither is an error worth blocking a tool call/// over. A call that already carries a stamp key under a value this hook/// did not just compute is different — see "Why an already-stamped call is/// refused" below — and gets a denial instead.////// # Why this provisions////// `SessionStart` is where an account is normally minted, and it is allowed to/// fail: the hook never breaks a session, so a server that was down at the/// moment the session opened leaves it with no DID and no second chance. Hook/// configuration is read once at session start, so restarting the server does/// not help and neither does anything else short of ending the session.////// Provisioning here as well makes `SessionStart` an optimisation rather than/// a precondition, and the same branch covers an agent whose account was/// minted against a different server — see [`HookState::usable`]. The cost is/// one request on the first scrobble of a session that has no usable account,/// and none at all on the ordinary path.////// The identity still comes from the harness payload and never from the model,/// which is the property that makes a stamp worth anything. That is also why/// this is a hook rather than something the scrobble host offers: the Model/// Context Protocol carries no verified caller identity, so a "register me"/// tool would let a model claim to be any session.////// No `permissionDecision` is set on the ordinary path. Stamping is not a/// permission judgement, and answering `allow` here would silently/// auto-approve every scrobble — a widening of permissions the operator did/// not ask a stamping hook for. Allow the tool in `permissions` if that is/// wanted. A denial (see below) does set one, because refusing the call *is*/// the judgement in that case.////// # Why an already-stamped call is refused////// `didbot_hook`'s private `stamp` function used to refuse to overwrite a key already present/// on `tool_input`, on the theory that a call already carrying a stamp had/// already been handled and should be left alone. That protected nothing: the/// Model Context Protocol hands a tool call's arguments to this hook exactly/// as the model wrote them, so the only way one of/// [`didbot_hook::STAMP_KEYS`] could already be set is that the model set it/// — and the old code trusted that value rather than the harness's own,/// which is precisely the lie `docs/trust-model.md` says a model cannot tell./// `stamp` now always overwrites, so a forged value can never survive even if/// it reaches that function — but reaching it at all is the wrong outcome for/// a call shaped like this, silently or not.////// So this checks first, with [`didbot_hook::stamp_conflict`], comparing/// whatever the call already carries under one of those keys against the/// values this very call is about to be stamped with. A value that/// disagrees is refused with `PermissionDecision::Deny` and a loud/// diagnostic — a bug or an attack, and either way not something to pass/// through or fix up quietly. A value that agrees is not a conflict at all:/// see `stamp_conflict`'s own documentation for why presence alone cannot/// tell a forged call from the harness's own hook processing one call twice/// — this project's `settings.json` may wire the `PreToolUse` matcher at more/// than one configuration scope, and a harness that does runs every matching/// hook in a chain, each seeing the last one's `updatedInput` — and why/// comparing values can.async fn pre_tool_use( config: &Config, payload: &str, store: &StateStore, key: &AgentKey,) -> HookOutput { let input: PreToolUseInput = match serde_json::from_str(payload) { Ok(input) => input, Err(err) => { diag(format_args!("cannot parse PreToolUse payload: {err}")); return HookOutput::default(); } };
if !is_scrobble_tool(&input.tool_name) { return HookOutput::default(); }
// Only a scrobble gets this far, so the request a missing account costs is // bounded by how often an agent scrobbles rather than by how often it uses // a tool. if !store.load().usable(key, &config.pds_url) && !adopt_existing(config, store, key).await { diag(format_args!( "{} has no account on {}; provisioning one now", key.local_handle(), config.pds_url )); provision(config, store, key, &input.common).await; }
let state = store.load(); let did = match state.did(key) { Some(did) if state.usable(key, &config.pds_url) => did, _ => { diag(format_args!( "no usable account for {} on {}; leaving the scrobble unstamped", key.local_handle(), config.pds_url )); return HookOutput::default(); } };
// Everything this call is about to be stamped with, computed before the // call is touched at all: the DID always, and the rest exactly when the // ordinary stamping pass below would carry them. This doubles as the // truth `stamp_conflict` checks the incoming call against, so a value // already on the call either matches what is computed right here or is // refused — nothing in between. let token = state.token(key); let effort = input.common.effort.map(|effort| effort.level.as_str()); let agent_type = input.common.agent_type.as_deref(); let model = model_for(&input.common); let parent = parent_did(&state, &input.common); let turn = input.tool_use_id.as_deref();
let mut intended: Vec<(&str, &str)> = vec![(STAMP_KEY, did)]; if let Some(token) = token { intended.push((TOKEN_KEY, token)); } if let Some(effort) = effort { intended.push((EFFORT_KEY, effort)); } if let Some(agent_type) = agent_type { intended.push((AGENT_TYPE_KEY, agent_type)); } if let Some(model) = model.as_deref() { intended.push((MODEL_KEY, model)); } if let Some(parent) = parent { intended.push((PARENT_KEY, parent)); } if let Some(turn) = turn { intended.push((TURN_KEY, turn)); }
if let Some(conflict) = stamp_conflict(&input.tool_input, &intended) { diag(format_args!( "scrobble from {} arrived already carrying {} = {:?}, which this hook did not just \ write; refusing rather than trusting a value the model could have supplied", key.local_handle(), conflict.key, conflict.value )); return HookOutput { hook_specific_output: Some(HookSpecificOutput::deny( &input.common.hook_event_name, "this call already carries a stamp field this hook did not just write; \ a tool call may not set its own provenance", )), ..HookOutput::default() }; }
// `stamp_identity` and everything below it now always overwrite rather // than refuse — see "Why an already-stamped call is refused" above — so // the only way any of these return `None` is `tool_input` not being a // JSON object, which no MCP tool call is. let Some(stamped) = stamp_identity(&input.tool_input, did) else { diag(format_args!( "scrobble from {} has a non-object tool_input; cannot stamp it", key.local_handle() )); return HookOutput::default(); };
// The write credential rides with the identity, and is optional the same // way the provenance below is: an account provisioned before this key // existed, or one this hook lost track of, has none on file, and a // scrobble stamped with a DID and no credential is left for the personal // data server to refuse rather than blocked here. let mut stamped = stamped; if let Some(token) = token { if let Some(next) = stamp_token(&stamped, token) { stamped = next; } }
// Provenance rides along with the identity, and every piece of it is // optional in a way the DID is not. The harness reports effort only inside // a tool-use context; only a subagent has a type; and the model is read // out of a transcript whose shape nothing promises. A scrobble missing any // of them is still a scrobble, so each is stamped if it is known and // skipped if it is not. if let Some(effort) = input.common.effort { if let Some(next) = stamp_effort(&stamped, effort.level) { stamped = next; } } if let Some(agent_type) = agent_type { if let Some(next) = stamp_agent_type(&stamped, agent_type) { stamped = next; } } if let Some(model) = model.as_deref() { if let Some(next) = stamp_model(&stamped, model) { stamped = next; } } if let Some(parent) = parent { if let Some(next) = stamp_parent(&stamped, parent) { stamped = next; } } // The harness's own pairing identifier for this tool call, passed straight // through so that this hook's line, the scrobble host's line and the // server's line can be joined to each other and to the transcript. if let Some(turn) = turn { if let Some(next) = stamp_turn(&stamped, turn) { stamped = next; } }
// The hook's contribution to following one turn across three processes. // Only under the switch: this fires on every scrobble, and a line per // scrobble on stderr would be noise in the ordinary case — where the // scrobble host's own feed already shows what was said. if config.debug { diag(format_args!( "stamped a scrobble for {} as {did} turn={}", key.local_handle(), turn.unwrap_or("-") )); }
HookOutput { hook_specific_output: Some(HookSpecificOutput { updated_input: Some(stamped), ..HookSpecificOutput::for_event(&input.common.hook_event_name) }), ..HookOutput::default() }}
/// Re-records an account against the server that turns out to hold it.////// The unusable cases are not all the same. Two personal data servers really/// are two, and an account minted on one does not exist on the other — but/// `http://localhost:3000` and `http://127.0.0.1:3000` are two strings and one/// server, and this hook deliberately does not decide which spellings of a/// host mean the same thing.////// So before minting a second account it asks. A DID the configured server/// already holds is adopted, which costs one read and turns what was otherwise/// a dead end — provisioning refused because the account exists, the scrobble/// left unstamped, and the same on every scrobble after it — into a session/// that carries on.////// Returns whether an account was adopted.async fn adopt_existing(config: &Config, store: &StateStore, key: &AgentKey) -> bool { let Some(did) = store.load().did(key).map(str::to_owned) else { return false; }; if !PdsClient::new(config.clone()).has_repo(&did).await { return false; } store.update(|state| state.insert(key, did.clone(), &config.pds_url)); diag(format_args!( "{} already has {did} on {}; adopting it", key.local_handle(), config.pds_url )); true}
/// Everything the harness can say about an agent, for its profile record.////// The three fields that are the harness's job and nothing else's: what kind/// of agent this is, what model it is running, and what spawned it. None of/// them is offered to the model and none is read out of a tool call; the type/// comes off the hook payload, the model out of the session transcript/// and the parent out of the spawn sidecar.////// Every one of them is allowed to be absent, and at `SessionStart` the model/// usually is: the transcript has no assistant row until the model has/// answered once, which is after the account has been minted. The model is not/// among them and no longer needs to be — it changes every turn, so it is/// stamped on the record each turn writes rather than kept on the account.fn profile_facts(state: &HookState, common: &CommonInput) -> WireRegistration { WireRegistration { harness: Some(HARNESS.to_owned()), agent_type: common.agent_type.clone(), parent: parent_did(state, common).map(str::to_owned), }}
/// The DID of the agent that spawned this one, if this project minted it.////// Two ways to be absent, and both are ordinary. The main conversation has no/// spawning agent — its parent is the human, who has no account here — and/// `lineage::parent_key` answers `None` for it and for every failure to read/// the sidecar the lineage comes from. A parent that was never provisioned has/// no DID to point at, which happens when the personal data server was down at/// the moment the parent started.////// The distinction is worth a log line because only one of them is a surprise:/// a subagent whose parent is known but unprovisioned means the account graph/// has a hole in it, while a session with no parent is just a session.fn parent_did<'a>(state: &'a HookState, common: &CommonInput) -> Option<&'a str> { let key = lineage::parent_key(common)?; let did = state.did(&key); if did.is_none() { diag(format_args!( "{} was spawned by {}, which has no DID; leaving the scrobble without a parent", AgentKey::from_input(common).local_handle(), key.local_handle(), )); } did}
/// The model in force for this turn, read from the payload's transcript.////// Split out so the one place this project touches a file the harness does not/// promise the shape of is a named function with a log line on it. Absence is/// ordinary — no transcript path, no assistant row yet, a format that has/// moved — and is reported at the same level as everything else here rather/// than being silent, because a stamp that quietly stops appearing is the/// hardest kind of drift to notice.fn model_for(common: &CommonInput) -> Option<String> { let path = common.transcript_path.as_deref()?; let model = transcript::model_for_turn(std::path::Path::new(path)); if model.is_none() { diag(format_args!( "no model in the transcript at {path}; leaving the scrobble without one" )); } model}
/// Deletes the account for one agent and forgets it.////// Local state is cleared first, so that a server which refuses the deletion/// does not leave a stale DID that a later session would stamp with.async fn teardown(config: &Config, store: &StateStore, key: &AgentKey) { let Some((did, token)) = store.update(|state| state.remove(key)) else { return; }; delete(config, &[(did, token)], key.local_handle().as_str()).await;}
/// Deletes the session's account and every subagent account under it.async fn teardown_session(config: &Config, store: &StateStore, session_id: &str) { let dids = store.update(|state| state.remove_session(session_id)); if dids.is_empty() { return; } delete(config, &dids, session_id).await;}
/// Asks the server to delete accounts, reporting but not propagating failures.////// Accounts left behind by a failed deletion are unpinned, so a sweep on the/// server reclaims them; there is nothing useful for a hook to retry here.////// `deleteAgent` requires a credential now, and the one this hook has for an/// account is its own write credential — see [`PdsClient::delete`]. An/// account with no token on file is still asked for, and the refusal that/// follows is reported exactly like any other `PdsError` here.async fn delete(config: &Config, accounts: &[(String, Option<String>)], what: &str) { let client = PdsClient::new(config.clone()); for (did, token) in accounts { match client.delete(did, token.as_deref()).await { Ok(()) => diag(format_args!("deleted {did} for {what}")), Err(err) => diag(format_args!("cannot delete {did} for {what}: {err}")), } }}