Skip to content

fix(security): close Discord-attachment RCE via filename → fast-path shell injection - #1022

Merged
sonichi merged 2 commits into
sonichi:mainfrom
VasiliyRad:fix/discord-inbox-filename-sanitize
May 22, 2026
Merged

sonichi merged 2 commits into
sonichi:mainfrom
VasiliyRad:fix/discord-inbox-filename-sanitize

Conversation

@VasiliyRad

Copy link
Copy Markdown
Contributor

The bug (security — RCE)

Two-stage chain that lets a Discord user achieve arbitrary code execution on the owner's machine.

Stage 1: discord-bridge saves attachment filenames verbatim

src/discord-bridge.py (two sites, lines 2304 + 2357):

```python
local_path = INBOX_DIR / f"{int(time.time()*1000)}_{att.filename}"
```

Discord allows arbitrary filenames: spaces, quotes, semicolons, backticks, $, ... An attacker uploads x\"; touch /tmp/pwn; #.jpg. The bridge saves it as /tmp/discord-inbox/1779392000000_x\"; touch /tmp/pwn; #.jpg.

Stage 2: conversation-server fast path shell-splices the path

skills/phone-conversation/scripts/conversation-server.ts:310:

```ts
const image = execSync('ls -t /tmp/discord-inbox/*.jpg ...').toString().trim();
const result = execSync(`bash .../prepend-image.sh "${image}" "${video}" 3`, ...);
```

When the owner is on a phone call and says "prepend image to video," the image glob picks up the malicious filename. execSync (/bin/sh -c) expansion:

```bash
bash .../prepend-image.sh "/tmp/discord-inbox/.._x"; touch /tmp/pwn; #.jpg" "..." 3
```

The first arg to bash is \"/tmp/discord-inbox/..._x\"; the closing \" ends the argument. Then ; touch /tmp/pwn runs as a shell command, then ; #.jpg\" ... is a comment.

Concrete RCE from a Discord attachment + a single voice trigger from the owner.

Fix — two layers

Layer A: sanitize at the boundary

src/discord-bridge.py: new _safe_attachment_basename() helper. Mirrors _safe_id from src/agent-api.py. Applied at both attachment save sites.

Layer B: defense-in-depth at the use

skills/phone-conversation/scripts/conversation-server.ts: switch the fast path from
```ts
execSync(`bash .../prepend-image.sh "${image}" "${video}" 3`)
```
to
```ts
const scriptPath = `${process.env.HOME}/.claude/skills/video-concat/scripts/prepend-image.sh`;
execFileSync('bash', [scriptPath, image, video, '3'], ...);
```

execFileSync bypasses /bin/sh -c. Closes the injection class regardless of filename content.

Tests

8 cases on the sanitizer, 3 cases on the execFileSync wiring. All 11 pass locally; npm run typecheck clean; Python syntax clean.

Test plan

  • python3 tests/discord-bridge-attachment-filename-sanitize.test.py — 8/8 pass
  • npx tsx --test tests/conversation-server-fastpath-execfile.test.ts — 3/3 pass
  • Typecheck clean
  • Manual: upload Discord attachment named pwn\"; touch /tmp/pwn-RCE; #.jpg; verify saved path has shell-meta chars stripped and /tmp/pwn-RCE does NOT appear after triggering the fast path

🤖 Generated with Claude Code

…shell injection

## The bug (security — RCE)

Two-stage chain that lets a Discord user achieve arbitrary code
execution on the owner's machine:

### Stage 1: discord-bridge saves attachment filenames verbatim

`src/discord-bridge.py` (two sites):

  local_path = INBOX_DIR / f"{int(time.time()*1000)}_{att.filename}"

Discord allows arbitrary filenames: spaces, quotes, semicolons,
backticks, `$`, `..`. An attacker uploads:

  x"; touch /tmp/pwn; #.jpg

The bridge saves it as
  /tmp/discord-inbox/1779392000000_x"; touch /tmp/pwn; #.jpg

### Stage 2: conversation-server fast path shell-splices the path

