@openwop/openwop-conformance 1.5.0 → 1.6.1

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +2 -2
  3. package/api/asyncapi.yaml +25 -4
  4. package/api/openapi.yaml +371 -0
  5. package/coverage.md +31 -4
  6. package/fixtures/conformance-phase4-nondet-tool.json +53 -0
  7. package/fixtures/conformance-phase4-replay-divergence.json +40 -0
  8. package/fixtures.md +5 -3
  9. package/package.json +1 -1
  10. package/schemas/README.md +4 -0
  11. package/schemas/annotation-create.schema.json +37 -0
  12. package/schemas/annotation.schema.json +56 -0
  13. package/schemas/capabilities.schema.json +191 -3
  14. package/schemas/credential-reference.schema.json +21 -0
  15. package/schemas/node-pack-manifest.schema.json +112 -1
  16. package/schemas/run-diff-response.schema.json +64 -0
  17. package/schemas/run-event-payloads.schema.json +104 -2
  18. package/schemas/run-event.schema.json +8 -1
  19. package/schemas/run-snapshot.schema.json +11 -0
  20. package/src/lib/behavior-gate.ts +51 -0
  21. package/src/lib/driver.ts +13 -1
  22. package/src/lib/feedback.ts +31 -0
  23. package/src/lib/saml-idp.ts +179 -0
  24. package/src/scenarios/approval-gate-events.test.ts +61 -0
  25. package/src/scenarios/approval-gate-flow.test.ts +68 -0
  26. package/src/scenarios/auth-saml-profile.test.ts +119 -0
  27. package/src/scenarios/auth-scim-profile.test.ts +65 -0
  28. package/src/scenarios/authorization-fail-closed.test.ts +80 -0
  29. package/src/scenarios/authorization-roles-shape.test.ts +83 -0
  30. package/src/scenarios/connector-manifest-validity.test.ts +142 -0
  31. package/src/scenarios/credential-payload-redaction.test.ts +93 -0
  32. package/src/scenarios/credentials-capability-shape.test.ts +90 -0
  33. package/src/scenarios/cross-engine-append-behavior.test.ts +204 -0
  34. package/src/scenarios/cross-host-traceparent-propagation.test.ts +13 -6
  35. package/src/scenarios/cross-workspace-isolation.test.ts +72 -0
  36. package/src/scenarios/deadletter-capability-shape.test.ts +59 -0
  37. package/src/scenarios/deadletter-retry-exhaustion.test.ts +62 -0
  38. package/src/scenarios/experimental-tier-shape.test.ts +192 -0
  39. package/src/scenarios/feedback-capability-shape.test.ts +35 -0
  40. package/src/scenarios/feedback-correction-redaction.test.ts +35 -0
  41. package/src/scenarios/feedback-cross-tenant-isolation.test.ts +37 -0
  42. package/src/scenarios/feedback-fork-not-copied.test.ts +40 -0
  43. package/src/scenarios/feedback-on-terminal-run.test.ts +32 -0
  44. package/src/scenarios/feedback-record-and-list.test.ts +32 -0
  45. package/src/scenarios/feedback-unsupported-501.test.ts +32 -0
  46. package/src/scenarios/identity-owner-shape.test.ts +64 -0
  47. package/src/scenarios/multi-agent-confidence-escalation.test.ts +13 -12
  48. package/src/scenarios/multi-agent-memory-lifecycle.test.ts +87 -12
  49. package/src/scenarios/multi-region-idempotency-behavior.test.ts +203 -0
  50. package/src/scenarios/oauth-capability-shape.test.ts +97 -0
  51. package/src/scenarios/oauth-connector-redaction.test.ts +91 -0
  52. package/src/scenarios/pack-registry-isolation.test.ts +108 -0
  53. package/src/scenarios/pack-registry-publish.test.ts +1 -1
  54. package/src/scenarios/prompt-mutation-workspace-membership-enforced.test.ts +126 -0
  55. package/src/scenarios/prompt-read-workspace-membership-enforced.test.ts +183 -0
  56. package/src/scenarios/redaction.test.ts +4 -1
  57. package/src/scenarios/replay-divergence-at-refusal.test.ts +187 -7
  58. package/src/scenarios/replay-observable-sequence-determinism.test.ts +20 -6
  59. package/src/scenarios/run-diff.test.ts +143 -0
  60. package/src/scenarios/sandbox-capability-gate-respected.test.ts +7 -1
  61. package/src/scenarios/sandbox-memory-cap.test.ts +7 -5
  62. package/src/scenarios/sandbox-mvp-behavior.test.ts +280 -0
  63. package/src/scenarios/sandbox-no-cross-pack-mutation.test.ts +7 -1
  64. package/src/scenarios/sandbox-no-host-env-leak.test.ts +5 -1
  65. package/src/scenarios/sandbox-no-host-fs-escape.test.ts +9 -1
  66. package/src/scenarios/sandbox-no-host-process-escape.test.ts +5 -1
  67. package/src/scenarios/sandbox-no-network-escape.test.ts +5 -1
  68. package/src/scenarios/sandbox-timeout-cap.test.ts +7 -5
  69. package/src/scenarios/scheduling-capability-shape.test.ts +81 -0
  70. package/src/scenarios/scheduling-cron-fires-once.test.ts +66 -0
  71. package/src/scenarios/secret-leakage-otel-attribute.test.ts +241 -0
  72. package/src/scenarios/spec-corpus-validity.test.ts +6 -3
