@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
@@ -71,6 +71,7 @@
71
71
  "run.paused",
72
72
  "run.resumed",
73
73
  "run.restored-from-snapshot",
74
+ "run.dead_lettered",
74
75
  "node.started",
75
76
  "node.completed",
76
77
  "node.failed",
@@ -82,6 +83,9 @@
82
83
  "node.cancelled",
83
84
  "approval.requested",
84
85
  "approval.received",
86
+ "approval.granted",
87
+ "approval.rejected",
88
+ "approval.overridden",
85
89
  "clarification.requested",
86
90
  "clarification.resolved",
87
91
  "interrupt.requested",
@@ -126,7 +130,10 @@
126
130
  "conversation.closed",
127
131
  "memory.compacted",
128
132
  "core.workflowChain.event",
129
- "core.workflowChain.confidence-escalated"
133
+ "core.workflowChain.confidence-escalated",
134
+ "connector.authorized",
135
+ "connector.auth_expired",
136
+ "authorization.decided"
130
137
  ]
131
138
  }
132
139
  }
@@ -32,6 +32,17 @@
32
32
  ],
33
33
  "description": "Current run state. `waiting-external` MUST be used when the suspended interrupt's `kind` is `external-event` per `interrupt-profiles.md §openwop-interrupt-external-event` — distinguishes external-event waits from HITL waits at the wire level. Forward-compat: future statuses MAY be added; readers SHOULD treat unknown values as terminal-unknown rather than throw."
34
34
  },
35
+ "owner": {
36
+ "type": "object",
37
+ "description": "RFC 0048. The identity triple that owns this run. Redaction-safe — `principal` is an opaque identifier, never PII or credential material. Optional: single-tenant hosts omit it. A principal scoped to one `workspace` MUST NOT read a run owned by another (`run_forbidden`).",
38
+ "required": ["tenant"],
39
+ "properties": {
40
+ "tenant": { "type": "string", "minLength": 1, "description": "Top-level isolation boundary." },
41
+ "workspace": { "type": "string", "minLength": 1, "description": "Optional sub-tenant within the tenant (RFC 0048 workspace)." },
42
+ "principal": { "type": "string", "minLength": 1, "description": "Acting identity (user or agent) — opaque id, never PII." }
43
+ },
44
+ "additionalProperties": false
45
+ },
35
46
  "currentNodeId": {
36
47
  "type": "string",
37
48
  "description": "Set when the run is suspended at a specific node (`waiting-approval` / `waiting-input` / `waiting-external`) — identifies which node holds the interrupt."
@@ -105,3 +105,54 @@ export function behaviorGate(profileName: string, advertised: boolean): boolean
105
105
  );
106
106
  return false;
107
107
  }
