@chrono-meta/fh-gate 1.4.96 → 1.4.97
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 +11 -4
- package/README.md +19 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +70 -0
- package/package.json +5 -2
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/scripts/compaction_probe.sh +153 -10
- package/scripts/consent_registry_check.sh +63 -0
- package/scripts/lane_runner_check.sh +425 -0
- package/scripts/package_coverage_check.sh +182 -35
- package/scripts/psa_scan_lib.sh +164 -0
- package/scripts/public_surface_scan_files.sh +30 -0
- package/scripts/selfcheck.sh +332 -29
- package/scripts/test_consent_registry.sh +54 -0
- package/scripts/test_marker_crossfamily_lanes.sh +10 -1
- package/scripts/test_marker_floor_lanes.sh +25 -1
- package/scripts/test_psa_singlefile_lanes.sh +288 -0
- package/scripts/test_reviewer_capability_conformance.sh +10 -1
- package/scripts/test_selfcheck_state_lanes.sh +173 -0
- package/templates/goal-quench-settings-merged.json +30 -0
|
@@ -21,28 +21,6 @@ set -uo pipefail
|
|
|
21
21
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
22
22
|
cd "$REPO_ROOT" || exit 1
|
|
23
23
|
|
|
24
|
-
# Source-checkout test uses `-e`, not `-d`. In a git WORKTREE `.git` is a FILE (a gitdir pointer),
|
|
25
|
-
# so the old `-d` test read every worktree as "installed package" and skipped the check entirely —
|
|
26
|
-
# silently, with exit 0. Measured 2026-07-31: a worktree created specifically to approximate CI
|
|
27
|
-
# reported PASS while this check had not run at all, i.e. the instrument used to justify wiring CI
|
|
28
|
-
# was itself fail-open on the surface it was standing in. `-e` covers both the ordinary checkout
|
|
29
|
-
# (dir) and the worktree (file); genuine package mode has no `.git` of either kind, so it still
|
|
30
|
-
# skips. Anchored by scripts/test_package_coverage_lanes.sh.
|
|
31
|
-
if [ ! -e .git ]; then
|
|
32
|
-
echo "SKIP package-coverage (not a source checkout)"
|
|
33
|
-
exit 0
|
|
34
|
-
fi
|
|
35
|
-
# PREDICATE SPLIT (cross-family review, 2026-07-31). These were one condition, and folding them
|
|
36
|
-
# together meant `.git` present + manifest missing returned SKIP + exit 0 — "we are in a checkout
|
|
37
|
-
# and cannot read what ships" reported as "nothing to check here". Absence of the input is not
|
|
38
|
-
# absence of the defect; `not found != 0` (CLAUDE.md §Instrument-Calibration). Only the no-.git
|
|
39
|
-
# case is a legitimate skip (an installed package, where the un-shipped files are correctly gone).
|
|
40
|
-
if [ ! -f package.json ]; then
|
|
41
|
-
echo "FAIL package-coverage: source checkout with no package.json — the shipped file list is"
|
|
42
|
-
echo " unreadable, so coverage is UNMEASURED, not clean"
|
|
43
|
-
exit 1
|
|
44
|
-
fi
|
|
45
|
-
|
|
46
24
|
# ── Accepted-absent, with the reason each one is NOT a defect ────────────────────────────────
|
|
47
25
|
# Adding a line here is a decision, not a silencer: each entry states why shipping it would be
|
|
48
26
|
# wrong. If you cannot write that sentence, the file probably belongs in files[].
|
|
@@ -79,6 +57,45 @@ ACCEPTED_ABSENT=(
|
|
|
79
57
|
# anchor but guards on the subject's presence, so package mode SKIPs rather than falling through.
|
|
80
58
|
"scripts/sync-from-be.sh"
|
|
81
59
|
"scripts/sync_from_be_lanes.sh"
|
|
60
|
+
# ── The three lane suites selfcheck.sh's DEBT-12 pair-loop names but does not ship ────────────
|
|
61
|
+
# Added 2026-08-13, and the way they got here is the point: this check CAUGHT them. Before that
|
|
62
|
+
# loop existed, these names lived in lane_runner_check.sh's DEBT array as bare basenames
|
|
63
|
+
# ("test_chamber_run_lanes.sh"), which the extractor's `scripts/…\.(sh|py)` pattern does not
|
|
64
|
+
# match. Writing the same names as full paths in a SHIPPED file (selfcheck.sh) is what turned
|
|
65
|
+
# them into phantoms — a shipped document pointing at a file the package omits. The source-tree
|
|
66
|
+
# selfcheck went red on the first full run after the wiring, exactly as an adversarial review had
|
|
67
|
+
# predicted from static reading alone. Recorded here rather than "fixed" by dropping the prefix,
|
|
68
|
+
# because the prefix is what makes lane_runner_check.sh's direct-invocation detector see the
|
|
69
|
+
# wiring at all; removing it would trade a loud failure for a silent blind spot.
|
|
70
|
+
#
|
|
71
|
+
# Why each is legitimately unshipped — and one of them is a NAMED RESIDUAL, not a clean answer:
|
|
72
|
+
# · test_frontier_digest_retry.sh — its subject, scripts/frontier_digest_daily.sh, is itself
|
|
73
|
+
# ACCEPTED_ABSENT (a launchd cadence runner for this operator's machine). Anchor follows
|
|
74
|
+
# subject; a consumer has nothing for it to measure.
|
|
75
|
+
# · test_residency_closure_lanes.sh — same shape: residency_closure_scan.py does not ship, so
|
|
76
|
+
# its calibration has no subject on a consumer machine.
|
|
77
|
+
# · test_chamber_run_lanes.sh — 🟥 NOT the same shape. Its subject scripts/chamber_run.sh DOES
|
|
78
|
+
# ship. So a consumer receives the chamber runner and no calibration for it: a shipped gate
|
|
79
|
+
# whose known-pair cannot execute on the machine that runs the gate. That is the defect class
|
|
80
|
+
# this whole campaign is closing, one layer over. It is entered here rather than shipped
|
|
81
|
+
# because adding an anchor to files[] is a consumer-facing change that needs its own
|
|
82
|
+
# tarball-mode run to prove it does not false-FAIL, and this delta is a WIRING delta. The
|
|
83
|
+
# honest state is "declared, and the declaration records a debt" — carried to the card.
|
|
84
|
+
"scripts/test_chamber_run_lanes.sh"
|
|
85
|
+
"scripts/test_frontier_digest_retry.sh"
|
|
86
|
+
"scripts/residency_closure_scan.py"
|
|
87
|
+
"scripts/test_residency_closure_lanes.sh"
|
|
88
|
+
# ── The two settings destinations, surfaced 2026-08-13 by fixing this file's own extractor ────
|
|
89
|
+
# These were invisible until the `js|json` alternation below was corrected: every `.json`
|
|
90
|
+
# reference in every shipped doc was being truncated to `.js`, a path that exists nowhere, so
|
|
91
|
+
# `os.path.exists()` dropped it in silence. `.claude/settings.json` alone is named by TWENTY-SIX
|
|
92
|
+
# shipped documents and had never once been examined by the check that exists to examine exactly
|
|
93
|
+
# this. Both are INSTALL DESTINATIONS the consumer owns — the whole point of the docs that name
|
|
94
|
+
# them is "put this in YOUR settings" — so shipping either would overwrite the reader's own
|
|
95
|
+
# configuration with this harness's. Same reasoning as .claude/rules/local_fh_context.md above:
|
|
96
|
+
# the template is what ships, the destination is what the user creates.
|
|
97
|
+
".claude/settings.json"
|
|
98
|
+
".claude/settings.local.json"
|
|
82
99
|
# Its only input is `.claude/regression/probes.md`, itself ACCEPTED_ABSENT above (a consumer's
|
|
83
100
|
# regression run must not compare against this harness's probe set). Shipping the reader without
|
|
84
101
|
# its corpus would put a script in the package that can only ever report "instrument error".
|
|
@@ -99,10 +116,13 @@ ACCEPTED_ABSENT=(
|
|
|
99
116
|
# selfcheck goes red on a subject they do not have.
|
|
100
117
|
"scripts/test_probe_scope_lanes.sh"
|
|
101
118
|
# Measures what the LIVE `claude` CLI does with several SessionStart hooks on one matcher —
|
|
102
|
-
# so every run needs the CLI, auth, and spends tokens on the consumer's account.
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
119
|
+
# so every run needs the CLI, auth, and spends tokens on the consumer's account. This anchor DOES
|
|
120
|
+
# get a live run attempt when its subject ships and the file itself is present (selfcheck reports
|
|
121
|
+
# NOT EXERCISED, exit 2, when that run finds no CLI). This entry covers the OTHER case: the anchor
|
|
122
|
+
# FILE ITSELF is not shipped, so selfcheck never gets far enough to attempt the run at all — that
|
|
123
|
+
# case renders SKIP, not NOT EXERCISED (the two are for missing-file vs. present-file-no-CLI,
|
|
124
|
+
# not interchangeable; corrected 2026-08-12, cross-family review — the two exit paths were
|
|
125
|
+
# conflated in an earlier revision of this comment).
|
|
106
126
|
"scripts/test_sessionstart_multihook_lanes.sh"
|
|
107
127
|
# The launchd-driven daily cadence runner. Two independent reasons it must not ship: it is half of
|
|
108
128
|
# a pair whose other half is a machine-local plist (`scripts/com.forge-harness.frontier-digest.plist`),
|
|
@@ -114,28 +134,142 @@ ACCEPTED_ABSENT=(
|
|
|
114
134
|
"scripts/frontier_digest_daily.sh"
|
|
115
135
|
)
|
|
116
136
|
|
|
117
|
-
|
|
118
|
-
|
|
137
|
+
# `--list-accepted`: print the ACCEPTED_ABSENT paths, one per line, and exit — no git/package.json
|
|
138
|
+
# dependency, deliberately callable from a context this script's own coverage scan cannot run in
|
|
139
|
+
# (package mode, or a vendored tree with `.git` present but no `package.json` at this root). This is
|
|
140
|
+
# the single declared source of "known-legitimately-unshipped" for OTHER checks to consult instead of
|
|
141
|
+
# re-deriving the same judgment from an environment predicate (`.git` presence, directory existence)
|
|
142
|
+
# that answers a different question and can diverge from this list's actual coverage. Added
|
|
143
|
+
# 2026-08-12 (reship axis, cross-family review of card §🔱⑮ G/D) after selfcheck.sh's ref-path block
|
|
144
|
+
# was found re-deriving "is this legitimately absent" from `[ -e .git ]` — which reproduces the
|
|
145
|
+
# original bug in any git-tracked tree that vendors this package (a monorepo committing
|
|
146
|
+
# node_modules, or a consumer who runs `git init` after install): `.git` exists there, so the old
|
|
147
|
+
# predicate ran the check, found these exact paths missing, and FAILed — the same false-FAIL this
|
|
148
|
+
# array already declares correct to omit.
|
|
149
|
+
if [ "${1:-}" = "--list-accepted" ]; then
|
|
150
|
+
printf '%s\n' "${ACCEPTED_ABSENT[@]}"
|
|
151
|
+
exit 0
|
|
152
|
+
fi
|
|
153
|
+
|
|
154
|
+
# `--vs-tarball` swaps the coverage oracle from package.json files[] (a DECLARATION about the
|
|
155
|
+
# tarball) to `npm pack --dry-run --json` (the tarball). See the two-oracle comment in the python
|
|
156
|
+
# block. Kept as a flag rather than made the default for one reason: the default runs anywhere,
|
|
157
|
+
# offline, in ~1s, while this one shells out to npm and takes seconds — so the strict oracle belongs
|
|
158
|
+
# on the publish path, where the question "what does the consumer actually receive" is the one being
|
|
159
|
+
# asked, and the cheap one stays on every commit.
|
|
160
|
+
if [ "${1:-}" = "--vs-tarball" ]; then
|
|
161
|
+
export FH_PKG_ORACLE=tarball
|
|
162
|
+
shift
|
|
163
|
+
fi
|
|
164
|
+
|
|
165
|
+
# Source-checkout test uses `-e`, not `-d`. In a git WORKTREE `.git` is a FILE (a gitdir pointer),
|
|
166
|
+
# so the old `-d` test read every worktree as "installed package" and skipped the check entirely —
|
|
167
|
+
# silently, with exit 0. Measured 2026-07-31: a worktree created specifically to approximate CI
|
|
168
|
+
# reported PASS while this check had not run at all, i.e. the instrument used to justify wiring CI
|
|
169
|
+
# was itself fail-open on the surface it was standing in. `-e` covers both the ordinary checkout
|
|
170
|
+
# (dir) and the worktree (file); genuine package mode has no `.git` of either kind, so it still
|
|
171
|
+
# skips. Anchored by scripts/test_package_coverage_lanes.sh.
|
|
172
|
+
if [ ! -e .git ]; then
|
|
173
|
+
echo "SKIP package-coverage (not a source checkout)"
|
|
174
|
+
exit 0
|
|
175
|
+
fi
|
|
176
|
+
# PREDICATE SPLIT (cross-family review, 2026-07-31). These were one condition, and folding them
|
|
177
|
+
# together meant `.git` present + manifest missing returned SKIP + exit 0 — "we are in a checkout
|
|
178
|
+
# and cannot read what ships" reported as "nothing to check here". Absence of the input is not
|
|
179
|
+
# absence of the defect; `not found != 0` (CLAUDE.md §Instrument-Calibration). Only the no-.git
|
|
180
|
+
# case is a legitimate skip (an installed package, where the un-shipped files are correctly gone).
|
|
181
|
+
if [ ! -f package.json ]; then
|
|
182
|
+
echo "FAIL package-coverage: source checkout with no package.json — the shipped file list is"
|
|
183
|
+
echo " unreadable, so coverage is UNMEASURED, not clean"
|
|
184
|
+
exit 1
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
out=$(FH_PKG_ORACLE="${FH_PKG_ORACLE:-declaration}" python3 - "${ACCEPTED_ABSENT[@]}" <<'PY'
|
|
188
|
+
import re, os, json, sys, subprocess
|
|
119
189
|
accepted = set(sys.argv[1:])
|
|
120
190
|
files = json.load(open('package.json'))['files']
|
|
121
191
|
|
|
192
|
+
# ── TWO ORACLES, and the difference between them is the whole reason the second one exists ────
|
|
193
|
+
# `declaration` (default): a path is covered if package.json files[] says so. That is a CLAIM about
|
|
194
|
+
# the tarball, checkable without npm, and it is what every caller before 2026-08-13 used.
|
|
195
|
+
# `tarball`: a path is covered if it is actually in `npm pack --dry-run --json`. That is the tarball
|
|
196
|
+
# ITSELF.
|
|
197
|
+
# They come apart, and this repo has measured them coming apart (card §🔱⑮ G/C: repo ✅ / files[] ❌
|
|
198
|
+
# for two paths). A files[] entry can name a file that does not pack — .npmignore precedence, a
|
|
199
|
+
# pattern that no longer matches, a file deleted while its declaration stayed. In every one of those
|
|
200
|
+
# the declaration oracle says PASS and the consumer gets a broken reference, because the consumer
|
|
201
|
+
# receives the tarball and not the manifest. This is the general solution the campaign card has
|
|
202
|
+
# carried as open under "참조 ↔ 실제 출하 파일셋": the earlier repairs (A–D) routed AROUND it by
|
|
203
|
+
# consulting declarations, which was correct for those cases and is not the same thing as building
|
|
204
|
+
# it.
|
|
205
|
+
# FAIL-CLOSED, and deliberately not "fall back to the declaration": npm missing, a non-zero exit, or
|
|
206
|
+
# JSON that does not parse means the tarball is UNKNOWN, and a lenient fallback would silently
|
|
207
|
+
# convert the stricter oracle back into the weaker one — while still printing the stricter one's
|
|
208
|
+
# name. That is the "미측정을 통과로 렌더" class this whole file is an instrument against.
|
|
209
|
+
ORACLE = os.environ.get('FH_PKG_ORACLE', 'declaration')
|
|
210
|
+
packed = None
|
|
211
|
+
if ORACLE == 'tarball':
|
|
212
|
+
try:
|
|
213
|
+
r = subprocess.run(['npm', 'pack', '--dry-run', '--json'],
|
|
214
|
+
capture_output=True, text=True, timeout=180)
|
|
215
|
+
if r.returncode != 0:
|
|
216
|
+
print(f"ORACLE_UNAVAILABLE\tnpm pack exited {r.returncode}")
|
|
217
|
+
raise SystemExit(2)
|
|
218
|
+
packed = {f['path'] for f in json.loads(r.stdout)[0]['files']}
|
|
219
|
+
except FileNotFoundError:
|
|
220
|
+
print("ORACLE_UNAVAILABLE\tnpm is not on PATH — the tarball cannot be read")
|
|
221
|
+
raise SystemExit(2)
|
|
222
|
+
except (json.JSONDecodeError, KeyError, IndexError) as e:
|
|
223
|
+
print(f"ORACLE_UNAVAILABLE\tnpm pack --json did not parse ({type(e).__name__})")
|
|
224
|
+
raise SystemExit(2)
|
|
225
|
+
except subprocess.TimeoutExpired:
|
|
226
|
+
print("ORACLE_UNAVAILABLE\tnpm pack timed out")
|
|
227
|
+
raise SystemExit(2)
|
|
228
|
+
# An empty or absurdly small packed set means the instrument broke, not that the package is
|
|
229
|
+
# empty — same impossible-zero rule the shipped-doc guard below applies to its own extractor.
|
|
230
|
+
if not packed or len(packed) < 10:
|
|
231
|
+
print(f"ORACLE_UNAVAILABLE\tnpm pack reported {len(packed or [])} files — implausible")
|
|
232
|
+
raise SystemExit(2)
|
|
233
|
+
|
|
122
234
|
def covered(p):
|
|
235
|
+
if packed is not None:
|
|
236
|
+
return p in packed
|
|
123
237
|
return any(p == f or p.startswith(f.rstrip('/') + '/') for f in files)
|
|
124
238
|
|
|
125
239
|
shipped = []
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
240
|
+
if packed is not None:
|
|
241
|
+
# In tarball mode the set of SCANNED documents is the tarball too, not the declaration. Both
|
|
242
|
+
# halves have to move together: scanning a doc that does not actually ship would report a
|
|
243
|
+
# phantom no consumer can encounter, and that false positive is what would get the stricter
|
|
244
|
+
# oracle switched back off.
|
|
245
|
+
shipped = sorted(packed)
|
|
246
|
+
else:
|
|
247
|
+
for f in files:
|
|
248
|
+
if os.path.isfile(f):
|
|
249
|
+
shipped.append(f)
|
|
250
|
+
elif os.path.isdir(f):
|
|
251
|
+
for root, _, names in os.walk(f):
|
|
252
|
+
shipped.extend(os.path.join(root, n) for n in names)
|
|
132
253
|
|
|
133
254
|
# Only text surfaces can carry a reference a human or agent would follow.
|
|
134
255
|
shipped = [s for s in shipped if s.endswith(('.md', '.sh', '.js', '.json', '.yaml', '.yml'))]
|
|
135
256
|
|
|
136
257
|
pat = re.compile(
|
|
137
258
|
r'(?<![\w/.-])((?:scripts|templates|bin|docs|knowledge|plugins|\.claude)'
|
|
138
|
-
|
|
259
|
+
# LONGEST-FIRST, and the order is the whole fix. Python's `|` is leftmost-first, not
|
|
260
|
+
# longest-match, so the previous order `sh|py|js|md|yaml|yml|json|defaults` matched `.js`
|
|
261
|
+
# inside `.json` and truncated every JSON reference: `templates/settings.json` was extracted as
|
|
262
|
+
# `templates/settings.js`, a path that exists nowhere, so `os.path.exists()` said False and the
|
|
263
|
+
# reference was dropped in silence. This check has therefore NEVER examined a shipped doc's
|
|
264
|
+
# JSON references. Same trap in `yml|yaml` (harmless by luck: neither is a prefix of the other)
|
|
265
|
+
# and it would bite again for any future pair like `md`/`mdx`.
|
|
266
|
+
# Known-pair, run before the fix: `templates/settings.json` → `templates/settings.js` (broken)
|
|
267
|
+
# while `scripts/foo.sh` → `scripts/foo.sh` (control, intact). After: both intact.
|
|
268
|
+
# The `(?![A-Za-z0-9])` tail is the belt to the longest-first braces: it stops a correct
|
|
269
|
+
# alternative from matching a PREFIX of a longer real extension that nobody listed yet, so the
|
|
270
|
+
# next person who adds an extension cannot silently reintroduce this by putting it in the wrong
|
|
271
|
+
# place. Ordering alone is a convention; the lookahead is the mechanism.
|
|
272
|
+
r'/[A-Za-z0-9_./-]+\.(?:defaults|json|yaml|yml|sh|py|js|md)(?![A-Za-z0-9]))'
|
|
139
273
|
)
|
|
140
274
|
|
|
141
275
|
phantom = {}
|
|
@@ -192,6 +326,19 @@ PY
|
|
|
192
326
|
)
|
|
193
327
|
rc=$?
|
|
194
328
|
|
|
329
|
+
case "$out" in
|
|
330
|
+
ORACLE_UNAVAILABLE*)
|
|
331
|
+
# Only reachable with --vs-tarball. The stricter oracle could not be read, and the ONLY wrong
|
|
332
|
+
# answer here is to quietly re-run with the weaker one and print a pass — the caller asked
|
|
333
|
+
# "what does the consumer actually receive", and "I could not look" is not an answer to that.
|
|
334
|
+
echo "FAIL package-coverage (--vs-tarball): the tarball oracle is UNAVAILABLE, so coverage"
|
|
335
|
+
echo " against the real packed file set is UNMEASURED — not clean."
|
|
336
|
+
printf '%s\n' "$out" | sed 's/^ORACLE_UNAVAILABLE\t/ reason: /'
|
|
337
|
+
echo " Re-run without --vs-tarball to check against package.json files[] instead, but note"
|
|
338
|
+
echo " that is a DIFFERENT and weaker question (a declaration, not the tarball)."
|
|
339
|
+
exit 2 ;;
|
|
340
|
+
esac
|
|
341
|
+
|
|
195
342
|
if [ "$rc" -eq 2 ] || [ "$out" = "EXTRACTOR_BROKE" ]; then
|
|
196
343
|
echo "FAIL package-coverage: extractor scanned 0 shipped docs — the check broke, it did not pass"
|
|
197
344
|
exit 1
|
package/scripts/psa_scan_lib.sh
CHANGED
|
@@ -239,3 +239,167 @@ $PSA_STREAM
|
|
|
239
239
|
PSA_PAT
|
|
240
240
|
return $hit
|
|
241
241
|
}
|
|
242
|
+
|
|
243
|
+
# _psa_can_assign <name> <value> — 0 = "this exact assignment will succeed", 1 = it will not.
|
|
244
|
+
#
|
|
245
|
+
# Rounds 4→7 walked this from a declaration parse to a proxy probe to the real thing:
|
|
246
|
+
# R4 parsed `readonly -p` for `NAME=` — missed `readonly FOO` (prints `declare -r FOO`, no `=`)
|
|
247
|
+
# and false-positived on an unrelated readonly whose VALUE contained the string.
|
|
248
|
+
# R5 replaced it with a behavioural probe — but the probe assigned the variable's OWN CURRENT
|
|
249
|
+
# VALUE, while the caller then assigns something else.
|
|
250
|
+
# R7 found the gap that leaves: `declare -i PSA_ALLOWLIST` makes the self-assignment succeed and
|
|
251
|
+
# `PSA_ALLOWLIST=/dev/null` fail. Measured — probe=writable, real=FAIL. The caller then died
|
|
252
|
+
# mid-run, and on the psa_scan_file path a hit that psa_scan_tagged WOULD have reported was
|
|
253
|
+
# lost with it.
|
|
254
|
+
# So the probe now takes the value: it tests the operation that is actually about to happen, in a
|
|
255
|
+
# subshell where failure cannot reach the caller. Every earlier version tested a stand-in for it.
|
|
256
|
+
_psa_can_assign() {
|
|
257
|
+
case "$1" in
|
|
258
|
+
''|*[!A-Za-z0-9_]*|[0-9]*)
|
|
259
|
+
echo " ❌ _psa_can_assign: refusing non-identifier argument" >&2
|
|
260
|
+
return 1 ;; # cannot-assign → the caller refuses to mutate. Degrades away from mutating.
|
|
261
|
+
esac
|
|
262
|
+
( eval "$1=\"\$2\"" ) 2>/dev/null
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
# psa_require_live — prove the scanner actually RUNS before a 0 from it is allowed to mean "clean".
|
|
266
|
+
# Returns 0 = alive, 1 = dead (and says so on stderr).
|
|
267
|
+
#
|
|
268
|
+
# Why this exists (measured 2026-08-12): a caller hand-built the path-tagged stream and piped it into
|
|
269
|
+
# psa_scan_tagged from a shell whose PATH lacked `cat`. The function died on its first line, printed
|
|
270
|
+
# `command not found` into the captured output, and returned 0 — so the caller's known-positive
|
|
271
|
+
# (a file dense with operator literals) reported **0 hits**, indistinguishable from clean. The control
|
|
272
|
+
# is what caught it: the run was only recognisable as dead because a file that MUST hit did not.
|
|
273
|
+
# This function makes that control intrinsic instead of remembered — it does not check that binaries
|
|
274
|
+
# exist (a presence check is the weaker instrument this repo keeps re-learning), it checks that a
|
|
275
|
+
# synthetic token which MUST be reported IS reported, end to end through the real matcher.
|
|
276
|
+
#
|
|
277
|
+
# Cross-family round 1 (2026-08-12) refuted three things about the first draft; all three are fixed
|
|
278
|
+
# here and named so the next reader does not re-introduce them:
|
|
279
|
+
# · it exercised only the stdin path, so a missing `awk` — the binary the file path actually needs —
|
|
280
|
+
# was outside what "alive" certified. The self-test now goes through the SAME tagging path
|
|
281
|
+
# `psa_scan_file` uses, from a real temp file.
|
|
282
|
+
# · the canary was allowlistable: a `psa/selftest<TAB>PSA_SELFTEST_CANARY` row would mute it and a
|
|
283
|
+
# healthy scanner would report itself dead. The self-test now runs with the allowlist disabled.
|
|
284
|
+
# · it was not `errexit`-safe: under `set -e` the nonzero return from the hit-reporting path exited
|
|
285
|
+
# the caller's shell before the restore line. Status capture is now inside an `if`.
|
|
286
|
+
psa_require_live() {
|
|
287
|
+
local saved_stream saved_allow saved_stream_set saved_allow_set tmpd out rc _canary
|
|
288
|
+
saved_stream="${PSA_STREAM-}"; saved_allow="${PSA_ALLOWLIST-}"
|
|
289
|
+
saved_stream_set=""; saved_allow_set=""
|
|
290
|
+
[ "${PSA_STREAM+x}" = "x" ] && saved_stream_set=1
|
|
291
|
+
[ "${PSA_ALLOWLIST+x}" = "x" ] && saved_allow_set=1
|
|
292
|
+
tmpd=$(mktemp -d 2>/dev/null) || { echo " ❌ INSTRUMENT DEAD — mktemp unavailable." >&2; return 1; }
|
|
293
|
+
# Round 4 refuted the round-3 fix: `|| :` does NOT rescue an assignment to a readonly variable —
|
|
294
|
+
# bash aborts the shell before the `||` is ever considered. So the only safe move is to REFUSE
|
|
295
|
+
# before mutating anything. Detected, reported as DEAD, and the caller survives.
|
|
296
|
+
_canary=$(printf 'HIGH\tPSA_SELFTEST_CANARY')
|
|
297
|
+
if ! _psa_can_assign PSA_STREAM "$_canary" || ! _psa_can_assign PSA_ALLOWLIST /dev/null; then
|
|
298
|
+
echo " ❌ INSTRUMENT DEAD — PSA_STREAM/PSA_ALLOWLIST cannot take the values the self-test needs." >&2
|
|
299
|
+
echo " (readonly, or an attribute such as \`declare -i\` that rejects the value)" >&2
|
|
300
|
+
echo " Not attempting it: a failed assignment aborts an errexit caller outright." >&2
|
|
301
|
+
rm -rf "$tmpd" 2>/dev/null || :
|
|
302
|
+
return 1
|
|
303
|
+
fi
|
|
304
|
+
PSA_STREAM="$_canary"
|
|
305
|
+
PSA_ALLOWLIST=/dev/null
|
|
306
|
+
printf 'PSA_SELFTEST_CANARY\n' > "$tmpd/canary" 2>/dev/null
|
|
307
|
+
# Same pipeline shape as psa_scan_file: awk tags the file, psa_scan_tagged matches it.
|
|
308
|
+
if out=$(awk -v P="psa/selftest" '{print P "\t" $0}' "$tmpd/canary" 2>&1 | psa_scan_tagged 2>&1); then
|
|
309
|
+
rc=0
|
|
310
|
+
else
|
|
311
|
+
rc=$?
|
|
312
|
+
fi
|
|
313
|
+
rm -rf "$tmpd" 2>/dev/null || :
|
|
314
|
+
# Restore EXACTLY, including the difference between unset and set-empty (round 2): the first draft
|
|
315
|
+
# always re-set PSA_STREAM, turning an unset variable into an empty one — which the very guard in
|
|
316
|
+
# psa_scan_file below keys on. A restore that changes state is not a restore.
|
|
317
|
+
if [ -n "${saved_stream_set:-}" ]; then PSA_STREAM="$saved_stream"; else unset PSA_STREAM; fi
|
|
318
|
+
if [ -n "${saved_allow_set:-}" ]; then PSA_ALLOWLIST="$saved_allow"; else unset PSA_ALLOWLIST; fi
|
|
319
|
+
case "$out" in
|
|
320
|
+
*PSA_SELFTEST_CANARY*) [ "$rc" -eq 1 ] && return 0 ;;
|
|
321
|
+
esac
|
|
322
|
+
echo " ❌ INSTRUMENT DEAD — the scanner did not report a synthetic known-positive." >&2
|
|
323
|
+
echo " self-test rc=$rc out=[$out]" >&2
|
|
324
|
+
echo " A 0 from this scanner is UNMEASURED, not clean — do not report or publish a count from it." >&2
|
|
325
|
+
return 1
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
# psa_scan_file <path> — scan ONE file through the shared matcher.
|
|
329
|
+
#
|
|
330
|
+
# Three-valued on purpose: 0 = scanned, nothing reportable · 1 = scanned, hit(s) reported ·
|
|
331
|
+
# 3 = NOT SCANNED (instrument dead, bad usage, or missing file). Folding 3 into 0 is the exact defect
|
|
332
|
+
# this repo keeps meeting — `not found` is not `0`, and a file that does not exist is not an empty file.
|
|
333
|
+
# Callers that only branch on `if psa_scan_file f; then clean; fi` would read 3 as dirty (safe) and
|
|
334
|
+
# never as clean; callers that check `-eq 0` must treat 3 as unmeasured.
|
|
335
|
+
#
|
|
336
|
+
# ⚠️ The caller still owns the DISPLAY. A hit line (❌) and an allowlisted line (⚪) are different
|
|
337
|
+
# outcomes, and a display filter that greps only ❌ renders "allowlisted" as "no match" — measured
|
|
338
|
+
# on 2026-08-12, which is how an existing operator allowlist decision was misread as an absent
|
|
339
|
+
# pattern. If you filter this function's output, keep both markers or state which you dropped.
|
|
340
|
+
psa_scan_file() {
|
|
341
|
+
local out rc
|
|
342
|
+
if [ "$#" -ne 1 ]; then
|
|
343
|
+
echo " ⚠️ USAGE — psa_scan_file <path> (got $# argument(s)); NOT SCANNED" >&2
|
|
344
|
+
return 3
|
|
345
|
+
fi
|
|
346
|
+
# Patterns-not-loaded is the highest-value guard here, and it was missing from the first draft.
|
|
347
|
+
# Cross-family round 1 found it by simply following THIS FILE'S OWN advertised usage line, which
|
|
348
|
+
# said `. psa_scan_lib.sh && psa_scan_file <path>` and omitted psa_load — reproduced: a file dense
|
|
349
|
+
# with a known-positive token returned rc=0 with empty output. An empty pattern stream scans
|
|
350
|
+
# everything and reports nothing, which is the false-clean this whole change exists to remove.
|
|
351
|
+
if [ -z "${PSA_STREAM:-}" ]; then
|
|
352
|
+
echo " ⚠️ PATTERNS NOT LOADED — call psa_load <defaults> <override> first; NOT SCANNED" >&2
|
|
353
|
+
echo " (an empty pattern stream reports nothing, which is indistinguishable from clean)" >&2
|
|
354
|
+
return 3
|
|
355
|
+
fi
|
|
356
|
+
# A NON-EMPTY stream is not a COMPLETE one (round 2). If the committed defaults failed to load, or
|
|
357
|
+
# rows were dropped as unusable, the stream still matches *something* and a clean verdict from it
|
|
358
|
+
# is a verdict about a partial instrument.
|
|
359
|
+
#
|
|
360
|
+
# `case`, not `[ -ne ]` (round 3): a non-numeric value made `[` return 2, the `if` read that as
|
|
361
|
+
# false, and the guard fell through — reproduced with PSA_DEFAULTS_OK=x, which scanned a file to
|
|
362
|
+
# rc=0 CLEAN using a stream that did not contain the token. A guard that a garbage value walks
|
|
363
|
+
# straight past is not a guard, and the shell's own error line scrolled by unnoticed.
|
|
364
|
+
local _incomplete=""
|
|
365
|
+
case "${PSA_DEFAULTS_OK:-}" in 1) ;; *) _incomplete="defaults_ok=${PSA_DEFAULTS_OK:-unset}" ;; esac
|
|
366
|
+
case "${PSA_BAD_ROWS:-0}" in 0) ;; *) _incomplete="${_incomplete:+$_incomplete }bad_rows=${PSA_BAD_ROWS}" ;; esac
|
|
367
|
+
# The operator override carries the HIGH company/companion literals. Its absence is survivable for a
|
|
368
|
+
# single-file query (the committed defaults still apply) but the caller must never read the result as
|
|
369
|
+
# a full clean — so it is announced, not silently folded in.
|
|
370
|
+
# Round 4: this used to WARN and still return 0. That is the exact false-clean this change exists to
|
|
371
|
+
# remove — the override carries the HIGH company/companion literals, so without it the scan did not
|
|
372
|
+
# look at the highest-severity class at all, and "clean" is a claim the run cannot support. It now
|
|
373
|
+
# joins _incomplete. (No existing caller breaks: psa_scan_file is new in this change.)
|
|
374
|
+
case "${PSA_OVERRIDE_PRESENT:-}" in
|
|
375
|
+
1) ;;
|
|
376
|
+
*) _incomplete="${_incomplete:+$_incomplete }override_absent(HIGH operator literals unscanned)" ;;
|
|
377
|
+
esac
|
|
378
|
+
psa_require_live || return 3
|
|
379
|
+
if [ ! -f "$1" ]; then
|
|
380
|
+
echo " ⚠️ MISSING — $1 : NOT SCANNED (unmeasured, not 0 hits)" >&2
|
|
381
|
+
return 3
|
|
382
|
+
fi
|
|
383
|
+
# `awk | psa_scan_tagged` hides an awk read failure when the caller has no pipefail: awk fails,
|
|
384
|
+
# psa_scan_tagged sees an empty stream and returns 0 = clean. Materialise the tagged stream first
|
|
385
|
+
# so the read is a checkable step of its own (cross-family round 1).
|
|
386
|
+
local tmpf
|
|
387
|
+
tmpf=$(mktemp 2>/dev/null) || { echo " ⚠️ mktemp failed; NOT SCANNED" >&2; return 3; }
|
|
388
|
+
if ! awk -v P="$1" '{print P "\t" $0}' "$1" > "$tmpf" 2>/dev/null; then
|
|
389
|
+
rm -f "$tmpf" || :
|
|
390
|
+
echo " ⚠️ READ FAILED — $1 could not be tagged for scanning; NOT SCANNED" >&2
|
|
391
|
+
return 3
|
|
392
|
+
fi
|
|
393
|
+
if out=$(psa_scan_tagged < "$tmpf"); then rc=0; else rc=$?; fi
|
|
394
|
+
rm -f "$tmpf" || :
|
|
395
|
+
# Print findings FIRST, verdict second — round 3 caught the previous order suppressing real hits:
|
|
396
|
+
# an incomplete instrument returned 3 and emitted nothing, so a token the loaded patterns DID match
|
|
397
|
+
# was lost. "I could not certify this" and "I saw nothing" are different, and the fix for the second
|
|
398
|
+
# must not create the first. Partial evidence is reported; the verdict still refuses to say clean.
|
|
399
|
+
[ -n "$out" ] && printf '%s\n' "$out"
|
|
400
|
+
if [ -n "$_incomplete" ]; then
|
|
401
|
+
echo " ⚠️ INCOMPLETE PATTERN INSTRUMENT — $_incomplete : verdict is NOT SCANNED (any hits above are partial)" >&2
|
|
402
|
+
return 3
|
|
403
|
+
fi
|
|
404
|
+
return "$rc"
|
|
405
|
+
}
|
|
@@ -24,6 +24,36 @@
|
|
|
24
24
|
|
|
25
25
|
set -uo pipefail
|
|
26
26
|
|
|
27
|
+
# ── Misuse fails CLOSED, before the banner ──────────────────────────────────────────────────────
|
|
28
|
+
# This script scans the npm-published file set (derived from `npm pack --dry-run`). It takes NO
|
|
29
|
+
# positional arguments, and until 2026-08-12 it did not parse any either — it silently ignored them
|
|
30
|
+
# and printed its normal green. That turned a misuse into a false certification: a caller who ran
|
|
31
|
+
# bash scripts/public_surface_scan_files.sh <some/path>
|
|
32
|
+
# to ask "is THIS file clean?" got `✅ PASS`, about a file set that never contained <some/path>.
|
|
33
|
+
# Measured known-pair: no args → rc=0 PASS; `/nonexistent/definitely_not_a_file_zzz.md` → byte-identical
|
|
34
|
+
# rc=0 PASS; `PSA_PATTERNS=/nonexistent/nope` → rc=1 (so the green was a live green, not a dead one).
|
|
35
|
+
# The first person to hit it was not the author but the first real user, who nearly recorded
|
|
36
|
+
# "paper/forge_harness_v1.0.html is clean" from a scan that never opened that file.
|
|
37
|
+
# On an irreversible surface (publish) an instrument that cannot answer the question it was asked
|
|
38
|
+
# must refuse, not answer a different question in the affirmative.
|
|
39
|
+
# rc=2 is deliberately distinct from rc=1 (a real leak/incomplete instrument) and rc=0 (clean):
|
|
40
|
+
# "you used it wrong" and "your content is dirty" are different states and must not be collapsed.
|
|
41
|
+
if [ "$#" -gt 0 ]; then
|
|
42
|
+
echo "[Pre-Publish] public-surface scan — ✋ USAGE ERROR: this script takes no arguments (got $#: $*)" >&2
|
|
43
|
+
echo " It scans the npm-published file set only (npm pack --dry-run), never a path you pass in." >&2
|
|
44
|
+
# The psa_load call is NOT optional and is spelled out here on purpose: the first draft of this
|
|
45
|
+
# message omitted it, and cross-family review found that following it verbatim scans a
|
|
46
|
+
# known-positive file to rc=0/clean (empty pattern stream). An instruction that produces a false
|
|
47
|
+
# clean is the same defect as the guard above, one layer out.
|
|
48
|
+
echo " To scan ONE file:" >&2
|
|
49
|
+
echo " . scripts/psa_scan_lib.sh \\" >&2
|
|
50
|
+
echo " && psa_load .claude/rules/.public-surface-patterns.defaults .claude/rules/.public-surface-patterns \\" >&2
|
|
51
|
+
echo " && psa_scan_file <path> # rc: 0 clean · 1 hit(s) · 3 NOT SCANNED" >&2
|
|
52
|
+
echo " To scan tracked files: use the /public-surface-audit skill." >&2
|
|
53
|
+
echo " Refusing rather than reporting a PASS about a different file set (fail-closed)." >&2
|
|
54
|
+
exit 2
|
|
55
|
+
fi
|
|
56
|
+
|
|
27
57
|
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
28
58
|
PSA_DEFAULTS="$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults"
|
|
29
59
|
PSA_OVERRIDE="${PSA_PATTERNS:-$REPO_ROOT/.claude/rules/.public-surface-patterns}"
|