From 1b9d82d8533f95fb14fb486eafc29eb094617f32 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:07:21 +0900 Subject: [PATCH 1/2] fix(sandbox): match the Codex plugin script under any numeric version Co-Authored-By: Claude Opus 5.5 --- agents/claude/settings.json | 2 +- agents/hooks/guard_command.py | 20 ++++- tests/agents/claude/test_settings.py | 8 +- tests/agents/hooks/test_forbidden_command.py | 95 ++++++++++++++++---- 4 files changed, 101 insertions(+), 24 deletions(-) diff --git a/agents/claude/settings.json b/agents/claude/settings.json index 8ba725dc..2a0bb157 100644 --- a/agents/claude/settings.json +++ b/agents/claude/settings.json @@ -94,7 +94,7 @@ "docker *", "gh *", "herdr *", - "node \"~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs\" *", + "node \"~/.claude/plugins/cache/openai-codex/codex/*/scripts/codex-companion.mjs\" *", "watchman *" ] }, diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index 5c357409..29e4c8c3 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -237,11 +237,16 @@ def cleanup_objection(command: str, commands: tuple[shell_syntax.Command, ...]) def companion_objection(command: str, commands: tuple[shell_syntax.Command, ...]) -> Verdict: """Keep companion execution in the single-call form matched by its sandbox exclusion.""" + cache = "/.claude/plugins/cache/openai-codex/codex/" + roots = tuple(root + cache for root in ("~", str(Path.home()))) companion = any( Path(item.arguments[0]).name == "codex-companion.mjs" or ( Path(item.arguments[0]).name == "node" - and any(Path(argument).name == "codex-companion.mjs" for argument in item.arguments[1:]) + and any( + Path(argument).name == "codex-companion.mjs" or argument.startswith(roots) + for argument in item.arguments[1:] + ) ) for item in commands ) @@ -258,7 +263,18 @@ def companion_objection(command: str, commands: tuple[shell_syntax.Command, ...] except shell_syntax.UnsupportedSyntax: simple = False if simple: - return hook_decision.ALLOW + arguments = commands[0].arguments + script = ( + "(?:" + + "|".join(re.escape(root) for root in roots) + + r")[0-9]+\.[0-9]+\.[0-9]+/scripts/codex-companion\.mjs" + ) + if len(arguments) >= 2 and arguments[0] == "node" and re.fullmatch(script, arguments[1]): + return hook_decision.ALLOW + return hook_decision.deny( + "BLOCKED: forbidden command: expected node followed by the exact plugin cache script " + "at codex/../scripts/codex-companion.mjs." + ) return hook_decision.deny( 'BLOCKED: forbidden command: run it as one command starting with node "/codex-companion.mjs", ' "pass --cwd instead of cd, and no pipes, redirects, &&, or assignments, " diff --git a/tests/agents/claude/test_settings.py b/tests/agents/claude/test_settings.py index 545a8c6a..68d97f52 100644 --- a/tests/agents/claude/test_settings.py +++ b/tests/agents/claude/test_settings.py @@ -115,13 +115,15 @@ def test_dev_servers_and_end_to_end_tests_can_listen_on_local_ports() -> None: def test_codex_plugin_companion_reaches_codex_outside_the_sandbox() -> None: sandbox = json.loads((REPO_ROOT / "agents/claude/settings.json").read_text())["sandbox"] - companion = '"~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs"' + companion = '"~/.claude/plugins/cache/openai-codex/codex/*/scripts/codex-companion.mjs"' assert f"node {companion} *" in sandbox["excludedCommands"] -def test_excluded_commands_cannot_be_redirected_to_another_program_path() -> None: +def test_only_the_guarded_companion_path_has_an_internal_wildcard() -> None: sandbox = json.loads((REPO_ROOT / "agents/claude/settings.json").read_text())["sandbox"] - assert [command for command in sandbox["excludedCommands"] if "*" in command.removesuffix(" *")] == [] + assert [command for command in sandbox["excludedCommands"] if "*" in command.removesuffix(" *")] == [ + 'node "~/.claude/plugins/cache/openai-codex/codex/*/scripts/codex-companion.mjs" *' + ] def test_sandboxed_commands_cannot_read_the_codex_login() -> None: diff --git a/tests/agents/hooks/test_forbidden_command.py b/tests/agents/hooks/test_forbidden_command.py index 2999dc30..816c743e 100644 --- a/tests/agents/hooks/test_forbidden_command.py +++ b/tests/agents/hooks/test_forbidden_command.py @@ -16,14 +16,14 @@ @pytest.mark.parametrize( "command", [ - 'cd /tmp/project && node "/plugin path/codex-companion.mjs" --task review', - 'node "/plugin path/codex-companion.mjs" --task review; echo done', - 'node "/plugin path/codex-companion.mjs" --task review | tee /tmp/log', - 'node "/plugin path/codex-companion.mjs" --task review > /tmp/log', - 'env A=1 node "/plugin path/codex-companion.mjs" --task review', - 'A=1 node "/plugin path/codex-companion.mjs" --task review', - 'cd /tmp/project && "/plugin path/codex-companion.mjs" --task review', - 'echo ready; timeout 30 node "/plugin path/codex-companion.mjs" --task review', + 'cd /tmp/project && node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --task review', + 'node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --task review; echo done', + 'node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --task review | tee /tmp/log', + 'node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --task review > /tmp/log', + 'env A=1 node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --task review', + 'A=1 node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --task review', + 'cd /tmp/project && "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --task review', + 'echo ready; timeout 30 node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --task review', ], ) def test_companion_requires_one_simple_command(command: str) -> None: @@ -38,10 +38,10 @@ def test_companion_requires_one_simple_command(command: str) -> None: @pytest.mark.parametrize( "command", [ - 'node "/plugin path/codex-companion.mjs" --task "review $HOME"', - 'node "/plugin path/codex-companion.mjs" --task $TASK', - 'node "/plugin path/codex-companion.mjs" --focus "review `pwd`"', - 'node "/plugin path/codex-companion.mjs" --task "review $(pwd)"', + 'node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --task "review $HOME"', + 'node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --task $TASK', + 'node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --focus "review `pwd`"', + 'node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --task "review $(pwd)"', ], ) def test_companion_rejects_expansions_outside_single_quotes(command: str) -> None: @@ -58,7 +58,7 @@ def test_companion_is_denied_when_its_simple_command_shape_cannot_be_parsed(mock autospec=True, side_effect=guard.shell_syntax.UnsupportedSyntax("parser unavailable"), ) - command = 'node "/plugin path/codex-companion.mjs" --cwd /tmp/project --task "確認"' + command = 'node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --cwd /tmp/project --task "確認"' assert guard.decide("forbidden", {"tool_input": {"command": command}}).decision == "deny" @@ -66,11 +66,11 @@ def test_companion_is_denied_when_its_simple_command_shape_cannot_be_parsed(mock @pytest.mark.parametrize( "command", [ - 'node "/plugin path/codex-companion.mjs" --task review', - 'node "/plugin path/codex-companion.mjs" --cwd /tmp/project --task "日本語のタスクを確認"', - "node \"/plugin path/codex-companion.mjs\" --task 'review $HOME'", - 'node "/plugin path/codex-companion.mjs" --cwd /tmp/project --task "引用符と && | > は本文" --background', - 'echo "/plugin path/codex-companion.mjs" | tee /tmp/log', + 'node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --task review', + 'node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --cwd /tmp/project --task "日本語のタスクを確認"', + "node \"~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs\" --task 'review $HOME'", + 'node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --cwd /tmp/project --task "引用符と && | > は本文" --background', + 'echo "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" | tee /tmp/log', 'node "/plugin path/other.mjs" > /tmp/log', ], ) @@ -78,6 +78,65 @@ def test_simple_companion_calls_and_unrelated_commands_stay_allowed(kind: str, c assert guard.decide(kind, {"tool_input": {"command": command}}).decision == "allow" +@pytest.mark.parametrize("root", ["~", str(Path.home())]) +@pytest.mark.parametrize("version", ["0.0.0", "1.0.3", "12.345.6789"]) +def test_companion_accepts_numeric_versions_in_the_plugin_cache(root: str, version: str) -> None: + script = f"{root}/.claude/plugins/cache/openai-codex/codex/{version}/scripts/codex-companion.mjs" + command = f'node "{script}" task --cwd /tmp/project "review"' + assert guard.decide("forbidden", {"tool_input": {"command": command}}).decision == "allow" + + +def test_companion_uses_the_current_home_directory(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + home = tmp_path / "home.with[pattern]" + monkeypatch.setenv("HOME", str(home)) + script = f"{home}/.claude/plugins/cache/openai-codex/codex/1.2.3/scripts/codex-companion.mjs" + command = f'node "{script}" task "review"' + assert guard.decide("forbidden", {"tool_input": {"command": command}}).decision == "allow" + other_home = str(home).replace("home.with[pattern]", "homeXwithp") + command = command.replace(str(home), other_home) + assert guard.decide("forbidden", {"tool_input": {"command": command}}).decision == "deny" + + +@pytest.mark.parametrize("root", ["~", str(Path.home())]) +@pytest.mark.parametrize( + "suffix", + [ + "../1.0.3/scripts/codex-companion.mjs", + "1.0.3/../1.0.3/scripts/codex-companion.mjs", + "1.0.3/scripts/../../codex-companion.mjs", + "1.0.3/other/codex-companion.mjs", + "1.0.3/scripts/other.mjs", + "1.0.3/scripts/nested/codex-companion.mjs", + "v1.0.3/scripts/codex-companion.mjs", + "1.0.3-beta/scripts/codex-companion.mjs", + "1.0/scripts/codex-companion.mjs", + "1.0.3.4/scripts/codex-companion.mjs", + "1x0x3/scripts/codex-companion.mjs", + "\uff11.\uff10.\uff13/scripts/codex-companion.mjs", + "*/scripts/codex-companion.mjs", + "1.0.3//scripts/codex-companion.mjs", + "1.0.3/scripts/./codex-companion.mjs", + "1.0.3/scripts/codex-companion.mjs/", + ], +) +def test_companion_rejects_paths_outside_the_exact_versioned_script(root: str, suffix: str) -> None: + command = f'node "{root}/.claude/plugins/cache/openai-codex/codex/{suffix}" task "review"' + assert guard.decide("forbidden", {"tool_input": {"command": command}}).decision == "deny" + + +@pytest.mark.parametrize( + "command", + [ + 'node "/tmp/codex-companion.mjs" task "review"', + 'node "~/.claude/plugins/cache/other/codex/1.0.3/scripts/codex-companion.mjs" task "review"', + 'node --require /tmp/inject.js "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs"', + '"~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" task "review"', + ], +) +def test_companion_requires_node_followed_by_the_real_plugin_script(command: str) -> None: + assert guard.decide("forbidden", {"tool_input": {"command": command}}).decision == "deny" + + @pytest.mark.parametrize( "command,status,reason", [ From 82625f062957d3047949a9a5b8127cee0e08f1e6 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:12:19 +0900 Subject: [PATCH 2/2] fix(sandbox): reject a quoted literal tilde path to the Codex plugin script A quoted ~ is not expanded by the shell, so node resolves it relative to the writable cwd and would run a planted script outside the sandbox. Co-Authored-By: Claude Opus 5.5 --- agents/hooks/guard_command.py | 6 +---- tests/agents/hooks/test_forbidden_command.py | 23 +++++++++++++------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/agents/hooks/guard_command.py b/agents/hooks/guard_command.py index 29e4c8c3..95e74348 100755 --- a/agents/hooks/guard_command.py +++ b/agents/hooks/guard_command.py @@ -264,11 +264,7 @@ def companion_objection(command: str, commands: tuple[shell_syntax.Command, ...] simple = False if simple: arguments = commands[0].arguments - script = ( - "(?:" - + "|".join(re.escape(root) for root in roots) - + r")[0-9]+\.[0-9]+\.[0-9]+/scripts/codex-companion\.mjs" - ) + script = re.escape(str(Path.home()) + cache) + r"[0-9]+\.[0-9]+\.[0-9]+/scripts/codex-companion\.mjs" if len(arguments) >= 2 and arguments[0] == "node" and re.fullmatch(script, arguments[1]): return hook_decision.ALLOW return hook_decision.deny( diff --git a/tests/agents/hooks/test_forbidden_command.py b/tests/agents/hooks/test_forbidden_command.py index 816c743e..5d6edc03 100644 --- a/tests/agents/hooks/test_forbidden_command.py +++ b/tests/agents/hooks/test_forbidden_command.py @@ -66,10 +66,10 @@ def test_companion_is_denied_when_its_simple_command_shape_cannot_be_parsed(mock @pytest.mark.parametrize( "command", [ - 'node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --task review', - 'node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --cwd /tmp/project --task "日本語のタスクを確認"', - "node \"~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs\" --task 'review $HOME'", - 'node "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --cwd /tmp/project --task "引用符と && | > は本文" --background', + f'node "{Path.home()}/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --task review', + f'node "{Path.home()}/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --cwd /tmp/project --task "日本語のタスクを確認"', + f"node \"{Path.home()}/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs\" --task 'review $HOME'", + f'node "{Path.home()}/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" --cwd /tmp/project --task "引用符と && | > は本文" --background', 'echo "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" | tee /tmp/log', 'node "/plugin path/other.mjs" > /tmp/log', ], @@ -78,11 +78,18 @@ def test_simple_companion_calls_and_unrelated_commands_stay_allowed(kind: str, c assert guard.decide(kind, {"tool_input": {"command": command}}).decision == "allow" -@pytest.mark.parametrize("root", ["~", str(Path.home())]) +@pytest.mark.parametrize("quote", ['"', "'"]) +def test_companion_rejects_quoted_literal_tilde_paths(quote: str) -> None: + script = "~/.claude/plugins/cache/openai-codex/codex/1.0.3/scripts/codex-companion.mjs" + command = f"node {quote}{script}{quote} task --cwd /tmp/project review" + assert guard.decide("forbidden", {"tool_input": {"command": command}}).decision == "deny" + + +@pytest.mark.parametrize("quote", ['"', "'"]) @pytest.mark.parametrize("version", ["0.0.0", "1.0.3", "12.345.6789"]) -def test_companion_accepts_numeric_versions_in_the_plugin_cache(root: str, version: str) -> None: - script = f"{root}/.claude/plugins/cache/openai-codex/codex/{version}/scripts/codex-companion.mjs" - command = f'node "{script}" task --cwd /tmp/project "review"' +def test_companion_accepts_numeric_versions_in_the_plugin_cache(quote: str, version: str) -> None: + script = f"{Path.home()}/.claude/plugins/cache/openai-codex/codex/{version}/scripts/codex-companion.mjs" + command = f'node {quote}{script}{quote} task --cwd /tmp/project "review"' assert guard.decide("forbidden", {"tool_input": {"command": command}}).decision == "allow"