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
23 changes: 16 additions & 7 deletions .github/workflows/plananvil-codex-qualification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
type: choice
options:
- smoke
- c13
- full

permissions:
Expand Down Expand Up @@ -60,8 +61,11 @@ jobs:
test "${before}" = "${after}"

full:
name: C01-C16 live qualification
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.mode == 'full'
name: live capability qualification
if: >-
github.event_name == 'workflow_dispatch' &&
github.ref == 'refs/heads/main' &&
(inputs.mode == 'full' || inputs.mode == 'c13')
environment: plananvil-codex
runs-on:
- self-hosted
Expand Down Expand Up @@ -94,7 +98,7 @@ jobs:
fi
bwrap --version
if ! bwrap --unshare-user --uid 0 --gid 0 --ro-bind / / /bin/true; then
echo "::error::bubblewrap cannot create the user namespace required by the Codex command sandbox. Fix the Podman/host user-namespace policy before running C01-C16."
echo "::error::bubblewrap cannot create the user namespace required by the Codex command sandbox. Fix the Podman/host user-namespace policy before live qualification."
exit 2
fi

Expand Down Expand Up @@ -126,18 +130,23 @@ jobs:
python3 tools/prepare_capabilities.py --force
python3 tools/validate_capabilities.py

- name: Run C01-C16 sequential qualification
- name: Run selected live qualification
id: qualify
shell: bash
run: |
set -euo pipefail
cd "${QUALIFICATION_REPO}"
qualification_args=()
if [ "${{ inputs.mode }}" = "c13" ]; then
qualification_args+=(--only C13 --allow-c13-non-ephemeral-fallback)
fi
set +e
python3 tools/live_codex_qualification_harness_v4.py \
python3 tools/live_codex_qualification_harness_v5.py \
--root "${QUALIFICATION_REPO}" \
--source-commit "${GITHUB_SHA}" \
--run-id "${GITHUB_RUN_ID}" \
--output "${QUALIFICATION_ARTIFACT}"
--output "${QUALIFICATION_ARTIFACT}" \
"${qualification_args[@]}"
rc=$?
set -e
echo "exit_code=${rc}" >> "${GITHUB_OUTPUT}"
Expand All @@ -152,7 +161,7 @@ jobs:
if-no-files-found: error
retention-days: 14

- name: Enforce live release gate
- name: Enforce selected live gate
if: always()
shell: bash
run: |
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ All notable changes to PlanAnvil are documented here.
- extend deterministic live qualification for C02, C09, C11, C13, C14, and C16 with explicit-only skill policy, current AGENTS precedence evidence, project-scoped SubagentStart semantics, bytecode-free PlanAnvil bootstrap, and real Git signing/hook failure diagnostics;
- make C12 a deterministic runtime byte-budget probe with redundant `project_doc_max_bytes` enforcement, secret head/tail markers, zero-tool automatic-loading evidence, and outer PlanAnvil full-file hash verification;
- make C06, C08, and C09 deterministic live probes using the real PlanAnvil PreToolUse/PreCompact/PostCompact hooks, explicit postcondition evidence, low-limit `body_after_prefix` auto-compaction triggers, checkpoint repair, repeated compaction, and post-second-compaction continuation checks;
- add a diagnostic-only C13 transport mode that keeps `full` strict-ephemeral, retries non-ephemeral only for the recognized parent-thread registration failure, uses an isolated disposable `CODEX_HOME`, requires cleanup/auth-metadata invariants, and verifies real SubagentStart semantics with an outer-generated context proof before any baseline 2.3 decision;
- 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;
- document the controlled self-hosted Codex qualification path and keep the previous sandbox procedure as a manual fallback.
Expand Down
14 changes: 11 additions & 3 deletions docs/CODEX_SANDBOX_RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,21 @@ If the probe fails with `setting up uid map: Operation not permitted`, fix the P

