@chrono-meta/fh-gate 1.4.80 → 1.4.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,13 +11,13 @@
11
11
  "plugins": [
12
12
  {
13
13
  "name": "fh-meta",
14
- "version": "1.4.80",
14
+ "version": "1.4.82",
15
15
  "description": "Hub meta-operations toolkit — 35 skills + 7 agents. New in 1.4.53: `fh-codex-doctor` (npm bin) — Codex adapter drift scanner; reads the documented M1/M2/M3 skill tier map + skill/agent source and reports codex-native/adapter-required/claude-native/unclassified per unit, wired into `npm test`/`prepublishOnly` (fail-closed on unclassified Claude-native primitives). New in 1.4.49: steel-quench gains Step 0.6 Verdict-Invariance Probe (groundedness axis — a load-bearing judged gate's verdict must track behavior, not rubric phrasing; measured flip-count over cross-family paraphrases; arXiv:2605.06161 Policy Invariance anchor); multi_model_sidecar_strategy §Vendor-native harness (a model is strongest in its own vendor CLI — Claude/CC, GPT/codex, Gemini/Antigravity; a universal router degrades all of them, so it stays an autocomplete/QA sidecar, never orchestration); predelete_check.sh fail-closed rewrite; memory-hygiene A-TMA anchor. New in 1.4.48: phantom-quench + steel-quench gain external frontier anchors (arXiv:2607.02052 package-hallucination; arXiv:2607.02057 prompt-coverage-adequacy); README model-flat claim reframed from a per-release point-curve to structural invariants (operation flattens across tiers; depth tier-order fixed within a generation). New in 1.4.47: onboarding step ① surfaces the Mode D companion-store session-start load in the auto-read salience anchor (previously only in the local binding + rules, so a greeting could skip the load). New in 1.4.46: context-doctor command-output axis (route to rtk/proxy for verbose CLI stdout, complementing .claudeignore; risk-gated to token-scarce envs). New in 1.4.41: context-doctor 2026 trigger vocab (context engineering/rot/collapse) + phantom-citation hardening; hub measurement-integrity-checklist (cross-model measurement pre-flight: display-name pin/reps≥3/discriminating probe). New in 1.4.40: install-wizard queryable-wiki scaffold (INDEX + session-start read + R/W/C ingest). New in 1.4.39: auto-decorrelation (cross-family verifier sidecar recruitment) + video-ingest (capability-routed video ingestion). New in 1.4.x: verify-axis check-class taxonomy (mandatory-pass/measured/judged), no-reinvention Tier-0 inventory, 7-class failure taxonomy, Destructive-Op Gate, Wave-T (Temper), tier-floor governance, Mode D Model Notice, FC consent lane, default-Sonnet guidance. New in 1.3.0: public-surface-audit, field-harvest Mode B auto-trigger, 4-axis gate scope ext. Validated cross-CLI: Claude Code, Codex, Gemini.",
16
16
  "source": "./plugins/fh-meta"
17
17
  },
18
18
  {
19
19
  "name": "fh-commons",
20
- "version": "1.4.80",
20
+ "version": "1.4.82",
21
21
  "description": "Project-agnostic utility skills — 4 skills (convergence-loop · deliberation · mcp-circuit-breaker · token-budget-gate) + 1 agent (quench-challenger). Domain-independent utilities transplantable into any project.",
22
22
  "source": "./plugins/fh-commons"
23
23
  }
package/CLAUDE.md CHANGED
@@ -313,10 +313,12 @@ pipeline**, not an afterthought, and a below-floor orchestrator RUNS the review
313
313
  ## Field-Harness Diagnostic — "진단해줘 / 개선해줘" on a mapped project (compose → rank → HITL)
314
314
 
315
315
  The **on-demand pull sibling** of the gate above: a *project-level* "diagnose / improve this harness" ask
316
- composes the checks FH **already has** across **six lenses** — leak (`/public-surface-audit`, incl. **Step 3c ignore-verification** — a file believed gitignored but actually tracked is the leak this sub-step exists to catch) · split
316
+ composes the checks FH **already has** across **seven lenses** — leak (`/public-surface-audit`, incl. **Step 3c ignore-verification** — a file believed gitignored but actually tracked is the leak this sub-step exists to catch) · split
317
317
  integrity (`/phantom-quench` **Step 2.7**) · token/salience (`/context-doctor` · `/salience-splitter`) · structure
318
318
  (`/harness-doctor`) · verdict degrade (`scripts/degrade_direction_scan.sh`) · loop-readiness
319
- (`loop_engineering.md`) into **one ranked `M`/`S`/`R` list**. No-reinvention: it only routes and ranks.
319
+ (`loop_engineering.md`) · built-but-unwired (per-module caller grep a completed module with zero
320
+ external call sites; the dominant class of the 2026-08-01 qasp audit) — into **one ranked `M`/`S`/`R`
321
+ list**. No-reinvention: it only routes and ranks.
320
322
 
321
323
  **Resident guards (do not defer these to the detail file)**: **nothing is auto-fixed** — the list is the
322
324
  skill's job, the *go* is the human's; and **company residency (absolute)** — **raw company source, secrets,
@@ -23,6 +23,8 @@ is HITL — the diagnostic **proposes**, never auto-edits.
23
23
  | **Verdict/gate degrade** | `scripts/degrade_direction_scan.sh` | a field verdict/gate helper that degrades toward permissive (advisory pre-screen) |
24
24
  | **Loop-readiness** (황민호 loop-eng 5-question lens, 2026-07-10 — detail home: `loop_engineering.md`, incl. the FH loop inventory + design-time discipline) | *Loop-runtime axis — net-new vs Structure* (harness-doctor scans static form; this scans whether the path closes a loop). **Mechanical grep**: `/goal-quench`·`/loop` wiring present · check-class token declared. **Judged**: is the persisted state (card/handoff/memory) actually reloaded · is the declared check-class anchored, not judged-only · does the path halt. Done-When *presence* → see Structure row (no double-grep). **Adversarial pair** (for the judged sub-checks — decorrelated, behavior-vs-checklist): a target-tier blind sim that *runs* the path and observes whether it halts + persists, rather than re-checklisting it (the harness litmus shares this lens's axis, so it is a co-lens, not the adversary). | an agent path that *runs but doesn't loop*: no completion criterion (Done-When absent), judged-only validation with no anchor, no halt/budget guard (runaway/cost), or no state carried to the next run — the 5 questions (initiate · complete · validate · halt · persist) with 0 answers |
25
25
 
26
+ | **Built-but-unwired scan** (2026-08-01, from the qasp full audit) | **Mechanical grep**: for each module/entry-point under the project's source root, count call/import sites outside its own file and tests — `grep -rn "module_name" --include="*.py" | grep -v "module_file\|tests/"` per module (adapt the include glob per language). 0 external callers on a *completed* module = a finding. **Judged**: is it staged-for-later (documented as such) or genuinely orphaned capability? | the dominant defect class of the 2026-08-01 qasp audit: most M/S prescriptions were not "build new" but **wire what exists** — ET·delivery·feasibility modules complete with ZERO callers. A harness that keeps building finished capability nobody invokes reads as progress while shipping none; harness-doctor's Structure lens sees orphaned *harness units*, this lens sees orphaned *product modules* (net-new axis) |
27
+
26
28
  > **Considered-and-held, 7th-lens candidate (2026-07-24)**: the context-quality 7-criteria rubric
27
29
  > (arXiv:2607.14275 — role clarity · guardrail coverage · instruction consistency · tool schema ·
28
30
  > grounding sufficiency · injection hardening · token efficiency) is **not** adopted as a lens: its
@@ -47,6 +47,25 @@ actually loads (e.g. `AGENTS.md` alone, no `CLAUDE.md`) and present a trap the g
47
47
  refuse; post-fix it refuses, citing the now-local gate. The behavioral delta *is* the proof of
48
48
  locality — review alone cannot show it (review reads all files; the runtime does not).
49
49
 
50
+ ## External anchors (independent evidence, added 2026-08-01)
51
+
52
+ - **[arXiv 2607.25398 — "HANDBOOK.md: A Benchmark for Long-Context Agentic Instruction Following"](https://arxiv.org/abs/2607.25398)**
53
+ (Panavas et al.): 65 agentic tasks governed by expert-written SOPs of 20–124 pages, 824 programmatic
54
+ criteria; **the best of thirty evaluated model configurations passes 36.2% of trials, most frontier
55
+ configurations below 25%**. This anchors the *adjacent, stronger* claim this principle rests on:
56
+ even a prose gate the actor **does** load under-enforces at scale — placement is necessary but the
57
+ enforcement *modality* (hook/code vs prose) decides whether it fires. Cross-operator, cross-provider
58
+ evidence that a prose-only control layer is substantially under-enforced at scale — the salience-layer-over-
59
+ mechanical-floor split (CLAUDE.md 4-axis gate "Why hook", `[[feedback_vcs_layer_gate_enforcement]]`)
60
+ is independently reproduced, not FH-idiosyncratic.
61
+ - **[arXiv 2607.08028 — "From Prompts to Contracts: Harness Engineering for Auditable Enterprise LLM Agents"](https://arxiv.org/abs/2607.08028)**
62
+ (Ahn · Kim): independent convergence on code-enforced behavioral contracts over prompt instructions;
63
+ their ablation shows code-owned enforcement blocks violations prompting alone permits. Cross-audit:
64
+ `tracks/_audit/session_2026_08_01_prompts-to-contracts-sister.md` (B-tier, 2 imports identified).
65
+
66
+ These are external anchors for the **prose-vs-mechanical premise**; the locality claim itself
67
+ (N=2, one operator) still awaits direct cross-operator confirmation as named above.
68
+
50
69
  ## Relationship to other FH assets
51
70
 
52
71
  - **steel-quench** carries this as a Wave-1 attack angle ("Gate-locality — is every safety gate
@@ -149,6 +149,31 @@ human-review floor is structural, not a transitional patch. (Its Faros AI number
149
149
  the author's own caveat travels with any citation.)
150
150
  "Aggressive" Codex/Gemini use is bounded by the fit task-class above, never a blanket main-seat swap.
151
151
 
152
+ ### Decorrelation axes — model family AND authoring context (measured 2026-08-01)
153
+
154
+ Decorrelation has been treated as one axis (model family). A codex-outage session measured a second,
155
+ independent axis: **authoring-context share**. A 3-leg panel (agy/Gemini + local qwen3.6:35b + a
156
+ **same-family Claude challenger in an isolated context**) ran 3 rounds over qasp changes; the
157
+ distribution was sharp — the cheap local leg produced only refutable findings (2/2 source-rejected),
158
+ the cross-family leg caught real HIGHs, and the **context-independent same-family leg caught the
159
+ subtlest class** (a just-closed false-negative re-opened by an empty-canonical-key fall-through ·
160
+ a fabricated expected value · a doc insertion truncating a bullet). Author-optimism largely falls
161
+ away when the *context* is cut, even with the family held constant.
162
+
163
+ Operational rules this adds:
164
+ - A same-family isolated-context leg is a **legitimate decorrelation leg**, not a degraded
165
+ substitute — count it as one axis covered (context), with family still uncovered. The honest
166
+ disclosure when cross-family is unreachable is "family axis uncovered", not "no decorrelation".
167
+ - The two axes compose: cross-family + context-independent is stronger than either alone. The
168
+ 2026-08-01 follow-up run confirmed composition empirically — a GPT leg (OpenRouter, operator-directed
169
+ codex substitute) surfaced whole defect classes that both a Gemini leg and a context-independent
170
+ Claude leg had passed over (parser-bypass construction paths, duplicate-JSON-key ambiguity), while
171
+ the earlier legs had caught classes GPT did not re-find.
172
+ - **Review-target pinning corollary** (accidental pattern, worth reproducing deliberately): pin the
173
+ review target to a COMMIT while fixes proceed uncommitted in parallel — two legs then cross-confirm
174
+ each other when their findings independently converge on the same defects, converting duplication
175
+ into validation instead of waste.
176
+
152
177
  ### Vendor-native harness — the main layer stays multi-CLI, never Copilot-consolidated
153
178
 
154
179
  The harness-depth thesis has a **per-vendor corollary**: a frontier model realizes its *highest effective
@@ -594,3 +594,17 @@
594
594
  outcome: accepted
595
595
  evidence: "Two findings, both closed. (1) The node check was unreachable on the fresh-machine case because it lived in a gitignored registration — drove the split into scripts/fh_node_check.sh + a tracked snippet. (2) The wizard wrote a literal '<your-store>' placeholder and reported success, leaving a dead hook — the real BE_DIR is now baked in at write time."
596
596
  note: "Sonnet tier was the right instrument here: both findings are about instructions being followed literally, which is exactly where a weaker tier diverges from an author's intent."
597
+ - date: 2026-08-01
598
+ agent: fh-meta:challenger
599
+ task: "Axis-2 adversarial review of destructive_pre_gate.sh + lanes + snippet + gate_locality anchors"
600
+ mode: isolated Agent, fable-5, paired with cross-family agy sidecar (below)
601
+ outcome: accepted
602
+ evidence: "2 HIGH + 5 MED + 1 LOW, every finding probe-measured (not asserted). HIGH #1 (git -C bypass) explicitly tied to this environment's own trained command shape. Re-fetched both arXiv abstracts independently — figures double-sourced. One MED (#9) corrected the knowledge doc's rhetoric ('decorative'→'under-enforced'). Its noqa-self-grant observation was accepted as a design property, not a defect."
603
+ note: "The guard fired live on the challenger's own probe command mid-review — the mention-as-data FP class demonstrating itself inside the review that named it."
604
+ - date: 2026-08-01
605
+ agent: agy sidecar (Gemini 3.1 Pro (High), display-name pinned)
606
+ task: "Cross-family review x3: destructive_pre_gate (R1), qasp verdict_computer (R1), qasp fixes (R2 convergence)"
607
+ mode: headless agy -p, prompt-inlined sources (public/dev-track content only)
608
+ outcome: accepted
609
+ evidence: "Guard R1: 7 bypass + 3 FP classes, JSON-contract violations 0; 3 findings overlapped challenger = independent convergence; 1 suggestion (start-anchoring) refuted with recorded reason, later PARTIALLY vindicated by live prose FPs — resolved with leading-separator class instead of command-position anchor. qasp R1: HIGH status-enum hole independently converged with governor read; core_override_flag requiredness grounded in contract §105 before acting. R2: 5/5 CLOSED, CONVERGED."
610
+ note: "codex unavailable (~8/6); agy carried the cross-family leg for both FH and qasp reviews, mirroring the 07-31 panel precedent."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrono-meta/fh-gate",
3
- "version": "1.4.80",
3
+ "version": "1.4.82",
4
4
  "description": "FH runtime adapters — run FH governance, skills, and agents via Claude or Codex with machine-parseable gates.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -137,6 +137,10 @@
137
137
  "scripts/test_sidecar_calibrate_lanes.sh",
138
138
  "scripts/pipe_verdict_guard.sh",
139
139
  "scripts/test_pipe_verdict_guard_lanes.sh",
140
+ "scripts/test_destructive_pre_gate_lanes.sh",
141
+ "scripts/destructive_pre_gate.sh",
142
+ "scripts/test_stale_clone_guard_lanes.sh",
143
+ "scripts/stale_clone_guard.sh",
140
144
  "templates/settings.PreToolUse.snippet.json",
141
145
  "scripts/halffix_propagation_scan.sh",
142
146
  "scripts/test_halffix_lanes.sh",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-commons",
3
- "version": "1.4.80",
3
+ "version": "1.4.82",
4
4
  "engines": {
5
5
  "claudeCode": ">=1.0.0"
6
6
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-meta",
3
- "version": "1.4.80",
3
+ "version": "1.4.82",
4
4
  "engines": {
5
5
  "claudeCode": ">=1.0.0"
6
6
  },
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env bash
2
+ # destructive_pre_gate.sh — PreToolUse(Bash) advisory: a destructive command about to run.
3
+ #
4
+ # THE DEFECT
5
+ # A session under ship pressure self-justifies a destructive git/rm command and runs it in the
6
+ # same breath ("contents are identical, so reset --hard is safe"). Observed 2026-08-01 on a
7
+ # below-floor sidecar (GLM 4-bit): self-justification THEN `git reset --hard origin/main` — the
8
+ # weaker the tier, the weaker the self-inhibition, so the counterweight must be mechanical, not
9
+ # prose. Origin prototype: pmh-dev scripts/destructive_pre_gate.sh (field→meta reverse
10
+ # propagation, hardened here to the pipe_verdict_guard pattern).
11
+ #
12
+ # ROLE DECONFLICTION (no double-gate)
13
+ # templates/.git-hooks/pre-push already HARD-BLOCKS the git-side irreversible surface at push
14
+ # time (force/non-ff push, remote branch delete — DESTRUCTIVE_OP_OK channel). This guard is the
15
+ # shift-left SALIENCE layer at the Bash-call surface, and its net-new coverage is the LOCAL
16
+ # destructive ops no hook currently sees: reset --hard, clean -f, checkout ., stash drop/clear,
17
+ # rm -rf on root/home/cwd. For force-push it fires earlier than pre-push but never replaces it.
18
+ #
19
+ # DEGRADE DIRECTION: advisory. Warns and exits 0 — a false block on a developer's shell trains
20
+ # the --no-verify reflex on the hooks that DO guard irreversible surfaces. Set
21
+ # FH_DESTRUCTIVE_BLOCK=1 to escalate to exit 2 (stderr → model, call blocked).
22
+ # Unparseable/absent payload → silent exit 0 (an unparsed input is not a finding; block mode
23
+ # fails open on a dead python3 — same accepted residual as pipe_verdict_guard, the surface is
24
+ # the reversible Bash call, not the irreversible act itself).
25
+ #
26
+ # PRECISION OVER RECALL (named residuals, deliberate — the full list, per the Axis-2 pass 2026-08-01)
27
+ # NOT flagged, by decision or by construction:
28
+ # - `rm -rf <absolute path>` other than /, ~, . — scratchpad/build cleanup lives there and 100%
29
+ # FP trains dismissal of the one hit that matters (pipe_verdict S5 lesson).
30
+ # - `git rebase` — reflog-recoverable, and interactive rebase is unsupported here anyway.
31
+ # - Env-var / eval indirection (`rm -rf $HOME`, `OPT=--hard; git reset $OPT`), `find -delete`,
32
+ # `xargs rm -rf`, shell aliases — unreachable by a regex over the literal command by
33
+ # construction; a parser would be over-build for an advisory layer. Registered, not closed.
34
+ # - Mention-as-data FP: heredoc bodies, `echo "git reset --hard"`, `git log -S '…'` DO fire —
35
+ # the flatten step cannot tell code from quoted data (measured live: the guard fired on the
36
+ # Axis-2 reviewer's own probe command). Advisory-tolerated; use `# noqa: destructive-op` when
37
+ # editing/documenting this guard itself.
38
+ # - Two JSON-emitting hooks on one matcher (this + pipe_verdict_guard): per the hook docs each
39
+ # hook's stdout is parsed independently; simultaneous-fire not runtime-verified (LOW).
40
+ # - noqa smuggling: the opt-out is checked against the WHOLE command, so a multi-statement
41
+ # payload with a destructive line 1 and a `# noqa: destructive-op` on line 2 exempts both.
42
+ # Accepted: noqa is self-grantable by design on this advisory surface (same trust channel as
43
+ # the DESTRUCTIVE_OP_OK env ack) — the hard floor for the irreversible half stays pre-push.
44
+ # - Shell-escape reconstruction (`\g\i\t reset --hard`, `$'git' reset`) and quoted-space git -C
45
+ # paths (`git -C "/tmp/my repo" reset --hard`) — regex-with-normalization cannot dequote like
46
+ # a shell; a Bash-compatible tokenizer would be over-build for an advisory layer (GPT-round,
47
+ # registered). Deliberate-obfuscation is outside this guard's threat model anyway: it counters
48
+ # SELF-JUSTIFICATION, not adversarial evasion.
49
+ # - Block mode (FH_DESTRUCTIVE_BLOCK=1) is an ESCALATED ADVISORY, not an enforcement boundary:
50
+ # malformed payload / dead python3 / grep processing failure all fail open by the documented
51
+ # degrade direction (reversible surface). A project needing a hard floor on these ops wires a
52
+ # PreToolUse permission rule or server-side protection, not this guard (GPT-round, pinned by
53
+ # the fault-injection lanes).
54
+ #
55
+ # DELIVERY CHANNEL: advisory emits JSON on stdout — hookSpecificOutput.additionalContext (model)
56
+ # + systemMessage (user). permissionDecision DELIBERATELY ABSENT: the guard must never
57
+ # auto-approve what it exists to question (PR#217 contract, memory
58
+ # feedback_advisory_invisible_to_actor).
59
+ #
60
+ # Usage:
61
+ # hook: PreToolUse matcher "Bash" → bash scripts/destructive_pre_gate.sh
62
+ # test: printf '%s' "<command>" | bash scripts/destructive_pre_gate.sh --stdin-raw
63
+ # Opt out on a single command with a trailing `# noqa: destructive-op`.
64
+
65
+ set -u
66
+
67
+ CMD=""
68
+ if [ "${1:-}" = "--stdin-raw" ]; then
69
+ CMD=$(cat)
70
+ else
71
+ RAW=$(cat)
72
+ CMD=$(printf '%s' "$RAW" | python3 -c '
73
+ import json,sys
74
+ try: d = json.load(sys.stdin)
75
+ except Exception: sys.exit(0)
76
+ if d.get("tool_name") != "Bash": sys.exit(0)
77
+ sys.stdout.write(d.get("tool_input", {}).get("command", "") or "")
78
+ ' 2>/dev/null) || CMD=""
79
+ fi
80
+ [ -n "$CMD" ] || exit 0
81
+
82
+ # Explicit opt-out, checked before any rule.
83
+ printf '%s' "$CMD" | LC_ALL=C grep -qE '#[[:space:]]*noqa:?[[:space:]]*destructive-op' && exit 0
84
+
85
+ # Flatten to ONE line (grep is line-oriented; multi-line commands are the recurring shape) and pad
86
+ # with spaces so end-of-string flags match uniformly (` -f` at line end == ` -f `).
87
+ # Normalizations, each closing a measured bypass (cross-family Axis-2 pass, 2026-08-01):
88
+ # backslash-newline joined → `rm -rf \` + newline + `/` no longer splits the statement (A1)
89
+ # \r stripped → CRLF payloads no longer break the `[ ;]` terminators (A10)
90
+ # quotes stripped → `rm -rf "/"` matches like its unquoted twin (A3; mention-as-data
91
+ # FP surface unchanged — that class fires with or without quotes)
92
+ # tabs → spaces, squeezed → `git reset --hard` (double space) matches (A2)
93
+ # Anchoring: rows require a leading separator `[ (/]` before the command word (NOT command-position
94
+ # anchoring — sudo/env/xargs wrappers keep a space before the word and stay caught). This closes the
95
+ # measured prose FP where "Confi**rm** each … -toward-PASS … *" satisfied the rm row through a word
96
+ # TAIL (fired live twice on this guard's own review commands, 2026-08-01). Full command-position
97
+ # anchoring stays REJECTED (would drop sudo/env wrappers); `/` in the class keeps `/bin/rm` caught.
98
+ # GPT-round (R4) semantics fixes, each lane-pinned:
99
+ # backslash-newline joins with EMPTY string, matching shell semantics — a space here let
100
+ # `git re\` + newline + `set --hard` split the token and miss (measured bypass).
101
+ # Separators `&& || | & ( )` are space-padded so `:&&git reset --hard` and `--force|cat`
102
+ # satisfy the leading/trailing boundary classes without whitespace in the original.
103
+ JOINED="${CMD//\\$'\n'/}"
104
+ FLAT=" $(printf '%s' "$JOINED" | tr -d '\r' | tr '\n' ';' | sed 's/;/; /g' \
105
+ | sed -E 's/(\&\&|\|\||[|&()])/ \1 /g' | tr -d '"'"'" | tr '\t' ' ' | tr -s ' ') "
106
+
107
+ # Normalize git global options down to bare `git` BEFORE matching — the Axis-2 pass measured
108
+ # `git -C /repo reset --hard` sailing through every git row, and this environment specifically
109
+ # TRAINS that shape ("cwd resets between calls, use absolute paths / git -C"), so the origin
110
+ # defect (a self-justifying model) would most naturally emit exactly it. GPT-round extended the
111
+ # consumed set: --no-pager, -c k=v, -p/-P, and the space-separated --git-dir/--work-tree forms.
112
+ FLAT=$(printf '%s' "$FLAT" | sed -E 's/git( +(-C +[^ ;]+|--git-dir[= ][^ ;]+|--work-tree[= ][^ ;]+|-c +[^ ;]+|--no-pager|-[pP]))+/git/g')
113
+
114
+ # Dry-run neutralizer: `git clean -fdn` / `--dry-run` is non-destructive; rewrite it to a token no
115
+ # row matches, so the clean row cannot FP on a dry run (Axis-2 #6 — an FP here violates the very
116
+ # S5 lesson the header cites). GPT-round scoping: only OPTION tokens may sit between `clean` and
117
+ # the dry-run flag — a nested `"$(echo -n build)"` or a post-`--` pathspec `-n` no longer
118
+ # neutralizes a real deletion (`--` itself is not matched by the option-token group, so
119
+ # consumption stops there by construction).
120
+ FLAT=$(printf '%s' "$FLAT" | sed -E 's/git clean( +--?[a-zA-Z][a-zA-Z=-]*)* +(-[a-zA-Z]*n[a-zA-Z]*|--dry-run)( +--?[a-zA-Z][a-zA-Z=-]*)*/git clean DRYRUN/g')
121
+
122
+ # cwd-glob canonicalization: `././*` and friends collapse to `./*` so the rm row's target
123
+ # alternation sees the canonical spelling (GPT-round; brace-expansion globs stay a residual).
124
+ FLAT=$(printf '%s' "$FLAT" | sed -E 's/(\.\/)+/.\//g')
125
+
126
+ # ── Destructive pattern table: regex@@description ─────────────────────────────────────────────
127
+ # Delimiter is @@ because the regexes themselves carry `|` (alternation) — a `|` delimiter
128
+ # truncated every alternation-bearing pattern at split time (caught by the known-pair lanes on
129
+ # first run, 2026-08-01: 25/46 lanes failed with "brackets not balanced" before any live use).
130
+ # Matched with grep -E against the padded FLAT. Keep each pattern precise; add a lane pair in
131
+ # scripts/test_destructive_pre_gate_lanes.sh for every row you add (known-pair rule).
132
+ # GPT-round (R4) token-boundary discipline: every destructive OPTION must start its own token
133
+ # (preceded by a space) — `docs--hard`, `release--force`, `--exclude=cache`'s x, and
134
+ # `rm --verbose`'s r no longer satisfy rows through substrings of unrelated tokens. Long-form
135
+ # spellings the boundary would orphan are added explicitly (--force for clean, --recursive for
136
+ # rm, --force-with-lease=<ref>, worktree -f, restore -W).
137
+ PATTERNS=(
138
+ '[ (/]git reset ([^|;&]* )?--hard[ ;]@@git reset --hard discards ALL uncommitted changes irreversibly'
139
+ '[ (/]git clean ([^|;&]* )?(--force[ ;]|-[a-zA-Z]*[fxX][a-zA-Z]*[ ;])@@git clean -f/-x permanently deletes untracked files'
140
+ '[ (/]git checkout ([^|;&]*-- )?\.\/? @@git checkout . reverts every local modification'
141
+ '[ (/]git restore ([^|;&]* )?(--worktree|-[a-zA-Z]*W[a-zA-Z]*[ ;])@@git restore --worktree reverts working-tree changes'
142
+ '[ (/]git restore \.\/? @@git restore . reverts every local modification'
143
+ '[ (/]git push ([^|;&]* )?(--force(-with-lease(=[^ ;]+)?)?[ ;]|-[a-zA-Z]*f[a-zA-Z]*[ ;]|\+[^ ;]+[ ;])@@force push rewrites remote history (pre-push hook will also gate this — enumerate first)'
144
+ '[ (/]git branch ([^|;&]* )?(-[a-zA-Z]*D[ ;]|--delete( [^|;&]*)? --force[ ;]|--force( [^|;&]*)? --delete[ ;])@@git branch -D force-deletes a branch without merge check'
145
+ '[ (/]git stash (drop|clear)[ ;]@@git stash drop/clear permanently discards stashed work'
146
+ '[ (/]git worktree remove ([^|;&]* )?(--force[ ;]|-[a-zA-Z]*f[a-zA-Z]*[ ;])@@git worktree remove --force discards a dirty worktree'
147
+ '[ (/]rm ([^|;&]* )?(--recursive|-[a-zA-Z]*[rR][a-zA-Z]*)[^|;&]* (/\*?|~/?|\.\/?\*?|\*)[ ;]@@rm -rf on root/home/cwd/glob deletes irreplaceably'
148
+ '[ (/]rm ([^|;&]* )?--no-preserve-root@@rm --no-preserve-root is never routine'
149
+ )
150
+
151
+ hits=""
152
+ for entry in "${PATTERNS[@]}"; do
153
+ pattern="${entry%%@@*}"
154
+ desc="${entry#*@@}"
155
+ # LC_ALL=C: an inherited locale must never change what an ASCII pattern matches (the collation
156
+ # class killed a sibling probe silently — card-drift 2026-07-31; patterns here are ASCII-only).
157
+ if printf '%s' "$FLAT" | LC_ALL=C grep -qE "$pattern"; then
158
+ hits="${hits} ⚠️ DESTRUCTIVE-OP ${desc}
159
+ "
160
+ fi
161
+ done
162
+ [ -n "$hits" ] || exit 0
163
+
164
+ hits="${hits} Before running: is uncommitted/stashed state enumerated (git status / predelete_check.sh)?
165
+ Destructive-Op Gate order: enumerate → recover → destroy — never destroy-then-check.
166
+ Intentional and reviewed → re-run with trailing \`# noqa: destructive-op\`."
167
+
168
+ if [ "${FH_DESTRUCTIVE_BLOCK:-0}" = "1" ]; then
169
+ # Block mode: exit 2 = stderr fed to the model as the blocking reason; stdout ignored by contract.
170
+ printf '%s\n' "$hits" >&2
171
+ printf '%s\n' " [BLOCKED] FH_DESTRUCTIVE_BLOCK=1 — unset or add noqa after review to proceed." >&2
172
+ exit 2
173
+ fi
174
+
175
+ # Advisory mode: JSON on stdout, exit 0. Capture-then-emit; PYTHONIOENCODING pinned because the
176
+ # text carries non-ASCII (⚠️) and an inherited ascii codec was measured to kill the emission
177
+ # (pipe_verdict_guard lane C4). python3 owns escaping; on failure degrade to stderr (delivery
178
+ # lost, call undisturbed).
179
+ if json_out=$(printf '%s' "$hits" | PYTHONIOENCODING=utf-8 python3 -c '
180
+ import json, sys
181
+ h = sys.stdin.read()
182
+ print(json.dumps({
183
+ "systemMessage": h,
184
+ "hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": h},
185
+ }))
186
+ ' 2>/dev/null) && [ -n "$json_out" ]; then
187
+ printf '%s\n' "$json_out"
188
+ exit 0
189
+ fi
190
+ printf '%s' "$hits" >&2
191
+ exit 0
@@ -29,6 +29,26 @@
29
29
  # reflexes on hooks that DO guard irreversible surfaces. Set FH_PIPE_VERDICT_BLOCK=1 to escalate.
30
30
  # If the command cannot be extracted, it stays silent: an unparsed input is not a finding.
31
31
  #
32
+ # DELIVERY CHANNEL (added 2026-07-31 — closes N=8: detection 100%, delivery 0%)
33
+ # The first shipped version wrote its warning to stderr and exited 0. Per the hook contract
34
+ # (code.claude.com/docs/en/hooks), on exit 0 stderr is IGNORED and stdout is parsed for JSON —
35
+ # so the warning reached the transcript, never the model. The actor making the mistake never saw
36
+ # the guard fire (measured: the guard's own author repeated the guarded mistake twice the day
37
+ # after shipping it, N=7/N=8).
38
+ # Advisory now emits JSON on stdout: `hookSpecificOutput.additionalContext` (wrapped by CC in a
39
+ # system-reminder and injected into MODEL context) + top-level `systemMessage` (shown to the
40
+ # USER). Two audiences, two fields, one emission.
41
+ # ⚠️ `permissionDecision` is DELIBERATELY ABSENT: emitting "allow" alongside the warning would
42
+ # auto-approve the flagged command past the permission system — the guard must never grant what
43
+ # it exists to question. Block mode keeps the exit-2 path (stderr → fed to Claude, call blocked);
44
+ # on exit 2 stdout/JSON is ignored by contract, so stderr is the correct channel there.
45
+ # NAMED RESIDUAL (cross-family, 2026-07-31): with python3 broken/absent, payload extraction
46
+ # yields CMD="" and the guard exits 0 even under FH_PIPE_VERDICT_BLOCK=1 — block mode fails
47
+ # open on a dead interpreter. Accepted, not fixed: the Bash-call surface is re-runnable
48
+ # (reversible → degrade-to-advisory per the Surface-Class Degrade Invariant), and the
49
+ # alternative — exit 2 whenever python3 is missing — would block EVERY Bash call on such a
50
+ # machine, the exact false-block storm the advisory design exists to avoid.
51
+ #
32
52
  # Usage:
33
53
  # hook: PreToolUse matcher "Bash" → bash scripts/pipe_verdict_guard.sh
34
54
  # test: printf '%s' "<command>" | bash scripts/pipe_verdict_guard.sh --stdin-raw
@@ -88,6 +108,32 @@ fi
88
108
 
89
109
  [ -n "$hits" ] || exit 0
90
110
 
111
+ if [ "${FH_PIPE_VERDICT_BLOCK:-0}" = "1" ]; then
112
+ # Block mode: exit 2 = stderr fed to the model as the blocking reason; stdout ignored by contract.
113
+ printf '%s' "$hits" >&2
114
+ exit 2
115
+ fi
116
+
117
+ # Advisory mode: JSON on stdout, exit 0. additionalContext → model, systemMessage → user.
118
+ # NO permissionDecision — see DELIVERY CHANNEL header. python3 owns the JSON escaping; if it
119
+ # fails, degrade to the old stderr emission (delivery lost, but the call is never disturbed).
120
+ # Capture-then-emit, not stream: a producer that prints partial output and dies must never leave
121
+ # half a JSON object on the hook's stdout (cross-family finding, 2026-07-31). The codec pin must
122
+ # be PYTHONIOENCODING itself — the hits text carries non-ASCII (⚠️), an inherited
123
+ # PYTHONIOENCODING=ascii was measured to kill the emission (silently restoring the exact delivery
124
+ # loss this rewrite closes), and PYTHONUTF8=1 does NOT win over an explicit PYTHONIOENCODING
125
+ # (measured by lane C4: the UTF8-mode pin still emitted 0 bytes under the ascii env).
126
+ json_out=$(printf '%s' "$hits" | PYTHONIOENCODING=utf-8 python3 -c '
127
+ import json, sys
128
+ h = sys.stdin.read()
129
+ print(json.dumps({
130
+ "systemMessage": h,
131
+ "hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": h},
132
+ }))
133
+ ' 2>/dev/null)
134
+ if [ -n "$json_out" ]; then
135
+ printf '%s\n' "$json_out"
136
+ exit 0
137
+ fi
91
138
  printf '%s' "$hits" >&2
92
- if [ "${FH_PIPE_VERDICT_BLOCK:-0}" = "1" ]; then exit 2; fi
93
139
  exit 0
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # stale_clone_guard.sh — PreToolUse(Write) advisory: building on a stale clone.
3
+ #
4
+ # THE DEFECT (fh_signal_2026-07-31_qasp-wiring)
5
+ # found→extend is wired at the artifact/wiki level (R1-b), but nothing measures the LOCAL CLONE's
6
+ # freshness before a build starts. Measured 2026-07-31: a session built an act2 router on a qasp
7
+ # clone that was 46+ PRs behind origin — the tie-break lane it built already existed upstream, so
8
+ # it manufactured a partial duplicate. The session-start load refreshes only the hub's own
9
+ # companion store — mapped project clones are nobody's job, hence this guard.
10
+ #
11
+ # DESIGN
12
+ # Trigger = Write (file creation — the mechanical proxy for "build work entry"), NOT session
13
+ # start: fetching every mapped repo at session start is over-cost; the signal names work-entry
14
+ # as the correct trigger. Throttle: once per repo per day (marker in TMPDIR) — the freshness
15
+ # question is answered once, not per file.
16
+ # Advisory contract identical to pipe_verdict_guard/destructive_pre_gate: stdout JSON
17
+ # additionalContext (model) + systemMessage (user), NO permissionDecision, exit 0 always.
18
+ # A guard about freshness must never block writing — it surfaces `behind N`, the session decides
19
+ # (fetch may legitimately be skipped mid-hotfix).
20
+ #
21
+ # DEGRADE DIRECTION: fail-open everywhere — non-repo path, no upstream, fetch failure/timeout,
22
+ # dead python3, detached HEAD: all silent exit 0. A freshness advisory that blocks or errors
23
+ # trains hook resentment; the irreversible surfaces have their own gates.
24
+ #
25
+ # Test hooks (lanes): FH_STALE_CLONE_NO_FETCH=1 skips the network fetch (fixture repos already
26
+ # carry their remote state); FH_STALE_CLONE_MARKER_DIR overrides the throttle dir.
27
+ #
28
+ # NAMED RESIDUALS (GPT pass 2026-08-01 — registered, not fixed; advisory proportionality):
29
+ # day-scale throttle means an upstream advance AFTER the day's check goes unseen until tomorrow
30
+ # (the incident class is day-scale, deliberate cost trade) · fork-canonical baseline (current
31
+ # with origin/feature yet behind upstream/main) unmeasured — @{u} is the baseline · no-upstream /
32
+ # detached HEAD silent · prune-free fetch can report a deleted upstream · nested-repo/submodule
33
+ # ownership ambiguity on missing path levels · concurrent-Write double-fetch (duplicate advisory,
34
+ # harmless) · CRC marker-key collision · dead python3 → inert (consistent with sibling guards).
35
+
36
+ set -u
37
+
38
+ RAW=$(cat)
39
+ FILE=$(printf '%s' "$RAW" | python3 -c '
40
+ import json,sys
41
+ try: d = json.load(sys.stdin)
42
+ except Exception: sys.exit(0)
43
+ if d.get("tool_name") != "Write": sys.exit(0)
44
+ sys.stdout.write(d.get("tool_input", {}).get("file_path", "") or "")
45
+ ' 2>/dev/null) || FILE=""
46
+ [ -n "$FILE" ] || exit 0
47
+
48
+ # Creation-only scope (GPT pass): "build entry" means a NEW file — overwrites of existing files
49
+ # (generated output, doc touch-ups, conflict repair) neither fetch nor warn.
50
+ [ -e "$FILE" ] && exit 0
51
+
52
+ # Walk up to the nearest EXISTING ancestor — a Write may create several missing levels at once.
53
+ DIR=$(dirname "$FILE")
54
+ while [ ! -d "$DIR" ] && [ "$DIR" != "/" ] && [ -n "$DIR" ]; do
55
+ DIR=$(dirname "$DIR")
56
+ done
57
+ [ -d "$DIR" ] || exit 0
58
+ ROOT=$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null) || exit 0
59
+
60
+ # Throttle: one SUCCESSFUL check per repo per day. The marker is written only after a completed
61
+ # comparison (GPT pass: an attempt-marker turned every early-exit into a day-long silence).
62
+ # Default marker home is TMPDIR (user-private 0700 on macOS); the explicit subdir is created 0700
63
+ # so a /tmp fallback is not other-user-writable.
64
+ umask 077
65
+ MARKER_DIR="${FH_STALE_CLONE_MARKER_DIR:-${TMPDIR:-/tmp}/fh-stale-clone}"
66
+ mkdir -p "$MARKER_DIR" 2>/dev/null || exit 0
67
+ MARKER="$MARKER_DIR/$(printf '%s' "$ROOT" | cksum | cut -d' ' -f1)-$(date +%Y%m%d)"
68
+ [ -e "$MARKER" ] && exit 0
69
+
70
+ UPSTREAM=$(git -C "$ROOT" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null) || exit 0
71
+ REMOTE="${UPSTREAM%%/*}" # fetch the remote @{u} actually tracks, not a hardcoded origin (GPT pass)
72
+ [ -n "$REMOTE" ] || exit 0
73
+
74
+ if [ "${FH_STALE_CLONE_NO_FETCH:-0}" != "1" ]; then
75
+ # Bounded fetch: GIT_TERMINAL_PROMPT=0 kills the credential-prompt hang class; the hook-level
76
+ # timeout is the hard bound for wedged transports (macOS ships no GNU timeout).
77
+ GIT_TERMINAL_PROMPT=0 git -C "$ROOT" fetch -q "$REMOTE" 2>/dev/null || exit 0
78
+ fi
79
+
80
+ BEHIND=$(git -C "$ROOT" rev-list --count 'HEAD..@{u}' 2>/dev/null) || exit 0
81
+ case "$BEHIND" in ''|*[!0-9]*) exit 0 ;; esac
82
+ : > "$MARKER" 2>/dev/null || true # comparison completed — only now does the day-throttle arm
83
+ [ "$BEHIND" -gt 0 ] || exit 0
84
+
85
+ MSG=" ⚠️ STALE-CLONE $ROOT is behind $UPSTREAM by $BEHIND commit(s).
86
+ Building on a stale clone manufactures duplicates of work that already exists upstream
87
+ (measured: a 46-PR-behind clone rebuilt an existing lane, 2026-07-31).
88
+ Before creating new files here: git -C $ROOT pull --ff-only (or rebase your branch).
89
+ found→extend applies at repo level too — check what upstream already has."
90
+
91
+ if json_out=$(printf '%s' "$MSG" | PYTHONIOENCODING=utf-8 python3 -c '
92
+ import json, sys
93
+ h = sys.stdin.read()
94
+ print(json.dumps({
95
+ "systemMessage": h,
96
+ "hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": h},
97
+ }))
98
+ ' 2>/dev/null) && [ -n "$json_out" ]; then
99
+ printf '%s\n' "$json_out"
100
+ exit 0
101
+ fi
102
+ printf '%s\n' "$MSG" >&2
103
+ exit 0
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env bash
2
+ # test_destructive_pre_gate_lanes.sh — known pairs for scripts/destructive_pre_gate.sh
3
+ #
4
+ # LANES FIRST (2026-07-30 harvest #1): write the lane before touching the detector — rounds that
5
+ # did had zero fix-reverts-fix regressions. Every PATTERNS row in the guard owes a HIT lane and at
6
+ # least one adjacent CLEAN lane (the known-pair rule: an instrument that cannot separate a case you
7
+ # already know the answer to is not measuring, it is generating).
8
+ #
9
+ # WHAT IS BEING GUARDED
10
+ # A destructive command self-justified and executed in one breath (origin: GLM 4-bit sidecar ran
11
+ # `git reset --hard origin/main` after declaring it "safe", 2026-08-01, pmh-dev prototype).
12
+ # Advisory by default; FH_DESTRUCTIVE_BLOCK=1 escalates. The FP lanes matter as much as the HIT
13
+ # lanes: noise trains dismissal of the one warning that will matter (pipe_verdict S5 lesson).
14
+
15
+ set -u
16
+ G="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/destructive_pre_gate.sh"
17
+ pass=0; fail=0
18
+
19
+ # expect <label> <expected: HIT|CLEAN> <command-string>
20
+ expect() {
21
+ local label="$1" want="$2" cmd="$3" out got
22
+ out=$(printf '%s' "$cmd" | bash "$G" --stdin-raw 2>&1)
23
+ if printf '%s' "$out" | grep -q 'DESTRUCTIVE-OP'; then got=HIT; else got=CLEAN; fi
24
+ if [ "$got" = "$want" ]; then
25
+ printf ' ✅ %-52s %s (expected %s)\n' "$label" "$got" "$want"; pass=$((pass+1))
26
+ else
27
+ printf ' ❌ %-52s %s (expected %s)\n' "$label" "$got" "$want"; fail=$((fail+1))
28
+ printf ' cmd: %s\n out: %s\n' "$cmd" "$out"
29
+ fi
30
+ }
31
+
32
+ echo "[destructive-pre-gate] known pairs"
33
+ echo "-- git working-tree destroyers --"
34
+ expect "reset --hard (the measured origin shape)" HIT 'git reset --hard origin/main'
35
+ expect "reset --hard bare" HIT 'git reset --hard'
36
+ expect "reset --soft is CLEAN" CLEAN 'git reset --soft HEAD~1'
37
+ expect "reset (mixed, default) is CLEAN" CLEAN 'git reset HEAD~1'
38
+ expect "clean -fd" HIT 'git clean -fd'
39
+ expect "clean -fdx" HIT 'git clean -fdx'
40
+ expect "clean -n dry-run is CLEAN" CLEAN 'git clean -nd'
41
+ expect "checkout ." HIT 'git checkout .'
42
+ expect "checkout -- ." HIT 'git checkout -- .'
43
+ expect "checkout .gitignore is CLEAN" CLEAN 'git checkout .gitignore'
44
+ expect "checkout branch is CLEAN" CLEAN 'git checkout feature/x'
45
+ expect "restore --worktree" HIT 'git restore --staged --worktree src/'
46
+ expect "restore ." HIT 'git restore .'
47
+ expect "restore single file is CLEAN" CLEAN 'git restore src/app.py'
48
+
49
+ echo "-- history / remote / branch destroyers --"
50
+ expect "push --force" HIT 'git push --force origin main'
51
+ expect "push -f trailing (prototype [^i] missed)" HIT 'git push origin main -f'
52
+ expect "push --force-with-lease still flagged" HIT 'git push --force-with-lease'
53
+ expect "plain push is CLEAN" CLEAN 'git push origin feature/x'
54
+ expect "push -u is CLEAN" CLEAN 'git push -u origin feature/x'
55
+ expect "branch -D" HIT 'git branch -D old-branch'
56
+ expect "branch -d (merge-checked) is CLEAN" CLEAN 'git branch -d merged-branch'
57
+ expect "stash drop" HIT 'git stash drop'
58
+ expect "stash clear" HIT 'git stash clear'
59
+ expect "stash pop is CLEAN" CLEAN 'git stash pop'
60
+ expect "worktree remove --force" HIT 'git worktree remove --force ../wt'
61
+ expect "worktree remove plain is CLEAN" CLEAN 'git worktree remove ../wt'
62
+
63
+ echo "-- rm scope (precision over recall: only root/home/cwd/glob) --"
64
+ expect "rm -rf /" HIT 'rm -rf /'
65
+ expect "rm -rf ~" HIT 'rm -rf ~'
66
+ expect "rm -rf ." HIT 'rm -rf .'
67
+ expect "rm -rf ./" HIT 'rm -rf ./'
68
+ expect "rm -rf *" HIT 'rm -rf *'
69
+ expect "rm -fr / (flag order)" HIT 'rm -fr /'
70
+ expect "rm --no-preserve-root" HIT 'rm -rf --no-preserve-root /tmp/x'
71
+ expect "rm -rf scratch path is CLEAN (residual)" CLEAN 'rm -rf /tmp/scratch/build'
72
+ expect "rm -rf named dir is CLEAN" CLEAN 'rm -rf node_modules'
73
+ expect "rm single file is CLEAN" CLEAN 'rm -f out.log'
74
+
75
+ echo "-- adversarial shapes --"
76
+ expect "multi-line command still caught" HIT 'cd /repo
77
+ git reset --hard origin/main'
78
+ expect "compound command still caught" HIT 'git fetch origin && git reset --hard origin/main'
79
+ expect "multi-line legit stays CLEAN" CLEAN 'cd /repo
80
+ git status
81
+ git log --oneline -5'
82
+ expect "reset --hard with pathspec" HIT 'git reset --keep x; git reset --hard HEAD~2'
83
+
84
+ echo "-- Axis-2 round 2: cross-family + challenger bypass shapes (2026-08-01) --"
85
+ expect "git -C reset --hard (trained shape)" HIT 'git -C /repo reset --hard origin/main'
86
+ expect "git -C clean -fd" HIT 'git -C /repo clean -fd'
87
+ expect "git --git-dir reset --hard" HIT 'git --git-dir=/r/.git reset --hard'
88
+ expect "git -C on safe subcommand is CLEAN" CLEAN 'git -C /repo status'
89
+ expect "checkout HEAD -- ." HIT 'git checkout HEAD -- .'
90
+ expect "checkout ./" HIT 'git checkout ./'
91
+ expect "branch --delete --force" HIT 'git branch --delete --force old'
92
+ expect "rm -rf /* (root glob)" HIT 'rm -rf /*'
93
+ expect "rm -rf ./* (cwd glob)" HIT 'rm -rf ./*'
94
+ expect "rm -Rf / (uppercase R)" HIT 'rm -Rf /'
95
+ expect "rm -r -f / (split flags)" HIT 'rm -r -f /'
96
+ expect "rm -rf quoted root" HIT 'rm -rf "/"'
97
+ expect "double space between tokens" HIT 'git reset --hard'
98
+ expect "backslash line continuation" HIT $'rm -rf \\\n/'
99
+ expect "CRLF payload" HIT $'rm -rf /\r'
100
+ expect "push +refspec force" HIT 'git push origin +main'
101
+ expect "push-option with + is CLEAN" CLEAN 'git push origin main --push-option=a+b'
102
+ expect "clean -fdn dry-run is CLEAN" CLEAN 'git clean -fdn'
103
+ expect "clean -n -fdx dry-run is CLEAN" CLEAN 'git clean -n -fdx'
104
+ expect "stash dropstash substring is CLEAN" CLEAN 'git stash dropstash'
105
+ expect "stash drop with ref still HIT" HIT 'git stash drop stash@{1}'
106
+ # Named residuals pinned as lanes so a future "fix" that silently changes them is visible:
107
+ expect "residual: rm -rf \$HOME stays CLEAN" CLEAN 'rm -rf $HOME'
108
+ expect "residual: find -delete stays CLEAN" CLEAN 'find . -delete'
109
+ expect "residual: mention-as-data DOES fire" HIT 'echo "git reset --hard is dangerous"'
110
+ expect "residual: sudo wrapper still caught" HIT 'sudo rm -rf /'
111
+ expect "path-prefix /bin/rm still caught" HIT '/bin/rm -rf /'
112
+ expect "subshell (git reset --hard) caught" HIT 'out=$(git reset --hard 2>&1)'
113
+ # Round 3 (measured live 2x): English prose satisfied the rm row via the word TAIL of
114
+ # "Confirm" + a dash-word carrying r + a stray glob — leading-separator class kills it.
115
+ expect "prose word-tail rm FP stays CLEAN" CLEAN 'echo Round 2 verification. Confirm each prior finding is closed WITHOUT introducing a new default-toward-PASS hole (attack) * done'
116
+ expect "legit reset word-tail stays CLEAN" CLEAN 'echo the legit reset --hard flag docs'
117
+
118
+ echo "-- Round 4: GPT-family (gpt-5.6-sol via OpenRouter) decorrelation shapes (2026-08-01) --"
119
+ expect "token-split continuation gi\\ t" HIT $'git re\\\nset --hard'
120
+ expect ":&&git no-whitespace separator" HIT ':&&git reset --hard'
121
+ expect "false||git separator" HIT 'false||git reset --hard'
122
+ expect "trailing |cat after --force" HIT 'git push --force|cat'
123
+ expect "(rm -rf ~) closing paren" HIT '(rm -rf ~)'
124
+ expect "git --no-pager global opt" HIT 'git --no-pager reset --hard'
125
+ expect "git -c k=v global opt" HIT 'git -c core.a=b reset --hard'
126
+ expect "git --git-dir space form" HIT 'git --git-dir /r/.git reset --hard'
127
+ expect "nested \$(echo -n) not a dry-run" HIT 'git clean -fd "$(echo -n build)"'
128
+ expect "post-'--' -n pathspec not a dry-run" HIT 'git clean -fd -- -n'
129
+ expect "--force-with-lease=<ref>" HIT 'git push --force-with-lease=main'
130
+ expect "push -fu bundle" HIT 'git push -fu origin main'
131
+ expect "worktree remove -f short" HIT 'git worktree remove -f ../wt'
132
+ expect "restore -W short" HIT 'git restore -W src/'
133
+ expect "rm -rf ././* canonicalized" HIT 'rm -rf ././*'
134
+ expect "rm --recursive long form" HIT 'rm --recursive --force /'
135
+ expect "FP: docs--hard operand stays CLEAN" CLEAN 'git reset HEAD -- docs--hard'
136
+ expect "FP: clean --exclude=cache stays CLEAN" CLEAN 'git clean --exclude=cache build/'
137
+ expect "FP: branch name release--force CLEAN" CLEAN 'git branch --delete release--force'
138
+ expect "FP: rm --verbose / stays CLEAN" CLEAN 'rm --verbose /'
139
+ expect "FP: push --follow-tags stays CLEAN" CLEAN 'git push --follow-tags'
140
+ expect "dry-run with long opt before -n CLEAN" CLEAN 'git clean --quiet -n -fd'
141
+
142
+ echo "-- Round 4: fault-injection (pins the DOCUMENTED degrade, not a safety claim) --"
143
+ c_out=$(printf '%s' 'not json at all' | bash "$G" 2>/dev/null); c_rc=$?
144
+ if [ "$c_rc" -eq 0 ] && [ -z "$c_out" ]; then
145
+ printf ' ✅ %-52s %s\n' "F1 malformed payload: silent exit 0 (advisory)" OK; pass=$((pass+1))
146
+ else
147
+ printf ' ❌ %-52s rc=%s out=%s\n' "F1 malformed payload: silent exit 0 (advisory)" "$c_rc" "$c_out"; fail=$((fail+1))
148
+ fi
149
+ c_out=$(printf '%s' 'not json at all' | FH_DESTRUCTIVE_BLOCK=1 bash "$G" 2>/dev/null); c_rc=$?
150
+ if [ "$c_rc" -eq 0 ] && [ -z "$c_out" ]; then
151
+ printf ' ✅ %-52s %s\n' "F2 block+malformed: fails OPEN (documented residual)" OK; pass=$((pass+1))
152
+ else
153
+ printf ' ❌ %-52s rc=%s out=%s\n' "F2 block+malformed: fails OPEN (documented residual)" "$c_rc" "$c_out"; fail=$((fail+1))
154
+ fi
155
+
156
+ echo "-- opt-out --"
157
+ expect "noqa suppresses" CLEAN 'git reset --hard origin/main # noqa: destructive-op'
158
+
159
+ echo "-- C: delivery channel (advisory must reach the model, not a discarded stream) --"
160
+ HIT_CMD='git reset --hard origin/main'
161
+ payload() { python3 -c 'import json,sys; print(json.dumps({"tool_name":"Bash","tool_input":{"command":sys.argv[1]}}))' "$1"; }
162
+ c_errf=$(mktemp)
163
+
164
+ # C1 — advisory hit via full hook payload: stdout = one JSON object, additionalContext +
165
+ # systemMessage present, permissionDecision ABSENT, exit 0.
166
+ c_out=$(payload "$HIT_CMD" | bash "$G" 2>"$c_errf"); c_rc=$?
167
+ c_err=$(cat "$c_errf")
168
+ if [ "$c_rc" -eq 0 ] && printf '%s' "$c_out" | python3 -c '
169
+ import json, sys
170
+ d = json.load(sys.stdin)
171
+ h = d["hookSpecificOutput"]
172
+ assert h["hookEventName"] == "PreToolUse"
173
+ assert "DESTRUCTIVE-OP" in h["additionalContext"]
174
+ assert "DESTRUCTIVE-OP" in d["systemMessage"]
175
+ assert "permissionDecision" not in h and "permissionDecision" not in d
176
+ ' 2>/dev/null; then
177
+ printf ' ✅ %-52s %s\n' "C1 advisory: stdout JSON, no permissionDecision" OK; pass=$((pass+1))
178
+ else
179
+ printf ' ❌ %-52s rc=%s out=%s err=%s\n' "C1 advisory: stdout JSON, no permissionDecision" "$c_rc" "$c_out" "$c_err"; fail=$((fail+1))
180
+ fi
181
+
182
+ # C2 — block mode: exit 2, reason on stderr, stdout empty.
183
+ c_out=$(payload "$HIT_CMD" | FH_DESTRUCTIVE_BLOCK=1 bash "$G" 2>"$c_errf"); c_rc=$?
184
+ c_err=$(cat "$c_errf")
185
+ if [ "$c_rc" -eq 2 ] && printf '%s' "$c_err" | grep -q 'DESTRUCTIVE-OP' && [ -z "$c_out" ]; then
186
+ printf ' ✅ %-52s %s\n' "C2 block: exit 2, stderr carries the reason" OK; pass=$((pass+1))
187
+ else
188
+ printf ' ❌ %-52s rc=%s out=%s err=%s\n' "C2 block: exit 2, stderr carries the reason" "$c_rc" "$c_out" "$c_err"; fail=$((fail+1))
189
+ fi
190
+
191
+ # C3 — clean command: NO emission at all (silence must stay silent).
192
+ c_out=$(payload 'git status' | bash "$G" 2>"$c_errf"); c_rc=$?
193
+ rm -f "$c_errf"
194
+ if [ "$c_rc" -eq 0 ] && [ -z "$c_out" ]; then
195
+ printf ' ✅ %-52s %s\n' "C3 clean: no emission at all" OK; pass=$((pass+1))
196
+ else
197
+ printf ' ❌ %-52s rc=%s out=%s\n' "C3 clean: no emission at all" "$c_rc" "$c_out"; fail=$((fail+1))
198
+ fi
199
+
200
+ # C4 — hostile inherited codec (⚠️ is non-ASCII): JSON must still deliver under
201
+ # PYTHONIOENCODING=ascii thanks to the guard's own utf-8 pin.
202
+ c_out=$(payload "$HIT_CMD" | PYTHONIOENCODING=ascii bash "$G" 2>/dev/null); c_rc=$?
203
+ if [ "$c_rc" -eq 0 ] && printf '%s' "$c_out" | python3 -c '
204
+ import json, sys
205
+ d = json.load(sys.stdin)
206
+ assert "DESTRUCTIVE-OP" in d["hookSpecificOutput"]["additionalContext"]
207
+ ' 2>/dev/null; then
208
+ printf ' ✅ %-52s %s\n' "C4 ascii-codec env: JSON still delivered" OK; pass=$((pass+1))
209
+ else
210
+ printf ' ❌ %-52s rc=%s out=%s\n' "C4 ascii-codec env: JSON still delivered" "$c_rc" "$c_out"; fail=$((fail+1))
211
+ fi
212
+
213
+ # C5 — non-Bash tool payload: guard must stay silent (matcher is Bash, but defense in depth).
214
+ c_out=$(python3 -c 'import json;print(json.dumps({"tool_name":"Read","tool_input":{"file_path":"git reset --hard"}}))' | bash "$G" 2>/dev/null); c_rc=$?
215
+ if [ "$c_rc" -eq 0 ] && [ -z "$c_out" ]; then
216
+ printf ' ✅ %-52s %s\n' "C5 non-Bash payload: silent" OK; pass=$((pass+1))
217
+ else
218
+ printf ' ❌ %-52s rc=%s out=%s\n' "C5 non-Bash payload: silent" "$c_rc" "$c_out"; fail=$((fail+1))
219
+ fi
220
+
221
+ echo
222
+ if [ "$fail" -eq 0 ]; then
223
+ echo "[destructive-pre-gate] ✅ all $pass known pairs hold"; exit 0
224
+ else
225
+ echo "[destructive-pre-gate] ❌ $fail/$((pass+fail)) lanes failed"; exit 1
226
+ fi
@@ -88,6 +88,73 @@ expect "B braced form still caught" HIT 'a | b; rc=${PIPESTATUS[0]}'
88
88
  echo "-- opt-out --"
89
89
  expect "noqa suppresses" CLEAN 'bash g.sh | tail; rc=$? # noqa: pipe-verdict'
90
90
 
91
+ echo "-- C: delivery channel (closes N=8 — detection without delivery is decoration) --"
92
+ # These lanes assert the CHANNEL, not the detection: per the hook contract, on exit 0 only stdout
93
+ # JSON reaches anyone (additionalContext → model, systemMessage → user); stderr is discarded.
94
+ # A lane that only greps merged output would stay green while the warning goes nowhere — which is
95
+ # exactly the hole the 2026-07-31 rewrite closed. Verdicts are exit codes, not free-form stdout.
96
+ # NAMED RESIDUAL (cross-family LOW, accepted): $(…) capture strips NUL bytes, so a mutant emitting
97
+ # NUL+JSON would pass C1. The producer's hits text is static ASCII+⚠️ with no NUL source, so the
98
+ # lane does not pay for a byte-exact harness; revisit only if the producer ever emits dynamic bytes.
99
+ HIT_CMD='bash x.sh | tail -5; echo "exit=${PIPESTATUS[0]}"'
100
+ payload() { python3 -c 'import json,sys; print(json.dumps({"tool_name":"Bash","tool_input":{"command":sys.argv[1]}}))' "$1"; }
101
+
102
+ # All C lanes measure ONE invocation: stdout, stderr, and exit code from the same run (a pair of
103
+ # runs can each satisfy half the contract while no single run satisfies it — cross-family finding).
104
+ c_errf=$(mktemp)
105
+
106
+ # C1 — advisory hit, full hook payload path: stdout must be one JSON object carrying
107
+ # additionalContext + systemMessage, with permissionDecision ABSENT (emitting "allow" would
108
+ # auto-approve the flagged command — the guard must never grant what it questions). exit 0.
109
+ c_out=$(payload "$HIT_CMD" | bash "$G" 2>"$c_errf"); c_rc=$?
110
+ c_err=$(cat "$c_errf")
111
+ if [ "$c_rc" -eq 0 ] && printf '%s' "$c_out" | python3 -c '
112
+ import json, sys
113
+ d = json.load(sys.stdin)
114
+ h = d["hookSpecificOutput"]
115
+ assert h["hookEventName"] == "PreToolUse"
116
+ assert "PIPE-VERDICT" in h["additionalContext"]
117
+ assert "PIPE-VERDICT" in d["systemMessage"]
118
+ assert "permissionDecision" not in h and "permissionDecision" not in d
119
+ ' 2>/dev/null; then
120
+ printf ' ✅ %-52s %s\n' "C1 advisory: stdout JSON, no permissionDecision" OK; pass=$((pass+1))
121
+ else
122
+ printf ' ❌ %-52s rc=%s out=%s err=%s\n' "C1 advisory: stdout JSON, no permissionDecision" "$c_rc" "$c_out" "$c_err"; fail=$((fail+1))
123
+ fi
124
+
125
+ # C2 — block mode: exit 2, warning on stderr (stdout JSON is ignored by contract on exit 2).
126
+ c_out=$(payload "$HIT_CMD" | FH_PIPE_VERDICT_BLOCK=1 bash "$G" 2>"$c_errf"); c_rc=$?
127
+ c_err=$(cat "$c_errf")
128
+ if [ "$c_rc" -eq 2 ] && printf '%s' "$c_err" | grep -q 'PIPE-VERDICT' && [ -z "$c_out" ]; then
129
+ printf ' ✅ %-52s %s\n' "C2 block: exit 2, stderr carries the reason" OK; pass=$((pass+1))
130
+ else
131
+ printf ' ❌ %-52s rc=%s out=%s err=%s\n' "C2 block: exit 2, stderr carries the reason" "$c_rc" "$c_out" "$c_err"; fail=$((fail+1))
132
+ fi
133
+
134
+ # C3 — clean command through the full payload path: NO JSON at all (an empty advisory would still
135
+ # inject an empty reminder every Bash call — silence must stay silent). exit 0.
136
+ c_out=$(payload 'echo hello' | bash "$G" 2>"$c_errf"); c_rc=$?
137
+ rm -f "$c_errf"
138
+ if [ "$c_rc" -eq 0 ] && [ -z "$c_out" ]; then
139
+ printf ' ✅ %-52s %s\n' "C3 clean: no emission at all" OK; pass=$((pass+1))
140
+ else
141
+ printf ' ❌ %-52s rc=%s out=%s\n' "C3 clean: no emission at all" "$c_rc" "$c_out"; fail=$((fail+1))
142
+ fi
143
+
144
+ # C4 — hostile inherited codec: the hits text is non-ASCII (⚠️), and an environment-inherited
145
+ # PYTHONIOENCODING=ascii was measured (2026-07-31) to abort json.dumps and silently restore
146
+ # the pre-fix delivery loss. The guard pins PYTHONUTF8=1; this lane keeps that pin honest.
147
+ c_out=$(payload "$HIT_CMD" | PYTHONIOENCODING=ascii bash "$G" 2>/dev/null); c_rc=$?
148
+ if [ "$c_rc" -eq 0 ] && printf '%s' "$c_out" | python3 -c '
149
+ import json, sys
150
+ d = json.load(sys.stdin)
151
+ assert "PIPE-VERDICT" in d["hookSpecificOutput"]["additionalContext"]
152
+ ' 2>/dev/null; then
153
+ printf ' ✅ %-52s %s\n' "C4 ascii-codec env: JSON still delivered" OK; pass=$((pass+1))
154
+ else
155
+ printf ' ❌ %-52s rc=%s out=%s\n' "C4 ascii-codec env: JSON still delivered" "$c_rc" "$c_out"; fail=$((fail+1))
156
+ fi
157
+
91
158
  echo
92
159
  if [ "$fail" -eq 0 ]; then
93
160
  echo "[pipe-verdict-guard] ✅ all $pass known pairs hold"; exit 0
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env bash
2
+ # test_stale_clone_guard_lanes.sh — known pairs for scripts/stale_clone_guard.sh
3
+ #
4
+ # Fixture: a bare origin + clone A (pushes 2 extra commits) + clone B (stays behind).
5
+ # Lanes run with FH_STALE_CLONE_NO_FETCH=1 (remote state already local via clone-time fetch is
6
+ # NOT enough — B must explicitly fetch to know it is behind, so the fixture fetches for it; the
7
+ # no-fetch env only skips the guard's own network call) and a private marker dir per lane.
8
+
9
+ set -u
10
+ G="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/stale_clone_guard.sh"
11
+ pass=0; fail=0
12
+ WORK=$(mktemp -d)
13
+ trap 'rm -rf "$WORK"' EXIT
14
+
15
+ payload() { python3 -c 'import json,sys; print(json.dumps({"tool_name":"Write","tool_input":{"file_path":sys.argv[1]}}))' "$1"; }
16
+
17
+ # run <label> <expect HIT|CLEAN> <file_path>
18
+ run() {
19
+ local label="$1" want="$2" fpath="$3" got out
20
+ local mdir; mdir=$(mktemp -d "$WORK/marker.XXXXXX")
21
+ out=$(payload "$fpath" | FH_STALE_CLONE_NO_FETCH=1 FH_STALE_CLONE_MARKER_DIR="$mdir" bash "$G" 2>&1)
22
+ if printf '%s' "$out" | grep -q 'STALE-CLONE'; then got=HIT; else got=CLEAN; fi
23
+ if [ "$got" = "$want" ]; then
24
+ printf ' ✅ %-52s %s (expected %s)\n' "$label" "$got" "$want"; pass=$((pass+1))
25
+ else
26
+ printf ' ❌ %-52s %s (expected %s)\n out: %s\n' "$label" "$got" "$want" "$out"; fail=$((fail+1))
27
+ fi
28
+ }
29
+
30
+ echo "[stale-clone-guard] fixture build"
31
+ git init -q --bare "$WORK/origin.git"
32
+ # Default-branch portability (GPT pass): pin the bare repo's HEAD to main BEFORE cloning B —
33
+ # on master-default machines clone B otherwise has no checked-out branch and upstream setup fails.
34
+ git --git-dir="$WORK/origin.git" symbolic-ref HEAD refs/heads/main
35
+ git clone -q "$WORK/origin.git" "$WORK/A" 2>/dev/null
36
+ ( cd "$WORK/A" && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m base \
37
+ && git push -q origin HEAD:main && git branch -q --set-upstream-to=origin/main ) 2>/dev/null
38
+ git clone -q "$WORK/origin.git" "$WORK/B" 2>/dev/null
39
+ ( cd "$WORK/B" && git branch -q --set-upstream-to=origin/main ) 2>/dev/null
40
+ ( cd "$WORK/A" && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m one \
41
+ && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m two && git push -q origin HEAD:main ) 2>/dev/null
42
+ ( cd "$WORK/B" && git fetch -q origin ) 2>/dev/null # B now KNOWS it is behind 2
43
+
44
+ echo "[stale-clone-guard] known pairs"
45
+ run "behind clone fires" HIT "$WORK/B/newfile.py"
46
+ run "up-to-date clone stays silent" CLEAN "$WORK/A/newfile.py"
47
+ run "non-repo path stays silent" CLEAN "$WORK/newfile.py"
48
+ run "new nested dir in behind clone" HIT "$WORK/B/sub/dir/newfile.py"
49
+
50
+ # Creation-only scope: overwriting an EXISTING file must not fire (GPT pass).
51
+ touch "$WORK/B/existing.py"
52
+ run "overwrite of existing file is CLEAN" CLEAN "$WORK/B/existing.py"
53
+
54
+ # Non-origin remote name: @{u}-derived remote, no hardcoded origin (GPT pass).
55
+ git clone -q "$WORK/origin.git" "$WORK/C" 2>/dev/null
56
+ ( cd "$WORK/C" && git remote rename origin up && git fetch -q up \
57
+ && git branch -q --set-upstream-to=up/main && git reset -q --hard HEAD~2 ) 2>/dev/null # noqa: destructive-op (fixture repo)
58
+ run "non-origin remote name still fires" HIT "$WORK/C/newfile.py"
59
+
60
+ # Throttle: SAME marker dir, two calls — second must be silent.
61
+ mdir=$(mktemp -d "$WORK/marker.throttle")
62
+ o1=$(payload "$WORK/B/x.py" | FH_STALE_CLONE_NO_FETCH=1 FH_STALE_CLONE_MARKER_DIR="$mdir" bash "$G" 2>&1)
63
+ o2=$(payload "$WORK/B/y.py" | FH_STALE_CLONE_NO_FETCH=1 FH_STALE_CLONE_MARKER_DIR="$mdir" bash "$G" 2>&1)
64
+ if printf '%s' "$o1" | grep -q STALE-CLONE && [ -z "$o2" ]; then
65
+ printf ' ✅ %-52s OK\n' "throttle: 2nd call same repo+day silent"; pass=$((pass+1))
66
+ else
67
+ printf ' ❌ %-52s o1=%s o2=%s\n' "throttle: 2nd call same repo+day silent" "$o1" "$o2"; fail=$((fail+1))
68
+ fi
69
+
70
+ # Contract: HIT emits one JSON object with both channels and no permissionDecision.
71
+ mdir=$(mktemp -d "$WORK/marker.json")
72
+ jo=$(payload "$WORK/B/z.py" | FH_STALE_CLONE_NO_FETCH=1 FH_STALE_CLONE_MARKER_DIR="$mdir" bash "$G" 2>/dev/null)
73
+ if printf '%s' "$jo" | python3 -c '
74
+ import json,sys
75
+ d=json.load(sys.stdin)
76
+ h=d["hookSpecificOutput"]
77
+ assert h["hookEventName"]=="PreToolUse"
78
+ assert "STALE-CLONE" in h["additionalContext"] and "STALE-CLONE" in d["systemMessage"]
79
+ assert "permissionDecision" not in h and "permissionDecision" not in d
80
+ ' 2>/dev/null; then
81
+ printf ' ✅ %-52s OK\n' "C1 JSON dual-channel, no permissionDecision"; pass=$((pass+1))
82
+ else
83
+ printf ' ❌ %-52s out=%s\n' "C1 JSON dual-channel, no permissionDecision" "$jo"; fail=$((fail+1))
84
+ fi
85
+
86
+ # Fail-open lanes.
87
+ o=$(printf '%s' 'not json' | bash "$G" 2>&1); rc=$?
88
+ if [ "$rc" -eq 0 ] && [ -z "$o" ]; then
89
+ printf ' ✅ %-52s OK\n' "malformed payload: silent exit 0"; pass=$((pass+1))
90
+ else
91
+ printf ' ❌ %-52s rc=%s out=%s\n' "malformed payload: silent exit 0" "$rc" "$o"; fail=$((fail+1))
92
+ fi
93
+ o=$(python3 -c 'import json;print(json.dumps({"tool_name":"Edit","tool_input":{"file_path":"/x"}}))' | bash "$G" 2>&1); rc=$?
94
+ if [ "$rc" -eq 0 ] && [ -z "$o" ]; then
95
+ printf ' ✅ %-52s OK\n' "non-Write tool: silent"; pass=$((pass+1))
96
+ else
97
+ printf ' ❌ %-52s rc=%s out=%s\n' "non-Write tool: silent" "$rc" "$o"; fail=$((fail+1))
98
+ fi
99
+
100
+ echo
101
+ if [ "$fail" -eq 0 ]; then
102
+ echo "[stale-clone-guard] ✅ all $pass known pairs hold"; exit 0
103
+ else
104
+ echo "[stale-clone-guard] ❌ $fail/$((pass+fail)) lanes failed"; exit 1
105
+ fi
@@ -28,7 +28,27 @@
28
28
  "Verify after wiring (known pair, both directions):",
29
29
  " bash scripts/test_pipe_verdict_guard_lanes.sh # 15 pairs, expect all hold",
30
30
  " printf '%s' '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"g.sh | tail; rc=$?\"}}' \\",
31
- " | bash scripts/pipe_verdict_guard.sh # expect a PIPE-VERDICT R2 warning"
31
+ " | bash scripts/pipe_verdict_guard.sh # expect a PIPE-VERDICT R2 warning",
32
+ "",
33
+ "SECOND GUARD ON THE SAME SURFACE — destructive_pre_gate.sh (added 2026-08-01, field→meta",
34
+ "reverse propagation from pmh-dev): advisory warning when a Bash call carries a destructive",
35
+ "command (git reset --hard, clean -f, checkout ., force push, branch -D, stash drop/clear,",
36
+ "rm -rf on root/home/cwd). Origin: a below-floor sidecar self-justified `git reset --hard",
37
+ "origin/main` and ran it — the weaker the tier, the weaker the self-inhibition, so the",
38
+ "counterweight is mechanical. Net-new coverage is the LOCAL destructive ops no git hook sees;",
39
+ "the pre-push hook remains the hard floor for the push-time surface (role deconfliction in the",
40
+ "script header). Advisory by design, same contract as pipe_verdict_guard (additionalContext +",
41
+ "systemMessage, no permissionDecision); escalate with FH_DESTRUCTIVE_BLOCK=1. Opt out per",
42
+ "command with `# noqa: destructive-op`.",
43
+ " bash scripts/test_destructive_pre_gate_lanes.sh # 99 pairs, expect all hold",
44
+ "",
45
+ "THIRD GUARD, Write matcher — stale_clone_guard.sh (added 2026-08-01, fh_signal_2026-07-31):",
46
+ "advisory when a NEW file is created inside a git clone that is behind its upstream (fetch +",
47
+ "behind-count, once per repo per day, creation-only — overwrites never fire). Origin: a session",
48
+ "built on a 46-PR-behind clone and manufactured a duplicate of an upstream lane. found→extend",
49
+ "applies at repo level; this is its mechanical layer. Same advisory contract; fail-open on",
50
+ "every uncertainty (non-repo, no upstream, fetch failure).",
51
+ " bash scripts/test_stale_clone_guard_lanes.sh # 10 pairs, expect all hold"
32
52
  ],
33
53
  "project_settings_json": {
34
54
  "hooks": {
@@ -40,6 +60,21 @@
40
60
  "type": "command",
41
61
  "command": "bash \"$CLAUDE_PROJECT_DIR/scripts/pipe_verdict_guard.sh\"",
42
62
  "timeout": 5
63
+ },
64
+ {
65
+ "type": "command",
66
+ "command": "bash \"$CLAUDE_PROJECT_DIR/scripts/destructive_pre_gate.sh\"",
67
+ "timeout": 5
68
+ }
69
+ ]
70
+ },
71
+ {
72
+ "matcher": "Write",
73
+ "hooks": [
74
+ {
75
+ "type": "command",
76
+ "command": "bash \"$CLAUDE_PROJECT_DIR/scripts/stale_clone_guard.sh\"",
77
+ "timeout": 20
43
78
  }
44
79
  ]
45
80
  }