`skills/phone-conversation/scripts/conversation-server.ts:310`:

  const image = execSync('ls -t /tmp/discord-inbox/*.jpg ...')
    .toString().trim();
  const result = execSync(
    `bash .../prepend-image.sh "${image}" "${video}" 3`, ...);

When the owner is on a phone call and says "prepend image to video"
(triggers the concat fast path), the `image` glob picks up the
malicious filename. execSync uses `/bin/sh -c`:

  bash .../prepend-image.sh "/tmp/discord-inbox/.._x"; touch /tmp/pwn; #.jpg" "..." 3

The first arg to bash is `"/tmp/discord-inbox/..._x"` — closing `"`
ends the argument. Then `; touch /tmp/pwn` runs, then `; #.jpg" ...`
is a comment.

Concrete RCE from a Discord DM attachment + a single voice trigger
from the owner.

## Fix — two layers

### Layer A: sanitize at the boundary

`src/discord-bridge.py`: new `_safe_attachment_basename()` helper.
Mirrors `_safe_id` from `src/agent-api.py` — keep alphanumerics +
`._-`; replace everything else with `_`; strip path-traversal;
preserve short extensions for glob matching; cap length to bound
DoS via multi-kilobyte filenames. Applied at both `att.filename`
save sites.

### Layer B: defense-in-depth at the use

`skills/phone-conversation/scripts/conversation-server.ts`: switch
the prepend-image fast path from
  execSync(`bash .../prepend-image.sh "${image}" "${video}" 3`)
to
  const scriptPath = `${process.env.HOME}/.claude/skills/video-concat/scripts/prepend-image.sh`;
  execFileSync('bash', [scriptPath, image, video, '3'], ...);

`execFileSync` bypasses `/bin/sh -c`; each argv entry is a separate
arg to `bash` so no value spliced into argv gets interpreted as
shell syntax. Closes the injection class regardless of what the
filename contains.

