diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e5da51b..b1bf045 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,5 @@ /.github/codex/ @loadinglucian +/.github/codex-action-contract.json @loadinglucian /.github/workflows/ @loadinglucian /.github/CODEOWNERS @loadinglucian /.codex/ @loadinglucian @@ -6,4 +7,5 @@ /schemas/ @loadinglucian /scripts/admit-maintenance-plan @loadinglucian /scripts/seal-maintenance-patch @loadinglucian +/scripts/validate-codex-action-inputs @loadinglucian /scripts/verify-merge-admission @loadinglucian diff --git a/.github/codex-action-contract.json b/.github/codex-action-contract.json new file mode 100644 index 0000000..77ca465 --- /dev/null +++ b/.github/codex-action-contract.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 1, + "action": "openai/codex-action", + "commit": "52fe01ec70a42f454c9d2ebd47598f9fd6893d56", + "metadataDigest": "sha256:d1f3b1807fd9e1ff5ea1038730ec620dfca85337f2fda5605f777071e07204ae", + "allowedInputs": [ + "prompt", + "prompt-file", + "output-file", + "openai-api-key", + "responses-api-endpoint", + "working-directory", + "sandbox", + "permission-profile", + "codex-version", + "codex-args", + "output-schema", + "output-schema-file", + "model", + "effort", + "codex-home", + "safety-strategy", + "codex-user", + "allow-users", + "allow-bots", + "allow-bot-users" + ], + "securityInputs": { + "allow-bots": { + "kind": "boolean", + "requiredValue": false + }, + "allow-bot-users": { + "kind": "bot-user-list", + "requiredValues": [ + "github-actions[bot]" + ] + } + }, + "requiredCodexVersion": "0.145.0", + "expectedInvocations": 3 +} diff --git a/.github/workflows/maintenance-consumer.yml b/.github/workflows/maintenance-consumer.yml index 0d78cea..6b8c75c 100644 --- a/.github/workflows/maintenance-consumer.yml +++ b/.github/workflows/maintenance-consumer.yml @@ -99,20 +99,22 @@ jobs: '{shared:$shared,phaseTemplate:$phaseTemplate,eventContract:$eventContract}' } > maintenance-run/investigation-prompt.md mkdir -p "$RUNNER_TEMP/codex-home" - cp .codex/investigation.config.toml "$RUNNER_TEMP/codex-home/" + cp .codex/investigation.config.toml "$RUNNER_TEMP/codex-home/config.toml" - name: Run repository-scoped offline Codex investigation if: steps.compare.outputs.trigger != 'quiet' && steps.operator.outputs.state == 'enabled' uses: openai/codex-action@52fe01ec70a42f454c9d2ebd47598f9fd6893d56 # v1 with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} + codex-version: 0.145.0 prompt-file: maintenance-run/investigation-prompt.md output-file: maintenance-run/maintenance-plan.json codex-home: ${{ runner.temp }}/codex-home sandbox: read-only safety-strategy: drop-sudo allow-users: loadinglucian - allow-bots: github-actions[bot] - codex-args: '["--strict-config","--ephemeral","--profile","investigation","--output-schema","schemas/maintenance-plan.schema.json"]' + allow-bots: false + allow-bot-users: github-actions[bot] + codex-args: '["--strict-config","--ephemeral","--output-schema","schemas/maintenance-plan.schema.json"]' - name: Admit plan against exact policy and checkout if: steps.compare.outputs.trigger != 'quiet' && steps.operator.outputs.state == 'enabled' run: | @@ -191,19 +193,21 @@ jobs: '.agentContract.instructionDigests={shared:$shared,phaseTemplate:$phaseTemplate,eventContract:$eventContract}' \ maintenance-run/maintenance-plan.json > maintenance-run/implementation-plan.json mkdir -p "$RUNNER_TEMP/codex-home" - cp .codex/implementation.config.toml "$RUNNER_TEMP/codex-home/" + cp .codex/implementation.config.toml "$RUNNER_TEMP/codex-home/config.toml" - name: Run offline Codex implementation uses: openai/codex-action@52fe01ec70a42f454c9d2ebd47598f9fd6893d56 # v1 with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} + codex-version: 0.145.0 prompt-file: maintenance-run/implementation-prompt.md output-file: maintenance-run/implementation-result.json codex-home: ${{ runner.temp }}/codex-home sandbox: workspace-write safety-strategy: drop-sudo allow-users: loadinglucian - allow-bots: github-actions[bot] - codex-args: '["--strict-config","--ephemeral","--profile","implementation","--output-schema","schemas/agent-completion-assessment.schema.json"]' + allow-bots: false + allow-bot-users: github-actions[bot] + codex-args: '["--strict-config","--ephemeral","--output-schema","schemas/agent-completion-assessment.schema.json"]' - name: Seal admitted diff run: | ./scripts/seal-maintenance-patch \ @@ -341,19 +345,21 @@ jobs: printf '\n```\n' } > maintenance-run/repair-prompt.md mkdir -p "$RUNNER_TEMP/codex-home" - cp .codex/repair.config.toml "$RUNNER_TEMP/codex-home/" + cp .codex/repair.config.toml "$RUNNER_TEMP/codex-home/config.toml" - name: Run one offline Codex repair uses: openai/codex-action@52fe01ec70a42f454c9d2ebd47598f9fd6893d56 # v1 with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} + codex-version: 0.145.0 prompt-file: maintenance-run/repair-prompt.md output-file: maintenance-run/repair-result.json codex-home: ${{ runner.temp }}/codex-home sandbox: workspace-write safety-strategy: drop-sudo allow-users: loadinglucian - allow-bots: github-actions[bot] - codex-args: '["--strict-config","--ephemeral","--profile","repair","--output-schema","schemas/agent-completion-assessment.schema.json"]' + allow-bots: false + allow-bot-users: github-actions[bot] + codex-args: '["--strict-config","--ephemeral","--output-schema","schemas/agent-completion-assessment.schema.json"]' - name: Seal combined repaired diff run: | mv maintenance-run/repair-plan.json maintenance-run/implementation-plan.json diff --git a/README.md b/README.md index 556253e..b0e978e 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,11 @@ From a checkout containing both repositories: --output ./verification-results ``` +Each repository's `scripts/test.sh` also validates every pinned Codex Action +invocation, exact CLI version, and canonical `config.toml` loading against the +reviewed offline contract in `.github/codex-action-contract.json` before +exercising maintenance behavior. + Inspect `support-snapshot.json`, `maintenance-events/`, `readiness/`, retained workflow artifacts, and the event's GitHub issue. Recovery corrects the cause and reruns the normal admitted path; it never disables checksum, policy, diff --git a/maintenance/protected-paths.json b/maintenance/protected-paths.json index 45bb64c..5a489db 100644 --- a/maintenance/protected-paths.json +++ b/maintenance/protected-paths.json @@ -2,12 +2,14 @@ "schemaVersion": 1, "patterns": [ ".github/codex/maintenance/*", + ".github/codex-action-contract.json", ".github/workflows/*", ".codex/*", "schemas/*", "maintenance/*", "scripts/admit-maintenance-plan", "scripts/seal-maintenance-patch", + "scripts/validate-codex-action-inputs", "scripts/verify-merge-admission", "maintenance-events/*", "readiness/*", diff --git a/scripts/test.sh b/scripts/test.sh index a1516e1..bc794ac 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -6,6 +6,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" "$SCRIPT_DIR/check-public-language.sh" +"$SCRIPT_DIR/validate-codex-action-inputs" if [[ "$(uname -s)" != "Darwin" || "$(uname -m)" != "arm64" ]]; then echo "Plugin installation tests require macOS arm64." >&2 diff --git a/scripts/validate-codex-action-inputs b/scripts/validate-codex-action-inputs new file mode 100755 index 0000000..9ce931e --- /dev/null +++ b/scripts/validate-codex-action-inputs @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Validate every Codex Action invocation against the reviewed offline contract.""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import subprocess +import sys +from typing import Any + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +CONTRACT_PATH = ROOT / ".github/codex-action-contract.json" +BOT_USER_RE = re.compile(r"^[A-Za-z0-9-]+(?:\[bot\])?$") + + +def load_workflow(path: pathlib.Path) -> dict[str, Any]: + """Load one GitHub Actions workflow through Ruby's YAML parser.""" + script = ( + "document = YAML.safe_load(File.read(ARGV.fetch(0)), aliases: true); " + "STDOUT.write(JSON.generate(document))" + ) + result = subprocess.run( + ["ruby", "-ryaml", "-rjson", "-e", script, str(path)], + cwd=ROOT, + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + document = json.loads(result.stdout) + if not isinstance(document, dict): + raise ValueError(f"workflow is not an object: {path}") + return document + + +def validate() -> dict[str, Any]: + """Validate every repository Codex Action call and return audit evidence.""" + contract = json.loads(CONTRACT_PATH.read_text()) + action = contract["action"] + commit = contract["commit"] + if not re.fullmatch(r"[0-9a-f]{40}", commit): + raise ValueError("Codex Action contract commit is not an exact SHA") + if not re.fullmatch(r"sha256:[0-9a-f]{64}", contract["metadataDigest"]): + raise ValueError("Codex Action metadata digest is invalid") + + allowed_inputs = set(contract["allowedInputs"]) + required_codex_version = contract["requiredCodexVersion"] + if not re.fullmatch(r"\d+\.\d+\.\d+", required_codex_version): + raise ValueError("required Codex CLI version is not exact") + required_bot_users = set(contract["securityInputs"]["allow-bot-users"]["requiredValues"]) + expected_ref = f"{action}@{commit}" + invocations = [] + canonical_config_copies = 0 + for path in sorted((ROOT / ".github/workflows").glob("*.yml")): + workflow_body = path.read_text() + canonical_config_copies += workflow_body.count('"$RUNNER_TEMP/codex-home/config.toml"') + if re.search(r'cp\s+\.codex/\S+\.config\.toml\s+"\$RUNNER_TEMP/codex-home/"', workflow_body): + raise ValueError(f"{path.relative_to(ROOT)} copies a phase config under an unloaded filename") + document = load_workflow(path) + jobs = document.get("jobs", {}) + if not isinstance(jobs, dict): + raise ValueError(f"workflow jobs are not an object: {path}") + for job_name, job in jobs.items(): + if not isinstance(job, dict): + continue + for index, step in enumerate(job.get("steps", [])): + if not isinstance(step, dict): + continue + uses = step.get("uses") + if not isinstance(uses, str) or not uses.startswith(f"{action}@"): + continue + location = f"{path.relative_to(ROOT)}:{job_name}:step-{index + 1}" + if uses != expected_ref: + raise ValueError(f"{location} does not use reviewed Codex Action SHA") + inputs = step.get("with", {}) + if not isinstance(inputs, dict): + raise ValueError(f"{location} inputs are not an object") + unknown = sorted(set(inputs) - allowed_inputs) + if unknown: + raise ValueError(f"{location} has unknown Codex Action inputs: {unknown}") + if inputs.get("codex-version") != required_codex_version: + raise ValueError(f"{location} does not pin the reviewed Codex CLI version") + codex_args = inputs.get("codex-args") + try: + parsed_args = json.loads(codex_args) + except (TypeError, json.JSONDecodeError) as error: + raise ValueError(f"{location} codex-args must be a JSON array") from error + if not isinstance(parsed_args, list) or not all(isinstance(item, str) for item in parsed_args): + raise ValueError(f"{location} codex-args must contain only strings") + if "--strict-config" not in parsed_args or "--ephemeral" not in parsed_args: + raise ValueError(f"{location} omits required strict or ephemeral execution") + if any(item == "--profile" or item.startswith("--profile=") for item in parsed_args): + raise ValueError(f"{location} selects an undefined named profile") + allow_bots = inputs.get("allow-bots") + if type(allow_bots) is not bool or allow_bots is not False: + raise ValueError(f"{location} allow-bots must be the boolean false") + allow_bot_users = inputs.get("allow-bot-users") + if not isinstance(allow_bot_users, str): + raise ValueError(f"{location} allow-bot-users must be a bot username list") + bot_users = {item.strip() for item in allow_bot_users.split(",") if item.strip()} + if bot_users != required_bot_users or not all(BOT_USER_RE.fullmatch(item) for item in bot_users): + raise ValueError(f"{location} allow-bot-users does not match the reviewed bot allowlist") + invocations.append({"workflow": str(path.relative_to(ROOT)), "job": job_name, "step": index + 1}) + + expected_count = contract["expectedInvocations"] + if len(invocations) != expected_count: + raise ValueError(f"expected {expected_count} Codex Action invocations, found {len(invocations)}") + if canonical_config_copies != expected_count: + raise ValueError( + f"expected {expected_count} canonical Codex config copies, found {canonical_config_copies}" + ) + return { + "action": action, + "commit": commit, + "codexVersion": required_codex_version, + "metadataDigest": contract["metadataDigest"], + "invocations": invocations, + "result": "passed", + } + + +def main() -> int: + """Run validation and emit human-readable or machine-readable output.""" + parser = argparse.ArgumentParser() + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + try: + result = validate() + if args.json: + print(json.dumps(result, sort_keys=True)) + else: + print(f"Validated {len(result['invocations'])} pinned Codex Action invocations.") + return 0 + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError, subprocess.CalledProcessError) as error: + print(f"Codex Action input validation failed: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/test_maintenance.py b/test/test_maintenance.py index 2153ba0..4d3058b 100644 --- a/test/test_maintenance.py +++ b/test/test_maintenance.py @@ -62,8 +62,10 @@ def test_readiness_requires_exact_commits_and_digests(self): readiness("new_branch:8.6", "main", "bad", "bad", "main", []) def test_protected_controls_are_not_admissible(self): + self.assertTrue(protected(".github/codex-action-contract.json")) self.assertTrue(protected(".github/workflows/maintenance.yml")) self.assertTrue(protected("maintenance/admission.py")) + self.assertTrue(protected("scripts/validate-codex-action-inputs")) self.assertTrue(protected("maintenance-events/new-patch.json")) self.assertTrue(protected("readiness/new-branch.json")) self.assertFalse(protected("lib/releases.lua"))