@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.
@@ -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
@@ -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)"
@@ -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
- : > "$OUT"
45
- "$@" > "$OUT" 2>&1 &
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, or kill $PID" >&2
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 5
61
- waited=$((waited + 5))
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