Skip to content

Commit 5e42557

Browse files
Merge pull request #29 from NVIDIA-NeMo/security/shelltools-path-containment
Contain ShellTools file operations within cwd
2 parents dcac4ad + 1c607d2 commit 5e42557

3 files changed

Lines changed: 61 additions & 7 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# ShellTools File-Operation Path Containment
2+
3+
## Security issue
4+
5+
`ShellTools.read()`, both forms of `replace()`, and `write_file()` resolved
6+
user-controlled paths but did not verify that the result remained beneath
7+
`ShellTools.cwd`. Absolute paths and `..` components could therefore read or
8+
modify files elsewhere on the host. Match harvesting used the same unsafe
9+
resolution when creating editable file matches.
10+
11+
## Patch
12+
13+
All non-shell filesystem access now goes through a shared `_resolve_path()`
14+
helper. It resolves both the working directory and candidate path, then requires
15+
`resolved.relative_to(cwd)` to succeed before any file is opened, created, or
16+
modified. A path outside the current working directory raises `ValueError`.
17+
18+
Normal relative paths, nested directories, overwrites, and both existing
19+
`replace()` forms retain their behavior. Match harvesting fails closed and
20+
attaches no editable matches when a reported path is outside the working
21+
directory.
22+
23+
## Boundary
24+
25+
This change confines the explicit Python file-operation methods. `run()` is an
26+
intentional shell capability and must be separately sandboxed or withheld when
27+
arbitrary shell commands are outside the deployment's trust model.

src/nooa/tools/shell_tools.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,16 @@ async def close(self) -> None:
339339
"""Terminate the underlying bash session owned by this shell."""
340340
await self._session.close()
341341

342+
def _resolve_path(self, path: str) -> Path:
343+
"""Resolve a file-operation path and require it to remain inside cwd."""
344+
root = self.cwd.resolve()
345+
resolved = (root / path).resolve()
346+
try:
347+
resolved.relative_to(root)
348+
except ValueError as exc:
349+
raise ValueError(f"path escapes ShellTools cwd: {path}") from exc
350+
return resolved
351+
342352
async def run(
343353
self,
344354
command: Annotated[str, spec(description="Shell command to execute")],
@@ -538,10 +548,10 @@ async def _harvest_matches(self, command: str, displayed_stdout: str) -> list[Ma
538548
out: list[Match] = []
539549
for mpath, line_no in keep:
540550
if mpath not in file_cache:
541-
resolved = (self.cwd / mpath).resolve()
542551
try:
552+
resolved = self._resolve_path(mpath)
543553
file_cache[mpath] = resolved.read_text().splitlines(keepends=True)
544-
except OSError:
554+
except (OSError, ValueError):
545555
return None
546556
lines = file_cache[mpath]
547557
if not (1 <= line_no <= len(lines)):
@@ -656,7 +666,7 @@ async def read(
656666
Returns:
657667
Match with .text, .numbered, .path, .start, .end.
658668
"""
659-
resolved = (self.cwd / path).resolve()
669+
resolved = self._resolve_path(path)
660670
content = resolved.read_text()
661671
all_lines = content.splitlines(keepends=True)
662672
total = len(all_lines)
@@ -698,7 +708,7 @@ async def replace(
698708
"""
699709
if isinstance(target, Match):
700710
new_text = old_or_new
701-
resolved = (self.cwd / target.path).resolve()
711+
resolved = self._resolve_path(target.path)
702712
content = resolved.read_text()
703713
all_lines = content.splitlines(keepends=True)
704714

@@ -724,7 +734,7 @@ async def replace(
724734
"Did you mean replace(match, new_text)?"
725735
)
726736
old_text = old_or_new
727-
resolved = (self.cwd / target).resolve()
737+
resolved = self._resolve_path(target)
728738
content = resolved.read_text()
729739

730740
count = content.count(old_text)
@@ -762,7 +772,7 @@ async def write_file(
762772
path: File path (relative to cwd).
763773
content: Full file content.
764774
"""
765-
resolved = (self.cwd / path).resolve()
775+
resolved = self._resolve_path(path)
766776
resolved.parent.mkdir(parents=True, exist_ok=True)
767777
resolved.write_text(content)
768778
line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)

tests/tools/test_shell_tools_modern_behavior.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import pytest
1111

12-
from nooa.tools.shell_tools import ShellTools
12+
from nooa.tools.shell_tools import Match, ShellTools
1313

1414

1515
@pytest.fixture
@@ -71,6 +71,23 @@ async def test_write_file_is_overwrite(sh, tmp_path):
7171
assert (tmp_path / "f.txt").read_text() == "new"
7272

7373

74+
@pytest.mark.asyncio
75+
async def test_file_operations_reject_paths_outside_cwd(sh, tmp_path):
76+
outside = tmp_path.parent / f"{tmp_path.name}-outside.txt"
77+
outside.write_text("secret")
78+
79+
with pytest.raises(ValueError, match="escapes ShellTools cwd"):
80+
await sh.read(f"../{outside.name}")
81+
with pytest.raises(ValueError, match="escapes ShellTools cwd"):
82+
await sh.replace(f"../{outside.name}", "secret", "changed")
83+
with pytest.raises(ValueError, match="escapes ShellTools cwd"):
84+
await sh.replace(Match(f"../{outside.name}", 1, 1, "secret"), "changed")
85+
with pytest.raises(ValueError, match="escapes ShellTools cwd"):
86+
await sh.write_file(str(outside), "overwritten")
87+
88+
assert outside.read_text() == "secret"
89+
90+
7491
@pytest.mark.asyncio
7592
async def test_close_terminates_underlying_bash_session(sh):
7693
"""Verify close() terminates BashSession and the shell lazily restarts."""

0 commit comments

Comments
 (0)