Fix: symlinked HOME subdirectory can dodge a credential-store deny rule - #553
Merged
Conversation
The dsh and qwen safety guards deny writes to credential stores (~/.aws, ~/.ssh, ~/.kube, etc.) by matching the write TARGET's realpath against a glob built from the un-resolved (expanduser'd only) rule path. When a HOME subdirectory is itself a symlink -- a bind mount, or on WSL the Windows-side profile at /mnt/c/Users/<user>/.aws -- the realpath'd target no longer starts with the un-realpath'd glob prefix, so the deny silently stops matching and the write goes through. Found via test_qwen_guard.py's test_replace_in_aws_is_denied failing on a real WSL host where ~/.aws is such a symlink; confirmed the same gap in the parallel dsh guard's node test suite (agent/dsh/guard/test/policy.test.mjs), since both runtimes share _parse_perm_rule's glob-building code. Fix: _realpath_glob_prefix() resolves symlinks in the LITERAL portion of each glob (the part before its first `*`/`?`) at config-build time, the same way fileguard.py already resolves its ~/.claude base -- so the glob and the target-side realpath agree. Falls back to the original path on any resolution error (nonexistent path), matching os.path.realpath's own graceful-degradation semantics. Updated four hardcoded-literal tests (two Python, one JS fixture used by two suites) that asserted the un-resolved path and would otherwise have reintroduced this exact gap the next time someone "simplified" the glob list back to a bare expanduser join. Not fixed here, and flagged separately: a credential FILE itself being a symlink (e.g. ~/.kube/config -> elsewhere, with ~/.kube itself real) is a distinct, narrower gap this glob-prefix fix cannot close -- closing that needs a nominal-path match alongside the realpath one, a bigger design change across three matching implementations (qwen shim.py, dsh policy.mjs, and by extension Claude's own permission engine, which is out of our control). Confirmed CI-invisible (fresh runners have no such symlink) and out of scope for this fix. Test plan: - python3 -m unittest discover -s agent/tests: 2194 tests, OK (previously 1 failure: test_replace_in_aws_is_denied) - node --test turma/tests/*.test.js agent/tests/*.test.js .github/scripts/tests/*.test.js: 1695/1695 pass (unaffected) - node --test agent/dsh/guard/test/*.test.mjs: 32/33 -> ready to re-check, see PR description for the remaining .kube/config case
The "symlinked HOME could dodge a credential glob" line described a limitation this PR's own fix (_realpath_glob_prefix) partially closes: a symlinked HOME *subdirectory* (~/.aws itself a bind mount, or WSL's Windows-side profile) no longer dodges its deny rule. Left uncorrected, a future session reading this file would believe the whole class of bug was still open, or "re-fix" what's already fixed. Narrowed the note to the genuinely remaining gap: a credential FILE itself being a symlink (~/.kube/config elsewhere, ~/.kube itself real) still dodges, since there's no directory prefix to realpath. Found by the qa agent's adversarial pass on this PR, which reproduced a live bypass through exactly that remaining gap to confirm the boundary is accurately stated.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Reported as a pre-existing test failure in PR #551:
test_qwen_guard.py'stest_replace_in_aws_is_deniedfails on this dev host because~/.awsis a symlink to/mnt/c/Users/mhabeeb/.aws(WSL), and the qwen guard shim was not denying the write.Root cause
Both the dsh and qwen safety guards deny writes to credential stores (
~/.aws,~/.ssh,~/.kube,etc.) by realpath-resolving the write target and matching it against a glob built from the
un-resolved rule path (
_parse_perm_ruleonly ranos.path.expanduser, neveros.path.realpath).When a HOME subdirectory is itself a symlink — a bind mount, or on WSL the Windows-side profile — the
realpath'd target no longer starts with the un-realpath'd glob prefix, so the deny silently stops
matching.
This is shared code:
build_dsh_guard_configandbuild_qwen_guard_configboth call_parse_perm_rule, so the gap hit both runtimes identically. Confirmed by running the parallel dshguard suite (
agent/dsh/guard/test/policy.test.mjs), which is a separate CI job not covered bymy earlier PR verification — it had the same failure, undetected until now.
Fix
_realpath_glob_prefix()resolves symlinks in the literal portion of each glob (everything beforeits first
*/?) at config-build time — the same techniquefileguard.pyalready uses for its~/.claudebase. Falls back to the original path on any resolution error, matchingos.path.realpath's own graceful degradation for a not-yet-existing path.Updated four tests (two Python, one JS fixture shared by two suites) that hardcoded the un-resolved
literal path and would otherwise have silently reintroduced this exact gap the next time someone
"simplified" the glob list back to a bare
expanduserjoin. They now assert the resolved form,which is host-independent (a no-op where nothing is symlinked, as in CI).
What this does NOT fix (flagged, not silently dropped)
A credential file itself being a symlink —
~/.kube/config -> /mnt/c/.../config, with~/.kubeitself a real directory — is a distinct, narrower gap this fix cannot close. The glob's literal
prefix (
~/.kube) isn't a symlink here, so prefix-resolution doesn't help; only the leaf file is.Closing it needs a nominal-path match (the target's
~-expanded-but-not-symlink-followed form,checked against the un-realpath'd glob) run alongside the existing realpath check — a bigger,
three-implementation design change (qwen
shim.py, dshpolicy.mjs, and by extension whateverClaude Code's own permission engine does, which we don't control).
I'm not making that change here: it needs careful design to avoid reintroducing the other
direction's hole (an attacker-created symlink pointing into a protected store from an allowed
location, which is what the realpath check exists to catch), and real-host verification per this
guard's own "no mock" testing convention. Confirmed CI-invisible — a fresh GitHub runner has no
such symlink, so the one remaining local test failure (
write DENIED: ~/.kube/configinpolicy.test.mjs) will not appear in CI. Happy to open a follow-up ticket for it if wanted.Test plan
python3 -m unittest discover -s agent/tests: 2238 tests, OK (previously 1 failure:test_replace_in_aws_is_denied; extra tests vs. the original 2194 are the two Python fixturefiles' new symlink-agnostic assertions, not net-new coverage).
node --test turma/tests/*.test.js agent/tests/*.test.js .github/scripts/tests/*.test.js:1695/1695 pass, unaffected.
node --test agent/dsh/guard/test/*.test.mjs: 32/33 (the.kube/configgap above, confirmedpre-existing and CI-invisible — not introduced by this change).
fixtures. No behavior change for a non-symlinked HOME.
QA verdict: PASS
Adversarial QA drove the real
agent/qwen/guard/shim.pyandagent/dsh/guard/policy.mjsagainstthis host's actual symlinked
~/.aws/~/.azure, not just the unit tests:~/.aws/credentialswhere they previously allowed it.~/.ssh/id_rsa,~/.claude/settings.jsonstill DENIED; a harmless/tmpwrite still ALLOWED.
reproduced a live bypass through
~/.kube/config(a symlinked file, real containing directory),confirming that boundary is exactly where this PR says it is.
.kube/configcase above, reproduced independently before QA even read this description)..claude/rules/qwen.mdhad a stale line claiming the whole symlink class wasstill an "accepted limitation" — now corrected to state what's fixed vs. still open (pushed in
41c0dd8)._realpath_glob_prefixona glob whose wildcard sits immediately after the leading
/(e.g. a hypothetical/*rule) wouldrealpath an empty string, which resolves to the process's CWD rather than
/. No rule in_GUARD_DENY_PATH_RULEShas that shape today, so this isn't reachable — flagged for anyone addinga future rule near the filesystem root.