car-verify 0.56.1

Static plan verification for Agent IR
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! The SkillHone evaluator↔optimizer boundary as an information-flow policy.
//!
//! Applies *SkillHone: A Harness for Continual Agent Skill Evolution Through
//! Persistent Decision History* (arXiv 2606.08671) — see
//! `docs/proposals/skill-decision-history.md`, Slice 2. SkillHone separates a
//! self-improvement loop into two roles with different privileges:
//!
//! - the **evaluator** holds the practice probes, the held-out targets, and the
//!   validators, and runs candidate skills against them — it produces the
//!   *unredacted* evidence;
//! - the **optimizer** forms diagnoses and drafts skill revisions from that
//!   evidence, and **must not see the hidden probe targets or validators**,
//!   because an optimizer that can read the eval set overfits to it or leaks it;
//! - a **redactor** sits between them: it turns the evaluator's raw evidence
//!   into the redacted evidence the optimizer is allowed to consume.
//!
//! SkillHone enforces this by *convention* — the evaluation subagent is trusted
//! to redact before reporting. CAR can do better: the boundary is exactly a
//! Denning-style information-flow constraint, and [`crate::infoflow`] already
//! decides it. Evaluator evidence is [`Confidentiality::Secret`], the optimizer
//! is a [`sink`](ToolLabels::sink), and the redactor is a
//! [`declassifier`](ToolLabels::declassifier). Any flow from evaluator evidence
//! into the optimizer that does not pass through the redactor is a
//! [`FlowViolationKind::SensitiveToSink`] hazard the runtime can *block*, not a
//! promise it has to trust.
//!
//! What was missing — and what this module adds — is the **role→label binding**.
//! `infoflow` reasons about tools and state keys; it has no notion of
//! "evaluator" versus "optimizer". Hand-building the label map is error-prone:
//! forget to mark one evaluator tool `Secret`, or one optimizer tool a `sink`,
//! and the boundary silently stops holding while the report still reads `safe`.
//! This module derives the whole label set from a role assignment, so the
//! binding is stated once and cannot drift.
//!
//! **Scope.** The check is over a single [`ActionProposal`] whose DAG contains
//! both roles' actions and threads the evidence between them as shared state
//! keys (`state_dependencies`/`expected_effects`) — the same declared-effects
//! basis the rest of [`crate::infoflow`] rides. A leak that travels outside
//! declared state (a side channel, an out-of-band file) is invisible here, the
//! same way it is to every check in this crate. Pure: serde + car-ir, no model,
//! clock, or I/O.

use crate::infoflow::{
    check_information_flow, Confidentiality, FlowPolicy, FlowReport, FlowViolation,
    FlowViolationKind, ToolLabels, TrustLevel,
};
use car_ir::ActionProposal;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};

/// Capability tag stamped on evaluator tools in the derived labels — the side
/// that reads probe targets/validators and runs candidates.
pub const CAP_EVALUATOR: &str = "skillhone_evaluate";
/// Capability tag stamped on the redactor — the declassifier between the roles.
pub const CAP_REDACTOR: &str = "skillhone_redact";
/// Capability tag stamped on optimizer tools — the side that drafts revisions.
pub const CAP_OPTIMIZER: &str = "skillhone_optimize";

/// The three privilege roles of a SkillHone self-improvement loop. A tool
/// belongs to exactly one; [`BoundaryRoles::validate`] rejects overlaps.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BoundaryRole {
    /// Holds probe targets/validators and runs candidates — produces unredacted
    /// (`Secret`) evidence.
    Evaluator,
    /// Redacts evaluator evidence for the optimizer — a declassifier.
    Redactor,
    /// Forms diagnoses and drafts revisions — must not read unredacted evidence.
    Optimizer,
}

/// An assignment of tool names to boundary roles. Tools not named here are
/// unconstrained (treated as public/trusted/non-sink by [`crate::infoflow`]),
/// so the caller must name every tool that carries evaluator evidence or acts
/// for the optimizer — that is the whole point of deriving the labels from one
/// declaration instead of hand-maintaining them.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BoundaryRoles {
    /// Tools that read probe targets/validators or run candidates.
    #[serde(default)]
    pub evaluator_tools: Vec<String>,
    /// Tools that redact evaluator evidence (declassifiers).
    #[serde(default)]
    pub redactor_tools: Vec<String>,
    /// Tools that draft diagnoses/revisions for the optimizer.
    #[serde(default)]
    pub optimizer_tools: Vec<String>,
}

