From eb21179ddebdfc33b2a8615286746816d17a1181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AE=B6=E5=90=8D?= Date: Mon, 8 Jun 2026 10:18:48 +0800 Subject: [PATCH] fix: validate attached redirection paths --- src/path_scope.py | 9 +++++++++ tests/test_security_scope.py | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/path_scope.py b/src/path_scope.py index 52e7020c..31828a6a 100644 --- a/src/path_scope.py +++ b/src/path_scope.py @@ -11,6 +11,7 @@ _GLOB_META = set('*?[') _WINDOWS_DRIVE_RE = re.compile(r'^[A-Za-z]:[\\/]') _WINDOWS_UNC_RE = re.compile(r'^(?:\\\\|//)[^\\/]+[\\/][^\\/]+') _ENV_ASSIGNMENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=') +_REDIRECTION_TARGET_RE = re.compile(r'^(?:\d*)?(?:<>|>>?|<)(.+)$|^&>>?(.+)$') @dataclass(frozen=True) @@ -118,6 +119,7 @@ def extract_path_candidates(payload: str) -> tuple[str, ...]: for token in (*tokens, *raw_tokens): if not token or token.startswith('-') or _ENV_ASSIGNMENT_RE.match(token): continue + token = _strip_redirection_operator(token) expanded = os.path.expandvars(os.path.expanduser(token)) if _looks_like_path(token) or _looks_like_path(expanded): candidate = expanded if _looks_like_path(expanded) else token @@ -138,6 +140,13 @@ def _looks_like_path(token: str) -> bool: ) +def _strip_redirection_operator(token: str) -> str: + match = _REDIRECTION_TARGET_RE.match(token) + if match is None: + return token + return next(group for group in match.groups() if group is not None) + + def _is_windows_absolute(value: str) -> bool: return bool(_WINDOWS_DRIVE_RE.match(value) or _WINDOWS_UNC_RE.match(value)) diff --git a/tests/test_security_scope.py b/tests/test_security_scope.py index 55cba20a..59275dda 100644 --- a/tests/test_security_scope.py +++ b/tests/test_security_scope.py @@ -72,6 +72,28 @@ class WorkspacePathScopeTests(unittest.TestCase): self.assertFalse(decision.allowed) self.assertIn(str(outside.resolve()), decision.resolved or '') + def test_attached_shell_redirection_targets_are_validated(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = root / 'workspace' + outside = root / 'outside' + workspace.mkdir() + outside.mkdir() + (outside / 'secret.txt').write_text('secret') + + self.assertEqual( + ('../outside/secret.txt', '../outside/error.log'), + extract_path_candidates( + 'cat <../outside/secret.txt 2>../outside/error.log' + ), + ) + decision = WorkspacePathScope.from_root(workspace).validate_payload( + 'cat <../outside/secret.txt 2>../outside/error.log' + ) + + self.assertFalse(decision.allowed) + self.assertIn(str(outside.resolve()), decision.resolved or '') + def test_explicit_worktree_roots_are_allowed(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp)