use lex_vcs::{Attestation, AttestationKind, AttestationLog, AttestationResult, OpId};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::io::{self, Write};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlockedProducer {
pub tool: String,
pub reason: String,
pub blocked_at: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyFile {
#[serde(default)]
pub blocked_producers: Vec<BlockedProducer>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_attestations: Vec<RequiredAttestation>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RequiredAttestation {
pub kind: RequiredAttestationKind,
#[serde(default)]
pub when: AttestationCondition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RequiredAttestationKind {
TypeCheck,
Spec,
SandboxRun,
Examples,
DiffBody,
EffectAudit,
}
impl RequiredAttestationKind {
pub fn matches(&self, kind: &AttestationKind) -> bool {
matches!(
(self, kind),
(Self::TypeCheck, AttestationKind::TypeCheck)
| (Self::Spec, AttestationKind::Spec { .. })
| (Self::SandboxRun, AttestationKind::SandboxRun { .. })
| (Self::Examples, AttestationKind::Examples { .. })
| (Self::DiffBody, AttestationKind::DiffBody { .. })
| (Self::EffectAudit, AttestationKind::EffectAudit)
)
}
pub fn tag(&self) -> &'static str {
match self {
Self::TypeCheck => "type_check",
Self::Spec => "spec",
Self::SandboxRun => "sandbox_run",
Self::Examples => "examples",
Self::DiffBody => "diff_body",
Self::EffectAudit => "effect_audit",
}
}
pub fn from_tag(s: &str) -> Option<Self> {
match s {
"type_check" | "TypeCheck" => Some(Self::TypeCheck),
"spec" | "Spec" => Some(Self::Spec),
"sandbox_run" | "SandboxRun" => Some(Self::SandboxRun),
"examples" | "Examples" => Some(Self::Examples),
"diff_body" | "DiffBody" => Some(Self::DiffBody),
"effect_audit" | "EffectAudit" => Some(Self::EffectAudit),
_ => None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AttestationCondition {
#[default]
Always,
EffectsIntersect(BTreeSet<String>),
}
impl AttestationCondition {
pub fn applies(&self, op_effects: &BTreeSet<String>) -> bool {
match self {
AttestationCondition::Always => true,
AttestationCondition::EffectsIntersect(needed) => {
!needed.is_empty() && op_effects.iter().any(|e| needed.contains(e))
}
}
}
}
pub fn load(root: &Path) -> io::Result<Option<PolicyFile>> {
let path = root.join("policy.json");
if !path.exists() {
return Ok(None);
}
let bytes = fs::read(&path)?;
let file: PolicyFile = serde_json::from_slice(&bytes)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData,
format!("parsing {}: {e}", path.display())))?;
Ok(Some(file))
}
pub fn save(root: &Path, file: &PolicyFile) -> io::Result<()> {
fs::create_dir_all(root)?;
let path = root.join("policy.json");
let tmp = path.with_extension("json.tmp");
let bytes = serde_json::to_vec_pretty(file)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
{
let mut f = fs::File::create(&tmp)?;
f.write_all(&bytes)?;
f.sync_all()?;
}
fs::rename(&tmp, &path)
}
impl PolicyFile {
pub fn is_blocked(&self, tool: &str) -> bool {
self.blocked_producers.iter().any(|p| p.tool == tool)
}
pub fn find(&self, tool: &str) -> Option<&BlockedProducer> {
self.blocked_producers.iter().find(|p| p.tool == tool)
}
pub fn block(&mut self, tool: String, reason: String, now: u64) {
if self.is_blocked(&tool) {
return;
}
self.blocked_producers.push(BlockedProducer {
tool,
reason,
blocked_at: now,
});
}
pub fn unblock(&mut self, tool: &str) -> bool {
let before = self.blocked_producers.len();
self.blocked_producers.retain(|p| p.tool != tool);
before != self.blocked_producers.len()
}
pub fn require_attestation(
&mut self,
kind: RequiredAttestationKind,
when: AttestationCondition,
) -> bool {
let new = RequiredAttestation { kind, when };
if self.required_attestations.contains(&new) {
return false;
}
self.required_attestations.push(new);
true
}
pub fn unrequire_attestation(&mut self, kind: RequiredAttestationKind) -> usize {
let before = self.required_attestations.len();
self.required_attestations
.retain(|r| r.kind != kind);
before - self.required_attestations.len()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BranchAdvanceBlocked {
pub op_id: OpId,
pub stage_id: Option<String>,
pub missing: Vec<String>,
}
impl BranchAdvanceBlocked {
pub fn to_envelope(&self) -> serde_json::Value {
serde_json::json!({
"error": "BranchAdvanceBlocked",
"op_id": self.op_id,
"stage_id": self.stage_id,
"missing": self.missing,
})
}
}
pub fn check_required_attestations(
log: &AttestationLog,
candidate: &[(OpId, Option<String>, BTreeSet<String>)],
policy: &PolicyFile,
) -> Result<(), BranchAdvanceBlocked> {
if policy.required_attestations.is_empty() {
return Ok(());
}
for (op_id, stage_id_opt, op_effects) in candidate {
let stage_id = match stage_id_opt {
Some(s) => s,
None => continue,
};
let attestations = log
.list_for_stage(stage_id)
.map_err(|e| BranchAdvanceBlocked {
op_id: op_id.clone(),
stage_id: Some(stage_id.clone()),
missing: vec![format!("io:{e}")],
})?;
let mut missing: Vec<String> = Vec::new();
for rule in &policy.required_attestations {
if !rule.when.applies(op_effects) {
continue;
}
let satisfied = attestations.iter().any(|a| {
a.op_id.as_deref() == Some(op_id.as_str())
&& rule.kind.matches(&a.kind)
&& passed(&a.result)
});
if !satisfied {
missing.push(rule.kind.tag().to_string());
}
}
if !missing.is_empty() {
missing.sort();
missing.dedup();
return Err(BranchAdvanceBlocked {
op_id: op_id.clone(),
stage_id: Some(stage_id.clone()),
missing,
});
}
}
Ok(())
}
fn passed(r: &AttestationResult) -> bool {
matches!(r, AttestationResult::Passed)
}
#[allow(dead_code)]
fn _force_use(_: Attestation) {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ProducerBlocked {
pub op_id: OpId,
pub stage_id: String,
pub tool_id: String,
pub blocked_at: u64,
pub attestation_at: u64,
pub attestation_id: String,
}
impl ProducerBlocked {
pub fn to_envelope(&self) -> serde_json::Value {
serde_json::json!({
"error": "ProducerBlocked",
"op_id": self.op_id,
"stage_id": self.stage_id,
"tool_id": self.tool_id,
"blocked_at": self.blocked_at,
"attestation_at": self.attestation_at,
"attestation_id": self.attestation_id,
})
}
}
pub fn check_producer_block(
log: &AttestationLog,
candidate: &[(OpId, Option<String>, BTreeSet<String>)],
) -> Result<(), ProducerBlocked> {
let all = match log.list_all() {
Ok(v) => v,
Err(_) => return Ok(()),
};
use std::collections::HashMap;
let mut active: HashMap<String, u64> = HashMap::new();
let mut ordered: Vec<&Attestation> = all.iter().collect();
ordered.sort_by(|a, b| {
a.timestamp.cmp(&b.timestamp).then_with(|| {
let a_unblock = matches!(a.kind, AttestationKind::ProducerUnblock { .. });
let b_unblock = matches!(b.kind, AttestationKind::ProducerUnblock { .. });
a_unblock.cmp(&b_unblock)
})
});
for a in ordered {
match &a.kind {
AttestationKind::ProducerBlock { tool_id, blocked_at, .. } => {
active.insert(tool_id.clone(), *blocked_at);
}
AttestationKind::ProducerUnblock { tool_id, .. } => {
active.remove(tool_id);
}
_ => {}
}
}
if active.is_empty() {
return Ok(());
}
for (op_id, stage_id_opt, _) in candidate {
let stage_id = match stage_id_opt {
Some(s) => s,
None => continue,
};
let attestations = match log.list_for_stage(stage_id) {
Ok(v) => v,
Err(_) => continue,
};
for a in attestations {
if matches!(
a.kind,
AttestationKind::ProducerBlock { .. }
| AttestationKind::ProducerUnblock { .. }
) {
continue;
}
if let Some(&blocked_at) = active.get(&a.produced_by.tool) {
if a.timestamp >= blocked_at {
return Err(ProducerBlocked {
op_id: op_id.clone(),
stage_id: stage_id.clone(),
tool_id: a.produced_by.tool.clone(),
blocked_at,
attestation_at: a.timestamp,
attestation_id: a.attestation_id.clone(),
});
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn load_absent_returns_none() {
let tmp = tempdir().unwrap();
assert!(load(tmp.path()).unwrap().is_none());
}
#[test]
fn round_trip_through_disk() {
let tmp = tempdir().unwrap();
let mut f = PolicyFile::default();
f.block("bot-a".into(), "false positives".into(), 1000);
f.block("bot-b".into(), "stale model".into(), 2000);
save(tmp.path(), &f).unwrap();
let got = load(tmp.path()).unwrap().unwrap();
assert_eq!(got, f);
assert!(got.is_blocked("bot-a"));
assert!(!got.is_blocked("not-blocked"));
assert_eq!(got.find("bot-b").unwrap().reason, "stale model");
}
#[test]
fn block_is_idempotent() {
let mut f = PolicyFile::default();
f.block("bot".into(), "first reason".into(), 100);
f.block("bot".into(), "second reason — ignored".into(), 200);
assert_eq!(f.blocked_producers.len(), 1);
let entry = f.find("bot").unwrap();
assert_eq!(entry.blocked_at, 100);
assert_eq!(entry.reason, "first reason");
}
#[test]
fn unblock_removes_entry() {
let mut f = PolicyFile::default();
f.block("bot".into(), "x".into(), 1);
assert!(f.unblock("bot"));
assert!(!f.is_blocked("bot"));
assert!(!f.unblock("bot"));
}
#[test]
fn malformed_json_is_an_error() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join("policy.json"), "{ not json").unwrap();
let err = load(tmp.path()).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
}