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 `memory`/`query` classes are ever eligible.
85    pub fn grants_auto_apply(&self, family: &str, target_class: &str, severity: Severity) -> bool {
86        if !self.auto_apply_enabled || !matches!(target_class, "memory" | "query") {
87            return false;
88        }
89        self.auto_apply.iter().any(|g| {
90            crate::manifest::analyzer_family(&g.analyzer) == family
91                && g.targets.iter().any(|t| t == target_class)
92                && severity <= g.max_severity
93        })
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    /// §7.4's stated invariant, pinned: code and evalset targets are
102    /// excluded from auto-apply BY NAME — even a policy that explicitly
103    /// names those classes in a grant is inert, because
104    /// `grants_auto_apply` hard-codes memory|query.
105    #[test]
106    fn code_targets_never_auto_apply_even_when_granted() {
107        let p = Policy::from_json(
108            r#"{"auto_apply_enabled": true,
109                "auto_apply": [{"analyzer": "loop.codegen", "targets": ["code", "evalset", "memory"], "max_severity": "high"}]}"#,
110        )
111        .unwrap();
112        assert!(!p.grants_auto_apply("loop.codegen", "code", Severity::Info));
113        assert!(!p.grants_auto_apply("loop.codegen", "evalset", Severity::Info));
114        assert!(
115            p.grants_auto_apply("loop.codegen", "memory", Severity::Low),
116            "the same grant's memory leg still works — the exclusion is by class"
117        );
118    }
119
120    #[test]
121    fn default_policy_grants_nothing() {
122        let p = Policy::default();
123        assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Info));
124        assert!(!p.denies("loop.staleness"));
125        assert_eq!(p.telemetry, TelemetryMode::Aggregate);
126    }
127
128    #[test]
129    fn parses_and_grants() {
130        let p = Policy::from_json(
131            r#"{"auto_apply_enabled": true,
132                "auto_apply": [{"analyzer": "loop.duplicate_sweep", "targets": ["memory"], "max_severity": "low"}],
133                "deny": ["loop.staleness"],
134                "severity_floors": {"loop.contradiction_sweep": "high"}}"#,
135        )
136        .unwrap();
137        assert!(p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::Low));
138        assert!(!p.grants_auto_apply("loop.duplicate_sweep", "memory", Severity::High), "above max_severity");
139        assert!(!p.grants_auto_apply("loop.duplicate_sweep", "query", Severity::Low), "query not granted");
140        assert!(p.denies("loop.staleness"));
141        assert_eq!(p.severity_floor("loop.contradiction_sweep"), Some(Severity::High));
142    }
143
144    #[test]
145    fn prompt_and_host_targets_never_granted() {
146        let p = Policy::from_json(
147            r#"{"auto_apply_enabled": true,
148                "auto_apply": [{"analyzer": "x", "targets": ["prompt", "host"], "max_severity": "high"}]}"#,
149        )
150        .unwrap();
151        assert!(!p.grants_auto_apply("x", "prompt", Severity::Info));
152        assert!(!p.grants_auto_apply("x", "host", Severity::Info));
153    }
154
155    #[test]
156    fn unknown_keys_rejected() {
157        // A trust-floor field or an executable registration must not load.
158        assert!(Policy::from_json(r#"{"analyzer_cmd": "evil"}"#).is_err());
159        assert!(Policy::from_json(r#"{"auto_apply_free_text": true}"#).is_err());
160    }
161}