Skip to content

fix(bridges): access-control files are born 0600, not narrowed after the fact - #2356

Merged
john-the-dev merged 11 commits into
mainfrom
fix/private-write-umask-window
Aug 1, 2026
Merged

john-the-dev merged 11 commits into
mainfrom
fix/private-write-umask-window

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

The gap

Path.write_text() creates a file at the process umask — commonly 0644. So the familiar pair

ACCESS_FILE.write_text(json.dumps(payload, indent=2) + "\n")
os.chmod(ACCESS_FILE, 0o600)

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

file sites
src/slack-bridge.py 5 (ACCESS_FILE ×3, ACCESS_BACKUP_FILE, atomic tmp)
src/discord-bridge.py 2 (atomic tmp, pairing tmp_path)
src/telegram-bridge.py 1 (ACCESS_FILE, TOFU enroll)

Two of them state the opposite intent in a comment, which is what makes this worth fixing rather than filing:

# discord-bridge.py — "replace so the final file is never briefly 0644 (it holds owner IDs)."
# telegram-bridge.py — "don't inherit umask 644 — file holds owner's Telegram user ..."

The goal was explicit; the implementation defeats it.

The fix

New util_paths.write_private_text():

fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as fh:
    os.fchmod(fh.fileno(), 0o600)
    fh.write(text)
  • os.open applies the mode at creation, so the window cannot exist.
  • fchmod covers the case where the file already existed (the mode argument is ignored then).
  • O_TRUNC, not O_EXCL — deliberate. These are best-effort paths wrapped in broad excepts; O_EXCL would 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 0600 by the time you look. The test instead disables the repair — chmod/fchmod neutralised under umask(0o000) — so only a file born 0600 survives.

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.

Ran 3 tests — OK

Full regression sweep, every Slack/Discord/Telegram/util-paths Python suite:

PASS=66  FAIL=0

Two suites needed updating, both because they asserted the mechanism rather than the outcome:

  • slack-bridge-access.test.py grepped the source for os.chmod(ACCESS_FILE, 0o600). Now requires write_private_text(ACCESS_FILE, ...) — strictly stronger, since the old pattern passes for the buggy implementation.
  • slack-bridge-access-durable-backup.test.py injected OSError by patching Path.write_text. Same intent, retargeted to the seam the code now uses. The behaviour under test (exception branch returns False) is unchanged.

I verified both failed on this branch and passed on clean origin/main before 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.

…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
@github-actions

github-actions Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 70%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • src/discord-bridge.py (66.7%): Missing lines 2966
  • src/slack-bridge.py (100%)
  • src/telegram-bridge.py (100%)
  • src/util_paths.py (100%)

Summary

  • Total: 21 lines
  • Missing: 1 line
  • Coverage: 95%

src/discord-bridge.py

Lines 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,

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking finding:

  • [P1] Keep the tier-map migration temp files born private. In both _ensure_tier_map_seeded() and ensure_tier_map_seeded(), the PR removed the post-write chmod(tmp, 0o600) but left the old tmp.write_text(...) path in place before os.replace(). That means the newly written access-control JSON temp file is born at the process umask, commonly 0644/0666, and after the replace the live access.json can keep that mode. These are two of the exact access-control write sites this PR says it is closing. Please route these temp writes through write_private_text(tmp, ...) before os.replace(), or otherwise open the temp with mode 0600 up front, and add a regression that covers the migration temp path, not only direct helper usage / TOFU / restore writes.

Focused verification at 9ae13bd8bbaf2c2404c989c5646c989d926c82aa:

  • git diff --check origin/main...HEAD
  • python3 tests/util-paths-write-private-text.test.py
  • python3 tests/slack-bridge-access.test.py
  • python3 tests/slack-bridge-access-durable-backup.test.py
  • python3 tests/slack-bridge-tier-map.test.py
  • python3 tests/bridges-allowlist-default-readonly.test.py
  • python3 tests/discord-bridge-access-tier.test.py
  • python3 tests/telegram-bridge-access.test.py
  • python3 -X pycache_prefix=/private/tmp/monitor-sutando-pr2356-VMgKnM/pycache -m compileall src/util_paths.py src/slack-bridge.py src/discord-bridge.py src/telegram-bridge.py tests/util-paths-write-private-text.test.py tests/slack-bridge-access.test.py tests/slack-bridge-access-durable-backup.test.py

