fix(approval): collapse redundant path separators before sensitive-path detection - #89657
fix(approval): collapse redundant path separators before sensitive-path detection#89657Jiaaqiliu wants to merge 1 commit into
Conversation
…th detection
`~//.hermes/config.yaml` and `~/./.ssh/authorized_keys` name exactly the same
files as their single-separator spellings, but the sensitive-path fragments
(`_SSH_SENSITIVE_PATH`, `_HERMES_CONFIG_PATH`, the shell-rc and dotfile
targets) anchor on one `/` between components, so the redundant spellings slip
every terminal-side write rule.
`~/.hermes/config.yaml` is the approval policy itself — it holds
`approvals.mode`, yolo, and the permanent-approval allowlist — and the config
cache is mtime-keyed, so a write lands mid-session. An agent could therefore
write `approvals: {mode: off}` with no prompt and run every subsequent command
ungated. The same gap admitted `~//.ssh/authorized_keys` and `~//.bashrc`.
The absolute spellings were already covered: `_home_prefix_fold_regex` joins
components with `[/\\]+`, so `/Users/alice//.hermes/config.yaml` folds
correctly. This closes the same hole for the `~` / `$HOME` / `$HERMES_HOME`
forms that fold produces.
Both substitutions require a preceding path character, so URL schemes
(`https://`), UNC roots and integer division (`10 // 3`) are untouched.
92f327f to
fc18b95
Compare
andrexibiza
left a comment
There was a problem hiding this comment.
Reviewed exact head fc18b956780366e195f37df47dffc9cb855ebb57 against its actual parent/merge base 74f99af470ae8ce47f0903cf431d106cecbd37f2 and current main 5dd15872a6878a19b9b5478b6968b38f48dd311f. I checked both changed files, the full normalization order, every sensitive-path fragment and write verb that consumes it, the file-tools sibling boundary, exact-head Actions state, existing review/comment state, and the related path-hardening/provenance chain.
The underlying defect is real and the ~//... / ~/./... cases added here are correctly targeted, but the implementation still leaves the same approval-policy write bypass open through the brace forms the existing detector explicitly supports.
Blocking: ${HOME} / ${HERMES_HOME} spellings are not normalized
Both new substitutions require the character immediately before the redundant separator to match [\w~.\-]:
re.sub(r'(?<=[\w~.\-])(?:/\.)+/', '/', command)
re.sub(r'(?<=[\w~.\-])//+', '/', command)That works for $HOME//... because the preceding character is E, but not for ${HOME}//... because the preceding character is }. The same applies to /./ and to ${HERMES_HOME}.
Concrete residual reproductions on this exact head:
echo 'approvals: {mode: off}' > ${HOME}//.hermes/config.yaml
echo 'approvals: {mode: off}' > ${HOME}/./.hermes/config.yaml
echo 'approvals: {mode: off}' > ${HERMES_HOME}//config.yaml
echo 'approvals: {mode: off}' > ${HERMES_HOME}/./config.yaml
cat key >> ${HOME}//.ssh/authorized_keysAfter the new normalization and lowercase step, those remain respectively ${home}//..., ${home}/./..., and ${hermes_home}//.... _HERMES_CONFIG_PATH, _SSH_SENSITIVE_PATH, _SHELL_RC_FILES, and _CREDENTIAL_FILES deliberately include ${home} / ${hermes_home} alternatives, but each requires exactly one /; therefore the tee/redirection/in-place/copy patterns still do not match these shell-equivalent targets.
This is not a neighboring theoretical bypass: it is the same separator-canonicalization class, on the same terminal surface, against the same files, using syntax the current patterns claim to cover. In the config.yaml cases it preserves the exact mode-off chain described in the PR body.
Required fix: either include the closing brace in the accepted predecessor set if this lookbehind design is retained, or normalize recognized path tokens/root expressions rather than relying on a generic preceding-character heuristic. Please add focused regressions for both ${HOME} and ${HERMES_HOME}, with both // and /./, including at least the Hermes config and SSH/shell-rc families.
Secondary test-contract note
The comment says URLs are left alone, but only the scheme delimiter is protected. In https://example.com//api/v1, the second // is preceded by m, so the normalizer collapses that URL path. The current test only proves the resulting command is not classified as dangerous; it does not prove the stated non-rewrite property. Because this string is detection-only rather than executed, I do not treat that as a separate blocker, but either narrow the normalization to actual path tokens or reword/pin the intended contract directly against _normalize_command_for_detection.
Interlocks / ownership / provenance
- This is complementary to #78653 and #10682, which operate in
file_toolspath resolution, and to #71919, which preserves Windows executable-path boundaries in this same approval normalizer. It does not duplicate them. - The protected
config.yamltopology originates in #14639 by @Subway2023 and was salvaged into main with contributor credit in4e9d886d9d9391d096093071cd79ece7e543f3e0; this PR is a legitimate continuation of that terminal-side pairing rather than replacement work. - Per
SECURITY.md, the approval gate is an in-process heuristic, not an OS isolation boundary. This is still worthwhile hardening, but it should be described as closing this spelling class, not the adversarial-shell class generally. - Current
mainis five commits ahead of the PR's actual merge base, but none of that drift touchestools/approval.pyortests/tools/test_approval.py; there is no current semantic merge-order conflict. Rebase/current-base verification is still appropriate before merge.
Verification state
There were no prior reviews, issue comments, or review threads on this PR, so this is not duplicate review residue. Exact-head CI has not executed: CI, Docker, and Nix all currently conclude action_required, and the combined status endpoint exposes no completed contexts. The author's reported focused result is 165 passing tests with one baseline macOS failure; the brace-form cases above are not in that matrix.
Re-review gate: close the brace-form residual class, add the focused regressions, and obtain executed exact-head CI.
andrexibiza
left a comment
There was a problem hiding this comment.
Reviewed exact head fc18b956780366e195f37df47dffc9cb855ebb57 against the current main tip 5dd15872a6878a19b9b5478b6968b38f48dd311f (merge base 74f99af470ae8ce47f0903cf431d106cecbd37f2; this head is currently 5 commits behind / 1 ahead). I inspected the complete diff, the full _normalize_command_for_detection ordering, every shared sensitive-path fragment and its redirect/tee/copy/in-place consumers, the new tests, exact-head Actions state, existing review/comment history, and the adjacent file-tool, Windows-normalization, hardline, and profile-config work.
The reported defect is real, and the patch correctly catches the simple ~//... and ~/./... spellings covered by the new tests. Two remaining forms still resolve to the protected resources while passing through this normalizer unchanged or incompletely, so I do not think the security class is closed yet.
Blocking 1: braced $HOME / $HERMES_HOME forms remain bypasses
The protected-path fragments deliberately accept both unbraced and braced variables: $home / ${home} and $hermes_home / ${hermes_home}. But both new substitutions require the slash run to be preceded by (?:[\w~.\-]). In a braced expansion, the character immediately before the slash is }, so neither substitution runs.
Concrete residuals at this head:
echo 'approvals: {mode: off}' > ${HOME}//.hermes/config.yaml
echo x > ${HOME}//.hermes/.env
echo k >> ${HOME}//.ssh/authorized_keys
echo x >> ${HOME}//.bashrc
echo 'approvals: {mode: off}' > ${HERMES_HOME}//config.yamlThe shell expands those variables and POSIX resolves the redundant separators to the same files as the canonical spellings, but the detector still sees the doubled separator and the exact-one-slash fragments do not match. The first and last commands therefore preserve the full approval-policy rewrite path this PR is intended to close.
Required fix: make separator/dot-segment normalization understand the braced-variable token boundary rather than relying only on a preceding path character, and add regression cases for all four supported forms ($HOME, ${HOME}, $HERMES_HOME, ${HERMES_HOME}), including at least config, .env, SSH, and shell-rc targets.
Blocking 2: the two passes are not closed under composition
The /./ pass runs before the repeated-slash pass. A mixed alias can therefore cause pass 2 to create a fresh /./ segment after pass 1 has already finished:
~//./.ssh/authorized_keys -> ~/./.ssh/authorized_keys
~//./.bashrc -> ~/./.bashrc
Both final strings still miss the exact sensitive fragments. The same issue survives the absolute-home fold: /home/alice//./.ssh/authorized_keys folds to the ~//./... shape and then ends at ~/./....
Required fix: normalize in an order/fixed point that cannot expose a new no-op segment after its pass has run (for example, collapse repeated separators before collapsing /./ runs, with tests for mixed and repeated compositions). Please pin at least ~//./.ssh/authorized_keys, ~//./.bashrc, and an absolute-home //./ spelling.
Other side of the shape / interlocks
..aliases such as~/existing-dir/../.ssh/authorized_keysremain an adjacent canonical-path bypass class. I would track that separately rather than silently claiming full path identity here, because safe treatment needs to account for shell tokenization and symlink semantics.- #60523 is complementary hardline promotion, not a duplicate. It reuses
_HERMES_CONFIG_PATH/_HERMES_ENV_PATH, so these normalization gaps would bypass that stronger floor too; this normalizer needs to be correct before that policy becomes meaningful for all spellings. - #79089 is complementary resource coverage for profile config/env paths and inherits the same alias problem. Once rebased, its
${HOME}/.hermes/profiles/...forms need the corrected normalizer. - #71919 is adjacent work in this exact normalization function for Windows executable-path boundaries. There is no semantic supersession, but whichever branch rebases second must preserve both ordering constraints.
- #78653 and #10682 are the file-tool/sink side, not duplicates of this terminal-command fix. #78653 itself explicitly preserves #76247 as the original overlapping Windows file-tool implementation.
- The current terminal write family carries provenance from the #14639 line and the merged #38998 salvage of #36894; this PR is a shared-normalizer extension across those existing consumers, not a replacement for their command-specific coverage.
Verification state
- No prior reviews, comments, or review threads existed on this exact head, so this is not duplicate review residue.
- Exact-head GitHub Actions have not executed: CI, Docker, and Nix are all
action_required, and the combined status endpoint has no completed contexts. The author's focused local result is useful but is not an upstream exact-head CI receipt. - Current-main drift does not touch either changed file, but this head is five commits behind and will need a new commit for the blockers anyway.
Re-review gate: braced-variable normalization; composition-safe // + /./ normalization; focused regressions proving the residual commands above are detected by the real detect_dangerous_command; and executed exact-head CI.
What does this PR do?
_normalize_command_for_detectionfolds absolute home prefixes, strips backslash escapes, and expands$IFS— but it never collapses redundant/runs or no-op/./segments inside a path token. The sensitive-path fragments (_SSH_SENSITIVE_PATH,_HERMES_CONFIG_PATH, the shell-rc and dotfile targets) all anchor on exactly one/between components, so a spelling POSIX resolves to the very same file slips every terminal-side write rule.This matters most for
~/.hermes/config.yaml, for the reason the comment above_HERMES_CONFIG_PATHalready states: that file is the security policy (approvals.mode, yolo, the permanent-approval allowlist),_get_approval_mode()re-reads it live, andcheck_all_command_guardsreturns unconditionally onceapproval_mode == "off". A prompt-injected agent needs oneterminalcall to writeapprovals: {mode: off}with no prompt, after which every command in the session runs ungated.The absolute spellings were never affected —
_home_prefix_fold_regexjoins components with[/\\]+, so/Users/alice//.hermes/config.yamlis caught today. That inconsistency is what surfaced the gap: this PR gives the~/$HOME/$HERMES_HOMEforms the same tolerance.Related Issue
No existing issue found. I searched open and merged PRs for approval/sensitive-path/separator topics before writing this; the nearest neighbours (#78653 Windows separator normalization in file-tools, #71919 Windows path command boundaries, #10682 OS-agnostic sensitive path protection) address different layers and none collapses redundant separators in
tools/approval.py.Type of Change
Changes Made
tools/approval.py— in_normalize_command_for_detection, collapse/./runs and repeated/inside path tokens, immediately after the home folds and before the backslash-escape strip.tests/tools/test_approval.py— newTestRedundantPathSeparatorscovering doubled separators,/./segments (including repeated ones), and a false-positive guard.Both substitutions use a lookbehind requiring a path character (
[\w~.\-]), so URL schemes (https://), UNC roots (\\server) and integer division (10 // 3) are left alone.Placement is deliberate: it runs after
_rewrite_resolved_hermes_home/_rewrite_resolved_user_homeso it also normalizes the~-form those produce, and before the backslash strip for the same Windows reason documented there.How to Test
Before the fix:
After:
# (True, 'sensitive_redirect', 'overwrite system file via redirection')Regression suite:
165 passed on my machine. One unrelated pre-existing failure,
TestDetectDangerousRm::test_nonrecursive_verification_artifact_cleanup_is_not_dangerous, reproduces identically on unmodifiedmainhere — it is a macOS artifact (/tmpis a symlink to/private/tmp, so the canonical-target check in the temp-dir exemption does not match the mockedgettempdir), not a consequence of this change.Checklist
Code