## 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 preferred qualification path is `.github/workflows/plananvil-codex-qualification.yml`. 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.
Use `mode=full` for the release-gating C01-C16 sequence. While issue #17 is being resolved, `mode=c13` is a **diagnostic-only** transport comparison for C13; it does not change baseline 2.2 and it is not a substitute for the full release gate.

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 the current live qualification harness. In `full` mode the qualification contract remains strict-ephemeral: agent tasks use `codex exec --ephemeral`, model `gpt-5.6-sol`, approval policy `never`, model-tool network disabled, and `workspace-write` only for disposable fixture roots that require writes. Vetted project hooks may bypass the interactive hook-trust prompt; approval and filesystem sandboxing remain enabled.

The C13 diagnostic first runs the same real project-scoped SubagentStart fixture through `codex exec --ephemeral`. A non-ephemeral retry is permitted only when the ephemeral attempt matches the recognized `collab spawn failed: no thread with id` parent-thread registration failure. The retry keeps C13 read-only, approval `never`, model-tool network disabled, the trusted disposable project, and the real SubagentStart hook/child agent.

For that diagnostic retry the harness creates a separate disposable `CODEX_HOME`. It does not read or copy the authenticated runner's `auth.json`; when file-backed authentication is available it creates only a temporary symlink to the existing auth file, redirects SQLite/log storage into the disposable home, disables message-history persistence, records only the number of newly created rollout files, verifies the auth file metadata is unchanged, and removes the entire disposable home before accepting the trial. If file-backed authentication is unavailable, session persistence cannot be proven, cleanup fails, auth metadata changes, or any command/file tool is used by the root C13 probe, the diagnostic fails closed as `BLOCKED`.

