fix(exec): parse shell path boundaries safely

Co-authored-by: santhreal <64453045+santhreal@users.noreply.github.com>
This commit is contained in:
Xubin Ren
2026-08-13 01:53:38 +09:00
co-authored by santhreal
parent 001a7492c2
commit 01c7323d74
2 changed files with 263 additions and 11 deletions
+155
View File
@@ -521,6 +521,161 @@ def test_exec_blocks_outside_paths_with_redirection_and_delimiters(tmp_path):
assert "path outside working dir" in result
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink and quoting semantics")
@pytest.mark.parametrize("quoted", [True, False])
def test_exec_does_not_truncate_parentheses_in_symlink_paths(tmp_path, quoted):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
(outside / "secret.txt").write_text("secret")
link = workspace / "linked)dir"
link.symlink_to(outside, target_is_directory=True)
escaped_link = str(link).replace(")", r"\)")
rendered = f'"{link}/secret.txt"' if quoted else f"{escaped_link}/secret.txt"
command = f"cat {rendered}"
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert f"{link}/secret.txt" in tool._extract_absolute_paths(command)
result = tool._guard_command(command, str(workspace), workspace_root=str(workspace))
assert result is not None
assert "path outside working dir" in result
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX command substitution semantics")
def test_exec_checks_leaf_symlink_inside_command_substitution(tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
link = workspace / "secret-link"
link.symlink_to(outside, target_is_directory=True)
command = f'cat "$(printf %s {link})"'
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert str(link) in tool._extract_absolute_paths(command)
result = tool._guard_command(command, str(workspace), workspace_root=str(workspace))
assert result is not None
assert "path outside working dir" in result
@pytest.mark.parametrize(
("command", "not_a_posix_path"),
[
("curl https://example.com/outside/file", "/outside/file"),
("curl 'https://example.com/?next=/etc/passwd'", "/etc/passwd"),
("curl --url=https://example.com/?next=/etc/passwd", "/etc/passwd"),
("scp host:/etc/passwd .", "/etc/passwd"),
("echo C:/Windows/System32", "/Windows/System32"),
],
)
def test_exec_does_not_misclassify_nonlocal_slash_strings(command, not_a_posix_path):
assert not_a_posix_path not in ExecTool._extract_absolute_paths(command)
def test_exec_extracts_quoted_path_with_shell_punctuation():
path = "/tmp/a file)/with, punctuation"
assert ExecTool._extract_absolute_paths(f'cat "{path}"') == [path]
@pytest.mark.parametrize("uri", ["file:///etc/passwd", "file://localhost/%65tc/passwd"])
def test_exec_extracts_local_file_uri(uri):
assert "/etc/passwd" in ExecTool._extract_absolute_paths(f"curl {uri}")
def test_exec_blocks_file_uri_outside_workspace(tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside file.txt"
workspace.mkdir()
outside.write_text("secret")
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
result = tool._guard_command(
f"curl {outside.as_uri()}",
str(workspace),
workspace_root=str(workspace),
)
assert result is not None
assert "path outside working dir" in result
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX command substitution semantics")
def test_exec_checks_file_uri_leaf_symlink_inside_command_substitution(tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
link = workspace / "secret-link"
link.symlink_to(outside, target_is_directory=True)
command = f'curl "$(printf file://{link})"'
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert str(link) in tool._extract_absolute_paths(command)
result = tool._guard_command(command, str(workspace), workspace_root=str(workspace))
assert result is not None
assert "path outside working dir" in result
def test_exec_keeps_quoted_parenthesis_path_inside_workspace_allowed(tmp_path):
workspace = tmp_path / "workspace"
inside = workspace / "linked)dir" / "file.txt"
inside.parent.mkdir(parents=True)
inside.write_text("safe")
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert tool._guard_command(
f'cat "{inside}"',
str(workspace),
workspace_root=str(workspace),
) is None
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink and assignment semantics")
def test_exec_keeps_quoted_assignment_punctuation_inside_workspace_allowed(tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
(workspace / "linked").symlink_to(outside, target_is_directory=True)
inside = workspace / "linked;dir" / "file.txt"
inside.parent.mkdir()
inside.write_text("safe")
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert tool._guard_command(
f'x="{inside}"; cat "$x"',
str(workspace),
workspace_root=str(workspace),
) is None
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell command-string semantics")
def test_exec_recursively_checks_compact_shell_command_string(tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
link = workspace / "secret-link"
link.symlink_to(outside, target_is_directory=True)
command = f'sh -c "x={link};cat \\"$x\\""'
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
assert str(link) in tool._extract_absolute_paths(command)
result = tool._guard_command(command, str(workspace), workspace_root=str(workspace))
assert result is not None
assert "path outside working dir" in result
def test_exec_malformed_quote_still_extracts_path():
assert "/etc/passwd" in ExecTool._extract_absolute_paths('cat "/etc/passwd')
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX double-slash path semantics")
@pytest.mark.parametrize("path", ["//etc/passwd", "///etc/passwd"])
def test_exec_blocks_double_slash_absolute_paths(tmp_path, path):