diff --git a/tests/skills/tools/test_skill_run.py b/tests/skills/tools/test_skill_run.py index 2dfe7764..92cf9e02 100644 --- a/tests/skills/tools/test_skill_run.py +++ b/tests/skills/tools/test_skill_run.py @@ -87,6 +87,23 @@ def test_split_command_line(self): with pytest.raises(ValueError): _split_command_line("a | b") + @pytest.mark.parametrize( + ("command", "expected"), + [ + ('python3 calc.py --expr "A > 5"', ["python3", "calc.py", "--expr", "A > 5"]), + ("grep -E 'a|b' data.txt", ["grep", "-E", "a|b", "data.txt"]), + ('echo "x;y"', ["echo", "x;y"]), + (r"echo x\&y", ["echo", "x&y"]), + ], + ) + def test_split_command_line_allows_quoted_or_escaped_shell_meta(self, command, expected): + assert _split_command_line(command) == expected + + @pytest.mark.parametrize("meta", [">", "<", "|", ";", "&", "\n", "\r"]) + def test_split_command_line_rejects_unquoted_shell_meta(self, meta): + with pytest.raises(ValueError, match="shell meta character"): + _split_command_line(f"echo before{meta}after") + def test_build_editor_wrapper_script(self): script = _build_editor_wrapper_script("/tmp/file") assert script.startswith("#!/bin/sh") @@ -115,6 +132,20 @@ def test_build_command(self): assert cmd == "bash" assert "-c" in args + def test_build_restricted_command_preserves_quoted_shell_meta_argument(self): + repo = MagicMock() + repo.workspace_runtime = MagicMock() + tool = SkillRunTool(repository=repo, allowed_cmds=["python3"]) + + cmd, args = tool._build_command( + 'python3 calc.py --expr "A > 5"', + "/tmp/ws", + "skills/x", + ) + + assert cmd == "python3" + assert args == ["calc.py", "--expr", "A > 5"] + def test_get_repository(self): repo = MagicMock() repo.workspace_runtime = MagicMock() diff --git a/trpc_agent_sdk/skills/tools/_skill_run.py b/trpc_agent_sdk/skills/tools/_skill_run.py index 3794e8d6..5525e244 100644 --- a/trpc_agent_sdk/skills/tools/_skill_run.py +++ b/trpc_agent_sdk/skills/tools/_skill_run.py @@ -232,11 +232,6 @@ def _split_command_line(cmd: str) -> list[str]: """ if not cmd.strip(): raise ValueError("skill_run: command is empty") - for ch in _DISALLOWED_SHELL_META: - if ch in cmd: - raise ValueError(f"skill_run: shell meta character {ch!r} is not allowed when " - "command restrictions are enabled. Provide a single executable " - "with args only (no redirects/pipes/chaining).") args: list[str] = [] cur: list[str] = [] in_single = False @@ -256,6 +251,10 @@ def _split_command_line(cmd: str) -> list[str]: if not in_single and ch == '"': in_double = not in_double continue + if not in_single and not in_double and ch in _DISALLOWED_SHELL_META: + raise ValueError(f"skill_run: shell meta character {ch!r} is not allowed when " + "command restrictions are enabled. Provide a single executable " + "with args only (no redirects/pipes/chaining).") if not in_single and not in_double and ch in (" ", "\t"): if cur: args.append("".join(cur))