Skip to content

fix(tests): isolate notify-slack case i from the host's CLAUDE_CONFIG_DIR - #2204

Merged
john-the-dev merged 3 commits into
mainfrom
fix/notify-slack-test-hermeticity
Jul 20, 2026
Merged

john-the-dev merged 3 commits into
mainfrom
fix/notify-slack-test-hermeticity

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

closes #2203

Problem

tests/health-check-notify-slack.test.py case i redirects $HOME to a temp dir to test token-path resolution — but claude_home_path() prefers $CLAUDE_CONFIG_DIR, which is set on every claude-sutando install. The HOME redirect is silently bypassed and the host's real channels/slack/.env resolves into the assertions.

Effect on any slack-enrolled host: 3 deterministic failures — and the failure messages print the live bot token verbatim into test output/CI logs/agent transcripts (see #2203; this bit us 2026-07-20 during integration-branch suite verification). Vanilla env passes, which is why CI never saw it.

Fix (test-only, but issue-linked per queue policy)

  • save/pop CLAUDE_CONFIG_DIR + CLAUDE_HOME alongside HOME in case i, restored in finally — resolution falls to the redirected ~/.claude exactly as the fixture intends.
  • mask non-fixture values in the three failure messages so a future isolation regression fails loudly without printing a live secret.

Supersedes #1903 (same diagnosis; auto-closed for missing issue linkage, branch since stale).

Verification

  • CLAUDE_CONFIG_DIR → real enrolled config dir: was 3 failures → now 10/10 pass.
  • Unset (CI-like): 10/10 pass, unchanged.
  • Env restoration asserted in-process after the case runs.

🤖 Generated with Claude Code

…_DIR

case_i redirects HOME to a temp dir, but claude_home_path() prefers
$CLAUDE_CONFIG_DIR (set on every claude-sutando install) over ~/.claude —
so the redirect was bypassed and the host's REAL channels/slack/.env leaked
into the assertions: 3 failures on any slack-enrolled host, with the live
bot token printed verbatim in the failure messages.

Fix: save/pop CLAUDE_CONFIG_DIR and CLAUDE_HOME alongside HOME (restored in
finally), and mask non-fixture values in failure output so a future
isolation regression can't print a live token into CI logs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

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

Diff Coverage

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

No lines with coverage information in this diff.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Approval signal for head 0dbc7ab579283420874f81085ff7ff72a81ea47f: no blocking findings.

The change is narrowly scoped to the notify-slack test isolation path. Case i now clears both CLAUDE_CONFIG_DIR and CLAUDE_HOME for the fallback probe, restores the caller environment afterward, and masks non-fixture host values in assertion output. That closes the host-environment leakage class without widening credential access.

Focused checks in the isolated /private/tmp checkout:

  • git diff --check origin/main...HEAD passed.
  • python3 tests/health-check-notify-slack.test.py -v passed all 10 cases while the process had a fake host CLAUDE_CONFIG_DIR containing fake Slack tokens.
  • The failure output did not leak the fake host token values.

GitHub CI, diff coverage, CLA, and policy lint jobs are green on this head.

Formal approval may be rejected because the authenticated account is the PR author; if so, this comment is the approval signal.

Reviewed by Qingyun's 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.

Approve — clean, correct, security-conscious test-only fix (closes #2203).

Verified:

  • Root cause is real: claude_home_path() prefers $CLAUDE_CONFIG_DIR, which is set on every claude-sutando install, so case i's HOME redirect was silently bypassed and the host's real channels/slack/.env resolved into the assertions — 3 deterministic failures on any slack-enrolled host, with the live bot token printed verbatim into test output. Vanilla-env CI never saw it. Good catch.
  • Fix is right: CLAUDE_CONFIG_DIR + CLAUDE_HOME are popped alongside HOME and restored in finally with the same if saved is not None guard as the existing HOME/SLACK_BOT_TOKEN handling — resolution now falls to the redirected ~/.claude exactly as the fixture intends.
  • Defense-in-depth: the masked() allowlist (xoxb-from-channel, xoxb-from-repo, "") prints the three expected fixtures normally and reduces anything else to <non-fixture N-char value, masked> — so a future isolation regression fails loudly without leaking a secret.
  • Issue-linked per queue policy; supersedes the stale #1903.

merge=BEHIND (needs update-branch) but no code hazards. LGTM.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Justification: [change-requested: missing_before_after]

  • missing_before_after: Add before/after test output showing host env isolation and masking.

@liususan091219 liususan091219 added the request-change Has an active CHANGES_REQUESTED review label Jul 20, 2026
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Before/after showing both halves of the ask — host-env isolation and masking — reproduced with a synthetic token so no real secret was ever involved.

This host has no channels/slack/.env, so the bug wouldn't fire on it as-is. Rather than report "can't reproduce", I planted a fake host token at a temp $CLAUDE_CONFIG_DIR:

$CLAUDE_CONFIG_DIR/channels/slack/.env  →  SLACK_BOT_TOKEN="xoxb-HOST-SENTINEL-must-not-appear"

Before (origin/main) — rc=1, and the sentinel appears 3 times in the output:

✗ case i
    i) channel .env should win, got '<SENTINEL — this is where a REAL token would print>'
    i) fallback to $REPO/.env failed, got '<SENTINEL — this is where a REAL token would print>'
    i) absent token should be '', got '<SENTINEL — this is where a REAL token would print>'

(I redacted the sentinel above; the raw run printed it verbatim, which is the point.)

Both failure modes visible at once: the HOME redirect is bypassed because claude_home_path() prefers $CLAUDE_CONFIG_DIR, so the host value wins all three assertions — and the assertion messages print it. On a slack-enrolled host those three lines are your live bot token, in CI logs.

After (this branch) — rc=0, sentinel appears 0 times:

✓ case i

So the isolation half is doing the work (the fixture values win again), and the masking half means that even if isolation regresses later, the failure message can't print a non-fixture value.

Why the masking matters independently of the isolation fix, and I think this is the stronger half: isolation can be re-broken by any future env var that claude_home_path() learns to prefer. The masking is what makes that regression safe rather than merely detectable — it turns a credential disclosure into a <non-fixture 38-char value, masked>. Worth keeping even if someone later argues the isolation alone is sufficient.

One note on method: I deliberately did not run origin/main's version against this host's real $CLAUDE_CONFIG_DIR first. I checked whether a channels/slack/.env existed before running anything — had one been there, the "before" run would have printed a live credential into my own output, which is exactly the failure under review.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Before / after — plus a defect in this PR and a design question it exposed

1. The isolation fix works (before / after, this host)

CLAUDE_CONFIG_DIR is set on this install, which is the bug's precondition.

BEFORE (main)   ✓ a ✓ b ✓ c ✓ d ✓ e ✓ f ✓ g ✓ h  ✗ i     1 failure
                    i) channel .env should win, got 'xoxb-from-repo'

AFTER  (#2204)  ✓ a ✓ b ✓ c ✓ d ✓ e ✓ f ✓ g ✓ h  ✓ i

Case i fails on main for exactly the stated reason: CLAUDE_CONFIG_DIR bypasses the HOME redirect, so the fixture channel .env is never consulted and it falls through to the repo .env. The leaked value here was the fixture 'xoxb-from-repo', not a secret — this host has no channels/slack/.env. On a slack-enrolled host it would have been the live bot token, which is what the masking change addresses.

2. This PR ships a broken new test — case j fails

AFTER (#2204)   ✓ i   ✗ j
                    j) raised TypeError: '>=' not supported between instances of 'str' and 'int'

case j exists only in this PR (main has zero occurrences of "j)"), so this is a regression I introduced, and CI would fail on it.

Cause — the fixture invents a key production never writes. It seeds:

state.write_text(json.dumps({
    hash_key: now_ms - (25 * 3600 * 1000),
    "_last_hash": hash_key,          # <-- not a real key
}))

grep -n '_last_hash' src/health-check.py → no matches. Production treats every value in that dict as an int timestamp:

history = {k: v for k, v in history.items() if v >= cutoff}

so the string value blows up the prune. The fixture models a state shape that cannot occur.

3. With a valid fixture, case j still fails — and that part looks real

Removing only the invented key:

✗ case j
    j) unchanged failure set re-sent after 25h — spam bug regressed

The TypeError was masking the actual result. The mechanism is the 24h prune above: an entry seeded 25h ago is older than cutoff, so it is dropped, the "already sent" record disappears, and the identical DM goes out again.

What I am not doing, and why

