diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index b2b5b8f..e7d337e 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -28,7 +28,11 @@ If you touch GitHub at all (e.g. `gh pr diff`, `gh pr view`, `gh api`), route it - **correctness**: logic errors, edge cases, off-by-one, error handling, race conditions, broken invariants. If the task provides an APPROVED PLAN / authoritative scope (e.g. issue #100's plan gate), also verify the diff stays within it — a diff that exceeds the approved plan's declared files or approach is a - valid reject under this lens ("exceeds approved scope"). + valid reject under this lens ("exceeds approved scope"). Protected-paths hard reject (issue #94 Layer + 2): if the diff touches any path matching the adapter's `protectedPaths` globs (`.claude/gates.json`, + resolved via `GATES_FILE`), REJECT — an agent PR must not modify the loop's own control plane. + EXCEPTION: if the adapter's `protectedPaths` is empty or absent (the self-hosting override), skip this + check (the harness files are the product). - **tests**: do tests actually exercise the change? coverage of edge/failure paths? meaningful assertions, not just "it runs"? Run the test gate if needed. - **security**: injection, auth/access control, unsafe input, secrets, dependency risk, (for smart contracts) reentrancy/overflow/access — defer to the project security skill if configured. - **performance**: needless work, N+1, allocations, blocking calls, complexity regressions. diff --git a/.claude/gates.json b/.claude/gates.json index f7b3eae..910242e 100644 --- a/.claude/gates.json +++ b/.claude/gates.json @@ -77,5 +77,8 @@ "commentFix": { "botAllowlist": [] }, "_plan_note": "issue #100 — optional spec/plan gate before implementation starts. plan.gate: off (default; today's single-pass advance behavior, unchanged) | label (gate only planned issues that ALSO carry a plan-first label) | always (gate every planned+module:* issue). Unknown/missing value falls back to off. When gated, the loop posts ONE structured plan comment on the issue (marked ``) and labels it plan-review + needs-human instead of implementing; the owner reviews the comment and either replaces plan-review with plan-approved (approve — the next tick implements with the plan injected into the implementer and reviewers as authoritative scope) or removes plan-review (request changes — the loop re-plans). Owner-only approval is NOT identity-enforced — same limitation as the `planned` label itself (see docs/USAGE.md). Read by loop-census.sh/loop-tick.sh/loop-event.sh.", - "plan": { "gate": "off" } + "plan": { "gate": "off" }, + + "_protectedPaths_note": "issue #94 Layer 2 — deterministic protected-paths guard. Array of glob patterns (\"*\" = one path segment, \"**\" = any depth). An agent-authored PR whose diff touches ANY matching path is NEVER auto-merged: merge-ready.sh blocks the merge and labels the PR needs-human, and reviewers hard-reject it (see .claude/agents/reviewer.md). Protects the loop's own control plane from an injected instruction that tries to edit it. EMPTY ARRAY = disabled (same empty-means-skip convention as `gates`/`notify`) — self-hosted repos whose harness files ARE the product override this to [] in .claude/self/gates.json. Read from the adapter resolved via GATES_FILE (falls back to this root adapter). Documented in docs/HARDENING.md.", + "protectedPaths": [".claude/**", ".github/workflows/**", "gates.json", "**/gates.json"] } diff --git a/.claude/scripts/merge-ready.sh b/.claude/scripts/merge-ready.sh index fa7af23..73a1af7 100644 --- a/.claude/scripts/merge-ready.sh +++ b/.claude/scripts/merge-ready.sh @@ -42,7 +42,13 @@ repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" # shellcheck source=needs-human.sh if [ -f "$script_dir/needs-human.sh" ]; then . "$script_dir/needs-human.sh"; fi owner="${MERGE_APPROVER:-${repo%%/*}}" # the approver whose APPROVED review authorizes a merge -gates="$root/.claude/gates.json" + +# Adapter file: honor GATES_FILE (the self-host loop points at .claude/self/gates.json), +# fall back to the shipped root adapter. Both merge.baseBranch and protectedPaths +# (issue #94 Layer 2) are read from it, so the self-adapter's permissive protectedPaths +# override applies when the loop runs self-hosted. +gates_rel="${GATES_FILE:-.claude/gates.json}" +case "$gates_rel" in /*) gates="$gates_rel";; *) gates="$root/$gates_rel";; esac base="$(node -e "try{const g=require('$gates');process.stdout.write((g.merge&&g.merge.baseBranch)||'main')}catch(e){process.stdout.write('main')}")" # Decide MERGE / SKIP: for one PR's JSON (read on stdin). @@ -82,13 +88,52 @@ decide() { ' "$base" "$owner" } +# protected_paths_check (issue #94 Layer 2): reads the adapter's protectedPaths +# globs and the PR's changed-file list (`.files[].path`, on stdin) and prints the +# protected path(s) the diff touches (comma-joined), or nothing. Empty/absent +# protectedPaths = disabled (prints nothing) — same empty-means-skip convention as +# `notify`. Deterministic (no LLM); "*" matches one path segment, "**" any depth. +protected_paths_check() { + node -e ' + const gates = process.argv[1]; + let globs = []; + try { const g = require(gates); if (Array.isArray(g.protectedPaths)) globs = g.protectedPaths; } catch (e) {} + if (!globs.length) process.exit(0); + let p; try { p = JSON.parse(require("fs").readFileSync(0, "utf8")); } catch (e) { process.exit(0); } + const files = (p.files || []).map(f => f && f.path).filter(Boolean); + function toRe(glob) { + let re = ""; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === "*") { + if (glob[i + 1] === "*") { re += ".*"; i++; if (glob[i + 1] === "/") i++; } + else re += "[^/]*"; + } else if ("\\^$.|?+()[]{}".includes(c)) { re += "\\" + c; } + else re += c; + } + return new RegExp("^" + re + "$"); + } + const res = globs.map(toRe); + const hits = files.filter(f => res.some(r => r.test(f))); + if (hits.length) process.stdout.write([...new Set(hits)].join(", ")); + ' "$gates" +} + merged=0; skipped=0 for n in $(gh pr list -R "$repo" --base "$base" --state open --json number -q '.[].number'); do - data="$(gh pr view "$n" -R "$repo" --json number,title,isDraft,baseRefName,headRefName,mergeable,reviews,statusCheckRollup,commits)" + data="$(gh pr view "$n" -R "$repo" --json number,title,isDraft,baseRefName,headRefName,mergeable,reviews,statusCheckRollup,commits,files)" verdict="$(printf '%s' "$data" | decide)" title="$(printf '%s' "$data" | node -e 'process.stdout.write((JSON.parse(require("fs").readFileSync(0,"utf8")).title)||"")')" head_branch="$(printf '%s' "$data" | node -e 'process.stdout.write((JSON.parse(require("fs").readFileSync(0,"utf8")).headRefName)||"")')" + # Protected-paths guard (issue #94 Layer 2): even an owner-approved, CI-green PR + # must not auto-merge if its diff touches a path the adapter marks protected. + protected_hit="" + if [ "$verdict" = "MERGE" ]; then + protected_hit="$(printf '%s' "$data" | protected_paths_check)" + [ -n "$protected_hit" ] && verdict="SKIP:protected-paths" + fi + # needs-human (issue #99): a PR is genuinely blocked on the OWNER for # exactly two of decide()'s skip reasons -- no review submitted yet, or a # stale approval that no longer covers the current head (new commits @@ -112,6 +157,13 @@ for n in $(gh pr list -R "$repo" --base "$base" --state open --json number -q '. "PR #$n ready for your review" "$title ($reason_text)" fi ;; + SKIP:protected-paths) + if command -v needs_human_flag >/dev/null 2>&1; then + needs_human_flag "pr:$n" "protected-paths" "high" \ + "PR #$n touches protected paths -- human review required" \ + "$title: this PR's diff touches protected path(s): $protected_hit. Auto-merge is blocked by the protected-paths guard (issue #94 Layer 2). A human must review and merge it manually." + fi + ;; *) if command -v needs_human_clear >/dev/null 2>&1; then needs_human_clear "pr:$n" "pr-review" diff --git a/.claude/scripts/merge-ready.test.sh b/.claude/scripts/merge-ready.test.sh index a5d5fac..59507e4 100755 --- a/.claude/scripts/merge-ready.test.sh +++ b/.claude/scripts/merge-ready.test.sh @@ -227,6 +227,257 @@ check "C: head_branch->issue-number regex clears issue 77's attempt-budget AND s bash -c '[ "$(grep -c -- "-X DELETE repos/acme/repo/issues/77/labels/needs-human" "$1")" -eq 2 ]' _ "$gh_logC" check "C: no needs-human label ever ADDED (REST POST) on the merge path" bash -c '! grep -q -- "-X POST" "$1"' _ "$gh_logC" +# --------------------------------------------------------------------------- +# D. Protected-paths guard BLOCKS+LABELS (issue #94 Layer 2): an otherwise- +# MERGEable PR (owner-approved, CI-green) whose diff touches a path +# matching the fixture's protectedPaths (.claude/**) must NOT be merged -- +# verdict flips to SKIP:protected-paths, the needs-human label is added +# (REST POST), and the skip line in the output carries reason +# "protected-paths". +# --------------------------------------------------------------------------- +dirD="$(new_fixture scenarioD)" +cat > "$dirD/.claude/gates.json" <> $work/scenarioD-notify-fired.txt" } +EOF +gh_logD="$work/scenarioD-gh.log" +labeled_markerD="$work/scenarioD-labeled.marker" +cat > "$dirD/.claude/scripts/bot-gh.sh" <> "$gh_logD" +case "\$1" in + pr) + case "\$2" in + list) + if printf '%s\n' "\$*" | grep -q -- '--json number'; then + echo "13" + fi + ;; + view) + cat <<'JSON' +{"number":13,"title":"Touch harness","isDraft":false,"baseRefName":"main","headRefName":"feat/issue-13-harness","mergeable":"MERGEABLE","reviews":[{"author":{"login":"acme"},"state":"APPROVED","submittedAt":"2026-01-02T00:00:00Z"}],"statusCheckRollup":[],"commits":[{"committedDate":"2026-01-01T00:00:00Z"}],"files":[{"path":".claude/scripts/x.sh"}]} +JSON + ;; + comment) : ;; + merge) exit 1 ;; + *) : ;; + esac + ;; + api) + case "\$*" in + *"-X POST"*"/issues/13/labels --input -") + touch "$labeled_markerD" + ;; + *"-q .labels[].name"*) + [ -f "$labeled_markerD" ] && printf 'needs-human\n' + ;; + *) : ;; + esac + ;; + *) echo "unhandled: \$*" >&2; exit 1 ;; +esac +EOF +chmod +x "$dirD/.claude/scripts/bot-gh.sh" +outD="$(env -u GATES_FILE bash "$dirD/.claude/scripts/merge-ready.sh" "acme/repo" 2>&1)" + +check "D: no merge was attempted (protected-paths blocks an otherwise-MERGE verdict)" bash -c '! grep -q "^pr merge" "$1"' _ "$gh_logD" +check "D: needs-human label add attempted (REST POST)" grep -qF -- "-X POST repos/acme/repo/issues/13/labels --input -" "$gh_logD" +check "D: skip line reason is protected-paths" bash -c 'printf "%s\n" "$1" | grep -q "\"reason\":\"protected-paths\""' _ "$outD" + +# --------------------------------------------------------------------------- +# E. Protected-paths guard CLEAN PROCEEDS: same protectedPaths config as D, +# but the PR's diff touches an ordinary source file -- verdict stays MERGE +# and the PR merges normally, with no protected-paths reason anywhere in +# the output. +# --------------------------------------------------------------------------- +dirE="$(new_fixture scenarioE)" +cat > "$dirE/.claude/gates.json" <> $work/scenarioE-notify-fired.txt" } +EOF +gh_logE="$work/scenarioE-gh.log" +cat > "$dirE/.claude/scripts/bot-gh.sh" <> "$gh_logE" +case "\$1" in + pr) + case "\$2" in + list) + if printf '%s\n' "\$*" | grep -q -- '--json number'; then + echo "14" + fi + ;; + view) + cat <<'JSON' +{"number":14,"title":"Ship app feature","isDraft":false,"baseRefName":"main","headRefName":"feat/issue-14-app","mergeable":"MERGEABLE","reviews":[{"author":{"login":"acme"},"state":"APPROVED","submittedAt":"2026-01-02T00:00:00Z"}],"statusCheckRollup":[],"commits":[{"committedDate":"2026-01-01T00:00:00Z"}],"files":[{"path":"src/app.js"}]} +JSON + ;; + merge) exit 0 ;; + comment) : ;; + *) : ;; + esac + ;; + api) : ;; # every REST call here is a needs_human_clear DELETE no-op + *) echo "unhandled: \$*" >&2; exit 1 ;; +esac +EOF +chmod +x "$dirE/.claude/scripts/bot-gh.sh" +outE="$(env -u GATES_FILE bash "$dirE/.claude/scripts/merge-ready.sh" "acme/repo" 2>&1)" + +check "E: PR merged (protected-paths does not fire on a clean diff)" bash -c 'printf "%s\n" "$1" | grep -q "\"action\":\"merged\""' _ "$outE" +check "E: gh pr merge invoked" grep -q "pr merge 14 -R acme/repo --merge --delete-branch" "$gh_logE" +# NOTE: don't assert "output doesn't contain 'protected-paths'" verbatim -- the +# post-merge local_sync leg reports the CALLER's real checked-out branch name, +# which could itself contain that substring (e.g. this very branch). Assert on +# the specific skip-reason/label-add shapes protected-paths would produce instead. +check "E: no protected-paths skip reason in output" bash -c '! printf "%s\n" "$1" | grep -q "\"reason\":\"protected-paths\""' _ "$outE" +check "E: no needs-human label add attempted (nothing to flag)" bash -c '! grep -qF -- "-X POST repos/acme/repo/issues/14/labels --input -" "$1"' _ "$gh_logE" + +# --------------------------------------------------------------------------- +# F. Protected-paths EMPTY OVERRIDE DISABLES the guard (issue #94 Layer 2 self- +# host override): protectedPaths:[] in the fixture's gates.json means the +# check is disabled even though the diff touches a path that WOULD match +# ".claude/**" if the guard were enabled -- PR merges normally. +# --------------------------------------------------------------------------- +dirF="$(new_fixture scenarioF)" +cat > "$dirF/.claude/gates.json" <> $work/scenarioF-notify-fired.txt" } +EOF +gh_logF="$work/scenarioF-gh.log" +cat > "$dirF/.claude/scripts/bot-gh.sh" <> "$gh_logF" +case "\$1" in + pr) + case "\$2" in + list) + if printf '%s\n' "\$*" | grep -q -- '--json number'; then + echo "15" + fi + ;; + view) + cat <<'JSON' +{"number":15,"title":"Self-host harness slice","isDraft":false,"baseRefName":"main","headRefName":"feat/issue-15-harness","mergeable":"MERGEABLE","reviews":[{"author":{"login":"acme"},"state":"APPROVED","submittedAt":"2026-01-02T00:00:00Z"}],"statusCheckRollup":[],"commits":[{"committedDate":"2026-01-01T00:00:00Z"}],"files":[{"path":".claude/scripts/x.sh"}]} +JSON + ;; + merge) exit 0 ;; + comment) : ;; + *) : ;; + esac + ;; + api) : ;; # every REST call here is a needs_human_clear DELETE no-op + *) echo "unhandled: \$*" >&2; exit 1 ;; +esac +EOF +chmod +x "$dirF/.claude/scripts/bot-gh.sh" +outF="$(env -u GATES_FILE bash "$dirF/.claude/scripts/merge-ready.sh" "acme/repo" 2>&1)" + +check "F: PR merged (empty protectedPaths override disables the guard)" bash -c 'printf "%s\n" "$1" | grep -q "\"action\":\"merged\""' _ "$outF" +check "F: gh pr merge invoked despite touching .claude/**" grep -q "pr merge 15 -R acme/repo --merge --delete-branch" "$gh_logF" + +# --------------------------------------------------------------------------- +# G. GATES_FILE actually SET (issue #94 Layer 2 crux, TESTS reviewer finding): +# scenarios A-F never run merge-ready.sh with GATES_FILE actually set -- +# they all resolve the ROOT gates.json by DEFAULT. This scenario is the one +# that proves gates_rel="${GATES_FILE:-.claude/gates.json}" plus the +# `case "$gates_rel" in /*) ...; *) gates="$root/$gates_rel";; esac` split +# actually SELECTS which adapter's protectedPaths governs the guard. +# +# ONE fixture, ONE PR (#16, touches .claude/scripts/x.sh), THREE runs that +# differ ONLY in GATES_FILE, with OPPOSITE outcomes: +# G1 - GATES_FILE unset -> ROOT gates.json (protectedPaths:[]) +# -> guard DISABLED -> PR MERGES. +# G2 - GATES_FILE= -> SELF adapter (protectedPaths:[".claude/**"]) +# via the `*)` branch (gates="$root/$gates_rel") +# -> guard ENABLED -> PR BLOCKED. +# G3 - GATES_FILE= -> SAME self adapter, via the `/*)` +# branch this time -> guard ENABLED +# -> PR BLOCKED. +# G1-vs-G2 on the IDENTICAL fixture/PR, differing only in GATES_FILE, is the +# load-bearing assertion; G3 additionally proves the absolute-path branch. +# +# Each run gets its OWN gh.log (via GH_LOG, read by the fake bot-gh.sh +# below) so G1's merge call can never pollute a "no merge happened" assertion +# on G2/G3 (mirrors scenario A's separate-log-per-run discipline). Assertions +# are specific JSON-shape / gh-call greps, never a raw substring match on the +# whole output (the post-merge local_sync line echoes the caller's own +# checked-out branch name -- the false positive that bit scenario E). +# --------------------------------------------------------------------------- +dirG="$(new_fixture scenarioG)" +cat > "$dirG/.claude/gates.json" <> $work/scenarioG-notify-fired.txt" } +EOF +mkdir -p "$dirG/.claude/self" +cat > "$dirG/.claude/self/gates.json" < "$dirG/.claude/scripts/bot-gh.sh" <> "\$log" +case "\$1" in + pr) + case "\$2" in + list) + if printf '%s\n' "\$*" | grep -q -- '--json number'; then + echo "16" + fi + ;; + view) + cat <<'JSON' +{"number":16,"title":"Touch harness via adapter","isDraft":false,"baseRefName":"main","headRefName":"feat/issue-16-adapter","mergeable":"MERGEABLE","reviews":[{"author":{"login":"acme"},"state":"APPROVED","submittedAt":"2026-01-02T00:00:00Z"}],"statusCheckRollup":[],"commits":[{"committedDate":"2026-01-01T00:00:00Z"}],"files":[{"path":".claude/scripts/x.sh"}]} +JSON + ;; + merge) exit 0 ;; + comment) : ;; + *) : ;; + esac + ;; + api) + case "\$*" in + *"-X POST"*"/issues/16/labels --input -") + touch "$labeled_markerG" + ;; + *"-q .labels[].name"*) + [ -f "$labeled_markerG" ] && printf 'needs-human\n' + ;; + *) : ;; + esac + ;; + *) echo "unhandled: \$*" >&2; exit 1 ;; +esac +EOF +chmod +x "$dirG/.claude/scripts/bot-gh.sh" + +gh_logG1="$work/scenarioG-run1-gh.log" +outG1="$(env -u GATES_FILE GH_LOG="$gh_logG1" bash "$dirG/.claude/scripts/merge-ready.sh" "acme/repo" 2>&1)" + +gh_logG2="$work/scenarioG-run2-gh.log" +outG2="$(GATES_FILE=.claude/self/gates.json GH_LOG="$gh_logG2" bash "$dirG/.claude/scripts/merge-ready.sh" "acme/repo" 2>&1)" + +gh_logG3="$work/scenarioG-run3-gh.log" +outG3="$(GATES_FILE="$dirG/.claude/self/gates.json" GH_LOG="$gh_logG3" bash "$dirG/.claude/scripts/merge-ready.sh" "acme/repo" 2>&1)" + +check "G1: GATES_FILE unset -> ROOT adapter (protectedPaths:[]) -> guard DISABLED -> PR MERGES" \ + bash -c 'printf "%s\n" "$1" | grep -q "\"action\":\"merged\""' _ "$outG1" +check "G1: no protected-paths reason in output (guard disabled)" \ + bash -c '! printf "%s\n" "$1" | grep -q "\"reason\":\"protected-paths\""' _ "$outG1" +check "G1: gh pr merge invoked" \ + grep -q "pr merge 16 -R acme/repo --merge --delete-branch" "$gh_logG1" + +check "G2: GATES_FILE=relative self-adapter path -> resolves protectedPaths:[\".claude/**\"] -> guard ENABLED -> PR BLOCKED (opposite of G1 on the SAME fixture/PR)" \ + bash -c 'printf "%s\n" "$1" | grep -q "\"reason\":\"protected-paths\""' _ "$outG2" +check "G2: no merge attempted for PR 16" \ + bash -c '! grep -q "pr merge 16" "$1"' _ "$gh_logG2" +check "G2: needs-human label add attempted (REST POST)" \ + grep -qF -- "-X POST repos/acme/repo/issues/16/labels --input -" "$gh_logG2" + +check "G3: GATES_FILE=absolute self-adapter path (exercises the /* branch) also BLOCKS" \ + bash -c 'printf "%s\n" "$1" | grep -q "\"reason\":\"protected-paths\""' _ "$outG3" +check "G3: no merge attempted for PR 16 (absolute-path adapter selection)" \ + bash -c '! grep -q "pr merge 16" "$1"' _ "$gh_logG3" +check "G3: needs-human label add attempted (REST POST) via absolute-path adapter" \ + grep -qF -- "-X POST repos/acme/repo/issues/16/labels --input -" "$gh_logG3" + echo "" if [ "$fail" -eq 0 ]; then echo "merge-ready.test.sh: PASS ($ok checks)" diff --git a/.claude/self/gates.json b/.claude/self/gates.json index 3268369..1e1250d 100644 --- a/.claude/self/gates.json +++ b/.claude/self/gates.json @@ -82,5 +82,7 @@ "_plan_note": "issue #100 — optional spec/plan gate. plan.gate: off (default; unchanged single-pass advance behavior, byte-identical census output) | label (gate only planned issues that ALSO carry the plan-first label) | always (gate every planned+module candidate). Unknown/missing value falls back to off. Read by loop-census.sh (derives each candidate's plan state from its labels: plan-approved -> gated-approved, else plan-review -> awaiting-owner (blocked on the owner, skipped for advance_ready/fallback_ready like a 'Blocked by' edge), else gated-by-mode -> needs-plan, else ungated), loop-tick.sh (passes advance_mode through as telemetry), and loop-event.sh (builds a PLAN-ONLY driver prompt for needs-plan, injects the approved plan as authoritative scope for gated-approved, and today's single-pass prompt for ungated/off). Left off here so this repo's own self-hosted loop keeps its existing single-pass behavior — see docs/USAGE.md.", "plan": { "gate": "off" - } + }, + "_protectedPaths_note": "issue #94 Layer 2 — protected-paths guard override for THIS self-hosted repo. The root adapter ships a protective default (.claude/**, .github/workflows/**, gates.json) so downstream adopters' control-plane files can't be auto-merged by an agent. But in reCode the harness files under .claude/ ARE the product — legitimate slices of issue #94 itself (this very guard edits .claude/scripts/merge-ready.sh) must be mergeable — so this repo overrides the list to EMPTY, which disables the guard for self-hosted merges (empty-means-skip). See docs/HARDENING.md.", + "protectedPaths": [] } diff --git a/docs/HARDENING.md b/docs/HARDENING.md index 8d9908d..a59714c 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -47,6 +47,22 @@ of them — and don't mistake the default (non-strict) sandbox for a cage. --- +### Protected-paths guard (issue #94 Layer 2) + +A deterministic backstop against an injected instruction that tries to get an agent to edit the loop's +own control plane and have that change auto-merged. The adapter (`.claude/gates.json`, resolved via +`GATES_FILE`) carries a `protectedPaths` array of glob patterns; any agent-authored PR whose diff touches +a matching path is never auto-merged — `merge-ready.sh` blocks the merge and labels the PR `needs-human` +regardless of owner approval or green CI, and reviewers hard-reject it outright (see +`.claude/agents/reviewer.md`). The shipped root adapter defaults this to a protective set +(`.claude/**`, `.github/workflows/**`, `gates.json`, `**/gates.json`) so downstream adopters' harness and +CI files can't be silently rewritten by an agent. This repo's own self-adapter +(`.claude/self/gates.json`) overrides it to an empty array, which disables the guard — reCode's harness +files under `.claude/` ARE the product, so legitimate slices of work must remain mergeable when the loop +runs self-hosted. A fuller writeup is deferred to follow-up issue #166. + +--- + ## Step 1 — Machine-local hardened profile (`settings.local.json`) Put the hardened, bypass-enabled config in **`.claude/settings.local.json`**, not the committed