Skip to main content

ai_crew_sync/
hook.rs

1//! `ai-crew-sync context hook --binding <id> --event <event>`.
2//!
3//! The one thing lifecycle hooks run in authenticated mode. A hook is a
4//! short-lived process with no credential of its own; this helper reads the
5//! private binding record its conversation's proxy wrote (0700 directory,
6//! 0600 file), performs the operation with that window's session credential,
7//! and prints only what the host expects. The credential never reaches argv,
8//! stdout, a log or a tool result (ADR 0001).
9//!
10//! Three rules the implementation exists to keep:
11//!
12//! 1. **Same window, never a sibling.** The binding is looked up by the
13//!    host's conversation id. A missing or unusable binding is a quiet no-op,
14//!    never a fallback to a shared session or another team — a hook that
15//!    drained the wrong window's inbox would be a silent data loss.
16//! 2. **A hook never fences its own proxy.** It sends the epoch the proxy
17//!    recorded and never registers, so it cannot bump the epoch and lock out
18//!    the window it belongs to.
19//! 3. **Presence only, no reads that move a cursor.** The events here
20//!    publish presence and read context; the Stop drain still runs in the
21//!    shell script, which is where its loop guard lives. Its reads go
22//!    through `--event call`, one tool call as the bound window, so an
23//!    exported `BUS_TOKEN`/`BUS_SESSION` can never redirect them to a
24//!    sibling window's inbox.
25
26use std::path::Path;
27
28use anyhow::{Context as _, bail};
29use serde_json::{Value, json};
30
31use crate::context::{self, Binding};
32
33/// Lifecycle events this helper knows. Anything else is a caller mistake
34/// worth naming rather than a silent success.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum Event {
37    /// A conversation started: publish presence and return the context the
38    /// host injects into the model.
39    SessionStart,
40    /// Keep the window's presence alive mid-conversation.
41    Heartbeat,
42    /// The turn ended; the window is still open.
43    Stop,
44    /// The window is closing.
45    SessionEnd,
46    /// What this binding is, for troubleshooting. Never prints a secret.
47    Status,
48    /// One MCP tool call as the bound window (`--tool`, `--args`), printing
49    /// the tool's structured content. Silent, like the presence events,
50    /// when the binding is missing or carries no credential. Only the tools
51    /// in [`HOOK_CALL_TOOLS`] are served: a hook must not be able to issue,
52    /// rotate or revoke a credential, nor act on the bus beyond what its
53    /// scripts need.
54    Call,
55}
56
57/// The tools `--event call` serves: exactly what the hook scripts call
58/// through `bus-call.sh`. Anything else is refused before the binding is
59/// even read, so `resume_session` through a hook cannot rotate the proxy's
60/// credential and print the new secret.
61pub const HOOK_CALL_TOOLS: &[&str] = &["whoami", "read_messages", "team_digest", "heartbeat"];
62
63impl std::str::FromStr for Event {
64    type Err = anyhow::Error;
65    fn from_str(raw: &str) -> anyhow::Result<Self> {
66        match raw.trim().to_lowercase().replace(['-', ' '], "_").as_str() {
67            "session_start" | "sessionstart" => Ok(Self::SessionStart),
68            "heartbeat" => Ok(Self::Heartbeat),
69            "stop" => Ok(Self::Stop),
70            "session_end" | "sessionend" => Ok(Self::SessionEnd),
71            "status" => Ok(Self::Status),
72            "call" => Ok(Self::Call),
73            other => bail!(
74                "unknown hook event '{other}'; use session_start, heartbeat, stop, \
75                 session_end, status or call"
76            ),
77        }
78    }
79}
80
81/// How the binding was resolved, so `status` can explain a silent hook.
82#[derive(Debug)]
83pub enum Resolution {
84    /// A live binding with a usable credential.
85    Authenticated(Box<Binding>),
86    /// A binding exists but is unusable here: the window closed (its
87    /// credential is kept on disk for the proxy's own resume and for nothing
88    /// else), or the bus does not issue session credentials.
89    Unauthenticated(Box<Binding>),
90    /// No record for this conversation.
91    Missing,
92}
93
94pub fn resolve_binding(config_dir: &Path, binding: &str) -> Resolution {
95    match context::read_binding(config_dir, binding) {
96        None => Resolution::Missing,
97        // A closed record keeps its credential so the proxy can resume the
98        // same window later; a hook of a window that has closed acts as
99        // nobody, whatever the file still holds.
100        Some(b) if b.closed_at.is_some() => Resolution::Unauthenticated(Box::new(b)),
101        Some(b) => match b.session_token.as_deref().filter(|t| !t.is_empty()) {
102            Some(_) => Resolution::Authenticated(Box::new(b)),
103            None => Resolution::Unauthenticated(Box::new(b)),
104        },
105    }
106}
107
108/// Repository and branch of the working directory, for presence. Best
109/// effort; a directory that is not a checkout reports neither.
110fn git_place(dir: &Path) -> (Option<String>, Option<String>) {
111    let run = |args: &[&str]| {
112        std::process::Command::new("git")
113            .args(args)
114            .current_dir(dir)
115            .output()
116            .ok()
117            .filter(|o| o.status.success())
118            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
119            .filter(|s| !s.is_empty())
120    };
121    let repo = run(&["config", "--get", "remote.origin.url"]).map(|url| {
122        let trimmed = url.trim_end_matches(".git");
123        let tail: Vec<&str> = trimmed.rsplit(['/', ':']).take(2).collect();
124        if tail.len() == 2 {
125            format!("{}/{}", tail[1], tail[0])
126        } else {
127            trimmed.to_owned()
128        }
129    });
130    (repo, run(&["branch", "--show-current"]))
131}
132
133/// One tool call as the bound window, with its credential and epoch.
134async fn call_as_window(binding: &Binding, tool: &str, args: Value) -> anyhow::Result<Value> {
135    let url = binding
136        .mcp_url
137        .as_deref()
138        .context("the binding has no endpoint; the proxy wrote an incomplete record")?;
139    let token = binding
140        .session_token
141        .as_deref()
142        .context("the binding has no credential")?;
143    let session = binding.session.as_deref().unwrap_or_default();
144
145    let mut config =
146        rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(
147            url.to_owned(),
148        );
149    config.auth_header = Some(token.to_owned());
150    config.allow_stateless = true;
151    if !session.is_empty() {
152        config
153            .custom_headers
154            .insert(crate::auth::SESSION_HEADER.parse()?, session.parse()?);
155    }
156    // The proxy's epoch, never a new one: a hook that registered would bump
157    // the epoch and fence the very window it is running for.
158    if let Some(epoch) = binding.epoch {
159        config.custom_headers.insert(
160            crate::auth::EPOCH_HEADER.parse()?,
161            epoch.to_string().parse()?,
162        );
163    }
164    let transport = rmcp::transport::StreamableHttpClientTransport::from_config(config);
165    let client = {
166        use rmcp::ServiceExt;
167        rmcp::model::ClientConfig::default()
168            .serve(transport)
169            .await
170            .context("could not reach the bus with this window's credential")?
171    };
172    let arguments: rmcp::model::JsonObject =
173        serde_json::from_value(args).context("arguments must be an object")?;
174    let outcome = client
175        .call_tool(
176            rmcp::model::CallToolRequestParams::new(tool.to_owned()).with_arguments(arguments),
177        )
178        .await
179        .map_err(|e| anyhow::anyhow!("{tool}: {e}"));
180    let _ = client.cancel().await;
181    let result = outcome?;
182    if result.is_error == Some(true) {
183        bail!("{tool} returned an error");
184    }
185    Ok(result.structured_content.unwrap_or(Value::Null))
186}
187
188/// Presence for the bound window. `activity` empty clears it.
189async fn publish_presence(
190    binding: &Binding,
191    cwd: &Path,
192    status: &str,
193    reset_activity: bool,
194    ttl: i64,
195) -> anyhow::Result<()> {
196    let (repo, branch) = git_place(cwd);
197    let mut args = json!({ "status": status, "ttl_seconds": ttl });
198    if let Some(repo) = repo {
199        args["repo"] = Value::String(repo);
200    }
201    if let Some(branch) = branch {
202        args["branch"] = Value::String(branch);
203    }
204    if let Some(project) = binding.project.clone() {
205        args["project"] = Value::String(project);
206    }
207    if let Some(role) = binding.role.clone() {
208        args["role"] = Value::String(role);
209    }
210    if reset_activity {
211        args["activity"] = Value::String(String::new());
212    }
213    call_as_window(binding, "heartbeat", args).await.map(|_| ())
214}
215
216/// The lines a host injects at the start of a conversation: who this window
217/// is, what is waiting for it, and what the team has been doing.
218async fn session_start_context(binding: &Binding, digest_hours: i64) -> anyhow::Result<String> {
219    let who = call_as_window(binding, "whoami", json!({})).await?;
220    let agent = who["agent"].as_str().unwrap_or_default();
221    let team = who["team"].as_str().unwrap_or_default();
222    if agent.is_empty() || team.is_empty() {
223        bail!("the bus did not identify this window");
224    }
225    let session = who["session"].as_str().unwrap_or_default();
226
227    let mut lines = vec![format!(
228        "[ai-crew-sync] You are agent '{agent}' on team '{team}'. The team coordination bus \
229         is connected."
230    )];
231    if !session.is_empty() {
232        let mut line = format!(
233            "- This window is session '{session}'; teammates reach it exactly as \
234             '{agent}/{session}'."
235        );
236        if who["session_identity"].is_object() {
237            line.push_str(
238                " Its identity is proven by a session credential, not asserted in a header.",
239            );
240        }
241        lines.push(line);
242    }
243    match (who["project"].as_str(), who["role"].as_str()) {
244        (Some(p), Some(r)) => lines.push(format!(
245            "- Labelled project '{p}', role '{r}'. Teammates find it with list_sessions."
246        )),
247        _ => lines.push(
248            "- This window has no project/role label yet; set one with configure_session so \
249             teammates can find it with list_sessions."
250                .to_owned(),
251        ),
252    }
253    if let Some(channel) = who["default_channel"].as_str() {
254        lines.push(format!(
255            "- Messages with no channel go to #{channel} by default."
256        ));
257    }
258    let unread = who["unread_direct_messages"].as_i64().unwrap_or(0);
259    if unread > 0 {
260        lines.push(format!(
261            "- {unread} unread direct message(s) for this window. Read them with \
262             read_messages before starting work."
263        ));
264    }
265    let claimed = who["open_claimed_tasks"].as_i64().unwrap_or(0);
266    if claimed > 0 {
267        lines.push(format!(
268            "- {claimed} task(s) claimed by this window are still open (list_tasks \
269             mine_only=true)."
270        ));
271    }
272    if let Ok(digest) =
273        call_as_window(binding, "team_digest", json!({ "hours": digest_hours })).await
274    {
275        let mut compact = serde_json::to_string(&digest).unwrap_or_default();
276        if compact.len() > 2500 {
277            compact.truncate(2500);
278            compact.push_str("…(truncated — call team_digest for the whole picture)");
279        }
280        lines.push(format!(
281            "- Team activity, last {digest_hours}h (team_digest): {compact}"
282        ));
283    }
284    lines.push(
285        "- Nothing is pushed into an idle turn: call read_messages or wait_for_updates to \
286         receive what teammates sent. Claim shared work with claim_task before starting it."
287            .to_owned(),
288    );
289    Ok(lines.join("\n"))
290}
291
292/// Run one hook event. Returns what to print on stdout: the host's JSON for
293/// `session_start`, the tool's structured content for `call`, nothing for
294/// the presence events. `call` names the tool and its arguments for
295/// `Event::Call` and is ignored by every other event.
296pub async fn run(
297    config_dir: &Path,
298    binding_id: &str,
299    event: Event,
300    cwd: &Path,
301    digest_hours: i64,
302    call: Option<(String, Value)>,
303) -> anyhow::Result<Option<String>> {
304    // Refused before anything is read or contacted: the allowlist is the
305    // contract, not the binding's state.
306    if event == Event::Call
307        && let Some((tool, _)) = &call
308        && !HOOK_CALL_TOOLS.contains(&tool.as_str())
309    {
310        bail!(
311            "'{tool}' is not a hook operation: --event call serves {} only, so a hook can \
312             neither issue, rotate nor revoke a credential, nor act on the bus beyond what \
313             its scripts need",
314            HOOK_CALL_TOOLS.join(", ")
315        );
316    }
317    let resolved = resolve_binding(config_dir, binding_id);
318    if event == Event::Status {
319        let value = match &resolved {
320            Resolution::Authenticated(b) => json!({
321                "binding": binding_id,
322                "state": "authenticated",
323                "agent": b.agent,
324                "team": b.team,
325                "session": b.session,
326                "project": b.project,
327                "role": b.role,
328                "epoch": b.epoch,
329                "expires_at": b.expires_at,
330                "mcp_url": b.mcp_url,
331            }),
332            Resolution::Unauthenticated(b) => json!({
333                "binding": binding_id,
334                "state": "no-credential",
335                "agent": b.agent,
336                "team": b.team,
337                "session": b.session,
338                "closed_at": b.closed_at,
339            }),
340            Resolution::Missing => json!({
341                "binding": binding_id,
342                "state": "missing",
343                "hint": "no proxy of this conversation has registered a session; hooks stay \
344                         silent rather than acting as another window",
345            }),
346        };
347        return Ok(Some(serde_json::to_string_pretty(&value)?));
348    }
349
350    // Silence is the contract for every other event: a hook that cannot
351    // prove which window it is must do nothing at all, not act as a shared
352    // identity.
353    let binding = match resolved {
354        Resolution::Authenticated(b) => b,
355        Resolution::Unauthenticated(_) | Resolution::Missing => return Ok(None),
356    };
357
358    match event {
359        Event::Status => unreachable!("handled above"),
360        Event::SessionStart => {
361            // Presence first, with the activity line cleared: a session that
362            // has just started has not done anything yet.
363            let _ = publish_presence(&binding, cwd, "active", true, 900).await;
364            let context = session_start_context(&binding, digest_hours).await?;
365            Ok(Some(
366                json!({
367                    "hookSpecificOutput": {
368                        "hookEventName": "SessionStart",
369                        "additionalContext": context,
370                    }
371                })
372                .to_string(),
373            ))
374        }
375        Event::Heartbeat | Event::Stop => {
376            publish_presence(&binding, cwd, "active", false, 900).await?;
377            Ok(None)
378        }
379        Event::SessionEnd => {
380            publish_presence(&binding, cwd, "idle", false, 120).await?;
381            Ok(None)
382        }
383        Event::Call => {
384            let Some((tool, args)) = call else {
385                bail!("--event call needs --tool <name> and, optionally, --args <json object>");
386            };
387            let value = call_as_window(&binding, &tool, args).await?;
388            Ok(Some(value.to_string()))
389        }
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn event_names_are_forgiving_but_bounded() {
399        for (raw, expected) in [
400            ("session_start", Event::SessionStart),
401            ("SessionStart", Event::SessionStart),
402            ("session-start", Event::SessionStart),
403            ("heartbeat", Event::Heartbeat),
404            ("Stop", Event::Stop),
405            ("session_end", Event::SessionEnd),
406            ("status", Event::Status),
407            ("call", Event::Call),
408        ] {
409            assert_eq!(raw.parse::<Event>().unwrap(), expected, "{raw}");
410        }
411        let err = "drain".parse::<Event>().unwrap_err().to_string();
412        assert!(err.contains("session_start"), "{err}");
413    }
414
415    #[tokio::test]
416    async fn a_missing_or_closed_binding_is_silent_never_another_window() {
417        let dir = std::env::temp_dir().join(format!("acs-hook-{}", uuid::Uuid::new_v4()));
418        std::fs::create_dir_all(&dir).unwrap();
419        let cwd = dir.clone();
420
421        // Nothing at all: silence, and no attempt to reach any bus.
422        for event in [Event::SessionStart, Event::Heartbeat, Event::SessionEnd] {
423            assert!(
424                run(&dir, "conv-unknown", event, &cwd, 8, None)
425                    .await
426                    .unwrap()
427                    .is_none(),
428                "{event:?} spoke without a binding"
429            );
430        }
431
432        // A closed window: the record is there, the credential is not.
433        let path = context::binding_path(&dir, "conv-closed");
434        context::write_binding_file(
435            &path,
436            &json!({"session": "s-1", "agent": "joaquin", "team": "acme",
437                    "mcp_url": "http://127.0.0.1:1/mcp", "closed_at": "2026-09-20T00:00:00Z"})
438            .to_string(),
439        )
440        .unwrap();
441        assert!(
442            run(&dir, "conv-closed", Event::Heartbeat, &cwd, 8, None)
443                .await
444                .unwrap()
445                .is_none()
446        );
447
448        // A closed window that kept its credential for the proxy's resume:
449        // just as silent, and the secret stays off the status output.
450        let retained = context::binding_path(&dir, "conv-closed-token");
451        context::write_binding_file(
452            &retained,
453            &json!({"session": "s-2", "agent": "joaquin", "team": "acme",
454                    "mcp_url": "http://127.0.0.1:1/mcp", "session_token": "acss_retained",
455                    "session_id": "11111111-1111-1111-1111-111111111111", "epoch": 3,
456                    "closed_at": "2026-09-20T00:00:00Z"})
457            .to_string(),
458        )
459        .unwrap();
460        assert!(
461            run(&dir, "conv-closed-token", Event::Heartbeat, &cwd, 8, None)
462                .await
463                .unwrap()
464                .is_none(),
465            "a closed binding must not act, even with a credential on disk"
466        );
467        let out = run(&dir, "conv-closed-token", Event::Status, &cwd, 8, None)
468            .await
469            .unwrap()
470            .unwrap();
471        assert!(out.contains("no-credential"), "{out}");
472        assert!(
473            !out.contains("acss_retained"),
474            "status must not print a secret"
475        );
476
477        // A lifecycle or write tool is refused before the binding is even
478        // read, live or not: a hook that could resume the session would
479        // rotate the proxy's credential and print the new secret.
480        let live = context::binding_path(&dir, "conv-live");
481        context::write_binding_file(
482            &live,
483            &json!({"session": "s-3", "agent": "joaquin", "team": "acme",
484                    "mcp_url": "http://127.0.0.1:1/mcp", "session_token": "acss_live",
485                    "session_id": "22222222-2222-2222-2222-222222222222", "epoch": 1})
486            .to_string(),
487        )
488        .unwrap();
489        for tool in [
490            "resume_session",
491            "register_session",
492            "renew_session",
493            "revoke_session",
494            "recover_conversation_history",
495            "post_message",
496        ] {
497            let call = Some((tool.to_owned(), json!({})));
498            let err = run(&dir, "conv-live", Event::Call, &cwd, 8, call)
499                .await
500                .expect_err(tool)
501                .to_string();
502            assert!(err.contains("not a hook operation"), "{tool}: {err}");
503            assert!(!err.contains("acss_"), "{tool}: {err}");
504        }
505
506        // A call as the window is just as silent without a usable binding:
507        // it must never reach a bus as a shared identity.
508        for id in ["conv-unknown", "conv-closed", "conv-closed-token"] {
509            let call = Some(("whoami".to_owned(), json!({})));
510            assert!(
511                run(&dir, id, Event::Call, &cwd, 8, call)
512                    .await
513                    .unwrap()
514                    .is_none(),
515                "call spoke for {id}"
516            );
517        }
518
519        // `status` explains both cases without inventing an identity.
520        let out = run(&dir, "conv-unknown", Event::Status, &cwd, 8, None)
521            .await
522            .unwrap()
523            .unwrap();
524        assert!(out.contains("\"state\": \"missing\""), "{out}");
525        let out = run(&dir, "conv-closed", Event::Status, &cwd, 8, None)
526            .await
527            .unwrap()
528            .unwrap();
529        assert!(out.contains("no-credential"), "{out}");
530        assert!(
531            !out.contains("session_token"),
532            "status must not print a secret"
533        );
534
535        #[cfg(unix)]
536        {
537            use std::os::unix::fs::PermissionsExt;
538            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
539            assert_eq!(mode, 0o600, "binding files are private");
540            let dir_mode = std::fs::metadata(path.parent().unwrap())
541                .unwrap()
542                .permissions()
543                .mode()
544                & 0o777;
545            assert_eq!(dir_mode, 0o700, "the bindings directory is private");
546        }
547        let _ = std::fs::remove_dir_all(&dir);
548    }
549}