The C13 context proof is generated by the outer harness and is never included in the root-agent prompt or retained in evidence. A real SubagentStart proxy records only structural facts (`continue=false`, presence of `additionalContext`, event count) while delegating to the real hook. `REPRODUCED` requires exactly one real SubagentStart event and a child echo of the unseen injected proof. This diagnostic evidence is intended to decide issue #17; enabling the non-ephemeral fallback in `full` mode requires a separate baseline 2.3 contract update.

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 performs the Linux `bubblewrap` user-namespace probe before preparing fixtures, so an incompatible container fails in seconds instead of consuming a full C01-C16 run. It 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.
The workflow performs the Linux `bubblewrap` user-namespace probe before preparing fixtures, so an incompatible container fails in seconds instead of consuming a live qualification run. It 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`; a `c13` diagnostic exits successfully only when C13 semantics are reproduced under the controlled transport comparison. Partial/failed runs still upload their sanitized evidence artifact for diagnosis.

## Manual fallback

Expand Down
8 changes: 6 additions & 2 deletions tests/test_live_codex_qualification_harness_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,12 @@ def test_safety_boundary_is_not_weakened(self) -> None:
self.assertIn('sandbox="read-only"', self.source)
self.assertIn('sandbox="workspace-write"', self.source)

def test_full_workflow_uses_v4_wrapper(self) -> None:
self.assertIn("python3 tools/live_codex_qualification_harness_v4.py", self.workflow)
def test_v4_is_chained_under_current_v5_wrapper(self) -> None:
current = (ROOT / "tools" / "live_codex_qualification_harness_v5.py").read_text(
encoding="utf-8"
)
self.assertIn("import live_codex_qualification_harness_v4 as prior", current)
self.assertIn("python3 tools/live_codex_qualification_harness_v5.py", self.workflow)


if __name__ == "__main__":
Expand Down
91 changes: 91 additions & 0 deletions tests/test_live_codex_qualification_harness_v5.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from __future__ import annotations

from pathlib import Path
import unittest


ROOT = Path(__file__).resolve().parents[1]
MODULE_PATH = ROOT / "tools" / "live_codex_qualification_harness_v5.py"
WORKFLOW_PATH = ROOT / ".github" / "workflows" / "plananvil-codex-qualification.yml"
RUNBOOK_PATH = ROOT / "docs" / "CODEX_SANDBOX_RUNBOOK.md"


class LiveCodexHarnessV5Tests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.source = MODULE_PATH.read_text(encoding="utf-8")
cls.workflow = WORKFLOW_PATH.read_text(encoding="utf-8")
cls.runbook = RUNBOOK_PATH.read_text(encoding="utf-8")

def test_exact_target_capability_and_inheritance(self) -> None:
self.assertIn('TARGET_CAPABILITIES = {"C13"}', self.source)
self.assertIn("_ORIGINAL_CAPABILITY_RUNTIME = prior.capability_runtime", self.source)
self.assertIn("import live_codex_qualification_harness_v4 as prior", self.source)

def test_ephemeral_first_and_known_error_gated_fallback(self) -> None:
self.assertIn("KNOWN_PARENT_THREAD_FAILURE_RE", self.source)
self.assertIn("collab\\s+spawn\\s+failed", self.source)
self.assertIn("known_e and ALLOW_NON_EPHEMERAL_FALLBACK", self.source)
self.assertIn('transport="ephemeral"', self.source)
self.assertIn('transport="non-ephemeral"', self.source)
self.assertIn("ephemeral_known_transport_blocker_fallback_not_enabled", self.source)

def test_context_proof_is_not_exposed_to_root_prompt(self) -> None:
self.assertIn("_context_proof(source_commit)", self.source)
self.assertIn("Never invent or guess the opaque value", self.source)
self.assertIn("secret_value_retained_in_evidence", self.source)
self.assertNotIn("FIXTURE_SUBAGENT_CONTEXT", self.source)

def test_real_hook_and_continue_false_are_required(self) -> None:
self.assertIn('"matcher": "^fixture_agent$"', self.source)
self.assertIn('"hookEventName": "SubagentStart"', self.source)
self.assertIn('"continue": False', self.source)
self.assertIn("hook_continue_false", self.source)
self.assertIn("hook_additional_context", self.source)
self.assertIn("subagent_start_hook_events", self.source)

def test_non_ephemeral_home_is_isolated_without_copying_auth(self) -> None:
self.assertIn('os.symlink(str(original_auth), str(home / "auth.json"))', self.source)
self.assertNotIn("shutil.copy2(original_auth", self.source)
self.assertNotIn("read_bytes()", self.source)
self.assertIn('history.persistence="none"', self.source)
self.assertIn("sqlite_home=", self.source)
self.assertIn("log_dir=", self.source)
self.assertIn("session_cleanup_verified", self.source)
self.assertIn("auth_metadata_unchanged", self.source)

def test_full_release_path_cannot_enable_fallback_yet(self) -> None:
self.assertIn(
"C13 non-ephemeral fallback is diagnostic-only until the baseline contract is updated",
self.source,
)
self.assertIn("--allow-c13-non-ephemeral-fallback", self.source)
self.assertIn("--only", self.source)

def test_safety_boundary_is_not_weakened(self) -> None:
self.assertNotIn("--dangerously-bypass-approvals-and-sandbox", self.source)
self.assertNotIn("danger-full-access", self.source)
self.assertNotIn("--privileged", self.source)
self.assertNotIn("SYS_ADMIN", self.source)
self.assertIn('sandbox="read-only"', self.source)
self.assertIn("completed_command_items", self.source)
self.assertIn("completed_file_change_items", self.source)
self.assertIn("repository_unchanged", self.source)

def test_workflow_exposes_c13_diagnostic_but_keeps_full_separate(self) -> None:
self.assertIn("- c13", self.workflow)
self.assertIn("inputs.mode == 'c13'", self.workflow)
self.assertIn("python3 tools/live_codex_qualification_harness_v5.py", self.workflow)
self.assertIn("--only C13", self.workflow)
self.assertIn("--allow-c13-non-ephemeral-fallback", self.workflow)
self.assertIn("inputs.mode == 'full'", self.workflow)

def test_runbook_marks_c13_mode_diagnostic_only(self) -> None:
self.assertIn("`c13`", self.runbook)
self.assertIn("diagnostic", self.runbook.lower())
self.assertIn("baseline 2.3", self.runbook)
self.assertIn("full", self.runbook)


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