lex_store/policy.rs
1//! `<store>/policy.json` — local trust policy.
2//!
3//! Two orthogonal concerns share the same file:
4//!
5//! 1. **`blocked_producers`** (#181) — negative gate on
6//! attestations. Producers on this list keep their attestations
7//! in the log (audit trail intact) but consumers tag those rows
8//! `blocked`. Enforcement is at attestation-read time.
9//! 2. **`required_attestations`** (#245) — positive gate on
10//! *branch advancement*. Each entry says "every op landed on
11//! this branch must carry a `Passed` attestation of kind X (or
12//! of kind X *when its effects intersect Y*) before the branch
13//! head can move past it." This is the agent-shaped equivalent
14//! of "branch protection rules" in human VCSes, grounded in the
15//! attestation graph rather than human review.
16//!
17//! File schema (additive across versions):
18//!
19//! ```json
20//! {
21//! "blocked_producers": [
22//! {"tool": "buggy-bot", "reason": "false positives", "blocked_at": 1714960000}
23//! ],
24//! "required_attestations": [
25//! {"kind": "type_check", "when": {"always": null}},
26//! {"kind": "spec", "when": {"always": null}},
27//! {"kind": "sandbox_run", "when": {"effects_intersect": ["io", "net", "fs_write"]}}
28//! ]
29//! }
30//! ```
31//!
32//! Existing `policy.json` files keep working — `required_attestations`
33//! defaults to empty (no gate).
34
35use lex_vcs::{Attestation, AttestationKind, AttestationLog, AttestationResult, OpId};
36use serde::{Deserialize, Serialize};
37use std::collections::BTreeSet;
38use std::fs;
39use std::io::{self, Write};
40use std::path::Path;
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct BlockedProducer {
44 /// Matched against `ProducerDescriptor::tool`.
45 pub tool: String,
46 pub reason: String,
47 /// Wall-clock seconds since epoch when the block was added.
48 /// Useful for "blocked since X" rendering in the activity feed.
49 pub blocked_at: u64,
50}
51
52#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
53pub struct PolicyFile {
54 #[serde(default)]
55 pub blocked_producers: Vec<BlockedProducer>,
56 /// Positive gate on branch advance (#245). Empty means "no
57 /// requirements" — same behavior as before this field existed.
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
59 pub required_attestations: Vec<RequiredAttestation>,
60}
61
62/// One required-attestation rule. Says: "every op advancing the
63/// branch must carry a `Passed` attestation of `kind`, except
64/// possibly when `when` filters it out."
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct RequiredAttestation {
67 pub kind: RequiredAttestationKind,
68 /// Defaults to [`AttestationCondition::Always`] if absent in the
69 /// JSON — the typical "this attestation is mandatory for every
70 /// op" rule.
71 #[serde(default)]
72 pub when: AttestationCondition,
73}
74
75/// Which `AttestationKind` is required. Mirrors the variants the
76/// existing producers emit (`TypeCheck` from #130, `Spec` from
77/// #186, `SandboxRun` from `lex agent-tool`, etc.). Only the
78/// machine-emittable variants are exposed; human-only attestations
79/// like `Override` and `Block` aren't useful here.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum RequiredAttestationKind {
83 TypeCheck,
84 Spec,
85 SandboxRun,
86 Examples,
87 DiffBody,
88 EffectAudit,
89}
90
91impl RequiredAttestationKind {
92 /// Match against an actual [`AttestationKind`]. Variants with
93 /// payloads (e.g. `Spec { spec_id, … }`, `SandboxRun { effects }`)
94 /// match the type-tag only — any spec, any sandbox run.
95 pub fn matches(&self, kind: &AttestationKind) -> bool {
96 matches!(
97 (self, kind),
98 (Self::TypeCheck, AttestationKind::TypeCheck)
99 | (Self::Spec, AttestationKind::Spec { .. })
100 | (Self::SandboxRun, AttestationKind::SandboxRun { .. })
101 | (Self::Examples, AttestationKind::Examples { .. })
102 | (Self::DiffBody, AttestationKind::DiffBody { .. })
103 | (Self::EffectAudit, AttestationKind::EffectAudit)
104 )
105 }
106
107 /// CLI-friendly tag used by `lex policy require-attestation
108 /// <tag>` and rendered in `lex policy list`.
109 pub fn tag(&self) -> &'static str {
110 match self {
111 Self::TypeCheck => "type_check",
112 Self::Spec => "spec",
113 Self::SandboxRun => "sandbox_run",
114 Self::Examples => "examples",
115 Self::DiffBody => "diff_body",
116 Self::EffectAudit => "effect_audit",
117 }
118 }
119
120 /// Inverse of [`Self::tag`] — used when parsing CLI input.
121 pub fn from_tag(s: &str) -> Option<Self> {
122 match s {
123 "type_check" | "TypeCheck" => Some(Self::TypeCheck),
124 "spec" | "Spec" => Some(Self::Spec),
125 "sandbox_run" | "SandboxRun" => Some(Self::SandboxRun),
126 "examples" | "Examples" => Some(Self::Examples),
127 "diff_body" | "DiffBody" => Some(Self::DiffBody),
128 "effect_audit" | "EffectAudit" => Some(Self::EffectAudit),
129 _ => None,
130 }
131 }
132}
133
134/// When a [`RequiredAttestation`] applies to a given op.
135#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum AttestationCondition {
138 /// The attestation is required for every op.
139 #[default]
140 Always,
141 /// Only required when the op's declared effect set intersects
142 /// any of these effect strings. Matches the same effect-name
143 /// shape used in `OperationKind::AddFunction.effects` etc.
144 /// Empty set means the rule is effectively disabled (useful as
145 /// a temporary kill-switch without removing the entry).
146 EffectsIntersect(BTreeSet<String>),
147}
148
149impl AttestationCondition {
150 /// Whether the rule fires for an op whose declared effects are
151 /// `op_effects`. `EffectsIntersect` with an empty set never
152 /// fires.
153 pub fn applies(&self, op_effects: &BTreeSet<String>) -> bool {
154 match self {
155 AttestationCondition::Always => true,
156 AttestationCondition::EffectsIntersect(needed) => {
157 !needed.is_empty() && op_effects.iter().any(|e| needed.contains(e))
158 }
159 }
160 }
161}
162
163/// Load `<root>/policy.json`. Returns `Ok(None)` when absent
164/// (no policy → no blocks); `Ok(Some(default))` when the file
165/// exists but is empty/has no blocks.
166pub fn load(root: &Path) -> io::Result<Option<PolicyFile>> {
167 let path = root.join("policy.json");
168 if !path.exists() {
169 return Ok(None);
170 }
171 let bytes = fs::read(&path)?;
172 let file: PolicyFile = serde_json::from_slice(&bytes)
173 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData,
174 format!("parsing {}: {e}", path.display())))?;
175 Ok(Some(file))
176}
177
178/// Atomic write: tempfile + rename so a crashed write never
179/// leaves a half-truncated `policy.json`. Same pattern the
180/// attestation log uses.
181pub fn save(root: &Path, file: &PolicyFile) -> io::Result<()> {
182 fs::create_dir_all(root)?;
183 let path = root.join("policy.json");
184 let tmp = path.with_extension("json.tmp");
185 let bytes = serde_json::to_vec_pretty(file)
186 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
187 {
188 let mut f = fs::File::create(&tmp)?;
189 f.write_all(&bytes)?;
190 f.sync_all()?;
191 }
192 fs::rename(&tmp, &path)
193}
194
195impl PolicyFile {
196 /// Whether the named tool is on the block list.
197 pub fn is_blocked(&self, tool: &str) -> bool {
198 self.blocked_producers.iter().any(|p| p.tool == tool)
199 }
200
201 /// Look up the block entry, if any. Useful for "blocked
202 /// since X — reason: Y" rendering.
203 pub fn find(&self, tool: &str) -> Option<&BlockedProducer> {
204 self.blocked_producers.iter().find(|p| p.tool == tool)
205 }
206
207 /// Add a producer to the block list. Idempotent: blocking an
208 /// already-blocked tool is a no-op (preserves the original
209 /// `blocked_at`); the new reason is dropped. Callers that
210 /// want to update a reason should `unblock` then `block`.
211 pub fn block(&mut self, tool: String, reason: String, now: u64) {
212 if self.is_blocked(&tool) {
213 return;
214 }
215 self.blocked_producers.push(BlockedProducer {
216 tool,
217 reason,
218 blocked_at: now,
219 });
220 }
221
222 /// Remove a producer from the block list. Returns whether
223 /// the entry was present.
224 pub fn unblock(&mut self, tool: &str) -> bool {
225 let before = self.blocked_producers.len();
226 self.blocked_producers.retain(|p| p.tool != tool);
227 before != self.blocked_producers.len()
228 }
229
230 /// Add a `RequiredAttestation` rule. Idempotent on `(kind, when)`
231 /// — the same rule submitted twice is a single entry. Different
232 /// `when` clauses for the same `kind` are distinct rules and
233 /// stack (e.g. "always require Spec" plus "require SandboxRun
234 /// when effects intersect [io]").
235 pub fn require_attestation(
236 &mut self,
237 kind: RequiredAttestationKind,
238 when: AttestationCondition,
239 ) -> bool {
240 let new = RequiredAttestation { kind, when };
241 if self.required_attestations.contains(&new) {
242 return false;
243 }
244 self.required_attestations.push(new);
245 true
246 }
247
248 /// Remove every rule with the given kind. Returns how many
249 /// rules were removed. Use this to drop a requirement entirely;
250 /// for narrowing a rule (e.g. `Always` → `EffectsIntersect`)
251 /// remove + re-add.
252 pub fn unrequire_attestation(&mut self, kind: RequiredAttestationKind) -> usize {
253 let before = self.required_attestations.len();
254 self.required_attestations
255 .retain(|r| r.kind != kind);
256 before - self.required_attestations.len()
257 }
258}
259
260// ---------------------------------------------------------------- gate
261
262/// Why a branch advance was refused by the [`required_attestations`]
263/// gate. Surfaced as `StoreError::BranchAdvanceBlocked` and as a
264/// structured envelope on the HTTP API.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
266pub struct BranchAdvanceBlocked {
267 pub op_id: OpId,
268 /// Stage the gate was checking. `None` for ops that don't touch
269 /// a stage (imports, merges) — those are always allowed because
270 /// there's nothing to attest.
271 pub stage_id: Option<String>,
272 /// Tags of attestation kinds that were required but missing
273 /// (or present only as Failed/Inconclusive).
274 pub missing: Vec<String>,
275}
276
277impl BranchAdvanceBlocked {
278 /// Render as a structured JSON envelope. Used by the HTTP layer
279 /// and `lex` CLI's `--output json`.
280 pub fn to_envelope(&self) -> serde_json::Value {
281 serde_json::json!({
282 "error": "BranchAdvanceBlocked",
283 "op_id": self.op_id,
284 "stage_id": self.stage_id,
285 "missing": self.missing,
286 })
287 }
288}
289
290/// Verify that the candidate ops carry every required attestation.
291/// Called by [`crate::Store::apply_operation`] (and friends) between
292/// op persistence and branch-head advance.
293///
294/// `candidate` lists the ops *being added by this advance* — for the
295/// single-op apply path (today's only writer) it's a one-element
296/// slice. Each op is checked against the policy by walking the
297/// stage's attestation list once and matching on `op_id` and kind.
298///
299/// Ops without an attestable `stage_id` (imports, merges) pass the
300/// gate unconditionally — there's nothing to attest. The
301/// content-addressed merge resolution itself isn't where evidence
302/// belongs; the constituent stages on either side are.
303pub fn check_required_attestations(
304 log: &AttestationLog,
305 candidate: &[(OpId, Option<String>, BTreeSet<String>)],
306 policy: &PolicyFile,
307) -> Result<(), BranchAdvanceBlocked> {
308 if policy.required_attestations.is_empty() {
309 return Ok(());
310 }
311 for (op_id, stage_id_opt, op_effects) in candidate {
312 let stage_id = match stage_id_opt {
313 Some(s) => s,
314 // No stage to attest — skip. The policy is per-stage;
315 // an import or merge doesn't have a verdict surface.
316 None => continue,
317 };
318 let attestations = log
319 .list_for_stage(stage_id)
320 .map_err(|e| BranchAdvanceBlocked {
321 op_id: op_id.clone(),
322 stage_id: Some(stage_id.clone()),
323 missing: vec![format!("io:{e}")],
324 })?;
325 let mut missing: Vec<String> = Vec::new();
326 for rule in &policy.required_attestations {
327 if !rule.when.applies(op_effects) {
328 continue;
329 }
330 let satisfied = attestations.iter().any(|a| {
331 a.op_id.as_deref() == Some(op_id.as_str())
332 && rule.kind.matches(&a.kind)
333 && passed(&a.result)
334 });
335 if !satisfied {
336 missing.push(rule.kind.tag().to_string());
337 }
338 }
339 if !missing.is_empty() {
340 // De-dup in case the same kind is required twice with
341 // different `when` clauses; the user only needs to
342 // surface it once.
343 missing.sort();
344 missing.dedup();
345 return Err(BranchAdvanceBlocked {
346 op_id: op_id.clone(),
347 stage_id: Some(stage_id.clone()),
348 missing,
349 });
350 }
351 }
352 Ok(())
353}
354
355fn passed(r: &AttestationResult) -> bool {
356 matches!(r, AttestationResult::Passed)
357}
358
359#[allow(dead_code)]
360fn _force_use(_: Attestation) {} // keep unused-import warning quiet across feature flips
361
362// ----------------------------------------------- producer-block gate (#248)
363
364/// Why a branch advance was refused by the
365/// [`check_producer_block`] gate. Surfaced as
366/// `StoreError::ProducerBlocked` and as a distinct
367/// `ProducerBlocked` envelope on the HTTP API.
368///
369/// Distinct from [`BranchAdvanceBlocked`] (#245's positive
370/// attestation gate) because the response shape is different:
371/// the operator needs to know *which producer* was blocked and
372/// *which attestation* tripped the gate, not just "an
373/// attestation was missing."
374#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
375pub struct ProducerBlocked {
376 pub op_id: OpId,
377 /// Stage the offending attestation was about. Always set —
378 /// `ProducerBlock` only fires when a candidate op has at
379 /// least one stage with attestations.
380 pub stage_id: String,
381 /// Tool that's been retroactively quarantined.
382 pub tool_id: String,
383 /// Cutoff from the most recent `AttestationKind::ProducerBlock`
384 /// for this tool.
385 pub blocked_at: u64,
386 /// Wall-clock timestamp of the offending attestation. Always
387 /// `>= blocked_at`; otherwise the gate wouldn't have fired.
388 pub attestation_at: u64,
389 /// `attestation_id` of the offending attestation, so the
390 /// operator can drill in via `lex attest filter`.
391 pub attestation_id: String,
392}
393
394impl ProducerBlocked {
395 /// Render as a structured JSON envelope. Distinct `error`
396 /// discriminator (`ProducerBlocked`) so the HTTP layer can
397 /// route it to a different status code or recovery path
398 /// from `BranchAdvanceBlocked`.
399 pub fn to_envelope(&self) -> serde_json::Value {
400 serde_json::json!({
401 "error": "ProducerBlocked",
402 "op_id": self.op_id,
403 "stage_id": self.stage_id,
404 "tool_id": self.tool_id,
405 "blocked_at": self.blocked_at,
406 "attestation_at": self.attestation_at,
407 "attestation_id": self.attestation_id,
408 })
409 }
410}
411
412/// Verify that the candidate ops are not contaminated by
413/// attestations from a retroactively quarantined producer (#248).
414///
415/// Algorithm:
416///
417/// 1. Walk the entire attestation log to collect every
418/// `ProducerBlock` / `ProducerUnblock` record. Build a map
419/// `tool_id → Some(blocked_at)` reflecting the latest verdict
420/// per tool.
421/// 2. For each candidate op, list attestations on its
422/// `stage_id`. For every attestation produced by a currently-
423/// blocked tool with `attestation.timestamp >= block.blocked_at`,
424/// refuse the advance with the offending row's details.
425///
426/// Cost is `O(total attestations)` for step 1 — fine for small
427/// stores; a `by-tool` index becomes worthwhile if the producer
428/// list grows past dozens. Step 2 is `O(attestations on the
429/// candidate stage)` per op, dominated by step 1 for the common
430/// case of a single-op advance.
431pub fn check_producer_block(
432 log: &AttestationLog,
433 candidate: &[(OpId, Option<String>, BTreeSet<String>)],
434) -> Result<(), ProducerBlocked> {
435 // Step 1: build the active-block map.
436 let all = match log.list_all() {
437 Ok(v) => v,
438 // Empty log = nothing to check.
439 Err(_) => return Ok(()),
440 };
441 use std::collections::HashMap;
442 let mut active: HashMap<String, u64> = HashMap::new();
443 // Process in timestamp order so the latest verdict per tool
444 // wins. Ties go to ProducerUnblock (matches the spirit of
445 // is_stage_blocked).
446 let mut ordered: Vec<&Attestation> = all.iter().collect();
447 ordered.sort_by(|a, b| {
448 a.timestamp.cmp(&b.timestamp).then_with(|| {
449 // Same timestamp: ProducerUnblock sorts after
450 // ProducerBlock so it wins the "latest" check.
451 let a_unblock = matches!(a.kind, AttestationKind::ProducerUnblock { .. });
452 let b_unblock = matches!(b.kind, AttestationKind::ProducerUnblock { .. });
453 a_unblock.cmp(&b_unblock)
454 })
455 });
456 for a in ordered {
457 match &a.kind {
458 AttestationKind::ProducerBlock { tool_id, blocked_at, .. } => {
459 active.insert(tool_id.clone(), *blocked_at);
460 }
461 AttestationKind::ProducerUnblock { tool_id, .. } => {
462 active.remove(tool_id);
463 }
464 _ => {}
465 }
466 }
467 if active.is_empty() {
468 return Ok(());
469 }
470
471 // Step 2: per-op attestation walk.
472 for (op_id, stage_id_opt, _) in candidate {
473 let stage_id = match stage_id_opt {
474 Some(s) => s,
475 None => continue,
476 };
477 let attestations = match log.list_for_stage(stage_id) {
478 Ok(v) => v,
479 Err(_) => continue,
480 };
481 for a in attestations {
482 // Self-references (a producer's own ProducerBlock /
483 // Unblock attestations are stored at stage_id ==
484 // tool_id) shouldn't be flagged as contamination.
485 if matches!(
486 a.kind,
487 AttestationKind::ProducerBlock { .. }
488 | AttestationKind::ProducerUnblock { .. }
489 ) {
490 continue;
491 }
492 if let Some(&blocked_at) = active.get(&a.produced_by.tool) {
493 if a.timestamp >= blocked_at {
494 return Err(ProducerBlocked {
495 op_id: op_id.clone(),
496 stage_id: stage_id.clone(),
497 tool_id: a.produced_by.tool.clone(),
498 blocked_at,
499 attestation_at: a.timestamp,
500 attestation_id: a.attestation_id.clone(),
501 });
502 }
503 }
504 }
505 }
506 Ok(())
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use tempfile::tempdir;
513
514 #[test]
515 fn load_absent_returns_none() {
516 let tmp = tempdir().unwrap();
517 assert!(load(tmp.path()).unwrap().is_none());
518 }
519
520 #[test]
521 fn round_trip_through_disk() {
522 let tmp = tempdir().unwrap();
523 let mut f = PolicyFile::default();
524 f.block("bot-a".into(), "false positives".into(), 1000);
525 f.block("bot-b".into(), "stale model".into(), 2000);
526 save(tmp.path(), &f).unwrap();
527 let got = load(tmp.path()).unwrap().unwrap();
528 assert_eq!(got, f);
529 assert!(got.is_blocked("bot-a"));
530 assert!(!got.is_blocked("not-blocked"));
531 assert_eq!(got.find("bot-b").unwrap().reason, "stale model");
532 }
533
534 #[test]
535 fn block_is_idempotent() {
536 let mut f = PolicyFile::default();
537 f.block("bot".into(), "first reason".into(), 100);
538 f.block("bot".into(), "second reason — ignored".into(), 200);
539 assert_eq!(f.blocked_producers.len(), 1);
540 // Original blocked_at + reason preserved.
541 let entry = f.find("bot").unwrap();
542 assert_eq!(entry.blocked_at, 100);
543 assert_eq!(entry.reason, "first reason");
544 }
545
546 #[test]
547 fn unblock_removes_entry() {
548 let mut f = PolicyFile::default();
549 f.block("bot".into(), "x".into(), 1);
550 assert!(f.unblock("bot"));
551 assert!(!f.is_blocked("bot"));
552 // Second unblock is a no-op and returns false.
553 assert!(!f.unblock("bot"));
554 }
555
556 #[test]
557 fn malformed_json_is_an_error() {
558 let tmp = tempdir().unwrap();
559 std::fs::write(tmp.path().join("policy.json"), "{ not json").unwrap();
560 let err = load(tmp.path()).unwrap_err();
561 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
562 }
563}