@openwop/openwop-conformance 1.5.0 → 1.6.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 +19 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +8 -3
- package/api/openapi.yaml +305 -0
- package/coverage.md +29 -4
- package/fixtures/conformance-phase4-nondet-tool.json +53 -0
- package/fixtures/conformance-phase4-replay-divergence.json +40 -0
- package/fixtures.md +5 -3
- package/package.json +1 -1
- package/schemas/README.md +2 -0
- package/schemas/capabilities.schema.json +167 -3
- package/schemas/credential-reference.schema.json +21 -0
- package/schemas/node-pack-manifest.schema.json +112 -1
- package/schemas/run-diff-response.schema.json +64 -0
- package/schemas/run-event-payloads.schema.json +104 -2
- package/schemas/run-event.schema.json +8 -1
- package/schemas/run-snapshot.schema.json +11 -0
- package/src/lib/behavior-gate.ts +51 -0
- package/src/lib/driver.ts +13 -1
- package/src/lib/saml-idp.ts +179 -0
- package/src/scenarios/approval-gate-events.test.ts +61 -0
- package/src/scenarios/approval-gate-flow.test.ts +68 -0
- package/src/scenarios/auth-saml-profile.test.ts +119 -0
- package/src/scenarios/auth-scim-profile.test.ts +65 -0
- package/src/scenarios/authorization-fail-closed.test.ts +80 -0
- package/src/scenarios/authorization-roles-shape.test.ts +83 -0
- package/src/scenarios/connector-manifest-validity.test.ts +142 -0
- package/src/scenarios/credential-payload-redaction.test.ts +93 -0
- package/src/scenarios/credentials-capability-shape.test.ts +90 -0
- package/src/scenarios/cross-engine-append-behavior.test.ts +204 -0
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +13 -6
- package/src/scenarios/cross-workspace-isolation.test.ts +72 -0
- package/src/scenarios/deadletter-capability-shape.test.ts +59 -0
- package/src/scenarios/deadletter-retry-exhaustion.test.ts +62 -0
- package/src/scenarios/experimental-tier-shape.test.ts +192 -0
- package/src/scenarios/identity-owner-shape.test.ts +64 -0
- package/src/scenarios/multi-agent-confidence-escalation.test.ts +13 -12
- package/src/scenarios/multi-agent-memory-lifecycle.test.ts +87 -12
- package/src/scenarios/multi-region-idempotency-behavior.test.ts +203 -0
- package/src/scenarios/oauth-capability-shape.test.ts +97 -0
- package/src/scenarios/oauth-connector-redaction.test.ts +91 -0
- package/src/scenarios/pack-registry-isolation.test.ts +108 -0
- package/src/scenarios/pack-registry-publish.test.ts +1 -1
- package/src/scenarios/prompt-mutation-workspace-membership-enforced.test.ts +126 -0
- package/src/scenarios/prompt-read-workspace-membership-enforced.test.ts +183 -0
- package/src/scenarios/replay-divergence-at-refusal.test.ts +187 -7
- package/src/scenarios/replay-observable-sequence-determinism.test.ts +20 -6
- package/src/scenarios/run-diff.test.ts +143 -0
- package/src/scenarios/sandbox-capability-gate-respected.test.ts +7 -1
- package/src/scenarios/sandbox-memory-cap.test.ts +7 -5
- package/src/scenarios/sandbox-mvp-behavior.test.ts +280 -0
- package/src/scenarios/sandbox-no-cross-pack-mutation.test.ts +7 -1
- package/src/scenarios/sandbox-no-host-env-leak.test.ts +5 -1
- package/src/scenarios/sandbox-no-host-fs-escape.test.ts +9 -1
- package/src/scenarios/sandbox-no-host-process-escape.test.ts +5 -1
- package/src/scenarios/sandbox-no-network-escape.test.ts +5 -1
- package/src/scenarios/sandbox-timeout-cap.test.ts +7 -5
- package/src/scenarios/scheduling-capability-shape.test.ts +81 -0
- package/src/scenarios/scheduling-cron-fires-once.test.ts +66 -0
- package/src/scenarios/secret-leakage-otel-attribute.test.ts +241 -0
- package/src/scenarios/spec-corpus-validity.test.ts +2 -2
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://openwop.dev/spec/v1/run-event-payloads.schema.json",
|
|
4
4
|
"title": "RunEventPayloads",
|
|
5
|
-
"description": "Per-RunEventType payload schemas. The base RunEventDoc shape (run-event.schema.json) leaves `payload` permissive for forward-compat. This schema defines the canonical payload contract for each known RunEventType. Consumers MAY pin strict payload validation via `$defs.<typeId>` and `ajv.validate(schema.$defs[event.type], event.payload)`. Unknown event types MUST be tolerated (no $defs match → fold best-effort).\n\
|
|
5
|
+
"description": "Per-RunEventType payload schemas. The base RunEventDoc shape (run-event.schema.json) leaves `payload` permissive for forward-compat. This schema defines the canonical payload contract for each known RunEventType. Consumers MAY pin strict payload validation via `$defs.<typeId>` and `ajv.validate(schema.$defs[event.type], event.payload)`. Unknown event types MUST be tolerated (no $defs match → fold best-effort).\n\n71 variants from `run-event.schema.json#$defs.RunEventType` are covered, grouped into ~20 shape families with shared $defs. Naming convention: camelCase keys mirror dotted RunEventType names (e.g., `run.started` → `runStarted`).",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"$defs": {
|
|
8
8
|
"_typeIndex": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"run.paused": { "$ref": "#/$defs/runPaused" },
|
|
19
19
|
"run.resumed": { "$ref": "#/$defs/runResumed" },
|
|
20
20
|
"run.restored-from-snapshot":{ "$ref": "#/$defs/runRestoredFromSnapshot" },
|
|
21
|
+
"run.dead_lettered": { "$ref": "#/$defs/runDeadLettered" },
|
|
21
22
|
"node.started": { "$ref": "#/$defs/nodeStarted" },
|
|
22
23
|
"node.completed": { "$ref": "#/$defs/nodeCompleted" },
|
|
23
24
|
"node.failed": { "$ref": "#/$defs/nodeFailed" },
|
|
@@ -29,6 +30,9 @@
|
|
|
29
30
|
"node.cancelled": { "$ref": "#/$defs/nodeCancelled" },
|
|
30
31
|
"approval.requested": { "$ref": "#/$defs/approvalRequested" },
|
|
31
32
|
"approval.received": { "$ref": "#/$defs/approvalReceived" },
|
|
33
|
+
"approval.granted": { "$ref": "#/$defs/approvalGranted" },
|
|
34
|
+
"approval.rejected": { "$ref": "#/$defs/approvalRejected" },
|
|
35
|
+
"approval.overridden": { "$ref": "#/$defs/approvalOverridden" },
|
|
32
36
|
"clarification.requested": { "$ref": "#/$defs/clarificationRequested" },
|
|
33
37
|
"clarification.resolved": { "$ref": "#/$defs/clarificationResolved" },
|
|
34
38
|
"interrupt.requested": { "$ref": "#/$defs/interruptRequested" },
|
|
@@ -73,7 +77,48 @@
|
|
|
73
77
|
"conversation.closed": { "$ref": "#/$defs/conversationClosed" },
|
|
74
78
|
"memory.compacted": { "$ref": "#/$defs/memoryCompacted" },
|
|
75
79
|
"core.workflowChain.event": { "$ref": "#/$defs/coreWorkflowChainEvent" },
|
|
76
|
-
"core.workflowChain.confidence-escalated": { "$ref": "#/$defs/coreWorkflowChainConfidenceEscalated" }
|
|
80
|
+
"core.workflowChain.confidence-escalated": { "$ref": "#/$defs/coreWorkflowChainConfidenceEscalated" },
|
|
81
|
+
"connector.authorized": { "$ref": "#/$defs/connectorAuthorized" },
|
|
82
|
+
"connector.auth_expired": { "$ref": "#/$defs/connectorAuthExpired" },
|
|
83
|
+
"authorization.decided": { "$ref": "#/$defs/authorizationDecided" }
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
"authorizationDecided": {
|
|
88
|
+
"description": "RFC 0049 — emitted on a role-based authorization decision (allow or deny). Redaction-safe: `principal` is an opaque RFC 0048 id; `reason` carries no credential material. Every deny SHOULD be emitted and SHOULD feed the audit log (RFC 0009/0010). MUST NOT be emitted unless `capabilities.authorization.supported: true`.",
|
|
89
|
+
"type": "object",
|
|
90
|
+
"additionalProperties": false,
|
|
91
|
+
"required": ["principal", "action", "resource", "allowed"],
|
|
92
|
+
"properties": {
|
|
93
|
+
"principal": { "type": "string", "minLength": 1, "description": "Opaque RFC 0048 principal id — never PII." },
|
|
94
|
+
"action": { "type": "string", "minLength": 1, "description": "The attempted action, e.g. `runs:cancel`." },
|
|
95
|
+
"resource": { "type": "string", "minLength": 1, "description": "The target, e.g. a runId or workflowId." },
|
|
96
|
+
"allowed": { "type": "boolean" },
|
|
97
|
+
"reason": { "type": "string", "description": "Human-readable, redaction-safe — no credential material." }
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
"connectorAuthorized": {
|
|
102
|
+
"description": "RFC 0047 — emitted when the host acquires (or re-authorizes) a third-party OAuth token on a user's behalf via the `host.oauth` flow. Redaction-safe: carries the credential REFERENCE (RFC 0046), never token material. MUST NOT be emitted unless `capabilities.oauth.supported: true`.",
|
|
103
|
+
"type": "object",
|
|
104
|
+
"additionalProperties": false,
|
|
105
|
+
"required": ["provider", "credentialRef"],
|
|
106
|
+
"properties": {
|
|
107
|
+
"provider": { "type": "string", "minLength": 1, "description": "Provider id, matching an advertised `capabilities.oauth.providers[].id` (e.g. `slack`, `google`)." },
|
|
108
|
+
"credentialRef": { "type": "string", "minLength": 1, "description": "Opaque RFC 0046 credential reference where the acquired token is stored. NEVER the token itself." },
|
|
109
|
+
"scopes": { "type": "array", "items": { "type": "string" }, "description": "Scopes granted for the acquired token." }
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
"connectorAuthExpired": {
|
|
114
|
+
"description": "RFC 0047 — emitted when a stored OAuth token's refresh fails terminally (revoked/expired refresh token). Redaction-safe: no token material. MUST NOT be emitted unless `capabilities.oauth.supported: true`.",
|
|
115
|
+
"type": "object",
|
|
116
|
+
"additionalProperties": false,
|
|
117
|
+
"required": ["provider", "credentialRef"],
|
|
118
|
+
"properties": {
|
|
119
|
+
"provider": { "type": "string", "minLength": 1, "description": "Provider id, matching an advertised `capabilities.oauth.providers[].id`." },
|
|
120
|
+
"credentialRef": { "type": "string", "minLength": 1, "description": "Opaque RFC 0046 credential reference for the expired token." },
|
|
121
|
+
"reason": { "type": "string", "description": "Redaction-safe human-readable reason (e.g. `refresh_token_revoked`)." }
|
|
77
122
|
}
|
|
78
123
|
},
|
|
79
124
|
|
|
@@ -205,6 +250,17 @@
|
|
|
205
250
|
"inputs": { "$ref": "#/$defs/_inputsObject" },
|
|
206
251
|
"transport": { "type": "string", "enum": ["rest", "mcp", "a2a", "ui"] },
|
|
207
252
|
"engineVersion": { "type": "string" },
|
|
253
|
+
"owner": {
|
|
254
|
+
"type": "object",
|
|
255
|
+
"description": "RFC 0048. Redaction-safe echo of the run's owning identity triple (matches `RunSnapshot.owner`). Optional; single-tenant hosts omit it.",
|
|
256
|
+
"required": ["tenant"],
|
|
257
|
+
"properties": {
|
|
258
|
+
"tenant": { "type": "string", "minLength": 1 },
|
|
259
|
+
"workspace": { "type": "string", "minLength": 1 },
|
|
260
|
+
"principal": { "type": "string", "minLength": 1 }
|
|
261
|
+
},
|
|
262
|
+
"additionalProperties": false
|
|
263
|
+
},
|
|
208
264
|
"tags": { "type": "array", "items": { "type": "string" } },
|
|
209
265
|
"metadata": { "type": "object" }
|
|
210
266
|
},
|
|
@@ -272,6 +328,19 @@
|
|
|
272
328
|
"additionalProperties": true
|
|
273
329
|
},
|
|
274
330
|
|
|
331
|
+
"runDeadLettered": {
|
|
332
|
+
"description": "RFC 0053 — emitted when a run/node exhausts its retry policy (RFC 0009) and is routed to the durable dead-letter sink. The run remains fork-eligible (RFC 0011) for `capabilities.deadLetter.retentionDays`. Redaction-safe: `reason` carries no credential material. MUST NOT be emitted unless `capabilities.deadLetter.supported: true`.",
|
|
333
|
+
"type": "object",
|
|
334
|
+
"additionalProperties": false,
|
|
335
|
+
"required": ["runId", "reason", "attempts"],
|
|
336
|
+
"properties": {
|
|
337
|
+
"runId": { "type": "string", "minLength": 1 },
|
|
338
|
+
"nodeId": { "type": "string", "description": "The node whose retry exhaustion dead-lettered the run; absent for run-level failures." },
|
|
339
|
+
"reason": { "type": "string", "minLength": 1, "description": "Redaction-safe terminal-failure reason." },
|
|
340
|
+
"attempts": { "type": "integer", "minimum": 1, "description": "Total attempts made before dead-lettering." }
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
|
|
275
344
|
"nodeStarted": {
|
|
276
345
|
"type": "object",
|
|
277
346
|
"description": "Emitted when a node begins execution.",
|
|
@@ -419,6 +488,39 @@
|
|
|
419
488
|
},
|
|
420
489
|
"additionalProperties": true
|
|
421
490
|
},
|
|
491
|
+
"approvalGranted": {
|
|
492
|
+
"description": "RFC 0051 — emitted when an authorized principal grants a `core.openwop.governance.approvalGate`. Redaction-safe: `principal` is an opaque RFC 0048 id. MUST NOT be emitted unless the gate node is registered (peerDependency `authorization: 'supported'`).",
|
|
493
|
+
"type": "object",
|
|
494
|
+
"additionalProperties": false,
|
|
495
|
+
"required": ["gateId", "principal"],
|
|
496
|
+
"properties": {
|
|
497
|
+
"gateId": { "type": "string", "minLength": 1, "description": "Identifier of the approval gate node." },
|
|
498
|
+
"principal": { "type": "string", "minLength": 1, "description": "Opaque RFC 0048 principal id of the granting actor." },
|
|
499
|
+
"quorumProgress": { "type": "object", "additionalProperties": false, "properties": { "granted": { "type": "integer", "minimum": 0 }, "required": { "type": "integer", "minimum": 1 } }, "description": "When the gate requires a quorum: grants accumulated vs required." }
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
"approvalRejected": {
|
|
503
|
+
"description": "RFC 0051 — emitted when an authorized principal rejects an approval gate. The run loops back per the workflow edges (does not terminate by default). Redaction-safe.",
|
|
504
|
+
"type": "object",
|
|
505
|
+
"additionalProperties": false,
|
|
506
|
+
"required": ["gateId", "principal"],
|
|
507
|
+
"properties": {
|
|
508
|
+
"gateId": { "type": "string", "minLength": 1 },
|
|
509
|
+
"principal": { "type": "string", "minLength": 1, "description": "Opaque RFC 0048 principal id of the rejecting actor." },
|
|
510
|
+
"reason": { "type": "string", "description": "Redaction-safe human-readable reason." }
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
"approvalOverridden": {
|
|
514
|
+
"description": "RFC 0051 — emitted when a role-gated `override` path bypasses the gate (e.g. owner force-publish). MUST feed the audit log (RFC 0009/0010). Redaction-safe.",
|
|
515
|
+
"type": "object",
|
|
516
|
+
"additionalProperties": false,
|
|
517
|
+
"required": ["gateId", "principal", "reason"],
|
|
518
|
+
"properties": {
|
|
519
|
+
"gateId": { "type": "string", "minLength": 1 },
|
|
520
|
+
"principal": { "type": "string", "minLength": 1, "description": "Opaque RFC 0048 principal id of the overriding actor (MUST satisfy the override role)." },
|
|
521
|
+
"reason": { "type": "string", "minLength": 1, "description": "Required, redaction-safe rationale — the audit breadcrumb." }
|
|
522
|
+
}
|
|
523
|
+
},
|
|
422
524
|
"clarificationRequested": {
|
|
423
525
|
"type": "object",
|
|
424
526
|
"description": "Legacy kind-specific event for HITL clarification requests.",
|
|
@@ -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."
|
package/src/lib/behavior-gate.ts
CHANGED
|
@@ -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
|
-
|
|
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,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
|
+
});
|