The Slack channel uses ~/.claude/channels/slack/access.json to control who can reach your Claude Code session.
{
"dmPolicy": "pairing | allowlist | disabled",
"allowFrom": ["U12345678"],
"channels": {
"C12345678": {
"requireMention": true,
"allowFrom": ["U12345678"],
"allowBotIds": [],
"audit": "off"
}
},
"pending": {
"ABC123": {
"senderId": "U87654321",
"chatId": "D12345678",
"createdAt": 1711000000000,
"expiresAt": 1711003600000,
"replies": 1
}
},
"ackReaction": "eyes",
"textChunkLimit": 4000,
"chunkMode": "newline"
}Controls how DMs from unknown users are handled.
| Value | Behavior |
|---|---|
allowlist |
Only users in allowFrom can DM; others are silently dropped (default in this hardened fork) |
pairing |
Unknown senders get a 6-character code to approve via /slack-channel:access pair (upstream default; opt-in only) |
disabled |
All DMs dropped |
Note — default is
allowlist: this fork defaults toallowlistinstead of the upstreampairingdefault. The pairing flow lets any workspace member DM the bot, receive a pairing code, and then socially-engineer the operator into pasting/slack-channel:access pair <code>. To avoid that foothold, the operator must explicitly add their own Slack user ID toallowFrombefore DMs will reach the bot:/slack-channel:access add U01234567Replace
U01234567with your Slack user ID (visible from your Slack profile → More → Copy member ID). There is no longer a self-service pairing-code emission by default. To temporarily re-enable the pairing flow — for example, to onboard an additional trusted user — edit~/.claude/channels/slack/access.jsonand setdmPolicytopairing, then switch it back toallowlistafterwards.
Array of Slack user IDs (e.g., U12345678) allowed to send DMs. Managed via /slack-channel:access add/remove.
Map of channel IDs to policies. Only channels listed here are monitored.
requireMention: If true (the default for newly opted-in channels — "mention-to-engage"), only messages that @mention the bot are delivered. Once a human has mentioned the bot in a thread, their later messages in that same thread are delivered without re-mentioning (thread-stickiness,ccsc-apj.1); peer agents are never sticky and must @mention every message. If false ("ambient"), every message in the channel is delivered.allowFrom: If non-empty, only these user IDs are delivered from this channelallowBotIds: Opt-in list of bot user IDs allowed to deliver messages in this channel. Absent or empty (default) = all bot messages dropped. See "Multi-agent coordination" below.audit: Audit-log projection mode for this channel. See "Audit projection (audit)" below. Absent or'off'(default) = no projection. Values:'off'|'compact'|'full'.perUserSessions: If true, each distinct sender gets their own session within a shared thread — separate state file, supervisor handle, andownerId— so two humans talking in one thread don't share bridge-session state (ccsc-kl410). Absent or false (default) = one shared session per (channel, thread). Isolates the bridge's per-thread book-keeping, not Claude's own conversation memory.channelCircuitBreaker: Channel-wide peer-bot circuit breaker (ccsc-0k7x2) — trips when total bot velocity across all allowlisted bots is runaway-high, catching A→B→C→A rings the per-bot limit misses. Absent = default (40 msgs / 60s).{ "count": 0, "windowMs": 0 }disables.
A channel runs in one of three operator-chosen modes. This is CCSC's edge over a single-mode @-bot: you decide whether humans can talk past Claude, and whether Claude must be addressed explicitly.
| Mode | access.json |
When to use |
|---|---|---|
| Mention-to-engage (default) | "requireMention": true |
Shared human channels. People converse freely; Claude only hears messages that @-mention it. After one mention, the thread stays engaged so the human can keep talking without re-mentioning (thread-stickiness). |
| Ambient | "requireMention": false |
A dedicated bot channel where every message is meant for Claude. |
| Per-user allowlist | "allowFrom": ["U…"] |
Restrict which humans Claude even hears; composes with either mode above. |
Default: newly opted-in channels default to mention-to-engage (/slack-channel:access channel <id>); pass --ambient to opt into ambient instead. This keeps Claude quiet by default in shared channels — humans can talk to each other without any agent receiving the message until someone @-mentions it.
Thread-stickiness is human-only (ccsc-apj.1). Once a human engages a thread by mentioning the bot, their subsequent messages in that thread are delivered without a fresh mention ("mention once, then converse"). Peer agents (allowBotIds) are never sticky — a peer bot must @-mention the bot on every message. Making agents sticky would re-open the bot-loop/noise problem the peer-bot rate limiter guards, so agent engagement stays per-message-explicit.
By default, every message with a bot_id is dropped at the gate to prevent bot-loop amplification and unintended interactions with third-party integrations (Zapier, PagerDuty, GitHub, etc.). Channels that need to host multiple cooperating agents — for example, an ops-monitor bot and an engineering bot coordinating in an #incidents channel — opt in by listing specific bot user IDs in allowBotIds.
"channels": {
"C_INCIDENTS": {
"requireMention": false,
"allowFrom": ["U_OPS_BOT", "U_ENG_BOT", "U_HUMAN"],
"allowBotIds": ["U_OPS_BOT", "U_ENG_BOT"]
}
}Only bot user IDs explicitly listed in allowBotIds can deliver bot messages. Every other bot is dropped. Additional invariants enforced at the gate:
- Self-echo from this bot is always filtered — even if its own user ID appears in
allowBotIds. Filtering matches onbot_id,bot_profile.app_id, oruser === botUserIdto cover payload variants (includingas_user=falseposts and multi-workspace installs). - Permission-reply-shaped messages from peer bots (
y abcde,no xyzwq) are dropped at the gate before reaching the permission relay. Peer bots cannot approve tool calls regardless ofallowBotIdsmembership — the permission relay additionally requires the approver to be in the top-levelallowFrom. requireMentionandallowFromstill apply.allowBotIdsonly gets a peer-bot message past the defaultbot_iddrop; the usual channel gating runs afterward.- DMs are unaffected.
allowBotIdsis channel-scoped; bot messages in DM channels are always dropped.
Security note: Only add bot user IDs you operate or trust. A peer bot's messages reach Claude with the same effective trust as human messages, and a compromised peer bot can attempt prompt injection. The system prompt treats peer-bot content as untrusted, but that's a last-line defense — the first line is you being deliberate about what's in
allowBotIds. Cross-bot delivery requires explicit opt-in precisely so operators have to think about this tradeoff before enabling it.
Per-channel setting that controls whether tool-call decisions are mirrored into the Slack thread where they were issued. The authoritative audit record always lives in the hash-chained local journal at ~/.claude/channels/slack/audit.log — see 000-docs/audit-journal-architecture.md. The audit field here controls a projection of those journal events into Slack so operators can see what Claude is doing in the same thread they're reading.
Three modes:
| Mode | Projection | Content |
|---|---|---|
'off' (default) |
None. Zero Slack messages from the projection layer. | — |
'compact' |
One threaded receipt per approved tool call. | :receipt: <tool> + correlation ID. No tool inputs. |
'full' |
Same as compact plus a redacted preview of the tool's inputs. | :receipt: <tool> + input_preview (redacted per 30-A) + correlation ID. |
Example:
"channels": {
"C_DEPLOY": {
"requireMention": false,
"allowFrom": ["U_OPERATOR"],
"audit": "compact"
}
}Invariants:
- Default-safe. Absent or
'off'= no projection. No existing channel starts posting receipts on upgrade. - Projection never blocks execution. If Slack's API is flaky, rate-limited, or returns an error, the receipt post is skipped and the tool call still runs. Failures are logged to stderr only. The authoritative journal is unaffected.
- Receipt ≠ outcome. A receipt means "this tool call passed policy and was allowed to run." Whether it succeeded is not something the bridge observes — MCP's permission relay carries no completion signal. For real outcomes, inspect the local journal with
bun server.ts --verify-audit-log <path>. - Self-echoes stay filtered. If a channel opts into both
auditandallowBotIds, the bot's own receipts don't loop through its own gate. Locked in by Epic 30-B.8.
PII warning for
'full'mode: toolinput_previewis a string representation of the first ~200 chars of whatever Claude passed the tool. ForRead/Write/Bashcalls this usually includes file paths or command fragments; for text-generation tools it can include arbitrary user-authored content. The 30-A redaction layer scrubs known token patterns (API keys, GitHub tokens, etc.) but cannot catch unstructured PII. Only enable'full'in channels where the expected content is acceptable for the audience of that channel.
Active pairing codes. Auto-pruned on every gate check.
- Max 3 pending codes at once
- Each code expires after 1 hour
- Max 2 replies per code (initial + 1 reminder)
Emoji name (without colons) to react with when a message is delivered. Set to "" or omit to disable.
Maximum characters per outbound message. Default: 4000 (Slack's limit).
How to split long messages: "newline" (paragraph-aware, default) or "length" (fixed character count).
- File permissions:
0o600(owner read/write only) - Writes are atomic (write
.tmp, then rename) - Corrupt files are moved aside and replaced with defaults
- In static mode (
SLACK_ACCESS_MODE=static), the file is read once at boot and never mutated
All plugin state lives under ~/.claude/channels/slack/. Files are mode 0o600, directories 0o700 — owner-only access. The plugin is single-writer per state directory; running two plugin instances against the same directory is undefined behavior.
~/.claude/channels/slack/
├── .env # tokens (xoxb / xapp), SLACK_SENDABLE_ROOTS, SLACK_ACCESS_MODE (0o600)
├── access.json # this file — allowlist, pairing codes, per-channel policy (0o600)
├── inbox/ # downloaded attachments, auto-allowed for re-share via `reply` (0o700)
└── sessions/ # per-thread conversation state (v0.5.0+) (0o700)
├── .migrated # sentinel: migrator has run; future boots skip the scan (0o600)
├── C0123456789/ # one directory per Slack channel ID
│ ├── default.json # migrated flat pre-0.5.0 session, if the channel had one (0o600)
│ ├── 1700000000.000100.json # one file per thread_ts (0o600)
│ └── 1700000500.000200.json # (0o600)
└── D0987654321/ # DMs use the same per-channel layout (channel ID starts with D)
└── 1700000100.000300.json
A session is the unit of conversation state. One session corresponds to one Slack thread — not one channel. Two parallel threads in the same channel get two independent sessions and never observe each other's state.
channel— Slack channel ID, e.g.C0123456789(channel) orD0123456789(DM).thread—thread_tsfrom the Slack event. For top-level (non-threaded) messages, the plugin synthesisesthread = tsof the root message, so the first reply anchors the thread naturally.
Session files are self-describing: each JSON file duplicates the (channel, thread) key inside the file body. A moved or copied session file stays traceable under forensic inspection.
Pre-0.5.0, the plugin kept one file per channel at sessions/<channel>.json. The migrator runs once at boot:
- Finds each
sessions/*.jsonthat is a regular file. - Creates
sessions/<channel>/at mode0o700. - Moves the legacy file to
sessions/<channel>/default.json(atomic rename; mode preserved). - Drops
sessions/.migratedso subsequent boots no-op.
Existing conversations that predate thread-scoping surface as the default thread and continue without context loss. If the migrator encounters a partial prior migration (per-channel dir already exists), it leaves the legacy file in place and surfaces the conflict rather than clobber.
- Realpath-guarded joins. Every path is validated against
/^[A-Za-z0-9._-]+$/and additionally rejected if the component is exactly.or..(both match the regex but would escape thesessions/layer viapath.join). After the per-channel directory is created,realpathresolves it and the plugin verifies the state root is still a prefix — catches symlink-based smuggling (CWE-22). - Atomic writes. Every session save is
writeFile(<path>.tmp.<pid>, {flag: 'wx', mode: 0o600})→chmod 0o600→rename. Readers never observe a partial file; thewxflag makes a stale.tmp.*from a crashed prior writer a loud failure rather than a silent overwrite. - Fail-closed loader.
loadSessionrealpaths the file before reading; any containment breach or malformed JSON throws and the supervisor Quarantines the session. No silent degradation to an empty session.
Full design reference: 000-docs/session-state-machine.md.
A policy decides whether an MCP tool call proceeds, is denied, or requires a human approver. Policies are authored as JSON and validated at load with a Zod schema. The evaluator (evaluate() in policy.ts) is pure and uses first-applicable combining — the first rule whose match applies wins. See 000-docs/policy-evaluation-flow.md for the full decision procedure and worked examples.
type PolicyRule =
| { id: string; effect: 'auto_approve'; match: MatchSpec; priority?: number }
| { id: string; effect: 'deny'; match: MatchSpec; priority?: number; reason: string }
| { id: string; effect: 'require_approval'; match: MatchSpec; priority?: number; ttlMs?: number; approvers?: number }id— stable, human-readable. Shows up in the audit journal and any error surfaced to Claude. Duplicate ids are a load-time error.effect— one of three:auto_approve(allow without prompting),deny(refuse with a non-sensitive reason string),require_approval(hold until human approver(s) respond on Slack withinttlMs).priority— default100. Position in the policy array is the primary sort key (first-applicable).priorityis a tie-breaker within effect when two rules would otherwise be equivalent — not a global sort.reason(deny only) — 1–200 chars, surfaced to Claude so the model knows why the call was rejected. Keep non-sensitive.ttlMs(require_approval only) — approval freshness window. Default 5 minutes, hard ceiling 24 hours. Once granted, an approval auto-approves subsequent matching calls in the same(rule, sessionKey)until expiry.approvers(require_approval only) — quorum threshold. Default1(single-approver). Accepts 1–10. When ≥2, votes accumulate with NIST two-person integrity: the server dedups on verified Slackuser_id(never display name) so the same human cannot double-satisfy quorum. A singledenyvote from any allowlisted user rejects the request immediately regardless of the quorum count — one "no" overrides any number of "yes" answers.
The loader runs three checks at boot; all are warn-not-block except parse errors:
- Duplicate-id detection — fatal at boot. Two rules sharing an
idcannot coexist. - Shadow detection — warning. Flags later rules made unreachable by an earlier, less-specific rule. Operators may intentionally author unreachable rules (placeholders during refactors) so this is informational.
- Broad-auto-approve linter (ccsc-me6.7) — warning. Flags
auto_approverules whosematchlacks bothtoolandpathPrefix. Without one of those, the rule auto-approves any tool call within its scope — almost always a misconfiguration. Narrow the rule or convert torequire_approval.
type MatchSpec = {
tool?: string // exact MCP tool name, e.g. "upload_file"
pathPrefix?: string // canonicalized via realpath before compare (CWE-22 safe)
channel?: string // Slack channel ID, ^[CD][A-Z0-9]+$
actor?: 'session_owner' | 'claude_process'
argEquals?: Record<string, unknown> // subset-equality on validated MCP input args
}At least one field must be constrained. A match that restricts zero fields (either literally {} or { argEquals: {} }) is a load-time error — a rule that matches every call is almost always a bug.
tool— exact match only; no globbing.pathPrefix— compared afterrealpathresolves both sides;/etc/passwddoes not match prefix/etc/pass(there's a+ sepguard). A rule pointing at a nonexistent path is a load-time error.channel— Slack IDs starting withC(channel) orD(DM). Validated against^[CD][A-Z0-9]+$.actor— who is calling the tool.session_owneris the human at the terminal;claude_processis the Claude Code session. Human approvers arrive as a later turn (permission reply) and are never theactoron the original call.argEquals— subset equality on the MCP input object. Every listed key must match the call's input. Comparison is structural (JSON round-trip) — suitable for plain-JSON values only.
When no rule matches:
- Deny — if the tool is in the
requireAuthoredPolicyset (['upload_file']by default; grows as dangerous tools are added). Decision:{ kind: 'deny', rule: 'default', reason: 'no policy authored for …' }. - Allow — otherwise. Decision:
{ kind: 'allow' }(norulefield, since no rule matched).
The evaluator is a veto layer, not the sole gate. Even an allowed decision still flows through assertOutboundAllowed and assertSendable downstream — the policy engine is additional authorization, never sole authorization.
- Schema validation — Zod rejects malformed rules with structured errors naming the field and id.
- Duplicate-id rejection — two rules sharing an
idfail load. - Shadow-detection linter — warns (doesn't block) when a later rule is unreachable because an earlier rule's match is less-specific-or-equal on every field. Warnings go to stderr + audit log; operator sees them at boot and in CI.
- Monotonicity invariant — on hot reload, refuses to adopt a policy set that contains a new
auto_approverule whose match is covered by an existingdeny. Fail-closed because an accidental weakening reload is the exact shape of an attack via operator coercion.
Policies live under the top-level policy key in access.json (same file as dmPolicy, allowFrom, channels). The server parses and validates them once at boot via parsePolicyRules(); detectShadowing() runs and emits warnings to stderr. A malformed rule or a duplicate id is fatal at boot — the server exits with a descriptive error message. Silent degradation to "no policy" is not offered: policy enforcement is safety-critical, so a parse failure demands operator action rather than opening a hole.
{
"dmPolicy": "allowlist",
"allowFrom": ["U012AB3CD4E"],
"channels": { "C0123456789": { "replyAllowedFrom": [] } },
"pending": {},
"policy": [
{ "id": "safe-reads", "effect": "auto_approve", "match": { "tool": "read_file", "pathPrefix": "/workspace/docs" } },
{ "id": "no-shell", "effect": "deny", "match": { "tool": "run_shell" }, "reason": "Shell execution is not permitted from this channel." }
]
}A missing or empty policy field means "no authored rules" and is not an error — the evaluator applies defaults (allow most tools, deny tools in requireAuthoredPolicy like upload_file). This is the first-install path.
Hot reload is intentionally not supported (see 000-docs/v0.6.0-release-plan.md §R3). Operators restart the server to apply new rules. The checkMonotonicity() invariant is reserved for a future hot-reload path; landing one now would require a signal handler and a drain-in-flight-approvals protocol that v0.6.0 deliberately defers.
Epic 29-B wired the loader and the evaluate() call into the permission-relay handler. The wiring lives in server.ts at the PermissionRequestSchema handler — see the decidePermissionRoute() helper in lib.ts for the pure decision-routing logic.
The /slack-channel:policy skill (skills/policy/SKILL.md) is the ergonomic front door to authoring rules. It wraps atomic access.json writes with pre-write validation via scripts/policy-validate.ts, which runs the same parsePolicyRules() + detectShadowing() + detectBroadAutoApprove() functions the server uses at boot. Subcommands: list, lint, add <id> <effect> <json-match> [opts], remove <id>. The skill complements the hand-edit path; it does not replace it. Hot reload remains unsupported — every successful mutation ends with a "restart the server" notice.
The reply tool can attach files to Slack messages, but only files whose
real path (symlinks resolved) sits under an explicit allowlist of roots.
~/.claude/channels/slack/inbox/— always allowed; re-shares previously downloaded attachments.
Set SLACK_SENDABLE_ROOTS in ~/.claude/channels/slack/.env to a
colon-separated list of absolute paths:
SLACK_SENDABLE_ROOTS=/Users/you/projects/report-outputs:/tmp/claude-artifacts- Paths must be absolute; relative entries are silently dropped.
- Every configured path must exist and be readable at server startup. The
server fails fast (
process.exit(1)with a message listing each bad path) if any entry cannot berealpath-resolved — missing directory, broken symlink, or permission denied. Fix the path or remove it from.envand restart. This closes a TOCTOU window where a post-boot symlink could flip a previously-inaccessible root into a structurally different check. - Symlinks are followed via
realpathbefore the allowlist check, so symlinking a secret file into an allowed root will not bypass the guard. - The guard also applies a basename denylist that rejects common secret
filenames even inside allowlisted roots:
.env,.env.*,.netrc,.npmrc,.pypirc,*.pem,*.key,id_rsa/id_ecdsa/id_ed25519/id_dsa(and.pub),credentials,credentials.*,.git-credentials. - Any path descending through
.ssh,.aws,.gnupg,.config/gcloud,.config/gh, or.gitis rejected. - Paths containing a
..component are rejected.
If the reply tool tries to attach a path outside the allowlist (or on the denylist), the upload is blocked with a generic error that names WHICH check failed but does not echo the attempted path.
Destructive admin commands — !restart today, future !stop — require
cross-channel confirmation. Same-channel verb matching alone, even
gated by allowFrom, is not sufficient against the EchoLeak threat class
(see 000-docs/THREAT-MODEL.md T11): an
attacker who can inject content into the operator's trusted Slack
surface could craft both the verb AND a fake confirmation prompt response.
The two-step handshake closes that.
Step 1 — operator triggers
Channel C: Jeremy: !restart
│
▼
Server: mintNonce(userId='U_JEREMY', channelId='C') → nonce='a1b2c3d4e5f6g7h8'
Server: chat.postMessage({ channel: 'U_JEREMY', text: 'Confirm with: !restart a1b2c3d4e5f6g7h8' })
│
▼
Step 2 — out-of-band delivery
DM channel D (private): bot DMs the nonce to U_JEREMY
Step 3 — operator redeems
Channel C: Jeremy: !restart a1b2c3d4e5f6g7h8
│
▼
Server: verifyNonce('a1b2c3d4e5f6g7h8', { userId: 'U_JEREMY', channelId: 'C' }, store)
│
▼
✓ nonce exists ✓ not expired (within 60s TTL)
✓ not consumed (single-use)
✓ userId matches ✓ channelId matches origin
│
▼
Server: execute !restart, journal admin.restart event under v2 signing
| Property | What it prevents |
|---|---|
| Nonce delivered out-of-band (DM) | A content vector that injected the verb into channel C cannot also inject the nonce into a private DM. The bot's authenticated identity gates the DM. |
| Redemption in original channel | Pasting the nonce in the DM does NOT redeem it. An attacker who somehow reads the DM still cannot replay the nonce — they'd have to also post in channel C as the operator. |
| userId binding | Even allowlisted teammates cannot consume someone else's nonce. The verb-issuer must be the redeemer. |
| Single-use (consumed flag) | Replay attacks fail; each nonce works exactly once. |
| 60s TTL | A compromised state file decays fast. Long enough for an operator to read the DM and paste; short enough that stale nonces don't accumulate. |
| 64-bit entropy | Random-guess is computationally infeasible (2^64 / 60s ≈ 3.1×10^17 attempts/sec). |
| Constant-time comparison | Defense-in-depth against timing side channels; not load-bearing at this entropy but cheap. |
Per-user cap (MAX_LIVE_NONCES_PER_USER = 3) |
An attacker who can spam the verb trigger cannot grow the store unboundedly; oldest live nonce evicts. |
- Operator-as-attacker: an operator who voluntarily types
!restart, reads the DM, and pastes the nonce back is who the system trusts. No software can refuse a legitimate operator's keystrokes. - Compromised operator account: if the attacker controls the operator's Slack identity, they receive the DM and complete the handshake. The mitigation is account hygiene (Slack 2FA, etc.) — out of scope for the bridge.
- Restart loss: process restart drops every pending nonce. This is
intentional — persisting nonces across restarts would defeat the
"compromised state file decays fast" property. An operator who
triggers
!restart, the bridge restarts, then they try to redeem with the old nonce will see "unknown" and need to re-trigger.
The nonce flow applies to destructive verbs only:
| Verb | Nonce required? | Rationale |
|---|---|---|
!clear |
No | Reversible — clears bridge session state + Claude TUI conversation; operator can resume |
!restart |
Yes | Destructive — exits the Claude process; any in-flight work is lost |
!mute <agent> (future, ccsc-gjm) |
No | Reversible — peer-bot drop for a TTL; auto-clears |
!stop (future) |
Yes | Destructive — would halt mid-tool-call |
This trade is explicit: friction is reserved for verbs whose accidental
firing is expensive. !clear runs unguarded because a stray !clear
costs the operator a re-paste; !restart runs nonce-gated because a
stray !restart costs minutes of work.
Primitives live in nonce-hitl.ts (sibling module — pure, testable
without server.ts boot path):
createMemoryNonceStore()— in-memory store with per-user capmintNonce(userId, channelId, store, now?, ttlMs?, rng?)— generates, registers, returns the challengeverifyNonce(presentedNonce, { userId, channelId }, store, now?)— redeems, returns structuredok|unknown|expired|replay|wrong-channel|wrong-user
The dispatcher in admin.ts (ccsc-3w0) composes these with Slack DM
delivery — that module is the integration point, not this one.
See 000-docs/THREAT-MODEL.md T11 +
invariant #7 for the threat model the nonce flow addresses.