Skip to main content

car_server_core/assistant/
governance.rs

1//! Durable governance primitives for repository-scoped supervised sessions.
2//!
3//! This module is deliberately pure. The chat loop and daemon sync adapter own
4//! I/O; these types define the values that are persisted and the transitions
5//! that are legal, so restart and adversarial tests do not need a live model.
6
7use car_inference::tasks::generate::Message;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use sha2::{Digest, Sha256};
11use std::path::{Path, PathBuf};
12
13pub const CHECKPOINT_REGISTRY_KIND: &str = "assistant-checkpoint";
14pub const ACTION_REGISTRY_KIND: &str = "assistant-action";
15
16/// Canonical, existing repository root accepted by governed-host execution.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct RepositoryScope {
19    root: PathBuf,
20}
21
22impl RepositoryScope {
23    /// Validate an explicitly supplied repository root. The filesystem's
24    /// canonical path is the authority, which closes `..` and symlink aliases.
25    pub fn explicit(path: Option<&Path>) -> Result<Self, String> {
26        let path = path.ok_or("governed host execution requires an explicit --dir")?;
27        if !path.is_dir() {
28            return Err(format!(
29                "repository root '{}' is not a directory",
30                path.display()
31            ));
32        }
33        let root = path
34            .canonicalize()
35            .map_err(|e| format!("cannot resolve repository root '{}': {e}", path.display()))?;
36        if root.parent().is_none() {
37            return Err("repository root cannot be the filesystem root".to_string());
38        }
39        if let Some(home) = home_dir().and_then(|p| p.canonicalize().ok()) {
40            if root == home {
41                return Err("repository root cannot be the user's home directory".to_string());
42            }
43        }
44        // A repository-scoped agent must actually point at a repository. A
45        // worktree's `.git` may be either a directory or a gitdir file.
46        if !root.join(".git").exists() {
47            return Err(format!("'{}' is not a Git repository root", root.display()));
48        }
49        Ok(Self { root })
50    }
51
52    pub fn root(&self) -> &Path {
53        &self.root
54    }
55
56    /// Resolve an existing path and prove it remains beneath this scope.
57    pub fn existing_path(&self, path: &Path) -> Result<PathBuf, String> {
58        let candidate = if path.is_absolute() {
59            path.to_path_buf()
60        } else {
61            self.root.join(path)
62        };
63        let resolved = candidate
64            .canonicalize()
65            .map_err(|e| format!("cannot resolve '{}': {e}", candidate.display()))?;
66        if !resolved.starts_with(&self.root) {
67            return Err(format!(
68                "path '{}' escapes repository scope",
69                path.display()
70            ));
71        }
72        Ok(resolved)
73    }
74
75    /// Resolve a prospective write. Its nearest existing ancestor is
76    /// canonicalized, preventing a symlinked parent from escaping the root.
77    pub fn write_path(&self, path: &Path) -> Result<PathBuf, String> {
78        let candidate = if path.is_absolute() {
79            path.to_path_buf()
80        } else {
81            self.root.join(path)
82        };
83        let mut ancestor = candidate.as_path();
84        while !ancestor.exists() {
85            ancestor = ancestor
86                .parent()
87                .ok_or_else(|| format!("path '{}' has no existing ancestor", path.display()))?;
88        }
89        let resolved_ancestor = ancestor
90            .canonicalize()
91            .map_err(|e| format!("cannot resolve '{}': {e}", ancestor.display()))?;
92        if !resolved_ancestor.starts_with(&self.root) {
93            return Err(format!(
94                "path '{}' escapes repository scope",
95                path.display()
96            ));
97        }
98        let suffix = candidate
99            .strip_prefix(ancestor)
100            .map_err(|_| format!("cannot scope '{}'", candidate.display()))?;
101        Ok(resolved_ancestor.join(suffix))
102    }
103}
104
105fn home_dir() -> Option<PathBuf> {
106    std::env::var_os("HOME")
107        .or_else(|| std::env::var_os("USERPROFILE"))
108        .map(PathBuf::from)
109}
110
111/// Names a host credential capability without containing credential material.
112#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
113pub struct CredentialCapability(pub String);
114
115/// Exact scope shown to the operator and covered by the grant digest.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct ActionScope {
118    pub tool: String,
119    pub parameters: Value,
120    pub repository_root: PathBuf,
121    pub target: String,
122    pub environment: String,
123    #[serde(default)]
124    pub credential_capabilities: Vec<CredentialCapability>,
125}
126
127impl ActionScope {
128    pub fn canonicalize(mut self) -> Self {
129        self.credential_capabilities.sort();
130        self.credential_capabilities.dedup();
131        self
132    }
133
134    /// Content identity for approval and at-most-once dispatch. Secret values
135    /// are absent by construction; only capability names participate.
136    pub fn action_id(&self, session_id: &str, call_id: &str) -> String {
137        let scope = self.clone().canonicalize();
138        let value = serde_json::to_value(&scope).expect("ActionScope serializes");
139        let mut h = Sha256::new();
140        h.update(b"car-supervised-action-v1\x1f");
141        h.update(session_id.as_bytes());
142        h.update(b"\x1f");
143        h.update(call_id.as_bytes());
144        h.update(b"\x1f");
145        h.update(car_sync::canonical_json(&value).as_bytes());
146        format!("action-{:x}", h.finalize())
147    }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum ActionState {
153    Proposed,
154    Approved,
155    Denied,
156    Dispatched,
157    Completed,
158    Failed,
159    Indeterminate,
160}
161
162impl ActionState {
163    pub fn is_terminal(self) -> bool {
164        matches!(
165            self,
166            Self::Denied | Self::Completed | Self::Failed | Self::Indeterminate
167        )
168    }
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172pub struct SupervisedActionRecord {
173    pub id: String,
174    pub session_id: String,
175    pub call_id: String,
176    pub scope: ActionScope,
177    pub state: ActionState,
178    /// Runtime-generated receipt or diagnostic metadata. Never model-authored.
179    #[serde(default)]
180    pub receipt: Option<Value>,
181}
182
183impl SupervisedActionRecord {
184    pub fn propose(session_id: &str, call_id: &str, scope: ActionScope) -> Self {
185        let scope = scope.canonicalize();
186        Self {
187            id: scope.action_id(session_id, call_id),
188            session_id: session_id.to_string(),
189            call_id: call_id.to_string(),
190            scope,
191            state: ActionState::Proposed,
192            receipt: None,
193        }
194    }
195
196    pub fn transition(&mut self, next: ActionState, receipt: Option<Value>) -> Result<(), String> {
197        let valid = matches!(
198            (self.state, next),
199            (
200                ActionState::Proposed,
201                ActionState::Approved | ActionState::Denied
202            ) | (ActionState::Approved, ActionState::Dispatched)
203                | (
204                    ActionState::Dispatched,
205                    ActionState::Completed | ActionState::Failed | ActionState::Indeterminate
206                )
207        );
208        if !valid {
209            return Err(format!(
210                "invalid supervised action transition {:?} -> {:?}",
211                self.state, next
212            ));
213        }
214        self.state = next;
215        self.receipt = receipt;
216        Ok(())
217    }
218
219    /// Resume never automatically replays an action whose effect may have
220    /// crossed the process boundary.
221    pub fn resume_directive(&self) -> ResumeDirective {
222        match self.state {
223            ActionState::Proposed => ResumeDirective::AwaitApproval,
224            ActionState::Approved => ResumeDirective::Dispatch,
225            ActionState::Dispatched => ResumeDirective::MarkIndeterminate,
226            ActionState::Denied
227            | ActionState::Completed
228            | ActionState::Failed
229            | ActionState::Indeterminate => ResumeDirective::DoNotDispatch,
230        }
231    }
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum ResumeDirective {
236    AwaitApproval,
237    Dispatch,
238    MarkIndeterminate,
239    DoNotDispatch,
240}
241
242/// Durable grant. `action_id` is sufficient to bind all scope fields because
243/// it is their canonical digest; the redundant scope makes receipts legible.
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct ActionGrant {
246    pub action_id: String,
247    pub scope: ActionScope,
248    pub approved: bool,
249}
250
251impl ActionGrant {
252    pub fn authorizes(&self, action: &SupervisedActionRecord) -> bool {
253        self.approved
254            && self.action_id == action.id
255            && self.scope.clone().canonicalize() == action.scope
256            && action.state == ActionState::Proposed
257    }
258}
259
260#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
261pub struct CompletionMatrix {
262    pub local_verification: Option<String>,
263    pub remote_main: Option<String>,
264    pub ci_cd: Option<String>,
265    pub deployment: Option<String>,
266    pub health: Option<String>,
267    pub production_browser_proof: Option<String>,
268}
269
270/// Maximum tool-result bytes exposed on the chat wire or human CLI.
271///
272/// The model-facing observation has its own, larger bound. This smaller host
273/// projection is deliberately independent: a useful result row must not turn a
274/// chat transcript into a second copy of a 64 KiB HTTP body.
275pub const CHAT_TOOL_RESULT_EXCERPT_BYTES: usize = 2 * 1024;
276const CHAT_EVIDENCE_STRING_BYTES: usize = 512;
277const CHAT_EVIDENCE_ITEMS: usize = 20;
278const CHAT_TOOL_RECEIPTS: usize = 100;
279/// Desktop actions kept on one turn receipt. Bounding the list where it is
280/// BUILT leaves [`bound_receipt_report`]'s 64 KiB trim as the safety net it is
281/// meant to be, rather than the only thing standing between a tool-heavy turn
282/// and an unbounded array.
283const CHAT_DESKTOP_ACTIONS: usize = 100;
284/// Whole serialized `tool_receipts` array cap. Per-row bounds alone still let
285/// 100 near-limit rows turn one terminal frame into a ~200 KiB payload.
286const CHAT_TOOL_RECEIPT_REPORT_BYTES: usize = 64 * 1024;
287/// Hard ceiling for the complete serialized `receipt_report` chat frame.
288pub const CHAT_RECEIPT_REPORT_BYTES: usize = 64 * 1024;
289
290/// Redacted, bounded projection of one tool observation for a host UI.
291#[derive(Debug, Clone, PartialEq, Serialize)]
292pub struct BoundedToolResult {
293    pub tool: String,
294    pub ok: bool,
295    pub excerpt: String,
296    pub evidence: Value,
297}
298
299/// One non-software action shown in a turn receipt.
300#[derive(Debug, Clone, PartialEq, Serialize)]
301pub struct DesktopActionEvidence {
302    pub action: String,
303    pub target: Option<String>,
304    pub identifier: Option<String>,
305    pub verified: bool,
306    pub evidence: Value,
307}
308
309/// Project a raw tool observation onto the safe host-facing shape.
310///
311/// Every string is scrubbed through the feedback redactor before projection.
312/// Evidence is closed-world: only identifiers, destinations, statuses, titles,
313/// and collection counts are copied. Request bodies, headers, and arbitrary
314/// response fields never cross this boundary.
315pub fn bounded_tool_result(
316    tool: &str,
317    ok: bool,
318    content: &str,
319    params: Option<&Value>,
320) -> BoundedToolResult {
321    let parsed = serde_json::from_str::<Value>(content).ok().map(|value| {
322        car_feedback_core::redact::redact_json(car_feedback_core::redact::strip_env_maps(value))
323    });
324    // Prefer the structured scrub when possible: it removes non-allowlisted
325    // header fields entirely. Text-only scrubbing cannot know that a short
326    // `Authorization` value inside serialized JSON is still a credential.
327    let redacted_excerpt = parsed
328        .as_ref()
329        .map(Value::to_string)
330        .unwrap_or_else(|| car_feedback_core::redact::redact_text(content));
331    let excerpt = bound_string(&redacted_excerpt, CHAT_TOOL_RESULT_EXCERPT_BYTES);
332    BoundedToolResult {
333        tool: bound_string(tool, CHAT_EVIDENCE_STRING_BYTES),
334        ok,
335        excerpt,
336        evidence: extract_tool_evidence(parsed.as_ref(), params),
337    }
338}
339
340/// Bounded per-tool receipts emitted beside the turn receipt.
341///
342/// The returned omission count keeps a pathological turn honest without
343/// allowing an unbounded list onto the host wire.
344pub fn tool_receipts_for_wire(
345    receipts: &[super::agent_loop::AssistantToolReceipt],
346) -> (Vec<Value>, usize) {
347    let mut rows = Vec::new();
348    // JSON array brackets. Each later row also needs one comma.
349    let mut serialized_bytes = 2usize;
350    for receipt in receipts.iter().take(CHAT_TOOL_RECEIPTS) {
351        let result = bounded_tool_result(
352            &receipt.tool,
353            receipt.ok,
354            receipt.result.as_deref().unwrap_or_default(),
355            Some(&receipt.params),
356        );
357        let row = serde_json::json!({
358            "tool": result.tool,
359            "call_id": receipt.call_id.as_deref().map(|v| bound_string(v, CHAT_EVIDENCE_STRING_BYTES)),
360            "sequence": receipt.sequence,
361            "ok": receipt.ok,
362            "via": receipt.via.as_deref().map(|v| bound_string(v, CHAT_EVIDENCE_STRING_BYTES)),
363            "excerpt": result.excerpt,
364            "evidence": result.evidence,
365        });
366        let row_bytes = serde_json::to_vec(&row).map_or(0, |encoded| encoded.len());
367        let delimiter = usize::from(!rows.is_empty());
368        if serialized_bytes
369            .saturating_add(delimiter)
370            .saturating_add(row_bytes)
371            > CHAT_TOOL_RECEIPT_REPORT_BYTES
372        {
373            break;
374        }
375        serialized_bytes += delimiter + row_bytes;
376        rows.push(row);
377    }
378    let omitted = receipts.len().saturating_sub(rows.len());
379    (rows, omitted)
380}
381
382/// Bound completion-stage strings before they enter the terminal receipt.
383pub fn completion_matrix_for_wire(matrix: &CompletionMatrix) -> Value {
384    let mut value = serde_json::to_value(matrix).unwrap_or(Value::Null);
385    if let Some(object) = value.as_object_mut() {
386        for item in object.values_mut() {
387            if let Some(text) = item.as_str() {
388                *item = Value::String(bound_string(text, CHAT_TOOL_RESULT_EXCERPT_BYTES));
389            }
390        }
391    }
392    value
393}
394
395/// Enforce a ceiling on the complete receipt frame, not just each row.
396///
397/// The most repetitive evidence is removed first, with omission counts kept on
398/// the frame. Completion strings are already independently bounded above.
399pub fn bound_receipt_report(mut frame: Value) -> Value {
400    let encoded_len = |value: &Value| serde_json::to_vec(value).map_or(usize::MAX, |v| v.len());
401    while encoded_len(&frame) > CHAT_RECEIPT_REPORT_BYTES {
402        let Some(object) = frame.as_object_mut() else {
403            break;
404        };
405        let removed_tool = object
406            .get_mut("tool_receipts")
407            .and_then(Value::as_array_mut)
408            .is_some_and(|rows| rows.pop().is_some());
409        if removed_tool {
410            let omitted = object
411                .get("tool_receipts_omitted")
412                .and_then(Value::as_u64)
413                .unwrap_or(0)
414                .saturating_add(1);
415            object.insert("tool_receipts_omitted".into(), Value::from(omitted));
416            continue;
417        }
418        let removed_action = object
419            .get_mut("desktop_actions")
420            .and_then(Value::as_array_mut)
421            .is_some_and(|rows| rows.pop().is_some());
422        if removed_action {
423            let omitted = object
424                .get("desktop_actions_omitted")
425                .and_then(Value::as_u64)
426                .unwrap_or(0)
427                .saturating_add(1);
428            object.insert("desktop_actions_omitted".into(), Value::from(omitted));
429            continue;
430        }
431        let removed_claim = object
432            .get_mut("ungrounded_claims")
433            .and_then(Value::as_array_mut)
434            .is_some_and(|rows| rows.pop().is_some());
435        if removed_claim {
436            object.insert("ungrounded_claims_omitted".into(), Value::Bool(true));
437            continue;
438        }
439        // Defensive fallback for a future additive field that ignored all row
440        // bounds. Preserve the frame identity and make the loss explicit.
441        let kind = object.get("kind").cloned().unwrap_or(Value::Null);
442        let session_id = object.get("session_id").cloned().unwrap_or(Value::Null);
443        let tool_receipts_omitted = object
444            .get("tool_receipts_omitted")
445            .and_then(Value::as_u64)
446            .unwrap_or(0);
447        frame = serde_json::json!({
448            "kind": kind,
449            "session_id": session_id,
450            "receipt_report_truncated": true,
451            "tool_receipts_omitted": tool_receipts_omitted,
452        });
453        break;
454    }
455    frame
456}
457
458/// Build the task-oriented receipt section. Software-delivery evidence remains
459/// in [`CompletionMatrix`]; this projection is for desktop, personal-data, and
460/// web actions whose useful proof is an object id, message id, count, or final
461/// destination instead of a CI/deploy stage.
462///
463/// `mutating` is the advertised tool defs' self-declared mutation set (see
464/// [`super::agent_loop::mutating_tool_names`]). Name matching alone was wrong
465/// in both directions: `mail_draft` contains none of create/update/delete/send,
466/// and `browser_await_signin`/`browser_record_start`/`browser_record_stop` were
467/// added to the browser surface after the literal list was written — each was
468/// reported `verified: true` on `ok` alone, with no confirming read. The defs
469/// carry the flag already, so a tool added tomorrow is classified without
470/// editing this file. The literal list is kept as a union fallback for a
471/// receipt whose tool is not in the defs (a replayed transcript, a caller that
472/// advertised nothing): it can only ever add mutations, never remove one.
473/// Returns the bounded action list and how many did not fit its cap, so the
474/// frame can say what it dropped instead of a host silently seeing fewer
475/// actions than happened.
476pub fn desktop_actions_from_tool_receipts(
477    receipts: &[super::agent_loop::AssistantToolReceipt],
478    mutating: &std::collections::HashSet<String>,
479) -> (Vec<DesktopActionEvidence>, usize) {
480    // One projection per receipt, computed once. `bounded_tool_result` parses
481    // the observation as JSON and walks it through the feedback redactor; the
482    // write-verification scan below looks forward over every later receipt, so
483    // computing it inside that scan repeated the same parse and redaction once
484    // per write per later receipt.
485    let projected: Vec<BoundedToolResult> = receipts
486        .iter()
487        .map(|receipt| {
488            bounded_tool_result(
489                &receipt.tool,
490                receipt.ok,
491                receipt.result.as_deref().unwrap_or_default(),
492                Some(&receipt.params),
493            )
494        })
495        .collect();
496    let mut actions: Vec<DesktopActionEvidence> = Vec::new();
497    let mut omitted = 0usize;
498    for (index, receipt) in receipts.iter().enumerate() {
499        if !is_desktop_action_tool(&receipt.tool) {
500            continue;
501        }
502        if actions.len() == CHAT_DESKTOP_ACTIONS {
503            omitted += 1;
504            continue;
505        }
506        let result = &projected[index];
507        let identifier = preferred_identifier(&result.evidence);
508        let verified = receipt.ok
509            && (!is_desktop_mutation(&receipt.tool, mutating)
510                || identifier.as_deref().is_some_and(|identifier| {
511                    receipts[index + 1..]
512                        .iter()
513                        .zip(&projected[index + 1..])
514                        .any(|(later, later_result)| {
515                            later.ok
516                                && !is_desktop_mutation(&later.tool, mutating)
517                                && evidence_has_identifier(&later_result.evidence, identifier)
518                        })
519                }));
520        actions.push(DesktopActionEvidence {
521            action: bound_string(&receipt.tool, CHAT_EVIDENCE_STRING_BYTES),
522            target: action_target(&receipt.params),
523            identifier,
524            verified,
525            evidence: result.evidence.clone(),
526        });
527    }
528    (actions, omitted)
529}
530
531fn is_desktop_action_tool(tool: &str) -> bool {
532    tool.starts_with("calendar_")
533        || tool.starts_with("mail_")
534        || tool.starts_with("messages_")
535        || tool.starts_with("browse_")
536        || tool.starts_with("browser_")
537        || tool.starts_with("automation_")
538        || matches!(tool, "http_request" | "web_search" | "m365_task")
539}
540
541fn is_desktop_mutation(tool: &str, mutating: &std::collections::HashSet<String>) -> bool {
542    mutating.contains(tool) || is_desktop_mutation_by_name(tool)
543}
544
545/// The defs-independent fallback. Deliberately over-inclusive: a false
546/// "mutation" only demands a confirming read before a row reads `verified`.
547fn is_desktop_mutation_by_name(tool: &str) -> bool {
548    tool.contains("create")
549        || tool.contains("update")
550        || tool.contains("delete")
551        || tool.contains("send")
552        || matches!(
553            tool,
554            "browse_click"
555                | "browse_type"
556                | "browse_keypress"
557                | "browse_paste"
558                | "browse_navigate"
559                | "browse_scroll"
560                | "mail_draft"
561                | "browser_await_signin"
562                | "browser_record_start"
563                | "browser_record_stop"
564                | "automation_run_applescript"
565                | "automation_run_powershell"
566                | "automation_shortcuts_run"
567                | "m365_task"
568        )
569}
570
571fn action_target(params: &Value) -> Option<String> {
572    [
573        "title",
574        "url",
575        "to",
576        "recipient",
577        "event_id",
578        "message_id",
579        "query",
580        "task",
581        "path",
582    ]
583    .into_iter()
584    .find_map(|key| params.get(key).and_then(Value::as_str))
585    .map(car_feedback_core::redact::redact_text)
586    .map(|value| bound_string(&value, CHAT_EVIDENCE_STRING_BYTES))
587}
588
589fn preferred_identifier(evidence: &Value) -> Option<String> {
590    let object = evidence.as_object()?;
591    ["event_id", "message_id", "id", "final_url", "requested_url"]
592        .into_iter()
593        .find_map(|key| object.get(key).and_then(Value::as_str))
594        .map(str::to_string)
595        .or_else(|| {
596            object
597                .get("event_ids")
598                .and_then(Value::as_array)
599                .and_then(|ids| ids.first())
600                .and_then(Value::as_str)
601                .map(str::to_string)
602        })
603        .or_else(|| {
604            object
605                .get("message_ids")
606                .and_then(Value::as_array)
607                .and_then(|ids| ids.first())
608                .and_then(Value::as_str)
609                .map(str::to_string)
610        })
611}
612
613fn evidence_has_identifier(evidence: &Value, identifier: &str) -> bool {
614    let Some(object) = evidence.as_object() else {
615        return false;
616    };
617    ["event_id", "message_id", "id", "final_url", "requested_url"]
618        .into_iter()
619        .any(|key| object.get(key).and_then(Value::as_str) == Some(identifier))
620        || ["event_ids", "message_ids", "result_ids"]
621            .into_iter()
622            .any(|key| {
623                object
624                    .get(key)
625                    .and_then(Value::as_array)
626                    .is_some_and(|values| {
627                        values
628                            .iter()
629                            .any(|value| value.as_str() == Some(identifier))
630                    })
631            })
632}
633
634fn extract_tool_evidence(parsed: Option<&Value>, params: Option<&Value>) -> Value {
635    let mut evidence = serde_json::Map::new();
636    if let Some(requested) = params
637        .and_then(|value| value.get("url"))
638        .and_then(Value::as_str)
639    {
640        evidence.insert(
641            "requested_url".into(),
642            Value::String(bound_string(
643                &car_feedback_core::redact::redact_url(requested),
644                CHAT_EVIDENCE_STRING_BYTES,
645            )),
646        );
647    }
648    let Some(object) = parsed.and_then(Value::as_object) else {
649        return Value::Object(evidence);
650    };
651    for key in [
652        "requested_url",
653        "final_url",
654        "url",
655        "status",
656        "title",
657        "event_id",
658        "message_id",
659        "id",
660        "count",
661        "total",
662    ] {
663        if let Some(value) = object.get(key).and_then(bounded_scalar) {
664            evidence.insert(key.into(), value);
665        }
666    }
667    let redirected = evidence
668        .get("requested_url")
669        .and_then(Value::as_str)
670        .zip(evidence.get("final_url").and_then(Value::as_str))
671        .map(|(requested, final_url)| requested != final_url);
672    if let Some(redirected) = redirected {
673        evidence.insert("redirected".into(), Value::Bool(redirected));
674    }
675    if let Some(event) = object.get("event").and_then(Value::as_object) {
676        copy_nested_evidence(event, "id", "event_id", &mut evidence);
677        copy_nested_evidence(event, "title", "title", &mut evidence);
678        copy_nested_evidence(event, "url", "url", &mut evidence);
679    }
680    for (array_key, count_key, ids_key, id_field) in [
681        ("events", "event_count", "event_ids", "id"),
682        ("messages", "message_count", "message_ids", "message_id"),
683        ("results", "result_count", "result_ids", "id"),
684    ] {
685        let Some(items) = object.get(array_key).and_then(Value::as_array) else {
686            continue;
687        };
688        evidence.insert(count_key.into(), serde_json::json!(items.len()));
689        let ids: Vec<Value> = items
690            .iter()
691            .take(CHAT_EVIDENCE_ITEMS)
692            .filter_map(|item| {
693                item.get(id_field)
694                    .or_else(|| item.get("id"))
695                    .and_then(Value::as_str)
696            })
697            .map(|id| Value::String(bound_string(id, CHAT_EVIDENCE_STRING_BYTES)))
698            .collect();
699        if !ids.is_empty() {
700            evidence.insert(ids_key.into(), Value::Array(ids));
701        }
702    }
703    Value::Object(evidence)
704}
705
706fn copy_nested_evidence(
707    source: &serde_json::Map<String, Value>,
708    source_key: &str,
709    destination_key: &str,
710    destination: &mut serde_json::Map<String, Value>,
711) {
712    if destination.contains_key(destination_key) {
713        return;
714    }
715    if let Some(value) = source.get(source_key).and_then(bounded_scalar) {
716        destination.insert(destination_key.into(), value);
717    }
718}
719
720fn bounded_scalar(value: &Value) -> Option<Value> {
721    match value {
722        Value::String(value) => Some(Value::String(bound_string(
723            value,
724            CHAT_EVIDENCE_STRING_BYTES,
725        ))),
726        Value::Number(_) | Value::Bool(_) | Value::Null => Some(value.clone()),
727        Value::Array(_) | Value::Object(_) => None,
728    }
729}
730
731fn bound_string(value: &str, cap: usize) -> String {
732    if value.len() <= cap {
733        return value.to_string();
734    }
735    let mut end = cap;
736    while !value.is_char_boundary(end) {
737        end -= 1;
738    }
739    format!("{}…[truncated]…", &value[..end])
740}
741
742#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
743pub struct AssistantCheckpoint {
744    pub id: String,
745    pub session_id: String,
746    pub revision: u64,
747    pub repository_root: PathBuf,
748    pub messages: Vec<Message>,
749    #[serde(default)]
750    pub goal: Option<Value>,
751    #[serde(default)]
752    pub compaction: Option<Value>,
753    #[serde(default)]
754    pub completion: CompletionMatrix,
755}
756
757/// Project a conservative completion matrix from runtime tool receipts in the
758/// exact transcript. A field is populated only for an answered, successful
759/// call; shell success additionally requires `exit_code == 0`.
760pub fn completion_matrix_from_messages(messages: &[Message]) -> CompletionMatrix {
761    let mut calls: std::collections::HashMap<String, (String, Value)> =
762        std::collections::HashMap::new();
763    let mut matrix = CompletionMatrix::default();
764    for message in messages {
765        match message {
766            Message::Assistant { tool_calls, .. } => {
767                for call in tool_calls {
768                    if let Some(id) = &call.id {
769                        calls.insert(
770                            id.clone(),
771                            (
772                                call.name.clone(),
773                                serde_json::to_value(&call.arguments).unwrap_or(Value::Null),
774                            ),
775                        );
776                    }
777                }
778            }
779            Message::ToolResult {
780                tool_use_id,
781                content,
782                ..
783            } => {
784                let Some((tool, params)) = calls.get(tool_use_id) else {
785                    continue;
786                };
787                let parsed = serde_json::from_str::<Value>(content).ok();
788                let failed = content.starts_with("[FAILED]")
789                    || content.starts_with("[REJECTED]")
790                    || parsed
791                        .as_ref()
792                        .and_then(|value| value.get("error"))
793                        .is_some();
794                let shell_ok = tool != "shell"
795                    || parsed
796                        .as_ref()
797                        .and_then(|value| value.get("exit_code"))
798                        .and_then(Value::as_i64)
799                        == Some(0)
800                    || parsed
801                        .as_ref()
802                        .and_then(|value| value.get("ok"))
803                        .and_then(Value::as_bool)
804                        == Some(true);
805                if failed || !shell_ok {
806                    continue;
807                }
808                let command = params
809                    .get("command")
810                    .and_then(Value::as_str)
811                    .unwrap_or_default()
812                    .to_ascii_lowercase();
813                let command_tokens: Vec<&str> = command
814                    .split_whitespace()
815                    .map(|token| {
816                        token.trim_matches(|ch: char| {
817                            !ch.is_ascii_alphanumeric() && ch != '-' && ch != '/' && ch != '.'
818                        })
819                    })
820                    .filter(|token| !token.is_empty())
821                    .collect();
822                let evidence = format!("{tool} receipt {tool_use_id}: {content}");
823                if tool.starts_with("browser_") {
824                    matrix.production_browser_proof = Some(evidence.clone());
825                }
826                if command.contains("git push") {
827                    matrix.remote_main = Some(evidence.clone());
828                }
829                let is_ci = command_tokens
830                    .windows(2)
831                    .any(|pair| matches!(pair, ["az", "pipelines"] | ["gh", "run"]))
832                    || command_tokens.contains(&"pipeline");
833                if is_ci {
834                    matrix.ci_cd = Some(evidence.clone());
835                }
836                let is_deployment = command_tokens
837                    .iter()
838                    .any(|token| matches!(*token, "deploy" | "deployment"))
839                    || command.contains("/deploy.")
840                    || command.contains("/deploy/");
841                if is_deployment {
842                    matrix.deployment = Some(evidence.clone());
843                }
844                if command.contains("health") || command.contains("ready") {
845                    matrix.health = Some(evidence.clone());
846                }
847                if command.contains("test")
848                    || command.contains("cargo check")
849                    || command.contains("dotnet build")
850                    || command.contains("dotnet run")
851                    || command.contains("node --test")
852                    || command.contains("npm test")
853                    || command.contains("pnpm test")
854                    || command.contains("yarn test")
855                {
856                    matrix.local_verification = Some(evidence);
857                }
858            }
859            _ => {}
860        }
861    }
862    matrix
863}
864
865/// Persistence seam used by the supervised loop. Production implements this
866/// through daemon sync RPCs; tests can use an in-memory oplog-backed adapter.
867#[async_trait::async_trait]
868pub trait AssistantDurability: Send + Sync {
869    async fn load_checkpoint(
870        &self,
871        session_id: &str,
872    ) -> Result<Option<AssistantCheckpoint>, String>;
873
874    /// Append a new exact checkpoint. Implementations assign a strictly
875    /// increasing revision and retain `reason` as compaction/transition audit
876    /// metadata; callers never maintain a second conversation store.
877    async fn checkpoint(
878        &self,
879        session_id: &str,
880        messages: &[Message],
881        reason: &str,
882        goal: Option<Value>,
883    ) -> Result<(), String>;
884
885    async fn load_action(&self, action_id: &str) -> Result<Option<SupervisedActionRecord>, String>;
886
887    async fn record_action(&self, record: &SupervisedActionRecord) -> Result<(), String>;
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893    use serde_json::json;
894    use std::fs;
895
896    fn fixture_repo() -> tempfile::TempDir {
897        let dir = tempfile::tempdir().unwrap();
898        fs::create_dir(dir.path().join(".git")).unwrap();
899        dir
900    }
901
902    fn scope(root: &Path) -> ActionScope {
903        ActionScope {
904            tool: "shell".into(),
905            parameters: json!({"command": "git push origin HEAD:main"}),
906            repository_root: root.to_path_buf(),
907            target: "origin/main".into(),
908            environment: "disposable".into(),
909            credential_capabilities: vec![CredentialCapability("git:origin".into())],
910        }
911    }
912
913    #[test]
914    fn explicit_scope_rejects_missing_root_and_home() {
915        assert!(RepositoryScope::explicit(None).is_err());
916        assert!(RepositoryScope::explicit(Some(Path::new("/"))).is_err());
917        if let Some(home) = home_dir() {
918            assert!(RepositoryScope::explicit(Some(&home)).is_err());
919        }
920    }
921
922    #[cfg(unix)]
923    #[test]
924    fn scope_rejects_symlink_escape_for_reads_and_writes() {
925        use std::os::unix::fs::symlink;
926        let repo = fixture_repo();
927        let outside = tempfile::tempdir().unwrap();
928        fs::write(outside.path().join("secret"), "nope").unwrap();
929        symlink(outside.path(), repo.path().join("escape")).unwrap();
930        let scope = RepositoryScope::explicit(Some(repo.path())).unwrap();
931        assert!(scope.existing_path(Path::new("escape/secret")).is_err());
932        assert!(scope.write_path(Path::new("escape/new")).is_err());
933    }
934
935    #[test]
936    fn grant_is_exact_and_parameter_bound() {
937        let repo = fixture_repo();
938        let mut action = SupervisedActionRecord::propose("s", "c", scope(repo.path()));
939        let grant = ActionGrant {
940            action_id: action.id.clone(),
941            scope: action.scope.clone(),
942            approved: true,
943        };
944        assert!(grant.authorizes(&action));
945        action.scope.target = "other/main".into();
946        assert!(!grant.authorizes(&action));
947    }
948
949    #[test]
950    fn dispatched_resume_is_indeterminate_not_replayable() {
951        let repo = fixture_repo();
952        let mut action = SupervisedActionRecord::propose("s", "c", scope(repo.path()));
953        action.transition(ActionState::Approved, None).unwrap();
954        action.transition(ActionState::Dispatched, None).unwrap();
955        assert_eq!(
956            action.resume_directive(),
957            ResumeDirective::MarkIndeterminate
958        );
959        action
960            .transition(
961                ActionState::Indeterminate,
962                Some(json!({"reason": "process restart"})),
963            )
964            .unwrap();
965        assert_eq!(action.resume_directive(), ResumeDirective::DoNotDispatch);
966        assert!(action.transition(ActionState::Completed, None).is_err());
967    }
968
969    /// No advertised defs: exercises the name-list fallback alone.
970    fn no_defs() -> std::collections::HashSet<String> {
971        std::collections::HashSet::new()
972    }
973
974    fn receipt(
975        tool: &str,
976        ok: bool,
977        params: Value,
978        result: Value,
979    ) -> super::super::agent_loop::AssistantToolReceipt {
980        super::super::agent_loop::AssistantToolReceipt {
981            tool: tool.into(),
982            call_id: Some(format!("call-{tool}")),
983            sequence: Some(1),
984            ok,
985            params,
986            result: Some(result.to_string()),
987            via: None,
988        }
989    }
990
991    #[test]
992    fn bounded_tool_results_are_redacted_and_keep_closed_world_evidence() {
993        let secret = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJK";
994        let content = json!({
995            "requested_url": format!("https://example.test/start?token={secret}"),
996            "final_url": format!("https://example.test/final?token={secret}"),
997            "status": 200,
998            "title": "Done",
999            "body": format!(
1000                "{secret} {}",
1001                "safe words ".repeat(CHAT_TOOL_RESULT_EXCERPT_BYTES)
1002            ),
1003        })
1004        .to_string();
1005        let projected = bounded_tool_result("http_request", true, &content, None);
1006        assert!(projected.excerpt.contains("[REDACTED]"));
1007        assert!(!projected.excerpt.contains(secret));
1008        assert!(projected.excerpt.contains("…[truncated]…"));
1009        assert_eq!(projected.evidence["status"], 200);
1010        assert_eq!(projected.evidence["title"], "Done");
1011        assert_eq!(
1012            projected.evidence["final_url"],
1013            "https://example.test/final?token=[REDACTED]"
1014        );
1015        assert_eq!(projected.evidence["redirected"], true);
1016        assert!(projected.evidence.get("body").is_none());
1017    }
1018
1019    #[test]
1020    fn large_http_receipt_keeps_evidence_and_scrubs_userinfo_and_auth_headers() {
1021        let content = json!({
1022            "requested_url": "https://user:pass@example.test/start",
1023            "final_url": "https://user:pass@example.test/final",
1024            "status": 200,
1025            "request_headers": {
1026                "Authorization": "short-secret",
1027                "Content-Type": "application/json"
1028            },
1029            "body": "x".repeat(64 * 1024),
1030        })
1031        .to_string();
1032        let projected = bounded_tool_result("http_request", true, &content, None);
1033        assert_eq!(projected.evidence["status"], 200);
1034        assert_eq!(
1035            projected.evidence["requested_url"],
1036            "https://example.test/start"
1037        );
1038        assert_eq!(
1039            projected.evidence["final_url"],
1040            "https://example.test/final"
1041        );
1042        assert_eq!(projected.evidence["redirected"], true);
1043        assert!(!projected.excerpt.contains("Authorization"));
1044        assert!(!projected.excerpt.contains("short-secret"));
1045        assert!(projected.excerpt.contains("Content-Type"));
1046    }
1047
1048    #[test]
1049    fn per_tool_receipts_are_bounded_and_report_omissions() {
1050        let receipts: Vec<_> = (0..105)
1051            .map(|index| {
1052                receipt(
1053                    "calendar_events",
1054                    true,
1055                    json!({"start": index}),
1056                    json!({"events": [{"id": format!("event-{index}")}]}),
1057                )
1058            })
1059            .collect();
1060        let (wire, omitted) = tool_receipts_for_wire(&receipts);
1061        assert_eq!(wire.len(), CHAT_TOOL_RECEIPTS);
1062        assert_eq!(omitted, 5);
1063        assert_eq!(wire[0]["evidence"]["event_count"], 1);
1064        assert_eq!(wire[0]["evidence"]["event_ids"][0], "event-0");
1065        assert_eq!(wire[0]["sequence"], 1);
1066    }
1067
1068    #[test]
1069    fn complete_receipt_report_has_a_hard_serialized_size_ceiling() {
1070        let rows: Vec<Value> = (0..100)
1071            .map(|index| json!({"tool": "http_request", "excerpt": "x".repeat(2048), "index": index}))
1072            .collect();
1073        let report = bound_receipt_report(json!({
1074            "kind": "receipt_report",
1075            "session_id": "s1",
1076            "completion": completion_matrix_for_wire(&CompletionMatrix::default()),
1077            "desktop_actions": [],
1078            "tool_receipts": rows,
1079            "tool_receipts_omitted": 0,
1080            "ungrounded_claims": [],
1081        }));
1082        assert!(serde_json::to_vec(&report).unwrap().len() <= CHAT_RECEIPT_REPORT_BYTES);
1083        assert!(report["tool_receipts_omitted"].as_u64().unwrap() > 0);
1084    }
1085
1086    #[test]
1087    fn desktop_actions_record_calendar_counts_and_verify_reads() {
1088        let receipts = [receipt(
1089            "calendar_events",
1090            true,
1091            json!({"start": "2026-09-17T00:00:00Z", "end": "2026-09-18T00:00:00Z"}),
1092            json!({"events": [{"id": "event-7", "title": "Review"}]}),
1093        )];
1094        let (actions, _) = desktop_actions_from_tool_receipts(&receipts, &no_defs());
1095        assert_eq!(actions.len(), 1);
1096        assert_eq!(actions[0].action, "calendar_events");
1097        assert_eq!(actions[0].identifier.as_deref(), Some("event-7"));
1098        assert_eq!(actions[0].evidence["event_count"], 1);
1099        assert!(actions[0].verified);
1100    }
1101
1102    #[test]
1103    fn desktop_writes_are_attempted_until_a_later_read_confirms_the_identifier() {
1104        let mut receipts = vec![receipt(
1105            "calendar_create_event",
1106            true,
1107            json!({"title": "Review"}),
1108            json!({"ok": true, "event": {"id": "event-7", "title": "Review"}}),
1109        )];
1110        assert!(!desktop_actions_from_tool_receipts(&receipts, &no_defs()).0[0].verified);
1111        receipts.push(receipt(
1112            "calendar_events",
1113            true,
1114            json!({}),
1115            json!({"events": [{"id": "event-70", "title": "Wrong event"}]}),
1116        ));
1117        assert!(
1118            !desktop_actions_from_tool_receipts(&receipts, &no_defs()).0[0].verified,
1119            "substring matches are not verification"
1120        );
1121        receipts.push(receipt(
1122            "calendar_events",
1123            true,
1124            json!({}),
1125            json!({"events": [{"id": "event-7", "title": "Review"}]}),
1126        ));
1127        let (actions, _) = desktop_actions_from_tool_receipts(&receipts, &no_defs());
1128        assert!(actions[0].verified);
1129        assert!(actions[1].verified);
1130    }
1131
1132    /// The real tool names, not invented ones. `mail_draft` carries none of
1133    /// create/update/delete/send, and the `browser_*` recording/sign-in tools
1134    /// postdate the literal list — before the defs' `mutating` flag was
1135    /// consulted each of these reported `verified: true` on `ok` alone.
1136    #[test]
1137    fn live_mutating_tools_are_not_verified_without_a_confirming_read() {
1138        let defs = vec![
1139            json!({"name": "mail_draft", "mutating": true}),
1140            json!({"name": "browser_record_start", "mutating": true}),
1141            json!({"name": "browse_click", "mutating": true}),
1142            json!({"name": "mail_inbox"}),
1143        ];
1144        let mutating = super::super::agent_loop::mutating_tool_names(&defs);
1145        for tool in ["mail_draft", "browser_record_start", "browse_click"] {
1146            let receipts = [receipt(
1147                tool,
1148                true,
1149                json!({"to": "someone@example.test"}),
1150                json!({"ok": true, "id": "msg-1"}),
1151            )];
1152            let (actions, _) = desktop_actions_from_tool_receipts(&receipts, &mutating);
1153            assert_eq!(actions.len(), 1, "{tool} is a desktop action");
1154            assert!(
1155                !actions[0].verified,
1156                "{tool} mutates; `ok` alone is not verification"
1157            );
1158        }
1159
1160        // A later successful read carrying the same identifier verifies it.
1161        let receipts = [
1162            receipt(
1163                "mail_draft",
1164                true,
1165                json!({"to": "someone@example.test"}),
1166                json!({"ok": true, "id": "msg-1"}),
1167            ),
1168            receipt(
1169                "mail_inbox",
1170                true,
1171                json!({}),
1172                json!({"messages": [{"id": "msg-1", "subject": "Hi"}]}),
1173            ),
1174        ];
1175        assert!(desktop_actions_from_tool_receipts(&receipts, &mutating).0[0].verified);
1176
1177        // And a read stays verified on `ok`.
1178        let reads = [receipt(
1179            "mail_inbox",
1180            true,
1181            json!({}),
1182            json!({"messages": [{"id": "msg-9"}]}),
1183        )];
1184        assert!(desktop_actions_from_tool_receipts(&reads, &mutating).0[0].verified);
1185    }
1186
1187    /// The action list is bounded where it is built, so the 64 KiB frame trim
1188    /// stays a safety net; a host is told how many did not fit.
1189    #[test]
1190    fn desktop_actions_are_capped_at_build_time_and_report_omissions() {
1191        let receipts: Vec<_> = (0..CHAT_DESKTOP_ACTIONS + 7)
1192            .map(|index| {
1193                receipt(
1194                    "calendar_events",
1195                    true,
1196                    json!({"start": index}),
1197                    json!({"events": [{"id": format!("event-{index}")}]}),
1198                )
1199            })
1200            .collect();
1201        let (actions, omitted) = desktop_actions_from_tool_receipts(&receipts, &no_defs());
1202        assert_eq!(actions.len(), CHAT_DESKTOP_ACTIONS);
1203        assert_eq!(omitted, 7);
1204    }
1205
1206    #[test]
1207    fn telemetry_query_is_not_mislabeled_as_deployment_or_local_verification() {
1208        let call_id = "telemetry-1";
1209        let messages = vec![
1210            Message::Assistant {
1211                content: String::new(),
1212                tool_calls: vec![serde_json::from_value(json!({
1213                    "id": call_id,
1214                    "name": "shell",
1215                    "arguments": {
1216                        "command": "az monitor app-insights query --app ai-fms --analytics-query \"traces | project customDimensions_DeploymentId\""
1217                    }
1218                }))
1219                .unwrap()],
1220                thinking: vec![],
1221                            model_id: None,
1222                local_last_resort: false,
1223},
1224            Message::ToolResult {
1225                tool_use_id: call_id.into(),
1226                content: json!({"exit_code": 0, "output": "{\"tables\":[]}"}).to_string(),
1227                provenance: Default::default(),
1228            },
1229        ];
1230
1231        let matrix = completion_matrix_from_messages(&messages);
1232        assert!(matrix.deployment.is_none());
1233        assert!(matrix.ci_cd.is_none());
1234        assert!(matrix.local_verification.is_none());
1235    }
1236}