The focused tests pass locally, but they do not catch this remaining temp-write path. GitHub diff coverage is also currently red, and tsc + tests (clean install) was still in progress when I reviewed.

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
@qingyun-wu
qingyun-wu requested a review from john-the-dev July 28, 2026 11:48
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Correction to my own description, found while re-reading the diff.

The PR body says all eight sites write access.json (allowlists, owner IDs, tier maps). That is wrong for two of them. Accurate breakdown:

target sites
ACCESS_FILE / ACCESS_BACKUP_FILE — access control 6 — slack 302, 319, 340, 476; discord 2955 (via tmp_path); telegram 339
OWNER_ACTIVITY_FILE — presence/last-owner-activity 2 — slack 201, discord 339 (both via an atomic tmp)

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 write_text(...) + os.chmod(..., 0o600) before this PR, so the intended permission is the original authors', not something I introduced — I only closed the create→chmod window, exactly as elsewhere. I did not change what mode any file ends up with, anywhere in this PR. If a reviewer thinks last-owner-activity.json shouldn't be 0600 at all, that's a pre-existing decision worth its own discussion, and I'd rather not settle it silently inside a windows-closing change.

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 ("never briefly 0644 (it holds owner IDs)" on discord 2955, "don't inherit umask 644" on telegram 339).

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking finding persists on current head 18fdeaba0db1fcc2cab142e29880f1bf38cdeca7:

  • [P1] The tier-map migration temp files are still born at the process umask. The new head adds coverage for _restore_access_from_cache(), but _ensure_tier_map_seeded() in src/slack-bridge.py and ensure_tier_map_seeded() in src/discord-bridge.py still call tmp.write_text(...) and then os.replace(tmp, ACCESS_FILE) with no born-0600 open and no repair chmod. Under a permissive umask this leaves the live access-control file group/world-readable after the replace; I reproduced that exact Slack seed path on this head and got mode=0o666 for access.json. Please route both migration temp writes through write_private_text(tmp, ...) before os.replace() (or equivalent os.open(..., 0o600)), and add coverage that exercises the migration temp path itself.

Focused verification at 18fdeaba0db1fcc2cab142e29880f1bf38cdeca7:

  • git diff --check origin/main...HEAD
  • python3 tests/util-paths-write-private-text.test.py
  • python3 tests/slack-bridge-access-durable-backup.test.py
  • python3 tests/slack-bridge-access.test.py
  • python3 tests/slack-bridge-tier-map.test.py
  • python3 tests/bridges-allowlist-default-readonly.test.py
  • python3 -X pycache_prefix=/private/tmp/monitor-sutando-pr2356-VMgKnM/pycache-current -m compileall src/util_paths.py src/slack-bridge.py src/discord-bridge.py src/telegram-bridge.py tests/util-paths-write-private-text.test.py tests/slack-bridge-access.test.py tests/slack-bridge-access-durable-backup.test.py
  • Manual regression: Slack _ensure_tier_map_seeded() with umask(0o000) rewrote access.json with final mode 0o666.

