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.
- Docs: True up
CLAUDE.md+README.mdto the current codebase — test count~1,033/1,237→~1,251(authoritativebun testcount; noted theit/testgrep undercounts at ~1,120 because it missestest.eachexpansions and the Gherkin runner), refreshed the module LoC table (server.ts3820 /lib.ts2552 /supervisor.ts1810 /journal.ts1461), and dropped the stale "thin adapter" label onslack-delivery.ts(738 LoC). ReconcilesCLAUDE.md↔README.md↔AGENTS.md. (#257)
-
File-upload replies are now crash-durable — the crash-safety epic's rich paths are complete (
ccsc-o7x.5part 2 of 2, epicccsc-o7x). Wires the part-1 building blocks live:executeReplynow routes a non-streaming reply with files throughexecuteReplyFileDurablePath→deliverFileReplyDurably(records the text chunks + one obligation per file, delivers in order), and the delivery poller'ssendbranches onobligation.uploadto upload files via the new productioncreateFileSendDepsadapter (realfilesUploadV2). The adapter'sreadAndGuardre-runs the outbound exfil guard —assertSendable+assertNoSecretValueson the latin1 content and the filename — on the bytes it actually uploads (read-once, TOCTOU-safe), journalingexfil.blockand throwingExfilBlockedErroron a hit;findUploadeddedups by a recordeduploadedFileId(exact) or a(filename, size)thread scan scoped to files shared at/after the obligation'screatedAt(returningnull, 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'sdrainOutboxnow treatsExfilBlockedErroras non-retryable — a blocked file is dead-lettered on the first failure instead of burningmaxAttempts(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:ExfilBlockedErrordead-letters on the first failure).slack-delivery.tsstays 100% line / 100% func;server.tsdispatch + 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 epicccsc-o7x— part 1 of 2; the last deferred rich path). A reply withfilesuploads each viafilesUploadV2(a separate Slack upload, notchat.postMessage), whichccsc-o7x.3deferred. Design (ADR-002 addendum, 2026-06-24) records two hard facts and the contract they force: a Slack file-share message cannot carry appmetadata(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 inslack-delivery.ts:sendFileObligation(dedup → read+guard → upload, read-once: the injectedFileUploadDeps.readAndGuardreads the file once, scans those exact bytes, and returns them, thenuploadsends 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 ofdeliverChunkedReplyDurably: records all text-chunk + file obligations atomically, delivers in order, marks eachdelivered/pending/dead, recordsuploadedFileIdafter a successful upload for later dedup), andExfilBlockedError(in thelib.tskernel, so BOTH the inline path and the poller can classify it as non-retryable without aslack-delivery↔supervisorimport cycle) the loop treats as non-retryable (a blocked file is markeddead, never uploaded, never retried). The kernelDeliveryObligation(lib.ts) gains additive optionalupload+uploadedFileIdfields (AGP impact: none — the reply-delivery outbox is not in AGP's reimplemented subset, verified againstagent-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 wireexecuteReply/the poller — the productionfilesUploadV2adapter + dispatch is part 2 (which also adds thedrainOutboxExfilBlockedErrorbranch now that the error is in the kernel). 11 added tests;slack-delivery.tsstays 100% line / 100% func; all nine gates green. -
Streaming replies are now crash-durable (
ccsc-o7x.6, crash-safety epicccsc-o7x— second of the deferred rich paths, after chunkedccsc-o7x.4). A streaming reply (stream:true, text over the chunk limit) posts an initial message and grows it viachat.update— which can't be idempotently replayed mid-stream, soccsc-o7x.3deferred it. Design (ADR-002 addendum): a stream-finalize obligation carrying the full text.beginDurableStream(new, inslack-delivery.ts) records ONEpendingDeliveryObligationwhosepayloadis the complete reply text before the stream starts;executeReplyStreamingPaththen resolves it from thestreamReplyoutcome —completed→markDelivered; 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'ssenddoes not re-runassertOutboundAllowed, andstreamReplycan'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 obligationpending, and the boot-drain / poller redelivers the full text as a freshchat.postMessage(the partial streamed message is orphaned). Nolib.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 sofindDeliveredcan't recognise it) and the >40k-char single-message redelivery edge (dead-letters asmsg_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;markDeadwith reason; crash-leaves-pending across a restart;DurableUnavailableErrorrecords nothing);slack-delivery.tsstays 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 arequireMentionchannel 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 threadthread_ts ?? ts— human-only; peer agents must mention every message). Every inbound gate drop now carries a structureddropReasonso the journal shows why Claude stayed silent (ccsc-apj.2);isMentionedignores a<@bot>quoted inside a code block or blockquote via Slackblocks(ccsc-apj.4); the intentionalmessage_changededited-message drop is documented inARCHITECTURE.md+THREAT-MODEL.md(ccsc-apj.3); and interaction mode is a first-class/slack-channel:accesschoice 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 epicccsc-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 withrate.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, epicccsc-uorlh). A channel may setperUserSessionsso each sender gets their own bridge session within a shared thread (separate state file, supervisor handle, andownerId) —SessionKeygains an optionaluserIdthat nests the file atsessions/<channel>/<userId>/<thread>.jsonunder the same realpath + component-injection guards as channel/thread. Default off (behavior unchanged); isolates per-thread book-keeping, not Claude's conversation memory. Updates thesession-state-machine.mdcontract. 12 added tests; nine gates green. (PR #248) -
Slack-observable mutes + rate limits (
ccsc-yl6k9, epicccsc-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 epicccsc-uorlh). An optionalmaxConcurrentSessionscap (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 newsession.activate_rejectedEventKind (reactivating an already-live session is never capped). A read-only!agentsadmin 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 epicccsc-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 ownDeliveryObligationwith 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). Newsupervisor.recordTerminalDeliveries(token, replies[])— the batch sibling ofrecordTerminalDelivery— appends all Npendingobligations and clears the in-flight marker in one atomic write (all-or-nothing, so a crash mid-record never leaves a partially-recorded reply). Newslack-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 ipendingand 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 ideadand rethrow (the prefix already landed — the honest partial-reply outcome).executeReplynow 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, sopendingDeliveries(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 (6deliverChunkedReplyDurably: 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; 2recordTerminalDeliveries: atomic batch append + clears marker, fenced rejection writes nothing).slack-delivery.ts100 % line / 100 % func;supervisor.ts98.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 epicccsc-o7x).executeReplynow routes a single-message text reply (nostream, no files, fits one chunk, has a thread, supervisor up) through the durable path: it records aDeliveryObligationbefore thechat.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 inccsc-o7x.4/.5/.6). The metadata-stamping is now a single shared site (createReplyPosterinslack-delivery.ts, used by both the poller and the inline send); the durable path is extracted toexecuteReplyDurablePathto keepexecuteReplyunder the CRAP threshold. 1 added test (createReplyPosterstamps metadata + returns ts) atop thedeliverReplyDurablysuite from part 2; full suite 1133 pass;slack-delivery.ts100 % 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 epicccsc-o7x). An ADR-002 addendum records the reply-delivery contract for CCSC's inverted architecture (Claude is the host; a reply is a synchronousreplytool 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 beadsccsc-o7x.4/.5/.6. NewdeliverReplyDurably(deps, reply)inslack-delivery.tsis the tested building block: it records apendingobligation 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 → markdeadwith the error recorded); aDurableUnavailableError(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 touchexecuteReply— 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.ts100 % 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 epicccsc-o7x). Activates the reply-delivery machinery (ccsc-o7x.2.1–.2.3) in production: adeliveryTimertick callssupervisor.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 moduleslack-delivery.tsholdscreateDeliverySendDeps(client)— the Slack-facing glue binding the vendored idempotency logic (makeIdempotentSend/deliveryIdempotencyKeyinlib.ts) to a realWebClient:findDeliveredscans 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), andpostsends with the key in Slack messagemetadata. Kept as a sibling module (not inline inserver.ts) so it unit-tests against a fakedWebClientwithout triggeringserver.ts's module-load side effects; no Slack-SDK code crosses into the vendored kernel. This part does not touch thereplytool path —executeReplystill 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-enddrainOutbox× real adapter: posts-once-and-marks-delivered, ack-loss-recovery dedups without re-posting).slack-delivery.ts100 % 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.
-
Greptile reviewer config tuned to the fullest (
ccsc-9xl)..greptile/config.jsongainstriggerOnUpdates(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), andignorePatterns(a gitignore-style string skippingbun.lock,node_modules/, the vendored.audit-harness/, and minified bundles). Strictness 3,commentTypes ["logic","syntax"], and the four structured security-invariant rules are unchanged;statusCheckstays 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.2guarantees every policy decision is journaled with no silent gaps, but it could only assert that against a test-local route→kind switch:server.tswrote thepolicy.allow/policy.require/policy.approvedevents as inline object literals and runsmain()(plus top-level Slack-client construction) on import, so a test could not import the module to drive those exact writes. Ifserver.tsand the test-local map drifted, the audit gap would be invisible. The event source is now extracted intopolicy-dispatch.ts(already the side-effect-free home of theccsc-06sdeny helpers): pure buildersbuildPolicyAllowEvent/buildPolicyRequireEvent/buildPolicyApprovedEventthatserver.tscalls, plus an exhaustivepermissionRouteJournalEvents(route, ctx)dispatcher with anever-guard — a newPermissionRoutevariant that forgets its audit record now fails the production build, not just a test. Theccsc-1iw.2block re-anchors to the productionpermissionRouteJournalKinds. Thedenypair stays onrecordPolicyDenyToJournal(awaited + resilient before the MCP deny notification,ccsc-06s) — untouched — with a new consistency test pinning the dispatcher'sdenyarm to that helper so the two execution paths cannot drift. Type-only import ofPermissionRoutefrom the vendoredlib.tskernel (no kernel mutation). +8 tests (1156→1164);policy-dispatch.ts100% 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 "readROADMAP.mdNon-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 epicccsc-o7x— completes sub-epic 1-B). Closes the one gap the delivery poller's fencing lease can't: the ambiguous failure where achat.postMessagelands at Slack but the ack is lost before the obligation is markeddelivered, so the next pass would re-post the reply. New pure helpers inlib.ts(additive — vendored kernel):deliveryIdempotencyKey(ob)derives a deterministicccsc-reply:<id>key from the obligation's stableid(same obligation → same key, across restarts), andmakeIdempotentSend(deps)is a pure combinator that wraps a raw Slack post — before posting it asksdeps.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 messagemetadata,event_typeccsc_reply_deliveryvia the newDELIVERY_METADATA_EVENT_TYPE). The delivery poller (drainOutbox,ccsc-o7x.2.2) consumes the wrappedsendunchanged — 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.tsimports no Slack SDK; the productionfindDelivered(scanconversations.repliesfor the delivery metadata) andpost(chat.postMessagewith the key stamped) are injected fromserver.ts— that adapter + thedrainOutboxtimer 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-enddrainOutbox×makeIdempotentSendtest proving ack-loss on the first post yields exactly one visible message with the obligationdelivered). Sub-epic 1-B (ccsc-o7x.2) is now complete — outbox record (2.1) + leased poller (2.2) + idempotent redelivery (2.3).lib.tsstays at 100% line coverage. -
Leased delivery poller — retries retryable Slack errors with backoff, dead-letters permanent ones (
ccsc-o7x.2.2, crash-safety epicccsc-o7x— second of sub-epic 1-B). The consumer side of theccsc-o7x.2.1outbox:supervisor.drainOutbox(send, opts?)makes one pass over everypendingDeliveryObligation(pendingDeliveries()), performs the real Slack send via an injectedsend, and resolves each obligation in one fenced write — replacing the old stderr-and-swallow on a failedchat.postMessage. success →state: 'delivered',attemptsincremented; retryable error (rate limiting, transient 5xx, network) is retried in-pass with exponential backoff up to a cap (maxAttempts, defaultDEFAULT_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 optionalDeliveryObligation.lastError(a dead-letter is never a silent black hole). The classification + backoff are pure functions added tolib.ts—classifyDeliveryError,NON_RETRYABLE_SLACK_ERRORS,extractSlackErrorCode(readserr.data.errorthenerr.code, no Slack-SDK import),computeBackoffMs(deterministic exponential, capped) — added additively becauselib.tsis vendored by AGP (ADR 009); the send loop + state transitions live insupervisor.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 obligationpendingfor 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 inccsc-o7x.2.3; the lease covers the in-process superseded-owner race. The poller touches only the outbox — neveraudit.log, never the projection (new § "The delivery poller" inaudit-journal-architecture.md). Production timer-wiring ofdrainOutboxintoserver.tslands afterccsc-o7x.2.3so 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.ts98.33% line / 99.84% func,lib.ts100% line. -
Reply-delivery outbox — record a durable obligation at turn-terminal (
ccsc-o7x.2.1, crash-safety epicccsc-o7x— first of sub-epic 1-B). Replaces fire-and-forget replies with a transactional outbox: newDeliveryObligation(id+channel+thread+payload+attempts+state+createdAt) persisted on theSessionfile in an additive optionaloutboxarray.handle.recordTerminalDelivery(token, { id, channel, thread, payload })writes a freshpendingobligation 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 allpendingobligations 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 toaudit.logand the projection is never made authoritative for delivery (newaudit-journal-architecture.md§ "The reply-delivery outbox is NOT the audit projection" states the boundary). The send (consuming the obligation) + retry/dead-letter isccsc-o7x.2.2; idempotent redelivery isccsc-o7x.2.3. 8 unit tests (obligation stamped + persisted; atomic-with-terminal-marker clearsinFlightTurn+ appends in one write; fenced rejection writes nothing; accumulation;pendingDeliveriesacross sessions; crash → fresh-supervisor still sees pending; non-pending + clean sessions excluded; empty dir →[]).supervisor.tsstays at 100% line + function coverage. -
Lease-loss routes a live turn into quarantine (
ccsc-o7x.1.3, crash-safety epicccsc-o7x— completes sub-epic 1-A). A fencedhandle.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 existingquarantinedterminal (sharedquarantineSelfhelper, 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 subsequentactivate()rejects until a humanclearQuarantines it.fenceTokenis 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.tsstays 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 epicccsc-o7x). Makes the fencing lease crash-durable and recovers cleanly after a process death. New optionalinFlightTurnmarker (owner+token+startedAt+heartbeatAt) on the persistedSessionfile (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'srecoverOnStartup()reads every session file and, per persisted marker, classifies it with the pureclassifyRecovery(turn, now, ttlMs): a lapsed heartbeat (owner provably dead) is requeued — marker cleared, session left re-activatable,session.recovery.requeuedjournaled; 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.orphanedjournaled (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 ofccsc-o7x.1.1's fence). Two newEventKinds (session.recovery.requeued/session.recovery.orphaned).supervisor.tsis outside AGP's vendored kernel — zero re-vendor impact. 28 unit tests (pureclassifyRecoveryboundary; the sweep's clean / requeue / orphan / unreadable / token-seed / multi-session paths; andrecordTurnStart/recordTurnEndincl. a record→crash→fresh-supervisor-sweep round-trip).supervisor.ts+journal.tsstay at 100% line + function coverage. -
Per-turn fencing lease + heartbeat on the session supervisor (
ccsc-o7x.1.1, first of the crash-safety epicccsc-o7x). Every activeSessionHandlenow holds aLease(monotonictoken+owner+heartbeatAt) acquired right after it goes active. New pure helpers insupervisor.ts—isLeaseStale,heartbeatLease,resolveLeaseTtlMs(SLACK_SESSION_LEASE_TTL_MS, default 30s) — plushandle.heartbeat(token)(renews iff the token is the current owner's) and an additive optionalfenceTokenarg onhandle.update(fn, fenceToken?): a fenced write is rejected, without callingfnor 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 isccsc-o7x.1.2(the recovery sweep) and routing a lapsed lease into the quarantine terminal isccsc-o7x.1.3. Purely additive — the on-disk session file stays the source of truth, unfencedupdate(fn)is unchanged, andsupervisor.tsis 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.tsstays at 100% line + function coverage.
- Bumped
tsx4.21.0→4.22.4 to clear an esbuild RCE advisory (ccsc-dqu). GHSA-gv7w-rqvm-qjhr (high):esbuild >=0.17.0 <0.28.1ships the Deno install module without binary-integrity verification, enabling RCE via a maliciousNPM_CONFIG_REGISTRY. CCSC pulledesbuild@0.27.7transitively through thetsxdevDependency, which failed the CIbun audit --audit-level=highgate on every PR repo-wide.tsx@4.22.4(within the existing^4.21.0range) pinsesbuild ~0.28.0→ resolves the fixed0.28.1. Runtime exposure was nil —tsx/esbuildare build-time only (thenpx tsx server.tsNode fallback path); CCSC never invokes esbuild's Deno installer.bun audit→ 0 high vulnerabilities; typecheck + 1156-test suite green.
- Inbound secret-value scrub — a token can never surface in a tool result returned to the agent (
ccsc-z0n.2, token-firewall epicccsc-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, thedownload_attachmentAuthorizationheader), 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 pureredactSecretValues(text, placeholders)+buildSecretPlaceholderMap(resolve)inlib.ts, wired into ascrubToolResultover the single tool-dispatch return chokepoint inserver.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>}}, fromccsc-z0n.1) before the agent reads it, and the near-miss is journaled asexfil.block; and (b) aTHREAT-MODEL.mdT4 update documenting that CCSC achieves the credential-placeholder-swap goal structurally. This is the inbound (tool-result → agent) complement ofccsc-z0n.3's outbound (agent → Slack) guard, reusing the sameSECRET_DECLARATIONSset. 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 thebuildSecretPlaceholderMap→redactSecretValuesseam. - Value-exfiltration guard —
assertNoSecretValuesblocks a live token leaving in any outbound payload (ccsc-z0n.3, token-firewall epicccsc-z0n). New pureassertNoSecretValues(payload, secretValues)inlib.tsis the companion toassertSendable: 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 toassertSendable's signature) becauselib.tsis vendored by AGP (ADR 009); the two guards compose, they don't merge. The watch-set is built bybuildSecretValueSetfrom theSECRET_DECLARATIONStable (ccsc-z0n.1) — the guard never hardcodes a value or a name, and an empty set is a no-op.server.tsbuilds the live-value set from the parsed.envat boot and wires the guard into the three agent-controlled outbound surfaces — reply text (both streaming + non-streaming branches),edit_messagetext, and uploaded-file body + filename — each journaling anexfil.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 thebuildSecretValueSet→ guard seam end-to-end. AGPsubstrate/UPSTREAM.mdflags 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 epicccsc-z0n). NewSECRET_DECLARATIONSfrozen table inlib.tsis 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 —buildSecretValueSettakes a resolver solib.tsstays free ofprocess.envreads. Schema only: the boundary injection (ccsc-z0n.2) and theassertSendablevalue-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.
- 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 in000-docs/MUTATION_REPORT.md(policy.tsrecovered to 89.78% after theccsc-2etsurvivor-killing tests). Contributor-facing quality infrastructure only — no runtime behavior change. - Relicensed from MIT to Apache License 2.0. Replaces the MIT
LICENSEwith the canonical Apache 2.0 text, adds aNOTICEfile (Apache convention) attributing the project to Jeremy Longshore / Intent Solutions and noting the vendored MIT@intentsolutions/audit-harness, retags theSPDX-License-Identifierheader in all 32 first-party source/feature files toApache-2.0, and updatespackage.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).
- Fresh-clone onboarding surface —
AGENTS.md,CONTRIBUTING.md,/slack-channel:installskill, README hygiene (ccsc-8eo). Closes the cold-start gap surfaced by the post-v0.10 onboarding audit. NewAGENTS.mdat 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.mdexpanded 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, andbd-vs-GitHub-issue workflow for external contributors. Newskills/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;doctorruns 10 health checks against a live install (token validity viaauth.test, file perms, audit-chain integrity, bot-in-channel membership);repairauto-fixes safely fixable findings (perms, corruptaccess.json);manifestexports 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);resetarchivesaccess.json+sessions/while preserving.env+audit.log;uninstallarchives the state dir (norm -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, andFor AI-assisted setuplead-in pointing at the install skill. CONTRIBUTING link added near bottom.CLAUDE.mdis 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). Newaudit-key-cli.tspure module shipsrunAuditKeyCli(argv, deps, defaults)+auditKeyInit(opts, deps)+auditKeyRotate(opts, deps)+parseAuditKeyArgv(); newscripts/audit-key.tswires production deps (realsopssubprocess viaexecFile, realJournalWriter.open, realnode:fs/promises). Operator runsbun scripts/audit-key.ts initto generate a fresh Ed25519 keypair, writeaudit.key.sops.yaml.tmpmode 0o600,sops --encrypt --in-place, atomic rename →audit.key.sops.yaml, and print the public key forpass insert+ external gist publication.bun scripts/audit-key.ts rotate --reason=<scheduled-90day|compromise-suspected|operator-initiated> --confirm-bridge-stoppedloads the current keypair, generates a new one, writes ONE finalsystem.key_rotationevent toaudit.logsigned under the OLD key (carryinginput.old_public_key+input.new_public_key+input.rotation_reason), then atomically archivesaudit.key.sops.yaml→audit.key.sops.yaml.<unix-ms>.archivedand swaps in the new key. The verifier picks up the new key automatically on its next walk (RFC 6962 CT pattern, perverifyJournal()'s existingsystem.key_rotationhandler). Safety invariants enforced by the CLI: refuses to overwrite an existing key file oninit(one-shot per state dir); refuses on a stale.tmpor.new.tmp(interrupted prior run — operator must verify contents before proceeding); refusesrotatewithout--confirm-bridge-stopped(cross-process writer collision would corrupt the hash chain —ACTIVE_PATHSonly 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 viaparseKeyPairYaml, 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-endverifyJournalchain validation under OLD then NEW key, decrypt-failure, encrypt-failure-with-recovery-guidance), andrunAuditKeyClidispatch (5 — help, default keyPath, EX_USAGE 64 for malformed argv, unknown subcommand). Sync fix to000-docs/key-management.md§ Rotation cadence: the example JSON event usedbody.{...}field names but the writer never emittedbody— corrected to the actualjournal.tsshape (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). Newaudit-key-loader.tssibling module shipsloadSigningKey(opts)+parseNoAuditSigningFlag(argv). Spawns the operator's localsops -dbinary at boot, pipes plaintext YAML throughparseKeyPairYaml, returns a structuredAuditSigningResolution:loaded(withstaleWarningset when key is >90 days old),disabled(when--no-audit-signingflag set + file absent), orerror(every other failure mode).server.tsboot path now calls the loader BEFOREJournalWriter.open()and passessigningKey+ initialpolicyDigest(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/shmtmpfs, 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 withverifyJournal). First of 5 server-wiring beads (also filed:ccsc-0jjadmin dispatcher wiring,ccsc-00fmute+rate-limit store wiring,ccsc-h1hstream-reply wiring,ccsc-l1fccsc audit-key {init,rotate}CLI). - Multi-agent setup recipe doc (
ccsc-6gw, closes multi-agent epicccsc-7xq). New000-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 mutualallowBotIds, 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 epicccsc-7xqnow closes — 3 of 3 children shipped (rate limit, mute verb, recipe doc);ccsc-8fcslack/announce_taskdeferred per plan. !mute <@bot>/!unmute <@bot>operator verbs (ccsc-gjm, second piece of multi-agent epicccsc-7xq). Newmute-store.tssibling module shipscreateMuteStore()+parseSlackMention(). New admin verbs ride on theadmin.tsdispatcher fromccsc-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 inlib.tschecks the mute store BEFORE the rate-limit check — explicit operator intent always wins over automatic loop-breaker. Drop carriesdropReason: 'admin.muted'(distinguishable in audit log fromrate.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.isMutedauto-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 epicccsc-7xq). Newpeer-bot-rate-limit.tssibling module shipscreatePeerBotRateLimitStore()+RateLimitConfig+ the gate hook. Defends against A→B→A runaway loops when two or more peer bots are opted into one channel viaallowBotIds: 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 recordsgate.inbound.dropwithreason: 'rate.cross_bot_loop'(newGateDropReasonunion 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. NewChannelPolicy.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 inGateOptions.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). Newstream-reply.tssibling module shipsstreamReply(opts, deps)— posts the initial chunk viachat.postMessage, progressively appends viachat.updateon the cachedtsuntil 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 EventKindsystem.stream_finalize. Per the locked design: exactly TWO journal events per stream — onegate.outbound.allowat start carrying the pre-committed full-text SHA-256, onesystem.stream_finalizeat end carrying chunks-sent + matching hash. NO per-chunk events (would cause O(n²) canonicalize cost + finalization race). Cached-ts invariant: everychat.updatereuses thetsreturned by the initialchat.postMessage. Per-chunk gate check viaassertOutboundAllowedruns on every chunk (without per-chunk events) — if the channel is removed mid-stream the stream finalizes withoutcome: '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 to000-docs/audit-journal-architecture.md. - Admin commands hardened —
!clear/!restartrouted through gate → policy → journal → execute (ccsc-3w0). The convergence point of the CCSC rollout (#167). Newadmin.tsmodule shipsparseAdminCommand()(pure parser) +dispatchAdminCommand(cmd, deps)(composable dispatcher with injected deps for testability). Mirrors theacp-adapter.ts/policy-dispatch.ts/nonce-hitl.tspattern — pure sibling module, no boot-time side effects. Production wirescreateTmuxSendKeys(sessionName)(argv-modeexecFileSync('tmux', ['send-keys', '-t', SESSION, ...keys])— no shell interpolation, refuses empty sessionName loudly at boot).!clearis reversible (no nonce): runssupervisor.quiesceAndDeactivate()+ tmux/clear+ ♻️ reaction.!restartis destructive (HMAC nonce + cross-channel HITL required day-1 per ccsc-ofn): no-nonce → mint + DM challenge; with-nonce → verify; onok→ 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 recordsexpires_atbut NOT the nonce value (defeats journal-replay attack). NewChannelPolicy.adminCommands?: { allowFrom: string[] }per-channel opt-in (default-safe: absent → no admin verbs regardless of who types them). NewstripBotMention()helper inlib.ts— closes the Gemini #1 finding from PR #157 (mention-strip lets admin parser see normalized text onrequireMention=truechannels). New.dependency-cruiser.jsruleno-admin-imports-manifestenforces 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 withadmin.— Claude cannot invoke admin verbs by tool call; only operator Slack commands trigger them. 28 unit tests + 9 Gherkin acceptance scenarios infeatures/admin_commands.featurecover 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 to000-docs/audit-journal-architecture.md(EventKind table, nonce-not-journaled invariant, v2 signing rationale, module-boundary invariant). § "Admin-verb entry points (ccsc-3w0)" added to000-docs/session-state-machine.md(flow diagram, supervisor-API rationale, module placement rationale). - HMAC nonce + cross-channel HITL primitives (
ccsc-ofn). Newnonce-hitl.tsmodule ships the testable primitives for the two-step handshake that gates destructive admin verbs (!restarttoday, 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. ExportscreateMemoryNonceStore(),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. UsestimingSafeEqualfor 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 toACCESS.mddocumenting flow, threat coverage, and verb scope (nonce required for destructive verbs only —!clearruns unguarded,!restartis gated). The dispatcher integration arrives inccsc-3w0(admin commands); this PR delivers the primitives the admin module will compose with Slack DM delivery. - Policy.deny context-stripping (
ccsc-06s). On everypolicy.denydecision the dispatcher now writes a two-event journal sequence — full-detailpolicy.denyfollowed by a sanitisedpolicy.deny.context_strippedmarker 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 intopolicy-dispatch.ts(mirrors theacp-adapter.tspattern 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 EventKindpolicy.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 realJournalWriter. § "Context-stripping on policy.deny" added to000-docs/policy-evaluation-flow.mddocumenting 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). Newcrypto.tsmodule exportsEd25519KeyPair,generateKeyPair,parseKeyPairYaml/serializeKeyPairYaml(SOPS-decrypt round-trip),signBytes,verifySignatureBytes,derivePublicKey. Uses Node's built-in Ed25519 — no new runtime deps.JournalEventschema extends to az.union([z.literal(1), z.literal(2)])discriminated version; v2 events optionally carrysignature(88-char base64 of 64-byte Ed25519) +policy_attestation: { digest, alg: 'sha256' }.JournalWriteracceptssigningKey+policyDigestoptions; 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 everysystem.key_rotationevent (the event itself verifies under the OLD key, theninput.new_public_keybecomes active per RFC 6962 CT pattern). Newsystem.key_rotationEventKind. NewpolicyDigest()export frompolicy.ts(SHA-256 of canonical JCS of rules sorted by id — content-fingerprint, authoring-order-independent). New depcruise ruleno-journal-imports-policyenforces module-boundary invariant: the digest crosses as an opaque hex string. § "Signed events — journal v2" added to000-docs/audit-journal-architecture.md. - Tier-aware
evaluate()— strictest-tier-wins, first-applicable within tier (ccsc-8pw). Combining algorithm extends to walk tiers in orderadmin → 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 — existingaccess.jsonfiles load and evaluate identically. Captures the asymmetric override needed for multi-operator deployment: adminauto_approveoverrides workspacedeny(intended Admin-overrides path), admindenyoverrides workspaceauto_approve(the silent-footgun caseccsc-4g8lints for). § "Combining algorithm" rewritten in000-docs/policy-evaluation-flow.mdto document tier semantics. - Cross-tier shadow detection in
detectShadowing()(ccsc-4g8). Adds theMatchSpec.tier?: 'default' | 'workspace' | 'user' | 'admin'optional field (defaulting to'default'— existingaccess.jsonfiles load unchanged) and extendsdetectShadowing()with a second pass that flags cross-tier intersection between any non-Adminauto_approverule and any Admindenyrule. The classic within-tier subset check is preserved; the new check catches the silent-footgun class where a Workspaceauto_approveand an Admindenyaren't in a subset relationship but share a concrete tool call.ShadowWarninggains acrossTier: booleanfield (backward-compatible — existing warning consumers seecrossTier: falseon every existing warning). Adds 15 acceptance scenarios in the newfeatures/tier-shadow.feature(Gherkin, hash-pinned) + 15 direct unit tests inserver.test.tsformatchesIntersect/effectiveTier/ShadowWarningshape. § "Cross-tier intersection" added to000-docs/policy-evaluation-flow.mddocumenting the asymmetric check, intersection field semantics, and why tier is excluded from intersection itself.evaluate()does NOT yet act on tier — that lands inccsc-8pw(combined v2 cluster). 8 files pinned in.harness-hashnow (addedfeatures/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). Addsfeatures/jcs-vectors.json(30 supported vectors + 5 throws-vectors, every entry annotated with the RFC 8785 section it exercises) andfeatures/jcs-interop.test.tsrunning each vector throughcanonicalJson()(journal.ts:668) with byte-exact equality assertions. Vector file pinned in.harness-hashso any edit requires explicit--init(same tamper-evident discipline as.featurefiles). 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 to000-docs/audit-journal-architecture.md. 40 new tests, all green; total now 757.
- ACP adapter extracted from
server.tsintoacp-adapter.ts(ccsc-21xfollow-up). Addresses Gemini review on PR #172: (1) the inlined test duplicate ofmapAcpSessionCancelwas a drift risk — extracting the adapter into a side-effect-free module letsserver.test.tsimport the production code path directly; (2)AcpResponse.idwidened tostring | number | nullto be fully JSON-RPC 2.0 §5.1 compliant (the spec requiresnullwhen an error occurs detecting the request id) — the two unsafeas string | numbercasts 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.
- ACP boundary adapter —
mapAcpSessionCancelinserver.ts(ccsc-21x). Adds a thin JSON-RPC 2.0 adapter that translates Agent Client Protocolsession/cancelrequests onto the existingsupervisor.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 betweenmanifest.tsandpolicy.ts). Returns{ result: { stopReason: 'cancelled' } }on success; surfaces-32600Invalid Request /-32602Invalid params /-32603Internal 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 existingaudit.logfiles. Adds 13 round-trip tests + § ACP-mapping addendum to000-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.yamldecrypted to/dev/shmat boot via age; 90-day rotation cadence withsystem.key_rotationevent written under the old key; external public-key pin in an operator-controlled GitHub gist; lost-key recovery viaaudit.log.<timestamp>.broken-chain+system.chain_breakevent; compromise recovery viaccsc audit-key rotate --reason=compromise-suspected;--no-audit-signingemergency valve flag. Lands BEFORE the signing code inccsc-22lso 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).
- Intent Solutions testing harness baseline (audit-harness v0.1.0, vendored at
.audit-harness/with wrapper atscripts/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 in000-docs/audit-harness-test-baseline-2026-05-01.md. Upgrade in place viaAUDIT_HARNESS_VERSION=vX.Y.Z curl -sSL https://raw.githubusercontent.com/jeremylongshore/audit-harness/main/install.sh | bash.
- Removed
gemini-review.ymlworkflow — 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.tomlallowlist 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-action4.35.2 → 4.35.4 (dependabot).
replytool — passfilenameto Slack file uploads.server.tswas callingWebClient.filesUploadV2()with{ channel_id, file: <absolute path> }but never settingfilename. The@slack/web-apiv2 upload flow does not infer the filename from thefilepath when it's a string — Slack defaults tofile.txt, which means every uploaded asset (PNG, HTML, MD, PDF, etc.) was delivered to recipients as a.txtdownload with the wrong mimetype. Addsfilename: basename(resolved)to the upload args so the original filename and inferred mimetype are preserved end-to-end. Importsbasenamefromnode:path(already importsresolvefrom the same module).
- Cleared 6 high-severity transitive dep CVEs (
ccsc-7lh). Bumped@slack/web-api7.15.0 → 7.15.2 (brings axios^1.15.0→ 1.16.1) and@modelcontextprotocol/sdk1.27.1 → 1.29.0 (brings ajv with fresh fast-uri ≥3.1.2). Added a top-leveloverridesblock pinningaxios ^1.16.1andfast-uri ^3.1.2so 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-w52fis 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 (externalreplyfilename fix).
-
pairing.acceptedjournal 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-levelprevAllowFromsnapshot and diffs the currentallowFromagainst it on each read; every newly-added user id produces onepairing.acceptedevent with{ user: userId }payload. First call seeds the baseline without emitting — subsequent reads detect deltas. The diff logic lives in a puredetectNewAllowFrom(prev, current)primitive inlib.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 anyallowFromgrowth regardless of path — skill, manual edit, or tampering), silent on removal (nopairing.removedkind exists and none invented). Design rationale documented in000-docs/audit-journal-architecture.md §pairing-events. -
pairing.expiredjournal events (ccsc-rc1).lib.ts pruneExpired()now returns the[code, entry]tuples it removed (previouslyvoid);server.ts getAccess()iterates that return and emits onepairing.expiredevent per expiry (minimal-disclosure payload:{ channel: entry.chatId }, matching thepairing.issuedshape). Static-mode boot-time pruning remains silent because the journal isn't open at module-load time — static mode downgradesdmPolicytoallowlist, 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.acceptedremains unwired (tracked inccsc-scv, which needs an IPC design decision since the skill runs outside the server process). Four new unit tests inserver.test.tscover the new return value (empty pending, nothing-expired, mixed expired/live, multiple expiries).
JournalWriter.open()— reverse-chunk tail read + single file handle (ccsc-otd). Replaces the fullreadFileSyncof the audit log (used only to recoverlastHashandnextSeqfrom 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 onefsOpen('a+', 0o600)handle: reads use explicitpositionso 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 CodeQLjs/file-system-racepattern on this path. Four new tests inserver.test.tsexercise the reverse-scan edge cases (pre-existing empty file, trailing-newlines-only, last line straddling a chunk boundary, last line larger than one chunk).
- Server-side duplicate-policy-rule-id rejection (
ccsc-kx8).ACCESS.md§"Safety checks the loader runs" documented duplicate-id rejection as fatal at boot, butserver.ts loadPolicy()never wired the check — only the validator script shipped with the/slack-channel:policyskill caught it. A policy with two rules sharing anidwould load cleanly and the second rule would be silently unreachable (evaluator uses first-applicable). AddsassertUniqueRuleIds()topolicy.ts(sibling todetectShadowing/detectBroadAutoApprove; throws rather than returning warnings, matching the documented fatal contract).server.ts loadPolicy()now runs it afterparsePolicyRules()and fails boot with a descriptive error naming every duplicated id.scripts/policy-validate.tsswapped its localfindDuplicateIdshelper for the shared primitive so the skill and the server use one code path. Four new tests inserver.test.tslock the behavior.
/slack-channel:policyskill — author policy rules without hand-editingaccess.json(ccsc-0ss). New operator-facing skill atskills/policy/SKILL.mdwith four subcommands:list(tabular view of current rules),lint(shadow + broad-auto-approve warnings),add <id> <effect> <json-match> [opts], andremove <id>. All writes are atomic (access.json.tmp→ rename) with mode0o600. Pre-write validation runs through a newscripts/policy-validate.tswrapper that imports the realparsePolicyRules()/detectShadowing()/detectBroadAutoApprove()frompolicy.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 underccsc-kx8; both surfaces now callassertUniqueRuleIds()frompolicy.ts). Hot reload remains intentionally unsupported; every success message tells the operator to restart the server.
- Tighten
crap-scoreCI 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 (gate32,handleMessage35, 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.tsmax complexity dropped 32 → 21. Zero behavior change — all 682 tests pass bit-for-bit, including the 13 fast-check property tests added inccsc-363(2,600 assertions of gate invariants) and the 10 Gherkin scenarios infeatures/inbound_gate.feature. Completesccsc-53galongsideccsc-lry,ccsc-upx,ccsc-530. - Refactor handleMessage dispatcher — extract deliverEvent (
ccsc-530). ThehandleMessagefunction (cyclomatic complexity 35) has had its entirecase 'deliver':body extracted into a new named top-level async functiondeliverEvent(ev, access).handleMessageis now a thin three-arm dispatcher (drop / pair / deliver) with complexity 8.deliverEventcarries 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 anonymoussocket.on('interactive', …)handler (cyclomatic complexity 39) has been split into two named top-level async functions:handleMoreAction(handles theperm:more:detail-expand path, complexity 7) andhandleAllowDenyAction(handles theperm: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;handleMoreAction7;handleAllowDenyAction17. 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 anonymousswitchblock insidemcp.setRequestHandler(CallToolRequestSchema, …)(cyclomatic complexity 84) has been replaced by eight named top-level async functions (executeReply,executeReact,executeEditMessage,executeFetchMessages,executeDownloadAttachment,executeListSessions,executeReadPeerManifests,executePublishManifest) plus fourexecutePublishManifestGateNhelpers and oneexecutePublishManifestReplacehelper. The dispatcher is now a Zod-validation block + lookup-then-call pattern wired through aToolContextinterface that bundles the closure state each handler needs. Complexity before/after: dispatcher 84 → 5; all extracted handlers ≤17 (max isexecuteReplyat 17). No behavior changes; all 682 tests pass, coverage 98.39% / 98.75%.
- Pre-commit quality gate via Husky + lint-staged (
ccsc-rrj). Local.husky/pre-commitrunsbunx @biomejs/biome check --writeon staged TS/JS/JSON files;.husky/pre-pushrunstsc --noEmitfor cross-file guarantees. Both hooks preserve the beads integration block sobdsync keeps working after Husky takes overcore.hooksPath.prepare: huskyscript inpackage.jsonauto-installs hooks onbun install. - Property-based tests for
gate()(ccsc-363). Newfeatures/gate-properties.test.tsusesfast-checkto 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 upcomingccsc-u41gate()refactor — catches edge cases specific test cases cannot.
- Biome adoption and tightening — Wall 7b lint gate (
ccsc-dz8,ccsc-ana,ccsc-5vx). Adds@biomejs/biomeas 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). IncludesuseNodejsImportProtocol,useOptionalChain,noControlCharactersInRegex,noAssignInExpressions,noUnusedImports,useLiteralKeys,useTemplate. Formatter enabled withquoteStyle: '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). Wiresfeatures/runner.test.ts+features/runner.ts(minimal hand-rolled Gherkin parser, no new runtime deps) and five step-definition files underfeatures/steps/so all 37 scenarios across the five.featurefiles execute as real bun:test tests againstgate(),assertSendable(),assertOutboundAllowed(),evaluate(), andverifyJournal(). Updatesscripts/coverage-floor.shto compute the coverage average from production source files only (excluding test-only glue underfeatures/), preserving the 95% floor semantics. Flips the runner-status note infeatures/README.mdfrom "lint-only today" to "fully wired". - TS-aware cyclomatic-complexity gate (
ccsc-gh0). Addsscripts/crap-score.ts— a Bun-native TypeScript AST walker that measures cyclomatic complexity per function acrosslib.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 intoci.ymlas a regression gate at--threshold 85— the current ceiling (server.tshas one anon function at 84). Four functions sit above the Wall 5 ideal of 30 (gate()at 32,handleMessageat 35, two server.ts anons at 39 / 84) — follow-up bdccsc-53gtracks refactoring them and tightening the threshold to 30.000-docs/QUALITY_GATES.mdgains 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..gitleaksignorecarries 7 fingerprints for the journal-redactor test fixtures inserver.test.tsthat look like AWS/AKIA tokens but are string-concatenated test inputs. Adds abun audit --audit-level=highstep toci.ymlagainst the GitHub Advisory Database; one known path-to-regexp advisory inside@modelcontextprotocol/sdkis ignored explicitly (GHSA-j3q9-mxjg-w52f).bunfig.tomlcarries 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-testspass (ccsc-tr7,ccsc-s10). Scaffoldsfeatures/with five declarative.featurefiles — 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. Mirrorsgherkin-lint.shandharness-hash.shfrom the upstream skill intoscripts/so CI runners (which don't have$HOME/.claude/) can invoke them. Adds two CI steps:Gherkin lint (Wall 1)andHarness-hash verify (tamper check). Flips the Wall 1 row in000-docs/TEST_AUDIT.mdfrom ⚪ 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-testspass with deterministic evidence per Wall (ccsc-ao9). Rewrites000-docs/TEST_AUDIT.mdwith numbers verified against real tool output (594 tests, not the previously-claimed 549; 181.toThrow()not 104; 2.28 assertion density not 2.47). Adds000-docs/QUALITY_GATES.md— the Step 5.5 Quality-Gate-Sweep artifact the prior audit skipped. scripts/coverage-floor.sh— parsesbun test --coverageoutput and fails CI if line or function coverage drops below 95%. Bun 1.2.23 does not enforcecoverageThresholdviabunfig.toml, so we gate on stdout instead.- CI step: Coverage floor (≥95%) — wired after the existing
Teststep 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.jsconfig; CI-enforced; the existing regex test inserver.test.tsremains as a belt-and-suspenders guard. - Mutation testing with Stryker (
ccsc-ao9,ccsc-y4e,ccsc-l5z). Configuration instryker.conf.mjs; manual-run only (not in CI). Expanded scope from lib.ts-only tolib.ts + policy.ts + manifest.ts + journal.ts. Top-5 survivors on security primitives killed in PR #133. Final baseline documented in000-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). Added000-docs/AUTO_REMEDIATION_REPORT.mddocumenting the gap analysis outcome — rubric-driven remediation would add no value to the current suite; report captures the rationale for future auditors.
- Flaky
verifyJournal 1000-event chaintest (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.
- Per-channel
auditfield 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
auditis'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 → eventIdtracking map used for self-echo detection. Oldest entries evicted on overflow.
- 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.mdsection on projection semantics (#109).
- Gate self-echo regression test for audit receipts (#108) — ensures projected receipts don't re-enter the message pipeline even when the channel has
allowBotIdsconfigured. audit:offproduces zero Slack messages test (#107) — validates that the defaultaudit: 'off'setting results in no projection posts.
- Declarative policy enforcement at the permission-relay boundary. Operators author rules in
access.json.policy; the server consultsevaluate()on every tool-call permission request and routes to one of four outcomes:auto_allow(matchedauto_approve, no human prompt),deny(posts reason to thread, no exec),require_human(quorum tracking),default_human(no rule, legacy Block Kit flow). SeeACCESS.mdfor the schema and000-docs/policy-evaluation-flow.mdfor the decision procedure (#100). - Multi-approver quorum with NIST two-person integrity (#101).
require_approvalrules accept an optionalapproversfield (1–10, default 1). Votes accumulate on the verified Slackuser_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 (forrequire/approved) the verified approveruser_ids. thread_tspredicate onMatchSpec. 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 onauto_approverules that lack bothtoolandpathPrefix— almost always a misconfiguration. --verify-audit-logCLI subcommand (#98).bun server.ts --verify-audit-log <path>runsverifyJournal()before any state setup (no.env, no tokens needed). Exit codes 0/1/2 for ok / break / unexpected. Closes a design-doc gap where000-docs/audit-journal-architecture.mdspecified the CLI but only the programmatic form shipped.- Compile-time shape-drift guards (#102).
lib.tsduplicatesPolicyDecision/VerifyResultshapes (PolicyDecisionShape/VerifyResultShape) to stay decoupled frompolicy.ts/journal.ts. Bidirectionalextendschecks inpolicy.tsandjournal.tsbreak 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_approvebypass,denyreason surfacing,require:2quorum with NISTuser_iddedup, and thedefault_humanfallback 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).
- Policy rules now load at boot. A malformed
access.json.policyfield 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 (recordApprovalVote→grantPolicyApproval→journalWrite) lives in one place; per-resolver UX stays separate.
pairing.acceptedandpairing.expiredEventKinds remain unwired (P3,ccsc-4an— tracked for v0.6.1). Current journal capturespairing.issuedso the pairing lifecycle is reconstructible fromissued+ absence ofclaim.- No operator CLI for rule authoring; edit
access.json.policydirectly. A/slack-channel:policyskill is on the v0.6.1 roadmap. - Rules match on
tool,channel,thread_ts, andactor; theargEquals/pathPrefixpredicates in the schema do not yet match frompermission_requestnotifications because the MCP surface carriesinput_preview(string) rather than structured tool args. Tracked as a v0.7.x evolution.
- 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.saveSessionusesawait writeFileinstead ofwriteFileSyncand propagates file-write failures viaError.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 viaSLACK_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
EventKindvalues 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. Remainingpairing.*andpolicy.*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).
assertSendablestate-root denylist (S1) (#86) —assertSendablenow accepts an explicitstateRootparameter and rejects any path under it (viaisUnderRoot+ realpath resolution). Closes the doc-vs-code gap where CLAUDE.md claimed.env/access.json/audit.log/sessions/were blocked, but onlyallowlistRoots+ basename denylists were enforced. Operators who misconfigureSLACK_SENDABLE_ROOTS=~/.claudeno longer leakaccess.json.- Journal broken-flag + schema-parse ordering (S2, S3) (#89) —
_doWritenow checksif (this.broken) throw this.brokenat entry, ensuring in-flight queue entries reject correctly. ZodError duringJournalEvent.parse()no longer setsthis.broken(schema errors are retriable); parse now runs before hash computation so a bad event never mutates state. loadSessionZod schema validation (S4) (#87) —loadSessionnow validates against a strictSessionSchemaZod schema. Corrupt/attacker-modified session files (e.g.,ownerId: 123) throw at load time instead of passing through to the supervisor. Addedowneralias forownerIdduring 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 viasafeParseat switch entry. Malformed calls return structured error objects withcode: "invalid_params"instead of runtime exceptions. - Supervisor quarantine state survives deactivate (S6) (#90) —
SessionSupervisornow tracks quarantined keys in aMap<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 explicitclearQuarantine(key).
- Known caveats section removed — the
SessionHandle.update()stub blocker no longer applies; supervisor is fully wired.
- Thread-scoped session storage (#45, #48, #51–54). Sessions now live at
~/.claude/channels/slack/sessions/<channel>/<thread>.jsoninstead 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 adefaultthread without context loss. Directories are0o700, files remain0o600. See000-docs/session-state-machine.md. - Node.js / tsx as alternative MCP runtime (#65) — @gog5-ops. Fixes a Bun event-loop bug where Socket Mode
messageevents stop firing onceStdioServerTransportattaches its owndatalistener to stdin..mcp.jsonnow launches via./node_modules/.bin/tsx server.tsby default;tsxadded as a runtime dependency. The test suite still runs underbun:test(unaffected). - Declarative policy engine (#47, #57, #58) — internal; not yet wired into the request pipeline.
PolicyRuleZod 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. See000-docs/policy-evaluation-flow.mdandACCESS.md. - Session supervisor skeleton (#60, #62, #66) — internal; not yet wired.
SessionSupervisoractor (Armstrong-style) withactivate(key, initialOwnerId?)andquiesce(key)methods, in-flight tool-call tracking via AbortControllers, and structuredsession.activate/session.quiescelog lines for the future journal sink.deactivate()/ idle reaper /update()land in a subsequent v0.5.x. See000-docs/session-state-machine.md. - Design-in-public contracts (#38–44, #59) —
ARCHITECTURE.mdwith a four-principal model (user, Claude process, peer bots, Slack platform),000-docs/THREAT-MODEL.mdwith 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.
- 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_writebut never declared anmcpServers.githubblock 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.githubruns the officialghcr.io/github/github-mcp-servercontainer, and required env (GITHUB_TOKEN,ISSUE_*,PULL_REQUEST_NUMBER,REPOSITORY) is wired. If you rebase off this repo, cherry-pick.github/workflows/gemini-review.ymland.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.envtoken loading.
CLAUDE.mddrift —lib.tsline count (460 → ~915) andpolicy.tsdescription updated to reflect the merged evaluator / shadow linter / monotonicity check (Epic 29-A).
gate()permission-reply hardening —PERMISSION_REPLY_REchecked at the gate so peer bots (whoseallowBotIdsopt-in landed in v0.4.0) cannot inject permission-approval text. Self-echo detection triple-checksbot_id/bot_profile.app_id/user === botUserIdto cover Slack payload variants.
SessionHandle.update()remains a staged stub that rejects with a "not yet implemented" bead pointer. The next v0.5.x lands it together with thedeactivateand policy-wiring work, at which point the supervisor is callable fromserver.ts.
- Per-channel
allowBotIdsfor opt-in cross-bot message delivery (#33) — @CaseyMargell. Default-safe: absent or emptyallowBotIdspreserves the prior "all bot messages dropped" behavior for every existing install. Self-echo detection uses a triple-check againstbot_id,bot_profile.app_id, anduser === botUserIdto cover Slack payload variants (includingas_user=falseposts 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-levelallowFrom. 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).
- Gemini PR review workflow switched from
pull_requesttopull_request_targetso 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_REmoved fromserver.tstolib.tsso the gate can drop permission-reply-shaped peer-bot text. No behavior change for human permission replies (#33).
- Docs: corrected runtime dependency count in
CONTRIBUTING.md(was "three", actually four includingzod) and filled theCODE_OF_CONDUCT.mdenforcement contact placeholder withjeremy@intentsolutions.io(#28).
- MCP server now terminates cleanly on client disconnect (#7) — @jinsung-kang
- Deduplicated event delivery from
message+app_mentiondual-fire (#8) — @CaseyMargell
- 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
- File allowlist documentation in MCP instructions — Claude now knows which paths are blocked (#5)
- Security:
assertSendablerewritten 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_attachmenttools (#5) - Security: Display name sanitization prevents prompt injection via Slack usernames (#5)
- Security:
access.jsonatomic write with mode 0o600 passed directly towriteFileSync(#5) - Security:
isSlackFileUrl()validation prevents token exfiltration via crafted file URLs (#5) - Security: Dependencies pinned to exact versions with
--frozen-lockfileon install (#5) - Restored
start:nodescript for Node.js users (#5)
- BREAKING: Default
dmPolicychanged frompairingtoallowlist— new installs must add user ID before DMs work (#6) - BREAKING:
assertSendablepolicy changed from permissive (allow all except state dir) to restrictive (deny all except inbox + configuredSLACK_SENDABLE_ROOTS) (#5)
- 7 vulnerabilities closed from pre-deployment security review by @maui-99
- 34 new tests covering all security-critical functions (52 → 86 total)
- 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/permissioncapability 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
zodas explicit dependency for schema validation
- 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
- License changed from Apache-2.0 to MIT
- MCP server with
claude/channelcapability — 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.tsfor testability - CI pipeline with Bun typecheck and test runner (GitHub Actions)
- GitHub Pages landing site
- Project one-pager and operator-grade system analysis (gist)
- Plugin schema: renamed
manifest.json→plugin.jsonwith correct fields for upstream submission - MCP server args: fixed
.mcp.jsonto use correctserver.tspath - Skills directory structure: moved to
skills/{name}/SKILL.mdpattern per upstream conventions