claude-code-discord-bridge spawns Claude Code CLI subprocesses that can execute arbitrary code on the host machine (read/write files, run commands, make network requests). This is by design — Claude Code's value comes from its ability to interact with the development environment.
The bridge's security goal is:
Ensure that only authorized users can trigger Claude Code sessions, and that the bridge layer itself does not introduce additional attack surfaces beyond what Claude Code CLI already exposes.
| Threat | Mitigation |
|---|---|
| Unauthorized users invoking Claude | allowed_user_ids allowlist in ClaudeChatCog and SkillCommandCog |
| Shell injection via user prompts | create_subprocess_exec (no shell), -- separator before prompt arg |
| Flag injection via prompts | -- separator prevents -p, --resume etc. in prompt text |
| Session hijacking via crafted IDs | Strict regex validation: ^[a-f0-9\-]+$ |
| Skill name injection | Strict regex validation: ^[\w-]+$ |
| Path traversal via ingest attachments | Client filenames reduced to a safe basename, then re-checked against the ingest root inside the function that makes each filesystem call (_contained_path, and the same test written out in _unique_path) |
| Zip slip via ingest archives | Each archive member is resolved and refused if it escapes the extraction directory or the ingest root |
| Secrets leaking to Claude subprocess | _STRIPPED_ENV_KEYS removes DISCORD_BOT_TOKEN, CLAUDECODE, etc. from subprocess env |
| Claude reading Discord secrets via Bash tool | Environment stripping prevents echo $DISCORD_BOT_TOKEN in Claude's Bash |
| Nesting detection bypass | CLAUDECODE env var stripped — subprocess won't think it's already inside Claude Code |
| Scenario | Why |
|---|---|
| Claude Code accessing host filesystem | This is Claude Code's core functionality — restricting it defeats the purpose |
| Claude Code making network requests | Same as above — Claude Code needs internet access for web search, API calls, etc. |
| Claude Code modifying its own config | This is expected behavior (CLAUDE.md, memory files, etc.) |
| Discord server admin abuse | If someone has admin on your Discord server, they already have control |
| Physical access to the host | Out of scope — standard server security applies |
The security boundary is at the Discord layer, not the CLI layer. Once a session starts, Claude Code has full CLI-level access. The bridge's job is to ensure only the right person can start sessions.
# All arguments passed as a list — no shell interpolation
args = [self.command, "-p", "--output-format", "stream-json", ...]
# -- separator prevents the prompt from being interpreted as flags
args.append("--")
args.append(prompt)
# Spawned without shell
self._process = await asyncio.create_subprocess_exec(*args, ...)Why this matters:
- A prompt like
--dangerously-skip-permissionswon't be interpreted as a flag - A prompt like
$(rm -rf /)won't be shell-expanded create_subprocess_execpasses arguments directly to the exec syscall
if not re.match(r"^[a-f0-9\-]+$", session_id):
raise ValueError(f"Invalid session_id format: {session_id!r}")Session IDs come from Claude Code CLI output and are stored in SQLite. Before passing back via --resume, they're validated against a strict hex-and-hyphens pattern.
if not re.match(r"^[\w-]+$", name):
await interaction.response.send_message(f"Invalid skill name: `{name}`", ephemeral=True)
returnSkill names are passed to Claude Code as /{name}. The regex ensures only alphanumeric characters, underscores, and hyphens are allowed.
POST /api/ingest accepts base64 attachments from untrusted external clients and writes them to disk under an ingest root, so both the filename and any zip member name are attacker-controlled. Two independent guards apply.
First, the filename is reduced to a safe basename:
name = os.path.basename(str(raw or "")).strip()
name = _UNSAFE_FILENAME_RE.sub("_", name).lstrip(".")
return name or f"attachment_{index}"This strips directory components, replaces anything outside [\w.\-], and drops leading dots so an attachment can't masquerade as a dotfile.
Second, containment is re-established immediately before the filesystem call:
root = os.path.realpath(str(self._ingest_root()))
resolved = os.path.realpath(str(path))
if resolved != root and not resolved.startswith(root + os.sep):
return NoneWhy both:
- The basename sanitiser lives far from the
open()calls it protects. The containment test re-checks at the sink, so every path ccdb opens, stats or writes under the ingest tree is verified — a future refactor can't silently bypass it - Returning
Noneis a refusal, not a fallback path: callers skip the file rather than write it somewhere else _unique_pathbuilds its candidate list up front and re-checks every derived variant (name_2.ext,name_3.ext, …) before touching it, because those names are built from the same untrusted input- Running out of candidates is a refusal too. After 1000 same-named files
_unique_pathreturnsNonerather than falling back to auuid-suffixed name: 1000 identical filenames in one request is not a real export, and a random name nothing else can predict is worse than a clear error. A direct attachment becomes a400; a zip member is skipped - Comparing against
root + os.sep— not bareroot— is what stops a sibling directory likeingest-evilfrom passing a prefix test againstingest - The check is deliberately spelled
os.path.realpath+startswithrather thanPath.resolve()+relative_to(). The two are equivalent, but only the former is recognised as a path sanitiser by CodeQL; with the pathlib spelling the hardening was invisible to the very tool meant to verify it - For the same reason the test is written out in
_unique_pathinstead of delegating to_contained_path. The two are identical, but a guard that lives behind a call boundary does not propagate for static analysis: with the delegated version CodeQL still reported bothexists()calls as live path injections. Keeping therealpath+ prefix test in the same function as the filesystem call it protects makes the guarantee local — to a reader and to the analyser alike._contained_pathremains for the paths handed tohash_files(), where the delegation is recognised
Zip attachments are extracted member by member, and a member is skipped if it resolves outside the extraction directory or fails the ingest-root check.
Extraction never destroys the upload it came from. The original is deleted only once extraction has actually produced files: zipfile.is_zipfile() matches an end-of-central-directory record near the end of a file, so a large opaque binary — a Windows .evtx log, a dump, a capture — can carry a well-formed empty EOCD by chance and be read as an archive it isn't. Expanding such a file yields zero members, and unlinking it then destroyed the attachment while attachments_saved still reported success. A zip that yields nothing (empty, refused, or malformed) is kept as it arrived — replacing a file with nothing is never an improvement.
_STRIPPED_ENV_KEYS = frozenset({
"CLAUDECODE", # Nesting detection
"DISCORD_BOT_TOKEN", # Bot authentication
"DISCORD_TOKEN", # Alternative token var
"API_SECRET_KEY", # API authentication
})These variables are removed from the subprocess environment before spawning Claude Code:
- DISCORD_BOT_TOKEN / DISCORD_TOKEN: Prevents Claude Code from reading the Discord token via its Bash tool
- CLAUDECODE: Claude Code uses this to detect nesting. Stripping it ensures the subprocess runs as a fresh top-level instance
- API_SECRET_KEY: If the host bot exposes a REST API, this key shouldn't leak to Claude
General environment variables (PATH, HOME, ANTHROPIC_API_KEY, etc.) are passed through because Claude Code needs them to function. The ANTHROPIC_API_KEY is intentionally available — Claude Code uses it for API calls. If you need to restrict which API key Claude Code uses, configure it via Claude Code's own settings, not this bridge.
class ClaudeChatCog(commands.Cog):
def __init__(self, ..., allowed_user_ids: set[int] | None = None):
self._allowed_user_ids = allowed_user_ids
async def on_message(self, message):
if message.author.bot:
return
if self._allowed_user_ids is not None and message.author.id not in self._allowed_user_ids:
return- When
allowed_user_idsis set: only listed Discord user IDs can invoke Claude - When
allowed_user_idsisNone: all users in the channel can invoke Claude (for trusted private servers) - The same check applies to
SkillCommandCog
Both Cogs only respond to messages in the configured channel (channel_id) and its child threads. Messages in other channels are silently ignored.
message.author.bot check ensures bot messages (including webhook messages) don't trigger Claude sessions. This prevents infinite loops if Claude's output triggers another bot.
When building custom Cogs that respond to webhooks (like EbiBot's docs-sync), follow this pattern:
# Only respond to webhook messages
if not message.webhook_id:
return
# Fixed trigger string — no arbitrary command execution
if message.content.strip() != "🔄 expected-trigger":
return
# Hardcoded behavior — webhook cannot inject commands
prompt = HARDCODED_PROMPT # Server-side, not from webhookKey principles:
- Check
webhook_id— distinguishes webhooks from regular users - Fixed trigger strings — webhook cannot specify what to do, only trigger predefined actions
- Hardcoded prompts — all Claude Code prompts are defined server-side, never from webhook content
- SQLite database stores
thread_id→session_idmappings only - No user data, no messages, no secrets stored
- Parameterized queries throughout (
?placeholders, no string formatting) cleanup_old()method for age-based data removal
- Private Discord server: Run the bot on a server only you have access to
- Dedicated channel: Use a specific channel for Claude interactions, not a general chat
- Set
allowed_user_ids: Always set this in production — don't rely solely on channel permissions - Review Claude Code permissions: Configure
permission_modeandallowed_toolsto restrict Claude Code's capabilities as needed - Don't use
dangerously_skip_permissions: This flag exists for power users who understand the implications. It disables Claude Code's built-in safety prompts - Monitor the bot: Check logs regularly. Claude Code sessions are logged with timing and cost data
- Keep dependencies updated:
uv lock --upgrade-package claude-code-discord-bridge && uv sync
Before merging changes to runner.py, _run_helper.py, ext/api_server.py, or any Cog:
- No
shell=Truein any subprocess call -
--separator present before user-supplied arguments - All external input validated (session IDs, skill names, channel IDs)
- Any new filesystem write under the ingest root goes through
_contained_path/_unique_path - Containment checks stay in the function that makes the filesystem call — don't "clean up" an inline guard into a helper, it stops propagating for CodeQL
-
_STRIPPED_ENV_KEYScovers any new secret variables - No string formatting in SQL queries (use
?placeholders) -
allowed_user_idscheck present in any new message handler - No new
os.system(),subprocess.run(shell=True), oreval()calls