Skip to content

Commit 204d95d

Browse files
Merge pull request #179 from robercano/feat/issue-94-protected-paths
feat(harness): protected-paths guard (issue #94 Layer 2)
2 parents 7a1909f + 4150b9e commit 204d95d

6 files changed

Lines changed: 333 additions & 5 deletions

File tree

.claude/agents/reviewer.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ If you touch GitHub at all (e.g. `gh pr diff`, `gh pr view`, `gh api`), route it
2828
- **correctness**: logic errors, edge cases, off-by-one, error handling, race conditions, broken invariants.
2929
If the task provides an APPROVED PLAN / authoritative scope (e.g. issue #100's plan gate), also verify
3030
the diff stays within it — a diff that exceeds the approved plan's declared files or approach is a
31-
valid reject under this lens ("exceeds approved scope").
31+
valid reject under this lens ("exceeds approved scope"). Protected-paths hard reject (issue #94 Layer
32+
2): if the diff touches any path matching the adapter's `protectedPaths` globs (`.claude/gates.json`,
33+
resolved via `GATES_FILE`), REJECT — an agent PR must not modify the loop's own control plane.
34+
EXCEPTION: if the adapter's `protectedPaths` is empty or absent (the self-hosting override), skip this
35+
check (the harness files are the product).
3236
- **tests**: do tests actually exercise the change? coverage of edge/failure paths? meaningful assertions, not just "it runs"? Run the test gate if needed.
3337
- **security**: injection, auth/access control, unsafe input, secrets, dependency risk, (for smart contracts) reentrancy/overflow/access — defer to the project security skill if configured.
3438
- **performance**: needless work, N+1, allocations, blocking calls, complexity regressions.

.claude/gates.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,5 +77,8 @@
7777
"commentFix": { "botAllowlist": [] },
7878

7979
"_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 `<!-- plan-gate:plan -->`) 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.",
80-
"plan": { "gate": "off" }
80+
"plan": { "gate": "off" },
81+
82+
"_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.",
83+
"protectedPaths": [".claude/**", ".github/workflows/**", "gates.json", "**/gates.json"]
8184
}

.claude/scripts/merge-ready.sh

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,13 @@ repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}"
4242
# shellcheck source=needs-human.sh
4343
if [ -f "$script_dir/needs-human.sh" ]; then . "$script_dir/needs-human.sh"; fi
4444
owner="${MERGE_APPROVER:-${repo%%/*}}" # the approver whose APPROVED review authorizes a merge
45-
gates="$root/.claude/gates.json"
45+
46+
# Adapter file: honor GATES_FILE (the self-host loop points at .claude/self/gates.json),
47+
# fall back to the shipped root adapter. Both merge.baseBranch and protectedPaths
48+
# (issue #94 Layer 2) are read from it, so the self-adapter's permissive protectedPaths
49+
# override applies when the loop runs self-hosted.
50+
gates_rel="${GATES_FILE:-.claude/gates.json}"
51+
case "$gates_rel" in /*) gates="$gates_rel";; *) gates="$root/$gates_rel";; esac
4652
base="$(node -e "try{const g=require('$gates');process.stdout.write((g.merge&&g.merge.baseBranch)||'main')}catch(e){process.stdout.write('main')}")"
4753

4854
# Decide MERGE / SKIP:<reason> for one PR's JSON (read on stdin).
@@ -82,13 +88,52 @@ decide() {
8288
' "$base" "$owner"
8389
}
8490

91+
# protected_paths_check (issue #94 Layer 2): reads the adapter's protectedPaths
92+
# globs and the PR's changed-file list (`.files[].path`, on stdin) and prints the
93+
# protected path(s) the diff touches (comma-joined), or nothing. Empty/absent
94+
# protectedPaths = disabled (prints nothing) — same empty-means-skip convention as
95+
# `notify`. Deterministic (no LLM); "*" matches one path segment, "**" any depth.
96+
protected_paths_check() {
97+
node -e '
98+
const gates = process.argv[1];
99+
let globs = [];
100+
try { const g = require(gates); if (Array.isArray(g.protectedPaths)) globs = g.protectedPaths; } catch (e) {}
101+
if (!globs.length) process.exit(0);
102+
let p; try { p = JSON.parse(require("fs").readFileSync(0, "utf8")); } catch (e) { process.exit(0); }
103+
const files = (p.files || []).map(f => f && f.path).filter(Boolean);
104+
function toRe(glob) {
105+
let re = "";
106+
for (let i = 0; i < glob.length; i++) {
107+
const c = glob[i];
108+
if (c === "*") {
109+
if (glob[i + 1] === "*") { re += ".*"; i++; if (glob[i + 1] === "/") i++; }
110+
else re += "[^/]*";
111+
} else if ("\\^$.|?+()[]{}".includes(c)) { re += "\\" + c; }
112+
else re += c;
113+
}
114+
return new RegExp("^" + re + "$");
115+
}
116+
const res = globs.map(toRe);
117+
const hits = files.filter(f => res.some(r => r.test(f)));
118+
if (hits.length) process.stdout.write([...new Set(hits)].join(", "));
119+
' "$gates"
120+
}
121+
85122
merged=0; skipped=0
86123
for n in $(gh pr list -R "$repo" --base "$base" --state open --json number -q '.[].number'); do
87-
data="$(gh pr view "$n" -R "$repo" --json number,title,isDraft,baseRefName,headRefName,mergeable,reviews,statusCheckRollup,commits)"
124+
data="$(gh pr view "$n" -R "$repo" --json number,title,isDraft,baseRefName,headRefName,mergeable,reviews,statusCheckRollup,commits,files)"
88125
verdict="$(printf '%s' "$data" | decide)"
89126
title="$(printf '%s' "$data" | node -e 'process.stdout.write((JSON.parse(require("fs").readFileSync(0,"utf8")).title)||"")')"
90127
head_branch="$(printf '%s' "$data" | node -e 'process.stdout.write((JSON.parse(require("fs").readFileSync(0,"utf8")).headRefName)||"")')"
91128

129+
# Protected-paths guard (issue #94 Layer 2): even an owner-approved, CI-green PR
130+
# must not auto-merge if its diff touches a path the adapter marks protected.
131+
protected_hit=""
132+
if [ "$verdict" = "MERGE" ]; then
133+
protected_hit="$(printf '%s' "$data" | protected_paths_check)"
134+
[ -n "$protected_hit" ] && verdict="SKIP:protected-paths"
135+
fi
136+
92137
# needs-human (issue #99): a PR is genuinely blocked on the OWNER for
93138
# exactly two of decide()'s skip reasons -- no review submitted yet, or a
94139
# 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 '.
112157
"PR #$n ready for your review" "$title ($reason_text)"
113158
fi
114159
;;
160+
SKIP:protected-paths)
161+
if command -v needs_human_flag >/dev/null 2>&1; then
162+
needs_human_flag "pr:$n" "protected-paths" "high" \
163+
"PR #$n touches protected paths -- human review required" \
164+
"$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."
165+
fi
166+
;;
115167
*)
116168
if command -v needs_human_clear >/dev/null 2>&1; then
117169
needs_human_clear "pr:$n" "pr-review"

0 commit comments

Comments
 (0)