@chrono-meta/fh-gate 1.4.75 → 1.4.77
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/AGENTS.md +18 -0
- package/CLAUDE.md +7 -2
- package/knowledge/shared/harness-core/multi_model_sidecar_strategy.md +6 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +21 -2
- package/knowledge/shared/rules/operational_adaptation.md +130 -1
- 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/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +38 -3
- package/plugins/fh-meta/skills/sim-conductor/SKILL_detail.md +6 -0
- package/plugins/fh-meta/skills/steel-quench/SKILL_detail.md +6 -0
- package/scripts/consent_registry_check.sh +390 -0
- package/scripts/selfcheck.sh +30 -0
- package/scripts/sidecar_wait.sh +55 -5
- package/scripts/test_consent_registry.sh +255 -0
- package/scripts/test_sidecar_wait_stdin.sh +169 -0
- package/templates/consent_classes.yaml.example +75 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# consent_registry_check.sh — mechanical floor for accept-side consent promotion.
|
|
3
|
+
#
|
|
4
|
+
# WHY (cross-family review round 2, 2026-07-29)
|
|
5
|
+
# Round 1 verdict on the prose rule was REJECT; the revision moved it to NARROW-IT, and the
|
|
6
|
+
# reviewer's sharpest remaining point was that the revision "reads mechanical" while several of its
|
|
7
|
+
# predicates — sink tainting, promotion eligibility, effect-subset, expiry — were still semantic.
|
|
8
|
+
# A rule that reads as a control but cannot be checked is worse than an absent one: it buys the
|
|
9
|
+
# confidence without the enforcement. This script is the missing half. It does not judge; it joins
|
|
10
|
+
# `standing_consent` against the declared registry and fails closed on anything it cannot decide.
|
|
11
|
+
#
|
|
12
|
+
# WHAT IT ENFORCES (all mechanical — no model, no judgment)
|
|
13
|
+
# R1 registry schema — every class carries all required fields
|
|
14
|
+
# R2 eligibility soundness — promotion_eligible:true is FORBIDDEN when sinks/feeds are non-empty
|
|
15
|
+
# or contain `unknown` (the taint + unknown-is-not-reversible rules)
|
|
16
|
+
# R3 join — every standing_consent key resolves to a registered class
|
|
17
|
+
# R4 floor — every standing_consent key resolves to a promotion_eligible class
|
|
18
|
+
# R5 lease — every grant carries `expires` and is not past it
|
|
19
|
+
# R6 scope — every grant records `effects` AND `target`, so the subset check has something to
|
|
20
|
+
# compare against (a grant whose scope was never recorded cannot be re-validated)
|
|
21
|
+
#
|
|
22
|
+
# WHAT IT DOES *NOT* CHECK (named, so the prose above it cannot over-claim)
|
|
23
|
+
# - the effect-SUBSET comparison itself. R6 proves a baseline was recorded; it does not compare a
|
|
24
|
+
# live action's fingerprint against it, because this script never sees the live action. That
|
|
25
|
+
# comparison is still a runtime obligation of the rule, i.e. still salience-dependent.
|
|
26
|
+
# - the 3-consecutive count, retry dedupe, and same-operation identity — those live in the UAP
|
|
27
|
+
# logger, not here.
|
|
28
|
+
# - `excludes` / adversarial examples / independent review on a registry entry — required by the
|
|
29
|
+
# rule, not yet mechanized. Do not read a PASS here as "the registry was reviewed."
|
|
30
|
+
# - the existence or contents of `consent_runs.log`.
|
|
31
|
+
# A PASS from this script means the registry and the grants are WELL-FORMED and the floor join
|
|
32
|
+
# holds. It does not mean the promotion mechanism as a whole was verified.
|
|
33
|
+
#
|
|
34
|
+
# DEGRADE DIRECTION
|
|
35
|
+
# No registry file -> exit 0, prints "N/A: promotion DISABLED" (safe: nothing can promote)
|
|
36
|
+
# No UAP file -> exit 0, same
|
|
37
|
+
# Unparseable either file -> exit 1 (fail-closed: cannot decide == not allowed)
|
|
38
|
+
# Any R1-R6 violation -> exit 1
|
|
39
|
+
#
|
|
40
|
+
# "N/A" is printed as N/A, never as PASS — an unmeasured surface is not a clean one.
|
|
41
|
+
#
|
|
42
|
+
# Usage: bash scripts/consent_registry_check.sh [registry.yaml] [uap.md-or-yaml]
|
|
43
|
+
set -uo pipefail
|
|
44
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
45
|
+
REG="${1:-$ROOT/tracks/_meta/consent_classes.yaml}"
|
|
46
|
+
UAP="${2:-$ROOT/tracks/_meta/user_adaptation_profile.md}"
|
|
47
|
+
|
|
48
|
+
python3 - "$REG" "$UAP" <<'PY'
|
|
49
|
+
import sys, os, re, datetime
|
|
50
|
+
reg_path, uap_path = sys.argv[1], sys.argv[2]
|
|
51
|
+
|
|
52
|
+
def out(sym, msg): print(f" {sym} {msg}")
|
|
53
|
+
|
|
54
|
+
if not os.path.exists(reg_path):
|
|
55
|
+
print(f"consent-registry: N/A — no registry at {reg_path}; promotion DISABLED (not a PASS)")
|
|
56
|
+
sys.exit(0)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
import yaml
|
|
60
|
+
except ImportError:
|
|
61
|
+
print("consent-registry: FAIL — pyyaml unavailable, cannot validate; fail-closed")
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
# H9 DUPLICATE YAML KEYS. yaml.safe_load is last-wins on duplicate mapping keys, so
|
|
65
|
+
# `expires: 2020-01-01` followed by `expires: 2099-01-01` silently keeps the future one, and a
|
|
66
|
+
# duplicated grant key keeps whichever was written last. A consent record whose meaning depends on
|
|
67
|
+
# which duplicate a parser happens to keep is not a record. Reject duplicates at load time.
|
|
68
|
+
class NoDupLoader(yaml.SafeLoader):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def _no_dup(loader, node, deep=False):
|
|
72
|
+
# Resolve `<<: *anchor` merge keys FIRST. Without this the merge key survives as a literal `<<`
|
|
73
|
+
# entry and a perfectly ordinary DRY registry was refused (measured: a valid grant written via
|
|
74
|
+
# a merge key exited 1 as an unregistered class). Over-blocking is not a safety win — it trains
|
|
75
|
+
# the override reflex and turns the gate into decoration, so it counts as a defect like any
|
|
76
|
+
# fail-open.
|
|
77
|
+
loader.flatten_mapping(node)
|
|
78
|
+
seen = set()
|
|
79
|
+
for k, _ in node.value:
|
|
80
|
+
key = loader.construct_object(k, deep=deep)
|
|
81
|
+
try:
|
|
82
|
+
if key in seen:
|
|
83
|
+
raise ValueError(f"duplicate key {key!r}")
|
|
84
|
+
except TypeError:
|
|
85
|
+
pass
|
|
86
|
+
seen.add(key)
|
|
87
|
+
return yaml.SafeLoader.construct_mapping(loader, node, deep)
|
|
88
|
+
|
|
89
|
+
NoDupLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_dup)
|
|
90
|
+
|
|
91
|
+
def load(text_or_stream, what):
|
|
92
|
+
global fails
|
|
93
|
+
try:
|
|
94
|
+
return yaml.load(text_or_stream, Loader=NoDupLoader)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
print(f"consent-registry: FAIL — {what} unparseable ({e}); fail-closed")
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
fails = 0
|
|
100
|
+
reg = load(open(reg_path), "registry")
|
|
101
|
+
|
|
102
|
+
REQUIRED = ["name", "owner", "mode", "target", "capabilities", "sinks", "feeds", "promotion_eligible"]
|
|
103
|
+
STR_FIELDS = ["name", "owner", "mode", "target"]
|
|
104
|
+
IRREVERSIBLE = {"go-public", "publish", "delete", "history-rewrite", "unknown"}
|
|
105
|
+
NON_GRANT = {"declined", "unset", "revoked"}
|
|
106
|
+
MAX_LEASE_DAYS = 365
|
|
107
|
+
|
|
108
|
+
# The single normalizer for every scope comparison. Case-SENSITIVE by choice (a capability name is
|
|
109
|
+
# an identifier, not prose) and whitespace-insensitive; the point is that one function decides, so
|
|
110
|
+
# the two sides of a comparison can never drift apart.
|
|
111
|
+
def _norm(s):
|
|
112
|
+
return str(s).strip()
|
|
113
|
+
|
|
114
|
+
# H1 FALSY CONTAINERS. `reg or {}` / `classes or []` laundered `false`, `0`, `[]` and a bare
|
|
115
|
+
# `classes:` into a valid-empty registry that exited 0 — the same falsy-collapse defect already
|
|
116
|
+
# fixed on the GRANT side, left unfixed here. Half a fix propagated is a hole. Sentinel, then type,
|
|
117
|
+
# then default — and an empty registry is stated as N/A, never as a clean pass.
|
|
118
|
+
if not isinstance(reg, dict):
|
|
119
|
+
print(f"consent-registry: FAIL — registry root is {type(reg).__name__}, not a mapping; fail-closed")
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
if "classes" not in reg:
|
|
122
|
+
print("consent-registry: FAIL — registry has no `classes` key; fail-closed")
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
classes = reg["classes"]
|
|
125
|
+
if classes is None:
|
|
126
|
+
classes = []
|
|
127
|
+
if not isinstance(classes, list):
|
|
128
|
+
print(f"consent-registry: FAIL — `classes` is {type(classes).__name__}, not a list; fail-closed")
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
if not classes:
|
|
131
|
+
print("consent-registry: N/A — registry declares zero classes; promotion DISABLED (not a PASS)")
|
|
132
|
+
sys.exit(0)
|
|
133
|
+
|
|
134
|
+
by_name = {}
|
|
135
|
+
for i, c in enumerate(classes):
|
|
136
|
+
if not isinstance(c, dict):
|
|
137
|
+
out("❌", f"R1 class #{i} is not a mapping"); fails += 1; continue
|
|
138
|
+
nm = c.get("name", f"<unnamed #{i}>")
|
|
139
|
+
missing = [f for f in REQUIRED if f not in c]
|
|
140
|
+
if missing:
|
|
141
|
+
out("❌", f"R1 `{nm}` missing required field(s): {', '.join(missing)}"); fails += 1
|
|
142
|
+
continue
|
|
143
|
+
# R1-b STRICT TYPES. `promotion_eligible: "false"` (quoted) is a truthy STRING, so every
|
|
144
|
+
# eligibility test below silently inverts and an intended-ineligible class becomes promotable.
|
|
145
|
+
# Measured 2026-07-29 against a control: quoted "false" PASSed a grant that real `false` blocked.
|
|
146
|
+
# One quote character disarmed the floor — so the type is checked, not coerced.
|
|
147
|
+
if not isinstance(c["promotion_eligible"], bool):
|
|
148
|
+
out("❌", f"R1-b `{nm}` promotion_eligible must be a YAML boolean, got "
|
|
149
|
+
f"{type(c['promotion_eligible']).__name__} {c['promotion_eligible']!r} "
|
|
150
|
+
f"(a quoted \"false\" is truthy and would invert the floor)")
|
|
151
|
+
fails += 1; continue
|
|
152
|
+
# H2 scalar fields: a `mode: 123` or `target: ""` described nothing, so the entry a human was
|
|
153
|
+
# asked to review as a grant-of-future-autonomy was unreadable. H3 list ITEMS were never typed,
|
|
154
|
+
# only their container — `sinks: [123]` counted as a declared sink.
|
|
155
|
+
bad_field = False
|
|
156
|
+
for fld in STR_FIELDS:
|
|
157
|
+
if not isinstance(c[fld], str) or not c[fld].strip():
|
|
158
|
+
out("❌", f"R1-b `{nm}` `{fld}` must be a non-blank string, got "
|
|
159
|
+
f"{type(c[fld]).__name__} {c[fld]!r}")
|
|
160
|
+
fails += 1; bad_field = True
|
|
161
|
+
for fld in ("capabilities", "sinks", "feeds"):
|
|
162
|
+
if not isinstance(c[fld], list):
|
|
163
|
+
out("❌", f"R1-b `{nm}` `{fld}` must be a list, got {type(c[fld]).__name__}")
|
|
164
|
+
fails += 1; bad_field = True
|
|
165
|
+
elif not all(isinstance(x, str) and x.strip() for x in c[fld]):
|
|
166
|
+
out("❌", f"R1-b `{nm}` `{fld}` must contain only non-blank strings, got {c[fld]!r} "
|
|
167
|
+
f"— an unreadable sink is an UNDECLARED sink, and undeclared is unknown")
|
|
168
|
+
fails += 1; bad_field = True
|
|
169
|
+
if bad_field:
|
|
170
|
+
continue
|
|
171
|
+
# R1-c UNIQUE NAMES. Consent is keyed by class name; a duplicate silently shadowed the earlier
|
|
172
|
+
# entry, so appending an eligible twin below an ineligible one granted the ineligible class.
|
|
173
|
+
if nm in by_name:
|
|
174
|
+
out("❌", f"R1-c duplicate class name `{nm}` — consent is keyed by name, so a duplicate "
|
|
175
|
+
f"shadows the earlier entry and can launder an ineligible class")
|
|
176
|
+
fails += 1; continue
|
|
177
|
+
by_name[nm] = c
|
|
178
|
+
# R2 — eligibility must be SOUND, not merely asserted. This is the line that stops a class from
|
|
179
|
+
# declaring itself promotable while naming an irreversible sink two fields above.
|
|
180
|
+
taint = set(map(str, c["sinks"] or [])) | set(map(str, c["feeds"] or []))
|
|
181
|
+
bad = taint & IRREVERSIBLE
|
|
182
|
+
if c["promotion_eligible"] and bad:
|
|
183
|
+
out("❌", f"R2 `{nm}` claims promotion_eligible:true but sinks/feeds include {sorted(bad)}")
|
|
184
|
+
fails += 1
|
|
185
|
+
elif c["promotion_eligible"] and taint:
|
|
186
|
+
out("❌", f"R2 `{nm}` claims promotion_eligible:true with non-empty sinks/feeds {sorted(taint)} "
|
|
187
|
+
f"— unlisted sinks are UNKNOWN, and unknown is not reversible")
|
|
188
|
+
fails += 1
|
|
189
|
+
|
|
190
|
+
if fails == 0:
|
|
191
|
+
out("✅", f"R1/R2 registry schema + eligibility soundness ({len(by_name)} class(es))")
|
|
192
|
+
|
|
193
|
+
# ---- standing_consent side --------------------------------------------------------
|
|
194
|
+
if not os.path.exists(uap_path):
|
|
195
|
+
print(f"consent-registry: N/A — no UAP at {uap_path}; nothing granted (not a PASS)")
|
|
196
|
+
sys.exit(1 if fails else 0)
|
|
197
|
+
|
|
198
|
+
raw = open(uap_path, errors="replace").read()
|
|
199
|
+
# Match BOTH the block form (`standing_consent:` then an indented body) and the inline flow form
|
|
200
|
+
# (`standing_consent: {a: {...}}`). The first version matched only the block form, so an inline
|
|
201
|
+
# grant — including an EXPIRED one — was read as "no standing consent recorded" and reported PASS.
|
|
202
|
+
# A grant the checker cannot see is not an absent grant; that is a false clean, the worst outcome
|
|
203
|
+
# for a floor. Measured 2026-07-29 with a control (block form caught it, inline form did not).
|
|
204
|
+
grants = {}
|
|
205
|
+
# FIRST-MATCH SHADOWING (cross-family round 4, confirmed against a control): reading only the first
|
|
206
|
+
# `standing_consent:` meant a benign or empty one earlier in the file HID a real grant later in it —
|
|
207
|
+
# `standing_consent: {}` followed by an expired grant reported PASS, while the same expired grant
|
|
208
|
+
# alone was caught. A checker that stops at the first occurrence is trivially defeated by appending.
|
|
209
|
+
# There is exactly one consent block or the file is not decidable.
|
|
210
|
+
# YAML-KEY EQUIVALENCE (round 6): `standing_consent : {...}` — a space or tab before the colon —
|
|
211
|
+
# is the SAME YAML key but did not match `^standing_consent:`. Standalone it still failed closed
|
|
212
|
+
# via the no-known-form net below; but paired with a normal empty block it was invisible to both
|
|
213
|
+
# the count and the extraction, so the empty block was parsed and the real grant vanished. All
|
|
214
|
+
# three patterns now allow `[ \t]*` before the colon.
|
|
215
|
+
# RESIDUAL — MEASURED OVER-BLOCK (2026-07-29): a grant written with a YAML merge key
|
|
216
|
+
# (`defaults: &d {...}` + `standing_consent:\n <<: *d`) is REFUSED, because the anchor `&d` lives
|
|
217
|
+
# outside the extracted fragment and the alias is undefined when the fragment is re-parsed alone.
|
|
218
|
+
# That is ordinary DRY YAML, and over-blocking is a defect of the same weight as a fail-open: it
|
|
219
|
+
# trains the override reflex and turns the gate into decoration. A whole-file-parse-first path was
|
|
220
|
+
# attempted and reverted the same session — it regressed 16 of 40 anchor lanes, and shipping a
|
|
221
|
+
# broken floor to close an over-block is a worse trade. Anchor lane N4 pins the current behaviour so
|
|
222
|
+
# the next attempt is a measured delta, not a rediscovery.
|
|
223
|
+
# RESIDUAL (named): this is regex-scraping a YAML key out of a markdown file, so it approximates
|
|
224
|
+
# the YAML spec rather than implementing it (quoted keys, anchors, multi-doc streams are not
|
|
225
|
+
# handled). The mitigation is the fail-closed net below, not a claim of completeness.
|
|
226
|
+
occurrences = re.findall(r"^standing_consent[ \t]*:", raw, re.M)
|
|
227
|
+
if len(occurrences) > 1:
|
|
228
|
+
out("❌", f"R3 the UAP declares `standing_consent` {len(occurrences)} times — ambiguous which "
|
|
229
|
+
f"is authoritative, and an early benign block would shadow a later grant; fail-closed")
|
|
230
|
+
fails += 1
|
|
231
|
+
body = None
|
|
232
|
+
grants = None
|
|
233
|
+
else:
|
|
234
|
+
m = re.search(r"^standing_consent[ \t]*:[ \t]*(\{.*)$", raw, re.M) # inline flow form
|
|
235
|
+
if not m:
|
|
236
|
+
m = re.search(r"^standing_consent[ \t]*:[ \t]*$(.*?)(?=^\S|\Z)", raw, re.M | re.S) # block form
|
|
237
|
+
body = ("standing_consent:\n" + m.group(1)) if m else None # re-emitted canonically
|
|
238
|
+
else:
|
|
239
|
+
body = "standing_consent: " + m.group(1)
|
|
240
|
+
if grants is not None and body is not None:
|
|
241
|
+
try:
|
|
242
|
+
# NoDupLoader here too. The duplicate-key rejection was added on the registry side and
|
|
243
|
+
# NOT here in the same edit — the third half-fix of this session, committed while
|
|
244
|
+
# fixing a half-fix. Duplicate grant keys were still last-wins: an expired grant
|
|
245
|
+
# followed by a future one silently kept the future one.
|
|
246
|
+
parsed = yaml.load(body, Loader=NoDupLoader)
|
|
247
|
+
# NO `or {}` HERE. `[] or {}` / `False or {}` / `0 or {}` all evaluate to `{}`, which reached
|
|
248
|
+
# the type check already laundered into a valid-empty mapping — so `standing_consent:\n []`
|
|
249
|
+
# reported "no standing consent recorded" and exit 0, while the truthy `[a, b]` was caught
|
|
250
|
+
# (measured with that control, cross-family round 5). The falsy branch is exactly the one an
|
|
251
|
+
# accident produces. Sentinel first, type check second, defaulting last.
|
|
252
|
+
grants = (parsed if isinstance(parsed, dict) else {}).get("standing_consent", {})
|
|
253
|
+
if grants is None:
|
|
254
|
+
grants = {} # an explicitly empty `standing_consent:` key is a real empty set
|
|
255
|
+
if not isinstance(grants, dict):
|
|
256
|
+
out("❌", f"R3 standing_consent is not a mapping ({type(grants).__name__}); fail-closed")
|
|
257
|
+
fails += 1; grants = None
|
|
258
|
+
except Exception as e:
|
|
259
|
+
out("❌", f"R3 standing_consent block unparseable ({e}); fail-closed"); fails += 1
|
|
260
|
+
grants = None
|
|
261
|
+
elif re.search(r"standing_consent", raw):
|
|
262
|
+
# The key appears but neither form matched — do not silently read that as "nothing granted".
|
|
263
|
+
out("❌", "R3 `standing_consent` appears in the UAP but matched no known form; fail-closed")
|
|
264
|
+
fails += 1; grants = None
|
|
265
|
+
|
|
266
|
+
if grants is None:
|
|
267
|
+
pass
|
|
268
|
+
elif not grants:
|
|
269
|
+
out("✅", "R3-R6 no standing consent recorded (nothing to validate)")
|
|
270
|
+
else:
|
|
271
|
+
today = datetime.date.today()
|
|
272
|
+
validated = 0
|
|
273
|
+
for name, g in grants.items():
|
|
274
|
+
# H4 grant key: registry `name: 123` + UAP `123:` used to join fine because neither side was
|
|
275
|
+
# typed. Both sides are strings or the join is meaningless.
|
|
276
|
+
if not isinstance(name, str) or not name.strip():
|
|
277
|
+
out("❌", f"R3 grant key {name!r} must be a non-blank string"); fails += 1; continue
|
|
278
|
+
# `declined` / `unset` / `revoked` are first-class states in this storage model, not grants.
|
|
279
|
+
# Coercing them into `{"granted": ...}` turned a legitimate refusal into a malformed grant
|
|
280
|
+
# and reported violations against a user who said no.
|
|
281
|
+
if isinstance(g, str) and g.strip().lower() in NON_GRANT:
|
|
282
|
+
continue
|
|
283
|
+
if not isinstance(g, dict):
|
|
284
|
+
out("❌", f"R3 `{name}` has value {g!r} — not a grant mapping and not one of "
|
|
285
|
+
f"{sorted(NON_GRANT)}; fail-closed")
|
|
286
|
+
fails += 1; continue
|
|
287
|
+
# Normalize identically to the scalar branch above. Without .strip() a `state: "revoked "`
|
|
288
|
+
# fell through and was validated as an ACTIVE grant (cross-family round 4) — two spellings of
|
|
289
|
+
# the same state judged by two different normalizers is the divergent-normalizer class.
|
|
290
|
+
# H6 UNKNOWN STATE. Only the three canonical states were recognized; anything else — a typo
|
|
291
|
+
# like `revokedd`, or a state a future version adds — fell through and was validated as an
|
|
292
|
+
# ACTIVE grant. A record whose state we cannot read is not a grant we may honour.
|
|
293
|
+
if "state" in g:
|
|
294
|
+
st = g["state"]
|
|
295
|
+
if not isinstance(st, str) or st.strip().lower() not in (NON_GRANT | {"granted"}):
|
|
296
|
+
out("❌", f"R3 `{name}` has unrecognized state {st!r} — expected one of "
|
|
297
|
+
f"{sorted(NON_GRANT | {'granted'})}; fail-closed")
|
|
298
|
+
fails += 1; continue
|
|
299
|
+
if st.strip().lower() in NON_GRANT:
|
|
300
|
+
continue
|
|
301
|
+
validated += 1
|
|
302
|
+
c = by_name.get(name)
|
|
303
|
+
if c is None:
|
|
304
|
+
out("❌", f"R3 `{name}` granted but NOT in the registry (unregistered == unknown)"); fails += 1; continue
|
|
305
|
+
if not c.get("promotion_eligible"):
|
|
306
|
+
out("❌", f"R4 `{name}` granted but registry says promotion_eligible:false"); fails += 1; continue
|
|
307
|
+
# H5 `granted` was never checked at all — a grant with no grant date cannot be audited
|
|
308
|
+
# against the three approvals that were supposed to produce it.
|
|
309
|
+
def _date(v, fld):
|
|
310
|
+
if isinstance(v, datetime.date):
|
|
311
|
+
return v
|
|
312
|
+
if isinstance(v, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", v.strip()):
|
|
313
|
+
try:
|
|
314
|
+
return datetime.date.fromisoformat(v.strip())
|
|
315
|
+
except ValueError:
|
|
316
|
+
return None
|
|
317
|
+
return None # ints like 29991231 are NOT dates; basic-format parsing accepted them
|
|
318
|
+
gd = _date(g.get("granted"), "granted")
|
|
319
|
+
if gd is None:
|
|
320
|
+
out("❌", f"R5 `{name}` has missing or non-ISO `granted` ({g.get('granted')!r}) — "
|
|
321
|
+
f"expected YYYY-MM-DD"); fails += 1
|
|
322
|
+
elif gd > today:
|
|
323
|
+
# A consent dated in the future has not been given. `granted: 2099-01-01` passed.
|
|
324
|
+
out("❌", f"R5 `{name}` is `granted` {gd}, in the FUTURE — a consent that has not "
|
|
325
|
+
f"happened yet cannot authorize anything"); fails += 1
|
|
326
|
+
exp = g.get("expires")
|
|
327
|
+
if exp is None:
|
|
328
|
+
out("❌", f"R5 `{name}` granted with no `expires` — standing consent is a lease, not a transfer"); fails += 1
|
|
329
|
+
else:
|
|
330
|
+
d = _date(exp, "expires")
|
|
331
|
+
if d is None:
|
|
332
|
+
out("❌", f"R5 `{name}` has non-ISO expires={exp!r} — expected YYYY-MM-DD "
|
|
333
|
+
f"(`29991231` parsed as a date under basic-format rules and slipped through)")
|
|
334
|
+
fails += 1
|
|
335
|
+
elif d < today:
|
|
336
|
+
out("❌", f"R5 `{name}` expired {d} — must lapse to unset, not keep running"); fails += 1
|
|
337
|
+
# H7 LEASE BOUND. `expires: 9999-12-31` satisfied "has an expiry" while defeating the
|
|
338
|
+
# entire point of one. A lease longer than the maximum is a transfer wearing a lease's
|
|
339
|
+
# clothes.
|
|
340
|
+
elif gd is not None and (d - gd).days > MAX_LEASE_DAYS:
|
|
341
|
+
out("❌", f"R5 `{name}` lease is {(d - gd).days} days (max {MAX_LEASE_DAYS}) — "
|
|
342
|
+
f"an unbounded expiry is a transfer, not a lease"); fails += 1
|
|
343
|
+
# R6 — a grant with no recorded scope can never be re-validated against a drifted action.
|
|
344
|
+
# R6 — presence AND type. The registry side got strict types at R1-b; the grant side did not,
|
|
345
|
+
# so `effects: true` / `target: 123` were "recorded" and passed while a MISSING field was
|
|
346
|
+
# caught (measured with that control, cross-family round 7). Half a fix propagated is a hole:
|
|
347
|
+
# a baseline that is not a list-of-effects and a real target string cannot be compared against
|
|
348
|
+
# anything later, which is the entire purpose of recording it.
|
|
349
|
+
eff, tgt = g.get("effects"), g.get("target")
|
|
350
|
+
if eff is None or tgt is None:
|
|
351
|
+
out("❌", f"R6 `{name}` grant records no `effects`+`target` — the subset check has no baseline")
|
|
352
|
+
fails += 1
|
|
353
|
+
else:
|
|
354
|
+
if not isinstance(eff, list) or not eff or not all(isinstance(e, str) and e.strip() for e in eff):
|
|
355
|
+
out("❌", f"R6 `{name}` `effects` must be a non-empty list of strings, got "
|
|
356
|
+
f"{type(eff).__name__} {eff!r} — an uncomparable baseline is not a baseline")
|
|
357
|
+
fails += 1
|
|
358
|
+
if not isinstance(tgt, str) or not tgt.strip():
|
|
359
|
+
out("❌", f"R6 `{name}` `target` must be a non-empty string, got "
|
|
360
|
+
f"{type(tgt).__name__} {tgt!r}")
|
|
361
|
+
fails += 1
|
|
362
|
+
# R7 STORED-SCOPE SUBSET. The grant recorded its own scope and nothing compared it to
|
|
363
|
+
# what the registry actually authorizes, so `effects: [repo-mutation]` sat happily under
|
|
364
|
+
# a `capabilities: [read]` class. This is the mechanizable HALF of the subset rule: it
|
|
365
|
+
# cannot see a LIVE action (still a runtime obligation, still named as residual), but a
|
|
366
|
+
# STORED grant wider than its own class is checkable right here — and was not checked.
|
|
367
|
+
if isinstance(eff, list) and all(isinstance(e, str) for e in eff):
|
|
368
|
+
# One normalizer, both sides. Before this the grant side was stripped and the
|
|
369
|
+
# registry side was not, so `[" read "]` matched while `[READ]` did not — whitespace
|
|
370
|
+
# forgiving, case strict, for no stated reason. Divergent normalizers on the two
|
|
371
|
+
# sides of a comparison is the same defect class this file already fixed twice.
|
|
372
|
+
over = sorted({_norm(e) for e in eff} - {_norm(x) for x in c["capabilities"]})
|
|
373
|
+
if over:
|
|
374
|
+
out("❌", f"R7 `{name}` grant claims effect(s) {over} outside its registered "
|
|
375
|
+
f"capabilities {c['capabilities']} — the grant is wider than the class")
|
|
376
|
+
fails += 1
|
|
377
|
+
if isinstance(tgt, str) and _norm(tgt) != _norm(str(c["target"])):
|
|
378
|
+
out("❌", f"R7 `{name}` grant target {tgt!r} does not match the registered target "
|
|
379
|
+
f"{c['target']!r} — scope drift between grant and class")
|
|
380
|
+
fails += 1
|
|
381
|
+
if fails == 0:
|
|
382
|
+
skipped = len(grants) - validated
|
|
383
|
+
note = f" ({skipped} non-grant state(s) skipped)" if skipped else ""
|
|
384
|
+
out("✅", f"R3-R6 all {validated} active grant(s) registered, eligible, unexpired, "
|
|
385
|
+
f"scope-recorded{note}")
|
|
386
|
+
|
|
387
|
+
print("----")
|
|
388
|
+
print(f"consent-registry: {'PASS' if fails == 0 else f'{fails} violation(s)'}")
|
|
389
|
+
sys.exit(0 if fails == 0 else 1)
|
|
390
|
+
PY
|
package/scripts/selfcheck.sh
CHANGED
|
@@ -132,6 +132,36 @@ fi
|
|
|
132
132
|
# prevent. test_card_drift_probe.sh had shipped with ZERO callers since it was written; wiring it
|
|
133
133
|
# here closes that, and the anchors are added to files[] in the same change so package mode runs
|
|
134
134
|
# them too rather than reporting a deleted anchor.
|
|
135
|
+
# consent-class registry floor. Its subject decides whether standing consent may skip an approval
|
|
136
|
+
# prompt, so an uncalibrated instrument there hands out autonomy the operator never granted. The
|
|
137
|
+
# anchor was written into tests/ with ZERO callers first — the same defect this file already
|
|
138
|
+
# records twice above; wiring it here is the fix, not a note about the fix.
|
|
139
|
+
if [ ! -f scripts/consent_registry_check.sh ]; then
|
|
140
|
+
echo "SKIP test_consent_registry.sh (subject scripts/consent_registry_check.sh absent)"
|
|
141
|
+
elif [ -f scripts/test_consent_registry.sh ]; then
|
|
142
|
+
if ! bash scripts/test_consent_registry.sh; then
|
|
143
|
+
fail=1
|
|
144
|
+
fi
|
|
145
|
+
else
|
|
146
|
+
echo "FAIL test_consent_registry.sh: consent_registry_check.sh present but its anchor is missing"
|
|
147
|
+
fail=1
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
# sidecar_wait stdin plumbing. Its subject is dispatched by auto-decorrelation / steel-quench /
|
|
151
|
+
# sim-conductor / AGENTS.md as the REQUIRED wait form, so a regression there silently empties every
|
|
152
|
+
# cross-family verification. The anchor's first version shipped in tests/ with ZERO callers — the
|
|
153
|
+
# same defect the comment above records for test_card_drift_probe.sh, repeated one file later.
|
|
154
|
+
if [ ! -f scripts/sidecar_wait.sh ]; then
|
|
155
|
+
echo "SKIP test_sidecar_wait_stdin.sh (subject scripts/sidecar_wait.sh absent)"
|
|
156
|
+
elif [ -f scripts/test_sidecar_wait_stdin.sh ]; then
|
|
157
|
+
if ! bash scripts/test_sidecar_wait_stdin.sh; then
|
|
158
|
+
fail=1
|
|
159
|
+
fi
|
|
160
|
+
else
|
|
161
|
+
echo "FAIL test_sidecar_wait_stdin.sh: sidecar_wait.sh present but its anchor is missing"
|
|
162
|
+
fail=1
|
|
163
|
+
fi
|
|
164
|
+
|
|
135
165
|
for _anchor in scripts/test_session_close_lanes.sh scripts/test_card_drift_probe.sh; do
|
|
136
166
|
if [ ! -f scripts/session_close_check.sh ]; then
|
|
137
167
|
echo "SKIP ${_anchor##*/} (subject scripts/session_close_check.sh absent)"
|
package/scripts/sidecar_wait.sh
CHANGED
|
@@ -41,12 +41,59 @@ shift 2
|
|
|
41
41
|
[ "${1:-}" = "--" ] && shift
|
|
42
42
|
[ $# -gt 0 ] || { echo "sidecar_wait: no command given" >&2; exit 2; }
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
# Forward stdin to the child with an explicit fd dup.
|
|
45
|
+
#
|
|
46
|
+
# The hole (measured 2026-07-29, known-pair): `"$@" > "$OUT" 2>&1 &` gave the child /dev/null for
|
|
47
|
+
# stdin in a non-interactive shell, so the documented pipe form reached codex with NO prompt, codex
|
|
48
|
+
# answered "No prompt provided via stdin", and this wrapper reported COMPLETE — the exact 0-output
|
|
49
|
+
# misjudgment it exists to prevent, produced by itself.
|
|
50
|
+
#
|
|
51
|
+
# `<&0` is the whole fix: POSIX substitutes /dev/null ONLY when stdin is not explicitly redirected.
|
|
52
|
+
# The first repair spooled stdin to a tempfile instead, and adversarial review (Axis 2) showed that
|
|
53
|
+
# mechanism was both unnecessary AND strictly worse than the bug — reproduced, not argued:
|
|
54
|
+
# - the unbounded `cat` ran BEFORE the child, so an inherited never-EOF stdin hung the wrapper
|
|
55
|
+
# forever and the timeout budget never applied (`-- true` with inherited stdin → rc=124);
|
|
56
|
+
# - it CONSUMED the caller's stdin, so a caller reading 3 lines after the call read 0.
|
|
57
|
+
# A bounded-wait wrapper with an unbounded pre-step, and an input-preserving tool that eats input.
|
|
58
|
+
# The lesson is kept in the file: the simplest correct fix was one token, and the machinery built
|
|
59
|
+
# around it introduced two S-grade defects the original bug did not have.
|
|
60
|
+
: > "$OUT" || { echo "SIDECAR_VERDICT=OUTFILE_UNWRITABLE path=$OUT" >&2; exit 2; }
|
|
61
|
+
set -m # own process group per child, so the TIMEOUT kill can reach grandchildren
|
|
62
|
+
if [ -t 0 ]; then
|
|
63
|
+
# On a controlling tty, a child that READS stdin raises SIGTTIN and stops the whole process
|
|
64
|
+
# group — the wrapper with it — so the budget never fires and no verdict is emitted (measured:
|
|
65
|
+
# `cat` under a tty gave rc=124 and zero verdict lines, while `sleep 30` correctly TIMEOUTed).
|
|
66
|
+
# Interactive callers have no prompt to pipe anyway; the documented pipe form is never a tty.
|
|
67
|
+
"$@" < /dev/null > "$OUT" 2>&1 &
|
|
68
|
+
else
|
|
69
|
+
"$@" <&0 > "$OUT" 2>&1 &
|
|
70
|
+
fi
|
|
46
71
|
PID=$!
|
|
47
72
|
|
|
48
73
|
waited=0
|
|
49
74
|
last_size=0
|
|
75
|
+
# Poll interval is overridable so the regression anchor is not charged the 5s floor per
|
|
76
|
+
# invocation (a 25s anchor is an anchor people skip).
|
|
77
|
+
POLL="${SIDECAR_POLL:-5}"
|
|
78
|
+
# Validate it. Caller-controlled and unvalidated, this knob RESTORED the very failure the wrapper
|
|
79
|
+
# exists to prevent (measured 2026-07-29, budget 3s under an external timeout 10):
|
|
80
|
+
# SIDECAR_POLL=0 -> rc=124, `waited` never advances, TIMEOUT never fires, zero typed verdicts
|
|
81
|
+
# SIDECAR_POLL=0.5 -> rc=124, arithmetic error each iteration, assignment never lands
|
|
82
|
+
# SIDECAR_POLL=abc -> exits 1 (the documented TIMEOUT code) with NO verdict line and a live child
|
|
83
|
+
# The file's own "a 25s anchor is an anchor people skip" comment invites tuning this, and `0.5` is
|
|
84
|
+
# the obvious next step for someone doing that. An unbounded wait must not be reachable by typo.
|
|
85
|
+
# `10#` forces base 10: `test` reads 08 as decimal-8 and PASSES it, then $((waited + 08)) dies with
|
|
86
|
+
# "value too great for base" every iteration, waited never advances, and the wait is unbounded again
|
|
87
|
+
# — the first guard did not close its own finding (measured: 08/09 -> rc=124, zero verdicts).
|
|
88
|
+
# The ceiling matters just as much: the budget is checked at the TOP of the loop, so any POLL above
|
|
89
|
+
# it makes the effective wait POLL, not BUDGET (SIDECAR_POLL=600 -> rc=124). 600 is exactly what
|
|
90
|
+
# someone copying the budget argument into the knob writes.
|
|
91
|
+
_poll_raw="$POLL"
|
|
92
|
+
case "$POLL" in ''|*[!0-9]*) POLL=5 ;; *) POLL=$((10#$POLL)) 2>/dev/null || POLL=5 ;; esac
|
|
93
|
+
{ [ "$POLL" -ge 1 ] && [ "$POLL" -le 60 ]; } 2>/dev/null || POLL=5
|
|
94
|
+
# Never coerce silently on a script whose whole thesis is a typed channel.
|
|
95
|
+
[ "$POLL" = "$_poll_raw" ] || [ -z "${SIDECAR_POLL:-}" ] || \
|
|
96
|
+
echo "sidecar_wait: ignoring SIDECAR_POLL='$_poll_raw' (not an integer in 1..60); using $POLL" >&2
|
|
50
97
|
# Poll rather than `wait`, so a live-but-quiet process is distinguishable from a dead one and the
|
|
51
98
|
# caller can SEE progress. A silent minute on a reasoning model is normal; the earlier misreading
|
|
52
99
|
# happened precisely because silence was treated as termination.
|
|
@@ -54,11 +101,14 @@ while kill -0 "$PID" 2>/dev/null; do
|
|
|
54
101
|
if [ "$waited" -ge "$BUDGET" ]; then
|
|
55
102
|
size=$(wc -c < "$OUT" 2>/dev/null | tr -d ' ')
|
|
56
103
|
echo "SIDECAR_VERDICT=TIMEOUT waited=${BUDGET}s bytes=${size:-0} pid=$PID"
|
|
57
|
-
echo " the process is STILL RUNNING — this is not 'no output'. Raise the budget
|
|
104
|
+
echo " the process is STILL RUNNING — this is not 'no output'. Raise the budget if it needs longer." >&2
|
|
105
|
+
# Kill the GROUP. `kill "$PID"` reaches only the direct child, so `sh -c 'sleep N & wait'`
|
|
106
|
+
# left a live grandchild behind while the lane stayed green (measured wave 4).
|
|
107
|
+
kill -- -"$PID" 2>/dev/null || kill "$PID" 2>/dev/null
|
|
58
108
|
exit 1
|
|
59
109
|
fi
|
|
60
|
-
sleep
|
|
61
|
-
waited=$((waited +
|
|
110
|
+
sleep "$POLL"
|
|
111
|
+
waited=$((waited + POLL))
|
|
62
112
|
size=$(wc -c < "$OUT" 2>/dev/null | tr -d ' ')
|
|
63
113
|
if [ "${size:-0}" -ne "$last_size" ]; then
|
|
64
114
|
echo " … ${waited}s elapsed, ${size} bytes so far (alive)" >&2
|