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
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271pub struct AssistantCheckpoint {
272 pub id: String,
273 pub session_id: String,
274 pub revision: u64,
275 pub repository_root: PathBuf,
276 pub messages: Vec<Message>,
277 #[serde(default)]
278 pub goal: Option<Value>,
279 #[serde(default)]
280 pub compaction: Option<Value>,
281 #[serde(default)]
282 pub completion: CompletionMatrix,
283}
284
285pub fn completion_matrix_from_messages(messages: &[Message]) -> CompletionMatrix {
289 let mut calls: std::collections::HashMap<String, (String, Value)> =
290 std::collections::HashMap::new();
291 let mut matrix = CompletionMatrix::default();
292 for message in messages {
293 match message {
294 Message::Assistant { tool_calls, .. } => {
295 for call in tool_calls {
296 if let Some(id) = &call.id {
297 calls.insert(
298 id.clone(),
299 (
300 call.name.clone(),
301 serde_json::to_value(&call.arguments).unwrap_or(Value::Null),
302 ),
303 );
304 }
305 }
306 }
307 Message::ToolResult {
308 tool_use_id,
309 content,
310 ..
311 } => {
312 let Some((tool, params)) = calls.get(tool_use_id) else {
313 continue;
314 };
315 let parsed = serde_json::from_str::<Value>(content).ok();
316 let failed = content.starts_with("[FAILED]")
317 || content.starts_with("[REJECTED]")
318 || parsed
319 .as_ref()
320 .and_then(|value| value.get("error"))
321 .is_some();
322 let shell_ok = tool != "shell"
323 || parsed
324 .as_ref()
325 .and_then(|value| value.get("exit_code"))
326 .and_then(Value::as_i64)
327 == Some(0)
328 || parsed
329 .as_ref()
330 .and_then(|value| value.get("ok"))
331 .and_then(Value::as_bool)
332 == Some(true);
333 if failed || !shell_ok {
334 continue;
335 }
336 let command = params
337 .get("command")
338 .and_then(Value::as_str)
339 .unwrap_or_default()
340 .to_ascii_lowercase();
341 let command_tokens: Vec<&str> = command
342 .split_whitespace()
343 .map(|token| {
344 token.trim_matches(|ch: char| {
345 !ch.is_ascii_alphanumeric() && ch != '-' && ch != '/' && ch != '.'
346 })
347 })
348 .filter(|token| !token.is_empty())
349 .collect();
350 let evidence = format!("{tool} receipt {tool_use_id}: {content}");
351 if tool.starts_with("browser_") {
352 matrix.production_browser_proof = Some(evidence.clone());
353 }
354 if command.contains("git push") {
355 matrix.remote_main = Some(evidence.clone());
356 }
357 let is_ci = command_tokens
358 .windows(2)
359 .any(|pair| matches!(pair, ["az", "pipelines"] | ["gh", "run"]))
360 || command_tokens.contains(&"pipeline");
361 if is_ci {
362 matrix.ci_cd = Some(evidence.clone());
363 }
364 let is_deployment = command_tokens
365 .iter()
366 .any(|token| matches!(*token, "deploy" | "deployment"))
367 || command.contains("/deploy.")
368 || command.contains("/deploy/");
369 if is_deployment {
370 matrix.deployment = Some(evidence.clone());
371 }
372 if command.contains("health") || command.contains("ready") {
373 matrix.health = Some(evidence.clone());
374 }
375 if command.contains("test")
376 || command.contains("cargo check")
377 || command.contains("dotnet build")
378 || command.contains("dotnet run")
379 || command.contains("node --test")
380 || command.contains("npm test")
381 || command.contains("pnpm test")
382 || command.contains("yarn test")
383 {
384 matrix.local_verification = Some(evidence);
385 }
386 }
387 _ => {}
388 }
389 }
390 matrix
391}
392
393#[async_trait::async_trait]
396pub trait AssistantDurability: Send + Sync {
397 async fn load_checkpoint(
398 &self,
399 session_id: &str,
400 ) -> Result<Option<AssistantCheckpoint>, String>;
401
402 async fn checkpoint(
406 &self,
407 session_id: &str,
408 messages: &[Message],
409 reason: &str,
410 goal: Option<Value>,
411 ) -> Result<(), String>;
412
413 async fn load_action(&self, action_id: &str) -> Result<Option<SupervisedActionRecord>, String>;
414
415 async fn record_action(&self, record: &SupervisedActionRecord) -> Result<(), String>;
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use serde_json::json;
422 use std::fs;
423
424 fn fixture_repo() -> tempfile::TempDir {
425 let dir = tempfile::tempdir().unwrap();
426 fs::create_dir(dir.path().join(".git")).unwrap();
427 dir
428 }
429
430 fn scope(root: &Path) -> ActionScope {
431 ActionScope {
432 tool: "shell".into(),
433 parameters: json!({"command": "git push origin HEAD:main"}),
434 repository_root: root.to_path_buf(),
435 target: "origin/main".into(),
436 environment: "disposable".into(),
437 credential_capabilities: vec![CredentialCapability("git:origin".into())],
438 }
439 }
440
441 #[test]
442 fn explicit_scope_rejects_missing_root_and_home() {
443 assert!(RepositoryScope::explicit(None).is_err());
444 assert!(RepositoryScope::explicit(Some(Path::new("/"))).is_err());
445 if let Some(home) = home_dir() {
446 assert!(RepositoryScope::explicit(Some(&home)).is_err());
447 }
448 }
449
450 #[cfg(unix)]
451 #[test]
452 fn scope_rejects_symlink_escape_for_reads_and_writes() {
453 use std::os::unix::fs::symlink;
454 let repo = fixture_repo();
455 let outside = tempfile::tempdir().unwrap();
456 fs::write(outside.path().join("secret"), "nope").unwrap();
457 symlink(outside.path(), repo.path().join("escape")).unwrap();
458 let scope = RepositoryScope::explicit(Some(repo.path())).unwrap();
459 assert!(scope.existing_path(Path::new("escape/secret")).is_err());
460 assert!(scope.write_path(Path::new("escape/new")).is_err());
461 }
462
463 #[test]
464 fn grant_is_exact_and_parameter_bound() {
465 let repo = fixture_repo();
466 let mut action = SupervisedActionRecord::propose("s", "c", scope(repo.path()));
467 let grant = ActionGrant {
468 action_id: action.id.clone(),
469 scope: action.scope.clone(),
470 approved: true,
471 };
472 assert!(grant.authorizes(&action));
473 action.scope.target = "other/main".into();
474 assert!(!grant.authorizes(&action));
475 }
476
477 #[test]
478 fn dispatched_resume_is_indeterminate_not_replayable() {
479 let repo = fixture_repo();
480 let mut action = SupervisedActionRecord::propose("s", "c", scope(repo.path()));
481 action.transition(ActionState::Approved, None).unwrap();
482 action.transition(ActionState::Dispatched, None).unwrap();
483 assert_eq!(
484 action.resume_directive(),
485 ResumeDirective::MarkIndeterminate
486 );
487 action
488 .transition(
489 ActionState::Indeterminate,
490 Some(json!({"reason": "process restart"})),
491 )
492 .unwrap();
493 assert_eq!(action.resume_directive(), ResumeDirective::DoNotDispatch);
494 assert!(action.transition(ActionState::Completed, None).is_err());
495 }
496
497 #[test]
498 fn telemetry_query_is_not_mislabeled_as_deployment_or_local_verification() {
499 let call_id = "telemetry-1";
500 let messages = vec![
501 Message::Assistant {
502 content: String::new(),
503 tool_calls: vec![serde_json::from_value(json!({
504 "id": call_id,
505 "name": "shell",
506 "arguments": {
507 "command": "az monitor app-insights query --app ai-fms --analytics-query \"traces | project customDimensions_DeploymentId\""
508 }
509 }))
510 .unwrap()],
511 thinking: vec![],
512 model_id: None,
513 local_last_resort: false,
514},
515 Message::ToolResult {
516 tool_use_id: call_id.into(),
517 content: json!({"exit_code": 0, "output": "{\"tables\":[]}"}).to_string(),
518 provenance: Default::default(),
519 },
520 ];
521
522 let matrix = completion_matrix_from_messages(&messages);
523 assert!(matrix.deployment.is_none());
524 assert!(matrix.ci_cd.is_none());
525 assert!(matrix.local_verification.is_none());
526 }
527}