Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 96 additions & 5 deletions .github/workflows/plananvil-codex-qualification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ name: PlanAnvil Codex qualification

on:
workflow_dispatch:
inputs:
mode:
description: Qualification mode
required: true
default: smoke
type: choice
options:
- smoke
- full

permissions:
contents: read
Expand All @@ -13,7 +22,7 @@ concurrency:
jobs:
smoke:
name: codex smoke test
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.mode == 'smoke'
environment: plananvil-codex
runs-on:
- self-hosted
Expand All @@ -40,10 +49,6 @@ jobs:
test -n "${workspace}"
test -d "${workspace}"
cd "${workspace}"

# The workspace helper intentionally provides a disposable empty
# directory. Initialize a local fixture repository before invoking
# Codex so Git-based qualification checks have a valid boundary.
git init -q
git rev-parse --is-inside-work-tree

Expand All @@ -53,3 +58,89 @@ jobs:
printf '%s\n' "${output}" | grep -Fq 'PLANANVIL_CODEX_SMOKE_OK'
after="$(git status --porcelain=v1 --untracked-files=all)"
test "${before}" = "${after}"

full:
name: C01-C16 live qualification
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.mode == 'full'
environment: plananvil-codex
runs-on:
- self-hosted
- linux
- x64
- plananvil
- codex
timeout-minutes: 180
steps:
- name: Validate controlled runner
shell: bash
run: |
set -euo pipefail
test "${GITHUB_REF}" = "refs/heads/main"
command -v plananvil-qualification-workspace
command -v codex
command -v git
command -v python3
codex --version
git --version
python3 --version

- name: Create trusted main qualification workspace
shell: bash
run: |
set -euo pipefail
workspace="$(plananvil-qualification-workspace)"
test -n "${workspace}"
test -d "${workspace}"
repo="${workspace}/repo"
artifact="${workspace}/evidence-artifact"
mkdir -p "${repo}" "${artifact}"

git -C "${repo}" init -q
git -C "${repo}" remote add origin "https://github.com/${GITHUB_REPOSITORY}.git"
git -C "${repo}" fetch --depth=1 origin "${GITHUB_SHA}"
git -C "${repo}" checkout --detach -q "${GITHUB_SHA}"
test "$(git -C "${repo}" rev-parse HEAD)" = "${GITHUB_SHA}"

echo "QUALIFICATION_REPO=${repo}" >> "${GITHUB_ENV}"
echo "QUALIFICATION_ARTIFACT=${artifact}" >> "${GITHUB_ENV}"

- name: Materialize and validate evidence templates
shell: bash
run: |
set -euo pipefail
cd "${QUALIFICATION_REPO}"
python3 tools/prepare_capabilities.py --force
python3 tools/validate_capabilities.py

- name: Run C01-C16 sequential qualification
id: qualify
shell: bash
run: |
set -euo pipefail
cd "${QUALIFICATION_REPO}"
set +e
python3 tools/live_codex_qualification.py \
--root "${QUALIFICATION_REPO}" \
--source-commit "${GITHUB_SHA}" \
--run-id "${GITHUB_RUN_ID}" \
--output "${QUALIFICATION_ARTIFACT}"
rc=$?
set -e
echo "exit_code=${rc}" >> "${GITHUB_OUTPUT}"
exit 0

- name: Upload sanitized capability evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: plananvil-codex-evidence-${{ github.run_id }}
path: ${{ env.QUALIFICATION_ARTIFACT }}
if-no-files-found: error
retention-days: 14

- name: Enforce live release gate
if: always()
shell: bash
run: |
set -euo pipefail
test "${{ steps.qualify.outputs.exit_code }}" = "0"
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ All notable changes to PlanAnvil are documented here.

## [Unreleased]

### Added

- add a controlled `workflow_dispatch`-only Codex qualification workflow for the trusted `plananvil-codex` self-hosted runner;
- add a sequential C01-C16 live qualification controller that creates disposable fixture repositories, runs fresh ephemeral Codex trials, sanitizes structural evidence, rehashes/validates each package, and exports evidence only as a GitHub Actions artifact;
- add regression tests ensuring the live qualification controller redacts private-looking data and never disables the Codex sandbox/approval boundary.

### Changed

- update pinned `actions/checkout` and `actions/setup-python` workflow SHAs to the current v7 releases while retaining immutable action pinning and Node 24 compatibility;
- require production releases to use a GitHub-verified signed annotated tag whose target is reachable from `main`;
- fail the production release gate closed when the release worktree is dirty or Git cleanliness cannot be verified.
- fail the production release gate closed when the release worktree is dirty or Git cleanliness cannot be verified;
- document the controlled self-hosted Codex qualification path and keep the previous sandbox procedure as a manual fallback.

## [0.2.0] - 2026-08-28

Expand Down
14 changes: 13 additions & 1 deletion docs/CODEX_SANDBOX_RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,19 @@ This is the remaining live-runtime step after deterministic/release hardening is
- Git available and target fixture repositories disposable;
- no real credentials, private repository URLs, personal paths, or proprietary source in fixtures/evidence.

