Skip to content

Commit 2e52ea7

Browse files
authored
Merge pull request #3237 from hiisandog/fix/redirection-path-scope-20260608
fix: validate attached redirection paths
2 parents 9b3548c + eb21179 commit 2e52ea7

2 files changed

Lines changed: 31 additions & 0 deletions

File tree

src/path_scope.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
_WINDOWS_DRIVE_RE = re.compile(r'^[A-Za-z]:[\\/]')
1212
_WINDOWS_UNC_RE = re.compile(r'^(?:\\\\|//)[^\\/]+[\\/][^\\/]+')
1313
_ENV_ASSIGNMENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=')
14+
_REDIRECTION_TARGET_RE = re.compile(r'^(?:\d*)?(?:<>|>>?|<)(.+)$|^&>>?(.+)$')
1415

1516

1617
@dataclass(frozen=True)
@@ -118,6 +119,7 @@ def extract_path_candidates(payload: str) -> tuple[str, ...]:
118119
for token in (*tokens, *raw_tokens):
119120
if not token or token.startswith('-') or _ENV_ASSIGNMENT_RE.match(token):
120121
continue
122+
token = _strip_redirection_operator(token)
121123
expanded = os.path.expandvars(os.path.expanduser(token))
122124
if _looks_like_path(token) or _looks_like_path(expanded):
123125
candidate = expanded if _looks_like_path(expanded) else token
@@ -138,6 +140,13 @@ def _looks_like_path(token: str) -> bool:
138140
)
139141

140142

143+
def _strip_redirection_operator(token: str) -> str:
144+
match = _REDIRECTION_TARGET_RE.match(token)
145+
if match is None:
146+
return token
147+
return next(group for group in match.groups() if group is not None)
148+
149+
141150
def _is_windows_absolute(value: str) -> bool:
142151
return bool(_WINDOWS_DRIVE_RE.match(value) or _WINDOWS_UNC_RE.match(value))
143152

tests/test_security_scope.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,28 @@ def test_shell_environment_expansion_is_validated(self) -> None:
7272
self.assertFalse(decision.allowed)
7373
self.assertIn(str(outside.resolve()), decision.resolved or '')
7474

75+
def test_attached_shell_redirection_targets_are_validated(self) -> None:
76+
with tempfile.TemporaryDirectory() as tmp:
77+
root = Path(tmp)
78+
workspace = root / 'workspace'
79+
outside = root / 'outside'
80+
workspace.mkdir()
81+
outside.mkdir()
82+
(outside / 'secret.txt').write_text('secret')
83+
84+
self.assertEqual(
85+
('../outside/secret.txt', '../outside/error.log'),
86+
extract_path_candidates(
87+
'cat <../outside/secret.txt 2>../outside/error.log'
88+
),
89+
)
90+
decision = WorkspacePathScope.from_root(workspace).validate_payload(
91+
'cat <../outside/secret.txt 2>../outside/error.log'
92+
)
93+
94+
self.assertFalse(decision.allowed)
95+
self.assertIn(str(outside.resolve()), decision.resolved or '')
96+
7597
def test_explicit_worktree_roots_are_allowed(self) -> None:
7698
with tempfile.TemporaryDirectory() as tmp:
7799
root = Path(tmp)

0 commit comments

Comments
 (0)