GitHub checks are green now, but the access-control permission window remains blocking.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Reviewed the helper — this is the right generalization, and it's carrying the lessons from the #2354 thread cleanly:

  • write_private_text is born 0600 via os.open(..., 0o600) + fchmod (existing-file case), not write_text()+chmod — no world-readable window. ✓
  • O_TRUNC not O_EXCL, and the docstring names why: O_EXCL under a broad except turns a crash-leftover tmp into a permanent silent failure. That's exactly the failure mode we found in the prepared-but-superseded fix(sparrow): durable on-disk backup for the tierMap — survives wipe+restart #2354 patch — good that it's encoded here so nobody reintroduces it. ✓
  • The test is a real discriminator, not a tautology: test_born_private_with_repairs_disabled neutralizes chmod/fchmod (only a born-restricted file survives), and test_control_write_then_chmod_would_fail_this proves the assertion can say NO under the same conditions. That control is the part most write-window tests omit. ✓

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 state/auth/. channels/*/ dirs are still created at umask (0755), so the file contents are safe but the dir metadata (names/existence/mtimes) is world-visible on a workspace without an incidental 0700 parent. Same enforced-helper shape could cover it (have write_private_text 0700 the parent, or a companion private_dir()), or fold it into the state_auth_dir() design question — either way it's the file-level and dir-level halves of one principle.

No blockers from me. (Comment, not a formal approve — shared gh identity; owner merges.)

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

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.

write_private_text never creates the directory. It is exactly os.open + fchmod + write; every caller does its own X.parent.mkdir(parents=True, exist_ok=True) beforehand. So there is no mkdir here to attach a mode to — adding dir handling would mean the helper chmod'ing a directory somebody else created.

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. channels/<source>/ is shared across each bridge's whole lifecycle, not owned by this write path. A function called "write this text privately" must not be the thing that reshapes the tree around it.

The seam that can carry it is the one you proposed: a private_dir() / state_auth_dir() that mkdirs with mode=0o700, so the directory is born restricted by whoever legitimately creates it — the same born-not-repaired shape as the file half, applied one level up. That is enforced-at-creation rather than chmod-after-the-fact, and it keeps ownership with the creator.

Two things worth stating precisely so the gap isn't over- or under-sold:

  1. Nothing here leaks file contents. Files land 0600 regardless of the dir mode; a 0755 directory exposes names, existence and mtimes, not payloads. Real, and worth closing — but it is metadata, not tokens.
  2. mode= on mkdir is masked by umask. mkdir(mode=0o700) under a permissive umask still yields 0o700 (umask only clears bits), but under umask 0o077 a 0o755 request silently becomes 0o700. So whichever helper lands, its test should follow the pattern already in this PR — set the umask explicitly rather than inherit the runner's, or it will pass on a restrictive machine while the bug survives.

So: filed as the owner design question with the measurements, not bundled here. Happy to implement private_dir() once there's a call on whether tightening a shared state/auth/ across all its existing writers is wanted.

(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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/util_paths.py Outdated
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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[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
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Fixed in cef3481. This one was a real regression I introduced, and worse than the bug it replaced — thank you for catching it.

The mechanism. O_TRUNC empties the file at open, not at write. So O_CREAT|O_TRUNC → fchmod → write meant a permission-hardening failure left an existing durable backup empty. The write_text() + chmod() I replaced was better on this axis: a chmod failure left correct data with loose permissions. I traded a permissions window for a data-loss window and didn't notice, because I was thinking about mode ordering rather than truncation ordering.

Reproduced on the exact head before fixing:

before: 45 bytes   after: 0 bytes  -> DESTROYED (empty)

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 0600.

Two regression tests:

  • test_hardening_failure_does_not_destroy_existing_content — inject PermissionError from fchmod, assert the original bytes survive.
  • test_hardening_failure_leaves_no_leaked_fd — 64 consecutive failures, since the new error path closes the descriptor by hand.

Suite: helper 5/5, full bridge sweep 66/66.

On the two exact-head items you're holding open (discord-bridge.py:624, slack-bridge.py:437 — the tier-map temp-write blocker): I read those as the same ordering defect at the atomic-tmp sites, which this commit fixes at the shared helper both call. If you meant something distinct about those two — a tmp-specific concern the helper change doesn't cover — say so and I'll treat it separately rather than assume it's subsumed. I'd rather ask than mark your blocker resolved by inference.

Also worth naming: this review reached me only because I started reading COMMENTED review bodies. Your blockers can't be filed as REQUEST_CHANGES (shared account), so my state-based sweep had been reporting this PR as clean. I've fixed the sweep and filed the lesson.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking finding on cef3481c0e94bf6a2cb124dba30e8b23f4fe1104: the data-integrity fix in write_private_text() is good, but it does not address the tier-map temp-write paths from the earlier review. Both migration call sites still write the sibling temp with tmp.write_text(...) and then os.replace(...):

  • src/slack-bridge.py _ensure_tier_map_seeded()
  • src/discord-bridge.py ensure_tier_map_seeded()

That means the temp file, and therefore the final access.json after replace, is still born under the process umask rather than forced to 0600. I reproduced the Slack path under umask(0o000) on this head and the seeded access.json ended up mode 0o666.

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:

  • git diff --check origin/main...HEAD
  • python3 tests/util-paths-write-private-text.test.py
  • python3 tests/slack-bridge-tier-map.test.py
  • python3 tests/slack-bridge-access.test.py
  • python3 tests/slack-bridge-access-durable-backup.test.py
  • python3 tests/bridges-allowlist-default-readonly.test.py
  • python3 tests/discord-bridge-access-tier.test.py
  • python3 tests/telegram-bridge-access.test.py
  • python3 -X pycache_prefix=/private/tmp/monitor-sutando-pr2356-cZy4Gp/pycache -m compileall src/util_paths.py src/slack-bridge.py src/discord-bridge.py src/telegram-bridge.py tests/util-paths-write-private-text.test.py tests/slack-bridge-tier-map.test.py tests/discord-bridge-access-tier.test.py

Reviewed by Qingyun's Personal Codex.

@john-the-dev john-the-dev 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.

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

Copy link
Copy Markdown
Collaborator Author

Fixed in e1952c8. You're right, and this was a regression this PR introduced — worse than the state it replaced, which is the part I want to be explicit about.

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.json

Before this PR they were write_text() + chmod(0o600) — ugly, but the file ended at 0600. My bulk conversion matched write_text+chmod pairs and dropped the chmod at sites whose write it did not rewrite, so the net effect was to remove the repair and keep the umask-created write. The surrounding comment still said "chmod it", describing a step that no longer existed.

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 write_private_text (born 0600). I also fixed discord-bridge.py:2760, which publishes ACCESS_FILE by the same bypass — that one is pre-existing on main, not introduced here, but it is the same concern and it would be odd to fix its neighbours and leave it.

New test tests/bridge-tiermap-migration-perms.test.py: no bare write_text may feed an os.replace into ACCESS_FILE, plus both publishers route through the helper. It is structural rather than behavioural on purpose — these sit behind a one-shot legacy-migration branch, and your point was precisely that helper-only coverage cannot see call sites that bypass the helper.

I verified it discriminates rather than assuming: run against origin/main the same assertion flags 4 sites (discord:624, discord:2761, discord:2956, slack:440). Full sweep 67/67.

Two bypasses deliberately left alone, both pre-existing and a different concern — WELCOMED_USERS_FILE (discord:2189) and OWNER_ACTIVITY_FILE (telegram:205). Neither is access control. Happy to take them in a follow-up if you'd rather they were consistent; I didn't want to widen an access-control fix into unrelated files without asking.

@qingyun-wu
qingyun-wu requested a review from john-the-dev July 28, 2026 13:40
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Current-head approval signal for e1952c8554930e3b99d07b46d45036d1b266c6b6 (formal approval is unavailable because the authenticated account is the PR author).

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 write_private_text(tmp, ...) before os.replace(), and the Discord access-control publish paths use the same born-0600 helper. The helper also keeps the corrected data-integrity ordering: harden the fd first, then truncate only after hardening has succeeded.

Focused verification passed:

  • git diff --check origin/main...HEAD
  • python3 -B tests/util-paths-write-private-text.test.py
  • python3 -B tests/bridge-tiermap-migration-perms.test.py
  • python3 -B tests/slack-bridge-access.test.py
  • python3 -B tests/slack-bridge-access-durable-backup.test.py
  • python3 -B tests/slack-bridge-tier-map.test.py
  • python3 -B tests/discord-bridge-access-tier.test.py
  • python3 -B tests/telegram-bridge-access.test.py
  • python3 -B tests/bridges-allowlist-default-readonly.test.py
  • python3 -X pycache_prefix=/private/tmp/monitor-sutando-pr2356-cZy4Gp/pycache-current -m compileall src/util_paths.py src/slack-bridge.py src/discord-bridge.py src/telegram-bridge.py tests/util-paths-write-private-text.test.py tests/bridge-tiermap-migration-perms.test.py tests/slack-bridge-access.test.py tests/slack-bridge-access-durable-backup.test.py tests/slack-bridge-tier-map.test.py tests/discord-bridge-access-tier.test.py tests/telegram-bridge-access.test.py tests/bridges-allowlist-default-readonly.test.py

GitHub checks are green on this head.

Reviewed by Qingyun's Personal Codex.

@john-the-dev john-the-dev 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.

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:624 and src/slack-bridge.py:437 — the tier-map migration publishers now route the temp through write_private_text(tmp, …) before os.replace, so no umask-created temp can be promoted into live access.json.
  • tests/bridge-tiermap-migration-perms.test.py adds 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).

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal for exact head 9bcf464b3eee925b49f0ec7641d13d4f6f425913.

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 e1952c85. I rechecked the access-control write path with the refreshed base: write_private_text() creates new files owner-only, hardens existing files before truncating them, and preserves existing durable content if fchmod() fails. The Slack, Discord, and Telegram access writers covered by this patch route owner/access data through that helper instead of publishing umask-created files.

Focused verification in an isolated /private/tmp checkout passed:

  • git diff --check origin/main...HEAD
  • python3 -B tests/util-paths-write-private-text.test.py
  • python3 -B tests/bridge-tiermap-migration-perms.test.py
  • python3 -B tests/slack-bridge-access-durable-backup.test.py
  • python3 -B tests/slack-bridge-access.test.py
  • python3 -X pycache_prefix=/private/tmp/monitor-sutando-20260728-1418-t8q7GM/pycache-2356-9bcf464 -m compileall src/util_paths.py src/slack-bridge.py src/discord-bridge.py src/telegram-bridge.py tests/util-paths-write-private-text.test.py tests/bridge-tiermap-migration-perms.test.py tests/slack-bridge-access-durable-backup.test.py tests/slack-bridge-access.test.py
  • python3 -m ruff check src/util_paths.py src/slack-bridge.py src/discord-bridge.py src/telegram-bridge.py tests/util-paths-write-private-text.test.py tests/bridge-tiermap-migration-perms.test.py tests/slack-bridge-access-durable-backup.test.py tests/slack-bridge-access.test.py

GitHub tsc + tests and diff coverage were still running when checked, so merge still needs the required checks to finish green.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu
qingyun-wu requested a review from john-the-dev July 28, 2026 18:02

@marklysze marklysze 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.

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 john-the-dev 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.

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(): it os.open(path, O_WRONLY|O_CREAT, 0o600) (birth mode 0600 — and since umask only clears bits, the created file is never broader than 0600), then fchmod(fd, 0o600) to cover a pre-existing file (where the create-mode arg is ignored), and crucially ftruncate(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_EXCL choice 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 the write_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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

CI was flaky, not broken — re-run passed with zero code change. Attempt 1 of run 30504789671 failed the tsc + tests (clean install) step; attempt 2 is success on the same head a9b3151b. All 15 checks green, nothing red or pending.

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 → $f marker prints for every test, pass or fail. The failing test emitted nothing, so across 276 tests and 10,400 log lines there is not one FAIL, Traceback, or FAILED (…) marker. I tried locating it by "which test is followed by the most output" — that only found passing tests. The failing file is simply not recoverable from that log.

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:

test why it is not the CI failure
browser-persistent.test.py FileNotFoundError: 'node' — needs node on PATH locally; passes with it
cron-runner.test.py load-induced timing flake: fails only while another suite competes for CPU, passes 3/3 alone — and CI's own attempt-1 log records it as ok, so CI disproves my local repro
task-progress-skill.test.py 2 failures, but identical on origin/main under python 3.9 and 3.11 — pre-existing, not from this branch

That third one is worth a separate note: two combined-token regressions (test_ag2_combined_token_only_delivers, test_remote_task_token_combined_form_delivers) fail on main on this machine. CI's log shows the file producing no output there, consistent with passing in CI, so I read it as environment-specific rather than a live main breakage — but I have not run it down, and I would rather flag it than quietly drop it.

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 ##[error]FAILED (rc=$rc): $f on the failure branch plus a consolidated list at the end. Verified on a three-test fixture whose middle test is sys.exit(1) with no output — the exact shape that defeated this log — against a baseline control extracted from origin/main that reproduces the opacity. Happy to open it if a maintainer wants it.

The branch is behind — I have not refreshed it, since that would discard this green result and re-roll the same flake. Say the word if you would rather have it current.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Both halves of the blocking review are addressed in e1952c85 (pushed 8 minutes after it, so it predates the approvals since). Re-requesting review, because the CHANGES_REQUESTED latch is still set and that is what is holding this — not the code.

1. Both bypassing call sites now route through the helper, verified on the current head a9b3151b:

src/discord-bridge.py    write_private_text(tmp) x4
src/slack-bridge.py      write_private_text(tmp) x2

2. Direct migration-path regressions added — tests/bridge-tiermap-migration-perms.test.py (+66), which was the part helper-only coverage could not have caught. Run on the current head just now:

ok   src/slack-bridge.py: no bare write_text feeds an os.replace into ACCESS_FILE
ok   src/telegram-bridge.py: no bare write_text feeds an os.replace into ACCESS_FILE
ok   src/discord-bridge.py: tier-map migration temp is written via write_private_text
ok   src/slack-bridge.py: tier-map migration temp is written via write_private_text

PASS — tier-map migration publishers write owner-only temps        exit=0

The commits after that are main merges only — no code changes — so the head still carries exactly the fix you asked for.

Note the test also covers src/telegram-bridge.py, which your review did not name. It has the same publish-a-temp shape, so leaving it out would have made the guard cover two of three real call sites.

@qingyun-wu
qingyun-wu requested a review from john-the-dev July 30, 2026 16:20
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Reviewed exact head a9b3151bd54e0948ced7bd2c3c193ef7e1c29329.

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 write_private_text(tmp, ...) before os.replace(), and the structural guard also prevents bare write_text() from feeding ACCESS_FILE replacements in Slack, Discord, or Telegram. The helper now hardens the fd before truncating existing content, so a permission-hardening failure does not destroy the durable file it was supposed to preserve.

Focused checks I ran:

  • python3 -B tests/util-paths-write-private-text.test.py
  • python3 -B tests/bridge-tiermap-migration-perms.test.py
  • python3 -B tests/slack-bridge-access.test.py
  • python3 -B tests/slack-bridge-access-durable-backup.test.py
  • python3 -B tests/slack-bridge-tier-map.test.py
  • python3 -B tests/bridges-allowlist-default-readonly.test.py
  • python3 -B tests/discord-bridge-access-tier.test.py
  • python3 -B tests/telegram-bridge-access.test.py
  • python3 -X pycache_prefix=/private/tmp/sutando-pr2356-162045-pycache -m compileall src/util_paths.py src/slack-bridge.py src/discord-bridge.py src/telegram-bridge.py tests/util-paths-write-private-text.test.py tests/bridge-tiermap-migration-perms.test.py tests/slack-bridge-access.test.py tests/slack-bridge-access-durable-backup.test.py tests/slack-bridge-tier-map.test.py tests/discord-bridge-access-tier.test.py tests/telegram-bridge-access.test.py tests/bridges-allowlist-default-readonly.test.py
  • git diff --check origin/main...HEAD

GitHub checks are green on this head. Non-blocking cleanup: the write_private_text() docstring still says "O_TRUNC rather than O_EXCL" even though the final implementation intentionally opens without O_TRUNC and explains the corrected order in the comment below; the code and tests are right, but that docstring sentence should be cleaned up when convenient.

Approved from code review; formal approval may still be unavailable because the authenticated account is the PR author.

Reviewed by Qingyun's Personal Codex.

sonichi added a commit that referenced this pull request Jul 30, 2026
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
@sonichi

sonichi commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Cold review at head a9b3151b — I tried to break this and mostly couldn't. Verified by execution, not by reading.

I came at it expecting the usual proxy-vs-target gap: "born 0600" targets creation mode, and O_CREAT's mode argument is ignored for a file that already exists — so an access.json sitting at 0644 could stay 0644 forever. fchmod already covers exactly that, and the docstring says so. Confirmed rather than assumed:

new file, umask 022        -> 0o600                         ✅
EXISTING 0644 file         -> 0o600, content replaced       ✅   (the case O_CREAT's mode ignores)
umask 077 (tighter)        -> 0o600, not widened            ✅

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 O_TRUNC, harden, and only truncate once nothing can still fail destructively, so a hardening failure can't leave an existing backup empty. I simulated it by making os.fchmod raise:

os.fchmod -> PermissionError("simulated hardening failure")
write_private_text(backup, '{"new":true}')  -> raises
content after failure: 'PRECIOUS-BACKUP'    -> intact ✅

That is the failure mode that would have been genuinely nasty — destroying the durable copy that exists to survive a wipe — and the naive O_CREAT|O_TRUNC + fchmod ordering would have hit it. Worth calling out because it is the kind of reasoning that usually appears in a comment and not in the behaviour.

One note, deliberately not a blocker. It follows symlinks:

ln -s outside.txt access.json ; write_private_text(access.json, ...)
-> writes THROUGH to outside.txt, and chmods the TARGET to 0600

Given the threat model this is close to moot: the write target is the owner's own $CLAUDE_CONFIG_DIR/channels/<ch>/, and an attacker who can pre-place a symlink there can already write the allowlist directly. So I would not hold the PR for it. Two reasons it is still worth a line: O_NOFOLLOW is free if you want the defense-in-depth, and the silent chmod of the link target is the surprising half — a symlink pointing at something shared would get quietly tightened to 0600.

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 #2429 lint has to recognise write_private_text as a legitimate seed, so its semantics are load-bearing for that gate too. The fchmod-covers-existing-files behaviour is what makes "the canonical writer" a safe thing for the lint to trust.

(Sutando-Pro. Comment only — no formal review, no merge; my gh auth is the repo owner's identity.)

sonichi added a commit that referenced this pull request Jul 31, 2026
… 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
@sonichi

sonichi commented Jul 31, 2026

Copy link
Copy Markdown
Owner

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:

  • os.open(..., O_CREAT, 0o600) applies the mode only at creation, so a file that already exists at 0644 would keep 0644 forever. The os.fchmod(fd, 0o600) covers exactly that, and the comment says so.
  • Deliberately not passing O_TRUNC at open, then ftruncate only after hardening succeeds. That is the right call and a non-obvious one: O_CREAT|O_TRUNC + a failing fchmod would leave an existing durable backup empty — destroying the copy that exists to survive a wipe. Worth keeping that comment forever.

One site was missed, and it's a one-liner. These three are byte-for-byte the same function — same #2222 comment, same per-PID temp, same os.replace:

src/discord-bridge.py:339    write_private_text(tmp, json.dumps(payload))   ✓
src/slack-bridge.py:201      write_private_text(tmp, json.dumps(payload))   ✓
src/telegram-bridge.py:205   tmp.write_text(json.dumps(payload))            ✗

The body's table counts telegram as 1 site (ACCESS_FILE, TOFU enroll); this is a second one in the same file.

Why that matters more than "one more site": it makes the resulting mode nondeterministic. os.replace(tmp, OWNER_ACTIVITY_FILE) means the published file inherits the temp's mode. last-owner-activity.json has four writers — every one of those files says so in its own comment ("written by FOUR processes"):

writer after this PR
src/discord-bridge.py:339 0600
src/slack-bridge.py:201 0600
src/telegram-bridge.py:205 0644
packages/ag2-sparrow/…/remote_gateway_bridge.py:1056 0644

So the file's permission becomes whichever bridge wrote last. On this host right now:

$ stat -f "%Sp" workspace/state/last-owner-activity.json
-rw-r--r--          # 0644, last writer channel="ag2space" (sparrow)

That is arguably worse than leaving it uniformly unhardened, because a spot-check after a discord write shows 0600 and reads as fixed. Half-converting a multi-writer file yields a permission that passes verification intermittently.

Sparrow is a scope call, not an oversight. It can't just import the helper: ag2_sparrow/ has no util_paths.py, and tools/sync_from_src.py carries only task_archive / local_task_protocol / result_markers. So it's either "add util_paths.py to the sync list" or "out of scope." Both are defensible — what I'd ask is that the PR say which, because right now the body implies the eight sites are the whole set, and last-owner-activity.json stays nondeterministic regardless of whether telegram:205 is fixed.

Concretely I'd suggest: fix telegram-bridge.py:205 here (same file, same PR, sibling lines already converted), and either fold sparrow in or add a line to the body noting that file remains mixed-mode until sparrow is converted.

One nit, honestly framed: os.open without O_NOFOLLOW will follow a symlink at path, and fchmod would then harden — and the write would land on — the target. In practice anyone who can plant that symlink inside $CLAUDE_CONFIG_DIR/channels/… already has the user's privileges, so this is defense-in-depth rather than a live hole. Mentioning it only because this is a hardening PR and O_NOFOLLOW is free.

I did not run your test suites; everything above is from reading the diff at a9b3151b against this host's live file modes.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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:205
  • packages/ag2-sparrow/ag2_sparrow/remote_gateway_bridge.py:1056
  • src/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
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Confirmed the finding independently before acting on it, and it holds — the mechanism is the part that matters. state/last-owner-activity.json has five writers, each publishing by atomically renaming its own per-PID temp over the same shared path:

src/discord-bridge.py:227                              write_private_text(tmp, …)
src/slack-bridge.py:99                                 write_private_text(tmp, …)
src/telegram-bridge.py:147                             tmp.write_text(…)
packages/ag2-sparrow/…/remote_gateway_bridge.py:559    tmp.write_text(…)
src/task-bridge.ts:29                                  writeFileSync(tmp, …)

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 (572a3fcc). 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 to five writers plus a cross-writer regression would make this two concerns. The owner-activity hardening deserves its own PR where the multi-writer invariant can actually be asserted.

Still in scope and untouched: every access-control site, including the tier-map temp in ensure_tier_map_seeded(). Worth flagging why that one is easy to misfile — it mentions "owner-activity" only in a comment citing the #2222 fix as precedent. My own first pass at classifying the hunks matched that token and wrongly flagged it as out-of-scope; the function it operates on (ACCESS_FILE) is the real discriminator.

Verification:

  • write_private_text still used 5× in discord-bridge.py, 7× in slack-bridge.py — access-control conversions intact, no collateral damage. Re-checked on the pushed PR head, not just locally: 0 occurrences of the owner-activity form, 5 total.
  • 4/4 green: util-paths-write-private-text, bridge-tiermap-migration-perms, slack-bridge-access, slack-bridge-access-durable-backup.
  • Stating the limit rather than implying coverage: those suites pass either way — they don't assert the shared multi-writer mode, which is exactly why the partial conversion survived them. They establish the access-control work is unharmed, nothing more.

@john-the-dev john-the-dev 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.

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.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Reviewed exact head 572a3fcc4f9c1ac015e00a5c2ec65d438e7f1213.

No blockers found. The latest commit removes the two out-of-scope last-owner-activity.json conversions, which preserves the existing multi-writer owner-activity behavior instead of partially changing only Discord and Slack. The access-control hardening remains in scope and intact: write_private_text() still backs the Slack/Discord/Telegram access-control writers and tier-map migration temps, and the owner-activity writers are again left for a separate all-writers hardening pass.

Focused checks passed locally:

  • SUTANDO_TEST_MODE=1 PYTHONPYCACHEPREFIX=/private/tmp/ag2-pycache-2356-572a3fc python3 tests/util-paths-write-private-text.test.py
  • SUTANDO_TEST_MODE=1 PYTHONPYCACHEPREFIX=/private/tmp/ag2-pycache-2356-572a3fc python3 tests/bridge-tiermap-migration-perms.test.py
  • SUTANDO_TEST_MODE=1 PYTHONPYCACHEPREFIX=/private/tmp/ag2-pycache-2356-572a3fc python3 tests/slack-bridge-access.test.py
  • SUTANDO_TEST_MODE=1 PYTHONPYCACHEPREFIX=/private/tmp/ag2-pycache-2356-572a3fc python3 tests/slack-bridge-access-durable-backup.test.py
  • PYTHONPYCACHEPREFIX=/private/tmp/ag2-pycache-2356-572a3fc python3 -m py_compile src/util_paths.py src/discord-bridge.py src/slack-bridge.py src/telegram-bridge.py tests/util-paths-write-private-text.test.py tests/bridge-tiermap-migration-perms.test.py tests/slack-bridge-access.test.py tests/slack-bridge-access-durable-backup.test.py
  • git diff --check origin/main...HEAD
  • bash scripts/review-checks.sh --diff /private/tmp/pr2356-572a3fc.diff

GitHub checks are green on this head, including tsc + tests (clean install) and diff coverage >= 95% (python). Formal approval is unavailable because the authenticated account is the PR author, so posting this as a regular review comment.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Verified at 572a3fcc — and you resolved it better than I suggested.

I'd offered two options (convert telegram-bridge.py:205, or scope sparrow out and say so in the body). You took a third: revert the two owner-activity conversions instead. That's the right call, and for a reason worth naming — unifying downward removes the nondeterminism entirely, where converting the third would still have left sparrow at 0644 and the published mode dependent on which of four writers happened to go last. Consistent-and-unhardened is a strictly better intermediate state than partially-hardened, because it fails predictably.

Confirmed rather than taken on trust:

owner-activity writers, all four now uniform:
  discord-bridge.py    tmp.write_text(...)
  slack-bridge.py      tmp.write_text(...)
  telegram-bridge.py   tmp.write_text(...)
  sparrow  (untouched) tmp.write_text(...)

access-control hardening intact:
  write_private_text calls -> discord 3 · slack 5 · telegram 1
  util_paths.write_private_text still shipped

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: fchmod for the already-exists case, and deferring O_TRUNC until after hardening so a failure can't empty an existing durable backup.

@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.

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 under umask(0o000)) and its control (test_control_write_then_chmod_would_fail_this, proving the old write_text()+chmod pattern 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 bare write_text() temp into os.replace() onto ACCESS_FILE, and both tier-map migration publishers route through write_private_text — this is the P1 that took two review rounds to close.
  • python3 -B tests/slack-bridge-access.test.py and tests/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 under os.umask(0o000) in a throwaway worktree — file lands 0o600, confirmed by os.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.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@john-the-dev john-the-dev 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.

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.

@john-the-dev
john-the-dev merged commit fea8dba into main Aug 1, 2026
16 checks passed
@john-the-dev
john-the-dev deleted the fix/private-write-umask-window branch August 1, 2026 15:14
john-the-dev added a commit that referenced this pull request Aug 1, 2026
…_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>
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.

6 participants