Skip to content

Latest commit

 

History

History
351 lines (249 loc) · 105 KB

File metadata and controls

351 lines (249 loc) · 105 KB

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

Changed

  • Docs: True up CLAUDE.md + README.md to the current codebase — test count ~1,033/1,237~1,251 (authoritative bun test count; noted the it/test grep undercounts at ~1,120 because it misses test.each expansions and the Gherkin runner), refreshed the module LoC table (server.ts 3820 / lib.ts 2552 / supervisor.ts 1810 / journal.ts 1461), and dropped the stale "thin adapter" label on slack-delivery.ts (738 LoC). Reconciles CLAUDE.mdREADME.mdAGENTS.md. (#257)

[0.12.0] - 2026-06-24

Added

  • File-upload replies are now crash-durable — the crash-safety epic's rich paths are complete (ccsc-o7x.5 part 2 of 2, epic ccsc-o7x). Wires the part-1 building blocks live: executeReply now routes a non-streaming reply with files through executeReplyFileDurablePathdeliverFileReplyDurably (records the text chunks + one obligation per file, delivers in order), and the delivery poller's send branches on obligation.upload to upload files via the new production createFileSendDeps adapter (real filesUploadV2). The adapter's readAndGuard re-runs the outbound exfil guard — assertSendable + assertNoSecretValues on the latin1 content and the filename — on the bytes it actually uploads (read-once, TOCTOU-safe), journaling exfil.block and throwing ExfilBlockedError on a hit; findUploaded dedups by a recorded uploadedFileId (exact) or a (filename, size) thread scan scoped to files shared at/after the obligation's createdAt (returning null, never '') — the delivery-window filter is load-bearing: an older same-name+size file from a previous turn would otherwise falsely dedup and drop the new upload (a loss). The poller's drainOutbox now treats ExfilBlockedError as non-retryable — a blocked file is dead-lettered on the first failure instead of burning maxAttempts (the bytes won't pass on a retry). A blocked file inline still surfaces to the agent (propagates), exactly as the best-effort path did; a files-only reply (empty text) uploads without an empty message. With this, single (.3), chunked (.4), streaming (.6), and file (.5) replies are all durable — the epic's rich paths are done. 14 added tests (createFileSendDeps: read-once guard order, three exfil-block paths, dedup by exact id (not time-scoped) / (filename,size) in-window / stale-older-file-not-matched / no-match / no-thread / size-error, upload + nested-id extraction + no-descriptor; drainOutbox: ExfilBlockedError dead-letters on the first failure). slack-delivery.ts stays 100% line / 100% func; server.ts dispatch + poller wiring is typecheck-covered (consistent with its untested sibling paths). The honest residual stands: Slack file shares carry no app metadata, so dedup is a (filename,size) scan with one narrow double-upload window — a double-deliver of an already-guarded file, never a loss or a gate bypass.

  • Durable file-upload reply building block + design (ccsc-o7x.5, crash-safety epic ccsc-o7x — part 1 of 2; the last deferred rich path). A reply with files uploads each via filesUploadV2 (a separate Slack upload, not chat.postMessage), which ccsc-o7x.3 deferred. Design (ADR-002 addendum, 2026-06-24) records two hard facts and the contract they force: a Slack file-share message cannot carry app metadata (so the text path's metadata-keyed exact-once is impossible — dedup must be a thread scan), and a file's bytes can change between record-time and redelivery (so the exfil guard must run at every upload, not just at record-time). New, tested building blocks in slack-delivery.ts: sendFileObligation (dedup → read+guard → upload, read-once: the injected FileUploadDeps.readAndGuard reads the file once, scans those exact bytes, and returns them, then upload sends that same buffer with no second read — closing a TOCTOU gap where the guarded bytes could differ from the sent bytes; the read+guard re-runs on every call that reaches the upload, inline and poller, so a file that gained a secret since record-time is blocked at redelivery), deliverFileReplyDurably (the files-bearing sibling of deliverChunkedReplyDurably: records all text-chunk + file obligations atomically, delivers in order, marks each delivered/pending/dead, records uploadedFileId after a successful upload for later dedup), and ExfilBlockedError (in the lib.ts kernel, so BOTH the inline path and the poller can classify it as non-retryable without a slack-deliverysupervisor import cycle) the loop treats as non-retryable (a blocked file is marked dead, never uploaded, never retried). The kernel DeliveryObligation (lib.ts) gains additive optional upload + uploadedFileId fields (AGP impact: none — the reply-delivery outbox is not in AGP's reimplemented subset, verified against agent-governance-plane/substrate/UPSTREAM.md). Documented residual: a narrow (filename, size)-scan dedup window (Slack's file API permits no exact key). This part does not wire executeReply/the poller — the production filesUploadV2 adapter + dispatch is part 2 (which also adds the drainOutbox ExfilBlockedError branch now that the error is in the kernel). 11 added tests; slack-delivery.ts stays 100% line / 100% func; all nine gates green.

  • Streaming replies are now crash-durable (ccsc-o7x.6, crash-safety epic ccsc-o7x — second of the deferred rich paths, after chunked ccsc-o7x.4). A streaming reply (stream:true, text over the chunk limit) posts an initial message and grows it via chat.update — which can't be idempotently replayed mid-stream, so ccsc-o7x.3 deferred it. Design (ADR-002 addendum): a stream-finalize obligation carrying the full text. beginDurableStream (new, in slack-delivery.ts) records ONE pending DeliveryObligation whose payload is the complete reply text before the stream starts; executeReplyStreamingPath then resolves it from the streamReply outcome — completedmarkDelivered; a clean in-process failure (failed_mid_stream, gate_rejected_at_start, or an init throw) → markDead (NOT left pending). The dead-on-failure rule is load-bearing: the delivery poller's send does not re-run assertOutboundAllowed, and streamReply can't distinguish a mid-stream gate rejection from a transient Slack error, so a gate-rejected stream must never be auto-redelivered. The new guarantee is exactly the bead's wording: a process crash mid-stream (neither mark runs) leaves the obligation pending, and the boot-drain / poller redelivers the full text as a fresh chat.postMessage (the partial streamed message is orphaned). No lib.ts (AGP-vendored kernel) or poller changes — the full-text obligation is an ordinary single-message one the existing poller already redelivers. Documented residuals: the completed-but-crashed-before-marking window (one un-deduped duplicate, mirroring the pre-idempotency-key text path — the streamed message carries no delivery metadata so findDelivered can't recognise it) and the >40k-char single-message redelivery edge (dead-letters as msg_too_long, but such a reply couldn't have streamed cleanly to begin with). 5 added tests (beginDurableStream: records one full-text pending obligation; markDelivered; markDead with reason; crash-leaves-pending across a restart; DurableUnavailableError records nothing); slack-delivery.ts stays 100% line / 100% func; all nine gates green. File-upload durability (ccsc-o7x.5) is the last deferred rich path.

  • Mention-gated channels are now usable — "mention once, then converse" (epic ccsc-apj). On a requireMention channel a human who has @-mentioned the bot in a thread keeps talking in that thread without re-mentioning (ccsc-apj.1, thread-stickiness keyed by the session thread thread_ts ?? tshuman-only; peer agents must mention every message). Every inbound gate drop now carries a structured dropReason so the journal shows why Claude stayed silent (ccsc-apj.2); isMentioned ignores a <@bot> quoted inside a code block or blockquote via Slack blocks (ccsc-apj.4); the intentional message_changed edited-message drop is documented in ARCHITECTURE.md + THREAT-MODEL.md (ccsc-apj.3); and interaction mode is a first-class /slack-channel:access choice with new channels defaulting to mention-to-engage (ccsc-apj.5). Three operator-controlled modes (ambient / mention-to-engage / per-user allowlist). 35 added tests; nine gates + CodeQL + gitleaks green. (PR #244)

  • Channel-wide circuit breaker for N-cycle peer-bot loops (ccsc-0k7x2, multi-agent hardening epic ccsc-uorlh). The per-(channel, bot) limiter only breaks pairwise loops; a 3+-bot ring (A→B→C→A) keeps each sender under its own cap. A new channel-aggregate counter trips the whole channel's peer-bot delivery when total velocity is runaway-high (drops with rate.channel_cycle; ChannelPolicy.channelCircuitBreaker, default 40 msgs/60s, { count: 0, windowMs: 0 } disables). 7 added tests; nine gates green. (PR #246)

  • Optional per-user session isolation (ccsc-kl410, epic ccsc-uorlh). A channel may set perUserSessions so each sender gets their own bridge session within a shared thread (separate state file, supervisor handle, and ownerId) — SessionKey gains an optional userId that nests the file at sessions/<channel>/<userId>/<thread>.json under the same realpath + component-injection guards as channel/thread. Default off (behavior unchanged); isolates per-thread book-keeping, not Claude's conversation memory. Updates the session-state-machine.md contract. 12 added tests; nine gates green. (PR #248)

  • Slack-observable mutes + rate limits (ccsc-yl6k9, epic ccsc-uorlh). Read-only admin verbs !mute-status (active mutes + remaining TTL) and !rate-limit (effective per-bot + circuit-breaker thresholds), plus a mute auto-expire notice from the reaper. View-only by design — threshold tuning stays terminal-only (access.json) so a Slack message can never loosen a security limit; the read verbs are allowlist-gated but not journaled (reads, not state changes). 13 added tests; nine gates green. (PR #249)

  • Global session backpressure + agents-online view (ccsc-4e9bf, completes epic ccsc-uorlh). An optional maxConcurrentSessions cap (SLACK_MAX_CONCURRENT_SESSIONS) makes the supervisor refuse a new session once live + in-flight activations reach the cap — shedding load before file handles/memory are exhausted — and journals the refusal as a new session.activate_rejected EventKind (reactivating an already-live session is never capped). A read-only !agents admin verb lists the peer bots seen active in the channel in the last 5 minutes (derived from the rate-limit store's per-channel activity). Default: unlimited. 13 added tests; nine gates green.

  • Chunked (multi-message) replies are now durable (ccsc-o7x.4, crash-safety epic ccsc-o7x). Extends the loss-proof reply guarantee from the single-message case (ccsc-o7x.3) to replies that exceed the chunk limit and split into N Slack messages. Design — one obligation per chunk: each chunk becomes its own DeliveryObligation with id <reply.id>:<i>, so it carries a distinct idempotency key (ccsc-reply:<reply.id>:<i>) and a crash mid-way redelivers only the chunks that did not land (per-chunk dedup, never re-posting the prefix). New supervisor.recordTerminalDeliveries(token, replies[]) — the batch sibling of recordTerminalDelivery — appends all N pending obligations and clears the in-flight marker in one atomic write (all-or-nothing, so a crash mid-record never leaves a partially-recorded reply). New slack-delivery.deliverChunkedReplyDurably(deps, reply) records all N before any send, then posts in order: all post → delivered (returns the first chunk's ts); a transient error on chunk i → mark i pending and stop (do not post i+1…), return queued so the poller redelivers i…N-1 in order (a chunk never lands out of order, and the caller must not retry-and-double-post the sent prefix); a non-retryable error → mark i dead and rethrow (the prefix already landed — the honest partial-reply outcome). executeReply now routes any non-streaming, file-free threaded reply through the durable path — one chunk → the single-message path, N chunks → executeReplyChunkedDurablePath (extracted to keep the CRAP score down) — falling back to the direct chunk loop only if the session can't go durable (DurableUnavailableError). The delivery poller is untouched: each chunk is just a normal obligation with a unique key, so pendingDeliveries (array order) + drainOutbox (sequential) already preserve order and dedup per chunk — zero poller changes; lib.ts (the AGP-vendored kernel) is likewise unchanged. File uploads (ccsc-o7x.5) and streaming (ccsc-o7x.6) remain best-effort (they don't enqueue → zero double-send risk), durability deferred with a documented path (ADR-002 addendum, 2026-06-12). 8 added tests (6 deliverChunkedReplyDurably: in-order posting under per-chunk keys, record-all-before-send, transient-stops-and-queues-the-tail, non-retryable-marks-dead-and-rethrows, unactivatable→DurableUnavailableError-records-nothing, N=1 degenerate; 2 recordTerminalDeliveries: atomic batch append + clears marker, fenced rejection writes nothing). slack-delivery.ts 100 % line / 100 % func; supervisor.ts 98.41 %; full suite 1156 pass; all nine gates + CodeQL + gitleaks green.

  • Reply tool is now loss-proof for the common case (ccsc-o7x.3, part 3 of 3 — completes the wiring of crash-safety epic ccsc-o7x). executeReply now routes a single-message text reply (no stream, no files, fits one chunk, has a thread, supervisor up) through the durable path: it records a DeliveryObligation before the chat.postMessage, sends once with the idempotency key stamped into Slack message metadata, and resolves delivered (returns the ts, as before) / queued (transient Slack error → the background poller redelivers idempotently; the agent is told it's queued so it doesn't retry-and-double-post) / error (non-retryable → surfaced to the agent). A crash after terminal-but-before-send now leaves a pending obligation the boot-time drain redelivers, and a transient failure that used to surface as a tool error is now retried to completion. If the session can't go durable (DurableUnavailableError) it falls back to the prior direct send — no regression. Chunked / file / streaming replies are unchanged (they don't enqueue; durability tracked in ccsc-o7x.4/.5/.6). The metadata-stamping is now a single shared site (createReplyPoster in slack-delivery.ts, used by both the poller and the inline send); the durable path is extracted to executeReplyDurablePath to keep executeReply under the CRAP threshold. 1 added test (createReplyPoster stamps metadata + returns ts) atop the deliverReplyDurably suite from part 2; full suite 1133 pass; slack-delivery.ts 100 % line; all nine gates green. The epic's core "loss-proof reply" guarantee is now live in production for the common reply shape.

  • Durable reply-delivery building block + design (ccsc-o7x.3, part 2 of 3 — crash-safety epic ccsc-o7x). An ADR-002 addendum records the reply-delivery contract for CCSC's inverted architecture (Claude is the host; a reply is a synchronous reply tool call, so there's no turn-terminal): Option A — "safety-net behind the reply tool." Scope is the single-message text reply (no stream, no files, within one chunk, has a thread) — the case where one obligation = one Slack message so the idempotency key dedups exactly; chunked / file / streaming replies keep today's best-effort behavior and do not enqueue (zero double-send risk), with durability deferred to new beads ccsc-o7x.4/.5/.6. New deliverReplyDurably(deps, reply) in slack-delivery.ts is the tested building block: it records a pending obligation before the send (crash-before-send safe), attempts one inline send, and resolves delivered (mark + return ts) / queued (transient → leave pending, poller redelivers idempotently, caller must not retry) / rethrow (non-retryable → mark dead with the error recorded); a DurableUnavailableError (session unactivatable / no lease, before anything is recorded) signals the caller to fall back to a direct send. Obligation-state marking is best-effort (a fenced-write failure leaves the poller to reconcile from disk). This part does not yet touch executeReply — wiring it into the reply path is part 3 (the final, isolated change). 5 tests (success-marks-delivered-returns-ts, records-before-send, transient→queued-stays-pending, non-retryable→dead+rethrow, unactivatable→DurableUnavailableError-records-nothing). slack-delivery.ts 100 % line / 99 % function coverage; depcruise clean at 18 modules.

  • Outbox delivery poller wired into the runtime (ccsc-o7x.3, part 1 of 2 — crash-safety epic ccsc-o7x). Activates the reply-delivery machinery (ccsc-o7x.2.1.2.3) in production: a deliveryTimer tick calls supervisor.drainOutbox() on an interval (SLACK_DELIVERY_POLL_MS, default 15 s), with a boot-time drain that recovers obligations a prior process left pending (crash recovery for replies) and a shutdown clear before the supervisor drain — mirroring the idle-reaper timer exactly (unref'd, cleared first). New side-effect-free sibling module slack-delivery.ts holds createDeliverySendDeps(client) — the Slack-facing glue binding the vendored idempotency logic (makeIdempotentSend / deliveryIdempotencyKey in lib.ts) to a real WebClient: findDelivered scans the thread (conversations.replies + include_all_metadata) for our own prior post by its stamped key (so an ack-loss redelivery is a no-op), and post sends with the key in Slack message metadata. Kept as a sibling module (not inline in server.ts) so it unit-tests against a faked WebClient without triggering server.ts's module-load side effects; no Slack-SDK code crosses into the vendored kernel. This part does not touch the reply tool path — executeReply still posts as before; flipping it to record durable obligations is part 2 (the focused, security-sensitive change). 6 tests (createDeliverySendDeps: post-stamps-metadata, findDelivered hit / miss / empty-thread-short-circuit; end-to-end drainOutbox × real adapter: posts-once-and-marks-delivered, ack-loss-recovery dedups without re-posting). slack-delivery.ts 100 % line + function coverage; depcruise clean at 18 modules.

  • ROADMAP.md — vision, current focus, and explicit Non-Goals (ccsc-9jk). States CCSC's narrow charter as a single-process governance substrate and, in a prominent Non-Goals section, names what is out of scope by design — chief among them multi-process / multi-session / multi-runtime orchestration, which belongs in the companion agent-governance-plane (AGP), not here. Linked from the README Links line. Motivated by an unsolicited multi-process re-architecture PR: the durable fix for off-roadmap drive-bys is to publish the scope so they self-deflect before code is written.

Changed

  • Greptile reviewer config tuned to the fullest (ccsc-9xl). .greptile/config.json gains triggerOnUpdates (re-review on every push), fixWithAI (one-click AI fix), confidenceScoreSection + sequenceDiagramSection (the schema-correct { "included": true } object form — confirmed against Greptile's config-reference, not a bare boolean), and ignorePatterns (a gitignore-style string skipping bun.lock, node_modules/, the vendored .audit-harness/, and minified bundles). Strictness 3, commentTypes ["logic","syntax"], and the four structured security-invariant rules are unchanged; statusCheck stays off — Greptile is advisory, the nine-gate CI is the deterministic merge gate. Config-only; no source or quality-gate changes.

  • The "no silent gaps" journal invariant now binds production dispatch (ccsc-175). ccsc-1iw.2 guarantees every policy decision is journaled with no silent gaps, but it could only assert that against a test-local route→kind switch: server.ts wrote the policy.allow / policy.require / policy.approved events as inline object literals and runs main() (plus top-level Slack-client construction) on import, so a test could not import the module to drive those exact writes. If server.ts and the test-local map drifted, the audit gap would be invisible. The event source is now extracted into policy-dispatch.ts (already the side-effect-free home of the ccsc-06s deny helpers): pure builders buildPolicyAllowEvent / buildPolicyRequireEvent / buildPolicyApprovedEvent that server.ts calls, plus an exhaustive permissionRouteJournalEvents(route, ctx) dispatcher with a never-guard — a new PermissionRoute variant that forgets its audit record now fails the production build, not just a test. The ccsc-1iw.2 block re-anchors to the production permissionRouteJournalKinds. The deny pair stays on recordPolicyDenyToJournal (awaited + resilient before the MCP deny notification, ccsc-06s) — untouched — with a new consistency test pinning the dispatcher's deny arm to that helper so the two execution paths cannot drift. Type-only import of PermissionRoute from the vendored lib.ts kernel (no kernel mutation). +8 tests (1156→1164); policy-dispatch.ts 100% func coverage; all nine gates + CodeQL + gitleaks green.

  • CONTRIBUTING.md — issue-first rule keyed on scope, not identity (ccsc-9jk). Strengthens the soft "open an issue first" note into a clear rule: small fixes can go straight to a PR, but anything larger should start as an issue confirming it's on-roadmap; large or architectural PRs opened with no prior discussion may be closed unmerged regardless of quality; and PRs must complete the template (description + Security-impact section) before review. Adds a "read ROADMAP.md Non-Goals first" pointer up top. Docs-only; no code or gate changes.

  • Idempotent redelivery — a replayed send after a lost ack never double-posts (ccsc-o7x.2.3, crash-safety epic ccsc-o7x — completes sub-epic 1-B). Closes the one gap the delivery poller's fencing lease can't: the ambiguous failure where a chat.postMessage lands at Slack but the ack is lost before the obligation is marked delivered, so the next pass would re-post the reply. New pure helpers in lib.ts (additive — vendored kernel): deliveryIdempotencyKey(ob) derives a deterministic ccsc-reply:<id> key from the obligation's stable id (same obligation → same key, across restarts), and makeIdempotentSend(deps) is a pure combinator that wraps a raw Slack post — before posting it asks deps.findDelivered(channel, thread, key) whether a message bearing that key already exists in the thread; if so the send is a no-op, otherwise it posts under the key (stamped into Slack message metadata, event_type ccsc_reply_delivery via the new DELIVERY_METADATA_EVENT_TYPE). The delivery poller (drainOutbox, ccsc-o7x.2.2) consumes the wrapped send unchanged — idempotency is a property of the send, not the poll loop. Result: exactly-once visible delivery = lease (in-process superseded-owner race) + idempotency key (cross-restart ack-loss race). lib.ts imports no Slack SDK; the production findDelivered (scan conversations.replies for the delivery metadata) and post (chat.postMessage with the key stamped) are injected from server.ts — that adapter + the drainOutbox timer wiring are the remaining production integration (the logic + contracts land here). 7 unit tests (key determinism + id-derivation + metadata-marker; first-post-under-key, replay-is-no-op, at-most-once under simulated ack-loss at the combinator level; and an end-to-end drainOutbox × makeIdempotentSend test proving ack-loss on the first post yields exactly one visible message with the obligation delivered). Sub-epic 1-B (ccsc-o7x.2) is now complete — outbox record (2.1) + leased poller (2.2) + idempotent redelivery (2.3). lib.ts stays at 100% line coverage.

  • Leased delivery poller — retries retryable Slack errors with backoff, dead-letters permanent ones (ccsc-o7x.2.2, crash-safety epic ccsc-o7x — second of sub-epic 1-B). The consumer side of the ccsc-o7x.2.1 outbox: supervisor.drainOutbox(send, opts?) makes one pass over every pending DeliveryObligation (pendingDeliveries()), performs the real Slack send via an injected send, and resolves each obligation in one fenced write — replacing the old stderr-and-swallow on a failed chat.postMessage. successstate: 'delivered', attempts incremented; retryable error (rate limiting, transient 5xx, network) is retried in-pass with exponential backoff up to a cap (maxAttempts, default DEFAULT_MAX_DELIVERY_ATTEMPTS = 5), then dead-lettered if still failing — bounded, never an infinite loop; non-retryable error (channel gone, bad auth, payload malformed) → state: 'dead' immediately with the Slack error code recorded in a new additive optional DeliveryObligation.lastError (a dead-letter is never a silent black hole). The classification + backoff are pure functions added to lib.tsclassifyDeliveryError, NON_RETRYABLE_SLACK_ERRORS, extractSlackErrorCode (reads err.data.error then err.code, no Slack-SDK import), computeBackoffMs (deterministic exponential, capped) — added additively because lib.ts is vendored by AGP (ADR 009); the send loop + state transitions live in supervisor.ts (outside the vendored kernel). Lease discipline — no double-send: each obligation is delivered under the session's fencing lease (ccsc-o7x.1.1), re-checked (heartbeat) immediately before each send and before the persist; a superseded lease makes the poller yield without sending/committing and leave the obligation pending for the live owner — and that benign yield does not quarantine the session (which would lock out the new owner). The residual sent-but-not-marked crash-window duplicate is closed by the idempotency key in ccsc-o7x.2.3; the lease covers the in-process superseded-owner race. The poller touches only the outbox — never audit.log, never the projection (new § "The delivery poller" in audit-journal-architecture.md). Production timer-wiring of drainOutbox into server.ts lands after ccsc-o7x.2.3 so redelivery is idempotent before the loop runs live. 25 unit tests (pure helpers: 3 classification + 3 extraction + 3 backoff; poller: empty-outbox, delivered + cleared-from-pending, non-retryable immediate dead-letter, retryable→cap→dead-letter with the backoff schedule asserted, retryable→recovers→delivered, default cap, lease-superseded-mid-retry yields without a second send, lease-superseded-before-persist yields without committing, multi-session independence, corrupt-file skip, idempotent second pass). supervisor.ts 98.33% line / 99.84% func, lib.ts 100% line.

  • Reply-delivery outbox — record a durable obligation at turn-terminal (ccsc-o7x.2.1, crash-safety epic ccsc-o7x — first of sub-epic 1-B). Replaces fire-and-forget replies with a transactional outbox: new DeliveryObligation (id + channel + thread + payload + attempts + state + createdAt) persisted on the Session file in an additive optional outbox array. handle.recordTerminalDelivery(token, { id, channel, thread, payload }) writes a fresh pending obligation and clears the in-flight-turn marker in one atomic save (fenced by the lease token), so "turn done, reply owed" is a single durable fact recorded before the Slack send — a crash after terminal-but-before-send leaves a pending obligation the poller can honor. supervisor.pendingDeliveries() is the read side: it scans every session file and returns all pending obligations across the population (the delivery poller's input, ccsc-o7x.2.2), best-effort skipping unreadable files. The outbox is a separate sink from the audit journal/projection — it is authoritative for delivery, the journal for what happened; the obligation is never written to audit.log and the projection is never made authoritative for delivery (new audit-journal-architecture.md § "The reply-delivery outbox is NOT the audit projection" states the boundary). The send (consuming the obligation) + retry/dead-letter is ccsc-o7x.2.2; idempotent redelivery is ccsc-o7x.2.3. 8 unit tests (obligation stamped + persisted; atomic-with-terminal-marker clears inFlightTurn + appends in one write; fenced rejection writes nothing; accumulation; pendingDeliveries across sessions; crash → fresh-supervisor still sees pending; non-pending + clean sessions excluded; empty dir → []). supervisor.ts stays at 100% line + function coverage.

  • Lease-loss routes a live turn into quarantine (ccsc-o7x.1.3, crash-safety epic ccsc-o7x — completes sub-epic 1-A). A fenced handle.update(fn, fenceToken) that fails — the owner's token was superseded by a newer owner, or its lease heartbeat lapsed past the TTL — is now treated as lease loss, not a transient retry: the handle is driven into the existing quarantined terminal (shared quarantineSelf helper, also adopted by the save-failure path) and removed from the supervisor's active set, so the turn performs no further tool calls or sends and a subsequent activate() rejects until a human clearQuarantines it. fenceToken is the owner's own held lease token, so a mismatch is a real loss rather than a stray caller; a healthy fenced write (live token, fresh lease) is unaffected, and the idle reaper leaves quarantined sessions alone. Closes the gap where a turn that lost ownership could keep acting (split-brain risk). 6 unit tests (quarantine-on-superseded-token, quarantine-on-lapsed-heartbeat, no-further-work-after-quarantine, excluded-from-active-set + clear-and-reactivate, reaper-skips-quarantined, healthy-write-does-not-quarantine). supervisor.ts stays at 100% line + function coverage. Sub-epic 1-A (ccsc-o7x.1) is now complete — lease (1.1) + crash-durable recovery (1.2) + lease-loss quarantine (1.3).

  • Boot-time crash-recovery sweep — recoverOnStartup() (ccsc-o7x.1.2, crash-safety epic ccsc-o7x). Makes the fencing lease crash-durable and recovers cleanly after a process death. New optional inFlightTurn marker (owner + token + startedAt + heartbeatAt) on the persisted Session file (additive — existing files load unchanged); handle.recordTurnStart(token) / recordTurnEnd() write and clear it via the atomic save path (fenced by the lease token). On boot the supervisor's recoverOnStartup() reads every session file and, per persisted marker, classifies it with the pure classifyRecovery(turn, now, ttlMs): a lapsed heartbeat (owner provably dead) is requeued — marker cleared, session left re-activatable, session.recovery.requeued journaled; a still-fresh heartbeat (a second live owner on the same state dir cannot be ruled out) or an unreadable file is orphaned into quarantine — activate() then rejects until a human clears it, session.recovery.orphaned journaled (fail closed). The sweep also seeds the monotonic lease-token counter above every persisted token, so a restarted process never re-issues a token a crashed owner held (the durable half of ccsc-o7x.1.1's fence). Two new EventKinds (session.recovery.requeued / session.recovery.orphaned). supervisor.ts is outside AGP's vendored kernel — zero re-vendor impact. 28 unit tests (pure classifyRecovery boundary; the sweep's clean / requeue / orphan / unreadable / token-seed / multi-session paths; and recordTurnStart/recordTurnEnd incl. a record→crash→fresh-supervisor-sweep round-trip). supervisor.ts + journal.ts stay at 100% line + function coverage.

  • Per-turn fencing lease + heartbeat on the session supervisor (ccsc-o7x.1.1, first of the crash-safety epic ccsc-o7x). Every active SessionHandle now holds a Lease (monotonic token + owner + heartbeatAt) acquired right after it goes active. New pure helpers in supervisor.tsisLeaseStale, heartbeatLease, resolveLeaseTtlMs (SLACK_SESSION_LEASE_TTL_MS, default 30s) — plus handle.heartbeat(token) (renews iff the token is the current owner's) and an additive optional fenceToken arg on handle.update(fn, fenceToken?): a fenced write is rejected, without calling fn or persisting, when no live lease is held, the token has been superseded by a newer owner, or the lease's heartbeat has lapsed past the TTL — so a resurrected old owner can't clobber a turn a new owner has taken over. The token comes from a process-monotonic counter; crash-durable monotonicity across a restart is ccsc-o7x.1.2 (the recovery sweep) and routing a lapsed lease into the quarantine terminal is ccsc-o7x.1.3. Purely additive — the on-disk session file stays the source of truth, unfenced update(fn) is unchanged, and supervisor.ts is outside AGP's vendored kernel (zero re-vendor impact). 19 unit tests (the pure helpers + supervisor integration: lease recorded on activation, monotonic tokens across owners, heartbeat renewal vs superseded-token no-op, and fenced-write rejection on superseded token / lapsed heartbeat / renewed-heartbeat recovery / unfenced backward-compat). supervisor.ts stays at 100% line + function coverage.

Security

  • Bumped tsx 4.21.0→4.22.4 to clear an esbuild RCE advisory (ccsc-dqu). GHSA-gv7w-rqvm-qjhr (high): esbuild >=0.17.0 <0.28.1 ships the Deno install module without binary-integrity verification, enabling RCE via a malicious NPM_CONFIG_REGISTRY. CCSC pulled esbuild@0.27.7 transitively through the tsx devDependency, which failed the CI bun audit --audit-level=high gate on every PR repo-wide. tsx@4.22.4 (within the existing ^4.21.0 range) pins esbuild ~0.28.0 → resolves the fixed 0.28.1. Runtime exposure was nil — tsx/esbuild are build-time only (the npx tsx server.ts Node fallback path); CCSC never invokes esbuild's Deno installer. bun audit → 0 high vulnerabilities; typecheck + 1156-test suite green.

[0.11.0] - 2026-06-03

Added

  • Inbound secret-value scrub — a token can never surface in a tool result returned to the agent (ccsc-z0n.2, token-firewall epic ccsc-z0n). Completes the epic. Finding that reshaped this bead: ADR-002 §1's placeholder-swap assumes the agent process holds the credential, but CCSC's architecture is an MCP-stdio split — the Claude session spawns the bridge as a separate subprocess, the tokens live only in the bridge process, and they flow only into Slack-bound sinks (WebClient, SocketModeClient, the download_attachment Authorization header), never into a tool result. The agent literally cannot read the token, so a placeholder-injection layer would be redundant. Instead this bead delivers (a) the runtime defense-in-depth backstop: new pure redactSecretValues(text, placeholders) + buildSecretPlaceholderMap(resolve) in lib.ts, wired into a scrubToolResult over the single tool-dispatch return chokepoint in server.ts — if a future tool or refactor ever placed a live secret value in a result, it is swapped for that secret's placeholder ({{CCSC_SECRET:<name>}}, from ccsc-z0n.1) before the agent reads it, and the near-miss is journaled as exfil.block; and (b) a THREAT-MODEL.md T4 update documenting that CCSC achieves the credential-placeholder-swap goal structurally. This is the inbound (tool-result → agent) complement of ccsc-z0n.3's outbound (agent → Slack) guard, reusing the same SECRET_DECLARATIONS set. 11 unit tests cover the value → placeholder map (incl. the round-trip back to the declared name), replace-all + multi-value + count semantics, clean / empty-map / empty-text / empty-key no-ops, and the buildSecretPlaceholderMapredactSecretValues seam.
  • Value-exfiltration guard — assertNoSecretValues blocks a live token leaving in any outbound payload (ccsc-z0n.3, token-firewall epic ccsc-z0n). New pure assertNoSecretValues(payload, secretValues) in lib.ts is the companion to assertSendable: that guard blocks secret files by path, this blocks secret values by content — closing the case where a live Slack token is pasted into message text, a file body, or an attachment filename rather than a state file. Added additively (no change to assertSendable's signature) because lib.ts is vendored by AGP (ADR 009); the two guards compose, they don't merge. The watch-set is built by buildSecretValueSet from the SECRET_DECLARATIONS table (ccsc-z0n.1) — the guard never hardcodes a value or a name, and an empty set is a no-op. server.ts builds the live-value set from the parsed .env at boot and wires the guard into the three agent-controlled outbound surfaces — reply text (both streaming + non-streaming branches), edit_message text, and uploaded-file body + filename — each journaling an exfil.block (reason names the guard, never the value) on a hit. Bridge-internal sends (audit receipts, pairing prompts) are not agent-controlled and are not scanned. The thrown message never echoes the matched value or the payload. 13 unit tests cover detection anywhere in the payload, the three wired surfaces, the no-echo discipline, near-miss (prefix-only) safety, empty-set / empty-payload / non-string no-ops, the empty-string-entry guard, and the buildSecretValueSet → guard seam end-to-end. AGP substrate/UPSTREAM.md flags this as a deliberate kernel re-sync (AGP wants the stronger guard too).
  • Secret-declaration schema — one table is the source of placeholder, guard, and routing (ccsc-z0n.1, first of the token-firewall epic ccsc-z0n). New SECRET_DECLARATIONS frozen table in lib.ts is the single place a secret's identity is defined; the placeholder the agent sees (secretPlaceholder / secretNameFromPlaceholder, {{CCSC_SECRET:<name>}} form), the outbound value-exfiltration guard watch-set (declaredSecretNames / buildSecretValueSet), and the host-bound routing rule (allowedSinkFor, SecretSink) all derive from it — no drift across the three consumers (ADR-002 §1 credential placeholder-swap + §6 declaration-as-enforcement). Declares the two Slack tokens the runtime loads (SLACK_BOT_TOKEN/xoxb-slack-web-api, SLACK_APP_TOKEN/xapp-slack-socket-api). Pure / side-effect-free — buildSecretValueSet takes a resolver so lib.ts stays free of process.env reads. Schema only: the boundary injection (ccsc-z0n.2) and the assertSendable value-guard extension (ccsc-z0n.3) build additively on this table. 17 unit tests cover the placeholder round-trip, frozen / no-duplicate table, the guard set deriving from the table (not the resolver's keys), empty-skip + dedup, and the no-drift property across all three consumers.

Changed

  • Stryker mutation-score floor wired as a scheduled CI gate (ccsc-0mn + ccsc-2et). The mutation suite (lib.ts + policy.ts + manifest.ts + journal.ts) now runs on a schedule with a JSON reporter so survivors are extractable, and the score is gated as a required check rather than a manual ~45-min run. Baselines recorded in 000-docs/MUTATION_REPORT.md (policy.ts recovered to 89.78% after the ccsc-2et survivor-killing tests). Contributor-facing quality infrastructure only — no runtime behavior change.
  • Relicensed from MIT to Apache License 2.0. Replaces the MIT LICENSE with the canonical Apache 2.0 text, adds a NOTICE file (Apache convention) attributing the project to Jeremy Longshore / Intent Solutions and noting the vendored MIT @intentsolutions/audit-harness, retags the SPDX-License-Identifier header in all 32 first-party source/feature files to Apache-2.0, and updates package.json, .claude-plugin/plugin.json, README/docs license badges, and CONTRIBUTING. The vendored .audit-harness/ copy keeps its own MIT license. Restores the project's original license (it had briefly been MIT — see the entry below).

[0.10.0] - 2026-05-24

Added

  • Fresh-clone onboarding surface — AGENTS.md, CONTRIBUTING.md, /slack-channel:install skill, README hygiene (ccsc-8eo). Closes the cold-start gap surfaced by the post-v0.10 onboarding audit. New AGENTS.md at repo root follows the open agents.md spec (read natively by Claude Code, OpenAI Codex CLI, Cursor, Cline, Aider, Devin, Sourcegraph Amp, Gemini CLI, Continue, Roo Code, Factory Droids, GitHub Copilot, Windsurf, Amazon Q): module layout for all 17 production files, three critical setup gotchas (Slack-bot-must-be-added-to-channel, Bun, Claude Code ≥v2.1.80 + claude.ai login), code-style boundaries (95% coverage floor, CRAP 30, four runtime deps), architecture invariants (31-A.4, no-journal-imports-policy, no-admin-imports-manifest). CONTRIBUTING.md expanded from thin 67-line skeleton to full contributor guide covering branching (feat/<...>-bz-<bead>), Gemini-review loop, the 9-gate local sweep, security-sensitive surfaces, and bd-vs-GitHub-issue workflow for external contributors. New skills/install/ directory with multi-mode SKILL.md (install / doctor / verify / repair / manifest / reset / tour / uninstall) — fresh install walkthrough delegates to /slack-channel:configure + /slack-channel:access pair, never re-implements; doctor runs 10 health checks against a live install (token validity via auth.test, file perms, audit-chain integrity, bot-in-channel membership); repair auto-fixes safely fixable findings (perms, corrupt access.json); manifest exports a Slack app manifest JSON so the user can one-click import all 8 scopes + 4 events instead of clicking through the UI (saves ~10 min); reset archives access.json + sessions/ while preserving .env + audit.log; uninstall archives the state dir (no rm -rf, one rollback path preserved). Three reference docs back the skill: references/prerequisites.md (Bun + Node + Docker fallbacks), references/slack-app-setup.md (manual UI walkthrough with the 8 scopes + 4 events + interactivity toggle), references/troubleshooting.md (10 silent-failure modes ranked by frequency, with the bot-not-in-channel killer at #1). README Quick Start gains Prerequisites block, step 3.5 (add bot to channel), step 5 (verification round-trip), inline Troubleshooting section, and For AI-assisted setup lead-in pointing at the install skill. CONTRIBUTING link added near bottom. CLAUDE.md is NOT modified — maintainer-only file per explicit constraint. No production code, tests, CI, or dependencies touched.
  • ccsc audit-key {init, rotate} operator CLI (ccsc-l1f — fifth and final server-wiring bead for the v0.10 rollout; closes the server-wiring chunk). New audit-key-cli.ts pure module ships runAuditKeyCli(argv, deps, defaults) + auditKeyInit(opts, deps) + auditKeyRotate(opts, deps) + parseAuditKeyArgv(); new scripts/audit-key.ts wires production deps (real sops subprocess via execFile, real JournalWriter.open, real node:fs/promises). Operator runs bun scripts/audit-key.ts init to generate a fresh Ed25519 keypair, write audit.key.sops.yaml.tmp mode 0o600, sops --encrypt --in-place, atomic rename → audit.key.sops.yaml, and print the public key for pass insert + external gist publication. bun scripts/audit-key.ts rotate --reason=<scheduled-90day|compromise-suspected|operator-initiated> --confirm-bridge-stopped loads the current keypair, generates a new one, writes ONE final system.key_rotation event to audit.log signed under the OLD key (carrying input.old_public_key + input.new_public_key + input.rotation_reason), then atomically archives audit.key.sops.yamlaudit.key.sops.yaml.<unix-ms>.archived and swaps in the new key. The verifier picks up the new key automatically on its next walk (RFC 6962 CT pattern, per verifyJournal()'s existing system.key_rotation handler). Safety invariants enforced by the CLI: refuses to overwrite an existing key file on init (one-shot per state dir); refuses on a stale .tmp or .new.tmp (interrupted prior run — operator must verify contents before proceeding); refuses rotate without --confirm-bridge-stopped (cross-process writer collision would corrupt the hash chain — ACTIVE_PATHS only protects against same-process); cleans up plaintext tmp on encrypt failure so plaintext key material never survives the CLI process; rolls back the archive step if the final rename fails (operator never left with no key file). Failure messages are descriptive — encryptInPlace-failure on rotate explicitly tells the operator the rotation event WAS written under the OLD key and provides recovery guidance. 24 unit tests cover the argv parser (8), init (4 — happy path with byte-exact round-trip via parseKeyPairYaml, refuse-overwrite, refuse-stale-tmp, encrypt-failure-cleanup), rotate (5 — refuse-no-confirm, refuse-no-current-key, refuse-stale-new-tmp, happy path with end-to-end verifyJournal chain validation under OLD then NEW key, decrypt-failure, encrypt-failure-with-recovery-guidance), and runAuditKeyCli dispatch (5 — help, default keyPath, EX_USAGE 64 for malformed argv, unknown subcommand). Sync fix to 000-docs/key-management.md § Rotation cadence: the example JSON event used body.{...} field names but the writer never emitted body — corrected to the actual journal.ts shape (input.{...}, prevHash, outcome). Closes the five-bead server-wiring chunk: ccsc-uge (audit-key loader) + ccsc-0jj (admin dispatch wired) + ccsc-00f (mute+rate-limit gate wired) + ccsc-h1h (stream-reply wired) + ccsc-l1f (this CLI) — every v0.10 primitive that had been shipped-but-dormant is now active in production with an operator-facing surface for the audit-signing key.
  • SOPS+age boot-time loading of the Ed25519 audit-signing key (ccsc-uge — first of five server-wiring follow-ups for the v0.10 rollout). New audit-key-loader.ts sibling module ships loadSigningKey(opts) + parseNoAuditSigningFlag(argv). Spawns the operator's local sops -d binary at boot, pipes plaintext YAML through parseKeyPairYaml, returns a structured AuditSigningResolution: loaded (with staleWarning set when key is >90 days old), disabled (when --no-audit-signing flag set + file absent), or error (every other failure mode). server.ts boot path now calls the loader BEFORE JournalWriter.open() and passes signingKey + initial policyDigest(policyRules) into the writer's options. Result: the Ed25519 + JCS + policy_attestation signing pipeline (ccsc-22l) that has been shipped-but-dormant since PR #177 now activates in production when an operator has a SOPS key in place. Loud refusal on missing-file-without-flag (refuses to start in an ambiguous state); graceful v1-mode fallback when operator explicitly opts out. Decrypted YAML lives in process memory only — never written to disk (no /dev/shm tmpfs, no temp file; SOPS subprocess pipes directly to stdout, consumed in-memory). 11 unit tests cover the flag parser + all 5 resolution paths (disabled, missing-file-refusal, loaded happy, stale warning, decrypt failure, malformed YAML, tamper-checked public_key mismatch, ~/ expansion, end-to-end round-trip with verifyJournal). First of 5 server-wiring beads (also filed: ccsc-0jj admin dispatcher wiring, ccsc-00f mute+rate-limit store wiring, ccsc-h1h stream-reply wiring, ccsc-l1f ccsc audit-key {init,rotate} CLI).
  • Multi-agent setup recipe doc (ccsc-6gw, closes multi-agent epic ccsc-7xq). New 000-docs/multi-agent-channels.md — operator-facing walkthrough for the multi-agent experience CCSC was designed for: humans + multiple AI agents (Claude, Codex, Gemini, etc.) converse together in one Slack channel, each driven by its own bridge. Covers architecture diagram, what's already there in CCSC, the 6-step recipe (register second bot's Slack app, stand up second bridge, configure mutual allowBotIds, set per-channel rate limit, enable admin commands, verify via audit log), common failure modes (loop / mis-mention / cross-bot view miss / wrong-operator permission routing), and explicit non-goals (no E2E encryption, no cross-workspace federation, no shared model context, no agent runtime ownership). README links to it from the existing "Multi-agent coordination" section. Multi-agent epic ccsc-7xq now closes — 3 of 3 children shipped (rate limit, mute verb, recipe doc); ccsc-8fc slack/announce_task deferred per plan.
  • !mute <@bot> / !unmute <@bot> operator verbs (ccsc-gjm, second piece of multi-agent epic ccsc-7xq). New mute-store.ts sibling module ships createMuteStore() + parseSlackMention(). New admin verbs ride on the admin.ts dispatcher from ccsc-3w0: human in the channel types !mute <@CodexBot> → resolves the Slack mention to a bot user_id → adds (channel, bot_id) to the in-memory store with a default 5-minute TTL → the inbound gate drops that bot's messages from that channel until TTL expires (or operator types !unmute <@CodexBot> for early release). 4 new EventKinds: admin.mute, admin.mute.denied, admin.unmute, admin.unmute.denied. Gate integration in lib.ts checks the mute store BEFORE the rate-limit check — explicit operator intent always wins over automatic loop-breaker. Drop carries dropReason: 'admin.muted' (distinguishable in audit log from rate.cross_bot_loop). Mute / unmute do NOT require HMAC nonce — reversible, auto-expiring, friction not warranted. Most-recent mute overwrites earlier mute for same (channel, bot_id) pair. isMuted auto-prunes expired entries on each call. 26 unit tests (+4 parser, +10 store, +5 dispatcher, +3 gate-integration, +4 covering parseAdminCommand/admin verbs).
  • Per-bot per-channel sliding-window rate limit (ccsc-gyt, first piece of multi-agent epic ccsc-7xq). New peer-bot-rate-limit.ts sibling module ships createPeerBotRateLimitStore() + RateLimitConfig + the gate hook. Defends against A→B→A runaway loops when two or more peer bots are opted into one channel via allowBotIds: each (channel, sender_bot_id) pair gets its own sliding window; default cap is 10 messages in 60s. Over threshold → gate() returns { action: 'drop', dropReason: 'rate.cross_bot_loop' } and the journal records gate.inbound.drop with reason: 'rate.cross_bot_loop' (new GateDropReason union type so future drop variants can be enumerated). Rejected timestamps are NOT appended to the bucket (no unbounded growth during sustained loops; window slides cleanly when older entries age out). Per-bot AND per-channel isolation enforced — one bot exceeding doesn't drop another bot's messages, one channel's saturation doesn't affect another channel. New ChannelPolicy.peerBotRateLimit?: { count: number; windowMs: number } field for per-channel tuning. Operator can disable with explicit { count: 0, windowMs: 0 }. Default-on when the store is wired in GateOptions.peerBotRateLimitStore. Tests-without-store get backward-compat behavior (no rate limit applied). 15 new unit tests + 3 gate-integration tests cover threshold boundary, window expiry, per-bot isolation, per-channel isolation, drop-no-append invariant, prune sweep, threshold=0 (always drop) + threshold=large (never drop) boundaries, mixed allow/drop pattern through sliding window.
  • Streaming Slack replies via chat.update (ccsc-ele). New stream-reply.ts sibling module ships streamReply(opts, deps) — posts the initial chunk via chat.postMessage, progressively appends via chat.update on the cached ts until the full text is delivered. Closes the perceived-latency UX issue for long Claude replies (the user sees the message body grow in real time instead of waiting for the full reply). New EventKind system.stream_finalize. Per the locked design: exactly TWO journal events per stream — one gate.outbound.allow at start carrying the pre-committed full-text SHA-256, one system.stream_finalize at end carrying chunks-sent + matching hash. NO per-chunk events (would cause O(n²) canonicalize cost + finalization race). Cached-ts invariant: every chat.update reuses the ts returned by the initial chat.postMessage. Per-chunk gate check via assertOutboundAllowed runs on every chunk (without per-chunk events) — if the channel is removed mid-stream the stream finalizes with outcome: 'deny' + reason. Rate-limit aware (1 req/sec per Slack Tier 4). Asymmetric audit-resilience: journal failure at stream start REJECTS the stream (refuses to post bytes that can't be journaled); journal failure at finalize is swallowed (chunks already landed, can't be unwound). 12 new unit tests cover happy paths (single chunk, multi-chunk, hash match, expected_chunks invariant), gate-rejected-at-start, mid-stream gate rejection, mid-stream Slack 429 rejection, cached-ts invariant, exactly-one-allow + exactly-one-finalize, sleep-before-update ordering, both audit-resilience asymmetry paths. § "Streaming reply" added to 000-docs/audit-journal-architecture.md.
  • Admin commands hardened — !clear / !restart routed through gate → policy → journal → execute (ccsc-3w0). The convergence point of the CCSC rollout (#167). New admin.ts module ships parseAdminCommand() (pure parser) + dispatchAdminCommand(cmd, deps) (composable dispatcher with injected deps for testability). Mirrors the acp-adapter.ts / policy-dispatch.ts / nonce-hitl.ts pattern — pure sibling module, no boot-time side effects. Production wires createTmuxSendKeys(sessionName) (argv-mode execFileSync('tmux', ['send-keys', '-t', SESSION, ...keys]) — no shell interpolation, refuses empty sessionName loudly at boot). !clear is reversible (no nonce): runs supervisor.quiesceAndDeactivate() + tmux /clear + ♻️ reaction. !restart is destructive (HMAC nonce + cross-channel HITL required day-1 per ccsc-ofn): no-nonce → mint + DM challenge; with-nonce → verify; on ok → tmux /exit + 🔄 reaction. 5 new EventKinds signed under v2 from day 1: admin.clear, admin.clear.denied, admin.restart, admin.restart.denied, admin.restart.challenge. The challenge event records expires_at but NOT the nonce value (defeats journal-replay attack). New ChannelPolicy.adminCommands?: { allowFrom: string[] } per-channel opt-in (default-safe: absent → no admin verbs regardless of who types them). New stripBotMention() helper in lib.ts — closes the Gemini #1 finding from PR #157 (mention-strip lets admin parser see normalized text on requireMention=true channels). New .dependency-cruiser.js rule no-admin-imports-manifest enforces the same isolation as 31-A.4: admin verbs are authoritative, manifest is advertising-only ("advertisements are not grants"). Virtual-tool invariant pinned by test: no registered MCP tool name starts with admin. — Claude cannot invoke admin verbs by tool call; only operator Slack commands trigger them. 28 unit tests + 9 Gherkin acceptance scenarios in features/admin_commands.feature cover happy paths, allowlist gate, challenge phase, redemption with each ccsc-ofn failure mode, ordering invariant (journal before tmux), audit-resilience (broken journal does NOT block execution), allowlist short-circuit (non-allowlisted user with valid nonce → denied at gate, verifier never called — prevents nonce-probing by unauthorized users), and the virtual-tool invariant. § "Admin verb events (ccsc-3w0)" added to 000-docs/audit-journal-architecture.md (EventKind table, nonce-not-journaled invariant, v2 signing rationale, module-boundary invariant). § "Admin-verb entry points (ccsc-3w0)" added to 000-docs/session-state-machine.md (flow diagram, supervisor-API rationale, module placement rationale).
  • HMAC nonce + cross-channel HITL primitives (ccsc-ofn). New nonce-hitl.ts module ships the testable primitives for the two-step handshake that gates destructive admin verbs (!restart today, future !stop): operator types verb in channel C → server mints 16-char hex nonce (64-bit entropy, 60s TTL, single-use) and DMs it to the operator → operator types !restart <nonce> in the SAME channel C → server verifies + executes. The cross-channel property closes the EchoLeak threat class (T11): an attacker who can inject content into channel C cannot also inject the bot's DM. Exports createMemoryNonceStore(), mintNonce(), verifyNonce(), with structured failure modes (unknown / expired / replay / wrong-channel / wrong-user). Per-user cap (MAX_LIVE_NONCES_PER_USER = 3) prevents resource exhaustion via verb-spam. Uses timingSafeEqual for the nonce comparison — defense-in-depth at this entropy. 21 unit tests cover mint shape, expiry boundary, replay rejection, cross-user/cross-channel rejection, failed verifications NOT consuming the nonce (so an attacker can't burn legitimate operators' challenges), pruneExpired sweep, store independence across operators and channels. § "HMAC nonce + cross-channel HITL" added to ACCESS.md documenting flow, threat coverage, and verb scope (nonce required for destructive verbs only — !clear runs unguarded, !restart is gated). The dispatcher integration arrives in ccsc-3w0 (admin commands); this PR delivers the primitives the admin module will compose with Slack DM delivery.
  • Policy.deny context-stripping (ccsc-06s). On every policy.deny decision the dispatcher now writes a two-event journal sequence — full-detail policy.deny followed by a sanitised policy.deny.context_stripped marker that records the minimisation was applied — and sends a minimal MCP notification to Claude carrying ONLY { request_id, behavior: 'deny' }. No rule id, no reason, no input echo. Closes the retry-rephrase loop where a denied tool call's response would otherwise feed the model enough information to dodge the rule on the next turn. Two new helpers extracted into policy-dispatch.ts (mirrors the acp-adapter.ts pattern from PR #173): buildDenyNotificationParams(request_id) pins the wire-format invariant; recordPolicyDenyToJournal(writeEvent, detail) enforces the order-of-operations contract (journal-first, then notification). New EventKind policy.deny.context_stripped. 7 new tests cover the wire shape, journal ordering, audit resilience (second event still attempted on first-event failure), and end-to-end via real JournalWriter. § "Context-stripping on policy.deny" added to 000-docs/policy-evaluation-flow.md documenting what CCSC can excise (the response Claude observes) vs. cannot (Claude Code's KV cache) and why this is the implementable floor for an MCP-bridge architecture.
  • Journal v2 — Ed25519-signed audit events with policy attestation (ccsc-22l). New crypto.ts module exports Ed25519KeyPair, generateKeyPair, parseKeyPairYaml/serializeKeyPairYaml (SOPS-decrypt round-trip), signBytes, verifySignatureBytes, derivePublicKey. Uses Node's built-in Ed25519 — no new runtime deps. JournalEvent schema extends to a z.union([z.literal(1), z.literal(2)]) discriminated version; v2 events optionally carry signature (88-char base64 of 64-byte Ed25519) + policy_attestation: { digest, alg: 'sha256' }. JournalWriter accepts signingKey + policyDigest options; when present, emits v2 signed events; when absent, emits v1 backward-compatibly. setPolicyDigest() updates attestation on hot-reload. verifyJournal(path, { initialPublicKey }) walks mixed chains: accepts v1, v2, or v1→v2 contiguous transition; rejects v2→v1 as rollback tamper signal; switches active public key on every system.key_rotation event (the event itself verifies under the OLD key, then input.new_public_key becomes active per RFC 6962 CT pattern). New system.key_rotation EventKind. New policyDigest() export from policy.ts (SHA-256 of canonical JCS of rules sorted by id — content-fingerprint, authoring-order-independent). New depcruise rule no-journal-imports-policy enforces module-boundary invariant: the digest crosses as an opaque hex string. § "Signed events — journal v2" added to 000-docs/audit-journal-architecture.md.
  • Tier-aware evaluate() — strictest-tier-wins, first-applicable within tier (ccsc-8pw). Combining algorithm extends to walk tiers in order admin → user → workspace → default; first matching rule in the strictest non-empty tier wins. Within a tier, the v0.5.x first-applicable behavior is unchanged. Backward-compatible: when no rule declares a tier, every rule lives in 'default' and behavior collapses to the original single-tier first-applicable — existing access.json files load and evaluate identically. Captures the asymmetric override needed for multi-operator deployment: admin auto_approve overrides workspace deny (intended Admin-overrides path), admin deny overrides workspace auto_approve (the silent-footgun case ccsc-4g8 lints for). § "Combining algorithm" rewritten in 000-docs/policy-evaluation-flow.md to document tier semantics.
  • Cross-tier shadow detection in detectShadowing() (ccsc-4g8). Adds the MatchSpec.tier?: 'default' | 'workspace' | 'user' | 'admin' optional field (defaulting to 'default' — existing access.json files load unchanged) and extends detectShadowing() with a second pass that flags cross-tier intersection between any non-Admin auto_approve rule and any Admin deny rule. The classic within-tier subset check is preserved; the new check catches the silent-footgun class where a Workspace auto_approve and an Admin deny aren't in a subset relationship but share a concrete tool call. ShadowWarning gains a crossTier: boolean field (backward-compatible — existing warning consumers see crossTier: false on every existing warning). Adds 15 acceptance scenarios in the new features/tier-shadow.feature (Gherkin, hash-pinned) + 15 direct unit tests in server.test.ts for matchesIntersect / effectiveTier / ShadowWarning shape. § "Cross-tier intersection" added to 000-docs/policy-evaluation-flow.md documenting the asymmetric check, intersection field semantics, and why tier is excluded from intersection itself. evaluate() does NOT yet act on tier — that lands in ccsc-8pw (combined v2 cluster). 8 files pinned in .harness-hash now (added features/tier-shadow.feature). Coverage went UP: line 98.72% → 98.89%, func 99.02% → 99.12%. Total tests 757 → 787.
  • RFC 8785 JCS interop test + pinned vector suite (ccsc-713). Adds features/jcs-vectors.json (30 supported vectors + 5 throws-vectors, every entry annotated with the RFC 8785 section it exercises) and features/jcs-interop.test.ts running each vector through canonicalJson() (journal.ts:668) with byte-exact equality assertions. Vector file pinned in .harness-hash so any edit requires explicit --init (same tamper-evident discipline as .feature files). Closes the divergence gate that journal v2 (ccsc-22l) needs: when events are signed with Ed25519, third-party verifiers compute canonical bytes against our published public key — byte-divergence between implementations would silently break verification. Documents the narrower input domain we support (integer-only, BMP-only) via 5 throws-vectors that assert rejection. § "Canonicalization interop" added to 000-docs/audit-journal-architecture.md. 40 new tests, all green; total now 757.

Changed

  • ACP adapter extracted from server.ts into acp-adapter.ts (ccsc-21x follow-up). Addresses Gemini review on PR #172: (1) the inlined test duplicate of mapAcpSessionCancel was a drift risk — extracting the adapter into a side-effect-free module lets server.test.ts import the production code path directly; (2) AcpResponse.id widened to string | number | null to be fully JSON-RPC 2.0 §5.1 compliant (the spec requires null when an error occurs detecting the request id) — the two unsafe as string | number casts are removed. Coverage went up (98.47% → 98.72% line / 98.82% → 99.02% func) because tests now exercise the real adapter, not a copy. No behavior change.

Added

  • ACP boundary adapter — mapAcpSessionCancel in server.ts (ccsc-21x). Adds a thin JSON-RPC 2.0 adapter that translates Agent Client Protocol session/cancel requests onto the existing supervisor.quiesce(key) call. Additive only — the supervisor's internal vocabulary (activate / quiesce / deactivate / quarantine) is unchanged, no method renames, no parameter shape changes. The adapter is the ONLY place ACP terminology appears in the codebase (mirrors the 31-A.4 isolation pattern between manifest.ts and policy.ts). Returns { result: { stopReason: 'cancelled' } } on success; surfaces -32600 Invalid Request / -32602 Invalid params / -32603 Internal error per JSON-RPC 2.0. Forward-compat for the day Anthropic ships external-message-injection mid-turn (anthropics/claude-code#53049) — the migration becomes a single function edit, not a vocabulary rename that would break ~704 tests and the meaning of existing audit.log files. Adds 13 round-trip tests + § ACP-mapping addendum to 000-docs/session-state-machine.md + new invariant #9 ("ACP terminology appears only in the boundary adapter").
  • 000-docs/key-management.md — operational doc for the audit-signing key (ccsc-c2z). Specifies the load-bearing operational decisions for the Ed25519 signing key that will back journal v2 (ccsc-22l): SOPS-encrypted file at ~/.claude/channels/slack/audit.key.sops.yaml decrypted to /dev/shm at boot via age; 90-day rotation cadence with system.key_rotation event written under the old key; external public-key pin in an operator-controlled GitHub gist; lost-key recovery via audit.log.<timestamp>.broken-chain + system.chain_break event; compromise recovery via ccsc audit-key rotate --reason=compromise-suspected; --no-audit-signing emergency valve flag. Lands BEFORE the signing code in ccsc-22l so the operational decisions are decided in writing first. Inherits the Intent Solutions SOPS + age secrets standard.
  • THREAT-MODEL.md — T11 (operator-coerced admin command, EchoLeak class) + invariant #7 (ccsc-o6x). Adds explicit recognition of the EchoLeak / CVE-2025-32711 threat class (zero-click prompt injection coercing a privileged operator into emitting an admin verb), with citations to PromptArmor's Slack-AI exfiltration finding (MITRE ATLAS AML-CS0035), the Anthropic Slack MCP unfurl advisory, and "Your AI, My Shell" (Liu et al. 2025, 84% attack success against CLI agent surfaces). Invariant #7 formalizes the operational floor: admin verbs cannot be promoted from chat content without a server-minted HMAC nonce + cross-channel confirmation. This is the unblock for the Admin Commands + Audit/Policy/Governance v2 Cluster rollout (tracking issue #167) — every subsequent design doc in the rollout cites T11. No code change; the invariants are enforced by the beads that follow (ccsc-3w0, ccsc-ofn, ccsc-22l, ccsc-8pw, ccsc-06s).

[0.9.1] - 2026-05-13

Added

  • Intent Solutions testing harness baseline (audit-harness v0.1.0, vendored at .audit-harness/ with wrapper at scripts/audit-harness). Adds hash-pinning, escape-scan, CRAP, architecture, bias, and Gherkin-lint primitives as deterministic in-repo scripts so testing-policy enforcement travels with the code (never references ~/.claude/ paths from hooks or CI). Per-repo baseline notes in 000-docs/audit-harness-test-baseline-2026-05-01.md. Upgrade in place via AUDIT_HARNESS_VERSION=vX.Y.Z curl -sSL https://raw.githubusercontent.com/jeremylongshore/audit-harness/main/install.sh | bash.

Changed

  • Removed gemini-review.yml workflow — Gemini PR review now runs via the GitHub App, not an in-repo workflow. Reduces CI surface area and removes the workflow-side maintenance burden.
  • .gitleaks.toml allowlist for .beads/ memory and GHSA- advisory IDs* — replaces fingerprint-by-fingerprint exemptions with structural path + regex allowlists so future bd memory additions and GHSA citations don't trip the secrets scan.
  • CI dep maintenance: github/codeql-action 4.35.2 → 4.35.4 (dependabot).

Fixed

  • reply tool — pass filename to Slack file uploads. server.ts was calling WebClient.filesUploadV2() with { channel_id, file: <absolute path> } but never setting filename. The @slack/web-api v2 upload flow does not infer the filename from the file path when it's a string — Slack defaults to file.txt, which means every uploaded asset (PNG, HTML, MD, PDF, etc.) was delivered to recipients as a .txt download with the wrong mimetype. Adds filename: basename(resolved) to the upload args so the original filename and inferred mimetype are preserved end-to-end. Imports basename from node:path (already imports resolve from the same module).

Security

  • Cleared 6 high-severity transitive dep CVEs (ccsc-7lh). Bumped @slack/web-api 7.15.0 → 7.15.2 (brings axios ^1.15.0 → 1.16.1) and @modelcontextprotocol/sdk 1.27.1 → 1.29.0 (brings ajv with fresh fast-uri ≥3.1.2). Added a top-level overrides block pinning axios ^1.16.1 and fast-uri ^3.1.2 so the lockfile cannot regress on transitive selection. Addresses: 4 axios CVEs (GHSA-q8qp-cvcw-x6jj prototype pollution → credential injection, GHSA-pmwg-cvhr-8vh7 NO_PROXY bypass via 127.0.0.0/8, GHSA-pf86-5x62-jrwf response-tampering gadgets, GHSA-6chq-wfr3-2hj9 header injection) and 2 fast-uri CVEs (GHSA-v39h-62p7-jpjc host confusion, GHSA-q3j6-qgpj-74h6 path traversal). bun audit --audit-level=high --ignore=GHSA-j3q9-mxjg-w52f is now clean. All 704 tests pass; coverage, depcruise, gherkin-lint, harness-hash, crap-score gates unchanged. Unblocks CI on main, PR #161 (dependabot codeql-action bump), and PR #162 (external reply filename fix).

[0.9.0] - 2026-04-23

Added

  • pairing.accepted journal events via lazy allowFrom snapshot diff (ccsc-scv). Closes the 19/19 audit EventKind coverage gap. The /slack-channel:access pair <code> skill runs in Claude's process, not the server's, so direct emit is impossible without an IPC channel. Three alternatives (fs.watch, skill-writes-journal-drain-file, relocate pairing into server.ts) were considered and all rejected — each introduces platform-variance, second-writer, or new-IPC-trust-boundary costs disproportionate to the value of one observational event. Chosen approach: server.ts getAccess() (already called on every inbound message) holds a module-level prevAllowFrom snapshot and diffs the current allowFrom against it on each read; every newly-added user id produces one pairing.accepted event with { user: userId } payload. First call seeds the baseline without emitting — subsequent reads detect deltas. The diff logic lives in a pure detectNewAllowFrom(prev, current) primitive in lib.ts (10 new unit tests). Properties: platform-agnostic, first-call-safe (no boot-time event spam for pre-existing allowlist entries), tamper-resistant by construction (fires on any allowFrom growth regardless of path — skill, manual edit, or tampering), silent on removal (no pairing.removed kind exists and none invented). Design rationale documented in 000-docs/audit-journal-architecture.md §pairing-events.

  • pairing.expired journal events (ccsc-rc1). lib.ts pruneExpired() now returns the [code, entry] tuples it removed (previously void); server.ts getAccess() iterates that return and emits one pairing.expired event per expiry (minimal-disclosure payload: { channel: entry.chatId }, matching the pairing.issued shape). Static-mode boot-time pruning remains silent because the journal isn't open at module-load time — static mode downgrades dmPolicy to allowlist, so no new pending entries are created after boot and residue is cleared once without an audit trace (documented inline). Audit EventKind coverage moves from 17/19 to 18/19 of the v0.5.0-declared kinds — pairing.accepted remains unwired (tracked in ccsc-scv, which needs an IPC design decision since the skill runs outside the server process). Four new unit tests in server.test.ts cover the new return value (empty pending, nothing-expired, mixed expired/live, multiple expiries).

Changed

  • JournalWriter.open() — reverse-chunk tail read + single file handle (ccsc-otd). Replaces the full readFileSync of the audit log (used only to recover lastHash and nextSeq from the last line) with a 64 KiB reverse-chunk scan from EOF backwards until the last newline boundary is found. Drops startup memory from O(file size) to O(last line size). Also unifies the previous read-then-fsOpen pair into one fsOpen('a+', 0o600) handle: reads use explicit position so they don't disturb the append pointer, and writes still go through O_APPEND (atomic EOF writes preserved). The single-handle design incidentally closes the stat-then-use TOCTOU window that triggered the earlier CodeQL js/file-system-race pattern on this path. Four new tests in server.test.ts exercise the reverse-scan edge cases (pre-existing empty file, trailing-newlines-only, last line straddling a chunk boundary, last line larger than one chunk).

Fixed

  • Server-side duplicate-policy-rule-id rejection (ccsc-kx8). ACCESS.md §"Safety checks the loader runs" documented duplicate-id rejection as fatal at boot, but server.ts loadPolicy() never wired the check — only the validator script shipped with the /slack-channel:policy skill caught it. A policy with two rules sharing an id would load cleanly and the second rule would be silently unreachable (evaluator uses first-applicable). Adds assertUniqueRuleIds() to policy.ts (sibling to detectShadowing / detectBroadAutoApprove; throws rather than returning warnings, matching the documented fatal contract). server.ts loadPolicy() now runs it after parsePolicyRules() and fails boot with a descriptive error naming every duplicated id. scripts/policy-validate.ts swapped its local findDuplicateIds helper for the shared primitive so the skill and the server use one code path. Four new tests in server.test.ts lock the behavior.

Added

  • /slack-channel:policy skill — author policy rules without hand-editing access.json (ccsc-0ss). New operator-facing skill at skills/policy/SKILL.md with four subcommands: list (tabular view of current rules), lint (shadow + broad-auto-approve warnings), add <id> <effect> <json-match> [opts], and remove <id>. All writes are atomic (access.json.tmp → rename) with mode 0o600. Pre-write validation runs through a new scripts/policy-validate.ts wrapper that imports the real parsePolicyRules() / detectShadowing() / detectBroadAutoApprove() from policy.ts — the same functions the server uses at boot — so a rule that validates clean here will load clean. Adds a duplicate-id check in the validator to match what ACCESS.md §"Safety checks" documents (server-side enforcement landed under ccsc-kx8; both surfaces now call assertUniqueRuleIds() from policy.ts). Hot reload remains intentionally unsupported; every success message tells the operator to restart the server.

Changed

  • Tighten crap-score CI threshold from 85 → 30 (ccsc-510). Wall 5 ideal now enforced in CI. Prior threshold 85 was a regression-only gate tuned to the previous ceiling; all four outliers (gate 32, handleMessage 35, interactive-handler anon 39, MCP-dispatch anon 84) are now refactored under ccsc-53g. Current ceiling is 28 (server.ts) — ~2 points of headroom. Every future PR must justify any function landing above the Wall 5 ideal.
  • Refactor gate() — extract per-channel-type handlers (ccsc-u41). The primary inbound security boundary (complexity 32) has been split into three internal helpers: handleBotEvent (block 1 — self-echo triple-check + allowBotIds opt-in + PERMISSION_REPLY_RE block), handleDmEvent (block 4 — DM allowlist + dmPolicy + pairing flow with MAX_PAIRING_REPLIES and MAX_PENDING DoS guards), handleChannelEvent (block 5 — channel opt-in + allowFrom + requireMention). gate() is now a thin 5-block router with complexity 6; lib.ts max complexity dropped 32 → 21. Zero behavior change — all 682 tests pass bit-for-bit, including the 13 fast-check property tests added in ccsc-363 (2,600 assertions of gate invariants) and the 10 Gherkin scenarios in features/inbound_gate.feature. Completes ccsc-53g alongside ccsc-lry, ccsc-upx, ccsc-530.
  • Refactor handleMessage dispatcher — extract deliverEvent (ccsc-530). The handleMessage function (cyclomatic complexity 35) has had its entire case 'deliver': body extracted into a new named top-level async function deliverEvent(ev, access). handleMessage is now a thin three-arm dispatcher (drop / pair / deliver) with complexity 8. deliverEvent carries the session tracking, supervisor activation, permission-reply detection (quorum + single-approver paths), and final MCP channel notification; complexity 27. No behavior changes; all 682 tests pass, coverage 98.39% / 98.75%.
  • Refactor interactive permission handler — extract verb helpers (ccsc-upx). The 240-line anonymous socket.on('interactive', …) handler (cyclomatic complexity 39) has been split into two named top-level async functions: handleMoreAction (handles the perm:more: detail-expand path, complexity 7) and handleAllowDenyAction (handles the perm:allow: / perm:deny: quorum + verdict path, complexity 17). The anonymous handler is now a thin dispatcher: ack → action-id parse → allowlist guard → delegate. Complexity before/after: anon 39 → 7; handleMoreAction 7; handleAllowDenyAction 17. No behavior changes; all 682 tests pass, coverage 98.39% / 98.75%.
  • Refactor MCP tool dispatcher — extract per-tool handler functions (ccsc-lry). The monolithic 655-line anonymous switch block inside mcp.setRequestHandler(CallToolRequestSchema, …) (cyclomatic complexity 84) has been replaced by eight named top-level async functions (executeReply, executeReact, executeEditMessage, executeFetchMessages, executeDownloadAttachment, executeListSessions, executeReadPeerManifests, executePublishManifest) plus four executePublishManifestGateN helpers and one executePublishManifestReplace helper. The dispatcher is now a Zod-validation block + lookup-then-call pattern wired through a ToolContext interface that bundles the closure state each handler needs. Complexity before/after: dispatcher 84 → 5; all extracted handlers ≤17 (max is executeReply at 17). No behavior changes; all 682 tests pass, coverage 98.39% / 98.75%.

Added

  • Pre-commit quality gate via Husky + lint-staged (ccsc-rrj). Local .husky/pre-commit runs bunx @biomejs/biome check --write on staged TS/JS/JSON files; .husky/pre-push runs tsc --noEmit for cross-file guarantees. Both hooks preserve the beads integration block so bd sync keeps working after Husky takes over core.hooksPath. prepare: husky script in package.json auto-installs hooks on bun install.
  • Property-based tests for gate() (ccsc-363). New features/gate-properties.test.ts uses fast-check to assert 11 invariants across the inbound-gate input space: self-echo drops (3 identifier paths), bot opt-in requirements (allowBotIds presence + match + permission-reply blocking), no-user drops, non-file-share subtype drops, DM allowlist behavior, and channel-gate behavior. Each property runs 200 random inputs. Adds 13 tests / 2,600 assertions to the suite. Safety net for the upcoming ccsc-u41 gate() refactor — catches edge cases specific test cases cannot.

[0.8.0] - 2026-04-21

Added

  • Biome adoption and tightening — Wall 7b lint gate (ccsc-dz8, ccsc-ana, ccsc-5vx). Adds @biomejs/biome as a devDep with a 30-rule configuration across 5 categories: suspicious (8 rules), correctness (6 rules), security (1 rule), complexity (6 rules), style (3 rules). Includes useNodejsImportProtocol, useOptionalChain, noControlCharactersInRegex, noAssignInExpressions, noUnusedImports, useLiteralKeys, useTemplate. Formatter enabled with quoteStyle: 'single', semicolons: 'asNeeded', trailingCommas: 'all', indentWidth: 2, lineWidth: 100. One-time reformat pass in PR #143 (commit added to .git-blame-ignore-revs). Autofix pass in PR #142 addressed 295 violations. Wired as a required CI step.
  • Gherkin runner (ccsc-mjw). Wires features/runner.test.ts + features/runner.ts (minimal hand-rolled Gherkin parser, no new runtime deps) and five step-definition files under features/steps/ so all 37 scenarios across the five .feature files execute as real bun:test tests against gate(), assertSendable(), assertOutboundAllowed(), evaluate(), and verifyJournal(). Updates scripts/coverage-floor.sh to compute the coverage average from production source files only (excluding test-only glue under features/), preserving the 95% floor semantics. Flips the runner-status note in features/README.md from "lint-only today" to "fully wired".
  • TS-aware cyclomatic-complexity gate (ccsc-gh0). Adds scripts/crap-score.ts — a Bun-native TypeScript AST walker that measures cyclomatic complexity per function across lib.ts, policy.ts, manifest.ts, journal.ts, supervisor.ts, server.ts. Replaces the regex-based branches-per-function proxy used during the deep audit. Wired into ci.yml as a regression gate at --threshold 85 — the current ceiling (server.ts has one anon function at 84). Four functions sit above the Wall 5 ideal of 30 (gate() at 32, handleMessage at 35, two server.ts anons at 39 / 84) — follow-up bd ccsc-53g tracks refactoring them and tightening the threshold to 30. 000-docs/QUALITY_GATES.md gains a row 8c for this gate.
  • Supply-chain + secrets PR gates (ccsc-bsz, ccsc-8g6). Adds .github/workflows/secrets-scan.yml — installs gitleaks v8.30.1, redact-verbose-scans PR diffs (or full history on push-to-main), fails on any leak. .gitleaksignore carries 7 fingerprints for the journal-redactor test fixtures in server.test.ts that look like AWS/AKIA tokens but are string-concatenated test inputs. Adds a bun audit --audit-level=high step to ci.yml against the GitHub Advisory Database; one known path-to-regexp advisory inside @modelcontextprotocol/sdk is ignored explicitly (GHSA-j3q9-mxjg-w52f). bunfig.toml carries a commented [install.security] stanza as a documented extension point for a third-party supply-chain scanner (Aikido/Socket/Snyk/Sandworm) if one is ever picked. 000-docs/QUALITY_GATES.md §9c/§9d flipped from 🟡/🔴 to 🟢.
  • Integrity closeout on the /audit-tests pass (ccsc-tr7, ccsc-s10). Scaffolds features/ with five declarative .feature files — one per security primitive (gate(), assertSendable(), assertOutboundAllowed(), evaluate(), verifyJournal()) — covering the inbound gate, file exfiltration guard, outbound reply filter, policy evaluator, and audit-chain verifier. Mirrors gherkin-lint.sh and harness-hash.sh from the upstream skill into scripts/ so CI runners (which don't have $HOME/.claude/) can invoke them. Adds two CI steps: Gherkin lint (Wall 1) and Harness-hash verify (tamper check). Flips the Wall 1 row in 000-docs/TEST_AUDIT.md from ⚪ N/A to 🟢 lint-gated and adds "Post-audit follow-up" + "Integrity note" sections with the 8 deferred bds filed alongside this pass.
  • Deep /audit-tests pass with deterministic evidence per Wall (ccsc-ao9). Rewrites 000-docs/TEST_AUDIT.md with numbers verified against real tool output (594 tests, not the previously-claimed 549; 181 .toThrow() not 104; 2.28 assertion density not 2.47). Adds 000-docs/QUALITY_GATES.md — the Step 5.5 Quality-Gate-Sweep artifact the prior audit skipped.
  • scripts/coverage-floor.sh — parses bun test --coverage output and fails CI if line or function coverage drops below 95%. Bun 1.2.23 does not enforce coverageThreshold via bunfig.toml, so we gate on stdout instead.
  • CI step: Coverage floor (≥95%) — wired after the existing Test step in .github/workflows/ci.yml. Current coverage is 98.37% line / 98.75% function; the 95% floor gives ~3 points of headroom for feature PRs with staged tests.
  • Formal 31-A.4 architecture invariant via dependency-cruiser + .dependency-cruiser.js config; CI-enforced; the existing regex test in server.test.ts remains as a belt-and-suspenders guard.
  • Mutation testing with Stryker (ccsc-ao9, ccsc-y4e, ccsc-l5z). Configuration in stryker.conf.mjs; manual-run only (not in CI). Expanded scope from lib.ts-only to lib.ts + policy.ts + manifest.ts + journal.ts. Top-5 survivors on security primitives killed in PR #133. Final baseline documented in 000-docs/MUTATION_REPORT.md: 85.22% (1578 killed / 275 survived / 7 timed out of 1860 mutants). Per-file breakdown: journal.ts 87.76%, lib.ts 84.78%, manifest.ts 92.06%, policy.ts 78.00%.
  • Step 8 auto-remediation report (ccsc-71v). Added 000-docs/AUTO_REMEDIATION_REPORT.md documenting the gap analysis outcome — rubric-driven remediation would add no value to the current suite; report captures the rationale for future auditors.

Fixed

  • Flaky verifyJournal 1000-event chain test (ccsc-80e) — per-test timeout bumped to 15 s via the bun:test 3rd-arg signature. The test runs in ~3 s in isolation but can exceed bun's default 5 s timeout under I/O contention when the full 594-test suite competes for disk.

[0.7.0] - 2026-04-19

Added

  • Per-channel audit field on ChannelPolicy (#105) — operators configure audit projection behavior per channel: 'off' (default, no Slack posts), 'compact' (tool name + outcome only), or 'full' (includes redacted input preview). The authoritative audit log (~/.claude/channels/slack/audit.log) is unaffected by this setting — it always records all events.
  • Pre-execution audit receipts projected to Slack threads (#106) — when audit is 'compact' or 'full', the server posts a receipt to the originating thread before tool execution. Receipt includes tool name, decision (policy.allow/policy.deny/policy.require/policy.approved), and (in full mode) a redacted input preview. Self-echoes of these receipts are dropped by the inbound gate's triple-check.
  • auditReceipts map capped at 500 entries (#110) — memory safety guard prevents unbounded growth of the ts → eventId tracking map used for self-echo detection. Oldest entries evicted on overflow.

Changed

  • Documentation: clarified the distinction between the authoritative audit log (hash-chained, local, always-on) and the Slack projection (best-effort, per-channel opt-in). Updated CLAUDE.md and added 000-docs/audit-journal-architecture.md section on projection semantics (#109).

Tests

  • Gate self-echo regression test for audit receipts (#108) — ensures projected receipts don't re-enter the message pipeline even when the channel has allowBotIds configured.
  • audit:off produces zero Slack messages test (#107) — validates that the default audit: 'off' setting results in no projection posts.

[0.6.0] - 2026-04-20

Added

  • Declarative policy enforcement at the permission-relay boundary. Operators author rules in access.json.policy; the server consults evaluate() on every tool-call permission request and routes to one of four outcomes: auto_allow (matched auto_approve, no human prompt), deny (posts reason to thread, no exec), require_human (quorum tracking), default_human (no rule, legacy Block Kit flow). See ACCESS.md for the schema and 000-docs/policy-evaluation-flow.md for the decision procedure (#100).
  • Multi-approver quorum with NIST two-person integrity (#101). require_approval rules accept an optional approvers field (1–10, default 1). Votes accumulate on the verified Slack user_id — the same human cannot double-satisfy quorum regardless of display name. A single deny from any allowlisted user rejects the request immediately, regardless of quorum count. Block Kit message updates show progress (N/M approvals). Binding rationale tracked in issue #97.
  • Four new journal EventKinds now emit at enforcement time: policy.allow, policy.deny, policy.require, policy.approved. Declared in v0.5.0; now wired. Every enforcement decision produces a hash-chained audit record with rule id, session key, and (for require/approved) the verified approver user_ids.
  • thread_ts predicate on MatchSpec. Policies scope to a specific thread within a channel. Schema landed in #96 before the evaluator wiring so operators can ship thread-scoped rules against a stable v1 shape.
  • Boot-time policy footgun linter (detectBroadAutoApprove) (#101). Warns on auto_approve rules that lack both tool and pathPrefix — almost always a misconfiguration.
  • --verify-audit-log CLI subcommand (#98). bun server.ts --verify-audit-log <path> runs verifyJournal() before any state setup (no .env, no tokens needed). Exit codes 0/1/2 for ok / break / unexpected. Closes a design-doc gap where 000-docs/audit-journal-architecture.md specified the CLI but only the programmatic form shipped.
  • Compile-time shape-drift guards (#102). lib.ts duplicates PolicyDecision / VerifyResult shapes (PolicyDecisionShape / VerifyResultShape) to stay decoupled from policy.ts / journal.ts. Bidirectional extends checks in policy.ts and journal.ts break the build the moment either side drifts — already caught one real drift at review time.
  • End-to-end contract tests for the policy enforcement chain (#103). Seven integration tests covering auto_approve bypass, deny reason surfacing, require:2 quorum with NIST user_id dedup, and the default_human fallback when no rule matches.
  • Frozen 000-docs/v0.6.0-release-plan.md (#99) — design-in-public commitment to what's in v0.6.0, what's out, and why.
  • 471 tests — up from 430 in v0.5.1 (+41 covering policy enforcement, multi-approver quorum, NIST dedup, route mapping, boot-time linter, and the full end-to-end policy chain).

Changed

  • Policy rules now load at boot. A malformed access.json.policy field is fatal at boot — policy is safety-critical, silent degradation is not offered. Missing / empty policy is NOT an error (first-install path is preserved). Hot reload intentionally deferred per release plan §R3.
  • Vote-handling DRY refactor (#102). The Block Kit button handler and the text-reply handler now share a processApprovalVote() helper — the state transition (recordApprovalVotegrantPolicyApprovaljournalWrite) lives in one place; per-resolver UX stays separate.

Known Limitations

  • pairing.accepted and pairing.expired EventKinds remain unwired (P3, ccsc-4an — tracked for v0.6.1). Current journal captures pairing.issued so the pairing lifecycle is reconstructible from issued + absence of claim.
  • No operator CLI for rule authoring; edit access.json.policy directly. A /slack-channel:policy skill is on the v0.6.1 roadmap.
  • Rules match on tool, channel, thread_ts, and actor; the argEquals / pathPrefix predicates in the schema do not yet match from permission_request notifications because the MCP surface carries input_preview (string) rather than structured tool args. Tracked as a v0.7.x evolution.

[0.5.1] - 2026-04-19

Added

  • SessionHandle.update() mutex-serialized state mutation (#92) — closes the "not yet implemented" stub from v0.5.0. update(fn) now uses a per-handle write queue so concurrent tool calls serialize their state transitions. saveSession uses await writeFile instead of writeFileSync and propagates file-write failures via Error.cause.
  • SessionSupervisor wired into server.ts (#93) — the supervisor from v0.5.0 is now live: createSessionSupervisor(STATE_DIR) at boot, supervisor.activate() on every inbound message (human and peer-bot), idle reaper on 60s interval via SLACK_SESSION_IDLE_MS, supervisor.shutdown() on SIGTERM/SIGINT/stdin-EOF. Quarantine tracking persists across deactivate/activate cycles.
  • Journal event emission at gate/session/pairing paths (#94) — 10 of the 19 EventKind values now fire in production: system.boot, system.shutdown, gate.inbound.deliver, gate.inbound.drop, gate.outbound.allow, gate.outbound.deny, exfil.block, session.activate, session.quiesce, session.deactivate. Remaining pairing.* and policy.* events land when their trigger points exist (pairing.accepted lives in the CLI skill, not the server). Journal path: ~/.claude/channels/slack/audit.log.
  • 430 tests — up from 370 in v0.5.0 (+60 covering the security fixes and wiring).

Fixed

  • assertSendable state-root denylist (S1) (#86) — assertSendable now accepts an explicit stateRoot parameter and rejects any path under it (via isUnderRoot + realpath resolution). Closes the doc-vs-code gap where CLAUDE.md claimed .env/access.json/audit.log/sessions/ were blocked, but only allowlistRoots + basename denylists were enforced. Operators who misconfigure SLACK_SENDABLE_ROOTS=~/.claude no longer leak access.json.
  • Journal broken-flag + schema-parse ordering (S2, S3) (#89) — _doWrite now checks if (this.broken) throw this.broken at entry, ensuring in-flight queue entries reject correctly. ZodError during JournalEvent.parse() no longer sets this.broken (schema errors are retriable); parse now runs before hash computation so a bad event never mutates state.
  • loadSession Zod schema validation (S4) (#87) — loadSession now validates against a strict SessionSchema Zod schema. Corrupt/attacker-modified session files (e.g., ownerId: 123) throw at load time instead of passing through to the supervisor. Added owner alias for ownerId during migration.
  • Per-tool Zod input schemas for MCP handlers (S5) (#91) — All 9 MCP tools (reply, react, edit_message, fetch_messages, download_attachment, upload_file, list_channels, get_thread_replies, list_sessions) now validate args via safeParse at switch entry. Malformed calls return structured error objects with code: "invalid_params" instead of runtime exceptions.
  • Supervisor quarantine state survives deactivate (S6) (#90) — SessionSupervisor now tracks quarantined keys in a Map<string, Error>. deactivate() on a quarantined handle moves it to the quarantine map (not silent deletion). activate() on a quarantined key rejects with the original error until explicit clearQuarantine(key).

Changed

  • Known caveats section removed — the SessionHandle.update() stub blocker no longer applies; supervisor is fully wired.

[0.5.0] - 2026-04-19

Added

  • Thread-scoped session storage (#45, #48, #51–54). Sessions now live at ~/.claude/channels/slack/sessions/<channel>/<thread>.json instead of one file per channel. Two parallel threads in the same Slack channel no longer share state; each has its own mutex and rolling context. Migration from the v0.4.x flat layout runs once at boot — existing conversations surface as a default thread without context loss. Directories are 0o700, files remain 0o600. See 000-docs/session-state-machine.md.
  • Node.js / tsx as alternative MCP runtime (#65) — @gog5-ops. Fixes a Bun event-loop bug where Socket Mode message events stop firing once StdioServerTransport attaches its own data listener to stdin. .mcp.json now launches via ./node_modules/.bin/tsx server.ts by default; tsx added as a runtime dependency. The test suite still runs under bun:test (unaffected).
  • Declarative policy engine (#47, #57, #58) — internal; not yet wired into the request pipeline. PolicyRule Zod schema with three effects (auto_approve / deny / require_approval), evaluate() decision procedure following XACML first-applicable ordering, shadow-rule detection for load-time linting, and monotonicity checks to prevent hot reloads from widening auto-approve coverage over existing denies. Enforcement ships in a subsequent v0.5.x once the session supervisor is complete. See 000-docs/policy-evaluation-flow.md and ACCESS.md.
  • Session supervisor skeleton (#60, #62, #66) — internal; not yet wired. SessionSupervisor actor (Armstrong-style) with activate(key, initialOwnerId?) and quiesce(key) methods, in-flight tool-call tracking via AbortControllers, and structured session.activate / session.quiesce log lines for the future journal sink. deactivate() / idle reaper / update() land in a subsequent v0.5.x. See 000-docs/session-state-machine.md.
  • Design-in-public contracts (#38–44, #59) — ARCHITECTURE.md with a four-principal model (user, Claude process, peer bots, Slack platform), 000-docs/THREAT-MODEL.md with trust boundaries and T1–T10 threats, and frozen design docs for the session state machine, policy evaluation, audit journal, and bot-manifest protocol. Every subsequent PR in these areas is reviewable against the doc; drift is a review block.
  • .gemini/commands/gemini-review.toml — project-specific review prompt grounded in the threat model, gate invariants, policy purity, and supervisor mutex rules. See the [Changed] section below for the workflow fix.

Changed

  • Gemini PR review workflow actually runs reviews now (#61, #64). The prior config allowlisted pull_request_read / add_comment_to_pending_review / pull_request_review_write but never declared an mcpServers.github block to provide them, so Gemini ran silently with a default "You are a helpful assistant." prompt and posted nothing. Ported the working pattern from sibling repos: slash-command prompt (/gemini-review) resolves to the TOML above, mcpServers.github runs the official ghcr.io/github/github-mcp-server container, and required env (GITHUB_TOKEN, ISSUE_*, PULL_REQUEST_NUMBER, REPOSITORY) is wired. If you rebase off this repo, cherry-pick .github/workflows/gemini-review.yml and .gemini/commands/gemini-review.toml.
  • Boot-time fail-fast on unparseable SENDABLE_ROOTS (#56) — misconfig surfaces at startup instead of at first outbound upload, matching the posture of .env token loading.

Fixed

  • CLAUDE.md driftlib.ts line count (460 → ~915) and policy.ts description updated to reflect the merged evaluator / shadow linter / monotonicity check (Epic 29-A).

Security

  • gate() permission-reply hardeningPERMISSION_REPLY_RE checked at the gate so peer bots (whose allowBotIds opt-in landed in v0.4.0) cannot inject permission-approval text. Self-echo detection triple-checks bot_id / bot_profile.app_id / user === botUserId to cover Slack payload variants.

Known caveats

  • SessionHandle.update() remains a staged stub that rejects with a "not yet implemented" bead pointer. The next v0.5.x lands it together with the deactivate and policy-wiring work, at which point the supervisor is callable from server.ts.

[0.4.0] - 2026-04-18

Added

  • Per-channel allowBotIds for opt-in cross-bot message delivery (#33) — @CaseyMargell. Default-safe: absent or empty allowBotIds preserves the prior "all bot messages dropped" behavior for every existing install. Self-echo detection uses a triple-check against bot_id, bot_profile.app_id, and user === botUserId to cover Slack payload variants (including as_user=false posts and multi-workspace installs). Permission-reply-shaped peer-bot messages (y abcde, no xyzwq) are dropped at the gate. Peer bots still cannot approve permission prompts — that path is gated on the top-level allowFrom. See ACCESS.md for schema and security tradeoffs.
  • System prompt now warns Claude that peer-bot messages carry the same prompt-injection risk as human messages and may be coordinated by an attacker who controls the peer bot's session (#33).
  • Audit log line on every delivered bot message ([slack] bot message delivered) — diagnostics for multi-agent flows (#33).

Changed

  • Gemini PR review workflow switched from pull_request to pull_request_target so fork PRs receive AI review (#34). Workflow is read-only by design: checks out the PR head SHA but never executes any code from it, and the run-gemini-cli action is restricted to read-only shell tools.
  • PERMISSION_REPLY_RE moved from server.ts to lib.ts so the gate can drop permission-reply-shaped peer-bot text. No behavior change for human permission replies (#33).

Fixed

  • Docs: corrected runtime dependency count in CONTRIBUTING.md (was "three", actually four including zod) and filled the CODE_OF_CONDUCT.md enforcement contact placeholder with jeremy@intentsolutions.io (#28).

[0.3.1] - 2026-04-15

Fixed

  • MCP server now terminates cleanly on client disconnect (#7) — @jinsung-kang
  • Deduplicated event delivery from message + app_mention dual-fire (#8) — @CaseyMargell

Changed

  • Governance: Added CODEOWNERS, PR template, SECURITY.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md (#10)
  • CI: Enabled Gemini PR review, CodeQL, OpenSSF Scorecard (#9, #10)
  • CI: Bumped actions/checkout v4→v6, codeql-action v3→v4, upload-artifact to v7 (#14, #17, #21)
  • Dependabot: Removed npm ecosystem (bun.lockb incompatibility), kept github-actions only (#20)
  • Docs: Updated CLAUDE.md line counts and dependency count (#22)
  • Docs: Added contributors section to README

[0.3.0] - 2026-04-11

Added

  • File allowlist documentation in MCP instructions — Claude now knows which paths are blocked (#5)

Fixed

  • Security: assertSendable rewritten with defense-in-depth — allowlist roots + basename denylist + parent-dir denylist + symlink resolution (#5)
  • Security: Outbound gate enforced on react, edit_message, fetch_messages, download_attachment tools (#5)
  • Security: Display name sanitization prevents prompt injection via Slack usernames (#5)
  • Security: access.json atomic write with mode 0o600 passed directly to writeFileSync (#5)
  • Security: isSlackFileUrl() validation prevents token exfiltration via crafted file URLs (#5)
  • Security: Dependencies pinned to exact versions with --frozen-lockfile on install (#5)
  • Restored start:node script for Node.js users (#5)

Changed

  • BREAKING: Default dmPolicy changed from pairing to allowlist — new installs must add user ID before DMs work (#6)
  • BREAKING: assertSendable policy changed from permissive (allow all except state dir) to restrictive (deny all except inbox + configured SLACK_SENDABLE_ROOTS) (#5)

Security

  • 7 vulnerabilities closed from pre-deployment security review by @maui-99
  • 34 new tests covering all security-critical functions (52 → 86 total)

[0.2.0] - 2026-04-09

Added

  • Permission relay — approve/deny Claude Code tool calls remotely via Slack (#3)
  • Block Kit interactive buttons for permission prompts (Allow/Deny/Details) (#4)
  • claude/channel/permission capability declaration
  • Text-based fallback for permission replies (y/n + 5-char code)
  • TTL-based cleanup for pending permission requests (5-minute expiry)
  • mrkdwn escaping to prevent Slack injection via tool names/descriptions
  • zod as explicit dependency for schema validation

Fixed

  • Security: owner-only approval for permission prompts (session owner must be in allowFrom)
  • Security: outbound gate enforced on permission relay messages
  • Security: delete-after-send ordering to prevent lost verdicts if notification fails
  • Anthropic spec compliance: skill namespace, install commands (#2)
  • Documentation: added "the" before "Claude Code", removed first MCP claims

Changed

  • License changed from Apache-2.0 to MIT

[0.1.0] - 2026-03-20

Added

  • MCP server with claude/channel capability — single-file implementation (server.ts)
  • Socket Mode connection (no public URL required)
  • Inbound gate with sender allowlist and pairing flow
  • Outbound gate restricting replies to delivered channels
  • File exfiltration guard (assertSendable())
  • Tools: reply, react, edit_message, fetch_messages, download_attachment
  • Text chunking at 4000 chars (paragraph-aware)
  • Thread support via thread_ts
  • Attachment download with name sanitization
  • Bot message filtering
  • Link unfurling disabled on all outbound messages
  • Static access mode (SLACK_ACCESS_MODE=static)
  • Skills: /slack-channel:configure, /slack-channel:access
  • Three runtime options: Bun, Node.js/npx, Docker
  • Test suite for security-critical functions — gate, assertSendable, assertOutboundAllowed (server.test.ts)
  • Extracted shared types and helpers to lib.ts for testability
  • CI pipeline with Bun typecheck and test runner (GitHub Actions)
  • GitHub Pages landing site
  • Project one-pager and operator-grade system analysis (gist)

Fixed

  • Plugin schema: renamed manifest.jsonplugin.json with correct fields for upstream submission
  • MCP server args: fixed .mcp.json to use correct server.ts path
  • Skills directory structure: moved to skills/{name}/SKILL.md pattern per upstream conventions