From 696ba97e15ce571e4e65e4b1acf75a95b0710b41 Mon Sep 17 00:00:00 2001 From: 1wgrumph <1wgrumph@gmail.com> Date: Sun, 26 Jul 2026 07:52:18 -0500 Subject: [PATCH 01/10] Sync public export from bran-dev 3cde6f0 --- .bran-export.json | 14 ++-- .bran/policy.yaml | 9 +-- README.md | 127 ++++++++++++++++++++++++++++++- docs/integrations/agent-setup.md | 2 - 4 files changed, 135 insertions(+), 17 deletions(-) diff --git a/.bran-export.json b/.bran-export.json index 773f7b8..b040b36 100644 --- a/.bran-export.json +++ b/.bran-export.json @@ -1,14 +1,14 @@ { "schema_version": 1, "source_repository": "alphazede/bran-dev", - "source_commit": "10d762d3b0c4e22d157568399950412ebe66890e", + "source_commit": "3cde6f06586d41b96cfad0da57015ed8919b9322", "public_repository": "alphazede/bran", "files": [ { "path": ".bran/policy.yaml", "mode": "100644", - "bytes": 856, - "sha256": "09800066556b08e36b3dae0c6775a304d728ace1db37de5ab10fe4d3adb3999c" + "bytes": 975, + "sha256": "41e89d763cdd6beac49d0372e1758cc86c1e7cf6e15a494ded429fbbf7b6c7ac" }, { "path": ".branignore", @@ -61,8 +61,8 @@ { "path": "README.md", "mode": "100644", - "bytes": 5774, - "sha256": "3a03238e1e72e9b2560477591914e219bd66fb2e650be3f47c46a059bb467a9d" + "bytes": 11524, + "sha256": "0baf93700cc37a3e0cdd501725e135f99d915c6b628889baf69b1d4d39bc878d" }, { "path": "assets/brand/bran-repository-raven.png", @@ -373,8 +373,8 @@ { "path": "docs/integrations/agent-setup.md", "mode": "100644", - "bytes": 4405, - "sha256": "6d6e874ea6f601eef0aa1574a15b45fbe78a2b42c5014319164873e175e8fb8d" + "bytes": 4362, + "sha256": "ae7f8e79701bc0f59f137869e3a9aefe76f0a0889a1c070e35e30cd1ba5bd662" }, { "path": "examples/headless/README.md", diff --git a/.bran/policy.yaml b/.bran/policy.yaml index 9c48820..94e8276 100644 --- a/.bran/policy.yaml +++ b/.bran/policy.yaml @@ -27,13 +27,12 @@ coverage: - unclassified document_coverage: - native_bundle: + legacy_documents: - README.md - canonical_documents: - docs/integrations/agent-setup.md - - skill/use-bran/SKILL.md - legacy_documents: - - examples/headless/README.md + excluded_documents: + skill/use-bran/SKILL.md: Agent skill frontmatter is parsed by the host and is not an OKF concept document. + examples/headless/README.md: Headless command reference, not an OKF concept document. bridge_targets: - LICENSE-APACHE - LICENSE-MIT diff --git a/README.md b/README.md index c26d86e..9f1b2a4 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,10 @@ --- type: product-readme title: BRAN -okf_status: active tags: - public - developer -freshness: "2026-07-25" resource: https://github.com/alphazede/bran -public_boundary: public --- # BRAN @@ -76,6 +73,130 @@ journeys, reasoning and tool recipes, no-session operation, and the offline return check. To let an external agent host call BRAN, install the instructions in [`skill/use-bran`](skill/use-bran/SKILL.md). +## Make an agent actually use BRAN + +Giving an agent access to BRAN is not enough. Without a timely reminder, an +agent usually reaches for built-in search tools because they are always +available and never report `unavailable`. In one dev-node session on +2026-07-25, a BRAN banner appeared on every turn while the agent still used raw +search dozens of times without invoking BRAN. + +Two structural issues caused that behavior: + +1. A session-start or prompt-time reminder is stale by the time the agent forms + a search. The reminder needs to run on the search tool call itself. +2. BRAN requires a native `.bran/policy.yaml` at the repository root. Without + one, `bran_status: unavailable` is the correct result, and ordinary + repository discovery is the correct fallback. Coverage is a precondition + for adoption. + +### Add coverage before reminders + +Audit target repositories for `.bran/policy.yaml` before wiring hooks. Do not +nag an agent toward BRAN in a repository where it is unavailable; include the +known coverage gaps in local injected context instead. + +When existing public-facing Markdown cannot carry BRAN classification +frontmatter, keep the native index private, classify existing documents with +`legacy_baseline`, and exclude private or generated state with `.branignore`. +This provides repository coverage without changing the published Markdown. + +### Remind the agent at search time + +Use a `PreToolUse` command hook and match the tool names emitted by the actual +harness. Tool vocabularies differ: + +| Harness | Search path | Matcher | +|---|---|---| +| Claude Code | Native `Grep`/`Glob`, plus raw search through its shell tool | `Grep\|Glob\|Bash` | +| Codex | Unified shell commands such as `rg`, `grep`, `git grep`, and `find` | `Bash` | + +If a Codex surface exposes a dedicated search function, match its reported tool +name as well. Use `/hooks` to inspect the active hook sources and observed tool +names instead of assuming that another harness's matcher vocabulary applies. +For a broad matcher such as `Bash`, the script should inspect `tool_input` and +stay silent unless the command is a repository search. Resolve native coverage +from the call's current working directory on every invocation instead of +hard-coding a list of covered or uncovered repositories; that list becomes +wrong as soon as a policy is added or removed. + +Keep the hook in a script file and reference it by absolute path. Both +harnesses send a JSON payload on stdin. A Codex `PreToolUse` reminder returns an +event-specific JSON object like this: + +```json +{ + "systemMessage": "BRAN_SEARCH_ALERT: raw rg repository search requested in a BRAN-covered checkout.", + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": "Use the verified BRAN binary for this repository-knowledge search." + } +} +``` + +Codex treats non-empty hook stdout as JSON. Plain text on stdout causes an +`invalid ... JSON output` hook failure. Exit successfully with no output when +the hook does not apply. + +Codex also requires review of every new or changed non-managed hook definition. +Open `/hooks`, inspect the source and exact command, and trust it; until then, +Codex intentionally skips the changed hook. Test the stored command first, and +start a fresh session if an already-running session still has the previous +matcher set loaded. Do not use a trust-bypass flag as normal installation +guidance. + +### Make raw-search fallback visible + +A search hook sees the raw tool call but cannot reliably prove that a BRAN +query succeeded earlier in the conversation. Treat every matching raw search +as an observable fallback: return a top-level `systemMessage` beginning with a +stable marker such as `BRAN_SEARCH_ALERT`, and use `additionalContext` to make +the agent report whether BRAN was used and why the fallback is still needed. +This lets an owner find adoption loopholes without blocking legitimate +diagnostic searches or maintaining fragile per-session state. + +In a covered repository, the alert should require `bran_status` plus a bounded +fallback reason. In an uncovered repository, it should explicitly report +`bran_status: unavailable` and allow ordinary discovery. Stay silent for +unrelated shell commands so the warning remains useful instead of becoming +background noise. + +The injected context should tell the agent to: + +- Resolve the pinned BRAN binary and verify its SHA-256 against the release pin. +- Use ordinary discovery immediately when the repository has no native policy. +- Trim query output so BRAN saves context instead of consuming it. +- Report `bran_status` as `hit`, `miss`, `stale`, `conflict`, or `unavailable`. + +For example, keep the highest-ranked sources and top-level metrics while +dropping the duplicate provenance payload: + +```sh +bran query "" | + jq '{status, source_rankings: .data.source_rankings[:8], metrics, warnings, failures}' +``` + +### Test both hook directions + +Do not assume that a stored hook configuration is valid. Inline shell embedded +in JSON is easy to damage through escaping, so prefer an executable script and +test the command exactly as stored: + +```sh +echo '{"hook_event_name":"PreToolUse","tool_name":"Grep","tool_input":{"pattern":"x"}}' | + /absolute/path/bran-search.sh +``` + +Confirm that a matching payload produces valid JSON with non-empty +`additionalContext`. Then send an unrelated payload and confirm the hook emits +nothing. A noisy hook that fires on every command will eventually be disabled. + +In the 2026-07-25 dev-node observation, one repository query narrowed 5.9 MB of +candidate sources to 411 KB, placed the correct file at rank 2, and reported +about 102,000 estimated tokens of context avoided. This is an observed result, +not a general performance guarantee, and it only helps when the hook fires and +the returned JSON is trimmed. + Connected tasks require a valid project-local `.bran/settings.conf` with `profile=connected-agent`. Set `BRAN_AGENT_PROFILE`, `BRAN_AGENT_PROVIDER`, `BRAN_AGENT_MODEL`, `BRAN_AGENT_REASONING`, and `BRAN_AGENT_ACCOUNT_REF` to diff --git a/docs/integrations/agent-setup.md b/docs/integrations/agent-setup.md index 1740b74..3c6b118 100644 --- a/docs/integrations/agent-setup.md +++ b/docs/integrations/agent-setup.md @@ -1,11 +1,9 @@ --- type: integration-guide title: Agent setup -okf_status: active tags: - public - developer -public_boundary: public --- # Agent setup From df6f3fa4595897ed51e3593665239bbf35b801ca Mon Sep 17 00:00:00 2001 From: William Rumph <41762695+1wgrumph@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:22:42 -0500 Subject: [PATCH 02/10] Public export snapshot from bran-dev 1938b38 Adds the okf-v0.2 compatibility profile, body-content ranking with unmatched-entity miss reporting, the shared digest validator, removal of hardcoded user-home paths with an enforcement check, and a test cleanup. Source: alphazede/bran-dev 1938b3838e832e4e8035a86aab804257c6303124 Produced by tools/ci/public_export.py from a clean committed source. Checks: fast SUCCESS, CodeQL (actions/python/rust) SUCCESS. --- .bran-export.json | 130 ++- crates/bran-cli/src/main.rs | 579 +++++++++-- crates/bran-core/src/adapters/connected.rs | 14 +- crates/bran-core/src/adapters/provider/mod.rs | 81 +- crates/bran-core/src/agent/coordinator.rs | 745 ++++++++++++++- crates/bran-core/src/agent/delegate.rs | 216 +++++ crates/bran-core/src/agent/receipt.rs | 137 ++- crates/bran-core/src/agent/runtime.rs | 105 ++ crates/bran-core/src/boundary.rs | 55 +- crates/bran-core/src/frontmatter.rs | 482 ++++++++++ crates/bran-core/src/lib.rs | 3 +- crates/bran-core/src/profile.rs | 902 +++++++++++++++++- docs/integrations/agent-setup.md | 30 + .../okf-v0.2-attested-computation.fixture | 19 + .../okf-v0.2-index-version.fixture | 10 + .../okf-v0.2-legacy-fallback.fixture | 9 + .../conformance/okf-v0.2-lifecycle.fixture | 6 + .../conformance/okf-v0.2-malformed.fixture | 12 + fixtures/conformance/okf-v0.2-minimal.fixture | 4 + fixtures/conformance/okf-v0.2-sources.fixture | 12 + .../conformance/okf-v0.2-trust-list.fixture | 12 + .../okf-v0.2-unknown-tolerated.fixture | 7 + .../okf-v0.2-verified-bare.fixture | 7 + schemas/bran-profile-result.schema.json | 13 +- .../okf-v0.2-normalized-bundle.schema.json | 218 +++++ tools/ci/public_boundary_check.py | 123 +++ tools/ci/test-budget.json | 12 +- 27 files changed, 3782 insertions(+), 161 deletions(-) create mode 100644 crates/bran-core/src/frontmatter.rs create mode 100644 fixtures/conformance/okf-v0.2-attested-computation.fixture create mode 100644 fixtures/conformance/okf-v0.2-index-version.fixture create mode 100644 fixtures/conformance/okf-v0.2-legacy-fallback.fixture create mode 100644 fixtures/conformance/okf-v0.2-lifecycle.fixture create mode 100644 fixtures/conformance/okf-v0.2-malformed.fixture create mode 100644 fixtures/conformance/okf-v0.2-minimal.fixture create mode 100644 fixtures/conformance/okf-v0.2-sources.fixture create mode 100644 fixtures/conformance/okf-v0.2-trust-list.fixture create mode 100644 fixtures/conformance/okf-v0.2-unknown-tolerated.fixture create mode 100644 fixtures/conformance/okf-v0.2-verified-bare.fixture create mode 100644 schemas/okf-v0.2-normalized-bundle.schema.json diff --git a/.bran-export.json b/.bran-export.json index b040b36..6303697 100644 --- a/.bran-export.json +++ b/.bran-export.json @@ -1,7 +1,7 @@ { "schema_version": 1, "source_repository": "alphazede/bran-dev", - "source_commit": "3cde6f06586d41b96cfad0da57015ed8919b9322", + "source_commit": "1938b3838e832e4e8035a86aab804257c6303124", "public_repository": "alphazede/bran", "files": [ { @@ -127,8 +127,8 @@ { "path": "crates/bran-cli/src/main.rs", "mode": "100644", - "bytes": 303969, - "sha256": "0828f6a16e80b3f375a2af5fb7b0d27a1155d6919a67eef4ba0e1c2116bdd232" + "bytes": 322237, + "sha256": "ae35ec304b1c49cc01e3831e43f00b656af7d76fb2f4849f79a92034fa9eb924" }, { "path": "crates/bran-core/Cargo.toml", @@ -139,8 +139,8 @@ { "path": "crates/bran-core/src/adapters/connected.rs", "mode": "100644", - "bytes": 41012, - "sha256": "e8b0a5dae90e6edbf1ee89553a7c3efee311c51a910d35616121a6ea18bbe5dd" + "bytes": 41177, + "sha256": "5d796d6104c3d712756bd6337f03c4debf96a8a6ea1f47adf95a0373b67d5111" }, { "path": "crates/bran-core/src/adapters/mod.rs", @@ -151,8 +151,8 @@ { "path": "crates/bran-core/src/adapters/provider/mod.rs", "mode": "100644", - "bytes": 52658, - "sha256": "302a9908246982698cfb4865c4025658df231980523663ff0746386c797a2254" + "bytes": 55253, + "sha256": "c61f86ea5fbb926ce78515796d979d059644dddb553217ef5d65c5397c793a7e" }, { "path": "crates/bran-core/src/adapters/sqz.rs", @@ -163,14 +163,14 @@ { "path": "crates/bran-core/src/agent/coordinator.rs", "mode": "100644", - "bytes": 102379, - "sha256": "96948a8bb47499b251267654edfd513f7c3b02084b1bff393ca208ec6d56e75d" + "bytes": 129321, + "sha256": "2ecca6b675070125689cd87a16a8fa70bebf81e277ea645b895b0e2126240ca9" }, { "path": "crates/bran-core/src/agent/delegate.rs", "mode": "100644", - "bytes": 8796, - "sha256": "a084b13823e5b60a4e4d472a866767c9aa96dd477619e9d908209bfd2d8170e8" + "bytes": 16452, + "sha256": "f4cbe45b410c42d1eaac208c05dce00e738815286ff46f18ba7fbae07936dda1" }, { "path": "crates/bran-core/src/agent/mod.rs", @@ -181,8 +181,8 @@ { "path": "crates/bran-core/src/agent/receipt.rs", "mode": "100644", - "bytes": 32228, - "sha256": "1be6db8e9acdf9bf79acc70f8735db9255989f4ec0f51c869e224e3240ea8fae" + "bytes": 36977, + "sha256": "0153d02bfd9bb0149372c305c1c316dce9fd4391e6413a031a1b7a874de4ca84" }, { "path": "crates/bran-core/src/agent/result_store.rs", @@ -193,8 +193,8 @@ { "path": "crates/bran-core/src/agent/runtime.rs", "mode": "100644", - "bytes": 17781, - "sha256": "e0fc6b3ba16cb59f8cce459ea4eea256ae57b1d8e93c05fe7fbbb6141e492d4b" + "bytes": 20778, + "sha256": "01443bf113656bc5749d56e0ad6be5df051a1a0f09de4b62f54361031db49a91" }, { "path": "crates/bran-core/src/agent/synthetic.rs", @@ -205,8 +205,8 @@ { "path": "crates/bran-core/src/boundary.rs", "mode": "100644", - "bytes": 14555, - "sha256": "88f6465645dc4181816569b9653ac14b2d1d5345096f8ccaf0c1bcae29c31076" + "bytes": 16201, + "sha256": "2c905fbd3a7802457d7b034de5841118c348dc20eb0093ca13874113058a316b" }, { "path": "crates/bran-core/src/bundle.rs", @@ -226,6 +226,12 @@ "bytes": 28482, "sha256": "4ffc3752682049af4cca5a91e2f05e49ef3ed69579ca234482fc2dc39b718f8b" }, + { + "path": "crates/bran-core/src/frontmatter.rs", + "mode": "100644", + "bytes": 17047, + "sha256": "bec21f18b563fcaf6675d4a1435227e892be4ed9abd58697bdb4b7ab86b06c0c" + }, { "path": "crates/bran-core/src/graph/mod.rs", "mode": "100644", @@ -247,8 +253,8 @@ { "path": "crates/bran-core/src/lib.rs", "mode": "100644", - "bytes": 24783, - "sha256": "720e3f787d0d046c603e5935d0601aa642d57d8b64e72c8e7f029b7a52c736de" + "bytes": 24814, + "sha256": "dd2d8c4c2f66b171aa149423b805dd5c92de6d88b8ba4f6e5cd3f3587fafccd2" }, { "path": "crates/bran-core/src/metadata/mod.rs", @@ -277,8 +283,8 @@ { "path": "crates/bran-core/src/profile.rs", "mode": "100644", - "bytes": 164523, - "sha256": "643c6cf2509a5c5382a22a1517d51e1c0fc47e381d56103b3dddbe702cb29808" + "bytes": 201864, + "sha256": "86041ed0e64e50bcfc87cd3a2144e90f96fd08be21da91bd5120e6c86f97f91e" }, { "path": "crates/bran-core/src/repair/mod.rs", @@ -373,8 +379,8 @@ { "path": "docs/integrations/agent-setup.md", "mode": "100644", - "bytes": 4362, - "sha256": "ae7f8e79701bc0f59f137869e3a9aefe76f0a0889a1c070e35e30cd1ba5bd662" + "bytes": 6176, + "sha256": "f9ecccc289fd31330021a143c2e04ffe7b40ad273d3d75a8b63052789d5b1d88" }, { "path": "examples/headless/README.md", @@ -418,6 +424,66 @@ "bytes": 88, "sha256": "24bff5202be999a46737153a06593bb64b90884142aa490028e1bd7dabee09cf" }, + { + "path": "fixtures/conformance/okf-v0.2-attested-computation.fixture", + "mode": "100644", + "bytes": 372, + "sha256": "19f1db346cf8336488fd572726658d51c3b32462a1712db9771d9b866347cdfa" + }, + { + "path": "fixtures/conformance/okf-v0.2-index-version.fixture", + "mode": "100644", + "bytes": 123, + "sha256": "4c41d2b35b49c5b4d1f8237680ab4aa8aedf95bc4646959a943967ed4c69f8e5" + }, + { + "path": "fixtures/conformance/okf-v0.2-legacy-fallback.fixture", + "mode": "100644", + "bytes": 179, + "sha256": "2d483aa30328162e09ab1c25c2350a54bd3169c1a8e445bef1850a52c62e0028" + }, + { + "path": "fixtures/conformance/okf-v0.2-lifecycle.fixture", + "mode": "100644", + "bytes": 91, + "sha256": "bc707af353f5b32c97261b015a30c066e9d4adf7140345ce7b6b360a04f67740" + }, + { + "path": "fixtures/conformance/okf-v0.2-malformed.fixture", + "mode": "100644", + "bytes": 192, + "sha256": "924cef8272dc95ec7dc513c40bf8a90bfa1bf1315e8130b7108380bd8a204154" + }, + { + "path": "fixtures/conformance/okf-v0.2-minimal.fixture", + "mode": "100644", + "bytes": 70, + "sha256": "5623f2512a16c43f65ab95238869b703a569fcf83ff31f6f585dd129c2813a4d" + }, + { + "path": "fixtures/conformance/okf-v0.2-sources.fixture", + "mode": "100644", + "bytes": 249, + "sha256": "44267f2cec7a06785efdc8c1e477dbf748d78c52201f3482e93ffe4220558727" + }, + { + "path": "fixtures/conformance/okf-v0.2-trust-list.fixture", + "mode": "100644", + "bytes": 217, + "sha256": "5c4d12e4fed22a7c9c7efaf88a51b70d1f60012a8be0a7a4887426dd601f1371" + }, + { + "path": "fixtures/conformance/okf-v0.2-unknown-tolerated.fixture", + "mode": "100644", + "bytes": 119, + "sha256": "2d80c613bd7cc7efd5380714e2b6ffb259082e08262bd350edef2397ceeaceba" + }, + { + "path": "fixtures/conformance/okf-v0.2-verified-bare.fixture", + "mode": "100644", + "bytes": 133, + "sha256": "952faa1b3861ea3db5d1bd9a7ce905139fdcd23f0a24bd148df32b0562804acb" + }, { "path": "fixtures/conformance/strict-gap-strict-selected.fixture", "mode": "100644", @@ -523,8 +589,8 @@ { "path": "schemas/bran-profile-result.schema.json", "mode": "100644", - "bytes": 2096, - "sha256": "cb3a3bfcd515ba4488ac147e789a8d86fb9d9341d456f8e4c7be934e8dc4f1a4" + "bytes": 2330, + "sha256": "3ecd57b3af88fc43360280b426ae7665b8ef27c28041174f715ff85fe1792736" }, { "path": "schemas/bran-release-manifest.schema.json", @@ -568,6 +634,12 @@ "bytes": 3647, "sha256": "f9361110b76a4b3e9a4cf4606aa51c5465a53e1f0d40e0b8b3af15e54e31cc04" }, + { + "path": "schemas/okf-v0.2-normalized-bundle.schema.json", + "mode": "100644", + "bytes": 6850, + "sha256": "558372e5490d7847049c464a919655233d0096f5540b4263b4fe44b4b1b166f8" + }, { "path": "schemas/repository-scan-snapshot.schema.json", "mode": "100644", @@ -613,8 +685,8 @@ { "path": "tools/ci/public_boundary_check.py", "mode": "100644", - "bytes": 9024, - "sha256": "1d43fc6615ef2b25ee27aa165a7b45779f8c0f0530a868de89ca740bfcd65c5d" + "bytes": 13392, + "sha256": "c33d14a56fd3f4c4e838c6e915088c6de6ae3d2d0df1d67390939db8c00a0583" }, { "path": "tools/ci/public_export.py", @@ -643,8 +715,8 @@ { "path": "tools/ci/test-budget.json", "mode": "100644", - "bytes": 8932, - "sha256": "9937ae287e49aaca8bee5ff77cb4c30529290362f36ecd747c39b34bae2cf3c2" + "bytes": 9552, + "sha256": "866404a337764c0b351e7d7ae54c511691fa4a9638f7b8c6c7cf91d7782e9115" }, { "path": "tools/ci/test_budget_check.py", diff --git a/crates/bran-cli/src/main.rs b/crates/bran-cli/src/main.rs index d69fd10..1b5c402 100644 --- a/crates/bran-cli/src/main.rs +++ b/crates/bran-cli/src/main.rs @@ -22,7 +22,9 @@ use bran_core::adapters::{ use bran_core::agent::coordinator::{ AgentRuntime, AgentRuntimeAuthority, AgentRuntimeConfig, AgentSqzAdapter, RuntimePorts, }; -use bran_core::agent::delegate::{DelegationOptions, DelegationRequest, GroundingContract}; +use bran_core::agent::delegate::{ + AdmittedEvidence, DelegationOptions, DelegationRequest, GroundingContract, +}; use bran_core::agent::receipt::InlineResult; use bran_core::agent::receipt::{sqz_receipt_json, DelegationReceipt}; use bran_core::agent::result_store::{ @@ -40,7 +42,7 @@ use bran_core::graph::{ Confidence, EdgeCertainty, EdgeRelationship, GraphInput, GraphLimits, KnowledgeGraph, NodeId, NodeInput, NodeRole, Provenance, }; -use bran_core::metadata::{FactProvenance, MetadataFact}; +use bran_core::metadata::FactProvenance; use bran_core::migration::{self, MigrationError}; use bran_core::packet::{ DependencyClosureLimits, EvidenceContent, EvidencePriority, PacketAssembler, @@ -51,7 +53,6 @@ use bran_core::profile::BRAN_STRICT; use bran_core::profile::{Diagnostic, ProfileValidator, ValidationStatus}; use bran_core::repair::{MaintainerAuthority, RepairCoordinator, RepairReceipt, RepairTerminal}; use bran_core::scan::{is_knowledge_document_path, RepositoryScanner, ScanConfig, ScanSnapshot}; -use bran_core::schema::YamlValue; use bran_core::view::{ Presentation, ViewCompiler, ViewField, ViewFilter, ViewGrouping, ViewSort, ViewSource, ViewSpec, }; @@ -108,6 +109,7 @@ const CONNECTED_AGENT_PREAMBLE: &str = "inner-agent-rules: 3. Stay grounded and mark uncertainty or missing evidence. 4. Stay read-only and leave decisions and implementation to the outer agent. 5. Obey the bounded repository and tool policy; never expose credentials or fabricate sources or results. +6. Treat every factual statement as material; its claim text must be exact supporting text or a symbol copied from the current file and bound to a cited path plus the supplied SHA-256 digest. Return the answer as those claim texts in the same order, separated only by newlines. "; @@ -1442,32 +1444,156 @@ const RANKED_FACT_KEYS: &[&str] = &[ ]; const PARENT_QUERY_FACT_KEYS: &[&str] = &["okf_status", "status", "public_boundary"]; +/// Common grammatical words that must not drive selection (issue #18). +const QUERY_STOP_WORDS: &[&str] = &["about", "and", "for", "from", "not", "the", "this", "with"]; + +/// Plain query words and high-specificity identifier units. +/// +/// A hyphenated, underscored, or dotted identifier ("example-entity-unit") is +/// one high-specificity unit for match purposes: its sub-tokens are never +/// extracted as independent terms, so a sub-token match cannot count as the +/// entity matching (issue #18). +fn query_terms_and_entities(query_text: &str) -> (BTreeSet, BTreeSet) { + let mut terms = BTreeSet::new(); + let mut entities = BTreeSet::new(); + let mut run = String::new(); + for character in query_text.chars() { + if character.is_alphanumeric() || matches!(character, '-' | '_' | '.') { + run.push(character.to_ascii_lowercase()); + } else { + classify_query_run(&run, &mut terms, &mut entities); + run.clear(); + } + } + classify_query_run(&run, &mut terms, &mut entities); + (terms, entities) +} + +/// One maximal identifier-character run becomes either a high-specificity +/// entity unit or a plain word. Short separator fragments ("e.g.", "v0.2") +/// are dropped entirely rather than treated as words. +fn classify_query_run(run: &str, terms: &mut BTreeSet, entities: &mut BTreeSet) { + if run.is_empty() { + return; + } + let trimmed = run.trim_matches(['-', '_', '.']); + if trimmed.contains(['-', '_', '.']) { + if trimmed.len() >= 6 { + entities.insert(trimmed.to_owned()); + } + return; + } + if trimmed.len() >= 3 && !QUERY_STOP_WORDS.contains(&trimmed) { + terms.insert(trimmed.to_owned()); + } +} + +/// The body content of one scanned entry: source bytes after the frontmatter +/// (or commented YAML) header, or the whole source when no header is present. +fn document_body(snapshot: &ScanSnapshot, locator: &str) -> Option { + let entry = snapshot.entries.get(locator)?; + let source = std::str::from_utf8(entry.source.as_ref()).ok()?; + let start = entry + .metadata + .facts + .iter() + .find_map(|fact| match &fact.provenance { + FactProvenance::MarkdownFrontmatter => markdown_header_end(source), + FactProvenance::CommentedYaml => commented_header_end(source), + _ => None, + }) + .unwrap_or(0); + Some(source[start..].to_owned()) +} + +/// One warning naming every query term or entity unit that matched no +/// document, when any. Unmatched entity units lead the list and are named +/// whole, never as their sub-tokens. +fn unmatched_query_warnings(query_text: &str, matched_terms: &BTreeSet) -> Vec { + let (terms, entities) = query_terms_and_entities(query_text); + let unmatched = entities + .iter() + .filter(|term| !matched_terms.contains(*term)) + .cloned() + .chain( + terms + .iter() + .filter(|term| !matched_terms.contains(*term)) + .cloned(), + ) + .collect::>(); + if unmatched.is_empty() { + return vec![]; + } + let listed = unmatched.len().min(8); + let mut message = format!("unmatched_query_terms: {}", unmatched[..listed].join(",")); + if unmatched.len() > listed { + message.push_str(&format!(",+{} more", unmatched.len() - listed)); + } + vec![message] +} + fn source_rankings( graph_input: &GraphInput, + snapshot: &ScanSnapshot, query_text: &str, max_sources: usize, -) -> Vec { - let terms = query_text - .split(|c: char| !c.is_alphanumeric()) - .map(str::to_ascii_lowercase) - .filter(|term| { - term.len() >= 3 - && !matches!( - term.as_str(), - "about" | "and" | "for" | "from" | "the" | "this" | "with" - ) - }) - .collect::>(); +) -> (Vec, BTreeSet) { + let (terms, entities) = query_terms_and_entities(query_text); + let mut matched_terms = BTreeSet::new(); let mut matches = graph_input .nodes() .iter() .filter(|node| node.role() == NodeRole::Document) .filter_map(|node| { - let locator = node.provenance().locator().to_ascii_lowercase(); + let locator_original = node.provenance().locator(); + let locator = locator_original.to_ascii_lowercase(); + let body = + document_body(snapshot, locator_original).map(|body| body.to_ascii_lowercase()); let mut exact_matches = 0; let mut partial_matches = 0; let mut exact_fields = BTreeSet::new(); let mut partial_fields = BTreeSet::new(); + // A high-specificity identifier unit matches only as a whole; a + // sub-token match never counts as the entity matching. + for entity in &entities { + let mut entity_fields = BTreeSet::new(); + if locator.contains(entity.as_str()) { + entity_fields.insert("path"); + } + for (key, value) in RANKED_FACT_KEYS.iter().flat_map(|key| { + node.facts() + .values(key) + .into_iter() + .flatten() + .map(move |value| (*key, value.as_str())) + }) { + if value.to_ascii_lowercase().contains(entity.as_str()) { + entity_fields.insert(key); + } + } + for (key, value) in PARENT_QUERY_FACT_KEYS.iter().flat_map(|key| { + node.facts() + .values(key) + .into_iter() + .flatten() + .map(move |value| (*key, value.as_str())) + }) { + if value.to_ascii_lowercase().contains(entity.as_str()) { + entity_fields.insert(key); + } + } + if let Some(body) = &body { + if body.contains(entity.as_str()) { + entity_fields.insert("body"); + } + } + if !entity_fields.is_empty() { + exact_matches += 1; + exact_fields.extend(entity_fields); + matched_terms.insert(entity.clone()); + } + } for (key, value) in std::iter::once(("path", locator.as_str())).chain( RANKED_FACT_KEYS.iter().flat_map(|key| { node.facts() @@ -1479,16 +1605,26 @@ fn source_rankings( ) { let value = value.to_ascii_lowercase(); for term in &terms { - let exact_path_term = key == "path" - && value - .split(|character: char| !character.is_alphanumeric()) - .any(|part| part == term); - if value == *term || exact_path_term { + if value == *term { exact_matches += 1; exact_fields.insert(key); + matched_terms.insert(term.clone()); + } else if key == "path" + && value + .split(|character: char| !character.is_alphanumeric()) + .any(|part| part == term) + { + // A path-segment equality is a containment signal, not + // a whole-value equality: a generic query term that + // happens to appear in an unrelated path must not + // outrank real content matches. + partial_matches += 1; + partial_fields.insert("path"); + matched_terms.insert(term.clone()); } else if value.contains(term.as_str()) { partial_matches += 1; partial_fields.insert(key); + matched_terms.insert(term.clone()); } } } @@ -1504,9 +1640,23 @@ fn source_rankings( if value.contains(term.as_str()) { partial_matches += 1; partial_fields.insert(key); + matched_terms.insert(term.clone()); + } + } + } + let mut body_matches = 0; + if let Some(body) = &body { + for term in &terms { + if body.contains(term.as_str()) { + body_matches += 1; + matched_terms.insert(term.clone()); } } } + if body_matches > 0 { + partial_matches += body_matches; + partial_fields.insert("body"); + } let status_rank = match node .facts() .values("okf_status") @@ -1562,6 +1712,20 @@ fn source_rankings( }) }) .collect::>(); + // A high-specificity entity unit that matched no document means the query + // names something this repository does not contain. When nothing else in + // the query matched at identity level (exact fact or path equality), the + // remaining generic body matches are not evidence for the entity: return + // no rankings so a caller cannot mistake command success for evidence + // coverage (issue #18). Exact content matches keep the rankings, with the + // unmatched unit still surfaced by name in the warnings. + if entities + .iter() + .any(|entity| !matched_terms.contains(entity)) + && !matches.iter().any(|ranking| ranking.exact_matches > 0) + { + return (Vec::new(), matched_terms); + } matches.sort_by(|left, right| { right .exact_matches @@ -1578,7 +1742,7 @@ fn source_rankings( for (index, ranking) in matches.iter_mut().enumerate() { ranking.rank = index + 1; } - matches + (matches, matched_terms) } fn query_view_spec(rankings: &[SourceRanking], max_sources: usize) -> ViewSpec { @@ -1621,6 +1785,7 @@ fn locator_evidence_content( node: &NodeInput, ranking: Option<&SourceRanking>, graph: &KnowledgeGraph, + content_digest: Option<&str>, excerpt: Option<&str>, ) -> String { let metadata = RANKED_FACT_KEYS @@ -1651,17 +1816,20 @@ fn locator_evidence_content( .take(4) .collect::>() .join(";"); + let digest = content_digest.map_or_else(String::new, |digest| { + format!("content-digest-sha256: {digest}\n") + }); let excerpt = excerpt.map_or_else(String::new, |excerpt| format!("excerpt: {excerpt}\n")); match ranking { Some(ranking) => format!( - "path: {}\nrank: {}\nscore: exact={} partial={} active={} canonical={} public_safe={} confidence={} freshness={}\nmatch_reason: {}\nmetadata: {}\nrelationships: {}\n{}", + "path: {}\nrank: {}\nscore: exact={} partial={} active={} canonical={} public_safe={} confidence={} freshness={}\nmatch_reason: {}\nmetadata: {}\nrelationships: {}\n{}{}", ranking.locator, ranking.rank, ranking.exact_matches, ranking.partial_matches, ranking.active, ranking.canonical, ranking.public_safe, ranking.confidence, - ranking.freshness, ranking.match_reason, metadata, relationships, excerpt + ranking.freshness, ranking.match_reason, metadata, relationships, digest, excerpt ), None => format!( - "path: {}\nrank: dependency\nmetadata: {}\nrelationships: {}\n{}", - node.provenance().locator(), metadata, relationships, excerpt + "path: {}\nrank: dependency\nmetadata: {}\nrelationships: {}\n{}{}", + node.provenance().locator(), metadata, relationships, digest, excerpt ), } } @@ -1855,7 +2023,8 @@ fn do_query(root: String, query_text: String) -> QueryPacketResult { let edge_count = graph_input.edges().len().max(1); let limits = GraphLimits::new(node_count, edge_count).map_err(|e| format!("limits_error: {:?}", e))?; - let rankings = source_rankings(&graph_input, &query_text, QUERY_RESULT_LIMIT); + let (rankings, matched_terms) = + source_rankings(&graph_input, &snapshot, &query_text, QUERY_RESULT_LIMIT); let spec = query_view_spec(&rankings, QUERY_RESULT_LIMIT); let graph = KnowledgeGraph::build(graph_input, limits).map_err(|e| format!("graph_error: {:?}", e))?; @@ -1884,11 +2053,12 @@ fn do_query(root: String, query_text: String) -> QueryPacketResult { let estimated = selected_bytes / 4 + usize::from(!selected_bytes.is_multiple_of(4)); let context_bytes_avoided = candidate_bytes.saturating_sub(selected_bytes); - let warns: Vec = snapshot + let mut warns: Vec = snapshot .diagnostics .iter() .map(|d| format!("{:?}", d)) .collect(); + warns.extend(unmatched_query_warnings(&query_text, &matched_terms)); let (locs_json, why_selected_json) = selected_sources_json(&selected); let source_rankings_json = source_rankings_json(&rankings, &selected_ids); @@ -1939,7 +2109,8 @@ fn do_packet(root: String, query_text: String, controls: &ExperimentalControls) let edge_count = graph_input.edges().len().max(1); let limits = GraphLimits::new(node_count, edge_count).map_err(|e| format!("limits_error: {:?}", e))?; - let rankings = source_rankings(&graph_input, &query_text, controls.max_sources()); + let (rankings, matched_terms) = + source_rankings(&graph_input, &snapshot, &query_text, controls.max_sources()); let spec = query_view_spec(&rankings, controls.max_sources()); let graph = KnowledgeGraph::build(graph_input, limits).map_err(|e| format!("graph_error: {:?}", e))?; @@ -1984,6 +2155,7 @@ fn do_packet(root: String, query_text: String, controls: &ExperimentalControls) node, ranking, &graph, + None, public_safe_excerpt(node, &snapshot, controls.excerpt_bytes).as_deref(), ), if !anchors.is_empty() { @@ -2088,11 +2260,12 @@ fn do_packet(root: String, query_text: String, controls: &ExperimentalControls) let est = encoded_packet_bytes.div_ceil(4); let tr = pkt.receipt.truncated; - let warns: Vec = snapshot + let mut warns: Vec = snapshot .diagnostics .iter() .map(|d| format!("{:?}", d)) .collect(); + warns.extend(unmatched_query_warnings(&query_text, &matched_terms)); let data = format!( "{{\"root\":\"{}\",\"query\":\"{}\",\"controls\":{},\"payload\":\"{}\",\"selected_locators\":[{}],\"why_selected\":[{}],\"source_rankings\":[{}],\"seed_ids\":[{}],\"admitted_dependency_ids\":[{}],\"selected_ids\":[{}],\"candidate_source_bytes\":{},\"selected_source_bytes\":{},\"context_bytes_avoided\":{},\"excerpt_bytes\":{},\"raw_bytes\":{},\"encoded_packet_bytes\":{},\"estimated_tokens\":{},\"token_estimate_method\":\"bytes-divided-by-four-ceiling\",\"actual_model_input_tokens\":\"unavailable\",\"runtime_token_ceiling\":{},\"truncated\":{},\"sqz\":{}}}", @@ -2155,8 +2328,10 @@ fn do_check( } let okf = &vres.okf_compatibility; + let v0_2 = &vres.okf_v0_2; let strict = &vres.bran_strict; let okf_diags = format_diagnostics(&okf.diagnostics); + let v0_2_diags = format_diagnostics(&v0_2.diagnostics); let strict_diags = format_diagnostics(&strict.diagnostics); let sel_err_json = match &vres.selected_profile_error { Some(d) => format!( @@ -2184,12 +2359,15 @@ fn do_check( }; let data = format!( - "{{\"root\":\"{}\",\"selected_profile\":\"{}\",\"okf_compatibility\":{{\"profile\":\"{}\",\"status\":\"{}\",\"diagnostics\":[{}]}},\"bran_strict\":{{\"profile\":\"{}\",\"status\":\"{}\",\"diagnostics\":[{}]}},\"selected_profile_error\":{},\"selected_passed\":{},\"exit_code\":{}}}", + "{{\"root\":\"{}\",\"selected_profile\":\"{}\",\"okf_compatibility\":{{\"profile\":\"{}\",\"status\":\"{}\",\"diagnostics\":[{}]}},\"okf_v0_2\":{{\"profile\":\"{}\",\"status\":\"{}\",\"diagnostics\":[{}]}},\"bran_strict\":{{\"profile\":\"{}\",\"status\":\"{}\",\"diagnostics\":[{}]}},\"selected_profile_error\":{},\"selected_passed\":{},\"exit_code\":{}}}", json_escape(&root), json_escape(&selected_profile), json_escape(&okf.profile), status_str(&okf.status), okf_diags, + json_escape(&v0_2.profile), + status_str(&v0_2.status), + v0_2_diags, json_escape(&strict.profile), status_str(&strict.status), strict_diags, @@ -2231,18 +2409,28 @@ fn derive_bundle_from_snapshot(snapshot: &ScanSnapshot) -> Result continue, }; let (raw, body) = split_frontmatter(&source); - let fm_map = build_map_from_facts(&entry.metadata.facts); - let fm = if let Some(reason) = entry - .metadata - .warnings - .iter() - .find_map(|warning| warning.strip_prefix("malformed-metadata: ")) - { - Frontmatter::malformed(raw, reason) - } else if fm_map.is_empty() && raw.is_empty() { + // The scanner's flat fact parser cannot represent nested v0.2 + // families (sources, generated, verified, ...), so the raw frontmatter + // is re-parsed structurally. A successful structural parse wins even + // when the scanner warned; the scanner's reason is kept only when the + // structural parse also fails. + let fm = if raw.is_empty() { Frontmatter::empty() } else { - Frontmatter::from_parsed(raw, fm_map) + match bran_core::frontmatter::parse_frontmatter(&raw) { + Ok(fields) => Frontmatter::from_parsed(raw, fields), + Err(_) => { + let reason = entry + .metadata + .warnings + .iter() + .find_map(|warning| warning.strip_prefix("malformed-metadata: ")); + match reason { + Some(reason) => Frontmatter::malformed(raw, reason), + None => Frontmatter::malformed(raw, "invalid frontmatter"), + } + } + } }; docs.push(Doc::new(path.clone(), source, body, fm)); } @@ -2280,30 +2468,6 @@ fn split_frontmatter(source: &str) -> (String, String) { (raw, body) } -fn build_map_from_facts(facts: &[MetadataFact]) -> BTreeMap { - let mut grouped: BTreeMap> = BTreeMap::new(); - for f in facts { - if f.provenance == FactProvenance::MarkdownFrontmatter { - grouped - .entry(f.key.clone()) - .or_default() - .push(f.value.clone()); - } - } - let mut map = BTreeMap::new(); - for (k, vs) in grouped { - if vs.len() == 1 { - map.insert(k, YamlValue::String(vs[0].clone())); - } else { - map.insert( - k, - YamlValue::Sequence(vs.into_iter().map(YamlValue::String).collect()), - ); - } - } - map -} - fn status_str(s: &ValidationStatus) -> &'static str { match s { ValidationStatus::Pass => "pass", @@ -3416,7 +3580,10 @@ impl SqzPort for ConnectedSqzPort { Ok(SqzPortOutput::new( evidence .lines() - .filter(|line| line.starts_with("locator=")) + .filter(|line| { + line.starts_with("locator=") + || line.starts_with("content-digest-sha256:") + }) .collect::>() .join("\n"), SqzIdentity::approved(), @@ -3509,7 +3676,12 @@ fn grounded_request_with( .map_err(|_| ConnectedSetupFailure::GroundingFailed)?; let node_count = graph_input.nodes().len().max(1); let edge_count = graph_input.edges().len().max(1); - let rankings = source_rankings(&graph_input, request.prompt(), controls.max_sources()); + let (rankings, _matched_terms) = source_rankings( + &graph_input, + &snapshot, + request.prompt(), + controls.max_sources(), + ); let spec = query_view_spec(&rankings, controls.max_sources()); let graph = KnowledgeGraph::build( graph_input, @@ -3540,6 +3712,10 @@ fn grounded_request_with( .enumerate() .map(|(index, node)| { let ranking = ranking_by_id.get(node.id()).copied(); + let digest = snapshot + .entries + .get(node.provenance().locator()) + .map(|entry| ResultId::sha256(entry.source.as_ref()).value().to_owned()); let anchors = ranking .filter(|ranking| ranking.rank == 1) .and_then(|_| { @@ -3557,6 +3733,7 @@ fn grounded_request_with( node, ranking, &graph, + digest.as_deref(), public_safe_excerpt(node, &snapshot, controls.excerpt_bytes).as_deref(), ), if !anchors.is_empty() { @@ -3613,6 +3790,17 @@ fn grounded_request_with( .iter() .map(|item| item.provenance.locator().to_owned()) .collect::>(); + let admitted_evidence = locators + .iter() + .map(|locator| { + let entry = snapshot + .entries + .get(locator) + .ok_or(ConnectedSetupFailure::GroundingFailed)?; + AdmittedEvidence::new(locator, ResultId::sha256(entry.source.as_ref()).value()) + .map_err(|_| ConnectedSetupFailure::GroundingFailed) + }) + .collect::, _>>()?; let mut anchors = Vec::new(); let mut start = 0; while start < request.prompt().len() { @@ -3639,7 +3827,17 @@ fn grounded_request_with( .collect::, _>>() .map_err(|_| ConnectedSetupFailure::GroundingFailed)?, ); - let grounding_contract = GroundingContract::new(locators, anchors) + anchors.extend( + admitted_evidence + .iter() + .enumerate() + .map(|(index, evidence)| { + PreservationAnchor::new(format!("source-digest-{index}"), evidence.content_digest()) + }) + .collect::, _>>() + .map_err(|_| ConnectedSetupFailure::GroundingFailed)?, + ); + let grounding_contract = GroundingContract::with_evidence(root, admitted_evidence, anchors) .map_err(|_| ConnectedSetupFailure::GroundingFailed)?; let prompt = prompt_prefix + &packet.payload; if prompt.len() > 65_536 { @@ -4206,14 +4404,31 @@ fn do_get(root: &Path, result_id: &str) -> CliResult { .map(|citation| format!("\"{}\"", json_escape(citation))) .collect::>() .join(","); + let claims = result + .claims() + .iter() + .map(|claim| { + format!( + "{{\"id\":\"{}\",\"text\":\"{}\",\"material\":{},\"locator\":\"{}\",\"content_digest\":\"{}\",\"support\":\"{}\"}}", + json_escape(claim.id()), + json_escape(claim.text()), + claim.material(), + json_escape(claim.locator()), + json_escape(claim.content_digest()), + json_escape(claim.support()) + ) + }) + .collect::>() + .join(","); CliResult::success(make_envelope( "get", "ok", &format!( - "{{\"result_id\":\"{}\",\"answer\":\"{}\",\"citations\":[{}]}}", + "{{\"result_id\":\"{}\",\"answer\":\"{}\",\"citations\":[{}],\"claims\":[{}]}}", json_escape(result_id), json_escape(result.answer()), - citations + citations, + claims ), &[], &[], @@ -4633,6 +4848,7 @@ const fn agent_failure_code(failure: AgentFailure) -> &'static str { AgentFailure::TokenBudgetUnattested => "token_budget_unattested", AgentFailure::TokenCeilingExceeded => "token_ceiling_exceeded", AgentFailure::GroundingFailed => "grounding_failed", + AgentFailure::ClaimUnsupported => "claim_unsupported", } } @@ -5274,8 +5490,8 @@ mod tests { let rep = "p3-replacement-bytes-exact\n".to_owned(); let proot = root.to_string_lossy().into_owned(); - let seed_document = "---\ntype: concept\ntitle: Seed\nokf_status: active\ntags: p3\ntags: packet\ntimestamp: 2026-07-19T00:00:00Z\nresource: test://seed\npublic_boundary: safe\ndependency: dep.md\n---\nseed-full-body-sentinel [dependency](dep.md)\n# Citations\nref\n"; - let dependency_document = "---\ntype: concept\ntitle: Dep\nokf_status: active\ntags: p3\ntags: packet\ntimestamp: 2026-07-19T00:00:00Z\nresource: test://dep\npublic_boundary: safe\n---\ndep [reference](x)\n# Citations\nref\n"; + let seed_document = "---\ntype: concept\ntitle: Seed\nokf_status: active\ntags: [p3, packet]\ntimestamp: 2026-07-19T00:00:00Z\nresource: test://seed\npublic_boundary: safe\ndependency: dep.md\n---\nseed-full-body-sentinel [dependency](dep.md)\n# Citations\nref\n"; + let dependency_document = "---\ntype: concept\ntitle: Dep\nokf_status: active\ntags: [p3, packet]\ntimestamp: 2026-07-19T00:00:00Z\nresource: test://dep\npublic_boundary: safe\n---\ndep [reference](x)\n# Citations\nref\n"; std::fs::write(root.join("seed.md"), seed_document.as_bytes()).unwrap(); std::fs::write(root.join("dep.md"), dependency_document.as_bytes()).unwrap(); @@ -5651,10 +5867,15 @@ mod tests { "account retry queue deduplication".to_owned(), ]); assert_eq!(okf_query.exit_code, ExitCode::SUCCESS); + // The metadata-exact task contract ranks first; the document whose + // body contains the full phrase now ranks second via body content. assert!(okf_query.output.contains( - "\"selected_locators\":[\"src/worker.rs\",\"task-retry.md\",\"tests/worker_check.rs\"]" + "\"selected_locators\":[\"distractor.md\",\"src/worker.rs\",\"task-retry.md\",\"tests/worker_check.rs\"]" )); - assert!(!okf_query.output.contains("distractor.md")); + assert!(okf_query.output.contains( + "\"source_rankings\":[{\"locator\":\"task-retry.md\",\"rank\":1,\"score\":{\"exact\":2" + )); + assert!(okf_query.output.contains("partial:body")); let okf_packet = CliApp::run(vec![ "packet".to_owned(), proot.clone(), @@ -5664,13 +5885,15 @@ mod tests { assert!(okf_packet .output .contains("\"locator\":\"task-retry.md\",\"reason\":\"metadata_seed\"")); + assert!(okf_packet + .output + .contains("\"locator\":\"distractor.md\",\"reason\":\"metadata_seed\"")); assert!(okf_packet .output .contains("\"locator\":\"src/worker.rs\",\"reason\":\"declared_implementation\"")); assert!(okf_packet .output .contains("\"locator\":\"tests/worker_check.rs\",\"reason\":\"declared_validation\"")); - assert!(!okf_packet.output.contains("distractor.md")); let okf_estimate = okf_packet .output .split_once("\"estimated_tokens\":") @@ -5795,7 +6018,7 @@ mod tests { b"bran-cli-fixture-v1\n", ) .unwrap(); - let valid_doc = "---\ntype: concept\ntitle: P3 Headless\nokf_status: active\ntags: p3\ntags: headless\ntimestamp: 2026-07-19T00:00:00Z\nresource: test://p3\npublic_boundary: safe\n---\nBody [link](x).\n# Citations\nref\n"; + let valid_doc = "---\ntype: concept\ntitle: P3 Headless\nokf_status: active\ntags: [p3, headless]\ntimestamp: 2026-07-19T00:00:00Z\nresource: test://p3\npublic_boundary: safe\n---\nBody [link](x).\n# Citations\nref\n"; std::fs::write(root.join("p3.md"), valid_doc.as_bytes()).unwrap(); let stale_target = "stale.txt".to_owned(); @@ -6130,8 +6353,9 @@ mod tests { && bytes[2] == b' ' }) .count(), - 5 + 6 ); + assert!(preamble.contains("Treat every factual statement as material")); Ok(bran_core::agent::synthetic::connected_receipt_for( request, false, )) @@ -6340,7 +6564,7 @@ mod tests { && bytes[2] == b' ' }) .count(), - 5 + 6 ); assert_numbered_rules(preamble); assert!(grounded @@ -6387,7 +6611,7 @@ mod tests { && bytes[2] == b' ' }) .count(), - 5 + 6 ); assert!(response_limited_grounding .prompt() @@ -6416,10 +6640,16 @@ mod tests { bran_core::agent::delegate::DelegationOptions::new(), ) .unwrap(); - assert_eq!( - super::grounded_request(&oversized_root, &oversized_request), - Err(super::ConnectedSetupFailure::GroundingFailed) - ); + // A term that exists only in the document body is now groundable + // (issue #19), and the 70 KiB body must not bloat the packet: the + // evidence is the metadata descriptor, not the raw body. + let grounded_oversized = super::grounded_request(&oversized_root, &oversized_request) + .expect("body-only content must be groundable"); + assert!(grounded_oversized + .grounding_contract() + .unwrap() + .admits_citation("only.md")); + assert!(grounded_oversized.prompt().len() < 2_000); let configured_descriptor = super::ConfiguredAgentDescriptor { profile: bran_core::agent::AgentProfile::new( "local-agent", @@ -6452,11 +6682,14 @@ mod tests { .grounding_contract() .unwrap() .preservation_anchors(); - assert_eq!(long_anchors.len(), 4); - assert_eq!(long_anchors[2].id(), "task-000"); - assert_eq!(long_anchors[2].value(), &long_task[..512]); - assert_eq!(long_anchors[3].id(), "task-001"); - assert_eq!(long_anchors[3].value(), &long_task[512..]); + // Body-content ranking (#19) admits repl.txt to the packet: its body + // "p3-replacement-bytes-exact" matches the task term "exact", so the + // grounding contract now carries three sources plus three digests. + assert_eq!(long_anchors.len(), 8); + assert_eq!(long_anchors[6].id(), "task-000"); + assert_eq!(long_anchors[6].value(), &long_task[..512]); + assert_eq!(long_anchors[7].id(), "task-001"); + assert_eq!(long_anchors[7].value(), &long_task[512..]); let mut tui_options = bran_core::agent::delegate::DelegationOptions::new(); tui_options.model_override = Some("alternate-model".to_owned()); tui_options.reasoning_override = Some(bran_core::agent::ReasoningLevel::Max); @@ -6655,7 +6888,9 @@ mod tests { "sqz-applied-v1\n{}", original_evidence .lines() - .filter(|line| line.starts_with("locator=")) + .filter(|line| { + line.starts_with("locator=") || line.starts_with("content-digest-sha256:") + }) .collect::>() .join("\n") ) @@ -6995,6 +7230,45 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn check_okf_v0_2_profile_end_to_end() { + let (root, _) = scratch_check_root("okf-v0-2-profile"); + // Nested v0.2 families: the scanner's flat parser cannot represent + // them and warns, which exercises the structural re-parse in + // derive_bundle_from_snapshot. + std::fs::write( + root.join("doc.md"), + "---\ntype: Concept\ngenerated:\n by: agent/1\n at: 2026-07-01T00:00:00Z\nverified:\n - by: human:alice\n at: 2026-07-02T00:00:00Z\n---\nbody\n", + ) + .unwrap(); + + let result = CliApp::run_with_stdin( + vec![ + "check".to_owned(), + "--policy-stdin".to_owned(), + root.to_string_lossy().into_owned(), + "okf-v0.2".to_owned(), + ], + minimal_valid_policy().as_bytes(), + ); + + assert!(!result.is_error); + assert_eq!(result.exit_code, TypedExit::Success.code()); + assert!(result.output.contains("\"selected_profile\":\"okf-v0.2\"")); + assert!(result.output.contains( + "\"okf_v0_2\":{\"profile\":\"okf-v0.2\",\"status\":\"pass\",\"diagnostics\":[]}" + )); + assert!(result + .output + .contains("\"okf_compatibility\":{\"profile\":\"okf-v0.1\"")); + assert!(result + .output + .contains("\"bran_strict\":{\"profile\":\"bran-strict\"")); + assert!(result.output.contains("\"selected_passed\":true")); + assert!(result.output.contains("\"exit_code\":0")); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn check_stdin_no_side_effects() { let (root, bran_dir) = scratch_check_root("stdin-no-side"); @@ -7260,6 +7534,133 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn query_ranks_body_phrase_document_first() { + // A phrase that appears only in a document body must retrieve that + // document ahead of any path-token coincidence (issue #19). + let root = + std::env::temp_dir().join(format!("bran-query-body-phrase-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join(".bran")).unwrap(); + std::fs::write(root.join(".bran/policy.yaml"), minimal_valid_policy()).unwrap(); + std::fs::write( + root.join("canonical.md"), + "---\ntype: concept\ntitle: Canonical anchor\ntags: anchor\n---\nThe proposal builds on the compatible superset not a fork idea.\n", + ) + .unwrap(); + std::fs::write( + root.join("not-unrelated.md"), + "---\ntype: concept\ntitle: Unrelated incident\ntags: incident\n---\nno matching content here\n", + ) + .unwrap(); + + let result = CliApp::run(vec![ + "query".to_owned(), + root.to_string_lossy().into_owned(), + "compatible superset not a fork".to_owned(), + ]); + + assert_eq!(result.exit_code, ExitCode::SUCCESS, "{}", result.output); + assert!(result.output.contains( + "\"source_rankings\":[{\"locator\":\"canonical.md\",\"rank\":1,\"score\":{\"exact\":0,\"partial\":3" + )); + assert!(result.output.contains("partial:body")); + // The generic term "not" is stop-listed (issue #18); the remaining + // body phrase must still outrank any path-token coincidence and rank + // the full-phrase document first. + assert!(!result + .output + .contains("\"locator\":\"not-unrelated.md\",\"rank\":1")); + assert!(!result.output.contains("unmatched_query_terms")); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn query_returns_empty_when_high_specificity_entity_unmatched() { + // A hyphenated identifier that matches no document must not present + // generic sub-token matches as ordinary evidence: no rankings and a + // warning naming the whole unit (issue #18). + let root = std::env::temp_dir().join(format!( + "bran-query-unmatched-entity-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join(".bran")).unwrap(); + std::fs::write(root.join(".bran/policy.yaml"), minimal_valid_policy()).unwrap(); + std::fs::write( + root.join("notes.md"), + "---\ntype: concept\ntitle: Notes\n---\nGeneric notes about the nonexistent collector wrapper and runner.\n", + ) + .unwrap(); + + let result = CliApp::run(vec![ + "query".to_owned(), + root.to_string_lossy().into_owned(), + "zzq-entity-unit-8873".to_owned(), + ]); + + assert_eq!(result.exit_code, ExitCode::SUCCESS, "{}", result.output); + assert!(result.output.contains("\"source_rankings\":[],")); + assert!(!result.output.contains("\"locator\":\"notes.md\"")); + assert!(result + .output + .contains("unmatched_query_terms: zzq-entity-unit-8873")); + + // The same holds when generic words around the missing entity match + // bodies: their weak matches must not be presented as evidence either. + let diluted = CliApp::run(vec![ + "query".to_owned(), + root.to_string_lossy().into_owned(), + "Where are the zzq-entity-unit-8873 wrapper, runner, and tests documented?".to_owned(), + ]); + assert_eq!(diluted.exit_code, ExitCode::SUCCESS, "{}", diluted.output); + assert!(diluted.output.contains("\"source_rankings\":[],")); + assert!(!diluted.output.contains("\"locator\":\"notes.md\"")); + assert!(diluted + .output + .contains("unmatched_query_terms: zzq-entity-unit-8873")); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn sub_token_does_not_count_as_entity_match() { + // Only the whole identifier unit matches: a document holding just its + // sub-tokens as separate words must not be presented as evidence for + // the entity (issue #18). + let root = std::env::temp_dir().join(format!( + "bran-query-sub-token-entity-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join(".bran")).unwrap(); + std::fs::write(root.join(".bran/policy.yaml"), minimal_valid_policy()).unwrap(); + std::fs::write( + root.join("home.md"), + "---\ntype: concept\ntitle: Home\n---\nThe zzq-entity-unit-8873 wrapper and runner are implemented here.\n", + ) + .unwrap(); + std::fs::write( + root.join("noise.md"), + "---\ntype: concept\ntitle: Noise\n---\nThe entity unit wrapper notes live here.\n", + ) + .unwrap(); + + let result = CliApp::run(vec![ + "query".to_owned(), + root.to_string_lossy().into_owned(), + "zzq-entity-unit-8873".to_owned(), + ]); + + assert_eq!(result.exit_code, ExitCode::SUCCESS, "{}", result.output); + assert!(result.output.contains( + "\"source_rankings\":[{\"locator\":\"home.md\",\"rank\":1,\"score\":{\"exact\":1" + )); + assert!(result.output.contains("exact:body")); + assert!(!result.output.contains("\"locator\":\"noise.md\"")); + assert!(!result.output.contains("unmatched_query_terms")); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn check_coverage_policy_error_is_typed_and_non_echoing() { let invalid = CliApp::run_with_stdin( diff --git a/crates/bran-core/src/adapters/connected.rs b/crates/bran-core/src/adapters/connected.rs index eab8c08..46f4bf8 100644 --- a/crates/bran-core/src/adapters/connected.rs +++ b/crates/bran-core/src/adapters/connected.rs @@ -2,7 +2,7 @@ use super::sqz::{SqzAdapter, SqzAdapterConfig, SqzFailureReason, SqzPort, SqzReceipt, SqzStatus}; use crate::agent::result_store::ResultId; -use crate::agent::runtime::ProviderTokenUsage; +use crate::agent::runtime::{valid_sha256, ProviderTokenUsage}; use crate::packet::{ContextPacket, PacketReceipt, PreservationAnchor, StructuralPacket}; use std::collections::{BTreeMap, BTreeSet}; @@ -288,7 +288,7 @@ fn validate( || !valid_id(&citation.claim_id) || !valid_field(&citation.node_id) || !valid_field(&citation.locator) - || !valid_digest(&citation.content_digest) + || !valid_sha256(&citation.content_digest) || !ids.insert(citation.id.clone()) { bad += 1; @@ -309,7 +309,7 @@ fn validate( || usage.support.len() > MAX_TEXT || !valid_field(&usage.node_id) || !valid_field(&usage.locator) - || !valid_digest(&usage.content_digest) + || !valid_sha256(&usage.content_digest) || !ids.insert(usage.id.clone()) { bad += 1; @@ -542,9 +542,6 @@ fn valid_id(value: &str) -> bool { fn valid_field(value: &str) -> bool { !value.trim().is_empty() && value.len() <= MAX_TEXT && !value.contains(['\r', '\n']) } -fn valid_digest(value: &str) -> bool { - value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) -} fn digest(bytes: &[u8]) -> String { ResultId::sha256(bytes).value().to_owned() } @@ -964,6 +961,11 @@ mod tests { invalid.usages.push(invalid.usages[0].clone()); assert_grounding_rejected(packet(), invalid); // duplicate usage ID + let mut invalid = response(); + invalid.citations[0].content_digest = "A".repeat(64); + invalid.usages[0].content_digest = "A".repeat(64); + assert_grounding_rejected(packet(), invalid); // uppercase-hex digest never matches a packet item + let mut invalid = response(); invalid.claims[0].id = "malformed id".into(); invalid.citations[0].id = "malformed citation".into(); diff --git a/crates/bran-core/src/adapters/provider/mod.rs b/crates/bran-core/src/adapters/provider/mod.rs index a32605d..392b0f1 100644 --- a/crates/bran-core/src/adapters/provider/mod.rs +++ b/crates/bran-core/src/adapters/provider/mod.rs @@ -17,8 +17,8 @@ use std::time::{Duration, Instant}; use crate::agent::result_store::ResultId; use crate::agent::runtime::{ - ArtifactKind, LosslessArtifact, ProviderError, ProviderExecutionEvidence, ProviderOutput, - ProviderPort, ProviderRequest, ProviderTokenUsage, + ArtifactKind, LosslessArtifact, ProviderClaim, ProviderError, ProviderExecutionEvidence, + ProviderOutput, ProviderPort, ProviderRequest, ProviderTokenUsage, }; const MAX_FRAME: usize = 4 * 1024 * 1024; @@ -843,6 +843,12 @@ fn parse_result( "actual_output_tokens", "answer", "citation", + "claim_id", + "claim_text", + "claim_material", + "claim_locator", + "claim_content_digest", + "claim_support", "provider_run_id", "effective_provider", "effective_model", @@ -888,6 +894,12 @@ fn parse_result( &required, &[ "citation", + "claim_id", + "claim_text", + "claim_material", + "claim_locator", + "claim_content_digest", + "claim_support", "artifact_kind", "artifact_media_type", "artifact_id", @@ -968,6 +980,7 @@ fn parse_result( return Err(ProviderError::InvalidOutput); } let citations = values.get("citation").cloned().unwrap_or_default(); + let claims = parse_claims(&values)?; let artifacts = parse_artifacts(&values)?; let effective = |key: &str, legacy: &str| -> Result, ProviderError> { let reported = values @@ -1005,6 +1018,7 @@ fn parse_result( }, artifacts, ) + .and_then(|output| output.with_claims(claims)) .map_err(|_| ProviderError::InvalidOutput)?; match preflight.ceiling { Some(ceiling) => output @@ -1014,6 +1028,53 @@ fn parse_result( } } +fn parse_claims( + values: &BTreeMap>, +) -> Result, ProviderError> { + let fields = [ + values.get("claim_id").map(Vec::as_slice).unwrap_or(&[]), + values.get("claim_text").map(Vec::as_slice).unwrap_or(&[]), + values + .get("claim_material") + .map(Vec::as_slice) + .unwrap_or(&[]), + values + .get("claim_locator") + .map(Vec::as_slice) + .unwrap_or(&[]), + values + .get("claim_content_digest") + .map(Vec::as_slice) + .unwrap_or(&[]), + values + .get("claim_support") + .map(Vec::as_slice) + .unwrap_or(&[]), + ]; + let count = fields[0].len(); + if fields.iter().any(|field| field.len() != count) { + return Err(ProviderError::InvalidOutput); + } + (0..count) + .map(|index| { + let material = match fields[2][index].as_str() { + "true" => true, + "false" => false, + _ => return Err(ProviderError::InvalidOutput), + }; + ProviderClaim::new( + &fields[0][index], + &fields[1][index], + material, + &fields[3][index], + &fields[4][index], + &fields[5][index], + ) + .map_err(|_| ProviderError::InvalidOutput) + }) + .collect() +} + fn parse_artifacts( values: &BTreeMap>, ) -> Result, ProviderError> { @@ -1310,6 +1371,7 @@ fn provider_protocol_self_check() -> Result<(), ProviderError> { )?; let artifact_bytes = [0, 0xff, b'{', b'}']; let artifact_id = ResultId::sha256(&artifact_bytes).to_string(); + let claim_digest = "a".repeat(64); let result_payload = |no_session| { fields(&[ ("stage", "result"), @@ -1329,6 +1391,13 @@ fn provider_protocol_self_check() -> Result<(), ProviderError> { ("actual_input_tokens", "10"), ("actual_output_tokens", "7"), ("answer", "fixture answer"), + ("citation", "src/fixture.rs"), + ("claim_id", "claim-1"), + ("claim_text", "fixture_symbol exists"), + ("claim_material", "true"), + ("claim_locator", "src/fixture.rs"), + ("claim_content_digest", &claim_digest), + ("claim_support", "fixture_symbol"), ("provider_run_id", "fixture-run"), ("effective_provider", "fixture-provider-v2"), ("effective_model", "unavailable"), @@ -1352,12 +1421,20 @@ fn provider_protocol_self_check() -> Result<(), ProviderError> { if output.effective_provider() != Some("fixture-provider-v2") || output.effective_model().is_some() || output.effective_reasoning() != Some("low") + || output.claims().len() != 1 + || output.claims()[0].locator() != "src/fixture.rs" + || output.claims()[0].support() != "fixture_symbol" || output.artifacts().len() != 1 || output.artifacts()[0].bytes() != artifact_bytes || output.artifacts()[0].id().to_string() != artifact_id { return Err(ProviderError::InvalidOutput); } + let mut malformed_claims = parse(&payload)?; + malformed_claims.remove("claim_support"); + if parse_claims(&malformed_claims).is_ok() { + return Err(ProviderError::InvalidOutput); + } if parse_result( &result_payload("false"), &request, diff --git a/crates/bran-core/src/agent/coordinator.rs b/crates/bran-core/src/agent/coordinator.rs index 8f1e79f..92f4b2e 100644 --- a/crates/bran-core/src/agent/coordinator.rs +++ b/crates/bran-core/src/agent/coordinator.rs @@ -637,6 +637,32 @@ impl AgentRuntime { request.tool_policy().clone(), agent_profile_registry, ); + if request.grounding_contract().is_some() + && !grounding_execution_attested(&requested, &effective) + { + return incomplete( + requested, + effective, + AgentFailure::InvalidOutput, + Some(input.receipt().clone()), + None, + input_bytes, + 0, + request.no_session(), + ); + } + if !grounded_claims_accepted(request, &provider_output) { + return incomplete( + requested, + effective, + AgentFailure::ClaimUnsupported, + Some(input.receipt().clone()), + None, + input_bytes, + 0, + request.no_session(), + ); + } if let Err(failure) = enforced_token_budget(request, &provider_output) { return incomplete( requested, @@ -682,10 +708,23 @@ impl AgentRuntime { request.no_session(), ); } + if request.grounding_contract().is_some() && output.payload() != provider_output.answer() { + return incomplete( + requested, + effective, + AgentFailure::ClaimUnsupported, + Some(input.receipt().clone()), + Some(output.receipt().clone()), + input_bytes, + output.payload().len(), + request.no_session(), + ); + } - let inline = InlineResult::new( + let inline = InlineResult::with_claims( output.payload(), provider_output.citations().iter().cloned(), + provider_output.claims().iter().cloned(), ) .map_err(|_| AgentRuntimeInternalError::ReceiptInvariant)?; let stored_ref = match store_output( @@ -867,6 +906,15 @@ fn safe_provider_surfaces(output: &super::runtime::ProviderOutput) -> bool { .chain(output.effective_provider().map(str::as_bytes)) .chain(output.effective_model().map(str::as_bytes)) .chain(output.effective_reasoning().map(str::as_bytes)) + .chain(output.claims().iter().flat_map(|claim| { + [ + claim.id().as_bytes(), + claim.text().as_bytes(), + claim.locator().as_bytes(), + claim.content_digest().as_bytes(), + claim.support().as_bytes(), + ] + })) .all(|bytes| crate::adapters::sqz::public_dlp_findings(bytes).is_empty()); strings_are_safe && output.artifacts().iter().all(|artifact| { @@ -904,6 +952,50 @@ fn grounded_citations_accepted(request: &DelegationRequest, citations: &[String] }) } +fn grounding_execution_attested( + requested: &RequestedExecution, + effective: &EffectiveExecution, +) -> bool { + matches!(effective.profile(), Attestation::Attested(profile) if profile.name() == requested.profile_name()) + && matches!((requested.provider(), effective.provider()), + (Attestation::Attested(requested), Attestation::Attested(effective)) if requested == effective) + && matches!((requested.model(), effective.model()), + (Attestation::Attested(requested), Attestation::Attested(effective)) if requested == effective) + && matches!((requested.reasoning(), effective.reasoning()), + (Attestation::Attested(requested), Attestation::Attested(effective)) if requested == effective) +} + +fn grounded_claims_accepted( + request: &DelegationRequest, + output: &super::runtime::ProviderOutput, +) -> bool { + let Some(contract) = request.grounding_contract() else { + return true; + }; + let claims = output.claims(); + let grounded_answer = claims + .iter() + .map(|claim| claim.text()) + .collect::>() + .join("\n"); + contract.has_verifiable_evidence() + && !claims.is_empty() + && output.answer() == grounded_answer + && claims.iter().all(|claim| { + claim.material() + && claim.text() == claim.support() + && output + .citations() + .iter() + .any(|citation| citation == claim.locator()) + }) + && contract.verifies_support( + claims + .iter() + .map(|claim| (claim.locator(), claim.content_digest(), claim.support())), + ) +} + fn accepted_sqz(receipt: &SqzReceipt, payload: &str) -> bool { if receipt.failure_reason.is_some() || receipt.fidelity_status != FidelityStatus::Passed @@ -1083,11 +1175,13 @@ fn store_output( #[cfg(test)] mod tests { - use super::super::delegate::{DelegationOptions, DelegationRequest, GroundingContract}; + use super::super::delegate::{ + AdmittedEvidence, DelegationOptions, DelegationRequest, GroundingContract, + }; use super::super::result_store::{MemoryResultStore, ResultStoreError}; use super::super::runtime::{ AgentFailure, ArtifactKind, Attestation, AuthError, AuthStore, InvocationLifecycle, - InvocationOutcome, InvocationState, LosslessArtifact, ProviderError, + InvocationOutcome, InvocationState, LosslessArtifact, ProviderClaim, ProviderError, ProviderExecutionEvidence, ProviderOutput, ProviderPort, ProviderRequest, ProviderTokenUsage, }; @@ -1101,6 +1195,8 @@ mod tests { ToolPolicy, }; use std::cell::{Cell, RefCell}; + use std::fs; + use std::path::PathBuf; use std::rc::Rc; use std::time::Duration; @@ -1384,6 +1480,28 @@ mod tests { let receipt = make_sqz_receipt(payload); AgentSqzOutput::new(payload.to_owned(), receipt) } + + fn evaluate_with_anchors( + &self, + stage: SqzStage, + payload: &str, + max_output_bytes: usize, + preservation_anchors: &[PreservationAnchor], + ) -> Result { + if preservation_anchors + .iter() + .any(|anchor| !payload.contains(anchor.value())) + { + return Err(AgentSqzError::new(AgentSqzFailureCode::InvalidOutput, None)); + } + let mut output = self.evaluate(stage, payload, max_output_bytes)?; + output.receipt.required_fidelity_anchor_ids = preservation_anchors + .iter() + .map(|anchor| anchor.id().to_owned()) + .collect(); + output.receipt.missing_fidelity_anchor_ids.clear(); + Ok(output) + } } fn make_success_provider_output() -> ProviderOutput { @@ -1416,6 +1534,106 @@ mod tests { .unwrap() } + fn grounding_fixture() -> (PathBuf, String, String) { + let root = std::env::temp_dir().join(format!( + "bran-claim-grounding-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir(&root).unwrap(); + let src1 = "pub fn supported_symbol() -> u32 { 42 }\n"; + let doc2 = "The architecture contract preserves grounded evidence.\n"; + fs::write(root.join("src1"), src1).unwrap(); + fs::write(root.join("doc2"), doc2).unwrap(); + ( + root, + ResultId::sha256(src1.as_bytes()).value().to_owned(), + ResultId::sha256(doc2.as_bytes()).value().to_owned(), + ) + } + + fn grounded_provider_output(src1_digest: &str, doc2_digest: &str) -> ProviderOutput { + let claims = [ + ProviderClaim::new( + "claim-src1", + "supported_symbol", + true, + "src1", + src1_digest, + "supported_symbol", + ) + .unwrap(), + ProviderClaim::new( + "claim-doc2", + "architecture contract preserves grounded evidence", + true, + "doc2", + doc2_digest, + "architecture contract preserves grounded evidence", + ) + .unwrap(), + ]; + ProviderOutput::with_effective_execution( + claims + .iter() + .map(ProviderClaim::text) + .collect::>() + .join("\n"), + ["src1", "doc2"], + Some("prov-run-xyz"), + ProviderExecutionEvidence::new( + Some("sol"), + Some("fixture-provider"), + Some("fixture-sol"), + Some("high"), + ) + .unwrap(), + ProviderTokenUsage { + actual_input_tokens: Some(123), + actual_output_tokens: Some(9), + }, + Vec::::new(), + ) + .unwrap() + .with_claims(claims) + .unwrap() + } + + fn custom_grounded_provider_output( + citations: &[&str], + claim: ProviderClaim, + effective_model: Option<&str>, + ) -> ProviderOutput { + let answer = claim.text().to_owned(); + ProviderOutput::with_effective_execution( + answer, + citations.iter().copied(), + Some("prov-run-grounded"), + ProviderExecutionEvidence::new( + Some("sol"), + Some("fixture-provider"), + effective_model, + Some("high"), + ) + .unwrap(), + ProviderTokenUsage { + actual_input_tokens: Some(123), + actual_output_tokens: Some(9), + }, + Vec::::new(), + ) + .unwrap() + .with_claims([claim]) + .unwrap() + } + + fn single_citation_provider_output(claim: ProviderClaim) -> ProviderOutput { + custom_grounded_provider_output(&["src1"], claim, Some("fixture-sol")) + } + fn canonical_result(answer: &str, citations: &[String]) -> Vec { InlineResult::new(answer, citations.iter().cloned()) .unwrap() @@ -1543,12 +1761,21 @@ mod tests { PreservationAnchor::new("architecture", "architecture-contract-alpha").unwrap(), PreservationAnchor::new("late-evidence", "late-evidence-anchor-omega").unwrap(), ]; - let grounding = - GroundingContract::new(["src1", "doc2"], grounding_anchors.clone()).unwrap(); + let (grounding_root, src1_digest, doc2_digest) = grounding_fixture(); + let grounding = GroundingContract::with_evidence( + &grounding_root, + [ + AdmittedEvidence::new("src1", &src1_digest).unwrap(), + AdmittedEvidence::new("doc2", &doc2_digest).unwrap(), + ], + grounding_anchors.clone(), + ) + .unwrap(); let mut grounded_options = make_trusted_opts(); grounded_options.grounding_contract = Some(grounding); let grounded_auth = FakeAuthStore::always_ok(); - let grounded_provider = FakeProviderPort::success(success_out.clone()); + let grounded_provider = + FakeProviderPort::success(grounded_provider_output(&src1_digest, &doc2_digest)); let mut grounded_store = MemoryResultStore::new(8, 10000, 2000, 10000).unwrap(); let grounded_ports = RuntimePorts::new( &grounded_auth, @@ -1578,14 +1805,113 @@ mod tests { assert!(grounded_receipt .provenance() .contains(&"bran-grounding-validated".to_string())); + let grounded_inline = grounded_receipt.inline_result().unwrap(); + assert_eq!(grounded_inline.claims().len(), 2); + assert!(grounded_inline + .encode_canonical() + .starts_with(b"bran-agent-result-v2")); + assert_eq!( + InlineResult::decode_canonical(&grounded_inline.encode_canonical()).unwrap(), + grounded_inline.clone() + ); assert_eq!(off_calls.get(), 0); - let rejected_grounding = - GroundingContract::new(["src1"], grounding_anchors.clone()).unwrap(); + let rewritten_grounding = GroundingContract::with_evidence( + &grounding_root, + [ + AdmittedEvidence::new("src1", &src1_digest).unwrap(), + AdmittedEvidence::new("doc2", &doc2_digest).unwrap(), + ], + grounding_anchors.clone(), + ) + .unwrap(); + let mut rewritten_options = make_trusted_opts(); + rewritten_options.grounding_contract = Some(rewritten_grounding); + let rewritten_auth = FakeAuthStore::always_ok(); + let rewritten_provider = + FakeProviderPort::success(grounded_provider_output(&src1_digest, &doc2_digest)); + let rewritten_sqz = FakeAgentSqzPort::rewrite_output(); + let mut rewritten_store = MemoryResultStore::new(8, 10000, 2000, 10000).unwrap(); + let rewritten_receipt = rt + .invoke( + &make_request("sol", grounded_prompt, rewritten_options), + AgentRuntimeAuthority::new(false, true, false), + ®istry, + || { + RuntimePorts::new( + &rewritten_auth, + &rewritten_provider, + &rewritten_sqz, + &mut rewritten_store, + ) + }, + 1001, + ) + .unwrap(); + assert!( + matches!( + rewritten_receipt.outcome(), + InvocationOutcome::Incomplete { + failure: AgentFailure::ClaimUnsupported, + .. + } + ), + "unexpected outcome: {:?}", + rewritten_receipt.outcome() + ); + assert!(rewritten_receipt.inline_result().is_none()); + assert!(rewritten_receipt.stored_result_ref().is_none()); + + let missing_claims_grounding = GroundingContract::with_evidence( + &grounding_root, + [ + AdmittedEvidence::new("src1", &src1_digest).unwrap(), + AdmittedEvidence::new("doc2", &doc2_digest).unwrap(), + ], + grounding_anchors.clone(), + ) + .unwrap(); + let mut missing_claims_options = make_trusted_opts(); + missing_claims_options.grounding_contract = Some(missing_claims_grounding); + let missing_claims_auth = FakeAuthStore::always_ok(); + let missing_claims_provider = FakeProviderPort::success(success_out.clone()); + let mut missing_claims_store = MemoryResultStore::new(8, 10000, 2000, 10000).unwrap(); + let missing_claims_receipt = rt + .invoke( + &make_request("sol", grounded_prompt, missing_claims_options), + AgentRuntimeAuthority::new(false, true, false), + ®istry, + || { + RuntimePorts::new( + &missing_claims_auth, + &missing_claims_provider, + &off_adapter, + &mut missing_claims_store, + ) + }, + 1001, + ) + .unwrap(); + assert!(matches!( + missing_claims_receipt.outcome(), + InvocationOutcome::Incomplete { + failure: AgentFailure::ClaimUnsupported, + .. + } + )); + assert!(missing_claims_receipt.inline_result().is_none()); + + let rejected_grounding = GroundingContract::with_evidence( + &grounding_root, + [AdmittedEvidence::new("src1", &src1_digest).unwrap()], + grounding_anchors.clone(), + ) + .unwrap(); let mut rejected_options = make_trusted_opts(); rejected_options.grounding_contract = Some(rejected_grounding); let rejected_auth = FakeAuthStore::always_ok(); - let rejected_provider = FakeProviderPort::success(success_out.clone()); + let rejected_provider = + FakeProviderPort::success(grounded_provider_output(&src1_digest, &doc2_digest)); let mut rejected_store = MemoryResultStore::new(8, 10000, 2000, 10000).unwrap(); let rejected_ports = RuntimePorts::new( &rejected_auth, @@ -1599,7 +1925,7 @@ mod tests { AgentRuntimeAuthority::new(false, true, false), ®istry, || rejected_ports, - 1001, + 1002, ) .unwrap(); assert!(matches!( @@ -1611,6 +1937,405 @@ mod tests { )); assert!(rejected_receipt.inline_result().is_none()); assert!(rejected_receipt.stored_result_ref().is_none()); + + let invented_grounding = GroundingContract::with_evidence( + &grounding_root, + [AdmittedEvidence::new("src1", &src1_digest).unwrap()], + grounding_anchors.clone(), + ) + .unwrap(); + let invented_output = single_citation_provider_output( + ProviderClaim::new( + "claim-invented", + "nonexistent_symbol", + true, + "src1", + &src1_digest, + "nonexistent_symbol", + ) + .unwrap(), + ); + let mut invented_options = make_trusted_opts(); + invented_options.grounding_contract = Some(invented_grounding); + let invented_auth = FakeAuthStore::always_ok(); + let invented_provider = FakeProviderPort::success(invented_output); + let mut invented_store = MemoryResultStore::new(8, 10000, 2000, 10000).unwrap(); + let invented_receipt = rt + .invoke( + &make_request("sol", grounded_prompt, invented_options), + AgentRuntimeAuthority::new(false, true, false), + ®istry, + || { + RuntimePorts::new( + &invented_auth, + &invented_provider, + &off_adapter, + &mut invented_store, + ) + }, + 1003, + ) + .unwrap(); + assert!(matches!( + invented_receipt.outcome(), + InvocationOutcome::Incomplete { + failure: AgentFailure::ClaimUnsupported, + .. + } + )); + assert!(invented_receipt.inline_result().is_none()); + assert!(invented_receipt.stored_result_ref().is_none()); + + let mismatch_grounding = GroundingContract::with_evidence( + &grounding_root, + [AdmittedEvidence::new("src1", &src1_digest).unwrap()], + grounding_anchors.clone(), + ) + .unwrap(); + let mismatch_output = single_citation_provider_output( + ProviderClaim::new( + "claim-mismatch", + "unsupported summary", + true, + "src1", + &src1_digest, + "supported_symbol", + ) + .unwrap(), + ); + let mut mismatch_options = make_trusted_opts(); + mismatch_options.grounding_contract = Some(mismatch_grounding); + let mismatch_auth = FakeAuthStore::always_ok(); + let mismatch_provider = FakeProviderPort::success(mismatch_output); + let mut mismatch_store = MemoryResultStore::new(8, 10000, 2000, 10000).unwrap(); + let mismatch_receipt = rt + .invoke( + &make_request("sol", grounded_prompt, mismatch_options), + AgentRuntimeAuthority::new(false, true, false), + ®istry, + || { + RuntimePorts::new( + &mismatch_auth, + &mismatch_provider, + &off_adapter, + &mut mismatch_store, + ) + }, + 1004, + ) + .unwrap(); + assert!(matches!( + mismatch_receipt.outcome(), + InvocationOutcome::Incomplete { + failure: AgentFailure::ClaimUnsupported, + .. + } + )); + + let wrong_digest_grounding = GroundingContract::with_evidence( + &grounding_root, + [AdmittedEvidence::new("src1", &src1_digest).unwrap()], + grounding_anchors.clone(), + ) + .unwrap(); + let wrong_digest_output = single_citation_provider_output( + ProviderClaim::new( + "claim-wrong-digest", + "supported_symbol", + true, + "src1", + "b".repeat(64), + "supported_symbol", + ) + .unwrap(), + ); + let mut wrong_digest_options = make_trusted_opts(); + wrong_digest_options.grounding_contract = Some(wrong_digest_grounding); + let wrong_digest_auth = FakeAuthStore::always_ok(); + let wrong_digest_provider = FakeProviderPort::success(wrong_digest_output); + let mut wrong_digest_store = MemoryResultStore::new(8, 10000, 2000, 10000).unwrap(); + let wrong_digest_receipt = rt + .invoke( + &make_request("sol", grounded_prompt, wrong_digest_options), + AgentRuntimeAuthority::new(false, true, false), + ®istry, + || { + RuntimePorts::new( + &wrong_digest_auth, + &wrong_digest_provider, + &off_adapter, + &mut wrong_digest_store, + ) + }, + 1005, + ) + .unwrap(); + assert!(matches!( + wrong_digest_receipt.outcome(), + InvocationOutcome::Incomplete { + failure: AgentFailure::ClaimUnsupported, + .. + } + )); + + let uncited_grounding = GroundingContract::with_evidence( + &grounding_root, + [ + AdmittedEvidence::new("src1", &src1_digest).unwrap(), + AdmittedEvidence::new("doc2", &doc2_digest).unwrap(), + ], + grounding_anchors.clone(), + ) + .unwrap(); + let uncited_output = custom_grounded_provider_output( + &["doc2"], + ProviderClaim::new( + "claim-uncited", + "supported_symbol", + true, + "src1", + &src1_digest, + "supported_symbol", + ) + .unwrap(), + Some("fixture-sol"), + ); + let mut uncited_options = make_trusted_opts(); + uncited_options.grounding_contract = Some(uncited_grounding); + let uncited_auth = FakeAuthStore::always_ok(); + let uncited_provider = FakeProviderPort::success(uncited_output); + let mut uncited_store = MemoryResultStore::new(8, 10000, 2000, 10000).unwrap(); + let uncited_receipt = rt + .invoke( + &make_request("sol", grounded_prompt, uncited_options), + AgentRuntimeAuthority::new(false, true, false), + ®istry, + || { + RuntimePorts::new( + &uncited_auth, + &uncited_provider, + &off_adapter, + &mut uncited_store, + ) + }, + 1006, + ) + .unwrap(); + assert!(matches!( + uncited_receipt.outcome(), + InvocationOutcome::Incomplete { + failure: AgentFailure::ClaimUnsupported, + .. + } + )); + + let nonmaterial_grounding = GroundingContract::with_evidence( + &grounding_root, + [AdmittedEvidence::new("src1", &src1_digest).unwrap()], + grounding_anchors.clone(), + ) + .unwrap(); + let nonmaterial_output = single_citation_provider_output( + ProviderClaim::new( + "claim-nonmaterial", + "supported_symbol", + false, + "src1", + &src1_digest, + "supported_symbol", + ) + .unwrap(), + ); + let mut nonmaterial_options = make_trusted_opts(); + nonmaterial_options.grounding_contract = Some(nonmaterial_grounding); + let nonmaterial_auth = FakeAuthStore::always_ok(); + let nonmaterial_provider = FakeProviderPort::success(nonmaterial_output); + let mut nonmaterial_store = MemoryResultStore::new(8, 10000, 2000, 10000).unwrap(); + let nonmaterial_receipt = rt + .invoke( + &make_request("sol", grounded_prompt, nonmaterial_options), + AgentRuntimeAuthority::new(false, true, false), + ®istry, + || { + RuntimePorts::new( + &nonmaterial_auth, + &nonmaterial_provider, + &off_adapter, + &mut nonmaterial_store, + ) + }, + 1007, + ) + .unwrap(); + assert!(matches!( + nonmaterial_receipt.outcome(), + InvocationOutcome::Incomplete { + failure: AgentFailure::ClaimUnsupported, + .. + } + )); + + let unattested_grounding = GroundingContract::with_evidence( + &grounding_root, + [AdmittedEvidence::new("src1", &src1_digest).unwrap()], + grounding_anchors.clone(), + ) + .unwrap(); + let unattested_output = custom_grounded_provider_output( + &["src1"], + ProviderClaim::new( + "claim-unattested", + "supported_symbol", + true, + "src1", + &src1_digest, + "supported_symbol", + ) + .unwrap(), + None, + ); + let mut unattested_options = make_trusted_opts(); + unattested_options.grounding_contract = Some(unattested_grounding); + let unattested_auth = FakeAuthStore::always_ok(); + let unattested_provider = FakeProviderPort::success(unattested_output); + let mut unattested_store = MemoryResultStore::new(8, 10000, 2000, 10000).unwrap(); + let unattested_receipt = rt + .invoke( + &make_request("sol", grounded_prompt, unattested_options), + AgentRuntimeAuthority::new(false, true, false), + ®istry, + || { + RuntimePorts::new( + &unattested_auth, + &unattested_provider, + &off_adapter, + &mut unattested_store, + ) + }, + 1008, + ) + .unwrap(); + assert!(matches!( + unattested_receipt.outcome(), + InvocationOutcome::Incomplete { + failure: AgentFailure::InvalidOutput, + .. + } + )); + assert!(!unattested_receipt + .provenance() + .contains(&"bran-grounding-validated".to_string())); + + let stale_grounding = GroundingContract::with_evidence( + &grounding_root, + [AdmittedEvidence::new("src1", &src1_digest).unwrap()], + grounding_anchors.clone(), + ) + .unwrap(); + fs::write( + grounding_root.join("src1"), + "pub fn replacement_symbol() -> u32 { 7 }\n", + ) + .unwrap(); + let stale_output = single_citation_provider_output( + ProviderClaim::new( + "claim-stale", + "supported_symbol", + true, + "src1", + &src1_digest, + "supported_symbol", + ) + .unwrap(), + ); + let mut stale_options = make_trusted_opts(); + stale_options.grounding_contract = Some(stale_grounding); + let stale_auth = FakeAuthStore::always_ok(); + let stale_provider = FakeProviderPort::success(stale_output); + let mut stale_store = MemoryResultStore::new(8, 10000, 2000, 10000).unwrap(); + let stale_receipt = rt + .invoke( + &make_request("sol", grounded_prompt, stale_options), + AgentRuntimeAuthority::new(false, true, false), + ®istry, + || RuntimePorts::new(&stale_auth, &stale_provider, &off_adapter, &mut stale_store), + 1009, + ) + .unwrap(); + assert!(matches!( + stale_receipt.outcome(), + InvocationOutcome::Incomplete { + failure: AgentFailure::ClaimUnsupported, + .. + } + )); + assert!(stale_receipt.inline_result().is_none()); + assert!(stale_receipt.stored_result_ref().is_none()); + assert!(AdmittedEvidence::new("../src1", &src1_digest).is_err()); + assert!(AdmittedEvidence::new("/src1", &src1_digest).is_err()); + // Degenerate support: "u32" genuinely occurs in the fixture, so + // substring existence alone would accept it and prove nothing. The + // parse-time invariant and the boundary check must each reject it on + // their own. Uses a dedicated file because src1 was just rewritten to + // make the stale case stale. + let floor_body = "pub fn supported_symbol() -> u32 { 42 }\n"; + let floor_digest = ResultId::sha256(floor_body.as_bytes()).value().to_owned(); + fs::write(grounding_root.join("floor-src"), floor_body).unwrap(); + let floor_contract = GroundingContract::with_evidence( + &grounding_root, + [AdmittedEvidence::new("floor-src", &floor_digest).unwrap()], + grounding_anchors.clone(), + ) + .unwrap(); + assert!(ProviderClaim::new( + "claim-degenerate", + "u32", + true, + "floor-src", + &floor_digest, + "u32", + ) + .is_err()); + assert!(!floor_contract.verifies_support([("floor-src", floor_digest.as_str(), "u32")])); + assert!(!floor_contract.verifies_support([( + "floor-src", + floor_digest.as_str(), + " u32 " + )])); + // A present span exactly at the floor still verifies, so the floor + // rejects degenerate spans without rejecting legitimate short symbols. + assert!(floor_contract.verifies_support([( + "floor-src", + floor_digest.as_str(), + "supported_sy" + )])); + assert!(ProviderClaim::new( + "claim-at-floor", + "supported_sy", + true, + "floor-src", + &floor_digest, + "supported_sy", + ) + .is_ok()); + #[cfg(unix)] + { + std::os::unix::fs::symlink("src1", grounding_root.join("linked-src1")).unwrap(); + let replacement = "pub fn replacement_symbol() -> u32 { 7 }\n"; + let replacement_digest = ResultId::sha256(replacement.as_bytes()).value().to_owned(); + let linked_contract = GroundingContract::with_evidence( + &grounding_root, + [AdmittedEvidence::new("linked-src1", &replacement_digest).unwrap()], + grounding_anchors.clone(), + ) + .unwrap(); + assert!(!linked_contract.verifies_support([( + "linked-src1", + replacement_digest.as_str(), + "replacement_symbol", + )])); + } + fs::remove_dir_all(&grounding_root).unwrap(); let empty_grounding = GroundingContract::new(["src1"], grounding_anchors.clone()).unwrap(); let mut empty_options = make_trusted_opts(); empty_options.grounding_contract = Some(empty_grounding); diff --git a/crates/bran-core/src/agent/delegate.rs b/crates/bran-core/src/agent/delegate.rs index 1a512fa..23ac1f4 100644 --- a/crates/bran-core/src/agent/delegate.rs +++ b/crates/bran-core/src/agent/delegate.rs @@ -1,8 +1,13 @@ //! Immutable delegation request contract. use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; use crate::adapters::is_public_dlp_safe; +use crate::agent::result_store::ResultId; +use crate::agent::runtime::{valid_sha256, MIN_CLAIM_SUPPORT_BYTES}; use crate::packet::PreservationAnchor; use super::{ReasoningLevel, ToolPolicy}; @@ -17,10 +22,44 @@ pub struct GroundingContractError { _p: (), } +/// Bounded evidence admission contract for one provider invocation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdmittedEvidence { + locator: String, + content_digest: String, +} + +impl AdmittedEvidence { + pub fn new( + locator: impl Into, + content_digest: impl Into, + ) -> Result { + let locator = locator.into(); + let content_digest = content_digest.into(); + if !valid_locator(&locator) || !valid_sha256(&content_digest) { + return Err(GroundingContractError { _p: () }); + } + Ok(Self { + locator, + content_digest, + }) + } + + pub fn locator(&self) -> &str { + &self.locator + } + + pub fn content_digest(&self) -> &str { + &self.content_digest + } +} + /// Bounded evidence admission contract for one provider invocation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GroundingContract { admitted_citation_locators: Vec, + admitted_evidence: Vec, + repository_root: Option, preservation_anchors: Vec, } @@ -73,10 +112,57 @@ impl GroundingContract { } Ok(Self { admitted_citation_locators: unique_locators.into_iter().collect(), + admitted_evidence: Vec::new(), + repository_root: None, preservation_anchors: unique_anchors.into_values().collect(), }) } + pub fn with_evidence( + repository_root: impl AsRef, + admitted_evidence: impl IntoIterator, + preservation_anchors: impl IntoIterator, + ) -> Result { + let repository_root = fs::canonicalize(repository_root.as_ref()) + .map_err(|_| GroundingContractError { _p: () })?; + let root_metadata = fs::symlink_metadata(&repository_root) + .map_err(|_| GroundingContractError { _p: () })?; + if !repository_root.is_absolute() + || !root_metadata.is_dir() + || root_metadata.file_type().is_symlink() + { + return Err(GroundingContractError { _p: () }); + } + + let mut evidence = admitted_evidence.into_iter().collect::>(); + if evidence.is_empty() || evidence.len() > 128 { + return Err(GroundingContractError { _p: () }); + } + evidence.sort_by(|left, right| left.locator.cmp(&right.locator)); + if evidence + .windows(2) + .any(|pair| pair[0].locator == pair[1].locator) + { + return Err(GroundingContractError { _p: () }); + } + let locators = evidence + .iter() + .map(|item| item.locator.clone()) + .collect::>(); + let mut contract = Self::new(locators, preservation_anchors)?; + let evidence_bytes = evidence.iter().try_fold(0usize, |total, item| { + total + .checked_add(item.locator.len()) + .and_then(|value| value.checked_add(item.content_digest.len())) + }); + if evidence_bytes.is_none_or(|bytes| bytes > 65_536) { + return Err(GroundingContractError { _p: () }); + } + contract.admitted_evidence = evidence; + contract.repository_root = Some(repository_root); + Ok(contract) + } + pub fn admitted_citation_locators(&self) -> &[String] { &self.admitted_citation_locators } @@ -90,6 +176,136 @@ impl GroundingContract { .binary_search_by(|candidate| candidate.as_str().cmp(locator)) .is_ok() } + + pub fn admitted_evidence(&self, locator: &str) -> Option<&AdmittedEvidence> { + self.admitted_evidence + .binary_search_by(|candidate| candidate.locator.as_str().cmp(locator)) + .ok() + .map(|index| &self.admitted_evidence[index]) + } + + pub fn has_verifiable_evidence(&self) -> bool { + self.repository_root.is_some() && !self.admitted_evidence.is_empty() + } + + /// Reopens each uniquely cited file once and verifies the packet digest and + /// exact support bytes against current repository content. + pub fn verifies_support<'a>( + &self, + claims: impl IntoIterator, + ) -> bool { + let Some(root) = self.repository_root.as_deref() else { + return false; + }; + let mut current = BTreeMap::>::new(); + let mut count = 0usize; + for (locator, claimed_digest, support) in claims { + count += 1; + if count > 128 + || support.trim().len() < MIN_CLAIM_SUPPORT_BYTES + || support.len() > 65_536 + { + return false; + } + let Some(admitted) = self.admitted_evidence(locator) else { + return false; + }; + if claimed_digest != admitted.content_digest { + return false; + } + let bytes = match current.get(locator) { + Some(bytes) => bytes, + None => { + let Some(bytes) = read_current_file(root, locator) else { + return false; + }; + if ResultId::sha256(&bytes).value() != admitted.content_digest { + return false; + } + current.insert(locator.to_owned(), bytes); + current.get(locator).expect("inserted evidence must exist") + } + }; + if !bytes + .windows(support.len()) + .any(|candidate| candidate == support.as_bytes()) + { + return false; + } + } + count > 0 + } +} + +fn valid_locator(locator: &str) -> bool { + !locator.is_empty() + && locator.len() <= 1024 + && is_public_dlp_safe(locator) + && Path::new(locator) + .components() + .all(|component| matches!(component, Component::Normal(_))) +} + +fn read_current_file(root: &Path, locator: &str) -> Option> { + const MAX_FILE_BYTES: u64 = 1024 * 1024; + if !valid_locator(locator) { + return None; + } + let mut joined = root.to_path_buf(); + let components = Path::new(locator).components().collect::>(); + for (index, component) in components.iter().enumerate() { + let Component::Normal(component) = component else { + return None; + }; + joined.push(component); + let metadata = fs::symlink_metadata(&joined).ok()?; + if metadata.file_type().is_symlink() + || (index + 1 < components.len() && !metadata.file_type().is_dir()) + { + return None; + } + } + let joined_metadata = fs::symlink_metadata(&joined).ok()?; + if !joined_metadata.file_type().is_file() + || joined_metadata.file_type().is_symlink() + || joined_metadata.len() > MAX_FILE_BYTES + { + return None; + } + let canonical = fs::canonicalize(&joined).ok()?; + if !canonical.starts_with(root) || canonical == root { + return None; + } + let file = File::open(&canonical).ok()?; + let opened_metadata = file.metadata().ok()?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if joined_metadata.dev() != opened_metadata.dev() + || joined_metadata.ino() != opened_metadata.ino() + { + return None; + } + } + let mut bytes = Vec::new(); + file.take(MAX_FILE_BYTES + 1).read_to_end(&mut bytes).ok()?; + if bytes.len() as u64 > MAX_FILE_BYTES { + return None; + } + let final_metadata = fs::metadata(&canonical).ok()?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if opened_metadata.dev() != final_metadata.dev() + || opened_metadata.ino() != final_metadata.ino() + || opened_metadata.size() != final_metadata.size() + || opened_metadata.mtime() != final_metadata.mtime() + || opened_metadata.mtime_nsec() != final_metadata.mtime_nsec() + { + return None; + } + } + Some(bytes) } /// Immutable value object bundling delegation configuration. diff --git a/crates/bran-core/src/agent/receipt.rs b/crates/bran-core/src/agent/receipt.rs index 48c05c8..5dbdb01 100644 --- a/crates/bran-core/src/agent/receipt.rs +++ b/crates/bran-core/src/agent/receipt.rs @@ -2,7 +2,7 @@ //! Standard library only. Uses existing agent/runtime/result_store and adapters::SqzReceipt. use super::result_store::ResultId; -use super::runtime::{Attestation, InvocationOutcome}; +use super::runtime::{Attestation, InvocationOutcome, ProviderClaim}; use super::{AgentProfile, ReasoningLevel, ToolPolicy}; use crate::adapters::{DlpStatus, FidelityStatus, SqzReceipt, SqzStatus}; @@ -161,6 +161,7 @@ impl SqzStages { pub struct InlineResult { answer: String, citations: Vec, + claims: Vec, } impl InlineResult { @@ -181,7 +182,25 @@ impl InlineResult { return Err(ReceiptError { _p: () }); } } - Ok(Self { answer, citations }) + Ok(Self { + answer, + citations, + claims: Vec::new(), + }) + } + + pub fn with_claims( + answer: impl Into, + citations: impl IntoIterator>, + claims: impl IntoIterator, + ) -> Result { + let mut result = Self::new(answer, citations)?; + let claims = claims.into_iter().collect::>(); + if claims.len() > 128 { + return Err(ReceiptError { _p: () }); + } + result.claims = claims; + Ok(result) } pub fn answer(&self) -> &str { @@ -192,26 +211,64 @@ impl InlineResult { &self.citations } + pub fn claims(&self) -> &[ProviderClaim] { + &self.claims + } + /// Stable byte representation stored under `StoredResultRef::result_id`. pub fn encode_canonical(&self) -> Vec { let mut encoded = Vec::with_capacity( - self.answer.len() + self.citations.iter().map(String::len).sum::() + 32, + self.answer.len() + + self.citations.iter().map(String::len).sum::() + + self + .claims + .iter() + .map(|claim| { + claim.id().len() + + claim.text().len() + + claim.locator().len() + + claim.content_digest().len() + + claim.support().len() + + 32 + }) + .sum::() + + 48, ); - encoded.extend_from_slice(b"bran-agent-result-v1"); + encoded.extend_from_slice(if self.claims.is_empty() { + b"bran-agent-result-v1" + } else { + b"bran-agent-result-v2" + }); encode_field(&mut encoded, self.answer.as_bytes()); encoded.extend_from_slice(&(self.citations.len() as u64).to_be_bytes()); for citation in &self.citations { encode_field(&mut encoded, citation.as_bytes()); } + if !self.claims.is_empty() { + encoded.extend_from_slice(&(self.claims.len() as u64).to_be_bytes()); + for claim in &self.claims { + encode_field(&mut encoded, claim.id().as_bytes()); + encode_field(&mut encoded, claim.text().as_bytes()); + encoded.push(u8::from(claim.material())); + encode_field(&mut encoded, claim.locator().as_bytes()); + encode_field(&mut encoded, claim.content_digest().as_bytes()); + encode_field(&mut encoded, claim.support().as_bytes()); + } + } encoded } /// Decodes only the exact canonical representation; trailing or malformed /// bytes are rejected before the normal answer/citation bounds are applied. pub fn decode_canonical(encoded: &[u8]) -> Result { - let Some(mut remaining) = encoded.strip_prefix(b"bran-agent-result-v1") else { - return Err(ReceiptError { _p: () }); - }; + let (version, mut remaining) = + if let Some(remaining) = encoded.strip_prefix(b"bran-agent-result-v2") { + (2, remaining) + } else if let Some(remaining) = encoded.strip_prefix(b"bran-agent-result-v1") { + (1, remaining) + } else { + return Err(ReceiptError { _p: () }); + }; let answer = decode_field(&mut remaining)?; let citation_count = decode_u64(&mut remaining)?; let citation_count = @@ -223,15 +280,50 @@ impl InlineResult { for _ in 0..citation_count { citations.push(decode_field(&mut remaining)?); } - if !remaining.is_empty() { - return Err(ReceiptError { _p: () }); - } let answer = String::from_utf8(answer).map_err(|_| ReceiptError { _p: () })?; let citations = citations .into_iter() .map(|citation| String::from_utf8(citation).map_err(|_| ReceiptError { _p: () })) .collect::, _>>()?; - Self::new(answer, citations) + if version == 1 { + if !remaining.is_empty() { + return Err(ReceiptError { _p: () }); + } + return Self::new(answer, citations); + } + let claim_count = decode_u64(&mut remaining)?; + let claim_count = usize::try_from(claim_count).map_err(|_| ReceiptError { _p: () })?; + if claim_count == 0 || claim_count > 128 { + return Err(ReceiptError { _p: () }); + } + let mut claims = Vec::with_capacity(claim_count); + for _ in 0..claim_count { + let id = String::from_utf8(decode_field(&mut remaining)?) + .map_err(|_| ReceiptError { _p: () })?; + let text = String::from_utf8(decode_field(&mut remaining)?) + .map_err(|_| ReceiptError { _p: () })?; + let (&material, rest) = remaining.split_first().ok_or(ReceiptError { _p: () })?; + remaining = rest; + let material = match material { + 0 => false, + 1 => true, + _ => return Err(ReceiptError { _p: () }), + }; + let locator = String::from_utf8(decode_field(&mut remaining)?) + .map_err(|_| ReceiptError { _p: () })?; + let content_digest = String::from_utf8(decode_field(&mut remaining)?) + .map_err(|_| ReceiptError { _p: () })?; + let support = String::from_utf8(decode_field(&mut remaining)?) + .map_err(|_| ReceiptError { _p: () })?; + claims.push( + ProviderClaim::new(id, text, material, locator, content_digest, support) + .map_err(|_| ReceiptError { _p: () })?, + ); + } + if !remaining.is_empty() { + return Err(ReceiptError { _p: () }); + } + Self::with_claims(answer, citations, claims) } } @@ -657,6 +749,7 @@ fn failure_name(failure: super::runtime::AgentFailure) -> &'static str { super::runtime::AgentFailure::InvalidOutput => "invalid_output", super::runtime::AgentFailure::DlpRejected => "dlp_rejected", super::runtime::AgentFailure::GroundingFailed => "grounding_failed", + super::runtime::AgentFailure::ClaimUnsupported => "claim_unsupported", super::runtime::AgentFailure::TokenBudgetUnattested => "token_budget_unattested", super::runtime::AgentFailure::TokenCeilingExceeded => "token_ceiling_exceeded", super::runtime::AgentFailure::SqzInputFailed => "sqz_input_failed", @@ -888,6 +981,28 @@ fn field_inline(json: &mut String, name: &str, inline: Option<&InlineResult>) { field_str(json, "answer", inline.answer()); json.push(','); field_strings(json, "citations", inline.citations()); + json.push(','); + key(json, "claims"); + json.push('['); + for (index, claim) in inline.claims().iter().enumerate() { + if index != 0 { + json.push(','); + } + json.push('{'); + field_str(json, "id", claim.id()); + json.push(','); + field_str(json, "text", claim.text()); + json.push(','); + field_bool(json, "material", claim.material()); + json.push(','); + field_str(json, "locator", claim.locator()); + json.push(','); + field_str(json, "content_digest", claim.content_digest()); + json.push(','); + field_str(json, "support", claim.support()); + json.push('}'); + } + json.push(']'); json.push('}'); } None => json.push_str("null"), diff --git a/crates/bran-core/src/agent/runtime.rs b/crates/bran-core/src/agent/runtime.rs index 33b368a..3cdd698 100644 --- a/crates/bran-core/src/agent/runtime.rs +++ b/crates/bran-core/src/agent/runtime.rs @@ -172,6 +172,22 @@ fn is_valid_name(s: &str) -> bool { .all(|&b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-')) } +pub(crate) fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// Minimum trimmed byte length for claim support text. +/// +/// Support is verified by substring existence against the cited file. A span +/// shorter than this verifies against nearly any file, so it would prove only +/// that the bytes occur somewhere, not that the claim rests on a meaningful +/// span. The floor is deliberately below the shortest realistic symbol name so +/// it rejects degenerate spans without rejecting legitimate short symbols. +pub(crate) const MIN_CLAIM_SUPPORT_BYTES: usize = 12; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderError { Unavailable, @@ -270,10 +286,77 @@ pub struct ProviderTokenUsage { pub actual_output_tokens: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderClaim { + id: String, + text: String, + material: bool, + locator: String, + content_digest: String, + support: String, +} + +impl ProviderClaim { + pub fn new( + id: impl Into, + text: impl Into, + material: bool, + locator: impl Into, + content_digest: impl Into, + support: impl Into, + ) -> Result { + let claim = Self { + id: id.into(), + text: text.into(), + material, + locator: locator.into(), + content_digest: content_digest.into(), + support: support.into(), + }; + if !is_valid_name(&claim.id) + || claim.text.trim().is_empty() + || claim.text.len() > 65_536 + || claim.locator.trim().is_empty() + || claim.locator.len() > 1024 + || !valid_sha256(&claim.content_digest) + || claim.support.trim().len() < MIN_CLAIM_SUPPORT_BYTES + || claim.support.len() > 65_536 + { + return Err(ProviderOutputError { _p: () }); + } + Ok(claim) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn material(&self) -> bool { + self.material + } + + pub fn locator(&self) -> &str { + &self.locator + } + + pub fn content_digest(&self) -> &str { + &self.content_digest + } + + pub fn support(&self) -> &str { + &self.support + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderOutput { answer: String, citations: Vec, + claims: Vec, provider_run_id: Option, effective_model: Option, effective_reasoning: Option, @@ -360,6 +443,7 @@ impl ProviderOutput { Ok(Self { answer, citations, + claims: Vec::new(), provider_run_id, effective_model, effective_reasoning, @@ -408,6 +492,26 @@ impl ProviderOutput { &self.citations } + pub fn with_claims( + mut self, + claims: impl IntoIterator, + ) -> Result { + let claims = claims.into_iter().collect::>(); + if claims.len() > 128 { + return Err(ProviderOutputError { _p: () }); + } + let mut ids = std::collections::BTreeSet::new(); + if claims.iter().any(|claim| !ids.insert(claim.id.clone())) { + return Err(ProviderOutputError { _p: () }); + } + self.claims = claims; + Ok(self) + } + + pub fn claims(&self) -> &[ProviderClaim] { + &self.claims + } + pub fn provider_run_id(&self) -> Option<&str> { self.provider_run_id.as_deref() } @@ -543,6 +647,7 @@ pub enum AgentFailure { InvalidOutput, DlpRejected, GroundingFailed, + ClaimUnsupported, TokenBudgetUnattested, TokenCeilingExceeded, SqzInputFailed, diff --git a/crates/bran-core/src/boundary.rs b/crates/bran-core/src/boundary.rs index 91892d0..c3a1bf8 100644 --- a/crates/bran-core/src/boundary.rs +++ b/crates/bran-core/src/boundary.rs @@ -90,13 +90,34 @@ fn read_diagnostic(path: &str) -> Diagnostic { } } +/// True when the line contains an absolute path rooted in a user home +/// directory, for any user name. +/// +/// This deliberately matches by shape rather than by a fixed list of paths. A +/// hardcoded list only detects one machine's layout and publishes that layout +/// in source that ships publicly. +fn contains_home_path(line: &str) -> bool { + const ROOTS: [(&str, char); 3] = [("/home/", '/'), ("/Users/", '/'), ("C:\\Users\\", '\\')]; + for (root, separator) in ROOTS { + let mut rest = line; + while let Some(start) = rest.find(root) { + let after = &rest[start + root.len()..]; + let user_len = after + .find(separator) + .filter(|len| *len > 0 && after.len() > len + 1); + if user_len.is_some() { + return true; + } + rest = &rest[start + root.len()..]; + } + } + false +} + fn finding_codes(line: &str) -> BTreeSet<&'static str> { let lower = line.to_ascii_lowercase(); let mut codes = BTreeSet::new(); - if line.contains("/home/spectre/alphazede") - || line.contains("/home/spectre/Downloads") - || line.contains("/home/spectre/.codex") - { + if contains_home_path(line) { codes.insert("private_home_path"); } if line.contains("tools/agents/") @@ -349,14 +370,14 @@ mod tests { )); fs::create_dir_all(&root).unwrap(); let path = "bridge.mjs"; - let approved = "const path = '/home/spectre/alphazede/public';"; + let approved = "const path = '/home/example-user/workspace/public';"; fs::write(root.join(path), approved).unwrap(); let policy = policy(path, approved); assert!(validate_public_boundary(&root, &policy).is_empty()); fs::write( root.join(path), - "const path = '/home/spectre/alphazede/private';", + "const path = '/home/example-user/workspace/private';", ) .unwrap(); let findings = validate_public_boundary(&root, &policy); @@ -365,6 +386,28 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn private_home_path_is_detected_for_any_user_and_no_layout_is_hardcoded() { + for line in [ + "const path = '/home/anyone/projects/x';", + "const path = '/home/other-user/Downloads/y';", + "const path = '/Users/someone/Library/z';", + "const path = 'C:\\Users\\someone\\AppData';", + ] { + assert!( + finding_codes(line).contains("private_home_path"), + "expected private_home_path for {line}" + ); + } + // A bare home root with no path below it is not a private path leak. + for line in ["/home/", "/home/user", "see /Users/ for details"] { + assert!( + !finding_codes(line).contains("private_home_path"), + "unexpected private_home_path for {line}" + ); + } + } + #[test] fn credential_assignment_requires_exact_key_operator_and_real_value() { for line in [ diff --git a/crates/bran-core/src/frontmatter.rs b/crates/bran-core/src/frontmatter.rs new file mode 100644 index 0000000..b0040cf --- /dev/null +++ b/crates/bran-core/src/frontmatter.rs @@ -0,0 +1,482 @@ +//! Minimal deterministic YAML-frontmatter parser for BRAN profile validation. +//! +//! Parses the OKF frontmatter subset: flat scalars, nested block mappings, +//! block sequences of scalars, block sequences of mappings, and inline flow +//! scalar lists (`[a, b]`). Every scalar is preserved as +//! [`YamlValue::String`], matching the scanner's fact stream, while nested +//! structure is recovered so profile validation can shape-check the OKF v0.2 +//! optional families (`sources`, `usage_window`, `generated`, `verified`, +//! `status`, `stale_after`, and the Attested Computation fields). +//! +//! Unknown fields and unknown types are preserved verbatim, never rewritten. +//! Parsing never touches the body, never resolves links, and error messages +//! never echo raw values (secret-bearing content stays out of diagnostics). + +use crate::schema::YamlValue; +use std::collections::BTreeMap; + +/// Parse a YAML frontmatter block into a normalized mapping. +/// +/// `raw` may be the full fenced block (`---\n...\n---\n`) or the fence-free +/// content. A leading `---` line and a trailing `---` line are stripped when +/// present. Errors carry a structural reason only; raw values are never +/// echoed. +pub fn parse_frontmatter(raw: &str) -> Result, String> { + let content = strip_fences(raw); + let tokens = tokenize(content)?; + let mut entries = Vec::new(); + let mut index = 0; + build_mapping(&tokens, &mut index, 0, &mut entries)?; + if index != tokens.len() { + return Err("unexpected indented content".to_owned()); + } + let mut map = BTreeMap::new(); + for (key, value) in entries { + if map.insert(key.clone(), value).is_some() { + return Err(format!("duplicate frontmatter key: {key}")); + } + } + Ok(map) +} + +/// Strip a leading and trailing `---` fence line when present. +fn strip_fences(raw: &str) -> &str { + let trimmed = raw.trim_start_matches('\u{feff}'); + let Some(content) = trimmed + .strip_prefix("---\n") + .or_else(|| trimmed.strip_prefix("---\r\n")) + else { + return trimmed; + }; + // A trailing fence is the final line equal to "---". + let trimmed_end = content.trim_end(); + let final_line = trimmed_end.rsplit('\n').next().unwrap_or(""); + if final_line.trim() == "---" { + let end = trimmed_end.len() - final_line.len(); + trimmed_end[..end].trim_end() + } else { + content + } +} + +/// One inline value on a `key:` or `- ` line. +#[derive(Clone, Debug, Eq, PartialEq)] +enum Inline { + Scalar(String), + FlowList(Vec), +} + +/// A structural line token. Indentation is measured in leading spaces. +#[derive(Clone, Debug, Eq, PartialEq)] +enum Token { + /// `key: value` (inline) or `key:` (nested block follows). + Key { + indent: usize, + key: String, + inline: Option, + }, + /// `- value` or `- key: value` (mapping item; deeper lines are its fields). + SeqItem { + indent: usize, + key: Option, + inline: Option, + }, +} + +fn tokenize(raw: &str) -> Result, String> { + let mut tokens = Vec::new(); + for raw_line in raw.lines() { + let line = raw_line.trim_end(); + let trimmed = line.trim_start(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let indent = line.len() - trimmed.len(); + if let Some(rest) = trimmed.strip_prefix("- ") { + let (key, inline) = split_sequence_value(rest)?; + tokens.push(Token::SeqItem { + indent, + key, + inline, + }); + continue; + } + let Some(colon) = trimmed.find(':') else { + return Err("not key/value YAML".to_owned()); + }; + let key = trimmed[..colon].trim(); + if !valid_key(key) { + return Err("unsupported YAML scalar".to_owned()); + } + let rest = &trimmed[colon + 1..]; + tokens.push(Token::Key { + indent, + key: key.to_owned(), + inline: parse_inline(rest)?, + }); + } + Ok(tokens) +} + +/// Split `- rest` into an optional mapping key and an inline value. +/// +/// A colon followed by whitespace (or end of line) marks a mapping item +/// (`- by: x`); a colon followed by other characters keeps the whole text a +/// scalar (`- https://x/y`). +fn split_sequence_value(rest: &str) -> Result<(Option, Option), String> { + let mut candidate = None; + for (offset, byte) in rest.bytes().enumerate() { + if byte == b':' { + let head = &rest[..offset]; + if valid_key(head.trim()) { + if offset + 1 >= rest.len() || rest.as_bytes()[offset + 1] == b' ' { + candidate = Some(offset); + } + break; + } + } + } + match candidate { + Some(colon) => Ok(( + Some(rest[..colon].trim().to_owned()), + parse_inline(&rest[colon + 1..])?, + )), + None => Ok((None, Some(Inline::Scalar(scalar(rest)?)))), + } +} + +/// Parse the value part of a `key:` line. `None` marks an empty value that +/// must be followed by a nested block. +fn parse_inline(rest: &str) -> Result, String> { + let rest = rest.trim(); + if rest.is_empty() { + return Ok(None); + } + if rest.starts_with(['{', '|', '>']) { + return Err("unsupported YAML scalar".to_owned()); + } + // Inline comments only start after whitespace; quoted values keep any ` #`. + let uncommented = if rest.starts_with(['"', '\'']) { + rest + } else { + strip_inline_comment(rest).unwrap_or("") + }; + let uncommented = uncommented.trim(); + if uncommented.is_empty() { + return Err("empty YAML scalar".to_owned()); + } + if uncommented.starts_with('[') { + if !uncommented.ends_with(']') { + return Err("malformed YAML list".to_owned()); + } + let inner = &uncommented[1..uncommented.len() - 1]; + let mut values = Vec::new(); + for item in inner.split(',') { + values.push(scalar(item)?); + } + return Ok(Some(Inline::FlowList(values))); + } + Ok(Some(Inline::Scalar(scalar(uncommented)?))) +} + +/// Drop a trailing ` # comment` from an unquoted scalar value. +fn strip_inline_comment(value: &str) -> Option<&str> { + value.split_once(" #").map_or(Some(value), |(head, _)| { + let head = head.trim_end(); + if head.is_empty() { + None + } else { + Some(head) + } + }) +} + +/// Parse and trim a scalar, stripping one layer of matching quotes. +fn scalar(value: &str) -> Result { + let value = value.trim(); + let unquoted = value + .strip_prefix('"') + .and_then(|item| item.strip_suffix('"')) + .or_else(|| { + value + .strip_prefix('\'') + .and_then(|item| item.strip_suffix('\'')) + }) + .unwrap_or(value) + .trim(); + if unquoted.is_empty() { + Err("empty YAML scalar".to_owned()) + } else { + Ok(unquoted.to_owned()) + } +} + +fn valid_key(key: &str) -> bool { + !key.is_empty() + && key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +/// Collect mapping entries at exactly `indent`. Stops at the first token with +/// a shallower or equal indentation (or end of input). +fn build_mapping( + tokens: &[Token], + index: &mut usize, + indent: usize, + entries: &mut Vec<(String, YamlValue)>, +) -> Result<(), String> { + while *index < tokens.len() { + match &tokens[*index] { + Token::SeqItem { + indent: item_indent, + .. + } => { + // A shallower sequence item closes a nested mapping block; + // anything deeper is malformed. + if *item_indent < indent { + break; + } + return Err("sequence item outside of a sequence".to_owned()); + } + Token::Key { + indent: key_indent, + key, + inline, + } if *key_indent == indent => { + let inline = inline.clone(); + if let Some(inline) = inline { + entries.push((key.clone(), yaml_value(inline))); + *index += 1; + continue; + } + // Nested block: the next token must be deeper. Skip the key + // token so the block parser reads the nested lines. + let Some(next) = tokens.get(*index + 1) else { + return Err("frontmatter key has an empty value".to_owned()); + }; + let next_indent = next_indent(next); + if next_indent <= indent { + return Err("frontmatter key has an empty value".to_owned()); + } + *index += 1; + let value = if matches!(next, Token::SeqItem { .. }) { + let mut items = Vec::new(); + build_sequence(tokens, index, next_indent, &mut items)?; + YamlValue::Sequence(items) + } else { + let mut nested = Vec::new(); + build_mapping(tokens, index, next_indent, &mut nested)?; + YamlValue::Mapping(nested.into_iter().collect()) + }; + entries.push((key.clone(), value)); + } + Token::Key { .. } => break, + } + } + Ok(()) +} + +fn next_indent(token: &Token) -> usize { + match token { + Token::Key { indent, .. } | Token::SeqItem { indent, .. } => *indent, + } +} + +/// Collect sequence items at exactly `indent`. Each item is a scalar or a +/// mapping whose deeper lines (indent greater than the item's) are its fields. +fn build_sequence( + tokens: &[Token], + index: &mut usize, + indent: usize, + items: &mut Vec, +) -> Result<(), String> { + while *index < tokens.len() { + let Token::SeqItem { + indent: item_indent, + key, + inline, + } = &tokens[*index] + else { + break; + }; + if *item_indent != indent { + break; + } + let key = key.clone(); + let inline = inline.clone(); + match key { + None => { + let Some(inline) = inline else { + return Err("empty sequence item".to_owned()); + }; + items.push(yaml_value(inline)); + *index += 1; + } + Some(key) => { + let mut mapping = BTreeMap::new(); + if let Some(inline) = inline { + mapping.insert(key.clone(), yaml_value(inline)); + } + // Deeper lines are this item's remaining fields. Skip the item + // token so the mapping parser reads the field lines, then push + // the completed item. + if let Some(next) = tokens.get(*index + 1) { + let next_indent = next_indent(next); + if next_indent > *item_indent { + *index += 1; + let mut fields = Vec::new(); + build_mapping(tokens, index, next_indent, &mut fields)?; + for (field, value) in fields { + if mapping.insert(field.clone(), value).is_some() { + return Err(format!("duplicate frontmatter key: {field}")); + } + } + items.push(YamlValue::Mapping(mapping)); + continue; + } + } + *index += 1; + items.push(YamlValue::Mapping(mapping)); + } + } + } + Ok(()) +} + +fn yaml_value(inline: Inline) -> YamlValue { + match inline { + Inline::Scalar(value) => YamlValue::String(value), + Inline::FlowList(values) => { + YamlValue::Sequence(values.into_iter().map(YamlValue::String).collect()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn string(value: &str) -> YamlValue { + YamlValue::String(value.to_owned()) + } + + fn mapping(pairs: &[(&str, YamlValue)]) -> YamlValue { + YamlValue::Mapping( + pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), value.clone())) + .collect(), + ) + } + + /// Each row is one supported YAML shape: (case, input, key, expected). + #[test] + fn parses_supported_yaml_shapes() { + let flow_and_comments = "tags: [a, b] # inline comment\ntype: Concept # note\n"; + let fenced = "---\ntype: Concept\nokf_version: \"0.2\"\n---\n"; + for (case, input, key, expected) in [ + ("flat scalar inside fences", fenced, "type", string("Concept")), + ("quoted scalar keeps its value", fenced, "okf_version", string("0.2")), + ("fence-free content", "type: Concept\n", "type", string("Concept")), + ( + "nested block mapping", + "generated:\n by: agent/1\n at: 2026-07-01T00:00:00Z\n", + "generated", + mapping(&[ + ("by", string("agent/1")), + ("at", string("2026-07-01T00:00:00Z")), + ]), + ), + ( + "block sequence of scalars", + "tags:\n - internal\n - public\n", + "tags", + YamlValue::Sequence(vec![string("internal"), string("public")]), + ), + ( + "block sequence of mappings", + "verified:\n - by: human:alice\n at: 2026-07-01T00:00:00Z\n - by: human:bob\n at: 2026-07-02T00:00:00Z\n", + "verified", + YamlValue::Sequence(vec![ + mapping(&[ + ("by", string("human:alice")), + ("at", string("2026-07-01T00:00:00Z")), + ]), + mapping(&[ + ("by", string("human:bob")), + ("at", string("2026-07-02T00:00:00Z")), + ]), + ]), + ), + ( + "nested mapping inside a sequence item", + "sources:\n - resource: https://example.invalid/a\n usage_count: 3\n", + "sources", + YamlValue::Sequence(vec![mapping(&[ + ("resource", string("https://example.invalid/a")), + ("usage_count", string("3")), + ])]), + ), + ( + "flow list with an inline comment", + flow_and_comments, + "tags", + YamlValue::Sequence(vec![string("a"), string("b")]), + ), + ( + "scalar on a commented line", + flow_and_comments, + "type", + string("Concept"), + ), + ( + "url scalar keeps its colons", + "resource: https://example.invalid/a#frag\n", + "resource", + string("https://example.invalid/a#frag"), + ), + ( + "sequence item with a colon stays scalar", + "sources:\n - https://example.invalid/b\n", + "sources", + YamlValue::Sequence(vec![string("https://example.invalid/b")]), + ), + ] { + let map = parse_frontmatter(input).unwrap_or_else(|error| panic!("{case}: {error}")); + assert_eq!(map.get(key), Some(&expected), "{case}"); + } + } + + #[test] + fn rejects_malformed_input_without_echoing_values() { + for (case, input) in [ + ("duplicate key", "type: A\ntype: B\n"), + ("unterminated flow sequence", "type: [\n"), + ("mapping key with no value", "generated:\n"), + ("scalar key with no value", "type:\n"), + ("flow mapping is unsupported", "type: {by: x}\n"), + ] { + assert!(parse_frontmatter(input).is_err(), "{case} must be rejected"); + } + + // A rejection must never echo the offending value back to the caller. + for input in [ + "type: [secret-value\n", + "generated: |secret-value\n", + "type: {secret-value\n", + ] { + let error = parse_frontmatter(input).expect_err("must be rejected"); + assert!( + !error.contains("secret-value"), + "error echoes raw value: {error}" + ); + } + } + + #[test] + fn empty_frontmatter_parses_to_empty_map() { + assert!(parse_frontmatter("---\n---\n").unwrap().is_empty()); + assert!(parse_frontmatter("").unwrap().is_empty()); + } +} diff --git a/crates/bran-core/src/lib.rs b/crates/bran-core/src/lib.rs index 2aa0d93..8272e0b 100644 --- a/crates/bran-core/src/lib.rs +++ b/crates/bran-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod boundary; pub mod bundle; pub mod derived_state; pub mod export; +pub mod frontmatter; pub mod graph; pub mod metadata; pub mod migration; @@ -20,7 +21,7 @@ pub mod view; // Profile validation exports (Slice 1.2 wiring only) pub use crate::profile::{ Diagnostic, ProfileOutcome, ProfileValidator, ValidationResult, ValidationStatus, BRAN_STRICT, - OKF_V0_1, + OKF_V0_1, OKF_V0_2, }; use std::collections::BTreeSet; diff --git a/crates/bran-core/src/profile.rs b/crates/bran-core/src/profile.rs index fd1c194..16d5f3a 100644 --- a/crates/bran-core/src/profile.rs +++ b/crates/bran-core/src/profile.rs @@ -15,6 +15,9 @@ use std::path::Path; /// Stable identifier for the OKF v0.1 compatibility profile. pub const OKF_V0_1: &str = "okf-v0.1"; +/// Stable identifier for the OKF v0.2 compatibility profile. +pub const OKF_V0_2: &str = "okf-v0.2"; + /// Stable identifier for the BRAN Strict readiness profile. pub const BRAN_STRICT: &str = "bran-strict"; @@ -41,11 +44,12 @@ pub struct ProfileOutcome { pub diagnostics: Vec, } -/// Dual-profile validation result. Both outcomes are always computed. +/// Triple-profile validation result. All three outcomes are always computed. /// Only `selected_profile` decides `selected_passed` and `exit_code`. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ValidationResult { pub okf_compatibility: ProfileOutcome, + pub okf_v0_2: ProfileOutcome, pub bran_strict: ProfileOutcome, pub selected_profile: String, /// Explicit selection failure, if the caller named an unsupported profile. @@ -59,6 +63,7 @@ impl ValidationResult { pub fn selected_passed(&self) -> bool { let outcome = match self.selected_profile.as_str() { OKF_V0_1 => &self.okf_compatibility, + OKF_V0_2 => &self.okf_v0_2, BRAN_STRICT => &self.bran_strict, _ => return false, }; @@ -102,7 +107,7 @@ impl ProfileValidator { Self::validate_with_policy(bundle, selected_profile, None) } - /// Validates the bundle for both profiles independently, driving BRAN strict + /// Validates the bundle for all three profiles independently, driving BRAN strict /// status/tags/public-boundary/frontmatter/source-links checks from `policy` /// when `Some`. When `None`, intrinsic BRAN strict shape checks still run /// with sensible defaults. @@ -112,13 +117,15 @@ impl ProfileValidator { policy: Option<&RepositoryPolicy>, ) -> ValidationResult { let okf = Self::validate_okf_compatibility(bundle, policy); + let okf_v0_2 = Self::validate_okf_v0_2(bundle, policy); let strict = Self::validate_bran_strict(bundle, policy); ValidationResult { okf_compatibility: okf, + okf_v0_2, bran_strict: strict, selected_profile: selected_profile.to_owned(), selected_profile_error: match selected_profile { - OKF_V0_1 | BRAN_STRICT => None, + OKF_V0_1 | OKF_V0_2 | BRAN_STRICT => None, _ => Some(Diagnostic { path: "".to_owned(), code: "unknown-profile".to_owned(), @@ -169,6 +176,73 @@ impl ProfileValidator { } } + /// OKF v0.2 compatibility: the permissive v0.1 conformance floor plus shape + /// validation of the optional v0.2 families when present. + /// + /// - The v0.1 floor is unchanged: parseable frontmatter plus a non-blank + /// string `type` on concept documents; reserved index.md/log.md keep + /// their structural checks. + /// - Bundle-root `index.md` may carry an `okf_version` frontmatter string. + /// Newer or unknown declared versions are consumed best-effort and never + /// rejected. + /// - Optional families are shape-validated only when present: `sources` / + /// `usage_window` (provenance), `generated` / `verified` (trust), + /// `status` / `stale_after` (lifecycle), and `runtime` / `parameters` / + /// `computation` / `executor` / `attester` for `type: Attested + /// Computation`. A bare `verified: { by, at }` mapping normalizes + /// identically to a one-element list. Missing families never fail + /// conformance. + /// - Unknown types, unknown fields, broken cross-links, and missing index + /// files are tolerated. BRAN producer extensions (`okf_status`, + /// `freshness`, `public_boundary`) stay valid and are never conflated + /// with upstream `status` (see [`upstream_status_to_okf_status`]). + fn validate_okf_v0_2(bundle: &Bundle, policy: Option<&RepositoryPolicy>) -> ProfileOutcome { + let mut diagnostics: Vec = Vec::new(); + let coverage = policy.and_then(|p| p.document_coverage.as_ref()); + + for (path, doc) in bundle.docs() { + if !portable_document_participates(path, coverage) { + continue; + } + match doc.kind() { + DocKind::Index => { + diagnostics.extend(okf_index_diagnostics(path, doc.body())); + if path == "index.md" { + diagnostics.extend(okf_v0_2_index_version_diagnostics( + path, + doc.frontmatter().parsed(), + )); + } + continue; + } + DocKind::Log => { + diagnostics.extend(okf_log_diagnostics(path, doc.body())); + continue; + } + DocKind::Concept { .. } => {} + } + let fm = doc.frontmatter(); + if let Some(diagnostic) = okf_diagnostic(path, fm.status(), fm.parsed()) { + diagnostics.push(diagnostic); + } + let Some(map) = fm.parsed() else { + continue; + }; + diagnostics.extend(okf_v0_2_family_diagnostics(path, map)); + } + + let status = if diagnostics.is_empty() { + ValidationStatus::Pass + } else { + ValidationStatus::Fail + }; + ProfileOutcome { + profile: OKF_V0_2.to_owned(), + status, + diagnostics, + } + } + fn validate_bran_strict(bundle: &Bundle, policy: Option<&RepositoryPolicy>) -> ProfileOutcome { let mut diagnostics: Vec = Vec::new(); @@ -810,6 +884,347 @@ fn okf_diagnostic( } } +/// Shape-validate the optional OKF v0.2 frontmatter families on one concept. +/// +/// Every family is optional: absence never fails conformance. Diagnostics are +/// deterministic and never echo raw values. +fn okf_v0_2_family_diagnostics(path: &str, map: &BTreeMap) -> Vec { + let mut diagnostics = Vec::new(); + + // --- provenance family: sources + usage_window --- + if let Some(value) = map.get("sources") { + match value.as_sequence() { + None => diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "sources-not-sequence".to_owned(), + message: "sources must be a sequence of entries".to_owned(), + }), + Some(entries) => { + for entry in entries { + let Some(fields) = entry.as_mapping() else { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "source-entry-not-mapping".to_owned(), + message: "each sources entry must be a mapping".to_owned(), + }); + continue; + }; + if !has_nonblank_string(fields, "resource") { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "source-entry-resource".to_owned(), + message: "each sources entry must carry a non-blank string resource" + .to_owned(), + }); + } + } + } + } + } + + if let Some(value) = map.get("usage_window") { + match value.as_mapping() { + None => diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "usage-window-not-mapping".to_owned(), + message: "usage_window must be a mapping".to_owned(), + }), + Some(fields) => { + if !has_nonblank_string(fields, "from") { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "usage-window-from".to_owned(), + message: "usage_window.from must be a non-blank string".to_owned(), + }); + } + if !has_nonblank_string(fields, "to") { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "usage-window-to".to_owned(), + message: "usage_window.to must be a non-blank string".to_owned(), + }); + } + } + } + } + + // --- trust family: generated + verified --- + if let Some(value) = map.get("generated") { + match value.as_mapping() { + None => diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "generated-not-mapping".to_owned(), + message: "generated must be a mapping".to_owned(), + }), + Some(fields) => { + if !has_nonblank_string(fields, "by") { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "generated-by".to_owned(), + message: "generated.by must be a non-blank string".to_owned(), + }); + } + if fields.contains_key("at") && !has_nonblank_string(fields, "at") { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "generated-at".to_owned(), + message: "generated.at must be a non-blank string when present".to_owned(), + }); + } + } + } + } + + if let Some(value) = map.get("verified") { + // A bare mapping normalizes to a one-element verification list. + let events: Vec<&BTreeMap> = match value { + YamlValue::Mapping(fields) => vec![fields], + YamlValue::Sequence(items) => { + let mut events = Vec::new(); + for item in items { + match item.as_mapping() { + Some(fields) => events.push(fields), + None => diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "verified-event-not-mapping".to_owned(), + message: "each verified event must be a mapping".to_owned(), + }), + } + } + events + } + _ => { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "verified-shape".to_owned(), + message: "verified must be a mapping or a sequence of verification events" + .to_owned(), + }); + Vec::new() + } + }; + for event in events { + if !has_nonblank_string(event, "by") { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "verified-by".to_owned(), + message: "each verified event must carry a non-blank string by".to_owned(), + }); + } + if !has_nonblank_string(event, "at") { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "verified-at".to_owned(), + message: "each verified event must carry a non-blank string at".to_owned(), + }); + } + } + } + + // --- lifecycle family: status + stale_after --- + if let Some(value) = map.get("status") { + let valid = + matches!(value, YamlValue::String(s) if upstream_status_to_okf_status(s).is_some()); + if !valid { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "status-value".to_owned(), + message: "status must be one of draft/stable/active/deprecated".to_owned(), + }); + } + } + + if let Some(value) = map.get("stale_after") { + let valid = matches!(value, YamlValue::String(s) if is_iso_date(s.trim())); + if !valid { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "stale-after-shape".to_owned(), + message: "stale_after must be a YYYY-MM-DD date".to_owned(), + }); + } + } + + // --- Attested Computation contract family --- + if map.get("type") == Some(&YamlValue::String("Attested Computation".to_owned())) { + if !has_nonblank_string(map, "runtime") { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "ac-runtime".to_owned(), + message: "type: Attested Computation requires a non-blank string runtime field" + .to_owned(), + }); + } + if let Some(value) = map.get("parameters") { + let valid = value.as_sequence().is_some_and(|items| { + items.iter().all(|item| { + item.as_mapping().is_some_and(|fields| { + has_nonblank_string(fields, "name") && has_nonblank_string(fields, "type") + }) + }) + }); + if !valid { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "ac-parameters".to_owned(), + message: "parameters must be a sequence of mappings with non-blank string name and type" + .to_owned(), + }); + } + } + if map.contains_key("computation") && !has_nonblank_string(map, "computation") { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "ac-computation".to_owned(), + message: "computation must be a non-blank string path when present".to_owned(), + }); + } + if let Some(value) = map.get("executor") { + let valid = value.as_mapping().is_some_and(|fields| { + if !has_nonblank_string(fields, "resource") { + return false; + } + match fields.get("receipt") { + Some(receipt) => receipt.as_sequence().is_some(), + None => true, + } + }); + if !valid { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "ac-executor".to_owned(), + message: "executor must be a mapping with a non-blank string resource and a sequence receipt" + .to_owned(), + }); + } + } + if let Some(value) = map.get("attester") { + let valid = value + .as_mapping() + .is_some_and(|fields| has_nonblank_string(fields, "resource")); + if !valid { + diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "ac-attester".to_owned(), + message: + "attester must be a mapping with a non-blank string resource when present" + .to_owned(), + }); + } + } + } + + diagnostics +} + +/// Bundle-root `index.md` may declare `okf_version` as a non-blank string. +/// Newer or unknown declared versions are consumed best-effort and never +/// rejected; only a non-string or blank declaration is a shape error. +fn okf_v0_2_index_version_diagnostics( + path: &str, + parsed: Option<&BTreeMap>, +) -> Vec { + let mut diagnostics = Vec::new(); + let Some(value) = parsed.and_then(|map| map.get("okf_version")) else { + return diagnostics; + }; + match value { + YamlValue::String(s) if !s.trim().is_empty() => {} + YamlValue::String(_) => diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "okf-version-blank".to_owned(), + message: "okf_version must be a non-blank string on the bundle-root index.md" + .to_owned(), + }), + _ => diagnostics.push(Diagnostic { + path: path.to_owned(), + code: "okf-version-non-string".to_owned(), + message: "okf_version must be a string on the bundle-root index.md".to_owned(), + }), + } + diagnostics +} + +/// Content-change time (OKF v0.2 §13.1 migration). +/// +/// Prefers `generated.at`; falls back to the legacy `timestamp` field when +/// `generated` is absent. Returns `None` when neither is present. +pub fn content_change_time(frontmatter: &BTreeMap) -> Option<&str> { + if let Some(YamlValue::Mapping(fields)) = frontmatter.get("generated") { + if let Some(YamlValue::String(at)) = fields.get("at") { + if !at.trim().is_empty() { + return Some(at); + } + } + } + match frontmatter.get("timestamp") { + Some(YamlValue::String(value)) if !value.trim().is_empty() => Some(value), + _ => None, + } +} + +/// Provenance (OKF v0.2 §13.1 migration). +/// +/// Prefers frontmatter `sources`; falls back to the legacy body `# Citations` +/// list for v0.1 documents. `sources` entries contribute their `resource` +/// when well-shaped; malformed entries are skipped. Returns an empty vector +/// when neither source of provenance is present. +pub fn provenance_resources(frontmatter: &BTreeMap, body: &str) -> Vec { + if let Some(YamlValue::Sequence(entries)) = frontmatter.get("sources") { + let mut resources = Vec::new(); + for entry in entries { + if let YamlValue::Mapping(fields) = entry { + if let Some(YamlValue::String(resource)) = fields.get("resource") { + if !resource.trim().is_empty() { + resources.push(resource.clone()); + } + } + } + } + return resources; + } + legacy_citation_targets(body) +} + +/// Legacy provenance: markdown link targets inside the `# Citations` section. +fn legacy_citation_targets(body: &str) -> Vec { + let citations = body.find("# Citations").map_or("", |start| &body[start..]); + let mut targets = Vec::new(); + let bytes = citations.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b']' && bytes[i + 1] == b'(' { + let start = i + 2; + let mut end = start; + while end < bytes.len() && bytes[end] != b')' { + end += 1; + } + let target = std::str::from_utf8(&bytes[start..end]).unwrap_or(""); + if !target.trim().is_empty() { + targets.push(target.to_owned()); + } + i = end; + } + i += 1; + } + targets +} + +/// Explicit upstream `status` to BRAN `okf_status` mapping. +/// +/// `draft` -> `draft`, `active` -> `stable`, `deprecated` -> `deprecated`; +/// `stable` maps to itself. Returns `None` for unknown values. Upstream +/// `status` is never silently conflated with `okf_status`: BRAN strict still +/// requires the `okf_status` field independently. +pub fn upstream_status_to_okf_status(status: &str) -> Option<&'static str> { + match status { + "draft" => Some("draft"), + "active" | "stable" => Some("stable"), + "deprecated" => Some("deprecated"), + _ => None, + } +} + // Google Knowledge Catalog OKF SPEC.md §§3.1, 6, 7, 9, 11: // https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md // Reserved index.md files are headed link indexes; log.md files are flat, @@ -3978,4 +4393,485 @@ mod tests { "permuted bundle must produce identical ordered diagnostics" ); } + + // =================================================================== + // OKF v0.2 profile tests (issue alphazede/bran#1) + // =================================================================== + + /// Build a concept document from a full fixture source (fenced frontmatter + /// followed by a body). The frontmatter is re-parsed with the structural + /// parser, mirroring how the CLI constructs bundles from scanned sources. + fn v0_2_document(path: &str, source: &str) -> Doc { + let body_start = source.find("\n---\n").map(|pos| pos + 5).unwrap_or(0); + let raw = &source[..body_start]; + let body = &source[body_start..]; + let fields = crate::frontmatter::parse_frontmatter(raw) + .expect("fixture frontmatter must be structurally parseable"); + Doc::new( + path, + source.to_owned(), + body, + Frontmatter::from_parsed(raw, fields), + ) + } + + fn v0_2_codes(result: &ValidationResult, profile: &str) -> Vec { + let outcome = match profile { + OKF_V0_1 => &result.okf_compatibility, + OKF_V0_2 => &result.okf_v0_2, + BRAN_STRICT => &result.bran_strict, + _ => panic!("unknown profile {profile}"), + }; + let mut codes: Vec = outcome.diagnostics.iter().map(|d| d.code.clone()).collect(); + codes.sort(); + codes + } + + #[test] + fn okf_v0_2_profile_selection_and_floor() { + let minimal = Bundle::from_documents([v0_2_document( + "concepts/minimal.md", + include_str!("../../../fixtures/conformance/okf-v0.2-minimal.fixture"), + )]) + .expect("minimal v0.2 bundle"); + let result = ProfileValidator::validate(&minimal, OKF_V0_2); + + // All three outcomes are always computed; selection governs exit. + assert_eq!(result.okf_compatibility.profile, OKF_V0_1); + assert_eq!(result.okf_v0_2.profile, OKF_V0_2); + assert_eq!(result.bran_strict.profile, BRAN_STRICT); + assert_eq!(result.okf_compatibility.status, ValidationStatus::Pass); + assert_eq!(result.okf_v0_2.status, ValidationStatus::Pass); + assert_eq!(result.selected_profile, OKF_V0_2); + assert!(result.selected_passed()); + assert_eq!(result.exit_code(), 0); + + // A v0.1 floor violation (unparseable frontmatter) fails v0.2 too. + let malformed_source = "---\ntype: [\n---\nBody.\n"; + let malformed = Bundle::from_documents([Doc::new( + "concepts/malformed.md", + malformed_source, + "Body.\n", + Frontmatter::malformed(malformed_source, "invalid yaml"), + )]) + .expect("malformed bundle"); + let malformed_result = ProfileValidator::validate(&malformed, OKF_V0_2); + assert_eq!(malformed_result.okf_v0_2.status, ValidationStatus::Fail); + assert_eq!( + malformed_result.okf_v0_2.diagnostics[0].code, + "malformed-frontmatter" + ); + assert!(!malformed_result.selected_passed()); + assert_eq!(malformed_result.exit_code(), 1); + } + + #[test] + fn okf_v0_2_optional_families_pass() { + let bundle = Bundle::from_documents([ + v0_2_document( + "concepts/sources.md", + include_str!("../../../fixtures/conformance/okf-v0.2-sources.fixture"), + ), + v0_2_document( + "concepts/trust.md", + include_str!("../../../fixtures/conformance/okf-v0.2-trust-list.fixture"), + ), + v0_2_document( + "concepts/verified-bare.md", + include_str!("../../../fixtures/conformance/okf-v0.2-verified-bare.fixture"), + ), + v0_2_document( + "concepts/lifecycle.md", + include_str!("../../../fixtures/conformance/okf-v0.2-lifecycle.fixture"), + ), + v0_2_document( + "concepts/computation.md", + include_str!("../../../fixtures/conformance/okf-v0.2-attested-computation.fixture"), + ), + v0_2_document( + "concepts/unknown.md", + include_str!("../../../fixtures/conformance/okf-v0.2-unknown-tolerated.fixture"), + ), + ]) + .expect("v0.2 families bundle"); + let result = ProfileValidator::validate(&bundle, OKF_V0_2); + assert_eq!( + result.okf_v0_2.status, + ValidationStatus::Pass, + "all optional families well-shaped must pass, got {:?}", + result.okf_v0_2.diagnostics + ); + assert!(result.selected_passed()); + } + + #[test] + fn okf_v0_2_malformed_families_fail_with_explicit_codes() { + let bundle = Bundle::from_documents([v0_2_document( + "concepts/malformed.md", + include_str!("../../../fixtures/conformance/okf-v0.2-malformed.fixture"), + )]) + .expect("malformed families bundle"); + let result = ProfileValidator::validate(&bundle, OKF_V0_2); + assert_eq!(result.okf_v0_2.status, ValidationStatus::Fail); + assert_eq!( + v0_2_codes(&result, OKF_V0_2), + vec![ + "generated-not-mapping".to_owned(), + "sources-not-sequence".to_owned(), + "stale-after-shape".to_owned(), + "status-value".to_owned(), + "usage-window-to".to_owned(), + "verified-event-not-mapping".to_owned(), + ] + ); + } + + #[test] + fn okf_v0_2_verified_bare_mapping_equals_one_element_list() { + let bare = Bundle::from_documents([v0_2_document( + "concepts/bare.md", + include_str!("../../../fixtures/conformance/okf-v0.2-verified-bare.fixture"), + )]) + .expect("bare verified bundle"); + let listed = Bundle::from_documents([v0_2_document( + "concepts/listed.md", + "---\ntype: Concept\nverified:\n - by: human:alice\n at: 2026-07-02T00:00:00Z\n---\nList form.\n", + )]) + .expect("listed verified bundle"); + let bare_result = ProfileValidator::validate(&bare, OKF_V0_2); + let listed_result = ProfileValidator::validate(&listed, OKF_V0_2); + assert_eq!( + bare_result.okf_v0_2, listed_result.okf_v0_2, + "bare verified mapping and one-element list must normalize identically" + ); + assert_eq!(bare_result.okf_v0_2.status, ValidationStatus::Pass); + } + + #[test] + fn okf_v0_2_index_version_cases() { + // Bundle-root index.md may declare any non-blank string version. + let declared = Bundle::from_documents([ + v0_2_document( + "index.md", + include_str!("../../../fixtures/conformance/okf-v0.2-index-version.fixture"), + ), + v0_2_document( + "concepts/concept.md", + include_str!("../../../fixtures/conformance/okf-v0.2-minimal.fixture"), + ), + ]) + .expect("versioned index bundle"); + let declared_result = ProfileValidator::validate(&declared, OKF_V0_2); + assert_eq!(declared_result.okf_v0_2.status, ValidationStatus::Pass); + + // Newer/unknown declared versions are consumed best-effort, never rejected. + let newer = Bundle::from_documents([ + v0_2_document( + "index.md", + "---\nokf_version: \"9.9\"\n---\n# Root\n\n- [Concept](concepts/concept.md)\n\n## Concepts\n\n- [Concept](concepts/concept.md)\n", + ), + v0_2_document( + "concepts/concept.md", + include_str!("../../../fixtures/conformance/okf-v0.2-minimal.fixture"), + ), + ]) + .expect("newer version index bundle"); + let newer_result = ProfileValidator::validate(&newer, OKF_V0_2); + assert_eq!(newer_result.okf_v0_2.status, ValidationStatus::Pass); + + // Blank and non-string declarations are shape errors. A blank string + // is not structurally parseable, so it is exercised through a + // programmatically built parsed map (the shape check is defensive). + let blank_source = + "---\nokf_version: \"\"\n---\n# Root\n\n- [Concept](concepts/concept.md)\n\n## Concepts\n\n- [Concept](concepts/concept.md)\n"; + let blank_body = "# Root\n\n- [Concept](concepts/concept.md)\n\n## Concepts\n\n- [Concept](concepts/concept.md)\n"; + let mut blank_fields = BTreeMap::new(); + blank_fields.insert("okf_version".to_owned(), YamlValue::String(String::new())); + let blank = Bundle::from_documents([Doc::new( + "index.md", + blank_source, + blank_body, + Frontmatter::from_parsed(blank_source, blank_fields), + )]) + .expect("blank version index bundle"); + let blank_result = ProfileValidator::validate(&blank, OKF_V0_2); + assert_eq!( + v0_2_codes(&blank_result, OKF_V0_2), + vec!["okf-version-blank".to_owned()] + ); + + let non_string = Bundle::from_documents([v0_2_document( + "index.md", + "---\nokf_version: [1, 2]\n---\n# Root\n\n- [Concept](concepts/concept.md)\n\n## Concepts\n\n- [Concept](concepts/concept.md)\n", + )]) + .expect("non-string version index bundle"); + let non_string_result = ProfileValidator::validate(&non_string, OKF_V0_2); + assert_eq!( + v0_2_codes(&non_string_result, OKF_V0_2), + vec!["okf-version-non-string".to_owned()] + ); + + // The version check applies to the bundle-root index.md only. + let nested = Bundle::from_documents([ + v0_2_document( + "nested/index.md", + "---\nokf_version: \"0.2\"\n---\n# Nested\n\n- [Concept](concept.md)\n\n## Concepts\n\n- [Concept](concept.md)\n", + ), + v0_2_document( + "concepts/concept.md", + include_str!("../../../fixtures/conformance/okf-v0.2-minimal.fixture"), + ), + ]) + .expect("nested version index bundle"); + let nested_result = ProfileValidator::validate(&nested, OKF_V0_2); + assert_eq!(nested_result.okf_v0_2.status, ValidationStatus::Pass); + } + + #[test] + fn okf_v0_2_status_value_and_producer_extensions() { + // Every mapped upstream status passes. + for status in ["draft", "stable", "active", "deprecated"] { + let bundle = Bundle::from_documents([v0_2_document( + "concepts/status.md", + &format!("---\ntype: Concept\nstatus: {status}\n---\nBody.\n"), + )]) + .expect("status bundle"); + let result = ProfileValidator::validate(&bundle, OKF_V0_2); + assert_eq!( + result.okf_v0_2.status, + ValidationStatus::Pass, + "status {status} must pass" + ); + } + + // Unknown status fails with the explicit code. + let unknown = Bundle::from_documents([v0_2_document( + "concepts/status.md", + "---\ntype: Concept\nstatus: retired\n---\nBody.\n", + )]) + .expect("unknown status bundle"); + let unknown_result = ProfileValidator::validate(&unknown, OKF_V0_2); + assert_eq!( + v0_2_codes(&unknown_result, OKF_V0_2), + vec!["status-value".to_owned()] + ); + + // BRAN producer extensions stay valid and are never conflated with + // upstream status: okf_status is not checked by the v0.2 profile, and + // an upstream status never satisfies BRAN strict's okf_status field. + let extensions = Bundle::from_documents([v0_2_document( + "concepts/ext.md", + "---\ntype: Concept\nstatus: draft\nokf_status: active\nfreshness: 2026-07-01\npublic_boundary: private\n---\nBody.\n", + )]) + .expect("extension bundle"); + let extensions_result = ProfileValidator::validate(&extensions, OKF_V0_2); + assert_eq!(extensions_result.okf_v0_2.status, ValidationStatus::Pass); + + let conflation_guard = Bundle::from_documents([v0_2_document( + "concepts/guard.md", + "---\ntype: Concept\nstatus: active\n---\nBody.\n", + )]) + .expect("conflation guard bundle"); + let guard_result = ProfileValidator::validate(&conflation_guard, BRAN_STRICT); + let guard_codes: Vec<_> = guard_result + .bran_strict + .diagnostics + .iter() + .map(|d| d.code.as_str()) + .collect(); + assert!( + guard_codes.contains(&"status"), + "upstream status must not satisfy BRAN strict okf_status, got {guard_codes:?}" + ); + } + + #[test] + fn okf_v0_2_attested_computation_cases() { + // Each row is (case, frontmatter source, expected v0.2 codes). + // An empty expected list means the document must pass. + for (case, source, expected) in [ + ( + "missing runtime", + "---\ntype: Attested Computation\nparameters:\n - name: seed\n type: integer\n---\nBody.\n", + &["ac-runtime"][..], + ), + ( + "scalar parameters", + "---\ntype: Attested Computation\nruntime: python3\nparameters: scalar\n---\nBody.\n", + &["ac-parameters"][..], + ), + ( + "parameter entry missing type", + "---\ntype: Attested Computation\nruntime: python3\nparameters:\n - name: seed\n---\nBody.\n", + &["ac-parameters"][..], + ), + ( + "non-string computation", + "---\ntype: Attested Computation\nruntime: python3\ncomputation: [a, b]\n---\nBody.\n", + &["ac-computation"][..], + ), + ( + "scalar executor", + "---\ntype: Attested Computation\nruntime: python3\nexecutor: exec/1\n---\nBody.\n", + &["ac-executor"][..], + ), + ( + "executor without resource", + "---\ntype: Attested Computation\nruntime: python3\nexecutor:\n receipt:\n - sha256: deadbeef\n---\nBody.\n", + &["ac-executor"][..], + ), + ( + "scalar receipt", + "---\ntype: Attested Computation\nruntime: python3\nexecutor:\n resource: https://example.invalid/exec\n receipt: deadbeef\n---\nBody.\n", + &["ac-executor"][..], + ), + ( + "scalar attester", + "---\ntype: Attested Computation\nruntime: python3\nattester: att/1\n---\nBody.\n", + &["ac-attester"][..], + ), + ( + "executor with resource and no receipt passes", + "---\ntype: Attested Computation\nruntime: python3\nexecutor:\n resource: https://example.invalid/exec\n---\nBody.\n", + &[][..], + ), + ] { + let bundle = Bundle::from_documents([v0_2_document("concepts/ac.md", source)]) + .unwrap_or_else(|error| panic!("{case}: {error:?}")); + let result = ProfileValidator::validate(&bundle, OKF_V0_2); + let expected_codes: Vec = + expected.iter().map(|code| (*code).to_owned()).collect(); + assert_eq!(v0_2_codes(&result, OKF_V0_2), expected_codes, "{case}"); + } + } + + #[test] + fn okf_v0_2_content_change_time_migration() { + let mut frontmatter = BTreeMap::new(); + assert_eq!(content_change_time(&frontmatter), None); + + frontmatter.insert( + "generated".to_owned(), + YamlValue::Mapping(BTreeMap::from([ + ("by".to_owned(), YamlValue::String("agent/1".to_owned())), + ( + "at".to_owned(), + YamlValue::String("2026-07-01T00:00:00Z".to_owned()), + ), + ])), + ); + frontmatter.insert( + "timestamp".to_owned(), + YamlValue::String("2026-01-01".to_owned()), + ); + assert_eq!( + content_change_time(&frontmatter), + Some("2026-07-01T00:00:00Z"), + "generated.at must win over legacy timestamp" + ); + + frontmatter.remove("generated"); + assert_eq!( + content_change_time(&frontmatter), + Some("2026-01-01"), + "legacy timestamp must be the fallback" + ); + + frontmatter.insert( + "generated".to_owned(), + YamlValue::Mapping(BTreeMap::from([( + "by".to_owned(), + YamlValue::String("agent/1".to_owned()), + )])), + ); + assert_eq!( + content_change_time(&frontmatter), + Some("2026-01-01"), + "generated without at falls back to legacy timestamp" + ); + } + + #[test] + fn okf_v0_2_provenance_migration() { + // Frontmatter sources win; only well-shaped entries contribute. + let mut frontmatter = BTreeMap::new(); + frontmatter.insert( + "sources".to_owned(), + YamlValue::Sequence(vec![ + YamlValue::Mapping(BTreeMap::from([ + ( + "resource".to_owned(), + YamlValue::String("https://example.invalid/r1".to_owned()), + ), + ("title".to_owned(), YamlValue::String("One".to_owned())), + ])), + YamlValue::String("https://example.invalid/skip".to_owned()), + ]), + ); + let body = "# Citations\n- [Alpha](https://example.invalid/legacy)\n"; + assert_eq!( + provenance_resources(&frontmatter, body), + vec!["https://example.invalid/r1".to_owned()] + ); + + // Without sources, the legacy # Citations list is the fallback. + frontmatter.remove("sources"); + assert_eq!( + provenance_resources(&frontmatter, body), + vec!["https://example.invalid/legacy".to_owned(),] + ); + + // No sources, no citations section, no provenance. + assert!(provenance_resources(&frontmatter, "Body only.\n").is_empty()); + } + + #[test] + fn okf_v0_2_upstream_status_mapping() { + assert_eq!(upstream_status_to_okf_status("draft"), Some("draft")); + assert_eq!(upstream_status_to_okf_status("active"), Some("stable")); + assert_eq!(upstream_status_to_okf_status("stable"), Some("stable")); + assert_eq!( + upstream_status_to_okf_status("deprecated"), + Some("deprecated") + ); + assert_eq!(upstream_status_to_okf_status("retired"), None); + assert_eq!(upstream_status_to_okf_status(""), None); + } + + #[test] + fn okf_v0_2_v0_1_floor_regression() { + // The v0.1 reserved-document structural checks are unchanged under a + // v0.2 selection: the invalid index/log fixtures fail with the exact + // v0.1 codes. + let invalid = Bundle::from_documents([ + v0_2_document( + "index.md", + &format!( + "---\ntype: Concept\n---\n{}", + include_str!("../../../fixtures/conformance/okf-v0.1-index-invalid.fixture") + ), + ), + v0_2_document( + "log.md", + &format!( + "---\ntype: Concept\n---\n{}", + include_str!("../../../fixtures/conformance/okf-v0.1-log-invalid.fixture") + ), + ), + ]) + .expect("invalid v0.1 floor bundle"); + let result = ProfileValidator::validate(&invalid, OKF_V0_2); + assert_eq!(result.okf_v0_2.status, ValidationStatus::Fail); + assert_eq!( + v0_2_codes(&result, OKF_V0_2), + vec![ + "okf-index-empty-section".to_owned(), + "okf-index-link-before-heading".to_owned(), + "okf-log-date-order".to_owned(), + "okf-log-entry-before-date".to_owned(), + "okf-log-invalid-date-heading".to_owned(), + "okf-log-invalid-date-heading".to_owned(), + ] + ); + } } diff --git a/docs/integrations/agent-setup.md b/docs/integrations/agent-setup.md index 3c6b118..f2ea515 100644 --- a/docs/integrations/agent-setup.md +++ b/docs/integrations/agent-setup.md @@ -83,6 +83,36 @@ token counts as estimates. ```sh bran get ``` + +### Grounded result contract + +The external host remains provider-neutral. For a grounded `bran -p` request, +its result frame must emit aligned repeated values for `claim_id`, `claim_text`, +`claim_material`, `claim_locator`, `claim_content_digest`, and `claim_support`. +Every claim is material, `claim_text` and `claim_support` must be identical exact +text or a symbol copied from the current cited file, and `answer` must contain +the ordered claim texts separated only by newlines. `claim_locator` must also be +present as a normal `citation`, while `claim_content_digest` must echo the +SHA-256 supplied in BRAN's bounded packet. `claim_support` must be at least 12 +bytes after trimming; shorter spans are rejected before verification. + +Before storing a result, BRAN reopens every uniquely cited regular file at most +once, rejects symlinked or escaping paths, recomputes its SHA-256, and checks the +exact support bytes. Missing claims, invented symbols, stale files or digests, +unattested execution identity, degenerate support spans, and answer/claim +mismatches fail closed as an incomplete receipt. Claim verification adds bounded +local file I/O; it does not make another model call. Ungrounded provider calls +may omit the claim fields. + +What this contract does and does not prove. Support is verified by exact +substring existence against the current file, with no uniqueness or position +requirement beyond the minimum length. A validated claim therefore proves that +the quoted span **exists verbatim in the cited file at the digest BRAN +supplied** — that is, the claim is not fabricated and not stale. It does not +prove that the span is the *relevant* occurrence, nor that it answers the +question asked. Treat a grounded result as evidence against fabrication, not as +a correctness or attribution guarantee. + 4. Disable Connected Agent in TUI settings, or prove the same boundary directly: ```sh diff --git a/fixtures/conformance/okf-v0.2-attested-computation.fixture b/fixtures/conformance/okf-v0.2-attested-computation.fixture new file mode 100644 index 0000000..1f69b50 --- /dev/null +++ b/fixtures/conformance/okf-v0.2-attested-computation.fixture @@ -0,0 +1,19 @@ +--- +type: Attested Computation +runtime: python3 +parameters: + - name: seed + type: integer + - name: corpus + type: path + required: true +computation: scripts/rank.py +executor: + resource: https://example.invalid/exec + receipt: + - sha256: deadbeef + - pid: 42 +attester: + resource: https://example.invalid/att +--- +Attested computation contract family present. diff --git a/fixtures/conformance/okf-v0.2-index-version.fixture b/fixtures/conformance/okf-v0.2-index-version.fixture new file mode 100644 index 0000000..8999d67 --- /dev/null +++ b/fixtures/conformance/okf-v0.2-index-version.fixture @@ -0,0 +1,10 @@ +--- +okf_version: "0.2" +--- +# Root + +- [Nested index](missing/nested-index.md) + +## Concepts + +- [Concept](missing/concept.md) diff --git a/fixtures/conformance/okf-v0.2-legacy-fallback.fixture b/fixtures/conformance/okf-v0.2-legacy-fallback.fixture new file mode 100644 index 0000000..8d8f420 --- /dev/null +++ b/fixtures/conformance/okf-v0.2-legacy-fallback.fixture @@ -0,0 +1,9 @@ +--- +type: Concept +timestamp: 2026-01-01 +--- +Legacy v0.1-shaped document with body citations. + +# Citations +- [Alpha](https://example.invalid/a) +- [Beta](https://example.invalid/b) diff --git a/fixtures/conformance/okf-v0.2-lifecycle.fixture b/fixtures/conformance/okf-v0.2-lifecycle.fixture new file mode 100644 index 0000000..a3191ff --- /dev/null +++ b/fixtures/conformance/okf-v0.2-lifecycle.fixture @@ -0,0 +1,6 @@ +--- +type: Concept +status: deprecated +stale_after: 2026-12-31 +--- +Lifecycle family present. diff --git a/fixtures/conformance/okf-v0.2-malformed.fixture b/fixtures/conformance/okf-v0.2-malformed.fixture new file mode 100644 index 0000000..296f15c --- /dev/null +++ b/fixtures/conformance/okf-v0.2-malformed.fixture @@ -0,0 +1,12 @@ +--- +type: Concept +sources: not-a-sequence +usage_window: + from: 2026-01-01 +generated: scalar +verified: + - 2026-07-01 +status: retired +stale_after: tomorrow +--- +Badly shaped optional families. diff --git a/fixtures/conformance/okf-v0.2-minimal.fixture b/fixtures/conformance/okf-v0.2-minimal.fixture new file mode 100644 index 0000000..04e0f33 --- /dev/null +++ b/fixtures/conformance/okf-v0.2-minimal.fixture @@ -0,0 +1,4 @@ +--- +type: Concept +--- +Minimal concept with no optional v0.2 families. diff --git a/fixtures/conformance/okf-v0.2-sources.fixture b/fixtures/conformance/okf-v0.2-sources.fixture new file mode 100644 index 0000000..92b5062 --- /dev/null +++ b/fixtures/conformance/okf-v0.2-sources.fixture @@ -0,0 +1,12 @@ +--- +type: Concept +sources: + - resource: https://example.invalid/r1 + title: Research one + usage_count: 4 + - resource: https://example.invalid/r2 +usage_window: + from: 2026-01-01 + to: 2026-12-31 +--- +Provenance family present and well shaped. diff --git a/fixtures/conformance/okf-v0.2-trust-list.fixture b/fixtures/conformance/okf-v0.2-trust-list.fixture new file mode 100644 index 0000000..d47c45e --- /dev/null +++ b/fixtures/conformance/okf-v0.2-trust-list.fixture @@ -0,0 +1,12 @@ +--- +type: Concept +generated: + by: agent/1 + at: 2026-07-01T00:00:00Z +verified: + - by: human:alice + at: 2026-07-02T00:00:00Z + - by: human:bob + at: 2026-07-03T00:00:00Z +--- +Trust family as a verification list. diff --git a/fixtures/conformance/okf-v0.2-unknown-tolerated.fixture b/fixtures/conformance/okf-v0.2-unknown-tolerated.fixture new file mode 100644 index 0000000..abd4695 --- /dev/null +++ b/fixtures/conformance/okf-v0.2-unknown-tolerated.fixture @@ -0,0 +1,7 @@ +--- +type: Unknown Type +frobnicate: 42 +--- +Unknown types and unknown fields are tolerated. + +- [Broken link](missing.md) diff --git a/fixtures/conformance/okf-v0.2-verified-bare.fixture b/fixtures/conformance/okf-v0.2-verified-bare.fixture new file mode 100644 index 0000000..505fc6e --- /dev/null +++ b/fixtures/conformance/okf-v0.2-verified-bare.fixture @@ -0,0 +1,7 @@ +--- +type: Concept +verified: + by: human:alice + at: 2026-07-02T00:00:00Z +--- +Bare verified mapping normalizes to a one-element list. diff --git a/schemas/bran-profile-result.schema.json b/schemas/bran-profile-result.schema.json index cc54225..8e4c93c 100644 --- a/schemas/bran-profile-result.schema.json +++ b/schemas/bran-profile-result.schema.json @@ -1,13 +1,14 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://schemas.alphazede.dev/bran/profile-result/schema.json", - "title": "BRAN dual-profile validation result", - "description": "This schema is the planned future wire projection of ValidationResult. Both profile outcomes are exposed. selected_profile alone controls selected_passed and exit_code, which are projected/computed from ValidationResult methods (selected_passed(), exit_code()) rather than stored Rust fields. Link targets remain unvalidated.", + "title": "BRAN triple-profile validation result", + "description": "This schema is the planned future wire projection of ValidationResult. All three profile outcomes are exposed. selected_profile alone controls selected_passed and exit_code, which are projected/computed from ValidationResult methods (selected_passed(), exit_code()) rather than stored Rust fields. Link targets remain unvalidated.", "type": "object", "additionalProperties": true, - "required": ["okf_compatibility", "bran_strict", "selected_profile", "selected_passed", "exit_code", "selected_profile_error"], + "required": ["okf_compatibility", "okf_v0_2", "bran_strict", "selected_profile", "selected_passed", "exit_code", "selected_profile_error"], "properties": { "okf_compatibility": { "$ref": "#/$defs/okfOutcome" }, + "okf_v0_2": { "$ref": "#/$defs/v0_2Outcome" }, "bran_strict": { "$ref": "#/$defs/strictOutcome" }, "selected_profile": { "type": "string" }, "selected_passed": { "type": "boolean" }, @@ -26,6 +27,12 @@ { "properties": { "profile": { "const": "okf-v0.1" } } } ] }, + "v0_2Outcome": { + "allOf": [ + { "$ref": "#/$defs/profileOutcome" }, + { "properties": { "profile": { "const": "okf-v0.2" } } } + ] + }, "strictOutcome": { "allOf": [ { "$ref": "#/$defs/profileOutcome" }, diff --git a/schemas/okf-v0.2-normalized-bundle.schema.json b/schemas/okf-v0.2-normalized-bundle.schema.json new file mode 100644 index 0000000..2521daf --- /dev/null +++ b/schemas/okf-v0.2-normalized-bundle.schema.json @@ -0,0 +1,218 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.alphazede.dev/bran/okf-v0.2-normalized-bundle/schema.json", + "title": "BRAN normalized OKF v0.2 bundle evidence", + "description": "Normalized evidence for the okf-v0.2 compatibility profile. Builds on the v0.1 normalized shape (schema_version 2) and adds the optional OKF v0.2 family projections: provenance (sources, usage_window), trust (generated, verified), lifecycle (status, stale_after), and the Attested Computation contract fields. Every family is optional: absence is conformance-valid. This schema preserves producer extensions and does not resolve Markdown links or parse source Markdown/YAML.", + "type": "object", + "additionalProperties": true, + "required": ["schema_version", "docs"], + "properties": { + "schema_version": { "const": "2" }, + "docs": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/document" } + } + }, + "$defs": { + "document": { + "type": "object", + "additionalProperties": true, + "required": ["path", "source", "body", "frontmatter"], + "properties": { + "path": { "type": "string" }, + "source": { "type": "string" }, + "body": { "type": "string" }, + "frontmatter": { "$ref": "#/$defs/frontmatter" }, + "sources": { "$ref": "#/$defs/sources" }, + "usage_window": { "$ref": "#/$defs/usageWindow" }, + "generated": { "$ref": "#/$defs/generated" }, + "verified": { "$ref": "#/$defs/verified" }, + "status": { "$ref": "#/$defs/status" }, + "stale_after": { "$ref": "#/$defs/staleAfter" }, + "runtime": { "type": "string" }, + "parameters": { "$ref": "#/$defs/parameters" }, + "computation": { "type": "string" }, + "executor": { "$ref": "#/$defs/executor" }, + "attester": { "$ref": "#/$defs/attester" } + } + }, + "frontmatter": { + "type": "object", + "additionalProperties": true, + "required": ["raw", "parsed", "status"], + "properties": { + "raw": { "type": "string" }, + "parsed": { + "oneOf": [ + { "type": "null" }, + { "type": "object", "additionalProperties": { "$ref": "#/$defs/yamlValue" } } + ] + }, + "status": { + "oneOf": [ + { "const": "ok" }, + { + "type": "object", + "additionalProperties": true, + "required": ["malformed"], + "properties": { "malformed": { "type": "string" } } + } + ] + } + } + }, + "sources": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["resource"], + "properties": { + "resource": { "type": "string" }, + "id": { "type": "string" }, + "title": { "type": "string" }, + "author": { "type": "string" }, + "usage_count": { "type": "integer" }, + "last_modified": { "type": "string" } + } + } + }, + "usageWindow": { + "type": "object", + "additionalProperties": true, + "required": ["from", "to"], + "properties": { + "from": { "type": "string" }, + "to": { "type": "string" } + } + }, + "generated": { + "type": "object", + "additionalProperties": true, + "required": ["by"], + "properties": { + "by": { "type": "string" }, + "at": { "type": "string" } + } + }, + "verified": { + "oneOf": [ + { "$ref": "#/$defs/verificationEvent" }, + { + "type": "array", + "items": { "$ref": "#/$defs/verificationEvent" } + } + ] + }, + "verificationEvent": { + "type": "object", + "additionalProperties": true, + "required": ["by", "at"], + "properties": { + "by": { "type": "string" }, + "at": { "type": "string" } + } + }, + "status": { + "enum": ["draft", "stable", "active", "deprecated"] + }, + "staleAfter": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "parameters": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["name", "type"], + "properties": { + "name": { "type": "string" }, + "type": { "type": "string" }, + "required": { "type": "boolean" } + } + } + }, + "executor": { + "type": "object", + "additionalProperties": true, + "required": ["resource"], + "properties": { + "resource": { "type": "string" }, + "receipt": { "type": "array", "items": { "type": "object" } } + } + }, + "attester": { + "type": "object", + "additionalProperties": true, + "required": ["resource"], + "properties": { + "resource": { "type": "string" } + } + }, + "yamlValue": { + "oneOf": [ + { + "type": "object", + "required": ["__yaml_type__", "__yaml_value__"], + "properties": { + "__yaml_type__": { "const": "null" }, + "__yaml_value__": { "type": "null" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["__yaml_type__", "__yaml_value__"], + "properties": { + "__yaml_type__": { "const": "bool" }, + "__yaml_value__": { "type": "boolean" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["__yaml_type__", "__yaml_value__"], + "properties": { + "__yaml_type__": { "const": "number" }, + "__yaml_value__": { "type": "string" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["__yaml_type__", "__yaml_value__"], + "properties": { + "__yaml_type__": { "const": "string" }, + "__yaml_value__": { "type": "string" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["__yaml_type__", "__yaml_value__"], + "properties": { + "__yaml_type__": { "const": "sequence" }, + "__yaml_value__": { + "type": "array", + "items": { "$ref": "#/$defs/yamlValue" } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["__yaml_type__", "__yaml_value__"], + "properties": { + "__yaml_type__": { "const": "mapping" }, + "__yaml_value__": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/yamlValue" } + } + }, + "additionalProperties": false + } + ] + } + } +} diff --git a/tools/ci/public_boundary_check.py b/tools/ci/public_boundary_check.py index aab9aa2..c558cfb 100644 --- a/tools/ci/public_boundary_check.py +++ b/tools/ci/public_boundary_check.py @@ -4,6 +4,7 @@ from __future__ import annotations import hashlib +import json import os import stat import subprocess @@ -21,6 +22,104 @@ ), } +# Home-directory roots, assembled from fragments so this source file does not itself +# contain a literal user-home path. The checker scans itself; a literal here would make +# it report its own source. +HOME_ROOTS: tuple[tuple[str, str], ...] = ( + ("/ho" + "me/", "/"), + ("/Us" + "ers/", "/"), + ("C:\\Us" + "ers\\", "\\"), +) + +# A home path may appear in public source only when the user segment is an obvious +# placeholder. Any other segment is a real local layout and must not ship publicly. +PLACEHOLDER_HOME_USERS = frozenset( + { + "user", + "users", + "username", + "example-user", + "home-user", + "someone", + "anyone", + "other-user", + "$USER", + "", + } +) + + +def public_export_surface(root: Path) -> frozenset[str] | None: + """Repository-relative paths the exporter actually publishes. + + The home-path rule applies to shipped files only. Private roots such as + plans and submissions legitimately reference local paths and never leave + this repository. Returns None when no manifest is present. + """ + manifest_path = root / "public-export.json" + if not manifest_path.is_file(): + return None + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + allowed_files = set(manifest.get("allowed_files", [])) + allowed_roots = tuple(manifest.get("allowed_roots", [])) + excluded_files = set(manifest.get("excluded_files", [])) + excluded_roots = tuple(manifest.get("excluded_roots", [])) + result = subprocess.run( + ["git", "ls-files", "-z", "--cached"], + cwd=root, + capture_output=True, + check=True, + ) + surface: set[str] = set() + for entry in result.stdout.split(b"\0"): + if not entry: + continue + rel = os.fsdecode(entry) + if rel in excluded_files or (excluded_roots and rel.startswith(excluded_roots)): + continue + if rel in allowed_files or (allowed_roots and rel.startswith(allowed_roots)): + surface.add(rel) + return frozenset(surface) + + +SEGMENT_DELIMITERS = " \t\"'`,;:()[]{}<>*=|" + + +def hardcoded_home_users(text: str) -> tuple[str, ...]: + """Return non-placeholder user segments of absolute home paths found in text. + + Scans line by line. A user segment ends at the path separator or at any + delimiter that terminates a literal, so a bare home root mentioned in prose + is not treated as a layout leak. + """ + found: list[str] = [] + for line in text.splitlines(): + for root, separator in HOME_ROOTS: + start = 0 + while True: + index = line.find(root, start) + if index == -1: + break + start = index + len(root) + segment = "" + has_path_below = False + for character in line[start:]: + if character == separator: + has_path_below = True + break + if character in SEGMENT_DELIMITERS: + break + segment += character + if ( + has_path_below + and segment + and segment not in PLACEHOLDER_HOME_USERS + and segment not in found + ): + found.append(segment) + return tuple(found) + + # Keep the complete canaries out of this source file so the checker (which is itself # scanned) does not mask an accidental literal copy here. CANARIES = ( @@ -181,6 +280,12 @@ def main() -> int: allowed_skips = 0 allowed_binaries = 0 violations: list[tuple[Path, tuple[str, ...]]] = [] + home_violations: list[tuple[Path, tuple[str, ...]]] = [] + try: + export_surface = public_export_surface(repo) + except Exception as exc: + print(f"FAIL failed to read public export manifest: {exc}") + return 1 for path in enumerated: try: rel = path.relative_to(repo) @@ -212,6 +317,10 @@ def main() -> int: scanned += 1 if matches: violations.append((path, matches)) + if export_surface is not None and rel.as_posix() in export_surface: + leaked = hardcoded_home_users(text) + if leaked: + home_violations.append((path, leaked)) if violations: print("FAIL synthetic public-boundary canary found outside rejected test fixture") @@ -223,6 +332,20 @@ def main() -> int: print(f" {rel}: {', '.join(matches)}") return 1 + if home_violations: + print("FAIL hardcoded user-home path found in the public surface") + for path, users in home_violations: + try: + rel = path.relative_to(repo) + except ValueError: + rel = path + print(f" {rel}: user segment(s) {', '.join(users)}") + print( + " Public source must not contain a real local layout. Detect paths by " + "shape, or use a placeholder user segment." + ) + return 1 + if allowed_skips != 1: print(f"FAIL designated rejected fixture absent from Git enumeration or duplicated (observed count {allowed_skips})") return 1 diff --git a/tools/ci/test-budget.json b/tools/ci/test-budget.json index ec72055..35f2006 100644 --- a/tools/ci/test-budget.json +++ b/tools/ci/test-budget.json @@ -91,7 +91,17 @@ "fixtures/conformance/okf-v0.1-log-invalid.fixture", "fixtures/conformance/okf-v0.1-log-valid.fixture", "fixtures/conformance/strict-gap-strict-selected.fixture", - "fixtures/conformance/bran-policy-parity.fixture" + "fixtures/conformance/bran-policy-parity.fixture", + "fixtures/conformance/okf-v0.2-attested-computation.fixture", + "fixtures/conformance/okf-v0.2-index-version.fixture", + "fixtures/conformance/okf-v0.2-legacy-fallback.fixture", + "fixtures/conformance/okf-v0.2-lifecycle.fixture", + "fixtures/conformance/okf-v0.2-malformed.fixture", + "fixtures/conformance/okf-v0.2-minimal.fixture", + "fixtures/conformance/okf-v0.2-sources.fixture", + "fixtures/conformance/okf-v0.2-trust-list.fixture", + "fixtures/conformance/okf-v0.2-unknown-tolerated.fixture", + "fixtures/conformance/okf-v0.2-verified-bare.fixture" ] }, { From 15617f5a2aa7f6145c062ee3bcff2a8b482ba1b3 Mon Sep 17 00:00:00 2001 From: 1wgrumph <1wgrumph@gmail.com> Date: Fri, 7 Aug 2026 09:44:54 -0500 Subject: [PATCH 03/10] Public export snapshot from bran-dev 4e28fd5 Records the shipped BRAN version (0.1.0) in the export receipt, read from the committed CLI manifest so it is a deterministic function of the source commit. --- .bran-export.json | 7 ++++--- tools/ci/public_export.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.bran-export.json b/.bran-export.json index 6303697..c677ac1 100644 --- a/.bran-export.json +++ b/.bran-export.json @@ -1,7 +1,8 @@ { "schema_version": 1, "source_repository": "alphazede/bran-dev", - "source_commit": "1938b3838e832e4e8035a86aab804257c6303124", + "source_commit": "4e28fd5f0d45d47b05f97bc35abbd6e2c30b9c61", + "version": "0.1.0", "public_repository": "alphazede/bran", "files": [ { @@ -691,8 +692,8 @@ { "path": "tools/ci/public_export.py", "mode": "100644", - "bytes": 19157, - "sha256": "82629365b0fc24ab7c4077c7c45305e6010065745d9fedf0baa4946cf68d9bed" + "bytes": 20580, + "sha256": "a0fa15276e2e12a98dd3545578296ac2896602c7cc9d96bda1f77b3361f94b4a" }, { "path": "tools/ci/release-check.sh", diff --git a/tools/ci/public_export.py b/tools/ci/public_export.py index a932c6e..9a3d75c 100644 --- a/tools/ci/public_export.py +++ b/tools/ci/public_export.py @@ -227,6 +227,35 @@ def select_public(tree: dict[str, GitBlob], config: ExportConfig) -> dict[str, G return selected +CLI_MANIFEST_PATH = "crates/bran-cli/Cargo.toml" + + +def exported_version(selected: dict[str, GitBlob]) -> str | None: + """Read the shipped BRAN version from the committed CLI manifest. + + Taken from the exported blob rather than the working tree so the receipt + stays a deterministic function of the source commit. Returns None when the + surface has no CLI manifest, so an export of a non-BRAN surface (the + contract self-test builds one) still produces a receipt. + """ + blob = selected.get(CLI_MANIFEST_PATH) + if blob is None: + return None + in_package = False + for line in blob.data.decode("utf-8").splitlines(): + stripped = line.strip() + if stripped.startswith("["): + in_package = stripped == "[package]" + continue + if in_package and stripped.startswith("version"): + _, _, raw = stripped.partition("=") + version = raw.strip().strip('"') + if version: + return version + break + raise ExportError(f"{CLI_MANIFEST_PATH} has no package version") + + def receipt_bytes(config: ExportConfig, commit: str, selected: dict[str, GitBlob]) -> bytes: receipt = { "schema_version": 1, @@ -243,6 +272,15 @@ def receipt_bytes(config: ExportConfig, commit: str, selected: dict[str, GitBlob for blob in selected.values() ], } + version = exported_version(selected) + if version is not None: + # Keep the version next to the commit it was read from. + ordered = {} + for key, value in receipt.items(): + ordered[key] = value + if key == "source_commit": + ordered["version"] = version + receipt = ordered return (json.dumps(receipt, indent=2, ensure_ascii=False) + "\n").encode("utf-8") From 7a6655cf4c7393a2f1925353bd2620d2de1a23a9 Mon Sep 17 00:00:00 2001 From: 1wgrumph <1wgrumph@gmail.com> Date: Fri, 7 Aug 2026 10:24:06 -0500 Subject: [PATCH 04/10] Public export snapshot from bran-dev 94b66ce Adds the Sigstore keyless release contract and workflow, and rewrites the README around install steps, real command output, and the offline-or-connected split. --- .bran-export.json | 36 +- .github/workflows/release.yml | 178 ++++++++ README.md | 407 +++++++++--------- crates/bran-core/src/lib.rs | 53 ++- .../release/valid-exact-release-manifest.json | 4 +- schemas/bran-release-manifest.schema.json | 17 +- tools/ci/release-check.sh | 21 +- tools/ci/release_contract_check.py | 33 +- tools/ci/release_seal.py | 224 ++++++---- 9 files changed, 620 insertions(+), 353 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.bran-export.json b/.bran-export.json index c677ac1..532a217 100644 --- a/.bran-export.json +++ b/.bran-export.json @@ -1,7 +1,7 @@ { "schema_version": 1, "source_repository": "alphazede/bran-dev", - "source_commit": "4e28fd5f0d45d47b05f97bc35abbd6e2c30b9c61", + "source_commit": "94b66ce2ea63b434a7571ef614121ecb5ee963bd", "version": "0.1.0", "public_repository": "alphazede/bran", "files": [ @@ -23,6 +23,12 @@ "bytes": 696, "sha256": "75002eee19da62eeb11dbc9c86eb67d6fde08635d3277e41298e887305f92128" }, + { + "path": ".github/workflows/release.yml", + "mode": "100644", + "bytes": 6636, + "sha256": "584fb7bb2ceaab988be2500a7ab8598ed6ab073ce410546e7808ad5d5e3749a7" + }, { "path": ".gitignore", "mode": "100644", @@ -62,8 +68,8 @@ { "path": "README.md", "mode": "100644", - "bytes": 11524, - "sha256": "0baf93700cc37a3e0cdd501725e135f99d915c6b628889baf69b1d4d39bc878d" + "bytes": 9773, + "sha256": "e1e78f08b7f5bfe79f6a922d86df0d14de8a10d6e0771b2e602afda6ce502c3d" }, { "path": "assets/brand/bran-repository-raven.png", @@ -254,8 +260,8 @@ { "path": "crates/bran-core/src/lib.rs", "mode": "100644", - "bytes": 24814, - "sha256": "dd2d8c4c2f66b171aa149423b805dd5c92de6d88b8ba4f6e5cd3f3587fafccd2" + "bytes": 25540, + "sha256": "7823d50d4b59746c09635472364a415123afadfac1845b3345250e8486f5118a" }, { "path": "crates/bran-core/src/metadata/mod.rs", @@ -560,8 +566,8 @@ { "path": "fixtures/release/valid-exact-release-manifest.json", "mode": "100644", - "bytes": 2900, - "sha256": "5e47f7482fbcdd38ddf7d8e32f25e2bb83cefef8c3b1e948472ac4317be694da" + "bytes": 3067, + "sha256": "1f5ac7c18b2519b2132171250e4f163392bc966df5f18cc17c5d2c464c5ec669" }, { "path": "fixtures/repair/rollback-v1.json", @@ -596,8 +602,8 @@ { "path": "schemas/bran-release-manifest.schema.json", "mode": "100644", - "bytes": 7321, - "sha256": "cba9b364722a0d68abb25eb54dbd6ef8b79333b377c639357d66165702bbf0d6" + "bytes": 7579, + "sha256": "e6425b8e29e09f399c72eb69e440002d3ba73f161b7551a883df08b36a493674" }, { "path": "schemas/bran-repository-policy.schema.json", @@ -698,20 +704,20 @@ { "path": "tools/ci/release-check.sh", "mode": "100755", - "bytes": 1093, - "sha256": "29360f0ac6fe9701eb42c3357428f135d8f62028aeb2fa2a7bccd9290de8aac5" + "bytes": 1471, + "sha256": "bf04ac9e19e67ba4e4b84255cb25d44b4f8980f8ec5e7f9839f8e01f19cd9bb9" }, { "path": "tools/ci/release_contract_check.py", "mode": "100644", - "bytes": 11801, - "sha256": "36f0aa9098e350410d057cc1fe387d3e053816ab56c5658d6cdebbad5da1ccb2" + "bytes": 12318, + "sha256": "d37bb1c35618214d4d58b52d5e317c68bdd1864b0d9d097c748f62247aed5106" }, { "path": "tools/ci/release_seal.py", "mode": "100755", - "bytes": 22511, - "sha256": "32063c65aaff99ddc39ed47fb07ace8b30a022d9f3e177ce35469978ab5fb302" + "bytes": 24663, + "sha256": "7d6fa282b8ca614a135dbf90e1504558b40e3195e8352d8f2abde334393eaee8" }, { "path": "tools/ci/test-budget.json", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3ead78c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,178 @@ +name: release + +on: + push: + tags: + - "bran-v*" + +jobs: + build: + name: build ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + # Least privilege: building needs to read the tag and nothing else. Only the + # sign job may write releases or mint an OIDC token. + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + - target: aarch64-unknown-linux-gnu + runner: ubuntu-24.04-arm + - target: x86_64-apple-darwin + runner: macos-13 + - target: aarch64-apple-darwin + runner: macos-14 + - target: x86_64-pc-windows-msvc + runner: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Build release archive + run: ./tools/ci/build-release.sh --target "${{ matrix.target }}" --tag "${{ github.ref_name }}" --dist dist + - uses: actions/upload-artifact@v4 + with: + name: artifact-${{ matrix.target }} + path: dist/ + + sign: + name: Sign checksums and emit manifest + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: artifact-* + path: dist + merge-multiple: true + - name: Produce SHA256SUMS + run: | + python3 - "${{ github.ref_name }}" <<'PY' + import hashlib + import pathlib + import sys + + tag = sys.argv[1] + targets = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + ] + names = [ + f"{tag}-{target}.zip" if target == "x86_64-pc-windows-msvc" else f"{tag}-{target}.tar.gz" + for target in targets + ] + dist = pathlib.Path("dist") + lines = [] + for name in sorted(names): + digest = hashlib.sha256((dist / name).read_bytes()).hexdigest() + lines.append(f"{digest} {name}") + (dist / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8") + PY + - uses: sigstore/cosign-installer@v3 + - name: Sign SHA256SUMS with cosign keyless + run: cosign sign-blob --yes --bundle dist/SHA256SUMS.sigstore dist/SHA256SUMS + - name: Emit bran-release-manifest.json + env: + TAG: ${{ github.ref_name }} + SOURCE_COMMIT: ${{ github.sha }} + CERTIFICATE_IDENTITY: https://github.com/alphazede/bran/.github/workflows/release.yml@refs/tags/${{ github.ref_name }} + run: | + python3 - <<'PY' + import hashlib + import json + import os + import pathlib + from datetime import datetime, timezone + + tag = os.environ["TAG"] + source_commit = os.environ["SOURCE_COMMIT"] + certificate_identity = os.environ["CERTIFICATE_IDENTITY"] + issuer = "https://token.actions.githubusercontent.com" + dist = pathlib.Path("dist") + + def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + def media_type(name): + return "application/zip" if name.endswith(".zip") else "application/gzip" + + targets = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + ] + names = [ + f"{tag}-{target}.zip" if target == "x86_64-pc-windows-msvc" else f"{tag}-{target}.tar.gz" + for target in targets + ] + assets = [ + { + "name": name, + "url": f"https://github.com/alphazede/bran/releases/download/{tag}/{name}", + "sha256": digest(dist / name), + "media_type": media_type(name), + } + for name in names + ] + sums_digest = digest(dist / "SHA256SUMS") + sig_digest = digest(dist / "SHA256SUMS.sigstore") + assets += [ + {"name": "SHA256SUMS", + "url": f"https://github.com/alphazede/bran/releases/download/{tag}/SHA256SUMS", + "sha256": sums_digest, "media_type": "text/plain"}, + {"name": "SHA256SUMS.sigstore", + "url": f"https://github.com/alphazede/bran/releases/download/{tag}/SHA256SUMS.sigstore", + "sha256": sig_digest, "media_type": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + ] + bundle = json.loads((dist / "SHA256SUMS.sigstore").read_text(encoding="utf-8")) + signed_at = datetime.fromtimestamp( + bundle["logEntry"]["integratedTime"], timezone.utc + ).strftime("%Y-%m-%dT%H:%M:%SZ") + lockfile_digest = digest(pathlib.Path("Cargo.lock")) + manifest = { + "schema_version": "1.0.0", + "tag": tag, + "repository": "alphazede/bran", + "source_commit": source_commit, + "lockfile_sha256": lockfile_digest, + "immutable": True, + "manifest_asset": "bran-release-manifest.json", + "assets": assets, + "checksums": {"asset": "SHA256SUMS", "algorithm": "sha256", "sha256": sums_digest}, + "signature": { + "asset": "SHA256SUMS.sigstore", + "format": "sigstore-bundle", + "certificate_identity": certificate_identity, + "certificate_oidc_issuer": issuer, + "signed_at": signed_at, + }, + "provenance": { + "format": "https://slsa.dev/provenance/v1", + "predicate_type": "https://slsa.dev/provenance/v1", + "source_repository": "alphazede/bran", + "source_commit": source_commit, + "lockfile_sha256": lockfile_digest, + "build_type": "https://alphazede.dev/bran/build/v1", + }, + } + (dist / "bran-release-manifest.json").write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8" + ) + PY + - uses: actions/upload-artifact@v4 + with: + name: release-files + path: dist/ diff --git a/README.md b/README.md index 9f1b2a4..1e6eea7 100644 --- a/README.md +++ b/README.md @@ -7,238 +7,235 @@ tags: resource: https://github.com/alphazede/bran --- -# BRAN +# BRAN — deterministic code search and context packets for LLM agents -![BRAN seated between two ravens beneath the memory tree](assets/brand/bran-repository-raven.png) +**BRAN is a local-first Rust CLI for deterministic code search: it ranks a +repository offline and assembles a bounded context packet, so AI agents get the +right files without searching for them.** No embeddings and no index server. It +runs fully offline, or connected to a model you choose — an API key is optional +and unused by default. -BRAN helps you understand and validate a repository locally. Use the headless -`bran` command in scripts and agent workflows, or open the optional terminal -interface to browse. Scanning, focused evidence packets, validation, and -offline browsing work without an agent account. +[![CI](https://github.com/alphazede/bran/actions/workflows/bran-fast.yml/badge.svg)](https://github.com/alphazede/bran/actions/workflows/bran-fast.yml) +![Rust](https://img.shields.io/badge/rust-stable-orange) +![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue) -## Build and try it +## Why not just grep? -Run the fast checks: +`rg` answers "which lines contain this string." An agent asking "where is +authentication handled?" gets an unranked wall of matches and burns tokens +sorting it. BRAN answers "which files are authoritative for this question," +ranked, with the reason attached. + +| | ripgrep / grep | Embedding RAG | BRAN | +|---|---|---|---| +| Ranking | none | similarity | declared authority + path + body + metadata | +| Determinism | yes | no | yes — identical input, identical output | +| Needs a model | no | yes | optional — works either way | +| Needs an index server | no | usually | no | +| Reports a miss | n/a | rarely | yes, explicitly | +| Offline | yes | rarely | yes | + +## Install + +Prebuilt archives for Linux, macOS, and Windows are attached to each release: ```sh -./tools/ci/check.sh --fast +https://github.com/alphazede/bran/releases/download/bran-v0.1.0/ ``` -Try a quick smoke test from the repository root: +Or build from source: ```sh -cargo run --quiet --bin bran -- smoke +cargo install --git https://github.com/alphazede/bran --tag bran-v0.1.0 bran-cli ``` -The command prints a versioned JSON response. Start the TUI with: +## Quickstart + +Rank the sources for a question: ```sh -cargo run --quiet --bin bran -- tui +bran query . "valid_sha256" | jq '{status, source_rankings: .data.source_rankings[:3], metrics}' ``` -On first launch, BRAN shows the requested and available settings for offline -mode, SQZ, connected agents, voice, history, and saved chats. If a capability -is unavailable, BRAN says so instead of pretending it worked. The default setup -is offline, read-only, and keeps no conversation history. +```json +{ + "status": "ok", + "source_rankings": [ + { "rank": 1, "locator": "crates/bran-core/src/agent/runtime.rs", "match_reason": "exact:body" }, + { "rank": 2, "locator": "crates/bran-core/src/agent/delegate.rs", "match_reason": "exact:body" }, + { "rank": 3, "locator": "crates/bran-core/src/adapters/connected.rs", "match_reason": "exact:body" } + ], + "metrics": { + "candidate_source_bytes": 2702707, + "selected_source_bytes": 133660, + "context_bytes_avoided": 2569047, + "estimated_tokens": 33415 + } +} +``` -To cap a connected task, set `tokens=N`. BRAN treats this as a requested host -limit until the connected adapter confirms enforcement. Leaving it unset does -not block connected work or imply that a token limit is enforced. Version 2 -settings migrate the old numeric default to `unset`; set `tokens=N` again if -you want an explicit limit. The separate 65,536-byte answer limit protects -storage. It is not a token limit or token-usage measurement. +That is 2.7 MB of candidate sources narrowed to 134 KB. Trim the output with +`jq` so BRAN saves context instead of consuming it. -During onboarding, you can choose `agent=`, `model=`, and `reasoning=` values, -including `reasoning=max`, for the current TUI session. You can also choose -`retention=none|structured|saved`. These options control BRAN's existing -history and saved-chat behavior. They never accept credentials or change -global configuration, and provider-side conversation retention remains -disabled. +## A miss looks like a miss -After onboarding, inspect local readiness without contacting an account: +An agent cannot tell a good answer from a confident wrong one. So when a +high-specificity entity has no match, BRAN returns nothing and says why, +instead of padding the result with files that matched the generic words around +it: ```sh -bran doctor --onboarding -bran doctor --agent -bran agents list +bran query . "nonexistent-collector-xyz" ``` -Both doctor modes are read-only. Their JSON output shows unavailable -capabilities and attestation details, and confirms that they made no provider, -authentication, or network calls. `bran doctor --agent` continues to return -validation status until the connected runtime and host attestation are active, -even when `local_setup_ready` is true. See -[Agent setup](docs/integrations/agent-setup.md) for the two supported setup -journeys, reasoning and tool recipes, no-session operation, and the offline -return check. To let an external agent host call BRAN, install the instructions -in [`skill/use-bran`](skill/use-bran/SKILL.md). - -## Make an agent actually use BRAN - -Giving an agent access to BRAN is not enough. Without a timely reminder, an -agent usually reaches for built-in search tools because they are always -available and never report `unavailable`. In one dev-node session on -2026-07-25, a BRAN banner appeared on every turn while the agent still used raw -search dozens of times without invoking BRAN. - -Two structural issues caused that behavior: - -1. A session-start or prompt-time reminder is stale by the time the agent forms - a search. The reminder needs to run on the search tool call itself. -2. BRAN requires a native `.bran/policy.yaml` at the repository root. Without - one, `bran_status: unavailable` is the correct result, and ordinary - repository discovery is the correct fallback. Coverage is a precondition - for adoption. - -### Add coverage before reminders - -Audit target repositories for `.bran/policy.yaml` before wiring hooks. Do not -nag an agent toward BRAN in a repository where it is unavailable; include the -known coverage gaps in local injected context instead. - -When existing public-facing Markdown cannot carry BRAN classification -frontmatter, keep the native index private, classify existing documents with -`legacy_baseline`, and exclude private or generated state with `.branignore`. -This provides repository coverage without changing the published Markdown. - -### Remind the agent at search time - -Use a `PreToolUse` command hook and match the tool names emitted by the actual -harness. Tool vocabularies differ: - -| Harness | Search path | Matcher | -|---|---|---| -| Claude Code | Native `Grep`/`Glob`, plus raw search through its shell tool | `Grep\|Glob\|Bash` | -| Codex | Unified shell commands such as `rg`, `grep`, `git grep`, and `find` | `Bash` | - -If a Codex surface exposes a dedicated search function, match its reported tool -name as well. Use `/hooks` to inspect the active hook sources and observed tool -names instead of assuming that another harness's matcher vocabulary applies. -For a broad matcher such as `Bash`, the script should inspect `tool_input` and -stay silent unless the command is a repository search. Resolve native coverage -from the call's current working directory on every invocation instead of -hard-coding a list of covered or uncovered repositories; that list becomes -wrong as soon as a policy is added or removed. - -Keep the hook in a script file and reference it by absolute path. Both -harnesses send a JSON payload on stdin. A Codex `PreToolUse` reminder returns an -event-specific JSON object like this: - ```json { - "systemMessage": "BRAN_SEARCH_ALERT: raw rg repository search requested in a BRAN-covered checkout.", - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "additionalContext": "Use the verified BRAN binary for this repository-knowledge search." - } + "status": "ok", + "source_rankings": [], + "warnings": ["unmatched_query_terms: nonexistent-collector-xyz"] } ``` -Codex treats non-empty hook stdout as JSON. Plain text on stdout causes an -`invalid ... JSON output` hook failure. Exit successfully with no output when -the hook does not apply. +Command success is not evidence coverage. Empty results are a feature. -Codex also requires review of every new or changed non-managed hook definition. -Open `/hooks`, inspect the source and exact command, and trust it; until then, -Codex intentionally skips the changed hook. Test the stored command first, and -start a fresh session if an already-running session still has the previous -matcher set loaded. Do not use a trust-bypass flag as normal installation -guidance. +## Commands -### Make raw-search fallback visible +| Command | Purpose | +|---|---| +| `bran query ` | Rank the sources for a request | +| `bran packet ` | Assemble a bounded context packet | +| `bran check ` | Validate against `okf-v0.1`, `okf-v0.2`, or `bran-strict` | +| `bran maintain ` | Bounded repair under explicit authority | +| `bran tui` | Browse the repository offline | +| `bran doctor --onboarding\|--agent` | Read-only local readiness check | +| `bran get ` | Retrieve a stored result | -A search hook sees the raw tool call but cannot reliably prove that a BRAN -query succeeded earlier in the conversation. Treat every matching raw search -as an observable fallback: return a top-level `systemMessage` beginning with a -stable marker such as `BRAN_SEARCH_ALERT`, and use `additionalContext` to make -the agent report whether BRAN was used and why the fallback is still needed. -This lets an owner find adoption loopholes without blocking legitimate -diagnostic searches or maintaining fragile per-session state. +Every command emits versioned JSON. `query`, `packet`, `check`, and `tui` need +no account and make no network calls. -In a covered repository, the alert should require `bran_status` plus a bounded -fallback reason. In an uncovered repository, it should explicitly report -`bran_status: unavailable` and allow ordinary discovery. Stay silent for -unrelated shell commands so the warning remains useful instead of becoming -background noise. +## The schema layer: OKF -The injected context should tell the agent to: +BRAN ranks on declared authority, not guesswork. That declaration is the +Open Knowledge Format, or [OKF](https://github.com/GoogleCloudPlatform/knowledge-catalog), +Google's open spec. YAML frontmatter turns ordinary markdown into a queryable knowledge graph: -- Resolve the pinned BRAN binary and verify its SHA-256 against the release pin. -- Use ordinary discovery immediately when the repository has no native policy. -- Trim query output so BRAN saves context instead of consuming it. -- Report `bran_status` as `hit`, `miss`, `stale`, `conflict`, or `unavailable`. +```yaml +--- +type: Concept +title: Ranking precedence +status: active +tags: [developer] +resource: https://github.com/alphazede/bran +--- +``` -For example, keep the highest-ranked sources and top-level metrics while -dropping the duplicate provenance payload: +`type` is the only required field. Optional families cover provenance +(`sources`, `usage_window`), trust (`generated`, `verified`), and lifecycle +(`status`, `stale_after`). ```sh -bran query "" | - jq '{status, source_rankings: .data.source_rankings[:8], metrics, warnings, failures}' +bran check . okf-v0.2 +``` + +```json +{ + "selected_profile": "okf-v0.2", + "selected_passed": true, + "okf_compatibility": { "profile": "okf-v0.1", "status": "pass" }, + "okf_v0_2": { "profile": "okf-v0.2", "status": "pass" }, + "bran_strict": { "profile": "bran-strict", "status": "pass" } +} ``` -### Test both hook directions +All three results are reported independently and only the selected profile +controls the exit code, so OKF conformance is never confused with house rules. + +## Export the knowledge graph + +`bran_core::export` emits an Obsidian-compatible vault from the graph, so a +repository can be browsed visually. See +[`examples/obsidian/usage.rs`](examples/obsidian/usage.rs). + +## Offline or connected — both are first class + +BRAN runs either way, and the same commands work in both modes. + +**Offline** is the default and needs no account, no key, and no network. +Scanning, ranking, packets, validation, and the TUI are complete on their own — +this is not a trial tier. -Do not assume that a stored hook configuration is valid. Inline shell embedded -in JSON is easy to damage through escaping, so prefer an executable script and -test the command exactly as stored: +**Connected** adds a model that reads what BRAN selected and answers with +citations. It is opt-in per invocation. + +### Where the models go + +If you connect a model, put it in the middle tier rather than the top: + +1. **BRAN** decides *which* files matter. Deterministic, offline, free. +2. **A fast or local model** — a Flash-class model, or something on your own + hardware — reads those files and condenses them. +3. **The frontier model** receives that clean, bounded context and reasons. + +The expensive model should never be the thing hunting through a repository. +Retrieval is a search problem, not a reasoning problem. + +### Connect a model + +BRAN has **no API-key flag and never copies credentials**. You point it at a +profile; the account reference becomes an opaque one-way handle before any +request, receipt, or diagnostic is written. + +1. Create a project-local `.bran/settings.conf` with `profile=connected-agent`. +2. Describe the connection through the environment — a reference, not a secret: + + ```sh + export BRAN_AGENT_PROFILE= + export BRAN_AGENT_PROVIDER= + export BRAN_AGENT_MODEL= + export BRAN_AGENT_REASONING=medium + export BRAN_AGENT_ACCOUNT_REF= + ``` + +3. Check what is actually available before relying on it: + + ```sh + bran agents list + bran doctor --agent + ``` + +4. Run a bounded, grounded request: + + ```sh + bran -p --agent --reasoning medium --tools read,search \ + --trust-current-root "which module owns frontmatter validation?" + ``` + +`--tools read,search` limits it to repository read and search. `--no-session` +disables retention. `--offline` forces the deterministic profile even when a +profile is configured, so you can always fall back: ```sh -echo '{"hook_event_name":"PreToolUse","tool_name":"Grep","tool_input":{"pattern":"x"}}' | - /absolute/path/bran-search.sh +bran -p --agent --offline --no-session "offline return proof" ``` -Confirm that a matching payload produces valid JSON with non-empty -`additionalContext`. Then send an unrelated payload and confirm the hook emits -nothing. A noisy hook that fires on every command will eventually be disabled. - -In the 2026-07-25 dev-node observation, one repository query narrowed 5.9 MB of -candidate sources to 411 KB, placed the correct file at rank 2, and reported -about 102,000 estimated tokens of context avoided. This is an observed result, -not a general performance guarantee, and it only helps when the hook fires and -the returned JSON is trimmed. - -Connected tasks require a valid project-local `.bran/settings.conf` with -`profile=connected-agent`. Set `BRAN_AGENT_PROFILE`, `BRAN_AGENT_PROVIDER`, -`BRAN_AGENT_MODEL`, `BRAN_AGENT_REASONING`, and `BRAN_AGENT_ACCOUNT_REF` to -describe the agent connection. -`BRAN_EXTERNAL_HOST_EXECUTABLE`, `BRAN_EXTERNAL_HOST_SHA256`, and -`BRAN_SQZ_EXECUTABLE` identify the local adapters. The external host timeout is -30 seconds by default; set `BRAN_EXTERNAL_HOST_TIMEOUT_SECONDS` to a whole -number from 1 through 600 for a slower call. - -These values are references, not credentials. BRAN has no API-key flag and -never copies credentials. It validates every value and converts the account -reference into an opaque, one-way handle before creating requests, receipts, -diagnostics, or `agents list` output. The raw environment value is never -echoed. - -The approved SQZ 1.1.1 digest identifies the verified platform artifact. -Platforms without that exact artifact report connected SQZ as unavailable. -`bran packet` also honors project `sqz=true` without contacting a model or -provider, and returns the post-policy packet with a complete SQZ receipt. When -SQZ is off, BRAN makes no SQZ process call. When it is on, BRAN fails visibly if -the executable, identity, fidelity, DLP, or output contract is invalid or -unavailable. - -Settings alone never give an agent permission to run. Add -`--trust-current-root` to each connected `bran -p` call, or enter -`trust-current-root` for the current TUI session. BRAN scans the repository, -builds a bounded evidence packet, applies the configured SQZ policy, and then -calls the configured host. It stores completed results and lossless artifacts -under the IDs in `receipt.stored_result_ref`. Storage is limited by item count, -total bytes, and TTL, and remains separate from conversation history. Run -`bran get ` to retrieve the decoded answer and its citations. +If a capability is unavailable, BRAN says `unavailable` rather than pretending +it worked. Requested and effective capability are always reported separately. -## Releases +## Use it with an agent -BRAN does not have a published release yet. When releases begin, each version -will use an exact `bran-vX.Y.Z` tag. Downloads will be available under: +Giving an agent access is not enough. Without a reminder it reaches for +built-in search, which is always available and never reports `unavailable`. +Install [`skill/use-bran`](skill/use-bran/SKILL.md) for the agent-facing +instructions, and see [Agent setup](docs/integrations/agent-setup.md) for hook +recipes, reasoning and tool configuration, and the offline return check. -```text -https://github.com/alphazede/bran/releases/download/bran-vX.Y.Z/ -``` +## Releases -Each release will include these five platform archives: +Each release uses an exact `bran-vX.Y.Z` tag. Downloads live under +`https://github.com/alphazede/bran/releases/download/bran-vX.Y.Z/` — exact tags +only, never `latest`. + +Five platform archives are published: - `bran-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz` - `bran-vX.Y.Z-aarch64-unknown-linux-gnu.tar.gz` @@ -246,21 +243,41 @@ Each release will include these five platform archives: - `bran-vX.Y.Z-aarch64-apple-darwin.tar.gz` - `bran-vX.Y.Z-x86_64-pc-windows-msvc.zip` -Each release will also include `SHA256SUMS`, `SHA256SUMS.sig`, and -`bran-release-manifest.json`. - -- `bran-release-manifest.json` records release provenance. -- SBOMs are not yet part of the release workflow. -- Release notes belong to the tagged release. Installation and downloads use - exact tags rather than `latest`. +Alongside them: `SHA256SUMS`, `SHA256SUMS.sigstore`, and +`bran-release-manifest.json`, which records release provenance. -To install a specific release with Cargo: +Signing is Sigstore keyless via GitHub OIDC — there is no long-lived key to +manage or leak. Verify a download: ```sh -cargo install --git https://github.com/alphazede/bran --tag bran-vX.Y.Z --locked bran-cli +cosign verify-blob SHA256SUMS \ + --bundle SHA256SUMS.sigstore \ + --certificate-identity "https://github.com/alphazede/bran/.github/workflows/release.yml@refs/tags/bran-v0.1.0" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" +sha256sum -c SHA256SUMS --ignore-missing ``` +SBOMs are not yet part of the release workflow. + +## FAQ + +**Does BRAN need an API key?** No. A key or auth session is entirely optional. +Scanning, ranking, packets, validation, and the TUI are fully offline and make +no network calls. A connected mode exists and is opt-in. + +**If I add a model, which one?** A fast or local one. Use a Flash-class or +self-hosted model to read the files BRAN selected and hand the condensed result +to your frontier model. See [Where the models go](#where-the-models-go). + +**Does it replace RAG?** For code and docs, often yes. It separates retrieval +from reasoning, so a cheap deterministic step feeds the expensive model. + +**What languages does it support?** Ranking is language-agnostic; it operates on +paths, document bodies, structure, and OKF metadata. + +**Why did my query return nothing?** Because nothing matched. Check the +`unmatched_query_terms` warning — that is BRAN refusing to guess. + ## License -BRAN is available under your choice of the [Apache License 2.0](LICENSE-APACHE) -or the [MIT License](LICENSE-MIT). +Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE). diff --git a/crates/bran-core/src/lib.rs b/crates/bran-core/src/lib.rs index 8272e0b..504b395 100644 --- a/crates/bran-core/src/lib.rs +++ b/crates/bran-core/src/lib.rs @@ -30,7 +30,9 @@ use std::fmt; const DOWNLOAD_ROOT: &str = "https://github.com/alphazede/bran/releases/download/"; const CHECKSUMS: &str = "SHA256SUMS"; -const CHECKSUMS_SIGNATURE: &str = "SHA256SUMS.sig"; +const CHECKSUMS_SIGNATURE: &str = "SHA256SUMS.sigstore"; +const SIGNATURE_FORMAT: &str = "sigstore-bundle"; +const SIGSTORE_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com"; const MANIFEST: &str = "bran-release-manifest.json"; /// A validated immutable Bran release tag. @@ -117,12 +119,13 @@ pub struct DeclaredChecksums { pub sha256: String, } -/// Declared OpenPGP signature metadata (shape only). +/// Declared Sigstore keyless signature metadata (shape only). #[derive(Clone, Debug, Eq, PartialEq)] pub struct DeclaredSignature { pub asset: String, pub format: String, - pub key_fingerprint: String, + pub certificate_identity: String, + pub certificate_oidc_issuer: String, pub signed_at: String, } @@ -145,7 +148,7 @@ pub struct DeclaredProvenance { /// truth for the contract). /// /// This verifies declared Slice 1.1 metadata structure and semantic binding only. -/// It does NOT fetch bytes, recompute digests, execute OpenPGP verification, +/// It does NOT fetch bytes, recompute digests, execute signature verification, /// or attest provenance. Those later cryptographic operations remain outside /// this slice. #[derive(Clone, Debug, Eq, PartialEq)] @@ -187,7 +190,8 @@ pub enum ReleaseVerificationError { MalformedChecksumsDigest(String), SignatureAssetMismatch { expected: String, actual: String }, SignatureFormatMismatch { expected: String, actual: String }, - MalformedSignatureFingerprint(String), + MalformedCertificateIdentity(String), + CertificateOidcIssuerMismatch { expected: String, actual: String }, MalformedSignatureTimestamp(String), ProvenanceFormatMismatch, ProvenancePredicateTypeMismatch, @@ -270,10 +274,16 @@ impl fmt::Display for ReleaseVerificationError { Self::SignatureFormatMismatch { expected, actual } => { write!(formatter, "signature.format must be {expected}: {actual}") } - Self::MalformedSignatureFingerprint(f) => { + Self::MalformedCertificateIdentity(i) => { write!( formatter, - "signature.key_fingerprint must be exactly 40 or 64 lowercase hex: {f}" + "signature.certificate_identity must be an https URL: {i}" + ) + } + Self::CertificateOidcIssuerMismatch { expected, actual } => { + write!( + formatter, + "signature.certificate_oidc_issuer must be {expected}: {actual}" ) } Self::MalformedSignatureTimestamp(t) => { @@ -328,7 +338,7 @@ impl ReleaseVerifier { /// The complete immutable (hashed) asset names for this release. /// /// Exactly seven assets are required and order-insensitive: - /// five platform archives plus SHA256SUMS and SHA256SUMS.sig. + /// five platform archives plus SHA256SUMS and SHA256SUMS.sigstore. /// The bran-release-manifest.json is declared via manifest_asset /// but is distributed without a self-digest entry in the hashed assets. /// @@ -406,15 +416,15 @@ impl ReleaseVerifier { /// - lowercase 64-hex lockfile_sha256 /// - immutable == true /// - manifest_asset == "bran-release-manifest.json" (distributed without self-digest) - /// - exactly seven order-insensitive hashed assets (5 platform + SHA256SUMS + SHA256SUMS.sig) + /// - exactly seven order-insensitive hashed assets (5 platform + SHA256SUMS + SHA256SUMS.sigstore) /// - exact tag/name/direct URL binding for every asset (no /latest) /// - each asset has lowercase 64-hex sha256 and non-blank media_type /// - checksums bound to the SHA256SUMS asset's sha256 (with correct asset/algorithm) - /// - OpenPGP signature metadata: asset, format=openpgp, exactly 40 or 64 lowercase hex fingerprint, strict UTC YYYY-MM-DDTHH:MM:SSZ signed_at (full Gregorian calendar/leap-day validated) + /// - Sigstore keyless signature metadata: asset, format=sigstore-bundle, https URL certificate_identity, exact certificate_oidc_issuer const, strict UTC YYYY-MM-DDTHH:MM:SSZ signed_at (full Gregorian calendar/leap-day validated) /// - SLSA v1 provenance: format/predicate consts, repository const, source_commit/lockfile_sha256 cross-bound to top level (40/64 hex), build_type https://... /// /// This verifies declared Slice 1.1 metadata structure/semantic binding only. - /// It does NOT fetch bytes, recompute digests, execute OpenPGP verification, + /// It does NOT fetch bytes, recompute digests, execute signature verification, /// or attest provenance—those later cryptographic operations remain outside this slice. /// /// Python (tools/ci/release_contract_check.py) is the fixture/schema semantic oracle. @@ -528,17 +538,23 @@ impl ReleaseVerifier { actual: sig.asset.clone(), }); } - if sig.format != "openpgp" { + if sig.format != SIGNATURE_FORMAT { return Err(ReleaseVerificationError::SignatureFormatMismatch { - expected: "openpgp".to_owned(), + expected: SIGNATURE_FORMAT.to_owned(), actual: sig.format.clone(), }); } - if !is_fingerprint(&sig.key_fingerprint) { - return Err(ReleaseVerificationError::MalformedSignatureFingerprint( - sig.key_fingerprint.clone(), + if !sig.certificate_identity.starts_with("https://") { + return Err(ReleaseVerificationError::MalformedCertificateIdentity( + sig.certificate_identity.clone(), )); } + if sig.certificate_oidc_issuer != SIGSTORE_OIDC_ISSUER { + return Err(ReleaseVerificationError::CertificateOidcIssuerMismatch { + expected: SIGSTORE_OIDC_ISSUER.to_owned(), + actual: sig.certificate_oidc_issuer.clone(), + }); + } if !is_strict_utc_datetime(&sig.signed_at) { return Err(ReleaseVerificationError::MalformedSignatureTimestamp( sig.signed_at.clone(), @@ -611,11 +627,6 @@ fn is_sha256(s: &str) -> bool { is_lowercase_hex(s, 64) } -fn is_fingerprint(s: &str) -> bool { - let l = s.len(); - (l == 40 || l == 64) && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) -} - /// Exact strict UTC shape: YYYY-MM-DDTHH:MM:SSZ (20 bytes, ASCII digits + separators + Z). /// Then numeric range + real proleptic Gregorian calendar (incl. leap days) using /// divisibility rules (no external crate, matches Python stdlib datetime semantics). diff --git a/fixtures/release/valid-exact-release-manifest.json b/fixtures/release/valid-exact-release-manifest.json index 78695c5..bc4f9e6 100644 --- a/fixtures/release/valid-exact-release-manifest.json +++ b/fixtures/release/valid-exact-release-manifest.json @@ -13,9 +13,9 @@ { "name": "bran-v1.2.3-aarch64-apple-darwin.tar.gz", "url": "https://github.com/alphazede/bran/releases/download/bran-v1.2.3/bran-v1.2.3-aarch64-apple-darwin.tar.gz", "sha256": "4444444444444444444444444444444444444444444444444444444444444444", "media_type": "application/gzip" }, { "name": "bran-v1.2.3-x86_64-pc-windows-msvc.zip", "url": "https://github.com/alphazede/bran/releases/download/bran-v1.2.3/bran-v1.2.3-x86_64-pc-windows-msvc.zip", "sha256": "5555555555555555555555555555555555555555555555555555555555555555", "media_type": "application/zip" }, { "name": "SHA256SUMS", "url": "https://github.com/alphazede/bran/releases/download/bran-v1.2.3/SHA256SUMS", "sha256": "6666666666666666666666666666666666666666666666666666666666666666", "media_type": "text/plain" }, - { "name": "SHA256SUMS.sig", "url": "https://github.com/alphazede/bran/releases/download/bran-v1.2.3/SHA256SUMS.sig", "sha256": "7777777777777777777777777777777777777777777777777777777777777777", "media_type": "application/pgp-signature" } + { "name": "SHA256SUMS.sigstore", "url": "https://github.com/alphazede/bran/releases/download/bran-v1.2.3/SHA256SUMS.sigstore", "sha256": "7777777777777777777777777777777777777777777777777777777777777777", "media_type": "application/vnd.dev.sigstore.bundle.v0.3+json" } ], "checksums": { "asset": "SHA256SUMS", "algorithm": "sha256", "sha256": "6666666666666666666666666666666666666666666666666666666666666666" }, - "signature": { "asset": "SHA256SUMS.sig", "format": "openpgp", "key_fingerprint": "0123456789abcdef0123456789abcdef01234567", "signed_at": "2026-01-01T00:00:00Z" }, + "signature": { "asset": "SHA256SUMS.sigstore", "format": "sigstore-bundle", "certificate_identity": "https://github.com/alphazede/bran/.github/workflows/release.yml@refs/tags/bran-v1.2.3", "certificate_oidc_issuer": "https://token.actions.githubusercontent.com", "signed_at": "2026-01-01T00:00:00Z" }, "provenance": { "format": "https://slsa.dev/provenance/v1", "predicate_type": "https://slsa.dev/provenance/v1", "source_repository": "alphazede/bran", "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "lockfile_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "build_type": "https://alphazede.dev/bran/build/v1" } } diff --git a/schemas/bran-release-manifest.schema.json b/schemas/bran-release-manifest.schema.json index d021695..fb1defa 100644 --- a/schemas/bran-release-manifest.schema.json +++ b/schemas/bran-release-manifest.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://schemas.alphazede.dev/bran/release-manifest/v1/schema.json", "title": "Bran exact-release manifest", - "description": "Non-circular release manifest contract. assets holds exactly seven hashed files (five platform archives + SHA256SUMS + SHA256SUMS.sig) in any order. manifest_asset is the const 'bran-release-manifest.json' with no self-digest inside. Build order: archives, SHA256SUMS (covers archives), SHA256SUMS.sig (signs sums), manifest (records digests/metadata, distributed unhashed). source_commit and provenance.source_commit are 40-hex git SHA-1; lockfile_sha256, asset sha256, checksums.sha256 are 64-hex. Asset order insignificant. key_fingerprint is exactly 40 or 64 lowercase hex; signed_at is the strict UTC subset exactly YYYY-MM-DDTHH:MM:SSZ (ASCII digits/separators only; year 0001-9999, real proleptic Gregorian/leap-day; pattern enforces no year 0000 / invalid m/d / non-leap Feb29 / Feb30 / 24h / 60minsec, format kept for Draft2020-12; hours 00-23, min/sec 00-59). Schema cannot bind dynamic tag/name/URL equality; x-semantic-oracle is the mandatory stdlib checker.", + "description": "Non-circular release manifest contract. assets holds exactly seven hashed files (five platform archives + SHA256SUMS + SHA256SUMS.sigstore) in any order. manifest_asset is the const 'bran-release-manifest.json' with no self-digest inside. Build order: archives, SHA256SUMS (covers archives), SHA256SUMS.sigstore (sigstore-bundle signs sums), manifest (records digests/metadata, distributed unhashed). source_commit and provenance.source_commit are 40-hex git SHA-1; lockfile_sha256, asset sha256, checksums.sha256 are 64-hex. Asset order insignificant. certificate_identity is the signing workflow's https OIDC subject URI; certificate_oidc_issuer is the exact GitHub Actions issuer const; signed_at is the strict UTC subset exactly YYYY-MM-DDTHH:MM:SSZ (ASCII digits/separators only; year 0001-9999, real proleptic Gregorian/leap-day; pattern enforces no year 0000 / invalid m/d / non-leap Feb29 / Feb30 / 24h / 60minsec, format kept for Draft2020-12; hours 00-23, min/sec 00-59). Schema cannot bind dynamic tag/name/URL equality; x-semantic-oracle is the mandatory stdlib checker.", "x-semantic-oracle": "tools/ci/release_contract_check.py", "type": "object", "additionalProperties": false, @@ -81,13 +81,16 @@ "signature": { "type": "object", "additionalProperties": false, - "required": ["asset", "format", "key_fingerprint", "signed_at"], + "required": ["asset", "format", "certificate_identity", "certificate_oidc_issuer", "signed_at"], "properties": { - "asset": { "const": "SHA256SUMS.sig" }, - "format": { "const": "openpgp" }, - "key_fingerprint": { + "asset": { "const": "SHA256SUMS.sigstore" }, + "format": { "const": "sigstore-bundle" }, + "certificate_identity": { "type": "string", - "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + "pattern": "^https://" + }, + "certificate_oidc_issuer": { + "const": "https://token.actions.githubusercontent.com" }, "signed_at": { "type": "string", @@ -182,7 +185,7 @@ "signatureAsset": { "allOf": [ { "$ref": "#/$defs/asset" }, - { "properties": { "name": { "const": "SHA256SUMS.sig" } } } + { "properties": { "name": { "const": "SHA256SUMS.sigstore" } } } ] } } diff --git a/tools/ci/release-check.sh b/tools/ci/release-check.sh index f52958e..d19ac90 100755 --- a/tools/ci/release-check.sh +++ b/tools/ci/release-check.sh @@ -3,14 +3,15 @@ set -eu usage() { - printf 'usage: %s --tag TAG --dist DIR [--dry-run-unsigned] [--fingerprint FINGERPRINT]\n' "$0" >&2 + printf 'usage: %s --tag TAG --dist DIR [--dry-run-unsigned] [--certificate-identity IDENTITY] [--certificate-oidc-issuer ISSUER]\n' "$0" >&2 exit 2 } tag= dist= dry= -fingerprint= +certificate_identity= +certificate_oidc_issuer= while [ $# -gt 0 ]; do case "$1" in --tag) @@ -23,9 +24,14 @@ while [ $# -gt 0 ]; do dist=$2 shift 2 ;; - --fingerprint) + --certificate-identity) [ $# -ge 2 ] || usage - fingerprint=$2 + certificate_identity=$2 + shift 2 + ;; + --certificate-oidc-issuer) + [ $# -ge 2 ] || usage + certificate_oidc_issuer=$2 shift 2 ;; --dry-run-unsigned) @@ -45,7 +51,10 @@ set -- python3 "$script_dir/release_seal.py" --tag "$tag" --dist "$dist" if [ -n "$dry" ]; then set -- "$@" "$dry" fi -if [ -n "$fingerprint" ]; then - set -- "$@" --fingerprint "$fingerprint" +if [ -n "$certificate_identity" ]; then + set -- "$@" --certificate-identity "$certificate_identity" +fi +if [ -n "$certificate_oidc_issuer" ]; then + set -- "$@" --certificate-oidc-issuer "$certificate_oidc_issuer" fi exec "$@" diff --git a/tools/ci/release_contract_check.py b/tools/ci/release_contract_check.py index cec498d..a11b217 100644 --- a/tools/ci/release_contract_check.py +++ b/tools/ci/release_contract_check.py @@ -13,10 +13,10 @@ SCHEMA_VERSION = "1.0.0" REPOSITORY = "alphazede/bran" +OIDC_ISSUER = "https://token.actions.githubusercontent.com" TAG_PATTERN = re.compile(r"bran-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") SHA256_PATTERN = re.compile(r"[0-9a-f]{64}\Z") GIT_SHA_PATTERN = re.compile(r"[0-9a-f]{40}\Z") -FINGERPRINT_PATTERN = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z") STRICT_SIGNED_AT_REGEX = r"^(?:(?:000[1-9]|00[1-9][0-9]|0[1-9][0-9]{2}|[1-9][0-9]{3})-(?:(?:(?:0[13578]|1[02]))-(?:0[1-9]|[12][0-9]|3[01])|(?:(?:0[469]|11))-(?:0[1-9]|[12][0-9]|30)|02-(?:0[1-9]|1[0-9]|2[0-8]))|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:04|08|12|16|20|24|28|32|36|40|44|48|52|56|60|64|68|72|76|80|84|88|92|96)00)-02-29)T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]Z$" SIGNED_AT_PATTERN = re.compile(STRICT_SIGNED_AT_REGEX) RELEASE_BASE = "https://github.com/alphazede/bran/releases/download" @@ -24,7 +24,8 @@ # Expected schema constants for drift detection (exact strings must match schema) # STRICT_SIGNED_AT_REGEX and EXPECTED_SIGNED_AT_PATTERN are identical (schema pattern uses $ anchors; datetime.strptime is semantic defense) -EXPECTED_FINGERPRINT_PATTERN = r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$" +EXPECTED_CERTIFICATE_IDENTITY_PATTERN = r"^https://" +EXPECTED_OIDC_ISSUER = OIDC_ISSUER EXPECTED_SIGNED_AT_PATTERN = STRICT_SIGNED_AT_REGEX EXPECTED_SIGNED_AT_FORMAT = "date-time" @@ -48,8 +49,8 @@ def is_git_sha(value: Any) -> bool: return isinstance(value, str) and GIT_SHA_PATTERN.fullmatch(value) is not None -def is_fingerprint(value: Any) -> bool: - return isinstance(value, str) and FINGERPRINT_PATTERN.fullmatch(value) is not None +def is_certificate_identity(value: Any) -> bool: + return isinstance(value, str) and value.startswith("https://") def is_strict_utc_datetime(value: Any) -> bool: @@ -82,7 +83,7 @@ def expected_asset_names(tag: str) -> tuple[str, ...]: f"{tag}-aarch64-apple-darwin.tar.gz", f"{tag}-x86_64-pc-windows-msvc.zip", "SHA256SUMS", - "SHA256SUMS.sig", + "SHA256SUMS.sigstore", ) @@ -163,13 +164,16 @@ def validate_manifest(manifest: Any) -> list[str]: errors.append("checksums.sha256 must match the SHA256SUMS asset") signature = manifest["signature"] - if not is_object_with_keys(signature, {"asset", "format", "key_fingerprint", "signed_at"}): - errors.append("signature must contain exactly asset, format, key_fingerprint, and signed_at") + signature_keys = {"asset", "format", "certificate_identity", "certificate_oidc_issuer", "signed_at"} + if not is_object_with_keys(signature, signature_keys): + errors.append("signature must contain exactly asset, format, certificate_identity, certificate_oidc_issuer, and signed_at") else: - if signature["asset"] != "SHA256SUMS.sig" or signature["format"] != "openpgp": - errors.append("signature must describe SHA256SUMS.sig in openpgp format") - if not is_fingerprint(signature["key_fingerprint"]): - errors.append("signature.key_fingerprint must be exactly 40 or 64 lowercase hex") + if signature["asset"] != "SHA256SUMS.sigstore" or signature["format"] != "sigstore-bundle": + errors.append("signature must describe SHA256SUMS.sigstore as a sigstore-bundle") + if not is_certificate_identity(signature["certificate_identity"]): + errors.append("signature.certificate_identity must be an https URL") + if signature["certificate_oidc_issuer"] != OIDC_ISSUER: + errors.append(f"signature.certificate_oidc_issuer must be {OIDC_ISSUER}") if not is_strict_utc_datetime(signature["signed_at"]): errors.append("signature.signed_at must be strict UTC YYYY-MM-DDTHH:MM:SSZ") @@ -217,8 +221,11 @@ def main() -> int: return 1 signature = (schema.get("properties") or {}).get("signature", {}).get("properties", {}) or {} - if signature.get("key_fingerprint", {}).get("pattern") != EXPECTED_FINGERPRINT_PATTERN: - print("FAIL release contract check: schema key_fingerprint pattern drifted from expected constant") + if signature.get("certificate_identity", {}).get("pattern") != EXPECTED_CERTIFICATE_IDENTITY_PATTERN: + print("FAIL release contract check: schema certificate_identity pattern drifted from expected constant") + return 1 + if signature.get("certificate_oidc_issuer", {}).get("const") != EXPECTED_OIDC_ISSUER: + print("FAIL release contract check: schema certificate_oidc_issuer const drifted from expected constant") return 1 signed_at = signature.get("signed_at", {}) if signed_at.get("pattern") != EXPECTED_SIGNED_AT_PATTERN or signed_at.get("format") != EXPECTED_SIGNED_AT_FORMAT: diff --git a/tools/ci/release_seal.py b/tools/ci/release_seal.py index e284cde..d9e97f5 100755 --- a/tools/ci/release_seal.py +++ b/tools/ci/release_seal.py @@ -14,6 +14,7 @@ import tempfile from contextlib import redirect_stdout from datetime import datetime, timezone +from functools import partial from pathlib import Path from typing import Callable @@ -55,57 +56,66 @@ def git_state(root: Path, tag: str) -> tuple[str, str | None, bool | None, str | return "", None, None, None +def bundle_signed_at(signature: Path) -> str: + """Return the Rekor integrated time of a cosign bundle as strict UTC.""" + try: + bundle = json.loads(signature.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError( + "signature verification unavailable: sigstore bundle is not valid JSON" + ) from error + entry = bundle.get("logEntry") if isinstance(bundle, dict) else None + integrated = entry.get("integratedTime") if isinstance(entry, dict) else None + if not isinstance(integrated, int) or isinstance(integrated, bool) or integrated < 0: + raise ValueError( + "signature verification unavailable: sigstore bundle has no valid Rekor integrated time" + ) + try: + return datetime.fromtimestamp(integrated, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + except (OSError, OverflowError, ValueError) as error: + raise ValueError( + "signature verification unavailable: sigstore bundle has an invalid Rekor integrated time" + ) from error + + def verify_signature( sums: Path, signature: Path, *, + certificate_identity: str, + certificate_oidc_issuer: str, _which: Callable[[str], str | None] = shutil.which, _run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, -) -> tuple[str, str]: - """Return the verified fingerprint and signature time.""" - gpg = _which("gpg") - if not gpg: - raise ValueError("signature verification unavailable: gpg is not installed") +) -> tuple[str, str, str]: + """Verify a cosign keyless bundle against the checksums with cosign. + + Returns the enforced certificate identity, OIDC issuer, and the bundle's + Rekor integrated time. Fails closed when cosign is absent. + """ + cosign = _which("cosign") + if not cosign: + raise ValueError("signature verification unavailable: cosign is not installed") try: result = _run( [ - gpg, - "--batch", - "--no-auto-key-retrieve", - "--auto-key-locate", - "clear", - "--status-fd", - "1", - "--verify", + cosign, + "verify-blob", + "--bundle", str(signature), + "--certificate-identity", + certificate_identity, + "--certificate-oidc-issuer", + certificate_oidc_issuer, str(sums), ], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) except OSError as error: - raise ValueError(f"signature verification unavailable: cannot run gpg: {error}") from error + raise ValueError(f"signature verification unavailable: cannot run cosign: {error}") from error if result.returncode: - detail = result.stderr.strip() or "no VALIDSIG status" + detail = result.stderr.strip() or "cosign verify-blob failed" raise ValueError(f"signature verification unavailable: {detail}") - - valid = [line.split() for line in result.stdout.splitlines() if line.startswith("[GNUPG:] VALIDSIG ")] - if len(valid) != 1 or len(valid[0]) < 5: - raise ValueError("signature verification unavailable: expected exactly one complete VALIDSIG status") - fingerprint, creation_date, creation_epoch = valid[0][2:5] - fingerprint = fingerprint.lower() - if not contract.is_fingerprint(fingerprint): - raise ValueError("signature verification unavailable: gpg returned an invalid signer fingerprint") - try: - epoch = int(creation_epoch) - if epoch < 0: - raise ValueError - signed_at = datetime.fromtimestamp(epoch, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - parsed_date = datetime.strptime(creation_date, "%Y-%m-%d").strftime("%Y-%m-%d") - except (OSError, OverflowError, ValueError) as error: - raise ValueError("signature verification unavailable: gpg returned an invalid signature time") from error - if parsed_date != creation_date or creation_date != signed_at[:10]: - raise ValueError("signature verification unavailable: gpg signature date and epoch disagree") - return fingerprint, signed_at + return certificate_identity, certificate_oidc_issuer, bundle_signed_at(signature) def archives(tag: str) -> tuple[str, ...]: @@ -201,14 +211,16 @@ def expected_manifest( assets = [{"name": name, "url": f"{contract.RELEASE_BASE}/{tag}/{name}", "sha256": get_arch(name), "media_type": media(name)} for name in names] assets += [ {"name": "SHA256SUMS", "url": f"{contract.RELEASE_BASE}/{tag}/SHA256SUMS", "sha256": sums_d, "media_type": "text/plain"}, - {"name": "SHA256SUMS.sig", "url": f"{contract.RELEASE_BASE}/{tag}/SHA256SUMS.sig", "sha256": sig_d, "media_type": "application/pgp-signature"}, + {"name": "SHA256SUMS.sigstore", "url": f"{contract.RELEASE_BASE}/{tag}/SHA256SUMS.sigstore", "sha256": sig_d, "media_type": "application/vnd.dev.sigstore.bundle.v0.3+json"}, ] return { "schema_version": contract.SCHEMA_VERSION, "tag": tag, "repository": contract.REPOSITORY, "source_commit": head, "lockfile_sha256": lock_digest, "immutable": True, "manifest_asset": "bran-release-manifest.json", "assets": assets, "checksums": {"asset": "SHA256SUMS", "algorithm": "sha256", "sha256": sums_d}, - "signature": {"asset": "SHA256SUMS.sig", "format": "openpgp", "key_fingerprint": signer, + "signature": {"asset": "SHA256SUMS.sigstore", "format": "sigstore-bundle", + "certificate_identity": signer, + "certificate_oidc_issuer": contract.OIDC_ISSUER, "signed_at": signed_at}, "provenance": {"format": "https://slsa.dev/provenance/v1", "predicate_type": "https://slsa.dev/provenance/v1", "source_repository": contract.REPOSITORY, "source_commit": head, "lockfile_sha256": lock_digest, @@ -216,9 +228,10 @@ def expected_manifest( } -def seal(tag: str, dist: Path, dry_run: bool, required_fingerprint: str | None, +def seal(tag: str, dist: Path, dry_run: bool, required_identity: str | None, + required_issuer: str | None, _git: Callable[[Path, str], tuple[str, str | None, bool | None, str | None]] = git_state, - _proof: Callable[[Path, Path], tuple[str, str]] = verify_signature) -> int: + _proof: Callable[[Path, Path], tuple[str, str, str]] = verify_signature) -> int: if not contract.TAG_PATTERN.fullmatch(tag): print(f"FAIL invalid tag (must be bran-vX.Y.Z): {tag}") return 1 @@ -246,13 +259,13 @@ def seal(tag: str, dist: Path, dry_run: bool, required_fingerprint: str | None, ) return 1 manifest_path = dist / "bran-release-manifest.json" - sig_path = dist / "SHA256SUMS.sig" + sig_path = dist / "SHA256SUMS.sigstore" if dry_run: if manifest_path.exists() or manifest_path.is_symlink(): print("FAIL dry-run unsigned rejects bran-release-manifest.json final-state input") return 1 if sig_path.exists() or sig_path.is_symlink(): - print("FAIL dry-run unsigned rejects SHA256SUMS.sig final-state input") + print("FAIL dry-run unsigned rejects SHA256SUMS.sigstore final-state input") return 1 try: names = require_archives(tag, dist) @@ -285,12 +298,15 @@ def seal(tag: str, dist: Path, dry_run: bool, required_fingerprint: str | None, print(f"wrote non-final evidence: {path.name}") return 0 - if not required_fingerprint or not contract.is_fingerprint(required_fingerprint.lower()): - print("FAIL real mode requires a valid --fingerprint policy value") + if not required_identity or not contract.is_certificate_identity(required_identity): + print("FAIL real mode requires a valid --certificate-identity policy value") + return 1 + if required_issuer != contract.OIDC_ISSUER: + print("FAIL real mode requires --certificate-oidc-issuer to be the exact GitHub Actions issuer") return 1 try: manifest_blob = _ensure_real_file_for_read(manifest_path, "bran-release-manifest.json") - sig_snapshot = snapshot(sig_path, "SHA256SUMS.sig") + sig_snapshot = snapshot(sig_path, "SHA256SUMS.sigstore") except ValueError as error: print(f"FAIL {error}") return 1 @@ -305,12 +321,16 @@ def seal(tag: str, dist: Path, dry_run: bool, required_fingerprint: str | None, arch_digests = {name: value[1] for name, value in archive_snapshots.items()} sums_dig_frozen = hashlib.sha256(sums_blob).hexdigest() sig_dig_frozen = sig_snapshot[1] + proof = _proof + if _proof is verify_signature: + proof = partial(verify_signature, certificate_identity=required_identity, + certificate_oidc_issuer=required_issuer) try: - signer, signed_at = _proof(sums, sig_path) - if signer != required_fingerprint.lower(): + signer, issuer, signed_at = proof(sums, sig_path) + if signer != required_identity or issuer != required_issuer: raise ValueError( - f"verified signer fingerprint mismatch: actual={signer} " - f"required={required_fingerprint.lower()}" + f"verified signer certificate mismatch: actual={signer}/{issuer} " + f"required={required_identity}/{required_issuer}" ) except (OSError, ValueError) as error: print(f"FAIL {error}") @@ -323,8 +343,8 @@ def _recheck_all() -> None: raise ValueError(f"{name} changed during verification") if _ensure_real_file_for_read(sums, "SHA256SUMS") != sums_blob: raise ValueError("SHA256SUMS changed during verification") - if snapshot(sig_path, "SHA256SUMS.sig") != sig_snapshot: - raise ValueError("SHA256SUMS.sig changed during verification") + if snapshot(sig_path, "SHA256SUMS.sigstore") != sig_snapshot: + raise ValueError("SHA256SUMS.sigstore changed during verification") if _ensure_real_file_for_read(manifest_path, "bran-release-manifest.json") != manifest_blob: raise ValueError("bran-release-manifest.json changed during verification") @@ -370,11 +390,14 @@ def _recheck_all() -> None: def test_p4_sealed_release() -> None: """The single named P4 journey covers dry-run and strict refusal paths.""" print("=== P4-SEALED-RELEASE self-test ===") - tag, fingerprint, head = "bran-v4.2.0", "0123456789abcdef0123456789abcdef01234567", "a" * 40 + tag = "bran-v4.2.0" + identity = f"https://github.com/alphazede/bran/.github/workflows/release.yml@refs/tags/{tag}" + issuer = contract.OIDC_ISSUER + head = "a" * 40 signed_at = "2026-01-02T03:04:05Z" lock_digest = digest(bran_root() / "Cargo.lock") good_git = lambda _root, _tag: (head, head, False, lock_digest) - good_proof = lambda _sums, _signature: (fingerprint, signed_at) + good_proof = lambda _sums, _signature: (identity, issuer, signed_at) def expect_rejection(label: str, action: Callable[[], int]) -> None: with redirect_stdout(io.StringIO()): @@ -383,27 +406,35 @@ def expect_rejection(label: str, action: Callable[[], int]) -> None: commands: list[list[str]] = [] - def fake_gpg(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + def fake_cosign(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: commands.append(command) - status = f"[GNUPG:] VALIDSIG {fingerprint.upper()} 2026-01-02 1767323045 0 4 0 1 10 00\n" - return subprocess.CompletedProcess(command, 0, status, "") - - verified = verify_signature( - Path("SHA256SUMS"), - Path("SHA256SUMS.sig"), - _which=lambda _name: "/usr/bin/gpg", - _run=fake_gpg, - ) - assert verified == (fingerprint, signed_at) - assert commands == [[ - "/usr/bin/gpg", "--batch", "--no-auto-key-retrieve", "--auto-key-locate", "clear", - "--status-fd", "1", "--verify", "SHA256SUMS.sig", "SHA256SUMS", - ]] - try: - verify_signature(Path("SHA256SUMS"), Path("SHA256SUMS.sig"), _which=lambda _name: None) - raise AssertionError("missing gpg was accepted") - except ValueError as error: - assert "gpg is not installed" in str(error) + return subprocess.CompletedProcess(command, 0, "Verified OK\n", "") + + with tempfile.TemporaryDirectory() as tmp: + bundle_path = Path(tmp) / "SHA256SUMS.sigstore" + bundle_path.write_text(json.dumps({"logEntry": {"integratedTime": 1767323045}}), encoding="utf-8") + verified = verify_signature( + Path(tmp) / "SHA256SUMS", + bundle_path, + certificate_identity=identity, + certificate_oidc_issuer=issuer, + _which=lambda _name: "/usr/bin/cosign", + _run=fake_cosign, + ) + assert verified == (identity, issuer, signed_at) + assert commands == [[ + "/usr/bin/cosign", "verify-blob", "--bundle", str(bundle_path), + "--certificate-identity", identity, + "--certificate-oidc-issuer", issuer, + str(Path(tmp) / "SHA256SUMS"), + ]] + try: + verify_signature(Path(tmp) / "SHA256SUMS", bundle_path, + certificate_identity=identity, certificate_oidc_issuer=issuer, + _which=lambda _name: None) + raise AssertionError("missing cosign was accepted") + except ValueError as error: + assert "cosign is not installed" in str(error) with tempfile.TemporaryDirectory() as tmp: dist = Path(tmp) @@ -411,60 +442,63 @@ def fake_gpg(command: list[str], **_kwargs: object) -> subprocess.CompletedProce (dist / name).write_bytes(f"artifact-{index}".encode()) missing = dist / archives(tag)[0] missing.unlink() - expect_rejection("missing archive", lambda: seal(tag, dist, True, None, good_git, good_proof)) + expect_rejection("missing archive", lambda: seal(tag, dist, True, None, None, good_git, good_proof)) missing.write_bytes(b"artifact-0") extra = dist / f"{tag}-unsupported.tar.gz" extra.symlink_to(archives(tag)[1]) - expect_rejection("unexpected archive symlink", lambda: seal(tag, dist, True, None, good_git, good_proof)) + expect_rejection("unexpected archive symlink", lambda: seal(tag, dist, True, None, None, good_git, good_proof)) extra.unlink() - expect_rejection("wrong tag", lambda: seal(tag, dist, True, None, lambda *_: (head, "b" * 40, False, lock_digest), good_proof)) - expect_rejection("dirty tree", lambda: seal(tag, dist, True, None, lambda *_: (head, head, True, lock_digest), good_proof)) - expect_rejection("wrong lock", lambda: seal(tag, dist, True, None, lambda *_: (head, head, False, "0" * 64), good_proof)) - assert seal(tag, dist, True, None, good_git, good_proof) == 0 + expect_rejection("wrong tag", lambda: seal(tag, dist, True, None, None, lambda *_: (head, "b" * 40, False, lock_digest), good_proof)) + expect_rejection("dirty tree", lambda: seal(tag, dist, True, None, None, lambda *_: (head, head, True, lock_digest), good_proof)) + expect_rejection("wrong lock", lambda: seal(tag, dist, True, None, None, lambda *_: (head, head, False, "0" * 64), good_proof)) + assert seal(tag, dist, True, None, None, good_git, good_proof) == 0 sums = dist / "SHA256SUMS" assert sums.read_text(encoding="utf-8") == expected_sums(archives(tag), dist) evidence = dist / "bran-release-evidence.unsigned.json" first = evidence.read_bytes() - assert seal(tag, dist, True, None, good_git, good_proof) == 0 and evidence.read_bytes() == first + assert seal(tag, dist, True, None, None, good_git, good_proof) == 0 and evidence.read_bytes() == first assert not (dist / "bran-release-manifest.json").exists() - signature = dist / "SHA256SUMS.sig" + signature = dist / "SHA256SUMS.sigstore" signature.write_bytes(b"final-state signature") - expect_rejection("signature-only dry-run", lambda: seal(tag, dist, True, None, good_git, good_proof)) + expect_rejection("signature-only dry-run", lambda: seal(tag, dist, True, None, None, good_git, good_proof)) signature.unlink() sums.write_text("drift\n", encoding="utf-8") - expect_rejection("checksum drift", lambda: seal(tag, dist, True, None, good_git, good_proof)) + expect_rejection("checksum drift", lambda: seal(tag, dist, True, None, None, good_git, good_proof)) sums.write_text(expected_sums(archives(tag), dist), encoding="utf-8") signature.write_bytes(b"not accepted by the injected verifier alone") manifest_path = dist / "bran-release-manifest.json" - expect_rejection("missing manifest", lambda: seal(tag, dist, False, fingerprint, good_git, good_proof)) + expect_rejection("missing manifest", lambda: seal(tag, dist, False, identity, issuer, good_git, good_proof)) + expect_rejection("missing issuer policy", lambda: seal(tag, dist, False, identity, None, good_git, good_proof)) + expect_rejection("wrong issuer policy", lambda: seal(tag, dist, False, identity, "https://evil.example/", good_git, good_proof)) manifest = expected_manifest( - tag, head, lock_digest, archives(tag), dist, sums, signature, fingerprint, signed_at + tag, head, lock_digest, archives(tag), dist, sums, signature, identity, signed_at ) manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") - expect_rejection("unverified signature", lambda: seal(tag, dist, False, fingerprint, good_git, lambda *_: (_ for _ in ()).throw(ValueError("signature verification unavailable: no key")))) - expect_rejection("wrong signer", lambda: seal(tag, dist, False, fingerprint, good_git, lambda *_: ("f" * 40, signed_at))) + expect_rejection("unverified signature", lambda: seal(tag, dist, False, identity, issuer, good_git, lambda *_: (_ for _ in ()).throw(ValueError("signature verification unavailable: certificate identity does not match")))) + expect_rejection("wrong signer", lambda: seal(tag, dist, False, identity, issuer, good_git, lambda *_: ("https://evil.example/", issuer, signed_at))) + expect_rejection("wrong issuer", lambda: seal(tag, dist, False, identity, issuer, good_git, lambda *_: (identity, "https://evil.example/", signed_at))) original_manifest = manifest_path.read_bytes() - assert seal(tag, dist, False, fingerprint, good_git, good_proof) == 0 + assert seal(tag, dist, False, identity, issuer, good_git, good_proof) == 0 assert manifest_path.read_bytes() == original_manifest original_sums = sums.read_bytes() - def mutating_proof(_sums: Path, _signature: Path) -> tuple[str, str]: + def mutating_proof(_sums: Path, _signature: Path) -> tuple[str, str, str]: sums.write_bytes(original_sums + b"drift") - return fingerprint, signed_at - expect_rejection("mutated supporting asset", lambda: seal(tag, dist, False, fingerprint, good_git, mutating_proof)) + return identity, issuer, signed_at + expect_rejection("mutated supporting asset", lambda: seal(tag, dist, False, identity, issuer, good_git, mutating_proof)) sums.write_bytes(original_sums) symlink_archive = dist / archives(tag)[0] saved_archive = dist / "saved-archive" symlink_archive.rename(saved_archive) symlink_archive.symlink_to(saved_archive.name) - expect_rejection("symlink archive", lambda: seal(tag, dist, False, fingerprint, good_git, good_proof)) + expect_rejection("symlink archive", lambda: seal(tag, dist, False, identity, issuer, good_git, good_proof)) symlink_archive.unlink() saved_archive.rename(symlink_archive) - expect_rejection("dry-run final-state inputs", lambda: seal(tag, dist, True, None, good_git, good_proof)) + expect_rejection("dry-run final-state inputs", lambda: seal(tag, dist, True, None, None, good_git, good_proof)) assert manifest_path.read_bytes() == original_manifest manifest["assets"][0]["sha256"] = "0" * 64 manifest_path.write_text(json.dumps(manifest), encoding="utf-8") tampered_manifest = manifest_path.read_bytes() - expect_rejection("tampered manifest", lambda: seal(tag, dist, False, fingerprint, good_git, good_proof)) + expect_rejection("tampered manifest", lambda: seal(tag, dist, False, identity, issuer, good_git, good_proof)) assert manifest_path.read_bytes() == tampered_manifest print("=== P4-SEALED-RELEASE self-test PASS ===") @@ -481,9 +515,11 @@ def main() -> int: parser.add_argument("--tag", required=True) parser.add_argument("--dist", required=True, type=Path) parser.add_argument("--dry-run-unsigned", action="store_true") - parser.add_argument("--fingerprint") + parser.add_argument("--certificate-identity") + parser.add_argument("--certificate-oidc-issuer") args = parser.parse_args() - return seal(args.tag, args.dist.resolve(), args.dry_run_unsigned, args.fingerprint) + return seal(args.tag, args.dist.resolve(), args.dry_run_unsigned, + args.certificate_identity, args.certificate_oidc_issuer) if __name__ == "__main__": From e7425c9f3920fcfcf5fcc13425e054d840dc715a Mon Sep 17 00:00:00 2001 From: 1wgrumph <1wgrumph@gmail.com> Date: Fri, 7 Aug 2026 10:30:58 -0500 Subject: [PATCH 05/10] Public export snapshot from bran-dev 5e6f0f5 Fixes release build portability on Windows and macOS runners. --- .bran-export.json | 6 +++--- .github/workflows/release.yml | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.bran-export.json b/.bran-export.json index 532a217..bbe265b 100644 --- a/.bran-export.json +++ b/.bran-export.json @@ -1,7 +1,7 @@ { "schema_version": 1, "source_repository": "alphazede/bran-dev", - "source_commit": "94b66ce2ea63b434a7571ef614121ecb5ee963bd", + "source_commit": "5e6f0f5379010dbbacdfcba8f61dc69c832c7883", "version": "0.1.0", "public_repository": "alphazede/bran", "files": [ @@ -26,8 +26,8 @@ { "path": ".github/workflows/release.yml", "mode": "100644", - "bytes": 6636, - "sha256": "584fb7bb2ceaab988be2500a7ab8598ed6ab073ce410546e7808ad5d5e3749a7" + "bytes": 7058, + "sha256": "53a63d2609b1ec1431894899f57dfc42256d211b163babc8a070fdde73acc661" }, { "path": ".gitignore", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ead78c..bb59822 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,15 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} + # build-release.sh packages archives with python3. Windows runners expose + # Python as `python`, not `python3`, so pin an interpreter explicitly. + - uses: actions/setup-python@v5 + with: + python-version: "3.12" - name: Build release archive + # build-release.sh is /bin/sh. The default shell on windows-latest is + # PowerShell, which cannot execute it; `bash` resolves to Git Bash there. + shell: bash run: ./tools/ci/build-release.sh --target "${{ matrix.target }}" --tag "${{ github.ref_name }}" --dist dist - uses: actions/upload-artifact@v4 with: From 208e9932f5df017491bae4d6108018b1f087ef2d Mon Sep 17 00:00:00 2001 From: 1wgrumph <1wgrumph@gmail.com> Date: Fri, 7 Aug 2026 11:29:44 -0500 Subject: [PATCH 06/10] Public export snapshot from bran-dev 73481c7 Rust xtask release packager, publish step, and pre-publish manifest validation. --- .bran-export.json | 36 +++-- .github/workflows/release.yml | 48 ++++-- Cargo.lock | 95 ++++++++++++ Cargo.toml | 2 +- tools/ci/build-release.sh | 64 +------- xtask/Cargo.toml | 10 ++ xtask/src/archive.rs | 285 ++++++++++++++++++++++++++++++++++ xtask/src/main.rs | 168 ++++++++++++++++++++ 8 files changed, 632 insertions(+), 76 deletions(-) create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/archive.rs create mode 100644 xtask/src/main.rs diff --git a/.bran-export.json b/.bran-export.json index bbe265b..d12a61d 100644 --- a/.bran-export.json +++ b/.bran-export.json @@ -1,7 +1,7 @@ { "schema_version": 1, "source_repository": "alphazede/bran-dev", - "source_commit": "5e6f0f5379010dbbacdfcba8f61dc69c832c7883", + "source_commit": "73481c7ba1770624dc89129a152b295decad5808", "version": "0.1.0", "public_repository": "alphazede/bran", "files": [ @@ -26,8 +26,8 @@ { "path": ".github/workflows/release.yml", "mode": "100644", - "bytes": 7058, - "sha256": "53a63d2609b1ec1431894899f57dfc42256d211b163babc8a070fdde73acc661" + "bytes": 8251, + "sha256": "86796ba128a0ba82473dea045ff26c48e67a7531125d44b034e39415d48749e8" }, { "path": ".gitignore", @@ -38,14 +38,14 @@ { "path": "Cargo.lock", "mode": "100644", - "bytes": 6486, - "sha256": "7305ad8cdf4a72f852943c840893720a9781c1445ed955d5fb9b019fb8d680a1" + "bytes": 8934, + "sha256": "84d67f2d5d5ec36a738e56400dab679d139cf7bb15b1012b3f9d9a48606a1602" }, { "path": "Cargo.toml", "mode": "100644", - "bytes": 96, - "sha256": "3322881f7489b12e80a2b7a727cec3d2173f9615f03dc6fbb5a28490037f6da3" + "bytes": 105, + "sha256": "b8ff81cee89b842d6970d0ecb1670c77dd98f28ddfacacc5564d430570768372" }, { "path": "LICENSE", @@ -680,8 +680,8 @@ { "path": "tools/ci/build-release.sh", "mode": "100755", - "bytes": 4290, - "sha256": "97b70095b0a16e8eb186fb24d2cfbd3bce14063b331c1b746e4582f6c74a1577" + "bytes": 2854, + "sha256": "f84343135189fb33fcbcb41e55792a87f63731bc9e2a710fc0e0adf4636e0204" }, { "path": "tools/ci/check.sh", @@ -730,6 +730,24 @@ "mode": "100644", "bytes": 33505, "sha256": "ff60cd69b4a5e7994004095441491fe24b3635159676ea4133955006b9187f1c" + }, + { + "path": "xtask/Cargo.toml", + "mode": "100644", + "bytes": 264, + "sha256": "a4786c44a9e41ef709353d292d586628775a6007115bfa33d64b68aa57b5fd75" + }, + { + "path": "xtask/src/archive.rs", + "mode": "100644", + "bytes": 11299, + "sha256": "b62ebac302bd3d718a29d6c22d2e7440780372ead11b313e8d7bf1a0177025b4" + }, + { + "path": "xtask/src/main.rs", + "mode": "100644", + "bytes": 5272, + "sha256": "142d55253c8c8912015a0c70a35675adc7dc899ffe5a998809a695b7e70cf169" } ] } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb59822..35f2fba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,16 +32,10 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - # build-release.sh packages archives with python3. Windows runners expose - # Python as `python`, not `python3`, so pin an interpreter explicitly. - - uses: actions/setup-python@v5 - with: - python-version: "3.12" + # The xtask packages archives with only cargo; no python interpreter or + # /bin/sh shim is needed on any runner. - name: Build release archive - # build-release.sh is /bin/sh. The default shell on windows-latest is - # PowerShell, which cannot execute it; `bash` resolves to Git Bash there. - shell: bash - run: ./tools/ci/build-release.sh --target "${{ matrix.target }}" --tag "${{ github.ref_name }}" --dist dist + run: cargo run --locked -p xtask -- package --target "${{ matrix.target }}" --tag "${{ github.ref_name }}" --dist dist - uses: actions/upload-artifact@v4 with: name: artifact-${{ matrix.target }} @@ -180,7 +174,43 @@ jobs: json.dumps(manifest, indent=2) + "\n", encoding="utf-8" ) PY + # Validate the generated manifest against the shipped release contract + # before anything is published. A manifest that violates its own contract + # must never reach a release. + - name: Verify the generated manifest satisfies the release contract + run: | + python3 - <<'PY' + import json + import pathlib + import sys + + sys.path.insert(0, "tools/ci") + import release_contract_check as contract + + manifest = json.loads( + pathlib.Path("dist/bran-release-manifest.json").read_text(encoding="utf-8") + ) + errors = contract.validate_manifest(manifest) + if errors: + print("FAIL generated manifest violates the release contract") + for error in errors: + print(f" {error}") + raise SystemExit(1) + print("PASS generated manifest satisfies the release contract") + PY - uses: actions/upload-artifact@v4 with: name: release-files path: dist/ + # Publish the release. Without this the assets exist only as workflow + # artifacts and every releases/download URL in the manifest would 404. + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + gh release create "$TAG" \ + --repo "${{ github.repository }}" \ + --title "$TAG" \ + --notes "Release $TAG. Verify with cosign: see the Releases section of the README." \ + dist/* diff --git a/Cargo.lock b/Cargo.lock index a1f60c1..65fb6db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "bitflags" version = "2.13.1" @@ -31,6 +37,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -66,6 +81,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -76,6 +97,32 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "libc" version = "0.2.186" @@ -109,6 +156,22 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -203,12 +266,24 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -251,3 +326,23 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "flate2", + "zip", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "indexmap", + "memchr", + "typed-path", +] diff --git a/Cargo.toml b/Cargo.toml index 2a06de8..31f367e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["crates/bran-cli", "crates/bran-core", "crates/bran-tui"] +members = ["crates/bran-cli", "crates/bran-core", "crates/bran-tui", "xtask"] resolver = "2" diff --git a/tools/ci/build-release.sh b/tools/ci/build-release.sh index 6fd0166..ec70c2c 100755 --- a/tools/ci/build-release.sh +++ b/tools/ci/build-release.sh @@ -1,6 +1,8 @@ #!/bin/sh # build-release.sh: plan + per-target package for exact 5 cross artifacts. -# --plan validates names only (non-mutating). Build uses --locked, fails on missing target. +# --plan validates names only (non-mutating). The build and packaging are +# delegated to the Rust xtask (cargo run -p xtask -- package), so the release +# path needs only cargo. Build uses --locked, fails on missing target. set -eu usage() { @@ -93,6 +95,7 @@ if $plan_mode; then exit 0 fi +# Reject unknown targets before delegating (artifact_name exits non-zero). name=$(artifact_name "$target") script_dir=$(CDPATH="" cd "$(dirname "$0")" && pwd -P) bran_root=$(CDPATH="" cd "$script_dir/../.." && pwd -P) @@ -102,60 +105,7 @@ if [ ! -f "$bran_root/Cargo.lock" ]; then exit 1 fi -mkdir -p "$dist" -printf 'BUILD target=%s tag=%s artifact=%s\n' "$target" "$tag" "$name" - +# The xtask performs the build and the deterministic packaging; cargo is the +# only runtime the release path needs. cd "$bran_root" -cargo build --release --locked --target "$target" --bin bran - -case "$target" in - "$WX86") bin_path="target/$target/release/bran.exe" ;; - *) bin_path="target/$target/release/bran" ;; -esac - -[ -f "$bin_path" ] || { printf 'FAIL binary not found: %s\n' "$bin_path" >&2; exit 1; } - -out="$dist/$name" -case "$target" in - "$WX86") member=bran.exe; package=zip ;; - *) member=bran; package=tar.gz ;; -esac - -python3 - "$bin_path" "$out" "$member" "$package" <<'PY' -import gzip -import io -import os -import sys -import tarfile -import zipfile - -binary, output, member, package = sys.argv[1:] -with open(binary, "rb") as source: - data = source.read() - -if package == "tar.gz": - with open(output, "wb") as raw: - with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: - with tarfile.open(fileobj=compressed, mode="w", format=tarfile.USTAR_FORMAT) as archive: - info = tarfile.TarInfo(member) - info.type = tarfile.REGTYPE - info.mode = 0o755 - info.uid = info.gid = info.mtime = 0 - info.uname = info.gname = "" - info.size = len(data) - archive.addfile(info, io.BytesIO(data)) -elif package == "zip": - info = zipfile.ZipInfo(member, (1980, 1, 1, 0, 0, 0)) - info.create_system = 3 - info.external_attr = 0o100755 << 16 - info.compress_type = zipfile.ZIP_STORED - info.extra = info.comment = b"" - with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as archive: - archive.comment = b"" - archive.writestr(info, data) -else: - raise RuntimeError("unsupported package format") -PY -[ -f "$out" ] || { printf 'FAIL no archive: %s\n' "$out" >&2; exit 1; } -printf 'CREATED %s\n' "$out" -exit 0 +exec cargo run --locked -p xtask -- package --target "$target" --tag "$tag" --dist "$dist" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..e6c96bc --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "xtask" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +flate2 = { version = "=1.1.9", default-features = false, features = ["rust_backend"] } +zip = { version = "=8.6.0", default-features = false } diff --git a/xtask/src/archive.rs b/xtask/src/archive.rs new file mode 100644 index 0000000..733d17d --- /dev/null +++ b/xtask/src/archive.rs @@ -0,0 +1,285 @@ +//! Deterministic archive writing. +//! +//! These writers are hand-rolled so every byte is under our control. The +//! properties match what `tools/ci/build-release.sh` used to produce with +//! python3's gzip/tarfile/zipfile. Byte identity with that output is not +//! required; determinism is: the same binary always yields the same archive +//! bytes, on every host. + +use std::fs; +use std::io::{self, Read, Seek, Write}; +use std::path::Path; + +/// Writes `data` as member `member` inside a gzip-compressed USTAR archive. +/// +/// Gzip: no filename, no extra fields, mtime 0. Tar: USTAR, single regular +/// file member with mode 0o755, uid 0, gid 0, mtime 0, empty uname/gname. +pub fn write_tar_gz(path: &Path, member: &str, data: &[u8]) -> io::Result<()> { + write_tar_gz_to(io::BufWriter::new(fs::File::create(path)?), member, data).map(|_| ()) +} + +fn write_tar_gz_to(mut writer: W, member: &str, data: &[u8]) -> io::Result { + let encoder = flate2::GzBuilder::new() + .mtime(0) + .write(&mut writer, flate2::Compression::default()); + let mut encoder = io::BufWriter::new(encoder); + write_ustar(&mut encoder, member, data)?; + encoder.flush()?; + let encoder = encoder.into_inner().map_err(io::Error::from)?; + encoder.finish()?; + Ok(writer) +} + +const TAR_BLOCK_SIZE: usize = 512; + +/// Writes a single-member USTAR archive: one 512-byte header, the file data +/// padded to the block boundary, and two zero blocks ending the archive. +fn write_ustar(writer: &mut W, member: &str, data: &[u8]) -> io::Result<()> { + if member.len() > 100 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("tar member name too long: {member}"), + )); + } + let mut header = [0u8; TAR_BLOCK_SIZE]; + header[..member.len()].copy_from_slice(member.as_bytes()); + write_octal(&mut header[100..108], 0o755)?; // mode + write_octal(&mut header[108..116], 0)?; // uid + write_octal(&mut header[116..124], 0)?; // gid + write_octal(&mut header[124..136], data.len() as u64)?; + write_octal(&mut header[136..148], 0)?; // mtime + header[156] = b'0'; // regular file + header[257..263].copy_from_slice(b"ustar\0"); + header[263..265].copy_from_slice(b"00"); + // uname, gname, devmajor, devminor and prefix stay zeroed. + // Checksum: the header with the checksum field treated as spaces. + header[148..156].fill(b' '); + let sum: u64 = header.iter().map(|&byte| u64::from(byte)).sum(); + write_octal(&mut header[149..156], sum)?; + writer.write_all(&header)?; + writer.write_all(data)?; + let padding = (TAR_BLOCK_SIZE - (data.len() % TAR_BLOCK_SIZE)) % TAR_BLOCK_SIZE; + if padding > 0 { + writer.write_all(&[0u8; TAR_BLOCK_SIZE][..padding])?; + } + writer.write_all(&[0u8; TAR_BLOCK_SIZE * 2])?; // end-of-archive marker + Ok(()) +} + +/// Writes a leading-zero octal number NUL-terminated into a fixed-width +/// field, the USTAR convention (e.g. mode 0o755 -> "0000755\0"). +fn write_octal(field: &mut [u8], value: u64) -> io::Result<()> { + let width = field.len() - 1; + let mut remaining = value; + for slot in field[..width].iter_mut().rev() { + *slot = b'0' + (remaining % 8) as u8; + remaining /= 8; + } + if remaining != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "value {value:#o} does not fit in {} octal digits", + width - 1 + ), + )); + } + field[width] = 0; + Ok(()) +} + +/// Writes `data` as member `member` inside a ZIP archive, STORED +/// (uncompressed), with no extra fields, no comments and no timestamps +/// beyond the DOS epoch 1980-01-01 00:00:00. +pub fn write_zip(path: &Path, member: &str, data: &[u8]) -> io::Result<()> { + write_zip_to(fs::File::create(path)?, member, data).map(|_| ()) +} + +fn write_zip_to(mut writer: W, member: &str, data: &[u8]) -> io::Result { + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored) + .unix_permissions(0o755) + .system(zip::System::Unix); + let mut archive = zip::ZipWriter::new(&mut writer); + archive.start_file(member, options)?; + archive.write_all(data)?; + archive.finish()?; + patch_zip_external_attr(&mut writer)?; + Ok(writer) +} + +/// Patches the central directory's external-attribute field to +/// `0o100755 << 16`. +/// +/// `unix_permissions` masks mode bits to 0o777, dropping the S_IFREG bit of +/// the 0o100755 mode the release contract specifies. The central directory +/// offset is read from the end-of-central-directory record, so the patch does +/// not depend on any particular header layout. +fn patch_zip_external_attr(writer: &mut W) -> io::Result<()> { + let end = writer.stream_position()?; + if end < 22 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "zip is too small to hold an end-of-central-directory record", + )); + } + writer.seek(io::SeekFrom::Start(end - 22))?; + let mut eocd = [0u8; 22]; + writer.read_exact(&mut eocd)?; + if eocd[..4] != [0x50, 0x4b, 0x05, 0x06] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "missing end-of-central-directory record", + )); + } + let central_offset = u64::from(u32::from_le_bytes([eocd[16], eocd[17], eocd[18], eocd[19]])); + // A central directory entry is: 4-byte signature, then 34 bytes of fixed + // fields, then the external-attribute field (APPNOTE 4.3.12). + writer.seek(io::SeekFrom::Start(central_offset + 4 + 34))?; + writer.write_all(&(0o100755u32 << 16).to_le_bytes())?; + writer.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A payload spanning several tar blocks with varied bytes. + fn payload() -> Vec { + let mut bytes = Vec::with_capacity(3000); + for i in 0..3000u32 { + bytes.push(((i * 31 + (i >> 3)) & 0xff) as u8); + } + bytes + } + + #[test] + fn tar_gz_is_deterministic() { + let data = payload(); + let first = write_tar_gz_to(Vec::new(), "bran", &data).unwrap(); + let second = write_tar_gz_to(Vec::new(), "bran", &data).unwrap(); + assert_eq!(first, second); + } + + #[test] + fn zip_is_deterministic() { + let data = payload(); + let first = write_zip_to(io::Cursor::new(Vec::new()), "bran.exe", &data) + .unwrap() + .into_inner(); + let second = write_zip_to(io::Cursor::new(Vec::new()), "bran.exe", &data) + .unwrap() + .into_inner(); + assert_eq!(first, second); + } + + #[test] + fn tar_gz_has_expected_member_metadata() { + let data = payload(); + let gzip = write_tar_gz_to(Vec::new(), "bran", &data).unwrap(); + // gzip header: magic, deflate, no flags (no filename/extra/comment), + // mtime 0. + assert_eq!(&gzip[..2], &[0x1f, 0x8b]); + assert_eq!(gzip[2], 8); + assert_eq!(gzip[3], 0); + assert_eq!(&gzip[4..8], &[0, 0, 0, 0]); + + let mut tar = Vec::new(); + flate2::read::GzDecoder::new(&gzip[..]) + .read_to_end(&mut tar) + .unwrap(); + assert_eq!(tar.len(), 512 + padded_len(data.len()) + 1024); + + let header = &tar[..512]; + assert_eq!(header_name(header), "bran"); + assert_eq!(parse_octal(&header[100..108]), 0o755); // mode + assert_eq!(parse_octal(&header[108..116]), 0); // uid + assert_eq!(parse_octal(&header[116..124]), 0); // gid + assert_eq!(parse_octal(&header[124..136]), data.len() as u64); + assert_eq!(parse_octal(&header[136..148]), 0); // mtime + assert_eq!(header[156], b'0'); // regular file + assert_eq!(&header[257..263], b"ustar\0"); + assert_eq!(&header[263..265], b"00"); + assert!(header[265..297].iter().all(|&b| b == 0)); // uname + assert!(header[297..329].iter().all(|&b| b == 0)); // gname + assert_eq!(&tar[512..512 + data.len()], &data[..]); + // checksum: header with the checksum field treated as spaces. + let mut sum: u64 = 0; + for (i, &byte) in header.iter().enumerate() { + sum += if (148..156).contains(&i) { + 0x20 + } else { + u64::from(byte) + }; + } + assert_eq!(parse_octal(&header[148..156]), sum); + // padding and the end-of-archive marker are zero. + assert!(tar[512 + data.len()..].iter().all(|&b| b == 0)); + } + + #[test] + fn zip_has_expected_entry_properties() { + let data = payload(); + let archive = write_zip_to(io::Cursor::new(Vec::new()), "bran.exe", &data) + .unwrap() + .into_inner(); + let (offset, count) = central_directory(&archive); + assert_eq!(count, 1); + let entry = &archive[offset..]; + assert_eq!(&entry[..4], &[0x50, 0x4b, 0x01, 0x02]); + let version_made_by = u16::from_le_bytes([entry[4], entry[5]]); + assert_eq!(version_made_by >> 8, 3); // create_system: Unix + let method = u16::from_le_bytes([entry[10], entry[11]]); + assert_eq!(method, 0); // STORED + let time = u16::from_le_bytes([entry[12], entry[13]]); + let date = u16::from_le_bytes([entry[14], entry[15]]); + assert_eq!(time, 0); + assert_eq!(date, 0x21); // DOS epoch: 1980-01-01 00:00:00 + let size = u32::from_le_bytes([entry[24], entry[25], entry[26], entry[27]]); + assert_eq!(size, data.len() as u32); + let name_len = u16::from_le_bytes([entry[28], entry[29]]); + let extra_len = u16::from_le_bytes([entry[30], entry[31]]); + let comment_len = u16::from_le_bytes([entry[32], entry[33]]); + assert_eq!(&entry[46..46 + name_len as usize], b"bran.exe"); + assert_eq!(extra_len, 0); + assert_eq!(comment_len, 0); + let external = u32::from_le_bytes([entry[38], entry[39], entry[40], entry[41]]); + assert_eq!(external, 0o100755 << 16); + // the archive comment is empty + assert_eq!(&archive[archive.len() - 2..], &[0, 0]); + } + + fn padded_len(n: usize) -> usize { + n.div_ceil(512) * 512 + } + + fn header_name(header: &[u8]) -> String { + let end = header[..100].iter().position(|&b| b == 0).unwrap_or(100); + String::from_utf8(header[..end].to_vec()).unwrap() + } + + fn parse_octal(field: &[u8]) -> u64 { + let s: String = field + .iter() + .map(|&b| b as char) + .skip_while(|&c| c == ' ') + .take_while(|&c| c != '\0' && c != ' ') + .collect(); + u64::from_str_radix(&s, 8).unwrap() + } + + /// Returns the offset and entry count of the central directory. + fn central_directory(archive: &[u8]) -> (usize, usize) { + let eocd = archive.len() - 22; + assert_eq!(&archive[eocd..eocd + 4], &[0x50, 0x4b, 0x05, 0x06]); + let count = u16::from_le_bytes([archive[eocd + 10], archive[eocd + 11]]) as usize; + let offset = u32::from_le_bytes([ + archive[eocd + 16], + archive[eocd + 17], + archive[eocd + 18], + archive[eocd + 19], + ]) as usize; + (offset, count) + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..dc9be74 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,168 @@ +//! BRAN release packager (xtask). +//! +//! Replaces the shell+python packaging that `tools/ci/build-release.sh` used +//! to inline, so the release path needs only cargo: +//! +//! ```text +//! cargo run -p xtask -- package --target --tag --dist +//! ``` +//! +//! Builds the `bran` binary with `--locked` and writes the deterministic +//! archive described in `tools/ci/build-release.sh` and `docs/plans/`. + +mod archive; + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; + +/// The exact five release targets. Anything else is rejected. +const TARGETS: [&str; 5] = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", +]; + +const WINDOWS_TARGET: &str = "x86_64-pc-windows-msvc"; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("FAIL {message}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let mut args = env::args().skip(1); + match args.next().as_deref() { + Some("package") => package(args), + Some(other) => Err(format!("unknown command: {other}")), + None => Err(usage()), + } +} + +fn usage() -> String { + "usage: cargo run -p xtask -- package --target TRIPLE --tag TAG --dist DIR".to_string() +} + +fn package(args: impl Iterator) -> Result<(), String> { + let mut target = None; + let mut tag = None; + let mut dist = None; + let mut args = args.into_iter(); + while let Some(option) = args.next() { + let value = match option.as_str() { + "--target" | "--tag" | "--dist" => args + .next() + .ok_or_else(|| format!("missing value for {option}"))?, + _ => return Err(format!("unknown option: {option}")), + }; + match option.as_str() { + "--target" => target = Some(value), + "--tag" => tag = Some(value), + _ => dist = Some(value), + } + } + let target = target.ok_or("missing --target TRIPLE")?; + let tag = tag.ok_or("missing --tag TAG")?; + let dist = dist.ok_or("missing --dist DIR")?; + + if !TARGETS.contains(&target.as_str()) { + return Err(format!( + "unknown target: {target} (expected one of: {})", + TARGETS.join(", ") + )); + } + validate_tag(&tag)?; + + let archive_name = archive_name(&tag, &target); + println!("BUILD target={target} tag={tag} artifact={archive_name}"); + + let root = workspace_root()?; + let status = Command::new("cargo") + .args([ + "build", + "--release", + "--locked", + "--target", + target.as_str(), + "--bin", + "bran", + ]) + .current_dir(&root) + .status() + .map_err(|error| format!("failed to run cargo build: {error}"))?; + if !status.success() { + return Err("cargo build failed".to_string()); + } + + let binary_name = if target == WINDOWS_TARGET { + "bran.exe" + } else { + "bran" + }; + let binary_path = root + .join("target") + .join(&target) + .join("release") + .join(binary_name); + let binary = fs::read(&binary_path) + .map_err(|error| format!("binary not found at {}: {error}", binary_path.display()))?; + + let dist = PathBuf::from(dist); + let dist = if dist.is_absolute() { + dist + } else { + env::current_dir() + .map_err(|error| format!("cannot resolve --dist: {error}"))? + .join(dist) + }; + fs::create_dir_all(&dist).map_err(|error| format!("cannot create --dist dir: {error}"))?; + let out_path = dist.join(&archive_name); + let result = if target == WINDOWS_TARGET { + archive::write_zip(&out_path, binary_name, &binary) + } else { + archive::write_tar_gz(&out_path, binary_name, &binary) + }; + result.map_err(|error| format!("failed to write {}: {error}", out_path.display()))?; + println!("CREATED {}", out_path.display()); + Ok(()) +} + +/// Rejects tags that could not be a safe single path component: the tag is +/// part of the artifact filename. +fn validate_tag(tag: &str) -> Result<(), String> { + if tag.is_empty() || tag == "." || tag == ".." || tag.contains('/') || tag.contains('\\') { + return Err(format!("invalid tag: {tag:?}")); + } + Ok(()) +} + +/// The workspace root is the parent of the xtask crate; cargo sets +/// CARGO_MANIFEST_DIR whenever the xtask is built, so this works no matter +/// which directory the caller runs `cargo run -p xtask` from. +fn workspace_root() -> Result { + let manifest_dir = env::var_os("CARGO_MANIFEST_DIR") + .ok_or("CARGO_MANIFEST_DIR is not set; run via `cargo run -p xtask`")?; + let manifest_dir = PathBuf::from(manifest_dir); + manifest_dir.parent().map(Path::to_path_buf).ok_or_else(|| { + format!( + "CARGO_MANIFEST_DIR has no parent: {}", + manifest_dir.display() + ) + }) +} + +fn archive_name(tag: &str, target: &str) -> String { + if target == WINDOWS_TARGET { + format!("{tag}-{target}.zip") + } else { + format!("{tag}-{target}.tar.gz") + } +} From b5047a2673c9d8eb9375fc0ba97a5e5e300f1d57 Mon Sep 17 00:00:00 2001 From: 1wgrumph <1wgrumph@gmail.com> Date: Fri, 7 Aug 2026 11:35:58 -0500 Subject: [PATCH 07/10] Public export snapshot from bran-dev 32f3792 Pin third-party release actions to commit SHAs. --- .bran-export.json | 6 +++--- .github/workflows/release.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bran-export.json b/.bran-export.json index d12a61d..562cf89 100644 --- a/.bran-export.json +++ b/.bran-export.json @@ -1,7 +1,7 @@ { "schema_version": 1, "source_repository": "alphazede/bran-dev", - "source_commit": "73481c7ba1770624dc89129a152b295decad5808", + "source_commit": "32f37928f77b21b1d1a55a22862c91f61fce7ed0", "version": "0.1.0", "public_repository": "alphazede/bran", "files": [ @@ -26,8 +26,8 @@ { "path": ".github/workflows/release.yml", "mode": "100644", - "bytes": 8251, - "sha256": "86796ba128a0ba82473dea045ff26c48e67a7531125d44b034e39415d48749e8" + "bytes": 8337, + "sha256": "ac6713baf1765339026fd05e1161e07eb1b943c21a6553daf77ad7081a73a1de" }, { "path": ".gitignore", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35f2fba..17db837 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: runner: windows-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: targets: ${{ matrix.target }} # The xtask packages archives with only cargo; no python interpreter or @@ -81,7 +81,7 @@ jobs: lines.append(f"{digest} {name}") (dist / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8") PY - - uses: sigstore/cosign-installer@v3 + - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 - name: Sign SHA256SUMS with cosign keyless run: cosign sign-blob --yes --bundle dist/SHA256SUMS.sigstore dist/SHA256SUMS - name: Emit bran-release-manifest.json From 237bfdd7139ea6e8b19c31c496f0fb93ec6643d4 Mon Sep 17 00:00:00 2001 From: 1wgrumph <1wgrumph@gmail.com> Date: Fri, 7 Aug 2026 16:28:00 -0500 Subject: [PATCH 08/10] Public export snapshot from bran-dev cd396e1 Fixes Windows release packaging: the zip handle must be readable. --- .bran-export.json | 6 +++--- xtask/src/archive.rs | 47 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/.bran-export.json b/.bran-export.json index 562cf89..66dc83c 100644 --- a/.bran-export.json +++ b/.bran-export.json @@ -1,7 +1,7 @@ { "schema_version": 1, "source_repository": "alphazede/bran-dev", - "source_commit": "32f37928f77b21b1d1a55a22862c91f61fce7ed0", + "source_commit": "cd396e16a2f4c9a53d95cab6eb2ef2d617964351", "version": "0.1.0", "public_repository": "alphazede/bran", "files": [ @@ -740,8 +740,8 @@ { "path": "xtask/src/archive.rs", "mode": "100644", - "bytes": 11299, - "sha256": "b62ebac302bd3d718a29d6c22d2e7440780372ead11b313e8d7bf1a0177025b4" + "bytes": 13176, + "sha256": "65241bbec66e7d6cbe542186126f8f52a7241ed2d3cdde75ed2727f6965cec28" }, { "path": "xtask/src/main.rs", diff --git a/xtask/src/archive.rs b/xtask/src/archive.rs index 733d17d..05d6655 100644 --- a/xtask/src/archive.rs +++ b/xtask/src/archive.rs @@ -92,7 +92,17 @@ fn write_octal(field: &mut [u8], value: u64) -> io::Result<()> { /// (uncompressed), with no extra fields, no comments and no timestamps /// beyond the DOS epoch 1980-01-01 00:00:00. pub fn write_zip(path: &Path, member: &str, data: &[u8]) -> io::Result<()> { - write_zip_to(fs::File::create(path)?, member, data).map(|_| ()) + // patch_zip_external_attr reads the end-of-central-directory record back, + // so the handle must be readable. File::create is write-only: that compiles + // (File: Read) but fails at runtime with os error 5 on Windows and os + // error 9 on Unix. + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(path)?; + write_zip_to(file, member, data).map(|_| ()) } fn write_zip_to(mut writer: W, member: &str, data: &[u8]) -> io::Result { @@ -282,4 +292,39 @@ mod tests { ]) as usize; (offset, count) } + + /// Regression: `write_zip` opens a real file, unlike the `Cursor`-based + /// tests above. `File::create` yields a write-only handle, and + /// `patch_zip_external_attr` reads the archive back — which compiles, + /// because `File: Read`, but fails at runtime. Windows reports os error 5 + /// and Linux os error 9. This is the only test that covers the path-taking + /// entry point the release actually calls. + #[test] + fn write_zip_writes_a_real_file_it_can_read_back() { + let dir = std::env::temp_dir().join(format!("bran-xtask-zip-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("bran-test-x86_64-pc-windows-msvc.zip"); + let data = b"windows binary bytes".to_vec(); + + write_zip(&path, "bran.exe", &data).expect("write_zip must succeed against a real file"); + + let written = fs::read(&path).unwrap(); + assert!(!written.is_empty(), "archive must not be empty"); + assert_eq!( + &written[..4], + &[0x50, 0x4b, 0x03, 0x04], + "must start with a local file header" + ); + + // Deterministic: a second write of the same input is byte-identical. + let second = dir.join("second.zip"); + write_zip(&second, "bran.exe", &data).unwrap(); + assert_eq!( + written, + fs::read(&second).unwrap(), + "zip output must be deterministic" + ); + + fs::remove_dir_all(&dir).unwrap(); + } } From 66b0d3a505a9df7f0096ecc625f5a14fe0c30073 Mon Sep 17 00:00:00 2001 From: 1wgrumph <1wgrumph@gmail.com> Date: Fri, 7 Aug 2026 16:59:07 -0500 Subject: [PATCH 09/10] Public export snapshot from bran-dev 9c2e7da Build the Intel macOS target on an Apple Silicon runner. --- .bran-export.json | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bran-export.json b/.bran-export.json index 66dc83c..f9c55bb 100644 --- a/.bran-export.json +++ b/.bran-export.json @@ -1,7 +1,7 @@ { "schema_version": 1, "source_repository": "alphazede/bran-dev", - "source_commit": "cd396e16a2f4c9a53d95cab6eb2ef2d617964351", + "source_commit": "9c2e7dab3c67503615bf6ccc0d32f754cb679544", "version": "0.1.0", "public_repository": "alphazede/bran", "files": [ @@ -27,7 +27,7 @@ "path": ".github/workflows/release.yml", "mode": "100644", "bytes": 8337, - "sha256": "ac6713baf1765339026fd05e1161e07eb1b943c21a6553daf77ad7081a73a1de" + "sha256": "0c4ca4de81371c50f95ebfb13103b28557ad48aeecbcac8f3cb0712adee38a37" }, { "path": ".gitignore", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 17db837..092d500 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: - target: aarch64-unknown-linux-gnu runner: ubuntu-24.04-arm - target: x86_64-apple-darwin - runner: macos-13 + runner: macos-14 - target: aarch64-apple-darwin runner: macos-14 - target: x86_64-pc-windows-msvc From 4097d3359b78fde68eccbd193d04c8c38906449f Mon Sep 17 00:00:00 2001 From: 1wgrumph <1wgrumph@gmail.com> Date: Fri, 7 Aug 2026 17:16:06 -0500 Subject: [PATCH 10/10] Public export snapshot from bran-dev 0a1de91 Read the Rekor inclusion time from either cosign bundle shape. --- .bran-export.json | 6 +++--- .github/workflows/release.yml | 32 +++++++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/.bran-export.json b/.bran-export.json index f9c55bb..580cc23 100644 --- a/.bran-export.json +++ b/.bran-export.json @@ -1,7 +1,7 @@ { "schema_version": 1, "source_repository": "alphazede/bran-dev", - "source_commit": "9c2e7dab3c67503615bf6ccc0d32f754cb679544", + "source_commit": "0a1de91258669123a3488af9b20d47a4d16b53c2", "version": "0.1.0", "public_repository": "alphazede/bran", "files": [ @@ -26,8 +26,8 @@ { "path": ".github/workflows/release.yml", "mode": "100644", - "bytes": 8337, - "sha256": "0c4ca4de81371c50f95ebfb13103b28557ad48aeecbcac8f3cb0712adee38a37" + "bytes": 9504, + "sha256": "e4591fa17f50387917c6a64a7590594646e0fe75727852c43757d6b37ede4387" }, { "path": ".gitignore", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 092d500..95d45fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,18 +131,40 @@ jobs: ] sums_digest = digest(dist / "SHA256SUMS") sig_digest = digest(dist / "SHA256SUMS.sigstore") + bundle = json.loads((dist / "SHA256SUMS.sigstore").read_text(encoding="utf-8")) + + def rekor_integrated_time(doc): + """Rekor inclusion time, from either cosign bundle shape. + + `cosign sign-blob --bundle` writes the legacy cosign bundle + (rekorBundle.Payload.integratedTime). `--new-bundle-format` + writes a Sigstore v0.3 bundle + (verificationMaterial.tlogEntries[].integratedTime, a string). + Fail closed rather than publish a manifest with an invented time. + """ + legacy = (doc.get("rekorBundle") or {}).get("Payload") or {} + if "integratedTime" in legacy: + return int(legacy["integratedTime"]), "application/vnd.dev.cosign.simplesigning.v1+json" + entries = (doc.get("verificationMaterial") or {}).get("tlogEntries") or [] + if entries and "integratedTime" in entries[0]: + return int(entries[0]["integratedTime"]), "application/vnd.dev.sigstore.bundle.v0.3+json" + raise SystemExit( + "cosign bundle has no recognised Rekor integratedTime; keys: " + + ", ".join(sorted(doc)) + ) + + integrated_time, bundle_media_type = rekor_integrated_time(bundle) + signed_at = datetime.fromtimestamp(integrated_time, timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) assets += [ {"name": "SHA256SUMS", "url": f"https://github.com/alphazede/bran/releases/download/{tag}/SHA256SUMS", "sha256": sums_digest, "media_type": "text/plain"}, {"name": "SHA256SUMS.sigstore", "url": f"https://github.com/alphazede/bran/releases/download/{tag}/SHA256SUMS.sigstore", - "sha256": sig_digest, "media_type": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "sha256": sig_digest, "media_type": bundle_media_type}, ] - bundle = json.loads((dist / "SHA256SUMS.sigstore").read_text(encoding="utf-8")) - signed_at = datetime.fromtimestamp( - bundle["logEntry"]["integratedTime"], timezone.utc - ).strftime("%Y-%m-%dT%H:%M:%SZ") lockfile_digest = digest(pathlib.Path("Cargo.lock")) manifest = { "schema_version": "1.0.0",