@@ -0,0 +1,97 @@
1
+ /**
2
+ * oauth-capability-shape — RFC 0047 §A advertisement-shape verification.
3
+ *
4
+ * Status: DRAFT. RFC 0047 (`host.oauth`) is `Draft`. The
5
+ * `capabilities.oauth` block has landed in `schemas/capabilities.schema.json`.
6
+ *
7
+ * Always runs (shape-only): when the host advertises `capabilities.oauth`,
8
+ * its fields MUST be well-formed; when it doesn't, the block is absent.
9
+ *
10
+ * What this scenario asserts:
11
+ * 1. `capabilities.oauth` is either absent or a well-formed object.
12
+ * 2. When `supported: true`, `grants` (when present) is a subset of
13
+ * {authorization_code, client_credentials, refresh_token}, and every
14
+ * `providers[]` entry has a non-empty `id` (RFC 0047 §A).
15
+ *
16
+ * @see RFCS/0047-host-oauth-connector-flows.md
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { driver } from '../lib/driver.js';
21
+
22
+ interface DiscoveryOAuthProvider {
23
+ id?: string;
24
+ authUrl?: string;
25
+ tokenUrl?: string;
26
+ scopesSupported?: string[];
27
+ }
28
+
29
+ interface DiscoveryOAuth {
30
+ supported?: boolean;
31
+ grants?: string[];
32
+ providers?: DiscoveryOAuthProvider[];
33
+ }
34
+
35
+ interface DiscoveryDoc {
36
+ capabilities?: {
37
+ oauth?: DiscoveryOAuth;
38
+ };
39
+ }
40
+
41
+ const VALID_GRANTS: ReadonlySet<string> = new Set([
42
+ 'authorization_code',
43
+ 'client_credentials',
44
+ 'refresh_token',
45
+ ]);
46
+
47
+ async function readOAuth(): Promise<DiscoveryOAuth | null> {
48
+ const res = await driver.get('/.well-known/openwop');
49
+ const body = res.json as DiscoveryDoc | undefined;
50
+ return body?.capabilities?.oauth ?? null;
51
+ }
52
+
53
+ describe('oauth-capability-shape: advertisement shape (RFC 0047 §A)', () => {
54
+ it('capabilities.oauth is either absent or well-formed', async () => {
55
+ const oauth = await readOAuth();
56
+ if (oauth === null) return; // host doesn't advertise host.oauth at all
57
+ expect(
58
+ typeof oauth.supported,
59
+ driver.describe(
60
+ 'capabilities.schema.json §oauth',
61
+ 'capabilities.oauth.supported MUST be a boolean when oauth is advertised',
62
+ ),
63
+ ).toBe('boolean');
64
+ });
65
+
66
+ it('grants is a subset of the canonical grant set when supported', async () => {
67
+ const oauth = await readOAuth();
68
+ if (!oauth?.supported || oauth.grants === undefined) return;
69
+ expect(
70
+ Array.isArray(oauth.grants),
71
+ driver.describe('RFC 0047 §A', 'capabilities.oauth.grants MUST be an array'),
72
+ ).toBe(true);
73
+ for (const grant of oauth.grants) {
74
+ expect(
75
+ VALID_GRANTS.has(grant),
76
+ driver.describe(
77
+ 'RFC 0047 §A',
78
+ `capabilities.oauth.grants entries MUST be one of {${[...VALID_GRANTS].join(', ')}}, got: ${grant}`,
79
+ ),
80
+ ).toBe(true);
81
+ }
82
+ });
83
+
84
+ it('every advertised provider has a non-empty id when supported', async () => {
85
+ const oauth = await readOAuth();
86
+ if (!oauth?.supported || oauth.providers === undefined) return;
87
+ for (const provider of oauth.providers) {
88
+ expect(
89
+ typeof provider.id === 'string' && provider.id.length > 0,
90
+ driver.describe(
91
+ 'RFC 0047 §A',
92
+ 'each capabilities.oauth.providers[] entry MUST declare a non-empty id',
93
+ ),
94
+ ).toBe(true);
95
+ }
96
+ });
97
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * oauth-connector-redaction — RFC 0047 §C / §D + `credential-payload-redaction`.
3
+ *
4
+ * Status: DRAFT. RFC 0047 (`host.oauth`) is `Draft`. Reuses the RFC 0046
5
+ * SECURITY invariant `credential-payload-redaction` — OAuth tokens acquired
6
+ * via host.oauth are stored as host.credentials entries and are subject to
7
+ * the same no-plaintext-on-the-wire rule.
8
+ *
9
+ * Capability-gated: skips when the host does not advertise
10
+ * `capabilities.oauth.supported = true`.
11
+ *
12
+ * What this scenario asserts:
13
+ * 1. Advertisement shape — `capabilities.oauth.supported` is a boolean.
14
+ * 2. Token-material redaction MUST-NOT — when the host exposes the optional
15
+ * `POST /v1/host/sample/oauth/connector-echo` test seam (a synthetic
16
+ * provider acquires a token whose value is a known canary, then a
17
+ * connector node runs), the canary MUST NOT appear in ANY observable
18
+ * run surface, and `connector.authorized` MUST carry the credential
19
+ * reference rather than the token.
20
+ *
21
+ * Hosts without the seam soft-skip the redaction probe (404).
22
+ *
23
+ * @see RFCS/0047-host-oauth-connector-flows.md
24
+ * @see SECURITY/invariants.yaml id: credential-payload-redaction
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest';
28
+ import { driver } from '../lib/driver.js';
29
+
30
+ interface DiscoveryOAuth {
31
+ supported?: boolean;
32
+ }
33
+
34
+ interface DiscoveryDoc {
35
+ capabilities?: {
36
+ oauth?: DiscoveryOAuth;
37
+ };
38
+ }
39
+
40
+ const TOKEN_CANARY = 'OPENWOP_OAUTH_CANARY_b7d3e1a9c2';
41
+
42
+ async function readOAuth(): Promise<DiscoveryOAuth | null> {
43
+ const res = await driver.get('/.well-known/openwop');
44
+ const body = res.json as DiscoveryDoc | undefined;
45
+ return body?.capabilities?.oauth ?? null;
46
+ }
47
+
48
+ describe('oauth-connector-redaction: advertisement shape (RFC 0047 §A)', () => {
49
+ it('capabilities.oauth.supported is a boolean when advertised', async () => {
50
+ const oauth = await readOAuth();
51
+ if (oauth === null) return;
52
+ expect(
53
+ typeof oauth.supported,
54
+ driver.describe(
55
+ 'capabilities.schema.json §oauth',
56
+ 'capabilities.oauth.supported MUST be a boolean when oauth is advertised',
57
+ ),
58
+ ).toBe('boolean');
59
+ });
60
+ });
61
+
62
+ describe('oauth-connector-redaction: token material MUST NOT cross the wire (RFC 0047 §C.2)', () => {
63
+ it('canary token is absent from every observable run surface', async () => {
64
+ const oauth = await readOAuth();
65
+ if (!oauth?.supported) return; // capability-gated
66
+
67
+ // Seam contract: a synthetic provider issues a token whose value is
68
+ // TOKEN_CANARY, a connector node runs, and the run's observable surfaces
69
+ // (events incl. connector.authorized + snapshot + debug bundle) are returned.
70
+ const res = await driver.post('/v1/host/sample/oauth/connector-echo', { canary: TOKEN_CANARY });
71
+ // 404 from a host that hasn't wired the test seam is a soft-skip.
72
+ if (res.status === 404) return;
73
+
74
+ expect(
75
+ res.status,
76
+ driver.describe(
77
+ 'RFC 0047 §C',
78
+ 'the oauth connector-echo seam MUST acquire the token and return the run observable surfaces',
79
+ ),
80
+ ).toBeLessThan(400);
81
+
82
+ const serialized = JSON.stringify(res.json ?? {});
83
+ expect(
84
+ serialized.includes(TOKEN_CANARY),
85
+ driver.describe(
86
+ 'SECURITY/invariants.yaml credential-payload-redaction',
87
+ 'acquired OAuth token material MUST NOT appear in inputs, variables, channels, events, snapshot, or debug bundle — only the credential reference may cross the wire',
88
+ ),
89
+ ).toBe(false);
90
+ });
91
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Pack-registry test-mode isolation — RFC 0025 §C point 1.
3
+ *
4
+ * Status: BEHAVIORAL (soft-skip). A pack PUT'd to `/v1/packs-test/*` MUST
5
+ * NOT appear in `/v1/packs/*` listings. This anchors the test-mode
6
+ * mirror's load-bearing safety invariant: the conformance suite is
7
+ * trusted to drive publish-error-catalog traffic against the test
8
+ * namespace precisely because the test catalog is guaranteed distinct
9
+ * from the production catalog.
10
+ *
11
+ * Soft-skips when the host doesn't advertise
12
+ * `capabilities.packs.testMode.supported: true` (or advertises
13
+ * `isolated: false` — in which case the host is honestly disclaiming
14
+ * the invariant and the conformance suite's other publish-error tests
15
+ * are not applicable either).
16
+ *
17
+ * @see RFCS/0025-test-mode-registry-namespace.md §C "Isolation guarantees"
18
+ * @see schemas/capabilities.schema.json §packs.testMode
19
+ * @see pack-registry-publish.test.ts (the 25 sibling scenarios this invariant unblocks)
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest';
23
+ import { driver } from '../lib/driver.js';
24
+
25
+ interface DiscoveryDoc {
26
+ capabilities?: Record<string, unknown>;
27
+ }
28
+
29
+ interface TestModeAdvertisement {
30
+ readonly supported: boolean;
31
+ readonly isolated: boolean;
32
+ }
33
+
34
+ async function getTestModeAdvertisement(): Promise<TestModeAdvertisement | null> {
35
+ const res = await driver.get('/.well-known/openwop');
36
+ const body = res.json as DiscoveryDoc | undefined;
37
+ const top = body?.capabilities as Record<string, unknown> | undefined;
38
+ const packs = top && typeof top === 'object' ? (top['packs'] as Record<string, unknown> | undefined) : undefined;
39
+ const testMode = packs && typeof packs === 'object' ? (packs['testMode'] as Record<string, unknown> | undefined) : undefined;
40
+ if (!testMode || typeof testMode !== 'object') return null;
41
+ return {
42
+ supported: testMode['supported'] === true,
43
+ isolated: testMode['isolated'] === true,
44
+ };
45
+ }
46
+
47
+ function freshPackName(): string {
48
+ return `core.openwop.test-isolation-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
49
+ }
50
+
51
+ describe('pack-registry-isolation: test catalog MUST NOT bleed into production (RFC 0025 §C.1)', () => {
52
+ it('a pack PUT to /v1/packs-test/{name} MUST NOT appear in GET /v1/packs/{name}', async () => {
53
+ const adv = await getTestModeAdvertisement();
54
+ if (!adv || !adv.supported) return; // host doesn't advertise the seam
55
+ if (!adv.isolated) return; // host explicitly disclaims the invariant — no contract to assert
56
+
57
+ const name = freshPackName();
58
+ const version = '1.0.0';
59
+
60
+ // PUT to the test namespace. The body is intentionally minimal — the
61
+ // isolation invariant is independent of whether validation accepts
62
+ // or rejects the publish. Either outcome is fine; what's tested is
63
+ // that NEITHER outcome causes the pack to surface in the production
64
+ // catalog.
65
+ const putRes = await driver.put(
66
+ `/v1/packs-test/${encodeURIComponent(name)}/-/${encodeURIComponent(version)}.tgz`,
67
+ Buffer.from([0x1f, 0x8b, 0]),
68
+ { headers: { 'Content-Type': 'application/octet-stream' } },
69
+ );
70
+
71
+ // If the seam returns 404, the test-mode endpoint isn't actually
72
+ // wired up despite the advertisement — pack-registry-publish.test.ts
73
+ // catches that drift in 24 other scenarios; soft-skip here.
74
+ if (putRes.status === 404) return;
75
+
76
+ // Probe the production namespace. The invariant: a pack written
77
+ // via /v1/packs-test/* MUST NOT be retrievable via /v1/packs/*.
78
+ const prodRes = await driver.get(`/v1/packs/${encodeURIComponent(name)}`);
79
+
80
+ // 404 is the canonical "not found" — exactly what isolation requires.
81
+ // 200 with a payload that does NOT name our pack would mean the host
82
+ // returned a listing of unrelated packs (some hosts serve search-shaped
83
+ // results on /v1/packs/{nonexistent}); we check the negative explicitly.
84
+ if (prodRes.status === 200) {
85
+ const body = prodRes.json as Record<string, unknown> | undefined;
86
+ const stringified = body ? JSON.stringify(body) : '';
87
+ expect(
88
+ stringified.includes(name),
89
+ driver.describe(
90
+ 'RFCS/0025-test-mode-registry-namespace.md §C point 1',
91
+ `pack name '${name}' was written via /v1/packs-test/${name}@${version} but appeared in /v1/packs/${name} response body — test-catalog isolation MUST hold`,
92
+ ),
93
+ ).toBe(false);
94
+ return;
95
+ }
96
+
97
+ // Acceptable: 4xx range (404 pack_not_found is the spec-canonical
98
+ // shape; 410/422 also fine — any "not present in production catalog"
99
+ // signal satisfies the invariant).
100
+ expect(
101
+ prodRes.status >= 400 && prodRes.status < 500,
102
+ driver.describe(
103
+ 'RFCS/0025-test-mode-registry-namespace.md §C point 1',
104
+ `expected production-namespace GET to return 4xx for a test-namespace-only pack '${name}', got ${prodRes.status}`,
105
+ ),
106
+ ).toBe(true);
107
+ });
108
+ });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Pack-registry publish scenarios — `node-packs.md` §"PUT /v1/packs/{name}/-/{version}.tgz".
3
3
  *
4
- * Status: BEHAVIORAL (soft-skip). Per RFC 0025 (`Draft` 2026-05-19),
4
+ * Status: BEHAVIORAL (soft-skip). Per RFC 0025 (`Active` 2026-05-19),
5
5
  * the conformance suite drives the documented 19-code error catalog
6
6
  * via the test-mode mirror namespace `/v1/packs-test/*`, gated on
7
7
  * `capabilities.packs.testMode.supported: true`. Each scenario soft-
@@ -0,0 +1,126 @@
1
+ /**
2
+ * prompt-mutation-workspace-membership-enforced — RFC 0028 Tier-2 §"Workspace
3
+ * membership on workspace-scoped writes" verification.
4
+ *
5
+ * Status: ACTIVE (capability-gated; behavioral when the host advertises
6
+ * `capabilities.prompts.mutableLibrary: true`). Hosts that don't advertise
7
+ * mutableLibrary soft-skip cleanly.
8
+ *
9
+ * The contract (spec/v1/prompts.md §"Discovery & distribution" §"REST
10
+ * endpoints" §"Workspace membership on workspace-scoped writes"):
11
+ *
12
+ * Hosts MUST verify that the authenticated principal is a member of the
13
+ * target workspace BEFORE honoring any POST / PUT / DELETE to a
14
+ * workspace-scoped /v1/prompts* resource. A workspaceId supplied by the
15
+ * caller (request body, URL, or query string) MUST NOT be trusted as
16
+ * authorization on its own. Non-members MUST be rejected fail-closed
17
+ * (typically 403) before any persistence occurs.
18
+ *
19
+ * The probe drives `POST /v1/prompts` with a `workspaceId` the conformance
20
+ * principal cannot be a member of (a cryptographically-unique random value
21
+ * by default; operator-overridable via `OPENWOP_TEST_NONMEMBER_WORKSPACE_ID`
22
+ * for hosts that need a specific synthetic workspace shape). The behavioral
23
+ * MUST is that the host refuses — NOT a 2xx. Any 4xx/5xx is acceptable
24
+ * (401 = auth not configured for this surface; 403 = membership check;
25
+ * 404 = endpoint absent; 422 = body validation; 501 = capability not
26
+ * provided). The failure mode this invariant guards against is a SILENT
27
+ * 2xx with a write to a workspace the caller doesn't belong to — that's the
28
+ * RFC 0028 Tier-2 vulnerability self-disclosed by an adopter on 2026-05-25.
29
+ *
30
+ * Why a random workspaceId is sufficient: a non-member workspace check is
31
+ * negative-space — the host MUST refuse for ANY workspace the principal
32
+ * isn't a member of, and a random UUID has astronomically-low collision
33
+ * probability with any real workspace membership grant.
34
+ *
35
+ * @see RFCS/0028-prompt-library-endpoints.md §"Post-promotion notes"
36
+ * @see spec/v1/prompts.md §"Security invariants" §prompt-mutation-workspace-membership-enforced
37
+ * @see spec/v1/auth.md §"Identity claims — tenant · workspace · principal"
38
+ * @see RFCS/0048-tenant-workspace-principal-identity-model.md §D
39
+ */
40
+
41
+ import { describe, it, expect } from 'vitest';
42
+ import { randomUUID } from 'node:crypto';
43
+ import { driver } from '../lib/driver.js';
44
+
45
+ const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
46
+
47
+ interface DiscoveryDoc {
48
+ capabilities?: {
49
+ prompts?: {
50
+ mutableLibrary?: unknown;
51
+ };
52
+ };
53
+ }
54
+
55
+ async function readDiscovery(): Promise<DiscoveryDoc | null> {
56
+ try {
57
+ const res = await driver.get('/.well-known/openwop');
58
+ if (res.status !== 200) return null;
59
+ return res.json as DiscoveryDoc;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ describe.skipIf(HTTP_SKIP)(
66
+ 'prompt-mutation-workspace-membership-enforced: writes to non-member workspaces MUST be refused (RFC 0028 Tier-2)',
67
+ () => {
68
+ it('POST /v1/prompts with a workspaceId the principal is not a member of MUST NOT succeed with 2xx', async (ctx) => {
69
+ const d = await readDiscovery();
70
+ if (d === null) {
71
+ ctx.skip();
72
+ return;
73
+ }
74
+ const mutableLibrary = d.capabilities?.prompts?.mutableLibrary;
75
+ if (mutableLibrary !== true) {
76
+ ctx.skip();
77
+ return;
78
+ }
79
+
80
+ const nonMemberWorkspaceId =
81
+ process.env.OPENWOP_TEST_NONMEMBER_WORKSPACE_ID ??
82
+ `openwop-conformance-nonmember-${randomUUID()}`;
83
+
84
+ const res = await driver.post('/v1/prompts', {
85
+ workspaceId: nonMemberWorkspaceId,
86
+ templateId: `conformance-membership-probe-${randomUUID()}`,
87
+ version: '1.0.0',
88
+ kind: 'system',
89
+ text: 'conformance probe — SHOULD NOT persist',
90
+ });
91
+
92
+ // The conformance MUST: the host MUST NOT honor a write to a workspace
93
+ // the caller cannot prove membership of. Any refusal (4xx/5xx) is
94
+ // acceptable; a 2xx silent success is the failure mode that the RFC
95
+ // 0028 Tier-2 self-disclosed vulnerability demonstrated.
96
+ expect(
97
+ res.status,
98
+ driver.describe(
99
+ 'spec/v1/prompts.md §Workspace membership on workspace-scoped reads and writes',
100
+ `mutating /v1/prompts MUST refuse a write to a non-member workspace; ` +
101
+ `got ${res.status} ${res.text.slice(0, 200)}`,
102
+ ),
103
+ ).toBeGreaterThanOrEqual(400);
104
+
105
+ // T1 canonicalization (2026-05-25): when the host CHOOSES 403 to
106
+ // signal the authz boundary, the response envelope MUST carry
107
+ // `error: "workspace_membership_required"` per rest-endpoints.md
108
+ // §"Common error codes". Hosts that refuse with other codes
109
+ // (401 if they treat the failure as authentication-level, 404 to
110
+ // avoid existence disclosure, 5xx on infra failure) have the
111
+ // refusal accepted above but the envelope shape is NOT constrained
112
+ // by this scenario — the canonical envelope is conditional on the
113
+ // 403 status code, not a forced upgrade.
114
+ if (res.status === 403) {
115
+ const body = res.json as { error?: unknown } | null;
116
+ expect(
117
+ body?.error,
118
+ driver.describe(
119
+ 'spec/v1/rest-endpoints.md §Common error codes — workspace_membership_required',
120
+ `403 refusal of a workspace-scoped mutation MUST carry error: "workspace_membership_required"; got error: ${JSON.stringify(body?.error)}`,
121
+ ),
122
+ ).toBe('workspace_membership_required');
123
+ }
124
+ });
125
+ },
126
+ );
@@ -0,0 +1,183 @@
1
+ /**
2
+ * prompt-read-workspace-membership-enforced — RFC 0028 Tier-2 §"Workspace
3
+ * membership on workspace-scoped reads and writes" verification (READ path).
4
+ *
5
+ * Status: ACTIVE (capability-gated; behavioral when the host advertises
6
+ * `capabilities.prompts.supported: true` AND accepts `?workspaceId=` on
7
+ * `GET /v1/prompts`). Hosts that don't expose workspace-scoped reads
8
+ * (host-only template libraries with no workspace dimension) self-skip
9
+ * via response-shape detection.
10
+ *
11
+ * The contract (spec/v1/prompts.md §"Discovery & distribution" §"REST
12
+ * endpoints" §"Workspace membership on workspace-scoped reads and writes"):
13
+ *
14
+ * Read paths are NOT exempt from the workspace-membership invariant
15
+ * just because they don't write. A GET /v1/prompts?workspaceId=<not-mine>
16
+ * that returns another workspace's templates is a cross-tenant data leak
17
+ * with the same blast radius as a cross-tenant write. Hosts MUST verify
18
+ * the authenticated principal's workspace membership BEFORE returning
19
+ * workspace-scoped content.
20
+ *
21
+ * Gate per MyndHyve relay 2026-05-25 ("Option B"): probe ALL hosts that
22
+ * advertise `capabilities.prompts.supported: true` regardless of
23
+ * `mutableLibrary`; read-only hosts that expose `?workspaceId=` reads are
24
+ * NOT exempt from the symmetric authz invariant. Hosts that don't expose
25
+ * workspace-scoped reads at all self-skip via the response interpretation
26
+ * below (the suite avoids inventing a new capability field just for this
27
+ * gating concern).
28
+ *
29
+ * The probe drives `GET /v1/prompts?workspaceId=<random-uuid>` and
30
+ * interprets the response:
31
+ *
32
+ * - 4xx (any code) — PASS (refused). If 403 specifically, additionally
33
+ * pin `error === "workspace_membership_required"` per the canonical
34
+ * envelope in rest-endpoints.md §"Common error codes".
35
+ * - 200 with `templates: []` — PASS. The host correctly returned no
36
+ * content for a workspace the principal isn't a member of. A random
37
+ * UUID workspace also definitionally has no real content, so an empty
38
+ * result is the correct null answer.
39
+ * - 200 with `templates: [non-empty]` — FAIL. The host returned content
40
+ * for an unauthorized workspace. This is the cross-tenant data leak
41
+ * failure mode. (Note: this scenario uses a random workspaceId so any
42
+ * non-empty result is a leak — there can't legitimately be templates
43
+ * in a freshly-generated nonexistent workspace.)
44
+ * - 200 without a `templates[]` field, or a response shape that doesn't
45
+ * resemble the documented `/v1/prompts` list shape — SKIP with a
46
+ * diagnostic log. Indicates the host doesn't recognize `?workspaceId=`
47
+ * on this endpoint (e.g., host-only template library with no
48
+ * workspace dimension).
49
+ * - 5xx — PASS (refused; envelope shape unconstrained).
50
+ *
51
+ * Why a random workspaceId is sufficient: the assertion is negative-space.
52
+ * A host that correctly enforces membership MUST refuse for ANY workspace
53
+ * the principal isn't a member of, and a random UUID has astronomically-low
54
+ * collision probability with any real workspace membership grant. A host
55
+ * that returns templates from a random UUID workspace is leaking content
56
+ * from somewhere (host-built-in misclassified as workspace, or a silent
57
+ * fall-through to another workspace's content, or a query bug returning
58
+ * everything).
59
+ *
60
+ * @see RFCS/0028-prompt-library-endpoints.md §"Post-promotion notes"
61
+ * @see spec/v1/prompts.md §"Security invariants" §prompt-read-workspace-membership-enforced
62
+ * @see spec/v1/rest-endpoints.md §"Common error codes" §workspace_membership_required
63
+ * @see spec/v1/auth.md §"Identity claims — tenant · workspace · principal"
64
+ * @see RFCS/0048-tenant-workspace-principal-identity-model.md §D
65
+ */
66
+
67
+ import { describe, it, expect } from 'vitest';
68
+ import { randomUUID } from 'node:crypto';
69
+ import { driver } from '../lib/driver.js';
70
+
71
+ const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
72
+
73
+ interface DiscoveryDoc {
74
+ capabilities?: {
75
+ prompts?: {
76
+ supported?: unknown;
77
+ };
78
+ };
79
+ }
80
+
81
+ interface PromptListResponse {
82
+ templates?: unknown;
83
+ }
84
+
85
+ async function readDiscovery(): Promise<DiscoveryDoc | null> {
86
+ try {
87
+ const res = await driver.get('/.well-known/openwop');
88
+ if (res.status !== 200) return null;
89
+ return res.json as DiscoveryDoc;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ describe.skipIf(HTTP_SKIP)(
96
+ 'prompt-read-workspace-membership-enforced: workspace-scoped reads MUST NOT leak templates from another workspace (RFC 0028 Tier-2)',
97
+ () => {
98
+ it('GET /v1/prompts?workspaceId=<non-member> MUST refuse OR return empty templates[] — never another workspace\'s content', async (ctx) => {
99
+ const d = await readDiscovery();
100
+ if (d === null) {
101
+ ctx.skip();
102
+ return;
103
+ }
104
+ const promptsSupported = d.capabilities?.prompts?.supported;
105
+ if (promptsSupported !== true) {
106
+ ctx.skip();
107
+ return;
108
+ }
109
+
110
+ const nonMemberWorkspaceId =
111
+ process.env.OPENWOP_TEST_NONMEMBER_WORKSPACE_ID ??
112
+ `openwop-conformance-nonmember-${randomUUID()}`;
113
+
114
+ const res = await driver.get(
115
+ `/v1/prompts?workspaceId=${encodeURIComponent(nonMemberWorkspaceId)}`,
116
+ );
117
+
118
+ // 4xx — refused. Acceptable shape for the membership-required failure
119
+ // (and any other refusal mode the host chooses: 401, 404 for
120
+ // existence-disclosure avoidance, etc).
121
+ if (res.status >= 400 && res.status < 500) {
122
+ // Canonical envelope on 403 per rest-endpoints.md §"Common error codes".
123
+ if (res.status === 403) {
124
+ const body = res.json as { error?: unknown } | null;
125
+ expect(
126
+ body?.error,
127
+ driver.describe(
128
+ 'spec/v1/rest-endpoints.md §Common error codes — workspace_membership_required',
129
+ `403 refusal of a workspace-scoped read MUST carry error: "workspace_membership_required"; got error: ${JSON.stringify(body?.error)}`,
130
+ ),
131
+ ).toBe('workspace_membership_required');
132
+ }
133
+ return;
134
+ }
135
+
136
+ // 5xx — refused (infrastructure failure is acceptable; envelope shape
137
+ // unconstrained).
138
+ if (res.status >= 500) return;
139
+
140
+ // 2xx — must inspect the response body. The failure mode this
141
+ // invariant guards against is a 200 response that LEAKS templates
142
+ // from a workspace the principal isn't a member of.
143
+ if (res.status >= 200 && res.status < 300) {
144
+ const body = res.json as PromptListResponse | null;
145
+ if (
146
+ body === null ||
147
+ typeof body !== 'object' ||
148
+ !('templates' in body)
149
+ ) {
150
+ // Host doesn't recognize `?workspaceId=` on this endpoint
151
+ // (response shape doesn't include the documented `templates[]`
152
+ // field). Soft-skip: this scenario probes hosts that expose
153
+ // workspace-scoped reads, and a host without that surface is
154
+ // simply out of scope.
155
+ ctx.skip();
156
+ return;
157
+ }
158
+ const templates = body.templates;
159
+ if (!Array.isArray(templates)) {
160
+ // Same: unrecognized shape, skip.
161
+ ctx.skip();
162
+ return;
163
+ }
164
+
165
+ // A random non-member workspaceId can never legitimately contain
166
+ // templates the caller is authorized to see. Any non-empty result
167
+ // is a cross-tenant data leak.
168
+ expect(
169
+ templates.length,
170
+ driver.describe(
171
+ 'spec/v1/prompts.md §Workspace membership on workspace-scoped reads and writes',
172
+ `GET /v1/prompts?workspaceId=<random-non-member> MUST NOT return any templates; got ${templates.length} templates which is a cross-tenant data leak (the random workspaceId is freshly generated per probe and cannot legitimately contain authorized content)`,
173
+ ),
174
+ ).toBe(0);
175
+ return;
176
+ }
177
+
178
+ // Other status codes (1xx, 3xx) — soft-skip with note. Not a clear
179
+ // signal either way.
180
+ ctx.skip();
181
+ });
182
+ },
183
+ );
@@ -100,7 +100,10 @@ describe('redaction: /.well-known/openwop secrets+aiProviders shape contract', (
100
100
  'when secrets.supported is true, scopes MUST be non-empty',
101
101
  )).toBeGreaterThanOrEqual(1);
102
102
  for (const scope of scopes) {
103
- expect(['tenant', 'user', 'run']).toContain(scope);
103
+ // Allowlist MUST track the `secrets.scopes` enum in capabilities.schema.json
104
+ // (`["tenant", "user", "run", "workspace"]`). `workspace` is the RFC 0046/0048
105
+ // sub-tenant scope — additive; hosts that advertise it (e.g. MyndHyve) are conformant.
106
+ expect(['tenant', 'user', 'run', 'workspace']).toContain(scope);
104
107
  }
105
108
  expect(s.resolution, driver.describe(
106
109
  'capabilities.md §"Secrets"',