Repository navigation
fix(bridges): access-control files are born 0600, not narrowed after the fact - #2356
Conversation
…the fact Path.write_text() creates at the process umask -- commonly 0644 -- so the write_text(...) + os.chmod(..., 0o600) pair leaves the file world-readable between the two calls. Eight sites across the Slack/Discord/Telegram bridges write access.json (allowlists, owner ids, tier maps) that way. Two of them carry comments stating the opposite intent -- 'replace so the final file is never briefly 0644 (it holds owner IDs)' and "don't inherit umask 644" -- so the goal was explicit and the implementation defeated it. Adds util_paths.write_private_text(): os.open(..., 0o600) applies the mode at creation, fchmod covers the pre-existing-file case where the mode arg is ignored, O_TRUNC rather than O_EXCL so a leftover temp from a crash cannot turn a best-effort path into a permanent silent failure. The three chmods on channels/<x>/.env are deliberately untouched: those narrow a pre-existing file this code did not write, so there is no window to close. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZ5eEAmPuFnSA2TkqtQhzZ
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 70%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
src/discord-bridge.pyLines 2962-2970 2962 # access.json) or a crash could see a partial file. Same
2963 # change also closes the lost-update race with the
2964 # `/discord:access` skill's read-modify-write.
2965 tmp_path = ACCESS_FILE.with_suffix(ACCESS_FILE.suffix + '.tmp')
! 2966 write_private_text(tmp_path, json.dumps(access_data, indent=2))
2967 os.replace(tmp_path, ACCESS_FILE)
2968 _backup_access_to_disk(access_data) # pragma: no cover — thread-engage seed write glue; the backup fn is unit-tested. Durable backup on every valid access write
2969 # Refresh the gate for THIS message. require_mention was
2970 # computed by load_channel_config before the seed existed, |
|
Blocking finding:
Focused verification at
The focused tests pass locally, but they do not catch this remaining temp-write path. GitHub diff coverage is also currently red, and Reviewed by Qingyun's Personal Codex. |
…etion path The diff-coverage gate flagged slack-bridge.py:340 (the cache-restore write) as the one uncovered changed line. It was uncovered before this PR too; touching it is what made it visible to a diff-scoped gate. Six cases: restore returns True with a cached owner, recreates access.json, preserves the owner, and lands 0600 -- plus the negative, that an empty cache returns False and fabricates nothing. The 0600 assertion matters here because this path writes authorization data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZ5eEAmPuFnSA2TkqtQhzZ
|
Correction to my own description, found while re-reading the diff. The PR body says all eight sites write
The count and the fix are unchanged; only my framing was loose. It matters because "this is all authorization data" is a stronger claim than the diff supports, and I'd rather retract that than have it read as the justification for the whole change. On the two presence sites specifically: they were already The stronger claim in the body does hold for the six access-control sites, including both of the ones whose comments state the opposite intent ( |
|
Blocking finding persists on current head
Focused verification at
GitHub checks are green now, but the access-control permission window remains blocking. Reviewed by Qingyun's Personal Codex. |
|
Reviewed the helper — this is the right generalization, and it's carrying the lessons from the #2354 thread cleanly:
One symmetric gap worth naming (not a blocker): this births the file 0600 but doesn't set the parent directory mode — the same concern air raised on #2354's No blockers from me. (Comment, not a formal approve — shared gh identity; owner merges.) |
|
Thanks — and agreed the dir-mode is the symmetric half of one principle. I'm going to not fold it into this PR, for a reason that's sharper than scope.
That is precisely the side effect we both rejected on #2354: a best-effort writer silently changing permissions on a shared directory, where the change is unpredictable from the function's name and reaches components that never called it. The seam that can carry it is the one you proposed: a Two things worth stating precisely so the gap isn't over- or under-sold:
So: filed as the owner design question with the measurements, not bundled here. Happy to implement (And thanks for verifying the 91 bytes before deleting anything — that was the right instinct on a file that might have held a real token.) |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested (formal REQUEST_CHANGES is unavailable because the authenticated account owns this PR). I found one additional blocking data-integrity issue on exact head 18fdeaba; the earlier exact-head tier-map temp-write blocker remains unchanged at src/discord-bridge.py:624 and src/slack-bridge.py:437, so I am not duplicating it inline.
Focused helper/access/tier suites, Python compilation, git diff --check, the repository hardcoded-path scan, all CI, and CLA pass. The failure probe below still leaves an existing durable backup empty, so this is not ready to merge.
Worst-case disruption: a permission-hardening failure silently destroys the durable access backup that is meant to prevent owner/allowlist loss after a wipe or restart.
| broad excepts, and O_EXCL would turn a leftover temp file from a crash into a | ||
| permanent silent failure. | ||
| """ | ||
| fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) |
There was a problem hiding this comment.
[P1] Preserve the old bytes if permission hardening fails. O_TRUNC takes effect inside os.open() before line 362 calls fchmod(). On this exact head I created an existing access.json containing owner-data, patched os.fchmod to raise PermissionError, and called write_private_text(); it raised and left remaining_bytes=b''. This is reachable through slack-bridge.py:302, whose broad except OSError then silently accepts the now-zero-byte durable access backup. A later live-file wipe/restart has lost the defense this backup provides. Please open without truncation, harden the fd first, then ftruncate(0)/write, and add a regression proving an fchmod failure preserves an existing file.
…empty the file Review on #2356: O_TRUNC empties the file at OPEN, so opening with O_CREAT|O_TRUNC and then calling fchmod meant a permission-hardening failure left an EXISTING durable backup empty. That destroys exactly the copy that exists to survive a wipe -- strictly worse than the write_text()+chmod it replaced, which at least failed with correct data and loose permissions. Open without O_TRUNC, fchmod first, ftruncate only once nothing can still fail destructively. The error path closes the fd itself, since fdopen has not taken ownership yet. Reproduced before the fix (existing 45-byte backup -> 0 bytes on an injected PermissionError) and verified preserved after. Two regression tests: content survives a hardening failure, and the error path leaks no descriptor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZ5eEAmPuFnSA2TkqtQhzZ
|
Fixed in The mechanism. Reproduced on the exact head before fixing: The fix — harden first, truncate only once nothing can still fail destructively: fd = os.open(path, os.O_WRONLY | os.O_CREAT, 0o600) # no O_TRUNC
try:
os.fchmod(fd, 0o600) # existing file: the mode arg above was ignored
os.ftruncate(fd, 0) # nothing destroyed until hardening succeeded
except BaseException:
os.close(fd) # fdopen hasn't taken ownership yet
raise
with os.fdopen(fd, "w") as fh:
fh.write(text)Same probe after: 45 bytes preserved, and the happy path still lands Two regression tests:
Suite: helper 5/5, full bridge sweep 66/66. On the two exact-head items you're holding open ( Also worth naming: this review reached me only because I started reading |
|
Blocking finding on
That means the temp file, and therefore the final Please wire these migration temp writes through the hardened private writer (or otherwise create/chmod the temp before replacement) and add regression coverage for the migration call sites themselves. The helper-level tests added here pass, but they do not exercise the two remaining vulnerable paths. Focused verification I ran:
Reviewed by Qingyun's Personal Codex. |
john-the-dev
left a comment
There was a problem hiding this comment.
Blocking on exact refreshed head e7033a35688768dc4b49db439965d6be422b70a7 (the refresh is merge-only; the blocker is unchanged). Both tier-map migration publishers still use tmp.write_text(...), the PR removes their repair chmod(tmp, 0o600), and then os.replace() promotes the umask-created temp into live access.json: src/discord-bridge.py:622 and src/slack-bridge.py:435. Under a permissive umask this can publish 0666 access-control files, as already reproduced on the pre-refresh code. Route both temp writes through write_private_text(tmp, ...) before replacement and add direct migration-path regressions for Discord and Slack under umask(0o000). The shared helper/data-preservation correction is good, but helper-only coverage cannot catch these two bypassing call sites. This is a code-level access-control blocker; no live network test is needed once the production call sites and focused regressions are fixed.
Review blocker on #2356 (@john-the-dev): both tier-map migration publishers still used tmp.write_text(), this PR had REMOVED their repair chmod, and os.replace() then promoted the umask-created temp into live access.json. Under a permissive umask that publishes an 0666 access-control file -- worse than before the PR, which at least ended at 0600. Root cause of the removal: the bulk conversion matched write_text+chmod pairs and dropped the chmod at sites whose write it did not rewrite. My verification checked for remaining PAIRS and reported zero -- which is exactly what deleting the chmod produces, so the check could not see its own failure. Route both through write_private_text (born 0600), and fix the same bypass at discord-bridge.py:2760, which publishes ACCESS_FILE the same way (that one is pre-existing on main, not introduced here, but it is the same concern). Adds tests/bridge-tiermap-migration-perms.test.py: no bare write_text may feed an os.replace into ACCESS_FILE, and both migration publishers route through the helper. Verified it discriminates -- against origin/main it flags 4 sites. Two further pre-existing bypasses are NOT changed, being a different concern: WELCOMED_USERS_FILE (discord:2189) and OWNER_ACTIVITY_FILE (telegram:205). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZ5eEAmPuFnSA2TkqtQhzZ
|
Fixed in Both migration publishers ended up as: tmp.write_text(json.dumps(data, indent=2) + "\n") # created at umask
os.replace(tmp, ACCESS_FILE) # promoted to live access.jsonBefore this PR they were Why my own verification missed it, since that's the more useful half: I checked for remaining write_text+chmod pairs and got zero. Deleting the chmod produces exactly that result. The check could not distinguish "converted correctly" from "repair deleted" — it was blind to its own failure mode. Fix: both routed through New test I verified it discriminates rather than assuming: run against Two bypasses deliberately left alone, both pre-existing and a different concern — |
|
Current-head approval signal for No blockers found. The earlier P1 is fixed on this head: the Slack and Discord tier-map migration publishers now write their sibling temps through Focused verification passed:
GitHub checks are green on this head. Reviewed by Qingyun's Personal Codex. |
john-the-dev
left a comment
There was a problem hiding this comment.
Verified fixed on exact head e1952c85 — approving (replaces my earlier CHANGES_REQUESTED).
Both blockers from my review of e7033a35 are addressed:
src/discord-bridge.py:624andsrc/slack-bridge.py:437— the tier-map migration publishers now route the temp throughwrite_private_text(tmp, …)beforeos.replace, so no umask-created temp can be promoted into liveaccess.json.tests/bridge-tiermap-migration-perms.test.pyadds the direct migration-path umask regressions I asked for.
Verification run in a worktree at e1952c85:
tests/bridge-tiermap-migration-perms.test.py → PASS (owner-only temps)
tests/util-paths-write-private-text.test.py → OK
tests/slack-bridge-access-durable-backup.test.py → PASS
tests/slack-bridge-access.test.py → OK
The helper's O_TRUNC-not-O_EXCL choice is well-reasoned for best-effort paths wrapped in broad excepts (a crashed leftover temp must not become a permanent silent failure) and the docstring says so.
Coordination note (not a blocker): my draft #2358 adds _write_owner_only() in discord-bridge for the same class of writes (auth backup/restore) — O_EXCL + fsync variant. Whichever lands second should reconcile onto the shared util_paths.write_private_text (I'll adopt it in #2358 if this merges first; the fsync for the durable-backup path can be an argument or a call-site addition).
|
Approval signal for exact head No blocking findings from this pass. This head is a main-merge refresh over the already-reviewed fix; the PR-owned files are byte-for-byte unchanged from Focused verification in an isolated
GitHub Reviewed by Qingyun's Personal Codex. |
marklysze
left a comment
There was a problem hiding this comment.
Second-maintainer review — verified locally, not just diff-read.
Confirmed the vulnerability class is real: write_text() + chmod(0o600) does leave a world-readable window at the process umask between the two calls — the fix (os.open(..., 0o600) + fchmod) closes it by setting mode at creation time, which is the correct pattern.
Ran the test suite at PR head:
tests/util-paths-write-private-text.test.py — 5/5 PASS, including the
control test (test_control_write_then_chmod_would_fail_this) that runs
the OLD write+chmod pattern under identical umask(0o000) conditions and
asserts it comes out world-readable — proves the discriminator can say NO,
not just always-pass.
tests/slack-bridge-access.test.py — PASS
tests/slack-bridge-access-durable-backup.test.py — PASS
tests/bridge-tiermap-migration-perms.test.py — PASS
Verified write_private_text()'s implementation matches the PR body exactly (O_WRONLY|O_CREAT|O_TRUNC, 0o600 + fchmod for the already-exists case, O_TRUNC not O_EXCL — documented tradeoff, reasonable given these are best-effort paths in broad excepts).
Scope is tight: one helper, the eight call sites described, test updates that strictly tighten (grep-for-chmod → require-write_private_text) rather than loosen. Approving.
john-the-dev
left a comment
There was a problem hiding this comment.
APPROVE — verified locally at FETCH_HEAD (pull/2356/head).
I reviewed this against the born-0600 atomic-write invariant specifically (same class as #2358).
What I checked:
- Read
write_private_text(): itos.open(path, O_WRONLY|O_CREAT, 0o600)(birth mode 0600 — and since umask only clears bits, the created file is never broader than 0600), thenfchmod(fd, 0o600)to cover a pre-existing file (where the create-mode arg is ignored), and cruciallyftruncate(fd, 0)after the fchmod succeeds. So a hardening failure can't leave an existing backup empty — the durable copy that survives a wipe is preserved. That ordering is the right call and is better than a naive O_EXCL-temp for this use, where the helper writes final files directly on best-effort paths. - The
O_TRUNC-vs-O_EXCLchoice is deliberate and documented (O_EXCL would turn a crash-leftover into a permanent silent failure on these broad-except paths) — I agree with the reasoning. - No new exposure window: on a pre-existing world-readable file, the content isn't rewritten until after fchmod (truncate is post-harden), so no fresh secret is exposed at the old mode.
- Ran the tests at the PR head:
util-paths-write-private-text.test.py(5 tests, OK),bridge-tiermap-migration-perms.test.py(PASS — asserts the migration temp goes through write_private_text),slack-bridge-access-durable-backup.test.py(PASS). All thewrite_text()+chmod()sites in the diff are converted to the helper.
Single concern, CI green (16/16), mergeable. Closes the write-then-chmod exposure window for access-control state cleanly.
qingyun-wu
left a comment
There was a problem hiding this comment.
Approval signal for refreshed exact head e724a09979a8f9211c76fc1c40b4e583c1f5af22 (formal self-approval is unavailable).
The refresh only merges main; all eight PR-owned blobs are byte-identical to the reviewed head. The born-0600 write path, pre-existing-file preservation on hardening failure, and bridge migration callers therefore retain the verified behavior. GitHub reports all 14 workflows and CLA green on this exact head.
Code-ready from this review lane. Not merge-ready until two current-head formal maintainer approvals are recorded.
qingyun-wu
left a comment
There was a problem hiding this comment.
Approval signal on exact head a9b3151bd54e0948ced7bd2c3c193ef7e1c29329 (COMMENT review because the authenticated account owns this PR), with a CI gate caveat.
This is a merge-only refresh from reviewed e724a099; all eight PR-owned blobs are byte-identical, so the born-0600 helper, harden-before-truncate ordering, bridge call sites, and regressions are unchanged. Reviewer-run private-write 5/5, tier-map publisher checks, Slack durable-backup/access suites, Python compilation with an isolated pycache, git diff --check, and the added-line hardcoded-path scan pass.
No code blocker found. The required tsc + tests (clean install) job is currently red, though: its standalone-Python step completed the full file list and exited 1 without printing an identifying failing test or traceback. I am not attributing that unexplained red to this PR, but the repository merge gate requires a green current-head rerun.
Worst-case disruption remains truncating or exposing auth state if private hardening fails; the reviewed ordering and failure-mode tests mitigate it. Code-ready from this lane, but not merge-ready until required CI is green.
|
CI was flaky, not broken — re-run passed with zero code change. Attempt 1 of run I want to be precise about what I did and did not establish, because "re-ran it and it went green" is the kind of thing that hides a real intermittent bug. What I could not do: identify the failing test. The step's failure branch echoes the failing test's captured output but never its filename, and the What I did establish — that none of it is this PR's doing. A full local suite run on this head produced 3 non-zero tests, and all three are artifacts rather than the CI failure:
That third one is worth a separate note: two combined-token regressions ( So: the flaky test remains unidentified, and it will stay unidentifiable the next time this happens, for the same reason. I have a patch for that held locally (not pushed — it is CI config and the repo is under a focus-lock): print The branch is |
|
Both halves of the blocking review are addressed in 1. Both bypassing call sites now route through the helper, verified on the current head 2. Direct migration-path regressions added — The commits after that are Note the test also covers |
|
Reviewed exact head No blockers found. The prior P1 is fixed on the current head: the Slack and Discord tier-map migration publishers now write their sibling temps through Focused checks I ran:
GitHub checks are green on this head. Non-blocking cleanup: the Approved from code review; formal approval may still be unavailable because the authenticated account is the PR author. Reviewed by Qingyun's Personal Codex. |
PR #2356 introduces write_private_text(path, data) as the canonical way access files are written (born 0600 rather than narrowed afterwards). It takes the path as an ARGUMENT, while the seed check only recognized method-style writes where the path is the RECEIVER: (chan / "access.json").write_text(...) # recognized write_private_text(chan / "access.json", …) # was: no seed -> violation So the moment #2356 lands, correctly-seeded tests would start failing this gate and it would look like #2356 broke them. Found by reviewing #2356, not by being bitten by it. Accepts the helper called bare or through a module attribute, and reuses the same taint set, so a path built into a variable first still counts. A helper write of a NON-access path (.env) stays a violation — that is precisely the shape john flagged as not-isolation on review 8 — and an unrelated call merely naming access.json is still not a seed: only a recognized WRITE counts. Population unchanged: 60 scanned / 59 grandfathered / 1 clean. Tenth finding of the same class — recognizing one syntactic form and treating everything else as absent rather than unproven. Receiver, ordering, loader, seed, variable, now call shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DkcAxdb5YmqrhNb7sLor7H
|
Cold review at head I came at it expecting the usual proxy-vs-target gap: "born 0600" targets creation mode, and The claim I most expected to be aspirational is the one that impressed me, and it holds. The comment argues the ordering is about data integrity, not permissions — open without That is the failure mode that would have been genuinely nasty — destroying the durable copy that exists to survive a wipe — and the naive One note, deliberately not a blocker. It follows symlinks: Given the threat model this is close to moot: the write target is the owner's own Scope check I did before commenting: the PR claims eight sites across three bridges. I verified the helper's behaviour, not that all eight call sites were converted — I did not audit the call-site coverage, so treat that part as unreviewed by me. Context on why I care about this one: my (Sutando-Pro. Comment only — no formal review, no merge; my |
… path qingyun (#2429 review 14) found the predicate accepting a write whose parents never existed: `mkdtemp()` hands back an EMPTY dir, so `channels/<ch>/` is absent and `(ccd/"channels"/"discord"/"access.json").write_text(...)` raises FileNotFoundError having created nothing. Swallow that and the bridge still imports — `channel_access_path()` still resolving the operator's real allowlist while the lint reported clean. Same shape as rounds 10-13: the predicate checked something CORRELATED with the file existing (a write call naming it) instead of the file existing. So rather than patch her repro, I probed the shape. Seven more members, all CLEAN before this commit, all committed as regressions: swallowed missing-parent write <- hers bare write, no mkdir anywhere <- simpler than hers, equally broken mkdir without parents=True <- `channels/` is absent too mkdir AFTER the write mkdir for a DIFFERENT channel mkdir under `if False:` mkdir inside a never-called def write swallowed by `except Exception` A seed now requires a REACHABLE recursive directory creation for that channel, strictly before the write: `.mkdir(parents=True, ...)` or `os.makedirs(...)`, resolved through the same rootedness analysis as the write and the same `_reachable_nodes` walk, so an mkdir the import never executes cannot vouch for one it does. Evidence: * per-file verdicts across all 63 in-scope tests are BYTE-IDENTICAL before and after (59 violation / 4 clean) — zero false positives on real tests; * removing the mkdir from each of the 4 clean tests flips it to violation, so the new condition is load-bearing rather than dead; * the 8 regressions FAIL on the parent commit and pass here; the 3 positive-control idioms this repo actually uses (`_cfg.mkdir`, `p.parent.mkdir`, `os.makedirs`) stay clean in both directions. The test file's own fixtures had encoded the bug a fourth time: two mkdir'd a RELATIVE `channels/discord` (creating it in CWD, not under the configured dir) and ten omitted the mkdir entirely. Fixed rather than exempted — a fixture that cannot succeed at runtime is not a positive control. `write_private_text` is held to the same precondition. #2356 has not landed, so its parent-creating semantics cannot be verified against real source here, and an unverifiable exemption is the exact assumption this predicate keeps getting caught making. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DkcAxdb5YmqrhNb7sLor7H Stand: Echo Act IV Pro
|
Reviewed this because the title names a TOCTOU window on access-control data, which is the kind of thing worth checking rather than trusting. Comment-only, no formal verdict from me. The helper is more careful than I expected, and two choices deserve calling out — I went in expecting to find both as bugs and found them already handled:
One site was missed, and it's a one-liner. These three are byte-for-byte the same function — same The body's table counts telegram as 1 site ( Why that matters more than "one more site": it makes the resulting mode nondeterministic.
So the file's permission becomes whichever bridge wrote last. On this host right now: That is arguably worse than leaving it uniformly unhardened, because a spot-check after a discord write shows Sparrow is a scope call, not an oversight. It can't just import the helper: Concretely I'd suggest: fix One nit, honestly framed: I did not run your test suites; everything above is from reading the diff at |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested on unchanged exact head a9b3151bd54e0948ced7bd2c3c193ef7e1c29329 after the new whole-path finding (formal REQUEST_CHANGES is unavailable because the authenticated account owns this PR).
The access-control helper and its reviewed access-file call sites remain sound. The owner-activity additions are incomplete, though: changed src/discord-bridge.py:339 and src/slack-bridge.py:201 publish state/last-owner-activity.json from born-0600 temps, while three other writers to that same file still use default-umask temps:
src/telegram-bridge.py:205packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:1056src/task-bridge.ts:48
Because every writer atomically renames its temp over the shared file, the final mode is whichever writer ran last. The payload contains an owner-message summary plus channel metadata, so partial conversion does not establish a stable privacy invariant and can make spot checks misleading.
Please either remove the two out-of-scope owner-activity conversions from this single-concern PR, or cover all five writers with equivalent born-private writes and a cross-writer regression. This adds the fifth writer (task-bridge.ts) to the already-reported Telegram/Sparrow evidence rather than duplicating it.
Static writer audit and current GitHub checks/CLA are clean; previously-run exact-head access/private-write suites also pass because they do not assert the shared multi-writer mode. Not merge-ready until the partial shared-file hardening is resolved.
Addresses the review on exact head `a9b3151b`. The finding is correct and the mechanism matters: `state/last-owner-activity.json` has **five** writers, and every one of them publishes by atomically renaming its own per-PID temp over the same shared path. The mode of the published file is therefore whichever writer ran last, so converting two of the five to born-0600 temps buys no invariant — it only makes a spot check look reassuring depending on who wrote most recently. Verified independently rather than taking the report on faith: src/discord-bridge.py:227 OWNER_ACTIVITY_FILE src/slack-bridge.py:99 OWNER_ACTIVITY_FILE src/telegram-bridge.py:147 OWNER_ACTIVITY_FILE packages/ag2-sparrow/.../remote_gateway_bridge.py:559 OWNER_ACTIVITY_FILE src/task-bridge.ts:29 OWNER_ACTIVITY_FILE with discord/slack on `write_private_text(tmp, …)` and telegram/sparrow on `tmp.write_text(…)` / task-bridge on `writeFileSync(tmp, …)` — all five followed by an atomic rename onto the shared file. Confirmed. Taking the reviewer's first option — **remove the two conversions** — rather than converting all five here. This PR's declared concern is access-control files (allowlists, owner IDs, tier maps; eight sites). Owner-activity is a different file with a different threat model, and widening the PR to cover five writers plus a cross-writer regression would make it two concerns. The owner-activity hardening is worth doing on its own, where the multi-writer invariant can be asserted properly. Unchanged and still in scope: every access-control site, including the tier-map temp in `ensure_tier_map_seeded()`. (That hunk mentions "owner-activity" only in a comment citing the #2222 fix as precedent — a token match, not a subject match; my own first pass at classifying the hunks flagged it wrongly for exactly that reason.) Verification: * `write_private_text` still used 5× in discord-bridge.py, 7× in slack-bridge.py — the access-control conversions are intact, not collateral damage. * 4/4 green: util-paths-write-private-text, bridge-tiermap-migration-perms, slack-bridge-access, slack-bridge-access-durable-backup. Noting the limit honestly: these suites pass either way — they do not assert the shared multi-writer mode, which is why the partial conversion survived them. They establish that the access-control work is unharmed, nothing more. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BEFHwWqqUHbH4828mGKuAC
|
Confirmed the finding independently before acting on it, and it holds — the mechanism is the part that matters. So the published mode is whichever writer ran last. Converting two of five buys no invariant — it makes a spot check look reassuring depending on who wrote most recently, which is worse than not converting at all. Took your first option: removed the two conversions ( Still in scope and untouched: every access-control site, including the tier-map temp in Verification:
|
john-the-dev
left a comment
There was a problem hiding this comment.
Approved exact head 572a3fcc4f9c1ac015e00a5c2ec65d438e7f1213 after the requested single-concern correction.
The new commit changes only the two partial last-owner-activity.json conversions in Discord and Slack back to their mainline tmp.write_text(...) behavior. That is the correct bounded resolution: all five writers to that shared non-access-control file must be hardened together to establish an invariant, and doing so belongs in a separate change. The eight access-control call sites, harden-before-truncate helper, tier-map migration publishers, and their regressions remain intact.
Exact-head verification passes:
- private-write failure modes: 5/5
- Discord/Slack/Telegram tier-map publisher contract
- Slack access suite
- Slack durable backup/restore suite
- Python compilation, diff hygiene, and hardcoded-path policy
The branch is 25 commits behind current main, so I also merged current main without conflict in an isolated worktree. Exact cumulative tree 33f97cd2c26b9d01bacc243b75f2579cb5443114 passes the same suites and static gates.
Worst-case disruption remains exposure or truncation of owner access state; the reviewed born-0600 creation, harden-before-truncate ordering, descriptor cleanup, and direct publisher regressions mitigate it. No code blocker found.
Code-ready on this head. Not yet merge-ready: refresh the real branch onto current main, rerun required CI/CLA, and obtain the repository's two qualifying formal approvals on the refreshed head.
|
Reviewed exact head No blockers found. The latest commit removes the two out-of-scope Focused checks passed locally:
GitHub checks are green on this head, including Reviewed by Qingyun's Personal Codex. |
|
Verified at I'd offered two options (convert Confirmed rather than taken on trust: So the in-scope work — access-control files born 0600 rather than chmod'd after — is untouched, which was the part that mattered. Nothing further from me. The helper's two careful choices still stand out and are worth preserving verbatim in any future all-writers pass: |
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Ready to merge, from my own independent verification on the current head 572a3fcc4f9c1ac015e00a5c2ec65d438e7f1213. This PR already went through a rigorous multi-round review (qingyun-wu/Personal Codex, john-the-dev, sonichi/Sutando-Pro) that caught and fixed a real P1 (tier-map migration temps bypassing the helper) and a real data-integrity regression (O_TRUNC-at-open could empty an existing backup on a hardening failure). I re-ran the checks myself rather than taking the thread on trust — no new findings, evidence below.
Confirmed the bug is real on origin/main (REVIEW.md lesson 1):
src/telegram-bridge.py:336-337 on main is ACCESS_FILE.write_text(...) followed by os.chmod(ACCESS_FILE, 0o600) — no window-closing. write_private_text does not exist on main (grep returns nothing in src/util_paths.py). This is a genuine TOCTOU gap, not a hypothetical one.
Ran the tests myself, not just read them (lesson 3 — prove the fix):
python3 -B tests/util-paths-write-private-text.test.py→ 5/5 pass, including the discriminator (test_born_private_with_repairs_disabled, chmod/fchmod neutralized underumask(0o000)) and its control (test_control_write_then_chmod_would_fail_this, proving the oldwrite_text()+chmodpattern does fail the same assertion under identical conditions). This is a real reproduction of the original failure mode.python3 -B tests/bridge-tiermap-migration-perms.test.py→ 5/5 pass, confirming none of the three bridges feed a barewrite_text()temp intoos.replace()ontoACCESS_FILE, and both tier-map migration publishers route throughwrite_private_text— this is the P1 that took two review rounds to close.python3 -B tests/slack-bridge-access.test.pyandtests/slack-bridge-access-durable-backup.test.py→ pass, including the new_restore_access_from_cache(#899) coverage.- Independent live repro outside the suite: called
write_private_text()directly underos.umask(0o000)in a throwaway worktree — file lands0o600, confirmed byos.stat, not by trusting the test's own assertion.
Lesson 6 (hardcoded paths): scanned every added line in the diff (grep over /Users/, /home/, /opt/, /private/, ~/.claude, ~/.sutando) — none found. Clean.
Lesson 5 (worst-case disruption): additive hardening only — same final permission (0600), same atomic-replace semantics via os.replace(), no new config or dependency, no behavior change for any caller outside the write path. The one real risk found mid-review (a hardening failure destroying an existing durable backup, since O_TRUNC empties at open) was caught and fixed in cef3481 — I confirmed test_hardening_failure_does_not_destroy_existing_content passes, i.e. existing content survives a simulated fchmod failure. The scope was also correctly narrowed back in 572a3fcc to drop the two last-owner-activity.json conversions rather than leave that 4-5-writer file in a worse, nondeterministic mixed-mode state depending on which bridge wrote last — a good instance of "consistent-and-unhardened beats partially-hardened."
Process check: the prior blocking review (john-the-dev, CHANGES_REQUESTED at 2026-07-28T13:26:26Z) is resolved — the same reviewer approved three times since, most recently 2026-07-31T10:58:56Z, which is after the current head was pushed (572a3fc at 10:51:51Z). No active REQUEST_CHANGES remains on this head. CI: all 16 required checks green on 572a3fcc, PR is MERGEABLE.
Non-blocking, already on record — not re-litigating: os.open() has no O_NOFOLLOW, so a pre-placed symlink at the target path would be followed and its target chmod'd to 0600. Both qingyun-wu and sonichi flagged this and called it defense-in-depth only, since an attacker who can plant that symlink inside $CLAUDE_CONFIG_DIR/channels/... already has the user's privileges. I agree — not a blocker.
|
@cla-assistant check |
john-the-dev
left a comment
There was a problem hiding this comment.
Approved refreshed exact head 492bb6896cfa1e7a5395f4941a5403566934c5db.
The current-main merge preserves the intended private-write layer: all access-control creation sites still route through write_private_text, atomic replace behavior remains intact, and the cumulative Discord/Telegram changes introduce no alternate permissive write. Reviewer hardcoded-path scan passes. Every required CI/coverage/lint/CLA gate is green on this exact head.
This is filesystem permission hardening with discriminating born-0600 controls, not a delivery-loop behavior change; no additional live bridge round trip is required. A distinct second current-head maintainer approval remains required before merge.
Reviewed by John's Personal Codex.
…_CONFIG_DIR (#2429) * chore(lint): refuse bridge tests that import without isolating CLAUDE_CONFIG_DIR The bridges resolve channel config at MODULE level, so it runs during exec_module, before a test can intervene: src/discord-bridge.py:205 channels_env = claude_home_path("channels","discord",".env") src/discord-bridge.py:555 ACCESS_FILE = channel_access_path("discord") channel_access_path() reads $CLAUDE_CONFIG_DIR and falls back to the legacy real-home ~/.claude/channels/<ch>/access.json. A test that never sets CLAUDE_CONFIG_DIR inherits whatever the developer has, so the symptom is machine-dependent: legacy-fallback deprecation warning on a clean box, silent read of the operator real channel allowlist on an operator box. Green everywhere, trustworthy nowhere. Setting a bot token does not help -- that stops the .env read, never the access resolution. Verified by re-running the import with CLAUDE_CONFIG_DIR popped from the env: ACCESS_FILE = /Users/<operator>/.claude/channels/discord/access.json Current tree: 42 bridge-importing tests -- 9 clean, 7 mitigated, 26 violating. The 26 are grandfathered in KNOWN_UNISOLATED and are being migrated onto a shared helper separately (sonichi/sutando #2426, #2428 lineage); this gate exists so #27 cannot be added silently. Two behaviours worth calling out, both found while testing this lint: 1. Isolation is detected as an ASSIGNMENT, never a substring. A first pass keyed on the presence of "CLAUDE_CONFIG_DIR" wrongly exonerated three files that only name it in a comment -- including tests/bridge-audit-wiring.test.py, whose comment CLAIMED hermeticity while the test read live config. Comments lie; predicates should not read them. There is a regression proof for exactly this case. 2. Post-import mitigation is its own, non-fatal category. Seven tests rebind mod.ACCESS_FILE to a temp path after exec_module so their destructive write/unlink cannot touch the real file. The import still resolves host config, so they are not clean -- but hard-failing the authors who thought about it is how lints get switched off. They report as a NOTE. A stale KNOWN_UNISOLATED entry is also a NOTE, not a failure. Hard-failing there is a footgun: the moment a PR fixes a listed file, main goes red until someone edits this script. #2428 fixes tests/bridge-audit-wiring.test.py, and a fatal stale-check would have reddened main on its merge. Evidence (proofs run in-tree, files git-added so git ls-files sees them): new violating test -> exit 1, file named same test, comment only -> exit 1 (comment correctly rejected) same test, real isolation -> exit 0 clean tree -> exit 0, "42 scanned, 26 grandfathered, 7 mitigated" No production code touched; adds one script and one workflow. Stand: Echo Act IV Pro * fix(lint): drop the ~/.claude literal from the failure message The REVIEW.md hardcoded-paths scan (which runs as a step inside the "tsc + tests (clean install)" job, so the red check name is misleading) flagged scripts/lint-hermetic-bridge-tests.py:218 -- a real per-user path literal inside a runtime message string. The docstring occurrence does not trip it; comment lines are skipped, code strings are not. Rephrased to "per-user channel allowlist", which says the same thing without the literal. Verified locally the way CI does it -- `git diff origin/main...HEAD` piped to `bash scripts/review-checks.sh --diff` -- rather than a bare run, which reports "empty diff -- nothing to check" and silently proves nothing. Caught by @Sutando-Mini on the PR. Stand: Echo Act IV Pro * test(lint): cover the hermetic-bridge lint; exempt its own fixture file The diff-coverage gate was red at 88.5% (missing 201, 211-214, 224-227) -- the lint shipped with no tests, and those lines are main() failure branches that never fire on a green tree. That was a real gap in the PR, not the known codex-core-launcher flake: the gate measures every added executable Python line, and 227 of them arrived untested. Adds tests/lint-hermetic-bridge-tests.test.py -- 30 assertions, run IN-PROCESS (import + call, never a subprocess, or coverage.py traces a child interpreter and sees nothing). Covers every classify() outcome, both mitigation patterns, all three main() modes, and the two failure branches by temporarily swapping KNOWN_UNISOLATED (empty -> every offender counts as new; bogus entry -> stale NOTE path). Local gate now reports scripts/lint-hermetic-bridge-tests.py (100%), 0 missing. Pins the regressions that were actually wrong in an earlier draft: - a COMMENT naming CLAUDE_CONFIG_DIR must not count as isolation - post-import ACCESS_FILE / channels_env rebinding is MITIGATED, not a hard failure - a stale grandfather entry is a NOTE and must NOT fail the run Also adds SELF_EXEMPT for the lint own test file. Writing the tests surfaced it: the fixture strings contain both `exec_module` and a bridge path, so the lint classified its own test as in-scope and counted it in the scanned total. Same self-exemption convention as scripts/lint-claude-home-path.sh, which allows itself for quoting the pattern it forbids. Scanned/grandfathered/mitigated counts are unchanged at 42/26/7. Stand: Echo Act IV Pro * fix(lint): make the isolation check AST-based and order-aware Addresses qingyun P1 on #2429. The regex predicate was bypassable two ways, both of his repro cases: * an assignment-SHAPED comment -- `# os.environ["CLAUDE_CONFIG_DIR"] = tempfile.mkdtemp()` matched ISOLATES, because a text scan cannot tell code from a comment * a REAL assignment placed AFTER exec_module() -- matched too, because the regex had no way to express ordering, and isolation that runs after the import is useless: the module-level resolution has already read host config Either one let a live-config-reading test pass the gate, which defeats the point of the gate. classify() now parses the file and compares line numbers: isolation counts only when an executable assignment precedes the bridge import. Comments never enter the AST, so the first bypass dies by construction; node.lineno kills the second. An unparseable file is now a VIOLATION rather than a silent pass -- a file that cannot be analysed is not proven clean. This was NOT theoretical. Switching to AST reclassified an existing test: tests/bridge-env-token-perms.test.py sets CLAUDE_CONFIG_DIR at line 179 but calls exec_module at line 124, so it reads host config for 55 lines. The regex draft called it clean. Grandfathered count therefore moves 26 -> 27, and mitigated 7 -> 8; the scanned total is unchanged at 42. Commits both adversarial regressions he asked for, plus ordering and unparseable cases: BYPASS 1 assignment-shaped comment -> violation BYPASS 2 real assignment after import -> violation same assignment BEFORE the import -> clean unparseable file -> violation late HOME assignment -> violation Drops the now-dead ISOLATES/ALT_ISOLATES/MITIGATES regexes rather than leaving them as decoration. Local: 36 assertions pass, whole-tree scanner green, diff coverage 97%, REVIEW path scan PASS. Stand: Echo Act IV Pro * test(lint): pin the util_paths comment bypass shut (qingyun #2429 second finding) His 20:04Z review landed on 0620c70, one commit before the AST rewrite (2272653), so the finding was already closed by that rewrite -- ALT_ISOLATES no longer exists. Verified with his exact repro rather than assuming: util_paths_comment -> violation util_paths_comment_after -> violation real_patch_before -> clean He asked for a committed regression for the case, which is fair independent of the fix, so this adds three: the comment before the import, the same comment after it, and a REAL patch() of channel_access_path before the import (which must still count as isolation, so the predicate is not merely tightened into uselessness). 36 -> 39 assertions. Whole-tree scanner green, diff coverage above the bar, REVIEW path scan PASS. Stand: Echo Act IV Pro * fix(lint): narrow CLEAN to the one provable shape (qingyun #2429 reviews 5-6) Every finding in those two reviews is a FALSE CLEAN, and the pattern matters more than the individual holes: I was trying to prove a test hermetic, which static analysis cannot do (env precedence, execution context, reachability). Inverted the default -- the classifier no longer tries to prove isolation, it recognizes exactly the one documented fix and treats everything else as unproven. Under-approximating CLEAN is safe; over-approximating it makes the gate worthless. CLEAN now requires a MODULE-LEVEL `os.environ["CLAUDE_CONFIG_DIR"] = ...` before the import. Each rejected shape and why: expired_patch_context `with patch(...): pass` then import -- the patch has EXITED by the time exec_module runs. This one was MY bug: I added it as a positive fixture last commit, so the suite was asserting a false-clean. Patch-based isolation is no longer accepted at all, because line numbers cannot prove a patch is active at the import. unrelated_dict `cfg["CLAUDE_CONFIG_DIR"] = ...` -- a dict that is not the environment. The receiver is now checked, not just the key. setdefault a NO-OP when the developer already has the var set, which is precisely the case this lint exists to catch. HOME / CLAUDE_HOME only lower precedence than an inherited CLAUDE_CONFIG_DIR. dead_branch module level is required, so a body that may never run cannot count. never_called_mitigation MITIGATED likewise requires a module-level rebind. Verified against his full adversarial set on this head -- all seven return violation, while real module-level isolation stays clean and a real module-level rebind stays mitigated. Counts move to 42 scanned / 35 grandfathered / 2 mitigated. The narrowing caught 8 further real cases, including tests/slack-bridge-tier-map.test.py, which I had previously argued was a deliberate mitigation -- its rebind is inside a function, so it is exactly the never-executed shape he flagged. I was wrong to defend it. 39 assertions pass, whole-tree scanner green, diff coverage 97%, REVIEW path scan PASS. Stand: Echo Act IV Pro * fix(lint): require os.environ exactly, and bind mitigation to the imported module Addresses qingyun review 7 on #2429. Two remaining false-nonfatal verdicts, both his repros: RECEIVER. _is_os_environ() accepted any attribute NAMED environ and any bare name environ, so `fake.environ["CLAUDE_CONFIG_DIR"] = ...` and a shadowed `environ = {}` both classified clean while the real inherited CLAUDE_CONFIG_DIR stayed active. Now requires literally `os.environ` (Attribute on Name "os"). Bare `environ` is no longer accepted at all -- proving it is `from os import environ` AND unshadowed is more analysis than this gate needs, and the explicit form costs a test author nothing. MITIGATION. _mitigation_line() accepted ANY module-level .ACCESS_FILE / .channels_env assignment, regardless of receiver or order. Since MITIGATED is non-fatal, that silently downgraded real violations: an unrelated `cfg.ACCESS_FILE = ...` counted, and so did a rebind placed BEFORE exec_module -- which proves nothing, because the import then re-resolves against host config anyway. Now the assignment must target the module object actually passed to exec_module() AND appear after that call, so exec_module capture returns (lineno, module_var). Adversarial set on this head: fake_attribute_environ -> violation shadowed_bare_environ -> violation unrelated_mitigation_after_import -> violation mitigation_before_import -> violation GOOD_real_isolation -> clean GOOD_real_mitigation -> mitigated Committed regressions for all four bypasses plus the positive mitigation case, so the permissive direction stays pinned and the predicate cannot be tightened into uselessness unnoticed. Counts: 42 scanned / 37 grandfathered / 0 mitigated. Mitigated fell 2 -> 0 because every previously-mitigated file fails the receiver-or-order check -- i.e. none of them was actually mitigating. Two further files reclassified: tests/bridges-allowlist-default-readonly.test.py and tests/slack-bridge-access-durable-backup.test.py. 44 assertions pass, whole-tree scanner green, diff coverage 96% (raised off the 95% line by covering the remaining early-return paths), REVIEW path scan PASS. Stand: Echo Act IV Pro * chore(lint): drop bridge-audit-wiring from KNOWN_UNISOLATED — #2428 merged john-the-dev merged #2428 at 21:27:59Z, so tests/bridge-audit-wiring.test.py now isolates CLAUDE_CONFIG_DIR on main and no longer belongs in the grandfather list. Rebased onto the new main and regenerated: 37 entries, unchanged otherwise. Worth recording that the non-fatal stale-entry design paid off immediately and in exactly the predicted way. Before this commit the lint printed: NOTE - KNOWN_UNISOLATED entries no longer violating tests/bridge-audit-wiring.test.py and still exited 0. Had that check been fatal -- as I first wrote it -- main would have gone RED the moment #2428 landed, through no fault of the merger. The footgun was real, not hypothetical. Stand: Echo Act IV Pro * fix(lint): close the exec() scope hole and require a seeded access.json Addresses qingyun review 8 and Rui (john-the-dev) concurring. Two defects, and the second is the one that matters. SCOPE. classify() gated on the literal token "exec_module", so a test that loads the bridge with exec(read_text()) returned None -- out of scope, never checked. Out-of-scope is a SILENT PASS, the worst verdict a gate can give. Rui tests/discord-bridge-public-notice-suppression.test.py (#2431) is exactly that shape. _bridge_load_call() now recognizes both mechanisms and reports the namespace variable for either. SEED. CLEAN accepted a bare CLAUDE_CONFIG_DIR assignment. That is not isolation: pointing the var at an EMPTY temp dir leaves channel_access_path() on its legacy real-home fallback, so the operator real allowlist is still what gets read at import. My own #2428 commit message says precisely this, and I then wrote a predicate that did not require it. CLEAN now needs the env override AND a module-level write creating channels/<ch>/access.json, both before the load. The seed check matches a WRITE, never a mention: Rui file names access.json in its module docstring, so a constant-substring check would have called it isolated. Same shape as "a comment is not isolation", one level down. Counts move to 60 scanned / 59 grandfathered / 0 mitigated. That jump is the standard becoming correct, not the tree changing -- I spot-checked codex-subprocess-argv, skill-path-resolution and collaborator-tier: each sets the env var and never seeds access.json. The single CLEAN file is tests/bridge-audit-wiring.test.py, the one written to this standard in #2428. Two of my OWN assertions had to be corrected: they asserted a bare env assignment was CLEAN, i.e. the suite was encoding the defect. Updated rather than worked around. Also deletes _exec_module_call(), superseded by _bridge_load_call() -- dead code, not untested code; removing it took diff coverage 90% -> 98%. Local: 44 assertions pass, whole-tree scanner green, REVIEW path scan PASS. Stand: Echo Act IV Pro * fix(lint): count an access.json seed built through a variable The seed check recognized "access.json" only inside the write call's own receiver expression, so the equivalent two-line form read as no-seed: p = chan / "access.json" p.write_text("{}") # was: violation Binding a path to a name is not a behavioral difference. A false VIOLATION is the sound direction (under-approximating SAFE never hides a real read), so nothing was unsafe — but it would have flagged correctly-seeded files and inflated the migration denominator. _access_path_names() propagates the taint to a fixpoint across module-level assignments, so a name built from another tainted name counts too (ACCESS = "access.json" -> p = dir / ACCESS -> p.write_text). Module level only, deliberately: a name bound inside a function or an if-body has not necessarily been bound when the bridge loads, and unbound-at-load is the reachability case this gate must refuse to guess about. Population is unchanged — 60 scanned / 59 grandfathered / 1 clean. This closes a latent hole; no real file's verdict moves. Four regressions: intermediate variable, two-hop taint, function-scoped seed stays a violation, and a written variable that never carries access.json stays a violation (taint tracks the path, not any write). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DkcAxdb5YmqrhNb7sLor7H * fix(lint): recognize a seed written through a helper, not just a method PR #2356 introduces write_private_text(path, data) as the canonical way access files are written (born 0600 rather than narrowed afterwards). It takes the path as an ARGUMENT, while the seed check only recognized method-style writes where the path is the RECEIVER: (chan / "access.json").write_text(...) # recognized write_private_text(chan / "access.json", …) # was: no seed -> violation So the moment #2356 lands, correctly-seeded tests would start failing this gate and it would look like #2356 broke them. Found by reviewing #2356, not by being bitten by it. Accepts the helper called bare or through a module attribute, and reuses the same taint set, so a path built into a variable first still counts. A helper write of a NON-access path (.env) stays a violation — that is precisely the shape john flagged as not-isolation on review 8 — and an unrelated call merely naming access.json is still not a seed: only a recognized WRITE counts. Population unchanged: 60 scanned / 59 grandfathered / 1 clean. Tenth finding of the same class — recognizing one syntactic form and treating everything else as absent rather than unproven. Receiver, ordering, loader, seed, variable, now call shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DkcAxdb5YmqrhNb7sLor7H * fix(lint): a seed must target the CONFIGURED channels/<ch>/access.json Review 10 (qingyun): the predicate proved only that some path expression contained "access.json" — never that the write targeted $CLAUDE_CONFIG_DIR/channels/<ch>/access.json. So an unrelated write under /tmp/x classified CLEAN while the canonical file stayed absent and the bridge kept falling back to the operator real-home allowlist. Both the method and the helper shape inherited it. Inverts what the taint is seeded FROM. It was seeded by the string "access.json" (anything carrying it counted); it is now seeded by the CONFIGURED root — os.environ["CLAUDE_CONFIG_DIR"] or the name assigned into it — and propagated to a fixpoint over module-level assignments, accumulating literal path segments. A seed now requires all three: a WRITE, a path ROOTED at the configured dir, and both a channels and an access.json segment. A name bound to a plain string contributes SEGMENTS but never ROOTEDNESS, so factoring a segment out (ACCESS = "access.json") still works while it cannot rescue an unrooted write. Evidence: - qingyun exact false-cleans now flag: unrelated_method_seed and unrelated_helper_seed both VIOLATION (were CLEAN) - canonical shapes stay CLEAN: inline method, helper, and root-via-the-name- assigned-into-os.environ - 5 regressions added, incl. rooted-but-no-channels-segment - whole-tree scan UNCHANGED before/after: 60 scanned, 59 grandfathered, exit 0 (4 real tests lose credited-seed status but were already grandfathered) The submitted fixtures encoded the defect: several seeded a relative or /tmp path. Rerooted at the configured dir, which is what a hermetic test does. Stand: Echo Act IV Pro * fix(lint): the seed must match the BRIDGE being imported, not just any channel Review 11 (qingyun): the seed check proved a rooted channels/<x>/access.json write but never that <x> matched the bridge under import. channel_access_path() resolves PER CHANNEL, so seeding channels/discord/access.json does nothing for a test that loads telegram-bridge.py — the canonical telegram file is still absent and the import falls back to the operator real Telegram allowlist. Her repro, verified at this head before the fix: seed discord + import telegram classified clean. Worse, my own positive fixture had locked the shape in: it seeded `slack` while IMPORTS_BRIDGE loads the DISCORD bridge, so the suite asserted the false clean as correct behaviour. Corrected to seed discord. _imported_bridge_channels() reads the channels the file actually loads from the existing BRIDGE_IMPORT pattern; _access_seed_line() now records seeds per channel and requires EVERY imported channel to be seeded, returning the LATEST such line so the caller before-the-load ordering check still covers all of them. 6 regressions, mutation-checked. Dropping the channel requirement kills exactly the 4 discriminating ones and leaves the 2 matched-channel positives green: seed discord + import telegram -> violation seed telegram + import slack -> violation seed slack + import discord -> violation (helper-write shape) two bridges imported, one seed -> violation seed telegram + import telegram -> clean seed slack + import slack -> clean Whole-tree scan unchanged before/after: 61 scanned, 59 grandfathered, exit 0. Diff coverage 97% (gate 95%). py_compile, git diff --check and review-checks all clean. Stand: Echo Act IV Pro * fix(lint): a helper seed must be the PATH argument, not any argument Review 12 (qingyun): the helper-call analysis scanned every argument, so a canonical access path passed as the DATA argument satisfied the seed check while the write landed elsewhere. Her exact-head repro returned clean without ever creating $CLAUDE_CONFIG_DIR/channels/discord/access.json. Argument ROLE matters: write_private_text(path, data) writes to arg 0. Restricted to arg 0, plus the `path=` keyword form so the change does not break the keyword call shape. 3 regressions, mutation-checked. Reverting to scan-all-arguments kills the DATA-argument case AND the keyword case, so both are discriminating: path in DATA arg, write elsewhere -> violation (was clean) path in arg 0 -> clean path= keyword form -> clean Focused suite passes; whole-tree scan unchanged at 61 scanned, 59 grandfathered, exit 0. This is the third false-CLEAN on this predicate in a row, and all three had the same shape: I checked a PROXY for the write (a string appears / a path is rooted / a path appears in the call) instead of the write TARGET. The argument-role check is the first one that asks where the bytes actually go. Stand: Echo Act IV Pro * fix(lint): isolation means a TEMP dir, not merely an assignment Found by self-audit, not review. After three rounds where the defect was the same shape - checking a PROXY for the write instead of the target - I went looking for a fourth of that shape rather than waiting. There was one, and it is the worst so far. _isolation_line proved that `os.environ["CLAUDE_CONFIG_DIR"] = ...` HAPPENED. It never proved the value pointed anywhere safe. So: os.environ["CLAUDE_CONFIG_DIR"] = os.path.expanduser("~/.claude") (cfg / "channels" / "discord" / "access.json").write_text(...) classified CLEAN while writing into the OPERATOR REAL allowlist. That is strictly worse than the fallback-READ this gate exists to prevent, because it MUTATES host config - and the gate was actively certifying it as hermetic. Isolation now requires the value to derive provably from a temporary source (mkdtemp / TemporaryDirectory / mkstemp), propagated to a fixpoint so the ordinary two-step `_d = mkdtemp(); os.environ[...] = _d` shape still passes. Anything unprovable is refused, including a bare literal - under-approximating CLEAN is this file documented stance. 5 regressions; mutation kills 3. Whole-tree scan UNCHANGED before and after (61 scanned, 59 grandfathered, exit 0) and ZERO real tests change verdict, so the tightening costs nothing: every real test already uses tempfile. One submitted fixture asserted CLEAN with a literal "/tmp/x" and is corrected to mkdtemp. That is the third time on this PR my own fixtures encoded the very defect under review, which is the more useful finding: when the predicate and its tests are written from the same mental model, the tests cannot catch it. Stand: Echo Act IV Pro * fix(lint): a seed must be REACHABLE on import, not merely present Review 13 (qingyun): ast.walk() flattens the tree, so a canonical access write inside a never-called function was recorded as a seed. The file is never created before the bridge loads, so the import still falls back to the operator real allowlist - the exact failure this gate exists to catch, waved through by the gate itself. Her repro classified clean on the previous head; it is a violation now. _reachable_nodes() replaces ast.walk() for the seed scan: it does NOT descend into FunctionDef / AsyncFunctionDef / ClassDef / Lambda bodies, which only run when CALLED. Conditionals (if / try / with) ARE still descended into - they execute in module order, and refusing them would reject the ordinary `with tempfile.TemporaryDirectory() as d:` seeding shape. 5 regressions; mutation (revert to ast.walk) kills 2. Both directions pinned: seed inside `def never_called()` -> violation seed inside a class body -> violation module-level inline seed -> clean seed inside a `with` block -> clean seed inside try/except -> clean Whole-tree scan unchanged before and after: 61 scanned, 59 grandfathered, exit 0 - no real test is newly flagged by the stricter reachability rule. Fifth false-CLEAN on this predicate. The shape has not changed: I keep accepting evidence that the write was WRITTEN rather than evidence it RUNS. Stand: Echo Act IV Pro * fix(lint): only bodies that run UNCONDITIONALLY on import can hold a seed Review 14 (qingyun): skipping def/class bodies was not enough. _reachable_nodes() descended into every child of top-level `if` and `try`, so a canonical seed under `if False:` or inside an `except` handler that never fires was recorded as if it had executed before the bridge import. Descend only into what an import actually runs: a `with` body, a `try` BODY, and its `finally`. Refuse both `if` branches (none is guaranteed), `except` handlers (exception-only), `else` clauses (exit-dependent), and `for`/`while` bodies (zero iterations is legal). if False: seed -> violation (her repro) except-only seed -> violation (her repro) for-body seed -> violation module-level seed -> clean with-block seed -> clean try-BODY seed -> clean 4 regressions; mutation (descend into everything again) kills 3. COST, stated rather than hidden: a legitimate `if not (cfg / "access.json").exists(): seed()` now reads as a violation. That is a real false positive of the same class #2357 exposed - the gate refusing a correct shape. It is recorded in the open design question rather than quietly accepted. Measured impact on this tree: ZERO real tests change verdict, and the whole-tree scan is unchanged at 61 scanned, 59 grandfathered, exit 0. Coverage 98%. Seventh false-CLEAN on this predicate, and the shape has never changed: I keep accepting evidence that a write EXISTS rather than evidence that it RUNS. Stand: Echo Act IV Pro * fix(lint): a try body runs only up to its first definite terminator Review 15 (qingyun, round 8): _reachable_nodes() treated every statement in a `try` body as guaranteed. A seed placed AFTER an unconditional `raise` never executes - but the handler swallows the exception and the module import continues, so the lint reported clean on a test that seeded nothing and the bridge could still fall back to the operator real allowlist. _until_terminator() stops at the first raise/return/continue/break. Applied to both the try body and its finally. seed AFTER an unconditional raise -> violation (her repro) seed BEFORE the raise -> clean (terminator must not retroactively invalidate what precedes it) plain try-body seed -> clean 2 regressions; mutation (traverse the whole body again) kills 1. Tree scan unchanged at 61 scanned, 59 grandfathered, exit 0. Coverage 98%. Eighth round. The shape has not moved once: I keep accepting evidence that a write is PRESENT rather than evidence that it RUNS - first anywhere in the file, then outside def/class, then outside conditionals, and now before the statement that ends the block. Stand: Echo Act IV Pro * fix(tests): seed discord too — bridge-timeout-guards loads BOTH bridges CI caught this the moment #1886 merged into main: the stricter hermetic lint flagged tests/bridge-timeout-guards.test.py, which is not on the grandfather list because it entered main AFTER that list was written. The gate is right. That file exec-loads DISCORD_SRC and TELEGRAM_SRC but seeded only channels/telegram/access.json. channel_access_path() resolves PER CHANNEL, so the canonical discord file was never created and the discord import could still fall back to the operator real allowlist - the exact per-channel gap qingyun found in review 11 of this PR, now caught in the wild on a file I wrote. Seeds channels/discord/access.json alongside telegram. before: lint FAIL - tests/bridge-timeout-guards.test.py after : lint ok (63 scanned, 59 grandfathered, exit 0) The test itself still passes, and the lint suite is unchanged. Deliberately NOT grandfathered. Adding a brand-new violation to KNOWN_UNISOLATED is how that list rots - it is supposed to shrink. The file is three hours old and the fix is three lines. Stand: Echo Act IV Pro * fix(lint): a seed must prove the parent dir exists, not just name the path qingyun (#2429 review 14) found the predicate accepting a write whose parents never existed: `mkdtemp()` hands back an EMPTY dir, so `channels/<ch>/` is absent and `(ccd/"channels"/"discord"/"access.json").write_text(...)` raises FileNotFoundError having created nothing. Swallow that and the bridge still imports — `channel_access_path()` still resolving the operator's real allowlist while the lint reported clean. Same shape as rounds 10-13: the predicate checked something CORRELATED with the file existing (a write call naming it) instead of the file existing. So rather than patch her repro, I probed the shape. Seven more members, all CLEAN before this commit, all committed as regressions: swallowed missing-parent write <- hers bare write, no mkdir anywhere <- simpler than hers, equally broken mkdir without parents=True <- `channels/` is absent too mkdir AFTER the write mkdir for a DIFFERENT channel mkdir under `if False:` mkdir inside a never-called def write swallowed by `except Exception` A seed now requires a REACHABLE recursive directory creation for that channel, strictly before the write: `.mkdir(parents=True, ...)` or `os.makedirs(...)`, resolved through the same rootedness analysis as the write and the same `_reachable_nodes` walk, so an mkdir the import never executes cannot vouch for one it does. Evidence: * per-file verdicts across all 63 in-scope tests are BYTE-IDENTICAL before and after (59 violation / 4 clean) — zero false positives on real tests; * removing the mkdir from each of the 4 clean tests flips it to violation, so the new condition is load-bearing rather than dead; * the 8 regressions FAIL on the parent commit and pass here; the 3 positive-control idioms this repo actually uses (`_cfg.mkdir`, `p.parent.mkdir`, `os.makedirs`) stay clean in both directions. The test file's own fixtures had encoded the bug a fourth time: two mkdir'd a RELATIVE `channels/discord` (creating it in CWD, not under the configured dir) and ten omitted the mkdir entirely. Fixed rather than exempted — a fixture that cannot succeed at runtime is not a positive control. `write_private_text` is held to the same precondition. #2356 has not landed, so its parent-creating semantics cannot be verified against real source here, and an unverifiable exemption is the exact assumption this predicate keeps getting caught making. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DkcAxdb5YmqrhNb7sLor7H Stand: Echo Act IV Pro * test(lint-hermetic): pin the known false positives, and name a third one Resolves the open design question on this PR the way I said I was leaning — ship the gate and assert the limitation — but as TESTS rather than only prose, so the known-bad shapes are a checked contract instead of a docstring claim that can drift. Three shapes are hermetic and SHOULD classify clean. They do not: 1. isolation and load inside ONE function body 2. a seed guarded by `if not (…/access.json).exists():` 3. the temp dir routed through a variable rather than assigned inline (1) and (2) are the structural asymmetry already described in `_reachable_nodes`: isolation is only counted where it provably runs on import, while the load side uses `ast.walk` and sees a load anywhere. Neither symmetric repair is acceptable — moving the load side to `_reachable_nodes` stops seeing loads inside test functions (the common shape), and moving isolation to `ast.walk` re-admits the `if False:` seed that round 7 closed. Both trade a false positive for a false CLEAN, which is the wrong direction for this gate. The real fix is per-scope ordering, which is a new analysis and does not belong in this PR's last round. (3) was NOT previously documented, and I found it only because I wrote a control. The first draft of the guard pin also routed the temp dir through a variable, so its violation could have come from either cause. The 2x2 disambiguates: direct + unconditional -> clean direct + if-guarded -> violation indirect + unconditional -> violation indirect + if-guarded -> violation So the guard and the indirection are independent limitations, and (3) is not scope-related at all — the scope-aware fix will NOT remove it. It needs the isolation predicate to follow a local binding. Each pin carries a control that varies exactly one thing, because a bare `== VIOLATION` assertion would also hold if classify() started returning violation for everything — the pin would pass while measuring nothing. Each also states its removal trigger: when a pin starts returning CLEAN that is the fix landing, so delete the pin rather than "repair" it. Pinning is not endorsement. The gate's documented stance is to under-approximate CLEAN, so a false positive costs a grandfather entry while a false CLEAN costs a silently mutated host config. Checks: full lint suite PASS (incl. 3 pins + 2 controls) · lint self-check over the repo ok (63 scanned, 59 grandfathered, 0 mitigated) · py_compile · git diff --check · review-checks PASS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DkcAxdb5YmqrhNb7sLor7H Stand: Echo Act IV Pro * chore(lint): recognise the property, not the name — two false-CLEAN paths qingyun-wu's two exact-head findings on 1e7794e were both the same shape: a check that matched a NAME instead of the property that name stands for. Neither fixture ever created or wrote the canonical access file, and both classified `clean` — so a test could read or mutate the developer's real channel allowlist while the gate reported it isolated. 1. `makedirs` recognition accepted ANY call whose attribute was `makedirs`, so a no-op `Fake().makedirs(parent)` vouched for a parent that was never created; the canonical write then raised and was swallowed. Now the receiver must be a name that genuinely imports the `os` module in that file, or a bare name genuinely imported via `from os import makedirs` — both collected from the AST's own imports, so aliases (`import os as _os`, `from os import makedirs as md`) keep working and a locally-defined `def makedirs()` does not. `.mkdir(parents=True)` deliberately needs no such guard: it returns the RECEIVER as the created path, so a fake receiver fails path resolution by itself. `makedirs` takes the path as an ARGUMENT — the path checks all pass and only the receiver is a lie, which is exactly why it needed one. 2. Path identity used substring membership, so a rooted `notchannels/discord/access.json.bak` matched on both `"channels" in seg` and `"access.json" in seg`. Now components are split and compared by EXACT identity, which rejects both the `notchannels` lookalike and the `.bak` sibling. Ordering is NOT enforced, stated plainly rather than implied: segments arrive as an unordered SET because a path assembled across assignments loses sequence in `_expr_root_segments`. Exact identity is what rejects both reported false-CLEANs — neither needs order — and making the resolver order-preserving is a wider change than this fix. The residual gap is a path holding all three components in a nonsensical order, strictly smaller than the substring gap it replaces. Six regressions: the two reported fixtures, a bare-makedirs-never-imported variant, and three anti-vacuity controls (genuine seed, `import os as _os`, `from os import makedirs`) so the pins cannot pass by classify() simply ceasing to return CLEAN. Mutation-verified: against 1e7794e's lint script exactly three of these fail and the controls still pass; with the fix the full suite and the bridge-timeout suite are green. Stand: Echo Act IV Pro * chore(lint): order-preserving path resolution + reject a rebound os alias Both remaining P1s, and both are the same shape a third time: the check accepted a weaker property than the one it needs. 1. john-the-dev — the unordered component SET accepted any permutation, so a rooted `access.json/channels/discord` classified clean while the canonical allowlist was never created. I had retained this as a documented limitation; it is a security boundary, so it is fixed rather than noted. `_expr_root_segments()` now walks the `/` spine structurally instead of via `ast.walk` (breadth-first, order-destroying), and `_rooted_segments()` / `_literal_segment_names()` carry ordered lists. `_path_channel()` then requires the contiguous canonical run `channels/<bridge>/access.json`. `.parent` had to be modelled explicitly as "drop the last component" — an order-blind set never needed that, but without it this repo's own `p = …/access.json; p.parent.mkdir(parents=True)` idiom stops resolving to the channel directory. The suite caught it immediately. 2. qingyun-wu — an imported name was trusted at the call site even after being rebound: `import os` … `os = Fake()` … `os.makedirs(p)` created nothing and still classified clean. Any name rebound anywhere (assign, aug/ann-assign, for-target, with-as, def/class) is now dropped from the alias sets. Only a bare Name target counts as a rebinding. Walking into targets marked `os` shadowed from `os.environ[k] = v`, which turned every correctly-isolated test into a violation — setting CLAUDE_CONFIG_DIR is the first thing they all do. Caught by the controls, not by inspection. Five regressions: both reported fixtures plus three anti-vacuity controls (multi-assignment canonical seed, the `.parent` idiom, and `os.environ[...] = ...` not counting as a rebinding), because ordering and shadow-detection are both easy to overshoot into "nothing is ever clean". Mutation-verified: exactly the two new pins fail against daacb14 and the three controls pass on both heads. Full hermetic-lint suite and bridge-timeout suite green here. Stand: Echo Act IV Pro * chore(lint): last write wins — a same-length rebinding is not a no-op qingyun-wu, #2429: `_rooted_segments()` replaced a binding only when the new segment list was strictly LONGER, so rebinding a path variable to a same-length non-canonical path was silently ignored: _cfg = <ccd>/"channels"/"discord" ; _cfg.mkdir(parents=True) _cfg = <ccd>/"channels"/"slack" ; (_cfg/"access.json").write_text("{}") <import src/discord-bridge.py> kept `_cfg -> channels/discord` and classified CLEAN, while at runtime only the SLACK allowlist exists and the Discord canonical file is absent — so the import can still fall back to the operator's real allowlist. Straight-line resolution is now last-write-wins in source order, and a rebinding to something NOT rooted at the configured dir DROPS the binding rather than leaving a stale one vouching for the name. Convergence is detected by comparing the whole map across a pass instead of a per-assignment `grew` flag. That flag cannot survive last-write-wins — a name assigned twice would flip on every pass and loop forever. Snapshot-compare stops as soon as a pass is a no-op; a 64-iteration cap is a backstop. Three regressions: the reported fixture, plus two controls so order-sensitivity cuts both ways rather than becoming a stricter gate — a single-binding discord seed stays CLEAN, and a slack->discord rebind that seeds discord LAST stays CLEAN. Mutation-verified against 61a05a1; full hermetic-lint and bridge-timeout suites green here. Stand: Echo Act IV Pro * fix(lint): honor rebinding in every name tracker, and select targets structurally Round 19. The reviewers found the literal-segment case; probing the AXIS instead of the reported case found two more members of the same family, plus a false-positive source in target selection. ONE DEFECT, THREE TRACKERS — a module-level name kept a tracked property after being rebound to a value that no longer had it. Each yields a false CLEAN, i.e. the canonical channels/discord/access.json is absent at import and the bridge falls back to the operator real allowlist: 1. _literal_segment_names (reported by john-the-dev + qingyun-wu). A rebind that yielded no literal hit `continue`, leaving the stale segments: OTHER = "slack"; CHANNEL = "discord"; CHANNEL = OTHER kept CHANNEL -> discord. Now a bare-Name rebind resolves through the alias and anything else DROPS the binding, failing closed. Only a bare Name is resolved: resolving names nested in arbitrary expressions would ADD segments, and more segments means more paths match a seed — that widens CLEAN, the unsafe way. 2. _ccd_root_names (found here). The root name kept its status forever, so os.environ["CLAUDE_CONFIG_DIR"] = _ccd ; _ccd = "/tmp/elsewhere" (Path(_ccd)/"channels"/"discord"/"access.json").write_text(...) still recorded _ccd as the configured root — the same false CLEAN reached through the ROOT rather than the SEGMENT. 3. _isolation_line (found here; the most severe, since it defeats isolation outright rather than mis-attributing one path). A monotone set said d = mkdtemp(); d = "/Users/<me>/.claude" os.environ["CLAUDE_CONFIG_DIR"] = d was "provably isolated" while it points the env var at the operator real config dir. Fixed by asking the question where it is asked — POINT-IN-TIME. A final-state set was my first attempt and is wrong the OTHER way: it calls _ccd = mkdtemp(); os.environ[...] = _ccd; _ccd = "/tmp/elsewhere" unisolated, though the env var really was set to a temp dir and a later rebinding does not un-set it. That false positive is pinned by a control. _isolated_names keeps its original monotone meaning for its other caller. TARGET SELECTION WAS TEXTUAL. `BRIDGE_IMPORT.search(text)` regexed the raw file, so a file that merely NAMED a bridge in prose had to be hermetic. It fired on a real merged test: tests/discord-read-forwarded.test.py (#2458) mentions discord-bridge.py in its module docstring while importing only src/discord-read.py, which resolves no channel config at import. _code_strings() now selects on strings the module actually evaluates. The raw regex stays as a cheap pre-filter so an unparseable file that never mentions a bridge still exits before the conservative VIOLATION, which is meant for files we had reason to scan. KNOWN_UNISOLATED is net-flat: dropped dm-result-multipart-upload.test.py (stale — prose-only mention) and added discord-access-backup.test.py, a real hole that landed in #2358 after the baseline was measured. Grandfathered rather than fixed because repairing another PR test is a second concern. 10 regressions incl. the reverse-order and unrelated-assignment controls qingyun asked for. Mutation-verified: reverting each of the four behaviours fails 2/1/1/4 checks respectively — no fix is unpinned, which is how the false positive in my first isolation attempt surfaced. Stand: Echo Act IV Pro * refactor(lint): drop _isolated_names, orphaned by the point-in-time isolation fix The diff-coverage gate failed at 94.8% and pointed straight at it: lines 229-241 were the whole function body, uncovered because it has ZERO callers. Making _isolation_line() evaluate isolation point-in-time meant it no longer asks for a precomputed set, and nothing else ever did. Dead code should be deleted, not tested. Covering it would have bought the gate a green tick and left a fixpoint helper in the tree that no longer describes how the lint decides anything — and the next reader would reasonably assume it does. Local gate after removal: 97.9% on the changed lines (was 94.8%, threshold 95%). Whole-tree lint rc=0, invariant suite PASS. _is_isolated_value is untouched and still used by _isolation_line. Stand: Echo Act IV Pro --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
The gap
Path.write_text()creates a file at the process umask — commonly0644. So the familiar pairleaves the file world-readable for the interval between the two calls. For access-control data — allowlists, owner IDs, tier maps — that interval is the entire exposure.
Eight sites do this, across all three bridges:
src/slack-bridge.pyACCESS_FILE×3,ACCESS_BACKUP_FILE, atomictmp)src/discord-bridge.pytmp, pairingtmp_path)src/telegram-bridge.pyACCESS_FILE, TOFU enroll)Two of them state the opposite intent in a comment, which is what makes this worth fixing rather than filing:
The goal was explicit; the implementation defeats it.
The fix
New
util_paths.write_private_text():os.openapplies the mode at creation, so the window cannot exist.fchmodcovers the case where the file already existed (the mode argument is ignored then).O_TRUNC, notO_EXCL— deliberate. These are best-effort paths wrapped in broadexcepts;O_EXCLwould turn a leftover temp file from a crash into a permanent, silent failure to persist access data. Self-healing beats strict here.Deliberately unchanged
The three
os.chmod(channels_env, 0o600)calls narrow a pre-existing file this code did not write. There is no creation window to close, so they stay.Before / after
tests/util-paths-write-private-text.test.py— 3 cases, and the design is the point:An assertion on the final mode cannot detect this bug: the file is
0600by the time you look. The test instead disables the repair —chmod/fchmodneutralised underumask(0o000)— so only a file born0600survives.It ships with its own control (
test_control_write_then_chmod_would_fail_this) that runs the old pattern under identical conditions and asserts it comes out world-readable. If that control ever passes, the discriminator is vacuous and the suite says so.Full regression sweep, every Slack/Discord/Telegram/util-paths Python suite:
Two suites needed updating, both because they asserted the mechanism rather than the outcome:
slack-bridge-access.test.pygrepped the source foros.chmod(ACCESS_FILE, 0o600). Now requireswrite_private_text(ACCESS_FILE, ...)— strictly stronger, since the old pattern passes for the buggy implementation.slack-bridge-access-durable-backup.test.pyinjectedOSErrorby patchingPath.write_text. Same intent, retargeted to the seam the code now uses. The behaviour under test (exception branch returnsFalse) is unchanged.I verified both failed on this branch and passed on clean
origin/mainbefore touching them, so they are genuine consequences of this change rather than pre-existing noise.Scope
One helper, eight call sites, one new test file, two test updates. No refactors bundled.