diff --git a/.agent_harness/command_guard_core.py b/.agent_harness/command_guard_core.py index 393ca31487..ceda151242 100644 --- a/.agent_harness/command_guard_core.py +++ b/.agent_harness/command_guard_core.py @@ -33,9 +33,109 @@ _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"} +_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", @@ -630,22 +730,77 @@ 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 _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: @@ -1062,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 @@ -1152,9 +1327,37 @@ 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": + 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 + 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) @@ -1179,6 +1382,23 @@ 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 not segment or _token_command_name(segment[0]) != "git": + continue + subcommand, aliases = _git_subcommand_and_aliases(segment[1:]) + if subcommand == "commit": + return True + 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 + + def _is_relative_to(path: Path, parent: Path) -> bool: try: path.relative_to(parent) @@ -1189,7 +1409,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 +1423,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): @@ -1241,29 +1466,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_codex_hooks.py b/.agent_harness/tests/test_codex_hooks.py new file mode 100644 index 0000000000..22b9c1020c --- /dev/null +++ b/.agent_harness/tests/test_codex_hooks.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +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 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") + 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) + + 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 b80c63128c..46fd4b52a2 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(): @@ -375,6 +375,33 @@ 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_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) @@ -385,6 +412,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) @@ -531,10 +580,41 @@ 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) + + 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", + "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", + "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", + ): + 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", + "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)) 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 d7ffbb224f..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 @@ -33,17 +32,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 +78,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: @@ -100,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/.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 理由を返します。 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 内に収まる場合だけ実行を許可します。 diff --git a/changelog.d/unreleased/3478.fixed.md b/changelog.d/unreleased/3478.fixed.md new file mode 100644 index 0000000000..cd9eaab855 --- /dev/null +++ b/changelog.d/unreleased/3478.fixed.md @@ -0,0 +1,19 @@ +--- +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 + +- **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 します。