From 8bcd66fc16baed891f6d6b80fd61fea034a7167d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 13 Jun 2026 19:04:04 +0900 Subject: [PATCH 1/8] Fail closed Codex hook diagnostics (#3476) --- .agent_harness/tests/test_codex_hooks.py | 61 ++++++++++++++++++++++++ .codex/hooks/bash_guard.py | 48 ++++++++++--------- .codex/hooks/permission_request_guard.py | 52 ++++++++++---------- changelog.d/unreleased/3476.fixed.md | 17 +++++++ 4 files changed, 130 insertions(+), 48 deletions(-) create mode 100644 .agent_harness/tests/test_codex_hooks.py create mode 100644 changelog.d/unreleased/3476.fixed.md diff --git a/.agent_harness/tests/test_codex_hooks.py b/.agent_harness/tests/test_codex_hooks.py new file mode 100644 index 0000000000..914ecb8edd --- /dev/null +++ b/.agent_harness/tests/test_codex_hooks.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from unittest import TestCase + + +class CodexHookAdapterTests(TestCase): + @property + def repo_root(self) -> Path: + return Path(__file__).resolve().parents[2] + + def run_hook(self, relative_path: str, payload: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(self.repo_root / relative_path)], + input=payload, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=10, + check=False, + ) + + def denial_reason(self, relative_path: str, output: str) -> str: + if not output: + self.fail(f"{relative_path} did not emit hook JSON") + data = json.loads(output) + hook_output = data["hookSpecificOutput"] + if relative_path.endswith("permission_request_guard.py"): + return hook_output["decision"]["message"] + return hook_output["permissionDecisionReason"] + + def test_malformed_payloads_fail_closed_with_parse_diagnostics(self) -> None: + for hook in (".codex/hooks/bash_guard.py", ".codex/hooks/permission_request_guard.py"): + with self.subTest(hook=hook): + proc = self.run_hook(hook, "{") + + self.assertIn("failed to parse Codex hook input; failing closed", self.denial_reason(hook, proc.stdout)) + + def test_missing_command_fails_closed_with_command_diagnostics(self) -> None: + payload = json.dumps({"cwd": str(self.repo_root), "tool_input": {}}) + + for hook in (".codex/hooks/bash_guard.py", ".codex/hooks/permission_request_guard.py"): + with self.subTest(hook=hook): + proc = self.run_hook(hook, payload) + + self.assertIn("Bash command missing from hook input; failing closed", self.denial_reason(hook, proc.stdout)) + + def test_git_root_resolution_failures_fail_closed_with_root_diagnostics(self) -> None: + missing_cwd = self.repo_root / ".agent_harness" / "__missing_codex_hook_cwd__" + payload = json.dumps({"cwd": str(missing_cwd), "tool_input": {"command": "echo ok"}}) + + for hook in (".codex/hooks/bash_guard.py", ".codex/hooks/permission_request_guard.py"): + with self.subTest(hook=hook): + proc = self.run_hook(hook, payload) + + reason = self.denial_reason(hook, proc.stdout) + self.assertIn("could not resolve git project root", reason) + self.assertIn("failing closed", reason) diff --git a/.codex/hooks/bash_guard.py b/.codex/hooks/bash_guard.py index d7ffbb224f..7d298fd098 100644 --- a/.codex/hooks/bash_guard.py +++ b/.codex/hooks/bash_guard.py @@ -33,17 +33,35 @@ def load_core(): core = load_core() +def deny(reason: str) -> None: + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + }, + ensure_ascii=False, + ) + ) + sys.exit(2) + + def load_payload() -> dict: try: return json.load(sys.stdin) - except Exception: - return {} + except Exception as exc: + deny(f"failed to parse Codex hook input; failing closed: {exc}") def get_command(payload: dict) -> str: tool_input = payload.get("tool_input") or {} command = tool_input.get("command") - return command if isinstance(command, str) else "" + if not isinstance(command, str): + deny("Bash command missing from hook input; failing closed") + return command def resolve_project_root(cwd: Path) -> Path: @@ -61,26 +79,10 @@ def resolve_project_root(cwd: Path) -> Path: output = (proc.stdout or "").strip() if output: return Path(output).resolve() - except Exception: - pass - - return cwd.resolve() - - -def deny(reason: str) -> None: - print( - json.dumps( - { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": reason, - } - }, - ensure_ascii=False, - ) - ) - sys.exit(2) + details = (proc.stderr or proc.stdout or f"exit {proc.returncode}").strip() + deny(f"could not resolve git project root from {cwd}; failing closed: {details}") + except Exception as exc: + deny(f"could not resolve git project root from {cwd}; failing closed: {exc}") def main() -> None: diff --git a/.codex/hooks/permission_request_guard.py b/.codex/hooks/permission_request_guard.py index 5704f168d0..b2b674f413 100644 --- a/.codex/hooks/permission_request_guard.py +++ b/.codex/hooks/permission_request_guard.py @@ -26,17 +26,37 @@ def load_core(): core = load_core() +def deny(reason: str) -> None: + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": { + "behavior": "deny", + "message": f"Blocked by CodeIndex guard: {reason}", + }, + } + }, + ensure_ascii=False, + ) + ) + sys.exit(0) + + def load_payload() -> dict: try: return json.load(sys.stdin) - except Exception: - return {} + except Exception as exc: + deny(f"failed to parse Codex hook input; failing closed: {exc}") def get_command(payload: dict) -> str: tool_input = payload.get("tool_input") or {} command = tool_input.get("command") - return command if isinstance(command, str) else "" + if not isinstance(command, str): + deny("Bash command missing from hook input; failing closed") + return command def resolve_project_root(cwd: Path) -> Path: @@ -54,28 +74,10 @@ def resolve_project_root(cwd: Path) -> Path: output = (proc.stdout or "").strip() if output: return Path(output).resolve() - except Exception: - pass - - return cwd.resolve() - - -def deny(reason: str) -> None: - print( - json.dumps( - { - "hookSpecificOutput": { - "hookEventName": "PermissionRequest", - "decision": { - "behavior": "deny", - "message": f"Blocked by CodeIndex guard: {reason}", - }, - } - }, - ensure_ascii=False, - ) - ) - sys.exit(0) + details = (proc.stderr or proc.stdout or f"exit {proc.returncode}").strip() + deny(f"could not resolve git project root from {cwd}; failing closed: {details}") + except Exception as exc: + deny(f"could not resolve git project root from {cwd}; failing closed: {exc}") def main() -> None: diff --git a/changelog.d/unreleased/3476.fixed.md b/changelog.d/unreleased/3476.fixed.md new file mode 100644 index 0000000000..95f790cf1b --- /dev/null +++ b/changelog.d/unreleased/3476.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3476 +affected: + - .codex/hooks/bash_guard.py + - .codex/hooks/permission_request_guard.py + - .agent_harness/tests/test_codex_hooks.py +--- + +## English + +- **Codex command guards now fail closed with explicit hook diagnostics (#3476)** - malformed hook payloads, missing Bash commands, and unresolved Git project roots now return clear deny reasons instead of falling through to generic guard behavior. + +## 日本語 + +- **Codex command guard が明示的な hook 診断付きで fail closed するようになりました (#3476)** - 不正な hook payload、欠落した Bash command、Git project root の解決失敗は、汎用 guard 挙動へ流れず明確な deny 理由を返します。 From c8bb3993d5e391c152a111e81091a1b3df40d557 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 13 Jun 2026 19:06:59 +0900 Subject: [PATCH 2/8] Fail closed command guard script scans (#3477) --- .agent_harness/command_guard_core.py | 9 ++++++-- .../tests/test_command_guard_core.py | 22 +++++++++++++++++++ changelog.d/unreleased/3477.fixed.md | 16 ++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3477.fixed.md diff --git a/.agent_harness/command_guard_core.py b/.agent_harness/command_guard_core.py index 393ca31487..2043d140ab 100644 --- a/.agent_harness/command_guard_core.py +++ b/.agent_harness/command_guard_core.py @@ -1189,7 +1189,7 @@ def _is_relative_to(path: Path, parent: Path) -> bool: def check_script_file(path: Path, project_root: Path) -> GuardDecision: if not path.exists(): - return _allow(f"script not found: {path}") + return _deny(f"candidate script not found; failing closed: {path}") if not path.is_file(): return _deny(f"candidate script is not a file: {path}") @@ -1203,9 +1203,14 @@ def check_script_file(path: Path, project_root: Path) -> GuardDecision: return _deny(f"script outside project is blocked: {path}") try: - data = resolved.read_bytes() + with resolved.open("rb") as handle: + data = handle.read(MAX_SCRIPT_SCAN_BYTES + 1) except Exception as exc: return _deny(f"could not inspect script before execution; failing closed: {path}: {exc}") + if len(data) > MAX_SCRIPT_SCAN_BYTES: + return _deny( + f"candidate script exceeds {MAX_SCRIPT_SCAN_BYTES} byte scan limit; failing closed: {path}" + ) text = data.decode("utf-8", errors="ignore") if ANSI_C_QUOTE_RE.search(text): diff --git a/.agent_harness/tests/test_command_guard_core.py b/.agent_harness/tests/test_command_guard_core.py index b80c63128c..ec2c42e541 100644 --- a/.agent_harness/tests/test_command_guard_core.py +++ b/.agent_harness/tests/test_command_guard_core.py @@ -385,6 +385,28 @@ def test_check_script_file_denies_outside_project_root(self) -> None: self.assertFalse(decision.allowed) + def test_check_script_file_denies_missing_candidate_script(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + missing = root / "tools" / "missing.sh" + + decision = core.check_script_file(missing, project_root=root) + + self.assertFalse(decision.allowed) + self.assertIn("candidate script not found", decision.reason) + + def test_check_script_file_denies_scripts_above_scan_byte_limit(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + script = root / "tools" / "large.sh" + script.parent.mkdir(parents=True, exist_ok=True) + script.write_text(" " * (core.MAX_SCRIPT_SCAN_BYTES + 1), encoding="utf-8") + + decision = core.check_script_file(script, project_root=root) + + self.assertFalse(decision.allowed) + self.assertIn("scan limit", decision.reason) + def test_check_script_file_denies_forbidden_content(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/changelog.d/unreleased/3477.fixed.md b/changelog.d/unreleased/3477.fixed.md new file mode 100644 index 0000000000..d50aae9843 --- /dev/null +++ b/changelog.d/unreleased/3477.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3477 +affected: + - .agent_harness/command_guard_core.py + - .agent_harness/tests/test_command_guard_core.py +--- + +## English + +- **Command guard script inspection now fails closed for missing or oversized scripts (#3477)** - candidate script paths must exist and fit within the bounded scan window before execution is allowed. + +## 日本語 + +- **Command guard の script inspection が missing script と oversized script で fail closed するようになりました (#3477)** - 候補 script path は存在し、上限付き scan window 内に収まる場合だけ実行を許可します。 From f4052fd2e41fb12141d7d6e521f445618c93811c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 13 Jun 2026 19:09:38 +0900 Subject: [PATCH 3/8] Fail closed staged secret checks without gitleaks (#3478) --- .agent_harness/command_guard_core.py | 28 ++----------------- .../tests/test_command_guard_core.py | 10 +++---- changelog.d/unreleased/3478.fixed.md | 16 +++++++++++ 3 files changed, 24 insertions(+), 30 deletions(-) create mode 100644 changelog.d/unreleased/3478.fixed.md diff --git a/.agent_harness/command_guard_core.py b/.agent_harness/command_guard_core.py index 2043d140ab..8b8ab3ea1b 100644 --- a/.agent_harness/command_guard_core.py +++ b/.agent_harness/command_guard_core.py @@ -1246,29 +1246,7 @@ def staged_secret_check(cwd: Path) -> GuardDecision: return _deny("gitleaks blocked this commit:\n" + output) return _allow("gitleaks passed") - try: - proc = subprocess.run( - ["git", "diff", "--cached", "--unified=0", "--no-ext-diff"], - cwd=str(cwd), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=30, - check=False, - ) - except Exception as exc: - return _deny(f"could not inspect staged diff for secrets; failing closed: {exc}") - if proc.returncode != 0: - return _deny("could not inspect staged diff for secrets; install gitleaks or fix git diff") - - added_lines = "\n".join( - line[1:] - for line in (proc.stdout or "").splitlines() - if line.startswith("+") and not line.startswith("+++") + return _deny( + "gitleaks is unavailable; refusing git commit because the text-only staged diff fallback " + "cannot safely inspect binary or encoded staged content" ) - for pattern, name in _SECRET_PATTERNS: - if pattern.search(added_lines): - return _deny( - f"secret-looking staged content detected before commit: {name}; install gitleaks for better scanning" - ) - return _allow("staged secret scan passed") diff --git a/.agent_harness/tests/test_command_guard_core.py b/.agent_harness/tests/test_command_guard_core.py index ec2c42e541..e911053651 100644 --- a/.agent_harness/tests/test_command_guard_core.py +++ b/.agent_harness/tests/test_command_guard_core.py @@ -5,7 +5,7 @@ import sys from pathlib import Path from unittest import TestCase -from unittest.mock import Mock, patch +from unittest.mock import patch def load_core(): @@ -553,10 +553,10 @@ def test_env_chdir_wrapped_script_execution_is_denied(self) -> None: self.assertFalse(decision.allowed) - def test_staged_secret_check_uses_git_diff_fallback(self) -> None: - fake_proc = Mock(returncode=0, stdout="+ api_key = 'sk-abcdefghijklmnopqrstuvwx123456'\n", stderr="") - - with patch.object(core.shutil, "which", return_value=None), patch.object(core.subprocess, "run", return_value=fake_proc): + def test_staged_secret_check_denies_when_gitleaks_is_unavailable(self) -> None: + with patch.object(core.shutil, "which", return_value=None): decision = core.staged_secret_check(Path("/tmp")) self.assertFalse(decision.allowed) + self.assertIn("gitleaks is unavailable", decision.reason) + self.assertIn("text-only staged diff fallback", decision.reason) diff --git a/changelog.d/unreleased/3478.fixed.md b/changelog.d/unreleased/3478.fixed.md new file mode 100644 index 0000000000..18972861eb --- /dev/null +++ b/changelog.d/unreleased/3478.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3478 +affected: + - .agent_harness/command_guard_core.py + - .agent_harness/tests/test_command_guard_core.py +--- + +## English + +- **Command guard secret checks now fail closed when gitleaks is unavailable (#3478)** - git commits are denied instead of passing through the text-only staged diff fallback that cannot safely cover binary or encoded staged content. + +## 日本語 + +- **gitleaks が利用できない場合の command guard secret check が fail closed するようになりました (#3478)** - binary や encoded staged content を安全に網羅できない text-only staged diff fallback を成功扱いせず、git commit を deny します。 From 70e132c10a8f562569dd7213b72ac7d8627350a3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 13 Jun 2026 19:20:38 +0900 Subject: [PATCH 4/8] Detect git commit global-option forms (#3478) --- .agent_harness/command_guard_core.py | 9 ++++ .agent_harness/tests/test_codex_hooks.py | 47 +++++++++++++++++++ .../tests/test_command_guard_core.py | 23 +++++++++ .claude/hooks/bash-guard.py | 3 +- .codex/hooks/bash_guard.py | 3 +- changelog.d/unreleased/3478.fixed.md | 3 ++ 6 files changed, 84 insertions(+), 4 deletions(-) diff --git a/.agent_harness/command_guard_core.py b/.agent_harness/command_guard_core.py index 8b8ab3ea1b..b5177006c8 100644 --- a/.agent_harness/command_guard_core.py +++ b/.agent_harness/command_guard_core.py @@ -1179,6 +1179,15 @@ def candidate_script_paths(command: str, cwd: Path) -> list[Path]: return _candidate_script_paths_from_tokens(_split_command(command), cwd) +def command_is_git_commit(command: str) -> bool: + tokens = _expand_env_split_strings(_split_command(command)) + for segment in _token_segments(tokens): + segment = _strip_transparent_script_wrappers(_strip_leading_env_assignments(segment)) + if segment and _token_command_name(segment[0]) == "git" and _git_subcommand(segment[1:]) == "commit": + return True + return False + + def _is_relative_to(path: Path, parent: Path) -> bool: try: path.relative_to(parent) diff --git a/.agent_harness/tests/test_codex_hooks.py b/.agent_harness/tests/test_codex_hooks.py index 914ecb8edd..22b9c1020c 100644 --- a/.agent_harness/tests/test_codex_hooks.py +++ b/.agent_harness/tests/test_codex_hooks.py @@ -1,8 +1,10 @@ from __future__ import annotations import json +import os import subprocess import sys +import tempfile from pathlib import Path from unittest import TestCase @@ -23,6 +25,20 @@ def run_hook(self, relative_path: str, payload: str) -> subprocess.CompletedProc check=False, ) + def run_hook_with_env( + self, relative_path: str, payload: str, env: dict[str, str] + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(self.repo_root / relative_path)], + input=payload, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=10, + check=False, + env=env, + ) + def denial_reason(self, relative_path: str, output: str) -> str: if not output: self.fail(f"{relative_path} did not emit hook JSON") @@ -59,3 +75,34 @@ def test_git_root_resolution_failures_fail_closed_with_root_diagnostics(self) -> reason = self.denial_reason(hook, proc.stdout) self.assertIn("could not resolve git project root", reason) self.assertIn("failing closed", reason) + + def test_codex_bash_guard_secret_check_detects_git_commit_with_global_options(self) -> None: + with tempfile.TemporaryDirectory(dir=self.repo_root / ".agent_harness") as tmp: + bin_dir = Path(tmp) / "bin" + bin_dir.mkdir() + fake_git = bin_dir / "git" + fake_git.write_text( + "#!/bin/sh\n" + "if [ \"$1\" = \"rev-parse\" ]; then\n" + f" printf '%s\\n' '{self.repo_root}'\n" + " exit 0\n" + "fi\n" + "printf 'unexpected git invocation\\n' >&2\n" + "exit 1\n", + encoding="utf-8", + ) + fake_git.chmod(0o700) + + env = dict(os.environ) + env["PATH"] = str(bin_dir) + payload = json.dumps( + { + "cwd": str(self.repo_root), + "tool_input": {"command": "git -c user.name=Codex commit -m test"}, + } + ) + + proc = self.run_hook_with_env(".codex/hooks/bash_guard.py", payload, env) + + reason = self.denial_reason(".codex/hooks/bash_guard.py", proc.stdout) + self.assertIn("gitleaks is unavailable", reason) diff --git a/.agent_harness/tests/test_command_guard_core.py b/.agent_harness/tests/test_command_guard_core.py index e911053651..482ba642a2 100644 --- a/.agent_harness/tests/test_command_guard_core.py +++ b/.agent_harness/tests/test_command_guard_core.py @@ -560,3 +560,26 @@ def test_staged_secret_check_denies_when_gitleaks_is_unavailable(self) -> None: self.assertFalse(decision.allowed) self.assertIn("gitleaks is unavailable", decision.reason) self.assertIn("text-only staged diff fallback", decision.reason) + + def test_command_is_git_commit_detects_global_options_and_wrappers(self) -> None: + for command in ( + "git commit -m test", + "git -c user.name=Codex commit -m test", + "git --no-pager commit -m test", + "/usr/bin/git commit -m test", + "env git -c user.email=codex@example.invalid commit -m test", + "time git commit -m test", + "true && git --git-dir .git commit -m test", + ): + with self.subTest(command=command): + self.assertTrue(core.command_is_git_commit(command)) + + def test_command_is_git_commit_ignores_non_commit_git_commands(self) -> None: + for command in ( + "git status", + "git commit-tree HEAD", + "echo git commit", + "git -c user.name=Codex status", + ): + with self.subTest(command=command): + self.assertFalse(core.command_is_git_commit(command)) diff --git a/.claude/hooks/bash-guard.py b/.claude/hooks/bash-guard.py index 03df247a7c..4fe85ee746 100755 --- a/.claude/hooks/bash-guard.py +++ b/.claude/hooks/bash-guard.py @@ -12,7 +12,6 @@ import importlib.util import json import os -import re import subprocess import sys from pathlib import Path @@ -122,7 +121,7 @@ def main() -> None: if not script_decision.allowed: emit_deny(script_decision.reason) - if re.search(r"(?i)(^|[\s;&|()`])git\s+commit\b", command): + if core.command_is_git_commit(command): commit_decision = core.staged_secret_check(cwd) if not commit_decision.allowed: emit_deny(commit_decision.reason) diff --git a/.codex/hooks/bash_guard.py b/.codex/hooks/bash_guard.py index 7d298fd098..590b6c75c9 100644 --- a/.codex/hooks/bash_guard.py +++ b/.codex/hooks/bash_guard.py @@ -12,7 +12,6 @@ import importlib.util import json import os -import re import subprocess import sys from pathlib import Path @@ -102,7 +101,7 @@ def main() -> None: if not script_decision.allowed: deny(script_decision.reason) - if re.search(r"(?i)(^|[\s;&|()`])git\s+commit\b", command): + if core.command_is_git_commit(command): commit_decision = core.staged_secret_check(cwd) if not commit_decision.allowed: deny(commit_decision.reason) diff --git a/changelog.d/unreleased/3478.fixed.md b/changelog.d/unreleased/3478.fixed.md index 18972861eb..cd9eaab855 100644 --- a/changelog.d/unreleased/3478.fixed.md +++ b/changelog.d/unreleased/3478.fixed.md @@ -3,8 +3,11 @@ category: fixed issues: - 3478 affected: + - .codex/hooks/bash_guard.py + - .claude/hooks/bash-guard.py - .agent_harness/command_guard_core.py - .agent_harness/tests/test_command_guard_core.py + - .agent_harness/tests/test_codex_hooks.py --- ## English From f997b185909890d279c3919dd137630a5be2ee58 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 13 Jun 2026 19:32:44 +0900 Subject: [PATCH 5/8] Detect git commit aliases in guard (#3478) --- .agent_harness/command_guard_core.py | 67 +++++++++++++++++-- .../tests/test_command_guard_core.py | 6 ++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/.agent_harness/command_guard_core.py b/.agent_harness/command_guard_core.py index b5177006c8..c3cbe82e72 100644 --- a/.agent_harness/command_guard_core.py +++ b/.agent_harness/command_guard_core.py @@ -630,22 +630,73 @@ def _tokenized_forbidden_script_reason(text: str) -> str | None: return _tokenized_forbidden_command_reason(text) -def _git_subcommand(args: list[str]) -> str: +def _git_alias_name(config_key: str) -> str | None: + key = config_key.split("=", 1)[0].lower() + if not key.startswith("alias."): + return None + name = key[len("alias.") :] + return name or None + + +def _collect_git_alias_config(config: str, aliases: dict[str, str | None], *, unknown_value: bool = False) -> None: + name = _git_alias_name(config) + if name is None: + return + aliases[name] = None if unknown_value or "=" not in config else config.split("=", 1)[1] + + +def _git_subcommand_and_aliases(args: list[str]) -> tuple[str, dict[str, str | None]]: + aliases: dict[str, str | None] = {} index = 0 options_with_values = {"-c", "-C", "--config-env", "--exec-path", "--git-dir", "--work-tree", "--namespace"} while index < len(args): arg = args[index] if arg in options_with_values: + if index + 1 < len(args): + if arg == "-c": + _collect_git_alias_config(args[index + 1], aliases) + elif arg == "--config-env": + _collect_git_alias_config(args[index + 1], aliases, unknown_value=True) index += 2 continue + if arg.startswith("--config-env="): + _collect_git_alias_config(arg.split("=", 1)[1], aliases, unknown_value=True) + index += 1 + continue if any(arg.startswith(option + "=") for option in options_with_values if option.startswith("--")): index += 1 continue if arg.startswith("-"): index += 1 continue - return arg - return "" + return arg, aliases + return "", aliases + + +def _git_subcommand(args: list[str]) -> str: + return _git_subcommand_and_aliases(args)[0] + + +def _git_alias_targets_commit(value: str | None) -> bool: + if value is None: + return True + + text = value.strip() + if text.startswith("!"): + text = text[1:].strip() + + tokens = _expand_env_split_strings(_split_command(text)) + for segment in _token_segments(tokens): + segment = _strip_transparent_script_wrappers(_strip_leading_env_assignments(segment)) + if not segment: + continue + command_name = _token_command_name(segment[0]) + if command_name == "commit": + return True + if command_name == "git" and _git_subcommand(segment[1:]) == "commit": + return True + + return bool(re.search(r"(?i)(^|[^\w./-])git\s+commit(?=$|[^\w./-])", text)) def _contains_inline_interpreter(command: str) -> bool: @@ -1183,7 +1234,15 @@ def command_is_git_commit(command: str) -> bool: tokens = _expand_env_split_strings(_split_command(command)) for segment in _token_segments(tokens): segment = _strip_transparent_script_wrappers(_strip_leading_env_assignments(segment)) - if segment and _token_command_name(segment[0]) == "git" and _git_subcommand(segment[1:]) == "commit": + if not segment or _token_command_name(segment[0]) != "git": + continue + subcommand, aliases = _git_subcommand_and_aliases(segment[1:]) + if subcommand == "commit": + return True + alias = aliases.get(subcommand.lower()) + if alias is not None and _git_alias_targets_commit(alias): + return True + if subcommand.lower() in aliases and aliases[subcommand.lower()] is None: return True return False diff --git a/.agent_harness/tests/test_command_guard_core.py b/.agent_harness/tests/test_command_guard_core.py index 482ba642a2..223f328a2a 100644 --- a/.agent_harness/tests/test_command_guard_core.py +++ b/.agent_harness/tests/test_command_guard_core.py @@ -568,6 +568,10 @@ def test_command_is_git_commit_detects_global_options_and_wrappers(self) -> None "git --no-pager commit -m test", "/usr/bin/git commit -m test", "env git -c user.email=codex@example.invalid commit -m test", + "git -c alias.ci=commit ci -m test", + "git -c alias.ci='commit --verbose' ci -m test", + "git -c alias.ci='!git commit' ci -m test", + "git --config-env=alias.ci=CI_ALIAS ci -m test", "time git commit -m test", "true && git --git-dir .git commit -m test", ): @@ -580,6 +584,8 @@ def test_command_is_git_commit_ignores_non_commit_git_commands(self) -> None: "git commit-tree HEAD", "echo git commit", "git -c user.name=Codex status", + "git -c alias.ci=status ci", + "git -c alias.ci=commit status", ): with self.subTest(command=command): self.assertFalse(core.command_is_git_commit(command)) From 41e19990d7855c0c49474a1f076c5333c6aee50f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 13 Jun 2026 19:33:11 +0900 Subject: [PATCH 6/8] Preserve python module guard invocations (#3477) --- .agent_harness/command_guard_core.py | 19 +++++++++++++++++-- .../tests/test_command_guard_core.py | 12 ++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.agent_harness/command_guard_core.py b/.agent_harness/command_guard_core.py index c3cbe82e72..92a09ecadd 100644 --- a/.agent_harness/command_guard_core.py +++ b/.agent_harness/command_guard_core.py @@ -1203,9 +1203,24 @@ def _candidate_script_paths_from_tokens(tokens: list[str], cwd: Path) -> list[Pa if first in {"bash", "sh", "zsh", "fish", "python", "python3", "ruby", "perl", "node", "deno", "php"}: if any(token in _INLINE_INTERPRETER_FLAGS for token in tokens[1:]): return result - for token in tokens[1:]: - if token.startswith("-"): + index = 1 + while index < len(tokens): + token = tokens[index] + if first in {"python", "python3"} and token == "-m": + return result + if first in {"python", "python3"} and token in {"-W", "-X"}: + index += 2 + continue + if token == "--": + index += 1 + if index >= len(tokens): + return result + token = tokens[index] + elif token.startswith("-"): + index += 1 continue + if token.startswith("-"): + return result path = _token_path(token, cwd) if path is not None: result.append(path) diff --git a/.agent_harness/tests/test_command_guard_core.py b/.agent_harness/tests/test_command_guard_core.py index 223f328a2a..cba8b61d3e 100644 --- a/.agent_harness/tests/test_command_guard_core.py +++ b/.agent_harness/tests/test_command_guard_core.py @@ -375,6 +375,18 @@ def test_candidate_script_paths_detects_direct_and_interpreter_scripts(self) -> self.assertEqual([script.resolve()], env_split) self.assertEqual([script.resolve()], env_argv0) + def test_candidate_script_paths_ignores_python_module_invocation(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + + for command in ( + "python -m unittest discover -s .agent_harness/tests", + "python3 -I -m unittest .agent_harness.tests.test_command_guard_core", + "env python3 -m pytest .agent_harness/tests", + ): + with self.subTest(command=command): + self.assertEqual([], core.candidate_script_paths(command, cwd=root)) + def test_check_script_file_denies_outside_project_root(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) From 33cb58c49ac03330f7779b56ef254dd1aad8c072 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 13 Jun 2026 19:47:43 +0900 Subject: [PATCH 7/8] Fail closed unknown git commit aliases (#3478) --- .agent_harness/command_guard_core.py | 109 +++++++++++++++++- .../tests/test_command_guard_core.py | 2 + 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/.agent_harness/command_guard_core.py b/.agent_harness/command_guard_core.py index 92a09ecadd..858bafd724 100644 --- a/.agent_harness/command_guard_core.py +++ b/.agent_harness/command_guard_core.py @@ -36,6 +36,103 @@ _UNKNOWN_GLOBAL_OPTION_SUBCOMMAND = "__unknown_global_option__" _HIGH_RISK_UNKNOWN_GLOBAL_OPTION_REASON = "unrecognized global option before high-risk CLI subcommand is blocked" _TRANSPARENT_SCRIPT_WRAPPERS = {"time", "timeout", "gtimeout", "command", "exec", "nice", "nohup"} +_KNOWN_GIT_SUBCOMMANDS = { + "add", + "am", + "archive", + "bisect", + "blame", + "branch", + "bugreport", + "bundle", + "cat-file", + "check-attr", + "check-ignore", + "check-mailmap", + "check-ref-format", + "checkout", + "cherry", + "cherry-pick", + "clean", + "clone", + "column", + "commit-tree", + "config", + "count-objects", + "credential", + "describe", + "diff", + "diff-files", + "diff-index", + "diff-tree", + "difftool", + "fetch", + "filter-branch", + "for-each-ref", + "format-patch", + "fsck", + "gc", + "grep", + "hash-object", + "help", + "index-pack", + "init", + "log", + "ls-files", + "ls-remote", + "maintenance", + "merge", + "merge-base", + "merge-file", + "merge-index", + "merge-tree", + "mergetool", + "mktag", + "mktree", + "mv", + "name-rev", + "notes", + "pack-objects", + "patch-id", + "prune", + "pull", + "push", + "range-diff", + "read-tree", + "rebase", + "reflog", + "remote", + "repack", + "replace", + "request-pull", + "rerere", + "reset", + "restore", + "rev-list", + "rev-parse", + "revert", + "rm", + "shortlog", + "show", + "show-branch", + "show-ref", + "sparse-checkout", + "stash", + "status", + "submodule", + "switch", + "symbolic-ref", + "tag", + "update-index", + "update-ref", + "verify-commit", + "verify-pack", + "verify-tag", + "version", + "whatchanged", + "worktree", + "write-tree", +} _SEARCH_OR_DISCOVERY_COMMANDS = { "grep", "egrep", @@ -699,6 +796,10 @@ def _git_alias_targets_commit(value: str | None) -> bool: return bool(re.search(r"(?i)(^|[^\w./-])git\s+commit(?=$|[^\w./-])", text)) +def _git_subcommand_may_be_alias(subcommand: str) -> bool: + return bool(subcommand) and subcommand.lower() not in _KNOWN_GIT_SUBCOMMANDS + + def _contains_inline_interpreter(command: str) -> bool: tokens = _expand_env_split_strings(_split_command(command)) for index, token in enumerate(tokens): @@ -1254,10 +1355,10 @@ def command_is_git_commit(command: str) -> bool: subcommand, aliases = _git_subcommand_and_aliases(segment[1:]) if subcommand == "commit": return True - alias = aliases.get(subcommand.lower()) - if alias is not None and _git_alias_targets_commit(alias): - return True - if subcommand.lower() in aliases and aliases[subcommand.lower()] is None: + alias_key = subcommand.lower() + if alias_key in aliases: + return _git_alias_targets_commit(aliases[alias_key]) + if _git_subcommand_may_be_alias(subcommand): return True return False diff --git a/.agent_harness/tests/test_command_guard_core.py b/.agent_harness/tests/test_command_guard_core.py index cba8b61d3e..05e95b9794 100644 --- a/.agent_harness/tests/test_command_guard_core.py +++ b/.agent_harness/tests/test_command_guard_core.py @@ -584,6 +584,8 @@ def test_command_is_git_commit_detects_global_options_and_wrappers(self) -> None "git -c alias.ci='commit --verbose' ci -m test", "git -c alias.ci='!git commit' ci -m test", "git --config-env=alias.ci=CI_ALIAS ci -m test", + "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=alias.ci GIT_CONFIG_VALUE_0=commit git ci -m test", + "git ci -m test", "time git commit -m test", "true && git --git-dir .git commit -m test", ): From 0a4235b1291dafdee86417b23f9a8b565663e2ca Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 13 Jun 2026 19:49:26 +0900 Subject: [PATCH 8/8] Scan python module script runners (#3477) --- .agent_harness/command_guard_core.py | 36 +++++++++++++++++++ .../tests/test_command_guard_core.py | 15 ++++++++ 2 files changed, 51 insertions(+) diff --git a/.agent_harness/command_guard_core.py b/.agent_harness/command_guard_core.py index 858bafd724..ceda151242 100644 --- a/.agent_harness/command_guard_core.py +++ b/.agent_harness/command_guard_core.py @@ -33,6 +33,9 @@ _INLINE_INTERPRETERS = {"python", "python3", "ruby", "perl", "node", "deno", "php"} _INLINE_SHELLS = {"bash", "sh", "zsh", "fish"} _INLINE_SHELL_VARIABLES = {"$SHELL", "${SHELL}"} +_PYTHON_MODULES_WITHOUT_SCRIPT_OPERANDS = {"pytest", "unittest"} +_PYTHON_MODULES_WITH_SCRIPT_OPERANDS = {"cProfile", "pdb", "profile", "trace"} +_PYTHON_MODULE_SCRIPT_OPTION_VALUES = {"-m", "-o", "-s", "--file", "--coverdir", "--ignore-dir"} _UNKNOWN_GLOBAL_OPTION_SUBCOMMAND = "__unknown_global_option__" _HIGH_RISK_UNKNOWN_GLOBAL_OPTION_REASON = "unrecognized global option before high-risk CLI subcommand is blocked" _TRANSPARENT_SCRIPT_WRAPPERS = {"time", "timeout", "gtimeout", "command", "exec", "nice", "nohup"} @@ -1214,6 +1217,26 @@ def _command_is_safe_cdidx_mcp_init_smoke(command: str, cwd: Path) -> bool: return bool(match and _token_is_expanded_installed_cdidx(match.group("cdidx"), cwd)) +def _python_module_script_operand(args: list[str], cwd: Path) -> Path | None: + index = 0 + while index < len(args): + token = args[index] + if token == "--": + index += 1 + continue + if token in _PYTHON_MODULE_SCRIPT_OPTION_VALUES: + index += 2 + continue + if any(token.startswith(option + "=") for option in _PYTHON_MODULE_SCRIPT_OPTION_VALUES if option.startswith("--")): + index += 1 + continue + if token.startswith("-"): + index += 1 + continue + return _token_path(token, cwd) + return None + + def should_skip_script_scan(decision: GuardDecision, path: Path, project_root: Path) -> bool: return ( decision.allowed @@ -1308,6 +1331,19 @@ def _candidate_script_paths_from_tokens(tokens: list[str], cwd: Path) -> list[Pa while index < len(tokens): token = tokens[index] if first in {"python", "python3"} and token == "-m": + if index + 1 >= len(tokens): + return result + module = tokens[index + 1] + if module in _PYTHON_MODULES_WITHOUT_SCRIPT_OPERANDS: + return result + if module in _PYTHON_MODULES_WITH_SCRIPT_OPERANDS: + path = _python_module_script_operand(tokens[index + 2 :], cwd) + if path is not None: + result.append(path) + return result + path = _token_path(module, cwd) + if path is not None: + result.append(path) return result if first in {"python", "python3"} and token in {"-W", "-X"}: index += 2 diff --git a/.agent_harness/tests/test_command_guard_core.py b/.agent_harness/tests/test_command_guard_core.py index 05e95b9794..46fd4b52a2 100644 --- a/.agent_harness/tests/test_command_guard_core.py +++ b/.agent_harness/tests/test_command_guard_core.py @@ -387,6 +387,21 @@ def test_candidate_script_paths_ignores_python_module_invocation(self) -> None: with self.subTest(command=command): self.assertEqual([], core.candidate_script_paths(command, cwd=root)) + def test_candidate_script_paths_scans_python_module_script_runners(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + script = root / "tools" / "guard.py" + script.parent.mkdir(parents=True, exist_ok=True) + script.write_text("print('ok')", encoding="utf-8") + + for command in ( + "python3 -m cProfile tools/guard.py", + "python3 -m trace --trace tools/guard.py", + "python3 -m pdb tools/guard.py", + ): + with self.subTest(command=command): + self.assertEqual([script.resolve()], core.candidate_script_paths(command, cwd=root)) + def test_check_script_file_denies_outside_project_root(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp)