Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agents/claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 *"
]
},
Expand Down
16 changes: 14 additions & 2 deletions agents/hooks/guard_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -258,7 +263,14 @@ 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 = 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(
"BLOCKED: forbidden command: expected node followed by the exact plugin cache script "
"at codex/<digits>.<digits>.<digits>/scripts/codex-companion.mjs."
)
return hook_decision.deny(
'BLOCKED: forbidden command: run it as one command starting with node "<plugin path>/codex-companion.mjs", '
"pass --cwd <dir> instead of cd, and no pipes, redirects, &&, or assignments, "
Expand Down
8 changes: 5 additions & 3 deletions tests/agents/claude/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
102 changes: 84 additions & 18 deletions tests/agents/hooks/test_forbidden_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -58,26 +58,92 @@ 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"


@pytest.mark.parametrize("kind", ["allowed", "forbidden"])
@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',
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',
],
)
def test_simple_companion_calls_and_unrelated_commands_stay_allowed(kind: str, command: str) -> None:
assert guard.decide(kind, {"tool_input": {"command": command}}).decision == "allow"


@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(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"


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",
[
Expand Down
Loading