From 7079dd0611f24240fa04bb2462126a0f4636303b Mon Sep 17 00:00:00 2001 From: RNT56 Date: Sun, 12 Jul 2026 10:19:17 +0200 Subject: [PATCH 1/3] Harden CrustCore end-to-end trust boundaries --- .github/CODEOWNERS | 49 +- .github/dependabot.yml | 16 + .github/workflows/ci.yml | 27 +- .github/workflows/release.yml | 10 +- CHANGELOG.md | 27 + Cargo.lock | 423 +++++++---- Cargo.toml | 4 +- README.md | 43 +- THREAT_MODEL.md | 3 + crates/crustcore-backend/src/integrate.rs | 18 +- crates/crustcore-backend/src/lib.rs | 14 +- crates/crustcore-backend/src/verify.rs | 17 +- crates/crustcore-backend/src/worker.rs | 32 +- crates/crustcore-chat/src/lib.rs | 4 +- crates/crustcore-chat/src/terminal.rs | 77 +- crates/crustcore-daemon/Cargo.toml | 3 + .../src/bin/crustcore-daemon.rs | 141 +++- crates/crustcore-daemon/src/exec.rs | 18 +- crates/crustcore-daemon/src/github.rs | 24 +- crates/crustcore-daemon/src/onboarding.rs | 46 +- crates/crustcore-daemon/src/product.rs | 2 +- crates/crustcore-daemon/src/registry.rs | 7 +- crates/crustcore-daemon/src/runtime.rs | 25 +- crates/crustcore-daemon/src/task.rs | 677 ++++++++++++++++-- crates/crustcore-dev/src/auth.rs | 2 +- crates/crustcore-eval/tests/golden.rs | 37 +- crates/crustcore-eval/tests/redteam.rs | 16 +- crates/crustcore-eventlog/src/lib.rs | 68 ++ crates/crustcore-flow/tests/live_flow.rs | 8 +- crates/crustcore-kernel/src/event.rs | 13 +- crates/crustcore-kernel/src/kernel.rs | 91 ++- crates/crustcore-net/Cargo.toml | 16 +- crates/crustcore-net/src/credsource.rs | 18 +- crates/crustcore-net/src/githubapp.rs | 220 +++--- crates/crustcore-net/src/lib.rs | 2 +- crates/crustcore-policy/src/caps.rs | 159 +++- crates/crustcore-policy/src/decision.rs | 25 +- crates/crustcore-receipts/Cargo.toml | 1 + crates/crustcore-receipts/src/lib.rs | 210 +++++- crates/crustcore-sandbox/src/lib.rs | 329 +++++++-- crates/crustcore-secrets/Cargo.toml | 4 +- crates/crustcore-secrets/src/lib.rs | 41 +- crates/crustcore-secrets/src/store.rs | 34 +- crates/crustcore-telemetry/src/run.rs | 2 +- crates/crustcore-types/src/budget.rs | 44 +- crates/crustcore-worktree/src/tools.rs | 132 ++-- crates/crustcore-zeroize/Cargo.toml | 11 + crates/crustcore-zeroize/src/lib.rs | 127 ++++ crates/crustcore/src/main.rs | 468 ++++++++++-- docs/chat.md | 16 +- docs/event-log.md | 7 + docs/github.md | 23 +- docs/nano-size-budget.md | 5 +- docs/receipts.md | 16 +- docs/sandbox.md | 37 +- docs/secrets.md | 2 +- 56 files changed, 3009 insertions(+), 882 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 crates/crustcore-zeroize/Cargo.toml create mode 100644 crates/crustcore-zeroize/src/lib.rs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4280b13..f272fa4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,25 +1,36 @@ # CrustCore code owners. # -# Contract files (CLAUDE.md §7.3, ROADMAP.md §20.2) are the trust boundary: -# changes are serialized and require maintainer review. Listing them here makes -# that review a required, automated gate rather than a convention. +# Contract files and trust-boundary implementations are serialized and require +# maintainer review. Branch protection must require code-owner approval for this +# file to become an enforced server-side gate. # Default owner for everything. -* @RNT56 +* @RNT56 -# Contract files — never edit in parallel; maintainer review required. -/CLAUDE.md @RNT56 -/AGENTS.md @RNT56 -/INVARIANTS.md @RNT56 -/THREAT_MODEL.md @RNT56 -/SECURITY.md @RNT56 -/Cargo.toml @RNT56 -/Cargo.lock @RNT56 -/docs/policy.md @RNT56 -/docs/secrets.md @RNT56 -/docs/sandbox.md @RNT56 -/docs/backend-contract.md @RNT56 -/crates/crustcore-kernel/src/event.rs @RNT56 -/crates/crustcore-kernel/src/action.rs @RNT56 +# Repository contracts and release/security metadata. +/CLAUDE.md @RNT56 +/AGENTS.md @RNT56 +/INVARIANTS.md @RNT56 +/THREAT_MODEL.md @RNT56 +/SECURITY.md @RNT56 +/Cargo.toml @RNT56 +/Cargo.lock @RNT56 +/.github/workflows/ @RNT56 + +# Public trust contracts. +/docs/policy.md @RNT56 +/docs/secrets.md @RNT56 +/docs/sandbox.md @RNT56 +/docs/backend-contract.md @RNT56 + +# Code-level trust boundaries. +/crates/crustcore-kernel/src/event.rs @RNT56 +/crates/crustcore-kernel/src/action.rs @RNT56 +/crates/crustcore-kernel/src/state.rs @RNT56 /crates/crustcore-policy/src/decision.rs @RNT56 -/crates/crustcore-secrets/src/lib.rs @RNT56 +/crates/crustcore-policy/src/caps.rs @RNT56 +/crates/crustcore-backend/src/lib.rs @RNT56 +/crates/crustcore-backend/src/integrate.rs @RNT56 +/crates/crustcore-secrets/ @RNT56 +/crates/crustcore-zeroize/ @RNT56 +/crates/crustcore-sandbox/ @RNT56 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5d2ebea --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + rust-security-and-maintenance: + patterns: + - "*" + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2d0ad7..1030747 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,21 @@ env: CARGO_TERM_COLOR: always jobs: + security-audit: + name: dependency audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.cargo + key: audit-${{ hashFiles('Cargo.lock') }} + - name: Install cargo-audit + run: cargo install cargo-audit --locked + - name: Reject vulnerable locked dependencies + run: cargo audit --deny warnings + verify: name: verify (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -24,10 +39,10 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: components: rustfmt, clippy @@ -49,7 +64,7 @@ jobs: && echo "bwrap functional" || echo "bwrap present but not functional (tests will gate-only)" - name: Cache cargo registry and target - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | ~/.cargo/registry @@ -87,9 +102,9 @@ jobs: name: nano size gate (<800kB) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/cache@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | ~/.cargo/registry diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba9c2c7..ad458fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,10 +25,10 @@ jobs: - { os: ubuntu-latest, target: x86_64-linux } # the flagship size target - { os: macos-latest, target: aarch64-macos } steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Build the release artifacts shell: bash @@ -69,7 +69,7 @@ jobs: fi cat "SHA256SUMS-${{ matrix.target }}.txt" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: dist-${{ matrix.target }} path: dist/* @@ -81,9 +81,9 @@ jobs: if: startsWith(github.ref, 'refs/tags/') # only on a tag, never on a manual dry-run runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 # for the release-notes file + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: dist merge-multiple: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 308c480..3abf00a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -378,6 +378,23 @@ agent/PR/role/size/invariant audit trail. ### Fixed +- **End-to-end trust-boundary hardening.** macOS Seatbelt now denies host reads and + writes by default, uses a private per-run temp directory, and binds every sandbox + capability to the exact profile it authorizes. Capability fields are sealed behind + policy-issued constructors; task creation refuses missing or partially unlimited + budgets (including finite daemon token ceilings); verifier attestations are + non-cloneable; concurrent daemon tasks receive unique durable ids and worktrees. +- **Durable verifier evidence.** `crustcore run` and live daemon tasks now persist + owner-only atomic event logs, embed the authenticated receipt in the matching + `ToolCallCompleted` frame, verify the receipt-to-log join before completion, and use + an owner-only persistent receipt key. `crustcore inspect` authenticates and joins + persisted receipts instead of checking only frame hashes. +- **Verified draft-PR execution.** The live daemon now validates repo/ref configuration, + commits and verifies the exact candidate, persists its authenticated evidence before + integration, pushes through an owner-only ephemeral credential-helper lease, and opens + a real draft PR through the GitHub REST adapter. No-op workers are refused even when + they claim completion, and native mode refuses coding goals it cannot implement. + - **Final-polish audit (multi-agent) — fixed 5 union-merge + hygiene defects.** A comprehensive end-to-end audit (6 finder dimensions; every finding adversarially verified to reject churn) surfaced, and this fixes: a missing untrusted-input bound on @@ -392,6 +409,15 @@ agent/PR/role/size/invariant audit trail. ### Changed +- **Supply-chain baseline hardened.** GitHub App RS256 signing moved from the + RustCrypto `rsa` crate with its unpatched timing advisory to `jsonwebtoken` backed + by AWS-LC. Sidecar secret values use `zeroize`; nano secret bytes and receipt MAC + keys use the tiny audited first-party `crustcore-zeroize` primitive, preserving + the zero-third-party nano graph. CI runs + `cargo audit --deny warnings`; workflow actions are immutable-SHA pinned; Dependabot + and CODEOWNERS cover dependencies, workflows, contracts, secrets, and sandbox code. + The minimum supported Rust version is now 1.88. + - **Consolidated the duplicated security primitives into `crustcore-types::hash`.** The constant-time 32-byte compare (`ct_eq`) and the hex decoders (`hex_val`, `hex32_decode`) were copy-pasted across `crustcore-receipts`, `crustcore-daemon` (webhook + Slack), and @@ -411,6 +437,7 @@ agent/PR/role/size/invariant audit trail. | Date | Phase/Task | Change | PR / Branch | Agent / Role | Nano Δ | Invariants | | --- | --- | --- | --- | --- | --- | --- | +| 2026-07-12 | end-to-end-hardening | Sandbox/capability/budget seals; non-cloneable verifier evidence; durable event-log + receipt join; real confined verified-patch-to-draft-PR path; unique daemon task ids; AWS-LC JWT migration; supply-chain policy | `codex/end-to-end-hardening` | Codex (Supervisor/Implementer) | +16.5 KiB local macOS nano (444.6 KiB total, 55.6%) | Enforces 1-3, 6-14, 18-20; completion now requires persisted joined evidence | | 2026-06-28 | v0.6/E.1 | Cockpit view core: `build_cockpit` composes TaskDetail/EvidenceSummary(refs-only)/ApprovalForm(op-hash-bound) from the read-model, bounded; renders evidence, mints nothing. Completes Phase E + all of v0.6 | `claude/v06-e1-cockpit` | Claude (Implementer) | 0 kB (crustcore-dev) | Enforces 2, 5, 11, 13, 14; read-model only, op-bound approvals, no minting | | 2026-06-28 | v0.6/E.3 | `slack::SlackAllowlist` + `normalize_message` mirroring Telegram (same RuntimeEvent stream + gates, deny-all empty) + redacted `render_to_slack`; live API `#[ignore]`d | `claude/v06-e3-slack` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 1-5, 7, 8, 11, 15, 16; opt-in, redacted, same dispatch as Telegram | | 2026-06-28 | v0.6/F.1 | `snapshot_all`/`adopt_from_snapshot` cross-process recovery: stable ids, re-leased under new owner, Pending-resume-from-log, carried-usage re-charge, over-budget→terminal. Completes Phase F | `claude/v06-f1-recovery` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 11, 12, 13; recovery restores supervision, never completion | diff --git a/Cargo.lock b/Cargo.lock index c1d6131..ae8ec55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,30 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.7.9" @@ -132,12 +156,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "block-buffer" version = "0.10.4" @@ -147,6 +165,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.0" @@ -160,6 +184,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -180,10 +206,13 @@ dependencies = [ ] [[package]] -name = "const-oid" -version = "0.9.6" +name = "cmake" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] [[package]] name = "cpufeatures" @@ -261,6 +290,7 @@ version = "0.5.0" dependencies = [ "crustcore-backend", "crustcore-chat", + "crustcore-eventlog", "crustcore-kernel", "crustcore-net", "crustcore-netproto", @@ -271,6 +301,7 @@ dependencies = [ "crustcore-secrets", "crustcore-types", "crustcore-worktree", + "zeroize", ] [[package]] @@ -412,13 +443,12 @@ dependencies = [ name = "crustcore-net" version = "0.5.0" dependencies = [ - "base64", "crustcore-netproto", "crustcore-types", - "rsa", + "jsonwebtoken", "serde_json", - "sha2", "ureq", + "zeroize", ] [[package]] @@ -448,6 +478,7 @@ name = "crustcore-receipts" version = "0.5.0" dependencies = [ "crustcore-types", + "crustcore-zeroize", ] [[package]] @@ -473,8 +504,10 @@ version = "0.5.0" dependencies = [ "aes-gcm", "crustcore-types", - "getrandom", + "crustcore-zeroize", + "getrandom 0.2.17", "scrypt", + "zeroize", ] [[package]] @@ -542,6 +575,10 @@ dependencies = [ "crustcore-types", ] +[[package]] +name = "crustcore-zeroize" +version = "0.5.0" + [[package]] name = "crypto-common" version = "0.1.7" @@ -563,15 +600,10 @@ dependencies = [ ] [[package]] -name = "der" -version = "0.7.10" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "digest" @@ -580,7 +612,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "const-oid", "crypto-common", "subtle", ] @@ -596,6 +627,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "equivalent" version = "1.0.2" @@ -627,6 +664,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -681,6 +724,17 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "ghash" version = "0.5.1" @@ -921,25 +975,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "lazy_static" -version = "1.5.0" +name = "jobserver" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "spin", + "getrandom 0.4.3", + "libc", ] [[package]] -name = "libc" -version = "0.2.186" +name = "js-sys" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] [[package]] -name = "libm" -version = "0.2.16" +name = "libc" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "litemap" @@ -993,38 +1071,27 @@ dependencies = [ ] [[package]] -name = "num-bigint-dig" -version = "0.8.6" +name = "num-bigint" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ - "lazy_static", - "libm", "num-integer", - "num-iter", "num-traits", - "rand", - "smallvec", - "zeroize", ] [[package]] -name = "num-integer" -version = "0.1.46" +name = "num-conv" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] -name = "num-iter" -version = "0.1.45" +name = "num-integer" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "autocfg", - "num-integer", "num-traits", ] @@ -1035,7 +1102,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -1061,12 +1127,13 @@ dependencies = [ ] [[package]] -name = "pem-rfc7468" -version = "0.7.0" +name = "pem" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64ct", + "base64", + "serde_core", ] [[package]] @@ -1082,25 +1149,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" +name = "pkg-config" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "polyval" @@ -1124,13 +1176,10 @@ dependencies = [ ] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "proc-macro2" @@ -1151,24 +1200,10 @@ dependencies = [ ] [[package]] -name = "rand" -version = "0.8.6" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand_core" @@ -1176,7 +1211,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -1216,32 +1251,12 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rustls" version = "0.23.40" @@ -1274,7 +1289,7 @@ checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -1397,7 +1412,6 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", "rand_core", ] @@ -1407,6 +1421,18 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -1429,22 +1455,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1506,6 +1516,56 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1686,6 +1746,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -1738,6 +1804,51 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -1895,26 +2006,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -1941,6 +2032,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 51cf951..401d19c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "crates/crustcore", "crates/crustcore-kernel", "crates/crustcore-types", + "crates/crustcore-zeroize", "crates/crustcore-policy", "crates/crustcore-eventlog", "crates/crustcore-receipts", @@ -53,7 +54,7 @@ members = [ [workspace.package] version = "0.5.0" edition = "2021" -rust-version = "1.85" +rust-version = "1.88" license = "Apache-2.0" authors = ["The CrustCore Authors"] repository = "https://github.com/RNT56/CrustCore" @@ -66,6 +67,7 @@ categories = ["development-tools", "command-line-utilities"] [workspace.dependencies] crustcore-kernel = { path = "crates/crustcore-kernel", version = "0.5.0" } crustcore-types = { path = "crates/crustcore-types", version = "0.5.0" } +crustcore-zeroize = { path = "crates/crustcore-zeroize", version = "0.5.0" } crustcore-policy = { path = "crates/crustcore-policy", version = "0.5.0" } crustcore-eventlog = { path = "crates/crustcore-eventlog", version = "0.5.0" } crustcore-receipts = { path = "crates/crustcore-receipts", version = "0.5.0" } diff --git a/README.md b/README.md index 8369fae..4c1b5fc 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@ **A sub-800 kB Rust core that owns completion, integration, secrets, and approvals — so a patch ships because your verify command passed in a clean sandbox, not because a model said it was done.** [![CI](https://github.com/RNT56/CrustCore/actions/workflows/ci.yml/badge.svg)](https://github.com/RNT56/CrustCore/actions/workflows/ci.yml) - ![nano size](https://img.shields.io/badge/nano-478.7_KiB-2ea44f) - ![full size](https://img.shields.io/badge/full-576.7_KiB-2ea44f) - ![tests](https://img.shields.io/badge/tests-1120_passing-2ea44f) + ![nano size](https://img.shields.io/badge/nano-%3C800_KiB-2ea44f) + ![all-packs size](https://img.shields.io/badge/all--packs-%3C2_MiB-2ea44f) + ![tests](https://img.shields.io/badge/tests-1119_passing-2ea44f)  ![invariants](https://img.shields.io/badge/invariants-20_enforced-1f6feb)  ![kernel](https://img.shields.io/badge/kernel-std--only_%C2%B7_no_async%2Fnet%2Fdb-8957e5) - ![rust](https://img.shields.io/badge/rust-1.85+-orange) + ![rust](https://img.shields.io/badge/rust-1.88+-orange)  ![license](https://img.shields.io/badge/license-Apache--2.0-1f6feb) Models may propose. Subagents may explore. External workers may produce patches. Tools may execute.
@@ -82,10 +82,10 @@ proof, not a vibe. **Tiny by architecture, not by flag** -The trusted binary is **478.7 KiB stripped** (Linux x86_64) and *refuses* to link Tokio, TLS, a +The trusted binary is **444.6 KiB stripped** in the current macOS validation and *refuses* to link Tokio, TLS, a database, an MCP SDK, or any provider SDK — a CI size gate keeps it that way. Small enough to read end to end in an afternoon. Even with **every capability pack linked** -(`crustcore --features full`) the binary is just **576.7 KiB** — still under the 600 KiB +(`crustcore --features full`) the binary is **525.8 KiB** on the same host — still under the 600 KiB stretch goal — because the heavy live stacks run in spawned sidecars, never linked in. @@ -184,12 +184,12 @@ Full design: **[docs/architecture.md](./docs/architecture.md)**  ·  s | | | | --- | --- | -| **Footprint** | the trusted binary is **478.7 KiB** stripped (Linux x86_64) — std-only, with no async runtime, network, or database linked in | +| **Footprint** | the trusted binary is **444.6 KiB** stripped in the current macOS validation — first-party-only, with no async runtime, network, or database linked in; CI independently enforces the 800 KiB cap on Linux | | **Trusted core** | the kernel · a hash-chained event log + tool receipts · symlink-safe path confinement · a sandboxed command runner · the worktree verify loop · the type-sealed `VerifiedPatch` | | **Model & secrets** | a unified multi-modal provider registry — completion, embedding, and rerank — reached through a *spawned* helper · a secret broker with an encrypted vault and a redaction / taint boundary | | **Integrations** | a Telegram control channel · GitHub REST + hardened webhooks · an MCP gateway / client / server · subagent supervision & execution · a second-opinion advisor · repo & semantic memory | | **Compose & build** | a typed workflow graph · a session / artifact service · the `#[crust_tool]` authoring macro · RAG + vector-store adapters · OpenTelemetry / GenAI export · a loopback developer UI | -| **Verified quality** | **~1,120 tests** — property tests, no-panic fuzzes, tamper tests, goldens — plus red-team fixtures for prompt-injection, path-escape, fake tool results, secret-leak, hidden-MCP-instructions, memory-as-authority, and forged / replayed webhooks | +| **Verified quality** | **1,119 passing all-feature tests** — property tests, no-panic fuzzes, tamper tests, goldens — plus red-team fixtures for prompt-injection, path-escape, fake tool results, secret-leak, hidden-MCP-instructions, memory-as-authority, and forged / replayed webhooks | --- @@ -197,8 +197,8 @@ Full design: **[docs/architecture.md](./docs/architecture.md)**  ·  s | Tier | Size (Linux x86_64) | Purpose | | --- | --- | --- | -| **`crustcore` / `crustcore-nano`** | **478.7 KiB** | the trusted local verifier harness — the flagship | -| **`crustcore --features full`** | **576.7 KiB** | *every* capability pack (net + daemon + mcp + index + chat) linked into **one** binary — only +98 KiB over nano, **still under the 600 KiB stretch goal**, because the heavy stacks stay in sidecars | +| **`crustcore` / `crustcore-nano`** | **444.6 KiB** (current macOS validation) | the trusted local verifier harness — the flagship | +| **`crustcore --features full`** | **525.8 KiB** (current macOS validation) | *every* in-process capability pack (net protocol + daemon + mcp + index + chat) linked into **one** binary, still under the 600 KiB stretch goal because heavy live stacks stay in sidecars | | `crustcore-net` | 3–8 MB | network + provider sidecar (Tokio/TLS/providers) — a *spawned* helper, never linked into nano | | `crustcore-daemon` | 4–10 MB | long-running runtime: Telegram/GitHub loops, supervision | | `crustcore-mcp` | 3–10 MB | MCP gateway/client/server + code-mode | @@ -207,8 +207,9 @@ Full design: **[docs/architecture.md](./docs/architecture.md)**  ·  s Both flagship figures are measured by the CI size gate (`cargo xtask size-check` / `cargo xtask full-size`); `crustcore --features full` links every capability pack's **decision core** but -none of the heavy live I/O — that runs in the spawned sidecars above. (macOS: nano 412.0 KiB, -full 493.1 KiB.) +none of the heavy live I/O — that runs in the spawned sidecars above. Exact bytes are +platform-dependent; `cargo xtask size-check` and `cargo xtask full-size` report the +current host, while CI enforces the Linux release gates. Higher-level packs build on these — each non-nano and feature-gated, so they never touch the flagship binary: `crustcore-flow` (typed workflow graph), @@ -227,7 +228,7 @@ never touch the flagship binary: `crustcore-flow` (typed workflow graph), cargo xtask verify # 2. Build the flagship and print its size. -cargo xtask size-check # crustcore-nano: 478.7 KiB (Linux x86_64) +cargo xtask size-check # build + enforce the host's <800 KiB nano gate # 3. Is this host ready to run verified tasks? cargo run -p crustcore --no-default-features --features nano -- doctor @@ -236,9 +237,10 @@ cargo run -p crustcore --no-default-features --features nano -- doctor # and complete ONLY if it passes. crustcore run -dir . -goal "fix the failing test" -verify "cargo test" -# 5. Audit it: verify the hash chain and replay the run. -crustcore inspect ./events.log # → INTACT, with a task summary -crustcore export ./events.log # → JSONL +# 5. Audit the exact path printed as `audit-log:` by the run. +crustcore inspect ~/.local/state/crustcore/runs/run-….cclog +# → frame chain INTACT; verifier receipt authenticated and joined +crustcore export ~/.local/state/crustcore/runs/run-….cclog # 6. Cut a checksummed release artifact. cargo xtask release # → SHA256SUMS + release-manifest.txt @@ -246,9 +248,10 @@ cargo xtask release # → SHA256SUMS + release-manifest.txt Runs on **Linux and macOS** — sandboxed execution uses `bubblewrap` on Linux and `sandbox-exec` (Seatbelt) on macOS, with the same deny-all-egress, writes-confined-to- -the-worktree posture; the nano build is reproducible on both. The workspace is -**std-only and builds offline**; heavy dependencies live only in the sidecar packs. -Requires a stable Rust toolchain (≥ 1.85) with `rustfmt` and `clippy`, pinned in +the-worktree posture; the nano build is reproducible on both. The kernel/nano path stays +dependency-light and builds from the locked source set; HTTP, TLS, provider, and GitHub +cryptography dependencies live only in opt-in sidecar packs. +Requires a stable Rust toolchain (≥ 1.88) with `rustfmt` and `clippy`, pinned in [`rust-toolchain.toml`](./rust-toolchain.toml). Release & operations: **[docs/releasing.md](./docs/releasing.md)**. @@ -263,7 +266,7 @@ chat front door, the Telegram bot, and the model helper, and spawns *itself* as cargo build --release -p crustcore-full --features all # the heavy convenience tier crustcore-full setup # write a config file (model keys + optional bot token) -crustcore-full chat # a terminal coding agent — type, get answers, ask it to fix code +crustcore-full chat # terminal conversation + safe task classification/handoff crustcore-full serve --pair # or: run a Telegram bot (then --chat-id --dir . --verify '') ``` diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index de40fde..9446b0c 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -153,6 +153,9 @@ Each threat below names the **asset at risk**, the **mitigation**, and the bytes, rejects absolute writes, normalizes, resolves the deepest existing ancestor, rejects symlink escape, and uses no-follow semantics ([`ROADMAP.md` §8.2](./ROADMAP.md), [`docs/sandbox.md`](./docs/sandbox.md)). + Execution backends independently deny host reads/writes by default: Linux uses + namespace mounts and macOS Seatbelt reopens only system runtime, narrow toolchain, + worktree, and per-run private-scratch roots. - **Invariants:** **8** (policy), and the typed-path enforcement behind it. ### 5.4 Sandbox escape diff --git a/crates/crustcore-backend/src/integrate.rs b/crates/crustcore-backend/src/integrate.rs index 661488f..284337a 100644 --- a/crates/crustcore-backend/src/integrate.rs +++ b/crates/crustcore-backend/src/integrate.rs @@ -87,14 +87,14 @@ pub fn open_pr( return Err(IntegrateError::ApprovalExpired); } let cap = approval.value(); - if !branch_under_prefix(head_branch, cap.branch_prefix.0.as_str()) { + if !branch_under_prefix(head_branch, cap.branch_prefix().0.as_str()) { return Err(IntegrateError::BranchNotUnderPrefix( head_branch.to_string(), )); } let body = format_pr_body(&patch); Ok(PrIntent { - repo: RepoRef(cap.repo.0.clone()), + repo: RepoRef(cap.repo().0.clone()), head_branch: head_branch.to_string(), base_branch: base_branch.to_string(), draft: true, @@ -226,11 +226,15 @@ mod tests { } fn cap() -> GitHubWriteCap { - GitHubWriteCap { - repo: RepoRef(BoundedText::truncated("RNT56/CrustCore", 64)), - branch_prefix: BranchPrefix(BoundedText::truncated("crustcore/", 64)), - scope: ScopeId(1), - } + AuthorizedUser::bind(1) + .approve_github_write( + RepoRef(BoundedText::truncated("RNT56/CrustCore", 64)), + BranchPrefix(BoundedText::truncated("crustcore/", 64)), + ScopeId(1), + ApprovalId(1), + Timestamp::from_millis(1), + ) + .0 } fn approved(cap: GitHubWriteCap, expires_ms: u64) -> Approved { diff --git a/crates/crustcore-backend/src/lib.rs b/crates/crustcore-backend/src/lib.rs index ccc5bfb..2fa812f 100644 --- a/crates/crustcore-backend/src/lib.rs +++ b/crates/crustcore-backend/src/lib.rs @@ -118,7 +118,17 @@ impl VerifierName { /// A patch the verifier has confirmed in a clean sandbox. **Only** the verifier /// constructs this (no `From`); it is the only thing that may /// integrate, complete, or open a PR (invariant 13). -#[derive(Debug, Clone, PartialEq, Eq)] +/// +/// It is deliberately not `Clone`: consuming an attestation at completion or +/// integration cannot silently duplicate the authority it conveys. +/// +/// ```compile_fail +/// use crustcore_backend::VerifiedPatch; +/// fn duplicate(patch: VerifiedPatch) { +/// let _copy = patch.clone(); +/// } +/// ``` +#[derive(Debug, PartialEq, Eq)] pub struct VerifiedPatch { patch: PatchRef, verifier: VerifierName, @@ -184,7 +194,7 @@ impl VerifiedPatch { /// A completed task: proof that a task finished because the verifier passed. /// Constructed only by [`complete_task`], which accepts a [`VerifiedPatch`] *by /// value* — so a task can only complete from verifier evidence (invariant 13). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] pub struct Completion { /// The verified patch that completed the task. pub patch: VerifiedPatch, diff --git a/crates/crustcore-backend/src/verify.rs b/crates/crustcore-backend/src/verify.rs index 54c3207..6ba9b2e 100644 --- a/crates/crustcore-backend/src/verify.rs +++ b/crates/crustcore-backend/src/verify.rs @@ -385,7 +385,7 @@ mod tests { // ---- Golden task: "fix failing test" (P5.6) ---- - use crustcore_policy::SandboxExecCap; + use crustcore_policy::{PolicySnapshot, RiskProfile, SandboxExecCap}; use crustcore_receipts::MacKey; use crustcore_sandbox::SandboxProfile; use crustcore_types::{EventSeq, JobId, ScopeId, TaskId, Timestamp, ToolCallId}; @@ -414,10 +414,10 @@ mod tests { } fn cap() -> SandboxExecCap { - SandboxExecCap { - profile: ScopeId(1), - scope: ScopeId(1), - } + PolicySnapshot::new(RiskProfile::Supervised) + .authorize_reversible(ScopeId(1)) + .expect("supervised test policy should authorize sandbox execution") + .sandbox_exec(ScopeId(1)) } /// The verifier-owned-completion gate (P5.4–P5.6, invariant 13): a task with a @@ -518,8 +518,11 @@ mod tests { VerifyOutcome::Verified(verified) => { let completion = crate::complete_task(*verified); assert!( - completion.patch.receipt().result_matches(b""), - "verify of `test -f FIXED` emits no output, so the receipt binds to empty" + completion + .patch + .receipt() + .args_matches(failing_spec.display().as_bytes()), + "the completion receipt must bind to the verifier command" ); } other => panic!("after the fix, verify must mint a VerifiedPatch, got {other:?}"), diff --git a/crates/crustcore-backend/src/worker.rs b/crates/crustcore-backend/src/worker.rs index 6f1316d..8925cbc 100644 --- a/crates/crustcore-backend/src/worker.rs +++ b/crates/crustcore-backend/src/worker.rs @@ -37,7 +37,7 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use crustcore_path::WorktreeRoot; -use crustcore_policy::{FsReadCap, SandboxExecCap}; +use crustcore_policy::{FsReadCap, PolicySnapshot, RiskProfile, SandboxExecCap}; use crustcore_runner::{CommandResult, CommandSpec}; use crustcore_sandbox::{run_command, SandboxError, SandboxProfile}; use crustcore_types::hash::sha256; @@ -392,6 +392,9 @@ pub struct ChangedFile { /// result** — no patch is produced, so nothing can be verified or completed. #[derive(Debug)] pub enum WorkerError { + /// The worker returned without changing the disposable worktree. A coding + /// task cannot complete from a clean baseline plus a generic passing test. + NoChanges, /// A path outside the worktree changed during the worker run (invariant 6/7; /// `docs/backend-contract.md` §4.3). The result is rejected. OutOfRootWrite(String), @@ -408,6 +411,7 @@ pub enum WorkerError { impl core::fmt::Display for WorkerError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { + WorkerError::NoChanges => write!(f, "worker produced no worktree changes"), WorkerError::OutOfRootWrite(p) => { write!(f, "worker wrote outside the worktree: {p}") } @@ -516,6 +520,9 @@ where // Re-derive the truth from the worktree: confined changed files + real diff. let changed_files = confine_worktree_changes(worktree)?; let (status_bytes, diff) = extract_change(worktree)?; + if changed_files.is_empty() { + return Err(WorkerError::NoChanges); + } // Content-address the patch over the worktree's OWN observed change state — // never the worker's self-reported diff. @@ -653,10 +660,10 @@ fn extract_change(worktree: &WorktreeRoot) -> Result<(Vec, Vec), WorkerE /// Builds a read capability for the worktree (a fresh confined root; scope is a /// placeholder until the kernel wires real scopes through the worker path). fn read_cap(worktree: &WorktreeRoot) -> FsReadCap { - FsReadCap { - root: worktree.clone(), - scope: ScopeId(0), - } + PolicySnapshot::new(RiskProfile::Supervised) + .authorize_reversible(ScopeId(0)) + .expect("supervised policy permits confined reads") + .fs_read(worktree.clone()) } /// Splits `git status --porcelain -z` output into records. With `-z`, records are @@ -1218,6 +1225,21 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + #[test] + fn seam_rejects_a_noop_worker_even_when_it_claims_done() { + let Some((base, _dir, wt)) = git_worktree("noop") else { + return; + }; + let backend = ExternalCommandBackend::new("/bin/true", vec![]); + let input = WorkerInput::for_task(TaskId(1), "fix the code", &wt); + let err = run_external_worker_with(&backend, &input, &wt, |_spec| { + Ok(fake_result(b"[[CRUSTCORE_DONE]]\n")) + }) + .unwrap_err(); + assert!(matches!(err, WorkerError::NoChanges), "{err}"); + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn seam_rejects_out_of_root_write() { let Some((base, _dir, wt)) = git_worktree("oob") else { diff --git a/crates/crustcore-chat/src/lib.rs b/crates/crustcore-chat/src/lib.rs index 94186b5..0fd31e3 100644 --- a/crates/crustcore-chat/src/lib.rs +++ b/crates/crustcore-chat/src/lib.rs @@ -55,7 +55,9 @@ pub use persona::{OperatorSteering, Persona, MAX_PREAMBLE_BYTES}; pub use route::{ChatRoute, Classifier}; pub use session::{ChatConfig, ChatSession, ConsultFn, Turn}; pub use steer::{Activity, Disposition, Inbound, InboundKind, TurnQueue, MAX_QUEUE_DEPTH}; -pub use terminal::{complete_text, run_repl, run_terminal}; +pub use terminal::{ + complete_text, run_repl, run_repl_with_tasks, run_terminal, run_terminal_with_tasks, +}; use crustcore_netproto::MAX_TEXT_BYTES; diff --git a/crates/crustcore-chat/src/terminal.rs b/crates/crustcore-chat/src/terminal.rs index 2d3a413..f50a90c 100644 --- a/crates/crustcore-chat/src/terminal.rs +++ b/crates/crustcore-chat/src/terminal.rs @@ -19,7 +19,7 @@ use std::io::{BufRead, Write}; -use crustcore_netproto::{CompleteRequest, NetHelper, SpawnedHelper}; +use crustcore_netproto::{BoundedText, CompleteRequest, NetHelper, SpawnedHelper, MAX_TEXT_BYTES}; use crustcore_secrets::Redactor; use crate::session::{ChatConfig, ChatSession, ConsultFn, Turn}; @@ -53,6 +53,24 @@ pub fn run_repl( input: &mut In, output: &mut Out, model: &mut ConsultFn<'_>, +) -> std::io::Result<()> { + let mut handoff = |route: &str, prompt: &str| { + format!("[route: {route} — hand off to the kernel task flow: {prompt}]") + }; + run_repl_with_tasks(redactor, config, input, output, model, &mut handoff) +} + +/// The same redacted REPL with a real task dispatcher. The dispatcher is called only +/// for an authorized operator turn classified as [`Turn::StartTask`]; its return value +/// is rendered as a bounded status line. This keeps routing in `crustcore-chat` while +/// execution remains in the kernel/backend-owning binary. +pub fn run_repl_with_tasks( + redactor: &Redactor, + config: ChatConfig, + input: &mut In, + output: &mut Out, + model: &mut ConsultFn<'_>, + task: &mut dyn FnMut(&str, &str) -> String, ) -> std::io::Result<()> { let mut session = ChatSession::new(redactor, config); let mut line = String::new(); @@ -95,12 +113,11 @@ pub fn run_repl( match session.handle(&turn_text, &mut *model) { Turn::Answer(a) => writeln!(output, "crustcore> {}", a.as_str())?, Turn::Notice(n) => writeln!(output, "crustcore: {}", n.as_str())?, - Turn::StartTask { route, prompt } => writeln!( - output, - "[route: {} — hand off to the kernel task flow: {}]", - route.as_str(), - prompt - )?, + Turn::StartTask { route, prompt } => { + let status = task(route.as_str(), prompt.as_str()); + let status = BoundedText::truncated(status, MAX_TEXT_BYTES); + writeln!(output, "{}", status.as_str())?; + } } } } @@ -129,6 +146,23 @@ pub fn run_terminal( run_repl(redactor, config, &mut input, &mut output, &mut model) } +/// Runs the live terminal front door with a caller-owned task dispatcher. +pub fn run_terminal_with_tasks( + program: &str, + args: &[&str], + redactor: &Redactor, + config: ChatConfig, + task: &mut dyn FnMut(&str, &str) -> String, +) -> std::io::Result<()> { + let mut spawned = SpawnedHelper::spawn(program, args)?; + let stdin = std::io::stdin(); + let mut input = stdin.lock(); + let stdout = std::io::stdout(); + let mut output = stdout.lock(); + let mut model = |req: &CompleteRequest| complete_text(spawned.helper(), req); + run_repl_with_tasks(redactor, config, &mut input, &mut output, &mut model, task) +} + #[cfg(test)] mod tests { use super::*; @@ -206,6 +240,35 @@ mod tests { assert!(out.contains("(run cancelled)")); } + #[test] + fn repl_dispatches_task_turns_to_the_kernel_callback() { + let broker = SecretBroker::new(InMemoryStore::new()); + let mut input = BufReader::new(Cursor::new(b"rename the foo variable\n".to_vec())); + let mut output = Vec::new(); + let mut model = |_req: &CompleteRequest| None; + let mut calls = Vec::new(); + let mut task = |route: &str, prompt: &str| { + calls.push((route.to_string(), prompt.to_string())); + "crustcore task> verified".to_string() + }; + + run_repl_with_tasks( + broker.redactor(), + ChatConfig::default(), + &mut input, + &mut output, + &mut model, + &mut task, + ) + .unwrap(); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].1, "rename the foo variable"); + assert!(String::from_utf8(output) + .unwrap() + .contains("crustcore task> verified")); + } + #[test] fn repl_redacts_a_secret_in_a_converse_answer() { // RED-TEAM through the whole REPL: the model's answer echoes a secret; the user diff --git a/crates/crustcore-daemon/Cargo.toml b/crates/crustcore-daemon/Cargo.toml index f3a2490..b811357 100644 --- a/crates/crustcore-daemon/Cargo.toml +++ b/crates/crustcore-daemon/Cargo.toml @@ -37,6 +37,8 @@ crustcore-worktree = { workspace = true, optional = true } crustcore-sandbox = { workspace = true, optional = true } crustcore-receipts = { workspace = true, optional = true } crustcore-path = { workspace = true, optional = true } +crustcore-eventlog = { workspace = true, optional = true } +zeroize = "1.9" [features] # Default: mock-driven, no live transports. CI builds/tests this — green, no network, @@ -62,6 +64,7 @@ live = [ "dep:crustcore-sandbox", "dep:crustcore-receipts", "dep:crustcore-path", + "dep:crustcore-eventlog", "crustcore-net/live", "crustcore-net/github-app", ] diff --git a/crates/crustcore-daemon/src/bin/crustcore-daemon.rs b/crates/crustcore-daemon/src/bin/crustcore-daemon.rs index 7577167..06cd454 100644 --- a/crates/crustcore-daemon/src/bin/crustcore-daemon.rs +++ b/crates/crustcore-daemon/src/bin/crustcore-daemon.rs @@ -36,6 +36,9 @@ fn main() -> ExitCode { print!("{}", usage()); ExitCode::SUCCESS } + DaemonCommand::CredentialHelper { config, operation } => { + credential_helper(&config, &operation) + } DaemonCommand::Unknown(cmd) => { eprintln!("crustcore-daemon: unknown command '{cmd}'\n"); print!("{}", usage()); @@ -44,6 +47,82 @@ fn main() -> ExitCode { } } +fn credential_helper(config_path: &str, operation: &str) -> ExitCode { + use std::io::{Read as _, Write as _}; + + if matches!(operation, "store" | "erase") { + return ExitCode::SUCCESS; + } + if operation != "get" { + return ExitCode::from(2); + } + let path = std::path::Path::new(config_path); + let meta = match std::fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(_) => return ExitCode::from(1), + }; + if meta.file_type().is_symlink() || !meta.is_file() || meta.len() > 16 * 1024 { + return ExitCode::from(1); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if meta.permissions().mode() & 0o077 != 0 { + return ExitCode::from(1); + } + } + let bytes = match std::fs::read(path) { + Ok(bytes) => zeroize::Zeroizing::new(bytes), + Err(_) => return ExitCode::from(1), + }; + let Some(rest) = bytes.strip_prefix(b"CCGH1\n") else { + return ExitCode::from(1); + }; + let Some(split) = rest.iter().position(|byte| *byte == b'\n') else { + return ExitCode::from(1); + }; + let repo = match std::str::from_utf8(&rest[..split]) { + Ok(repo) if !repo.is_empty() => repo, + _ => return ExitCode::from(1), + }; + let token = match std::str::from_utf8(&rest[split + 1..]) { + Ok(token) if !token.is_empty() && !token.chars().any(|ch| matches!(ch, '\r' | '\n')) => { + token + } + _ => return ExitCode::from(1), + }; + + let mut request_bytes = Vec::new(); + if std::io::stdin() + .lock() + .take(8 * 1024 + 1) + .read_to_end(&mut request_bytes) + .is_err() + || request_bytes.len() > 8 * 1024 + { + return ExitCode::from(1); + } + let request = crustcore_daemon::github::parse_credential_request(&String::from_utf8_lossy( + &request_bytes, + )); + if request.protocol != "https" + || request.host != crustcore_daemon::github::GITHUB_HOST + || crustcore_daemon::github::repo_from_credential_path(&request.path) != repo + { + return ExitCode::from(1); + } + let response = + zeroize::Zeroizing::new(crustcore_daemon::github::credential_helper_response(token)); + if std::io::stdout() + .lock() + .write_all(response.as_bytes()) + .is_err() + { + return ExitCode::from(1); + } + ExitCode::SUCCESS +} + // --------------------------------------------------------------------------- // Pure, testable argument parsing // --------------------------------------------------------------------------- @@ -75,6 +154,9 @@ pub struct ServeOpts { /// `--branch-prefix `: the prefix the PR head branch is confined under /// (default `crustcore`). pub branch_prefix: Option, + /// `--github-token-file `: owner-only GitHub credential file used by the + /// trusted push/REST integration path. + pub github_token_file: Option, } /// A parsed daemon subcommand. @@ -90,6 +172,9 @@ pub enum DaemonCommand { Help, /// An unrecognized subcommand (carries the verb for the error reply). Unknown(String), + /// Internal git credential-helper mode. It is invoked only by the trusted + /// integration path; token bytes travel on the helper pipe, never argv/env. + CredentialHelper { config: String, operation: String }, } /// Parses the argv tail into a [`DaemonCommand`]. Reads `CRUSTCORE_TELEGRAM_ALLOW` for @@ -107,6 +192,10 @@ pub fn parse_args(args: &[String]) -> DaemonCommand { "doctor" => DaemonCommand::Doctor, "version" | "--version" | "-V" => DaemonCommand::Version, "help" | "--help" | "-h" => DaemonCommand::Help, + "credential-helper" if args.len() == 3 => DaemonCommand::CredentialHelper { + config: args[1].clone(), + operation: args[2].clone(), + }, other => DaemonCommand::Unknown(other.to_string()), } } @@ -134,6 +223,9 @@ pub fn parse_serve_opts(env_allow: &str, serve_args: &[String]) -> ServeOpts { a if a.starts_with("--branch-prefix") => { opts.branch_prefix = flag_value(a, "--branch-prefix", &mut it) } + a if a.starts_with("--github-token-file") => { + opts.github_token_file = flag_value(a, "--github-token-file", &mut it) + } _ => {} } } @@ -263,6 +355,12 @@ fn serve_live(opts: &ServeOpts, allowlist: ChatAllowlist, token: String) -> Exit .unwrap_or_default(); let task = build_task_spec(opts); + if opts.open_pr && task.as_ref().is_some_and(|task| task.pr.is_none()) { + eprintln!( + "crustcore-daemon serve: --open-pr requires both --repo owner/name and \ + --github-token-file ; PR mode is disabled" + ); + } if task.is_none() { eprintln!( "crustcore-daemon serve: no --dir given — I'll chat, but task execution is off \ @@ -312,14 +410,18 @@ fn build_task_spec(opts: &ServeOpts) -> Option // PR mode is opt-in (`--open-pr --repo owner/name`): a verified task opens a *draft* // PR, gated on a per-launch human approval. Without `--repo` it stays off (no PR). let pr = if opts.open_pr { - opts.repo.as_deref().map(|repo| PrTarget { - repo: repo.to_string(), - base_branch: opts.base.clone().unwrap_or_else(|| "main".to_string()), - branch_prefix: opts - .branch_prefix - .clone() - .unwrap_or_else(|| "crustcore".to_string()), - }) + opts.repo + .as_deref() + .zip(opts.github_token_file.as_deref()) + .map(|(repo, credential_file)| PrTarget { + repo: repo.to_string(), + base_branch: opts.base.clone().unwrap_or_else(|| "main".to_string()), + branch_prefix: opts + .branch_prefix + .clone() + .unwrap_or_else(|| "crustcore".to_string()), + credential_file: credential_file.into(), + }) } else { None }; @@ -388,6 +490,9 @@ COMMANDS: --repo Repository draft PRs target (owner/name). --base Base branch a draft PR targets (default main). --branch-prefix

