Skip to main content

areev_loop/
policy.rs

1//! Host policy — the optional `loop-policy.json` (proposal §6.2). It is the
2//! **only** place auto-apply is granted, and it is host config (per-process,
3//! never persisted in a memory file). All fields default-closed; the whole
4//! struct rejects unknown keys, so a policy that tries to register an
5//! executable (`--analyzer-cmd`) or touch a trust-floor field fails to load —
6//! a stolen or committed policy file must be inert.
7//!
8//! Precedence (enforced by the engine): engine ceilings > host CLI flags >
9//! this policy file > memory-file config. "The file selects and restricts;
10//! only the host grants."
11
12use crate::error::{Error, Result};
13use crate::model::Severity;
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16
17/// Telemetry sidecar mode (host-only).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum TelemetryMode {
21    Off,
22    #[default]
23    Aggregate,
24    Full,
25}
26
27/// One auto-apply grant: an analyzer family may auto-apply to these target
28/// classes up to (and including) `max_severity`.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct AutoApplyGrant {
32    /// Analyzer family (e.g. `loop.duplicate_sweep`) or full id; matched by
33    /// family so a version bump keeps the grant.
34    pub analyzer: String,
35    /// Eligible target classes: `memory` and/or `query` only (prompt/host are
36    /// never auto-appliable and are rejected at eval time regardless).
37    pub targets: Vec<String>,
38    /// Highest severity this grant covers.
39    pub max_severity: Severity,
40}
41
42/// The parsed host policy. Everything default-closed.
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct Policy {
46    /// Master opt-in (same posture as `allow_destructive_ops`: default off).
47    /// Auto-apply never fires unless this is true AND a grant matches.
48    #[serde(default)]
49    pub auto_apply_enabled: bool,
50    /// Auto-apply grants (default: none).
51    #[serde(default)]
52    pub auto_apply: Vec<AutoApplyGrant>,
53    /// Analyzer families the host disables entirely.
54    #[serde(default)]
55    pub deny: Vec<String>,
56    /// Per-analyzer severity floors (family → floor); combined with the
57    /// file's floors by taking the stricter of the two.
58    #[serde(default)]
59    pub severity_floors: BTreeMap<String, Severity>,
60    #[serde(default)]
61    pub telemetry: TelemetryMode,
62}
63
64impl Policy {
65    /// Parse a policy JSON string. Unknown keys are rejected (fail-closed).
66    pub fn from_json(s: &str) -> Result<Self> {
67        serde_json::from_str(s).map_err(|e| Error::InvalidProposal(format!("policy: {e}")))
68    }
69
70    /// Is this analyzer family denied by the host?
71    pub fn denies(&self, family: &str) -> bool {
72        self.deny.iter().any(|d| crate::manifest::analyzer_family(d) == family)
73    }
74
75    /// The host severity floor for a family, if any.
76    pub fn severity_floor(&self, family: &str) -> Option<Severity> {
77        self.severity_floors
78            .iter()
79            .find(|(k, _)| crate::manifest::analyzer_family(k) == family)
80            .map(|(_, v)| *v)
81    }
82
83    /// Does a grant permit auto-applying this family to `target_class` at
84    /// `severity`? Only the `memory` class is ever eligible.
85    ///
86    /// `query` was eligible until definition rewrites became executable
87    /// (issue #28). A grain edit changes one remembered value; a saved-query
88    /// or template rewrite changes what EVERY future context contains — the
89    /// blast radius is every turn from now on, not one fact. So a definition
90    /// rewrite always requires a human APPROVE + APPLY with `BECAUSE`, and
91    /// the class is excluded here by name, exactly as `code`/`evalset` are.
92    pub fn grants_auto_apply(&self, family: &str, target_class: &str, severity: Severity) -> bool {
93        if !self.auto_apply_enabled || target_class != "memory" {
94            return false;
95        }
96        self.auto_apply.iter().any(|g| {
97            crate::manifest::analyzer_family(&g.analyzer) == family
98                && g.targets.iter().any(|t| t == target_class)
99                && severity <= g.max_severity
100        })
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    /// §7.4's stated invariant, pinned: code and evalset targets are
109    /// excluded from auto-apply BY NAME — even a policy that explicitly
110    /// names those classes in a grant is inert, because
111    /// `grants_auto_apply` hard-codes memory|query.
112    #[test]
113    fn code_targets_never_auto_apply_even_when_granted() {
114        let p = Policy::from_json(
115            r#"{"auto_apply_enabled": true,
116                "auto_apply": [{"analyzer": "loop.codegen", "targets": ["code", "evalset", "memory"], "max_severity": "high"}]}"#,
117        )
118        .unwrap();
119        assert!(!p.grants_auto_apply("loop.codegen", "code", Severity::Info));
120        assert!(!p.grants_auto_apply("loop.codegen", "evalset", Severity::Info));
121        assert!(
122            p.grants_auto_apply("loop.codegen", "memory", Severity::Low),
123            "the same grant's memory leg still works — the exclusion is by class"
124        );
125    }
126
127    #[test]
128    fn default_policy_grants_nothing() {
129        let p = Policy::default();
130        assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Info));
131        assert!(!p.denies("loop.staleness"));
132        assert_eq!(p.telemetry, TelemetryMode::Aggregate);
133    }
134
135    #[test]
136    fn parses_and_grants() {
137        let p = Policy::from_json(
138            r#"{"auto_apply_enabled": true,
139                "auto_apply": [{"analyzer": "loop.duplicate_sweep", "targets": ["memory"], "max_severity": "low"}],
140                "deny": ["loop.staleness"],
141                "severity_floors": {"loop.contradiction_sweep": "high"}}"#,
142        )
143        .unwrap();
144        assert!(p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Low));
145        assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::High), "above max_severity");
146        assert!(!p.grants_auto_apply("loop.duplicate_sweep", "query", Severity::Low), "query not granted");
147        assert!(p.denies("loop.staleness"));
148        assert_eq!(p.severity_floor("loop.contradiction_sweep"), Some(Severity::High));
149    }
150
151    #[test]
152    fn prompt_and_host_targets_never_granted() {
153        let p = Policy::from_json(
154            r#"{"auto_apply_enabled": true,
155                "auto_apply": [{"analyzer": "x", "targets": ["prompt", "host"], "max_severity": "high"}]}"#,
156        )
157        .unwrap();
158        assert!(!p.grants_auto_apply("x", "prompt", Severity::Info));
159        assert!(!p.grants_auto_apply("x", "host", Severity::Info));
160    }
161
162    #[test]
163    fn unknown_keys_rejected() {
164        // A trust-floor field or an executable registration must not load.
165        assert!(Policy::from_json(r#"{"analyzer_cmd": "evil"}"#).is_err());
166        assert!(Policy::from_json(r#"{"auto_apply_free_text": true}"#).is_err());
167    }
168}