Also expands `~` to `$HOME` (execFileSync doesn't run via shell, so
`~` wouldn't be expanded).

## Tests

8 cases in `tests/discord-bridge-attachment-filename-sanitize.test.py`:
  - normal filenames preserved
  - shell-injection chars stripped
  - backtick + `$` stripped
  - path-traversal stripped
  - empty/pure-special → fallback name
  - common extensions preserved (glob matching)
  - Unicode replaced
  - overlong filename capped

3 cases in `tests/conversation-server-fastpath-execfile.test.ts`:
  - execFileSync imported
  - execFileSync('bash', [...]) pattern present
  - raw execSync(`bash ... "${image}"`) pattern absent

All 11 pass locally. `npm run typecheck` clean. Python syntax clean.

Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com>

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the Discord-attachment RCE fix. The two-stage chain is correctly diagnosed and the two-layer fix closes it at both ends — ready to merge.

Things I like:

  1. Defense at the boundary AND at the use. Either layer alone would close the RCE, but doing both is the right call: _safe_attachment_basename() (discord-bridge.py) means no dangerous filename ever lands in /tmp/discord-inbox/ for any consumer, and the execFileSync switch (conversation-server.ts:~310) protects the fast path even if a future write site forgets to sanitize.
  2. _safe_attachment_basename is carefully built — allowlist [a-zA-Z0-9_\-.], .strip("._") to kill leading .. traversal, length cap, and extension-preservation so the downstream *.jpg/*.png globs keep matching legitimate uploads. I traced ../../../etc/passwd through it: /→_, leading dotted run stripped → etc_passwd, no slash, no .. — matches the test's assertions.
  3. Mirroring _safe_id from agent-api.py keeps the sanitizer shape consistent across the codebase's two attacker-facing ingest points — one mental model, not two.
  4. The fast-path fix correctly handles the no-shell consequence: ~ doesn't expand under execFileSync, so the script path is rebuilt as ${process.env.HOME}/.claude/.... Easy detail to miss; the PR got it right.
  5. Both attachment save sites in discord-bridge.py (the embed-snapshot loop ~2335 and the main-message loop ~2391) are patched — not just the one in the headline example.

Non-blocking observations:

  • The .py test actually executes _safe_attachment_basename against real adversarial inputs (quotes, backticks, $IFS, traversal, unicode, 10KB name) — genuine behavioral coverage, stronger than the source-grep guards. The .ts side is source-grep (consistent with the repo convention).
  • _safe_attachment_basename collapses every disallowed char to _, so two distinct uploads (a b.jpg, a-b.jpg) can sanitize to the same basename — but the int(time.time()*1000)_ timestamp prefix already disambiguates, so no collision in practice. Just noting it isn't an injective transform.

Cross-PR: conversation-server.ts is also touched by #1019 (the /meeting handler) and #1018 (config path) in different regions — no textual conflict expected with this PR's import line + fast-path change, but second-to-merge should re-run typecheck.

Tests: 8 behavioral py assertions + 3 ts guards; the PR body reports all passing.

Correctly diagnosed RCE, fixed at both the boundary and the use, with real behavioral tests on the sanitizer. LGTM.
— Lucy (Mac Studio bot)

@sonichi
sonichi merged commit 5cac015 into sonichi:main May 22, 2026
1 of 2 checks passed
sonichi added a commit that referenced this pull request May 22, 2026
…stall CI

The test added by #1022 stubbed only `os.environ["DISCORD_BOT_TOKEN"]`,
but `src/discord-bridge.py` reads the token from
`~/.claude/channels/discord/.env` (FILE path), not from the env var, and
`exit(1)`s at module load when the file is missing. CI has no such file
→ the test crashed before any assertion ran (npm test:py exit 1).

Stub HOME to a temp dir + write a fake `.env` with the token. The
module then loads cleanly without requiring a real bot credential in
CI. Test logic itself is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi added a commit that referenced this pull request May 22, 2026
…sting (#1042)

* fix(tests): restore main CI — _isVoiceTask fixture order + import hoisting

Two post-#1023 test-only fixes; main has been CI-red since #1023 merged.

1. tests/task-bridge-isvoice.test.ts — VOICE_BODY / NON_VOICE_BODY had
   `task:` first, headers after. Pre-#1023 the reader used
   `.some()` over all lines so this worked; post-#1023 the reader
   stops at `task:` (closes the body-forging vector @qingyun-wu
   flagged), so any header AFTER `task:` is invisible. Reordered
   fixtures to put `task:` last, matching the writer convention
   #1023 enforces on all three task-bridge writers.

2. tests/task-bridge-stop-at-task-delimiter.test.ts — set
   SUTANDO_WORKSPACE = TMP at the top of the file, then statically
   imported task-bridge. ES-module `import` statements are hoisted
   per spec — the static import ran BEFORE the env-set, so
   resolveWorkspace() captured the ambient workspace at module
   load and `_isVoiceTask` looked there instead of TMP. Tests
   expecting `true` failed (file not where the function looked);
   tests expecting `false` passed by accident (file-not-found →
   false). Switched to top-level `await import(...)` so the import
   runs in source order, after the env is set.

Verified: typecheck clean; 13/13 cases in both files pass; full
suite goes from 7 failing → 0 failing on these two suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): stub HOME so discord-bridge module load works on clean-install CI

The test added by #1022 stubbed only `os.environ["DISCORD_BOT_TOKEN"]`,
but `src/discord-bridge.py` reads the token from
`~/.claude/channels/discord/.env` (FILE path), not from the env var, and
`exit(1)`s at module load when the file is missing. CI has no such file
→ the test crashed before any assertion ran (npm test:py exit 1).

Stub HOME to a temp dir + write a fake `.env` with the token. The
module then loads cleanly without requiring a real bot credential in
CI. Test logic itself is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): task-priority fixture order — task: last, per #1023

`tests/task-priority.test.py:test_parse_priority_from_text_finds_header`
had `task:` on line 3 with `priority: urgent` on line 5. PR #1023 added
`task:` to `parse_priority_from_text`'s break condition (closing the
body-forging vector @qingyun-wu flagged on #991), so `priority:` lines
AFTER `task:` are now treated as body content — the test flipped from
pass → fail in CI when #1023 merged.

Reorder the fixture: `task:` last, header fields before. Test logic
unchanged. Same class of fixture-order bug as the two test files
already fixed in this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi added a commit that referenced this pull request May 25, 2026
…achments (#1029)

* feat(dm-result): REST multipart upload — actually deliver [file:] attachments

## Why — closes the follow-up flagged in PR #985

PR #985 stripped `[file:|send:|attach:]` markers from the dm-result
body before sending so attackers couldn't deliver the literal text
`[file: /path]` to the user's DM. But that left a real gap: files
that legitimately accompany a result body (screenshots, briefings,
generated docs) were dropped entirely on the REST-only fallback
path. The TODO comment on the strip site explicitly called out:

  "REST multipart upload for actual file delivery is a follow-up"

This PR implements that follow-up. The WS-connected live bridge
calls `discord.File(path)` for each marker; this REST path now
builds the equivalent `multipart/form-data` POST by hand using only
stdlib `urllib.request` (consistent with the rest of dm-result.py's
zero-dependency posture).

## What ships

### 1. Path allowlist (mirrors discord-bridge)

`_SEND_ALLOWED_ROOTS` + `_SEND_ALLOWED_PREFIXES` + `_is_path_sendable`
— byte-identical policy to the canonical helper in
`src/discord-bridge.py`. Keeping the policy in sync means a future
allowlist tightening in either path doesn't drift the other.

### 2. Multipart upload helper

`_send_message_with_files(channel_id, token, content, file_paths)`
constructs the multipart envelope:

  - random boundary via uuid4 (won't collide with payload)
  - `payload_json` part for the message body (`{"content": "..."}`)
  - `files[N]` parts with `filename=` and binary content
  - 30s timeout (multipart uploads can be slower than JSON; bound the
    invocation time)

Filename header sanitization mirrors PR #1022's
`_safe_attachment_basename` shape — CR/LF/`"` in basenames are
replaced with `_`, length capped at 80 chars. Without this, a file
whose basename contains `"\r\nContent-Disposition: form-data;
name="evil"; filename="exfil` could inject its own headers into the
multipart envelope.

### 3. send_dm() updated

Splits the file markers into `sendable` (passes allowlist) vs
`rejected` (logged to stderr, not uploaded). Sends text chunks
first via the existing JSON path, then attaches files in batches of
10 (Discord's per-message attachment cap) as separate multipart
POSTs.

Backwards-compatible: a body with no markers goes through unchanged.

## Tests

`tests/dm-result-multipart-upload.test.py` (5 cases) — replaces
`urllib.request.urlopen` with `_FakeTransport` that captures the
full request body bytes so the test can assert on the actual
multipart envelope shape:

  - allowlisted file uploads via multipart (file bytes present)
  - non-allowlisted file rejected (zero multipart calls)
  - file-only message (empty content but valid multipart)
  - 11 files split into 10+1 batches
  - filename header sanitization (CR/LF/`"` stripped — defends
    against multipart-envelope splicing via attacker-supplied
    filename, same class as PR #1022)

All 5 pass locally.

Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com>

* refactor(send-allowlist): extract shared module per @liususan091219 review

Lucy's PR #1029 review flagged the file-attachment allowlist as a
copy between discord-bridge.py and dm-result.py, with a "keep in sync"
comment that wouldn't prevent drift — and noted that copies "will
drift" regardless. Per her recommendation: extract to a tiny shared
module that both consumers import.

## Pre-existing drift this also fixes

The two copies were already out of sync:

  - `src/discord-bridge.py` had the richer set: `_REPO / "results"`,
    `_REPO / "notes"`, `shared_personal_path("notes", _REPO)`,
    `_REPO / "docs"`, `~/Desktop/iclr-backups`,
    `~/Documents/sutando-launch-assets`, plus the four `/tmp/*`
    prefixes.
  - `src/dm-result.py` (from PR #1029) had the narrower set: only
    `_REPO / "results"`, `_REPO / "notes"`, `_REPO / "docs"`, and
    the same four prefixes — missing the personal-notes / Desktop /
    Documents roots.

So a file like `~/Desktop/iclr-backups/asset.png` would deliver via
the live WS bridge but get rejected by the REST fallback. Same shape
as the kinds of drift Lucy was guarding against. The extract makes
the policy a single set; both paths now respect the same allowlist.

## What ships

### `src/send_allowlist.py` (new)

  - `SEND_ALLOWED_ROOTS` — the canonical superset (all 6 roots).
  - `SEND_ALLOWED_PREFIXES` — the four `/tmp/*` prefixes.
  - `is_path_sendable(fpath) -> bool` — the canonical predicate.

Imports `resolve_workspace` and `shared_personal_path` at module-
load so the per-user paths resolve correctly. Stays dep-light
(stdlib only beyond those two existing helpers).

### `src/discord-bridge.py`

Replaces 21-line inline definition + 22-line function with an import
+ a one-line alias (`_is_path_sendable = _is_path_sendable_shared`).
The 7 existing call sites in this file don't need touching because
the local name `_is_path_sendable` is preserved.

### `src/dm-result.py`

Replaces the PR #1029 inline copy with the same import. Local names
`_SEND_ALLOWED_ROOTS` / `_SEND_ALLOWED_PREFIXES` preserved via
import-as alias so other tests that reference them keep working.

## Tests

`tests/send-allowlist-shared.test.py` — 6 architectural assertions:

  - shared module exists
  - discord-bridge.py imports from it
  - dm-result.py imports from it
  - no inline (re-)definitions of `SEND_ALLOWED_ROOTS` /
    `SEND_ALLOWED_PREFIXES` in either consumer
  - no inline `def [_]is_path_sendable(` in either consumer
  - the shared module contains the documented set (drops are
    deliberate, pinned by this test)

The architectural pin is the load-bearing piece: it makes future
"oh I'll just add it inline real quick" regressions fail loudly.

All 6 tests pass locally. Existing
`tests/dm-result-multipart-upload.test.py` (5 cases) and
`tests/dm-result-send-dm.test.py` (5 cases) still pass — no
behavior change for the live paths, just plumbing.

Co-Authored-By: liususan091219 (review feedback)
Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com>

* test(discord-bridge-allowlist): accept send_allowlist.py as helper location

Per @qingyun-wu PR #1029 review — CI red because the structural test
in `tests/discord-bridge-allowlist.test.py` greps for
`def _is_path_sendable(...)` in `src/discord-bridge.py`. The PR moves
the canonical implementation into `src/send_allowlist.py` (per
@liususan091219's drift-extraction review on #1029) and leaves only
a runtime alias in discord-bridge.py, so the original regex misses.

Fix: scan BOTH source files. Helper is found in either location:

  - Legacy inline form: `def _is_path_sendable(...)` in discord-bridge.py
  - Post-refactor form: `def is_path_sendable(...)` in send_allowlist.py

If the helper lives in send_allowlist.py, the test additionally
verifies discord-bridge.py imports it — without the import, the gate
sites (`discord.File(fpath)`) would reference an undefined name.
DOTALL match so multi-line `from send_allowlist import (… as …, ...)`
parenthesized imports work.

The contract this test guards (helper uses os.path.realpath for
CodeQL sanitizer, checks both ROOTS and PREFIXES, fail-closed
default, all sinks gated) is unchanged — only the location-discovery
logic was updated.

Co-Authored-By: qingyun-wu (review feedback)
Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com>

---------

Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…shell injection (sonichi#1022)

## The bug (security — RCE)

Two-stage chain that lets a Discord user achieve arbitrary code
execution on the owner's machine:

### Stage 1: discord-bridge saves attachment filenames verbatim

`src/discord-bridge.py` (two sites):

  local_path = INBOX_DIR / f"{int(time.time()*1000)}_{att.filename}"

Discord allows arbitrary filenames: spaces, quotes, semicolons,
backticks, `$`, `..`. An attacker uploads:

  x"; touch /tmp/pwn; #.jpg

The bridge saves it as
  /tmp/discord-inbox/1779392000000_x"; touch /tmp/pwn; #.jpg

### Stage 2: conversation-server fast path shell-splices the path

`skills/phone-conversation/scripts/conversation-server.ts:310`:

  const image = execSync('ls -t /tmp/discord-inbox/*.jpg ...')
    .toString().trim();
  const result = execSync(
    `bash .../prepend-image.sh "${image}" "${video}" 3`, ...);

When the owner is on a phone call and says "prepend image to video"
(triggers the concat fast path), the `image` glob picks up the
malicious filename. execSync uses `/bin/sh -c`:

  bash .../prepend-image.sh "/tmp/discord-inbox/.._x"; touch /tmp/pwn; #.jpg" "..." 3

The first arg to bash is `"/tmp/discord-inbox/..._x"` — closing `"`
ends the argument. Then `; touch /tmp/pwn` runs, then `; #.jpg" ...`
is a comment.

Concrete RCE from a Discord DM attachment + a single voice trigger
from the owner.

## Fix — two layers

### Layer A: sanitize at the boundary

`src/discord-bridge.py`: new `_safe_attachment_basename()` helper.
Mirrors `_safe_id` from `src/agent-api.py` — keep alphanumerics +
`._-`; replace everything else with `_`; strip path-traversal;
preserve short extensions for glob matching; cap length to bound
DoS via multi-kilobyte filenames. Applied at both `att.filename`
save sites.

### Layer B: defense-in-depth at the use

`skills/phone-conversation/scripts/conversation-server.ts`: switch
the prepend-image fast path from
  execSync(`bash .../prepend-image.sh "${image}" "${video}" 3`)
to
  const scriptPath = `${process.env.HOME}/.claude/skills/video-concat/scripts/prepend-image.sh`;
  execFileSync('bash', [scriptPath, image, video, '3'], ...);

`execFileSync` bypasses `/bin/sh -c`; each argv entry is a separate
arg to `bash` so no value spliced into argv gets interpreted as
shell syntax. Closes the injection class regardless of what the
filename contains.

Also expands `~` to `$HOME` (execFileSync doesn't run via shell, so
`~` wouldn't be expanded).

## Tests

8 cases in `tests/discord-bridge-attachment-filename-sanitize.test.py`:
  - normal filenames preserved
  - shell-injection chars stripped
  - backtick + `$` stripped
  - path-traversal stripped
  - empty/pure-special → fallback name
  - common extensions preserved (glob matching)
  - Unicode replaced
  - overlong filename capped

3 cases in `tests/conversation-server-fastpath-execfile.test.ts`:
  - execFileSync imported
  - execFileSync('bash', [...]) pattern present
  - raw execSync(`bash ... "${image}"`) pattern absent

All 11 pass locally. `npm run typecheck` clean. Python syntax clean.

Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…sting (sonichi#1042)

* fix(tests): restore main CI — _isVoiceTask fixture order + import hoisting

Two post-sonichi#1023 test-only fixes; main has been CI-red since sonichi#1023 merged.

1. tests/task-bridge-isvoice.test.ts — VOICE_BODY / NON_VOICE_BODY had
   `task:` first, headers after. Pre-sonichi#1023 the reader used
   `.some()` over all lines so this worked; post-sonichi#1023 the reader
   stops at `task:` (closes the body-forging vector @qingyun-wu
   flagged), so any header AFTER `task:` is invisible. Reordered
   fixtures to put `task:` last, matching the writer convention
   sonichi#1023 enforces on all three task-bridge writers.

2. tests/task-bridge-stop-at-task-delimiter.test.ts — set
   SUTANDO_WORKSPACE = TMP at the top of the file, then statically
   imported task-bridge. ES-module `import` statements are hoisted
   per spec — the static import ran BEFORE the env-set, so
   resolveWorkspace() captured the ambient workspace at module
   load and `_isVoiceTask` looked there instead of TMP. Tests
   expecting `true` failed (file not where the function looked);
   tests expecting `false` passed by accident (file-not-found →
   false). Switched to top-level `await import(...)` so the import
   runs in source order, after the env is set.

Verified: typecheck clean; 13/13 cases in both files pass; full
suite goes from 7 failing → 0 failing on these two suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): stub HOME so discord-bridge module load works on clean-install CI

The test added by sonichi#1022 stubbed only `os.environ["DISCORD_BOT_TOKEN"]`,
but `src/discord-bridge.py` reads the token from
`~/.claude/channels/discord/.env` (FILE path), not from the env var, and
`exit(1)`s at module load when the file is missing. CI has no such file
→ the test crashed before any assertion ran (npm test:py exit 1).

Stub HOME to a temp dir + write a fake `.env` with the token. The
module then loads cleanly without requiring a real bot credential in
CI. Test logic itself is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): task-priority fixture order — task: last, per sonichi#1023

`tests/task-priority.test.py:test_parse_priority_from_text_finds_header`
had `task:` on line 3 with `priority: urgent` on line 5. PR sonichi#1023 added
`task:` to `parse_priority_from_text`'s break condition (closing the
body-forging vector @qingyun-wu flagged on sonichi#991), so `priority:` lines
AFTER `task:` are now treated as body content — the test flipped from
pass → fail in CI when sonichi#1023 merged.

Reorder the fixture: `task:` last, header fields before. Test logic
unchanged. Same class of fixture-order bug as the two test files
already fixed in this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…achments (sonichi#1029)

* feat(dm-result): REST multipart upload — actually deliver [file:] attachments

## Why — closes the follow-up flagged in PR sonichi#985

PR sonichi#985 stripped `[file:|send:|attach:]` markers from the dm-result
body before sending so attackers couldn't deliver the literal text
`[file: /path]` to the user's DM. But that left a real gap: files
that legitimately accompany a result body (screenshots, briefings,
generated docs) were dropped entirely on the REST-only fallback
path. The TODO comment on the strip site explicitly called out:

  "REST multipart upload for actual file delivery is a follow-up"

This PR implements that follow-up. The WS-connected live bridge
calls `discord.File(path)` for each marker; this REST path now
builds the equivalent `multipart/form-data` POST by hand using only
stdlib `urllib.request` (consistent with the rest of dm-result.py's
zero-dependency posture).

## What ships

### 1. Path allowlist (mirrors discord-bridge)

`_SEND_ALLOWED_ROOTS` + `_SEND_ALLOWED_PREFIXES` + `_is_path_sendable`
— byte-identical policy to the canonical helper in
`src/discord-bridge.py`. Keeping the policy in sync means a future
allowlist tightening in either path doesn't drift the other.

### 2. Multipart upload helper

`_send_message_with_files(channel_id, token, content, file_paths)`
constructs the multipart envelope:

  - random boundary via uuid4 (won't collide with payload)
  - `payload_json` part for the message body (`{"content": "..."}`)
  - `files[N]` parts with `filename=` and binary content
  - 30s timeout (multipart uploads can be slower than JSON; bound the
    invocation time)

Filename header sanitization mirrors PR sonichi#1022's
`_safe_attachment_basename` shape — CR/LF/`"` in basenames are
replaced with `_`, length capped at 80 chars. Without this, a file
whose basename contains `"\r\nContent-Disposition: form-data;
name="evil"; filename="exfil` could inject its own headers into the
multipart envelope.

### 3. send_dm() updated

Splits the file markers into `sendable` (passes allowlist) vs
`rejected` (logged to stderr, not uploaded). Sends text chunks
first via the existing JSON path, then attaches files in batches of
10 (Discord's per-message attachment cap) as separate multipart
POSTs.

Backwards-compatible: a body with no markers goes through unchanged.

## Tests

`tests/dm-result-multipart-upload.test.py` (5 cases) — replaces
`urllib.request.urlopen` with `_FakeTransport` that captures the
full request body bytes so the test can assert on the actual
multipart envelope shape:

  - allowlisted file uploads via multipart (file bytes present)
  - non-allowlisted file rejected (zero multipart calls)
  - file-only message (empty content but valid multipart)
  - 11 files split into 10+1 batches
  - filename header sanitization (CR/LF/`"` stripped — defends
    against multipart-envelope splicing via attacker-supplied
    filename, same class as PR sonichi#1022)

All 5 pass locally.

Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com>

* refactor(send-allowlist): extract shared module per @liususan091219 review

Lucy's PR sonichi#1029 review flagged the file-attachment allowlist as a
copy between discord-bridge.py and dm-result.py, with a "keep in sync"
comment that wouldn't prevent drift — and noted that copies "will
drift" regardless. Per her recommendation: extract to a tiny shared
module that both consumers import.

## Pre-existing drift this also fixes

The two copies were already out of sync:

  - `src/discord-bridge.py` had the richer set: `_REPO / "results"`,
    `_REPO / "notes"`, `shared_personal_path("notes", _REPO)`,
    `_REPO / "docs"`, `~/Desktop/iclr-backups`,
    `~/Documents/sutando-launch-assets`, plus the four `/tmp/*`
    prefixes.
  - `src/dm-result.py` (from PR sonichi#1029) had the narrower set: only
    `_REPO / "results"`, `_REPO / "notes"`, `_REPO / "docs"`, and
    the same four prefixes — missing the personal-notes / Desktop /
    Documents roots.

So a file like `~/Desktop/iclr-backups/asset.png` would deliver via
the live WS bridge but get rejected by the REST fallback. Same shape
as the kinds of drift Lucy was guarding against. The extract makes
the policy a single set; both paths now respect the same allowlist.

## What ships

### `src/send_allowlist.py` (new)

  - `SEND_ALLOWED_ROOTS` — the canonical superset (all 6 roots).
  - `SEND_ALLOWED_PREFIXES` — the four `/tmp/*` prefixes.
  - `is_path_sendable(fpath) -> bool` — the canonical predicate.

Imports `resolve_workspace` and `shared_personal_path` at module-
load so the per-user paths resolve correctly. Stays dep-light
(stdlib only beyond those two existing helpers).

### `src/discord-bridge.py`

Replaces 21-line inline definition + 22-line function with an import
+ a one-line alias (`_is_path_sendable = _is_path_sendable_shared`).
The 7 existing call sites in this file don't need touching because
the local name `_is_path_sendable` is preserved.

### `src/dm-result.py`

Replaces the PR sonichi#1029 inline copy with the same import. Local names
`_SEND_ALLOWED_ROOTS` / `_SEND_ALLOWED_PREFIXES` preserved via
import-as alias so other tests that reference them keep working.

## Tests

`tests/send-allowlist-shared.test.py` — 6 architectural assertions:

  - shared module exists
  - discord-bridge.py imports from it
  - dm-result.py imports from it
  - no inline (re-)definitions of `SEND_ALLOWED_ROOTS` /
    `SEND_ALLOWED_PREFIXES` in either consumer
  - no inline `def [_]is_path_sendable(` in either consumer
  - the shared module contains the documented set (drops are
    deliberate, pinned by this test)

The architectural pin is the load-bearing piece: it makes future
"oh I'll just add it inline real quick" regressions fail loudly.

All 6 tests pass locally. Existing
`tests/dm-result-multipart-upload.test.py` (5 cases) and
`tests/dm-result-send-dm.test.py` (5 cases) still pass — no
behavior change for the live paths, just plumbing.

Co-Authored-By: liususan091219 (review feedback)
Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com>

* test(discord-bridge-allowlist): accept send_allowlist.py as helper location

Per @qingyun-wu PR sonichi#1029 review — CI red because the structural test
in `tests/discord-bridge-allowlist.test.py` greps for
`def _is_path_sendable(...)` in `src/discord-bridge.py`. The PR moves
the canonical implementation into `src/send_allowlist.py` (per
@liususan091219's drift-extraction review on sonichi#1029) and leaves only
a runtime alias in discord-bridge.py, so the original regex misses.

Fix: scan BOTH source files. Helper is found in either location:

  - Legacy inline form: `def _is_path_sendable(...)` in discord-bridge.py
  - Post-refactor form: `def is_path_sendable(...)` in send_allowlist.py

If the helper lives in send_allowlist.py, the test additionally
verifies discord-bridge.py imports it — without the import, the gate
sites (`discord.File(fpath)`) would reference an undefined name.
DOTALL match so multi-line `from send_allowlist import (… as …, ...)`
parenthesized imports work.

The contract this test guards (helper uses os.path.realpath for
CodeQL sanitizer, checks both ROOTS and PREFIXES, fail-closed
default, all sinks gated) is unchanged — only the location-discovery
logic was updated.

Co-Authored-By: qingyun-wu (review feedback)
Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com>

---------

Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants