@openwop/openwop-conformance 1.53.1 → 1.57.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 +20 -0
- package/README.md +2 -2
- package/api/openapi.yaml +9 -0
- package/coverage.md +3 -0
- package/package.json +1 -1
- package/schemas/README.md +1 -0
- package/schemas/agent-manifest.schema.json +23 -1
- package/schemas/capabilities.schema.json +80 -3
- package/schemas/connection-pack-manifest.schema.json +5 -0
- package/schemas/frontend-plugin-manifest.schema.json +9 -3
- package/schemas/residency.schema.json +16 -0
- package/schemas/run-snapshot.schema.json +6 -1
- package/schemas/workflow-chain-pack-manifest.schema.json +85 -6
- package/schemas/workflow-definition.schema.json +4 -3
- package/src/lib/anonymousActor.ts +99 -0
- package/src/lib/workflow-chain-expansion.ts +342 -0
- package/src/scenarios/agent-manifest-role-profile.test.ts +116 -0
- package/src/scenarios/anonymous-actor-audit-opaque.test.ts +71 -0
- package/src/scenarios/anonymous-actor-default-deny.test.ts +87 -0
- package/src/scenarios/anonymous-actor-egress-guarded.test.ts +54 -0
- package/src/scenarios/anonymous-actor-no-secret-reach.test.ts +78 -0
- package/src/scenarios/anonymous-actor-shape.test.ts +173 -0
- package/src/scenarios/anonymous-actor-write-gated.test.ts +83 -0
- package/src/scenarios/chain-produced-var-roundtrip.test.ts +152 -0
- package/src/scenarios/chain-subchain-cycle-rejected.test.ts +141 -0
- package/src/scenarios/chain-subchain-fanout.test.ts +139 -0
- package/src/scenarios/chain-subchain-sibling.test.ts +147 -0
- package/src/scenarios/chain-subchain-unsupported-refused.test.ts +66 -0
- package/src/scenarios/connection-pack-manifest-valid.test.ts +33 -0
- package/src/scenarios/data-residency-admission.test.ts +138 -0
- package/src/scenarios/edge-condition-truthy-falsy.test.ts +104 -0
- package/src/scenarios/frontend-plugin-packs.test.ts +24 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymous-actor SSRF-guarded, credential-safe egress (RFC 0132 §C.3) — backs
|
|
3
|
+
* the `anon-actor-egress-ssrf-guarded` SECURITY invariant (composes RFC 0079
|
|
4
|
+
* `egress-credential-audience-bound`).
|
|
5
|
+
*
|
|
6
|
+
* An anon-initiated egress MUST ride the host's SSRF-guarded, audience-bound
|
|
7
|
+
* egress path (RFC 0076 §B safeFetch + RFC 0079 credential↔destination binding).
|
|
8
|
+
* A host-issued or tenant BYOK credential MUST NOT attach to an anon egress
|
|
9
|
+
* whose destination is not in the credential's provenance `audiences`; the
|
|
10
|
+
* default posture is `downgraded` (anonymous egress, no credential) or `denied`.
|
|
11
|
+
* An anon actor never becomes a confused deputy for a tenant credential.
|
|
12
|
+
*
|
|
13
|
+
* Capability-gated on `capabilities.anonymousActor.supported`; soft-skips when
|
|
14
|
+
* unadvertised or when the reference seam is unwired (404). Hard-fails under
|
|
15
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true`. Passing non-vacuously graduates
|
|
16
|
+
* `anon-actor-egress-ssrf-guarded` reference-impl → protocol tier.
|
|
17
|
+
*
|
|
18
|
+
* @see RFCS/0132-anonymous-actor-authorization.md §C.3
|
|
19
|
+
* @see RFCS/0079-credential-provenance-and-egress-policy.md
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest';
|
|
23
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
24
|
+
import { driver } from '../lib/driver.js';
|
|
25
|
+
import { readAnonymousActorCap, anonDispatch } from '../lib/anonymousActor.js';
|
|
26
|
+
|
|
27
|
+
const PROFILE = 'openwop-anonymous-actor';
|
|
28
|
+
|
|
29
|
+
describe('anonymous-actor-egress-guarded (RFC 0132 §C.3)', () => {
|
|
30
|
+
it('an out-of-audience anon egress is denied/downgraded and attaches no credential', async () => {
|
|
31
|
+
const cap = await readAnonymousActorCap();
|
|
32
|
+
const supportsWriteEgress = (cap?.tiers ?? []).includes('bounded-write-egress');
|
|
33
|
+
// Gate on the write/egress tier specifically — a read-only host has no egress path here.
|
|
34
|
+
if (!behaviorGate(PROFILE, cap?.supported === true && supportsWriteEgress)) return;
|
|
35
|
+
const res = await anonDispatch({
|
|
36
|
+
tool: 'http.fetch',
|
|
37
|
+
destination: 'https://attacker.example/exfil',
|
|
38
|
+
});
|
|
39
|
+
if (res.status === 404 || res.status === 405) return; // seam unwired — soft-skip
|
|
40
|
+
const egress = res.json?.egressDecided;
|
|
41
|
+
expect(
|
|
42
|
+
egress,
|
|
43
|
+
driver.describe('RFC 0132 §C.3', 'an anon egress MUST ride the RFC 0079 egress-decision path'),
|
|
44
|
+
).toBeDefined();
|
|
45
|
+
expect(
|
|
46
|
+
['denied', 'downgraded'],
|
|
47
|
+
driver.describe('RFC 0132 §C.3', 'an out-of-audience anon egress MUST be denied or downgraded — never allowed-with-credential'),
|
|
48
|
+
).toContain(egress?.decision);
|
|
49
|
+
expect(
|
|
50
|
+
egress?.credentialAttached === true,
|
|
51
|
+
driver.describe('SECURITY anon-actor-egress-ssrf-guarded', 'no tenant/host credential MUST attach out-of-audience'),
|
|
52
|
+
).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymous-actor no-secret / no-cross-tenant reach (RFC 0132 §C.2/§C.3) —
|
|
3
|
+
* backs the `anon-actor-no-secret-reach` SECURITY invariant (SR-1 + CTI-1 parity).
|
|
4
|
+
*
|
|
5
|
+
* A `read`-tier tool granted to an anonymous actor MUST be tenant-scoped to the
|
|
6
|
+
* surface's tenant, MUST NOT resolve/return/reach any secret or BYOK credential
|
|
7
|
+
* material, and MUST NOT reach cross-tenant data. A cross-tenant read fails
|
|
8
|
+
* closed (`run_forbidden` / an empty result — never another tenant's data). A
|
|
9
|
+
* planted BYOK canary in the surface tenant's secrets MUST NEVER surface on any
|
|
10
|
+
* observable output of an anon tool call.
|
|
11
|
+
*
|
|
12
|
+
* Capability-gated on `capabilities.anonymousActor.supported`; soft-skips when
|
|
13
|
+
* unadvertised or when the reference seam is unwired (404). Hard-fails under
|
|
14
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true`. Passing non-vacuously graduates
|
|
15
|
+
* `anon-actor-no-secret-reach` reference-impl → protocol tier.
|
|
16
|
+
*
|
|
17
|
+
* @see RFCS/0132-anonymous-actor-authorization.md §C.2, §C.3
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from 'vitest';
|
|
21
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
22
|
+
import { driver } from '../lib/driver.js';
|
|
23
|
+
import { isAnonymousActorAdvertised, anonDispatch } from '../lib/anonymousActor.js';
|
|
24
|
+
|
|
25
|
+
const PROFILE = 'openwop-anonymous-actor';
|
|
26
|
+
|
|
27
|
+
/** The canary the harness expects to be planted in the surface tenant's secrets;
|
|
28
|
+
* it MUST NOT appear anywhere in an anon tool call's observable output. */
|
|
29
|
+
const BYOK_CANARY = 'sk-openwop-anon-canary-DO-NOT-LEAK';
|
|
30
|
+
|
|
31
|
+
describe('anonymous-actor-no-secret-reach (RFC 0132 §C.2/§C.3)', () => {
|
|
32
|
+
it('a read-tier anon tool call never surfaces a planted BYOK canary', async () => {
|
|
33
|
+
if (!behaviorGate(PROFILE, await isAnonymousActorAdvertised())) return;
|
|
34
|
+
const res = await anonDispatch({ tool: 'catalog.read', args: { probeSecrets: true } });
|
|
35
|
+
if (res.status === 404 || res.status === 405) return; // seam unwired — soft-skip
|
|
36
|
+
const serialized = JSON.stringify(res.json ?? {});
|
|
37
|
+
expect(
|
|
38
|
+
serialized.includes(BYOK_CANARY),
|
|
39
|
+
driver.describe('SECURITY anon-actor-no-secret-reach', 'an anon tool call MUST NOT surface secret/BYOK material'),
|
|
40
|
+
).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('a cross-tenant read fails closed — the caller-supplied tenant is never resolved', async () => {
|
|
44
|
+
if (!behaviorGate(PROFILE, await isAnonymousActorAdvertised())) return;
|
|
45
|
+
// A visitor tries to influence the tenant via tool args. Per RFC 0132 §A.2 the
|
|
46
|
+
// caller MUST NOT be able to supply or influence the anon actor's tenant: a
|
|
47
|
+
// conformant host either DENIES the attempt, or NEUTRALIZES it by scoping to
|
|
48
|
+
// the surface's own tenant — it MUST NOT resolve to the caller-supplied tenant
|
|
49
|
+
// and MUST NOT return that tenant's data. Both outcomes are "fail closed"; the
|
|
50
|
+
// one non-conformant behavior is resolving/returning the caller's tenant.
|
|
51
|
+
const CROSS = 'other-tenant';
|
|
52
|
+
const res = await anonDispatch({ tool: 'catalog.read', args: { tenant: CROSS } });
|
|
53
|
+
if (res.status === 404 || res.status === 405) return; // seam unwired — soft-skip
|
|
54
|
+
const decided = res.json?.authorizationDecided?.payload;
|
|
55
|
+
const deniedByDecision = decided?.allowed === false;
|
|
56
|
+
const deniedByStatus = res.status === 403 || res.status === 404;
|
|
57
|
+
// The resolved tenant (owner triple or the decision's `resource`) MUST NOT be
|
|
58
|
+
// the caller-supplied one — the host ignored the attacker-controlled input.
|
|
59
|
+
const resolvedTenant = res.json?.owner?.tenant ?? decided?.resource?.replace(/^tenant:/, '');
|
|
60
|
+
const neutralizedToSurface =
|
|
61
|
+
typeof resolvedTenant === 'string' && resolvedTenant.length > 0 && resolvedTenant !== CROSS;
|
|
62
|
+
expect(
|
|
63
|
+
deniedByDecision || deniedByStatus || neutralizedToSurface,
|
|
64
|
+
driver.describe(
|
|
65
|
+
'SECURITY anon-actor-no-secret-reach (CTI-1 / RFC 0132 §A.2)',
|
|
66
|
+
'a cross-tenant anon read MUST fail closed — denied, or the caller-supplied tenant ignored (never resolved to the caller’s tenant)',
|
|
67
|
+
),
|
|
68
|
+
).toBe(true);
|
|
69
|
+
// On the denial path there MUST be no result body; on the neutralized path a
|
|
70
|
+
// result scoped to the SURFACE tenant is fine (it is not cross-tenant data).
|
|
71
|
+
if (deniedByDecision || deniedByStatus) {
|
|
72
|
+
expect(
|
|
73
|
+
res.json?.result,
|
|
74
|
+
driver.describe('SECURITY anon-actor-no-secret-reach (CTI-1)', 'a denied cross-tenant anon read MUST NOT return data'),
|
|
75
|
+
).toBeUndefined();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymous-actor authorization — capability + snapshot + audit shapes (RFC 0132).
|
|
3
|
+
*
|
|
4
|
+
* Always-on, server-free schema-shape probe. Verifies that:
|
|
5
|
+
* - `capabilities.anonymousActor` is declared with its `supported` / `tiers` /
|
|
6
|
+
* `writeEgressControls` / `failClosed` sub-flags.
|
|
7
|
+
* - the `anonymousActor` block validates a conforming `read` advert and a
|
|
8
|
+
* conforming `bounded-write-egress` advert, and REJECTS the negatives:
|
|
9
|
+
* `tiers: []` (minItems); `bounded-write-egress` without `writeEgressControls`
|
|
10
|
+
* (the §B.2 conditional-MUST); `writeEgressControls` present without the
|
|
11
|
+
* write tier (§B.2 else); `supported: false` (the block is omitted when
|
|
12
|
+
* unsupported, `const: true`); and an unknown property (additionalProperties).
|
|
13
|
+
* - `run-snapshot.owner` accepts the optional `principalKind: "anonymous"` and
|
|
14
|
+
* rejects an out-of-enum `"guest"`.
|
|
15
|
+
* - the anon audit reuses the existing RFC 0049 `authorization.decided` event:
|
|
16
|
+
* a content-free grant/deny record (opaque `anon:` principal + a machine
|
|
17
|
+
* `reason`) validates, and `authorization.decided` is in the RunEventType enum.
|
|
18
|
+
*
|
|
19
|
+
* Behavioral assertions (default-deny grant, no-secret-reach, SSRF-guarded egress,
|
|
20
|
+
* gated writes, opaque audit) are gated on `capabilities.anonymousActor.supported`
|
|
21
|
+
* and land in the five `anonymous-actor-*.test.ts` scenarios (deferred per RFC 0132
|
|
22
|
+
* §Conformance — reference host soft-skips until openwop-app wires a tool-enabled
|
|
23
|
+
* public surface). This scenario asserts the wire contract, not host behavior.
|
|
24
|
+
*
|
|
25
|
+
* Spec references:
|
|
26
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/capabilities.md (§anonymousActor)
|
|
27
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/auth.md (§Identity claims — the anonymous principal kind)
|
|
28
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0132-anonymous-actor-authorization.md
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { readFileSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
35
|
+
import addFormats from 'ajv-formats';
|
|
36
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
37
|
+
|
|
38
|
+
/** Server-free assertion-message helper (mirrors driver.describe without OPENWOP_BASE_URL). */
|
|
39
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
40
|
+
|
|
41
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
42
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('anonymous-actor-shape: capability advertisement (RFC 0132 §B, server-free)', () => {
|
|
46
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
47
|
+
const anon = (caps.properties as Record<string, { properties?: Record<string, unknown> }>)
|
|
48
|
+
.anonymousActor;
|
|
49
|
+
|
|
50
|
+
it('the capabilities schema declares anonymousActor with its sub-flags', () => {
|
|
51
|
+
expect(
|
|
52
|
+
anon,
|
|
53
|
+
why('capabilities.md §anonymousActor', 'capabilities.anonymousActor MUST be declared'),
|
|
54
|
+
).toBeDefined();
|
|
55
|
+
for (const flag of ['supported', 'tiers', 'writeEgressControls', 'failClosed']) {
|
|
56
|
+
expect(
|
|
57
|
+
anon?.properties?.[flag],
|
|
58
|
+
why('capabilities.md §anonymousActor', `anonymousActor.${flag} MUST be declared`),
|
|
59
|
+
).toBeDefined();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('validates conforming read + bounded-write-egress adverts and rejects the negatives', () => {
|
|
64
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
65
|
+
addFormats(ajv);
|
|
66
|
+
const validate = ajv.compile(anon as Record<string, unknown>);
|
|
67
|
+
|
|
68
|
+
// Positive — read-only tier.
|
|
69
|
+
expect(
|
|
70
|
+
validate({ supported: true, tiers: ['read'], failClosed: true }),
|
|
71
|
+
why('RFC 0132 §B', 'a conforming read-tier advert MUST validate'),
|
|
72
|
+
).toBe(true);
|
|
73
|
+
// Positive — bounded-write-egress tier with a mandatory control.
|
|
74
|
+
expect(
|
|
75
|
+
validate({
|
|
76
|
+
supported: true,
|
|
77
|
+
tiers: ['read', 'bounded-write-egress'],
|
|
78
|
+
writeEgressControls: ['hitl'],
|
|
79
|
+
failClosed: true,
|
|
80
|
+
}),
|
|
81
|
+
why('RFC 0132 §B', 'a bounded-write-egress advert with a control MUST validate'),
|
|
82
|
+
).toBe(true);
|
|
83
|
+
|
|
84
|
+
// Negative — empty tiers (minItems: 1).
|
|
85
|
+
expect(
|
|
86
|
+
validate({ supported: true, tiers: [] }),
|
|
87
|
+
why('RFC 0132 §B', 'tiers: [] MUST be rejected (minItems)'),
|
|
88
|
+
).toBe(false);
|
|
89
|
+
// Negative — write tier without a control (the §B.2 conditional-MUST).
|
|
90
|
+
expect(
|
|
91
|
+
validate({ supported: true, tiers: ['bounded-write-egress'] }),
|
|
92
|
+
why('RFC 0132 §B.2', 'bounded-write-egress without writeEgressControls MUST be rejected'),
|
|
93
|
+
).toBe(false);
|
|
94
|
+
// Negative — controls advertised without the write tier (§B.2 else branch).
|
|
95
|
+
expect(
|
|
96
|
+
validate({ supported: true, tiers: ['read'], writeEgressControls: ['hitl'] }),
|
|
97
|
+
why('RFC 0132 §B.2', 'writeEgressControls without the write tier MUST be rejected'),
|
|
98
|
+
).toBe(false);
|
|
99
|
+
// Negative — supported: false (the block is omitted when unsupported, const: true).
|
|
100
|
+
expect(
|
|
101
|
+
validate({ supported: false, tiers: ['read'] }),
|
|
102
|
+
why('RFC 0132 §B', 'supported: false MUST be rejected (const: true — omit the block instead)'),
|
|
103
|
+
).toBe(false);
|
|
104
|
+
// Negative — unknown property (additionalProperties: false).
|
|
105
|
+
expect(
|
|
106
|
+
validate({ supported: true, tiers: ['read'], surface: 'wgt_abc' }),
|
|
107
|
+
why('RFC 0132 §B', 'an unknown property MUST be rejected (additionalProperties: false)'),
|
|
108
|
+
).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('anonymous-actor-shape: owner.principalKind (RFC 0132 §A, server-free)', () => {
|
|
113
|
+
it('run-snapshot owner accepts principalKind "anonymous" and rejects "guest"', () => {
|
|
114
|
+
const snap = loadSchema('run-snapshot.schema.json');
|
|
115
|
+
const owner = (snap.properties as Record<string, unknown>).owner as Record<string, unknown>;
|
|
116
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
117
|
+
addFormats(ajv);
|
|
118
|
+
const validate = ajv.compile(owner);
|
|
119
|
+
expect(
|
|
120
|
+
validate({ tenant: 'acme', principal: 'anon:sess-3f9c', principalKind: 'anonymous' }),
|
|
121
|
+
why('RFC 0132 §A', 'owner.principalKind "anonymous" MUST validate'),
|
|
122
|
+
).toBe(true);
|
|
123
|
+
// Absent principalKind is today's RFC 0048 behavior — still valid.
|
|
124
|
+
expect(
|
|
125
|
+
validate({ tenant: 'acme', principal: 'u_42' }),
|
|
126
|
+
why('RFC 0048', 'owner without principalKind MUST still validate'),
|
|
127
|
+
).toBe(true);
|
|
128
|
+
expect(
|
|
129
|
+
validate({ tenant: 'acme', principalKind: 'guest' }),
|
|
130
|
+
why('RFC 0132 §A', 'owner.principalKind "guest" MUST be rejected (enum)'),
|
|
131
|
+
).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('anonymous-actor-shape: audit reuses authorization.decided (RFC 0132 §D, server-free)', () => {
|
|
136
|
+
const payloads = loadSchema('run-event-payloads.schema.json');
|
|
137
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
138
|
+
addFormats(ajv);
|
|
139
|
+
ajv.addSchema(payloads, 'payloads');
|
|
140
|
+
const decided = ajv.getSchema('payloads#/$defs/authorizationDecided');
|
|
141
|
+
|
|
142
|
+
it('a content-free anon grant + deny record validates against the existing $def', () => {
|
|
143
|
+
expect(decided, 'the authorizationDecided $def MUST exist (no new event minted)').toBeTruthy();
|
|
144
|
+
// Grant — the §G positive example.
|
|
145
|
+
expect(
|
|
146
|
+
decided!({
|
|
147
|
+
principal: 'anon:sess-3f9c',
|
|
148
|
+
action: 'tool:catalog.read',
|
|
149
|
+
resource: 'tenant:acme',
|
|
150
|
+
allowed: true,
|
|
151
|
+
reason: 'anon-granted',
|
|
152
|
+
}),
|
|
153
|
+
why('RFC 0132 §D', 'a conforming anon grant record MUST validate'),
|
|
154
|
+
).toBe(true);
|
|
155
|
+
// Deny — a not-granted tool.
|
|
156
|
+
expect(
|
|
157
|
+
decided!({
|
|
158
|
+
principal: 'anon:sess-3f9c',
|
|
159
|
+
action: 'tool:crm.write',
|
|
160
|
+
resource: 'tenant:acme',
|
|
161
|
+
allowed: false,
|
|
162
|
+
reason: 'anon-not-granted',
|
|
163
|
+
}),
|
|
164
|
+
why('RFC 0132 §D', 'a conforming anon deny record MUST validate'),
|
|
165
|
+
).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('authorization.decided is in the RunEventType enum (the reused audit event)', () => {
|
|
169
|
+
const runEvent = loadSchema('run-event.schema.json');
|
|
170
|
+
const enumVals = (runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum ?? [];
|
|
171
|
+
expect(enumVals).toContain('authorization.decided');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymous-actor bounded-write/egress is gated (RFC 0132 §C.3) — backs the
|
|
3
|
+
* `anon-actor-write-egress-gated` SECURITY invariant.
|
|
4
|
+
*
|
|
5
|
+
* A `bounded-write-egress` tool — one that mutates durable state or performs
|
|
6
|
+
* outbound egress — is permitted for an anonymous actor ONLY behind a mandatory
|
|
7
|
+
* control: a per-action HITL/approval gate (RFC 0051 — the action suspends
|
|
8
|
+
* pending a human decision) OR a hard rate-limit AND a per-session action cap.
|
|
9
|
+
* An anon write/egress with no resolvable control MUST be denied. With a `hitl`
|
|
10
|
+
* control, the action MUST suspend on an approval interrupt BEFORE any durable
|
|
11
|
+
* write.
|
|
12
|
+
*
|
|
13
|
+
* Capability-gated on `capabilities.anonymousActor.supported` + the
|
|
14
|
+
* `bounded-write-egress` tier; soft-skips when unadvertised or when the seam is
|
|
15
|
+
* unwired (404). Hard-fails under `OPENWOP_REQUIRE_BEHAVIOR=true`. Passing
|
|
16
|
+
* non-vacuously graduates `anon-actor-write-egress-gated` reference-impl →
|
|
17
|
+
* protocol tier.
|
|
18
|
+
*
|
|
19
|
+
* @see RFCS/0132-anonymous-actor-authorization.md §C.3
|
|
20
|
+
* @see RFCS/0051-approval-deployment-gate-primitive.md
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
25
|
+
import { driver } from '../lib/driver.js';
|
|
26
|
+
import { readAnonymousActorCap, anonDispatch } from '../lib/anonymousActor.js';
|
|
27
|
+
|
|
28
|
+
const PROFILE = 'openwop-anonymous-actor';
|
|
29
|
+
|
|
30
|
+
describe('anonymous-actor-write-gated (RFC 0132 §C.3)', () => {
|
|
31
|
+
it('an anon bounded-write tool is gated — denied when ungated, or suspends on an approval interrupt', async () => {
|
|
32
|
+
const cap = await readAnonymousActorCap();
|
|
33
|
+
const supportsWrite = (cap?.tiers ?? []).includes('bounded-write-egress');
|
|
34
|
+
if (!behaviorGate(PROFILE, cap?.supported === true && supportsWrite)) return;
|
|
35
|
+
|
|
36
|
+
const res = await anonDispatch({ tool: 'lead.capture', args: { email: 'visitor@example.com' } });
|
|
37
|
+
if (res.status === 404 || res.status === 405) return; // seam unwired — soft-skip
|
|
38
|
+
|
|
39
|
+
const controls = cap?.writeEgressControls ?? [];
|
|
40
|
+
const decided = res.json?.authorizationDecided?.payload;
|
|
41
|
+
const suspended = res.json?.interrupt?.kind !== undefined;
|
|
42
|
+
|
|
43
|
+
if (controls.includes('hitl')) {
|
|
44
|
+
// With a HITL control the write MUST suspend on an approval interrupt before durable write.
|
|
45
|
+
expect(
|
|
46
|
+
suspended,
|
|
47
|
+
driver.describe('RFC 0132 §C.3 (hitl)', 'an anon bounded-write MUST suspend on an approval interrupt before the write'),
|
|
48
|
+
).toBe(true);
|
|
49
|
+
expect(
|
|
50
|
+
res.json?.result,
|
|
51
|
+
driver.describe('RFC 0132 §C.3 (hitl)', 'no durable write result before the approval resolves'),
|
|
52
|
+
).toBeUndefined();
|
|
53
|
+
} else {
|
|
54
|
+
// With only a rate-limit/session-cap control, the seam surfaces a gate; an
|
|
55
|
+
// ungated write MUST be denied (never a silent durable write).
|
|
56
|
+
const gatedOrDenied = suspended || decided?.allowed === false || res.status === 429;
|
|
57
|
+
expect(
|
|
58
|
+
gatedOrDenied,
|
|
59
|
+
driver.describe('SECURITY anon-actor-write-egress-gated', 'an anon bounded-write MUST be gated — an ungated write MUST be denied'),
|
|
60
|
+
).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('an anon write with no resolvable control is denied with a machine reason', async () => {
|
|
65
|
+
const cap = await readAnonymousActorCap();
|
|
66
|
+
const supportsWrite = (cap?.tiers ?? []).includes('bounded-write-egress');
|
|
67
|
+
if (!behaviorGate(PROFILE, cap?.supported === true && supportsWrite)) return;
|
|
68
|
+
// Probe a surface deliberately configured with a write grant but no control.
|
|
69
|
+
const res = await anonDispatch({ tool: 'lead.capture', surface: 'sample-uncontrolled-surface' });
|
|
70
|
+
if (res.status === 404 || res.status === 405) return; // seam unwired / surface absent — soft-skip
|
|
71
|
+
const decided = res.json?.authorizationDecided?.payload;
|
|
72
|
+
expect(
|
|
73
|
+
decided?.allowed === false || res.status === 403 || res.status === 429,
|
|
74
|
+
driver.describe('SECURITY anon-actor-write-egress-gated', 'an anon write with no resolvable control MUST be denied'),
|
|
75
|
+
).toBe(true);
|
|
76
|
+
if (decided && decided.allowed === false) {
|
|
77
|
+
expect(
|
|
78
|
+
decided.reason,
|
|
79
|
+
driver.describe('RFC 0132 §C.3', 'an ungated anon write denial carries a machine reason'),
|
|
80
|
+
).toBe('anon-write-ungated');
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Produced (run-scoped) variables — round-trip + closed-world (RFC 0133 §2).
|
|
3
|
+
*
|
|
4
|
+
* Server-free scenario. Exercises `emitProducedVariables` + `validateVariableReads`
|
|
5
|
+
* in the reference library against a chain that declares `producedVariables` a
|
|
6
|
+
* downstream node reads via a `{ type:"variable", variableName }` input binding.
|
|
7
|
+
* Asserts the normative contract from `workflow-chain-packs.md`
|
|
8
|
+
* §"Produced (run-scoped) variables (RFC 0133)":
|
|
9
|
+
*
|
|
10
|
+
* - §2.3: declared `producedVariables` are emitted into the expanded
|
|
11
|
+
* `WorkflowDefinition.variables[]` as run-scoped entries (name + type,
|
|
12
|
+
* NO author-time value — distinct from `parameters`).
|
|
13
|
+
* - §2.2: a `{ type:"variable" }` read of a DECLARED name validates.
|
|
14
|
+
* - §2.2: a `{ type:"variable" }` read of an UNDECLARED name fails closed with
|
|
15
|
+
* `variable_undeclared` (the closed-world guard — "reads a value nothing
|
|
16
|
+
* produces").
|
|
17
|
+
* - a read of a MATERIALIZED PARAMETER name (RFC 0124 deferred mode) validates
|
|
18
|
+
* even without a `producedVariables` entry (params + produced vars compose).
|
|
19
|
+
*
|
|
20
|
+
* @see spec/v1/workflow-chain-packs.md §"Produced (run-scoped) variables (RFC 0133)"
|
|
21
|
+
* @see conformance/src/lib/workflow-chain-expansion.ts (emitProducedVariables, validateVariableReads)
|
|
22
|
+
* @see RFCS/0133-workflow-chain-composition.md
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import {
|
|
27
|
+
emitProducedVariables,
|
|
28
|
+
validateVariableReads,
|
|
29
|
+
VariableUndeclaredError,
|
|
30
|
+
ProducedVarProducerUnknownError,
|
|
31
|
+
type WorkflowChain,
|
|
32
|
+
} from '../lib/workflow-chain-expansion.js';
|
|
33
|
+
|
|
34
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
35
|
+
const SPEC = 'workflow-chain-packs.md §"Produced (run-scoped) variables (RFC 0133)"';
|
|
36
|
+
|
|
37
|
+
/** `generate` writes `plan`; `decompose` reads it via a variable binding. */
|
|
38
|
+
const PLAN_GEN: WorkflowChain = {
|
|
39
|
+
chainId: 'kicktodo.plan-generation',
|
|
40
|
+
version: '1.0.0',
|
|
41
|
+
label: 'Plan generation',
|
|
42
|
+
description: 'generate writes plan; decompose reads it.',
|
|
43
|
+
parameters: {},
|
|
44
|
+
producedVariables: [
|
|
45
|
+
{ name: 'plan', producedBy: 'generate', type: 'object', description: 'the generated plan' },
|
|
46
|
+
],
|
|
47
|
+
dag: {
|
|
48
|
+
nodes: [
|
|
49
|
+
{ id: 'generate', typeId: 'core.ai.callPrompt', config: { systemPrompt: 'Generate a plan.' } },
|
|
50
|
+
{ id: 'decompose', typeId: 'core.ai.callPrompt', inputs: { plan: { type: 'variable', variableName: 'plan' } } },
|
|
51
|
+
],
|
|
52
|
+
edges: [{ from: 'generate', to: 'decompose' }],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe('chain-produced-var-roundtrip: run-scoped variables (RFC 0133 §2, server-free)', () => {
|
|
57
|
+
it('emits declared producedVariables into variables[] (name + type, NO value)', () => {
|
|
58
|
+
const vars = emitProducedVariables(PLAN_GEN);
|
|
59
|
+
expect(vars, why(SPEC, '§2.3 — one run-scoped variable emitted')).toEqual([{ name: 'plan', type: 'object' }]);
|
|
60
|
+
// Run-scoped: NO author-time value rides the emitted entry.
|
|
61
|
+
expect('value' in (vars[0] as object), why(SPEC, '§2.3 — no author-time value (distinct from parameters)')).toBe(
|
|
62
|
+
false,
|
|
63
|
+
);
|
|
64
|
+
expect(
|
|
65
|
+
'defaultValue' in (vars[0] as object),
|
|
66
|
+
why(SPEC, '§2.3 — no default value (a produced var is written during the run)'),
|
|
67
|
+
).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('validates a variable read of a DECLARED producedVariables name', () => {
|
|
71
|
+
expect(() => validateVariableReads(PLAN_GEN), why(SPEC, '§2.2 — declared read is closed-world valid')).not.toThrow();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rejects a variable read of an UNDECLARED name (variable_undeclared)', () => {
|
|
75
|
+
const undeclared: WorkflowChain = {
|
|
76
|
+
...PLAN_GEN,
|
|
77
|
+
producedVariables: [], // nothing declared
|
|
78
|
+
dag: {
|
|
79
|
+
nodes: [
|
|
80
|
+
{ id: 'generate', typeId: 'core.ai.callPrompt', config: {} },
|
|
81
|
+
{
|
|
82
|
+
id: 'decompose',
|
|
83
|
+
typeId: 'core.ai.callPrompt',
|
|
84
|
+
inputs: { plan: { type: 'variable', variableName: 'plan' } },
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
expect(() => validateVariableReads(undeclared), why(SPEC, '§2.2 — undeclared read MUST reject')).toThrow(
|
|
90
|
+
VariableUndeclaredError,
|
|
91
|
+
);
|
|
92
|
+
try {
|
|
93
|
+
validateVariableReads(undeclared);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
expect((e as VariableUndeclaredError).code, why(SPEC, 'wire code variable_undeclared')).toBe('variable_undeclared');
|
|
96
|
+
expect((e as VariableUndeclaredError).variableName, why(SPEC, 'names the offending variable')).toBe('plan');
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rejects a producedVariables whose producedBy names a NON-EXISTENT node (produced_var_producer_unknown)', () => {
|
|
101
|
+
const badProducer: WorkflowChain = {
|
|
102
|
+
...PLAN_GEN,
|
|
103
|
+
producedVariables: [{ name: 'plan', producedBy: 'ghost', type: 'object' }],
|
|
104
|
+
};
|
|
105
|
+
expect(
|
|
106
|
+
() => validateVariableReads(badProducer),
|
|
107
|
+
why(SPEC, '§2.2 — producedBy MUST be a real fragment node'),
|
|
108
|
+
).toThrow(ProducedVarProducerUnknownError);
|
|
109
|
+
try {
|
|
110
|
+
validateVariableReads(badProducer);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
expect((e as ProducedVarProducerUnknownError).code, why(SPEC, 'distinct wire code')).toBe(
|
|
113
|
+
'produced_var_producer_unknown',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('rejects a producedVariables name that COLLIDES with a parameter (channels MUST be disjoint)', () => {
|
|
119
|
+
const collide: WorkflowChain = {
|
|
120
|
+
...PLAN_GEN,
|
|
121
|
+
parameters: { type: 'object', properties: { plan: { type: 'object' } } },
|
|
122
|
+
producedVariables: [{ name: 'plan', producedBy: 'generate', type: 'object' }],
|
|
123
|
+
};
|
|
124
|
+
expect(
|
|
125
|
+
() => validateVariableReads(collide),
|
|
126
|
+
why(SPEC, '§2.2 — a produced var colliding with a parameter name MUST reject'),
|
|
127
|
+
).toThrow(VariableUndeclaredError);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('validates a read of a MATERIALIZED PARAMETER name (params + produced vars compose)', () => {
|
|
131
|
+
// A read whose name is NOT a produced var but IS a materialized parameter
|
|
132
|
+
// (RFC 0124 deferred mode) is closed-world valid.
|
|
133
|
+
const paramRead: WorkflowChain = {
|
|
134
|
+
...PLAN_GEN,
|
|
135
|
+
producedVariables: [],
|
|
136
|
+
dag: {
|
|
137
|
+
nodes: [
|
|
138
|
+
{ id: 'n', typeId: 'core.ai.callPrompt', inputs: { seed: { type: 'variable', variableName: 'seed' } } },
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
expect(
|
|
143
|
+
() => validateVariableReads(paramRead, new Set(['seed'])),
|
|
144
|
+
why(SPEC, '§2.2 — a materialized-parameter read validates'),
|
|
145
|
+
).not.toThrow();
|
|
146
|
+
// …but still rejects when the name is neither a produced var nor a param.
|
|
147
|
+
expect(
|
|
148
|
+
() => validateVariableReads(paramRead, new Set()),
|
|
149
|
+
why(SPEC, '§2.2 — same read with no matching param still rejects'),
|
|
150
|
+
).toThrow(VariableUndeclaredError);
|
|
151
|
+
});
|
|
152
|
+
});
|