Skip to main content

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}