@chrono-meta/fh-gate 1.4.76 → 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/CLAUDE.md +7 -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/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
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "fh-meta",
|
|
14
|
-
"version": "1.4.
|
|
14
|
+
"version": "1.4.77",
|
|
15
15
|
"description": "Hub meta-operations toolkit — 35 skills + 7 agents. New in 1.4.53: `fh-codex-doctor` (npm bin) — Codex adapter drift scanner; reads the documented M1/M2/M3 skill tier map + skill/agent source and reports codex-native/adapter-required/claude-native/unclassified per unit, wired into `npm test`/`prepublishOnly` (fail-closed on unclassified Claude-native primitives). New in 1.4.49: steel-quench gains Step 0.6 Verdict-Invariance Probe (groundedness axis — a load-bearing judged gate's verdict must track behavior, not rubric phrasing; measured flip-count over cross-family paraphrases; arXiv:2605.06161 Policy Invariance anchor); multi_model_sidecar_strategy §Vendor-native harness (a model is strongest in its own vendor CLI — Claude/CC, GPT/codex, Gemini/Antigravity; a universal router degrades all of them, so it stays an autocomplete/QA sidecar, never orchestration); predelete_check.sh fail-closed rewrite; memory-hygiene A-TMA anchor. New in 1.4.48: phantom-quench + steel-quench gain external frontier anchors (arXiv:2607.02052 package-hallucination; arXiv:2607.02057 prompt-coverage-adequacy); README model-flat claim reframed from a per-release point-curve to structural invariants (operation flattens across tiers; depth tier-order fixed within a generation). New in 1.4.47: onboarding step ① surfaces the Mode D companion-store session-start load in the auto-read salience anchor (previously only in the local binding + rules, so a greeting could skip the load). New in 1.4.46: context-doctor command-output axis (route to rtk/proxy for verbose CLI stdout, complementing .claudeignore; risk-gated to token-scarce envs). New in 1.4.41: context-doctor 2026 trigger vocab (context engineering/rot/collapse) + phantom-citation hardening; hub measurement-integrity-checklist (cross-model measurement pre-flight: display-name pin/reps≥3/discriminating probe). New in 1.4.40: install-wizard queryable-wiki scaffold (INDEX + session-start read + R/W/C ingest). New in 1.4.39: auto-decorrelation (cross-family verifier sidecar recruitment) + video-ingest (capability-routed video ingestion). New in 1.4.x: verify-axis check-class taxonomy (mandatory-pass/measured/judged), no-reinvention Tier-0 inventory, 7-class failure taxonomy, Destructive-Op Gate, Wave-T (Temper), tier-floor governance, Mode D Model Notice, FC consent lane, default-Sonnet guidance. New in 1.3.0: public-surface-audit, field-harvest Mode B auto-trigger, 4-axis gate scope ext. Validated cross-CLI: Claude Code, Codex, Gemini.",
|
|
16
16
|
"source": "./plugins/fh-meta"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"name": "fh-commons",
|
|
20
|
-
"version": "1.4.
|
|
20
|
+
"version": "1.4.77",
|
|
21
21
|
"description": "Project-agnostic utility skills — 4 skills (convergence-loop · deliberation · mcp-circuit-breaker · token-budget-gate) + 1 agent (quench-challenger). Domain-independent utilities transplantable into any project.",
|
|
22
22
|
"source": "./plugins/fh-commons"
|
|
23
23
|
}
|
package/CLAUDE.md
CHANGED
|
@@ -531,7 +531,7 @@ Proposal format: `"I see [X]. Want me to run /[skill] to [one-line description]?
|
|
|
531
531
|
| **"진단해줘", "개선해줘", "diagnose this", "improve this harness", "check this project", "audit this project"** — said while working **in a mapped project** (not a single-file ask) | **Field-Harness Diagnostic** (see §Field-Harness Diagnostic above → compose existing checks into one ranked M/S/R list → HITL approval per item, nothing auto-fixed) |
|
|
532
532
|
| **"새 프로젝트", "하네스 작성해줘", "이 프로젝트 가속화", "harness-ify this", "accelerate this project"** — an onboarding/acceleration door (returning-menu ①②③) | **Onboarding / Acceleration Autopilot** (see §Onboarding / Acceleration Autopilot above → Phase 0 auto-discover + branch → innovator-centered recommend → ranked install plan → HITL per item, non-overwriting; "끝까지 자율로" → full-autonomy under /goal-quench gate) |
|
|
533
533
|
|
|
534
|
-
**Guard**: Do not propose a skill that is already running. One signal = one-line proposal (no pressure). Before proposing, consult the UAP (§Operational Adaptation Loop): a skill the user has rejected 3+ times is **suppressed**, not re-proposed.
|
|
534
|
+
**Guard**: Do not propose a skill that is already running. One signal = one-line proposal (no pressure). Before proposing, consult the UAP (§Operational Adaptation Loop): a skill the user has rejected 3+ times is **suppressed**, not re-proposed — and, symmetrically, a class **accepted 3× consecutively** earns a one-time "stop asking?" offer (§Consent promotion; never on irreversible surfaces).
|
|
535
535
|
For per-skill utterance patterns, see the relevant `SKILL.md §Trigger Phrases` section.
|
|
536
536
|
|
|
537
537
|
### Cadence Rules — Check at Session Start
|
|
@@ -564,7 +564,8 @@ Some proposals are not *time*-overdue — they fire **once when a specific work
|
|
|
564
564
|
Self-healing is not only FH-self-dev (Mode D 4-axis) and reactive (`verify-bidirectional`). A **standing, per-user operational loop** tunes FH behavior to the individual during normal field use, and escalates **only generalizable** learnings to the `field-harvest` → FH-origin PR funnel — idiosyncratic taste stays local (drift guard).
|
|
565
565
|
|
|
566
566
|
- **User Adaptation Profile (UAP)** — `tracks/_meta/user_adaptation_profile.md` (local/gitignored; **behavioral prefs only, never domain content**). Records skill-proposal outcomes (`accepted`/`rejected`/`sustained` — same vocabulary as `operations.md`), preferred tier/language/cadence, recurring friction, muted nags.
|
|
567
|
-
- **Pass** — rides `field-harvest` Mode B at field-session close (no new trigger, one per session): READ to apply (suppress a 3×-rejected proposal, default to preferred tier, mute declined cadence nags), WRITE to update outcomes.
|
|
567
|
+
- **Pass** — rides `field-harvest` Mode B at field-session close (no new trigger, one per session): READ to apply (suppress a 3×-rejected proposal, **offer standing consent on a 3×-accepted class**, default to preferred tier, mute declined cadence nags), WRITE to update outcomes.
|
|
568
|
+
- **Consent promotion (accept-side)** — repeated approval must offer to stop asking, not bill the same prompt forever: 3 consecutive `accepted` on a **registered** class (`tracks/_meta/consent_classes.yaml` — classes are declared, never minted mid-run) → **offer once, quoting the three approvals and the exact scope** → granted = a **time-limited lease**, revocable, and every unprompted run announces itself. **Not symmetric with suppression**: a bad suppression costs a re-ask, a bad grant has side effects. **Floor, decided mechanically from the registry — never by the session's own judgment**: a class never promotes if its sinks are irreversible (publish · delete · history-rewrite), if it *feeds* such a sink (**taint propagates through reversible steps**), or if that is **unknown** — unknown is not reversible. No UAP / no registry entry / expired → keep asking (absent ≠ granted). **Named residual: the ledger is self-attested** — mitigated (append-only, quoted evidence), not closed.
|
|
568
569
|
- **Generalization gate** — idiosyncratic → UAP local; generalizable (any user benefits; `≥40%` reject = redefine candidate / `≥60%` accept = reinforce, per `operations.md` gate) → `field-harvest` Mode A → FH PR (HITL).
|
|
569
570
|
- **Ephemeral guard** — UAP is gitignored, wiped on cloud reclaim; in ephemeral sessions operate from defaults, do not fabricate it.
|
|
570
571
|
|
|
@@ -699,6 +700,10 @@ Closing phrase detected ("wrap up", "done", "good work", "end session", etc.)
|
|
|
699
700
|
outcomes. ⑤ is ATOMIC and owns BOTH writes: (a) append any close-time finding to
|
|
700
701
|
`fh_completed_{date}.md` FIRST, (b) then write the card. Once ⑤ starts, `fh_completed`
|
|
701
702
|
is CLOSED — a later append re-opens the violation ⑤ exists to prevent.
|
|
703
|
+
**Late finding (named case)**: a finding that surfaces AFTER (b) — including while writing
|
|
704
|
+
the final message to the operator — means ⑤ is **not done**. Re-run ⑤ **whole**: append,
|
|
705
|
+
then **rewrite the card**. Appending alone is the violation; the card must never be older
|
|
706
|
+
than `fh_completed`.
|
|
702
707
|
→ ⑥ Commit card + push
|
|
703
708
|
```
|
|
704
709
|
|
|
@@ -28,9 +28,136 @@ This loop fills that gap. It is deliberately thin: it **reuses** existing parts
|
|
|
28
28
|
|
|
29
29
|
Runs at field-session close, **riding `field-harvest` Mode B** — no new trigger, never an interception. One pass per session.
|
|
30
30
|
|
|
31
|
-
- **READ** (session start / proposal time): apply UAP — suppress a skill proposal rejected 3+ times (an `accepted` record carries **no** positive auto-action —
|
|
31
|
+
- **READ** (session start / proposal time): apply UAP — suppress a skill proposal rejected 3+ times, apply any **standing consent** granted per §Consent promotion below (an `accepted` record on its own still carries **no** positive auto-action — acceptance alone never auto-runs anything; only a granted standing consent does), default to the preferred tier, mute cadence nags the user always declines, and **apply capability-escalation consent** (`sidecar_consent`/`floorup_consent` `declined` → route to the Sonnet / Tier-3 floor, recommend-only, no re-nag; `unset` → ask-once at first need per the consent protocol). (Tier note: the UAP tier default is a session-depth setting; the Mode D model notice is model-only + advisory and never overrides it.)
|
|
32
32
|
- **WRITE** (session close): update outcome counts + new friction points.
|
|
33
33
|
|
|
34
|
+
## Consent promotion (accept-side) — repeated approval must offer to stop asking
|
|
35
|
+
|
|
36
|
+
The suppression rule above is **one-directional**: repeated *rejection* changes FH's behavior, repeated
|
|
37
|
+
*approval* changed nothing, so a user who approves the same class every time keeps paying the same
|
|
38
|
+
prompt forever. That asymmetry is the defect this section closes. It is the general-user form of the
|
|
39
|
+
elastic-granularity principle already granted to the operator (`feedback_hitl_elastic_granularity_cto`):
|
|
40
|
+
**the floor is fixed, the approval grain is not.**
|
|
41
|
+
|
|
42
|
+
**Promotion is not the mirror image of suppression.** Rejection suppresses a *proposal* — a wrong
|
|
43
|
+
suppression costs one manual re-ask. Acceptance authorizes an *action* with the user absent — a wrong
|
|
44
|
+
grant has side effects. Symmetric machinery over asymmetric stakes is itself the error (cross-family
|
|
45
|
+
review, 2026-07-29). So the accept side carries three things the reject side does not: an **explicitly
|
|
46
|
+
scoped** class, an **expiry**, and a **separate confirmation** that is not the same "yes" that approved
|
|
47
|
+
the action in front of the user.
|
|
48
|
+
|
|
49
|
+
**Classes are declared, never minted at runtime** (this is what makes the trigger mechanical). A class
|
|
50
|
+
is promotion-eligible only if it exists in the **consent-class registry**, `tracks/_meta/consent_classes.yaml`,
|
|
51
|
+
declaring: `owner` (gate/skill) · `mode` · `target scope` · `capabilities` (read / local-write /
|
|
52
|
+
network / dispatch / repo-mutation) · `sinks` (what downstream surface the action can reach). A session
|
|
53
|
+
may **propose** a registry entry (HITL, like any asset) but may **not** invent one mid-run and start
|
|
54
|
+
counting against it. *Origin: without a registry the guard below did not bind — the class could simply
|
|
55
|
+
be recorded over-broad at birth (`sim-conductor` rather than `dispatch read-only sim on a local
|
|
56
|
+
artifact`), and no after-the-fact widening rule can catch a class that was born wide.*
|
|
57
|
+
|
|
58
|
+
**Registry authorship is itself a capture surface** (cross-family round 2): closing runtime minting
|
|
59
|
+
still leaves the agent drafting a broad entry, framing it as housekeeping, and later enjoying the
|
|
60
|
+
approved breadth. So a proposed entry is promotion-eligible only after it carries (a) an explicit
|
|
61
|
+
`excludes:` list of neighbouring actions the class must **not** cover, (b) 2+ **adversarial examples** —
|
|
62
|
+
concrete actions a reader might assume are inside and that the author asserts are outside — and (c) a
|
|
63
|
+
review by something other than the proposing session (the human, or a cross-family auditor). A class
|
|
64
|
+
definition is reviewed as a **grant of future autonomy**, not as a config row.
|
|
65
|
+
|
|
66
|
+
**Mechanical floor**: `scripts/consent_registry_check.sh` — joins `standing_consent` against the
|
|
67
|
+
registry and enforces schema, eligibility soundness (a class naming an irreversible or unlisted sink
|
|
68
|
+
**cannot** declare itself promotable), registration, expiry, and recorded scope. Missing registry → N/A
|
|
69
|
+
+ promotion disabled; unparseable → fail-closed. Run it before trusting any grant; the prose above is
|
|
70
|
+
the salience layer over this check, not the enforcement.
|
|
71
|
+
|
|
72
|
+
**Trigger**: the same registered class recorded `accepted` **3 consecutive times**, counted across
|
|
73
|
+
sessions from the UAP outcome log. Refinements that keep the count honest:
|
|
74
|
+
- *Consecutive* means consecutive **within that class's own entries**; other classes interleaved do not
|
|
75
|
+
break the streak, a single `rejected` or `modified` does. An approval the user altered before granting
|
|
76
|
+
is logged `modified`, never `accepted`.
|
|
77
|
+
- **Only a promotion-eligible approval prompt counts** — one user gesture, one entry. **Retries of the
|
|
78
|
+
same operation count once**, and one "yes, do those three" is **one** approval, not three. Ordinary
|
|
79
|
+
supervised retry ("응, 다시 해봐" ×3) is not durable consent and must never reach the threshold.
|
|
80
|
+
- The running count is **visible to the user at each approval** (`1/3` · `2/3` · `3/3`), so the offer is
|
|
81
|
+
never the first time they learn a streak was being tallied.
|
|
82
|
+
|
|
83
|
+
**Action — offer once, with the evidence in the offer**:
|
|
84
|
+
|
|
85
|
+
> "`<class>` 을 3번 연속 승인했다 (`<date1>`, `<date2>`, `<date3>` — 각각 `<one-line what was approved>`).
|
|
86
|
+
> 범위: `<mode · target · capabilities · sinks>`. 앞으로 `<N>`일간 안 묻고 진행할까?
|
|
87
|
+
> (언제든 '다시 물어봐')"
|
|
88
|
+
|
|
89
|
+
The offer **quotes the three approvals and the exact scope**; a grant the user cannot audit is not
|
|
90
|
+
consent. Then:
|
|
91
|
+
|
|
92
|
+
- **granted** → write `standing_consent: <class>: {granted: <date>, expires: <date+N>, effects: [...]}`.
|
|
93
|
+
Later instances run unprompted, each **states in one line what it did**, and each **appends a durable
|
|
94
|
+
entry to `tracks/_meta/consent_runs.log`**. *Post-action chat notice is not a control* (cross-family
|
|
95
|
+
round 2): a line the user scrolls past has stopped the prompt without replacing it. The chat line is
|
|
96
|
+
courtesy; the log is the audit surface, and it is the reason standing consent may cover only actions
|
|
97
|
+
that are **recoverable and locally reviewable** — an unrecoverable action was already excluded by the
|
|
98
|
+
floor, and an unreviewable one is excluded here.
|
|
99
|
+
**Expiry is not optional** — at expiry the consent lapses to `unset` and the class is asked again;
|
|
100
|
+
standing consent is a renewable lease, not a transfer of the decision.
|
|
101
|
+
- **declined** → write `declined`. **Never ask again for that class version** — the same no-re-nag rule
|
|
102
|
+
as muted cadence reminders. *Scoped to the version, not forever*: a user may decline because the
|
|
103
|
+
timing was wrong, and permanent suppression with no renewal path is its own defect. A re-offer is
|
|
104
|
+
allowed only when the class is **materially narrowed** (a new registry version with strictly smaller
|
|
105
|
+
scope) or the user asks. Re-offering the same scope is a nag.
|
|
106
|
+
- Revocation is always available and never negotiated: "다시 물어봐" / "revoke" → `unset`.
|
|
107
|
+
|
|
108
|
+
**Floor — what never promotes (규약; this is the whole constraint)**: promotion is available only where
|
|
109
|
+
the *protocol still passes*. Applicability is decided **mechanically, from the registry entry — never by
|
|
110
|
+
the running session's judgment**, because the session that wants to stop being asked is the worst
|
|
111
|
+
possible arbiter of whether it may. A class never promotes, at any count, when:
|
|
112
|
+
|
|
113
|
+
1. its `sinks` include an **irreversible surface** — publish / go-public, delete, history-rewrite, or any
|
|
114
|
+
surface guarded by a fail-closed Irreversibility gate (`CLAUDE.md §Irreversibility Gates`); **or**
|
|
115
|
+
2. it **feeds, queues, or enables** such a sink even though it does not itself cross one — **sink
|
|
116
|
+
tainting**. Reversible steps compose into irreversible outcomes: "edit workflow config" + "write
|
|
117
|
+
release notes" + "create local commit" are each locally reversible, and a CI workflow publishes the
|
|
118
|
+
result. Taint propagates along that chain; the floor is inherited, not re-derived per step; **or**
|
|
119
|
+
3. its irreversibility is **unknown or not yet classified** → **no promotion, ask**. Unknown is not
|
|
120
|
+
reversible. (An unregistered class is by definition unknown, which is why 1–3 collapse to a single
|
|
121
|
+
mechanical test against the registry.)
|
|
122
|
+
|
|
123
|
+
Their cost is not the prompt, it is that the thing cannot be undone — grain-independent by construction.
|
|
124
|
+
**The promotion offer itself is HITL**: this section grants no path to auto-approval the user did not
|
|
125
|
+
answer "yes" to once, knowingly and in scope, which is why it does not contradict the "acceptance alone
|
|
126
|
+
never auto-runs" rule above.
|
|
127
|
+
|
|
128
|
+
**Degrade direction (fail-closed), three ways**: no UAP (ephemeral/cloud session, wiped profile) → **no
|
|
129
|
+
promotion, keep asking**. No registry entry → **no promotion, keep asking**. Expired or unparseable
|
|
130
|
+
record → **`unset`, keep asking**. A missing consent record is never `granted` — an absent measurement is
|
|
131
|
+
not a yes.
|
|
132
|
+
|
|
133
|
+
**Named residual — the ledger is self-attested (cross-family, 2026-07-29, HIGH, NOT closed).** The UAP
|
|
134
|
+
outcome log is written by the same agent that benefits from fewer prompts, and it is gitignored, so there
|
|
135
|
+
is no ordinary review trail. Nothing in this section fixes that; the honest mitigations only *narrow* it:
|
|
136
|
+
consent events are **append-only** (an entry is superseded, never edited or deleted), the offer must
|
|
137
|
+
**quote the three approvals verbatim** so a user can catch a fabricated streak at the one moment it
|
|
138
|
+
matters, and every unprompted run **announces itself**. A real fix requires the consent record to be
|
|
139
|
+
produced by the approval channel rather than by the consuming agent — outside this rule's reach. **Until
|
|
140
|
+
then, treat every standing consent as auditable-by-the-user-only, and never widen the mechanism's scope
|
|
141
|
+
on the assumption the ledger is trustworthy.**
|
|
142
|
+
|
|
143
|
+
**Consent binds to the action's SHAPE, not its label** (found by blind target-tier sim, 2026-07-29):
|
|
144
|
+
a class name is a string, and the action behind it can change after consent is granted. A sim that
|
|
145
|
+
merely returned a report when you said "stop asking" may, ten sessions later, write into shared memory
|
|
146
|
+
and trigger a downstream commit — same label, different blast radius, HITL skipped. So a grant records
|
|
147
|
+
**what it was granted for**: the owning gate/skill, and the set of **effect classes** the action had at
|
|
148
|
+
grant time (reads · local writes · network · dispatch · repo-mutation) **plus the `target` scope and
|
|
149
|
+
the `sinks` fingerprint**. On any later run whose fingerprint is **not a subset** of the granted one,
|
|
150
|
+
standing consent **reverts to `unset` and asks again**, naming what widened. Effect classes alone are
|
|
151
|
+
too coarse to be the whole test (cross-family round 2): "local write" stays "local write" whether the
|
|
152
|
+
target is a scratch report or a policy file — the *target* is where that drift shows, which is why it
|
|
153
|
+
is part of the fingerprint and not merely descriptive. Widening is the trigger; narrowing is not. This is the same discipline as the
|
|
154
|
+
byte-identity anchors used elsewhere: consent is pinned to a fingerprint, not to a name, because
|
|
155
|
+
**the name is exactly what does not change when the danger does.**
|
|
156
|
+
|
|
157
|
+
**Guard against class inflation**: the class recorded is the *narrow* one that was actually approved
|
|
158
|
+
3×, never a widened parent. Three approvals of "dispatch a Sonnet sim" do not grant "dispatch any
|
|
159
|
+
agent" (`feedback_scope_widening_needs_grounding` — widening judgments get no free pass).
|
|
160
|
+
|
|
34
161
|
## Generalization gate → reverse-PR funnel
|
|
35
162
|
|
|
36
163
|
This is the operator's **"원본 반영 가치"** criterion made mechanical. Split each UAP learning:
|
|
@@ -46,6 +173,8 @@ This is the operator's **"원본 반영 가치"** criterion made mechanical. Spl
|
|
|
46
173
|
|
|
47
174
|
- **UAP WRITE ran** at field-session close (or was correctly skipped — absent profile / ephemeral session). *Check class: mandatory-pass (binary — did Step 5-B.1 execute or log a skip reason).*
|
|
48
175
|
- **UAP READ applied** at session start / proposal time when a profile exists (preferred tier defaulted, 3×-rejected proposals suppressed, declined cadence nags muted). *Check class: judged, pair: the target-tier blind sim below.*
|
|
176
|
+
- **A class at 3 consecutive `accepted`** was either offered promotion once, or correctly not offered with the reason recorded (irreversible surface · already `declined` · no UAP). *Check class: measured — the consecutive count is read off the UAP outcome log, not recalled.*
|
|
177
|
+
- **Every `standing_consent` key resolves to a registry entry whose `sinks` are irreversible-free, and is unexpired.** *Check class: mandatory-pass (binary — join `standing_consent` keys against `consent_classes.yaml`, reject any key that is unregistered, taint-reachable to an irreversible sink, or past `expires`; any hit is a defect, not a judgment call).*
|
|
49
178
|
- **No domain content** entered the UAP this session. *Check class: judged, pair: phantom/content scan of the UAP diff.*
|
|
50
179
|
|
|
51
180
|
## Guards
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrono-meta/fh-gate",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.77",
|
|
4
4
|
"description": "FH runtime adapters — run FH governance, skills, and agents via Claude or Codex with machine-parseable gates.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -106,6 +106,10 @@
|
|
|
106
106
|
"scripts/test_memory_link_check.sh",
|
|
107
107
|
"scripts/memory_nearcheck.py",
|
|
108
108
|
"scripts/sidecar_wait.sh",
|
|
109
|
+
"scripts/test_sidecar_wait_stdin.sh",
|
|
110
|
+
"scripts/consent_registry_check.sh",
|
|
111
|
+
"scripts/test_consent_registry.sh",
|
|
112
|
+
"templates/consent_classes.yaml.example",
|
|
109
113
|
"scripts/test_session_close_lanes.sh",
|
|
110
114
|
"scripts/test_card_drift_probe.sh",
|
|
111
115
|
"scripts/universal_guard_check.sh",
|
|
@@ -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
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test_consent_registry.sh — known-pair anchor for scripts/consent_registry_check.sh.
|
|
3
|
+
#
|
|
4
|
+
# WHY (cross-family review rounds 1-3, 2026-07-29)
|
|
5
|
+
# The accept-side consent-promotion rule went REJECT -> NARROW-IT -> NARROW-IT across three
|
|
6
|
+
# adversarial rounds. Round 3 stopped attacking the prose and attacked the validator, and found
|
|
7
|
+
# four ways the FLOOR ITSELF was fail-open. Three were confirmed against controls:
|
|
8
|
+
#
|
|
9
|
+
# - `promotion_eligible: "false"` (quoted) is a truthy STRING. Every eligibility test inverted,
|
|
10
|
+
# so an intended-ineligible class was granted. One quote character disarmed the floor.
|
|
11
|
+
# - a duplicate class name silently shadowed the earlier entry, so appending an eligible twin
|
|
12
|
+
# below an ineligible one laundered the ineligible class into a grant.
|
|
13
|
+
# - `standing_consent: {inline: ...}` matched no pattern, so an EXPIRED inline grant was read as
|
|
14
|
+
# "no standing consent recorded" and reported PASS — a grant the checker cannot see is not an
|
|
15
|
+
# absent grant, it is a false clean.
|
|
16
|
+
#
|
|
17
|
+
# Each of those is a check that reported green while doing nothing, which is worse than an absent
|
|
18
|
+
# check: it buys confidence without enforcement. Hence this anchor.
|
|
19
|
+
#
|
|
20
|
+
# Lanes: N* = known-negative (must pass) · P* = known-positive (must be caught) · D* = degrade.
|
|
21
|
+
# Exit 0 = all lanes correct. Exit 1 = the validator regressed toward permissiveness.
|
|
22
|
+
set -uo pipefail
|
|
23
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
24
|
+
CHK="$ROOT/scripts/consent_registry_check.sh"
|
|
25
|
+
[ -f "$CHK" ] || { echo "FAIL: $CHK not found"; exit 1; }
|
|
26
|
+
|
|
27
|
+
pass=0; fail=0
|
|
28
|
+
ok() { printf ' ✅ %s\n' "$1"; pass=$((pass+1)); }
|
|
29
|
+
bad() { printf ' ❌ %s\n' "$1"; fail=$((fail+1)); }
|
|
30
|
+
TD="$(mktemp -d)"; trap 'rm -rf "$TD"' EXIT
|
|
31
|
+
|
|
32
|
+
# want_rc: expected exit. want_rule: a rule id that must appear in the output ("" = don't care).
|
|
33
|
+
lane() {
|
|
34
|
+
local desc="$1" want_rc="$2" want_rule="$3"
|
|
35
|
+
bash "$CHK" "$TD/r.yaml" "$TD/u.md" >"$TD/o" 2>&1
|
|
36
|
+
local rc=$?
|
|
37
|
+
if [ "$rc" -ne "$want_rc" ]; then
|
|
38
|
+
bad "$desc — exit $rc, expected $want_rc"; sed 's/^/ /' "$TD/o"; return
|
|
39
|
+
fi
|
|
40
|
+
if [ -n "$want_rule" ] && ! grep -q "❌ $want_rule" "$TD/o"; then
|
|
41
|
+
bad "$desc — exit was right but rule $want_rule was not the reason"; sed 's/^/ /' "$TD/o"; return
|
|
42
|
+
fi
|
|
43
|
+
ok "$desc"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
OKCLASS=' - {name: ok, owner: o, mode: m, target: t, capabilities: [read], sinks: [], feeds: [], promotion_eligible: true}'
|
|
47
|
+
mkreg() { printf 'classes:\n%s\n' "$1" > "$TD/r.yaml"; }
|
|
48
|
+
mkuap() { printf '%s\n' "$1" > "$TD/u.md"; }
|
|
49
|
+
|
|
50
|
+
mkreg "$OKCLASS"
|
|
51
|
+
mkuap 'standing_consent:
|
|
52
|
+
ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
|
|
53
|
+
lane "N1 a well-formed registry + eligible unexpired scoped grant passes" 0 ""
|
|
54
|
+
|
|
55
|
+
mkuap 'standing_consent:
|
|
56
|
+
ok: declined'
|
|
57
|
+
lane "N2 a declined record is a valid non-grant state, not a malformed grant" 0 ""
|
|
58
|
+
|
|
59
|
+
mkreg ' - {name: s, owner: o, mode: m, target: t, capabilities: [read], sinks: [go-public], feeds: [], promotion_eligible: true}'
|
|
60
|
+
mkuap 'standing_consent: {}'
|
|
61
|
+
lane "P1 a class naming an irreversible SINK cannot declare itself promotable" 1 "R2"
|
|
62
|
+
|
|
63
|
+
mkreg ' - {name: f, owner: o, mode: m, target: t, capabilities: [read], sinks: [], feeds: [go-public], promotion_eligible: true}'
|
|
64
|
+
lane "P2 taint — a class that FEEDS an irreversible sink cannot be promotable" 1 "R2"
|
|
65
|
+
|
|
66
|
+
mkreg "$OKCLASS"
|
|
67
|
+
mkuap 'standing_consent:
|
|
68
|
+
ghost: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
|
|
69
|
+
lane "P3 a grant on an unregistered class is refused (unregistered == unknown)" 1 "R3"
|
|
70
|
+
|
|
71
|
+
mkuap 'standing_consent:
|
|
72
|
+
ok: {granted: 2026-01-01, expires: 2026-06-30, effects: [read], target: t}'
|
|
73
|
+
lane "P4 an expired lease does not keep running" 1 "R5"
|
|
74
|
+
|
|
75
|
+
mkuap 'standing_consent:
|
|
76
|
+
ok: {granted: 2026-07-29, expires: 2026-12-31}'
|
|
77
|
+
lane "P5 a grant with no recorded scope has no re-validation baseline" 1 "R6"
|
|
78
|
+
|
|
79
|
+
# P6-P8 are the round-3 fail-opens. Each of these PASSED before the fix.
|
|
80
|
+
mkreg ' - {name: q, owner: o, mode: m, target: t, capabilities: [read], sinks: [], feeds: [], promotion_eligible: "false"}'
|
|
81
|
+
mkuap 'standing_consent:
|
|
82
|
+
q: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
|
|
83
|
+
lane "P6 quoted \"false\" is rejected as a type error, not read as truthy" 1 "R1-b"
|
|
84
|
+
|
|
85
|
+
mkreg ' - {name: d, owner: o, mode: m, target: t, capabilities: [read], sinks: [go-public], feeds: [], promotion_eligible: false}
|
|
86
|
+
- {name: d, owner: o, mode: m, target: t, capabilities: [read], sinks: [], feeds: [], promotion_eligible: true}'
|
|
87
|
+
mkuap 'standing_consent:
|
|
88
|
+
d: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
|
|
89
|
+
lane "P7 a duplicate class name cannot launder an ineligible class" 1 "R1-c"
|
|
90
|
+
|
|
91
|
+
mkreg "$OKCLASS"
|
|
92
|
+
mkuap 'standing_consent: {ok: {granted: 2026-01-01, expires: 2020-01-01, effects: [read], target: t}}'
|
|
93
|
+
lane "P8 an INLINE grant is parsed, not silently read as 'nothing granted'" 1 "R5"
|
|
94
|
+
|
|
95
|
+
# P10-P11 are the round-4 fail-opens. Both PASSED before the fix, and both were confirmed against a
|
|
96
|
+
# control: the SAME expired grant was caught when it stood alone.
|
|
97
|
+
mkreg "$OKCLASS"
|
|
98
|
+
mkuap 'standing_consent: {}
|
|
99
|
+
|
|
100
|
+
notes in between
|
|
101
|
+
|
|
102
|
+
standing_consent:
|
|
103
|
+
ok: {granted: 2026-01-01, expires: 2020-01-01, effects: [read], target: t}'
|
|
104
|
+
lane "P10 an early empty consent block cannot shadow a later grant (first-match)" 1 "R3"
|
|
105
|
+
|
|
106
|
+
mkuap 'standing_consent:
|
|
107
|
+
ok: {state: "revoked ", granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
|
|
108
|
+
if bash "$CHK" "$TD/r.yaml" "$TD/u.md" 2>&1 | grep -q "0 active grant"; then
|
|
109
|
+
ok "P11 a mapping non-grant state is normalized like the scalar one (no divergent normalizer)"
|
|
110
|
+
else
|
|
111
|
+
bad "P11 \`state: \"revoked \"\` was validated as an ACTIVE grant"
|
|
112
|
+
bash "$CHK" "$TD/r.yaml" "$TD/u.md" 2>&1 | sed 's/^/ /'
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
# P12 is the round-5 fail-open: `or {}` collapsed FALSY non-mappings into a valid-empty mapping
|
|
116
|
+
# before the type check, so `[]`, `false` and `0` all reported "no standing consent" and exit 0 —
|
|
117
|
+
# while the truthy `[a, b]` was caught. The falsy branch is the one an accident actually produces.
|
|
118
|
+
for badval in ' []' ' false' ' 0'; do
|
|
119
|
+
mkreg "$OKCLASS"
|
|
120
|
+
printf 'standing_consent:\n%s\n' "$badval" > "$TD/u.md"
|
|
121
|
+
lane "P12 a falsy non-mapping standing_consent ($badval) is a type error, not 'none granted'" 1 "R3"
|
|
122
|
+
done
|
|
123
|
+
|
|
124
|
+
# P13/N3 — round-6 fail-open: `standing_consent :` (space or tab before the colon) is the SAME YAML
|
|
125
|
+
# key but matched none of the patterns. Standalone it still failed closed via the no-known-form net;
|
|
126
|
+
# paired with a normal empty block it was invisible to both the count and the extraction, so the
|
|
127
|
+
# empty block parsed and the real grant vanished. Confirmed against a control (the identical expired
|
|
128
|
+
# grant in canonical form was caught).
|
|
129
|
+
mkreg "$OKCLASS"
|
|
130
|
+
mkuap 'standing_consent: {}
|
|
131
|
+
|
|
132
|
+
standing_consent : {ok: {granted: 2026-01-01, expires: 2020-01-01, effects: [read], target: t}}'
|
|
133
|
+
lane "P13 a space-before-colon key cannot hide behind a canonical block" 1 "R3"
|
|
134
|
+
|
|
135
|
+
mkuap 'standing_consent :
|
|
136
|
+
ok: {granted: 2026-01-01, expires: 2020-01-01, effects: [read], target: t}'
|
|
137
|
+
lane "P13-b a space-before-colon BLOCK form is parsed and its grant validated" 1 "R5"
|
|
138
|
+
|
|
139
|
+
mkuap "$(printf 'standing_consent\t: {ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}}')"
|
|
140
|
+
lane "N3 a tab-before-colon key with a VALID grant passes (no over-block)" 0 ""
|
|
141
|
+
|
|
142
|
+
# P14 — round-7 fail-open: R6 presence-checked `effects`/`target` but never typed them, while the
|
|
143
|
+
# registry side had strict types since R1-b. A fix propagated to one side only is a hole. Control:
|
|
144
|
+
# a MISSING field was caught; a type-wrong one passed.
|
|
145
|
+
mkreg "$OKCLASS"
|
|
146
|
+
mkuap 'standing_consent:
|
|
147
|
+
ok: {granted: 2026-07-29, expires: 2026-12-31, effects: true, target: 123}'
|
|
148
|
+
lane "P14 a type-wrong grant scope is rejected, not counted as recorded" 1 "R6"
|
|
149
|
+
|
|
150
|
+
mkuap 'standing_consent:
|
|
151
|
+
ok: {granted: 2026-07-29, expires: 2026-12-31, effects: "read", target: " "}'
|
|
152
|
+
lane "P14-b a scalar effects / whitespace-only target is rejected" 1 "R6"
|
|
153
|
+
|
|
154
|
+
mkuap 'standing_consent:
|
|
155
|
+
ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [], target: t}'
|
|
156
|
+
lane "P14-c an EMPTY effects list is not a baseline" 1 "R6"
|
|
157
|
+
|
|
158
|
+
# H1-H9 — round-8 exhaustive field audit found NINE remaining holes in one pass, after four rounds
|
|
159
|
+
# of one-per-round trickle. The reframe ("audit every field, don't hand me the most important one")
|
|
160
|
+
# is what surfaced them; the per-round drip was a slow enumeration, not convergence.
|
|
161
|
+
mkuap 'standing_consent: {}'
|
|
162
|
+
printf 'classes: false\n' > "$TD/r.yaml"
|
|
163
|
+
lane "H1 a falsy \`classes\` is not laundered into an empty registry" 1 ""
|
|
164
|
+
printf 'classes:\n' > "$TD/r.yaml"
|
|
165
|
+
lane "H1-b a null \`classes\` is N/A, not a clean PASS" 0 ""
|
|
166
|
+
if grep -q 'N/A' "$TD/o"; then ok "H1-c the empty registry is LABELLED N/A"; else bad "H1-c empty registry read as clean"; fi
|
|
167
|
+
|
|
168
|
+
mkreg ' - {name: n, owner: o, mode: 123, target: t, capabilities: [read], sinks: [], feeds: [], promotion_eligible: true}'
|
|
169
|
+
lane "H2 a non-string scalar registry field is a type error" 1 "R1-b"
|
|
170
|
+
mkreg ' - {name: n, owner: o, mode: m, target: "", capabilities: [read], sinks: [], feeds: [], promotion_eligible: true}'
|
|
171
|
+
lane "H2-b a blank registry target is a type error" 1 "R1-b"
|
|
172
|
+
mkreg ' - {name: n, owner: o, mode: m, target: t, capabilities: [read], sinks: [123], feeds: [], promotion_eligible: false}'
|
|
173
|
+
lane "H3 an unreadable sink item is UNDECLARED, not an empty sink list" 1 "R1-b"
|
|
174
|
+
|
|
175
|
+
mkreg "$OKCLASS"
|
|
176
|
+
mkuap 'standing_consent:
|
|
177
|
+
ok: {expires: 2026-12-31, effects: [read], target: t}'
|
|
178
|
+
lane "H5 a grant with no \`granted\` date cannot be audited" 1 "R5"
|
|
179
|
+
mkuap 'standing_consent:
|
|
180
|
+
ok: {granted: 2026-07-29, expires: 29991231, effects: [read], target: t}'
|
|
181
|
+
lane "H7 a non-ISO integer expiry is not a date" 1 "R5"
|
|
182
|
+
mkuap 'standing_consent:
|
|
183
|
+
ok: {granted: 2026-07-29, expires: 9999-12-31, effects: [read], target: t}'
|
|
184
|
+
lane "H7-b an unbounded lease is a transfer wearing a lease's clothes" 1 "R5"
|
|
185
|
+
mkuap 'standing_consent:
|
|
186
|
+
ok: {state: revokedd, granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
|
|
187
|
+
lane "H6 a typo'\''d state fails closed instead of passing as active" 1 "R3"
|
|
188
|
+
mkuap 'standing_consent:
|
|
189
|
+
ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [repo-mutation], target: t}'
|
|
190
|
+
lane "H8 a grant wider than its registered capabilities is refused" 1 "R7"
|
|
191
|
+
mkuap 'standing_consent:
|
|
192
|
+
ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: everything}'
|
|
193
|
+
lane "H8-b grant/class target drift is refused" 1 "R7"
|
|
194
|
+
mkuap 'standing_consent:
|
|
195
|
+
ok: {granted: 2026-07-29, expires: 2020-01-01, effects: [read], target: t}
|
|
196
|
+
ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
|
|
197
|
+
lane "H9 duplicate YAML keys are rejected, not resolved last-wins" 1 ""
|
|
198
|
+
|
|
199
|
+
# H4 had a fix but NO lane — the mutation sweep caught that (disabling it failed zero lanes).
|
|
200
|
+
# An uncovered check is a check that gets deleted quietly later.
|
|
201
|
+
# NOTE the assertion is on the MESSAGE, not the rule id: with the type check disabled a numeric key
|
|
202
|
+
# still exits 1 via "not in the registry", so a rule-id assertion passed either way and the mutation
|
|
203
|
+
# sweep caught zero lanes. A lane that cannot separate two paths is not measuring the one it names.
|
|
204
|
+
mkuap 'standing_consent:
|
|
205
|
+
123: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
|
|
206
|
+
bash "$CHK" "$TD/r.yaml" "$TD/u.md" >"$TD/o" 2>&1
|
|
207
|
+
if [ $? -eq 1 ] && grep -q 'grant key 123 must be a non-blank string' "$TD/o"; then
|
|
208
|
+
ok "H4 a non-string grant key is refused AS A TYPE ERROR (not merely as unregistered)"
|
|
209
|
+
else
|
|
210
|
+
bad "H4 numeric grant key was not refused by the type check"
|
|
211
|
+
sed 's/^/ /' "$TD/o"
|
|
212
|
+
fi
|
|
213
|
+
|
|
214
|
+
mkreg ' - {name: c, owner: o, mode: m, target: t, capabilities: "read", sinks: [], feeds: [], promotion_eligible: true}'
|
|
215
|
+
mkuap 'standing_consent: {}'
|
|
216
|
+
lane "P9 a scalar where a list is required is a type error" 1 "R1-b"
|
|
217
|
+
|
|
218
|
+
# N4 — PINS A KNOWN OVER-BLOCK, it does not endorse it. A grant written with a YAML merge key is
|
|
219
|
+
# currently REFUSED because the anchor lives outside the extracted fragment (see the residual note
|
|
220
|
+
# in consent_registry_check.sh). This lane asserts the CURRENT behaviour so that a future fix shows
|
|
221
|
+
# up as a lane change rather than being rediscovered from scratch — and so nobody reads the 40/40
|
|
222
|
+
# as "no known over-block".
|
|
223
|
+
mkreg "$OKCLASS"
|
|
224
|
+
mkuap 'defaults: &d {ok: {granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}}
|
|
225
|
+
standing_consent:
|
|
226
|
+
<<: *d'
|
|
227
|
+
bash "$CHK" "$TD/r.yaml" "$TD/u.md" >"$TD/o" 2>&1
|
|
228
|
+
if [ $? -eq 1 ]; then
|
|
229
|
+
ok "N4 merge-key grant is refused — KNOWN OVER-BLOCK, pinned not endorsed (see residual note)"
|
|
230
|
+
else
|
|
231
|
+
ok "N4 merge-key grant now PASSES — the over-block was fixed; update this lane and the residual note"
|
|
232
|
+
fi
|
|
233
|
+
|
|
234
|
+
rm -f "$TD/r.yaml"
|
|
235
|
+
lane "D1 a missing registry is N/A with promotion disabled (never a silent PASS)" 0 ""
|
|
236
|
+
if grep -q 'N/A' "$TD/o"; then
|
|
237
|
+
ok "D1-b the missing-registry result is LABELLED N/A, not reported as clean"
|
|
238
|
+
else
|
|
239
|
+
bad "D1-b a missing registry produced exit 0 without an N/A label — that reads as PASS"
|
|
240
|
+
fi
|
|
241
|
+
|
|
242
|
+
printf 'classes: [ {name: broken\n' > "$TD/r.yaml"
|
|
243
|
+
lane "D2 an unparseable registry fails closed (cannot decide == not allowed)" 1 ""
|
|
244
|
+
|
|
245
|
+
# The shipped example must satisfy the validator — otherwise the artifact FH hands users is the
|
|
246
|
+
# first counter-example. (Hand-verify-one-sample discipline, applied to our own template.)
|
|
247
|
+
if bash "$CHK" "$ROOT/templates/consent_classes.yaml.example" /dev/null >/dev/null 2>&1; then
|
|
248
|
+
ok "S1 the shipped templates/consent_classes.yaml.example validates"
|
|
249
|
+
else
|
|
250
|
+
bad "S1 the shipped example registry does NOT validate"
|
|
251
|
+
fi
|
|
252
|
+
|
|
253
|
+
echo "----"
|
|
254
|
+
echo "consent-registry anchor: $pass passed, $fail failed"
|
|
255
|
+
[ "$fail" -eq 0 ] || exit 1
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test_sidecar_wait_stdin.sh — known-pair anchor for scripts/sidecar_wait.sh's stdin plumbing.
|
|
3
|
+
#
|
|
4
|
+
# WHY (measured 2026-07-29)
|
|
5
|
+
# `sidecar_wait.sh` shipped in v1.4.76 documenting this invocation in its own header:
|
|
6
|
+
# printf '%s' "$prompt" | bash scripts/sidecar_wait.sh out.txt 600 -- codex exec -m gpt-5.5 -
|
|
7
|
+
# It did not work. `"$@" > "$OUT" 2>&1 &` gives the child /dev/null for stdin in a
|
|
8
|
+
# non-interactive shell, so codex received no prompt, answered "No prompt provided via stdin",
|
|
9
|
+
# and the wrapper reported SIDECAR_VERDICT=COMPLETE — a sidecar that never ran, reported as
|
|
10
|
+
# complete. That is the exact misjudgment the script exists to prevent, produced by the script.
|
|
11
|
+
#
|
|
12
|
+
# The FIRST repair was worse than the bug, and adversarial review caught it before it shipped.
|
|
13
|
+
# It spooled stdin to a tempfile; reproduced consequences:
|
|
14
|
+
# - the unbounded `cat` ran BEFORE the child, so an inherited never-EOF stdin hung the wrapper
|
|
15
|
+
# forever and the timeout budget never applied (`-- true` with inherited stdin → rc=124);
|
|
16
|
+
# - it CONSUMED the caller's stdin (a caller reading 3 lines afterwards read 0).
|
|
17
|
+
# The correct fix is one token: `<&0`. POSIX substitutes /dev/null only when stdin is NOT
|
|
18
|
+
# explicitly redirected. Lanes P2/P3 exist because the anchor's first version reported 4/4 green
|
|
19
|
+
# in the same shell where the argv form returned rc=124 — it bound only P1.
|
|
20
|
+
#
|
|
21
|
+
# Lanes
|
|
22
|
+
# P1 piped stdin REACHES the child (the original hole)
|
|
23
|
+
# P2 argv-form with an inherited never-EOF stdin does NOT hang (spool regression)
|
|
24
|
+
# P3 the caller's own stdin is NOT consumed by the wrapper (spool regression)
|
|
25
|
+
# P4 verdict codes: TIMEOUT=1 · EMPTY=0 · COMPLETE=0, and EMPTY/COMPLETE are distinguished
|
|
26
|
+
# P5 an unwritable outfile is exit 2, not "EMPTY" (a false clean on the typed channel)
|
|
27
|
+
#
|
|
28
|
+
# Verdicts are read by running the wrapper DIRECTLY — piping it into `tail` and reading `$?`
|
|
29
|
+
# answers with tail's status and turns a real exit 1 green.
|
|
30
|
+
#
|
|
31
|
+
# Exit 0 = all lanes correct. Exit 1 = the plumbing regressed.
|
|
32
|
+
set -uo pipefail
|
|
33
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
34
|
+
SW="$ROOT/scripts/sidecar_wait.sh"
|
|
35
|
+
[ -f "$SW" ] || { echo "FAIL: $SW not found"; exit 1; }
|
|
36
|
+
# NOTE: SIDECAR_POLL is set PER LANE, never exported globally. A global export pinned the knob for
|
|
37
|
+
# every lane and thereby MASKED a bad default — a regression to `SIDECAR_POLL:-0` passed 5/5.
|
|
38
|
+
FAST="SIDECAR_POLL=1"
|
|
39
|
+
|
|
40
|
+
pass=0; fail=0
|
|
41
|
+
ok() { printf ' ✅ %s\n' "$1"; pass=$((pass+1)); }
|
|
42
|
+
bad() { printf ' ❌ %s\n' "$1"; fail=$((fail+1)); }
|
|
43
|
+
TD="$(mktemp -d)"; trap 'rm -rf "$TD"' EXIT
|
|
44
|
+
|
|
45
|
+
# P1 — `cat` echoes whatever it receives; an empty outfile means stdin was swallowed, which is
|
|
46
|
+
# exactly what the shipped version did.
|
|
47
|
+
printf 'MARKER_STDIN_ARRIVED' | SIDECAR_POLL=1 bash "$SW" "$TD/p1.out" 20 -- cat >/dev/null 2>&1
|
|
48
|
+
if grep -q 'MARKER_STDIN_ARRIVED' "$TD/p1.out" 2>/dev/null; then
|
|
49
|
+
ok "P1 piped stdin reaches the child process"
|
|
50
|
+
else
|
|
51
|
+
bad "P1 STDIN HOLE IS BACK — child saw no stdin (got: '$(head -c 60 "$TD/p1.out" 2>/dev/null)')"
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
# P2 — the lane the first anchor lacked. A never-EOF stdin must not block the wrapper: the child is
|
|
55
|
+
# launched immediately and the budget governs. `sleep 20 |` keeps the pipe open with no data.
|
|
56
|
+
( sleep 6 | SIDECAR_POLL=1 timeout 5 bash "$SW" "$TD/p2.out" 3 -- echo ARGV_OK ) >/dev/null 2>&1
|
|
57
|
+
rc_hang=$?
|
|
58
|
+
if [ "$rc_hang" -ne 124 ] && grep -q 'ARGV_OK' "$TD/p2.out" 2>/dev/null; then
|
|
59
|
+
ok "P2 an inherited never-EOF stdin does not hang the wrapper (child still runs)"
|
|
60
|
+
else
|
|
61
|
+
bad "P2 the wrapper HUNG or never ran the child on inherited stdin (rc=$rc_hang, out='$(head -c 40 "$TD/p2.out" 2>/dev/null)')"
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# P3 — the wrapper must not eat the caller's stream. `echo` reads nothing, so all three lines must
|
|
65
|
+
# remain readable afterwards. The spool version left zero.
|
|
66
|
+
n=$(printf 'l1\nl2\nl3\n' | { SIDECAR_POLL=1 bash "$SW" "$TD/p3.out" 20 -- echo x >/dev/null 2>&1
|
|
67
|
+
c=0; while read -r _; do c=$((c+1)); done; echo "$c"; })
|
|
68
|
+
if [ "$n" = "3" ]; then
|
|
69
|
+
ok "P3 the caller's own stdin survives the call (3/3 lines still readable)"
|
|
70
|
+
else
|
|
71
|
+
bad "P3 the wrapper consumed the caller's stdin ($n of 3 lines left)"
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
# P4 — verdict channel. `-- true` writes nothing and is EMPTY, NOT COMPLETE; the first anchor
|
|
75
|
+
# labelled that lane "COMPLETE" and so never exercised COMPLETE at all.
|
|
76
|
+
SIDECAR_POLL=1 bash "$SW" "$TD/p4t.out" 2 -- sleep 30 >"$TD/p4t.txt" 2>&1 </dev/null; rc_t=$?
|
|
77
|
+
SIDECAR_POLL=1 bash "$SW" "$TD/p4e.out" 20 -- true >"$TD/p4e.txt" 2>&1 </dev/null; rc_e=$?
|
|
78
|
+
SIDECAR_POLL=1 bash "$SW" "$TD/p4c.out" 20 -- echo hi >"$TD/p4c.txt" 2>&1 </dev/null; rc_c=$?
|
|
79
|
+
if [ "$rc_t" -eq 1 ] && [ "$rc_e" -eq 0 ] && [ "$rc_c" -eq 0 ] \
|
|
80
|
+
&& grep -q 'SIDECAR_VERDICT=TIMEOUT' "$TD/p4t.txt" \
|
|
81
|
+
&& grep -q 'SIDECAR_VERDICT=EMPTY' "$TD/p4e.txt" \
|
|
82
|
+
&& grep -q 'SIDECAR_VERDICT=COMPLETE' "$TD/p4c.txt"; then
|
|
83
|
+
ok "P4 verdicts intact and distinguished (TIMEOUT=1 · EMPTY=0 · COMPLETE=0)"
|
|
84
|
+
else
|
|
85
|
+
bad "P4 verdict channel drifted (timeout rc=$rc_t, empty rc=$rc_e, complete rc=$rc_c)"
|
|
86
|
+
head -1 "$TD/p4t.txt" "$TD/p4e.txt" "$TD/p4c.txt" 2>/dev/null | sed 's/^/ /'
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
# P5 — an outfile that cannot be written made `wc -c` fail, `${size:-0}` read 0, and the wrapper
|
|
90
|
+
# announce EMPTY with exit 0. The sidecar had in fact produced output. Same false-clean class as
|
|
91
|
+
# the bug this file anchors, on the typed channel itself.
|
|
92
|
+
SIDECAR_POLL=1 bash "$SW" "$TD/nodir/x.out" 10 -- echo hi >/dev/null 2>&1 </dev/null
|
|
93
|
+
rc_w=$?
|
|
94
|
+
if [ "$rc_w" -eq 2 ]; then
|
|
95
|
+
ok "P5 an unwritable outfile fails closed (exit 2), never 'EMPTY'"
|
|
96
|
+
else
|
|
97
|
+
bad "P5 an unwritable outfile returned rc=$rc_w — a sidecar that ran reported as saying nothing"
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
# P6 — the knob added while making this anchor cheap re-opened the unbounded wait. Each bad value
|
|
101
|
+
# must still produce a bounded TIMEOUT. Deliberately NOT using $FAST: the point is a bad POLL.
|
|
102
|
+
p6=0
|
|
103
|
+
for badpoll in 0 0.5 abc 08 09 600; do
|
|
104
|
+
SIDECAR_POLL="$badpoll" timeout 12 bash "$SW" "$TD/p6.out" 2 -- sleep 30 >"$TD/p6.txt" 2>&1
|
|
105
|
+
rc6=$?
|
|
106
|
+
if [ "$rc6" -eq 1 ] && grep -q 'SIDECAR_VERDICT=TIMEOUT' "$TD/p6.txt"; then p6=$((p6+1)); fi
|
|
107
|
+
done
|
|
108
|
+
if [ "$p6" -eq 6 ]; then
|
|
109
|
+
ok "P6 a malformed SIDECAR_POLL (0·0.5·abc·08·09·600) still times out — the budget is not disarmable"
|
|
110
|
+
else
|
|
111
|
+
bad "P6 only $p6/6 malformed poll values produced a bounded TIMEOUT (unbounded wait is reachable)"
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
# P7 — TIMEOUT must not orphan the sidecar, INCLUDING grandchildren: `kill "$PID"` reached only the
|
|
115
|
+
# direct child, so `sh -c 'sleep N & wait'` survived while this lane stayed green. The marker is
|
|
116
|
+
# per-run: a machine-global `pgrep -f 'sleep 25'` false-FAILs on any concurrent sleep and its
|
|
117
|
+
# failure branch would kill unrelated host processes.
|
|
118
|
+
MARK="p7_$$_$(date +%s 2>/dev/null || echo x)"
|
|
119
|
+
SIDECAR_POLL=1 bash "$SW" "$TD/p7.out" 2 -- sh -c "sleep 25 & wait # $MARK" >"$TD/p7.txt" 2>&1 </dev/null
|
|
120
|
+
sleep 1
|
|
121
|
+
if ! pgrep -f "$MARK" >/dev/null 2>&1; then
|
|
122
|
+
ok "P7 a timed-out sidecar is killed with its grandchildren, not orphaned past our exit"
|
|
123
|
+
else
|
|
124
|
+
bad "P7 a grandchild survived TIMEOUT (orphan restored)"; pkill -f "$MARK" 2>/dev/null
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
# P9 — the tty path. HONEST SCOPE, do not read this lane as more than it is.
|
|
128
|
+
#
|
|
129
|
+
# It asserts that a stdin-reading child under an allocated pty still produces a bounded verdict. It
|
|
130
|
+
# does NOT bind the `if [ -t 0 ]` branch: removing that branch entirely leaves this lane GREEN
|
|
131
|
+
# (measured 2026-07-29). Cross-family review reproduced a SIGTTIN stop (rc=124, no verdict) under a
|
|
132
|
+
# pty before `set -m` was added for the group-kill; this harness cannot reproduce that condition,
|
|
133
|
+
# so whether the branch is still load-bearing is UNMEASURED — the branch is kept as defensive, not
|
|
134
|
+
# as something this anchor proves.
|
|
135
|
+
#
|
|
136
|
+
# A lane that cannot separate a known-positive from a known-negative is not measuring; it is
|
|
137
|
+
# generating. Shipping it as a plain green would manufacture exactly the false confidence this
|
|
138
|
+
# file exists to prevent, so it is labelled instead of silently counted.
|
|
139
|
+
if command -v script >/dev/null 2>&1; then
|
|
140
|
+
# `< /dev/null` on `script` itself: with the harness's inherited stdin it could not allocate a pty
|
|
141
|
+
# at all and wrote no rc file, so the lane failed identically with AND without the fix under test
|
|
142
|
+
# — it discriminated nothing. The pty it allocates for the inner command is what matters.
|
|
143
|
+
# The inner shell writes its rc to a FILE. Parsing `script`'s own stdout fails: it emits ^D and
|
|
144
|
+
# CRLF, and the first version of this lane read rc as empty and reported a defect that did not
|
|
145
|
+
# exist — the target was fine, the instrument was not.
|
|
146
|
+
script -q /dev/null bash -c "SIDECAR_POLL=1 timeout 12 bash '$SW' '$TD/p9.out' 3 -- cat >'$TD/p9.txt' 2>&1; echo \$? > '$TD/p9rc'" >/dev/null 2>&1 < /dev/null
|
|
147
|
+
rc9=$(tr -dc '0-9' < "$TD/p9rc" 2>/dev/null)
|
|
148
|
+
if [ -n "$rc9" ] && [ "$rc9" != "124" ] && grep -q 'SIDECAR_VERDICT=' "$TD/p9.txt" 2>/dev/null; then
|
|
149
|
+
ok "P9 the tty path runs and emits a verdict (UNCALIBRATED — see note; does NOT bind the branch)"
|
|
150
|
+
else
|
|
151
|
+
bad "P9 tty case rc='${rc9:-<unread>}' verdict='$(head -1 "$TD/p9.txt" 2>/dev/null)'"
|
|
152
|
+
fi
|
|
153
|
+
else
|
|
154
|
+
bad "P9 UNCALIBRATED — \`script\` unavailable, the tty branch cannot be exercised here"
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
# P8 — the child's stderr AND its exit code must reach the typed channel. Dropping `2>&1` or
|
|
158
|
+
# hardcoding rc=0 both passed the earlier anchor: it grepped the verdict WORD and the wrapper's
|
|
159
|
+
# own rc, never the child's.
|
|
160
|
+
SIDECAR_POLL=1 bash "$SW" "$TD/p8.out" 20 -- sh -c 'echo STDERR_MARK >&2; exit 3' >"$TD/p8.txt" 2>&1 </dev/null
|
|
161
|
+
if grep -q 'STDERR_MARK' "$TD/p8.out" 2>/dev/null && grep -q 'exit=3' "$TD/p8.txt"; then
|
|
162
|
+
ok "P8 child stderr is captured and its exit code reaches the verdict line"
|
|
163
|
+
else
|
|
164
|
+
bad "P8 stderr or child exit code lost (out='$(head -c 40 "$TD/p8.out" 2>/dev/null)' verdict='$(head -1 "$TD/p8.txt")')"
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
echo "----"
|
|
168
|
+
echo "sidecar_wait stdin anchor: $pass passed, $fail failed"
|
|
169
|
+
[ "$fail" -eq 0 ] || exit 1
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# consent_classes.yaml — the consent-class registry.
|
|
2
|
+
#
|
|
3
|
+
# Copy to `tracks/_meta/consent_classes.yaml` (gitignored, per-environment) to enable
|
|
4
|
+
# accept-side consent promotion. See `knowledge/shared/rules/operational_adaptation.md`
|
|
5
|
+
# §Consent promotion.
|
|
6
|
+
#
|
|
7
|
+
# WHY A REGISTRY AT ALL (cross-family review, 2026-07-29)
|
|
8
|
+
# The first draft let the running session name the class it was counting approvals against.
|
|
9
|
+
# That defeats the class-inflation guard before it fires: the class does not need to be WIDENED
|
|
10
|
+
# later if it was born wide. Recording three approvals as `sim-conductor` instead of
|
|
11
|
+
# `dispatch read-only sim on a local artifact` silently grants every future sim-conductor mode.
|
|
12
|
+
# So classes are DECLARED here, reviewed by a human, and the session may only count against
|
|
13
|
+
# an entry that already exists. A session may propose a new entry; it may not mint one mid-run.
|
|
14
|
+
#
|
|
15
|
+
# THE FLOOR IS READ FROM THIS FILE, NOT JUDGED AT RUNTIME
|
|
16
|
+
# `sinks` is the field the irreversibility floor is decided from. A session asking itself
|
|
17
|
+
# "is this reversible?" is the wrong arbiter — it is the party that wants to stop being asked.
|
|
18
|
+
# Absent entry / absent file / unparseable entry => NO promotion, keep asking. Unknown is not
|
|
19
|
+
# reversible.
|
|
20
|
+
#
|
|
21
|
+
# TAINT
|
|
22
|
+
# A class inherits the floor if it FEEDS an irreversible sink, not only if it crosses one.
|
|
23
|
+
# `local-commit` below is the worked example: committing is locally reversible, but if a
|
|
24
|
+
# post-commit hook, sync daemon, or CI workflow publishes from that commit, the chain crossed
|
|
25
|
+
# go-public and the class must be marked tainted. Declare `feeds:` honestly — the whole point
|
|
26
|
+
# is that no single step in a bad chain looks dangerous on its own.
|
|
27
|
+
|
|
28
|
+
classes:
|
|
29
|
+
# ---- promotion-eligible example -------------------------------------------------
|
|
30
|
+
- name: dispatch-readonly-sim-local-artifact
|
|
31
|
+
owner: fh-meta:sim-conductor # the gate/skill that does the asking
|
|
32
|
+
mode: blind-target-tier-sim
|
|
33
|
+
target: a single local file under the current repo
|
|
34
|
+
capabilities: [read, dispatch] # read · local-write · network · dispatch · repo-mutation
|
|
35
|
+
sinks: [] # crosses nothing irreversible
|
|
36
|
+
feeds: [] # enables/queues nothing irreversible
|
|
37
|
+
promotion_eligible: true
|
|
38
|
+
lease_days: 30 # standing consent is a renewable lease, never permanent
|
|
39
|
+
|
|
40
|
+
# ---- NOT promotion-eligible: sink is irreversible --------------------------------
|
|
41
|
+
- name: npm-publish
|
|
42
|
+
owner: Pre-Publish Surface Gate
|
|
43
|
+
mode: registry-publish
|
|
44
|
+
target: the package tarball file set
|
|
45
|
+
capabilities: [read, network]
|
|
46
|
+
sinks: [go-public] # irreversible => never promotes, at any count
|
|
47
|
+
feeds: []
|
|
48
|
+
promotion_eligible: false
|
|
49
|
+
reason: "publish is effectively irreversible — a package is live the instant it ships and may
|
|
50
|
+
be cached or forked before any scrub (CLAUDE.md §Pre-Publish Surface Gate)."
|
|
51
|
+
|
|
52
|
+
# ---- NOT promotion-eligible: TAINTED (this is the one that looks safe) -----------
|
|
53
|
+
- name: local-commit
|
|
54
|
+
owner: FH 4-axis commit gate
|
|
55
|
+
mode: git-commit
|
|
56
|
+
target: the working tree
|
|
57
|
+
capabilities: [local-write, repo-mutation]
|
|
58
|
+
sinks: [] # committing itself is reversible
|
|
59
|
+
feeds: [go-public] # ...but CI/sync publishes from it
|
|
60
|
+
promotion_eligible: false
|
|
61
|
+
reason: "taint — reversible in isolation, feeds an irreversible sink. Every step in the
|
|
62
|
+
workflow-edit -> release-notes -> commit -> CI-publish chain is individually
|
|
63
|
+
reversible; the chain is not."
|
|
64
|
+
|
|
65
|
+
# ---- NOT promotion-eligible: unclassified ---------------------------------------
|
|
66
|
+
- name: run-arbitrary-shell
|
|
67
|
+
owner: (none — catch-all)
|
|
68
|
+
mode: unrestricted
|
|
69
|
+
target: unbounded
|
|
70
|
+
capabilities: [read, local-write, network, dispatch, repo-mutation]
|
|
71
|
+
sinks: [unknown] # unknown is NOT reversible
|
|
72
|
+
feeds: [unknown]
|
|
73
|
+
promotion_eligible: false
|
|
74
|
+
reason: "sinks cannot be enumerated, so the floor cannot be decided mechanically. A class
|
|
75
|
+
whose blast radius is unbounded is never a candidate, regardless of approval count."
|