@openwop/openwop-conformance 1.142.0 → 1.144.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/README.md CHANGED
@@ -94,7 +94,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
94
94
 
95
95
  ## What's Covered
96
96
 
97
- The current suite has 465 scenario files under `src/scenarios/`.
97
+ The current suite has 467 scenario files under `src/scenarios/`.
98
98
  - 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.
99
99
  - 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.**
100
100
  - 2026-08-18 (suite `1.136.7 → 1.136.8`, SP-04): NEW `spec-section-citations.test.ts` — server-free; a `<doc>.md §"<Section>"` citation of a checked doc MUST resolve to a heading that exists. `storage-adapters.md §"Claim acquisition"` was cited by four artifacts, including a normative MUST in `production-profile.md` §Durability, and the section did not exist for the life of RFC 0009. Scoped to the docs whose section citations carry the durability contract; a corpus-wide sweep finds ~260 unresolved citations that need triage before they can gate.
@@ -438,7 +438,7 @@ Server-required (added in 1.7.0):
438
438
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
439
439
  | **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. |
440
440
 
441
- Current source tree: 465 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
441
+ Current source tree: 467 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
442
442
 
443
443
  ## Remaining Gaps
444
444
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "1.142.0",
3
+ "version": "1.144.0",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "_comment": "Provenance of this vendored schemas/ copy. See conformance/README.md \u00a7\"Resolving the contract\". Compare against the stamp in your installed @openwop/openwop-conformance to detect a stale hand-copied contract.",
3
- "suiteVersion": "1.142.0",
4
- "corpusCommit": "aced8a6a82d751394c3b299fa4602b9dd5cc9971"
3
+ "suiteVersion": "1.144.0",
4
+ "corpusCommit": "1aca8bb8ebdfaf23b33283f9c313aacfebfd4f07"
5
5
  }
@@ -3691,6 +3691,10 @@
3691
3691
  "uniqueItems": true,
3692
3692
  "description": "Auth profiles the host claims. Canonical ids: `openwop-audit-log-integrity` (auth-profiles.md \u00a7Audit-log integrity), `openwop-auth-api-key-rotation`, `openwop-auth-oauth2-client-credentials`, `openwop-auth-oidc-user-bearer`, `openwop-auth-mtls`, `openwop-auth-saml` + `openwop-auth-scim` + `openwop-auth-ldap` (RFC 0050 enterprise identity). Clients SHOULD tolerate unknown profile ids."
3693
3693
  },
