From 96d6717fc599839f8d196c44803891239e70e969 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Mon, 22 Jun 2026 22:50:37 +0000 Subject: [PATCH] Add shared development execpolicy rules --- .codex/rules/development.rules | 75 ++++++++++++++++ codex-rs/docs/execpolicy-host-executables.md | 15 ++++ scripts/format.py | 1 + scripts/setup_dev_execpolicy.py | 92 ++++++++++++++++++++ scripts/test_format.py | 47 ++++++++++ scripts/test_setup_dev_execpolicy.py | 87 ++++++++++++++++++ 6 files changed, 317 insertions(+) create mode 100644 .codex/rules/development.rules create mode 100644 codex-rs/docs/execpolicy-host-executables.md create mode 100644 scripts/setup_dev_execpolicy.py create mode 100644 scripts/test_format.py create mode 100644 scripts/test_setup_dev_execpolicy.py diff --git a/.codex/rules/development.rules b/.codex/rules/development.rules new file mode 100644 index 0000000000..6f99fd49db --- /dev/null +++ b/.codex/rules/development.rules @@ -0,0 +1,75 @@ +# Shared rules only allow commands that are generally safe for every project +# developer. Prompt and denial policies belong in user config so the repository +# does not impose those flows globally. + +prefix_rule( + pattern = [ + "just", + [ + "fmt", + "fmt-check", + "test", + "clippy", + "fix", + "bench", + "bench-smoke", + "bazel-test", + "bazel-clippy", + "bazel-lock-check", + "bazel-lock-update", + "bazel-argument-comment-lint", + "argument-comment-lint", + "build-for-release", + ], + ], + decision = "allow", + justification = """ +Needed: host caches or nested sandbox capabilities. +Safe: fixed non-publishing recipes only operate on the trusted checkout. +""", +) + +prefix_rule( + pattern = ["cargo", ["build", "check", "clippy", "test"]], + decision = "allow", + justification = """ +Needed: Cargo uses host registry, Git, and cache state. +Safe: these commands only build, check, lint, or test the trusted workspace. +""", +) + +prefix_rule( + pattern = ["cargo", "nextest", "run"], + decision = "allow", + justification = """ +Needed: Nextest needs host runtime and nested sandboxes. +Safe: it only runs tests built from the trusted workspace. +""", +) + +prefix_rule( + pattern = ["bazel", ["build", "test", "query", "aquery", "cquery"]], + decision = "allow", + justification = """ +Needed: Bazel uses its host output base and caches. +Safe: these commands only build, test, or inspect trusted checkout targets. +""", +) + +prefix_rule( + pattern = ["pnpm", ["format", "test"]], + decision = "allow", + justification = """ +Needed: pnpm uses its host store and metadata cache. +Safe: checked-in scripts exclude install, exec, and publish operations. +""", +) + +prefix_rule( + pattern = ["pnpm", "run", ["format", "test"]], + decision = "allow", + justification = """ +Needed: pnpm uses its host store and metadata cache. +Safe: checked-in scripts exclude install, exec, and publish operations. +""", +) diff --git a/codex-rs/docs/execpolicy-host-executables.md b/codex-rs/docs/execpolicy-host-executables.md new file mode 100644 index 0000000000..0c758240a1 --- /dev/null +++ b/codex-rs/docs/execpolicy-host-executables.md @@ -0,0 +1,15 @@ +# Pin Codex development tool paths + +Pinning prevents trusted rules from matching an unexpected executable with the same name. + +From the development shell's repository root: + +```sh +mkdir -p ~/.codex/rules +python3 scripts/setup_dev_execpolicy.py \ + > ~/.codex/rules/codex_dev_tool_paths.rules +``` + +This pins binaries for the repository's +[development rules](../../.codex/rules/development.rules). Rules load when a +Codex session starts. diff --git a/scripts/format.py b/scripts/format.py index 6fa5aab699..755f8eb64c 100644 --- a/scripts/format.py +++ b/scripts/format.py @@ -72,6 +72,7 @@ def buildifier_formatter_group(*, check: bool) -> FormatterGroup: or name.endswith((".BUILD.bazel", ".MODULE.bazel", ".bzl", ".sky")) or ".bzl." in name or ".sky." in name + or (path.parent == Path(".codex/rules") and path.suffix == ".rules") ): buildifier_files.append(path.as_posix()) buildifier_files.sort() diff --git a/scripts/setup_dev_execpolicy.py b/scripts/setup_dev_execpolicy.py new file mode 100644 index 0000000000..ce37f9d739 --- /dev/null +++ b/scripts/setup_dev_execpolicy.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Print host executable pins for Codex development commands.""" + +from collections.abc import Callable +from collections.abc import Mapping +from collections.abc import Sequence +import json +import os +from pathlib import Path +import shutil +import sys + + +# Keep this explicit: justfile recipes can delegate to scripts that invoke other tools. +HOST_EXECUTABLES = ("cargo", "just", "bazel", "pnpm") +GENERATED_HEADER = "# Generated by scripts/setup_dev_execpolicy.py. Do not edit." +WINDOWS_EXECUTABLE_SUFFIXES = (".exe", ".cmd", ".bat", ".com") + + +def executable_lookup_name(path: Path, *, windows: bool = os.name == "nt") -> str: + name = path.name + if not windows: + return name + + name = name.lower() + for suffix in WINDOWS_EXECUTABLE_SUFFIXES: + if name.endswith(suffix): + return name[: -len(suffix)] + return name + + +def absolute_without_resolving(path: str) -> Path: + return Path(os.path.abspath(os.path.expanduser(path))) + + +def discover_host_executables( + names: Sequence[str] = HOST_EXECUTABLES, + *, + which: Callable[[str], str | None] = shutil.which, +) -> dict[str, Path]: + executables: dict[str, Path] = {} + missing: list[str] = [] + + for name in names: + raw_path = which(name) + if raw_path is None: + missing.append(name) + continue + + path = absolute_without_resolving(raw_path) + if executable_lookup_name(path) != executable_lookup_name(Path(name)): + raise RuntimeError( + f"resolved path for {name!r} must retain that executable name: {path}" + ) + executables[name] = path + + if missing: + raise RuntimeError( + "missing required host executables on PATH: " + ", ".join(missing) + ) + return executables + + +def render_host_executable_rules(executables: Mapping[str, Path]) -> str: + blocks = [] + for name, path in executables.items(): + blocks.append( + "\n".join( + [ + "host_executable(", + f" name = {json.dumps(name)},", + f" paths = [{json.dumps(str(path))}],", + ")", + ] + ) + ) + return f"{GENERATED_HEADER}\n\n" + "\n\n".join(blocks) + "\n" + + +def main() -> int: + try: + executables = discover_host_executables() + except RuntimeError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + sys.stdout.write(render_host_executable_rules(executables)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_format.py b/scripts/test_format.py new file mode 100644 index 0000000000..6440cc0973 --- /dev/null +++ b/scripts/test_format.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 + +import importlib.util +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + + +FORMAT_SCRIPT_PATH = Path(__file__).with_name("format.py") +FORMAT_SCRIPT_SPEC = importlib.util.spec_from_file_location( + "codex_format_script", + FORMAT_SCRIPT_PATH, +) +if FORMAT_SCRIPT_SPEC is None or FORMAT_SCRIPT_SPEC.loader is None: + raise RuntimeError(f"could not load {FORMAT_SCRIPT_PATH}") +format_script = importlib.util.module_from_spec(FORMAT_SCRIPT_SPEC) +sys.modules[FORMAT_SCRIPT_SPEC.name] = format_script +FORMAT_SCRIPT_SPEC.loader.exec_module(format_script) + + +class BuildifierFormatterGroupTest(unittest.TestCase): + def test_includes_top_level_codex_rule_files(self) -> None: + repository_files = b"\0".join( + [ + b".codex/rules/z.rules", + b".codex/rules/a.rules", + b".codex/rules/nested/ignored.rules", + b"elsewhere.rules", + ] + ) + + with patch.object( + format_script.subprocess, + "check_output", + return_value=repository_files, + ): + group = format_script.buildifier_formatter_group(check=True) + + self.assertEqual( + group.commands[0].args[4:], + (".codex/rules/a.rules", ".codex/rules/z.rules"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_setup_dev_execpolicy.py b/scripts/test_setup_dev_execpolicy.py new file mode 100644 index 0000000000..31beded8b3 --- /dev/null +++ b/scripts/test_setup_dev_execpolicy.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 + +from contextlib import redirect_stdout +import io +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import setup_dev_execpolicy + + +class SetupDevExecpolicyTest(unittest.TestCase): + def test_discovers_absolute_paths_without_resolving_them(self) -> None: + paths = { + "cargo": "/tools/bin/cargo", + "just": "/tools/bin/just", + } + + self.assertEqual( + setup_dev_execpolicy.discover_host_executables( + tuple(paths), + which=paths.get, + ), + { + "cargo": Path("/tools/bin/cargo"), + "just": Path("/tools/bin/just"), + }, + ) + + def test_reports_all_missing_executables(self) -> None: + with self.assertRaisesRegex( + RuntimeError, + "missing required host executables on PATH: just, pnpm", + ): + setup_dev_execpolicy.discover_host_executables( + ("cargo", "just", "pnpm"), + which=lambda name: "/tools/bin/cargo" if name == "cargo" else None, + ) + + def test_renders_host_executable_rules(self) -> None: + self.assertEqual( + setup_dev_execpolicy.render_host_executable_rules( + { + "cargo": Path("/tools/bin/cargo"), + "bazel": Path("/tools/bin/bazel"), + } + ), + """# Generated by scripts/setup_dev_execpolicy.py. Do not edit. + +host_executable( + name = "cargo", + paths = ["/tools/bin/cargo"], +) + +host_executable( + name = "bazel", + paths = ["/tools/bin/bazel"], +) +""", + ) + + def test_main_prints_rules(self) -> None: + executables = {"cargo": Path("/tools/bin/cargo")} + stdout = io.StringIO() + + with ( + patch.object( + setup_dev_execpolicy, + "discover_host_executables", + return_value=executables, + ), + redirect_stdout(stdout), + ): + result = setup_dev_execpolicy.main() + + self.assertEqual(result, 0) + self.assertEqual( + stdout.getvalue(), + setup_dev_execpolicy.render_host_executable_rules(executables), + ) + + +if __name__ == "__main__": + unittest.main()