Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 48 additions & 27 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.**
Expand Down
16 changes: 11 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
&nbsp;![nano size](https://img.shields.io/badge/nano-478.7_KiB-2ea44f)
&nbsp;![full size](https://img.shields.io/badge/full-576.7_KiB-2ea44f)
&nbsp;![tests](https://img.shields.io/badge/tests-834_passing-2ea44f)
&nbsp;![tests](https://img.shields.io/badge/tests-1120_passing-2ea44f)
&nbsp;![invariants](https://img.shields.io/badge/invariants-20_enforced-1f6feb)
&nbsp;![kernel](https://img.shields.io/badge/kernel-std--only_%C2%B7_no_async%2Fnet%2Fdb-8957e5)
&nbsp;![rust](https://img.shields.io/badge/rust-1.85+-orange)
Expand Down Expand Up @@ -189,7 +189,7 @@ Full design: **[docs/architecture.md](./docs/architecture.md)** &nbsp;·&nbsp; 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 |

---

Expand Down
11 changes: 0 additions & 11 deletions crates/crustcore-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
58 changes: 31 additions & 27 deletions crates/crustcore-daemon/src/slack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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::<u64>() else {
Expand All @@ -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());
Expand All @@ -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)]
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 2 additions & 33 deletions crates/crustcore-daemon/src/webhook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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<u8> {
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 {
Expand Down
11 changes: 2 additions & 9 deletions crates/crustcore-dev/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -98,15 +100,6 @@ fn decode_hex(s: &str) -> Option<Vec<u8>> {
Some(out)
}

fn hex_val(c: u8) -> Option<u8> {
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 {
Expand Down
11 changes: 1 addition & 10 deletions crates/crustcore-dev/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -439,15 +439,6 @@ fn decode_hex32(s: &str) -> Option<[u8; 32]> {
Some(out)
}

fn hex_val(c: u8) -> Option<u8> {
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)
Expand Down
13 changes: 3 additions & 10 deletions crates/crustcore-receipts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading