@openwop/openwop-conformance 2.34.2 → 2.35.0
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/CHANGELOG.md +24 -0
- package/README.md +3 -3
- package/coverage.md +3 -1
- package/dist/cli.js +3 -1
- package/dist/lib/certification-bundle-v3.js +43 -5
- package/dist/lib/scenario-disposition.js +18 -4
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +91 -11
- package/scenario-majors.json +8 -2
- package/schemas/CORPUS-STAMP.json +20 -20
- package/src/cli.ts +3 -1
- package/src/lib/certification-bundle-v3.ts +50 -5
- package/src/lib/scenario-disposition.ts +17 -3
- package/src/lib/terminal-shape.ts +30 -0
- package/src/scenarios/v2-bound-id-kinds.test.ts +91 -1
- package/src/scenarios/v2-callback-url-guarded.test.ts +53 -0
- package/src/scenarios/v2-relaxation-recorded.test.ts +13 -1
- package/src/scenarios/v2-terminal-event-once.test.ts +138 -0
- package/src/scenarios/v2-webhook-egress-refusal.test.ts +10 -0
- package/src/setup.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.35.0] — 2026-09-21 — blocked means blocked, the declarations are signed, and the header a subscriber reads is checked
|
|
4
|
+
|
|
5
|
+
### Security
|
|
6
|
+
|
|
7
|
+
- **`v2-webhook-egress-refusal` (`0171.webhook-egress-refused`) now registers loopback, cloud metadata and RFC 1918 written as IPv4-mapped IPv6** — `https://[::ffff:7f00:1]/`, `https://[::ffff:a9fe:a9fe]/`, `https://[::ffff:a00:1]/` — eleven probes, up from eight. A URL parser emits the mapped form in hex (`[::ffff:127.0.0.1]` has hostname `::ffff:7f00:1`), and an egress guard that recognises only the dotted mapped form lets all three through at registration and at delivery. They are loopback, link-local and RFC 1918 addresses under `webhooks.md` §SSRF as written, so this enforces an existing MUST under the same id. Measured: the steward's reference host before its fix accepted exactly these three (`3 of 11`); after the fix, none.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
> **The three rules below are RFC 0195's.** They were merged before that RFC was filed; it was then made `Active` by steward override of RFC 0147 §A.6, whose full comment window it did not run.
|
|
12
|
+
|
|
13
|
+
- **A `blocked` note stands at major 2 even after setup assertions.** A test that asserted something and then soft-skipped `blocked` recorded `executed-pass` with a `partial-witness:` detail — refused by the acceptance predicate, but counted by certification, so a profile could certify on a requirement nobody observed. At major 2 it now records `blocked`, which denies certification (`conformance.md` §Bundle v3). Swept first: every one of the 24 major-2 sites is "requirement unobserved" (unreachable, a control that did not answer, a window that closed), none an optional extra. Major 1 keeps its convention; the 146 v1-side sites were not measured and v1 bundles are read through its end of support. Measured on the committed bundles: the next cut changes 0 rows on the reference host and MyndHyve, 1 on openwop-app.
|
|
14
|
+
- **`host.relaxations[]` is signed.** It sits outside the attestation, so a relaxation deleted after signing let the verifier re-derive the relaxed profile as certified on a bundle that still verified. `witnessSha256` now digests `{ rows, relaxations }` whenever relaxations are declared, and the rows alone otherwise — every committed bundle digests unchanged. An older verifier fails closed (`witness-digest`) on a 2.35.0 bundle that declares relaxations. `v2-relaxation-recorded` now strips a signed relaxation and requires the rejection.
|
|
15
|
+
- **Opt-outs are derived and checked.** The verifier derives the operator's opt-outs from the signed `skipped` rows the behavior gate writes and rejects a bundle whose captured discovery document advertises one (`opted-out-but-advertised`); `--verify` prints the set. No schema change — a root-level list would have been unsigned. The file-level detail now says "profile or family".
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`v2-terminal-event-once` — RFC 0194 (`0194.terminal-once`, `0194.terminal-once.duplicate-delivery`).** A run's log carries exactly one terminal run event and no forward-execution event after it; `compensation.*` and `run.dead-lettered` may follow. The ordinary leg drives a noop run, and the failure and cancellable fixtures where advertised; the duplicate-delivery leg needs the RFC 0158 durability test hook (`inapplicable` without it). The log is read after it settles. Pure rule in `lib/terminal-shape.ts`, unit-tested against the two logs observed in openwop#1445.
|
|
20
|
+
- **`v2-callback-url-guarded` — RFC 0196 §A.3 (`0196.callback-url-guarded`), invariant `callback-url-egress-guarded`.** A host advertising `interrupt.callbackDelivery` must refuse at `createRun` a loopback, cloud-metadata or hex-mapped-loopback `callbackUrl` with `400 validation_error`, `details.field: callbackUrl`. `inapplicable` where the facet is absent — no committed host advertises it today.
|
|
21
|
+
- **`0187.bound-id-kinds.webhook-emitted` (openwop#1450).** The mint row reads the `webhookId` a host returns; a subscriber identifies its deliveries by the one it emits. A tier-2 host bound the 201 and left the delivery headers bare, and the mint row stayed green — the defect surfaced only as a durability failure elsewhere. The new leg drives one delivery, selects it by the run in the body (never by the header under test), and requires `OpenWOP-Webhook-Id` — and `X-openwop-Webhook-Id` on a host advertising both majors — to equal the minted id.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **`v2-bound-id-kinds` left a subscription behind on every run** (its `deliveryId` leg registered and never deleted). Every registering leg now deletes in `finally`. Measured: 1 leftover subscription per run before, 0 after.
|
|
26
|
+
|
|
3
27
|
## [2.34.2] — 2026-09-21 — a host that died on schedule failed the rows that were watching it recover
|
|
4
28
|
|
|
5
29
|
- **The RFC 0158 kill rows read the host through two calls that did not survive the host dying.** `waitBack()` treated a refused connection as "down, keep waiting"; the watch's `readStatus` / `readLog` called `driver.get` bare. A seam may answer and *then* die — `during-execution` dies at the first `node.started`, seconds after its response — so `waitBack` saw a 200 from the still-living process, the process died mid-watch, and `fetch failed` escaped the scenario as an **`executed-fail` — for recovering.** Measured by a tier-1 host at its exact production image under a real restart supervisor, behind a port proxy that accepts and then closes: four of five rows `executed-fail` on `UND_ERR_SOCKET: other side closed`, reproduced twice; the one that passed was the row whose kill lands before the seam answers.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# --legacy-peer-deps is REQUIRED, not optional: the exact peer pin is what npm's
|
|
12
12
|
# default resolver refuses. npm 10.9 fails outright with
|
|
13
13
|
# "Cannot read properties of null (reading 'edgesOut')" — use npm >= 11.
|
|
14
|
-
npm install --legacy-peer-deps @openwop/openwop-conformance@2.
|
|
14
|
+
npm install --legacy-peer-deps @openwop/openwop-conformance@2.35.0 @openwop/spec-artifacts@2.35.0
|
|
15
15
|
# or run without install:
|
|
16
16
|
npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
|
|
17
17
|
```
|
|
@@ -135,7 +135,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
|
|
|
135
135
|
|
|
136
136
|
## What's Covered
|
|
137
137
|
|
|
138
|
-
The current suite has
|
|
138
|
+
The current suite has 526 scenario files under `src/scenarios/`.
|
|
139
139
|
- 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
|
|
140
140
|
- 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
|
|
141
141
|
- 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
|
|
@@ -480,7 +480,7 @@ Server-required (added in 1.7.0):
|
|
|
480
480
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
481
481
|
| **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
|
|
482
482
|
|
|
483
|
-
Current source tree:
|
|
483
|
+
Current source tree: 526 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
484
484
|
|
|
485
485
|
## Remaining Gaps
|
|
486
486
|
|
package/coverage.md
CHANGED
|
@@ -144,7 +144,9 @@ The scenario groups in the table below (one row per group; count the rows — th
|
|
|
144
144
|
| `tool-session-lifecycle.test.ts` | `capabilities.toolCatalog.sessionLifecycle` (RFC 0078 §D, `tool-catalog.md`) | A (the §D bracket via `POST /v1/host/sample/tools/session-run` + the test event-log seam: `tool.session.opened` before the first RFC 0064 call event → `tool.session.closed` after the last, one shared `sessionId`, each carrying a `toolId`, `closed.outcome` ∈ {completed,failed,aborted,expired}, both content-free) | `host-pending` | `behaviorGate('openwop-tool-session-lifecycle', …)`. Seam-gated; soft-skips on 404. **Part of the RFC 0078 → Accepted bar.** First adopter: MyndHyve `toolCatalog`. |
|
|
145
145
|
| `egress-audience-binding.test.ts` | `capabilities.httpClient.egressPolicy.supported` (RFC 0079 §C, `host-capabilities.md`) + `SECURITY/invariants.yaml` `egress-credential-audience-bound` | A (KEYSTONE — the §C confused-deputy MUST via `POST /v1/host/sample/egress/decide`: an out-of-audience egress is `denied`/`downgraded` with `reason:"out-of-audience"` and the credential is NOT attached (`credentialAttached !== true`); a provenance-unevaluable egress fails closed `denied`+`reason:"provenance-unevaluable"`; decision/reason ∈ the closed enums) | `host-pending` | `behaviorGate('openwop-egress-audience-binding', …)`. Seam-gated; soft-skips on 404. **This is the RFC 0079 → Accepted bar** (the `egress-credential-audience-bound` invariant graduates reference-impl → protocol tier when this passes against a host). First adopter: MyndHyve `httpClient.egressPolicy`. |
|
|
146
146
|
| `egress-decision-content-free.test.ts` | `capabilities.httpClient.egressPolicy.supported` (RFC 0079 §F / SR-1) | A (the secret non-leak — a `canary` credential's sentinel never surfaces in the decision (`canaryLeaked !== true`), the `egress.decided` payload carries no forbidden content key, and `reason` stays in the CLOSED vocabulary so no blocked destination spills into a free-form field) | `host-pending` | `behaviorGate('openwop-egress-decision-content-free', …)`. Seam-gated; soft-skips on 404. **Part of the RFC 0079 → Accepted bar.** First adopter: MyndHyve `httpClient.egressPolicy`. |
|
|
147
|
-
| `v2-webhook-egress-refusal.test.ts` | the `webhooks` family (capability-gated) | A (`webhooks.md` §SSRF at MAJOR 2 —
|
|
147
|
+
| `v2-webhook-egress-refusal.test.ts` | the `webhooks` family (capability-gated) | A (`webhooks.md` §SSRF at MAJOR 2 — **eleven** registration probes since 2.35.0: the eight below plus loopback, cloud metadata and RFC 1918 written as IPv4-mapped IPv6, which a URL parser emits in HEX (`[::ffff:127.0.0.1]` has hostname `::ffff:7f00:1`) and a guard matching only the dotted mapped form lets through — same id, the same MUST. The original eight probes, one per class the sentence names: non-`https`, IPv4/IPv6 loopback, `localhost`, two RFC 1918 ranges, link-local / cloud metadata, IPv6 ULA; each MUST answer `400 webhook_url_rejected`. Until 2.33.0 no major-2 row asserted the guard refuses anything — the refusal scenarios were major-1 only. THREE OUTCOMES: all refused → `executed-pass`; a probe accepted with a `webhooks.*` relaxation DECLARED in `OPENWOP_HOST_RELAXATIONS` → `inapplicable`, and the declared relaxation denies the owning profile; a probe accepted with NOTHING declared → `executed-fail`, which the emitter and verifier read as an OBSERVED undeclared relaxation and deny every profile built on `webhooks` (`undeclared-relaxation-observed`). Registration-time only: no probe is a destination the suite owns, nothing is delivered, an accepted registration is deleted at once) |
|
|
148
|
+
| `v2-terminal-event-once.test.ts` | none for the ordinary leg (every host has runs; the failed and cancelled fixtures are each gated on being advertised); the RFC 0158 durability test hook for the duplicate-delivery leg | A (RFC 0194 §A: a run's log carries exactly one terminal run event and no forward-execution event after it — `compensation.*` and `run.dead-lettered` may follow. The log is read after it SETTLES, two reads ≥ 2 s apart agreeing, bounded at 20 s, because a second delivery can finish after the first terminal event. Two ids: `0194.terminal-once`, `0194.terminal-once.duplicate-delivery`) | `host-pending` | A dedicated file on purpose: a violation must fail this RFC's rows, not unrelated ones. |
|
|
149
|
+
| `v2-callback-url-guarded.test.ts` | the `interrupt` family with `interrupt.callbackDelivery: true` | A (RFC 0196 §A.3: `createRun` with a loopback, cloud-metadata or hex-mapped-loopback `callbackUrl` MUST answer `400 validation_error`, `details.field: callbackUrl`; invariant `callback-url-egress-guarded`) | `host-pending` | No committed host advertises the facet; witnessed on none today. |
|
|
148
150
|
| `v2-durability-recovery.test.ts` | the RFC 0158 host-extension durability seam `POST /host/durability/kill` (non-normative; §E mints no capability field, so seam PRESENCE is how a host says it claims a rung) | A (all five `durable-single-instance` rows: `kill-after-accept` as a HOLD-DISPATCH exercise, `kill-during-execution` asserting that work executing at a real process death is never observable as completed without re-execution, `duplicate-delivery` counted AT THE EFFECT'S DESTINATION — a receiver the suite owns, `effectUrl` — because a ledger keyed on effect identity admits one row per identity by construction and cannot show a double-fire (2.32.0), `bound-is-derived` as an explicitly-labelled paper check, and `poison-exhaustion` ported from the major-1 scenario — at major 2 it reads the canonical JSON log read `GET /runs/{runId}/events/poll` (NOT `/events`, which is SSE-only at major 2 — reading it as JSON made this row pass vacuously on a host serving exactly the OpenAPI; 2.32.0), so unlike its v1 twin it needs NO seam and can never record `blocked` for want of one a host did not wire) | `host-pending` | **2.34.0 — the rows now WRITE evidence into the bundle (RFC 0158 §E):** `bound-is-derived` records `evidence.recoveryBounds[]` per recovery class and each kill row records `evidence.recovery { class, boundMs, observedMs }` — the interval was always measured and, on a pass, never recorded. Both ride on the ROW so the witness digest, and through it the signature, covers them; the bundle claims `durability.rung` only when `deriveRung` supports it and `--verify` rejects a claim it cannot re-derive (`rung-not-derivable`). A kill row records nothing unless the seam NAMES the class it exercised (`recoveryClass`) and the bound response declares that class — no name, no rung, rather than a guessed one. **Disposition rule, and it protects fleet certification:** no seam route at all ⇒ `inapplicable` (the host claims no rung); seam present but an operator precondition unmet — a restart supervisor, which a black-box suite cannot supply — ⇒ `blocked` with the precondition NAMED. Collapsing the two would deny certification to every host the day these rows entered the lane. `peer-resume` is deliberately NOT written: §E makes it bundle-witnessed, so a scenario could only ever record `blocked`. **This is the RFC 0158 → Accepted bar, and it is now complete at major 2** — before the port, a major-2 bundle could carry at most four of the five rows and the rung was unwitnessable no matter what a host did, and its witness is openwop-app, which the RFC records as having observed `kill-during-execution` across a real `SIGKILL`. |
|
|
149
151
|
| `memory-degraded-projection.test.ts` | `capabilities.agents.manifestRuntime.supported` + `capabilities.memory.supported` (RFC 0080 §C, `agent-memory.md`) | A (the §C iff-contract on the NORMATIVE `GET /v1/agents`: a degraded entry MUST carry `memoryDegraded:true` + a non-empty, unique `degradedMemoryDimensions[]` drawn from the closed §A-name enum [read/write/search/long-term-durability/compaction/attribution/replay-snapshot/retention]; a non-degraded entry MUST NOT carry a non-empty list; the inventory is non-empty; the degraded branch runs non-vacuously when `OPENWOP_DEGRADED_AGENT_ID` names a known-degraded agent) | `host-pending` | `behaviorGate('openwop-memory-degraded', …)`. Black-box on the normative path (no POST seam); soft-skips on 404 / when the host computes no degradation. **This is the RFC 0080 → Accepted bar.** First adopter: MyndHyve `memory`. |
|
|
150
152
|
| `budget-enforcement.test.ts` | `capabilities.budget.supported` (RFC 0084 §C/§D, `budget-policy.md`) + `SECURITY/invariants.yaml` `budget-no-pricing-leak` | A (the §C/§D enforcement via `POST /v1/host/sample/budget/run` + the test event-log seam: a `hard-cost-exhaust` run emits the strict-ordered `budget.reserved → budget.consumed → budget.threshold.crossed{percent} → budget.exhausted → cap.breached{kind:"budget-cost"} → run.failed{error:"budget_exhausted"}` chain; a `model-denied` run is refused `budget_model_denied` BEFORE the provider call (fail-closed); an `advisory` host emits the `budget.*` events without stopping; every `budget.*` payload content-free — no pricing/rate) | `host-pending` | `behaviorGate('openwop-budget-enforcement', …)`. Seam-gated; soft-skips on 404. **This is the RFC 0084 → Accepted bar.** First adopter: MyndHyve `budget`. |
|
package/dist/cli.js
CHANGED
|
@@ -697,7 +697,7 @@ async function runCertify(args, baseUrl, apiKey) {
|
|
|
697
697
|
discovery: { url: discoveryUrl, sha256, protocolVersions, preferredVersion, document },
|
|
698
698
|
claimedProfiles: claimed3,
|
|
699
699
|
results: { totals: totals3, requirements: rows3 },
|
|
700
|
-
witnessSha256: witnessDigest(rows3),
|
|
700
|
+
witnessSha256: witnessDigest(rows3, relaxations),
|
|
701
701
|
assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
|
|
702
702
|
...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
|
|
703
703
|
// RFC 0158 §D: claimed ONLY when these rows support it. The verifier
|
|
@@ -908,6 +908,8 @@ async function main() {
|
|
|
908
908
|
`certified: ${verdict.certifiedProfiles.length > 0 ? verdict.certifiedProfiles.join(', ') : '(none)'}`,
|
|
909
909
|
// RFC 0158 §D/§E: the rung is a CLAIM; a claim the signed rows do not support is a rejection below.
|
|
910
910
|
`rung: ${bundle.durability?.rung ?? '(none claimed)'}`,
|
|
911
|
+
// 2.35.0: derived from the signed `skipped` rows; checked against the signed discovery document.
|
|
912
|
+
`opted out: ${verdict.optedOut.length > 0 ? verdict.optedOut.join(', ') : '(none)'}`,
|
|
911
913
|
'',
|
|
912
914
|
'What this command does NOT do:',
|
|
913
915
|
' · It does not re-run anything. A host that measured itself wrongly, and signed',
|
|
@@ -31,12 +31,26 @@ export function canonicalJSON(value) {
|
|
|
31
31
|
}
|
|
32
32
|
return JSON.stringify(value);
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* RFC 0148 §C — the digest over the reporter record (the requirement rows),
|
|
36
|
+
* and, from 2.35.0, the operator's declared relaxations when there are any.
|
|
37
|
+
*
|
|
38
|
+
* Why relaxations are in here: the attestation covers exactly
|
|
39
|
+
* `{ witnessSha256, host.build, suite.version, discovery.sha256 }`, so a
|
|
40
|
+
* `host.relaxations[]` beside the rows was UNSIGNED — strip it after signing
|
|
41
|
+
* and the verifier re-derives the relaxed profile as certified, on a bundle
|
|
42
|
+
* that still verifies. Included ONLY WHEN NON-EMPTY: the preimage is the rows
|
|
43
|
+
* array exactly as before for every bundle that declares nothing (all
|
|
44
|
+
* committed bundles digest unchanged, pinned in durability-evidence.test.ts),
|
|
45
|
+
* and `{ rows, relaxations }` otherwise. An older verifier fails closed on a
|
|
46
|
+
* 2.35.0 bundle that declares relaxations — `witness-digest`, never a pass.
|
|
47
|
+
*/
|
|
48
|
+
export function witnessDigest(rows, relaxations) {
|
|
36
49
|
const canonicalRows = [...rows].sort((a, b) => a.id.localeCompare(b.id)).map((r) => ({ id: r.id, scenario: r.scenario, result: r.result, ...(r.assertions === undefined ? {} : { assertions: r.assertions }), ...(r.detail === undefined ? {} : { detail: r.detail }),
|
|
37
50
|
// ONLY WHEN PRESENT: every bundle cut before 2.34.0 has no `evidence` and digests byte-identically.
|
|
38
51
|
...(r.evidence === undefined ? {} : { evidence: r.evidence }) }));
|
|
39
|
-
|
|
52
|
+
const preimage = relaxations !== undefined && relaxations.length > 0 ? { rows: canonicalRows, relaxations } : canonicalRows;
|
|
53
|
+
return createHash('sha256').update(canonicalJSON(preimage), 'utf8').digest('hex');
|
|
40
54
|
}
|
|
41
55
|
/** The bytes the attestation covers. */
|
|
42
56
|
export function attestationPayload(b) {
|
|
@@ -58,6 +72,14 @@ export function verifierSign(bundle, privateKeyPem, keyId) {
|
|
|
58
72
|
export function publicKeyFromPrivate(privateKeyPem) {
|
|
59
73
|
return createPublicKey(createPrivateKey(privateKeyPem)).export({ type: 'spki', format: 'pem' });
|
|
60
74
|
}
|
|
75
|
+
const OPT_OUT_ROW = 'openwop.profile.';
|
|
76
|
+
/** The opt-outs a bundle's signed rows record. */
|
|
77
|
+
export function optedOutFromRows(rows) {
|
|
78
|
+
return rows
|
|
79
|
+
.filter((r) => r.id.startsWith(OPT_OUT_ROW) && r.result === 'skipped' && (r.detail ?? '').includes('OPENWOP_OPTED_OUT_PROFILES'))
|
|
80
|
+
.map((r) => r.id.slice(OPT_OUT_ROW.length))
|
|
81
|
+
.sort();
|
|
82
|
+
}
|
|
61
83
|
export function verifyBundleV3(bundle, opts = {}) {
|
|
62
84
|
const rejections = [];
|
|
63
85
|
if (bundle.bundleVersion !== '3')
|
|
@@ -78,9 +100,9 @@ export function verifyBundleV3(bundle, opts = {}) {
|
|
|
78
100
|
for (const k of Object.keys(expected))
|
|
79
101
|
if (bundle.results?.totals?.[k] !== expected[k])
|
|
80
102
|
rejections.push({ kind: 'totals-mismatch', detail: `totals.${k} is ${String(bundle.results?.totals?.[k])} but the rows count ${expected[k]}` });
|
|
81
|
-
const digest = witnessDigest(rows);
|
|
103
|
+
const digest = witnessDigest(rows, bundle.host?.relaxations);
|
|
82
104
|
if (bundle.witnessSha256 !== digest)
|
|
83
|
-
rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows (${digest.slice(0, 12)})` });
|
|
105
|
+
rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows and declared relaxations (${digest.slice(0, 12)})` });
|
|
84
106
|
const assertions = rows.reduce((n, r) => n + (r.assertions ?? 0), 0);
|
|
85
107
|
if (bundle.assertionCount !== assertions)
|
|
86
108
|
rejections.push({ kind: 'assertion-count', detail: `assertionCount is ${String(bundle.assertionCount)} but the rows sum to ${assertions}` });
|
|
@@ -183,6 +205,21 @@ export function verifyBundleV3(bundle, opts = {}) {
|
|
|
183
205
|
}
|
|
184
206
|
else {
|
|
185
207
|
derivabilityChecked = true;
|
|
208
|
+
// RFC 0148 §B: "A host MUST NOT both advertise and opt out of the same
|
|
209
|
+
// profile." The suite refuses that at run time (behaviorGate throws),
|
|
210
|
+
// so a bundle that records both was edited after the run or emitted by
|
|
211
|
+
// something else. Both inputs are signed — the rows through
|
|
212
|
+
// witnessSha256, the document through discovery.sha256 — so the
|
|
213
|
+
// contradiction is checkable offline, and it is bundle-wide: an opt-out
|
|
214
|
+
// list that is false makes every `skipped` row in the bundle suspect.
|
|
215
|
+
const root = document;
|
|
216
|
+
for (const name of optedOutFromRows(rows)) {
|
|
217
|
+
const advertised = name.startsWith('family.')
|
|
218
|
+
? (() => { const v = root[name.slice('family.'.length)]; return typeof v === 'object' && v !== null && !Array.isArray(v); })()
|
|
219
|
+
: profileDerivable(document, name, targetMajor);
|
|
220
|
+
if (advertised)
|
|
221
|
+
rejections.push({ kind: 'opted-out-but-advertised', detail: `${name} is recorded as an operator opt-out, but the captured discovery document advertises it — a host MUST NOT both advertise and opt out of the same profile (RFC 0148 §B)` });
|
|
222
|
+
}
|
|
186
223
|
for (const p of bundle.claimedProfiles ?? []) {
|
|
187
224
|
// §B(1) binds the CERTIFICATION, not the listing: a profile a bundle
|
|
188
225
|
// names without certifying makes no claim for derivability to falsify.
|
|
@@ -209,5 +246,6 @@ export function verifyBundleV3(bundle, opts = {}) {
|
|
|
209
246
|
verifierSignatureVerified,
|
|
210
247
|
certifiedProfiles: bundleWide.length > 0 ? [] : certifiedProfiles.filter((p) => !scoped.has(p)),
|
|
211
248
|
derivabilityChecked,
|
|
249
|
+
optedOut: optedOutFromRows(rows),
|
|
212
250
|
};
|
|
213
251
|
}
|
|
@@ -96,13 +96,27 @@ export const PARTIAL_WITNESS_PREFIX = 'partial-witness: ';
|
|
|
96
96
|
* dishonest per-`it` rows denied certification to every profile on a host that
|
|
97
97
|
* simply did not advertise the gated surface.
|
|
98
98
|
*/
|
|
99
|
-
export function resolveItRecord(state, assertionCalls, gate, noted, firstError
|
|
99
|
+
export function resolveItRecord(state, assertionCalls, gate, noted, firstError,
|
|
100
|
+
/**
|
|
101
|
+
* 2.35.0, major 2 (`conformance.md` §Bundle v3): a requirement the test did
|
|
102
|
+
* not observe records `blocked` even when the test asserted setup facts
|
|
103
|
+
* first. At major 1 a `blocked` note after assertions stays a
|
|
104
|
+
* `partial-witness` pass unless the leg used `blockedDespiteAssertions`.
|
|
105
|
+
*
|
|
106
|
+
* Why the rule changed: a `partial-witness` row is refused by the acceptance
|
|
107
|
+
* predicate but COUNTED by certification, so a profile could certify on a
|
|
108
|
+
* requirement nobody observed. Swept before the change: all 24 major-2
|
|
109
|
+
* sites were "requirement unobserved" (unreachable, control silent, window
|
|
110
|
+
* closed), none an optional extra. Scoped to major 2 because the 146 v1-side
|
|
111
|
+
* sites were not measured and v1 bundles are read through its EOS.
|
|
112
|
+
*/
|
|
113
|
+
blockedStands = false) {
|
|
100
114
|
if (state === 'fail')
|
|
101
115
|
return { disposition: 'executed-fail', detail: `the test executed and failed: ${(firstError ?? 'no message').slice(0, 300)}` };
|
|
102
116
|
if (state === 'pass' && assertionCalls > 0) {
|
|
103
117
|
// `blockedDespiteAssertions` (soft-skip.ts): the leg says its setup
|
|
104
118
|
// assertions are not the requirement, and the requirement went unobserved.
|
|
105
|
-
if (noted !== null && noted.kind === 'blocked' && noted.conclusive === true)
|
|
119
|
+
if (noted !== null && noted.kind === 'blocked' && (noted.conclusive === true || blockedStands))
|
|
106
120
|
return { disposition: 'blocked', detail: noted.reason };
|
|
107
121
|
// A leg that asserted AND THEN soft-skipped is only a partial witness, and
|
|
108
122
|
// the file-level record has always said so (`resolveFileRecord` below).
|
|
@@ -141,14 +155,14 @@ export function fileDisposition(states, gateReason, assertionCount) {
|
|
|
141
155
|
return { disposition: 'inapplicable', detail: 'every test returned early through behaviorGate with zero assertions: profile not advertised in the captured discovery set' };
|
|
142
156
|
}
|
|
143
157
|
if (assertionCount === 0 && gateReason === 'skipped') {
|
|
144
|
-
return { disposition: 'skipped', detail: 'every test returned early through behaviorGate with zero assertions: operator opted the profile out (OPENWOP_OPTED_OUT_PROFILES)' };
|
|
158
|
+
return { disposition: 'skipped', detail: 'every test returned early through behaviorGate with zero assertions: operator opted the profile or family out (OPENWOP_OPTED_OUT_PROFILES)' };
|
|
145
159
|
}
|
|
146
160
|
return { disposition: 'executed-pass' };
|
|
147
161
|
}
|
|
148
162
|
if (gateReason === 'inapplicable')
|
|
149
163
|
return { disposition: 'inapplicable', detail: 'every test skipped: profile not advertised in the captured discovery set (behaviorGate)' };
|
|
150
164
|
if (gateReason === 'skipped')
|
|
151
|
-
return { disposition: 'skipped', detail: 'every test skipped: operator opted the profile out (OPENWOP_OPTED_OUT_PROFILES)' };
|
|
165
|
+
return { disposition: 'skipped', detail: 'every test skipped: operator opted the profile or family out (OPENWOP_OPTED_OUT_PROFILES)' };
|
|
152
166
|
return {
|
|
153
167
|
disposition: 'blocked',
|
|
154
168
|
detail: states.length === 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.35.0",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.
|
|
59
|
+
"@openwop/spec-artifacts": "2.35.0"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
|
-
"files":
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
5
|
+
"files": 584,
|
|
6
|
+
"tests": 2213,
|
|
7
|
+
"withStableId": 2213,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
|
-
"explicitIds":
|
|
9
|
+
"explicitIds": 2167
|
|
10
10
|
},
|
|
11
11
|
"records": [
|
|
12
12
|
{
|
|
@@ -26009,7 +26009,7 @@
|
|
|
26009
26009
|
{
|
|
26010
26010
|
"id": "openwop.it.v2-bound-id-kinds.subscriptionid-post-webhooks-mints-a-bound-webhookid-the-projected-segment-is-ac",
|
|
26011
26011
|
"file": "v2-bound-id-kinds.test.ts",
|
|
26012
|
-
"line":
|
|
26012
|
+
"line": 89,
|
|
26013
26013
|
"title": "subscriptionId: POST /webhooks mints a bound webhookId, the projected segment is accepted, and a foreign tenant segment is refused 403",
|
|
26014
26014
|
"explicitId": "openwop.requirement.0187.bound-id-kinds.webhook",
|
|
26015
26015
|
"citations": [
|
|
@@ -26033,10 +26033,29 @@
|
|
|
26033
26033
|
}
|
|
26034
26034
|
]
|
|
26035
26035
|
},
|
|
26036
|
+
{
|
|
26037
|
+
"id": "openwop.it.v2-bound-id-kinds.subscriptionid-the-webhookid-a-host-emits-on-a-delivery-is-the-one-it-minted",
|
|
26038
|
+
"file": "v2-bound-id-kinds.test.ts",
|
|
26039
|
+
"line": 132,
|
|
26040
|
+
"title": "subscriptionId: the webhookId a host EMITS on a delivery is the one it minted",
|
|
26041
|
+
"explicitId": "openwop.requirement.0187.bound-id-kinds.webhook-emitted",
|
|
26042
|
+
"citations": [
|
|
26043
|
+
{
|
|
26044
|
+
"section": "webhooks.md §Headers (RFC 0187 §A.1)",
|
|
26045
|
+
"requirement": null,
|
|
26046
|
+
"interpolated": true
|
|
26047
|
+
},
|
|
26048
|
+
{
|
|
26049
|
+
"section": "webhooks.md §Headers (dual emission, RFC 0176 §D.2)",
|
|
26050
|
+
"requirement": null,
|
|
26051
|
+
"interpolated": true
|
|
26052
|
+
}
|
|
26053
|
+
]
|
|
26054
|
+
},
|
|
26036
26055
|
{
|
|
26037
26056
|
"id": "openwop.it.v2-bound-id-kinds.interruptid-the-id-a-suspension-puts-on-the-wire-is-bound",
|
|
26038
26057
|
"file": "v2-bound-id-kinds.test.ts",
|
|
26039
|
-
"line":
|
|
26058
|
+
"line": 179,
|
|
26040
26059
|
"title": "interruptId: the id a suspension puts on the wire is bound",
|
|
26041
26060
|
"explicitId": null,
|
|
26042
26061
|
"citations": []
|
|
@@ -26044,7 +26063,7 @@
|
|
|
26044
26063
|
{
|
|
26045
26064
|
"id": "openwop.it.v2-bound-id-kinds.effectid-every-row-get-runs-runid-effects-serves-is-bound",
|
|
26046
26065
|
"file": "v2-bound-id-kinds.test.ts",
|
|
26047
|
-
"line":
|
|
26066
|
+
"line": 200,
|
|
26048
26067
|
"title": "effectId: every row GET /runs/{runId}/effects serves is bound",
|
|
26049
26068
|
"explicitId": "openwop.requirement.0187.bound-id-kinds.per-kind",
|
|
26050
26069
|
"citations": [
|
|
@@ -26057,7 +26076,7 @@
|
|
|
26057
26076
|
{
|
|
26058
26077
|
"id": "openwop.it.v2-bound-id-kinds.deliveryid-every-record-in-the-dead-letter-read-carries-a-bound-id-in-the-caller",
|
|
26059
26078
|
"file": "v2-bound-id-kinds.test.ts",
|
|
26060
|
-
"line":
|
|
26079
|
+
"line": 215,
|
|
26061
26080
|
"title": "deliveryId: every record in the dead-letter read carries a bound id in the caller's tenant",
|
|
26062
26081
|
"explicitId": "openwop.requirement.0187.bound-id-kinds.per-kind",
|
|
26063
26082
|
"citations": [
|
|
@@ -26184,6 +26203,23 @@
|
|
|
26184
26203
|
}
|
|
26185
26204
|
]
|
|
26186
26205
|
},
|
|
26206
|
+
{
|
|
26207
|
+
"id": "openwop.it.v2-callback-url-guarded.a-host-advertising-callback-delivery-refuses-a-callbackurl-its-egress-guard-woul",
|
|
26208
|
+
"file": "v2-callback-url-guarded.test.ts",
|
|
26209
|
+
"line": 36,
|
|
26210
|
+
"title": "a host advertising callback delivery refuses a callbackUrl its egress guard would refuse",
|
|
26211
|
+
"explicitId": "openwop.requirement.0196.callback-url-guarded",
|
|
26212
|
+
"citations": [
|
|
26213
|
+
{
|
|
26214
|
+
"section": "spec/v2/core/interrupt.md §Callback delivery (RFC 0196 §A.3)",
|
|
26215
|
+
"requirement": "a host advertising interrupt.callbackDelivery MUST refuse at createRun a callbackUrl the webhooks.md §SSRF registration guard would refuse"
|
|
26216
|
+
},
|
|
26217
|
+
{
|
|
26218
|
+
"section": "spec/v2/core/interrupt.md §Callback delivery (RFC 0196 §A.3)",
|
|
26219
|
+
"requirement": "the refusal MUST be 400 validation_error naming details.field \"callbackUrl\""
|
|
26220
|
+
}
|
|
26221
|
+
]
|
|
26222
|
+
},
|
|
26187
26223
|
{
|
|
26188
26224
|
"id": "openwop.it.v2-capabilities-root-closed.the-host-document-validates-against-capabilities-schema-json",
|
|
26189
26225
|
"file": "v2-capabilities-root-closed.test.ts",
|
|
@@ -28418,13 +28454,17 @@
|
|
|
28418
28454
|
{
|
|
28419
28455
|
"section": "security-defaults.md §Relaxations",
|
|
28420
28456
|
"requirement": "a relaxation recorded against an unclaimed profile is not a rejection — recording is the obligation, claiming is the defect"
|
|
28457
|
+
},
|
|
28458
|
+
{
|
|
28459
|
+
"section": "conformance.md §Bundle v3",
|
|
28460
|
+
"requirement": "a relaxation removed after signing MUST be detected — the declaration is covered by witnessSha256, so the stripped bundle fails `witness-digest`"
|
|
28421
28461
|
}
|
|
28422
28462
|
]
|
|
28423
28463
|
},
|
|
28424
28464
|
{
|
|
28425
28465
|
"id": "openwop.it.v2-relaxation-recorded.a-relaxation-denies-the-real-profile-that-owns-its-family-not-only-one-named-aft",
|
|
28426
28466
|
"file": "v2-relaxation-recorded.test.ts",
|
|
28427
|
-
"line":
|
|
28467
|
+
"line": 130,
|
|
28428
28468
|
"title": "a relaxation denies the REAL profile that owns its family, not only one named after it",
|
|
28429
28469
|
"explicitId": "openwop.requirement.0173.relaxation-recorded.owned-by-registry",
|
|
28430
28470
|
"citations": [
|
|
@@ -28449,7 +28489,7 @@
|
|
|
28449
28489
|
{
|
|
28450
28490
|
"id": "openwop.it.v2-relaxation-recorded.durability-is-a-closed-set-session-deployment-persisted",
|
|
28451
28491
|
"file": "v2-relaxation-recorded.test.ts",
|
|
28452
|
-
"line":
|
|
28492
|
+
"line": 155,
|
|
28453
28493
|
"title": "durability is a closed set: session | deployment | persisted",
|
|
28454
28494
|
"explicitId": "openwop.requirement.0173.relaxation-recorded.durability-closed",
|
|
28455
28495
|
"citations": [
|
|
@@ -29253,6 +29293,46 @@
|
|
|
29253
29293
|
}
|
|
29254
29294
|
]
|
|
29255
29295
|
},
|
|
29296
|
+
{
|
|
29297
|
+
"id": "openwop.it.v2-terminal-event-once.every-run-observed-to-a-terminal-state-has-exactly-one-terminal-event-and-no-for",
|
|
29298
|
+
"file": "v2-terminal-event-once.test.ts",
|
|
29299
|
+
"line": 71,
|
|
29300
|
+
"title": "every run observed to a terminal state has exactly one terminal event and no forward execution after it",
|
|
29301
|
+
"explicitId": "openwop.requirement.0194.terminal-once",
|
|
29302
|
+
"citations": [
|
|
29303
|
+
{
|
|
29304
|
+
"section": "spec/v2/core/events.md §The terminal event (RFC 0194 §A)",
|
|
29305
|
+
"requirement": "a run's log MUST contain exactly one terminal run event and no forward-execution event after it"
|
|
29306
|
+
},
|
|
29307
|
+
{
|
|
29308
|
+
"section": "spec/v2/core/events.md §The terminal event (RFC 0194 §A)",
|
|
29309
|
+
"requirement": null,
|
|
29310
|
+
"interpolated": true
|
|
29311
|
+
}
|
|
29312
|
+
]
|
|
29313
|
+
},
|
|
29314
|
+
{
|
|
29315
|
+
"id": "openwop.it.v2-terminal-event-once.work-delivered-twice-still-leaves-one-terminal-event-and-no-forward-execution-af",
|
|
29316
|
+
"file": "v2-terminal-event-once.test.ts",
|
|
29317
|
+
"line": 99,
|
|
29318
|
+
"title": "work delivered twice still leaves one terminal event and no forward execution after it",
|
|
29319
|
+
"explicitId": "openwop.requirement.0194.terminal-once.duplicate-delivery",
|
|
29320
|
+
"citations": [
|
|
29321
|
+
{
|
|
29322
|
+
"section": "spec/v2/core/events.md §The terminal event (RFC 0194 §A)",
|
|
29323
|
+
"requirement": "under a duplicate delivery the log MUST still contain exactly one terminal run event and no forward-execution event after it"
|
|
29324
|
+
},
|
|
29325
|
+
{
|
|
29326
|
+
"section": "spec/v2/core/events.md §The terminal event (RFC 0194 §A)",
|
|
29327
|
+
"requirement": "the redelivered run's log reached a terminal event"
|
|
29328
|
+
},
|
|
29329
|
+
{
|
|
29330
|
+
"section": "spec/v2/core/events.md §The terminal event (RFC 0194 §A)",
|
|
29331
|
+
"requirement": null,
|
|
29332
|
+
"interpolated": true
|
|
29333
|
+
}
|
|
29334
|
+
]
|
|
29335
|
+
},
|
|
29256
29336
|
{
|
|
29257
29337
|
"id": "openwop.it.v2-unmapped-type-refused.poll-over-a-log-with-an-unmapped-unprefixed-type-fails-with-500-event-type-unmap",
|
|
29258
29338
|
"file": "v2-unmapped-type-refused.test.ts",
|
|
@@ -29603,7 +29683,7 @@
|
|
|
29603
29683
|
{
|
|
29604
29684
|
"id": "openwop.it.v2-webhook-egress-refusal.a-webhook-registration-naming-a-destination-the-guard-must-refuse-is-refused-400",
|
|
29605
29685
|
"file": "v2-webhook-egress-refusal.test.ts",
|
|
29606
|
-
"line":
|
|
29686
|
+
"line": 92,
|
|
29607
29687
|
"title": "a webhook registration naming a destination the guard MUST refuse is refused 400 webhook_url_rejected",
|
|
29608
29688
|
"explicitId": "openwop.requirement.0171.webhook-egress-refused",
|
|
29609
29689
|
"citations": [
|
package/scenario-majors.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
|
|
3
3
|
"counts": {
|
|
4
|
-
"files":
|
|
4
|
+
"files": 526,
|
|
5
5
|
"v1": 445,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 83
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -1253,6 +1253,9 @@
|
|
|
1253
1253
|
"v2-bundle-v3-signed.test.ts": [
|
|
1254
1254
|
2
|
|
1255
1255
|
],
|
|
1256
|
+
"v2-callback-url-guarded.test.ts": [
|
|
1257
|
+
2
|
|
1258
|
+
],
|
|
1256
1259
|
"v2-capabilities-root-closed.test.ts": [
|
|
1257
1260
|
2
|
|
1258
1261
|
],
|
|
@@ -1445,6 +1448,9 @@
|
|
|
1445
1448
|
"v2-subject-link-record.test.ts": [
|
|
1446
1449
|
2
|
|
1447
1450
|
],
|
|
1451
|
+
"v2-terminal-event-once.test.ts": [
|
|
1452
|
+
2
|
|
1453
|
+
],
|
|
1448
1454
|
"v2-unmapped-type-refused.test.ts": [
|
|
1449
1455
|
2
|
|
1450
1456
|
],
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
|
|
3
3
|
"package": "@openwop/spec-artifacts",
|
|
4
|
-
"version": "2.
|
|
5
|
-
"corpusTag": "v2.
|
|
4
|
+
"version": "2.35.0",
|
|
5
|
+
"corpusTag": "v2.35.0",
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
8
8
|
"api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
|
|
9
9
|
"api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
|
|
10
10
|
"api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
|
|
11
11
|
"api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
|
|
12
|
-
"api/seams-v2.yaml": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "3ce63e8b7bd3b93b7bae997821d4f7f591da89c1045e76c28e7c3bd81561cae9",
|
|
13
|
+
"api/v2/asyncapi.yaml": "91a2b046c8cc3d24d4fca8e74da170f972e03115fa5a467f4f1a7db7113427a4",
|
|
14
|
+
"api/v2/openapi.yaml": "ddc0f56e92487f650fd8e5828cf61451617d7eefe425bd47d88d72675e008ab1",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"schemas/ai-envelope.schema.json": "78167a5767809286071bfcfcdfeceed0ed04c8b9f8c13079019d70eff4f351c1",
|
|
29
29
|
"schemas/annotation-create.schema.json": "567e8327777968e0ddab8e1547b4acd7beda734cf9d1fc6cde17037d8d9540d4",
|
|
30
30
|
"schemas/annotation.schema.json": "b26d22f9a97e1ae7c195e8799964aca804e78452a079ca3a0d61ea013907ce91",
|
|
31
|
-
"schemas/artifact-type-pack-manifest.schema.json": "
|
|
31
|
+
"schemas/artifact-type-pack-manifest.schema.json": "67895666b7201bb82e846f3e1fb86ddd07aa3a6a89ece9f98462a7bc8f35ee96",
|
|
32
32
|
"schemas/audit-verify-result.schema.json": "e8fd013fb610ba5db27ab0113ec2eb18e7da5f9310dd3de2dd3dc87a259c21fe",
|
|
33
33
|
"schemas/budget-policy.schema.json": "3d4fe30ae129fe136ab9ece395f2a2a42a76360786b1b9a1d5f1cbc753eb5b39",
|
|
34
34
|
"schemas/capabilities.schema.json": "b413e0235cad412081df09234237e0f322a85903b7411b91aaff54032e946671",
|
|
@@ -112,10 +112,10 @@
|
|
|
112
112
|
"schemas/v2/ai-envelope.schema.json": "0dc7dfbf032a37239ffd81fc2d67f999a40782ff5074866a56e414164cdee809",
|
|
113
113
|
"schemas/v2/annotation-create.schema.json": "ec32c22a243b343eeb6956a23be2d76ba5b50c8018ec3ec9d6efa5a1d9b0c792",
|
|
114
114
|
"schemas/v2/annotation.schema.json": "eb4813cdaa8e325412cfcaeeee5c8a28f3751c946dec64e546c7ba0ad3aad8d4",
|
|
115
|
-
"schemas/v2/artifact-type-pack-manifest.schema.json": "
|
|
115
|
+
"schemas/v2/artifact-type-pack-manifest.schema.json": "abb60653f2f29925fb430fa04fa03fa67e14af47c1f8c5c07db324576fc17fa9",
|
|
116
116
|
"schemas/v2/audit-verify-result.schema.json": "a748dfadb5155adfde8a91045e76b0db6695005fe559035d71670fdd55edb3ba",
|
|
117
117
|
"schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
|
|
118
|
-
"schemas/v2/capabilities.schema.json": "
|
|
118
|
+
"schemas/v2/capabilities.schema.json": "304cdcebb291b633abaac9faf91bfb62377125e7d53434cc78f1789d4315e52f",
|
|
119
119
|
"schemas/v2/certification-bundle.schema.json": "46a5a07670f2457bf1b7af8f6df1f4f76edb3ff7e64d1b6ed85738270c95668e",
|
|
120
120
|
"schemas/v2/channel-presence-payload.schema.json": "1c20ad810cb311d47aa0b95d928490826b2468fd161f884cd29e867053e6782f",
|
|
121
121
|
"schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
|
|
@@ -201,7 +201,7 @@
|
|
|
201
201
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
202
202
|
"spec/v1/alias-detectors.json": "40069d5976eeb6ba1384a648e57e5cfd673db3fce36175115fb8293bee9664d4",
|
|
203
203
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
204
|
-
"spec/v1/core-standard-manifest.json": "
|
|
204
|
+
"spec/v1/core-standard-manifest.json": "ea55ec7d5a86d574479c545f3cb62ea6689ae91e4411c4ba6b27c22ca4d1f980",
|
|
205
205
|
"spec/v1/deprecations.json": "307083ce29c23fd406015951f99a30d78d6187ff061d38dc9732f62191b40f3f",
|
|
206
206
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
207
207
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -214,29 +214,29 @@
|
|
|
214
214
|
"spec/v1/operation-path-manifest.json": "5f5f4e3842669371730ebbd1aace3fb794192615f0018dc144f484ba5db82ac3",
|
|
215
215
|
"spec/v1/spec-gaps.json": "eb3bfcb9c7d05c9a6845d4a40ec9493cfaa5a671932daf1af561310136fdabe8",
|
|
216
216
|
"spec/v2/README.md": "8dbfc17b10f373dba95bdd2e8fec0580f35b380a834d304be3e24e7498170185",
|
|
217
|
-
"spec/v2/core/capabilities.md": "
|
|
218
|
-
"spec/v2/core/conformance.md": "
|
|
217
|
+
"spec/v2/core/capabilities.md": "8ffa639da1504d57df235e8685095d78fe9591ee3ef7e99fea359e9df99a54b4",
|
|
218
|
+
"spec/v2/core/conformance.md": "f224d49c5376ad39ea8a972cf623d5212a5fe140fd46ee83f234f92f6548ee0e",
|
|
219
219
|
"spec/v2/core/connection-packs.md": "466bdb9dde79d85615ad8281dacfb7bf923ca22d7fb3ed219749e59574c753ed",
|
|
220
220
|
"spec/v2/core/conversation.md": "e425887c4ba199b8cdc5c99e792689f7bd7d46de492f28670de2e402fa6d9506",
|
|
221
221
|
"spec/v2/core/errors.md": "0dff4a0ae102a86f6a17a9a3b16a203292588a0f422ed19d1c0d9213995aa01d",
|
|
222
|
-
"spec/v2/core/events.md": "
|
|
222
|
+
"spec/v2/core/events.md": "e90f860b48b87b5fbd3824029009c3044a7446715484c55d68cc4ec005a03a25",
|
|
223
223
|
"spec/v2/core/form-content-packs.md": "af3517dd7125106088249c0e32220b0b9fc3e9304c10378a6327cc8ad84b6bf1",
|
|
224
224
|
"spec/v2/core/headers.md": "14cdf4ae9ee7d80eebfd426d7c3552e577baea0300915cd4bb3a68e8588195a8",
|
|
225
225
|
"spec/v2/core/host-services.md": "3753f1e034d325bb4bd5751ae6d321fbb16700d4e28032ab4e273d25eb134fc4",
|
|
226
226
|
"spec/v2/core/idempotency.md": "e4edae1834101fd331379d84de77eaa9cead49383529c31f8bcd18322f67c965",
|
|
227
227
|
"spec/v2/core/identity.md": "e44f34f7c2db1f12babb19ca8ee1e5b1401a89f38fd5c90d0c4b74c95f6e5d42",
|
|
228
228
|
"spec/v2/core/interop.md": "6bd581556b9ab1a1509c01b9a1ec2bcb9e4a8300e68fb01570704417e70f20b9",
|
|
229
|
-
"spec/v2/core/interrupt.md": "
|
|
229
|
+
"spec/v2/core/interrupt.md": "18bcdfee975b3d9c336bc9b1845176f5c158274f7b66bb1870c2d7b49d9cfcdc",
|
|
230
230
|
"spec/v2/core/overview.md": "7dc00ace32d4e4ce929a383651f0029fc4b7091ec87487b1bf26ead1be209e0e",
|
|
231
231
|
"spec/v2/core/packs.md": "1c0919059760f9a3940a93264a76b7e30f9a0f6f27ff19753cc88f15753d8088",
|
|
232
232
|
"spec/v2/core/persistence.md": "9785e536362ec2000dab7fee9847543240d40a907745efc70cf2c8b4c1b8344e",
|
|
233
|
-
"spec/v2/core/replay.md": "
|
|
234
|
-
"spec/v2/core/runs.md": "
|
|
233
|
+
"spec/v2/core/replay.md": "02d2757bd263a3e7e06dd6af0da9d58dff37202941f84b6fc097c083a8186146",
|
|
234
|
+
"spec/v2/core/runs.md": "05fba9b83bbfcca4bc531f9623bff7bf79d5f587170ed5e8b26cd536a950b2eb",
|
|
235
235
|
"spec/v2/core/security-defaults.md": "2a54ceaa4cef02c26ee0fbcf44f822ceeea79537c7d5a51ed300f21aa4fea576",
|
|
236
236
|
"spec/v2/core/versioning.md": "56c1e273c790bcb0a2c3c760ffc7595afc9f5d4a745965e57f36aefcd456da76",
|
|
237
|
-
"spec/v2/core/webhooks.md": "
|
|
237
|
+
"spec/v2/core/webhooks.md": "dca6ec657a7489d6469c2acb20f656e0bf9542dfc9144520f5441d83575c8025",
|
|
238
238
|
"spec/v2/core/workflow-chain-packs.md": "df78b76dd79f2267fb6b72c08e0911e5f9d5bb1925c6efdd6deda62fe8a51dc8",
|
|
239
|
-
"spec/v2/declaration.json": "
|
|
239
|
+
"spec/v2/declaration.json": "3a5960e97782d64ace047e5a1edd4ae5a7b956bdc94ff7bcfd9f4c270740340c",
|
|
240
240
|
"spec/v2/declaration.schema.json": "c86e3083fb9789c1958ecb4576cc6f71aa90e6f339521139238543ed08211dd4",
|
|
241
241
|
"spec/v2/errors.json": "296117235d21ce4a393371c2f80caf03a4ae93d1f80a89f87cd3f56902cf5b01",
|
|
242
242
|
"spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
|
|
@@ -265,7 +265,7 @@
|
|
|
265
265
|
"spec/v2/facets/auth.schema.json": "73440c944ad12c41aab0618aeb799a90a0b59e97fc685527f248afb7d015381a",
|
|
266
266
|
"spec/v2/facets/envelopeStrictness.schema.json": "28be0a27f880bd45ec1222a16155f5e9167bbca57bf0322970fe1eb2654562aa",
|
|
267
267
|
"spec/v2/facets/heartbeat.schema.json": "81d7fa1895a8b0241bd07cf00a7e7905bef0fd14eb3427325261c62eee5746a6",
|
|
268
|
-
"spec/v2/facets/interrupt.schema.json": "
|
|
268
|
+
"spec/v2/facets/interrupt.schema.json": "3cc228b9bee48922b22bd6b0bee1a3244dc83d377a616cfb523435368da11ebb",
|
|
269
269
|
"spec/v2/facets/mcp.schema.json": "f56609fe24ba6160ad29ae31aa16043354ff005e5cb6a0f3871c4d226087cc08",
|
|
270
270
|
"spec/v2/facets/packs.schema.json": "d544a280419c0052ce0f3c3b378f7aa04085cadc31df52a3c8f43da600b131b1",
|
|
271
271
|
"spec/v2/facets/replay.schema.json": "0a5aefb0108c708c6794fadd533cf256709704c4f16dbf44cd63fa8980c78018",
|
|
@@ -279,8 +279,8 @@
|
|
|
279
279
|
"spec/v2/path-manifest.json": "c123c9fd77dc1b3f9c2e9346ace80cdae007e6511138894956abda01e61ec1c6",
|
|
280
280
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
281
281
|
"spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
|
|
282
|
-
"spec/v2/release.json": "
|
|
282
|
+
"spec/v2/release.json": "5b9a956506b011835f1bd2fec54064d9cc8657d8a45a371314850da377d69069",
|
|
283
283
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
284
284
|
},
|
|
285
|
-
"corpusCommit": "
|
|
285
|
+
"corpusCommit": "a7ff2254a6ed9cbd47e327a95eb280e4aaf23699"
|
|
286
286
|
}
|
package/src/cli.ts
CHANGED
|
@@ -724,7 +724,7 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
724
724
|
discovery: { url: discoveryUrl, sha256, protocolVersions, preferredVersion, document },
|
|
725
725
|
claimedProfiles: claimed3,
|
|
726
726
|
results: { totals: totals3, requirements: rows3 },
|
|
727
|
-
witnessSha256: witnessDigest(rows3),
|
|
727
|
+
witnessSha256: witnessDigest(rows3, relaxations),
|
|
728
728
|
assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
|
|
729
729
|
...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
|
|
730
730
|
// RFC 0158 §D: claimed ONLY when these rows support it. The verifier
|
|
@@ -959,6 +959,8 @@ async function main(): Promise<never> {
|
|
|
959
959
|
`certified: ${verdict.certifiedProfiles.length > 0 ? verdict.certifiedProfiles.join(', ') : '(none)'}`,
|
|
960
960
|
// RFC 0158 §D/§E: the rung is a CLAIM; a claim the signed rows do not support is a rejection below.
|
|
961
961
|
`rung: ${bundle.durability?.rung ?? '(none claimed)'}`,
|
|
962
|
+
// 2.35.0: derived from the signed `skipped` rows; checked against the signed discovery document.
|
|
963
|
+
`opted out: ${verdict.optedOut.length > 0 ? verdict.optedOut.join(', ') : '(none)'}`,
|
|
962
964
|
'',
|
|
963
965
|
'What this command does NOT do:',
|
|
964
966
|
' · It does not re-run anything. A host that measured itself wrongly, and signed',
|
|
@@ -87,12 +87,26 @@ export function canonicalJSON(value: unknown): string {
|
|
|
87
87
|
return JSON.stringify(value);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
90
|
+
/**
|
|
91
|
+
* RFC 0148 §C — the digest over the reporter record (the requirement rows),
|
|
92
|
+
* and, from 2.35.0, the operator's declared relaxations when there are any.
|
|
93
|
+
*
|
|
94
|
+
* Why relaxations are in here: the attestation covers exactly
|
|
95
|
+
* `{ witnessSha256, host.build, suite.version, discovery.sha256 }`, so a
|
|
96
|
+
* `host.relaxations[]` beside the rows was UNSIGNED — strip it after signing
|
|
97
|
+
* and the verifier re-derives the relaxed profile as certified, on a bundle
|
|
98
|
+
* that still verifies. Included ONLY WHEN NON-EMPTY: the preimage is the rows
|
|
99
|
+
* array exactly as before for every bundle that declares nothing (all
|
|
100
|
+
* committed bundles digest unchanged, pinned in durability-evidence.test.ts),
|
|
101
|
+
* and `{ rows, relaxations }` otherwise. An older verifier fails closed on a
|
|
102
|
+
* 2.35.0 bundle that declares relaxations — `witness-digest`, never a pass.
|
|
103
|
+
*/
|
|
104
|
+
export function witnessDigest(rows: readonly BundleV3Requirement[], relaxations?: readonly BundleV3Relaxation[]): string {
|
|
92
105
|
const canonicalRows = [...rows].sort((a, b) => a.id.localeCompare(b.id)).map((r) => ({ id: r.id, scenario: r.scenario, result: r.result, ...(r.assertions === undefined ? {} : { assertions: r.assertions }), ...(r.detail === undefined ? {} : { detail: r.detail }),
|
|
93
106
|
// ONLY WHEN PRESENT: every bundle cut before 2.34.0 has no `evidence` and digests byte-identically.
|
|
94
107
|
...(r.evidence === undefined ? {} : { evidence: r.evidence }) }));
|
|
95
|
-
|
|
108
|
+
const preimage = relaxations !== undefined && relaxations.length > 0 ? { rows: canonicalRows, relaxations } : canonicalRows;
|
|
109
|
+
return createHash('sha256').update(canonicalJSON(preimage), 'utf8').digest('hex');
|
|
96
110
|
}
|
|
97
111
|
|
|
98
112
|
/** The bytes the attestation covers. */
|
|
@@ -138,6 +152,22 @@ export interface V3Verdict {
|
|
|
138
152
|
* checked. Read it before quoting a verdict.
|
|
139
153
|
*/
|
|
140
154
|
readonly derivabilityChecked: boolean;
|
|
155
|
+
/**
|
|
156
|
+
* 2.35.0 — the profiles and families the operator opted out of, derived from
|
|
157
|
+
* the SIGNED `skipped` rows `behaviorGate` writes (`openwop.profile.<name>`,
|
|
158
|
+
* where a family is `family.<key>`). Derived rather than read from a root
|
|
159
|
+
* member because a root member would sit outside the attestation.
|
|
160
|
+
*/
|
|
161
|
+
readonly optedOut: string[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const OPT_OUT_ROW = 'openwop.profile.';
|
|
165
|
+
/** The opt-outs a bundle's signed rows record. */
|
|
166
|
+
export function optedOutFromRows(rows: ReadonlyArray<{ readonly id: string; readonly result: string; readonly detail?: string }>): string[] {
|
|
167
|
+
return rows
|
|
168
|
+
.filter((r) => r.id.startsWith(OPT_OUT_ROW) && r.result === 'skipped' && (r.detail ?? '').includes('OPENWOP_OPTED_OUT_PROFILES'))
|
|
169
|
+
.map((r) => r.id.slice(OPT_OUT_ROW.length))
|
|
170
|
+
.sort();
|
|
141
171
|
}
|
|
142
172
|
|
|
143
173
|
export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3Verdict {
|
|
@@ -154,8 +184,8 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
|
|
|
154
184
|
const count = (d: BundleV3Result): number => rows.filter((r) => r.result === d).length;
|
|
155
185
|
const expected = { executedPass: count('executed-pass'), executedFail: count('executed-fail'), skipped: count('skipped'), inapplicable: count('inapplicable'), blocked: count('blocked') };
|
|
156
186
|
for (const k of Object.keys(expected) as (keyof typeof expected)[]) if (bundle.results?.totals?.[k] !== expected[k]) rejections.push({ kind: 'totals-mismatch', detail: `totals.${k} is ${String(bundle.results?.totals?.[k])} but the rows count ${expected[k]}` });
|
|
157
|
-
const digest = witnessDigest(rows);
|
|
158
|
-
if (bundle.witnessSha256 !== digest) rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows (${digest.slice(0, 12)})` });
|
|
187
|
+
const digest = witnessDigest(rows, bundle.host?.relaxations);
|
|
188
|
+
if (bundle.witnessSha256 !== digest) rejections.push({ kind: 'witness-digest', detail: `witnessSha256 ${String(bundle.witnessSha256).slice(0, 12)} does not equal the digest of the rows and declared relaxations (${digest.slice(0, 12)})` });
|
|
159
189
|
const assertions = rows.reduce((n, r) => n + (r.assertions ?? 0), 0);
|
|
160
190
|
if (bundle.assertionCount !== assertions) rejections.push({ kind: 'assertion-count', detail: `assertionCount is ${String(bundle.assertionCount)} but the rows sum to ${assertions}` });
|
|
161
191
|
const nonPass = rows.filter((r) => r.result !== 'executed-pass');
|
|
@@ -243,6 +273,20 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
|
|
|
243
273
|
derivabilityChecked = false;
|
|
244
274
|
} else {
|
|
245
275
|
derivabilityChecked = true;
|
|
276
|
+
// RFC 0148 §B: "A host MUST NOT both advertise and opt out of the same
|
|
277
|
+
// profile." The suite refuses that at run time (behaviorGate throws),
|
|
278
|
+
// so a bundle that records both was edited after the run or emitted by
|
|
279
|
+
// something else. Both inputs are signed — the rows through
|
|
280
|
+
// witnessSha256, the document through discovery.sha256 — so the
|
|
281
|
+
// contradiction is checkable offline, and it is bundle-wide: an opt-out
|
|
282
|
+
// list that is false makes every `skipped` row in the bundle suspect.
|
|
283
|
+
const root = document as unknown as Record<string, unknown>;
|
|
284
|
+
for (const name of optedOutFromRows(rows)) {
|
|
285
|
+
const advertised = name.startsWith('family.')
|
|
286
|
+
? (() => { const v = root[name.slice('family.'.length)]; return typeof v === 'object' && v !== null && !Array.isArray(v); })()
|
|
287
|
+
: profileDerivable(document as DiscoveryPayload, name, targetMajor);
|
|
288
|
+
if (advertised) rejections.push({ kind: 'opted-out-but-advertised', detail: `${name} is recorded as an operator opt-out, but the captured discovery document advertises it — a host MUST NOT both advertise and opt out of the same profile (RFC 0148 §B)` });
|
|
289
|
+
}
|
|
246
290
|
for (const p of bundle.claimedProfiles ?? []) {
|
|
247
291
|
// §B(1) binds the CERTIFICATION, not the listing: a profile a bundle
|
|
248
292
|
// names without certifying makes no claim for derivability to falsify.
|
|
@@ -268,5 +312,6 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
|
|
|
268
312
|
verifierSignatureVerified,
|
|
269
313
|
certifiedProfiles: bundleWide.length > 0 ? [] : certifiedProfiles.filter((p) => !scoped.has(p)),
|
|
270
314
|
derivabilityChecked,
|
|
315
|
+
optedOut: optedOutFromRows(rows),
|
|
271
316
|
};
|
|
272
317
|
}
|
|
@@ -104,12 +104,26 @@ export function resolveItRecord(
|
|
|
104
104
|
gate: { disposition: 'inapplicable' | 'skipped'; detail?: string } | undefined,
|
|
105
105
|
noted: { kind: 'inapplicable' | 'skipped' | 'blocked'; reason: string; conclusive?: true } | null,
|
|
106
106
|
firstError?: string,
|
|
107
|
+
/**
|
|
108
|
+
* 2.35.0, major 2 (`conformance.md` §Bundle v3): a requirement the test did
|
|
109
|
+
* not observe records `blocked` even when the test asserted setup facts
|
|
110
|
+
* first. At major 1 a `blocked` note after assertions stays a
|
|
111
|
+
* `partial-witness` pass unless the leg used `blockedDespiteAssertions`.
|
|
112
|
+
*
|
|
113
|
+
* Why the rule changed: a `partial-witness` row is refused by the acceptance
|
|
114
|
+
* predicate but COUNTED by certification, so a profile could certify on a
|
|
115
|
+
* requirement nobody observed. Swept before the change: all 24 major-2
|
|
116
|
+
* sites were "requirement unobserved" (unreachable, control silent, window
|
|
117
|
+
* closed), none an optional extra. Scoped to major 2 because the 146 v1-side
|
|
118
|
+
* sites were not measured and v1 bundles are read through its EOS.
|
|
119
|
+
*/
|
|
120
|
+
blockedStands = false,
|
|
107
121
|
): { disposition: Disposition; detail?: string } {
|
|
108
122
|
if (state === 'fail') return { disposition: 'executed-fail', detail: `the test executed and failed: ${(firstError ?? 'no message').slice(0, 300)}` };
|
|
109
123
|
if (state === 'pass' && assertionCalls > 0) {
|
|
110
124
|
// `blockedDespiteAssertions` (soft-skip.ts): the leg says its setup
|
|
111
125
|
// assertions are not the requirement, and the requirement went unobserved.
|
|
112
|
-
if (noted !== null && noted.kind === 'blocked' && noted.conclusive === true) return { disposition: 'blocked', detail: noted.reason };
|
|
126
|
+
if (noted !== null && noted.kind === 'blocked' && (noted.conclusive === true || blockedStands)) return { disposition: 'blocked', detail: noted.reason };
|
|
113
127
|
// A leg that asserted AND THEN soft-skipped is only a partial witness, and
|
|
114
128
|
// the file-level record has always said so (`resolveFileRecord` below).
|
|
115
129
|
// This `it`-level record dropped the note — and the `it`-level rows are the
|
|
@@ -148,12 +162,12 @@ export function fileDisposition(
|
|
|
148
162
|
return { disposition: 'inapplicable', detail: 'every test returned early through behaviorGate with zero assertions: profile not advertised in the captured discovery set' };
|
|
149
163
|
}
|
|
150
164
|
if (assertionCount === 0 && gateReason === 'skipped') {
|
|
151
|
-
return { disposition: 'skipped', detail: 'every test returned early through behaviorGate with zero assertions: operator opted the profile out (OPENWOP_OPTED_OUT_PROFILES)' };
|
|
165
|
+
return { disposition: 'skipped', detail: 'every test returned early through behaviorGate with zero assertions: operator opted the profile or family out (OPENWOP_OPTED_OUT_PROFILES)' };
|
|
152
166
|
}
|
|
153
167
|
return { disposition: 'executed-pass' };
|
|
154
168
|
}
|
|
155
169
|
if (gateReason === 'inapplicable') return { disposition: 'inapplicable', detail: 'every test skipped: profile not advertised in the captured discovery set (behaviorGate)' };
|
|
156
|
-
if (gateReason === 'skipped') return { disposition: 'skipped', detail: 'every test skipped: operator opted the profile out (OPENWOP_OPTED_OUT_PROFILES)' };
|
|
170
|
+
if (gateReason === 'skipped') return { disposition: 'skipped', detail: 'every test skipped: operator opted the profile or family out (OPENWOP_OPTED_OUT_PROFILES)' };
|
|
157
171
|
return {
|
|
158
172
|
disposition: 'blocked',
|
|
159
173
|
detail:
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0194 §A — the shape a run's log must have around its terminal event.
|
|
3
|
+
*
|
|
4
|
+
* Exactly one terminal run event, and after it no forward execution: no second
|
|
5
|
+
* terminal event, no run.started / run.resumed / run.resume-started /
|
|
6
|
+
* run.paused / run.restored-from-snapshot, no node.* and no interrupt.*.
|
|
7
|
+
* `compensation.*` and `run.dead-lettered` MAY follow (a compensating host
|
|
8
|
+
* unwinds after a cancelled parent — compensation.md §"Cancellation of the
|
|
9
|
+
* parent"); vendor-prefixed types are not constrained.
|
|
10
|
+
*
|
|
11
|
+
* Pure, so a scenario can add a leg under RFC 0194's ids cheaply, and so the
|
|
12
|
+
* rule is pinned without a host.
|
|
13
|
+
*/
|
|
14
|
+
export const TERMINAL_RUN_EVENTS: ReadonlySet<string> = new Set(['run.completed', 'run.failed', 'run.cancelled']);
|
|
15
|
+
const FORWARD_RUN_EVENTS: ReadonlySet<string> = new Set(['run.started', 'run.resumed', 'run.resume-started', 'run.paused', 'run.restored-from-snapshot']);
|
|
16
|
+
|
|
17
|
+
/** The first violation of §A.1–§A.2 in `types` (log order), or null when the shape holds. */
|
|
18
|
+
export function terminalShapeViolation(types: readonly string[]): string | null {
|
|
19
|
+
const terminals = types.map((t, i) => [t, i] as const).filter(([t]) => TERMINAL_RUN_EVENTS.has(t));
|
|
20
|
+
if (terminals.length === 0) return 'the log carries no terminal run event (run.completed / run.failed / run.cancelled)';
|
|
21
|
+
if (terminals.length > 1) return `the log carries ${terminals.length} terminal run events (${terminals.map(([t, i]) => `${t}@${i}`).join(', ')}) — exactly one is allowed`;
|
|
22
|
+
const at = terminals[0]![1];
|
|
23
|
+
for (let i = at + 1; i < types.length; i++) {
|
|
24
|
+
const t = types[i]!;
|
|
25
|
+
if (FORWARD_RUN_EVENTS.has(t) || t.startsWith('node.') || t.startsWith('interrupt.')) {
|
|
26
|
+
return `${t} at index ${i} follows the terminal event ${types[at]} at index ${at} — only compensation.* and run.dead-lettered may`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
@@ -27,17 +27,45 @@
|
|
|
27
27
|
* @see RFCS/0187-host-found-bindings.md §A
|
|
28
28
|
*/
|
|
29
29
|
import { describe, it, expect } from 'vitest';
|
|
30
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
30
31
|
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
31
32
|
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
32
33
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
33
34
|
import { softSkip } from '../lib/soft-skip.js';
|
|
34
|
-
import { resolveRegistrationUrl } from '../lib/webhook-receiver.js';
|
|
35
|
+
import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
|
|
36
|
+
import { scaledTimeoutMs } from '../lib/polling.js';
|
|
35
37
|
import { req } from '../lib/requirement-ids.js';
|
|
36
38
|
import { projectBoundId, BOUND_ID } from '../lib/bound-id.js';
|
|
37
39
|
|
|
38
40
|
const DOC = 'spec/v2/core/identity.md §5 (RFC 0187 §A)';
|
|
39
41
|
const WEBHOOK_ID = 'openwop.requirement.0187.bound-id-kinds.webhook';
|
|
40
42
|
const PER_KIND = 'openwop.requirement.0187.bound-id-kinds.per-kind';
|
|
43
|
+
/**
|
|
44
|
+
* 2.35.0 (openwop#1450). The mint leg above reads the id a host RETURNS; a
|
|
45
|
+
* subscriber identifies its deliveries by the id the host EMITS in the
|
|
46
|
+
* delivery headers. A tier-2 host bound the first and left the second bare,
|
|
47
|
+
* and this file stayed green — the defect surfaced only as a durability
|
|
48
|
+
* failure in another file, whose delivery filter keys on the header.
|
|
49
|
+
*/
|
|
50
|
+
const WEBHOOK_EMITTED = 'openwop.requirement.0187.bound-id-kinds.webhook-emitted';
|
|
51
|
+
|
|
52
|
+
type Delivery = { body: string; headers: Record<string, string | string[] | undefined> };
|
|
53
|
+
/** The suite's receiver, bound the way every webhook scenario binds it (pinned port / public front honoured). */
|
|
54
|
+
async function startReceiver(): Promise<{ server: Server; url: string; deliveries: Delivery[] }> {
|
|
55
|
+
const deliveries: Delivery[] = [];
|
|
56
|
+
const server = createServer((request: IncomingMessage, res: ServerResponse) => {
|
|
57
|
+
const chunks: Buffer[] = [];
|
|
58
|
+
request.on('data', (c: Buffer) => chunks.push(c));
|
|
59
|
+
request.on('end', () => { deliveries.push({ body: Buffer.concat(chunks).toString('utf8'), headers: request.headers }); res.writeHead(204); res.end(); });
|
|
60
|
+
});
|
|
61
|
+
const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
|
|
62
|
+
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
63
|
+
const binding = receiverBinding();
|
|
64
|
+
await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
|
|
65
|
+
const addr = server.address();
|
|
66
|
+
return { server, url: `http://${binding.advertise}:${typeof addr === 'object' && addr ? addr.port : 0}/hook`, deliveries };
|
|
67
|
+
}
|
|
68
|
+
const header = (d: Delivery, name: string): string | undefined => { const v = d.headers[name]; return Array.isArray(v) ? v[0] : v; };
|
|
41
69
|
|
|
42
70
|
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
43
71
|
try { return await fn(); } catch { return null; }
|
|
@@ -74,6 +102,11 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
|
|
|
74
102
|
expect(reg.status, req(WEBHOOK_ID, 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
|
|
75
103
|
const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
|
|
76
104
|
expectBound(webhookId, 'webhookId', 'POST /webhooks 201', WEBHOOK_ID);
|
|
105
|
+
// Never leave the subscription behind, whatever happens below (a leaked
|
|
106
|
+
// subscription is delivered to for the rest of the run and skews every
|
|
107
|
+
// later webhook file's attribution).
|
|
108
|
+
let deleted = false;
|
|
109
|
+
try {
|
|
77
110
|
if (typeof webhookId !== 'string' || !BOUND_ID.test(webhookId)) return softSkip('blocked', `the mint answered ${JSON.stringify(webhookId)}, which is not a tenant-bound id — the projection and 403 legs have no bound id to drive (the grammar failure is recorded by the assertion above)`);
|
|
78
111
|
|
|
79
112
|
// The 403 check §5 exists for: a bound id whose tenant is not the caller's.
|
|
@@ -86,10 +119,61 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
|
|
|
86
119
|
|
|
87
120
|
// The accept side: the RFC 0184 projection on a tenant-bound parameter that is not runId.
|
|
88
121
|
const del = await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId)}`));
|
|
122
|
+
deleted = del?.status === 204;
|
|
89
123
|
expect(
|
|
90
124
|
del?.status ?? null,
|
|
91
125
|
req(WEBHOOK_ID, DOC, `DELETE /webhooks/{webhookId} MUST accept the ~-projected spelling of the id it minted (RFC 0184 §A: every tenant-bound path parameter) — got ${del?.status ?? 'no response'} ${readErrorCode(del?.json) ?? ''}`.trim()),
|
|
92
126
|
).toBe(204);
|
|
127
|
+
} finally {
|
|
128
|
+
if (!deleted && typeof webhookId === 'string') await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId)}`));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('subscriptionId: the webhookId a host EMITS on a delivery is the one it minted', async () => {
|
|
133
|
+
const doc = await v2Discovery();
|
|
134
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
135
|
+
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised — nothing is delivered (gate recorded under openwop.family.webhooks)');
|
|
136
|
+
const rx = await startReceiver();
|
|
137
|
+
let webhookId: string | null = null;
|
|
138
|
+
try {
|
|
139
|
+
const registration = resolveRegistrationUrl(rx.url);
|
|
140
|
+
const reg = await http(() => driver.post('/webhooks', { url: registration.url, events: ['run.completed'] }));
|
|
141
|
+
if (reg === null) return softSkip('blocked', 'POST /webhooks unreachable (fetch failed)');
|
|
142
|
+
if (reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected') {
|
|
143
|
+
return softSkip('blocked', `host SSRF guard rejected the suite receiver ${registration.url} (webhooks.md §SSRF requires it) — set OPENWOP_WEBHOOK_RECEIVER_URL to a public https front for the receiver to witness what the host emits`);
|
|
144
|
+
}
|
|
145
|
+
const minted = (reg.json as { webhookId?: unknown } | null)?.webhookId;
|
|
146
|
+
if (reg.status !== 201 || typeof minted !== 'string') return softSkip('blocked', `POST /webhooks answered ${reg.status} without a webhookId — the mint leg above owns that obligation; this leg needs an id to compare`);
|
|
147
|
+
webhookId = minted;
|
|
148
|
+
const create = await http(() => driver.post('/runs', { workflowId: 'conformance-noop' }));
|
|
149
|
+
const runId = (create?.json as { runId?: unknown } | null)?.runId;
|
|
150
|
+
if (create === null || create.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs answered ${create?.status ?? 'no response'} — no run, so no delivery to read`);
|
|
151
|
+
// Select the delivery by the RUN in the body, never by the header under
|
|
152
|
+
// test: filtering on the header is exactly how a bare header hid in the
|
|
153
|
+
// durability scenario (openwop#1450).
|
|
154
|
+
const ours = (): Delivery[] => rx.deliveries.filter((d) => { try { return (JSON.parse(d.body) as { runId?: unknown }).runId === runId; } catch { return false; } });
|
|
155
|
+
const deadline = Date.now() + scaledTimeoutMs(20_000);
|
|
156
|
+
while (ours().length === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 250));
|
|
157
|
+
const delivery = ours()[0];
|
|
158
|
+
if (delivery === undefined) return softSkip('blocked', `no delivery for run ${runId} reached the suite receiver within ${scaledTimeoutMs(20_000)}ms — what the host emits was not observed`);
|
|
159
|
+
expect(
|
|
160
|
+
header(delivery, 'openwop-webhook-id'),
|
|
161
|
+
req(WEBHOOK_EMITTED, 'webhooks.md §Headers (RFC 0187 §A.1)', `the delivery's OpenWOP-Webhook-Id MUST equal the tenant-bound webhookId the host minted (${minted}) — a subscriber identifies its deliveries by the header, not by the 201 it received once`),
|
|
162
|
+
).toBe(minted);
|
|
163
|
+
// The X-openwop-* family is required only of a host advertising BOTH
|
|
164
|
+
// majors (webhooks.md: dual emission through the overlap). A v2-only host
|
|
165
|
+
// sends none, and failing it for that would be the suite's error.
|
|
166
|
+
const both = Array.isArray(doc.protocolVersions) && doc.protocolVersions.some((v) => typeof v === 'string' && v.startsWith('1.'));
|
|
167
|
+
if (both) {
|
|
168
|
+
expect(
|
|
169
|
+
header(delivery, 'x-openwop-webhook-id'),
|
|
170
|
+
req(WEBHOOK_EMITTED, 'webhooks.md §Headers (dual emission, RFC 0176 §D.2)', `a host advertising both majors MUST send X-openwop-Webhook-Id with the same value as OpenWOP-Webhook-Id (${minted})`),
|
|
171
|
+
).toBe(minted);
|
|
172
|
+
}
|
|
173
|
+
} finally {
|
|
174
|
+
if (webhookId !== null) await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId as string)}`));
|
|
175
|
+
await new Promise<void>((ok) => rx.server.close(() => ok()));
|
|
176
|
+
}
|
|
93
177
|
});
|
|
94
178
|
|
|
95
179
|
it('interruptId: the id a suspension puts on the wire is bound', async () => {
|
|
@@ -140,11 +224,17 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
|
|
|
140
224
|
if (reg === null || reg.status !== 201) return softSkip('blocked', `POST /webhooks answered ${reg?.status ?? 'no response'} — no subscription to read a sink for`);
|
|
141
225
|
const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
|
|
142
226
|
if (typeof webhookId !== 'string') return softSkip('blocked', 'the mint returned no webhookId');
|
|
227
|
+
// This leg only READS the sink; it must not leave its subscription behind
|
|
228
|
+
// (measured: it left one on every run until 2.35.0).
|
|
229
|
+
try {
|
|
143
230
|
const res = await http(() => driver.get(`/webhooks/${projectBoundId(webhookId)}/dead-letters`));
|
|
144
231
|
if (res === null) return softSkip('blocked', 'GET /webhooks/{webhookId}/dead-letters unreachable (fetch failed)');
|
|
145
232
|
expect(res.status, req(PER_KIND, 'RFC 0188 §A.1', 'a host advertising webhooks.deadLetter MUST serve the dead-letter read (200)')).toBe(200);
|
|
146
233
|
const rows = ((res.json as { deliveries?: Array<Record<string, unknown>> } | null)?.deliveries ?? []);
|
|
147
234
|
if (rows.length === 0) return softSkip('inapplicable', 'the subscription has no dead-lettered delivery in this run — the read is served and the shape is unwitnessed here; v2-webhook-durable-delivery drives an exhaustion and asserts the record');
|
|
148
235
|
for (const r of rows) expectBound(r['deliveryId'], 'deliveryId', 'GET /webhooks/{webhookId}/dead-letters', PER_KIND);
|
|
236
|
+
} finally {
|
|
237
|
+
await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId)}`));
|
|
238
|
+
}
|
|
149
239
|
});
|
|
150
240
|
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0196 §A.3 — a host that advertises `interrupt.callbackDelivery` holds
|
|
3
|
+
* `createRun.callbackUrl` to the `webhooks.md` §SSRF guard at create time
|
|
4
|
+
* (`spec/v2/core/interrupt.md` §Callback delivery). Target major 2.
|
|
5
|
+
*
|
|
6
|
+
* `callbackUrl` is an outbound request to a URL the CALLER chooses — the
|
|
7
|
+
* surface the SSRF guard exists for. Measured on three hosts before the RFC:
|
|
8
|
+
* one ignored it, one refused it, one delivered to it with no guard at all.
|
|
9
|
+
*
|
|
10
|
+
* Gated on the facet: a host that does not advertise delivery has no egress
|
|
11
|
+
* here to guard (it SHOULD refuse the member, which this file does not assert).
|
|
12
|
+
* No committed host advertises the facet today, so this row is witnessed on
|
|
13
|
+
* none; it is written so the first host that does is held to it.
|
|
14
|
+
*
|
|
15
|
+
* @see spec/v2/core/interrupt.md §Callback delivery
|
|
16
|
+
* @see RFCS/0196-callbackurl-and-embedded-ipv4-egress.md §A.3
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, it } from 'vitest';
|
|
19
|
+
import { driver } from '../lib/driver.js';
|
|
20
|
+
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
21
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
22
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
23
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
24
|
+
import { req } from '../lib/requirement-ids.js';
|
|
25
|
+
|
|
26
|
+
const ID = 'openwop.requirement.0196.callback-url-guarded';
|
|
27
|
+
const DOC = 'spec/v2/core/interrupt.md §Callback delivery (RFC 0196 §A.3)';
|
|
28
|
+
const FIXTURE = 'conformance-approval';
|
|
29
|
+
const PROBES = [
|
|
30
|
+
{ url: 'https://127.0.0.1/openwop-callback-probe', cls: 'loopback (IPv4)' },
|
|
31
|
+
{ url: 'https://169.254.169.254/latest/meta-data/', cls: 'cloud metadata' },
|
|
32
|
+
{ url: 'https://[::ffff:7f00:1]/openwop-callback-probe', cls: 'loopback as IPv4-mapped IPv6 (hex)' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
describe('v2 callbackUrl egress guard (RFC 0196 §A.3)', () => {
|
|
36
|
+
it('a host advertising callback delivery refuses a callbackUrl its egress guard would refuse', async () => {
|
|
37
|
+
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
38
|
+
const fam = await gateFamily('interrupt');
|
|
39
|
+
if (!fam) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
40
|
+
if (fam['callbackDelivery'] !== true) return softSkip('inapplicable', 'the host does not advertise interrupt.callbackDelivery — it delivers nothing to callbackUrl, so there is no egress to guard (RFC 0196 §A.2: it SHOULD refuse the member)');
|
|
41
|
+
const workflowId = isFixtureAdvertised(FIXTURE) ? FIXTURE : 'conformance-noop';
|
|
42
|
+
const accepted: string[] = [];
|
|
43
|
+
const wrong: string[] = [];
|
|
44
|
+
for (const p of PROBES) {
|
|
45
|
+
const r = await driver.post('/runs', { workflowId, callbackUrl: p.url });
|
|
46
|
+
if (r.status === 201) { accepted.push(`${p.cls} (${p.url})`); const id = (r.json as { runId?: unknown } | null)?.runId; if (typeof id === 'string') await driver.post(`/runs/${encodeURIComponent(id)}/cancel`, {}).catch(() => undefined); continue; }
|
|
47
|
+
const field = (r.json as { details?: { field?: unknown } } | null)?.details?.field;
|
|
48
|
+
if (r.status !== 400 || readErrorCode(r.json) !== 'validation_error' || field !== 'callbackUrl') wrong.push(`${p.cls}: ${r.status} ${readErrorCode(r.json) ?? ''} field=${String(field)}`);
|
|
49
|
+
}
|
|
50
|
+
expect(accepted, req(ID, DOC, 'a host advertising interrupt.callbackDelivery MUST refuse at createRun a callbackUrl the webhooks.md §SSRF registration guard would refuse')).toEqual([]);
|
|
51
|
+
expect(wrong, req(ID, DOC, 'the refusal MUST be 400 validation_error naming details.field "callbackUrl"')).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -58,7 +58,7 @@ function bundle(opts: { relaxations?: BundleV3Relaxation[]; certified: boolean;
|
|
|
58
58
|
{ id: 'openwop-discovery-core', evidenceTier: 'self', witnessCount: 1, certified: true },
|
|
59
59
|
],
|
|
60
60
|
results: { totals: { executedPass: ROWS.length, executedFail: 0, skipped: 0, inapplicable: 0, blocked: 0 }, requirements: ROWS },
|
|
61
|
-
witnessSha256: witnessDigest(ROWS),
|
|
61
|
+
witnessSha256: witnessDigest(ROWS, opts.relaxations),
|
|
62
62
|
assertionCount: ROWS.reduce((n, r) => n + (r.assertions ?? 0), 0),
|
|
63
63
|
};
|
|
64
64
|
return { ...unsigned, signature: signBundleV3(unsigned, hostPem, 'host-key-1') };
|
|
@@ -105,6 +105,18 @@ describe('RFC 0173 §A.2 — relaxation-recorded (unaided, fixture bundle)', ()
|
|
|
105
105
|
honest.rejections.map((r) => r.kind),
|
|
106
106
|
req('openwop.requirement.0173.relaxation-recorded', 'security-defaults.md §Relaxations', 'a relaxation recorded against an unclaimed profile is not a rejection — recording is the obligation, claiming is the defect'),
|
|
107
107
|
).not.toContain('relaxed-profile-certified');
|
|
108
|
+
|
|
109
|
+
// 2.35.0: the declaration is SIGNED. `host.relaxations[]` sits outside the
|
|
110
|
+
// attestation, so until the witness digest covered it a relaxation could be
|
|
111
|
+
// deleted after signing and the relaxed profile re-derived as certified on a
|
|
112
|
+
// bundle that still verified. Strip it from the honest bundle and re-verify.
|
|
113
|
+
const signed = bundle({ relaxations: [RELAXED], certified: false });
|
|
114
|
+
const stripped = { ...signed, host: { ...signed.host } };
|
|
115
|
+
delete (stripped.host as { relaxations?: unknown }).relaxations;
|
|
116
|
+
expect(
|
|
117
|
+
verifyBundleV3(stripped).rejections.map((r) => r.kind),
|
|
118
|
+
req('openwop.requirement.0173.relaxation-recorded', 'conformance.md §Bundle v3', 'a relaxation removed after signing MUST be detected — the declaration is covered by witnessSha256, so the stripped bundle fails `witness-digest`'),
|
|
119
|
+
).toContain('witness-digest');
|
|
108
120
|
});
|
|
109
121
|
|
|
110
122
|
// Until 2.33.0 every leg above used the FIXTURE profile `openwop-webhooks`, and
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0194 §A — a run's terminal event is emitted once and ends its forward
|
|
3
|
+
* execution (`spec/v2/core/events.md` §The terminal event). Target major 2.
|
|
4
|
+
*
|
|
5
|
+
* Found by two independently written hosts under duplicate delivery: one wrote
|
|
6
|
+
* `run.completed` then `node.failed`, the other two `run.completed` events
|
|
7
|
+
* (openwop#1445). Nothing in v2 said either was wrong.
|
|
8
|
+
*
|
|
9
|
+
* A DEDICATED file rather than a check bolted onto every scenario that reaches
|
|
10
|
+
* a terminal state: a host that violates this RFC must fail this RFC's rows,
|
|
11
|
+
* not unrelated ones (openwop#1450 showed what misattribution costs).
|
|
12
|
+
*
|
|
13
|
+
* The log is read after it SETTLES — two reads at least 2 s apart returning the
|
|
14
|
+
* same length, bounded at 20 s — because the second delivery's executor can
|
|
15
|
+
* finish after the first terminal event. Growth after a terminal event is the
|
|
16
|
+
* violation's own evidence, not an inconclusive read.
|
|
17
|
+
*
|
|
18
|
+
* @see spec/v2/core/events.md §The terminal event
|
|
19
|
+
* @see RFCS/0194-terminal-event-ends-forward-execution.md
|
|
20
|
+
*/
|
|
21
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
22
|
+
import { describe, expect, it } from 'vitest';
|
|
23
|
+
import { driver } from '../lib/driver.js';
|
|
24
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
25
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
26
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
27
|
+
import { scaledTimeoutMs } from '../lib/polling.js';
|
|
28
|
+
import { req } from '../lib/requirement-ids.js';
|
|
29
|
+
import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
|
|
30
|
+
import { terminalShapeViolation, TERMINAL_RUN_EVENTS } from '../lib/terminal-shape.js';
|
|
31
|
+
|
|
32
|
+
const ONCE = 'openwop.requirement.0194.terminal-once';
|
|
33
|
+
const DUP = 'openwop.requirement.0194.terminal-once.duplicate-delivery';
|
|
34
|
+
const DOC = 'spec/v2/core/events.md §The terminal event (RFC 0194 §A)';
|
|
35
|
+
const TERMINAL_STATUS = new Set(['completed', 'failed', 'cancelled']);
|
|
36
|
+
const KILL_SEAM = '/host/durability/kill';
|
|
37
|
+
|
|
38
|
+
async function http<T>(fn: () => Promise<T>): Promise<T | null> { try { return await fn(); } catch { return null; } }
|
|
39
|
+
|
|
40
|
+
async function status(runId: string): Promise<string | null> {
|
|
41
|
+
const r = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
42
|
+
return r !== null && r.status === 200 ? String((r.json as { status?: unknown } | null)?.status ?? '') : null;
|
|
43
|
+
}
|
|
44
|
+
async function waitStatus(runId: string, want: (s: string) => boolean, timeoutMs: number): Promise<string | null> {
|
|
45
|
+
const deadline = Date.now() + scaledTimeoutMs(timeoutMs);
|
|
46
|
+
let s: string | null = null;
|
|
47
|
+
while (Date.now() < deadline) { s = await status(runId); if (s !== null && want(s)) return s; await new Promise((r) => setTimeout(r, 250)); }
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
async function logTypes(runId: string): Promise<string[] | null> {
|
|
51
|
+
const r = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
|
|
52
|
+
if (r === null || r.status !== 200) return null;
|
|
53
|
+
const ev = (r.json as { events?: Array<{ type?: unknown }> } | null)?.events;
|
|
54
|
+
return Array.isArray(ev) ? ev.map((e) => String(e.type)) : null;
|
|
55
|
+
}
|
|
56
|
+
/** Re-read until two reads ≥ 2 s apart agree (bounded); returns the last read. */
|
|
57
|
+
async function settledLog(runId: string): Promise<string[] | null> {
|
|
58
|
+
const deadline = Date.now() + scaledTimeoutMs(20_000);
|
|
59
|
+
let last = await logTypes(runId);
|
|
60
|
+
while (Date.now() < deadline) {
|
|
61
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
62
|
+
const next = await logTypes(runId);
|
|
63
|
+
if (next === null) return last;
|
|
64
|
+
if (last !== null && next.length === last.length) return next;
|
|
65
|
+
last = next;
|
|
66
|
+
}
|
|
67
|
+
return last;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('v2 terminal event (RFC 0194 §A)', () => {
|
|
71
|
+
it('every run observed to a terminal state has exactly one terminal event and no forward execution after it', async () => {
|
|
72
|
+
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
73
|
+
req(ONCE, DOC, 'a run\'s log MUST contain exactly one terminal run event and no forward-execution event after it');
|
|
74
|
+
const cases: Array<{ fixture: string; cancel: boolean; want: string }> = [
|
|
75
|
+
{ fixture: 'conformance-noop', cancel: false, want: 'completed' },
|
|
76
|
+
{ fixture: 'conformance-noop', cancel: false, want: 'completed' },
|
|
77
|
+
{ fixture: 'conformance-failure', cancel: false, want: 'failed' },
|
|
78
|
+
{ fixture: 'conformance-cancellable', cancel: true, want: 'cancelled' },
|
|
79
|
+
];
|
|
80
|
+
const observed: Array<{ fixture: string; runId: string }> = [];
|
|
81
|
+
for (const c of cases) {
|
|
82
|
+
if (!isFixtureAdvertised(c.fixture)) continue;
|
|
83
|
+
const create = await http(() => driver.post('/runs', { workflowId: c.fixture }));
|
|
84
|
+
const runId = (create?.json as { runId?: unknown } | null)?.runId;
|
|
85
|
+
if (create === null || create.status !== 201 || typeof runId !== 'string') continue;
|
|
86
|
+
if (c.cancel) await http(() => driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {}));
|
|
87
|
+
// `cancelling` is not terminal; wait for the terminal status itself.
|
|
88
|
+
const s = await waitStatus(runId, (x) => TERMINAL_STATUS.has(x), 30_000);
|
|
89
|
+
if (s !== null && TERMINAL_STATUS.has(s)) observed.push({ fixture: c.fixture, runId });
|
|
90
|
+
}
|
|
91
|
+
if (observed.length === 0) return softSkip('blocked', 'no advertised fixture produced a run that reached a terminal status — there is no log to check');
|
|
92
|
+
for (const o of observed) {
|
|
93
|
+
const types = await settledLog(o.runId);
|
|
94
|
+
if (types === null) return softSkip('blocked', `GET /runs/{runId}/events/poll did not answer for ${o.fixture} — the log is unobservable`);
|
|
95
|
+
expect(terminalShapeViolation(types), req(ONCE, DOC, `${o.fixture} run ${o.runId}: exactly one terminal run event, and no forward-execution event after it — log: ${types.join(' ')}`)).toBeNull();
|
|
96
|
+
}
|
|
97
|
+
}, 180_000);
|
|
98
|
+
|
|
99
|
+
it('work delivered twice still leaves one terminal event and no forward execution after it', async () => {
|
|
100
|
+
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
101
|
+
req(DUP, DOC, 'under a duplicate delivery the log MUST still contain exactly one terminal run event and no forward-execution event after it');
|
|
102
|
+
const probe = await http(() => driver.get(KILL_SEAM));
|
|
103
|
+
if (probe === null) return softSkip('blocked', `GET ${KILL_SEAM} unreachable (fetch failed)`);
|
|
104
|
+
if (probe.status === 404 || probe.status === 405) return softSkip('inapplicable', `no RFC 0158 durability test hook at ${KILL_SEAM} (HTTP ${probe.status}) — a black-box suite cannot make a host redeliver accepted work, so this row needs the hook`);
|
|
105
|
+
const rx = await startEffectReceiver();
|
|
106
|
+
try {
|
|
107
|
+
const target = resolveRegistrationUrl(rx.url);
|
|
108
|
+
const fired = await http(() => driver.post(KILL_SEAM, { mode: 'duplicate-delivery', effectUrl: target.url }));
|
|
109
|
+
const runId = (fired?.json as { runId?: unknown } | null)?.runId;
|
|
110
|
+
if (fired === null || fired.status >= 400 || typeof runId !== 'string') return softSkip('blocked', `the durability hook answered ${fired?.status ?? 'no response'} for mode=duplicate-delivery — no redelivered run to read`);
|
|
111
|
+
const s = await waitStatus(runId, (x) => TERMINAL_STATUS.has(x), 60_000);
|
|
112
|
+
if (s === null || !TERMINAL_STATUS.has(s)) return softSkip('blocked', `the redelivered run did not reach a terminal status (last: ${s ?? 'unreadable'})`);
|
|
113
|
+
const deadline = Date.now() + scaledTimeoutMs(20_000);
|
|
114
|
+
while (rx.arrivals === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 250));
|
|
115
|
+
const types = await settledLog(runId);
|
|
116
|
+
if (types === null) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer — the log is unobservable');
|
|
117
|
+
expect(types.some((t) => TERMINAL_RUN_EVENTS.has(t)), req(DUP, DOC, 'the redelivered run\'s log reached a terminal event')).toBe(true);
|
|
118
|
+
expect(terminalShapeViolation(types), req(DUP, DOC, `work delivered twice MUST leave exactly one terminal run event and nothing forward after it — log: ${types.join(' ')}`)).toBeNull();
|
|
119
|
+
} finally {
|
|
120
|
+
await new Promise<void>((ok) => rx.server.close(() => ok()));
|
|
121
|
+
}
|
|
122
|
+
}, 180_000);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
async function startEffectReceiver(): Promise<{ server: Server; url: string; arrivals: number }> {
|
|
126
|
+
const state = { arrivals: 0 };
|
|
127
|
+
const server = createServer((request: IncomingMessage, res: ServerResponse) => {
|
|
128
|
+
request.on('data', () => { /* drain */ });
|
|
129
|
+
request.on('end', () => { state.arrivals++; res.writeHead(204); res.end(); });
|
|
130
|
+
});
|
|
131
|
+
const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
|
|
132
|
+
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
133
|
+
const binding = receiverBinding();
|
|
134
|
+
await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
|
|
135
|
+
const addr = server.address();
|
|
136
|
+
const out = { server, url: `http://${binding.advertise}:${typeof addr === 'object' && addr ? addr.port : 0}/effect`, get arrivals() { return state.arrivals; } };
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
@@ -64,6 +64,16 @@ const PROBES: ReadonlyArray<{ readonly url: string; readonly cls: string }> = [
|
|
|
64
64
|
{ url: 'https://192.168.255.1/openwop-egress-probe', cls: 'RFC 1918 (192.168/16)' },
|
|
65
65
|
{ url: 'https://169.254.169.254/latest/meta-data/', cls: 'link-local / cloud metadata' },
|
|
66
66
|
{ url: 'https://[fd00::1]/openwop-egress-probe', cls: 'IPv6 ULA' },
|
|
67
|
+
// 2.35.0 — the same MUST, spelled the way a URL parser spells it. An
|
|
68
|
+
// IPv4-mapped IPv6 literal is normalised to HEX (`[::ffff:127.0.0.1]` has
|
|
69
|
+
// hostname `::ffff:7f00:1`), and a guard that matched only the dotted mapped
|
|
70
|
+
// form let loopback, metadata and RFC 1918 through at registration and at
|
|
71
|
+
// delivery. Measured on the steward's own reference host.
|
|
72
|
+
// These are loopback / link-local / RFC 1918 addresses by any reading of
|
|
73
|
+
// webhooks.md §SSRF, so they enforce the existing obligation — same id.
|
|
74
|
+
{ url: 'https://[::ffff:7f00:1]/openwop-egress-probe', cls: 'loopback as IPv4-mapped IPv6 (hex)' },
|
|
75
|
+
{ url: 'https://[::ffff:a9fe:a9fe]/latest/meta-data/', cls: 'cloud metadata as IPv4-mapped IPv6 (hex)' },
|
|
76
|
+
{ url: 'https://[::ffff:a00:1]/openwop-egress-probe', cls: 'RFC 1918 (10/8) as IPv4-mapped IPv6 (hex)' },
|
|
67
77
|
];
|
|
68
78
|
|
|
69
79
|
/** The `webhooks.*` obligations the operator declared relaxed, if any. */
|
package/src/setup.ts
CHANGED
|
@@ -470,7 +470,7 @@ afterEach(({ task }) => {
|
|
|
470
470
|
// `inapplicable` came out `blocked` — which denies certification bundle-wide).
|
|
471
471
|
const noted = softSkipDispositionSince(file, _itSoftSkipMarks.get(file) ?? 0);
|
|
472
472
|
const err = (task.result?.errors ?? [])[0] as { message?: string } | undefined;
|
|
473
|
-
const rec = resolveItRecord(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip', calls, gate, noted, err?.message);
|
|
473
|
+
const rec = resolveItRecord(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip', calls, gate, noted, err?.message, targetMajor() === 2);
|
|
474
474
|
disposition = rec.disposition;
|
|
475
475
|
detail = rec.detail;
|
|
476
476
|
}
|