Skip to content

fix(dm-result): tell the recipient when an attachment is blocked - #2601

Merged
john-the-dev merged 2 commits into
mainfrom
fix/dm-result-silent-attachment-rejection
Aug 4, 2026
Merged

john-the-dev merged 2 commits into
mainfrom
fix/dm-result-silent-attachment-rejection

Conversation

@sonichi

@sonichi sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner

The claim in the code was wrong

src/dm-result.py had this comment at the rejection site:

# Same security signal as discord-bridge: rejected paths log
# but don't leak the failure to the user.

That parity does not exist. src/discord-bridge.py:4589:

await channel.send(f"(file not allowed: {fpath})")

discord-bridge tells the channel. dm-result printed to stderr and stopped.

Why it matters most in this file

dm-result.py is 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:

cause discord-bridge dm-result (before) dm-result (after)
path does not exist log only stderr only log only
path exists, outside allowlist (file not allowed: …) to channel stderr only notice in body

The 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:

$ git stash push src/dm-result.py && python3 tests/dm-result-send-dm.test.py
AssertionError: a blocked attachment must be announced; body was 'Here you go:'

At HEAD, including all four pre-existing dm-result suites unchanged:

$ python3 tests/dm-result-send-dm.test.py
All send_dm integration tests passed.
$ python3 tests/dm-result-skip-markers.test.py     -> OK
$ python3 tests/dm-result-multipart-upload.test.py -> All dm-result multipart-upload tests passed.
$ python3 tests/dm-result-cli.test.py              -> passes

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

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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})") — fpath goes out raw. No _safe_attachment_basename, no _sanitize, and no allowed_mentions on 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 bassilkhilo-ag2 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 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_recipient and test_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's src/dm-result.py against 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_compile clean, 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.

@github-actions

github-actions Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

❌ Test suite failed under instrumentation — coverage not measurable. See the job log.

@qingyun-wu qingyun-wu 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 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.py passed.
  • python3 tests/dm-result-skip-markers.test.py passed.
  • python3 tests/dm-result-multipart-upload.test.py passed.
  • python3 tests/dm-result-cli.test.py passed.
  • PYTHONPYCACHEPREFIX=/private/tmp/pycache-pr2601 python3 -m py_compile src/dm-result.py tests/dm-result-send-dm.test.py passed.
  • python3 -m ruff check src/dm-result.py tests/dm-result-send-dm.test.py passed.
  • git diff --check origin/main...HEAD passed.

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 qingyun-wu 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 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.

@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

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 os.path.isfile, so a value that is merely text takes the log-only path:

case                              sendable  isfile  branch
attacker string, no such file     False     False   log-only (no echo)
attacker markdown, no such file   False     False   log-only (no echo)
real file outside allowlist       False     True    ECHO to channel
real file inside allowlist        True      True    SEND

So pure prose injection is already blocked by the elif not os.path.isfile(fpath) split — the same split this PR adopts. That defuses my original framing, and it's worth saying plainly since I'm the one who raised it.

Not fully defused: the gate is on existence, and existence is manufacturable. /tmp is world-writable (sticky, but that only restricts deletion):

/tmp world-writable: True   sticky: True
created '/tmp/@everyone-see-this': sendable=False isfile=True -> ECHO

/tmp/sutando-* is allowlisted, but a name outside that prefix takes the echo branch. So the requirement isn't "attacker-chosen string", it's "local file creation plus influence over a result body" — a materially higher bar than text injection, and not the same as impossible.

The asymmetry that makes it worth a line somewhere: discord.Client(intents=intents) is constructed with no default allowed_mentions, and this file passes AllowedMentions(everyone=False, roles=False, …) explicitly at three other send sites (1597, 1662, 2872) — so the authors clearly know to. The two (file not allowed: {fpath}) sends pass none. Whether that yields a live ping depends on discord.py's/the API's default, which I did NOT verify — the checkable fact is only the asymmetry between those sites.

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 allowed_mentions on those two sends, not about attacker-controlled paths.

— Sutando-Mini (Echo Act IV Mini)

@qingyun-wu qingyun-wu 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 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.py
  • python3 tests/dm-result-skip-markers.test.py
  • python3 tests/dm-result-multipart-upload.test.py
  • python3 tests/dm-result-cli.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/pycache-pr2601-67aff python3 -m py_compile src/dm-result.py tests/dm-result-send-dm.test.py
  • python3 -m ruff check src/dm-result.py tests/dm-result-send-dm.test.py
  • git 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.

@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Merge-order note: this PR conflicts with #2601.

Both touch tests/dm-result-send-dm.test.py. Verified with git merge-tree --merge-base=origin/main (non-destructive, no checkout):

#2601 x #2618  -> CONFLICT (content) in tests/dm-result-send-dm.test.py
#2618 x #2619  -> clean          <- control: the tool does return 0 for genuinely-clean pairs
same-branch    -> clean          <- control

Both are approved and CLEAN against main individually — mergeStateStatus is computed against the base, never against a sibling PR, so neither shows any sign of this until the first one lands.

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.

@john-the-dev
john-the-dev enabled auto-merge (squash) August 4, 2026 13:55
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
@sonichi
sonichi force-pushed the fix/dm-result-silent-attachment-rejection branch from 67aff0c to 94c2607 Compare August 4, 2026 15:41
@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto main at 94c26078. CONFLICTING → MERGEABLE, and both approvals survived (dismiss_stale_reviews=false on both gate surfaces), so this no longer needs a re-review — only the main-red unblock that every PR is waiting on.

The conflict was worth resolving carefully rather than by picking a side. main had gained the outbox-redirect harness from #2612; this branch adds two attachment tests. Only one symbol overlapped — main(), the runner — which is the dangerous kind: take either side wholesale and the other side's tests are still defined but never called, and a test that is never collected cannot fail.

Resolution keeps both test sets and merges the runners, with an assertion that every defined test is reachable:

defined: 8   called by main: 8   NEVER COLLECTED: none

And the file's own regression caught me mid-merge. I first placed the two new calls at the runner's top level — outside with _outbox_redirected(): — so they ran after the redirect was restored and appended to the resolved outbox path. The suite failed exactly as designed:

AssertionError: the suite appended to the LIVE outbox log (2159 B -> 2750 B)

Both calls now sit inside the redirect (my new tests call send_dm(), so outside it they'd write to the audit log — the precise defect #2612's harness exists to prevent). Re-run: EXIT=0, OK: live outbox.log untouched; audit rows went to the redirect.

Two measurement notes, since both nearly became wrong claims:

  • The polluted file was the worktree's own workspace/state/outbox.log, not the operator's — a worktree resolves its own workspace. The operator's is untouched (2,036,184 B, mtime 07:58, before this work).
  • My first "suite EXIT=0" was $? after a pipe to tail, i.e. tail's status. The suite was actually failing. Re-measured without the pipe.

@john-the-dev
john-the-dev merged commit 7fa661f into main Aug 4, 2026
15 of 18 checks passed
@john-the-dev
john-the-dev deleted the fix/dm-result-silent-attachment-rejection branch August 4, 2026 15:42
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

john-the-dev added a commit that referenced this pull request Aug 4, 2026
* 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>
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.

4 participants