I am not pushing a fix. What case j asserts — never re-send an unchanged set — is stricter than what the current design implements, which is at most once per 24h. The 2026-07-01 complaint was about re-sending every hour; 24h may well be the intended landing point, in which case the test is wrong, not the code.

That is a product call about a never-resolving failure: silent forever, or a daily reminder? I have no basis to decide it, and picking one silently inside a test-isolation PR would bury it.

Proposed split: land the isolation + masking fix (sections 1–2 above, with case j removed), and take the re-send question to its own issue. Happy to do either once the intended behaviour is settled.

Method note: my first run of case j reported a TypeError, which reads like a plain test bug. Only correcting the fixture and re-running surfaced the real assertion failure underneath. A broken fixture doesn't just fail — it can hide the result you were trying to measure.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Retraction — my previous comment was wrong. I tested against the wrong tree.

Almost everything in my earlier comment is invalid. Correcting it in full.

What I did wrong: I ran the "before" and "after" from a local clone sitting on wip/preserve-builder-work-2026-07-11 — 1095 commits ahead of and 120 behind origin/main. I labelled its output "BEFORE (main)". It is not main. That branch's src/health-check.py predates the _last_hash feature entirely (grep -c '_last_hash' → 0, versus origin/main which defines _LAST_HASH_KEY = "_last_hash" at line 60).

Re-run in a clean worktree checked out at origin/main (455b1bd9):