Start the evidence branch by running:
## Controlled GitHub Actions path

The preferred qualification path is `.github/workflows/plananvil-codex-qualification.yml` in `full` mode. The workflow is intentionally `workflow_dispatch`-only, accepts execution only from `main`, uses Environment `plananvil-codex`, and targets `[self-hosted, linux, x64, plananvil, codex]`.

The controlled runner must provide `plananvil-qualification-workspace`. The workflow creates a disposable workspace with that helper, fetches only the exact dispatched `main` SHA, materializes the C01-C16 evidence templates, and runs `tools/live_codex_qualification.py` sequentially. The controller invokes every agent task through `codex exec --ephemeral`, pins model `gpt-5.6-sol`, uses approval policy `never`, disables network access for model-generated commands, and grants `workspace-write` only to disposable fixture roots when a trial requires writes. Vetted project hooks may bypass the interactive hook-trust prompt; approval and filesystem sandboxing remain enabled.

Raw Codex session streams are not retained. The controller keeps only sanitized final assertions, event-type counts, and relative Git structure required for evaluation. The self-hosted runner has repository read permission only and never pushes qualification changes.

The workflow uploads `plananvil-codex-evidence-<run-id>` as a short-lived artifact. Review that artifact before committing evidence through a normal protected pull request. A full workflow run exits successfully only when every release-gating capability is `REPRODUCED`; partial/failed runs still upload their sanitized evidence artifact for diagnosis.

## Manual fallback

For an equivalent manual run in a dedicated sandbox:

```text
codex --version
Expand Down
109 changes: 109 additions & 0 deletions tests/test_live_codex_qualification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
from __future__ import annotations

import importlib.util
import json
import tempfile
import unittest
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
MODULE_PATH = ROOT / "tools" / "live_codex_qualification.py"
SPEC = importlib.util.spec_from_file_location("live_codex_qualification", MODULE_PATH)
assert SPEC and SPEC.loader
live = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(live)


class LiveCodexQualificationTests(unittest.TestCase):
def test_sanitize_redacts_tokens_sessions_and_private_paths(self) -> None:
raw = (
"token=ghp_abcdefghijklmnop path=/home/alice/project/file "
"session=01a05825-18c0-78b2-a231-0ed1bcaf5be1"
)
value = live.sanitize_text(raw)
self.assertNotIn("ghp_abcdefghijklmnop", value)
self.assertNotIn("/home/alice", value)
self.assertNotIn("01a05825-18c0-78b2-a231-0ed1bcaf5be1", value)
self.assertIn("<REDACTED_TOKEN>", value)
self.assertIn("<PATH>", value)
self.assertIn("<SESSION_ID>", value)

def test_event_summary_retains_structure_not_content(self) -> None:
stream = "\n".join(
[
json.dumps({"type": "thread.started", "thread_id": "secret"}),
json.dumps(
{
"type": "item.completed",
"item": {
"type": "command_execution",
"command": "cat /home/alice/.codex/auth.json",
},
}
),
json.dumps(
{
"type": "item.completed",
"item": {"type": "file_change", "path": "/home/alice/file"},
}
),
]
)
summary = live.event_summary(stream)
self.assertEqual(summary["completed_command_items"], 1)
self.assertEqual(summary["completed_file_change_items"], 1)
self.assertNotIn("secret", json.dumps(summary))
self.assertNotIn("/home/alice", json.dumps(summary))

def test_common_codex_args_are_ephemeral_sandboxed_and_noninteractive(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
schema = root / "schema.json"
output = root / "output.json"
schema.write_text("{}\n", encoding="utf-8")
args = live.common_codex_args(
cwd=root,
sandbox="workspace-write",
schema=schema,
output=output,
add_dir=root / "worktrees",
trust_project=True,
hook_trust=True,
)
joined = " ".join(args)
self.assertIn("exec", args)
self.assertIn("--ephemeral", args)
self.assertIn("--ignore-user-config", args)
self.assertIn("--sandbox", args)
self.assertIn("workspace-write", args)
self.assertIn('approval_policy="never"', args)
self.assertIn("sandbox_workspace_write.network_access=false", args)
self.assertIn("--dangerously-bypass-hook-trust", args)
self.assertNotIn("--dangerously-bypass-approvals-and-sandbox", args)
self.assertNotIn("danger-full-access", joined)

def test_validate_plan_rejects_duplicate_trials(self) -> None:
plan = {
"capability_id": "C01",
"setup_summary": [],
"trials": [
{
"name": "same",
"prompt": "a",
"sandbox": "read-only",
"reset_before": True,
},
{
"name": "same",
"prompt": "b",
"sandbox": "read-only",
"reset_before": True,
},
],
}
self.assertIn("duplicate", live.validate_plan("C01", plan) or "")


if __name__ == "__main__":
unittest.main()
Loading