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#[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
285/// Project a conservative completion matrix from runtime tool receipts in the
286/// exact transcript. A field is populated only for an answered, successful
287/// call; shell success additionally requires `exit_code == 0`.
288pub 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/// Persistence seam used by the supervised loop. Production implements this
394/// through daemon sync RPCs; tests can use an in-memory oplog-backed adapter.
395#[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    /// Append a new exact checkpoint. Implementations assign a strictly
403    /// increasing revision and retain `reason` as compaction/transition audit
404    /// metadata; callers never maintain a second conversation store.
405    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}