@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/CLAUDE.md +4 -2
- package/knowledge/shared/harness-core/field_harness_diagnostic.md +2 -0
- package/knowledge/shared/harness-core/gate_locality_principle.md +19 -0
- package/knowledge/shared/harness-core/multi_model_sidecar_strategy.md +25 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +14 -0
- package/package.json +5 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/scripts/destructive_pre_gate.sh +191 -0
- package/scripts/pipe_verdict_guard.sh +47 -1
- package/scripts/stale_clone_guard.sh +103 -0
- package/scripts/test_destructive_pre_gate_lanes.sh +226 -0
- package/scripts/test_pipe_verdict_guard_lanes.sh +67 -0
- package/scripts/test_stale_clone_guard_lanes.sh +105 -0
- package/templates/settings.PreToolUse.snippet.json +36 -1
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "fh-meta",
|
|
14
|
-
"version": "1.4.
|
|
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.
|
|
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 **
|
|
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`)
|
|
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.
|
|
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",
|
|
@@ -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
|
}
|