108
+
109
+ /**
110
+ * RFC 0042 §D — experimental-tier soft-skip router for capability-gated
111
+ * scenarios. Wraps `behaviorGate` with an additional tier check.
112
+ *
113
+ * Returns true if the scenario should proceed with assertions, false if
114
+ * it should skip:
115
+ * - tier === 'experimental' AND OPENWOP_REQUIRE_EXPERIMENTAL not set → soft-skip
116
+ * (the scenario does NOT count toward "Failed" or "Skipped (capability-gated)"
117
+ * in the four-bucket taxonomy — a new fifth bucket "Skipped (experimental)"
118
+ * SHOULD be tallied by reporters; logged via the dedicated console.warn below.)
119
+ * - tier === 'experimental' AND OPENWOP_REQUIRE_EXPERIMENTAL=true → behave as
120
+ * stable (hand off to behaviorGate; honor OPENWOP_REQUIRE_BEHAVIOR + opt-out).
121
+ * - tier === 'stable' (default) → identical to behaviorGate.
122
+ *
123
+ * The `experimentalUntil` ISO-8601 date is logged for telemetry but does NOT
124
+ * gate behavior — the spec contract is that the wire shape MAY shift before
125
+ * the date, not that the scenario MUST stop running.
126
+ */
127
+ export function experimentalGate(
128
+ profileName: string,
129
+ advertised: boolean,
130
+ tier: 'stable' | 'experimental' | undefined,
131
+ experimentalUntil?: string,
132
+ ): boolean {
133
+ if (tier === 'experimental') {
134
+ const env = loadEnv();
135
+ const requireExperimental = process.env.OPENWOP_REQUIRE_EXPERIMENTAL === 'true';
136
+ if (!requireExperimental) {
137
+ // eslint-disable-next-line no-console
138
+ console.warn(
139
+ `[experimental capability: ${profileName}] host advertises tier='experimental'` +
140
+ (experimentalUntil ? ` until ${experimentalUntil}` : '') +
141
+ `; scenario skipped under default mode (set OPENWOP_REQUIRE_EXPERIMENTAL=true to run)`,
142
+ );
143
+ return false;
144
+ }
145
+ // Strict-mode experimental: hand off to behaviorGate with the standard
146
+ // advertised+opt-out rules. A host that advertises tier='experimental'
147
+ // AND opts the profile out via OPENWOP_OPTED_OUT_PROFILES would surface
148
+ // the standard advertise+opt-out conflict warning from behaviorGate.
149
+ // Don't strip the env.requireBehavior flag — that's a separate axis.
150
+ // eslint-disable-next-line no-console
151
+ console.warn(
152
+ `[experimental capability: ${profileName}] OPENWOP_REQUIRE_EXPERIMENTAL=true; ` +
153
+ `running as strict assertion against advertised='${advertised}'`,
154
+ );
155
+ void env;
156
+ }
157
+ return behaviorGate(profileName, advertised);
158
+ }
package/src/lib/driver.ts CHANGED
@@ -50,7 +50,19 @@ class OpenWOPDriver {
50
50
 
51
51
  const fetchInit: RequestInit = { method, headers };
52
52
  if (init.body !== undefined) {
53
- fetchInit.body = JSON.stringify(init.body);
53
+ // Buffer / Uint8Array bodies are sent as raw bytes — needed by the
54
+ // RFC 0025 test-mode publish scenarios so the host's body-shape
55
+ // check sees the bytes the caller actually wrote (rather than a
56
+ // JSON-stringified `{"type":"Buffer","data":[...]}` envelope).
57
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(init.body)) {
58
+ fetchInit.body = new Uint8Array(init.body);
59
+ } else if (init.body instanceof Uint8Array) {
60
+ fetchInit.body = init.body;
61
+ } else if (typeof init.body === 'string') {
62
+ fetchInit.body = init.body;
63
+ } else {
64
+ fetchInit.body = JSON.stringify(init.body);
65
+ }
54
66
  }
55
67
  const res = await fetch(url, fetchInit);
56
68
 
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared helpers for the RFC 0056 feedback/annotation conformance scenarios.
3
+ * Lives in lib/ (not a *.test.ts) so the scenarios can import it via the
4
+ * standard `../lib/feedback.js` path.
5
+ */
6
+ import { driver } from './driver.js';
7
+ import { isFixtureAdvertised } from './fixtures.js';
8
+
9
+ interface DiscoveryDoc {
10
+ capabilities?: Record<string, unknown>;
11
+ }
12
+
13
+ /** Reads `capabilities.feedback` from discovery; null when unadvertised. */
14
+ export async function readFeedbackCap(): Promise<Record<string, unknown> | null> {
15
+ const res = await driver.get('/.well-known/openwop');
16
+ const body = res.json as DiscoveryDoc | undefined;
17
+ const top = body?.capabilities as Record<string, unknown> | undefined;
18
+ const fb = top && typeof top === 'object' ? (top as Record<string, unknown>)['feedback'] : undefined;
19
+ return fb && typeof fb === 'object' ? (fb as Record<string, unknown>) : null;
20
+ }
21
+
22
+ const SEED_FIXTURE = 'conformance-a';
23
+
24
+ /** Seeds a run via the basic `conformance-a` fixture; null (soft-skip) when
25
+ * the fixture isn't advertised or creation fails. */
26
+ export async function seedRun(tenantId: string): Promise<string | null> {
27
+ if (!isFixtureAdvertised(SEED_FIXTURE)) return null;
28
+ const r = await driver.post('/v1/runs', { workflowId: SEED_FIXTURE, tenantId, inputs: {} });
29
+ if (r.status !== 200 && r.status !== 201) return null;
30
+ return (r.json as { runId?: string } | undefined)?.runId ?? null;
31
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Synthetic SAML 2.0 IdP for conformance scenarios (RFC 0050).
3
+ *
4
+ * Mints SAML assertions — a valid signed one plus the negative variants
5
+ * the `openwop-auth-saml` profile requires hosts to reject — and exposes
6
+ * the signing certificate a trusting host configures. Hermetic: uses only
7
+ * `node:crypto` stdlib (RSA-SHA256), no npm dependencies, no XML library.
8
+ *
9
+ * Scope: this harness is a wire-shape + validation-logic reference, NOT a
10
+ * full XML-DSig stack. It produces a controlled, fixed-shape assertion
11
+ * template and signs an enveloped digest of it with RSA-SHA256; `verify()`
12
+ * implements exactly the RFC 0050 §A MUST list (signature present + valid,
13
+ * `alg:none` rejected, validity window enforced, signature-wrapping
14
+ * rejected) so the suite can assert each negative variant is detectably
15
+ * malformed. A host's real SAML ACS validates the same assertions over the
16
+ * `auth/saml/validate` test seam; sign/verify here are mutually consistent
17
+ * by construction (the harness owns both the serialization and the digest).
18
+ *
19
+ * @see RFCS/0050-saml-scim-enterprise-identity-profiles.md §A
20
+ * @see spec/v1/auth-profiles.md §`openwop-auth-saml`
21
+ */
22
+
23
+ import {
24
+ createSign,
25
+ createVerify,
26
+ createHash,
27
+ generateKeyPairSync,
28
+ } from 'node:crypto';
29
+
30
+ /** The assertion variants the conformance suite exercises (1 positive + 6 negatives). */
31
+ export type SamlVariant =
32
+ | 'valid'
33
+ | 'alg-none'
34
+ | 'bad-signature'
35
+ | 'unsigned'
36
+ | 'expired'
37
+ | 'not-yet-valid'
38
+ | 'signature-wrapping';
39
+
40
+ export interface SamlVerifyResult {
41
+ /** True only for a well-formed, signed, in-window, non-wrapped assertion. */
42
+ readonly valid: boolean;
43
+ /** Machine-readable rejection cause; `null` when `valid`. */
44
+ readonly reason:
45
+ | null
46
+ | 'unsigned'
47
+ | 'alg-none'
48
+ | 'bad-signature'
49
+ | 'expired'
50
+ | 'not-yet-valid'
51
+ | 'signature-wrapping'
52
+ | 'malformed';
53
+ }
54
+
55
+ export interface SyntheticSamlIdp {
56
+ /** PEM signing certificate (public key) the host configures to trust this IdP. */
57
+ readonly certificatePem: string;
58
+ /** Mint a SAML assertion of the given variant. */
59
+ mint(variant: SamlVariant, opts?: { subject?: string }): string;
60
+ /** Validate an assertion per the RFC 0050 §A MUST list. */
61
+ verify(assertionXml: string): SamlVerifyResult;
62
+ }
63
+
64
+ const SIG_ALG_RSA_SHA256 = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256';
65
+ const SIG_ALG_NONE = 'http://www.w3.org/2000/09/xmldsig#none';
66
+
67
+ function digest(input: string): string {
68
+ return createHash('sha256').update(input, 'utf8').digest('base64');
69
+ }
70
+
71
+ /** Deterministic canonical form of the signed element: the harness controls
72
+ * the exact byte string, so sign/verify agree without a full C14N stack. */
73
+ function canonicalAssertion(id: string, subject: string, notBefore: string, notOnOrAfter: string): string {
74
+ return (
75
+ `<saml:Assertion ID="${id}" Version="2.0">` +
76
+ `<saml:Conditions NotBefore="${notBefore}" NotOnOrAfter="${notOnOrAfter}"/>` +
77
+ `<saml:Subject><saml:NameID>${subject}</saml:NameID></saml:Subject>` +
78
+ `</saml:Assertion>`
79
+ );
80
+ }
81
+
82
+ export function createSyntheticSamlIdp(): SyntheticSamlIdp {
83
+ const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
84
+ const certificatePem = publicKey.export({ format: 'pem', type: 'spki' }).toString();
85
+
86
+ function sign(canonical: string): string {
87
+ return createSign('RSA-SHA256').update(canonical, 'utf8').sign(privateKey, 'base64');
88
+ }
89
+
90
+ function envelope(parts: {
91
+ id: string;
92
+ subject: string;
93
+ notBefore: string;
94
+ notOnOrAfter: string;
95
+ sigAlg: string;
96
+ signatureValue: string | null;
97
+ refId: string; // the ID the <Reference> points at (≠ id ⇒ wrapping)
98
+ extraInjected?: string; // an unsigned injected assertion (wrapping attack)
99
+ }): string {
100
+ const inner = canonicalAssertion(parts.id, parts.subject, parts.notBefore, parts.notOnOrAfter);
101
+ const sig =
102
+ parts.signatureValue === null
103
+ ? ''
104
+ : `<ds:Signature>` +
105
+ `<ds:SignedInfo><ds:SignatureMethod Algorithm="${parts.sigAlg}"/>` +
106
+ `<ds:Reference URI="#${parts.refId}"><ds:DigestValue>${digest(inner)}</ds:DigestValue></ds:Reference>` +
107
+ `</ds:SignedInfo><ds:SignatureValue>${parts.signatureValue}</ds:SignatureValue></ds:Signature>`;
108
+ return `<samlp:Response>${parts.extraInjected ?? ''}${inner.replace('</saml:Assertion>', `${sig}</saml:Assertion>`)}</samlp:Response>`;
109
+ }
110
+
111
+ function mint(variant: SamlVariant, opts?: { subject?: string }): string {
112
+ const id = 'a-' + variant;
113
+ const subject = opts?.subject ?? 'user_42@example.com-opaque';
114
+ const now = Date.now();
115
+ const iso = (ms: number): string => new Date(ms).toISOString();
116
+ const past = iso(now - 3_600_000);
117
+ const future = iso(now + 3_600_000);
118
+ const canonical = canonicalAssertion(id, subject, past, future);
119
+
120
+ switch (variant) {
121
+ case 'valid':
122
+ return envelope({ id, subject, notBefore: past, notOnOrAfter: future, sigAlg: SIG_ALG_RSA_SHA256, signatureValue: sign(canonical), refId: id });
123
+ case 'unsigned':
124
+ return envelope({ id, subject, notBefore: past, notOnOrAfter: future, sigAlg: SIG_ALG_RSA_SHA256, signatureValue: null, refId: id });
125
+ case 'alg-none':
126
+ return envelope({ id, subject, notBefore: past, notOnOrAfter: future, sigAlg: SIG_ALG_NONE, signatureValue: '', refId: id });
127
+ case 'bad-signature':
128
+ return envelope({ id, subject, notBefore: past, notOnOrAfter: future, sigAlg: SIG_ALG_RSA_SHA256, signatureValue: Buffer.from('forged').toString('base64'), refId: id });
129
+ case 'expired': {
130
+ const c = canonicalAssertion(id, subject, iso(now - 7_200_000), past);
131
+ return envelope({ id, subject, notBefore: iso(now - 7_200_000), notOnOrAfter: past, sigAlg: SIG_ALG_RSA_SHA256, signatureValue: sign(c), refId: id });
132
+ }
133
+ case 'not-yet-valid': {
134
+ const c = canonicalAssertion(id, subject, future, iso(now + 7_200_000));
135
+ return envelope({ id, subject, notBefore: future, notOnOrAfter: iso(now + 7_200_000), sigAlg: SIG_ALG_RSA_SHA256, signatureValue: sign(c), refId: id });
136
+ }
137
+ case 'signature-wrapping': {
138
+ // Signature validly covers a benign assertion (refId = benign), but a
139
+ // second, attacker-injected assertion with a different Subject is what
140
+ // a naive consumer reads. The signed element ≠ the consumed element.
141
+ const benign = 'a-benign';
142
+ const benignCanonical = canonicalAssertion(benign, subject, past, future);
143
+ const injected = canonicalAssertion(id, 'attacker@evil.example-opaque', past, future);
144
+ const sig =
145
+ `<ds:Signature><ds:SignedInfo><ds:SignatureMethod Algorithm="${SIG_ALG_RSA_SHA256}"/>` +
146
+ `<ds:Reference URI="#${benign}"><ds:DigestValue>${digest(benignCanonical)}</ds:DigestValue></ds:Reference>` +
147
+ `</ds:SignedInfo><ds:SignatureValue>${sign(benignCanonical)}</ds:SignatureValue></ds:Signature>`;
148
+ // Consumed (first) assertion carries the signature but is the INJECTED one.
149
+ return `<samlp:Response>${injected.replace('</saml:Assertion>', `${sig}</saml:Assertion>`)}${benignCanonical}</samlp:Response>`;
150
+ }
151
+ }
152
+ }
153
+
154
+ function verify(assertionXml: string): SamlVerifyResult {
155
+ const sigAlg = /<ds:SignatureMethod Algorithm="([^"]+)"/.exec(assertionXml)?.[1];
156
+ const sigValue = /<ds:SignatureValue>([^<]*)<\/ds:SignatureValue>/.exec(assertionXml)?.[1];
157
+ const refId = /<ds:Reference URI="#([^"]+)"/.exec(assertionXml)?.[1];
158
+ // The consumed assertion is the FIRST <saml:Assertion> in the response.
159
+ const consumed = /<saml:Assertion ID="([^"]+)"[^>]*>[\s\S]*?<saml:Conditions NotBefore="([^"]+)" NotOnOrAfter="([^"]+)"\/>[\s\S]*?<saml:NameID>([^<]*)<\/saml:NameID>/.exec(assertionXml);
160
+ if (consumed === null) return { valid: false, reason: 'malformed' };
161
+ const [, consumedId, notBefore, notOnOrAfter, subject] = consumed;
162
+
163
+ if (sigValue === undefined || sigAlg === undefined) return { valid: false, reason: 'unsigned' };
164
+ if (sigAlg === SIG_ALG_NONE) return { valid: false, reason: 'alg-none' };
165
+ // Anti-wrapping: the signature MUST reference the consumed assertion.
166
+ if (refId !== consumedId) return { valid: false, reason: 'signature-wrapping' };
167
+
168
+ const canonical = canonicalAssertion(consumedId, subject, notBefore, notOnOrAfter);
169
+ const ok = createVerify('RSA-SHA256').update(canonical, 'utf8').verify(publicKey, sigValue, 'base64');
170
+ if (!ok) return { valid: false, reason: 'bad-signature' };
171
+
172
+ const now = Date.now();
173
+ if (now < Date.parse(notBefore)) return { valid: false, reason: 'not-yet-valid' };
174
+ if (now >= Date.parse(notOnOrAfter)) return { valid: false, reason: 'expired' };
175
+ return { valid: true, reason: null };
176
+ }
177
+
178
+ return { certificatePem, mint, verify };
179
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * approval-gate-events — RFC 0051 §B event-shape verification.
3
+ *
4
+ * Status: DRAFT. RFC 0051 (approval & deployment-gate primitive) is `Draft`.
5
+ * The `approval.granted` / `approval.rejected` / `approval.overridden` event
6
+ * payloads have landed in `schemas/run-event-payloads.schema.json` (+ the
7
+ * `RunEventType` enum).
8
+ *
9
+ * Server-free schema validation of the three governance events:
10
+ * - granted: requires `{ gateId, principal }`; optional `quorumProgress`.
11
+ * - rejected: requires `{ gateId, principal }`; optional `reason`.
12
+ * - overridden: requires `{ gateId, principal, reason }` (reason mandatory —
13
+ * the audit breadcrumb).
14
+ * - each rejects unknown properties (additionalProperties:false).
15
+ *
16
+ * @see RFCS/0051-approval-deployment-gate-primitive.md
17
+ * @see spec/v1/interrupt-profiles.md §`core.openwop.governance.approvalGate`
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { readFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import Ajv2020 from 'ajv/dist/2020.js';
24
+ import { SCHEMAS_DIR } from '../lib/paths.js';
25
+
26
+ interface PayloadsSchema {
27
+ $schema: string;
28
+ $defs: Record<string, Record<string, unknown>>;
29
+ }
30
+
31
+ const payloads = JSON.parse(
32
+ readFileSync(join(SCHEMAS_DIR, 'run-event-payloads.schema.json'), 'utf8'),
33
+ ) as PayloadsSchema;
34
+
35
+ function compile(defName: string) {
36
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
37
+ return ajv.compile({ $schema: payloads.$schema, ...payloads.$defs[defName] });
38
+ }
39
+
40
+ describe('category: approval-gate governance events (RFC 0051 §B)', () => {
41
+ it('approval.granted requires gateId + principal; quorumProgress optional', () => {
42
+ const v = compile('approvalGranted');
43
+ expect(v({ gateId: 'g1', principal: 'user_1' }), JSON.stringify(v.errors)).toBe(true);
44
+ expect(v({ gateId: 'g1', principal: 'user_1', quorumProgress: { granted: 1, required: 2 } })).toBe(true);
45
+ expect(v({ gateId: 'g1' })).toBe(false); // missing principal
46
+ expect(v({ gateId: 'g1', principal: 'user_1', role: 'admin' })).toBe(false); // unknown prop
47
+ });
48
+
49
+ it('approval.rejected requires gateId + principal; reason optional', () => {
50
+ const v = compile('approvalRejected');
51
+ expect(v({ gateId: 'g1', principal: 'user_1' }), JSON.stringify(v.errors)).toBe(true);
52
+ expect(v({ gateId: 'g1', principal: 'user_1', reason: 'incomplete' })).toBe(true);
53
+ expect(v({ principal: 'user_1' })).toBe(false); // missing gateId
54
+ });
55
+
56
+ it('approval.overridden requires gateId + principal + reason (audit breadcrumb)', () => {
57
+ const v = compile('approvalOverridden');
58
+ expect(v({ gateId: 'g1', principal: 'owner_1', reason: 'emergency publish' }), JSON.stringify(v.errors)).toBe(true);
59
+ expect(v({ gateId: 'g1', principal: 'owner_1' })).toBe(false); // reason MUST be present
60
+ });
61
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * approval-gate-flow — RFC 0051 §A behavioral verification.
3
+ *
4
+ * Status: DRAFT. RFC 0051 (approval & deployment-gate primitive) is `Draft`.
5
+ *
6
+ * Capability-gated: the `core.openwop.governance.approvalGate` node requires
7
+ * a host advertising `capabilities.authorization.supported = true`
8
+ * (peerDependency `authorization: 'supported'`). Skips otherwise.
9
+ *
10
+ * What this scenario asserts (via the optional
11
+ * `POST /v1/host/sample/governance/approval-gate` seam):
12
+ * 1. Unauthorized principal — a principal lacking `requiredRole`/`requiredScope`
13
+ * is denied; the gate does NOT release (fail-closed, RFC 0049 §C).
14
+ * 2. Override is audited — taking the role-gated `override` path returns an
15
+ * `approval.overridden` event whose `reason` is present.
16
+ *
17
+ * Hosts without the seam soft-skip the behavioral probes (404).
18
+ *
19
+ * @see RFCS/0051-approval-deployment-gate-primitive.md
20
+ * @see spec/v1/interrupt-profiles.md §`core.openwop.governance.approvalGate`
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest';
24
+ import { driver } from '../lib/driver.js';
25
+
26
+ interface DiscoveryDoc {
27
+ capabilities?: { authorization?: { supported?: boolean } };
28
+ }
29
+
30
+ async function authorizationSupported(): Promise<boolean> {
31
+ const res = await driver.get('/.well-known/openwop');
32
+ return (res.json as DiscoveryDoc | undefined)?.capabilities?.authorization?.supported === true;
33
+ }
34
+
35
+ describe('approval-gate-flow: role-gated, audited approval (RFC 0051 §A)', () => {
36
+ it('an unauthorized principal does NOT release the gate (fail-closed)', async () => {
37
+ if (!(await authorizationSupported())) return; // capability-gated
38
+ const res = await driver.post('/v1/host/sample/governance/approval-gate', {
39
+ scenario: 'unauthorized-grant',
40
+ principal: 'conformance-unauthorized-principal',
41
+ });
42
+ if (res.status === 404) return; // seam unwired — soft-skip
43
+ const body = res.json as { released?: boolean } | undefined;
44
+ expect(
45
+ body?.released,
46
+ driver.describe('RFC 0051 §A', 'an unauthorized principal MUST NOT release the gate (fail-closed)'),
47
+ ).toBe(false);
48
+ });
49
+
50
+ it('the override path emits an audited approval.overridden with a reason', async () => {
51
+ if (!(await authorizationSupported())) return; // capability-gated
52
+ const res = await driver.post('/v1/host/sample/governance/approval-gate', {
53
+ scenario: 'override',
54
+ principal: 'conformance-owner-principal',
55
+ reason: 'conformance emergency publish',
56
+ });
57
+ if (res.status === 404) return; // seam unwired — soft-skip
58
+ const body = res.json as { event?: { type?: string; payload?: { reason?: string } } } | undefined;
59
+ expect(
60
+ body?.event?.type,
61
+ driver.describe('RFC 0051 §B', 'taking the override path MUST emit approval.overridden'),
62
+ ).toBe('approval.overridden');
63
+ expect(
64
+ typeof body?.event?.payload?.reason === 'string' && body.event.payload.reason.length > 0,
65
+ driver.describe('RFC 0051 §B', 'approval.overridden MUST carry a non-empty reason (the audit breadcrumb)'),
66
+ ).toBe(true);
67
+ });
68
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * auth-saml-profile — RFC 0050: openwop-auth-saml profile.
3
+ *
4
+ * Status: DRAFT. RFC 0050 (SAML / SCIM enterprise identity profiles) is
5
+ * `Draft`. The profile is documented in `auth-profiles.md`
6
+ * §`openwop-auth-saml` and reserved in `capabilities.auth.profiles`.
7
+ *
8
+ * Capability shape runs unconditionally when the profile is advertised.
9
+ * The assertion-validation behavior (1 positive + ≥6 negatives: bad
10
+ * signature, `alg:none`, absent signature, `NotOnOrAfter` expiry,
11
+ * `NotBefore` not-yet-valid, signature-wrapping) is opt-in via
12
+ * `OPENWOP_TEST_SAML_IDP_URL` (operator-supplied synthetic IdP), because
13
+ * a deterministic XML-DSig signer harness isn't bundled yet — follows the
14
+ * `auth-mtls.test.ts` opt-in precedent. Soft-skips otherwise.
15
+ *
16
+ * @see RFCS/0050-saml-scim-enterprise-identity-profiles.md
17
+ * @see spec/v1/auth-profiles.md §`openwop-auth-saml`
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { driver } from '../lib/driver.js';
22
+ import { createSyntheticSamlIdp, type SamlVariant } from '../lib/saml-idp.js';
23
+
24
+ const SAML_PROFILE = 'openwop-auth-saml';
25
+
26
+ interface DiscoveryAuth {
27
+ profiles?: string[];
28
+ }
29
+
30
+ interface DiscoveryDoc {
31
+ capabilities?: { auth?: DiscoveryAuth };
32
+ extensions?: { auth?: DiscoveryAuth };
33
+ }
34
+
35
+ async function readProfiles(): Promise<string[] | null> {
36
+ const res = await driver.get('/.well-known/openwop');
37
+ const body = res.json as DiscoveryDoc | undefined;
38
+ return body?.capabilities?.auth?.profiles ?? body?.extensions?.auth?.profiles ?? null;
39
+ }
40
+
41
+ describe('auth-saml-profile: advertisement shape (RFC 0050)', () => {
42
+ it('auth.profiles, when present, is an array of non-empty strings', async () => {
43
+ const profiles = await readProfiles();
44
+ if (profiles === null) return; // host advertises no auth profiles
45
+ expect(
46
+ Array.isArray(profiles),
47
+ driver.describe('auth-profiles.md §Discovery', 'capabilities.auth.profiles MUST be an array'),
48
+ ).toBe(true);
49
+ for (const p of profiles) {
50
+ expect(typeof p === 'string' && p.length > 0).toBe(true);
51
+ }
52
+ });
53
+
54
+ it('claims openwop-auth-saml as a well-formed profile id when advertised', async () => {
55
+ const profiles = await readProfiles();
56
+ if (profiles === null || !profiles.includes(SAML_PROFILE)) return; // profile not claimed
57
+ expect(
58
+ profiles.includes(SAML_PROFILE),
59
+ driver.describe('RFC 0050 §A', 'openwop-auth-saml MUST appear verbatim in capabilities.auth.profiles when claimed'),
60
+ ).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe('auth-saml-profile: assertion validation (RFC 0050 §A — opt-in)', () => {
65
+ const idpUrl = process.env.OPENWOP_TEST_SAML_IDP_URL;
66
+
67
+ it('rejects an `alg:none` / unsigned assertion (synthetic IdP required)', async () => {
68
+ const profiles = await readProfiles();
69
+ if (profiles === null || !profiles.includes(SAML_PROFILE)) return; // capability-gated
70
+ if (idpUrl === undefined || idpUrl.length === 0) return; // opt-in: synthetic-IdP harness not provided
71
+ // With a synthetic IdP, an `alg:none`/unsigned assertion presented to the
72
+ // host's SAML ACS MUST be rejected with `unauthenticated`.
73
+ const res = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl, variant: 'alg-none' });
74
+ if (res.status === 404) return; // seam unwired
75
+ expect(
76
+ res.status,
77
+ driver.describe('RFC 0050 §A', 'an `alg:none`/unsigned SAML assertion MUST be rejected (non-2xx)'),
78
+ ).toBeGreaterThanOrEqual(400);
79
+ });
80
+ });
81
+
82
+ describe('category: auth-saml synthetic-IdP reference suite (RFC 0050 §A)', () => {
83
+ // Server-free: the bundled synthetic IdP (conformance/src/lib/saml-idp.ts)
84
+ // mints a valid assertion + the 6 negative variants, and its verify()
85
+ // implements the RFC 0050 §A MUST list. This proves each negative is
86
+ // detectably malformed and gives the suite a reference SAML validator.
87
+ // A host's real ACS validates the SAME assertions over the
88
+ // `auth/saml/validate` seam (gated above on OPENWOP_TEST_SAML_IDP_URL).
89
+ const idp = createSyntheticSamlIdp();
90
+
91
+ it('publishes a PEM signing certificate', () => {
92
+ expect(idp.certificatePem).toContain('BEGIN PUBLIC KEY');
93
+ });
94
+
95
+ it('accepts a valid signed, in-window, non-wrapped assertion', () => {
96
+ const r = idp.verify(idp.mint('valid'));
97
+ expect(r.valid, `expected valid; got reason=${r.reason}`).toBe(true);
98
+ });
99
+
100
+ const negatives: ReadonlyArray<[Exclude<SamlVariant, 'valid'>, string]> = [
101
+ ['alg-none', 'alg-none'],
102
+ ['unsigned', 'unsigned'],
103
+ ['bad-signature', 'bad-signature'],
104
+ ['expired', 'expired'],
105
+ ['not-yet-valid', 'not-yet-valid'],
106
+ ['signature-wrapping', 'signature-wrapping'],
107
+ ];
108
+
109
+ for (const [variant, expectedReason] of negatives) {
110
+ it(`rejects the ${variant} assertion (RFC 0050 §A MUST)`, () => {
111
+ const r = idp.verify(idp.mint(variant));
112
+ expect(r.valid, `${variant} MUST be rejected`).toBe(false);
113
+ expect(
114
+ r.reason,
115
+ `${variant} MUST be rejected for the ${expectedReason} reason`,
116
+ ).toBe(expectedReason);
117
+ });
118
+ }
119
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * auth-scim-profile — RFC 0050: openwop-auth-scim profile.
3
+ *
4
+ * Status: DRAFT. RFC 0050 (SAML / SCIM enterprise identity profiles) is
5
+ * `Draft`. The profile is documented in `auth-profiles.md`
6
+ * §`openwop-auth-scim` and reserved in `capabilities.auth.profiles`.
7
+ *
8
+ * Capability shape runs unconditionally when the profile is advertised.
9
+ * The provisioning roundtrip (SCIM user+group → RFC 0048 principal +
10
+ * RFC 0049 role; deactivate ⇒ subsequent deny) is opt-in via
11
+ * `OPENWOP_TEST_SCIM_URL` (operator-supplied SCIM endpoint), following the
12
+ * `auth-mtls.test.ts` opt-in precedent. Soft-skips otherwise.
13
+ *
14
+ * @see RFCS/0050-saml-scim-enterprise-identity-profiles.md
15
+ * @see spec/v1/auth-profiles.md §`openwop-auth-scim`
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { driver } from '../lib/driver.js';
20
+
21
+ const SCIM_PROFILE = 'openwop-auth-scim';
22
+
23
+ interface DiscoveryAuth {
24
+ profiles?: string[];
25
+ }
26
+
27
+ interface DiscoveryDoc {
28
+ capabilities?: { auth?: DiscoveryAuth };
29
+ extensions?: { auth?: DiscoveryAuth };
30
+ }
31
+
32
+ async function readProfiles(): Promise<string[] | null> {
33
+ const res = await driver.get('/.well-known/openwop');
34
+ const body = res.json as DiscoveryDoc | undefined;
35
+ return body?.capabilities?.auth?.profiles ?? body?.extensions?.auth?.profiles ?? null;
36
+ }
37
+
38
+ describe('auth-scim-profile: advertisement shape (RFC 0050)', () => {
39
+ it('claims openwop-auth-scim as a well-formed profile id when advertised', async () => {
40
+ const profiles = await readProfiles();
41
+ if (profiles === null || !profiles.includes(SCIM_PROFILE)) return; // profile not claimed
42
+ expect(
43
+ profiles.includes(SCIM_PROFILE),
44
+ driver.describe('RFC 0050 §B', 'openwop-auth-scim MUST appear verbatim in capabilities.auth.profiles when claimed'),
45
+ ).toBe(true);
46
+ });
47
+ });
48
+
49
+ describe('auth-scim-profile: provisioning roundtrip (RFC 0050 §B — opt-in)', () => {
50
+ const scimUrl = process.env.OPENWOP_TEST_SCIM_URL;
51
+
52
+ it('provisions a SCIM user → principal (SCIM endpoint required)', async () => {
53
+ const profiles = await readProfiles();
54
+ if (profiles === null || !profiles.includes(SCIM_PROFILE)) return; // capability-gated
55
+ if (scimUrl === undefined || scimUrl.length === 0) return; // opt-in: SCIM endpoint not provided
56
+ // A SCIM POST /Users against the operator-supplied endpoint MUST upsert a
57
+ // principal the host can subsequently resolve.
58
+ const res = await driver.post('/v1/host/sample/auth/scim/provision', { scimUrl, op: 'create-user' });
59
+ if (res.status === 404) return; // seam unwired
60
+ expect(
61
+ res.status,
62
+ driver.describe('RFC 0050 §B', 'a SCIM user provisioning MUST succeed (2xx) and upsert an RFC 0048 principal'),
63
+ ).toBeLessThan(400);
64
+ });
65
+ });