1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct RepositoryScope {
19 root: PathBuf,
20}
21
22impl RepositoryScope {
23 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
113pub struct CredentialCapability(pub String);
114
115#[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 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 #[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 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#[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
270pub 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;
279const CHAT_DESKTOP_ACTIONS: usize = 100;
284const CHAT_TOOL_RECEIPT_REPORT_BYTES: usize = 64 * 1024;
287pub const CHAT_RECEIPT_REPORT_BYTES: usize = 64 * 1024;
289
290#[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#[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
309pub 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 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
340pub fn tool_receipts_for_wire(
345 receipts: &[super::agent_loop::AssistantToolReceipt],
346) -> (Vec<Value>, usize) {
347 let mut rows = Vec::new();
348 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
382pub 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
395pub 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 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
458pub fn desktop_actions_from_tool_receipts(
477 receipts: &[super::agent_loop::AssistantToolReceipt],
478 mutating: &std::collections::HashSet<String>,
479) -> (Vec<DesktopActionEvidence>, usize) {
480 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
545fn 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
757pub 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#[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 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 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 #[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 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 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 #[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}