@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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authorization-fail-closed — RFC 0049 §C invariant verification.
|
|
3
|
+
*
|
|
4
|
+
* Status: DRAFT. RFC 0049 (RBAC scopes & authorization decisions) is `Draft`.
|
|
5
|
+
* Backs the SECURITY invariant `authorization-fail-closed`.
|
|
6
|
+
*
|
|
7
|
+
* Capability-gated: skips when the host does not advertise
|
|
8
|
+
* `capabilities.authorization.supported = true`.
|
|
9
|
+
*
|
|
10
|
+
* What this scenario asserts:
|
|
11
|
+
* 1. Advertisement shape — when authorization is supported, `failClosed`
|
|
12
|
+
* (when present) is exactly `true` (RFC 0049 §C).
|
|
13
|
+
* 2. Fail-closed MUST-NOT — when the host exposes the optional
|
|
14
|
+
* `POST /v1/host/sample/authorization/decide` test seam, a decision for
|
|
15
|
+
* a principal with an absent/unseeded role MUST resolve to
|
|
16
|
+
* `allowed: false`. The host MUST NOT default-allow under any error
|
|
17
|
+
* condition.
|
|
18
|
+
*
|
|
19
|
+
* Hosts without the seam soft-skip the behavioral probe (404).
|
|
20
|
+
*
|
|
21
|
+
* @see RFCS/0049-rbac-scopes-and-authorization-decisions.md
|
|
22
|
+
* @see SECURITY/invariants.yaml id: authorization-fail-closed
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { driver } from '../lib/driver.js';
|
|
27
|
+
|
|
28
|
+
interface DiscoveryAuthorization {
|
|
29
|
+
supported?: boolean;
|
|
30
|
+
failClosed?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DiscoveryDoc {
|
|
34
|
+
capabilities?: {
|
|
35
|
+
authorization?: DiscoveryAuthorization;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readAuthorization(): Promise<DiscoveryAuthorization | null> {
|
|
40
|
+
const res = await driver.get('/.well-known/openwop');
|
|
41
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
42
|
+
return body?.capabilities?.authorization ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('authorization-fail-closed: advertisement shape (RFC 0049 §C)', () => {
|
|
46
|
+
it('failClosed is exactly true when authorization is supported', async () => {
|
|
47
|
+
const authz = await readAuthorization();
|
|
48
|
+
if (!authz?.supported || authz.failClosed === undefined) return;
|
|
49
|
+
expect(
|
|
50
|
+
authz.failClosed,
|
|
51
|
+
driver.describe('RFC 0049 §C', 'capabilities.authorization.failClosed MUST be `true`'),
|
|
52
|
+
).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('authorization-fail-closed: absent/unseeded role MUST deny (RFC 0049 §C)', () => {
|
|
57
|
+
it('a decision for an unseeded-role principal resolves allowed=false', async () => {
|
|
58
|
+
const authz = await readAuthorization();
|
|
59
|
+
if (!authz?.supported) return; // capability-gated
|
|
60
|
+
|
|
61
|
+
// Seam contract: request an authorization decision for a principal whose
|
|
62
|
+
// role is absent/unseeded. The host MUST fail closed.
|
|
63
|
+
const res = await driver.post('/v1/host/sample/authorization/decide', {
|
|
64
|
+
principal: 'conformance-unseeded-principal',
|
|
65
|
+
action: 'runs:cancel',
|
|
66
|
+
resource: 'run-conformance-probe',
|
|
67
|
+
});
|
|
68
|
+
// 404 from a host that hasn't wired the seam is a soft-skip.
|
|
69
|
+
if (res.status === 404) return;
|
|
70
|
+
|
|
71
|
+
const decision = res.json as { allowed?: boolean } | undefined;
|
|
72
|
+
expect(
|
|
73
|
+
decision?.allowed,
|
|
74
|
+
driver.describe(
|
|
75
|
+
'SECURITY/invariants.yaml authorization-fail-closed',
|
|
76
|
+
'an absent/unseeded role MUST deny (allowed=false); the host MUST NOT default-allow',
|
|
77
|
+
),
|
|
78
|
+
).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authorization-roles-shape — RFC 0049 §A advertisement-shape verification.
|
|
3
|
+
*
|
|
4
|
+
* Status: DRAFT. RFC 0049 (RBAC scopes & authorization decisions) is `Draft`.
|
|
5
|
+
* The `capabilities.authorization` block has landed in
|
|
6
|
+
* `schemas/capabilities.schema.json`.
|
|
7
|
+
*
|
|
8
|
+
* Always runs (shape-only): when the host advertises
|
|
9
|
+
* `capabilities.authorization`, its fields MUST be well-formed.
|
|
10
|
+
*
|
|
11
|
+
* What this scenario asserts:
|
|
12
|
+
* 1. `capabilities.authorization` is either absent or a well-formed object.
|
|
13
|
+
* 2. When `supported: true`: `failClosed` (when present) is exactly `true`
|
|
14
|
+
* (RFC 0049 §C), and every `roles[]` entry has a non-empty `role` + a
|
|
15
|
+
* `scopes` array (RFC 0049 §A).
|
|
16
|
+
*
|
|
17
|
+
* @see RFCS/0049-rbac-scopes-and-authorization-decisions.md
|
|
18
|
+
* @see spec/v1/auth.md §"Role-based authorization (RFC 0049)"
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from 'vitest';
|
|
22
|
+
import { driver } from '../lib/driver.js';
|
|
23
|
+
|
|
24
|
+
interface DiscoveryRole {
|
|
25
|
+
role?: string;
|
|
26
|
+
scopes?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface DiscoveryAuthorization {
|
|
30
|
+
supported?: boolean;
|
|
31
|
+
failClosed?: boolean;
|
|
32
|
+
roles?: DiscoveryRole[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface DiscoveryDoc {
|
|
36
|
+
capabilities?: {
|
|
37
|
+
authorization?: DiscoveryAuthorization;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readAuthorization(): Promise<DiscoveryAuthorization | null> {
|
|
42
|
+
const res = await driver.get('/.well-known/openwop');
|
|
43
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
44
|
+
return body?.capabilities?.authorization ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('authorization-roles-shape: advertisement shape (RFC 0049 §A)', () => {
|
|
48
|
+
it('capabilities.authorization is either absent or well-formed', async () => {
|
|
49
|
+
const authz = await readAuthorization();
|
|
50
|
+
if (authz === null) return; // host doesn't advertise authorization at all
|
|
51
|
+
expect(
|
|
52
|
+
typeof authz.supported,
|
|
53
|
+
driver.describe(
|
|
54
|
+
'capabilities.schema.json §authorization',
|
|
55
|
+
'capabilities.authorization.supported MUST be a boolean when authorization is advertised',
|
|
56
|
+
),
|
|
57
|
+
).toBe('boolean');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('failClosed, when present, is exactly true (RFC 0049 §C)', async () => {
|
|
61
|
+
const authz = await readAuthorization();
|
|
62
|
+
if (!authz?.supported || authz.failClosed === undefined) return;
|
|
63
|
+
expect(
|
|
64
|
+
authz.failClosed,
|
|
65
|
+
driver.describe('RFC 0049 §C', 'capabilities.authorization.failClosed MUST be `true` (fail-closed)'),
|
|
66
|
+
).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('every advertised role has a non-empty role name + a scopes array', async () => {
|
|
70
|
+
const authz = await readAuthorization();
|
|
71
|
+
if (!authz?.supported || authz.roles === undefined) return;
|
|
72
|
+
for (const entry of authz.roles) {
|
|
73
|
+
expect(
|
|
74
|
+
typeof entry.role === 'string' && entry.role.length > 0,
|
|
75
|
+
driver.describe('RFC 0049 §A', 'each capabilities.authorization.roles[] entry MUST declare a non-empty role'),
|
|
76
|
+
).toBe(true);
|
|
77
|
+
expect(
|
|
78
|
+
Array.isArray(entry.scopes),
|
|
79
|
+
driver.describe('RFC 0049 §A', 'each role MUST declare a scopes array'),
|
|
80
|
+
).toBe(true);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connector-manifest-validity — RFC 0045 §A/§B verification.
|
|
3
|
+
*
|
|
4
|
+
* Status: DRAFT. RFC 0045 (connector pack manifest & action model) is `Draft`.
|
|
5
|
+
* The optional `connector` block + `Connector` / `ConnectorAuth` $defs have
|
|
6
|
+
* landed in `schemas/node-pack-manifest.schema.json`.
|
|
7
|
+
*
|
|
8
|
+
* Server-free schema + semantic validation. Two contracts:
|
|
9
|
+
* 1. Schema validity (§A): a well-formed `connector` block validates; a
|
|
10
|
+
* block missing `id`/`displayName` or an action missing `typeId` is
|
|
11
|
+
* rejected. Both ConnectorAuth variants (oauth2 / credential) validate.
|
|
12
|
+
* 2. Action resolution (§B): every `connector.actions[].typeId` and
|
|
13
|
+
* `connector.triggers[]` entry MUST resolve to a `nodes[].typeId` in the
|
|
14
|
+
* same manifest; an unresolved reference is `connector_action_unresolved`.
|
|
15
|
+
*
|
|
16
|
+
* The Connector subschema is extracted self-contained (Connector + its
|
|
17
|
+
* ConnectorAuth + NodeAuth $defs) so ajv compiles it without resolving the
|
|
18
|
+
* parent manifest's external `agent-manifest.schema.json` $ref.
|
|
19
|
+
*
|
|
20
|
+
* @see RFCS/0045-connector-pack-manifest-action-model.md
|
|
21
|
+
* @see schemas/node-pack-manifest.schema.json §Connector
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from 'vitest';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
28
|
+
import addFormats from 'ajv-formats';
|
|
29
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
30
|
+
|
|
31
|
+
interface SchemaDefs {
|
|
32
|
+
$defs: Record<string, unknown>;
|
|
33
|
+
$schema: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const manifestSchema = JSON.parse(
|
|
37
|
+
readFileSync(join(SCHEMAS_DIR, 'node-pack-manifest.schema.json'), 'utf8'),
|
|
38
|
+
) as SchemaDefs;
|
|
39
|
+
|
|
40
|
+
// Self-contained Connector schema: root = Connector, carrying the $defs it
|
|
41
|
+
// references (ConnectorAuth → NodeAuth). No external $ref resolution needed.
|
|
42
|
+
const connectorSchema = {
|
|
43
|
+
$schema: manifestSchema.$schema,
|
|
44
|
+
...(manifestSchema.$defs.Connector as Record<string, unknown>),
|
|
45
|
+
$defs: {
|
|
46
|
+
ConnectorAuth: manifestSchema.$defs.ConnectorAuth,
|
|
47
|
+
NodeAuth: manifestSchema.$defs.NodeAuth,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// §B action/trigger resolution — the semantic check the registry applies
|
|
52
|
+
// beyond pure JSON Schema (typeIds must resolve to real nodes).
|
|
53
|
+
interface ConnectorAction {
|
|
54
|
+
typeId: string;
|
|
55
|
+
}
|
|
56
|
+
interface ConnectorBlock {
|
|
57
|
+
actions?: ConnectorAction[];
|
|
58
|
+
triggers?: string[];
|
|
59
|
+
}
|
|
60
|
+
function unresolvedReferences(nodeTypeIds: string[], connector: ConnectorBlock): string[] {
|
|
61
|
+
const known = new Set(nodeTypeIds);
|
|
62
|
+
const refs = [
|
|
63
|
+
...(connector.actions ?? []).map((a) => a.typeId),
|
|
64
|
+
...(connector.triggers ?? []),
|
|
65
|
+
];
|
|
66
|
+
return refs.filter((t) => !known.has(t));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('category: connector-manifest validity — schema shape (RFC 0045 §A)', () => {
|
|
70
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
71
|
+
addFormats(ajv);
|
|
72
|
+
const validate = ajv.compile(connectorSchema);
|
|
73
|
+
|
|
74
|
+
it('positive: a well-formed connector block (oauth2 auth) validates', () => {
|
|
75
|
+
const ok = validate({
|
|
76
|
+
id: 'salesforce',
|
|
77
|
+
displayName: 'Salesforce',
|
|
78
|
+
auth: { type: 'oauth2', provider: 'salesforce', scopes: ['api'] },
|
|
79
|
+
actions: [
|
|
80
|
+
{ typeId: 'vendor.acme.salesforce.upsert', displayName: 'Upsert', idempotent: true, rateLimit: { requests: 100, perSeconds: 60 } },
|
|
81
|
+
{ typeId: 'vendor.acme.salesforce.query', displayName: 'Query', paginated: true },
|
|
82
|
+
],
|
|
83
|
+
triggers: ['vendor.acme.salesforce.onRecordChange'],
|
|
84
|
+
});
|
|
85
|
+
expect(ok, JSON.stringify(validate.errors)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('positive: credential-auth variant validates', () => {
|
|
89
|
+
const ok = validate({
|
|
90
|
+
id: 'stripe',
|
|
91
|
+
displayName: 'Stripe',
|
|
92
|
+
auth: { type: 'credential', key: 'stripe-secret-key', scope: 'workspace' },
|
|
93
|
+
actions: [{ typeId: 'vendor.acme.stripe.charge', displayName: 'Charge' }],
|
|
94
|
+
});
|
|
95
|
+
expect(ok, JSON.stringify(validate.errors)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('negative: connector missing displayName is rejected', () => {
|
|
99
|
+
expect(validate({ id: 'salesforce' })).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('negative: an action missing typeId is rejected', () => {
|
|
103
|
+
const ok = validate({
|
|
104
|
+
id: 'salesforce',
|
|
105
|
+
displayName: 'Salesforce',
|
|
106
|
+
actions: [{ displayName: 'Upsert' }],
|
|
107
|
+
});
|
|
108
|
+
expect(ok).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('negative: an unknown ConnectorAuth type is rejected', () => {
|
|
112
|
+
const ok = validate({
|
|
113
|
+
id: 'salesforce',
|
|
114
|
+
displayName: 'Salesforce',
|
|
115
|
+
auth: { type: 'basic', user: 'x' },
|
|
116
|
+
});
|
|
117
|
+
expect(ok).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('category: connector-manifest validity — action resolution (RFC 0045 §B)', () => {
|
|
122
|
+
const nodeTypeIds = [
|
|
123
|
+
'vendor.acme.salesforce.upsert',
|
|
124
|
+
'vendor.acme.salesforce.query',
|
|
125
|
+
'vendor.acme.salesforce.onRecordChange',
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
it('all action + trigger typeIds resolve to nodes[] in the same manifest', () => {
|
|
129
|
+
const unresolved = unresolvedReferences(nodeTypeIds, {
|
|
130
|
+
actions: [{ typeId: 'vendor.acme.salesforce.upsert' }, { typeId: 'vendor.acme.salesforce.query' }],
|
|
131
|
+
triggers: ['vendor.acme.salesforce.onRecordChange'],
|
|
132
|
+
});
|
|
133
|
+
expect(unresolved, 'every connector.actions[].typeId + triggers[] MUST resolve to a nodes[].typeId').toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('an action typeId not in nodes[] is flagged (connector_action_unresolved)', () => {
|
|
137
|
+
const unresolved = unresolvedReferences(nodeTypeIds, {
|
|
138
|
+
actions: [{ typeId: 'vendor.acme.salesforce.does-not-exist' }],
|
|
139
|
+
});
|
|
140
|
+
expect(unresolved).toContain('vendor.acme.salesforce.does-not-exist');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credential-payload-redaction — RFC 0046 §C.1 / §E invariant verification.
|
|
3
|
+
*
|
|
4
|
+
* Status: DRAFT. RFC 0046 (`host.credentials`) is `Draft`. Backs the
|
|
5
|
+
* SECURITY invariant `credential-payload-redaction`.
|
|
6
|
+
*
|
|
7
|
+
* Capability-gated: skips when the host does not advertise
|
|
8
|
+
* `capabilities.credentials.supported = true`.
|
|
9
|
+
*
|
|
10
|
+
* What this scenario asserts:
|
|
11
|
+
* 1. Advertisement shape — `capabilities.credentials` is well-formed when
|
|
12
|
+
* supported (delegated in detail to credentials-capability-shape).
|
|
13
|
+
* 2. Redaction MUST-NOT — when the host exposes the optional
|
|
14
|
+
* `POST /v1/host/sample/credentials/echo` test seam, resolving a seeded
|
|
15
|
+
* credential whose plaintext is a known canary MUST NOT cause that
|
|
16
|
+
* canary to appear in ANY of the run's observable surfaces (events,
|
|
17
|
+
* inputs, persisted variables, channels, snapshot, debug bundle). The
|
|
18
|
+
* reference is the only credential artifact permitted on the wire.
|
|
19
|
+
*
|
|
20
|
+
* Hosts without the test seam soft-skip the redaction probe (404) and still
|
|
21
|
+
* assert the advertisement shape — mirroring fs-path-traversal.
|
|
22
|
+
*
|
|
23
|
+
* @see RFCS/0046-host-credentials-capability.md
|
|
24
|
+
* @see SECURITY/invariants.yaml id: credential-payload-redaction
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest';
|
|
28
|
+
import { driver } from '../lib/driver.js';
|
|
29
|
+
|
|
30
|
+
interface DiscoveryCredentials {
|
|
31
|
+
supported?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface DiscoveryDoc {
|
|
35
|
+
capabilities?: {
|
|
36
|
+
credentials?: DiscoveryCredentials;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A unique canary the seam seeds as the resolved credential's plaintext.
|
|
41
|
+
const CANARY = 'OPENWOP_CRED_CANARY_4f1c8a2e9b';
|
|
42
|
+
|
|
43
|
+
async function readCredentials(): Promise<DiscoveryCredentials | null> {
|
|
44
|
+
const res = await driver.get('/.well-known/openwop');
|
|
45
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
46
|
+
return body?.capabilities?.credentials ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('credential-payload-redaction: advertisement shape (RFC 0046 §A)', () => {
|
|
50
|
+
it('capabilities.credentials.supported is a boolean when advertised', async () => {
|
|
51
|
+
const cred = await readCredentials();
|
|
52
|
+
if (cred === null) return;
|
|
53
|
+
expect(
|
|
54
|
+
typeof cred.supported,
|
|
55
|
+
driver.describe(
|
|
56
|
+
'capabilities.schema.json §credentials',
|
|
57
|
+
'capabilities.credentials.supported MUST be a boolean when credentials is advertised',
|
|
58
|
+
),
|
|
59
|
+
).toBe('boolean');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('credential-payload-redaction: resolved material MUST NOT cross the wire (RFC 0046 §C.1)', () => {
|
|
64
|
+
it('canary plaintext is absent from every observable run surface', async () => {
|
|
65
|
+
const cred = await readCredentials();
|
|
66
|
+
if (!cred?.supported) return; // capability-gated
|
|
67
|
+
|
|
68
|
+
// Seam contract: resolve a seeded credential whose plaintext is CANARY,
|
|
69
|
+
// run an echo node, and return the run's observable surfaces.
|
|
70
|
+
const res = await driver.post('/v1/host/sample/credentials/echo', { canary: CANARY });
|
|
71
|
+
// 404 from a host that hasn't wired the test seam is a soft-skip.
|
|
72
|
+
if (res.status === 404) return;
|
|
73
|
+
|
|
74
|
+
expect(
|
|
75
|
+
res.status,
|
|
76
|
+
driver.describe(
|
|
77
|
+
'SECURITY/invariants.yaml credential-payload-redaction',
|
|
78
|
+
'the credentials echo seam MUST resolve the seeded credential and return its observable surfaces',
|
|
79
|
+
),
|
|
80
|
+
).toBeLessThan(400);
|
|
81
|
+
|
|
82
|
+
// The entire serialized observable surface (events + inputs + variables +
|
|
83
|
+
// channels + snapshot + debug bundle) MUST NOT contain the canary plaintext.
|
|
84
|
+
const serialized = JSON.stringify(res.json ?? {});
|
|
85
|
+
expect(
|
|
86
|
+
serialized.includes(CANARY),
|
|
87
|
+
driver.describe(
|
|
88
|
+
'SECURITY/invariants.yaml credential-payload-redaction',
|
|
89
|
+
'resolved credential material MUST NOT appear in inputs, variables, channels, events, snapshot, or debug bundle — only the reference may cross the wire',
|
|
90
|
+
),
|
|
91
|
+
).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credentials-capability-shape — RFC 0046 §A advertisement-shape verification.
|
|
3
|
+
*
|
|
4
|
+
* Status: DRAFT. RFC 0046 (`host.credentials`) is `Draft`. The
|
|
5
|
+
* `capabilities.credentials` block has landed in
|
|
6
|
+
* `schemas/capabilities.schema.json` and the invariant row
|
|
7
|
+
* `credential-payload-redaction` is in `SECURITY/invariants.yaml`.
|
|
8
|
+
*
|
|
9
|
+
* Always runs (shape-only): when the host advertises `capabilities.credentials`,
|
|
10
|
+
* its fields MUST be well-formed; when it doesn't, the block is simply absent.
|
|
11
|
+
*
|
|
12
|
+
* What this scenario asserts:
|
|
13
|
+
* 1. `capabilities.credentials` is either absent or a well-formed object.
|
|
14
|
+
* 2. When `supported: true`, `scopes` (when present) is a subset of
|
|
15
|
+
* {user, workspace, tenant}, and `rotation` (when present) is one of
|
|
16
|
+
* {none, two-key-overlap} (RFC 0046 §A).
|
|
17
|
+
*
|
|
18
|
+
* @see RFCS/0046-host-credentials-capability.md
|
|
19
|
+
* @see SECURITY/invariants.yaml id: credential-payload-redaction
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest';
|
|
23
|
+
import { driver } from '../lib/driver.js';
|
|
24
|
+
|
|
25
|
+
interface DiscoveryCredentials {
|
|
26
|
+
supported?: boolean;
|
|
27
|
+
scopes?: string[];
|
|
28
|
+
encryptionAtRest?: boolean;
|
|
29
|
+
rotation?: string;
|
|
30
|
+
sharing?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DiscoveryDoc {
|
|
34
|
+
capabilities?: {
|
|
35
|
+
credentials?: DiscoveryCredentials;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const VALID_SCOPES: ReadonlySet<string> = new Set(['user', 'workspace', 'tenant']);
|
|
40
|
+
const VALID_ROTATION: ReadonlySet<string> = new Set(['none', 'two-key-overlap']);
|
|
41
|
+
|
|
42
|
+
async function readCredentials(): Promise<DiscoveryCredentials | null> {
|
|
43
|
+
const res = await driver.get('/.well-known/openwop');
|
|
44
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
45
|
+
return body?.capabilities?.credentials ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('credentials-capability-shape: advertisement shape (RFC 0046 §A)', () => {
|
|
49
|
+
it('capabilities.credentials is either absent or well-formed', async () => {
|
|
50
|
+
const cred = await readCredentials();
|
|
51
|
+
if (cred === null) return; // host doesn't advertise host.credentials at all
|
|
52
|
+
expect(
|
|
53
|
+
typeof cred.supported,
|
|
54
|
+
driver.describe(
|
|
55
|
+
'capabilities.schema.json §credentials',
|
|
56
|
+
'capabilities.credentials.supported MUST be a boolean when credentials is advertised',
|
|
57
|
+
),
|
|
58
|
+
).toBe('boolean');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('scopes is a subset of {user, workspace, tenant} when supported', async () => {
|
|
62
|
+
const cred = await readCredentials();
|
|
63
|
+
if (!cred?.supported || cred.scopes === undefined) return;
|
|
64
|
+
expect(
|
|
65
|
+
Array.isArray(cred.scopes),
|
|
66
|
+
driver.describe('RFC 0046 §A', 'capabilities.credentials.scopes MUST be an array'),
|
|
67
|
+
).toBe(true);
|
|
68
|
+
for (const scope of cred.scopes) {
|
|
69
|
+
expect(
|
|
70
|
+
VALID_SCOPES.has(scope),
|
|
71
|
+
driver.describe(
|
|
72
|
+
'RFC 0046 §A',
|
|
73
|
+
`capabilities.credentials.scopes entries MUST be one of {${[...VALID_SCOPES].join(', ')}}, got: ${scope}`,
|
|
74
|
+
),
|
|
75
|
+
).toBe(true);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rotation is one of {none, two-key-overlap} when present', async () => {
|
|
80
|
+
const cred = await readCredentials();
|
|
81
|
+
if (!cred?.supported || cred.rotation === undefined) return;
|
|
82
|
+
expect(
|
|
83
|
+
VALID_ROTATION.has(cred.rotation),
|
|
84
|
+
driver.describe(
|
|
85
|
+
'RFC 0046 §A',
|
|
86
|
+
`capabilities.credentials.rotation MUST be one of {${[...VALID_ROTATION].join(', ')}}, got: ${cred.rotation}`,
|
|
87
|
+
),
|
|
88
|
+
).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
});
|