use std::fmt;
use async_trait::async_trait;
use boundgraph_core::{
Canonical, CheckerId, Condensate, Digest, Evidence, EvidenceKind, GrantSet, Verdict, WorkOrder,
};
use boundgraph_grant::{Tri, set_leq};
use crate::sign::{Sign, SignError, sign_evidence};
pub fn checker_ceiling() -> GrantSet {
GrantSet::parse(["fs:read:**", "fs:list:**", "fs:stat:**", "proc:exec:**"])
.expect("the checker ceiling is a compile-time constant and must parse")
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum CheckError {
#[error("checker {checker} declares `{atom}`, which is outside what a checker may hold")]
Overreach {
checker: CheckerId,
atom: String,
},
#[error("cannot prove checker {checker}'s grants are within what a checker may hold")]
UndecidableGrants {
checker: CheckerId,
},
#[error("checker {checker} could not address its inputs: {detail}")]
Address {
checker: CheckerId,
detail: String,
},
#[error("the verdict from {checker} could not be signed: {source}")]
Sign {
checker: CheckerId,
#[source]
source: SignError,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Judgement {
pub verdict: Verdict,
pub also: Vec<Digest>,
}
impl Judgement {
pub fn pass() -> Self {
Self {
verdict: Verdict::Pass,
also: Vec::new(),
}
}
pub fn fail(reason: impl Into<String>) -> Self {
Self {
verdict: Verdict::Fail(reason.into()),
also: Vec::new(),
}
}
pub fn inconclusive(reason: impl Into<String>) -> Self {
Self {
verdict: Verdict::Inconclusive(reason.into()),
also: Vec::new(),
}
}
#[must_use]
pub fn covering(mut self, digest: Digest) -> Self {
self.also.push(digest);
self
}
pub fn is_pass(&self) -> bool {
self.verdict.is_pass()
}
}
pub fn ensure_model_free(checker: &CheckerId, grants: &GrantSet) -> Result<(), CheckError> {
let ceiling = checker_ceiling();
match set_leq(grants, &ceiling) {
Tri::Yes => Ok(()),
Tri::No => {
let atom = grants
.iter()
.find(|a| !set_leq(&GrantSet::new([(*a).clone()]), &ceiling).is_yes())
.map(ToString::to_string)
.unwrap_or_else(|| "?".to_string());
Err(CheckError::Overreach {
checker: checker.clone(),
atom,
})
}
Tri::Unknown => Err(CheckError::UndecidableGrants {
checker: checker.clone(),
}),
}
}
#[async_trait]
pub trait Checker: Send + Sync + fmt::Debug {
fn id(&self) -> CheckerId;
fn grants(&self) -> GrantSet {
GrantSet::EMPTY
}
async fn judge(&self, order: &WorkOrder, condensate: &Condensate) -> Judgement;
async fn check(
&self,
order: &WorkOrder,
condensate: &Condensate,
signer: &dyn Sign,
) -> Result<Evidence, CheckError> {
let id = self.id();
ensure_model_free(&id, &self.grants())?;
let address = |value: Result<Digest, boundgraph_core::CanonicalError>| {
value.map_err(|e| CheckError::Address {
checker: id.clone(),
detail: e.to_string(),
})
};
let order_digest = address(order.digest())?;
let condensate_digest = address(condensate.digest())?;
let judgement = self.judge(order, condensate).await;
let inputs = [order_digest, condensate_digest]
.into_iter()
.chain(judgement.also);
sign_evidence(
judgement.verdict,
id.clone(),
inputs,
EvidenceKind::Deterministic,
signer,
)
.map_err(|source| CheckError::Sign {
checker: id,
source,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Constant {
id: CheckerId,
verdict: Verdict,
}
impl Constant {
pub fn passing() -> Self {
Self {
id: CheckerId::new("constant-pass", 1),
verdict: Verdict::Pass,
}
}
pub fn failing(reason: impl Into<String>) -> Self {
Self {
id: CheckerId::new("constant-fail", 1),
verdict: Verdict::Fail(reason.into()),
}
}
pub fn undecided(reason: impl Into<String>) -> Self {
Self {
id: CheckerId::new("constant-inconclusive", 1),
verdict: Verdict::Inconclusive(reason.into()),
}
}
#[must_use]
pub fn named(mut self, name: impl Into<String>, version: u32) -> Self {
self.id = CheckerId::new(name, version);
self
}
}
#[async_trait]
impl Checker for Constant {
fn id(&self) -> CheckerId {
self.id.clone()
}
async fn judge(&self, _order: &WorkOrder, _condensate: &Condensate) -> Judgement {
Judgement {
verdict: self.verdict.clone(),
also: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sign::{LocalKey, verify};
use boundgraph_core::{
CondensatePayload, GrantAtom, Intent, ProposalOrigin, SchemaId, SigningKeyId, SlicerId,
};
fn order() -> WorkOrder {
WorkOrder::new(
Intent::new("patch.apply", &"fix the off-by-one").unwrap(),
vec![],
GrantSet::parse(["fs:write:/src/**"]).unwrap(),
ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
)
}
fn condensate() -> Condensate {
Condensate::new(
SchemaId::new("boundgraph.diff.v1", 1),
CondensatePayload::inline(&"one file changed").unwrap(),
Digest::of(b"the raw patch"),
400,
false,
SlicerId::new("diff", 1),
)
}
fn key() -> LocalKey {
LocalKey::from_seed("host-key-1", [7u8; 32])
}
#[derive(Debug)]
struct Declaring {
grants: GrantSet,
}
impl Declaring {
fn new(atoms: &[&str]) -> Self {
Self {
grants: GrantSet::parse(atoms.iter().copied()).expect("test grants should parse"),
}
}
}
#[async_trait]
impl Checker for Declaring {
fn id(&self) -> CheckerId {
CheckerId::new("declaring", 1)
}
fn grants(&self) -> GrantSet {
self.grants.clone()
}
async fn judge(&self, _order: &WorkOrder, _condensate: &Condensate) -> Judgement {
Judgement::pass()
}
}
#[tokio::test]
async fn check_takes_a_work_order_and_a_condensate_and_returns_evidence() {
let evidence = Constant::passing()
.check(&order(), &condensate(), &key())
.await
.unwrap();
assert_eq!(evidence.verdict, Verdict::Pass);
assert!(evidence.permits_commit());
}
#[tokio::test]
async fn the_checker_id_and_version_reach_the_evidence() {
let evidence = Constant::passing()
.named("json-schema", 3)
.check(&order(), &condensate(), &key())
.await
.unwrap();
assert_eq!(evidence.checker, CheckerId::new("json-schema", 3));
assert_eq!(evidence.checker.version, 3);
}
#[tokio::test]
async fn a_checker_returns_pass_fail_or_inconclusive() {
let cases = [
(Constant::passing(), true),
(Constant::failing("schema violation at /items/2"), false),
(Constant::undecided("the compiler timed out"), false),
];
for (checker, commits) in cases {
let evidence = checker
.check(&order(), &condensate(), &key())
.await
.unwrap();
assert_eq!(evidence.permits_commit(), commits, "{checker:?}");
}
}
#[tokio::test]
async fn inconclusive_never_commits() {
let evidence = Constant::undecided("the test run timed out")
.check(&order(), &condensate(), &key())
.await
.unwrap();
assert!(!evidence.permits_commit());
assert_eq!(evidence.verdict.reason(), Some("the test run timed out"));
}
#[tokio::test]
async fn evidence_names_the_work_order_and_the_condensate() {
let (order, condensate) = (order(), condensate());
let evidence = Constant::passing()
.check(&order, &condensate, &key())
.await
.unwrap();
assert!(evidence.covers(&order.digest().unwrap()));
assert!(evidence.covers(&condensate.digest().unwrap()));
assert!(!evidence.covers(&Digest::of(b"some other artefact")));
}
#[tokio::test]
async fn a_checker_can_name_further_inputs_it_looked_at() {
#[derive(Debug)]
struct Schema;
#[async_trait]
impl Checker for Schema {
fn id(&self) -> CheckerId {
CheckerId::new("json-schema", 1)
}
async fn judge(&self, _o: &WorkOrder, _k: &Condensate) -> Judgement {
Judgement::pass().covering(Digest::of(b"the schema"))
}
}
let evidence = Schema.check(&order(), &condensate(), &key()).await.unwrap();
assert!(evidence.covers(&Digest::of(b"the schema")));
}
#[tokio::test]
async fn the_evidence_is_signed_and_verifies() {
let key = key();
let evidence = Constant::passing()
.check(&order(), &condensate(), &key)
.await
.unwrap();
assert!(verify(&evidence, &key.public_key()).is_ok());
assert_eq!(evidence.key, SigningKeyId("host-key-1".into()));
assert_eq!(evidence.kind, EvidenceKind::Deterministic);
}
#[tokio::test]
async fn the_same_inputs_always_yield_the_same_evidence() {
let (order, condensate, key) = (order(), condensate(), key());
let checker = Constant::passing();
let first = checker.check(&order, &condensate, &key).await.unwrap();
for _ in 0..8 {
assert_eq!(
checker.check(&order, &condensate, &key).await.unwrap(),
first
);
}
}
#[tokio::test]
async fn different_inputs_yield_different_evidence() {
let key = key();
let checker = Constant::passing();
let one = checker.check(&order(), &condensate(), &key).await.unwrap();
let mut other = condensate();
other.raw = Digest::of(b"a different raw patch");
let two = checker.check(&order(), &other, &key).await.unwrap();
assert_ne!(one, two);
}
#[tokio::test]
async fn a_checker_that_could_reach_a_model_provider_is_refused() {
for reaching in [
"net:egress:api.anthropic.com:443",
"net:egress:**",
"secret:use:anthropic_key",
] {
let err = Declaring::new(&[reaching])
.check(&order(), &condensate(), &key())
.await
.unwrap_err();
assert!(
matches!(err, CheckError::Overreach { .. }),
"{reaching} should be refused, got {err:?}"
);
}
}
#[tokio::test]
async fn an_over_authorised_checker_never_runs() {
#[derive(Debug)]
struct Explodes;
#[async_trait]
impl Checker for Explodes {
fn id(&self) -> CheckerId {
CheckerId::new("explodes", 1)
}
fn grants(&self) -> GrantSet {
GrantSet::parse(["net:egress:**"]).unwrap()
}
async fn judge(&self, _o: &WorkOrder, _k: &Condensate) -> Judgement {
panic!("an over-authorised checker must not reach its own body");
}
}
assert!(
Explodes
.check(&order(), &condensate(), &key())
.await
.is_err()
);
}
#[tokio::test]
async fn a_checker_cannot_hold_a_clock_or_a_source_of_randomness() {
for nondeterministic in ["time:now", "rand:bytes"] {
let err = Declaring::new(&[nondeterministic])
.check(&order(), &condensate(), &key())
.await
.unwrap_err();
assert!(
matches!(err, CheckError::Overreach { .. }),
"{nondeterministic}"
);
}
}
#[tokio::test]
async fn a_checker_cannot_write_to_what_it_is_judging() {
let err = Declaring::new(&["fs:write:/src/**"])
.check(&order(), &condensate(), &key())
.await
.unwrap_err();
let CheckError::Overreach { atom, .. } = err else {
panic!("expected Overreach");
};
assert!(
atom.contains("fs:write"),
"the message should name it: {atom}"
);
}
#[tokio::test]
async fn the_grants_a_real_checker_needs_are_allowed() {
let checker = Declaring::new(&["fs:read:/workspace/**", "proc:exec:/usr/bin/cargo"]);
assert!(checker.check(&order(), &condensate(), &key()).await.is_ok());
}
#[tokio::test]
async fn declaring_nothing_is_the_default_and_is_allowed() {
assert_eq!(Constant::passing().grants(), GrantSet::EMPTY);
assert!(
Constant::passing()
.check(&order(), &condensate(), &key())
.await
.is_ok()
);
}
#[test]
fn an_undecidable_grant_is_refused_rather_than_assumed() {
let id = CheckerId::new("templated", 1);
let grants = GrantSet::new([GrantAtom::new("fs", "read", "{workspace}")]);
assert!(matches!(
ensure_model_free(&id, &grants),
Err(CheckError::UndecidableGrants { .. })
));
}
#[test]
fn the_ceiling_omits_every_domain_that_could_reach_a_model() {
let ceiling = checker_ceiling();
for domain in ["net", "secret", "time", "rand", "env"] {
assert!(
ceiling.iter().all(|a| a.domain != domain),
"`{domain}` must not be within a checker's authority"
);
}
assert!(ceiling.iter().all(|a| a.action != "write"));
}
#[tokio::test]
async fn a_signing_failure_yields_no_evidence() {
#[derive(Debug)]
struct Broken;
impl Sign for Broken {
fn key_id(&self) -> SigningKeyId {
SigningKeyId("kms-key-1".into())
}
fn sign(&self, _payload: &[u8]) -> Result<boundgraph_core::Signature, SignError> {
Err(SignError::Unavailable {
key: self.key_id(),
detail: "the KMS did not answer".into(),
})
}
}
let err = Constant::passing()
.check(&order(), &condensate(), &Broken)
.await
.unwrap_err();
assert!(matches!(err, CheckError::Sign { .. }));
}
#[test]
fn a_judgement_says_which_of_the_three_it_is() {
assert!(Judgement::pass().is_pass());
assert!(!Judgement::fail("no").is_pass());
assert!(!Judgement::inconclusive("could not tell").is_pass());
}
#[test]
fn a_failure_reason_survives_to_the_repair_loop() {
let judgement = Judgement::fail("expected string at /items/2, found number");
assert_eq!(
judgement.verdict.reason(),
Some("expected string at /items/2, found number")
);
}
}