From e57e52d136d76072c1cb8f91eef98ec42e9fd5a6 Mon Sep 17 00:00:00 2001 From: ryan kleeberger Date: Mon, 1 Jun 2026 17:04:39 -0500 Subject: [PATCH] fix(sdlc): bootstrap helper fails OPEN on infra error + atomic canonical hooks deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unclaimed governance-intake bootstrap (cc-task-gate.impl.sh section 3b) is the roleless session's ONLY sanctioned write path, yet it mapped EVERY non-{0,10} helper exit to a hard BLOCK — so python3's own "can't open file" rc==2 (an unreadable / mid-atomic-swap helper) was indistinguishable from a genuine BLOCKED verdict and fail-closed even a properly CLAIMED session during a hooks-doctor redeploy (the S2 incident). Mirror the shim's INV-5 fail-OPEN posture (master design section 2.2 / FM-15 / NEW-2): - A pre-invocation readability guard + an rc map where ONLY rc==12 blocks; every other non-{0,10} code (rc==2 infra, 1, 127, ...) is an infra signal, never a deny. - On infra error a bootstrap CANDIDATE write (a Write of a .md note under hapax-requests/active or hapax-cc-tasks/active) FAILS OPEN with a loud ledger line; any other mutation falls through to the normal claim/authority gate, so the fail-open never widens what a non-bootstrap mutation may do. hooks-doctor --deploy-canonical was non-atomic: it installed each closure file (unlinkat+create) with the impl FIRST, so during a redeploy a sibling was briefly absent and the new impl could go live ahead of its closure. Stage the whole closure + MANIFEST into a temp dir on the same filesystem and rename(2) each into place, publishing the impl LAST; refuse an incomplete closure up front so a refused deploy is a clean no-op (was a half-swapped closure). Regression tests: tests/hooks/test_cc_task_gate_bootstrap_failopen.py (the fail-open matrix incl. the claimed-session unblock) and three atomic-deploy tests in tests/hooks/test_hooks_doctor.py (incomplete-source no-op, strace rename/impl-last ordering, concurrent-redeploy no-missing-sibling). Task: reform-bootstrap-failopen-atomic-swap-20260601 AuthorityCase: CASE-SDLC-REFORM-001 Co-Authored-By: Claude Opus 4.8 (1M context) --- hooks/scripts/cc-task-gate.impl.sh | 102 +++++- hooks/scripts/hooks-doctor.sh | 72 ++++- .../test_cc_task_gate_bootstrap_failopen.py | 292 ++++++++++++++++++ tests/hooks/test_hooks_doctor.py | 184 +++++++++++ 4 files changed, 621 insertions(+), 29 deletions(-) create mode 100644 tests/hooks/test_cc_task_gate_bootstrap_failopen.py diff --git a/hooks/scripts/cc-task-gate.impl.sh b/hooks/scripts/cc-task-gate.impl.sh index 8259b867b..6eb3c154a 100755 --- a/hooks/scripts/cc-task-gate.impl.sh +++ b/hooks/scripts/cc-task-gate.impl.sh @@ -420,24 +420,92 @@ fi # a claim may create a new request or offered cc-task note, but only through a # path-scoped, content-validated Write event. Ordinary source/runtime/system # mutation and manual claim-file writes still fail closed below. -set +e -_bootstrap_output="$( - printf '%s' "$input" | python3 "$SCRIPT_DIR/cc-task-gate-bootstrap.py" 2>&1 -)" -_bootstrap_rc=$? -set -e -case "$_bootstrap_rc" in - 0) - [[ -n "$_bootstrap_output" ]] && printf '%s\n' "$_bootstrap_output" >&2 +# +# FAIL-OPEN ON INFRA ERROR (reform — bootstrap-failopen-atomic-swap). This is the +# roleless session's ONLY sanctioned write path, so it MUST mirror the shim's +# INV-5 posture (master design §2.2 / FM-15 / NEW-2): when the validator helper +# itself cannot run — unreadable, mid atomic-swap, or it crashes — a bootstrap +# CANDIDATE write fails OPEN (advisory + ledger) instead of fail-closed-blocking, +# and any other mutation falls through to the normal claim/authority gate. ONLY a +# genuine BLOCKED verdict (rc==12) from a helper that actually ran blocks; python's +# own "can't open file" rc==2 and every other non-{0,10} code are infra signals, +# never a deny. Before this fix the case mapped EVERY non-{0,10} code to exit 2, so +# a redeploy that briefly unlinked the helper fail-closed even a properly CLAIMED +# session (the S2 incident). +_bootstrap_helper="$SCRIPT_DIR/cc-task-gate-bootstrap.py" + +# _bootstrap_is_candidate_target — mirror the helper's candidate test in pure bash +# so the fail-OPEN stays narrow: only a Write of a .md note under the governance +# intake roots (hapax-requests/active or hapax-cc-tasks/active) fails open when the +# helper can't run. Any other mutation falls through to the normal gate — an infra +# error must never widen what a non-bootstrap mutation may do. +_bootstrap_is_candidate_target() { + [[ "$tool_name" == "Write" ]] || return 1 + local p="${edit_path/#\~/$HOME}" + [[ -n "$p" && "$p" == *.md ]] || return 1 + case "$p" in + "$HOME"/Documents/Personal/20-projects/hapax-requests/active/*) return 0 ;; + "$HOME"/Documents/Personal/20-projects/hapax-cc-tasks/active/*) return 0 ;; + esac + return 1 +} + +# _bootstrap_infra_failopen — shared "the validator could not run" handler: emit a +# loud ledger line + stderr advisory, then mirror INV-5 — fail OPEN (exit 0) for a +# candidate, else return so the caller falls through to the normal claim gate. +_bootstrap_infra_failopen() { + local reason="$1" detail="$2" is_cand="false" + if _bootstrap_is_candidate_target; then is_cand="true"; fi + local _bs_role="${HAPAX_AGENT_ROLE:-${CODEX_ROLE:-${CLAUDE_ROLE:-unknown}}}" + local _bs_ledger="${HAPAX_METHODOLOGY_LEDGER:-$HOME/.cache/hapax/methodology-emergency-ledger.jsonl}" + mkdir -p "$(dirname "$_bs_ledger")" 2>/dev/null || true + printf '{"ts":"%s","kind":"bootstrap_helper_infra_failopen","reason":"%s","detail":"%s","role":"%s","tool":"%s","path":"%s","candidate":%s}\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$reason" "$detail" "$_bs_role" "$tool_name" "${edit_path:-}" "$is_cand" \ + >> "$_bs_ledger" 2>/dev/null || true + if [[ "$is_cand" == "true" ]]; then + echo "cc-task-gate: bootstrap validator unavailable ($reason) — FAILING OPEN for governance-intake write (advisory, ledgered, INV-5): ${edit_path:-}" >&2 exit 0 - ;; - 10) - ;; - *) - [[ -n "$_bootstrap_output" ]] && printf '%s\n' "$_bootstrap_output" >&2 - exit 2 - ;; -esac + fi + echo "cc-task-gate: bootstrap validator unavailable ($reason) — non-candidate mutation falls through to the normal gate (advisory, ledgered)." >&2 + return 0 +} + +if [[ ! -r "$_bootstrap_helper" ]]; then + # Absent/unreadable (e.g. a concurrent hooks-doctor redeploy briefly unlinked + # it). Don't exec python on it — that would surface as rc==2 and historically + # fail closed. Go straight to the INV-5 fail-open handler. + _bootstrap_infra_failopen "helper_unreadable" "$_bootstrap_helper" +else + set +e + _bootstrap_output="$( + printf '%s' "$input" | python3 "$_bootstrap_helper" 2>&1 + )" + _bootstrap_rc=$? + set -e + case "$_bootstrap_rc" in + 0) + [[ -n "$_bootstrap_output" ]] && printf '%s\n' "$_bootstrap_output" >&2 + exit 0 + ;; + 10) + # NOT_CANDIDATE — fall through to the normal claim/authority gate. + ;; + 12) + # The ONLY blocking verdict: the helper ran and judged the bootstrap note + # invalid. A genuine deny. + [[ -n "$_bootstrap_output" ]] && printf '%s\n' "$_bootstrap_output" >&2 + exit 2 + ;; + *) + # Any other code (python rc 2 = can't open file, 1 = uncaught exception, + # 127 = python missing, …) is an INFRA signal, never a deny. Mirror INV-5: + # fail OPEN for a candidate, else fall through. Sanitize the captured output + # before it enters the JSONL ledger. + _bs_det="$(printf '%s' "${_bootstrap_output:-}" | tr '\n\r\t"\\' ' ' | cut -c1-160)" + _bootstrap_infra_failopen "helper_rc_${_bootstrap_rc}" "$_bs_det" + ;; + esac +fi # --- 3c. Shadow decision log (reform 3b PRODUCER source) --------------------- # From here on every exit is a genuine GATED decision (the non-mutating / cognition diff --git a/hooks/scripts/hooks-doctor.sh b/hooks/scripts/hooks-doctor.sh index d38bc78cd..af8a89e67 100755 --- a/hooks/scripts/hooks-doctor.sh +++ b/hooks/scripts/hooks-doctor.sh @@ -179,29 +179,77 @@ deploy_canonical() { echo "deploy: REFUSING to deploy an impl that lacks INV-5 is_cognition_path: $src/cc-task-gate.impl.sh" >&2 return 1 fi + # REFUSE an incomplete closure UP FRONT, before touching the live canonical: a + # missing sibling would make the deployed gate exit 2 on every mutation (the + # cc-task-gate-bootstrap.py omission incident). The OLD code installed the impl + # first and only THEN discovered a missing sibling, leaving a half-swapped + # closure live; checking + staging first makes a refused deploy a clean no-op. + local s + for s in "${CLOSURE_SIBLINGS[@]}"; do + if [[ ! -r "$src/$s" ]]; then + echo "deploy: REFUSING incomplete closure — source missing $src/$s" >&2 + return 1 + fi + done if [[ "$DRY" = 1 ]]; then - echo "[dry-run] would deploy gate closure: $src -> $CANONICAL_DIR" + echo "[dry-run] would atomically deploy gate closure: $src -> $CANONICAL_DIR" return 0 fi + mkdir -p "$CANONICAL_DIR" - # impl deploys AS cc-task-gate.sh (the name shims + settings.json resolve to). - install -m 0755 "$src/cc-task-gate.impl.sh" "$CANONICAL_DIR/cc-task-gate.sh" - # REFUSE an incomplete closure: a missing sibling would make the deployed gate - # exit 2 on every mutation (the cc-task-gate-bootstrap.py omission incident). - local s + + # ATOMIC DEPLOY (reform — bootstrap-failopen-atomic-swap). The old path used + # `install` (unlinkat+create) per file with the impl deployed FIRST, so during a + # redeploy every file was briefly ABSENT and a concurrent PreToolUse exec could + # run the new impl while its siblings (agent-role.sh / escape-grant.sh / + # cc-task-gate-bootstrap.py) were missing → fail-closed. We instead stage the + # whole closure + MANIFEST into a temp dir on the SAME filesystem, then rename(2) + # each file into place. rename(2) atomically replaces the destination (POSIX: no + # point at which a reader finds it missing), and we publish the impl + # (cc-task-gate.sh) LAST so a concurrent gate exec always sees a complete sibling + # set before the new impl becomes live. + local stage rc=0 + stage="$(mktemp -d "$CANONICAL_DIR/.deploy.tmp.XXXXXX")" || { + echo "deploy: could not create staging dir under $CANONICAL_DIR" >&2 + return 1 + } + + # Stage the impl (as cc-task-gate.sh) + every sibling into the temp dir. + if ! install -m 0755 "$src/cc-task-gate.impl.sh" "$stage/cc-task-gate.sh"; then + rm -rf "$stage" 2>/dev/null || true + echo "deploy: staging impl failed" >&2 + return 1 + fi for s in "${CLOSURE_SIBLINGS[@]}"; do - if [[ ! -r "$src/$s" ]]; then - echo "deploy: REFUSING incomplete closure — source missing $src/$s" >&2 + if ! install -m 0755 "$src/$s" "$stage/$s"; then + rm -rf "$stage" 2>/dev/null || true + echo "deploy: staging sibling $s failed" >&2 return 1 fi - install -m 0755 "$src/$s" "$CANONICAL_DIR/$s" done - ( cd "$CANONICAL_DIR" && sha256sum cc-task-gate.sh "${CLOSURE_SIBLINGS[@]}" 2>/dev/null ) \ - > "$CANONICAL_DIR/MANIFEST.sha256" 2>/dev/null || true + ( cd "$stage" && sha256sum cc-task-gate.sh "${CLOSURE_SIBLINGS[@]}" 2>/dev/null ) \ + > "$stage/MANIFEST.sha256" 2>/dev/null || true + + # Publish: siblings + MANIFEST FIRST, the impl LAST. Each mv is an atomic + # rename(2) within $CANONICAL_DIR (same filesystem as the staging dir) — no file + # is ever observed absent, and the impl never goes live ahead of its closure. + for s in "${CLOSURE_SIBLINGS[@]}"; do + mv -f "$stage/$s" "$CANONICAL_DIR/$s" || rc=1 + done + if [[ -f "$stage/MANIFEST.sha256" ]]; then + mv -f "$stage/MANIFEST.sha256" "$CANONICAL_DIR/MANIFEST.sha256" || rc=1 + fi + mv -f "$stage/cc-task-gate.sh" "$CANONICAL_DIR/cc-task-gate.sh" || rc=1 + rm -rf "$stage" 2>/dev/null || true + if [[ "$rc" != 0 ]]; then + echo "deploy: FAILED publishing staged closure to $CANONICAL_DIR" >&2 + return 1 + fi + local bindir="${HAPAX_LOCAL_BIN:-$HOME/.local/bin}" mkdir -p "$bindir" ln -sf "$CANONICAL_DIR/hooks-doctor.sh" "$bindir/hapax-hooks-doctor" - echo "deployed gate closure -> $CANONICAL_DIR (from $src)" + echo "deployed gate closure -> $CANONICAL_DIR (from $src, atomic)" check_canonical } diff --git a/tests/hooks/test_cc_task_gate_bootstrap_failopen.py b/tests/hooks/test_cc_task_gate_bootstrap_failopen.py new file mode 100644 index 000000000..01e266cf2 --- /dev/null +++ b/tests/hooks/test_cc_task_gate_bootstrap_failopen.py @@ -0,0 +1,292 @@ +"""Regression tests: the cc-task-gate bootstrap invocation must FAIL OPEN on infra +error (reform — bootstrap-failopen-atomic-swap, CASE-SDLC-REFORM-001). + +The unclaimed governance-intake bootstrap (section 3b of cc-task-gate.impl.sh) is +the roleless session's ONLY sanctioned write path. Historically it mapped EVERY +non-{0,10} helper exit to a hard BLOCK (exit 2) — so python3's own "can't open +file" rc==2 (an unreadable / mid-atomic-swap helper) was indistinguishable from a +genuine BLOCKED verdict, fail-closing even a properly CLAIMED session during a +hooks-doctor redeploy (the S2 incident). The fix mirrors the shim's INV-5 posture +(master design §2.2 / FM-15 / NEW-2): only rc==12 blocks; a candidate write fails +OPEN when the helper cannot run; every other mutation falls through to the normal +gate. + +These run a STAGED copy of the gate closure in a temp dir so the bootstrap helper's +readability / exit code can be controlled (the real helper is always present in the +repo). Self-contained per project conventions (no shared conftest). +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +HOOKS_SRC = REPO_ROOT / "hooks" / "scripts" +# Everything the impl SOURCES; the bootstrap helper is staged separately so each +# test controls its presence / exit code. +_CLOSURE = ("cc-task-gate.impl.sh", "agent-role.sh", "escape-grant.sh") +_HELPER = "cc-task-gate-bootstrap.py" + +_IDENTITY_ENV = ( + "HAPAX_AGENT_ROLE", + "HAPAX_AGENT_NAME", + "HAPAX_WORKTREE_ROLE", + "HAPAX_AGENT_SLOT", + "HAPAX_SESSION_ID", + "HAPAX_AGENT_INTERFACE", + "CLAUDE_ROLE", + "CLAUDE_CODE_SESSION_ID", + "CODEX_ROLE", + "CODEX_SESSION", + "CODEX_SESSION_NAME", + "CODEX_THREAD_ID", + "CODEX_THREAD_NAME", + "HAPAX_CC_TASK_GATE_OFF", + "HAPAX_METHODOLOGY_EMERGENCY", +) + + +def _stage_gate(tmp_path: Path, *, helper: str | int) -> Path: + """Stage the gate closure into tmp_path/gate and control the bootstrap helper. + + helper: "real" (copy the real validator), "absent" (omit it), "unreadable" + (copy it then chmod 000), or an int (a fake helper that consumes stdin then + exits with that code — to simulate rc==12 BLOCK, rc==2 infra, etc.). + """ + gate_dir = tmp_path / "gate" + gate_dir.mkdir(parents=True, exist_ok=True) + for name in _CLOSURE: + shutil.copy2(HOOKS_SRC / name, gate_dir / name) + (gate_dir / name).chmod(0o755) + helper_path = gate_dir / _HELPER + if helper == "real": + shutil.copy2(HOOKS_SRC / _HELPER, helper_path) + helper_path.chmod(0o755) + elif helper == "absent": + pass # deliberately not created + elif helper == "unreadable": + shutil.copy2(HOOKS_SRC / _HELPER, helper_path) + helper_path.chmod(0o000) + elif isinstance(helper, int): + # Consume stdin first so the `printf | python3` pipe never SIGPIPEs, then + # exit with the requested code. + helper_path.write_text(f"import sys\nsys.stdin.read()\nsys.exit({helper})\n") + helper_path.chmod(0o755) + else: # pragma: no cover - guard + raise ValueError(f"unknown helper mode: {helper!r}") + return gate_dir / "cc-task-gate.impl.sh" + + +def _run( + gate_impl: Path, + payload: dict[str, object], + tmp_path: Path, + *, + role: str | None = None, + extra_env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["HOME"] = str(tmp_path) + for key in _IDENTITY_ENV: + env.pop(key, None) + if role is not None: + env["HAPAX_AGENT_ROLE"] = role + if extra_env: + env.update(extra_env) + # cwd is a neutral non-worktree dir so role resolution never path-infers a lane. + return subprocess.run( + [str(gate_impl)], + input=json.dumps(payload), + capture_output=True, + text=True, + env=env, + cwd=str(tmp_path), + timeout=15, + check=False, + ) + + +def _ledger_text(home: Path) -> str: + path = home / ".cache" / "hapax" / "methodology-emergency-ledger.jsonl" + return path.read_text(encoding="utf-8") if path.exists() else "" + + +def _intake_note(home: Path, kind: str) -> Path: + sub = "hapax-cc-tasks" if kind == "cc-tasks" else "hapax-requests" + note = home / "Documents" / "Personal" / "20-projects" / sub / "active" / "new-thing.md" + note.parent.mkdir(parents=True, exist_ok=True) + return note + + +def _candidate_write(note: Path) -> dict[str, object]: + return { + "tool_name": "Write", + "tool_input": {"file_path": str(note), "content": "---\ntype: cc-task\n---\n"}, + } + + +def _make_vault(home: Path, *, task_id: str, assigned: str, scope: str = "/tmp/x") -> Path: + root = home / "Documents" / "Personal" / "20-projects" / "hapax-cc-tasks" / "active" + root.mkdir(parents=True, exist_ok=True) + note = root / f"{task_id}-t.md" + note.write_text( + "---\n" + "type: cc-task\n" + f"task_id: {task_id}\n" + 'title: "t"\n' + "status: in_progress\n" + f"assigned_to: {assigned}\n" + f"parent_spec: {home / 'spec.md'}\n" + "authority_case: CASE-TEST-001\n" + "stage: S6_IMPLEMENTATION\n" + "implementation_authorized: true\n" + "source_mutation_authorized: true\n" + "docs_mutation_authorized: true\n" + "runtime_mutation_authorized: false\n" + "route_metadata_schema: 1\n" + "mutation_scope_refs:\n" + f" - {scope}\n" + "created_at: 2026-06-01T00:00:00Z\n" + "updated_at: 2026-06-01T00:00:00Z\n" + "---\n\n# t\n\n## Session log\n" + ) + return note + + +def _claim(home: Path, role: str, task_id: str) -> None: + cache = home / ".cache" / "hapax" + cache.mkdir(parents=True, exist_ok=True) + (cache / f"cc-active-task-{role}").write_text(task_id + "\n") + + +# --- AC1: helper unreadable/missing → roleless intake creation FALLS OPEN -------- + + +@pytest.mark.parametrize("helper", ["absent", "unreadable"]) +@pytest.mark.parametrize("kind", ["cc-tasks", "requests"]) +def test_helper_unavailable_candidate_write_fails_open(tmp_path: Path, helper: str, kind: str): + gate = _stage_gate(tmp_path, helper=helper) + note = _intake_note(tmp_path, kind) + result = _run(gate, _candidate_write(note), tmp_path, role=None) + assert result.returncode == 0, ( + f"helper={helper} kind={kind}: roleless intake creation must FAIL OPEN, " + f"not block; stderr={result.stderr}" + ) + assert "FAILING OPEN" in result.stderr + assert "bootstrap_helper_infra_failopen" in _ledger_text(tmp_path), ( + "the fail-open must be loudly ledgered" + ) + + +# --- AC2: only rc==12 blocks; rc==2 (+ other infra codes) fall open -------------- + + +def test_rc12_blocks_a_candidate(tmp_path: Path): + # The helper RAN and judged the bootstrap note invalid — a genuine deny. + gate = _stage_gate(tmp_path, helper=12) + note = _intake_note(tmp_path, "cc-tasks") + result = _run(gate, _candidate_write(note), tmp_path, role=None) + assert result.returncode == 2, f"rc==12 must block; stderr={result.stderr}" + + +@pytest.mark.parametrize("rc", [1, 2, 3, 127]) +def test_other_rc_fails_open_for_candidate(tmp_path: Path, rc: int): + # python rc 2 == can't open file (mid-swap helper); 1 == uncaught exception; + # 127 == python missing. None is a deny — a candidate write must fail OPEN. + gate = _stage_gate(tmp_path, helper=rc) + note = _intake_note(tmp_path, "cc-tasks") + result = _run(gate, _candidate_write(note), tmp_path, role=None) + assert result.returncode == 0, f"rc=={rc} (infra) must fail open; stderr={result.stderr}" + + +def test_rc0_still_allows_candidate(tmp_path: Path): + gate = _stage_gate(tmp_path, helper=0) + note = _intake_note(tmp_path, "cc-tasks") + result = _run(gate, _candidate_write(note), tmp_path, role=None) + assert result.returncode == 0, f"rc==0 (valid) must allow; stderr={result.stderr}" + + +# --- Narrowness: an infra error must NOT widen what a non-bootstrap mutation does - + + +@pytest.mark.parametrize("helper", ["absent", 2]) +def test_helper_unavailable_noncandidate_unclaimed_still_blocks(tmp_path: Path, helper): + # A source Edit by an unclaimed (roleless) session is NOT a bootstrap candidate: + # the infra fail-open must not wave it through — it falls to the normal claim gate. + gate = _stage_gate(tmp_path, helper=helper) + src = tmp_path / "project" / "app.py" + result = _run( + gate, + {"tool_name": "Edit", "tool_input": {"file_path": str(src)}}, + tmp_path, + role=None, + extra_env={"HAPAX_SESSION_ID": "sidX"}, + ) + assert result.returncode == 2, ( + f"helper={helper}: a non-candidate unclaimed edit must NOT fail open; " + f"stderr={result.stderr}" + ) + assert "no claimed task" in result.stderr.lower() + + +# --- The coordinator unblock: a CLAIMED in-scope edit is not blocked by a bad helper + + +@pytest.mark.parametrize("helper", ["absent", "unreadable", 2]) +def test_helper_unavailable_does_not_block_claimed_inscope_edit(tmp_path: Path, helper): + # Before the fix, a mid-swap helper made section 3b exit 2 on EVERY mutation — + # so a properly claimed session doing authorized in-scope work was blocked. Now + # the infra error falls through to the normal gate, which allows the edit. + gate = _stage_gate(tmp_path, helper=helper) + _make_vault(tmp_path, task_id="t1", assigned="delta", scope="/tmp/x") + _claim(tmp_path, "delta", "t1") + result = _run( + gate, + {"tool_name": "Edit", "tool_input": {"file_path": "/tmp/x"}}, + tmp_path, + role="delta", + ) + assert result.returncode == 0, ( + f"helper={helper}: a claimed, authorized, in-scope edit must proceed even " + f"when the bootstrap helper can't run; stderr={result.stderr}" + ) + + +# --- Sanity: with the REAL helper present, behaviour is unchanged ----------------- + + +def test_real_helper_valid_candidate_still_allowed(tmp_path: Path): + gate = _stage_gate(tmp_path, helper="real") + request_root = tmp_path / "Documents/Personal/20-projects/hapax-requests/active" + request_root.mkdir(parents=True) + note = request_root / "REQ-20260601120000-x.md" + content = ( + "---\n" + "type: hapax-request\n" + "request_id: REQ-20260601120000\n" + "title: x\n" + "status: captured\n" + "requester: delta\n" + "created_at: 2026-06-01T12:00:00Z\n" + "updated_at: 2026-06-01T12:00:00Z\n" + "authority_requested: x\n" + "risk_guess: T1\n" + "requires_research: false\n" + "surfaces:\n - source\n" + "principle_flags:\n - none\n" + "tags:\n - intake\n" + "---\n\n# x\n" + ) + result = _run( + gate, + {"tool_name": "Write", "tool_input": {"file_path": str(note), "content": content}}, + tmp_path, + role=None, + ) + assert result.returncode == 0, f"real helper valid intake must allow; stderr={result.stderr}" diff --git a/tests/hooks/test_hooks_doctor.py b/tests/hooks/test_hooks_doctor.py index 2a95044a0..4ecff3079 100644 --- a/tests/hooks/test_hooks_doctor.py +++ b/tests/hooks/test_hooks_doctor.py @@ -11,13 +11,26 @@ import os import re +import shutil import subprocess +import threading +import time from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parents[2] +HOOKS = REPO_ROOT / "hooks" / "scripts" DOCTOR = REPO_ROOT / "hooks" / "scripts" / "hooks-doctor.sh" SHIM = REPO_ROOT / "hooks" / "scripts" / "cc-task-gate.sh" IMPL = REPO_ROOT / "hooks" / "scripts" / "cc-task-gate.impl.sh" +# The closure siblings hooks-doctor deploys alongside the impl (cc-task-gate.sh). +CLOSURE_SIBLINGS = ( + "agent-role.sh", + "escape-grant.sh", + "cc-task-gate-bootstrap.py", + "hooks-doctor.sh", +) SHIM_MARKER = "HAPAX-GATE-SHIM" STALE_GATE = "#!/usr/bin/env bash\n# old 427-line gate, no cognition carve-out\nexit 2\n" @@ -224,3 +237,174 @@ def test_fanout_shims_stale_lane_worktree(tmp_path): again = _run("--fanout", "--root", str(repo)) assert "0 gate(s) updated" in again.stdout + + +# --- atomic deploy (reform — bootstrap-failopen-atomic-swap) ---------------- +# The old deploy installed the impl FIRST and each closure file via `install` +# (unlinkat+create), so during a redeploy a sibling was briefly absent and the +# new impl could go live before its closure — a concurrent PreToolUse exec then +# sourced a half-written sibling / opened an absent helper and fail-closed. The +# fix stages the whole closure into a temp dir on the same filesystem and +# rename(2)s each file into place, publishing the impl LAST. + + +def _seed_incomplete_source(tmp_path: Path) -> Path: + """A --from source whose impl DIFFERS from the deployed one but is missing a + closure sibling (escape-grant.sh) — so an impl-first install would be visibly + detectable, while a staged deploy must refuse without touching the canonical.""" + src = tmp_path / "src" / "hooks" / "scripts" + src.mkdir(parents=True) + _write(src / "cc-task-gate.impl.sh", IMPL.read_text(encoding="utf-8") + "\n# v2 divergence\n") + for sib in ("agent-role.sh", "cc-task-gate-bootstrap.py", "hooks-doctor.sh"): + _write(src / sib, (HOOKS / sib).read_text(encoding="utf-8")) + # escape-grant.sh deliberately OMITTED → incomplete closure. + return tmp_path / "src" + + +def test_deploy_from_incomplete_source_leaves_canonical_untouched(tmp_path): + # Land a healthy v1 from the real repo, snapshot it, then attempt a deploy from + # an incomplete (different-impl, missing-sibling) source. The refused deploy must + # be a NO-OP on the live canonical — not a half-swapped closure. + canon = tmp_path / "canon" + bindir = tmp_path / "bin" + env = {"HAPAX_CANONICAL_HOOKS": str(canon), "HAPAX_LOCAL_BIN": str(bindir)} + r1 = _run("--deploy-canonical", env=env) + assert r1.returncode == 0, r1.stdout + r1.stderr + before = {p.name: p.read_bytes() for p in canon.iterdir() if p.is_file()} + assert "cc-task-gate.sh" in before + + src_root = _seed_incomplete_source(tmp_path) + r2 = _run("--deploy-canonical", "--from", str(src_root), env=env) + assert r2.returncode == 1 + assert "REFUSING" in r2.stderr + + after = {p.name: p.read_bytes() for p in canon.iterdir() if p.is_file()} + assert after == before, ( + "an incomplete-source deploy must not mutate the live canonical " + "(staging + up-front refusal), not a half-swapped closure" + ) + + +def test_deploy_publishes_via_atomic_rename_impl_last(tmp_path): + # strace evidence (the AC's named method): every closure file is published via a + # rename(2) (atomic, no absent window) and the impl (cc-task-gate.sh) is renamed + # LAST, after every sibling, from a staging temp dir. + strace = shutil.which("strace") + if strace is None: + pytest.skip("strace unavailable") + canon = tmp_path / "canon" + bindir = tmp_path / "bin" + env = {**os.environ, "HAPAX_CANONICAL_HOOKS": str(canon), "HAPAX_LOCAL_BIN": str(bindir)} + # Pre-deploy v1 so the traced deploy REPLACES existing files (the redeploy case). + r0 = _run( + "--deploy-canonical", + env={"HAPAX_CANONICAL_HOOKS": str(canon), "HAPAX_LOCAL_BIN": str(bindir)}, + ) + assert r0.returncode == 0, r0.stdout + r0.stderr + + trace = tmp_path / "trace.log" + proc = subprocess.run( + [ + strace, + "-f", + "-s", + "4096", + "-e", + "trace=rename,renameat,renameat2", + "-o", + str(trace), + "bash", + str(DOCTOR), + "--deploy-canonical", + ], + capture_output=True, + text=True, + env=env, + timeout=120, + check=False, + ) + if proc.returncode != 0 and "ptrace" in (proc.stderr or "").lower(): + pytest.skip("strace cannot ptrace in this sandbox") + assert proc.returncode == 0, proc.stdout + proc.stderr + lines = [ + ln + for ln in trace.read_text(encoding="utf-8", errors="replace").splitlines() + if "= 0" in ln and "rename" in ln + ] + if not lines: + pytest.skip("strace captured no rename syscalls (restricted)") + + def dest_idx(name: str) -> int: + needle = f'"{canon / name}"' # the DEST appears as a fully-quoted abs path + for i, ln in enumerate(lines): + if needle in ln: + return i + return -1 + + impl_idx = dest_idx("cc-task-gate.sh") + sib_idxs = {name: dest_idx(name) for name in CLOSURE_SIBLINGS} + assert impl_idx >= 0, f"impl must be published via rename; renames={lines}" + for name, idx in sib_idxs.items(): + assert idx >= 0, f"sibling {name} must be published via rename; renames={lines}" + assert impl_idx > max(sib_idxs.values()), ( + "the impl (cc-task-gate.sh) must be renamed into place LAST, after every " + "sibling, so a concurrent gate exec never sees a new impl ahead of its closure" + ) + assert ".deploy.tmp" in lines[impl_idx], ( + f"impl must be published FROM a staging temp dir (atomic): {lines[impl_idx]}" + ) + + +def test_concurrent_redeploy_never_exposes_incomplete_closure(tmp_path): + # AC4: under concurrent redeploy stress, the canonical must never present an impl + # without its full, non-empty closure (the window that made the gate open an + # absent bootstrap helper and fail closed). rename(2) never removes a file, so a + # sibling is never momentarily absent; impl-last keeps the impl behind its closure. + canon = tmp_path / "canon" + env = {"HAPAX_CANONICAL_HOOKS": str(canon), "HAPAX_LOCAL_BIN": str(tmp_path / "bin")} + r0 = _run("--deploy-canonical", env=env) + assert r0.returncode == 0, r0.stdout + r0.stderr + + closure = ("cc-task-gate.sh", *CLOSURE_SIBLINGS) + violations: list[str] = [] + stop = threading.Event() + run_env = {**os.environ, **env} + + def redeployer() -> None: + for _ in range(30): + if stop.is_set(): + break + subprocess.run( + ["bash", str(DOCTOR), "--deploy-canonical"], + capture_output=True, + text=True, + env=run_env, + check=False, + ) + + worker = threading.Thread(target=redeployer) + worker.start() + probes = 0 + try: + deadline = time.monotonic() + 10.0 + while worker.is_alive() and time.monotonic() < deadline: + if (canon / "cc-task-gate.sh").exists(): + for name in closure: + try: + size = (canon / name).stat().st_size + except FileNotFoundError: + # The TOCTOU window itself: a sibling vanished mid-deploy + # while the impl was present (old install-based unlinkat). + violations.append(f"missing {name} while impl present") + continue + if size == 0: + violations.append(f"empty {name} while impl present") + probes += 1 + finally: + stop.set() + worker.join(timeout=60) + + assert probes > 0 + assert not violations, ( + f"closure was incomplete during redeploy ({len(violations)} probes): {violations[:5]}" + )