Skip to main content

lex_store/
policy.rs

1//! `<store>/policy.json` — local trust policy (#181, originally
2//! called out in the v3 acceptance criteria for #172).
3//!
4//! v3 introduced human-issued attestations (`Override`, `Defer`,
5//! `Block`, `Unblock`); v3 follow-up adds the inverse: the human
6//! can signal that *agent*-issued attestations from a specific
7//! producer shouldn't be trusted. Enforcement is at attestation-
8//! read time — the on-disk attestation log keeps the original
9//! record, and consumers (web activity feed, CI gates, etc.)
10//! consult the policy to decide whether to surface a `blocked`
11//! tag or filter the row out.
12//!
13//! File schema (deliberately small, mirroring `users.json`):
14//!
15//! ```json
16//! {
17//!   "blocked_producers": [
18//!     {"tool": "buggy-bot", "reason": "false positives", "blocked_at": 1714960000}
19//!   ]
20//! }
21//! ```
22//!
23//! Matching is against `Attestation::produced_by.tool`. `model`
24//! is intentionally not part of the match key in v1 — the tool
25//! identifier is stable across model upgrades. Add a `model`
26//! field if a future use case actually needs it.
27
28use serde::{Deserialize, Serialize};
29use std::fs;
30use std::io::{self, Write};
31use std::path::Path;
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct BlockedProducer {
35    /// Matched against `ProducerDescriptor::tool`.
36    pub tool: String,
37    pub reason: String,
38    /// Wall-clock seconds since epoch when the block was added.
39    /// Useful for "blocked since X" rendering in the activity feed.
40    pub blocked_at: u64,
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
44pub struct PolicyFile {
45    #[serde(default)]
46    pub blocked_producers: Vec<BlockedProducer>,
47}
48
49/// Load `<root>/policy.json`. Returns `Ok(None)` when absent
50/// (no policy → no blocks); `Ok(Some(default))` when the file
51/// exists but is empty/has no blocks.
52pub fn load(root: &Path) -> io::Result<Option<PolicyFile>> {
53    let path = root.join("policy.json");
54    if !path.exists() {
55        return Ok(None);
56    }
57    let bytes = fs::read(&path)?;
58    let file: PolicyFile = serde_json::from_slice(&bytes)
59        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData,
60            format!("parsing {}: {e}", path.display())))?;
61    Ok(Some(file))
62}
63
64/// Atomic write: tempfile + rename so a crashed write never
65/// leaves a half-truncated `policy.json`. Same pattern the
66/// attestation log uses.
67pub fn save(root: &Path, file: &PolicyFile) -> io::Result<()> {
68    fs::create_dir_all(root)?;
69    let path = root.join("policy.json");
70    let tmp = path.with_extension("json.tmp");
71    let bytes = serde_json::to_vec_pretty(file)
72        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
73    {
74        let mut f = fs::File::create(&tmp)?;
75        f.write_all(&bytes)?;
76        f.sync_all()?;
77    }
78    fs::rename(&tmp, &path)
79}
80
81impl PolicyFile {
82    /// Whether the named tool is on the block list.
83    pub fn is_blocked(&self, tool: &str) -> bool {
84        self.blocked_producers.iter().any(|p| p.tool == tool)
85    }
86
87    /// Look up the block entry, if any. Useful for "blocked
88    /// since X — reason: Y" rendering.
89    pub fn find(&self, tool: &str) -> Option<&BlockedProducer> {
90        self.blocked_producers.iter().find(|p| p.tool == tool)
91    }
92
93    /// Add a producer to the block list. Idempotent: blocking an
94    /// already-blocked tool is a no-op (preserves the original
95    /// `blocked_at`); the new reason is dropped. Callers that
96    /// want to update a reason should `unblock` then `block`.
97    pub fn block(&mut self, tool: String, reason: String, now: u64) {
98        if self.is_blocked(&tool) {
99            return;
100        }
101        self.blocked_producers.push(BlockedProducer {
102            tool,
103            reason,
104            blocked_at: now,
105        });
106    }
107
108    /// Remove a producer from the block list. Returns whether
109    /// the entry was present.
110    pub fn unblock(&mut self, tool: &str) -> bool {
111        let before = self.blocked_producers.len();
112        self.blocked_producers.retain(|p| p.tool != tool);
113        before != self.blocked_producers.len()
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use tempfile::tempdir;
121
122    #[test]
123    fn load_absent_returns_none() {
124        let tmp = tempdir().unwrap();
125        assert!(load(tmp.path()).unwrap().is_none());
126    }
127
128    #[test]
129    fn round_trip_through_disk() {
130        let tmp = tempdir().unwrap();
131        let mut f = PolicyFile::default();
132        f.block("bot-a".into(), "false positives".into(), 1000);
133        f.block("bot-b".into(), "stale model".into(), 2000);
134        save(tmp.path(), &f).unwrap();
135        let got = load(tmp.path()).unwrap().unwrap();
136        assert_eq!(got, f);
137        assert!(got.is_blocked("bot-a"));
138        assert!(!got.is_blocked("not-blocked"));
139        assert_eq!(got.find("bot-b").unwrap().reason, "stale model");
140    }
141
142    #[test]
143    fn block_is_idempotent() {
144        let mut f = PolicyFile::default();
145        f.block("bot".into(), "first reason".into(), 100);
146        f.block("bot".into(), "second reason — ignored".into(), 200);
147        assert_eq!(f.blocked_producers.len(), 1);
148        // Original blocked_at + reason preserved.
149        let entry = f.find("bot").unwrap();
150        assert_eq!(entry.blocked_at, 100);
151        assert_eq!(entry.reason, "first reason");
152    }
153
154    #[test]
155    fn unblock_removes_entry() {
156        let mut f = PolicyFile::default();
157        f.block("bot".into(), "x".into(), 1);
158        assert!(f.unblock("bot"));
159        assert!(!f.is_blocked("bot"));
160        // Second unblock is a no-op and returns false.
161        assert!(!f.unblock("bot"));
162    }
163
164    #[test]
165    fn malformed_json_is_an_error() {
166        let tmp = tempdir().unwrap();
167        std::fs::write(tmp.path().join("policy.json"), "{ not json").unwrap();
168        let err = load(tmp.path()).unwrap_err();
169        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
170    }
171}