//! 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 = 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 { 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)], 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}")), } } }