diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f2095..b06a737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,19 @@ agent/PR/role/size/invariant audit trail. ### Added +- **Slack runtime control plane (roadmap-v0.6 E.3).** Added `crustcore_daemon::slack`: + a pure `SlackAllowlist` (per-workspace/per-channel, **deny-all empty** — invariants 5, + 15), `normalize_message(msg, allowlist) → Option` that maps a Slack event + onto the **same `RuntimeEvent` stream as Telegram** (plain → `QueuedTurn`, `!` → `Steer`, + `/` → `Command`, reaction → `ApprovalCallback` with the same nonce format) — so Slack + routes through the same policy gates, **not a parallel ungoverned surface** (invariants + 8, 16) — and `render_to_slack` which **redacts every secret** before any message leaves + (invariants 1–3). Text is untrusted + bounded (invariants 7, 11); approvals come from + Slack users gated by the allowlist, never model output (invariant 4); Slack is opt-in, + operator-bound via CLI (invariant 15). The Slack Bot API + Events-API/Socket-Mode + listener is the `live`-gated `#[ignore]`d `slack_live_round_trip_smoke` + (`TODO(slack-live)`), in runbook §F.7. 5 new tests; daemon-only; **zero nano impact**. + - **Cross-process task lease recovery (roadmap-v0.6 F.1).** Added `TaskRegistry::snapshot_all` + `adopt_from_snapshot` (with `TaskSnapshot` / `AdoptError`) — the recovery half of invariant 12. A restarting daemon re-adopts its running tasks @@ -360,6 +373,7 @@ agent/PR/role/size/invariant audit trail. | Date | Phase/Task | Change | PR / Branch | Agent / Role | Nano Δ | Invariants | | --- | --- | --- | --- | --- | --- | --- | +| 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 | | 2026-06-28 | v0.6/F.3 | `multirepo::classify_repo` (explicit-hint → sole-repo default → ambiguous asks) + `RepoBinding`; intent matches operator keywords only, never supplies a path | `claude/v06-f3-multirepo` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 7, 11; repo paths from config/CLI, shared global cap | | 2026-06-28 | v0.6/F.2 | Admin socket protocol: parse/frame(bounded)/nonce-auth + `dispatch_admin` (status/detail/cancel/kill) feeding the same owner-scoped path as Telegram; live listener `#[ignore]`d | `claude/v06-f2-adminsock` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 5, 11, 12; operator-only, owner-scoped cancel/kill | diff --git a/crates/crustcore-daemon/src/lib.rs b/crates/crustcore-daemon/src/lib.rs index 1dd5187..cb7c2af 100644 --- a/crates/crustcore-daemon/src/lib.rs +++ b/crates/crustcore-daemon/src/lib.rs @@ -80,6 +80,10 @@ pub mod runtime; /// a verified patch always outranks an unverified one (scoring never bypasses the verifier). pub mod score; pub mod selfimprove; +/// Slack runtime control plane (roadmap-v0.6 E.3): a pure `SlackAllowlist` + +/// `normalize_message` that mirror Telegram — feeding the same `RuntimeEvent` stream +/// through the same policy gates (opt-in, deny-all empty, redacted, nonce approvals). +pub mod slack; pub mod supervisor; /// Chat-launched verified tasks (the "do the work" half of the front door). Behind the /// `live` feature — it reuses the worktree/sandbox/verifier flow (non-nano deps). diff --git a/crates/crustcore-daemon/src/slack.rs b/crates/crustcore-daemon/src/slack.rs new file mode 100644 index 0000000..9d7c5f0 --- /dev/null +++ b/crates/crustcore-daemon/src/slack.rs @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Slack runtime control plane (roadmap-v0.6 E.3). +//! +//! Slack sits **alongside** Telegram + the cockpit, mirroring the allowlist, redaction, +//! and approval-nonce model and feeding the *same* [`RuntimeEvent`] stream the daemon +//! already dispatches — so it routes through the same policy gates, **not a parallel +//! ungoverned surface** (invariants 8, 16). It is **opt-in, never the default**: the +//! operator binds a workspace/channel via the CLI (not via DM); an empty allowlist denies +//! everything (invariants 5, 15). +//! +//! This module is the **pure core**: the [`SlackSignature`] request verifier (the "is +//! this really from Slack" gate — HMAC-SHA256 over `v0:{ts}:{body}` + a timestamp-freshness +//! replay defense, mirroring the GitHub webhook hardening), the allowlist, the inbound +//! `normalize_message`, and the redacted outbound render. The live Slack Bot API HTTP +//! client + the Events-API / Socket-Mode listener that *delivers* a verified request are +//! the `live` seam (the Telegram pattern), `#[ignore]`d. + +use std::collections::{BTreeMap, BTreeSet}; + +use crustcore_secrets::Redactor; +use crustcore_types::{hmac_sha256, BoundedText}; + +use crate::telegram::{CallbackData, Command, RuntimeEvent}; + +/// Max bytes of inbound Slack message text kept (bounded untrusted input; invariant 11). +pub const MAX_SLACK_TEXT: usize = 4096; +/// Max bytes of an outbound Slack message (bounded). +pub const MAX_SLACK_OUTBOUND: usize = 8192; + +/// A **per-workspace, per-channel** allowlist. **Deny-all when empty** (invariant 5): no +/// workspace or channel is authorized until the operator binds it (via CLI). Nesting +/// scopes a channel to its workspace so an id collision across workspaces can't leak. +#[derive(Debug, Default, Clone)] +pub struct SlackAllowlist { + workspaces: BTreeMap>, +} + +impl SlackAllowlist { + /// An empty allowlist — denies everything until channels are bound. + #[must_use] + pub fn new() -> Self { + SlackAllowlist::default() + } + + /// Binds `channel` in `workspace` (operator setup; not driven by message content). + pub fn allow(&mut self, workspace: impl Into, channel: impl Into) { + self.workspaces + .entry(workspace.into()) + .or_default() + .insert(channel.into()); + } + + /// Whether `(workspace, channel)` is authorized. An unknown workspace is rejected. + #[must_use] + pub fn is_allowed(&self, workspace: &str, channel: &str) -> bool { + self.workspaces + .get(workspace) + .is_some_and(|chans| chans.contains(channel)) + } +} + +/// An approval reaction (an emoji on a message carrying an approval nonce). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SlackReaction { + /// The callback/nonce payload bound to the reacted-to message. + pub callback: String, +} + +/// An inbound Slack event, already extracted from the API payload. The `text` is +/// **untrusted** (invariant 7); the workspace/channel/user are routing scope. +#[derive(Debug, Clone)] +pub struct SlackMessage { + /// Workspace (team) id. + pub workspace: String, + /// Channel id. + pub channel: String, + /// Author id (for the audit trail; authority is the allowlist, not the author). + pub user: String, + /// Message text (untrusted). + pub text: String, + /// Set when this is an approval reaction rather than a message. + pub reaction: Option, +} + +/// Normalizes a Slack event into the **same** [`RuntimeEvent`] the daemon dispatches for +/// Telegram (invariants 8, 16): +/// - denied workspace/channel → `None` (no event ever forms); +/// - a reaction → `ApprovalCallback` (same nonce format, parsed by `CallbackData::parse`); +/// - `/slash` → `Command`; `!`-prefixed → `Steer`; plain text → `QueuedTurn`. +/// +/// Text is bounded (invariant 11) and never interpreted as a prompt here — it is carried +/// as data for the daemon's existing dispatch + redaction path. Approvals come from +/// Slack *users* gated by the allowlist, never from model output (invariants 4, 5). +#[must_use] +pub fn normalize_message(msg: &SlackMessage, allowlist: &SlackAllowlist) -> Option { + if !allowlist.is_allowed(&msg.workspace, &msg.channel) { + return None; // deny-all / unknown workspace / unbound channel + } + if let Some(reaction) = &msg.reaction { + return CallbackData::parse(&reaction.callback).map(RuntimeEvent::ApprovalCallback); + } + let text = msg.text.trim(); + if text.is_empty() { + return None; + } + if text.starts_with('/') { + return Some(RuntimeEvent::Command(Command::parse(text))); + } + if let Some(steer) = text.strip_prefix('!') { + return Some(RuntimeEvent::Steer(BoundedText::truncated( + steer.trim(), + MAX_SLACK_TEXT, + ))); + } + Some(RuntimeEvent::QueuedTurn(BoundedText::truncated( + text, + MAX_SLACK_TEXT, + ))) +} + +/// Renders outbound text for Slack: **redacts every known secret** through the broker's +/// [`Redactor`] before it crosses the channel boundary (invariants 1–3), then bounds it. +/// The only text that ever reaches Slack is redacted + bounded. +#[must_use] +pub fn render_to_slack(text: &str, redactor: &Redactor) -> String { + let redacted = redactor.redact(text); + redacted.chars().take(MAX_SLACK_OUTBOUND).collect() +} + +/// Max accepted clock skew (seconds) for a Slack request timestamp — Slack's own +/// recommendation. A request older (or more forward-dated) than this is rejected as a +/// possible replay even if its signature is valid. +pub const SLACK_MAX_SKEW_SECS: u64 = 300; + +/// 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. +/// +/// Slack signs every request as `X-Slack-Signature: v0=HMAC_SHA256(signing_secret, +/// "v0:{timestamp}:{body}")`, with `X-Slack-Request-Timestamp: {timestamp}`. This mirrors +/// the GitHub webhook hardening: a forged or stale request **never forms a +/// [`SlackMessage`]**, so it can never reach the dispatch path (invariants 7, 8, 16). The +/// compare is constant-time (no timing oracle) and the signing secret is never logged or +/// returned. Std-only + CI-tested with signed fixtures; the only live inch is the HTTP +/// listener that *delivers* the request (the `live` Events-API/Socket-Mode seam). +pub struct SlackSignature { + secret: Vec, +} + +impl SlackSignature { + /// Bind the verifier to a workspace's signing secret. The secret is held only to + /// compute the HMAC; it is never serialized, logged, or returned. + #[must_use] + pub fn new(signing_secret: &[u8]) -> Self { + SlackSignature { + secret: signing_secret.to_vec(), + } + } + + /// Returns `true` iff `signature` (the `X-Slack-Signature` header, `v0=`) is a + /// valid HMAC over `v0:{timestamp}:{body}` **and** the request is fresh + /// (`|now_unix_secs - timestamp| <= `[`SLACK_MAX_SKEW_SECS`]). `timestamp` is the raw + /// `X-Slack-Request-Timestamp` header value (used verbatim in the basestring, exactly + /// as Slack signs it; parsed separately only for the freshness window). Untrusted + /// input — any malformed field returns `false` (fail-closed). + #[must_use] + pub fn verify( + &self, + signature: &str, + timestamp: &str, + body: &[u8], + now_unix_secs: u64, + ) -> bool { + // 1. Freshness: a captured-and-replayed (or forward-dated) request is rejected even + // with a valid signature. + let Ok(ts) = timestamp.parse::() else { + return false; + }; + if now_unix_secs.abs_diff(ts) > SLACK_MAX_SKEW_SECS { + return false; + } + // 2. Signature: HMAC over the RAW basestring `v0:{timestamp}:{body}` (timestamp used + // verbatim, matching how Slack signs it). + let Some(hex) = signature.strip_prefix("v0=") else { + return false; + }; + let Some(provided) = slack_hex32(hex) else { + return false; + }; + let mut base = Vec::with_capacity(3 + timestamp.len() + 1 + body.len()); + base.extend_from_slice(b"v0:"); + base.extend_from_slice(timestamp.as_bytes()); + 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; + } + 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)] +mod tests { + use super::*; + + fn allow_one() -> SlackAllowlist { + let mut a = SlackAllowlist::new(); + a.allow("W1", "C1"); + a + } + + fn msg(workspace: &str, channel: &str, text: &str) -> SlackMessage { + SlackMessage { + workspace: workspace.to_string(), + channel: channel.to_string(), + user: "U1".to_string(), + text: text.to_string(), + reaction: None, + } + } + + #[test] + fn an_empty_allowlist_denies_everything() { + let deny = SlackAllowlist::new(); + assert!(normalize_message(&msg("W1", "C1", "hello"), &deny).is_none()); + } + + #[test] + fn allowed_vs_blocked_channel_and_unknown_workspace() { + let a = allow_one(); + assert!(normalize_message(&msg("W1", "C1", "hello"), &a).is_some()); // allowed + assert!(normalize_message(&msg("W1", "C2", "hello"), &a).is_none()); // unbound channel + assert!(normalize_message(&msg("W2", "C1", "hello"), &a).is_none()); // unknown workspace + } + + #[test] + fn plain_steer_and_command_normalize_like_telegram() { + let a = allow_one(); + assert!(matches!( + normalize_message(&msg("W1", "C1", "do the thing"), &a), + Some(RuntimeEvent::QueuedTurn(_)) + )); + assert!(matches!( + normalize_message(&msg("W1", "C1", "!actually do this instead"), &a), + Some(RuntimeEvent::Steer(_)) + )); + assert!(matches!( + normalize_message(&msg("W1", "C1", "/status"), &a), + Some(RuntimeEvent::Command(Command::Status)) + )); + } + + #[test] + fn a_reaction_becomes_an_approval_callback_when_the_nonce_parses() { + let a = allow_one(); + // A valid Telegram-format callback should parse; an unparseable one yields no event. + let mut m = msg("W1", "C1", ""); + m.reaction = Some(SlackReaction { + callback: "not-a-valid-callback".to_string(), + }); + // Unparseable nonce → no event (never a spurious approval). + assert!(normalize_message(&m, &a).is_none()); + } + + #[test] + fn outbound_render_redacts_secrets_and_bounds() { + let mut r = Redactor::new(); + r.register("token", b"xoxb-SECRET"); + let out = render_to_slack("posting xoxb-SECRET to the channel", &r); + assert!( + !out.contains("xoxb-SECRET"), + "secret leaked to Slack: {out}" + ); + assert!(out.len() <= MAX_SLACK_OUTBOUND); + } + + // ----- E.3: Slack request signature + freshness gate ----- + + /// Lowercase-hex encode (test-only — builds a signature the way Slack would). + fn hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s + } + + const SIGNING_SECRET: &[u8] = b"8f742231b10e8888abcd99yyyzzz85a5"; + + fn valid_sig(ts: &str, body: &[u8]) -> String { + let mut base = Vec::new(); + base.extend_from_slice(b"v0:"); + base.extend_from_slice(ts.as_bytes()); + base.push(b':'); + base.extend_from_slice(body); + format!("v0={}", hex(&hmac_sha256(SIGNING_SECRET, &base))) + } + + #[test] + fn a_genuine_slack_signature_with_a_fresh_timestamp_verifies() { + let v = SlackSignature::new(SIGNING_SECRET); + let body = br#"{"type":"event_callback"}"#; + let ts = "1700000000"; + // now within the skew window. + assert!(v.verify(&valid_sig(ts, body), ts, body, 1700000010)); + } + + #[test] + fn a_forged_signature_is_rejected() { + let v = SlackSignature::new(SIGNING_SECRET); + let body = br#"{"type":"event_callback"}"#; + let ts = "1700000000"; + // Signed with the WRONG secret → no match. + let mut base = Vec::new(); + base.extend_from_slice(b"v0:"); + base.extend_from_slice(ts.as_bytes()); + base.push(b':'); + base.extend_from_slice(body); + let forged = format!("v0={}", hex(&hmac_sha256(b"not-the-secret", &base))); + assert!(!v.verify(&forged, ts, body, 1700000010)); + } + + #[test] + fn a_stale_or_forward_dated_request_is_rejected_even_if_signed() { + let v = SlackSignature::new(SIGNING_SECRET); + let body = br#"{"type":"event_callback"}"#; + let ts = "1700000000"; + let sig = valid_sig(ts, body); // a genuinely valid signature... + // ...but the request is 10 minutes old (> SLACK_MAX_SKEW_SECS) → replay-rejected. + assert!(!v.verify(&sig, ts, body, 1700000000 + 600)); + // ...and a forward-dated request is likewise rejected. + assert!(!v.verify(&sig, ts, body, 1700000000 - 600)); + } + + #[test] + fn malformed_signature_or_timestamp_fails_closed() { + let v = SlackSignature::new(SIGNING_SECRET); + let body = b"{}"; + // Missing `v0=` prefix, bad hex length, non-numeric timestamp — all reject. + assert!(!v.verify("deadbeef", "1700000000", body, 1700000000)); + assert!(!v.verify("v0=zz", "1700000000", body, 1700000000)); + assert!(!v.verify( + &valid_sig("1700000000", body), + "not-a-number", + body, + 1700000000 + )); + } + + #[test] + fn a_tampered_body_does_not_match_the_signature() { + let v = SlackSignature::new(SIGNING_SECRET); + let ts = "1700000000"; + let sig = valid_sig(ts, b"original body"); + // The signature was for a different body → reject (integrity, not just authenticity). + assert!(!v.verify(&sig, ts, b"tampered body", 1700000005)); + } + + // Live seam: the Slack Bot API HTTP client + Events-API/Socket-Mode listener. + #[cfg(feature = "live")] + #[test] + #[ignore = "live: Slack Bot API + Events-API/Socket-Mode round-trip against a real workspace (TODO(slack-live))"] + fn slack_live_round_trip_smoke() { + // See docs/live-socket-validation.md §F.7. Requires a real Slack workspace + token. + panic!("live seam: run manually with a real Slack workspace (see runbook §F.7)"); + } +} diff --git a/docs/live-socket-validation.md b/docs/live-socket-validation.md index c4a2d07..a1f7b22 100644 --- a/docs/live-socket-validation.md +++ b/docs/live-socket-validation.md @@ -83,6 +83,7 @@ cargo test --workspace -- --list --ignored | `live_get_updates_smoke` | F | `live` | [F.1](#f1) | `RestTelegram` shaping + redaction ✓ | easy (bot token) | | `live_telegram_round_trip_smoke` | F | `live` | [F.2](#f2) | runtime-channel decision logic ✓ | easy (bot token) | | `live_ws_sse_emits_a_snapshot` | F | — | [F.3](#f3) | snapshot serialize + `ws_stream` ✓ | easy (loopback port) | +| `slack_live_round_trip_smoke` | F | `live` | [F.7](#f7) | `SlackAllowlist`/`normalize_message`/render cores ✓ | medium (Slack workspace) | | `daemon_recover_xproc_live_smoke` | F | — | [F.6](#f6) | `snapshot_all`/`adopt_from_snapshot` cores ✓ | medium (restart) | | `multi_repo_live_smoke` | F | — | [F.5](#f5) | `classify_repo` routing core ✓ | medium (multiple repos) | | `daemon_admin_live_socket_smoke` | F | — | [F.4](#f4) | admin parse/frame/auth/dispatch cores ✓ | medium (bound socket) | @@ -452,6 +453,20 @@ 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.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)`.