impl BoundaryRoles {
    /// Reject a configuration that cannot mean what it says: a tool in more than
    /// one role, or a boundary missing either side (an evaluator with no
    /// optimizer, or vice versa, has nothing to separate). Returns the offending
    /// detail so a caller can surface it, rather than silently deriving labels
    /// that fail open.
    pub fn validate(&self) -> Result<(), String> {
        if self.evaluator_tools.is_empty() || self.optimizer_tools.is_empty() {
            return Err(
                "a boundary needs at least one evaluator tool and one optimizer tool".to_string(),
            );
        }
        let mut seen: HashMap<&str, BoundaryRole> = HashMap::new();
        for (tools, role) in [
            (&self.evaluator_tools, BoundaryRole::Evaluator),
            (&self.redactor_tools, BoundaryRole::Redactor),
            (&self.optimizer_tools, BoundaryRole::Optimizer),
        ] {
            for t in tools {
                if let Some(prev) = seen.insert(t.as_str(), role) {
                    return Err(format!(
                        "tool '{t}' is assigned to two roles ({prev:?} and {role:?}); \
                         each tool belongs to exactly one role"
                    ));
                }
            }
        }
        Ok(())
    }

    /// Derive the [`ToolLabels`] map that encodes this boundary for
    /// [`check_information_flow`]: evaluator tools produce `Secret` data,
    /// optimizer tools are sinks, the redactor is a declassifier.
    ///
    /// **Fail-safe on overlap.** [`validate`](Self::validate) rejects a tool in
    /// two roles, but this method is total so callers cannot get an unlabeled
    /// tool by skipping validation. If a tool is nonetheless listed under
    /// several roles, the *restrictive* flags win and the *opening* one loses:
    /// `Secret` (evaluator) and `sink` (optimizer) are applied after, and
    /// `declassifier` (redactor) is granted only to a tool that is redactor and
    /// nothing else. A misconfiguration therefore tightens the boundary, never
    /// silently opens it.
    pub fn labels(&self) -> HashMap<String, ToolLabels> {
        let evaluators: HashSet<&str> = self.evaluator_tools.iter().map(String::as_str).collect();
        let optimizers: HashSet<&str> = self.optimizer_tools.iter().map(String::as_str).collect();

        let mut map: HashMap<String, ToolLabels> = HashMap::new();

        // Redactor first, so a stray double assignment is overridden below.
        for t in &self.redactor_tools {
            let sole_redactor =
                !evaluators.contains(t.as_str()) && !optimizers.contains(t.as_str());
            map.insert(
                t.clone(),
                ToolLabels {
                    capability: Some(CAP_REDACTOR.to_string()),
                    declassifier: sole_redactor,
                    ..Default::default()
                },
            );
        }
        for t in &self.evaluator_tools {
            let e = map.entry(t.clone()).or_default();
            e.capability = Some(CAP_EVALUATOR.to_string());
            e.confidentiality = Confidentiality::Secret;
            e.declassifier = false;
        }
        for t in &self.optimizer_tools {
            let e = map.entry(t.clone()).or_default();
            e.capability = Some(CAP_OPTIMIZER.to_string());
            e.sink = true;
            // An optimizer that ingests external content is also an untrusted
            // boundary; mark it so any confidential input trips the check even
            // if a future policy narrows what counts as a `sink`.
            e.trust = TrustLevel::Untrusted;
            e.declassifier = false;
        }
        map
    }
}

/// The result of checking a proposal against a SkillHone boundary.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoundaryReport {
    /// True when no unredacted evaluator evidence reaches the optimizer.
    pub upheld: bool,
    /// The flows that breach the boundary — unredacted evaluator evidence
    /// reaching an optimizer action without passing the redactor. Each is a
    /// [`FlowViolationKind::SensitiveToSink`] from the underlying check.
    pub leaks: Vec<FlowViolation>,
    /// Role-aware, human/agent-actionable summary.
    pub summary: String,
    /// The full underlying [`FlowReport`], for callers that want to gate it via
    /// [`crate::infoflow::gate_flow`] alongside their other flow policy.
    pub flow: FlowReport,
}

/// Check a proposal against the evaluator↔optimizer boundary.
///
/// Derives the labels from `roles`, runs the information-flow check, and reports
/// any confidential-evidence-to-optimizer leak as a boundary breach. The
/// proposal must contain both roles' actions and thread the evidence between
/// them as declared state keys (see the module scope note).
pub fn check_eval_optimize_boundary(
    proposal: &ActionProposal,
    roles: &BoundaryRoles,
) -> BoundaryReport {
    // An unvalidated boundary cannot be upheld, and the failure is silent
    // rather than loud: a role list with no optimizer tools has no sink, so
    // the flow check finds nothing to violate and the report reads
    // "boundary holds" for a configuration this crate's own validator
    // rejects (car#1919). A caller that omitted one side is exactly the
    // caller least able to notice.
    if let Err(reason) = roles.validate() {
        return BoundaryReport {
            upheld: false,
            leaks: Vec::new(),
            summary: format!(
                "boundary not checked: the role assignment is invalid ({reason}). No claim is                  made about this proposal — fix the roles and re-check."
            ),
            // `safe: false` with no violations is the "no claim made"
            // encoding: nothing was checked, so reporting safe would repeat
            // the defect one field over.
            flow: FlowReport {
                safe: false,
                violations: Vec::new(),
            },
        };
    }
    let labels = roles.labels();
    // Default policy guards `Internal` and above; evaluator evidence is
    // `Secret`, so it trips. The redactor resets it to `Public`, which clears
    // the leak — exactly SkillHone's redacted-reporting escape hatch.
    let flow = check_information_flow(proposal, &labels, &FlowPolicy::default());
    let leaks: Vec<FlowViolation> = flow
        .violations
        .iter()
        .filter(|v| v.kind == FlowViolationKind::SensitiveToSink)
        .cloned()
        .collect();
    let upheld = leaks.is_empty();
    let summary = if upheld {
        "evaluator↔optimizer boundary holds: no unredacted evidence reaches the optimizer"
            .to_string()
    } else {
        format!(
            "boundary breached: {} flow(s) carry unredacted evaluator evidence to the optimizer \
             without passing the redactor",
            leaks.len()
        )
    };
    BoundaryReport {
        upheld,
        leaks,
        summary,
        flow,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn action(id: &str, tool: &str, reads: &[&str], writes: &[&str]) -> car_ir::Action {
        let effects: serde_json::Map<String, serde_json::Value> =
            writes.iter().map(|w| (w.to_string(), json!("v"))).collect();
        serde_json::from_value(json!({
            "type": "tool_call",
            "id": id,
            "tool": tool,
            "state_dependencies": reads,
            "expected_effects": effects,
        }))
        .unwrap()
    }

    fn proposal(actions: Vec<car_ir::Action>) -> ActionProposal {
        serde_json::from_value(json!({ "actions": actions })).unwrap()
    }

    fn roles() -> BoundaryRoles {
        BoundaryRoles {
            evaluator_tools: vec!["run_probe".into()],
            redactor_tools: vec!["redact".into()],
            optimizer_tools: vec!["draft_revision".into()],
        }
    }

    #[test]
    fn direct_evidence_to_optimizer_is_a_leak() {
        // evaluator writes `evidence` (Secret); optimizer reads it directly.
        let p = proposal(vec![
            action("e1", "run_probe", &[], &["evidence"]),
            action("o1", "draft_revision", &["evidence"], &["revision"]),
        ]);
        let r = check_eval_optimize_boundary(&p, &roles());
        assert!(!r.upheld, "{}", r.summary);
        assert_eq!(r.leaks.len(), 1);
        assert_eq!(r.leaks[0].kind, FlowViolationKind::SensitiveToSink);
        assert_eq!(r.leaks[0].actions, vec!["o1"]);
        assert_eq!(r.leaks[0].key.as_deref(), Some("evidence"));
    }

    #[test]
    fn evidence_through_redactor_upholds_the_boundary() {
        // evaluator -> redactor (declassifies to `redacted`) -> optimizer.
        let p = proposal(vec![
            action("e1", "run_probe", &[], &["evidence"]),
            action("r1", "redact", &["evidence"], &["redacted"]),
            action("o1", "draft_revision", &["redacted"], &["revision"]),
        ]);
        let r = check_eval_optimize_boundary(&p, &roles());
        assert!(r.upheld, "redacted evidence is allowed: {:?}", r.leaks);
    }

    #[test]
    fn transitive_evidence_without_redaction_still_leaks() {
        // A plain copy carries the evaluator taint forward to the optimizer.
        let p = proposal(vec![
            action("e1", "run_probe", &[], &["evidence"]),
            action("c1", "copy", &["evidence"], &["copy"]),
            action("o1", "draft_revision", &["copy"], &[]),
        ]);
        let r = check_eval_optimize_boundary(&p, &roles());
        assert!(!r.upheld);
        assert_eq!(r.leaks[0].actions, vec!["o1"]);
    }

    #[test]
    fn optimizer_reading_only_its_own_data_is_fine() {
        let p = proposal(vec![
            action("e1", "run_probe", &[], &["evidence"]),
            action("o1", "draft_revision", &["prior_history"], &["revision"]),
        ]);
        let r = check_eval_optimize_boundary(&p, &roles());
        assert!(r.upheld, "{}", r.summary);
    }

    /// car#1919. The exported check never ran the crate's own validator, and
    /// the failure was silent in the worst direction: a role list with NO
    /// optimizer tools has no sink, so the information-flow check finds
    /// nothing to violate and the report reads "boundary holds" for a
    /// configuration `validate` rejects outright.
    #[test]
    fn an_invalid_boundary_is_never_reported_as_upheld() {
        let proposal = proposal(vec![
            action("a", "score", &[], &["evidence"]),
            action("b", "tune", &["evidence"], &[]),
        ]);

        // One side omitted — the shape a caller most easily gets wrong.
        let missing_optimizer = BoundaryRoles {
            evaluator_tools: vec!["score".into()],
            redactor_tools: vec![],
            optimizer_tools: vec![],
        };
        assert!(
            missing_optimizer.validate().is_err(),
            "control: validate rejects this"
        );
        let report = check_eval_optimize_boundary(&proposal, &missing_optimizer);
        assert!(
            !report.upheld,
            "an unvalidated boundary must not report as upheld: {}",
            report.summary
        );
        assert!(report.summary.contains("invalid"));
        assert!(!report.flow.safe, "no check ran, so no safety claim");

        // The other invalid shape: one tool wearing two roles.
        let overlapping = BoundaryRoles {
            evaluator_tools: vec!["both".into()],
            redactor_tools: vec![],
            optimizer_tools: vec!["both".into()],
        };
        assert!(
            overlapping.validate().is_err(),
            "control: validate rejects this"
        );
        assert!(!check_eval_optimize_boundary(&proposal, &overlapping).upheld);
    }

    #[test]
    fn validate_rejects_overlapping_roles() {
        let bad = BoundaryRoles {
            evaluator_tools: vec!["shared".into()],
            redactor_tools: vec![],
            optimizer_tools: vec!["shared".into()],
        };
        assert!(bad.validate().is_err());
    }

    #[test]
    fn validate_requires_both_sides() {
        let only_eval = BoundaryRoles {
            evaluator_tools: vec!["run_probe".into()],
            ..Default::default()
        };
        assert!(only_eval.validate().is_err());
    }

    #[test]
    fn overlap_fails_safe_no_declassifier() {
        // A tool mistakenly listed as both redactor and evaluator must NOT be a
        // declassifier — the restrictive labeling wins, so the boundary tightens
        // rather than silently opening.
        let bad = BoundaryRoles {
            evaluator_tools: vec!["run_probe".into(), "shared".into()],
            redactor_tools: vec!["shared".into()],
            optimizer_tools: vec!["draft_revision".into()],
        };
        let labels = bad.labels();
        let shared = &labels["shared"];
        assert!(
            !shared.declassifier,
            "a tool that is also an evaluator must not declassify"
        );
        assert_eq!(shared.confidentiality, Confidentiality::Secret);
    }
}