TRUE BEFORE  (origin/main's own test + health-check)
   ✓ a … ✓ h   ✗ case i   ✓ case j      1 failure

TRUE AFTER   (this PR's test + origin/main's health-check)
   ✓ a … ✓ h   ✓ case i   ✓ case j      0 failures

Point by point:

My earlier claim Reality
"case j exists only in this PR; main has zero occurrences" False. case j is on origin/main and the two versions are byte-identical. My grep hit the WIP branch.
"This PR ships a broken new test" False. The PR is clean on main — all cases pass.
"The fixture invents _last_hash, a key production never writes" False. origin/main uses it (_LAST_HASH_KEY). I grepped a stale checkout.
"Real find: unchanged failure set re-sent after 25h" False. An artifact of running the fixture against pre-_last_hash code, which treats every dict value as an int timestamp and raises TypeError. case j passes on main — the spam guard works.
"case i fails on main" Correct, and confirmed on the real thing.

So the only surviving finding is the one this PR actually fixes: case i fails on origin/main because CLAUDE_CONFIG_DIR bypasses the HOME redirect. The PR is good as-is. The isolation fix and the token masking both stand, and there is no case j problem to split out.

I also filed an internal product question ("should a never-resolving failure re-DM daily or never?") on the strength of the bogus finding. Withdrawing it — the behaviour I thought I'd found doesn't exist.

The lesson, since it invalidated a whole comment: "before/after" is worthless unless the "before" is the tree the reviewer will merge into. I never checked what branch the clone was on. A git rev-parse --abbrev-ref HEAD would have cost one second and saved this. I'm re-verifying my other before/after comments from today against origin/main and will correct any that share the defect.

@liususan091219
liususan091219 dismissed their stale review July 20, 2026 17:11

Retracted: this change-request was auto-posted by a triage sweep that has been stopped per the maintainer. Dismissing so it no longer blocks the PR.

@liususan091219 liususan091219 removed the request-change Has an active CHANGES_REQUESTED review label Jul 20, 2026

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Reviewed safe on the cold-review pass: isolate notify-slack test — test-only.

Approval executed by Sutando-Pro (Stand: Echo Act IV Pro) at @sonichi's explicit direction on the cold-review shortlist.

@john-the-dev
john-the-dev enabled auto-merge (squash) July 20, 2026 21:19
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@john-the-dev
john-the-dev merged commit f1c105b into main Jul 20, 2026
10 checks passed
@john-the-dev
john-the-dev deleted the fix/notify-slack-test-hermeticity branch July 20, 2026 21:22
qingyun-wu added a commit that referenced this pull request Jul 29, 2026
Review blocker on this PR: `load_bridge()` set the process-wide
CLAUDE_CONFIG_DIR to a temp dir and never restored it.

Why that is not cosmetic: `claude_home_path()` reads $CLAUDE_CONFIG_DIR at
CALL time, not at import, so the mutation outlives this file. In the
standalone-test loop every sibling fixture that afterwards seeds
`Path.home()/".claude"` then reads a different config root than the bridge
does, which is the downstream failure reported in the open review thread.
The leak is invisible from inside this test — it only breaks the NEXT one.

- `temp_claude_config()`: a context manager that saves the prior value, points
  it at a throwaway dir, and puts it back in a `finally` (so a failing
  assertion cannot skip the restore) before removing the temp tree.
- `load_bridge(config_root)` now takes the dir instead of creating and
  leaking one; the #2204 host-leakage guard it was added for is unchanged —
  the caller's real config is still never written to.
- `main()` asserts the caller's environment came back, so this cannot
  silently regress: an unrestored var is otherwise undetectable here.

Evidence — the assertion can say NO. With the restore deliberately disabled:

    $ CLAUDE_CONFIG_DIR=/tmp/fake-ccd python3 tests/discord-bridge-collaborator-tier.test.py
    FAIL: team-collaborator path has issues:
      - CLAUDE_CONFIG_DIR not restored: '/tmp/fake-ccd' -> '/var/.../dbct-claude-home-yffcc76n'

and with the fix in place, on the clean non-default sequence the review named:

    $ CLAUDE_CONFIG_DIR=/tmp/fake-ccd python3 tests/discord-bridge-collaborator-tier.test.py
    PASS: discord-bridge.py team-collaborator engage path is correct.
    caller env after the run: /tmp/fake-ccd   (unchanged)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyfS386nrKn8FFdtRLme6x
john-the-dev added a commit that referenced this pull request Aug 1, 2026
…config dir (#2357)

* fix(tests): stop seeding a stub Discord token into the caller's real config dir

load_bridge() seeded DISCORD_BOT_TOKEN into $CLAUDE_CONFIG_DIR/channels/discord/.env
when absent -- the caller's REAL config directory. On a machine with no Discord
install that fabricates one: health-check then reports 'discord-bridge:
configured but not running' indefinitely, and a stub token is left sitting in the
user's config dir.

Hit this for real: running the bridge suites during unrelated work created the
file, and the false warning appeared in the next health check.

Seed into a temp CLAUDE_CONFIG_DIR instead. Same host-leakage class as #2204.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZ5eEAmPuFnSA2TkqtQhzZ

* fix(test): restore CLAUDE_CONFIG_DIR — the leak outlived this module

Review blocker on this PR: `load_bridge()` set the process-wide
CLAUDE_CONFIG_DIR to a temp dir and never restored it.

Why that is not cosmetic: `claude_home_path()` reads $CLAUDE_CONFIG_DIR at
CALL time, not at import, so the mutation outlives this file. In the
standalone-test loop every sibling fixture that afterwards seeds
`Path.home()/".claude"` then reads a different config root than the bridge
does, which is the downstream failure reported in the open review thread.
The leak is invisible from inside this test — it only breaks the NEXT one.

- `temp_claude_config()`: a context manager that saves the prior value, points
  it at a throwaway dir, and puts it back in a `finally` (so a failing
  assertion cannot skip the restore) before removing the temp tree.
- `load_bridge(config_root)` now takes the dir instead of creating and
  leaking one; the #2204 host-leakage guard it was added for is unchanged —
  the caller's real config is still never written to.
- `main()` asserts the caller's environment came back, so this cannot
  silently regress: an unrestored var is otherwise undetectable here.

Evidence — the assertion can say NO. With the restore deliberately disabled:

    $ CLAUDE_CONFIG_DIR=/tmp/fake-ccd python3 tests/discord-bridge-collaborator-tier.test.py
    FAIL: team-collaborator path has issues:
      - CLAUDE_CONFIG_DIR not restored: '/tmp/fake-ccd' -> '/var/.../dbct-claude-home-yffcc76n'

and with the fix in place, on the clean non-default sequence the review named:

    $ CLAUDE_CONFIG_DIR=/tmp/fake-ccd python3 tests/discord-bridge-collaborator-tier.test.py
    PASS: discord-bridge.py team-collaborator engage path is correct.
    caller env after the run: /tmp/fake-ccd   (unchanged)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyfS386nrKn8FFdtRLme6x

* test(discord): seed the token under the CONFIG ROOT, via one shared helper

Review blocker (#2357, john-the-dev 2026-07-30T02:24): removing this PR's
caller-config pollution exposed a supported split-config sequence that is red.
Three fixtures seeded `Path.home() / ".claude" / channels/discord/.env`, which is
the WRONG root whenever CLAUDE_CONFIG_DIR is set — i.e. in every real
split-config install. All three failed with
`DISCORD_BOT_TOKEN not set in $CLAUDE_CONFIG_DIR/channels/discord/.env`.

Those three were NOT introduced by the leak fix. They were always wrong about the
root; the unrestored CLAUDE_CONFIG_DIR from discord-bridge-collaborator-tier
happened to leave a config root containing a token, so later tests in the same
standalone run inherited it and passed for a reason unrelated to their own setup.
Newly exposed is not newly introduced — so the repair is to align the fixtures
with the production contract, not to restore the pollution.

- tests/_helpers/discord_env.py: `seed_discord_token()` resolves the root the way
  the BRIDGE resolves it ($CLAUDE_CONFIG_DIR, else ~/.claude) instead of picking
  one half. A fixture that hardcodes either half is correct only by coincidence,
  which is the whole bug. Never overwrites an existing .env, so a developer's real
  token survives a test run; idempotent for several fixtures in one process.
- The three fixtures now call it.
- tests/discord-bridge-split-config-root.test.py: the reviewer's reproduction as a
  test rather than a paragraph. Runs the four fixtures with FRESH, DISTINCT HOME
  and CLAUDE_CONFIG_DIR, requires exit 0 from each, and then asserts WHERE the
  token landed. Sequence order is deliberate — collaborator-tier runs first
  because it is the fixture whose leak used to mask the others, and reversing the
  order would hide the interaction under test.

Both halves of that regression matter: "all four exit 0" alone would also pass if
the fixtures stopped seeding entirely, so the location assertions are what pin the
contract.

Evidence — the reviewer's exact sequence, fresh distinct roots:

    AFTER                                              BEFORE (control)
    collaborator-tier      exit=0                      exit=0
    reply-directive        exit=0                      exit=1
    mod-judge-trackers     exit=0                      exit=1
    mod-judge-codex        exit=0                      exit=1
    token under $CLAUDE_CONFIG_DIR: yes                no
    token under HOME/.claude:       no                 yes

The new regression FAILS 5 of 6 checks on the pre-fix tree, including both
location assertions, so it discriminates rather than merely passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyfS386nrKn8FFdtRLme6x

* fix(tests): give each Discord fixture its own config root, not the caller's

Addresses john-the-dev's [P1] on b279402: the previous cut fixed WHICH root the
bridge reads but not WHOSE. `seed_discord_token()` wrote under the ambient root, so
a fixture run with a real CLAUDE_CONFIG_DIR left a 34-byte stub credential in the
caller's actual config dir — fabricating a Discord install on a machine that has
none. That is the production symptom this PR exists to fix, relocated rather than
removed.

Reviewer's repro, reproduced verbatim before changing anything:

    HOME=<fresh> CLAUDE_CONFIG_DIR=<fresh> \
      python3 tests/discord-bridge-reply-directive.test.py
    -> exit 0, and <fresh-ccd>/channels/discord/.env == 34 bytes of stub token

Fix: `temp_config_root()` in tests/_helpers/discord_env.py gives each fixture a
throwaway root, seeds it there, and restores the caller's environment in a
try/finally — restoring ABSENCE with a pop rather than assigning "", since those
are different states to any reader that does not .strip(). The bridge resolves its
token at exec time, so the temp root only has to be live across the exec.

The cross-fixture regression now asserts ISOLATION, which is the half that was
missing. Its previous assertion — that the token landed under the caller's config
root — verified routing while *requiring* the pollution. Routing is still pinned by
the four exit-0 checks: a fixture seeding the wrong root cannot load the bridge.

Verification:
  * all four fixtures, fresh distinct HOME+CLAUDE_CONFIG_DIR: exit 0, caller's
    config dir left with zero entries
  * tests/discord-bridge-split-config-root.test.py: PASS (7 checks)
  * CONTROL — reverted only the helper+fixture change, kept the new assertions:
    the four exit-0 checks still PASS while both isolation checks FAIL
    ("unexpected entries: ['channels']"). The old test could not have caught this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BEFHwWqqUHbH4828mGKuAC

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
Co-authored-by: john-the-dev <johnv.the.dev@gmail.com>
Co-authored-by: Susan Xueqing Liu <xliu127@stevens.edu>
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.

notify-slack test leaks the host's live Slack bot token into test output on enrolled hosts

5 participants