diff --git a/apps/decodex/src/radar.rs b/apps/decodex/src/radar.rs index 047a57d6..d897c069 100644 --- a/apps/decodex/src/radar.rs +++ b/apps/decodex/src/radar.rs @@ -2848,6 +2848,7 @@ fn run_codex_analysis( ) -> crate::prelude::Result<()> { let mut command = helper_command(root, request, RUN_CODEX_ANALYSIS_SCRIPT); + command.arg("--allow-ai-analysis-boundary"); command.args([ "--bundle", &path_arg(root, bundle), @@ -5516,6 +5517,7 @@ mod tests { ffi::OsString, fs, path::{Path, PathBuf}, + process::Command, }; use serde_json::{self, Value}; @@ -5847,6 +5849,37 @@ mod tests { assert!(rendered.get("how_to_try").is_none()); } + #[test] + fn analysis_helper_fails_closed_without_explicit_boundary_opt_in() { + let _env = TestEnvVars::set(&[("DECODEX_ALLOW_CODEX_ANALYSIS", None)]); + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("apps/decodex should live two levels under the repo root"); + let temp_dir = tempfile::tempdir().expect("temporary directory should be created"); + let bundle_path = temp_dir.path().join("missing-bundle.json"); + let output_path = temp_dir.path().join("analysis.json"); + let output = Command::new("python3") + .current_dir(repo_root) + .arg(repo_root.join(super::RUN_CODEX_ANALYSIS_SCRIPT)) + .arg("--bundle") + .arg(&bundle_path) + .arg("--out") + .arg(&output_path) + .arg("--repo-root") + .arg(repo_root) + .output() + .expect("Python analysis helper smoke command should execute"); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(!output.status.success()); + assert!( + stderr.contains("requires --allow-ai-analysis-boundary"), + "unexpected stderr: {stderr}" + ); + assert!(!output_path.exists()); + } + #[test] fn dry_run_backfill_selects_unpublished_release_window_prs() { let temp_dir = tempfile::tempdir().expect("temporary directory should be created"); diff --git a/docs/runbook/local-github-signal-workflow.md b/docs/runbook/local-github-signal-workflow.md index 30ba7356..443b5bc4 100644 --- a/docs/runbook/local-github-signal-workflow.md +++ b/docs/runbook/local-github-signal-workflow.md @@ -122,6 +122,13 @@ selects the release-window gaps and sequences deterministic Radar commands, whil AI review step follows the repo-local skills and schemas instead of running inside GitHub Actions. +`scripts/github/run_codex_analysis.py` remains only the bounded deterministic process +wrapper for that AI review step. Prefer `decodex radar backfill-release-range` or a +normal Codex automation session. Direct helper recovery runs must pass +`--allow-ai-analysis-boundary` or set `DECODEX_ALLOW_CODEX_ANALYSIS=1`, and the helper +still validates both the input bundle and the returned `analysis_draft` before writing +output. + The repository already includes a real sample for this flow: - bundle: `artifacts/github/bundles/openai-codex-pr-22414.json` @@ -189,7 +196,8 @@ The current Decodex boundary is: - GitHub Actions: deterministic upstream commit discovery, PR mapping, review-queue refresh, release-delta refresh, validation, and commit/push of changed metadata. Actions must not run Codex AI analysis, create `analysis_draft`, or execute release - backfills that cross that AI boundary. + backfills that cross that AI boundary. Actions also must not pass + `--allow-ai-analysis-boundary` or set `DECODEX_ALLOW_CODEX_ANALYSIS`. - Codex automation: AI source review, compatibility judgment, Publisher judgment, social publication, `analysis_draft` creation, `decodex radar render-signal`, and any promotion into signal or follow-up artifacts. diff --git a/docs/spec/upstream-review.md b/docs/spec/upstream-review.md index b5e4bbc5..a1915123 100644 --- a/docs/spec/upstream-review.md +++ b/docs/spec/upstream-review.md @@ -117,6 +117,14 @@ or public value. AI review must read enough source evidence to explain behavior. A PR title, release title, or deterministic queue hint is not enough for a confirmed claim. +The remaining Python analysis helper, `scripts/github/run_codex_analysis.py`, is only +the bounded deterministic process wrapper for this AI review boundary. It must validate +the input `github_change_bundle/v1`, run Codex with the checked `analysis_draft` output +schema, validate the returned draft again before writing it, and require an explicit +`--allow-ai-analysis-boundary` flag or `DECODEX_ALLOW_CODEX_ANALYSIS=1` environment +acknowledgement. The normal operator command surface remains Rust-owned +`decodex radar ...`; GitHub Actions must not set that acknowledgement. + ## Promotion boundary Promote an upstream review into: diff --git a/scripts/github/README.md b/scripts/github/README.md index ac696728..017db04c 100644 --- a/scripts/github/README.md +++ b/scripts/github/README.md @@ -7,7 +7,12 @@ Current helper: - `run_codex_analysis.py` invokes Codex in a read-only session and writes a validated `analysis_draft` artifact. It is the explicit AI boundary and is not a GitHub - Actions entrypoint. + Actions entrypoint. The wrapper behavior is deterministic: validate the bundle, run + Codex with the checked prompt and output schema, validate the returned draft, then + write the artifact. Direct invocation is a recovery or automation-only path and must + pass `--allow-ai-analysis-boundary` or set `DECODEX_ALLOW_CODEX_ANALYSIS=1`. + Normal operator workflows should reach it through Rust-owned `decodex radar` + commands such as `backfill-release-range`. Shared support: @@ -117,6 +122,12 @@ GitHub Actions may refresh upstream queues, release deltas, and validation throu promote source-backed conclusions into `upstream_impact/v1`, `analysis_draft`, `decodex radar render-signal` output, or `social_post/v1`. +Do not wire `run_codex_analysis.py` into GitHub Actions. Actions must not pass +`--allow-ai-analysis-boundary` or set `DECODEX_ALLOW_CODEX_ANALYSIS`; that +acknowledgement is reserved for Rust-owned local automation and explicit operator +recovery runs that still keep bundle validation and `analysis_draft` schema validation +inside the helper. + Repo-local skills under `dev/skills/` are reasoning instructions for the Codex analysis step and for manual Radar/Publisher work. They do not introduce extra intermediate artifact schemas unless the conclusion is promoted into one of the diff --git a/scripts/github/run_codex_analysis.py b/scripts/github/run_codex_analysis.py index 65e86582..e14a0ce1 100644 --- a/scripts/github/run_codex_analysis.py +++ b/scripts/github/run_codex_analysis.py @@ -5,6 +5,7 @@ import argparse import json +import os import subprocess import sys import tempfile @@ -17,9 +18,19 @@ from contracts import dump_json, load_json, validate_analysis_draft, validate_bundle # noqa: E402 +ALLOW_ANALYSIS_ENV = "DECODEX_ALLOW_CODEX_ANALYSIS" + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--allow-ai-analysis-boundary", + action="store_true", + help=( + "Required acknowledgement that this helper is crossing the Codex AI analysis " + "boundary. GitHub Actions must not set this." + ), + ) parser.add_argument("--bundle", required=True, help="Path to github_change_bundle/v1 JSON.") parser.add_argument("--out", required=True, help="Path to write the validated analysis JSON.") parser.add_argument("--repo-root", help="Repository root for codex exec. Defaults to the current repo root.") @@ -28,6 +39,10 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() +def analysis_boundary_allowed(args: argparse.Namespace) -> bool: + return args.allow_ai_analysis_boundary or os.environ.get(ALLOW_ANALYSIS_ENV) == "1" + + def repo_root_from(bundle_path: Path) -> Path: resolved = bundle_path.resolve() for root in resolved.parents: @@ -83,6 +98,13 @@ def extract_json_payload(raw: str) -> dict[str, Any]: def main() -> None: args = parse_args() + if not analysis_boundary_allowed(args): + raise SystemExit( + "Codex analysis helper requires --allow-ai-analysis-boundary or " + f"{ALLOW_ANALYSIS_ENV}=1. Use Rust-owned decodex radar commands for " + "deterministic Radar workflows; GitHub Actions must not run this helper." + ) + bundle_path = Path(args.bundle) bundle = load_json(bundle_path) bundle_validation = validate_bundle(bundle)