sark

Security model

Thread-scoped tokens, a fail-closed allowlist, and the limits that bound a compromised sandbox.

The sandbox is not trusted. It runs a model that reads text written by anyone in a Slack channel, and it has a shell. So the design assumes it can misbehave, and bounds what that costs: a compromised box can post into exactly one Slack thread, for at most 12 hours, at a bounded rate, and can reach nothing else.

Thread-scoped MCP tokens

The box calls back with a stateless HMAC token:

<base64url(payload)>.<base64url(hmac-sha256(payload))>

The payload names three things:

Field
tidthe thread (Durable Object name) this token may address
bidthe box generation it was minted for
iatissued-at, epoch seconds

So a leaked token can only ever address the thread it was minted for, only while that exact box is still the thread's box, and only for THREAD_TOKEN_MAX_AGE_SECONDS (12 hours) after it was issued. Verification rejects a bad signature, a malformed payload, an age past 12h, and a future iat beyond 120s of clock skew.

The bid check is enforced twice: the /mcp route resolves the Durable Object by tid, and invokeTool then compares the token's bid against the thread's current box. A token from a previous generation gets a plain error, not a post.

Why the token is not a box env var

Box env is fixed at fork time, so a token placed there would pin a box to one credential for its entire life, which is exactly wrong for a credential that has to expire. Instead the token is written to a file that the bootstrap script reads and immediately deletes as it registers the MCP server. Writing it per registration is what makes rotation possible.

Rotation

ensureMcp() re-mints and re-registers once a token is half-way to expiry (6 hours), so a long-lived thread rotates its credential rather than hitting the hard expiry mid-run.

What the box env carries

Only non-secret coordinates:

SLACK_MCP_URL     = ${PUBLIC_URL}/mcp
SLACK_THREAD_ID   = the thread id
SLACK_CHANNEL     = Slack channel      (Slack threads only)
SLACK_THREAD_TS   = Slack thread ts    (Slack threads only)
SLACK_TEAM        = Slack team id      (Slack threads only, when known)

Note what is not there: the Slack bot token. The sandbox never holds a Slack credential at all. It holds a credential for this Worker, which then talks to Slack on its behalf.

No ambient authority in the tools

The five MCP tools have no channel or thread parameter. There is no argument a compromised box can pass to redirect output. The destination comes from the token, and the Durable Object constructs the transport from its own stored session state.

See MCP tools.

The allowlist fails closed

isAllowed() refuses every mention when both ALLOWED_CHANNELS and ALLOWED_USERS are empty. You opt channels in; there is no opt-out mode. ALLOWED_TEAMS, when set, pins the workspace on top of that.

This is what stops a public channel from spinning up unbounded sandboxes. A refused mention gets an explanation in-thread and creates no box.

API_TOKEN is full bot authority

/api deliberately bypasses the Slack allowlist. A caller can address any thread id, and by passing slack coordinates on a prompt, make the bot post into any conversation the bot token can reach. That is what makes the surface useful for scripting, but it means API_TOKEN should be guarded exactly like SLACK_BOT_TOKEN. The allowlist constrains Slack mentions, not this.

The gate runs before every /api route, compares in constant time, and returns 503 when API_TOKEN is unset, so it fails closed rather than open.

Slack request verification

/slack/events verifies the Slack signature over the raw body before parsing anything, and returns 503 if SLACK_SIGNING_SECRET is unset rather than accepting unverified traffic. Retry deliveries are dropped at the edge on x-slack-retry-num, with event_id dedup in the Durable Object as the backstop.

Amplifier limits

A sandbox with a valid token still can't use /mcp as a megaphone:

LimitValue
JSON-RPC batch size32 calls per request
Request body1 MB (checked against content-length before parsing)
Queued messages per thread20
Prompt text16,000 characters
Recorded messages kept500
Run wall clockPROMPT_HARD_CAP_SECONDS (default 1200s)
Box TTLBOX_TTL_SECONDS (default 3600s)
Idle archiveIDLE_STOP_SECONDS (default 900s)

Prompt-injection hardening

Message bodies, display names, and /api metadata are untrusted text. The delimiters the prompt is built from are neutralized inside them, so a user cannot close their own block and open one under someone else's name. The instructions also state plainly that block contents are requests to consider, never instructions that override the system ones. See Prompt construction.

What users are shown when things break

Only messages written for a user are shown. BoxApiError.publicMessage is "The sandbox API returned an error (HTTP N)"; SessionError carries its own public text. Anything else becomes a generic line. Upstream response bodies carry internal identifiers, and sandbox stderr is not something the Worker controls at all. Both are log-only.

The MCP bootstrap goes further: sandbox output that failed is scrubbed of the token before it is logged, because a shell echoing the failing command would otherwise put a live credential in the logs.

Deliberate absences

  • One interactive route, gated. /slack/interactive serves the status-message buttons and the effort dropdown, nothing else. It verifies the signature and runs the same isAllowed gate as a mention before acting; anything that is not a block_actions payload for a known action id is dropped. The two destructive controls also carry a Block Kit confirm, so reaching the handler takes a deliberate second click.
  • Reactions cannot create anything. A control reaction acts only on a thread that already has a session, and one from outside the allowlist is dropped silently. Replying would make any emoji in a public channel a way to make the bot talk.
  • No session state in the MCP server. Every request is self-describing and authenticated by its own bearer token; there are no session ids to hijack and no SSE stream to hold open.
  • No unhandled error bodies in responses. app.onError logs the stack and returns a flat {"error":"internal error"}.

Rotating secrets

SecretEffect of rotating
MCP_TOKEN_SECRETInvalidates every token sitting in a live sandbox. Threads recover on their next turn when ensureMcp re-registers.
API_TOKENImmediately locks out /api callers, including npm run drive.
SLACK_BOT_TOKENThe bot stops posting; the Worker keeps running.
BOX_API_KEYEvery box operation fails; threads surface a sandbox-API error.

On this page