diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..332b9b94 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + # The JSON admission crate the canonical layer pins, and the DSSE crate the + # optional provenance export uses. Each new release arrives as a pull request + # that runs the canonical tests and the conformance replay before it lands. + - package-ecosystem: cargo + directory: / + target-branch: dev + schedule: + interval: weekly + allow: + - dependency-name: jcs-admit + - dependency-name: dsse diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 742332e2..a3e0d139 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,6 +134,38 @@ jobs: env: RUSTDOCFLAGS: -Dwarnings + conformance: + name: Conformance vectors + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # The ai-agent-action suite from agent-evidence-vectors, pinned to the + # commit of a release tag. Raise both to take the vectors a new release adds. + - uses: actions/checkout@v4 + with: + repository: probityai/agent-evidence-vectors + ref: e7508ced37dacc89decacd857e97930860ee08cd # v0.25.0 + path: agent-evidence-vectors + + - uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-conformance-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-conformance- + + - name: Replay the suite through the JSON admission + run: cargo test -p atomic-canonical --test conformance_corpus -- --ignored + env: + AEV_SUITE: ${{ github.workspace }}/agent-evidence-vectors/vectors-ai-agent-action + msrv: name: Minimum Supported Rust Version runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index fbabc9e0..56fc945f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,7 @@ serde_json = "1.0" # report -- a repeated member, nesting past the depth cap, a string that is not # a sequence of Unicode scalar values, a number outside the profile -- are # refused with a named error rather than collapsed or rounded. -jcs-admit = "0.1" +jcs-admit = "0.1.1" postcard = { version = "1.0", features = ["alloc"] } toml = "0.8" semver = "1" diff --git a/atomic-canonical/tests/conformance_corpus.rs b/atomic-canonical/tests/conformance_corpus.rs new file mode 100644 index 00000000..7286a157 --- /dev/null +++ b/atomic-canonical/tests/conformance_corpus.rs @@ -0,0 +1,153 @@ +//! Replays a whole conformance suite through `jcs::admit_document`. +//! +//! `tests/ingest_boundary.rs` pins four documents copied out of the +//! `ai-agent-action` suite in +//! . This test reads the +//! suite itself, at the release the CI job checks out, so a vector added +//! upstream is exercised here without anyone copying it in. +//! +//! Every document in the suite is a statement or a line of a record sidecar. +//! The test holds each one to the verdict its manifest entry implies: +//! +//! - a vector rejected for a byte-level fault (`ADMISSION_CODES`) must have at +//! least one document that `admit_document` refuses; +//! - a vector rejected as `noncanonical-bytes` must have a record line that is +//! admitted but is not its own canonical form; +//! - every other document, in accept and reject vectors alike, must be admitted; +//! - every record line of an accept vector must already be canonical, byte for +//! byte. +//! +//! The last rule is the one a second implementation breaks: the record lines +//! were written by the suite's generator, not by this crate, so a line that +//! re-canonicalizes to different bytes is a disagreement about RFC 8785 that +//! would change a hash. Reject vectors are exempt from it because several of +//! them carry a deliberately non-canonical record as their fault. +//! +//! One accept vector is left out, and `NOT_WHOLE_DOCUMENT` says why. +//! +//! The suite is not vendored, so the test is ignored by default. CI checks the +//! suite out and runs it with +//! `AEV_SUITE=/vectors-ai-agent-action cargo test -p atomic-canonical --test conformance_corpus -- --ignored`. + +use std::fs; +use std::path::{Path, PathBuf}; + +use atomic_canonical::jcs; +use serde_json::Value; + +/// Reject codes whose fault is in the bytes, so the admission layer owns them. +const ADMISSION_CODES: &[&str] = &[ + "duplicate-member", + "depth-exceeded", + "unsafe-integer", + "non-integer-in-signed-field", + "ill-formed-string", +]; + +/// Accept vectors whose bound is counted inside one member rather than from +/// the document root. `v43175cd2ad003639` nests its `extensions` value exactly +/// 128 deep, which the suite admits, so the statement around it is 130 deep and +/// `admit_document`, counting from the root it is handed, refuses it. The reject +/// side of the same bound, `vd94ac70c9f0d84bf`, is still checked. +const NOT_WHOLE_DOCUMENT: &[&str] = &["v43175cd2ad003639"]; + +/// Reject code for a record line that parses but is not canonical. +const NONCANONICAL: &str = "noncanonical-bytes"; + +fn suite_dir() -> PathBuf { + let dir = std::env::var_os("AEV_SUITE") + .expect("set AEV_SUITE to the vectors-ai-agent-action directory of a suite checkout"); + PathBuf::from(dir) +} + +fn read(path: &Path) -> Vec { + fs::read(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) +} + +/// The documents of one vector: its statement, then each record line. +fn documents(dir: &Path, vector: &Value) -> Vec<(String, Vec, bool)> { + let mut docs = Vec::new(); + let file = vector["file"].as_str().expect("vector names a file"); + docs.push((file.to_owned(), read(&dir.join(file)), false)); + if let Some(records) = vector["records"].as_str() { + let bytes = read(&dir.join(records)); + for (n, line) in bytes.split(|b| *b == b'\n').enumerate() { + if !line.is_empty() { + docs.push((format!("{records}:{}", n + 1), line.to_vec(), true)); + } + } + } + docs +} + +/// `Ok(true)` when admitted and canonical, `Ok(false)` when admitted but not +/// canonical, and the refusal otherwise. +fn judge(bytes: &[u8]) -> Result { + let value = jcs::admit_document(bytes).map_err(|e| e.to_string())?; + Ok(jcs::canonicalize(&value).as_bytes() == bytes) +} + +#[test] +#[ignore = "needs a checkout of the conformance suite; set AEV_SUITE"] +fn every_vector_meets_its_manifest_verdict() { + let dir = suite_dir(); + let manifest: Value = + serde_json::from_slice(&read(&dir.join("MANIFEST.json"))).expect("MANIFEST.json parses"); + let vectors = manifest["vectors"] + .as_array() + .expect("manifest lists vectors"); + assert!(!vectors.is_empty(), "the manifest lists no vectors"); + + let mut failures = Vec::new(); + let mut checked = 0usize; + for vector in vectors { + let id = vector["id"].as_str().unwrap_or("?"); + if NOT_WHOLE_DOCUMENT.contains(&id) { + continue; + } + let accept = vector["kind"].as_str() == Some("accept"); + let codes: Vec<&str> = vector["expected"]["codes"] + .as_array() + .map(|c| c.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let docs = documents(&dir, vector); + checked += docs.len(); + let verdicts: Vec<(&str, Result, bool)> = docs + .iter() + .map(|(name, bytes, is_record)| (name.as_str(), judge(bytes), *is_record)) + .collect(); + + if codes.iter().any(|c| ADMISSION_CODES.contains(c)) { + if verdicts.iter().all(|(_, v, _)| v.is_ok()) { + failures.push(format!("{id} {codes:?}: every document was admitted")); + } + } else if codes.contains(&NONCANONICAL) { + if !verdicts + .iter() + .any(|(_, v, is_record)| *is_record && *v == Ok(false)) + { + failures.push(format!( + "{id} {codes:?}: no admitted record line is noncanonical" + )); + } + } else { + for (name, v, is_record) in &verdicts { + match v { + Err(e) => failures.push(format!("{id} {codes:?}: {name} was refused: {e}")), + Ok(false) if accept && *is_record => { + failures.push(format!("{id} {codes:?}: {name} is not canonical")); + } + Ok(_) => {} + } + } + } + } + + assert!( + failures.is_empty(), + "{} of {} vectors disagree with the manifest ({checked} documents read):\n{}", + failures.len(), + vectors.len(), + failures.join("\n") + ); +} diff --git a/docs/README.md b/docs/README.md index debda40e..9566bde6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ Welcome to the Atomic VCS documentation. This directory contains comprehensive d | [Shim Operating Guide](./shim-operating-guide.md) | Git shim enable/disable, vault projection policy, trust, recovery, privacy, and observability (CB-13C; exercised against the shipping CLI) | Users, Agents | | [Stack Walkthrough](./walkthrough-stacks.md) | Hands-on tutorial for stacks, apply, and stash | Users, Contributors | | [Attestation Design](./attestation-design.md) | Graph-level audit nodes for AI cost, tokens, and compliance | Developers, Contributors | +| [Software Supply Chain Checks](./software-supply-chain.md) | The standards behind canonical bytes, proofs and signatures, and the CI jobs that hold them | Developers, Contributors | | [Intent Identity](./intent-identity.md) | ULID + `PROJECT::author::seq` intent identity and reference resolution | Users, Contributors | | [Intent Knowledge Graph](./intent-graph.md) | How intents become a queryable graph and the `atomic vault query` commands | Users, Contributors | | [ARCHITECTURE.md](./ARCHITECTURE.md) | System architecture, data model, and design decisions | Developers, Contributors | diff --git a/docs/software-supply-chain.md b/docs/software-supply-chain.md new file mode 100644 index 00000000..5ccb5284 --- /dev/null +++ b/docs/software-supply-chain.md @@ -0,0 +1,39 @@ +# Software Supply Chain Checks + +Atomic hashes and signs canonical JSON, so the bytes it admits and the bytes it writes have to agree with every other implementation of the same standards. This page lists the CI jobs that hold those properties, the version each one is pinned to, and how to run each one locally. For what each record means to a user, and how Atomic's records map to RFC 8785, W3C Data Integrity, DIDs, W3C PROV, DSSE and in-toto, see the user guide page [Software Supply Chain](https://docs.atomic.dev/concepts/software-supply-chain). + +## The standards in the code + +| Piece | Standard | Code | +|---|---|---| +| Canonical bytes before hashing or signing | RFC 8785 (JCS) | `atomic-canonical/src/jcs.rs`, `canonicalize` | +| Admission of JSON that arrives as bytes | RFC 7493 (I-JSON), integers only, depth 128 | `atomic-canonical/src/jcs.rs`, `admit_document`, through the `jcs-admit` crate | +| Content hash of a canonical node | `blake3:` of the JCS bytes | `atomic-canonical/src/hash.rs` | +| Proofs on intents, memories, grants, triage reports and PROV projections | W3C Data Integrity, `eddsa-jcs-2022` | `atomic-canonical/src/proof.rs` | +| Signer identifiers | `did:atomic`, `did:key` | `atomic-canonical/src/did.rs` | +| Agent grants and revocations | Signed `AgentDelegation`, `AgentDelegationRequest`, `DelegationRevocation` | `atomic-canonical/src/delegation.rs` | +| Change signatures | Ed25519 over the content hash, domain separated, key supplied by the verifier | `atomic-core/src/change/signing.rs` | +| Release downloads | SHA-256 | `.github/workflows/release.yml`, `checksums-sha256.txt` | + +The content hash and the Data Integrity proof both go through `jcs::canonicalize`, and every path that receives a grant certificate as bytes and later hashes, signs or verifies it goes through `jcs::admit_document` first. Keep it that way: a second canonicalization path is a second answer to what was signed. + +## The CI jobs + +| Job | What fails it | Pinned to | +|---|---|---| +| `Test` (`ci.yml`) | `atomic-canonical/tests/jcs_vectors.rs`, `depth_boundary.rs` and `ingest_boundary.rs` on Linux, macOS and Windows | `jcs-admit` in the workspace `Cargo.toml` | +| `Conformance vectors` (`ci.yml`) | A vector in the `ai-agent-action` suite of [agent-evidence-vectors](https://github.com/probityai/agent-evidence-vectors) whose verdict `admit_document` and `canonicalize` disagree with: a byte fault that is admitted, a document that should be admitted and is refused, or an accepting record line that is not already canonical | The suite at the commit of its `v0.25.0` tag; `jcs-admit` 0.1.1 | + +The conformance suite is not vendored. The test in `atomic-canonical/tests/conformance_corpus.rs` is ignored by default and reads the suite from `AEV_SUITE`: + +```bash +git clone --depth 1 --branch v0.25.0 https://github.com/probityai/agent-evidence-vectors /tmp/aev +AEV_SUITE=/tmp/aev/vectors-ai-agent-action \ + cargo test -p atomic-canonical --test conformance_corpus -- --ignored +``` + +`NOT_WHOLE_DOCUMENT` in that test names the one accepting vector it skips, `v43175cd2ad003639`, and why: the suite counts that vector's depth limit inside its `extensions` member, while `admit_document` counts from the document root. The rejecting side of the same limit, `vd94ac70c9f0d84bf`, is still checked. + +## Raising a pin + +To take a newer suite release, change both the `ref` commit and its tag comment in the `Conformance vectors` job, run the command above against the new tag, and fix or explain every vector that disagrees in the same pull request. Dependabot proposes each new `jcs-admit` release as a pull request against `dev` (`.github/dependabot.yml`), and that pull request runs the `Test` job's canonical tests and the conformance replay before it can land. A vector that starts failing after a raise is a disagreement about bytes that would change a hash or a signature, so it blocks the raise until it is understood.