3694
+ "subjectLinking": {
3695
+ "type": "boolean",
3696
+ "description": "RFC 0159 (auth-profiles.md \u00a7Subject linking). When true, this host advertises BOTH `openwop-auth-saml` and `openwop-auth-scim` AND honours the cross-profile subject-linking obligation: it links the SAML and SCIM principals of one human on an opaque, IdP-stable subject id (SCIM `externalId` \u2194 persistent SAML `NameID`), same-tenant only, and on SCIM deactivation fail-closes the linked SAML identity too (composing RFC 0049 \u00a7C) \u2014 so a provisioned leaver cannot still SSO in. The link key MUST NOT be a mutable/PII attribute (email, userName). Optional; absent/false means the SAML and SCIM lanes are independent and the combined leaver guarantee is NOT claimed. A host MUST NOT set this true unless both profiles are advertised and the obligation holds."
3697
+ },
3694
3698
  "rotation": {
3695
3699
  "type": "object",
3696
3700
  "description": "API-key rotation advertisement (auth-profiles.md \u00a7`openwop-auth-api-key-rotation`).",
@@ -0,0 +1,152 @@
1
+ /**
2
+ * auth-subject-link — RFC 0159: SCIM ⟷ SAML subject linking (the combined
3
+ * leaver contract).
4
+ *
5
+ * Status: DRAFT. RFC 0159 is `Active` (amends RFC 0050). The obligation is
6
+ * documented in `auth-profiles.md` §"Subject linking (SAML ⟷ SCIM)" and is
7
+ * discoverable via `capabilities.auth.subjectLinking`.
8
+ *
9
+ * Advertisement shape runs unconditionally when the flag is set. The
10
+ * cross-lane behavioral legs (a SCIM deactivation fail-closing the linked SAML
11
+ * identity; a mutable-key link never producing a cross-lane pass) are opt-in
12
+ * via `OPENWOP_TEST_SAML_IDP_URL` + `OPENWOP_TEST_SCIM_URL` (operator-supplied
13
+ * endpoints), following the `auth-scim-profile.test.ts` opt-in precedent.
14
+ * Soft-skips otherwise; soft-skips `blocked` until the host wires the seam.
15
+ *
16
+ * @see RFCS/0159-scim-saml-subject-linking.md
17
+ * @see spec/v1/auth-profiles.md §"Subject linking (SAML ⟷ SCIM)"
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { softSkip } from '../lib/soft-skip.js';
22
+ import { driver } from '../lib/driver.js';
23
+ import { capabilityFamily } from '../lib/discovery-capabilities.js';
24
+
25
+ const SAML_PROFILE = 'openwop-auth-saml';
26
+ const SCIM_PROFILE = 'openwop-auth-scim';
27
+
28
+ interface DiscoveryAuth {
29
+ profiles?: string[];
30
+ subjectLinking?: boolean;
31
+ }
32
+
33
+ interface DiscoveryDoc {
34
+ capabilities?: { auth?: DiscoveryAuth };
35
+ extensions?: { auth?: DiscoveryAuth };
36
+ }
37
+
38
+ async function readAuth(): Promise<DiscoveryAuth | null> {
39
+ const res = await driver.get('/.well-known/openwop');
40
+ const body = res.json as DiscoveryDoc | undefined;
41
+ return capabilityFamily<DiscoveryAuth>(body, 'auth') ?? body?.extensions?.auth ?? null;
42
+ }
43
+
44
+ describe('auth-subject-link: advertisement shape (RFC 0159 §B)', () => {
45
+ it('subjectLinking:true is only claimed alongside both SAML and SCIM profiles', async () => {
46
+ const auth = await readAuth();
47
+ if (auth === null || auth.subjectLinking !== true) {
48
+ return softSkip('inapplicable', 'auth.subjectLinking not claimed');
49
+ }
50
+ const profiles = auth.profiles ?? [];
51
+ expect(
52
+ profiles.includes(SAML_PROFILE) && profiles.includes(SCIM_PROFILE),
53
+ driver.describe(
54
+ 'auth-profiles.md §Subject linking',
55
+ 'a host MUST NOT set capabilities.auth.subjectLinking:true unless BOTH openwop-auth-saml and openwop-auth-scim are advertised',
56
+ ),
57
+ ).toBe(true);
58
+ });
59
+ });
60
+
61
+ describe('auth-subject-link: cross-lane deactivation (RFC 0159 §A.3 — opt-in)', () => {
62
+ const idpUrl = process.env.OPENWOP_TEST_SAML_IDP_URL;
63
+ const scimUrl = process.env.OPENWOP_TEST_SCIM_URL;
64
+
65
+ it('a SCIM deactivation fail-closes the linked SAML identity', async () => {
66
+ const auth = await readAuth();
67
+ if (auth === null || auth.subjectLinking !== true) return softSkip('inapplicable', 'capability-gated');
68
+ if (!idpUrl || !scimUrl) return softSkip('inapplicable', 'opt-in: SAML IdP and/or SCIM endpoint not provided');
69
+
70
+ // 1. Provision a SCIM user carrying an opaque, IdP-stable externalId.
71
+ const externalId = 'idp-op-8f3a';
72
+ const provision = await driver.post('/v1/host/sample/auth/scim/provision', {
73
+ scimUrl,
74
+ op: 'create-user',
75
+ externalId,
76
+ userName: 'r.smith',
77
+ });
78
+ if (provision.status === 404) return softSkip('blocked', 'seam unwired');
79
+ expect(provision.status, driver.describe('auth-profiles.md §Subject linking', 'SCIM provisioning MUST succeed')).toBeLessThan(400);
80
+
81
+ // 2. A valid SAML assertion whose persistent NameID equals the externalId
82
+ // links to the same subject and authenticates.
83
+ const before = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl, variant: 'valid', nameId: externalId });
84
+ if (before.status === 404) return softSkip('blocked', 'seam unwired');
85
+ expect(
86
+ (before.json as { authenticated?: boolean } | undefined)?.authenticated,
87
+ driver.describe('auth-profiles.md §Subject linking', 'a valid linked SAML assertion authenticates before deactivation'),
88
+ ).toBe(true);
89
+
90
+ // 3. SCIM-deactivate the provisioned user.
91
+ const deactivate = await driver.post('/v1/host/sample/auth/scim/provision', { scimUrl, op: 'deactivate-user', externalId });
92
+ expect(deactivate.status, driver.describe('auth-profiles.md §Subject linking', 'SCIM deactivation MUST succeed')).toBeLessThan(400);
93
+
94
+ // 4. THE CONTRACT: a subsequent SAML assertion for the linked subject MUST
95
+ // NOT yield an authorized decision (fail-closed across the link).
96
+ const after = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl, variant: 'valid', nameId: externalId });
97
+ expect(
98
+ (after.json as { authenticated?: boolean } | undefined)?.authenticated === true,
99
+ driver.describe(
100
+ 'auth-profiles.md §Subject linking',
101
+ 'RFC 0159 §A.3: after a SCIM deactivation the LINKED SAML identity MUST be denied (a provisioned leaver cannot still SSO in)',
102
+ ),
103
+ ).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe('auth-subject-link: link-key hygiene (RFC 0159 §A.2 — opt-in)', () => {
108
+ const idpUrl = process.env.OPENWOP_TEST_SAML_IDP_URL;
109
+ const scimUrl = process.env.OPENWOP_TEST_SCIM_URL;
110
+
111
+ it('a mutable/PII link key (email) never produces a cross-lane pass', async () => {
112
+ const auth = await readAuth();
113
+ if (auth === null || auth.subjectLinking !== true) return softSkip('inapplicable', 'capability-gated');
114
+ if (!idpUrl || !scimUrl) return softSkip('inapplicable', 'opt-in: SAML IdP and/or SCIM endpoint not provided');
115
+
116
+ // Attempt to form the cross-lane link on email (a mutable/PII attribute).
117
+ // §A.2 forbids it: the host MUST reject the mutable-key link (4xx) OR
118
+ // decline to apply any cross-lane effect from it. Either way, an email
119
+ // "link" MUST NOT yield an authorized cross-lane pass after a deactivation.
120
+ const link = await driver.post('/v1/host/sample/auth/scim/provision', {
121
+ scimUrl,
122
+ op: 'link',
123
+ linkKey: 'email',
124
+ email: 'r.smith@example.test',
125
+ });
126
+ if (link.status === 404) return softSkip('blocked', 'seam unwired');
127
+
128
+ if (link.status >= 400) {
129
+ // Conforming: the host rejected a mutable-key link outright.
130
+ expect(
131
+ link.status,
132
+ driver.describe('auth-profiles.md §Subject linking', 'RFC 0159 §A.2: a link on a mutable/PII key (email) MUST be rejected'),
133
+ ).toBeGreaterThanOrEqual(400);
134
+ return;
135
+ }
136
+
137
+ // If the host accepted the request, it MUST NOT have formed a cross-lane
138
+ // link on email: a deactivation via the email "link" MUST NOT deny (or
139
+ // otherwise act on) an unrelated SAML subject, and MUST NOT let a mutable
140
+ // key authorize one. Probe: deactivate by email, then a SAML assertion for
141
+ // a DIFFERENT opaque subject MUST be unaffected by the email operation.
142
+ await driver.post('/v1/host/sample/auth/scim/provision', { scimUrl, op: 'deactivate-user', email: 'r.smith@example.test' });
143
+ const other = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl, variant: 'valid', nameId: 'idp-op-DIFFERENT' });
144
+ expect(
145
+ (other.json as { linkedDenied?: boolean } | undefined)?.linkedDenied === true,
146
+ driver.describe(
147
+ 'auth-profiles.md §Subject linking',
148
+ 'RFC 0159 §A.2: a mutable-key (email) operation MUST NOT drive a cross-lane deny on any opaque subject',
149
+ ),
150
+ ).toBe(false);
151
+ });
152
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * tool-hooks-failure-honesty — RFC 0064 §F. A tool failure MUST be
3
+ * self-describing on the wire: a non-success `agent.toolReturned` MUST carry a
4
+ * failure discriminator — `error` populated (the `_errorObject`) and, when the
5
+ * host advertises `prePostEvents`, `status: 'error'` — and MUST NOT be
6
+ * represented as a bare/empty success. Closes the gap where a failed tool is
7
+ * indistinguishable from one that succeeded-empty (host-program WFAU-4).
8
+ *
9
+ * Gated on `capabilities.toolHooks.supported` + the tool-hooks seam. Drives the
10
+ * seam's `simulateToolError` arm; a host that advertises tool-hooks but has not
11
+ * wired that arm soft-skips (`blocked`) rather than failing — §F and the seam
12
+ * arm land together, so hosts wire it on adoption (the same soft-skip-until-wired
13
+ * discipline the other tool-hooks scenarios use).
14
+ *
15
+ * @see RFCS/0064-tool-invocation-hooks-and-authorization.md §F
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { softSkip } from '../lib/soft-skip.js';
20
+ import { driver } from '../lib/driver.js';
21
+ import { readToolHooksCap, invokeToolHook } from '../lib/toolHooks.js';
22
+
23
+ describe('tool-hooks-failure-honesty (RFC 0064 §F)', () => {
24
+ it('a failing tool yields a populated error, never a bare success', async () => {
25
+ const cap = await readToolHooksCap();
26
+ if (cap?.supported !== true) return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `cap?.supported !== true` returned early');
27
+ const res = await invokeToolHook({ principal: 'core.system', toolName: 'web.search', simulateToolError: true });
28
+ if (res === null) return softSkip('blocked', 'tool-hooks seam absent — soft-skip');
29
+
30
+ const returned = res.toolReturned ?? {};
31
+ const errRaw = returned['error'];
32
+ const err = errRaw != null && typeof errRaw === 'object' ? (errRaw as Record<string, unknown>) : undefined;
33
+ const isErrorStatus = returned['status'] === 'error';
34
+
35
+ // A host that has not wired the §F simulateToolError arm answers with a
36
+ // success (status 'ok' / an outcome). Soft-skip rather than red-light an
37
+ // honest pre-§F host — the prose MUST and this seam arm adopt together.
38
+ if (err === undefined && !isErrorStatus) {
39
+ return softSkip('blocked', 'host has not wired the RFC 0064 §F simulateToolError arm — soft-skip until adopted');
40
+ }
41
+
42
+ // §F: the failure carries a populated `_errorObject { code, message }`.
43
+ expect(
44
+ err !== undefined && typeof err['code'] === 'string' && (err['code'] as string).length > 0 && typeof err['message'] === 'string',
45
+ driver.describe('RFC 0064 §F', 'a failed tool MUST populate error {code, message} on agent.toolReturned'),
46
+ ).toBe(true);
47
+
48
+ // §F: `error` and `outcome` are mutually exclusive.
49
+ expect(
50
+ returned['outcome'] === undefined,
51
+ driver.describe('RFC 0064 §F', 'error and outcome are mutually exclusive on a failed tool-return'),
52
+ ).toBe(true);
53
+
54
+ // §F: a host advertising `prePostEvents` MUST also set `status: 'error'`.
55
+ if (cap['prePostEvents'] === true) {
56
+ expect(
57
+ returned['status'] === 'error',
58
+ driver.describe('RFC 0064 §F', 'a host advertising prePostEvents MUST set status:"error" on a tool failure'),
59
+ ).toBe(true);
60
+ }
61
+ });
62
+ });