Head-branch prefix (default crustcore). + --github-token-file + Owner-only fine-grained PAT/App-token file used by + the trusted credential helper and REST client. Requires CRUSTCORE_TELEGRAM_TOKEN= and a `live` build. doctor Report runtime environment readiness. version Print the daemon version. @@ -480,17 +585,37 @@ mod tests { "--base=develop", "--branch-prefix", "bots", + "--github-token-file=/secure/github.token", ]), ); assert!(opts.open_pr); assert_eq!(opts.repo.as_deref(), Some("owner/name")); assert_eq!(opts.base.as_deref(), Some("develop")); assert_eq!(opts.branch_prefix.as_deref(), Some("bots")); + assert_eq!( + opts.github_token_file.as_deref(), + Some("/secure/github.token") + ); // Off by default (no --open-pr → no PR mode even if --repo is given). let off = parse_serve_opts("", &args(&["--repo", "owner/name"])); assert!(!off.open_pr); } + #[test] + fn credential_helper_mode_requires_config_and_operation() { + assert_eq!( + parse_args(&args(&["credential-helper", "/tmp/config", "get"])), + DaemonCommand::CredentialHelper { + config: "/tmp/config".to_string(), + operation: "get".to_string(), + } + ); + assert_eq!( + parse_args(&args(&["credential-helper", "/tmp/config"])), + DaemonCommand::Unknown("credential-helper".to_string()) + ); + } + #[test] fn parse_allowlist_merges_env_then_flags_and_dedupes() { let ids = parse_allowlist( diff --git a/crates/crustcore-daemon/src/exec.rs b/crates/crustcore-daemon/src/exec.rs index 3d61295..4d0a3ff 100644 --- a/crates/crustcore-daemon/src/exec.rs +++ b/crates/crustcore-daemon/src/exec.rs @@ -789,19 +789,15 @@ mod tests { use super::*; fn executor(worker_cmd: Option<(String, Vec)>) -> WorktreeSubagentExecutor { - use crustcore_policy::SandboxExecCap; + use crustcore_policy::{PolicySnapshot, RiskProfile}; use crustcore_sandbox::SandboxProfile; use crustcore_types::ScopeId; - WorktreeSubagentExecutor::new( - ".", - SandboxExecCap { - profile: ScopeId(1), - scope: ScopeId(1), - }, - SandboxProfile::default_sandboxed(), - [0x5a; 32], - worker_cmd, - ) + let profile = SandboxProfile::default_sandboxed(); + let cap = PolicySnapshot::new(RiskProfile::Supervised) + .authorize_reversible(ScopeId(1)) + .expect("supervised policy permits sandboxed test execution") + .sandbox_exec(profile.id); + WorktreeSubagentExecutor::new(".", cap, profile, [0x5a; 32], worker_cmd) } #[test] diff --git a/crates/crustcore-daemon/src/github.rs b/crates/crustcore-daemon/src/github.rs index 68e4edb..5587ff1 100644 --- a/crates/crustcore-daemon/src/github.rs +++ b/crates/crustcore-daemon/src/github.rs @@ -110,9 +110,9 @@ impl RepoRegistry { } } - /// Registers `cap.repo` with its write capability. + /// Registers `cap.repo()` with its write capability. pub fn register(&mut self, cap: GitHubWriteCap) { - self.repos.insert(cap.repo.0.as_str().to_string(), cap); + self.repos.insert(cap.repo().0.as_str().to_string(), cap); } /// The write capability for a repo, if registered. @@ -294,10 +294,10 @@ pub fn validate_push(cap: &GitHubWriteCap, req: &PushRequest) -> Result<(), Push if !ALLOWED_HOSTS.contains(&req.host.as_str()) { return Err(PushDenied::Host(req.host.clone())); } - if req.repo != cap.repo.0.as_str() { + if req.repo != cap.repo().0.as_str() { return Err(PushDenied::RepoMismatch { requested: req.repo.clone(), - allowed: cap.repo.0.as_str().to_string(), + allowed: cap.repo().0.as_str().to_string(), }); } // Force is the explicit flag the helper parsed (covers every `--force…` @@ -325,7 +325,7 @@ pub fn validate_push(cap: &GitHubWriteCap, req: &PushRequest) -> Result<(), Push if branch == "main" || branch == "master" || branch == "HEAD" { return Err(PushDenied::ProtectedBranch(branch.to_string())); } - if !branch_under_prefix(branch, cap.branch_prefix.0.as_str()) { + if !branch_under_prefix(branch, cap.branch_prefix().0.as_str()) { return Err(PushDenied::BranchOutsidePrefix(branch.to_string())); } } @@ -768,11 +768,15 @@ mod tests { } fn cap() -> GitHubWriteCap { - GitHubWriteCap { - repo: RepoRef(BoundedText::truncated("RNT56/CrustCore", 64)), - branch_prefix: BranchPrefix(BoundedText::truncated("crustcore/", 64)), - scope: ScopeId(1), - } + AuthorizedUser::bind(1) + .approve_github_write( + RepoRef(BoundedText::truncated("RNT56/CrustCore", 64)), + BranchPrefix(BoundedText::truncated("crustcore/", 64)), + ScopeId(1), + ApprovalId(1), + Timestamp::from_millis(1), + ) + .0 } fn push(repo: &str, host: &str, refspec: &str) -> PushRequest { diff --git a/crates/crustcore-daemon/src/onboarding.rs b/crates/crustcore-daemon/src/onboarding.rs index 8b85dca..f2f8103 100644 --- a/crates/crustcore-daemon/src/onboarding.rs +++ b/crates/crustcore-daemon/src/onboarding.rs @@ -6,7 +6,7 @@ //! //! ```text //! InstallRedirect (untrusted) -- invariant 7: validated, never trusted as-is -//! -> cap_from_redirect -> GitHubWriteCap (scoped to repo + "crustcore/" prefix) +//! -> scope_from_redirect -> inert validated scope data //! -> RepoRegistry::register -- the repo becomes push-validatable (A.2) //! -> AuthorizedUser::approve -- mints Approved (inv. 4/14) //! -> RepoProfile::parse(crustcore.yml) -- bounds the untrusted config (existing parser) @@ -41,7 +41,7 @@ pub const MAX_REPO_SLUG: usize = 256; /// The parameters GitHub appends to the post-install redirect (`installation_id`, /// the `setup_action` id, and the repo the App was installed on). This is -/// **untrusted input** (invariant 7): [`cap_from_redirect`] validates it before it +/// **untrusted input** (invariant 7): [`scope_from_redirect`] validates it before it /// can register anything. #[derive(Debug, Clone)] pub struct InstallRedirect { @@ -122,17 +122,17 @@ fn validate_repo_slug(slug: &str) -> Result<(), OnboardingError> { Ok(()) } -/// Validates an [`InstallRedirect`] and builds the scoped [`GitHubWriteCap`]. +/// Validates an [`InstallRedirect`] and builds inert GitHub write scope data. /// Pure — no network. Rejects a malformed slug or an absent installation id so a /// bogus redirect can never register a writable repo (invariant 7). /// /// # Errors /// [`OnboardingError::MissingInstallationId`] / [`OnboardingError::InvalidRepo`] / /// [`OnboardingError::RepoSlugTooLong`]. -pub fn cap_from_redirect( +pub fn scope_from_redirect( redirect: &InstallRedirect, scope: ScopeId, -) -> Result { +) -> Result<(RepoRef, BranchPrefix, ScopeId), OnboardingError> { if redirect.installation_id == 0 { return Err(OnboardingError::MissingInstallationId); } @@ -144,11 +144,7 @@ pub fn cap_from_redirect( let branch_prefix = BranchPrefix( BoundedText::new(DEFAULT_BRANCH_PREFIX).expect("static branch prefix is within bounds"), ); - Ok(GitHubWriteCap { - repo, - branch_prefix, - scope, - }) + Ok((repo, branch_prefix, scope)) } /// The result of onboarding a repo: the write approval bound to the onboarding @@ -168,12 +164,12 @@ pub struct Onboarded { /// boundary**: `operator` is the [`AuthorizedUser`] who installed the App, bound /// out-of-band — never derived from model/worker/comment data (invariant 4). /// -/// Builds the capability twice (once for the registry's push validator, once -/// wrapped in the `Approved` for PR authorization) because a `GitHubWriteCap` is -/// intentionally non-`Clone`: capabilities are not silently duplicated. +/// Raw capability construction remains inside `crustcore-policy`; this layer +/// only validates inert redirect data and asks the authorized operator to issue +/// the registry + approved grants. /// /// # Errors -/// Propagates [`cap_from_redirect`] validation failures (the redirect is untrusted). +/// Propagates [`scope_from_redirect`] validation failures (the redirect is untrusted). pub fn onboard( registry: &mut RepoRegistry, operator: AuthorizedUser, @@ -183,10 +179,10 @@ pub fn onboard( approval_expires_at: Timestamp, ) -> Result { // Validate once up front so a bad redirect registers nothing. - let cap_for_registry = cap_from_redirect(redirect, scope)?; - let cap_for_approval = cap_from_redirect(redirect, scope)?; + let (repo, branch_prefix, scope) = scope_from_redirect(redirect, scope)?; + let (cap_for_registry, approval) = + operator.approve_github_write(repo, branch_prefix, scope, approval_id, approval_expires_at); registry.register(cap_for_registry); - let approval = operator.approve(cap_for_approval, approval_id, approval_expires_at); Ok(Onboarded { approval, installation_id: redirect.installation_id, @@ -244,10 +240,12 @@ mod tests { } #[test] - fn cap_from_a_good_redirect_is_scoped_to_repo_and_prefix() { - let cap = cap_from_redirect(&redirect("RNT56/CrustCore"), ScopeId(1)).unwrap(); - assert_eq!(cap.repo.0.as_str(), "RNT56/CrustCore"); - assert_eq!(cap.branch_prefix.0.as_str(), DEFAULT_BRANCH_PREFIX); + fn scope_from_a_good_redirect_is_scoped_to_repo_and_prefix() { + let (repo, prefix, scope) = + scope_from_redirect(&redirect("RNT56/CrustCore"), ScopeId(1)).unwrap(); + assert_eq!(repo.0.as_str(), "RNT56/CrustCore"); + assert_eq!(prefix.0.as_str(), DEFAULT_BRANCH_PREFIX); + assert_eq!(scope, ScopeId(1)); } #[test] @@ -255,7 +253,7 @@ mod tests { let mut r = redirect("RNT56/CrustCore"); r.installation_id = 0; assert_eq!( - cap_from_redirect(&r, ScopeId(1)).unwrap_err(), + scope_from_redirect(&r, ScopeId(1)).unwrap_err(), OnboardingError::MissingInstallationId ); } @@ -276,7 +274,7 @@ mod tests { "RNT56/.", ] { assert_eq!( - cap_from_redirect(&redirect(bad), ScopeId(1)).unwrap_err(), + scope_from_redirect(&redirect(bad), ScopeId(1)).unwrap_err(), OnboardingError::InvalidRepo, "slug {bad:?} should be rejected" ); @@ -287,7 +285,7 @@ mod tests { fn an_overlong_slug_is_refused() { let big = format!("owner/{}", "n".repeat(MAX_REPO_SLUG)); assert_eq!( - cap_from_redirect(&redirect(&big), ScopeId(1)).unwrap_err(), + scope_from_redirect(&redirect(&big), ScopeId(1)).unwrap_err(), OnboardingError::RepoSlugTooLong ); } diff --git a/crates/crustcore-daemon/src/product.rs b/crates/crustcore-daemon/src/product.rs index 8c8558a..5848b3c 100644 --- a/crates/crustcore-daemon/src/product.rs +++ b/crates/crustcore-daemon/src/product.rs @@ -269,7 +269,7 @@ impl Default for SupervisorBudgetProfile { SupervisorBudgetProfile { max_wall_ms: 30 * 60 * 1000, max_output_bytes: 1 << 20, - max_tokens: u64::MAX, + max_tokens: 1_000_000, repair_attempts: 2, } } diff --git a/crates/crustcore-daemon/src/registry.rs b/crates/crustcore-daemon/src/registry.rs index 8ac3c4c..1fc40c4 100644 --- a/crates/crustcore-daemon/src/registry.rs +++ b/crates/crustcore-daemon/src/registry.rs @@ -60,14 +60,15 @@ use crate::telegram::ChatId; /// interval so a slow tick never falsely expires a healthy task. pub const LEASE_TTL_MS: u64 = 60_000; -/// The default per-task resource budget (invariant 11). Bounds wall time and streamed output -/// so a runaway task is killed mid-run. Tokens are not metered for these worktree tasks. +/// The default per-task resource budget (invariant 11). Every represented axis is +/// finite, including tokens, so a newly admitted task never starts with implicit +/// unlimited authority. #[must_use] pub fn default_task_budget() -> AgentBudget { AgentBudget { max_wall_ms: 30 * 60 * 1000, // 30 minutes max_output_bytes: 1 << 20, // 1 MiB of streamed status - max_tokens: u64::MAX, + max_tokens: 1_000_000, } } diff --git a/crates/crustcore-daemon/src/runtime.rs b/crates/crustcore-daemon/src/runtime.rs index 4e9ea41..8141add 100644 --- a/crates/crustcore-daemon/src/runtime.rs +++ b/crates/crustcore-daemon/src/runtime.rs @@ -45,12 +45,12 @@ pub fn budget_for_route(route: ChatRoute) -> AgentBudget { ChatRoute::QuickFix => AgentBudget { max_wall_ms: 5 * 60 * 1000, max_output_bytes: 256 * 1024, - max_tokens: u64::MAX, + max_tokens: 100_000, }, ChatRoute::Feature | ChatRoute::Continue => AgentBudget { max_wall_ms: 15 * 60 * 1000, max_output_bytes: 512 * 1024, - max_tokens: u64::MAX, + max_tokens: 500_000, }, // A whole-project build gets the generous default tier. ChatRoute::Project => default_task_budget(), @@ -75,15 +75,17 @@ pub fn mint_github_write_cap( expires_at: Timestamp, ) -> Option> { let user = allowlist.authorized_user(chat)?; - let cap = GitHubWriteCap { - repo: RepoRef(BoundedText::truncated(repo, BoundedText::DEFAULT_MAX)), - branch_prefix: BranchPrefix(BoundedText::truncated( + let (_, approval) = user.approve_github_write( + RepoRef(BoundedText::truncated(repo, BoundedText::DEFAULT_MAX)), + BranchPrefix(BoundedText::truncated( branch_prefix, BoundedText::DEFAULT_MAX, )), - scope: ScopeId(1), - }; - Some(user.approve(cap, ApprovalId(approval_id), expires_at)) + ScopeId(1), + ApprovalId(approval_id), + expires_at, + ); + Some(approval) } /// If `event` is an approve/deny resolution for `approval_id` — a button callback or the @@ -1379,6 +1381,9 @@ mod tests { assert!(quick.max_wall_ms < feature.max_wall_ms); assert!(feature.max_wall_ms < project.max_wall_ms); assert!(quick.max_output_bytes < project.max_output_bytes); + assert!(quick.max_tokens < feature.max_tokens); + assert!(feature.max_tokens < project.max_tokens); + assert_ne!(project.max_tokens, u64::MAX); // Continue maps to the Feature tier (true resumption is a separate seam). assert_eq!( budget_for_route(ChatRoute::Continue).max_wall_ms, @@ -1508,8 +1513,8 @@ mod tests { Timestamp::from_millis(10_000), ) .expect("an allowlisted chat mints a cap"); - assert_eq!(cap.value().repo.0.as_str(), "owner/repo"); - assert_eq!(cap.value().branch_prefix.0.as_str(), "crustcore"); + assert_eq!(cap.value().repo().0.as_str(), "owner/repo"); + assert_eq!(cap.value().branch_prefix().0.as_str(), "crustcore"); assert!(cap.is_valid_at(Timestamp::from_millis(1000))); // A non-allowlisted chat gets NO GitHub write authority (invariant 4). assert!(mint_github_write_cap( diff --git a/crates/crustcore-daemon/src/task.rs b/crates/crustcore-daemon/src/task.rs index b5a8549..e931555 100644 --- a/crates/crustcore-daemon/src/task.rs +++ b/crates/crustcore-daemon/src/task.rs @@ -17,7 +17,7 @@ //! gets one `#[ignore]`d end-to-end test (it needs a sandbox backend + a git repo). #![cfg(feature = "live")] -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::Arc; use std::thread::JoinHandle; @@ -29,6 +29,333 @@ use crate::telegram::ChatId; /// Maximum verify output we fold into a final status line (bounded — invariant 11, /// §6.5). Verify output is untrusted (invariant 7); the runtime redacts before send. const MAX_VERIFY_TAIL: usize = 1500; +static NEXT_TASK_ID: AtomicU64 = AtomicU64::new(1); + +fn next_task_id() -> crustcore_types::TaskId { + let counter = NEXT_TASK_ID.fetch_add(1, Ordering::Relaxed); + let high = now_ts().as_millis() as u128; + let low = ((std::process::id() as u128) << 32) | counter as u128; + crustcore_types::TaskId((high << 64) | low) +} + +fn task_state_root() -> std::path::PathBuf { + if let Some(path) = std::env::var_os("CRUSTCORE_STATE") { + return path.into(); + } + if let Some(path) = std::env::var_os("XDG_STATE_HOME") { + return std::path::PathBuf::from(path).join("crustcore"); + } + if let Some(home) = std::env::var_os("HOME") { + return std::path::PathBuf::from(home).join(".local/state/crustcore"); + } + std::env::temp_dir().join("crustcore-state") +} + +struct TaskAudit { + log: crustcore_eventlog::EventLog, + path: std::path::PathBuf, +} + +impl TaskAudit { + fn new(task: crustcore_types::TaskId) -> Self { + TaskAudit { + log: crustcore_eventlog::EventLog::new(), + path: task_state_root() + .join("runs") + .join(format!("task-{:032x}.cclog", task.0)), + } + } + + fn append( + &mut self, + seq: u64, + kind: crustcore_kernel::EventKind, + task: crustcore_types::TaskId, + job: Option, + payload: &[u8], + ) { + let mut meta = crustcore_eventlog::FrameMeta::new(seq, kind) + .actor(crustcore_kernel::Actor::Adapter) + .visibility(crustcore_kernel::Visibility::Internal) + .timestamp(now_ts()) + .task(task); + if let Some(job) = job { + meta = meta.job(job); + } + self.log.append(&meta, payload); + } +} + +fn read_owner_only_token(path: &std::path::Path) -> Result, String> { + let meta = std::fs::symlink_metadata(path) + .map_err(|error| format!("cannot inspect GitHub token file: {error}"))?; + if meta.file_type().is_symlink() || !meta.is_file() || meta.len() > 16 * 1024 { + return Err("GitHub token file must be a small regular file, not a symlink".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if meta.permissions().mode() & 0o077 != 0 { + return Err("GitHub token file permissions must be owner-only (0600)".into()); + } + } + let bytes = zeroize::Zeroizing::new( + std::fs::read(path).map_err(|error| format!("cannot read GitHub token file: {error}"))?, + ); + let token = std::str::from_utf8(&bytes) + .map_err(|_| "GitHub token file is not valid UTF-8".to_string())?; + let token = token + .strip_suffix("\r\n") + .or_else(|| token.strip_suffix('\n')) + .unwrap_or(token); + if token.is_empty() + || token.trim() != token + || token.chars().any(|ch| matches!(ch, '\r' | '\n')) + { + return Err("GitHub token file must contain exactly one non-empty token".into()); + } + Ok(zeroize::Zeroizing::new(token.to_string())) +} + +fn valid_repo_slug(repo: &str) -> bool { + if repo.is_empty() || repo.len() > 200 { + return false; + } + let Some((owner, name)) = repo.split_once('/') else { + return false; + }; + let valid_segment = |segment: &str| { + !segment.is_empty() + && !matches!(segment, "." | "..") + && segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + }; + !name.contains('/') && valid_segment(owner) && valid_segment(name) +} + +fn valid_git_branch(branch: &str) -> bool { + !branch.is_empty() + && branch.len() <= 255 + && branch != "@" + && !branch.starts_with(['/', '.']) + && !branch.ends_with(['/', '.']) + && !branch.contains("..") + && !branch.contains("//") + && !branch.contains("@{") + && branch.split('/').all(|segment| { + !segment.is_empty() + && !matches!(segment, "." | "..") + && !segment.ends_with(".lock") + && segment.bytes().all(|byte| { + byte.is_ascii_graphic() + && !matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\') + }) + }) +} + +fn validate_pr_target(target: &PrTarget) -> Result<(), String> { + if !valid_repo_slug(&target.repo) { + return Err("PR repo must be a safe owner/name GitHub slug".into()); + } + if !valid_git_branch(&target.base_branch) { + return Err("PR base must be a valid Git branch name".into()); + } + if !valid_git_branch(&format!( + "{}/probe", + target.branch_prefix.trim_end_matches('/') + )) { + return Err("PR branch prefix must be a valid Git branch prefix".into()); + } + Ok(()) +} + +struct CredentialHelperLease { + dir: std::path::PathBuf, + helper_command: String, +} + +impl CredentialHelperLease { + fn create(task: crustcore_types::TaskId, repo: &str, token: &str) -> Result { + use std::io::Write as _; + + let dir = std::path::PathBuf::from(format!( + "/tmp/crustcore-credential-{}-{:032x}", + std::process::id(), + task.0 + )); + let _ = std::fs::remove_dir_all(&dir); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt as _; + let mut builder = std::fs::DirBuilder::new(); + builder.mode(0o700); + builder + .create(&dir) + .map_err(|error| format!("cannot create credential-helper dir: {error}"))?; + } + #[cfg(not(unix))] + std::fs::create_dir(&dir) + .map_err(|error| format!("cannot create credential-helper dir: {error}"))?; + let helper = dir.join("helper"); + let executable = std::env::current_exe() + .map_err(|error| format!("cannot locate daemon executable: {error}"))?; + #[cfg(unix)] + std::os::unix::fs::symlink(&executable, &helper) + .map_err(|error| format!("cannot link credential helper: {error}"))?; + #[cfg(not(unix))] + std::fs::copy(&executable, &helper) + .map_err(|error| format!("cannot copy credential helper: {error}"))?; + + let config = dir.join("config"); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options + .open(&config) + .map_err(|error| format!("cannot create credential config: {error}"))?; + let payload = zeroize::Zeroizing::new(format!("CCGH1\n{repo}\n{token}")); + file.write_all(payload.as_bytes()) + .map_err(|error| format!("cannot write credential config: {error}"))?; + file.sync_all() + .map_err(|error| format!("cannot sync credential config: {error}"))?; + Ok(CredentialHelperLease { + helper_command: format!( + "{} credential-helper {}", + helper.display(), + config.display() + ), + dir, + }) + } +} + +impl Drop for CredentialHelperLease { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn trusted_git(worktree: &crustcore_path::WorktreeRoot) -> std::process::Command { + let mut command = std::process::Command::new("git"); + command + .env_clear() + .env( + "PATH", + "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin", + ) + .env("HOME", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_TERMINAL_PROMPT", "0") + .arg("-C") + .arg(worktree.as_path()) + .args(["-c", "core.hooksPath=/dev/null"]) + .args(["-c", "commit.gpgSign=false"]) + .args(["-c", "core.fsmonitor=false"]); + command +} + +fn prepare_pr_commit( + worktree: &crustcore_path::WorktreeRoot, +) -> Result { + let add = trusted_git(worktree) + .args(["add", "-A", "--", "."]) + .output() + .map_err(|error| format!("cannot stage verified candidate: {error}"))?; + if !add.status.success() { + return Err(format!( + "staging verified candidate failed: {}", + bound_tail(&String::from_utf8_lossy(&add.stderr)) + )); + } + let commit = trusted_git(worktree) + .args([ + "-c", + "user.name=CrustCore", + "-c", + "user.email=crustcore@localhost", + "commit", + "-q", + "-m", + "CrustCore verified candidate", + ]) + .output() + .map_err(|error| format!("cannot commit candidate: {error}"))?; + if !commit.status.success() { + return Err(format!( + "candidate commit failed: {}", + bound_tail(&String::from_utf8_lossy(&commit.stderr)) + )); + } + let head = trusted_git(worktree) + .args(["rev-parse", "HEAD"]) + .output() + .map_err(|error| format!("cannot resolve candidate commit: {error}"))?; + if !head.status.success() { + return Err("cannot resolve candidate commit".into()); + } + Ok(crustcore_backend::PatchRef { + diff_hash: crustcore_types::hash::sha256(&head.stdout), + }) +} + +fn push_and_create_pr( + cap: &Approved, + intent: &crustcore_backend::integrate::PrIntent, + worktree: &crustcore_path::WorktreeRoot, + target: &PrTarget, + task: crustcore_types::TaskId, +) -> Result { + use crustcore_net::github::{GitHubApi as _, RestGitHub, GITHUB_API}; + use std::rc::Rc; + + let push = crate::github::PushRequest::new( + target.repo.clone(), + crate::github::GITHUB_HOST, + false, + vec![format!("HEAD:refs/heads/{}", intent.head_branch)], + ); + crate::github::validate_push(cap.value(), &push) + .map_err(|error| format!("push policy refused: {error:?}"))?; + + let token = read_owner_only_token(&target.credential_file)?; + let helper = CredentialHelperLease::create(task, &target.repo, token.as_str())?; + let mut command = trusted_git(worktree); + command.args(crate::github::confining_git_config(&helper.helper_command)); + let remote = format!("https://github.com/{}.git", target.repo); + let pushed = command + .args([ + "push", + "--porcelain", + "--", + &remote, + &format!("HEAD:refs/heads/{}", intent.head_branch), + ]) + .output() + .map_err(|error| format!("cannot launch confined git push: {error}"))?; + if !pushed.status.success() { + return Err(format!( + "git push failed: {}", + bound_tail(&String::from_utf8_lossy(&pushed.stderr)) + )); + } + + let credentials = + Rc::new(crustcore_net::credsource::StaticCredentials::new().with("github", token.as_str())); + let api = RestGitHub::new( + GITHUB_API, + "github", + Rc::new(crustcore_net::transport::UreqClient::new()), + credentials, + ); + api.create_pull(&crate::github::pr_intent_to_create_request(intent)) + .map_err(|error| format!("GitHub draft PR creation failed: {error}")) +} /// The coding backend for a chat-launched task. `Native` verifies the worktree as it /// stands (its HEAD is the verified state); the others run an external worker that @@ -59,6 +386,10 @@ pub struct PrTarget { pub base_branch: String, /// The branch prefix the head branch is confined under (e.g. `crustcore`). pub branch_prefix: String, + /// Owner-only file containing a fine-grained PAT or short-lived installation + /// token. The path is operator configuration; bytes never enter a model, + /// sandbox environment, argv, log, or worktree. + pub credential_file: std::path::PathBuf, } /// What a chat-launched task runs against: the repo, the verify command (split into @@ -213,11 +544,13 @@ fn run_one_task( ) -> String { use crustcore_backend::verify::{run_verify, VerifyIds, VerifyOutcome}; use crustcore_backend::{complete_task, PatchRef}; - use crustcore_policy::SandboxExecCap; + use crustcore_kernel::EventKind; + use crustcore_policy::{PolicySnapshot, RiskProfile}; + use crustcore_receipts::join::{verify_against_log, FrameRef}; use crustcore_receipts::{MacKey, ReceiptChain}; use crustcore_sandbox::SandboxProfile; use crustcore_types::hash::sha256; - use crustcore_types::{EventSeq, JobId, ScopeId, TaskId, ToolCallId}; + use crustcore_types::{EventSeq, JobId, ScopeId, ToolCallId}; use crustcore_worktree::WorktreeManager; // Resolve the verify spec up front (before any side effect): explicit program+args, @@ -227,23 +560,47 @@ fn run_one_task( Ok(s) => s, Err(e) => return format!("⛔ {e}"), }; + if matches!(spec.backend, TaskBackend::Native) && !goal.trim().is_empty() { + return "⛔ native is verify-only; configure codex, claude, or cmd for coding tasks." + .to_string(); + } + if let Some(target) = &spec.pr { + if let Err(error) = validate_pr_target(target) { + return format!("⛔ invalid PR configuration: {error}"); + } + } - let task = TaskId(1); + let task = next_task_id(); + let job = JobId(task.0); + let mut audit = TaskAudit::new(task); + audit.append(1, EventKind::TaskCreated, task, None, b"task created"); + audit.append(2, EventKind::JobQueued, task, Some(job), b"job queued"); + let key = match MacKey::load_or_create(&task_state_root().join("receipt.key")) { + Ok(key) => key, + Err(error) => return format!("⛔ receipt key unavailable: {error}"), + }; let manager = WorktreeManager::new(&spec.repo_root); let worktree = match manager.create_for(task) { Ok(w) => w, - Err(e) => return format!("❌ could not create worktree: {e}"), + Err(e) => { + audit.append(3, EventKind::TaskFailed, task, None, b"task failed"); + let _ = audit.log.store_atomic(&audit.path); + return format!("❌ could not create worktree: {e}"); + } }; + audit.append(3, EventKind::JobLeased, task, Some(job), b"job leased"); let _ = tx.send("📂 worktree ready…".to_string()); - let cap = SandboxExecCap { - profile: ScopeId(1), - scope: ScopeId(1), - }; let profile = SandboxProfile::default_sandboxed(); + let cap = PolicySnapshot::new(RiskProfile::Supervised) + .authorize_reversible(ScopeId(1)) + .expect("supervised policy permits sandboxed task execution") + .sandbox_exec(profile.id); // Cancel before producing the patch — cheapest abort point (worktree only). if cancel.load(Ordering::SeqCst) { + audit.append(4, EventKind::TaskKilled, task, None, b"task cancelled"); + let _ = audit.log.store_atomic(&audit.path); let _ = manager.remove(&worktree); return "⛔ CANCELLED before work started.".to_string(); } @@ -252,7 +609,7 @@ fn run_one_task( // repo is untouched). A worker runs sandboxed with no secrets; CrustCore re-derives // the real diff and takes only the (unverified) patch reference — the worker's // self-claim is never authority (invariants 6, 7). - let patch: PatchRef = match &spec.backend { + let mut patch: PatchRef = match &spec.backend { TaskBackend::Native => { let head = manager.head_commit(&worktree).unwrap_or_default(); PatchRef { @@ -267,6 +624,8 @@ fn run_one_task( match run_external_worker(coding.as_ref(), &input, &worktree, &cap, &profile) { Ok(product) => product.patch.0, Err(e) => { + audit.append(4, EventKind::TaskFailed, task, Some(job), b"task failed"); + let _ = audit.log.store_atomic(&audit.path); let _ = manager.remove(&worktree); return format!("❌ WORKER REJECTED: {}", bound_tail(&e.to_string())); } @@ -274,20 +633,60 @@ fn run_one_task( } }; + let prepared_branch = if let Some(cap) = pr_cap { + let prefix = cap.value().branch_prefix().0.as_str(); + let branch = format!("{prefix}/chat-{:032x}", task.0); + match prepare_pr_commit(&worktree) { + Ok(committed) => patch = committed, + Err(error) => { + audit.append(4, EventKind::TaskFailed, task, Some(job), b"commit failed"); + let _ = audit.log.store_atomic(&audit.path); + let _ = manager.remove(&worktree); + return format!("⛔ PR candidate preparation failed: {error}"); + } + } + Some(branch) + } else { + None + }; + // Cancel before the (expensive) verify run. if cancel.load(Ordering::SeqCst) { + audit.append( + 4, + EventKind::PatchProposed, + task, + Some(job), + b"patch proposed", + ); + audit.append(5, EventKind::TaskKilled, task, None, b"task cancelled"); + let _ = audit.log.store_atomic(&audit.path); let _ = manager.remove(&worktree); return "⛔ CANCELLED before verify.".to_string(); } let _ = tx.send(format!("🔬 verifying: {}…", verify_spec.display())); - let mut receipts = ReceiptChain::new(MacKey::new(run_key())); + audit.append( + 4, + EventKind::PatchProposed, + task, + Some(job), + b"patch proposed", + ); + audit.append( + 5, + EventKind::ToolCallStarted, + task, + Some(job), + b"verify started", + ); + let mut receipts = ReceiptChain::new(key); let ids = VerifyIds { task_id: task, - job_id: JobId(1), + job_id: job, tool_call_id: ToolCallId(1), - event_seq: EventSeq(1), + event_seq: EventSeq(6), now: now_ts(), }; @@ -305,15 +704,89 @@ fn run_one_task( let summary = match outcome { VerifyOutcome::Verified(verified) => { - // A verifier-minted patch (invariant 13). Two terminal paths: - // - PR mode (an approved cap): mint a *draft* PR intent from the verifier - // evidence under the human-approved capability (invariant 14). The intent - // is the completion for this task; the actual git push + GitHub REST POST - // are the reduced live socket (`TODO(P10-net-live)`). - // - otherwise: complete the task from the evidence (the original behavior). + let receipt = verified.receipt().clone(); + audit.append( + 6, + EventKind::ToolCallCompleted, + task, + Some(job), + &receipt.to_bytes(), + ); + audit.append( + 7, + EventKind::PatchVerified, + task, + Some(job), + b"patch verified", + ); + let frames: Vec = audit + .log + .iter() + .map(|decoded| FrameRef { + seq: decoded.frame.seq, + tool_completed: decoded.frame.kind == EventKind::ToolCallCompleted, + task_id: decoded.frame.task_id, + job_id: decoded.frame.job_id, + }) + .collect(); + if !receipts.verify(std::slice::from_ref(&receipt)).is_intact() + || !verify_against_log(&[receipt], &frames).is_joined() + { + let _ = manager.remove(&worktree); + return "⛔ audit receipt/event-log join failed; task not completed.".to_string(); + } + if let Err(error) = audit.log.store_atomic(&audit.path) { + let _ = manager.remove(&worktree); + return format!("⛔ audit persistence failed; task not completed: {error}"); + } match pr_cap { - Some(cap) => open_draft_pr(cap, *verified, spec, &verify_spec), + Some(cap) => { + let branch = prepared_branch + .as_deref() + .expect("PR authority always prepares a branch before verify"); + match open_draft_pr(cap, *verified, spec, &verify_spec, &worktree, branch, task) + { + Ok(message) => { + audit.append( + 8, + EventKind::GitHubOperationCompleted, + task, + Some(job), + b"draft PR opened", + ); + audit.append( + 9, + EventKind::TaskCompleted, + task, + None, + b"task completed", + ); + if let Err(error) = audit.log.store_atomic(&audit.path) { + format!( + "⛔ draft PR opened but final audit persistence failed: {error}" + ) + } else { + message + } + } + Err(error) => { + audit.append( + 8, + EventKind::TaskFailed, + task, + None, + b"integration failed", + ); + let _ = audit.log.store_atomic(&audit.path); + format!("⛔ verified, but draft PR integration failed: {error}") + } + } + } None => { + audit.append(8, EventKind::TaskCompleted, task, None, b"task completed"); + if let Err(error) = audit.log.store_atomic(&audit.path) { + return format!("⛔ audit persistence failed; task not completed: {error}"); + } let _completion = complete_task(*verified); format!( "✅ VERIFIED — '{}' passed; task completed.", @@ -323,12 +796,30 @@ fn run_one_task( } } VerifyOutcome::Failed { status, output } => { + audit.append( + 6, + EventKind::CommandCompleted, + task, + Some(job), + b"verify failed", + ); + audit.append( + 7, + EventKind::PatchRejected, + task, + Some(job), + b"patch rejected", + ); + audit.append(8, EventKind::TaskFailed, task, None, b"task failed"); + let _ = audit.log.store_atomic(&audit.path); format!( "❌ VERIFY FAILED ({status:?}): {}", bound_tail(output.as_str()) ) } VerifyOutcome::Refused(reason) => { + audit.append(6, EventKind::TaskFailed, task, None, b"verify refused"); + let _ = audit.log.store_atomic(&audit.path); format!("⛔ VERIFY REFUSED: {}", bound_tail(&reason)) } }; @@ -338,41 +829,33 @@ fn run_one_task( summary } -/// Opens a **draft** PR intent from a verifier-minted [`VerifiedPatch`] under the -/// human-approved `GitHubWriteCap` (invariants 13 + 14). The head branch is confined to -/// the cap's branch prefix; [`open_pr`](crustcore_backend::integrate::open_pr) re-checks -/// that and the approval expiry (defense-in-depth). The returned line reports the prepared -/// draft PR (title + branches); the actual git push of the head branch and the GitHub REST -/// `create_pull` are the reduced live socket (`TODO(P10-net-live)`) — they need real -/// credentials and a pushed branch, so they cannot run in CI. +/// Consumes verifier evidence, pushes the exact committed candidate through the +/// confined credential helper, and opens a real draft PR. No raw token enters the +/// sandbox, worktree, argv, environment, log, or returned status. fn open_draft_pr( cap: &Approved, verified: crustcore_backend::VerifiedPatch, spec: &TaskSpec, verify_spec: &crustcore_backend::verify::VerifySpec, -) -> String { + worktree: &crustcore_path::WorktreeRoot, + head_branch: &str, + task: crustcore_types::TaskId, +) -> Result { use crustcore_backend::integrate::open_pr; - // Head branch is confined under the approved cap's prefix (open_pr re-checks it). - let prefix = cap.value().branch_prefix.0.as_str(); - let head_branch = format!("{prefix}/chat-task"); - let base_branch = spec + let target = spec .pr .as_ref() - .map(|p| p.base_branch.clone()) - .unwrap_or_else(|| "main".to_string()); - - match open_pr(cap, verified, &head_branch, &base_branch, now_ts()) { - Ok(intent) => format!( - "✅ VERIFIED — '{}' passed. 📝 Draft PR prepared: {} → {} (\"{}\"). \ - The push + GitHub REST open is the live socket.", - verify_spec.display(), - intent.head_branch, - intent.base_branch, - intent.title - ), - Err(e) => format!("⛔ verified, but PR refused: {e}"), - } + .ok_or_else(|| "PR target disappeared before integration".to_string())?; + let intent = open_pr(cap, verified, head_branch, &target.base_branch, now_ts()) + .map_err(|error| format!("PR policy refused: {error}"))?; + let created = push_and_create_pr(cap, &intent, worktree, target, task)?; + Ok(format!( + "✅ VERIFIED — '{}' passed. Draft PR #{} opened: {}", + verify_spec.display(), + created.number, + bound_tail(&created.html_url) + )) } /// Resolves the verify spec for a task: explicit program + literal args (no shell — @@ -402,23 +885,6 @@ fn backend_for(b: &TaskBackend) -> Box [u8; 32] { - use std::io::Read as _; - let mut key = [0u8; 32]; - if let Ok(mut f) = std::fs::File::open("/dev/urandom") { - if f.read_exact(&mut key).is_ok() { - return key; - } - } - for (i, b) in key.iter_mut().enumerate() { - *b = 0xC0u8 ^ (i as u8); - } - key -} - /// Wall-clock timestamp for stamping the verify run. Time enters at the adapter layer, /// never inside the kernel. fn now_ts() -> crustcore_types::Timestamp { @@ -575,6 +1041,87 @@ mod tests { assert_ne!(TaskBackend::Native, TaskBackend::Codex); } + #[test] + fn pr_candidate_is_committed_before_verification() { + let dir = std::env::temp_dir().join(format!("cc-pr-commit-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let git = |args: &[&str]| { + std::process::Command::new("git") + .current_dir(&dir) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .args(args) + .status() + .unwrap() + .success() + }; + assert!(git(&["init", "-q"])); + std::fs::write(dir.join("file.txt"), b"before\n").unwrap(); + assert!(git(&["add", "."])); + assert!(git(&[ + "-c", + "user.name=ci", + "-c", + "user.email=ci@localhost", + "commit", + "-q", + "-m", + "init", + ])); + std::fs::write(dir.join("file.txt"), b"after\n").unwrap(); + let root = crustcore_path::WorktreeRoot::open(&dir).unwrap(); + let patch = prepare_pr_commit(&root).unwrap(); + assert_ne!(patch.diff_hash, [0u8; 32]); + let status = trusted_git(&root) + .args(["status", "--porcelain"]) + .output() + .unwrap(); + assert!(status.status.success()); + assert!(status.stdout.is_empty(), "committed tree must be clean"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn token_file_must_be_owner_only() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = std::env::temp_dir().join(format!("cc-token-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("token"); + std::fs::write(&path, b"github_pat_TEST").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(read_owner_only_token(&path).is_err()); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert_eq!( + read_owner_only_token(&path).unwrap().as_str(), + "github_pat_TEST" + ); + std::fs::write(&path, b" github_pat_TEST\n").unwrap(); + assert!(read_owner_only_token(&path).is_err()); + std::fs::write(&path, b"github_pat_TEST\nextra\n").unwrap(); + assert!(read_owner_only_token(&path).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn pr_configuration_rejects_slug_and_ref_injection() { + assert!(valid_repo_slug("owner/name")); + assert!(!valid_repo_slug("owner/name\nTOKEN")); + assert!(!valid_repo_slug("owner/name/extra")); + assert!(valid_git_branch("release/next")); + assert!(!valid_git_branch("main\ncredential.helper=evil")); + assert!(!valid_git_branch("refs/../main")); + let target = PrTarget { + repo: "owner/name".to_string(), + base_branch: "main".to_string(), + branch_prefix: "crustcore".to_string(), + credential_file: "/tmp/token".into(), + }; + assert!(validate_pr_target(&target).is_ok()); + } + // The real `run_one_task` needs a sandbox backend + a git repo worktree; it is // `#[ignore]`d (the reduced live seam). The threading core above is the CI-tested // part; this asserts the verified flow end to end on a host with a sandbox backend. @@ -593,7 +1140,7 @@ mod tests { backend: TaskBackend::Native, pr: None, }; - let line = run_one_task(&pass, "goal", &cancel, &tx, None); + let line = run_one_task(&pass, "", &cancel, &tx, None); assert!(line.starts_with("✅ VERIFIED"), "got: {line}"); let fail = TaskSpec { @@ -602,7 +1149,7 @@ mod tests { backend: TaskBackend::Native, pr: None, }; - let line = run_one_task(&fail, "goal", &cancel, &tx, None); + let line = run_one_task(&fail, "", &cancel, &tx, None); assert!(line.starts_with("❌ VERIFY FAILED"), "got: {line}"); } } diff --git a/crates/crustcore-dev/src/auth.rs b/crates/crustcore-dev/src/auth.rs index 2416a46..ebb01c1 100644 --- a/crates/crustcore-dev/src/auth.rs +++ b/crates/crustcore-dev/src/auth.rs @@ -85,7 +85,7 @@ fn ct_eq(a: &[u8], b: &[u8]) -> bool { } fn decode_hex(s: &str) -> Option> { - if s.len() % 2 != 0 { + if !s.len().is_multiple_of(2) { return None; } let bytes = s.as_bytes(); diff --git a/crates/crustcore-eval/tests/golden.rs b/crates/crustcore-eval/tests/golden.rs index 54ed820..d2ec9b3 100644 --- a/crates/crustcore-eval/tests/golden.rs +++ b/crates/crustcore-eval/tests/golden.rs @@ -5,6 +5,13 @@ //! sockets stay behind ignored smoke tests elsewhere; this suite exercises the //! verifier-owned completion and draft-PR decision loops with deterministic fixtures. +fn sandbox_cap(scope: crustcore_types::ScopeId) -> crustcore_policy::SandboxExecCap { + crustcore_policy::PolicySnapshot::new(crustcore_policy::RiskProfile::Supervised) + .authorize_reversible(scope) + .expect("supervised test policy should authorize sandbox execution") + .sandbox_exec(scope) +} + /// Golden (P5.6 / P16.7), the flagship task: a repo has a **failing test**; a worker /// fixes it in a disposable worktree, and **only the verifier completes** the task /// (DoD #3/#4/#5). It exercises the load-bearing properties through public APIs: @@ -21,7 +28,6 @@ fn golden_fix_failing_test() { use crustcore_backend::verify::{run_verify, VerifyIds, VerifyOutcome, VerifySpec}; use crustcore_backend::worker::{run_external_worker, ExternalCommandBackend, WorkerInput}; use crustcore_backend::{complete_task, PatchRef}; - use crustcore_policy::SandboxExecCap; use crustcore_receipts::{MacKey, ReceiptChain}; use crustcore_sandbox::SandboxProfile; use crustcore_types::{EventSeq, JobId, ScopeId, TaskId, Timestamp, ToolCallId}; @@ -66,10 +72,7 @@ fn golden_fix_failing_test() { let manager = WorktreeManager::with_base(&repo, &wt_base); let worktree = manager.create_for(TaskId(1)).expect("create worktree"); let profile = SandboxProfile::default_sandboxed(); - let cap = SandboxExecCap { - profile: ScopeId(1), - scope: ScopeId(1), - }; + let cap = sandbox_cap(ScopeId(1)); let ids = VerifyIds { task_id: TaskId(1), job_id: JobId(1), @@ -203,7 +206,6 @@ fn golden_add_small_feature() { use crustcore_backend::verify::{run_verify, VerifyIds, VerifyOutcome, VerifySpec}; use crustcore_backend::worker::{run_external_worker, ExternalCommandBackend, WorkerInput}; use crustcore_backend::{complete_task, PatchRef}; - use crustcore_policy::SandboxExecCap; use crustcore_receipts::{MacKey, ReceiptChain}; use crustcore_sandbox::SandboxProfile; use crustcore_types::{EventSeq, JobId, ScopeId, TaskId, Timestamp, ToolCallId}; @@ -247,10 +249,7 @@ fn golden_add_small_feature() { let manager = WorktreeManager::with_base(&repo, &wt_base); let worktree = manager.create_for(TaskId(1)).expect("create worktree"); let profile = SandboxProfile::default_sandboxed(); - let cap = SandboxExecCap { - profile: ScopeId(1), - scope: ScopeId(1), - }; + let cap = sandbox_cap(ScopeId(1)); let ids = VerifyIds { task_id: TaskId(1), job_id: JobId(1), @@ -363,7 +362,7 @@ fn golden_issue_to_pr_flow() { create_pull_body, CheckState, CreatePrRequest, GitHubApi, RestGitHub, GITHUB_API, }; use crustcore_net::transport::{Canned, ReplayClient}; - use crustcore_policy::{AuthorizedUser, GitHubWriteCap, SandboxExecCap}; + use crustcore_policy::AuthorizedUser; use crustcore_receipts::{MacKey, ReceiptChain}; use crustcore_sandbox::SandboxProfile; use crustcore_secrets::Redactor; @@ -424,10 +423,7 @@ fn golden_issue_to_pr_flow() { let manager = WorktreeManager::with_base(&repo, &wt_base); let worktree = manager.create_for(TaskId(7)).expect("create worktree"); let profile = SandboxProfile::default_sandboxed(); - let cap = SandboxExecCap { - profile: ScopeId(7), - scope: ScopeId(7), - }; + let cap = sandbox_cap(ScopeId(7)); let ids = VerifyIds { task_id: TaskId(7), job_id: JobId(7), @@ -496,13 +492,10 @@ fn golden_issue_to_pr_flow() { other => panic!("issue fix must mint a VerifiedPatch, got {other:?}"), }; - let write_cap = GitHubWriteCap { - repo: RepoRef(BoundedText::truncated("owner/name", 64)), - branch_prefix: BranchPrefix(BoundedText::truncated("crustcore/", 64)), - scope: ScopeId(7), - }; - let approval = AuthorizedUser::bind(42).approve( - write_cap, + let (_, approval) = AuthorizedUser::bind(42).approve_github_write( + RepoRef(BoundedText::truncated("owner/name", 64)), + BranchPrefix(BoundedText::truncated("crustcore/", 64)), + ScopeId(7), ApprovalId(700), Timestamp::from_millis(10_000), ); diff --git a/crates/crustcore-eval/tests/redteam.rs b/crates/crustcore-eval/tests/redteam.rs index f906581..e0ad733 100644 --- a/crates/crustcore-eval/tests/redteam.rs +++ b/crates/crustcore-eval/tests/redteam.rs @@ -23,8 +23,7 @@ fn redteam_scenarios_are_enumerated() { #[test] fn git_config_and_hooks_do_not_execute() { use crustcore_path::WorktreeRoot; - use crustcore_policy::FsReadCap; - use crustcore_policy::FsWriteCap; + use crustcore_policy::{FsReadCap, FsWriteCap, PolicySnapshot, RiskProfile}; use crustcore_types::ScopeId; use crustcore_worktree::{git_apply, git_diff, git_log, git_status}; @@ -92,10 +91,10 @@ fn git_config_and_hooks_do_not_execute() { std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); } - let cap = FsReadCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let issuer = PolicySnapshot::new(RiskProfile::Supervised) + .authorize_reversible(ScopeId(1)) + .expect("supervised test policy should authorize worktree access"); + let cap: FsReadCap = issuer.fs_read(WorktreeRoot::open(&dir).unwrap()); // Also plant a clean/smudge filter driver mapped by a committed // .gitattributes — this is the git_apply (smudge) / git_diff (clean) RCE. @@ -129,10 +128,7 @@ fn git_config_and_hooks_do_not_execute() { let _ = git_status(&cap); let _ = git_diff(&cap); let _ = git_log(&cap, 10); - let wcap = FsWriteCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let wcap: FsWriteCap = issuer.fs_write(WorktreeRoot::open(&dir).unwrap()); let patch = concat!( "--- a/a.rs\n", "+++ b/a.rs\n", diff --git a/crates/crustcore-eventlog/src/lib.rs b/crates/crustcore-eventlog/src/lib.rs index cba1207..0f88b4e 100644 --- a/crates/crustcore-eventlog/src/lib.rs +++ b/crates/crustcore-eventlog/src/lib.rs @@ -376,6 +376,52 @@ impl EventLog { &self.bytes } + /// Atomically persists an intact log with owner-only permissions. + /// + /// The temporary file is created beside the destination, synced, then renamed, + /// so a crash cannot expose a partially-written final log. A broken in-memory + /// chain is refused rather than copied to durable state. + pub fn store_atomic(&self, path: &std::path::Path) -> std::io::Result<()> { + use std::io::Write as _; + + if !self.verify().is_intact() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "refusing to persist a broken event log", + )); + } + let parent = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + std::fs::create_dir_all(parent)?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("events.cclog"); + let temp = parent.join(format!(".{name}.tmp-{}", std::process::id())); + let _ = std::fs::remove_file(&temp); + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let result = (|| { + let mut file = options.open(&temp)?; + file.write_all(&self.bytes)?; + file.sync_all()?; + std::fs::rename(&temp, path)?; + if let Ok(dir) = std::fs::File::open(parent) { + let _ = dir.sync_all(); + } + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temp); + } + result + } + /// Number of frames appended. #[must_use] pub fn len(&self) -> u64 { @@ -829,6 +875,28 @@ mod tests { .timestamp(Timestamp::from_millis(seq * 1000)) } + #[test] + fn atomic_store_roundtrips_an_intact_log() { + let dir = std::env::temp_dir().join(format!("cc-eventlog-store-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("run.cclog"); + let mut log = EventLog::new(); + log.append(&meta(1, EventKind::TaskCreated), b"created"); + log.store_atomic(&path).unwrap(); + let loaded = EventLog::from_bytes(std::fs::read(&path).unwrap()); + assert_eq!(loaded.bytes(), log.bytes()); + assert!(loaded.verify().is_intact()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + let _ = std::fs::remove_dir_all(&dir); + } + fn sample_log() -> EventLog { let mut log = EventLog::new(); log.append(&meta(1, EventKind::TaskCreated), b"created"); diff --git a/crates/crustcore-flow/tests/live_flow.rs b/crates/crustcore-flow/tests/live_flow.rs index a4e9b7a..87c3c92 100644 --- a/crates/crustcore-flow/tests/live_flow.rs +++ b/crates/crustcore-flow/tests/live_flow.rs @@ -50,10 +50,10 @@ fn ids() -> VerifyIds { } fn cap() -> SandboxExecCap { - SandboxExecCap { - profile: ScopeId(1), - scope: ScopeId(1), - } + PolicySnapshot::new(RiskProfile::Supervised) + .authorize_reversible(ScopeId(1)) + .expect("supervised test policy should authorize sandbox execution") + .sandbox_exec(ScopeId(1)) } /// A single `verify` flow over a real, sandboxed `run_verify`. Mirrors the backend diff --git a/crates/crustcore-kernel/src/event.rs b/crates/crustcore-kernel/src/event.rs index 0b0da7d..32bd77b 100644 --- a/crates/crustcore-kernel/src/event.rs +++ b/crates/crustcore-kernel/src/event.rs @@ -171,8 +171,9 @@ pub struct Event { pub resolution: Option, /// What this event consumed, folded into the task budget (invariant 11). pub budget_delta: Option, - /// The budget *limits* a task is seeded with (on `TaskCreated`; `None` = - /// unlimited). Distinct from `budget_delta`, which is consumption. + /// The finite budget limits a task is seeded with (required on `TaskCreated`). + /// Missing or partially unlimited budgets fail closed: no task is created. + /// Distinct from `budget_delta`, which is consumption. pub budget: Option, /// The lease holder for a job event (on `JobLeased` it sets the owner; on a /// later job event a mismatch marks it stale — invariant 12). @@ -195,6 +196,14 @@ impl Event { Event::base(kind, actor, Visibility::ModelVisible).with_seq(seq) } + /// Builds a well-formed task-creation event with an explicit trusted budget. + #[must_use] + pub fn task_created(seq: EventSeq, actor: Actor, task_id: TaskId, budget: Budget) -> Self { + Event::inbound(EventKind::TaskCreated, seq, actor) + .with_task(task_id) + .with_budget(budget) + } + fn base(kind: EventKind, actor: Actor, visibility: Visibility) -> Self { Event { kind, diff --git a/crates/crustcore-kernel/src/kernel.rs b/crates/crustcore-kernel/src/kernel.rs index 5ac5756..1799bea 100644 --- a/crates/crustcore-kernel/src/kernel.rs +++ b/crates/crustcore-kernel/src/kernel.rs @@ -109,8 +109,9 @@ impl Kernel { if event.kind == EventKind::TaskCreated { if let Some(tid) = event.task_id { if self.task_index(tid).is_none() { - let budget = event.budget.unwrap_or_else(Budget::unlimited); - self.tasks.push(TaskEntry::new(tid, budget, event.seq)); + if let Some(budget) = event.budget.filter(Budget::is_fully_bounded) { + self.tasks.push(TaskEntry::new(tid, budget, event.seq)); + } } } return; @@ -495,11 +496,12 @@ mod tests { let mut k = kernel(); let tid = TaskId(1); let mut seq = 1u64; - let mut created = - Event::inbound(EventKind::TaskCreated, EventSeq(seq), Actor::Adapter).with_task(tid); - if let Some(b) = seed_budget { - created = created.with_budget(b); - } + let created = Event::task_created( + EventSeq(seq), + Actor::Adapter, + tid, + seed_budget.unwrap_or_default(), + ); k.step(created); seq += 1; let jid = JobId(1); @@ -560,6 +562,36 @@ mod tests { assert_eq!(k.next_seq(), before.next()); } + #[test] + fn task_creation_requires_a_finite_budget_on_every_axis() { + let mut k = kernel(); + + k.step( + Event::inbound(EventKind::TaskCreated, EventSeq(1), Actor::Adapter) + .with_task(TaskId(1)), + ); + assert_eq!(k.task_status(TaskId(1)), None, "missing budget must refuse"); + + k.step( + Event::inbound(EventKind::TaskCreated, EventSeq(2), Actor::Adapter) + .with_task(TaskId(2)) + .with_budget(Budget::unlimited()), + ); + assert_eq!( + k.task_status(TaskId(2)), + None, + "any unlimited axis must refuse" + ); + + k.step(Event::task_created( + EventSeq(3), + Actor::Adapter, + TaskId(3), + Budget::standard_task(), + )); + assert_eq!(k.task_status(TaskId(3)), Some(TaskStatus::Created)); + } + // AC2: a terminal task absorbs every event and emits no tool actions. #[test] fn terminal_tasks_emit_no_tool_actions() { @@ -596,10 +628,12 @@ mod tests { for reversibility in [Reversibility::Irreversible, Reversibility::Destructive] { let mut k = Kernel::new(PolicySnapshot::new(profile)); let tid = TaskId(1); - k.step( - Event::inbound(EventKind::TaskCreated, EventSeq(1), Actor::Adapter) - .with_task(tid), - ); + k.step(Event::task_created( + EventSeq(1), + Actor::Adapter, + tid, + Budget::standard_task(), + )); k.step( Event::inbound(EventKind::JobQueued, EventSeq(2), Actor::Adapter) .with_task(tid) @@ -809,7 +843,7 @@ mod tests { #[test] fn budget_exhaustion_blocks_then_resumes() { for axis in BudgetAxis::ALL { - let budget = Budget::unlimited().with_limit(axis, 10); + let budget = Budget::standard_task().with_limit(axis, 10); let (mut k, tid, mut seq) = running_task(Some(budget)); let actions = k.step( Event::inbound( @@ -850,7 +884,7 @@ mod tests { // subsequent request until a user steer resumes it (the critical fix). #[test] fn budget_pause_is_durable() { - let budget = Budget::unlimited().with_limit(BudgetAxis::Tokens, 10); + let budget = Budget::standard_task().with_limit(BudgetAxis::Tokens, 10); let (mut k, tid, mut seq) = running_task(Some(budget)); k.step( Event::inbound( @@ -909,7 +943,7 @@ mod tests { // An untrusted request cannot silently clear a block by re-classifying. #[test] fn request_does_not_clear_a_block() { - let budget = Budget::unlimited().with_limit(BudgetAxis::Tokens, 10); + let budget = Budget::standard_task().with_limit(BudgetAxis::Tokens, 10); let (mut k, tid, mut seq) = running_task(Some(budget)); k.step( Event::inbound( @@ -977,7 +1011,12 @@ mod tests { fn policy_deny_blocks_the_task() { let mut k = Kernel::new(PolicySnapshot::new(RiskProfile::ReadOnly)); let tid = TaskId(1); - k.step(Event::inbound(EventKind::TaskCreated, EventSeq(1), Actor::Adapter).with_task(tid)); + k.step(Event::task_created( + EventSeq(1), + Actor::Adapter, + tid, + Budget::standard_task(), + )); k.step( Event::inbound(EventKind::JobQueued, EventSeq(2), Actor::Adapter) .with_task(tid) @@ -1032,9 +1071,12 @@ mod tests { ] { let mut k = kernel(); let tid = TaskId(1); - k.step( - Event::inbound(EventKind::TaskCreated, EventSeq(1), Actor::Adapter).with_task(tid), - ); + k.step(Event::task_created( + EventSeq(1), + Actor::Adapter, + tid, + Budget::standard_task(), + )); for (i, jid) in [JobId(1), JobId(2)].into_iter().enumerate() { let base = 2 + (i as u64) * 2; k.step( @@ -1071,7 +1113,7 @@ mod tests { let tid = TaskId(1); let jid = JobId(1); let events = [ - Event::inbound(EventKind::TaskCreated, EventSeq(1), Actor::Adapter).with_task(tid), + Event::task_created(EventSeq(1), Actor::Adapter, tid, Budget::standard_task()), Event::inbound(EventKind::JobQueued, EventSeq(2), Actor::Adapter) .with_task(tid) .with_job(jid), @@ -1178,7 +1220,7 @@ mod tests { #[test] fn paused_task_does_not_drive_the_model() { // (a) budget-Blocked task: leasing a second job emits no RequestModel. - let budget = Budget::unlimited().with_limit(BudgetAxis::Tokens, 10); + let budget = Budget::standard_task().with_limit(BudgetAxis::Tokens, 10); let (mut k, tid, mut seq) = running_task(Some(budget)); k.step( Event::inbound( @@ -1265,7 +1307,12 @@ mod tests { fn block_reason_is_cleared_when_leaving_blocked() { let mut k = Kernel::new(PolicySnapshot::new(RiskProfile::ReadOnly)); let tid = TaskId(1); - k.step(Event::inbound(EventKind::TaskCreated, EventSeq(1), Actor::Adapter).with_task(tid)); + k.step(Event::task_created( + EventSeq(1), + Actor::Adapter, + tid, + Budget::standard_task(), + )); k.step( Event::inbound(EventKind::JobQueued, EventSeq(2), Actor::Adapter) .with_task(tid) @@ -1379,7 +1426,7 @@ mod tests { Actor::Adapter, ) .with_task(TaskId(t)) - .with_budget(Budget::unlimited().with_limit(BudgetAxis::Tokens, 1_000)), + .with_budget(Budget::standard_task().with_limit(BudgetAxis::Tokens, 1_000)), ); } let mut lcg: u64 = 0x1234_5678_9abc_def0; diff --git a/crates/crustcore-net/Cargo.toml b/crates/crustcore-net/Cargo.toml index c3416d1..d0efa0b 100644 --- a/crates/crustcore-net/Cargo.toml +++ b/crates/crustcore-net/Cargo.toml @@ -11,6 +11,7 @@ description = "Network/provider sidecar for CrustCore (model transport, Telegram [dependencies] crustcore-types = { workspace = true } crustcore-netproto = { workspace = true } +zeroize = "1.9" # JSON shaping/parsing for the live provider adapters. A normal (non-optional) dep: # the adapters' parse/map/stream logic is exercised in CI with a canned ReplayClient # (no network), so serde_json must always be available. This is a *sidecar* crate @@ -31,14 +32,9 @@ ureq = { version = "2", optional = true } # RS256 JWT minting for GitHub App auth (P10/B2-gh-app), behind the `github-app` # feature ONLY — all three OPTIONAL so the default build (workspace + CI + the mock # helper + nano, which never links this crate at all) pulls none of them. -# `rsa` (RSASSA-PKCS1-v1_5) + `sha2` (SHA-256) sign the JWT; `base64` does the -# url-safe-no-pad JWS encoding. `forbidden-deps` asserts the default crustcore-net -# build links no rsa/sha2/base64. -rsa = { version = "0.9", optional = true } -# The `oid` feature is required: RSASSA-PKCS1-v1_5 (RS256) prefixes the DigestInfo -# OID of SHA-256, so `SigningKey::::new` needs `Sha256: AssociatedOid`. -sha2 = { version = "0.10", features = ["oid"], optional = true } -base64 = { version = "0.22", optional = true } +# `jsonwebtoken` is pinned to its AWS-LC backend: constant-time, audited crypto +# without the RustCrypto `rsa` crate's unpatched Marvin timing advisory. +jsonwebtoken = { version = "10.4", default-features = false, features = ["aws_lc_rs", "use_pem"], optional = true } [features] # Enables the real network transport (`UreqClient`) + the `live_engine` builder, and @@ -47,9 +43,9 @@ base64 = { version = "0.22", optional = true } live = ["dep:ureq"] # Enables GitHub App RS256 JWT minting + installation-token exchange (the `githubapp` -# module). Pulls the rsa/sha2/base64 crypto crates — OFF by default, never nano. +# module). Pulls the AWS-LC JWT stack — OFF by default, never nano. # Compose with `live` (`--features "live,github-app"`) for the real token exchange. -github-app = ["dep:rsa", "dep:sha2", "dep:base64"] +github-app = ["dep:jsonwebtoken"] # The sidecar helper binary nano spawns and talks to over the local protocol. [[bin]] diff --git a/crates/crustcore-net/src/credsource.rs b/crates/crustcore-net/src/credsource.rs index 634a8dc..8b79e84 100644 --- a/crates/crustcore-net/src/credsource.rs +++ b/crates/crustcore-net/src/credsource.rs @@ -36,6 +36,13 @@ pub struct AuthHeader { pub value: String, } +impl Drop for AuthHeader { + fn drop(&mut self) { + use zeroize::Zeroize as _; + self.value.zeroize(); + } +} + impl fmt::Debug for AuthHeader { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // Never print the secret-bearing value (invariant 2). @@ -71,7 +78,7 @@ pub trait CredentialSource { /// so they are not printed. #[derive(Default)] pub struct StaticCredentials { - keys: std::collections::BTreeMap, + keys: std::collections::BTreeMap>, } impl fmt::Debug for StaticCredentials { @@ -92,7 +99,8 @@ impl StaticCredentials { /// an assembled header in [`CredentialSource::header_for`]. #[must_use] pub fn with(mut self, label: &str, key: &str) -> Self { - self.keys.insert(label.to_string(), key.to_string()); + self.keys + .insert(label.to_string(), zeroize::Zeroizing::new(key.to_string())); self } } @@ -103,18 +111,18 @@ impl CredentialSource for StaticCredentials { Some(match style { AuthStyle::Bearer => AuthHeader { name: "Authorization", - value: format!("Bearer {key}"), + value: format!("Bearer {}", key.as_str()), }, AuthStyle::XApiKey => AuthHeader { name: "x-api-key", - value: key.clone(), + value: key.as_str().to_owned(), }, }) } fn bot_token(&self, label: &str) -> Option { // The same key store backs the Telegram bot token (URL-path credential). - self.keys.get(label).cloned() + self.keys.get(label).map(|key| key.as_str().to_owned()) } } diff --git a/crates/crustcore-net/src/githubapp.rs b/crates/crustcore-net/src/githubapp.rs index 9b9b630..e682742 100644 --- a/crates/crustcore-net/src/githubapp.rs +++ b/crates/crustcore-net/src/githubapp.rs @@ -16,8 +16,8 @@ //! `POST /app/installations//access_tokens`, authenticating with //! `Authorization: Bearer ` ([`AppTokenMinter::installation_token`]). //! -//! **Feature-gated (`github-app`), never nano.** The RSA + SHA-256 + base64 crates -//! this needs are *optional* dependencies behind the `github-app` feature, so the +//! **Feature-gated (`github-app`), never nano.** The AWS-LC-backed JWT implementation +//! is an *optional* dependency behind the `github-app` feature, so the //! default `crustcore-net` build (and the whole workspace/CI build, and the spawned //! mock helper) links none of them — `forbidden-deps` proves the default tree is //! HTTP/TLS- and crypto-clean. @@ -32,11 +32,7 @@ use std::rc::Rc; -use rsa::pkcs1v15::SigningKey; -use rsa::pkcs8::DecodePrivateKey; -use rsa::signature::{SignatureEncoding, Signer}; -use rsa::RsaPrivateKey; -use sha2::Sha256; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; use crate::github::GitHubError; use crate::transport::HttpClient; @@ -51,35 +47,23 @@ pub const JWT_TTL_SECS: u64 = 9 * 60; pub const JWT_IAT_BACKDATE_SECS: u64 = 30; /// A GitHub App's RSA private signing key, parsed from PEM. Holds the parsed -/// [`SigningKey`] only — the PEM bytes are consumed by [`AppRsaKey::from_pem`] and not +/// [`EncodingKey`] only — the PEM bytes are consumed by [`AppRsaKey::from_pem`] and not /// retained. Deliberately not `Debug`/`Clone`/`Serialize`: it is secret material /// (invariant 3) built just before signing and dropped. pub struct AppRsaKey { - signing_key: SigningKey, + signing_key: EncodingKey, } impl AppRsaKey { - /// Parses a PKCS#8 PEM RSA private key (GitHub Apps issue PKCS#1 `.pem`; callers - /// holding a PKCS#1 key can convert once at setup). The bytes are parsed and - /// dropped; only the signing key is retained. + /// Parses a PKCS#1 or PKCS#8 PEM RSA private key. The bytes are parsed and + /// dropped; only the AWS-LC-backed signing key is retained. /// /// # Errors /// [`JwtError::Key`] if the PEM is not a valid RSA private key. The error carries a /// fixed reason, **never** the key bytes. pub fn from_pem(pem: &str) -> Result { - let key = RsaPrivateKey::from_pkcs8_pem(pem).map_err(|_| JwtError::Key)?; - Ok(AppRsaKey { - signing_key: SigningKey::::new(key), - }) - } - - /// Builds the parsed key directly from an [`RsaPrivateKey`] (used by tests that - /// generate an ephemeral keypair, and by callers that already hold a parsed key). - #[must_use] - pub fn from_rsa(key: RsaPrivateKey) -> Self { - AppRsaKey { - signing_key: SigningKey::::new(key), - } + let signing_key = EncodingKey::from_rsa_pem(pem.as_bytes()).map_err(|_| JwtError::Key)?; + Ok(AppRsaKey { signing_key }) } } @@ -88,6 +72,8 @@ impl AppRsaKey { pub enum JwtError { /// The provided PEM was not a valid RSA private key (no key bytes are carried). Key, + /// AWS-LC refused to sign the fixed RS256 token. + Sign, } impl core::fmt::Display for JwtError { @@ -95,17 +81,11 @@ impl core::fmt::Display for JwtError { match self { // Fixed message — never the key material. JwtError::Key => write!(f, "invalid RSA private key"), + JwtError::Sign => write!(f, "RSA signing failed"), } } } -/// URL-safe base64 **without** padding, the JWT (JWS) encoding (RFC 7515 §2). -fn b64url(bytes: &[u8]) -> String { - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use base64::Engine as _; - URL_SAFE_NO_PAD.encode(bytes) -} - /// Mints a signed **RS256** GitHub App JWT for `app_id`, valid from `now_unix` /// (backdated by [`JWT_IAT_BACKDATE_SECS`]) for [`JWT_TTL_SECS`]. `now_unix` is the /// caller-supplied wall-clock time in seconds (the kernel owns time; this sidecar @@ -115,25 +95,13 @@ fn b64url(bytes: &[u8]) -> String { /// where the signature is `RSASSA-PKCS1-v1_5` over SHA-256 of the signing input. It is /// a **bearer credential** (it authenticates as the App) — pass it straight to the /// token exchange and drop it; never log it. -#[must_use] -pub fn mint_app_jwt(key: &AppRsaKey, app_id: &str, now_unix: u64) -> String { +pub fn mint_app_jwt(key: &AppRsaKey, app_id: &str, now_unix: u64) -> Result { let iat = now_unix.saturating_sub(JWT_IAT_BACKDATE_SECS); let exp = iat.saturating_add(JWT_TTL_SECS); - // Compact, fixed-shape header/claims so the signing input is exactly what GitHub - // verifies. `iss` is the App id (a string, per GitHub's examples). - let header = serde_json::json!({ "alg": "RS256", "typ": "JWT" }); + let header = Header::new(Algorithm::RS256); let claims = serde_json::json!({ "iat": iat, "exp": exp, "iss": app_id }); - - let header_b64 = b64url(&serde_json::to_vec(&header).unwrap_or_default()); - let claims_b64 = b64url(&serde_json::to_vec(&claims).unwrap_or_default()); - let signing_input = format!("{header_b64}.{claims_b64}"); - - // RS256 = RSASSA-PKCS1-v1_5 with SHA-256. The SigningKey hashes + signs. - let signature = key.signing_key.sign(signing_input.as_bytes()); - let sig_b64 = b64url(&signature.to_bytes()); - - format!("{signing_input}.{sig_b64}") + jsonwebtoken::encode(&header, &claims, &key.signing_key).map_err(|_| JwtError::Sign) } /// Builds the body for `POST /app/installations//access_tokens`. GitHub accepts an @@ -155,6 +123,13 @@ pub struct InstallationToken { pub expires_at: String, } +impl Drop for InstallationToken { + fn drop(&mut self) { + use zeroize::Zeroize as _; + self.token.zeroize(); + } +} + /// Mints installation tokens for a GitHub App over an [`HttpClient`] transport. Build /// it from the App id, the parsed [`AppRsaKey`], and the shared transport; call /// [`AppTokenMinter::installation_token`] per installation as the token nears expiry. @@ -195,7 +170,8 @@ impl AppTokenMinter { installation_id: u64, now_unix: u64, ) -> Result { - let jwt = mint_app_jwt(&self.key, &self.app_id, now_unix); + let jwt = mint_app_jwt(&self.key, &self.app_id, now_unix) + .map_err(|_| GitHubError::Unauthorized)?; let url = format!( "{}/app/installations/{installation_id}/access_tokens", self.base_url @@ -249,50 +225,64 @@ fn map_token_status(status: u16) -> GitHubError { mod tests { use super::*; use crate::transport::{Canned, ReplayClient}; - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use base64::Engine as _; - use rsa::pkcs1v15::VerifyingKey; - use rsa::signature::Verifier; - use rsa::RsaPublicKey; - - /// A small (1024-bit) RSA keypair generated fresh in the test — fast to generate, - /// never committed. Production keys are 2048-bit+, resolved via the broker. - fn test_keypair() -> (AppRsaKey, RsaPublicKey) { - let mut rng = rand_for_test(); - let private = RsaPrivateKey::new(&mut rng, 1024).expect("generate test RSA key"); - let public = RsaPublicKey::from(&private); - (AppRsaKey::from_rsa(private), public) - } - - // The `rsa` crate re-exports a compatible RNG via `rand_core`; generate with a - // seeded OS RNG so the test is self-contained. - fn rand_for_test() -> impl rsa::rand_core::CryptoRngCore { - rsa::rand_core::OsRng - } - - fn split_jwt(jwt: &str) -> (String, String, Vec) { - let parts: Vec<&str> = jwt.split('.').collect(); - assert_eq!(parts.len(), 3, "a JWT has three dot-separated parts"); - let header = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap(); - let claims = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap(); - let sig = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); - (header, claims, sig) + use jsonwebtoken::{dangerous, decode, DecodingKey, Validation}; + + const TEST_RSA_KEY: &str = r#"-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCUlqZHa4Ow8v8a +o+LneBZ4dbUNHGmvQ+GOe+bb9Co+gxOQ65zI3S5AgV4UItDVNVa2GbpFV+2vslCK +L0zGslXtas5g/u8IEfTyiAc4mwi5ePkPWkHPltLu1AhEFkCM3ah7ArRdTXp2Uu67 +M3FAJpWZnhXRzD4GdMXVVTdML4U8CDerhZke1wx1RvLOSTlGaG2LD5Svc9+RtKjr +bcPatiOcut7IfwRSi42kDmNf5qB/5cMiGzbA0ATh0bTDPzJ6U0kfGiRaUxyLlVcD +W2a8TskP0UQGi9um1wk+OiFocm3O9UhdosdNFn2nirb4zo4BkMR9quEBMMaex7b9 +mPZNA/W/AgMBAAECggEAGLFMYrQIR4CqSjgP/h1jxpLxCoO9QGYMdsw6WjUiTA8m +UkAFZw/ynr+g7cG3aKcbiNmPrfRlyHvejIg9vwtl4jWMpGvZN0McrK1UXeDNu1zS +Lu3139SVqyDLWt1DzuULGZ4icRruDfnMOInc+ScwVz4BLEM/z3zaX5BwxxVik84c +0U8K8axt8i2eUfmk+PO+qOh1bsW7+TVbDMm7Pd9dmtQVnjKjJWomdm4S+S1sgsMT +6AWKNK5Rp0g5mwcs2sfQxDvSR6vY2fR78yNXceQfBetTQbZt+Wot7Pd2uMQwSPgq +TjXLMeQSgYZCUq2hfO2n6Xwqg91r6d+6yRGC9ksVAQKBgQDQuh1ePxEW+oxMqhpJ +21V1NTNLXL5u4YyQwzbHiSisF2nvFXO+JA+Lx5zdK8GzCN1gk3j8esyQp5ltFjui +PQc+jKI8Y/k1ZISEFWf7fMU8Nn8ysA9dXdlMqOCn23TISfysM78F5Z5q4Huzu8K6 +ZhQjUPEm6Crrh/cYCdh1zIWggQKBgQC2Pbu3UdZf8hnb0HUBvReKDhhP1CaCIXIz +m7oHZiccsL0HPtN8tqVJO+8tCnWbNt88AWy/N1ttg6F+cCOyyzVYcfhCJFNDFnyB +a7kEZ9ALrza5/W5HnVlslxpwov6D2wnJQxvgoIjS1AQKAnKc5Ya3nom6eR2OAdUw +Yuc3DSZ2PwKBgAOWDwcdgkeoylxO1+DI+fDnlxgGYec5zNZ35CsNejtqs5E+Bx0P +NY0rQtCx/cP7tQIBxqRf37/kgUhUh3XEIqm6dNcgyJlYPsaeL4ksnZ7pOMpAKCNs +h10/0YxQwvLmAoda5D9PsKcZcEaoRTI6qsHolBwdBQ/C6EXrdWKgvvMBAoGAPP1q +Dk8ALLoMd2lLT1qmPxi6gDTi8lgZLTZnysQgQNTRXlRjWPCTXnAFepBujZSOnzlm +2JPBMGSGLpd/Cv5BCymRSSl5CBHFd1bC47uOf+qSqSostyDs5Y+oVJvoC97JZqbj +9IglYlF8TJFHJEUYkekn9NVF09m/LGNdOCpZfecCgYAJWSW+n1cZnMGm/2ivnnst +0DD5CicHngCF0sGKLZGAk0Nm15SHKoYLbQ+v8I7rU//TH8hJxq/CT4jTCiFyRgt7 +0RMel1B6ixq6DhUE9Ze10aUi7jU0QX58s96t6bgmhD/lHy41I8casXppbZ5No7s+ +XverGQjJluKLrhI/yNqINA== +-----END PRIVATE KEY-----"#; + + const TEST_RSA_PUBLIC_KEY: &str = r#"-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlJamR2uDsPL/GqPi53gW +eHW1DRxpr0Phjnvm2/QqPoMTkOucyN0uQIFeFCLQ1TVWthm6RVftr7JQii9MxrJV +7WrOYP7vCBH08ogHOJsIuXj5D1pBz5bS7tQIRBZAjN2oewK0XU16dlLuuzNxQCaV +mZ4V0cw+BnTF1VU3TC+FPAg3q4WZHtcMdUbyzkk5Rmhtiw+Ur3PfkbSo623D2rYj +nLreyH8EUouNpA5jX+agf+XDIhs2wNAE4dG0wz8yelNJHxokWlMci5VXA1tmvE7J +D9FEBovbptcJPjohaHJtzvVIXaLHTRZ9p4q2+M6OAZDEfarhATDGnse2/Zj2TQP1 +vwIDAQAB +-----END PUBLIC KEY-----"#; + + fn test_key() -> AppRsaKey { + AppRsaKey::from_pem(TEST_RSA_KEY).expect("parse committed test-only RSA key") } #[test] fn jwt_header_and_claims_are_rs256_and_within_the_cap() { - let (key, _pub) = test_keypair(); + let key = test_key(); let now = 1_700_000_000u64; - let jwt = mint_app_jwt(&key, "123456", now); - let (header, claims, _sig) = split_jwt(&jwt); + let jwt = mint_app_jwt(&key, "123456", now).unwrap(); + let decoded = dangerous::insecure_decode::(&jwt).unwrap(); // Header: RS256 / JWT. - let h: serde_json::Value = serde_json::from_str(&header).unwrap(); - assert_eq!(h["alg"], "RS256"); - assert_eq!(h["typ"], "JWT"); + assert_eq!(decoded.header.alg, Algorithm::RS256); + assert_eq!(decoded.header.typ.as_deref(), Some("JWT")); // Claims: iss == app_id, iat backdated, exp within GitHub's 10-minute cap. - let c: serde_json::Value = serde_json::from_str(&claims).unwrap(); + let c = decoded.claims; assert_eq!(c["iss"], "123456"); let iat = c["iat"].as_u64().unwrap(); let exp = c["exp"].as_u64().unwrap(); @@ -309,43 +299,43 @@ mod tests { // The load-bearing crypto assertion: an independently-derived verifying key // (from the public half of the keypair) accepts the signature over the exact // signing input `base64url(header).base64url(claims)`. - let (key, public) = test_keypair(); - let jwt = mint_app_jwt(&key, "987654", 1_700_000_000); - - let parts: Vec<&str> = jwt.split('.').collect(); - let signing_input = format!("{}.{}", parts[0], parts[1]); - let sig_bytes = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); - - let verifying = VerifyingKey::::new(public); - let signature = rsa::pkcs1v15::Signature::try_from(sig_bytes.as_slice()).unwrap(); - verifying - .verify(signing_input.as_bytes(), &signature) - .expect("the minted RS256 signature must verify against the App's public key"); + let key = test_key(); + let jwt = mint_app_jwt(&key, "987654", 1_700_000_000).unwrap(); + let mut validation = Validation::new(Algorithm::RS256); + validation.validate_exp = false; + validation.required_spec_claims.clear(); + decode::( + &jwt, + &DecodingKey::from_rsa_pem(TEST_RSA_PUBLIC_KEY.as_bytes()).unwrap(), + &validation, + ) + .expect("the minted RS256 signature must verify through AWS-LC"); } #[test] fn a_tampered_signing_input_fails_verification() { // Flipping a claim must invalidate the signature (the signature binds header + // claims, so a forged `iss`/`exp` is rejected). - let (key, public) = test_keypair(); - let jwt = mint_app_jwt(&key, "111", 1_700_000_000); - let parts: Vec<&str> = jwt.split('.').collect(); - let sig_bytes = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); - - let verifying = VerifyingKey::::new(public); - let signature = rsa::pkcs1v15::Signature::try_from(sig_bytes.as_slice()).unwrap(); - // Tamper: claim a different App id in the signing input. - let forged_claims = b64url( - &serde_json::to_vec( - &serde_json::json!({ "iat": 0, "exp": 540, "iss": "999-attacker" }), - ) - .unwrap(), - ); - let forged_input = format!("{}.{forged_claims}", parts[0]); + let key = test_key(); + let jwt = mint_app_jwt(&key, "111", 1_700_000_000).unwrap(); + let mut forged = jwt.into_bytes(); + let first_dot = forged.iter().position(|byte| *byte == b'.').unwrap(); + let claim_byte = first_dot + 2; + forged[claim_byte] = if forged[claim_byte] == b'A' { + b'B' + } else { + b'A' + }; + let mut validation = Validation::new(Algorithm::RS256); + validation.validate_exp = false; + validation.required_spec_claims.clear(); assert!( - verifying - .verify(forged_input.as_bytes(), &signature) - .is_err(), + decode::( + String::from_utf8(forged).unwrap(), + &DecodingKey::from_rsa_pem(TEST_RSA_PUBLIC_KEY.as_bytes()).unwrap(), + &validation, + ) + .is_err(), "a tampered signing input must NOT verify" ); } @@ -368,7 +358,7 @@ mod tests { #[test] fn installation_token_parses_token_and_expiry() { - let (key, _pub) = test_keypair(); + let key = test_key(); let minter = AppTokenMinter::new( crate::github::GITHUB_API, "123456", @@ -385,7 +375,7 @@ mod tests { #[test] fn installation_token_non_2xx_never_fabricates_a_token() { - let (key, _pub) = test_keypair(); + let key = test_key(); let minter = AppTokenMinter::new( crate::github::GITHUB_API, "123456", diff --git a/crates/crustcore-net/src/lib.rs b/crates/crustcore-net/src/lib.rs index eae96fb..5c6fe62 100644 --- a/crates/crustcore-net/src/lib.rs +++ b/crates/crustcore-net/src/lib.rs @@ -49,7 +49,7 @@ pub mod credsource; pub mod embed; pub mod github; /// GitHub App RS256 JWT minting + installation-token exchange (P10/B2-gh-app). Behind -/// the `github-app` feature so the RSA/SHA-256/base64 crates it needs never enter the +/// the `github-app` feature so the AWS-LC-backed JWT stack never enters the /// default (or nano) graph; the build/sign/verify + parse logic is CI-tested. #[cfg(feature = "github-app")] pub mod githubapp; diff --git a/crates/crustcore-policy/src/caps.rs b/crates/crustcore-policy/src/caps.rs index 582c3a6..0284dde 100644 --- a/crates/crustcore-policy/src/caps.rs +++ b/crates/crustcore-policy/src/caps.rs @@ -14,47 +14,154 @@ use crustcore_types::{ #[derive(Debug)] pub struct FsReadCap { /// The worktree this capability is scoped to. - pub root: WorktreeRoot, + root: WorktreeRoot, /// The policy scope that issued it. - pub scope: ScopeId, + scope: ScopeId, } /// Authority to write files within a worktree root. #[derive(Debug)] pub struct FsWriteCap { /// The worktree this capability is scoped to. - pub root: WorktreeRoot, + root: WorktreeRoot, /// The policy scope that issued it. - pub scope: ScopeId, + scope: ScopeId, } /// Authority to make network egress to an allowlisted set of domains. #[derive(Debug)] pub struct NetworkCap { /// Allowed domains; empty means deny-all (invariant 9; NullClaw lesson). - pub allowlist: DomainAllowlist, + allowlist: DomainAllowlist, /// The policy scope that issued it. - pub scope: ScopeId, + scope: ScopeId, } /// Authority to push/PR to a repository under a branch prefix. #[derive(Debug)] pub struct GitHubWriteCap { /// The repository this capability is scoped to. - pub repo: RepoRef, + repo: RepoRef, /// The branch prefix writes are confined to (e.g. `crustcore/`). - pub branch_prefix: BranchPrefix, + branch_prefix: BranchPrefix, /// The policy scope that issued it. - pub scope: ScopeId, + scope: ScopeId, } /// Authority to execute commands under a specific sandbox profile. #[derive(Debug)] pub struct SandboxExecCap { /// Identifier of the sandbox profile to run under (see `crustcore-sandbox`). - pub profile: ScopeId, + profile: ScopeId, /// The policy scope that issued it. - pub scope: ScopeId, + scope: ScopeId, +} + +/// Policy-authorized capability factory. Its fields are private and it can only +/// be created by [`crate::PolicySnapshot::authorize_reversible`], so capability +/// structs are no longer forgeable with public literals in downstream crates. +#[derive(Debug)] +pub struct CapabilityIssuer { + pub(crate) scope: ScopeId, +} + +impl CapabilityIssuer { + #[must_use] + pub fn fs_read(&self, root: WorktreeRoot) -> FsReadCap { + FsReadCap { + root, + scope: self.scope, + } + } + + #[must_use] + pub fn fs_write(&self, root: WorktreeRoot) -> FsWriteCap { + FsWriteCap { + root, + scope: self.scope, + } + } + + #[must_use] + pub fn network(&self, allowlist: DomainAllowlist) -> NetworkCap { + NetworkCap { + allowlist, + scope: self.scope, + } + } + + #[must_use] + pub fn sandbox_exec(&self, profile: ScopeId) -> SandboxExecCap { + SandboxExecCap { + profile, + scope: self.scope, + } + } +} + +impl FsReadCap { + #[must_use] + pub fn root(&self) -> &WorktreeRoot { + &self.root + } + + #[must_use] + pub fn scope(&self) -> ScopeId { + self.scope + } +} + +impl FsWriteCap { + #[must_use] + pub fn root(&self) -> &WorktreeRoot { + &self.root + } + + #[must_use] + pub fn scope(&self) -> ScopeId { + self.scope + } +} + +impl NetworkCap { + #[must_use] + pub fn allowlist(&self) -> &DomainAllowlist { + &self.allowlist + } + + #[must_use] + pub fn scope(&self) -> ScopeId { + self.scope + } +} + +impl GitHubWriteCap { + #[must_use] + pub fn repo(&self) -> &RepoRef { + &self.repo + } + + #[must_use] + pub fn branch_prefix(&self) -> &BranchPrefix { + &self.branch_prefix + } + + #[must_use] + pub fn scope(&self) -> ScopeId { + self.scope + } +} + +impl SandboxExecCap { + #[must_use] + pub fn profile(&self) -> ScopeId { + self.profile + } + + #[must_use] + pub fn scope(&self) -> ScopeId { + self.scope + } } /// A user authorized to grant approvals (e.g. an allowlisted Telegram chat). @@ -105,6 +212,36 @@ impl AuthorizedUser { ) -> Approved { Approved::new(value, approval_id, *self, expires_at) } + + /// Issues the two GitHub write grants needed by trusted onboarding: one + /// registry capability for push validation and one approval-wrapped + /// capability for the irreversible draft-PR action. Constructing both here + /// keeps raw `GitHubWriteCap` minting inside the policy crate. + #[must_use] + pub fn approve_github_write( + &self, + repo: RepoRef, + branch_prefix: BranchPrefix, + scope: ScopeId, + approval_id: ApprovalId, + expires_at: Timestamp, + ) -> (GitHubWriteCap, Approved) { + let registry = GitHubWriteCap { + repo: RepoRef(repo.0.clone()), + branch_prefix: BranchPrefix(branch_prefix.0.clone()), + scope, + }; + let approved = self.approve( + GitHubWriteCap { + repo, + branch_prefix, + scope, + }, + approval_id, + expires_at, + ); + (registry, approved) + } } /// Wraps a value (capability or action) together with the human approval that diff --git a/crates/crustcore-policy/src/decision.rs b/crates/crustcore-policy/src/decision.rs index 9b33f35..65bf18d 100644 --- a/crates/crustcore-policy/src/decision.rs +++ b/crates/crustcore-policy/src/decision.rs @@ -6,7 +6,9 @@ //! [`PolicySnapshot`] and asks it to classify a proposed action into a //! [`PolicyDecision`]; the kernel never performs the side effect itself. -use crustcore_types::Reversibility; +use crustcore_types::{Reversibility, ScopeId}; + +use crate::caps::CapabilityIssuer; /// A coding-adapted risk profile (`ROADMAP.md` §1.3 ZeroClaw lesson, adapted). #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] @@ -77,6 +79,16 @@ impl PolicySnapshot { } } } + + /// Produces a capability issuer only when this policy explicitly allows a + /// reversible side effect. Read-only policy and approval-requiring actions + /// return their original decision and mint no authority. + pub fn authorize_reversible(&self, scope: ScopeId) -> Result { + match self.classify(Reversibility::Reversible) { + PolicyDecision::Allow => Ok(CapabilityIssuer { scope }), + other => Err(other), + } + } } #[cfg(test)] @@ -101,4 +113,15 @@ mod tests { PolicyDecision::RequireApproval { .. } )); } + + #[test] + fn only_allowing_policy_can_mint_a_capability_issuer() { + let readonly = PolicySnapshot::new(RiskProfile::ReadOnly); + assert!(readonly.authorize_reversible(ScopeId(1)).is_err()); + let supervised = PolicySnapshot::new(RiskProfile::Supervised); + let issuer = supervised + .authorize_reversible(ScopeId(7)) + .expect("supervised reversible authority"); + assert_eq!(issuer.sandbox_exec(ScopeId(3)).scope(), ScopeId(7)); + } } diff --git a/crates/crustcore-receipts/Cargo.toml b/crates/crustcore-receipts/Cargo.toml index 686c79a..c4d6fda 100644 --- a/crates/crustcore-receipts/Cargo.toml +++ b/crates/crustcore-receipts/Cargo.toml @@ -10,3 +10,4 @@ description = "Tool receipts and MAC/hash chaining for CrustCore (anti-hallucina [dependencies] crustcore-types = { workspace = true } +crustcore-zeroize = { workspace = true } diff --git a/crates/crustcore-receipts/src/lib.rs b/crates/crustcore-receipts/src/lib.rs index 15afb36..f512e1b 100644 --- a/crates/crustcore-receipts/src/lib.rs +++ b/crates/crustcore-receipts/src/lib.rs @@ -24,19 +24,74 @@ pub const GENESIS_RECEIPT_HASH: [u8; 32] = [0u8; 32]; /// /// Its bytes are never printed (`Debug` is redacted), it is not `Serialize`, not /// `Clone` (so the key is not casually duplicated around memory), and it is -/// best-effort zeroed on drop — consistent with the secret-handling discipline +/// volatile-zeroed on drop — consistent with the secret-handling discipline /// (invariants 1–3). -pub struct MacKey([u8; 32]); +pub struct MacKey(crustcore_zeroize::ZeroizingArray<32>); impl MacKey { /// Wraps 32 bytes of key material. #[must_use] pub fn new(bytes: [u8; 32]) -> Self { - MacKey(bytes) + MacKey(crustcore_zeroize::ZeroizingArray::new(bytes)) + } + + /// Loads or atomically creates a 32-byte owner-only key file. + /// + /// Existing symlinks, non-regular files, wrong lengths, or group/world access + /// fail closed. Creation requires the OS random source; there is no fixed-key + /// fallback in a real run. + pub fn load_or_create(path: &std::path::Path) -> std::io::Result { + use std::io::{Read as _, Write as _}; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + if let Ok(meta) = std::fs::symlink_metadata(path) { + if meta.file_type().is_symlink() || !meta.is_file() || meta.len() != 32 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "receipt key must be a 32-byte regular file, not a symlink", + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if meta.permissions().mode() & 0o077 != 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "receipt key permissions must be owner-only (0600)", + )); + } + } + let mut raw = crustcore_zeroize::ZeroizingArray::new([0u8; 32]); + std::fs::File::open(path)?.read_exact(&mut *raw)?; + return Ok(MacKey(raw)); + } + + let mut raw = crustcore_zeroize::ZeroizingArray::new([0u8; 32]); + std::fs::File::open("/dev/urandom")?.read_exact(&mut *raw)?; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + match options.open(path) { + Ok(mut file) => { + file.write_all(raw.as_ref())?; + file.sync_all()?; + Ok(MacKey(raw)) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + MacKey::load_or_create(path) + } + Err(error) => Err(error), + } } fn as_bytes(&self) -> &[u8] { - &self.0 + &*self.0 } } @@ -46,15 +101,6 @@ impl core::fmt::Debug for MacKey { } } -impl Drop for MacKey { - fn drop(&mut self) { - // Best-effort scrub so the key does not linger in freed memory. Without - // `unsafe`/`zeroize` (forbidden/avoided here) the optimizer *may* elide - // this; it is defense-in-depth, not a guarantee. - self.0.fill(0); - } -} - /// A receipt binding a model-visible tool result to a real tool call. /// /// Contains only hashes and ids — **never** secret values (invariant 10). @@ -85,7 +131,113 @@ pub struct ToolReceipt { pub mac: [u8; 32], } +/// Maximum artifact hashes carried by one receipt (bounded-everything invariant). +pub const MAX_RECEIPT_ARTIFACTS: usize = 256; + +const RECEIPT_MAGIC: [u8; 4] = *b"CCR1"; +const RECEIPT_FIXED_BYTES: usize = 4 + 16 * 3 + 8 + 32 * 5 + 4; + +/// Why a persisted receipt record could not be decoded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReceiptDecodeError { + /// The record is shorter than the fixed receipt header. + Truncated, + /// The record has an unknown magic/version marker. + BadMagic, + /// The artifact count exceeds the hard receipt cap. + TooManyArtifacts, + /// The record length does not exactly match its artifact count. + InvalidLength, +} + impl ToolReceipt { + /// Encodes this secret-free receipt as a stable binary event-log payload. + #[must_use] + pub fn to_bytes(&self) -> Vec { + let artifact_count = self.artifact_hashes.len().min(MAX_RECEIPT_ARTIFACTS); + let mut out = Vec::with_capacity(RECEIPT_FIXED_BYTES + artifact_count * 32); + out.extend_from_slice(&RECEIPT_MAGIC); + out.extend_from_slice(&self.task_id.0.to_le_bytes()); + out.extend_from_slice(&self.job_id.0.to_le_bytes()); + out.extend_from_slice(&self.tool_call_id.0.to_le_bytes()); + out.extend_from_slice(&self.event_seq.0.to_le_bytes()); + out.extend_from_slice(&self.tool_name_hash); + out.extend_from_slice(&self.args_hash); + out.extend_from_slice(&self.result_hash); + out.extend_from_slice(&self.prev_receipt_hash); + out.extend_from_slice(&self.mac); + out.extend_from_slice(&(artifact_count as u32).to_le_bytes()); + for artifact in self.artifact_hashes.iter().take(artifact_count) { + out.extend_from_slice(&artifact.0); + } + out + } + + /// Decodes a receipt persisted by [`ToolReceipt::to_bytes`]. + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < RECEIPT_FIXED_BYTES { + return Err(ReceiptDecodeError::Truncated); + } + if bytes[..4] != RECEIPT_MAGIC { + return Err(ReceiptDecodeError::BadMagic); + } + let mut offset = 4usize; + let read_u128 = |input: &[u8], offset: &mut usize| { + let mut raw = [0u8; 16]; + raw.copy_from_slice(&input[*offset..*offset + 16]); + *offset += 16; + u128::from_le_bytes(raw) + }; + let read_u64 = |input: &[u8], offset: &mut usize| { + let mut raw = [0u8; 8]; + raw.copy_from_slice(&input[*offset..*offset + 8]); + *offset += 8; + u64::from_le_bytes(raw) + }; + let read_hash = |input: &[u8], offset: &mut usize| { + let mut raw = [0u8; 32]; + raw.copy_from_slice(&input[*offset..*offset + 32]); + *offset += 32; + raw + }; + let task_id = TaskId(read_u128(bytes, &mut offset)); + let job_id = JobId(read_u128(bytes, &mut offset)); + let tool_call_id = ToolCallId(read_u128(bytes, &mut offset)); + let event_seq = EventSeq(read_u64(bytes, &mut offset)); + let tool_name_hash = read_hash(bytes, &mut offset); + let args_hash = read_hash(bytes, &mut offset); + let result_hash = read_hash(bytes, &mut offset); + let prev_receipt_hash = read_hash(bytes, &mut offset); + let mac = read_hash(bytes, &mut offset); + let mut count_raw = [0u8; 4]; + count_raw.copy_from_slice(&bytes[offset..offset + 4]); + offset += 4; + let artifact_count = u32::from_le_bytes(count_raw) as usize; + if artifact_count > MAX_RECEIPT_ARTIFACTS { + return Err(ReceiptDecodeError::TooManyArtifacts); + } + let expected = offset.saturating_add(artifact_count.saturating_mul(32)); + if bytes.len() != expected { + return Err(ReceiptDecodeError::InvalidLength); + } + let mut artifact_hashes = Vec::with_capacity(artifact_count); + for _ in 0..artifact_count { + artifact_hashes.push(ArtifactId(read_hash(bytes, &mut offset))); + } + Ok(ToolReceipt { + task_id, + job_id, + tool_call_id, + tool_name_hash, + args_hash, + result_hash, + artifact_hashes, + event_seq, + prev_receipt_hash, + mac, + }) + } + /// Whether `result` hashes to this receipt's `result_hash` — i.e. the shown /// result is the one that was actually produced (`docs/receipts.md` §4). The /// model cannot show a different result without breaking this. @@ -238,7 +390,12 @@ impl ReceiptChain { tool_name_hash: sha256(params.tool_name), args_hash: sha256(params.args), result_hash: sha256(params.result), - artifact_hashes: params.artifacts.to_vec(), + artifact_hashes: params + .artifacts + .iter() + .take(MAX_RECEIPT_ARTIFACTS) + .copied() + .collect(), event_seq: params.event_seq, prev_receipt_hash: self.head, mac: [0u8; 32], @@ -355,6 +512,31 @@ mod tests { (chain, receipts) } + #[test] + fn persisted_receipt_roundtrips_exactly() { + let (chain, receipts) = chain_of(1); + let encoded = receipts[0].to_bytes(); + let decoded = ToolReceipt::from_bytes(&encoded).unwrap(); + assert_eq!(decoded, receipts[0]); + assert!(chain.verify(&[decoded]).is_intact()); + } + + #[test] + fn persisted_receipt_rejects_truncation_and_trailing_bytes() { + let (_, receipts) = chain_of(1); + let encoded = receipts[0].to_bytes(); + assert_eq!( + ToolReceipt::from_bytes(&encoded[..encoded.len() - 1]), + Err(ReceiptDecodeError::Truncated) + ); + let mut trailing = encoded; + trailing.push(0); + assert_eq!( + ToolReceipt::from_bytes(&trailing), + Err(ReceiptDecodeError::InvalidLength) + ); + } + #[test] fn minted_chain_verifies() { let (chain, receipts) = chain_of(3); diff --git a/crates/crustcore-sandbox/src/lib.rs b/crates/crustcore-sandbox/src/lib.rs index be80a51..d7c9b9a 100644 --- a/crates/crustcore-sandbox/src/lib.rs +++ b/crates/crustcore-sandbox/src/lib.rs @@ -45,6 +45,9 @@ pub enum NetworkPosture { /// A sandbox execution profile. #[derive(Debug, Clone)] pub struct SandboxProfile { + /// Stable identifier bound into [`SandboxExecCap`]. A capability minted for + /// one profile cannot be replayed against a more permissive profile. + pub id: crustcore_types::ScopeId, /// The execution tier this profile grants. pub tier: ExecutionTier, /// The network posture (deny-all by default). @@ -60,6 +63,7 @@ impl SandboxProfile { #[must_use] pub fn default_sandboxed() -> Self { SandboxProfile { + id: crustcore_types::ScopeId(1), tier: ExecutionTier::Sandboxed, network: NetworkPosture::DenyAll, stripped_env: default_stripped_env(), @@ -205,6 +209,8 @@ impl std::error::Error for SandboxError {} use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +#[cfg(target_os = "macos")] +use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering}; use crustcore_policy::SandboxExecCap; use crustcore_runner::run; @@ -490,15 +496,14 @@ fn sbpl_escape(s: &str) -> String { } /// Builds the Seatbelt (SBPL) profile string that confines a run: deny-all -/// network egress and writes confined to the worktree + temp dirs. Pure (no I/O), -/// so the deterministic profile-shape test runs on every platform. +/// network egress, a read barrier over the operator's home/shared temp trees, +/// and writes confined to the worktree + one private temp directory. Pure (no +/// I/O), so the deterministic profile-shape test runs on every platform. /// -/// `worktree` and `tmpdir` MUST already be **canonical** (real, symlink-resolved) -/// absolute paths — SBPL `subpath` matches the kernel's resolved path, and macOS -/// symlinks `/tmp`→`/private/tmp`, `/var`→`/private/var`, and worktrees under -/// `/var/folders/...`. Embedding an unresolved path would either fail open (a -/// rule that never matches) or block legitimate worktree writes. Canonicalization -/// (and the fail-closed-on-error policy) happens in [`SeatbeltBackend::profile_for`]. +/// Every supplied path MUST already be **canonical** (real, symlink-resolved). +/// `cargo_home` / `rustup_home` are narrow read-only exceptions inside the home +/// barrier so Rust builds can use downloaded sources/toolchains. Cargo credential +/// files are denied again after that exception. /// /// The recipe is "allow-all, then deny the two load-bearing classes, then /// re-allow the writable surface" (later SBPL rules override earlier ones): @@ -510,29 +515,121 @@ fn sbpl_escape(s: &str) -> String { /// release build where nothing references it. #[cfg(any(target_os = "macos", test))] #[must_use] -fn build_seatbelt_profile(worktree: &Path, tmpdir: &Path) -> String { +fn build_seatbelt_profile( + worktree: &Path, + tmpdir: &Path, + _home: &Path, + cargo_home: Option<&Path>, + rustup_home: Option<&Path>, +) -> String { let wt = sbpl_escape(&worktree.to_string_lossy()); let tmp = sbpl_escape(&tmpdir.to_string_lossy()); - format!( + let mut profile = format!( "(version 1)\n\ (allow default)\n\ (deny network*)\n\ (deny file-write*)\n\ - (allow file-write*\n\ + (deny file-read*)\n\ + (allow file-read*\n\ + \x20 (literal \"/\") (literal \"/etc\") (literal \"/tmp\") (literal \"/var\")\n\ + \x20 (subpath \"/System\") (subpath \"/usr\") (subpath \"/bin\")\n\ + \x20 (subpath \"/sbin\") (subpath \"/private/etc\") (subpath \"/etc\")\n\ + \x20 (subpath \"/private/var/db/dyld\") (subpath \"/private/var/db/timezone\")\n\ + \x20 (literal \"/private/var/db/DarwinDirectory/local/recordStore.data\")\n\ + \x20 (literal \"/private/var/db/eligibilityd/eligibility.plist\")\n\ + \x20 (subpath \"/Library/Apple\") (subpath \"/Library/Developer\")\n\ + \x20 (subpath \"/Library/Filesystems/NetFSPlugins\")\n\ + \x20 (subpath \"/Library/Preferences/Logging\")\n\ + \x20 (subpath \"/Applications/Xcode.app\") (subpath \"/opt\")\n\ + \x20 (subpath \"/dev\") (subpath \"{wt}\") (subpath \"{tmp}\"))\n" + ); + if let Some(path) = cargo_home { + let cargo = sbpl_escape(&path.to_string_lossy()); + profile.push_str(&format!("(allow file-read* (subpath \"{cargo}\"))\n")); + for name in ["credentials", "credentials.toml"] { + let secret = sbpl_escape(&path.join(name).to_string_lossy()); + profile.push_str(&format!("(deny file-read* (literal \"{secret}\"))\n")); + } + } + if let Some(path) = rustup_home { + let rustup = sbpl_escape(&path.to_string_lossy()); + profile.push_str(&format!("(allow file-read* (subpath \"{rustup}\"))\n")); + } + profile.push_str(&format!( + "(allow file-write*\n\ \x20 (subpath \"{wt}\")\n\ \x20 (subpath \"{tmp}\")\n\ - \x20 (subpath \"/private/tmp\")\n\ - \x20 (subpath \"/private/var/tmp\")\n\ \x20 (literal \"/dev/null\") (literal \"/dev/zero\")\n\ \x20 (literal \"/dev/stdout\") (literal \"/dev/stderr\") (literal \"/dev/tty\")\n\ \x20 (literal \"/dev/dtracehelper\") (literal \"/dev/urandom\"))\n" - ) + )); + profile +} + +#[cfg(target_os = "macos")] +static PRIVATE_TMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Per-run scratch storage. It prevents a sandboxed task from reading or +/// clobbering another process's files in the shared macOS temp trees. +#[cfg(target_os = "macos")] +struct PrivateTempDir(PathBuf); + +#[cfg(target_os = "macos")] +impl PrivateTempDir { + fn create() -> Result { + use std::os::unix::fs::PermissionsExt as _; + + let base = std::env::temp_dir(); + for _ in 0..32 { + let id = PRIVATE_TMP_COUNTER.fetch_add(1, AtomicOrdering::Relaxed); + let path = base.join(format!("crustcore-sandbox-{}-{id}", std::process::id())); + match std::fs::create_dir(&path) { + Ok(()) => { + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)) + .map_err(|e| { + SandboxError::Setup(format!( + "cannot secure private sandbox temp '{}': {e}", + path.display() + )) + })?; + let canonical = path.canonicalize().map_err(|e| { + SandboxError::Setup(format!( + "cannot canonicalize private sandbox temp '{}': {e}", + path.display() + )) + })?; + return Ok(PrivateTempDir(canonical)); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => { + return Err(SandboxError::Setup(format!( + "cannot create private sandbox temp under '{}': {e}", + base.display() + ))) + } + } + } + Err(SandboxError::Setup( + "could not allocate a unique private sandbox temp directory".to_string(), + )) + } + + fn as_path(&self) -> &Path { + &self.0 + } +} + +#[cfg(target_os = "macos")] +impl Drop for PrivateTempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } } /// The macOS `sandbox-exec` (Seatbelt) backend (`docs/sandbox.md` §3 "macOS"): /// wraps the command under an SBPL profile that **denies all network egress** and -/// **confines writes to the worktree** (plus the system temp dirs and a few -/// harmless `/dev` nodes), matching the bubblewrap backend's security posture +/// **confines reads away from the operator home** and writes to the worktree plus +/// a per-run private temp dir, matching the bubblewrap backend's security posture /// (Tier 2). Like bubblewrap, network is never re-enabled here in v1 — an /// `Allowlist` profile is **refused** by [`run_command`] rather than granting raw /// unproxied host networking. @@ -559,27 +656,48 @@ impl SeatbeltBackend { find_executable("sandbox-exec").map(|sandbox_exec| SeatbeltBackend { sandbox_exec }) } - /// Resolves the canonical worktree + TMPDIR and builds the SBPL profile for a - /// run rooted at `cwd`. **Fail-closed:** if a path cannot be canonicalized we + /// Resolves the canonical worktree, private TMPDIR, operator home, and narrow + /// Rust toolchain/cache exceptions. **Fail-closed:** if a required path cannot + /// be canonicalized we /// return [`SandboxError::Setup`] rather than embedding an unresolved path /// (which would either fail open or block legitimate worktree writes — see - /// [`build_seatbelt_profile`]). The TMPDIR is taken from the *sanitized* env - /// when present, else the process `TMPDIR`, else `/private/tmp`. + /// [`build_seatbelt_profile`]). TMPDIR must be provisioned by the backend. fn profile_for(cwd: &str, env: &BTreeMap) -> Result { let worktree = std::fs::canonicalize(cwd).map_err(|e| { SandboxError::Setup(format!("cannot canonicalize worktree '{cwd}': {e}")) })?; - // Prefer the sanitized env's TMPDIR (what the child will actually see), - // then the ambient one, then the macOS default. let tmp_raw = env .get("TMPDIR") .cloned() - .or_else(|| std::env::var("TMPDIR").ok()) - .unwrap_or_else(|| "/private/tmp".to_string()); + .ok_or_else(|| SandboxError::Setup("private TMPDIR was not provisioned".into()))?; let tmpdir = std::fs::canonicalize(&tmp_raw).map_err(|e| { SandboxError::Setup(format!("cannot canonicalize TMPDIR '{tmp_raw}': {e}")) })?; - Ok(build_seatbelt_profile(&worktree, &tmpdir)) + let home_raw = std::env::var("HOME").map_err(|_| { + SandboxError::Setup("HOME is required for the macOS read barrier".into()) + })?; + let home = std::fs::canonicalize(&home_raw).map_err(|e| { + SandboxError::Setup(format!("cannot canonicalize HOME '{home_raw}': {e}")) + })?; + let canonical_optional = + |value: Option| value.and_then(|path| std::fs::canonicalize(path).ok()); + let cargo_home = canonical_optional( + std::env::var("CARGO_HOME") + .ok() + .or_else(|| Some(home.join(".cargo").to_string_lossy().into_owned())), + ); + let rustup_home = canonical_optional( + std::env::var("RUSTUP_HOME") + .ok() + .or_else(|| Some(home.join(".rustup").to_string_lossy().into_owned())), + ); + Ok(build_seatbelt_profile( + &worktree, + &tmpdir, + &home, + cargo_home.as_deref(), + rustup_home.as_deref(), + )) } /// Builds the `sandbox-exec`-wrapped [`CommandSpec`] for `inner` under @@ -617,8 +735,12 @@ impl SeatbeltBackend { let cwd = inner.cwd.as_deref().ok_or_else(|| { SandboxError::Setup("seatbelt backend requires a worktree cwd".to_string()) })?; - let profile_str = Self::profile_for(cwd, &inner.env)?; - Ok(self.wrap(inner, &profile_str)) + // Inspection-only path: use the worktree as TMPDIR so the returned spec + // never references a private directory whose guard has already dropped. + let mut inspect = inner.clone(); + inspect.env.insert("TMPDIR".to_string(), cwd.to_string()); + let profile_str = Self::profile_for(cwd, &inspect.env)?; + Ok(self.wrap(&inspect, &profile_str)) } } @@ -635,12 +757,19 @@ impl SandboxBackend for SeatbeltBackend { let cwd = spec.cwd.clone().ok_or_else(|| { SandboxError::Setup("seatbelt backend requires a worktree cwd".to_string()) })?; - let env = sanitize_env(&spec.env, profile, worktree.as_deref())?; + let mut env = sanitize_env(&spec.env, profile, worktree.as_deref())?; + let private_tmp = PrivateTempDir::create()?; + env.insert( + "TMPDIR".to_string(), + private_tmp.as_path().to_string_lossy().into_owned(), + ); // Build the SBPL confinement from the canonical worktree + (sanitized) // TMPDIR; fail closed if either cannot be resolved. let profile_str = Self::profile_for(&cwd, &env)?; let wrapped = self.wrap(&CommandSpec { env, ..spec }, &profile_str); - run(&wrapped).map_err(|e| SandboxError::Setup(e.to_string())) + let result = run(&wrapped).map_err(|e| SandboxError::Setup(e.to_string())); + drop(private_tmp); + result } } @@ -885,10 +1014,17 @@ impl SandboxBackend for WindowsBackend { /// [`SandboxError::NoBackend`] if no backend can run the tier on this host; /// [`SandboxError::Setup`] on env/path validation failure or a non-executing tier. pub fn run_command( - _cap: &SandboxExecCap, + cap: &SandboxExecCap, profile: &SandboxProfile, spec: CommandSpec, ) -> Result { + if cap.profile() != profile.id { + return Err(SandboxError::Setup(format!( + "sandbox capability profile {:?} does not authorize profile {:?}", + cap.profile(), + profile.id + ))); + } match profile.tier { ExecutionTier::None | ExecutionTier::StructuredHost => { return Err(SandboxError::Setup( @@ -950,6 +1086,14 @@ pub fn run_command( #[cfg(test)] mod tests { use super::*; + use crustcore_policy::{PolicySnapshot, RiskProfile}; + + fn sandbox_cap(profile: ScopeId) -> SandboxExecCap { + PolicySnapshot::new(RiskProfile::Supervised) + .authorize_reversible(ScopeId(1)) + .expect("supervised test policy should authorize sandbox execution") + .sandbox_exec(profile) + } use crustcore_types::ScopeId; fn env(pairs: &[(&str, &str)]) -> BTreeMap { @@ -1114,7 +1258,10 @@ mod tests { // (canonical) worktree, and NOT grant write to an arbitrary outside path. let worktree = Path::new("/private/tmp/cc-worktree-abc"); let tmpdir = Path::new("/private/var/folders/xx/cc-tmp"); - let profile = build_seatbelt_profile(worktree, tmpdir); + let home = Path::new("/Users/someone"); + let cargo = Path::new("/Users/someone/.cargo"); + let rustup = Path::new("/Users/someone/.rustup"); + let profile = build_seatbelt_profile(worktree, tmpdir, home, Some(cargo), Some(rustup)); // Deny-all egress (mirrors bwrap --unshare-all). assert!( @@ -1143,6 +1290,23 @@ mod tests { let deny_at = profile.find("(deny file-write*)").unwrap(); let allow_at = profile.find("(allow file-write*").unwrap(); assert!(deny_at < allow_at, "deny must precede the re-allow"); + let writable_rules = &profile[allow_at..]; + assert!( + !writable_rules.contains("(subpath \"/private/tmp\")"), + "shared /private/tmp must never be writable" + ); + + // Read confinement: all host reads are denied first; only system runtime + // paths, the worktree/private temp, and narrow Rust caches are re-opened. + let read_deny = profile.find("(deny file-read*)").unwrap(); + let worktree_read = profile + .find("(subpath \"/private/tmp/cc-worktree-abc\")") + .unwrap(); + assert!(read_deny < worktree_read); + assert!(!profile.contains("(subpath \"/Users/someone\")")); + assert!(profile.contains("(allow file-read* (subpath \"/Users/someone/.cargo\"))")); + assert!(profile + .contains("(deny file-read* (literal \"/Users/someone/.cargo/credentials.toml\"))")); } #[test] @@ -1151,7 +1315,8 @@ mod tests { // string literal — both are backslash-escaped. let worktree = Path::new("/tmp/a\"b\\c"); let tmpdir = Path::new("/private/tmp"); - let profile = build_seatbelt_profile(worktree, tmpdir); + let profile = + build_seatbelt_profile(worktree, tmpdir, Path::new("/Users/a\"b\\c"), None, None); assert!( profile.contains("(subpath \"/tmp/a\\\"b\\\\c\")"), "quote and backslash must be escaped in the embedded path: {profile}" @@ -1162,11 +1327,9 @@ mod tests { fn allowlist_profile_is_refused_until_proxy_exists() { // Until the trusted egress proxy is built, an allowlist profile must be // refused rather than granting raw, unproxied host networking. - let cap = SandboxExecCap { - profile: ScopeId(1), - scope: ScopeId(1), - }; + let cap = sandbox_cap(ScopeId(1)); let profile = SandboxProfile { + id: ScopeId(1), tier: ExecutionTier::Sandboxed, network: NetworkPosture::Allowlist, stripped_env: default_stripped_env(), @@ -1177,14 +1340,23 @@ mod tests { )); } + #[test] + fn sandbox_capability_is_bound_to_the_exact_profile() { + let cap = sandbox_cap(ScopeId(7)); + let profile = SandboxProfile::default_sandboxed(); + let err = run_command(&cap, &profile, CommandSpec::new("/bin/true")).unwrap_err(); + assert!( + matches!(err, SandboxError::Setup(ref message) if message.contains("does not authorize")), + "a capability for another profile must fail before backend selection: {err}" + ); + } + #[test] fn non_executing_tiers_refuse() { - let cap = SandboxExecCap { - profile: ScopeId(1), - scope: ScopeId(1), - }; + let cap = sandbox_cap(ScopeId(1)); for tier in [ExecutionTier::None, ExecutionTier::StructuredHost] { let profile = SandboxProfile { + id: ScopeId(1), tier, network: NetworkPosture::DenyAll, stripped_env: default_stripped_env(), @@ -1215,10 +1387,7 @@ mod tests { if any_real_backend_available() { return; } - let cap = SandboxExecCap { - profile: ScopeId(1), - scope: ScopeId(1), - }; + let cap = sandbox_cap(ScopeId(1)); let profile = SandboxProfile::default_sandboxed(); let err = run_command(&cap, &profile, CommandSpec::new("/bin/echo")).unwrap_err(); assert!(matches!(err, SandboxError::NoBackend)); @@ -1235,11 +1404,9 @@ mod tests { if FirecrackerBackend::detect().is_some() { return; } - let cap = SandboxExecCap { - profile: ScopeId(1), - scope: ScopeId(1), - }; + let cap = sandbox_cap(ScopeId(1)); let profile = SandboxProfile { + id: ScopeId(1), tier: ExecutionTier::Hostile, network: NetworkPosture::DenyAll, stripped_env: default_stripped_env(), @@ -1432,6 +1599,7 @@ mod tests { // an exec vector → env sanitation rejects it before the live TODO. let backend = fake_firecracker(); let profile = SandboxProfile { + id: ScopeId(1), tier: ExecutionTier::Hostile, network: NetworkPosture::DenyAll, stripped_env: default_stripped_env(), @@ -1611,6 +1779,71 @@ mod tests { std::fs::remove_dir_all(&tmp).ok(); } + #[test] + fn read_outside_worktree_is_denied() { + if SeatbeltBackend::detect().is_none() { + return; + } + let tmp = std::env::temp_dir().join(format!("cc-sbx-read-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let canon = std::fs::canonicalize(&tmp).unwrap(); + let home = PathBuf::from(std::env::var("HOME").expect("HOME")); + let sentinel = home.join(format!(".crustcore-read-sentinel-{}", std::process::id())); + std::fs::write(&sentinel, b"must-not-be-readable").unwrap(); + + let script = format!("cat '{}'", sentinel.display()); + let r = run_in_seatbelt(&canon, &script, &[]); + assert!( + !r.is_success(), + "a sandboxed command must not read the operator home: {r:?}" + ); + assert!(!String::from_utf8_lossy(&r.stdout).contains("must-not-be-readable")); + + std::fs::remove_file(&sentinel).ok(); + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn shared_temp_write_is_denied_and_private_tmp_is_cleaned() { + if SeatbeltBackend::detect().is_none() { + return; + } + let tmp = std::env::temp_dir().join(format!("cc-sbx-tmp-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let canon = std::fs::canonicalize(&tmp).unwrap(); + let shared = PathBuf::from(format!( + "/private/tmp/crustcore-shared-temp-{}", + std::process::id() + )); + std::fs::remove_file(&shared).ok(); + + let denied = + run_in_seatbelt(&canon, &format!("echo pwned > '{}'", shared.display()), &[]); + assert!( + !denied.is_success(), + "shared temp write must be denied: {denied:?}" + ); + assert!(!shared.exists()); + + let private = run_in_seatbelt( + &canon, + "echo ok > \"$TMPDIR/probe\" && printf '%s' \"$TMPDIR\"", + &[], + ); + assert!( + private.is_success(), + "private TMPDIR must be writable: {private:?}" + ); + let private_path = PathBuf::from(String::from_utf8(private.stdout).unwrap()); + assert!( + !private_path.exists(), + "the per-run private TMPDIR must be removed after the command" + ); + + std::fs::remove_file(&shared).ok(); + std::fs::remove_dir_all(&tmp).ok(); + } + #[test] fn network_egress_is_denied() { if SeatbeltBackend::detect().is_none() { diff --git a/crates/crustcore-secrets/Cargo.toml b/crates/crustcore-secrets/Cargo.toml index 767072b..259bf92 100644 --- a/crates/crustcore-secrets/Cargo.toml +++ b/crates/crustcore-secrets/Cargo.toml @@ -10,6 +10,7 @@ description = "Typed secret handles and non-exfiltratable secret material for Cr [dependencies] crustcore-types = { workspace = true } +crustcore-zeroize = { workspace = true } # Encrypted-file vault backend (P8-store), behind the `vault-file` feature ONLY — # so the nano build (which links this crate for the trust types + InMemoryStore) @@ -19,11 +20,12 @@ crustcore-types = { workspace = true } aes-gcm = { version = "0.10", optional = true } scrypt = { version = "0.11", default-features = false, optional = true } getrandom = { version = "0.2", optional = true } +zeroize = { version = "1.9", features = ["alloc"], optional = true } [features] # Enables the encrypted-file vault `SecretStore` backend ([`store`]). Off by # default; the nano build never enables it, so nano links no crypto. -vault-file = ["dep:aes-gcm", "dep:scrypt", "dep:getrandom"] +vault-file = ["dep:aes-gcm", "dep:scrypt", "dep:getrandom", "dep:zeroize"] # OS keychain loaders (P8-store), behind their own features. Dependency-free: they # shell out to the system tool (`security` on macOS, `secret-tool` on Linux) and load diff --git a/crates/crustcore-secrets/src/lib.rs b/crates/crustcore-secrets/src/lib.rs index ce26707..b8a7bd8 100644 --- a/crates/crustcore-secrets/src/lib.rs +++ b/crates/crustcore-secrets/src/lib.rs @@ -119,14 +119,16 @@ pub enum SecretAvailability { /// let _: crustcore_secrets::ModelVisibleText = m.into(); /// ``` pub struct SecretMaterial { - bytes: Vec, + bytes: crustcore_zeroize::ZeroizingVec, } impl SecretMaterial { /// Wraps raw bytes. Constructed only by trusted store/broker code. #[must_use] pub fn new(bytes: Vec) -> Self { - SecretMaterial { bytes } + SecretMaterial { + bytes: crustcore_zeroize::ZeroizingVec::new(bytes), + } } /// Length in bytes (non-sensitive metadata). @@ -151,26 +153,6 @@ impl SecretMaterial { } } -impl Drop for SecretMaterial { - fn drop(&mut self) { - scrub(&mut self.bytes); - } -} - -/// Best-effort zeroization without `unsafe` or an external crate: zero the bytes, -/// then force the optimizer to treat the buffer as observed so the dead store is -/// not elided (`black_box` is std + stable). A `zeroize`-backed -/// `Zeroizing>` (volatile writes + fences) is the out-of-nano hardening -/// (`docs/secrets.md` §2.1); nano keeps this dep-free, no-`unsafe` best-effort -/// version. Used by every type that holds secret bytes ([`SecretMaterial`], -/// [`Redactor`]). -fn scrub(bytes: &mut [u8]) { - for b in bytes.iter_mut() { - *b = 0; - } - std::hint::black_box(bytes); -} - // --------------------------------------------------------------------------- // The redaction / taint boundary // --------------------------------------------------------------------------- @@ -219,7 +201,7 @@ pub struct Redactor { // (label, secret-value-bytes) pairs, longest-first. The bytes are a second copy // of each secret (redaction is impossible without the value); they are zeroized // on drop and never exposed. - needles: Vec<(String, Vec)>, + needles: Vec<(String, crustcore_zeroize::ZeroizingVec)>, } impl Redactor { @@ -239,7 +221,10 @@ impl Redactor { if value.is_empty() { return; } - self.needles.push((label.to_string(), value.to_vec())); + self.needles.push(( + label.to_string(), + crustcore_zeroize::ZeroizingVec::new(value.to_vec()), + )); self.needles .sort_by_key(|(_, v)| std::cmp::Reverse(v.len())); } @@ -355,14 +340,6 @@ impl Redactor { } } -impl Drop for Redactor { - fn drop(&mut self) { - for (_, v) in &mut self.needles { - scrub(v); - } - } -} - /// A wrapper marking data as **tainted** (potentially secret-bearing, e.g. raw /// tool stdout before redaction). The only way to derive model-visible text from /// it is through a [`Redactor`] ([`declassify`](Self::declassify)), so taint cannot diff --git a/crates/crustcore-secrets/src/store.rs b/crates/crustcore-secrets/src/store.rs index c9a762e..b48a6be 100644 --- a/crates/crustcore-secrets/src/store.rs +++ b/crates/crustcore-secrets/src/store.rs @@ -81,25 +81,11 @@ pub struct VaultEntry { pub value: Vec, } -/// A scratch buffer of secret-bearing bytes, **zeroed on drop** so it never lingers -/// in freed memory on *any* return path — success or an early error. Uses the crate's -/// `black_box`-fenced [`crate::scrub`], the same best-effort zeroization -/// [`crate::SecretMaterial`] uses (a `zeroize`-backed version is the out-of-nano -/// hardening; `docs/secrets.md` §2.1). -struct Scrubbed(Vec); -impl Drop for Scrubbed { - fn drop(&mut self) { - crate::scrub(&mut self.0); - } -} +/// A scratch buffer of secret-bearing bytes, zeroized on every return path. +struct Scrubbed(zeroize::Zeroizing>); /// The derived AEAD key, zeroed on drop on every path. -struct ScrubbedKey([u8; 32]); -impl Drop for ScrubbedKey { - fn drop(&mut self) { - crate::scrub(&mut self.0); - } -} +struct ScrubbedKey(zeroize::Zeroizing<[u8; 32]>); /// Derives the 32-byte AEAD key from `passphrase` + `salt` via scrypt. fn derive_key(passphrase: &[u8], salt: &[u8]) -> Result<[u8; 32], VaultError> { @@ -125,7 +111,7 @@ pub fn seal_vault( // Encode the plaintext: count, then per entry (id, label, value), all bounded. // `plaintext` and (below) `key` are Scrubbed/ScrubbedKey, so they are zeroed on // EVERY return path — including the early `?` errors below — not just on success. - let mut plaintext = Scrubbed(Vec::new()); + let mut plaintext = Scrubbed(zeroize::Zeroizing::new(Vec::new())); plaintext .0 .extend_from_slice(&(entries.len() as u32).to_le_bytes()); @@ -146,8 +132,8 @@ pub fn seal_vault( getrandom::getrandom(&mut salt).map_err(|_| VaultError::Crypto)?; getrandom::getrandom(&mut nonce).map_err(|_| VaultError::Crypto)?; - let key = ScrubbedKey(derive_key(passphrase, &salt)?); - let cipher = Aes256Gcm::new(Key::::from_slice(&key.0)); + let key = ScrubbedKey(zeroize::Zeroizing::new(derive_key(passphrase, &salt)?)); + let cipher = Aes256Gcm::new(Key::::from_slice(key.0.as_ref())); let ciphertext = cipher .encrypt(Nonce::from_slice(&nonce), plaintext.0.as_ref()) .map_err(|_| VaultError::Crypto)?; @@ -182,15 +168,15 @@ pub fn open_vault(path: &Path, passphrase: &[u8]) -> Result::from_slice(&key.0)); + let key = ScrubbedKey(zeroize::Zeroizing::new(derive_key(passphrase, salt)?)); + let cipher = Aes256Gcm::new(Key::::from_slice(key.0.as_ref())); // AEAD: a wrong key (passphrase) or any tamper makes this Err — fail closed. // `key` + `plaintext` are scrubbed on drop on every path (success or decode error). - let plaintext = Scrubbed( + let plaintext = Scrubbed(zeroize::Zeroizing::new( cipher .decrypt(Nonce::from_slice(nonce), ciphertext) .map_err(|_| VaultError::Decrypt)?, - ); + )); decode_entries(&plaintext.0) } diff --git a/crates/crustcore-telemetry/src/run.rs b/crates/crustcore-telemetry/src/run.rs index 0c7092c..2a2c422 100644 --- a/crates/crustcore-telemetry/src/run.rs +++ b/crates/crustcore-telemetry/src/run.rs @@ -111,7 +111,7 @@ pub fn run( for (i, input) in frames.iter().take(config.batch_bound).enumerate() { report.frames_seen += 1; // Deterministic sampling on frame index (no clock, no RNG). - if (i as u64) % rate != 0 { + if !(i as u64).is_multiple_of(rate) { continue; } // Project (pre-redaction), then redact at the single chokepoint. Trusted diff --git a/crates/crustcore-types/src/budget.rs b/crates/crustcore-types/src/budget.rs index ab4783f..d3fd553 100644 --- a/crates/crustcore-types/src/budget.rs +++ b/crates/crustcore-types/src/budget.rs @@ -185,9 +185,10 @@ impl BudgetCheck { /// A task's budget: one [`Meter`] per [`BudgetAxis`] (invariant 11). /// -/// Constructed unlimited and tightened per-axis by the adapter that creates the -/// task. The kernel only ever *reads* limits and *folds* deltas — it never sets a -/// limit from model-controlled input. +/// Constructed with finite limits on every axis by the trusted adapter that creates +/// the task. The kernel only ever *reads* limits and *folds* deltas — it never sets a +/// limit from model-controlled input. [`Budget::unlimited`] remains available for +/// isolated meter calculations, but the kernel rejects it as a task seed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Budget { meters: [Meter; BUDGET_AXIS_COUNT], @@ -195,11 +196,31 @@ pub struct Budget { impl Default for Budget { fn default() -> Self { - Budget::unlimited() + Budget::standard_task() } } impl Budget { + /// A conservative, finite baseline for an interactive coding task. + /// + /// Adapters may tighten any axis before submitting `TaskCreated`, but must not + /// loosen an axis to `u64::MAX`: the kernel accepts only fully bounded seeds. + #[must_use] + pub const fn standard_task() -> Self { + Budget { + meters: [ + Meter::with_limit(3_600_000), // wall: 1 hour + Meter::with_limit(3_600_000), // CPU: 1 hour + Meter::with_limit(4_294_967_296), // memory: 4 GiB peak + Meter::with_limit(2_147_483_648), // disk writes: 2 GiB + Meter::with_limit(268_435_456), // captured output: 256 MiB + Meter::with_limit(1_000_000), // model tokens + Meter::with_limit(100_000_000), // model cost: 100 base units + Meter::with_limit(8), // subagents + ], + } + } + /// A budget with no limit on any axis. #[must_use] pub const fn unlimited() -> Self { @@ -208,6 +229,12 @@ impl Budget { } } + /// Whether every resource axis has a real finite ceiling. + #[must_use] + pub fn is_fully_bounded(&self) -> bool { + self.meters.iter().all(|meter| meter.limit != u64::MAX) + } + /// The meter for `axis`. #[must_use] pub fn meter(&self, axis: BudgetAxis) -> Meter { @@ -264,6 +291,15 @@ mod tests { assert_eq!(check, BudgetCheck::WithinBudget); } + #[test] + fn default_task_budget_is_finite_on_every_axis() { + let budget = Budget::default(); + assert!(budget.is_fully_bounded()); + for axis in BudgetAxis::ALL { + assert_ne!(budget.meter(axis).limit, u64::MAX, "axis {axis:?}"); + } + } + #[test] fn record_exhausts_the_limited_axis() { let mut b = Budget::unlimited().with_limit(BudgetAxis::OutputBytes, 100); diff --git a/crates/crustcore-worktree/src/tools.rs b/crates/crustcore-worktree/src/tools.rs index 9a52e70..0ae6234 100644 --- a/crates/crustcore-worktree/src/tools.rs +++ b/crates/crustcore-worktree/src/tools.rs @@ -103,7 +103,7 @@ pub fn read_file( path: &ConfinedReadPath<'_>, max_bytes: usize, ) -> Result, ToolError> { - if !roots_match(&cap.root, path.root()) { + if !roots_match(cap.root(), path.root()) { return Err(ToolError::CapRootMismatch); } let file = std::fs::File::open(path.to_path()).map_err(io)?; @@ -124,7 +124,7 @@ pub fn write_file( path: &ConfinedWritePath<'_>, bytes: &[u8], ) -> Result<(), ToolError> { - if !roots_match(&cap.root, path.root()) { + if !roots_match(cap.root(), path.root()) { return Err(ToolError::CapRootMismatch); } // The git metadata dir is off-limits to structured writes (so the model can @@ -139,7 +139,7 @@ pub fn write_file( // `.git`) would let `link/config` slip past the component check. Resolve the // real on-disk location of the deepest existing ancestor and refuse if it // lands inside `/.git`. - refuse_real_git_dir(&cap.root, &target)?; + refuse_real_git_dir(cap.root(), &target)?; if let Some(parent) = target.parent() { std::fs::create_dir_all(parent).map_err(io)?; } @@ -167,7 +167,7 @@ pub fn search(cap: &FsReadCap, needle: &str, max_hits: usize) -> Result Result<(), ToolEr /// # Errors /// [`ToolError::Git`] if git fails, [`ToolError::Io`] if it cannot be spawned. pub fn git_status(cap: &FsReadCap) -> Result { - neutralize_attribute_drivers(cap.root.as_path())?; - let mut c = hardened_git(cap.root.as_path()); + neutralize_attribute_drivers(cap.root().as_path())?; + let mut c = hardened_git(cap.root().as_path()); c.args(["status", "--porcelain"]); run_git(c, None) } @@ -438,8 +438,8 @@ pub fn git_status(cap: &FsReadCap) -> Result { /// # Errors /// As [`git_status`]. pub fn git_status_all(cap: &FsReadCap) -> Result { - neutralize_attribute_drivers(cap.root.as_path())?; - let mut c = hardened_git(cap.root.as_path()); + neutralize_attribute_drivers(cap.root().as_path())?; + let mut c = hardened_git(cap.root().as_path()); c.args([ "status", "--porcelain", @@ -467,13 +467,13 @@ pub fn git_status_all(cap: &FsReadCap) -> Result { /// # Errors /// As [`git_status`] (e.g. an unborn `HEAD`). pub fn git_worktree_diff(cap: &FsReadCap) -> Result { - neutralize_attribute_drivers(cap.root.as_path())?; + neutralize_attribute_drivers(cap.root().as_path())?; // Mark untracked files intent-to-add so they appear in `git diff HEAD`. // Best-effort: if there is nothing to add, the diff still runs. - let mut add = hardened_git(cap.root.as_path()); + let mut add = hardened_git(cap.root().as_path()); add.args(["add", "--intent-to-add", "--", "."]); let _ = run_git(add, None); - let mut c = hardened_git(cap.root.as_path()); + let mut c = hardened_git(cap.root().as_path()); c.args([ "diff", "HEAD", @@ -491,8 +491,8 @@ pub fn git_worktree_diff(cap: &FsReadCap) -> Result { pub fn git_diff(cap: &FsReadCap) -> Result { // Neutralize attribute drivers (filter.clean would otherwise run during diff), // plus belt-and-braces flags against textconv/external-diff. - neutralize_attribute_drivers(cap.root.as_path())?; - let mut c = hardened_git(cap.root.as_path()); + neutralize_attribute_drivers(cap.root().as_path())?; + let mut c = hardened_git(cap.root().as_path()); c.args(["diff", "--no-color", "--no-ext-diff", "--no-textconv"]); run_git(c, None) } @@ -502,7 +502,7 @@ pub fn git_diff(cap: &FsReadCap) -> Result { /// # Errors /// As [`git_status`]. pub fn git_log(cap: &FsReadCap, max_entries: u32) -> Result { - let mut c = hardened_git(cap.root.as_path()); + let mut c = hardened_git(cap.root().as_path()); c.args([ "log", "--oneline", @@ -533,8 +533,8 @@ pub fn git_apply(cap: &FsWriteCap, patch: &[u8]) -> Result<(), ToolError> { } // Critical: `git apply` runs the smudge filter on the working tree. Neutralize // attribute-driven filter/diff drivers before applying. - neutralize_attribute_drivers(cap.root.as_path())?; - let mut c = hardened_git(cap.root.as_path()); + neutralize_attribute_drivers(cap.root().as_path())?; + let mut c = hardened_git(cap.root().as_path()); c.args(["apply", "--whitespace=nowarn"]); run_git(c, Some(patch)).map(|_| ()) } @@ -557,8 +557,23 @@ fn patch_creates_symlink(patch: &[u8]) -> bool { #[cfg(test)] mod tests { use super::*; + use crustcore_policy::{PolicySnapshot, RiskProfile}; use crustcore_types::ScopeId; + fn read_cap(root: WorktreeRoot) -> FsReadCap { + PolicySnapshot::new(RiskProfile::Supervised) + .authorize_reversible(ScopeId(1)) + .expect("supervised test policy should authorize reversible reads") + .fs_read(root) + } + + fn write_cap(root: WorktreeRoot) -> FsWriteCap { + PolicySnapshot::new(RiskProfile::Supervised) + .authorize_reversible(ScopeId(1)) + .expect("supervised test policy should authorize reversible writes") + .fs_write(root) + } + fn temp_dir(tag: &str) -> std::path::PathBuf { let mut p = std::env::temp_dir(); p.push(format!("cc-wt-{}-{tag}", std::process::id())); @@ -578,14 +593,8 @@ mod tests { fn write_then_read_roundtrips() { let dir = temp_dir("rw"); let root = WorktreeRoot::open(&dir).unwrap(); - let rcap = FsReadCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; - let wcap = FsWriteCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let rcap = read_cap(WorktreeRoot::open(&dir).unwrap()); + let wcap = write_cap(WorktreeRoot::open(&dir).unwrap()); let wpath = root.confine_write("src/lib.rs").unwrap(); write_file(&wcap, &wpath, b"fn main() {}").unwrap(); let rpath = root.confine_read("src/lib.rs").unwrap(); @@ -598,10 +607,7 @@ mod tests { fn write_into_dot_git_is_refused() { let dir = temp_dir("gitdir"); let root = WorktreeRoot::open(&dir).unwrap(); - let wcap = FsWriteCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let wcap = write_cap(WorktreeRoot::open(&dir).unwrap()); let wpath = root.confine_write(".git/hooks/pre-commit").unwrap(); assert!(matches!( write_file(&wcap, &wpath, b"#!/bin/sh\nevil").unwrap_err(), @@ -625,10 +631,7 @@ mod tests { std::fs::create_dir_all(dir.join(".git")).unwrap(); std::os::unix::fs::symlink(dir.join(".git"), dir.join("link")).unwrap(); let root = WorktreeRoot::open(&dir).unwrap(); - let wcap = FsWriteCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let wcap = write_cap(WorktreeRoot::open(&dir).unwrap()); // `link/config` is lexically clean but really resolves into `.git`. let wpath = root.confine_write("link/config").unwrap(); assert!(matches!( @@ -645,10 +648,7 @@ mod tests { let outside = temp_dir("search-outside"); std::fs::write(outside.join("secret.txt"), b"NEEDLE outside\n").unwrap(); std::os::unix::fs::symlink(&outside, dir.join("escape")).unwrap(); - let rcap = FsReadCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let rcap = read_cap(WorktreeRoot::open(&dir).unwrap()); let hits = search(&rcap, "NEEDLE", 10).unwrap(); assert!( hits.is_empty(), @@ -661,10 +661,7 @@ mod tests { #[test] fn git_apply_rejects_symlink_creating_patch() { let dir = temp_dir("apply-sym"); - let wcap = FsWriteCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let wcap = write_cap(WorktreeRoot::open(&dir).unwrap()); let patch = concat!( "diff --git a/link b/link\n", "new file mode 120000\n", @@ -686,10 +683,7 @@ mod tests { let dir_a = temp_dir("root-a"); let dir_b = temp_dir("root-b"); let root_a = WorktreeRoot::open(&dir_a).unwrap(); - let wcap_b = FsWriteCap { - root: WorktreeRoot::open(&dir_b).unwrap(), - scope: ScopeId(1), - }; + let wcap_b = write_cap(WorktreeRoot::open(&dir_b).unwrap()); let path_in_a = root_a.confine_write("x.txt").unwrap(); assert!(matches!( write_file(&wcap_b, &path_in_a, b"nope").unwrap_err(), @@ -703,14 +697,8 @@ mod tests { fn search_finds_matches_and_skips_git() { let dir = temp_dir("search"); let root = WorktreeRoot::open(&dir).unwrap(); - let wcap = FsWriteCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; - let rcap = FsReadCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let wcap = write_cap(WorktreeRoot::open(&dir).unwrap()); + let rcap = read_cap(WorktreeRoot::open(&dir).unwrap()); write_file( &wcap, &root.confine_write("a.txt").unwrap(), @@ -766,10 +754,7 @@ mod tests { return; }; let (rroot, _) = caps(&dir); - let rcap = FsReadCap { - root: rroot, - scope: ScopeId(1), - }; + let rcap = read_cap(rroot); // Clean tree. assert_eq!(git_status(&rcap).unwrap().trim(), ""); // Modify a tracked file -> status + diff reflect it. @@ -801,10 +786,7 @@ mod tests { // Give the diff something to render. std::fs::write(dir.join("README.md"), b"hello\nchanged\n").unwrap(); - let rcap = FsReadCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let rcap = read_cap(WorktreeRoot::open(&dir).unwrap()); let _ = git_diff(&rcap).unwrap(); assert!( !marker.exists(), @@ -857,10 +839,7 @@ mod tests { // git_diff: clean must not run. std::fs::write(dir.join("a.rs"), b"y\n").unwrap(); let _ = std::fs::remove_file(&clean_marker); - let rcap = FsReadCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let rcap = read_cap(WorktreeRoot::open(&dir).unwrap()); let _ = git_diff(&rcap); assert!( !clean_marker.exists(), @@ -873,10 +852,7 @@ mod tests { .status() .ok(); let _ = std::fs::remove_file(&smudge_marker); - let wcap = FsWriteCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let wcap = write_cap(WorktreeRoot::open(&dir).unwrap()); let patch = concat!( "--- a/a.rs\n", "+++ b/a.rs\n", @@ -909,10 +885,7 @@ mod tests { let _ = std::fs::remove_file(&attrs); std::os::unix::fs::symlink(&victim, &attrs).unwrap(); - let rcap = FsReadCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let rcap = read_cap(WorktreeRoot::open(&dir).unwrap()); // The wrapper refuses rather than following the symlink. assert!(matches!(git_status(&rcap), Err(ToolError::Git(_)))); // The out-of-tree victim is untouched. @@ -930,10 +903,7 @@ mod tests { eprintln!("skipping: git unavailable"); return; }; - let wcap = FsWriteCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let wcap = write_cap(WorktreeRoot::open(&dir).unwrap()); // Note: context/added lines must keep their leading " "/"+"; avoid string // line-continuations (which strip leading whitespace). let patch = concat!( @@ -972,10 +942,7 @@ mod tests { }; std::fs::write(dir.join("with space.pem"), b"x\n").unwrap(); std::fs::write(dir.join("café.txt"), b"y\n").unwrap(); - let rcap = FsReadCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let rcap = read_cap(WorktreeRoot::open(&dir).unwrap()); let out = git_status_all(&rcap).unwrap(); // Verbatim (unquoted) paths, NUL-separated. assert!( @@ -1000,10 +967,7 @@ mod tests { return; }; std::fs::write(dir.join("feature.rs"), b"pub fn feature() -> u32 { 42 }\n").unwrap(); - let rcap = FsReadCap { - root: WorktreeRoot::open(&dir).unwrap(), - scope: ScopeId(1), - }; + let rcap = read_cap(WorktreeRoot::open(&dir).unwrap()); // Plain `git diff` (working-vs-index) omits the untracked file entirely — // check this BEFORE git_worktree_diff, whose intent-to-add would otherwise // make the file visible to a later plain diff too. diff --git a/crates/crustcore-zeroize/Cargo.toml b/crates/crustcore-zeroize/Cargo.toml new file mode 100644 index 0000000..7eacbd0 --- /dev/null +++ b/crates/crustcore-zeroize/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "crustcore-zeroize" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Tiny audited volatile-zeroization primitive for CrustCore nano." + +[dependencies] diff --git a/crates/crustcore-zeroize/src/lib.rs b/crates/crustcore-zeroize/src/lib.rs new file mode 100644 index 0000000..ac37622 --- /dev/null +++ b/crates/crustcore-zeroize/src/lib.rs @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Minimal volatile zeroization for the nano trust boundary. +//! +//! Nano may link only `crustcore*` crates, while secret buffers still need a +//! compiler-resistant wipe. The one `unsafe` operation is a byte-sized volatile +//! write into a live, exclusively borrowed allocation. It is isolated here so +//! every consumer can remain `#![forbid(unsafe_code)]` and the exception is easy +//! to audit. + +#![deny(unsafe_op_in_unsafe_fn)] + +use core::ops::{Deref, DerefMut}; +use core::sync::atomic::{compiler_fence, Ordering}; + +/// An owned byte buffer overwritten with volatile zeroes before deallocation. +pub struct ZeroizingVec(Vec); + +/// A fixed-size byte array overwritten with volatile zeroes on drop. +pub struct ZeroizingArray([u8; N]); + +impl ZeroizingArray { + /// Wrap an owned byte array. + #[must_use] + pub fn new(bytes: [u8; N]) -> Self { + Self(bytes) + } + + /// Overwrite every byte and fence compiler reordering. + pub fn zeroize(&mut self) { + for byte in &mut self.0 { + // SAFETY: identical to `ZeroizingVec::zeroize`: this is one live, + // exclusively borrowed initialized byte in the backing array. + unsafe { core::ptr::write_volatile(byte, 0) }; + } + compiler_fence(Ordering::SeqCst); + } +} + +impl Deref for ZeroizingArray { + type Target = [u8; N]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ZeroizingArray { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl AsRef<[u8]> for ZeroizingArray { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl Drop for ZeroizingArray { + fn drop(&mut self) { + self.zeroize(); + } +} + +impl ZeroizingVec { + /// Wrap an owned buffer. + #[must_use] + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + + /// Overwrite every initialized byte and fence compiler reordering. + pub fn zeroize(&mut self) { + for byte in &mut self.0 { + // SAFETY: `byte` points to one initialized `u8` in an exclusively + // borrowed live Vec allocation. A byte has no invalid bit patterns. + // Volatile prevents removal as a dead store before deallocation. + unsafe { core::ptr::write_volatile(byte, 0) }; + } + compiler_fence(Ordering::SeqCst); + } +} + +impl Deref for ZeroizingVec { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ZeroizingVec { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl AsRef<[u8]> for ZeroizingVec { + fn as_ref(&self) -> &[u8] { + self + } +} + +impl Drop for ZeroizingVec { + fn drop(&mut self) { + self.zeroize(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_zeroize_overwrites_every_byte() { + let mut bytes = ZeroizingVec::new(vec![1, 2, 3, 4]); + bytes.zeroize(); + assert_eq!(&*bytes, &[0, 0, 0, 0]); + } + + #[test] + fn fixed_array_zeroize_overwrites_every_byte() { + let mut bytes = ZeroizingArray::new([7u8; 32]); + bytes.zeroize(); + assert_eq!(&*bytes, &[0u8; 32]); + } +} diff --git a/crates/crustcore/src/main.rs b/crates/crustcore/src/main.rs index 09f2d5b..bb6ba8b 100644 --- a/crates/crustcore/src/main.rs +++ b/crates/crustcore/src/main.rs @@ -22,10 +22,10 @@ use crustcore_eventlog::{ChainStatus, EventLog, FrameMeta}; use crustcore_kernel::{Actor, Event, EventKind, Kernel, Visibility}; use crustcore_policy::{PolicySnapshot, RiskProfile, SandboxExecCap}; use crustcore_receipts::join::{verify_against_log, FrameRef}; -use crustcore_receipts::{MacKey, ReceiptChain, ReceiptParams}; +use crustcore_receipts::{MacKey, ReceiptChain, ReceiptParams, ReceiptStatus, ToolReceipt}; use crustcore_sandbox::SandboxProfile; use crustcore_types::hash::sha256; -use crustcore_types::{EventSeq, JobId, ScopeId, TaskId, Timestamp, ToolCallId}; +use crustcore_types::{Budget, EventSeq, JobId, ScopeId, TaskId, Timestamp, ToolCallId}; use crustcore_worktree::WorktreeManager; fn main() -> ExitCode { @@ -106,9 +106,7 @@ fn doctor() -> ExitCode { }); // state dir — a writable scratch directory for worktrees/logs. - let state = std::env::var_os("CRUSTCORE_STATE") - .map(std::path::PathBuf::from) - .unwrap_or_else(std::env::temp_dir); + let state = state_root(); report.push(match probe_writable(&state) { Ok(()) => DoctorCheck::new( "state-dir", @@ -167,6 +165,73 @@ fn probe_writable(dir: &std::path::Path) -> std::io::Result<()> { Ok(()) } +fn state_root() -> std::path::PathBuf { + if let Some(path) = std::env::var_os("CRUSTCORE_STATE") { + return path.into(); + } + if let Some(path) = std::env::var_os("XDG_STATE_HOME") { + return std::path::PathBuf::from(path).join("crustcore"); + } + if let Some(home) = std::env::var_os("HOME") { + return std::path::PathBuf::from(home).join(".local/state/crustcore"); + } + std::env::temp_dir().join("crustcore-state") +} + +struct RunAudit { + log: EventLog, + path: std::path::PathBuf, +} + +impl RunAudit { + fn new(root: &std::path::Path) -> Self { + let name = format!("run-{}-{}.cclog", now_ts().as_millis(), std::process::id()); + RunAudit { + log: EventLog::new(), + path: root.join("runs").join(name), + } + } + + fn append( + &mut self, + seq: u64, + kind: EventKind, + actor: Actor, + task: TaskId, + job: Option, + payload: &[u8], + ) { + let mut meta = FrameMeta::new(seq, kind) + .actor(actor) + .visibility(Visibility::Internal) + .timestamp(now_ts()) + .task(task); + if let Some(job) = job { + meta = meta.job(job); + } + self.log.append(&meta, payload); + } + + fn persist(&self) -> std::io::Result<()> { + self.log.store_atomic(&self.path) + } +} + +fn load_or_create_receipt_key(root: &std::path::Path) -> std::io::Result { + MacKey::load_or_create(&root.join("receipt.key")) +} + +fn frame_refs(log: &EventLog) -> Vec { + log.iter() + .map(|decoded| FrameRef { + seq: decoded.frame.seq, + tool_completed: decoded.frame.kind == EventKind::ToolCallCompleted, + task_id: decoded.frame.task_id, + job_id: decoded.frame.job_id, + }) + .collect() +} + /// `crustcore run -dir -goal -verify ` — create a /// disposable worktree, rerun the verify command in a clean sandbox, and complete /// the task only if it passes (invariant 13). On failure or a missing sandbox @@ -208,62 +273,263 @@ fn run_task(run_args: &[String]) -> ExitCode { return ExitCode::from(2); } }; + if matches!(backend, Backend::Native) + && parsed + .goal + .as_deref() + .is_some_and(|goal| !goal.trim().is_empty()) + { + eprintln!( + "crustcore run: -backend native is verify-only and cannot satisfy a coding goal; \ + choose codex, claude, or cmd" + ); + return ExitCode::from(2); + } + + let state = state_root(); + let key = match load_or_create_receipt_key(&state) { + Ok(key) => key, + Err(error) => { + eprintln!("crustcore run: cannot load/create the receipt key: {error}"); + return ExitCode::from(1); + } + }; + + let task = TaskId(1); + let job = JobId(1); + let mut kernel = Kernel::new(PolicySnapshot::new(RiskProfile::Supervised)); + let mut audit = RunAudit::new(&state); + kernel.step(Event::task_created( + EventSeq(1), + Actor::Adapter, + task, + Budget::standard_task(), + )); + audit.append( + 1, + EventKind::TaskCreated, + Actor::Adapter, + task, + None, + b"task created", + ); + kernel.step( + Event::inbound(EventKind::JobQueued, EventSeq(2), Actor::Adapter) + .with_task(task) + .with_job(job), + ); + audit.append( + 2, + EventKind::JobQueued, + Actor::Adapter, + task, + Some(job), + b"job queued", + ); if let Some(goal) = parsed.goal.as_deref() { println!("goal: {goal}"); } println!("backend: {}", backend.label()); println!("verify: {}", spec.display()); + println!("audit-log: {}", audit.path.display()); - // One task per `run` (the autonomous, multi-task supervisor is later phases). - let task = TaskId(1); let manager = WorktreeManager::new(&repo_root); let worktree = match manager.create_for(task) { - Ok(w) => w, - Err(e) => { - eprintln!("crustcore run: could not create worktree: {e}"); + Ok(worktree) => worktree, + Err(error) => { + eprintln!("crustcore run: could not create worktree: {error}"); + kernel.step( + Event::inbound(EventKind::TaskFailed, EventSeq(3), Actor::Adapter).with_task(task), + ); + audit.append( + 3, + EventKind::TaskFailed, + Actor::Adapter, + task, + None, + b"task failed", + ); + let _ = audit.persist(); return ExitCode::from(1); } }; println!("worktree: {}", worktree.as_path().display()); + kernel.step( + Event::inbound(EventKind::JobLeased, EventSeq(3), Actor::Adapter) + .with_task(task) + .with_job(job) + .with_lease_owner(crustcore_types::LeaseOwner(1)), + ); + audit.append( + 3, + EventKind::JobLeased, + Actor::Adapter, + task, + Some(job), + b"job leased", + ); - let cap = SandboxExecCap { - profile: ScopeId(1), - scope: ScopeId(1), - }; let profile = SandboxProfile::default_sandboxed(); + let cap = PolicySnapshot::new(RiskProfile::Supervised) + .authorize_reversible(ScopeId(1)) + .expect("supervised policy permits sandboxed verification") + .sandbox_exec(profile.id); - // Produce a candidate change. The native path verifies the worktree as-is and - // references the verified state by HEAD. An external worker (codex/claude/cmd) - // runs sandboxed with no secrets, then CrustCore re-derives the real diff from - // the worktree and rejects any out-of-root write — the patch a worker produces - // is an *unverified* proposal (invariants 6/7). Either way the verifier below is - // the only thing that can complete the task (invariant 13). let patch = match produce_patch(&backend, &parsed, &manager, &worktree, &cap, &profile) { - Ok(p) => p, + Ok(patch) => patch, Err(code) => { + kernel.step( + Event::inbound(EventKind::TaskFailed, EventSeq(4), Actor::Adapter).with_task(task), + ); + audit.append( + 4, + EventKind::TaskFailed, + Actor::Adapter, + task, + Some(job), + b"task failed", + ); + let _ = audit.persist(); let _ = manager.remove(&worktree); return code; } }; + kernel.step( + Event::inbound(EventKind::PatchProposed, EventSeq(4), Actor::ExternalWorker) + .with_task(task) + .with_job(job), + ); + audit.append( + 4, + EventKind::PatchProposed, + Actor::ExternalWorker, + task, + Some(job), + b"patch proposed", + ); + kernel.step( + Event::inbound(EventKind::ToolCallStarted, EventSeq(5), Actor::Adapter) + .with_task(task) + .with_job(job) + .with_lease_owner(crustcore_types::LeaseOwner(1)), + ); + audit.append( + 5, + EventKind::ToolCallStarted, + Actor::Adapter, + task, + Some(job), + b"verify started", + ); - let mut receipts = ReceiptChain::new(MacKey::new(run_key())); + let mut receipts = ReceiptChain::new(key); let ids = VerifyIds { task_id: task, - job_id: JobId(1), + job_id: job, tool_call_id: ToolCallId(1), - event_seq: EventSeq(1), + event_seq: EventSeq(6), now: now_ts(), }; - let outcome = run_verify(&cap, &profile, &worktree, &spec, patch, &mut receipts, &ids); + let code = match outcome { VerifyOutcome::Verified(verified) => { - let _completion = complete_task(*verified); - println!("VERIFIED: '{}' passed — task completed.", spec.display()); - ExitCode::SUCCESS + let receipt = verified.receipt().clone(); + audit.append( + 6, + EventKind::ToolCallCompleted, + Actor::Adapter, + task, + Some(job), + &receipt.to_bytes(), + ); + kernel.step( + Event::inbound(EventKind::ToolCallCompleted, EventSeq(6), Actor::Adapter) + .with_task(task) + .with_job(job) + .with_lease_owner(crustcore_types::LeaseOwner(1)), + ); + kernel.step( + Event::inbound(EventKind::PatchVerified, EventSeq(7), Actor::Adapter) + .with_task(task), + ); + audit.append( + 7, + EventKind::PatchVerified, + Actor::Adapter, + task, + Some(job), + b"patch verified", + ); + audit.append( + 8, + EventKind::TaskCompleted, + Actor::Adapter, + task, + None, + b"task completed", + ); + + let receipt_ok = receipts.verify(std::slice::from_ref(&receipt)); + let join_ok = verify_against_log(&[receipt], &frame_refs(&audit.log)); + if !matches!(receipt_ok, ReceiptStatus::Intact { .. }) || !join_ok.is_joined() { + eprintln!("AUDIT REFUSED: receipt verification or event-log join failed; task NOT completed."); + ExitCode::from(1) + } else if let Err(error) = audit.persist() { + eprintln!("AUDIT REFUSED: could not persist verifier evidence: {error}"); + ExitCode::from(1) + } else { + kernel.step( + Event::inbound(EventKind::TaskCompleted, EventSeq(8), Actor::Adapter) + .with_task(task), + ); + let _completion = complete_task(*verified); + println!("VERIFIED: '{}' passed — task completed.", spec.display()); + ExitCode::SUCCESS + } } VerifyOutcome::Failed { status, output } => { + kernel.step( + Event::inbound(EventKind::CommandCompleted, EventSeq(6), Actor::Adapter) + .with_task(task) + .with_job(job) + .with_lease_owner(crustcore_types::LeaseOwner(1)), + ); + kernel.step( + Event::inbound(EventKind::PatchRejected, EventSeq(7), Actor::Adapter) + .with_task(task), + ); + kernel.step( + Event::inbound(EventKind::TaskFailed, EventSeq(8), Actor::Adapter).with_task(task), + ); + audit.append( + 6, + EventKind::CommandCompleted, + Actor::Adapter, + task, + Some(job), + b"verify failed", + ); + audit.append( + 7, + EventKind::PatchRejected, + Actor::Adapter, + task, + Some(job), + b"patch rejected", + ); + audit.append( + 8, + EventKind::TaskFailed, + Actor::Adapter, + task, + None, + b"task failed", + ); + if let Err(error) = audit.persist() { + eprintln!("crustcore run: could not persist failure audit: {error}"); + } eprintln!("VERIFY FAILED ({status:?}) — task NOT completed."); if !output.as_str().is_empty() { eprintln!("--- verify output (bounded) ---"); @@ -275,16 +541,29 @@ fn run_task(run_args: &[String]) -> ExitCode { ExitCode::from(1) } VerifyOutcome::Refused(reason) => { + kernel.step( + Event::inbound(EventKind::TaskFailed, EventSeq(6), Actor::Adapter).with_task(task), + ); + audit.append( + 6, + EventKind::TaskFailed, + Actor::Adapter, + task, + None, + b"verify refused", + ); + if let Err(error) = audit.persist() { + eprintln!("crustcore run: could not persist refusal audit: {error}"); + } eprintln!("VERIFY REFUSED: {reason}"); eprintln!( - "execution requires a sandbox backend (Linux bubblewrap); \ - see docs/sandbox.md. Nothing is completed without sandboxed verifier evidence." + "execution requires a sandbox backend; see docs/sandbox.md. \ + Nothing is completed without sandboxed verifier evidence." ); ExitCode::from(1) } }; - // Tear down the disposable worktree on every path (best-effort). let _ = manager.remove(&worktree); code } @@ -443,27 +722,6 @@ fn resolve_verify_spec( } } -/// A per-run MAC key for the receipt chain. CrustCore holds this key; the model -/// never does, so receipts are unforgeable (invariant 10). Persistent key -/// management arrives with the runtime; for a single local run we draw a fresh -/// random key (falling back to a fixed dev key if the OS RNG is unavailable). -fn run_key() -> [u8; 32] { - use std::io::Read as _; - let mut key = [0u8; 32]; - // Read exactly 32 bytes — `/dev/urandom` never reaches EOF, so a bounded - // `read_exact` is required (a plain read-to-end would never return). - if let Ok(mut f) = std::fs::File::open("/dev/urandom") { - if f.read_exact(&mut key).is_ok() { - return key; - } - } - // Deterministic fallback (no OS RNG): clearly-marked dev key. - for (i, b) in key.iter_mut().enumerate() { - *b = 0xC0u8 ^ (i as u8); - } - key -} - /// Wall-clock timestamp for stamping the verify run. Time is supplied by the /// adapter layer (here, the CLI), never read inside the kernel. fn now_ts() -> Timestamp { @@ -499,11 +757,52 @@ fn inspect(path: Option<&str>) -> ExitCode { let report = log.inspect(); print!("{report}"); match report.status { - ChainStatus::Intact { .. } => ExitCode::SUCCESS, + ChainStatus::Intact { .. } => match verify_persisted_receipts(&log) { + Ok(count) => { + if count > 0 { + println!("receipts: {count} authenticated and joined"); + } + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("crustcore: receipt audit failed: {error}"); + ExitCode::from(1) + } + }, ChainStatus::Broken { .. } => ExitCode::from(1), } } +fn verify_persisted_receipts(log: &EventLog) -> Result { + let mut receipts = Vec::new(); + for decoded in log.iter() { + if decoded.frame.kind == EventKind::ToolCallCompleted + && decoded.payload.starts_with(b"CCR1") + { + receipts.push( + ToolReceipt::from_bytes(decoded.payload) + .map_err(|error| format!("invalid receipt payload: {error:?}"))?, + ); + } + } + if receipts.is_empty() { + return Ok(0); + } + let chain = ReceiptChain::new( + load_or_create_receipt_key(&state_root()) + .map_err(|error| format!("receipt key unavailable: {error}"))?, + ); + let status = chain.verify(&receipts); + if !status.is_intact() { + return Err(format!("receipt MAC/chain broken: {status:?}")); + } + let join = verify_against_log(&receipts, &frame_refs(log)); + if !join.is_joined() { + return Err(format!("receipt/event-log join broken: {join:?}")); + } + Ok(receipts.len()) +} + /// `crustcore export ` — render the log as JSONL on stdout. The export is /// verification-gated (only verified frames are emitted, and never a tampered /// payload); if the chain is broken, a diagnostic goes to stderr and the exit @@ -617,8 +916,8 @@ fn net_subcommand(args: &[String]) -> ExitCode { /// the chat pack before it reaches the terminal (CLAUDE.md §16 — the CLI front door is /// a typed, redacted control plane, not a raw model pipe). #[cfg(feature = "chat")] -fn chat_subcommand(_args: &[String]) -> ExitCode { - use crustcore_chat::{run_terminal, ChatConfig, OperatorSteering, Persona}; +fn chat_subcommand(args: &[String]) -> ExitCode { + use crustcore_chat::{run_terminal_with_tasks, ChatConfig, OperatorSteering, Persona}; use crustcore_secrets::Redactor; let helper = @@ -645,8 +944,33 @@ fn chat_subcommand(_args: &[String]) -> ExitCode { // broker's pre-loaded redactor instead so stored secrets are scrubbed. let redactor = Redactor::new(); - println!("crustcore chat — type a message; `!text` steers, `/cancel` aborts, Ctrl-D exits."); - match run_terminal(&helper, &[], &redactor, config) { + let mut run_args = args.to_vec(); + if crustcore_cli::parse_run(&run_args).is_err() { + eprintln!( + "usage: crustcore chat [-dir ] [-verify ] \ + [-backend native|codex|claude|cmd] [-worker-cmd ]" + ); + return ExitCode::from(2); + } + if !run_args.iter().any(|arg| arg == "-dir" || arg == "--dir") { + run_args.extend(["-dir".to_string(), ".".to_string()]); + } + let mut dispatch = move |_route: &str, prompt: &str| { + let mut task_args = run_args.clone(); + task_args.extend(["-goal".to_string(), prompt.to_string()]); + match run_task(&task_args) { + code if code == ExitCode::SUCCESS => { + "crustcore task> verified task completed; audit path printed above".to_string() + } + _ => "crustcore task> task stopped; not completed".to_string(), + } + }; + + println!( + "crustcore chat — task-shaped turns run through the kernel; \ + `!text` steers, `/cancel` aborts, Ctrl-D exits." + ); + match run_terminal_with_tasks(&helper, &[], &redactor, config, &mut dispatch) { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("crustcore chat: {e}"); @@ -788,9 +1112,31 @@ mod tests { } #[test] - fn run_key_returns_nonzero_key() { - // Either the OS RNG (normal) or the deterministic fallback yields a - // non-zero 32-byte key; an all-zero key would be a bug. - assert_ne!(run_key(), [0u8; 32]); + fn receipt_key_is_persistent_and_owner_only() { + let dir = std::env::temp_dir().join(format!("cc-key-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut first = ReceiptChain::new(load_or_create_receipt_key(&dir).unwrap()); + let receipt = first.mint(&ReceiptParams { + task_id: TaskId(1), + job_id: JobId(1), + tool_call_id: ToolCallId(1), + tool_name: b"verify", + args: b"test", + result: b"ok", + artifacts: &[], + event_seq: EventSeq(1), + }); + let second = ReceiptChain::new(load_or_create_receipt_key(&dir).unwrap()); + assert!(second.verify(&[receipt]).is_intact()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(dir.join("receipt.key")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/docs/chat.md b/docs/chat.md index c901442..2312e0c 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -104,9 +104,11 @@ path and is never logged. ## 5.2 Chat → draft PR (opt-in, approval-gated) -Add `--open-pr --repo [--base main] [--branch-prefix crustcore]` to have a -verified chat task open a **draft** PR. Opening a PR is irreversible (invariant 14), so the -flow is **approve-first**: +Add `--open-pr --repo --github-token-file [--base main] +[--branch-prefix crustcore]` to have a verified chat task push its exact verified +commit and open a **draft** PR. The credential file must be a small regular, +owner-only (`0600`) file containing one fine-grained PAT or short-lived App token. +Opening a PR is irreversible (invariant 14), so the flow is **approve-first**: 1. You ask for work ("add a `--json` flag"). The bot replies with the operation and the **✅ approve / 🚫 deny** buttons — it has *not* started yet. @@ -119,9 +121,11 @@ flow is **approve-first**: Approving *before* the run means the `VerifiedPatch` never has to outlive the approval — the capability (plain data) moves into the task thread, where the patch is produced and consumed -in one place. The git push of the head branch and the GitHub REST `create_pull` are the -reduced live socket (`TODO(P10-net-live)`); everything up to the verifier-evidenced -`PrIntent` is exercised in CI. +in one place. After the verifier passes, the daemon persists and joins the receipt, pushes +the exact committed candidate through its confined credential helper, then calls GitHub's +draft-PR endpoint. The token is read only by the trusted daemon and helper; it never enters +the worker/verifier sandbox, worktree, argv, environment, event log, or status output. The +real GitHub socket remains a manual/live validation gate because CI has no repository token. ## 6. Reasoning streaming diff --git a/docs/event-log.md b/docs/event-log.md index 1001b21..7cb6199 100644 --- a/docs/event-log.md +++ b/docs/event-log.md @@ -262,6 +262,13 @@ acceptance criteria, a **task summary** and the **chain status** (intact vs firs break). It is a CLI surface — setup/admin/inspect/emergency only, not a chat channel (invariant 16; [`CLAUDE.md` §4](../CLAUDE.md)). +Real `crustcore run` and live daemon tasks persist their logs atomically under the +state directory's `runs/` tree with mode 0600. The writer verifies the in-memory +chain, writes and syncs a same-directory temporary file, renames it over the final +path, and syncs the parent directory. A successful verifier receipt is encoded in +its `ToolCallCompleted` payload; `inspect` also authenticates that receipt and joins +its task/job/sequence back to the frame before reporting success. + **JSONL export** (task P2.5) renders the binary log as one JSON object per line for human reading and tooling — the readability win NilCore got from JSONL, kept as an *export* so nano's on-disk format stays compact binary ([`ROADMAP.md` §1.1](../ROADMAP.md)). diff --git a/docs/github.md b/docs/github.md index af244da..8a1d933 100644 --- a/docs/github.md +++ b/docs/github.md @@ -131,10 +131,10 @@ invariant 1). Git operations inside a sandboxed task authenticate through a **local credential-helper proxy**: ```text -git in sandbox - -> local credential helper proxy (trusted process, outside sandbox) +trusted daemon, after sandboxed verification + -> confined git push + local credential helper (trusted process) -> validates repo / branch / refspec against the task's GitHubWriteCap - -> mints / injects a short-lived installation token + -> injects an owner-provided fine-grained PAT or short-lived installation token -> GitHub -> proxy returns only the operation result; token never lands in sandbox env ``` @@ -152,8 +152,10 @@ Why a proxy instead of `GITHUB_TOKEN` in the environment: force-push, not a protected branch) unless an `Approved` is presented. A request to push to `main` or force-push is rejected at the proxy even if the in-sandbox git command tries it. -- **Tokens are short-lived.** The proxy mints per-operation installation tokens - and lets them expire, minimizing the value of any leak. +- **Short-lived tokens are preferred.** The operational CLI accepts an owner-only + token file so either a short-lived App installation token or a fine-grained PAT + can be used. The file must be regular, non-symlinked, `0600`, bounded, and contain + exactly one token; the bytes are zeroized after push/REST use. This is the GitHub instantiation of the secret model's preferred injection order (credential proxy / git credential-helper proxy first; environment variable only @@ -297,7 +299,7 @@ Cannot force-push by default. -> §3.1, §4 CI failure can create a repair task. -> §7 ``` -**Status (Phase 10 decision cores + P10-net wire layer).** The *decisions* are +**Status (operational verified-patch → draft-PR path).** The *decisions* are implemented and tested: `crustcore-backend::integrate::open_pr` (the type-13 gate — only a `VerifiedPatch` + `Approved` yields a draft `PrIntent`; `format_pr_body` from verifier evidence), and `crustcore-daemon::github` @@ -311,9 +313,12 @@ the `live`-gated `UreqClient`. It takes **primitive** inputs (the daemon maps a `PrIntent` onto a `CreatePrRequest`), keeping the sidecar dependency-light; a GitHub response is **untrusted data** (only the fields we need are read, a non-2xx never fabricates a success), and the token is resolved per call via the credential proxy and -never appears in output or a routed error. **What remains** (`live`-gated, never CI): -the daemon driving the helper over the protocol to open a *real* PR end-to-end (which -un-defers the `golden_issue_to_pr_flow` golden) + the `#[ignore]`d `gh_live` test. +never appears in output or a routed error. The live daemon now drives this path end to +end: it validates the configured repo/ref scope, commits the candidate in the disposable +worktree, verifies that exact commit, persists the authenticated receipt/log join, performs +a non-force push through the confined helper, and calls `create_pull(draft=true)`. What +remains outside CI is only the real GitHub network validation itself (`gh_live` and the +full issue-to-PR live smoke), because those require an operator-owned repo and credential. ### 9.1 Testing notes diff --git a/docs/nano-size-budget.md b/docs/nano-size-budget.md index c97fd0d..e18a8a7 100644 --- a/docs/nano-size-budget.md +++ b/docs/nano-size-budget.md @@ -61,10 +61,11 @@ the only one that is a *release blocker* via invariant 19. The platform of recor for the hard target is **Linux x86_64** ([`ROADMAP.md` §22](../ROADMAP.md) DoD #1). **Current measured baseline (v0.1, full trusted core).** The std-only nano binary -(`cargo xtask size-check`) builds at **~412 KiB stripped** — about **52% of the +(`cargo xtask size-check`) builds at **444.6 KiB stripped** on the current macOS +validation host — about **56% of the 800 KiB budget** — with the whole trusted core linked and reachable via `crustcore run`/`doctor` (kernel, event log, receipts, path confinement, runner, sandbox, -worktree, verify loop). The ~388 KiB of remaining headroom is deliberate slack +worktree, verify loop). The ~355 KiB of remaining headroom is deliberate slack against future growth. Watch it shrink-or-hold on every PR via the gate; a sudden jump means a heavy dependency or a layering leak slipped in. diff --git a/docs/receipts.md b/docs/receipts.md index 463d7e4..9f14784 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -42,7 +42,7 @@ pub struct ToolReceipt { pub tool_name_hash: [u8; 32], pub args_hash: [u8; 32], pub result_hash: [u8; 32], - pub artifact_hashes: SmallVec<[[u8; 32]; 4]>, + pub artifact_hashes: Vec, // hard-capped at 256 pub event_seq: EventSeq, pub prev_receipt_hash: [u8; 32], pub mac: [u8; 32], @@ -63,9 +63,8 @@ pub struct ToolReceipt { | `mac` | a key the model never holds | makes receipts unforgeable by the model/worker (§6) | `args_hash`, `result_hash`, and `artifact_hashes` are content hashes; the receipt -commits to *content* without re-embedding it. `SmallVec<[[u8;32]; 4]>` keeps the -common small artifact count allocation-free, matching the kernel's allocation-light -posture ([`architecture.md` §2](./architecture.md)). +commits to *content* without re-embedding it. Artifact references are capped at 256 +before storage (invariant 11). --- @@ -99,7 +98,14 @@ Receipts do not include secret values. with invariants 1–3). They carry *hashes* of args/results, not the raw bytes, so a receipt is safe to keep, export, and show. Secret-bearing args/results must already be redacted before hashing for any model-visible surface - ([`secrets.md`](./secrets.md), [`security-model.md`](./security-model.md)). +([`secrets.md`](./secrets.md), [`security-model.md`](./security-model.md)). + +For real CLI and daemon tasks, the stable binary receipt record is the payload of +the exact `ToolCallCompleted` frame named by `event_seq`. The owner-only MAC key is +stored at `$CRUSTCORE_STATE/receipt.key` (or the XDG state fallback), and each run +log is atomically persisted mode 0600 under `runs/`. Completion happens only after +the MAC chain, event-log chain, and receipt-to-frame join all verify and the log is +durable. --- diff --git a/docs/sandbox.md b/docs/sandbox.md index 4bb294d..3f546ee 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -69,9 +69,8 @@ Notes: ## 3. Backend order per OS The launcher selects the strongest available backend for the OS -([`ROADMAP.md` §10.2`](../ROADMAP.md)). v0.1 ships the Linux backend v1 -(Phase 4); macOS/Windows and Firecracker come later -([`ROADMAP.md` §2.3, §21`](../ROADMAP.md)). +([`ROADMAP.md` §10.2`](../ROADMAP.md)). Linux bubblewrap and macOS Seatbelt are +implemented; hostile Tier-3 execution still requires Firecracker. **Linux:** @@ -112,33 +111,41 @@ ships on every macOS); a host without it **refuses** rather than degrading to unsandboxed execution. Like bubblewrap, allowlisted egress is not granted here in v1 — an `Allowlist` profile is refused until the trusted egress proxy exists. -The generated profile uses an "allow-all, then deny the two load-bearing classes, -then re-allow the writable surface" recipe (later SBPL rules override earlier -ones), so a toolchain keeps its read access while the two guarantees are enforced: +The generated profile keeps process services available, then denies network, +filesystem reads, and filesystem writes before reopening only explicit runtime, +toolchain, worktree, and private-scratch roots: ```scheme (version 1) (allow default) (deny network*) ; deny-all egress — mirrors bubblewrap --unshare-all -(deny file-write*) ; then re-allow only the writable surface below +(deny file-read*) ; reopen only system runtime + toolchain + task roots +(deny file-write*) ; reopen only the worktree and per-run private scratch +(allow file-read* + (subpath "/System") (subpath "/usr") + (subpath "/private/etc") (subpath "/Library/Developer") + (subpath "") + (subpath "") + (subpath "") + (subpath "")) (allow file-write* (subpath "") - (subpath "") - (subpath "/private/tmp") - (subpath "/private/var/tmp") + (subpath "") (literal "/dev/null") (literal "/dev/zero") (literal "/dev/stdout") (literal "/dev/stderr") (literal "/dev/tty") (literal "/dev/dtracehelper") (literal "/dev/urandom")) ``` -- `(deny network*)` is the deny-all-egress guarantee; `(deny file-write*)` - followed by re-allowing only the worktree + temp dirs is the write-confinement - guarantee. Both are verified by live confinement tests on macOS. +- `(deny network*)` is the deny-all-egress guarantee. Read and write denial are + both default-closed. Shared `/private/tmp` and `/private/var/tmp` are not reopened; + each run gets a mode-0700 scratch directory that is removed after execution. + Live tests prove outside reads, outside writes, shared-temp writes, and network + access are refused. - **Paths are canonicalized.** macOS symlinks `/tmp`→`/private/tmp`, `/var`→`/private/var`, and worktrees under `/var/folders/...`; SBPL `subpath` matches the kernel's **resolved** path. The backend therefore - `canonicalize`s the worktree and the (sanitized) `TMPDIR` before embedding them - (falling back to `/private/tmp` when `TMPDIR` is unset). If a path cannot be + `canonicalize`s the worktree and provisioned private `TMPDIR` before embedding + them. If a path cannot be canonicalized the backend **fails closed** (`SandboxError::Setup`) rather than embedding an unresolved path that would either fail open or block legitimate worktree writes. Embedded paths are escaped (`"` and `\`) to prevent breaking diff --git a/docs/secrets.md b/docs/secrets.md index e3b5998..698f15b 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -47,7 +47,7 @@ pub struct SecretHandle { /// The actual secret bytes. Never model-visible. pub struct SecretMaterial { - bytes: Zeroizing>, // zeroized on drop + bytes: crustcore_zeroize::ZeroizingVec, // volatile-zeroized on drop } ``` From 9ecacba94730ff76927c73c5027a04937ae5c0d0 Mon Sep 17 00:00:00 2001 From: RNT56 Date: Sun, 12 Jul 2026 10:21:15 +0200 Subject: [PATCH 2/3] Avoid duplicate pull request CI runs --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1030747..d71fa1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: ["**"] + branches: ["main"] pull_request: # Cancel superseded runs on the same ref. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3abf00a..b79fc0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -416,6 +416,8 @@ agent/PR/role/size/invariant audit trail. the zero-third-party nano graph. CI runs `cargo audit --deny warnings`; workflow actions are immutable-SHA pinned; Dependabot and CODEOWNERS cover dependencies, workflows, contracts, secrets, and sandbox code. + Feature branches validate once through `pull_request`; `push` CI is reserved for + `main`, eliminating duplicate PR-branch runs without weakening post-merge coverage. The minimum supported Rust version is now 1.88. - **Consolidated the duplicated security primitives into `crustcore-types::hash`.** The From 5c1370cf3e5aa531173154a9cbb6cdb5e088463d Mon Sep 17 00:00:00 2001 From: RNT56 Date: Sun, 12 Jul 2026 10:24:23 +0200 Subject: [PATCH 3/3] Upgrade CI actions to Node 24 majors --- .github/workflows/ci.yml | 12 ++++++------ .github/workflows/release.yml | 8 ++++---- CHANGELOG.md | 3 ++- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d71fa1f..5586c7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,9 +18,9 @@ jobs: name: dependency audit runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cargo key: audit-${{ hashFiles('Cargo.lock') }} @@ -39,7 +39,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable @@ -64,7 +64,7 @@ jobs: && echo "bwrap functional" || echo "bwrap present but not functional (tests will gate-only)" - name: Cache cargo registry and target - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/registry @@ -102,9 +102,9 @@ jobs: name: nano size gate (<800kB) runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/registry diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad458fe..19279f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: - { os: ubuntu-latest, target: x86_64-linux } # the flagship size target - { os: macos-latest, target: aarch64-macos } steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable @@ -69,7 +69,7 @@ jobs: fi cat "SHA256SUMS-${{ matrix.target }}.txt" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist-${{ matrix.target }} path: dist/* @@ -81,9 +81,9 @@ jobs: if: startsWith(github.ref, 'refs/tags/') # only on a tag, never on a manual dry-run runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: dist merge-multiple: true diff --git a/CHANGELOG.md b/CHANGELOG.md index b79fc0a..1adb40c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -414,7 +414,8 @@ agent/PR/role/size/invariant audit trail. by AWS-LC. Sidecar secret values use `zeroize`; nano secret bytes and receipt MAC keys use the tiny audited first-party `crustcore-zeroize` primitive, preserving the zero-third-party nano graph. CI runs - `cargo audit --deny warnings`; workflow actions are immutable-SHA pinned; Dependabot + `cargo audit --deny warnings`; workflow actions are immutable-SHA pinned at their + maintained Node 24 majors; Dependabot and CODEOWNERS cover dependencies, workflows, contracts, secrets, and sandbox code. Feature branches validate once through `pull_request`; `push` CI is reserved for `main`, eliminating duplicate PR-branch runs without weakening post-merge coverage.