diff --git a/CHANGELOG.md b/CHANGELOG.md index 02295e9..308c480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -376,8 +376,30 @@ agent/PR/role/size/invariant audit trail. `VerifierPlan` without reading repo contents or granting authority. No kernel or nano changes; no new dependencies. +### Fixed + +- **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 + `SlackSignature::verify` (body/timestamp are now capped — `MAX_SLACK_BODY` / + `MAX_SLACK_TIMESTAMP` — **before** any allocation or HMAC work, invariant 11); the + duplicate `### Added` block under `[0.5.0]` (a union-merge artifact — consolidated into + one section, no content lost); the out-of-order `F.4`–`F.7` subsections in + `docs/live-socket-validation.md` (reordered to ascending); and the stale `~834` test + count in `README.md` (badge + table → `~1,120`). A separate architecture re-audit found + **no** trust-boundary defects across the v0.6 additions (Slack/cockpit/admin/multirepo/ + token-stream). Non-nano + docs; one new bound test; `cargo xtask verify` green. + ### Changed +- **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 + `crustcore-dev` (auth + backend). They now live **once**, beside `sha256`/`hmac_sha256`, + as the single audited home for every MAC/signature check. Behaviour-identical (each + consumer's security tests pass unchanged); net nano size impact ~0 (the code already sat + in the nano-linked `receipts`/`types` graph). Also removed the now-unused `DaemonSurface` + marker enum. 3 new primitive tests. - **Workspace version bumped `0.4.0` → `0.5.0`.** Aligns the `[workspace.package]` version (and all 26 internal path-dependency pins) with the rolled `[0.5.0]` changelog section, so prebuilt artifacts (`crustcore --version`, the release @@ -449,33 +471,6 @@ agent/PR/role/size/invariant audit trail. live I/O (HTTP/TLS, DBs, tree-sitter) never links in. README + CLAUDE.md size tables updated. Invariant 19/20 (the same size-discipline thesis, now measured for the full build too). -### Fixed - -- **Docs accuracy — corrected the nano size figures + test count (a final-polish audit - finding).** The contract/overview docs cited **412.0 KiB / 51.5%** as the flagship "Linux - x86_64" nano size, but that is the **macOS** figure — the CI size gate reports Linux x86_64 - at **478.7 KiB (490184 bytes), 59.8%** of the 800 kB budget (still within the < 600 kB - *stretch* goal). The macOS figure is the smaller one (~412 KiB). Corrected across - `CLAUDE.md`, `README.md` (badge + prose + tier table + size-check example), and - `docs/roadmap-v0.2.md` (intro). Also refreshed the stale workspace **test count - (~663 → ~834)**. No code change — nano is byte-identical across this session's feature work - (confirmed: no nano-linked crate changed); the figures were simply mislabeled/stale. - -- **Chat front-door polish — two gaps found by an end-to-end completeness audit, closed.** - (1) **Route-aware budgets:** the chat classifier emits 4 execution routes - (QuickFix/Feature/Project/Continue) and threaded them to `LoopAction::LaunchTask.route`, but - the runtime *discarded* `route` and launched every task with one default budget — a dangling - feature. `runtime::budget_for_route` now honors it with tiered per-task budgets (QuickFix - tight → Project the generous default; Continue → Feature tier), wired through `TaskRunner:: - launch` and the PR-gated path. (2) **Abuse suppressor:** `telegram::AbuseSuppressor` (bounded, - per-chat, deterministic over injected time) rate-limits how often a not-allowlisted chat's - rejections are surfaced, so a flooding chat can't spam the risk signal (`docs/telegram.md` - §4) — the allowlist still rejects every message; this only smooths reporting. 5 new tests. - The audit also **refuted a false high-severity finding** (task progress reaching the user - unredacted — `renderer.notice` IS the sanctioned declassifier) and confirmed all 20 invariants - are structurally enforced + tested. Invariants 11. Nano: n/a (daemon-only). - -### Added - **Supervisor fan-out coordinator (`P11`) — race verified proposers, the verifier picks the winner.** `crustcore_daemon::exec::run_fanout` is the multi-proposer extension of @@ -814,6 +809,32 @@ agent/PR/role/size/invariant audit trail. stays `TODO(P9-net-live)` (no fake network loop). Pure `parse_args` unit-tested (11 tests). Non-nano, no new deps, nano unaffected. +### Fixed + +- **Docs accuracy — corrected the nano size figures + test count (a final-polish audit + finding).** The contract/overview docs cited **412.0 KiB / 51.5%** as the flagship "Linux + x86_64" nano size, but that is the **macOS** figure — the CI size gate reports Linux x86_64 + at **478.7 KiB (490184 bytes), 59.8%** of the 800 kB budget (still within the < 600 kB + *stretch* goal). The macOS figure is the smaller one (~412 KiB). Corrected across + `CLAUDE.md`, `README.md` (badge + prose + tier table + size-check example), and + `docs/roadmap-v0.2.md` (intro). Also refreshed the stale workspace **test count + (~663 → ~834)**. No code change — nano is byte-identical across this session's feature work + (confirmed: no nano-linked crate changed); the figures were simply mislabeled/stale. + +- **Chat front-door polish — two gaps found by an end-to-end completeness audit, closed.** + (1) **Route-aware budgets:** the chat classifier emits 4 execution routes + (QuickFix/Feature/Project/Continue) and threaded them to `LoopAction::LaunchTask.route`, but + the runtime *discarded* `route` and launched every task with one default budget — a dangling + feature. `runtime::budget_for_route` now honors it with tiered per-task budgets (QuickFix + tight → Project the generous default; Continue → Feature tier), wired through `TaskRunner:: + launch` and the PR-gated path. (2) **Abuse suppressor:** `telegram::AbuseSuppressor` (bounded, + per-chat, deterministic over injected time) rate-limits how often a not-allowlisted chat's + rejections are surfaced, so a flooding chat can't spam the risk signal (`docs/telegram.md` + §4) — the allowlist still rejects every message; this only smooths reporting. 5 new tests. + The audit also **refuted a false high-severity finding** (task progress reaching the user + unredacted — `renderer.notice` IS the sanctioned declassifier) and confirmed all 20 invariants + are structurally enforced + tested. Invariants 11. Nano: n/a (daemon-only). + ### Changed - **Invariants 15 & 16 amended (owner-authorized) to sanction the chat front door.** diff --git a/CLAUDE.md b/CLAUDE.md index cd52cab..a8d5a4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,15 +10,21 @@ optional capability packs. **Repository:** https://github.com/RNT56/CrustCore **Status:** v0.1 done; **v0.2 (Track A "light it up"), v0.3 (Track B "expand", B1–B6), -and v0.4 (Track C "compose & adopt", C1–C7) merged — tagged `v0.4.0`** — Phases 0–16 +and v0.4 (Track C "compose & adopt", C1–C7) merged — tagged `v0.4.0`; v0.5 (version roll) +and v0.6 (the GitHub-PR-supervisor roadmap — GitHub-App onboarding, a richer verifier + +repo memory, task-shape routing / multi-verifier review / evidence bundles, the daemon +task-loop, the loopback cockpit + `/crustcore` GitHub commands + Slack control plane + +CoT-stream redaction, and runtime lease-recovery / admin socket / multi-repo) merged to +`main`** — the signed tag/sign/publish is the only maintainer-owned step left. Phases 0–16 plus every Track A phase (P5-join, P7-live, P8-store, P9-net, P10-net, P11-exec, P12-native, P13-net, P14-store), every Track B surface (B1-mcp-modes, B2-gh-app, -B3-vector-memory, B4-sandbox-tiers, B5-autoloop, B6-release-infra), and every Track C -phase (C1-providers, C2-toolmacro, C3-flow, C4-session, C5-rag, C6-telemetry, C7-devui) -are complete and merged (see [`docs/roadmap-v0.2.md`](./docs/roadmap-v0.2.md)). Green +B3-vector-memory, B4-sandbox-tiers, B5-autoloop, B6-release-infra), every Track C +phase (C1-providers, C2-toolmacro, C3-flow, C4-session, C5-rag, C6-telemetry, C7-devui), +and every v0.6 task are complete and merged (see [`docs/roadmap-v0.6.md`](./docs/roadmap-v0.6.md), +and the earlier [`docs/roadmap-v0.2.md`](./docs/roadmap-v0.2.md)). Green `cargo xtask verify` on Linux and macOS; nano **478.7 KiB** (Linux x86_64, the flagship target; 412.0 KiB on macOS), 59.8% of the 800 kB budget — within the < 600 kB stretch -goal; **~834** workspace tests; the v0.1 +goal; **~1,120** workspace tests; the v0.1 [definition of done](./ROADMAP.md) §22, all 12 criteria, is met. The trusted `Kernel::step` state machine is real (task/job transitions, typed budgets, approval request/resolution; sync, deterministic, no async/net/db, no wall clock). The diff --git a/README.md b/README.md index 0f6762b..8369fae 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![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-834_passing-2ea44f) + ![tests](https://img.shields.io/badge/tests-1120_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) @@ -189,7 +189,7 @@ Full design: **[docs/architecture.md](./docs/architecture.md)**  ·  s | **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** | **834 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,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 | --- diff --git a/crates/crustcore-daemon/src/lib.rs b/crates/crustcore-daemon/src/lib.rs index 13e1bbe..9d66cd7 100644 --- a/crates/crustcore-daemon/src/lib.rs +++ b/crates/crustcore-daemon/src/lib.rs @@ -95,14 +95,3 @@ pub mod task; pub mod task_loop; pub mod telegram; pub mod webhook; - -/// Surfaces the daemon supervises. Marker enum so the crate is real. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DaemonSurface { - /// The Telegram runtime channel (default human channel; invariant 15). - Telegram, - /// The GitHub task/PR control plane. - GitHub, - /// The authenticated local/remote admin socket. - AdminSocket, -} diff --git a/crates/crustcore-daemon/src/slack.rs b/crates/crustcore-daemon/src/slack.rs index 9d7c5f0..ae1b726 100644 --- a/crates/crustcore-daemon/src/slack.rs +++ b/crates/crustcore-daemon/src/slack.rs @@ -18,7 +18,7 @@ use std::collections::{BTreeMap, BTreeSet}; use crustcore_secrets::Redactor; -use crustcore_types::{hmac_sha256, BoundedText}; +use crustcore_types::{ct_eq, hex32_decode, hmac_sha256, BoundedText}; use crate::telegram::{CallbackData, Command, RuntimeEvent}; @@ -132,6 +132,15 @@ pub fn render_to_slack(text: &str, redactor: &Redactor) -> String { /// possible replay even if its signature is valid. pub const SLACK_MAX_SKEW_SECS: u64 = 300; +/// Max accepted request-body size (bytes) for signature verification — bounds the work an +/// unauthenticated caller can force (invariant 11), mirroring `MAX_WEBHOOK_BODY`. Rejected +/// *before* the basestring is allocated or the HMAC is computed. +pub const MAX_SLACK_BODY: usize = 1024 * 1024; + +/// Max accepted `X-Slack-Request-Timestamp` length (bytes). A unix timestamp is ~10 +/// digits; anything longer is malformed and rejected before any allocation. +pub const MAX_SLACK_TIMESTAMP: usize = 32; + /// Verifies a Slack request's signature **and** timestamp freshness — the "is this really /// from Slack" gate that runs *before* [`normalize_message`] ever sees the body. /// @@ -170,6 +179,12 @@ impl SlackSignature { body: &[u8], now_unix_secs: u64, ) -> bool { + // 0. Bound the untrusted inputs BEFORE any allocation or HMAC work (invariant 11): + // an oversized body/timestamp from an unauthenticated caller is rejected outright, + // so it can never force an unbounded basestring allocation or HMAC computation. + if body.len() > MAX_SLACK_BODY || timestamp.len() > MAX_SLACK_TIMESTAMP { + return false; + } // 1. Freshness: a captured-and-replayed (or forward-dated) request is rejected even // with a valid signature. let Ok(ts) = timestamp.parse::() else { @@ -183,7 +198,7 @@ impl SlackSignature { let Some(hex) = signature.strip_prefix("v0=") else { return false; }; - let Some(provided) = slack_hex32(hex) else { + let Some(provided) = hex32_decode(hex) else { return false; }; let mut base = Vec::with_capacity(3 + timestamp.len() + 1 + body.len()); @@ -192,32 +207,8 @@ impl SlackSignature { base.push(b':'); base.extend_from_slice(body); let expected = hmac_sha256(&self.secret, &base); - slack_ct_eq(&provided, &expected) - } -} - -/// Constant-time 32-byte compare: visits every byte (no early return), so a near-miss -/// signature cannot be distinguished from a far-miss by timing. -fn slack_ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool { - a.iter() - .zip(b.iter()) - .fold(0u8, |acc, (x, y)| acc | (x ^ y)) - == 0 -} - -/// Decodes exactly 64 hex chars into the 32-byte HMAC-SHA256 digest, or `None`. -fn slack_hex32(s: &str) -> Option<[u8; 32]> { - let bytes = s.as_bytes(); - if bytes.len() != 64 { - return None; + ct_eq(&provided, &expected) } - let mut out = [0u8; 32]; - for (slot, pair) in out.iter_mut().zip(bytes.chunks_exact(2)) { - let hi = (pair[0] as char).to_digit(16)?; - let lo = (pair[1] as char).to_digit(16)?; - *slot = (hi * 16 + lo) as u8; - } - Some(out) } #[cfg(test)] @@ -368,6 +359,19 @@ mod tests { )); } + #[test] + fn an_oversized_body_or_timestamp_is_rejected_before_any_work() { + let v = SlackSignature::new(SIGNING_SECRET); + let ts = "1700000000"; + // A body past the bound is refused outright (invariant 11) — even though we never + // even reach the signature step, fail-closed returns false. + let huge = vec![b'x'; MAX_SLACK_BODY + 1]; + assert!(!v.verify(&valid_sig(ts, &huge), ts, &huge, 1700000005)); + // An over-long timestamp is likewise refused before allocation. + let long_ts = "1".repeat(MAX_SLACK_TIMESTAMP + 1); + assert!(!v.verify("v0=deadbeef", &long_ts, b"{}", 1700000005)); + } + #[test] fn a_tampered_body_does_not_match_the_signature() { let v = SlackSignature::new(SIGNING_SECRET); diff --git a/crates/crustcore-daemon/src/webhook.rs b/crates/crustcore-daemon/src/webhook.rs index 827d63c..7ad8195 100644 --- a/crates/crustcore-daemon/src/webhook.rs +++ b/crates/crustcore-daemon/src/webhook.rs @@ -28,7 +28,7 @@ use std::collections::{HashSet, VecDeque}; use crustcore_secrets::Redactor; -use crustcore_types::{hmac_sha256, BoundedText}; +use crustcore_types::{ct_eq, hex32_decode, hmac_sha256, BoundedText}; /// Cap on an inbound webhook body (bounded — a sender cannot flood us; invariant 11). pub const MAX_WEBHOOK_BODY: usize = 1024 * 1024; @@ -169,7 +169,7 @@ impl WebhookVerifier { let Some(hex) = signature.strip_prefix("sha256=") else { return false; }; - let Some(provided) = hex32(hex) else { + let Some(provided) = hex32_decode(hex) else { return false; }; let expected = hmac_sha256(&self.secret, body); @@ -188,37 +188,6 @@ fn parse_kind(event: &str) -> GitHubEventKind { } } -/// Constant-time 32-byte compare: visits every byte (no early return), so a near-miss -/// signature cannot be distinguished from a far-miss by timing. -fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool { - a.iter() - .zip(b.iter()) - .fold(0u8, |acc, (x, y)| acc | (x ^ y)) - == 0 -} - -/// Decodes exactly 64 hex chars into 32 bytes (the HMAC-SHA256 digest), or `None`. -fn hex32(s: &str) -> Option<[u8; 32]> { - let bytes = s.as_bytes(); - if bytes.len() != 64 { - return None; - } - let mut out = [0u8; 32]; - for (slot, pair) in out.iter_mut().zip(bytes.chunks_exact(2)) { - *slot = (hex_val(pair[0])? << 4) | hex_val(pair[1])?; - } - Some(out) -} - -fn hex_val(c: u8) -> Option { - match c { - b'0'..=b'9' => Some(c - b'0'), - b'a'..=b'f' => Some(c - b'a' + 10), - b'A'..=b'F' => Some(c - b'A' + 10), - _ => None, - } -} - /// A bounded FIFO replay guard over recent delivery ids: remembers the most recent /// [`MAX_SEEN_DELIVERIES`] ids and rejects any it has already seen. struct ReplayGuard { diff --git a/crates/crustcore-dev/src/auth.rs b/crates/crustcore-dev/src/auth.rs index 52b7446..2416a46 100644 --- a/crates/crustcore-dev/src/auth.rs +++ b/crates/crustcore-dev/src/auth.rs @@ -9,6 +9,8 @@ use std::fmt; +use crustcore_types::hex_val; + /// Number of random bytes in a launch token (256 bits). pub const TOKEN_BYTES: usize = 32; @@ -98,15 +100,6 @@ fn decode_hex(s: &str) -> Option> { Some(out) } -fn hex_val(c: u8) -> Option { - match c { - b'0'..=b'9' => Some(c - b'0'), - b'a'..=b'f' => Some(c - b'a' + 10), - b'A'..=b'F' => Some(c - b'A' + 10), - _ => None, - } -} - /// The result of authenticating a request. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthOutcome { diff --git a/crates/crustcore-dev/src/backend.rs b/crates/crustcore-dev/src/backend.rs index 3d04e1d..acb56fb 100644 --- a/crates/crustcore-dev/src/backend.rs +++ b/crates/crustcore-dev/src/backend.rs @@ -24,7 +24,7 @@ use crustcore_eventlog::{ChainStatus, EventLog, RedactionState}; use crustcore_receipts::join::JoinStatus; use crustcore_receipts::ToolReceipt; use crustcore_secrets::Redactor; -use crustcore_types::{ArtifactId, EventSeq, TaskId, Timestamp}; +use crustcore_types::{hex_val, ArtifactId, EventSeq, TaskId, Timestamp}; // --------------------------------------------------------------------------- // View models — already redacted, already bounded. Carry NO live/secret types. @@ -439,15 +439,6 @@ fn decode_hex32(s: &str) -> Option<[u8; 32]> { Some(out) } -fn hex_val(c: u8) -> Option { - match c { - b'0'..=b'9' => Some(c - b'0'), - b'a'..=b'f' => Some(c - b'a' + 10), - b'A'..=b'F' => Some(c - b'A' + 10), - _ => None, - } -} - impl ReadOnlyBackend for MockDevBackend { fn run_inspector(&self) -> RunInspectorView { crate::views::inspector::render(&self.log) diff --git a/crates/crustcore-receipts/src/lib.rs b/crates/crustcore-receipts/src/lib.rs index c62b64f..15afb36 100644 --- a/crates/crustcore-receipts/src/lib.rs +++ b/crates/crustcore-receipts/src/lib.rs @@ -12,7 +12,9 @@ pub mod join; -use crustcore_types::{hmac_sha256, sha256, ArtifactId, EventSeq, JobId, TaskId, ToolCallId}; +use crustcore_types::{ + ct_eq, hmac_sha256, sha256, ArtifactId, EventSeq, JobId, TaskId, ToolCallId, +}; /// The genesis `prev_receipt_hash`: the first receipt chains from all-zeros. pub const GENESIS_RECEIPT_HASH: [u8; 32] = [0u8; 32]; @@ -195,15 +197,6 @@ fn receipt_chain_hash(r: &ToolReceipt) -> [u8; 32] { sha256(&receipt_body(r)) } -/// Constant-time comparison of two 32-byte tags (avoids MAC timing leaks). -fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool { - let mut diff = 0u8; - for i in 0..32 { - diff |= a[i] ^ b[i]; - } - diff == 0 -} - /// Mints and verifies a chain of [`ToolReceipt`]s under a CrustCore-held key. #[derive(Debug)] pub struct ReceiptChain { diff --git a/crates/crustcore-types/src/hash.rs b/crates/crustcore-types/src/hash.rs index fcdacab..6d763e0 100644 --- a/crates/crustcore-types/src/hash.rs +++ b/crates/crustcore-types/src/hash.rs @@ -205,6 +205,48 @@ pub fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] { sha256(&outer) } +/// Constant-time equality for two 32-byte digests (a SHA-256 / HMAC-SHA256 output, or any +/// fixed 32-byte tag/nonce). Visits **every** byte with no early return, so a near-miss +/// cannot be distinguished from a far-miss by timing. This is the comparison every +/// secret/MAC/signature check must use (receipts invariant 10, webhook + Slack signature +/// verification) — the single audited home next to [`sha256`]/[`hmac_sha256`]. +#[must_use] +pub fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool { + let mut diff = 0u8; + for i in 0..32 { + diff |= a[i] ^ b[i]; + } + diff == 0 +} + +/// Decodes a single ASCII hex digit (`0-9`, `a-f`, `A-F`) to its 0–15 value, or `None` for +/// any non-hex byte. The building block for decoding a hex-encoded digest/signature. +#[must_use] +pub fn hex_val(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } +} + +/// Decodes exactly 64 ASCII hex chars into the 32-byte digest they encode, or `None` if the +/// length is not 64 or any char is non-hex. The standard decoder for a hex-encoded +/// SHA-256 / HMAC-SHA256 digest or signature (used by the webhook + Slack verifiers). +#[must_use] +pub fn hex32_decode(s: &str) -> Option<[u8; 32]> { + let bytes = s.as_bytes(); + if bytes.len() != 64 { + return None; + } + let mut out = [0u8; 32]; + for (slot, pair) in out.iter_mut().zip(bytes.chunks_exact(2)) { + *slot = (hex_val(pair[0])? << 4) | hex_val(pair[1])?; + } + Some(out) +} + #[cfg(test)] mod tests { use super::*; @@ -268,4 +310,42 @@ mod tests { "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54" ); } + + #[test] + fn ct_eq_matches_only_identical_digests() { + let a = sha256(b"alpha"); + assert!(ct_eq(&a, &a)); + assert!(ct_eq(&[0u8; 32], &[0u8; 32])); + // A single differing byte (anywhere) fails. + let mut b = a; + b[31] ^= 0x01; + assert!(!ct_eq(&a, &b)); + let mut c = a; + c[0] ^= 0x80; + assert!(!ct_eq(&a, &c)); + } + + #[test] + fn hex_val_decodes_every_case_and_rejects_non_hex() { + assert_eq!(hex_val(b'0'), Some(0)); + assert_eq!(hex_val(b'9'), Some(9)); + assert_eq!(hex_val(b'a'), Some(10)); + assert_eq!(hex_val(b'f'), Some(15)); + assert_eq!(hex_val(b'A'), Some(10)); + assert_eq!(hex_val(b'F'), Some(15)); + for bad in [b'g', b'G', b' ', b'/', b':', b'x', 0u8, 0xff] { + assert_eq!(hex_val(bad), None, "byte {bad} must not decode"); + } + } + + #[test] + fn hex32_decode_round_trips_a_digest_and_rejects_bad_input() { + let digest = sha256(b"round trip"); + let encoded = hex(&digest); // 64 lowercase hex chars + assert_eq!(hex32_decode(&encoded), Some(digest)); + // Wrong length and a non-hex char both fail. + assert_eq!(hex32_decode(&encoded[..63]), None); + assert_eq!(hex32_decode(&format!("{}zz", &encoded[..62])), None); + assert_eq!(hex32_decode(""), None); + } } diff --git a/crates/crustcore-types/src/lib.rs b/crates/crustcore-types/src/lib.rs index bca1479..93b7a97 100644 --- a/crates/crustcore-types/src/lib.rs +++ b/crates/crustcore-types/src/lib.rs @@ -21,7 +21,7 @@ pub mod text; pub mod time; pub use budget::{Budget, BudgetAxis, BudgetCheck, BudgetDelta, Meter, BUDGET_AXIS_COUNT}; -pub use hash::{hmac_sha256, sha256}; +pub use hash::{ct_eq, hex32_decode, hex_val, hmac_sha256, sha256}; pub use ids::{ ApprovalId, ArtifactId, CapabilityId, EventSeq, JobId, LeaseOwner, ScopeId, SecretId, TaskId, ToolCallId, diff --git a/docs/live-socket-validation.md b/docs/live-socket-validation.md index a1f7b22..b03ca84 100644 --- a/docs/live-socket-validation.md +++ b/docs/live-socket-validation.md @@ -453,20 +453,33 @@ cargo test --workspace -- --list --ignored > their live inches are covered by [F.2](#f2)/[A.2](#a2) (model + channel) and > [B.4](#b4) (the draft-PR POST) respectively. - -### F.7 — `slack_live_round_trip_smoke` — Slack control plane (E.3) -- **Test:** `crustcore-daemon/src/slack.rs::tests::slack_live_round_trip_smoke`, feature `live`. Seam tag `TODO(slack-live)`. -- **Socket:** the Slack Bot API HTTP client + the Events-API / Socket-Mode listener - (the spawned `crustcore-net` helper, the Telegram pattern). -- **CI core (passing):** `SlackAllowlist` (per-workspace/channel, **deny-all empty**), - `normalize_message` (plain → `QueuedTurn`, `!` → `Steer`, `/` → `Command`, reaction → - `ApprovalCallback` — the **same `RuntimeEvent` stream** as Telegram, invariants 8/16), - and `render_to_slack` (redacts every secret before the message leaves — invariants 1–3). -- **Prereq:** a real Slack workspace + bot token (broker) + signing secret. -- **Run:** `cargo test -p crustcore-daemon --features live slack::tests::slack_live_round_trip_smoke -- --ignored --nocapture` -- **Success:** an allowed-channel message dispatches like Telegram; a reaction resolves an - approval via its nonce; outbound text is redacted; Slack is opt-in (operator-bound via - CLI, never the default — invariant 15). **Difficulty: medium.** + +### F.4 — `daemon_admin_live_socket_smoke` — admin socket (F.2) +- **Test:** `crustcore-daemon/src/admin.rs::tests::daemon_admin_live_socket_smoke`. Seam tag `TODO(daemon-admin-live)`. +- **Socket:** the real admin `UnixListener` (mode 0600) / TCP-loopback fallback + a + length-prefixed framed query/cancel round-trip. +- **CI core (passing):** `parse_admin_command`, `frame`/`try_deframe` (bounded, hostile + length rejected), `authenticate` (constant-length nonce compare), and `dispatch_admin` + (owner-scoped cancel/kill — the same gate as Telegram, invariant 12; status snapshot). +- **Prereq:** a bound socket + the startup nonce file (`~/.crustcore/admin.nonce`, 0600). +- **Run:** `cargo test -p crustcore-daemon admin::tests::daemon_admin_live_socket_smoke -- --ignored --nocapture` +- **Success:** an authenticated client gets the status snapshot and can cancel an owned + task; a wrong nonce is dropped; operator-only, never model-facing (invariant 5). + **Difficulty: medium.** + + +### F.5 — `multi_repo_live_smoke` — multi-repo orchestration (F.3) +- **Test:** `crustcore-daemon/src/multirepo.rs::tests::multi_repo_live_smoke`. Seam tag `TODO(P10-multi-repo-live)`. +- **Socket:** the multi-repo CLI startup (`--repo id=/path`) + a simultaneous-task run. +- **CI core (passing):** `classify_repo` (explicit-hint routing, sole-repo default, ambiguous + → `None`, case-insensitive, path-free — repo paths come from config/CLI, never the intent, + invariant 7). The registry already supervises repo-agnostic tasks under the global cap + (invariant 11). +- **Prereq:** two or more real repos bound at startup. +- **Run:** `cargo test -p crustcore-daemon multirepo::tests::multi_repo_live_smoke -- --ignored --nocapture` +- **Success:** a launch routes to the right repo profile; two repos run tasks + simultaneously under the shared global concurrency cap. **Difficulty: medium.** + ### F.6 — `daemon_recover_xproc_live_smoke` — cross-process recovery (F.1) - **Test:** `crustcore-daemon/src/registry.rs::tests::daemon_recover_xproc_live_smoke`. Seam tag `TODO(daemon-recover-xproc-live)`. @@ -481,31 +494,23 @@ cargo test --workspace -- --list --ignored - **Run:** `cargo test -p crustcore-daemon registry::tests::daemon_recover_xproc_live_smoke -- --ignored --nocapture` - **Success:** after a restart the daemon reloads the dump and re-adopts the running tasks (stable ids), resuming supervision. **Difficulty: medium.** - -### F.5 — `multi_repo_live_smoke` — multi-repo orchestration (F.3) -- **Test:** `crustcore-daemon/src/multirepo.rs::tests::multi_repo_live_smoke`. Seam tag `TODO(P10-multi-repo-live)`. -- **Socket:** the multi-repo CLI startup (`--repo id=/path`) + a simultaneous-task run. -- **CI core (passing):** `classify_repo` (explicit-hint routing, sole-repo default, ambiguous - → `None`, case-insensitive, path-free — repo paths come from config/CLI, never the intent, - invariant 7). The registry already supervises repo-agnostic tasks under the global cap - (invariant 11). -- **Prereq:** two or more real repos bound at startup. -- **Run:** `cargo test -p crustcore-daemon multirepo::tests::multi_repo_live_smoke -- --ignored --nocapture` -- **Success:** a launch routes to the right repo profile; two repos run tasks - simultaneously under the shared global concurrency cap. **Difficulty: medium.** - -### F.4 — `daemon_admin_live_socket_smoke` — admin socket (F.2) -- **Test:** `crustcore-daemon/src/admin.rs::tests::daemon_admin_live_socket_smoke`. Seam tag `TODO(daemon-admin-live)`. -- **Socket:** the real admin `UnixListener` (mode 0600) / TCP-loopback fallback + a - length-prefixed framed query/cancel round-trip. -- **CI core (passing):** `parse_admin_command`, `frame`/`try_deframe` (bounded, hostile - length rejected), `authenticate` (constant-length nonce compare), and `dispatch_admin` - (owner-scoped cancel/kill — the same gate as Telegram, invariant 12; status snapshot). -- **Prereq:** a bound socket + the startup nonce file (`~/.crustcore/admin.nonce`, 0600). -- **Run:** `cargo test -p crustcore-daemon admin::tests::daemon_admin_live_socket_smoke -- --ignored --nocapture` -- **Success:** an authenticated client gets the status snapshot and can cancel an owned - task; a wrong nonce is dropped; operator-only, never model-facing (invariant 5). - **Difficulty: medium.** + + +### F.7 — `slack_live_round_trip_smoke` — Slack control plane (E.3) +- **Test:** `crustcore-daemon/src/slack.rs::tests::slack_live_round_trip_smoke`, feature `live`. Seam tag `TODO(slack-live)`. +- **Socket:** the Slack Bot API HTTP client + the Events-API / Socket-Mode listener + (the spawned `crustcore-net` helper, the Telegram pattern). +- **CI core (passing):** the `SlackSignature` request verifier (HMAC-SHA256 over + `v0:{ts}:{body}` + bounded inputs + timestamp-freshness replay defense, constant-time), + `SlackAllowlist` (per-workspace/channel, **deny-all empty**), + `normalize_message` (plain → `QueuedTurn`, `!` → `Steer`, `/` → `Command`, reaction → + `ApprovalCallback` — the **same `RuntimeEvent` stream** as Telegram, invariants 8/16), + and `render_to_slack` (redacts every secret before the message leaves — invariants 1–3). +- **Prereq:** a real Slack workspace + bot token (broker) + signing secret. +- **Run:** `cargo test -p crustcore-daemon --features live slack::tests::slack_live_round_trip_smoke -- --ignored --nocapture` +- **Success:** an allowed-channel message dispatches like Telegram; a reaction resolves an + approval via its nonce; outbound text is redacted; Slack is opt-in (operator-bound via + CLI, never the default — invariant 15). **Difficulty: medium.** ---