Skip to content

Fix: symlinked HOME subdirectory can dodge a credential-store deny rule - #553

Merged
xerhab merged 2 commits into
mainfrom
fix-symlinked-home-credential-glob
Aug 29, 2026
Merged

Fix: symlinked HOME subdirectory can dodge a credential-store deny rule#553
xerhab merged 2 commits into
mainfrom
fix-symlinked-home-credential-glob

Conversation

@xerhab

@xerhab xerhab commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Why

Reported as a pre-existing test failure in PR #551: test_qwen_guard.py's
test_replace_in_aws_is_denied fails on this dev host because ~/.aws is 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_rule only ran os.path.expanduser, never os.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_config and build_qwen_guard_config both call
_parse_perm_rule, so the gap hit both runtimes identically. Confirmed by running the parallel dsh
guard suite (agent/dsh/guard/test/policy.test.mjs), which is a separate CI job not covered by
my 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 before
its first */?) at config-build time — the same technique fileguard.py already uses for its
~/.claude base. Falls back to the original path on any resolution error, matching
os.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 expanduser join. 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 ~/.kube
itself 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, dsh policy.mjs, and by extension whatever
Claude 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/config in
policy.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 fixture
    files' 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/config gap above, confirmed
    pre-existing and CI-invisible — not introduced by this change).
  • Diff reviewed by hand; change is scoped to the shared glob-resolution helper + its four test
    fixtures. No behavior change for a non-symlinked HOME.

QA verdict: PASS

Adversarial QA drove the real agent/qwen/guard/shim.py and agent/dsh/guard/policy.mjs against
this host's actual symlinked ~/.aws/~/.azure, not just the unit tests:

  • The directory-symlink bypass is fixed — confirmed both guards now DENY a write to
    ~/.aws/credentials where they previously allowed it.
  • No regression~/.ssh/id_rsa, ~/.claude/settings.json still DENIED; a harmless /tmp
    write still ALLOWED.
  • The stated remaining gap is honest, not overstated or understated — QA independently
    reproduced a live bypass through ~/.kube/config (a symlinked file, real containing directory),
    confirming that boundary is exactly where this PR says it is.
  • Full suites green (2238 Python, 1695 Node, 32/33 dsh-guard node — the one failure is the
    .kube/config case above, reproduced independently before QA even read this description).
  • One finding acted on: .claude/rules/qwen.md had a stale line claiming the whole symlink class was
    still an "accepted limitation" — now corrected to state what's fixed vs. still open (pushed in
    41c0dd8).
  • One latent, non-exploitable edge case noted for awareness, not blocking: _realpath_glob_prefix on
    a glob whose wildcard sits immediately after the leading / (e.g. a hypothetical /* rule) would
    realpath an empty string, which resolves to the process's CWD rather than /. No rule in
    _GUARD_DENY_PATH_RULES has that shape today, so this isn't reachable — flagged for anyone adding
    a future rule near the filesystem root.

xerhab added 2 commits August 29, 2026 12:50
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.
@xerhab
xerhab merged commit e0083b1 into main Aug 29, 2026
4 checks passed
@xerhab
xerhab deleted the fix-symlinked-home-credential-glob branch August 29, 2026 17:18
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.

1 participant