@openwop/openwop-conformance 1.10.0 → 1.12.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 +48 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +70 -0
- package/api/openapi.yaml +268 -1
- package/coverage.md +33 -2
- package/fixtures/oauth-providers/synthetic.json +38 -0
- package/fixtures.md +10 -0
- package/package.json +1 -1
- package/schemas/README.md +12 -0
- package/schemas/agent-deployment-transition.schema.json +49 -0
- package/schemas/agent-deployment.schema.json +54 -0
- package/schemas/agent-eval-suite.schema.json +140 -0
- package/schemas/agent-inventory-response.schema.json +25 -0
- package/schemas/agent-manifest.schema.json +5 -0
- package/schemas/agent-org-chart.schema.json +82 -0
- package/schemas/agent-ref.schema.json +12 -2
- package/schemas/agent-roster-entry.schema.json +81 -0
- package/schemas/agent-roster-response.schema.json +21 -0
- package/schemas/budget-policy.schema.json +18 -0
- package/schemas/capabilities.schema.json +277 -0
- package/schemas/credential-provenance.schema.json +18 -0
- package/schemas/eval-summary.schema.json +92 -0
- package/schemas/node-pack-manifest.schema.json +17 -0
- package/schemas/org-chart-responsibility-view.schema.json +26 -0
- package/schemas/run-event-payloads.schema.json +286 -3
- package/schemas/run-event.schema.json +19 -0
- package/schemas/tool-descriptor.schema.json +63 -0
- package/schemas/trigger-subscription.schema.json +26 -0
- package/src/lib/agentOrgChart.ts +82 -0
- package/src/lib/agentRoster.ts +76 -0
- package/src/lib/liveRuntime.ts +59 -0
- package/src/lib/profiles.ts +157 -0
- package/src/lib/runtimeRequires.ts +38 -0
- package/src/lib/safeFetch.ts +87 -0
- package/src/lib/triggerBridge.ts +74 -0
- package/src/scenarios/agent-deployment-shape.test.ts +139 -0
- package/src/scenarios/agent-eval-suite-shape.test.ts +167 -0
- package/src/scenarios/agent-live-allowlist-enforced.test.ts +53 -0
- package/src/scenarios/agent-live-invocation-bracket.test.ts +98 -0
- package/src/scenarios/agent-live-runtime-shape.test.ts +98 -0
- package/src/scenarios/agent-live-structured-output.test.ts +58 -0
- package/src/scenarios/agent-org-chart-scoping.test.ts +137 -0
- package/src/scenarios/agent-org-chart-shape.test.ts +127 -0
- package/src/scenarios/agent-platform-profile.test.ts +158 -0
- package/src/scenarios/agent-roster-attribution.test.ts +179 -0
- package/src/scenarios/agent-roster-shape.test.ts +146 -0
- package/src/scenarios/budget-policy-shape.test.ts +136 -0
- package/src/scenarios/egress-provenance-shape.test.ts +137 -0
- package/src/scenarios/memory-capability-model-shape.test.ts +186 -0
- package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +145 -0
- package/src/scenarios/org-position-no-authority-escalation.test.ts +78 -0
- package/src/scenarios/runtime-requires-install-gate.test.ts +92 -0
- package/src/scenarios/runtime-requires-shape.test.ts +134 -0
- package/src/scenarios/safefetch-behavior.test.ts +99 -0
- package/src/scenarios/safefetch-live-audit.test.ts +175 -0
- package/src/scenarios/spec-corpus-validity.test.ts +19 -3
- package/src/scenarios/tool-descriptor-shape.test.ts +133 -0
- package/src/scenarios/trigger-bridge-delivery.test.ts +126 -0
- package/src/scenarios/trigger-bridge-shape.test.ts +135 -0
- package/src/scenarios/x-openwop-form-pack-manifest.test.ts +155 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oauth-authorization-code-roundtrip — RFC 0047 §C (the authorization-code grant
|
|
3
|
+
* end-to-end) + §C.2 / `credential-payload-redaction`.
|
|
4
|
+
*
|
|
5
|
+
* Closes the RFC 0047 Tier-2 gap: `oauth-capability-shape` proves the discovery
|
|
6
|
+
* block is well-formed and `oauth-connector-redaction` proves an already-acquired
|
|
7
|
+
* token doesn't leak — but nothing exercised the actual authorization-code DANCE
|
|
8
|
+
* (redirect → callback → token exchange) against a known provider. This scenario
|
|
9
|
+
* drives that roundtrip against ONE canonical synthetic provider whose endpoints a
|
|
10
|
+
* conformance test double serves, so a host can prove the grant without a live IdP.
|
|
11
|
+
*
|
|
12
|
+
* The synthetic provider + its canned exchange are defined in
|
|
13
|
+
* `fixtures/oauth-providers/synthetic.json`; the constants below mirror it (kept
|
|
14
|
+
* inline so the scenario runs from the published tarball without fixture-path
|
|
15
|
+
* resolution, exactly like `oauth-connector-redaction`'s TOKEN_CANARY).
|
|
16
|
+
*
|
|
17
|
+
* Capability-gated: skips unless the host advertises
|
|
18
|
+
* `capabilities.oauth.supported = true` AND lists `authorization_code` in
|
|
19
|
+
* `capabilities.oauth.grants`. Behavioral probe drives the optional host seam
|
|
20
|
+
* `POST /v1/host/sample/oauth/authorize-code-roundtrip`; a 404 (seam not wired)
|
|
21
|
+
* is a soft-skip — this is a Tier-2 host-pending scenario.
|
|
22
|
+
*
|
|
23
|
+
* Asserts, when the seam is present:
|
|
24
|
+
* 1. The roundtrip succeeds and returns a credential REFERENCE (the token was
|
|
25
|
+
* acquired + persisted as a host.credentials entry), never the token itself.
|
|
26
|
+
* 2. `connector.authorized` carries `{ provider, credentialRef, scopes }` and
|
|
27
|
+
* none of the token / refresh / code / state / redirectUri / codeVerifier.
|
|
28
|
+
* 3. RFC 0047 §C — the authorization code, redirect URI, state, and PKCE
|
|
29
|
+
* verifier MUST NOT appear on ANY run-visible surface; §C.2 — neither MUST
|
|
30
|
+
* the access/refresh token (the canaries are absent from the whole response).
|
|
31
|
+
*
|
|
32
|
+
* @see RFCS/0047-host-oauth-connector-flows.md §C
|
|
33
|
+
* @see conformance/fixtures/oauth-providers/synthetic.json
|
|
34
|
+
* @see SECURITY/invariants.yaml id: credential-payload-redaction
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { describe, it, expect } from 'vitest';
|
|
38
|
+
import { driver } from '../lib/driver.js';
|
|
39
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
40
|
+
|
|
41
|
+
interface DiscoveryOAuth {
|
|
42
|
+
supported?: boolean;
|
|
43
|
+
grants?: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Mirrors fixtures/oauth-providers/synthetic.json — keep in sync.
|
|
47
|
+
const SYNTHETIC = {
|
|
48
|
+
provider: 'synthetic',
|
|
49
|
+
authUrl: 'https://oauth.synthetic.openwop.test/authorize',
|
|
50
|
+
tokenUrl: 'https://oauth.synthetic.openwop.test/token',
|
|
51
|
+
scopes: ['openwop.read', 'openwop.write'],
|
|
52
|
+
authorizationCode: 'openwop-synthetic-auth-code-1f4b9e',
|
|
53
|
+
state: 'openwop-synthetic-state-7c2a8d',
|
|
54
|
+
redirectUri: 'https://host.example/openwop/oauth/callback',
|
|
55
|
+
codeVerifier: 'openwop-synthetic-pkce-verifier-3e9f1b2c5a7d4e8f0a1b2c3d4e5f6a7b',
|
|
56
|
+
accessTokenCanary: 'OPENWOP_OAUTH_TOKEN_CANARY_9d4c1f7a',
|
|
57
|
+
refreshTokenCanary: 'OPENWOP_OAUTH_REFRESH_CANARY_2b8e6a3f',
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
// Values that MUST NOT appear on any run-visible surface (RFC 0047 §C + §C.2).
|
|
61
|
+
const SECRET_VALUES: readonly string[] = [
|
|
62
|
+
SYNTHETIC.accessTokenCanary,
|
|
63
|
+
SYNTHETIC.refreshTokenCanary,
|
|
64
|
+
SYNTHETIC.authorizationCode,
|
|
65
|
+
SYNTHETIC.state,
|
|
66
|
+
SYNTHETIC.codeVerifier,
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
async function readOAuth(): Promise<DiscoveryOAuth | null> {
|
|
70
|
+
const res = await driver.get('/.well-known/openwop');
|
|
71
|
+
return capabilityFamily<DiscoveryOAuth>(res.json, 'oauth') ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('oauth-authorization-code-roundtrip: the grant dance (RFC 0047 §C)', () => {
|
|
75
|
+
it('acquires a token via authorization_code and returns a reference, never the token', async () => {
|
|
76
|
+
const oauth = await readOAuth();
|
|
77
|
+
if (!oauth?.supported) return; // capability-gated
|
|
78
|
+
if (!Array.isArray(oauth.grants) || !oauth.grants.includes('authorization_code')) return; // grant-gated
|
|
79
|
+
|
|
80
|
+
// Seam contract: the host performs the full authorization-code roundtrip
|
|
81
|
+
// against the synthetic provider's authUrl/tokenUrl, persists the acquired
|
|
82
|
+
// token as a host.credentials entry, and returns the run-observable surfaces
|
|
83
|
+
// (events incl. connector.authorized + snapshot + any debug bundle) plus the
|
|
84
|
+
// resulting credentialRef.
|
|
85
|
+
const res = await driver.post('/v1/host/sample/oauth/authorize-code-roundtrip', {
|
|
86
|
+
provider: SYNTHETIC.provider,
|
|
87
|
+
authUrl: SYNTHETIC.authUrl,
|
|
88
|
+
tokenUrl: SYNTHETIC.tokenUrl,
|
|
89
|
+
scopes: SYNTHETIC.scopes,
|
|
90
|
+
authorizationCode: SYNTHETIC.authorizationCode,
|
|
91
|
+
state: SYNTHETIC.state,
|
|
92
|
+
redirectUri: SYNTHETIC.redirectUri,
|
|
93
|
+
codeVerifier: SYNTHETIC.codeVerifier,
|
|
94
|
+
accessTokenCanary: SYNTHETIC.accessTokenCanary,
|
|
95
|
+
refreshTokenCanary: SYNTHETIC.refreshTokenCanary,
|
|
96
|
+
});
|
|
97
|
+
// A host that hasn't wired the seam soft-skips (Tier-2, host-pending).
|
|
98
|
+
if (res.status === 404) return;
|
|
99
|
+
|
|
100
|
+
expect(
|
|
101
|
+
res.status,
|
|
102
|
+
driver.describe(
|
|
103
|
+
'RFC 0047 §C',
|
|
104
|
+
'the authorize-code-roundtrip seam MUST perform the authorization_code grant against the synthetic provider and return the run observable surfaces',
|
|
105
|
+
),
|
|
106
|
+
).toBeLessThan(400);
|
|
107
|
+
|
|
108
|
+
const body = (res.json ?? {}) as { credentialRef?: unknown };
|
|
109
|
+
expect(
|
|
110
|
+
typeof body.credentialRef === 'string' && body.credentialRef.length > 0,
|
|
111
|
+
driver.describe(
|
|
112
|
+
'RFC 0047 §C',
|
|
113
|
+
'a successful roundtrip MUST resolve to a credential REFERENCE (token persisted as a host.credentials entry), not the raw token',
|
|
114
|
+
),
|
|
115
|
+
).toBe(true);
|
|
116
|
+
|
|
117
|
+
// §C + §C.2 — no secret material anywhere in the observable response.
|
|
118
|
+
const serialized = JSON.stringify(res.json ?? {});
|
|
119
|
+
for (const secret of SECRET_VALUES) {
|
|
120
|
+
expect(
|
|
121
|
+
serialized.includes(secret),
|
|
122
|
+
driver.describe(
|
|
123
|
+
'RFC 0047 §C / SECURITY/invariants.yaml credential-payload-redaction',
|
|
124
|
+
`the authorization code, state, PKCE verifier, and acquired token material MUST NOT appear on any run-visible surface — leaked: ${secret.slice(0, 16)}…`,
|
|
125
|
+
),
|
|
126
|
+
).toBe(false);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// §C — connector.authorized carries the reference + scopes, never the token.
|
|
130
|
+
const events = (res.json as { events?: Array<{ type?: string; payload?: Record<string, unknown> }> })?.events;
|
|
131
|
+
if (Array.isArray(events)) {
|
|
132
|
+
const authorized = events.find((e) => e?.type === 'connector.authorized');
|
|
133
|
+
if (authorized?.payload) {
|
|
134
|
+
const keys = Object.keys(authorized.payload);
|
|
135
|
+
expect(
|
|
136
|
+
keys.includes('credentialRef') && !keys.includes('access_token') && !keys.includes('refresh_token'),
|
|
137
|
+
driver.describe(
|
|
138
|
+
'RFC 0047 §C',
|
|
139
|
+
'connector.authorized MUST carry { provider, credentialRef, scopes } and MUST NOT carry token material',
|
|
140
|
+
),
|
|
141
|
+
).toBe(true);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Org position confers no authority — the §B invariant, behavioral leg
|
|
3
|
+
* (RFC 0087 §B) — the protocol-tier `org-position-no-authority-escalation`.
|
|
4
|
+
*
|
|
5
|
+
* The STRUCTURAL leg (the `agent-org-chart.schema.json` is `additionalProperties:
|
|
6
|
+
* false` and rejects an authority-bearing field on a member) is always-on /
|
|
7
|
+
* server-free in `agent-org-chart-shape.test.ts`. This scenario is the
|
|
8
|
+
* BEHAVIORAL leg, gated on `capabilities.agents.orgChart.supported`: it proves
|
|
9
|
+
* against the LIVE host that the org-chart projector strips position-as-authority
|
|
10
|
+
* — no member, department, or responsibility-view object served on the wire
|
|
11
|
+
* carries an authority-bearing field (`scopes` / `canDispatch` / `permissions` /
|
|
12
|
+
* `authority` / `roleGrants` / `capabilities`), at every install scope. An org
|
|
13
|
+
* edge is an *ownership + reporting* record, never an authority grant.
|
|
14
|
+
*
|
|
15
|
+
* Soft-skips when unadvertised (default) / hard-fails under
|
|
16
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true`.
|
|
17
|
+
*
|
|
18
|
+
* The deeper authority-invariance legs — a manager agent cannot dispatch a
|
|
19
|
+
* report's tools (RFC 0002 §A14), an RFC 0049 authorization decision is
|
|
20
|
+
* invariant to org position, an RFC 0051 approval gate is not satisfied by org
|
|
21
|
+
* seniority — require a non-normative host authorization-decide hook to force
|
|
22
|
+
* black-box; a conformant host need not expose one, so (mirroring the RFC 0070
|
|
23
|
+
* `agent-manifest-runtime` confidence-escalation note) they stay reference-impl
|
|
24
|
+
* tier and are NOT asserted here. The wire-projection proof below is the
|
|
25
|
+
* load-bearing, hook-free behavioral guarantee.
|
|
26
|
+
*
|
|
27
|
+
* Spec references:
|
|
28
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/agent-org-chart.md (§B)
|
|
29
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0087-agent-org-chart.md (§B)
|
|
30
|
+
* - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (org-position-no-authority-escalation)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect } from 'vitest';
|
|
34
|
+
import { driver } from '../lib/driver.js';
|
|
35
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
36
|
+
import { readOrgChartCap, getOrgChart, getDepartmentView, AUTHORITY_FIELDS } from '../lib/agentOrgChart.js';
|
|
37
|
+
|
|
38
|
+
/** Assert an org-chart wire object carries no authority-bearing field. */
|
|
39
|
+
function expectNoAuthority(obj: Record<string, unknown> | undefined, where: string): void {
|
|
40
|
+
if (!obj || typeof obj !== 'object') return;
|
|
41
|
+
for (const f of AUTHORITY_FIELDS) {
|
|
42
|
+
expect(
|
|
43
|
+
!(f in obj),
|
|
44
|
+
driver.describe('RFC 0087 §B / org-position-no-authority-escalation', `${where} MUST NOT carry the authority field "${f}" — org position confers no authority`),
|
|
45
|
+
).toBe(true);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('org-position-no-authority-escalation (RFC 0087 §B, behavioral)', () => {
|
|
50
|
+
it('the live org-chart wire carries no authority-bearing field on any member/department/view', async () => {
|
|
51
|
+
const cap = await readOrgChartCap();
|
|
52
|
+
if (!behaviorGate('openwop-org-position-no-authority', cap?.supported === true)) return;
|
|
53
|
+
|
|
54
|
+
const chart = await getOrgChart();
|
|
55
|
+
if (chart === null) return; // advertised but read not served yet — soft-skip
|
|
56
|
+
|
|
57
|
+
for (const m of chart.members ?? []) {
|
|
58
|
+
expectNoAuthority(m as Record<string, unknown>, 'an org-chart member');
|
|
59
|
+
}
|
|
60
|
+
for (const d of chart.departments ?? []) {
|
|
61
|
+
expectNoAuthority(d as Record<string, unknown>, 'an org-chart department');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The §D responsibility roll-up is a portfolio union (workflow ids), never an
|
|
65
|
+
// authority grant — assert its members + the view object are authority-free too.
|
|
66
|
+
const probeDeptId = (chart.departments ?? [])[0]?.departmentId;
|
|
67
|
+
if (typeof probeDeptId === 'string') {
|
|
68
|
+
const { status, view } = await getDepartmentView(probeDeptId);
|
|
69
|
+
if (status === 200 && view) {
|
|
70
|
+
expectNoAuthority(view as unknown as Record<string, unknown>, 'the responsibility view');
|
|
71
|
+
expectNoAuthority(view.department as Record<string, unknown> | undefined, "the responsibility view's department");
|
|
72
|
+
for (const m of view.members ?? []) {
|
|
73
|
+
expectNoAuthority(m as Record<string, unknown>, 'a responsibility-view member');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pack runtime-requirements install gate — `registry-operations.md`
|
|
3
|
+
* §"Runtime-requirement install gate" + `node-packs.md` §"Runtime platform
|
|
4
|
+
* requirements" (RFC 0076 §A).
|
|
5
|
+
*
|
|
6
|
+
* Seam-gated behavioral scenarios for the install-time gate. A sandbox host MUST
|
|
7
|
+
* evaluate a pack's `runtime.requires[]` against the primitives it will grant
|
|
8
|
+
* and refuse install (`pack_runtime_requirement_unmet`) for any it won't grant —
|
|
9
|
+
* rather than silently installing and failing at first invocation (the
|
|
10
|
+
* `node:dns/promises` trial-load failure that motivated RFC 0076). A non-gating
|
|
11
|
+
* host SHOULD instead project `runtime.requires[]` onto the pack's inventory
|
|
12
|
+
* entry for operator visibility.
|
|
13
|
+
*
|
|
14
|
+
* 1. install-grant — requires ⊆ grant-set ⇒ install succeeds.
|
|
15
|
+
* 2. install-refuse — a required primitive the host won't grant ⇒
|
|
16
|
+
* `pack_runtime_requirement_unmet { unmet, manifest, advice? }`, reusing the
|
|
17
|
+
* `capability_not_provided` envelope shape.
|
|
18
|
+
* 3. non-sandbox projection — a host that does NOT gate platform access
|
|
19
|
+
* installs and projects the declared requires[] for visibility (the §A SHOULD).
|
|
20
|
+
*
|
|
21
|
+
* All three drive `POST /v1/host/sample/packs/install-gate` and soft-skip when
|
|
22
|
+
* the host doesn't wire the seam (404). Behavior grade is `host-pending` until a
|
|
23
|
+
* runtime-requires-gating host (MyndHyve is the first adopter) lights it up.
|
|
24
|
+
*
|
|
25
|
+
* @see spec/v1/registry-operations.md §"Runtime-requirement install gate"
|
|
26
|
+
* @see spec/v1/host-sample-test-seams.md §"Open seams"
|
|
27
|
+
* @see RFCS/0076-pack-runtime-requirements-and-host-safe-fetch.md §A
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { driver } from '../lib/driver.js';
|
|
32
|
+
import { installGate } from '../lib/runtimeRequires.js';
|
|
33
|
+
|
|
34
|
+
function manifest(requires: string[]) {
|
|
35
|
+
return {
|
|
36
|
+
name: 'vendor.example.http',
|
|
37
|
+
version: '1.0.0',
|
|
38
|
+
engines: { openwop: '>=1.1 <2.0.0' },
|
|
39
|
+
runtime: { language: 'javascript', entry: 'index.mjs', requires },
|
|
40
|
+
nodes: [{ typeId: 'vendor.example.http.fetch', version: '1.0.0', category: 'integration', role: 'side-effect' }],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('runtime-requires install gate (RFC 0076 §A)', () => {
|
|
45
|
+
it('install-grant: requires ⊆ grant-set ⇒ install succeeds', async () => {
|
|
46
|
+
const res = await installGate({ manifest: manifest(['net.dns']), grantSet: ['net.dns', 'net.outbound'] });
|
|
47
|
+
if (res === null) return; // seam absent — soft-skip
|
|
48
|
+
expect(
|
|
49
|
+
res.status,
|
|
50
|
+
driver.describe('registry-operations.md §"Runtime-requirement install gate"', 'a pack whose runtime.requires are all grantable MUST install (no refusal)'),
|
|
51
|
+
).toBe(200);
|
|
52
|
+
expect(
|
|
53
|
+
res.body.outcome,
|
|
54
|
+
driver.describe('registry-operations.md §"Runtime-requirement install gate"', 'a granted install reports outcome:"installed"'),
|
|
55
|
+
).toBe('installed');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('install-refuse: an ungrantable primitive ⇒ pack_runtime_requirement_unmet', async () => {
|
|
59
|
+
const res = await installGate({ manifest: manifest(['net.dns']), grantSet: [] });
|
|
60
|
+
if (res === null) return; // seam absent — soft-skip
|
|
61
|
+
expect(
|
|
62
|
+
res.status,
|
|
63
|
+
driver.describe('registry-operations.md §"Runtime-requirement install gate"', 'a pack requiring an ungranted primitive MUST be refused at install (not at first invocation)'),
|
|
64
|
+
).toBe(400);
|
|
65
|
+
expect(
|
|
66
|
+
res.body.error,
|
|
67
|
+
driver.describe('registry-operations.md §"Runtime-requirement install gate"', 'the refusal MUST carry error code pack_runtime_requirement_unmet'),
|
|
68
|
+
).toBe('pack_runtime_requirement_unmet');
|
|
69
|
+
expect(
|
|
70
|
+
Array.isArray(res.body.unmet) && (res.body.unmet as unknown[]).includes('net.dns'),
|
|
71
|
+
driver.describe('registry-operations.md §"Runtime-requirement install gate"', 'unmet[] MUST list the ungranted primitive(s) (capability_not_provided envelope)'),
|
|
72
|
+
).toBe(true);
|
|
73
|
+
expect(
|
|
74
|
+
typeof res.body.manifest === 'string' && (res.body.manifest as string).includes('vendor.example.http'),
|
|
75
|
+
driver.describe('registry-operations.md §"Runtime-requirement install gate"', 'the refusal MUST name the offending manifest (name@version)'),
|
|
76
|
+
).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('non-sandbox projection: a non-gating host installs and projects requires[] (§A SHOULD)', async () => {
|
|
80
|
+
const res = await installGate({ manifest: manifest(['net.dns', 'net.outbound']), gating: false });
|
|
81
|
+
if (res === null) return; // seam absent — soft-skip
|
|
82
|
+
// A non-gating host installs unconditionally; the SHOULD is the projection.
|
|
83
|
+
// If the host gates anyway (returns 400) the projection SHOULD does not apply — tolerate either install shape.
|
|
84
|
+
if (res.status !== 200) return;
|
|
85
|
+
if (res.body.requiresProjected === undefined) return; // SHOULD, not MUST — a non-projecting host is conformant
|
|
86
|
+
const projected = res.body.requiresProjected as unknown;
|
|
87
|
+
expect(
|
|
88
|
+
Array.isArray(projected) && ['net.dns', 'net.outbound'].every((t) => (projected as unknown[]).includes(t)),
|
|
89
|
+
driver.describe('node-packs.md §"Runtime platform requirements"', 'a non-gating host that projects SHOULD surface the declared runtime.requires[] on the inventory entry verbatim'),
|
|
90
|
+
).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pack runtime-requirements vocabulary + shape — `node-packs.md`
|
|
3
|
+
* §"Runtime platform requirements" + `schemas/node-pack-manifest.schema.json`
|
|
4
|
+
* `$defs/Runtime.requires` (RFC 0076 §A).
|
|
5
|
+
*
|
|
6
|
+
* Server-free schema-validation scenario. The `runtime.requires[]` field is an
|
|
7
|
+
* OPTIONAL, closed, runtime-agnostic vocabulary a pack uses to declare the
|
|
8
|
+
* platform primitives its code exercises, so a sandbox host can gate at install
|
|
9
|
+
* time instead of trial-load. This file exercises the schema layer (the §A
|
|
10
|
+
* "vocabulary-validation" normative behavior — a raw builtin name is rejected —
|
|
11
|
+
* plus the additive/empty-array shape contract):
|
|
12
|
+
*
|
|
13
|
+
* 1. Positive: a manifest declaring valid primitives validates cleanly.
|
|
14
|
+
* 2. Positive: the field is OPTIONAL — a manifest omitting it validates.
|
|
15
|
+
* 3. Positive: an empty array (`requires: []`) validates and is equivalent to
|
|
16
|
+
* omission (no host may read a distinct meaning into it; §A).
|
|
17
|
+
* 4. Positive: every one of the 8 vocabulary tokens individually validates.
|
|
18
|
+
* 5. Negative — raw builtin name: `"node:dns/promises"` (the value that
|
|
19
|
+
* motivated the abstract vocabulary) is rejected; the registry/host
|
|
20
|
+
* surfaces this as `invalid_manifest`.
|
|
21
|
+
* 6. Negative — duplicate token: `uniqueItems` is enforced.
|
|
22
|
+
*
|
|
23
|
+
* The install-time GATE behavior (grant / refuse → `pack_runtime_requirement_unmet`,
|
|
24
|
+
* and the non-sandbox-host SHOULD-projection) is host behavior and lives in the
|
|
25
|
+
* seam-gated `runtime-requires-install-gate.test.ts`.
|
|
26
|
+
*
|
|
27
|
+
* @see spec/v1/node-packs.md §"Runtime platform requirements"
|
|
28
|
+
* @see spec/v1/registry-operations.md §"Runtime-requirement install gate"
|
|
29
|
+
* @see schemas/node-pack-manifest.schema.json
|
|
30
|
+
* @see RFCS/0076-pack-runtime-requirements-and-host-safe-fetch.md
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect } from 'vitest';
|
|
34
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
37
|
+
import addFormats from 'ajv-formats';
|
|
38
|
+
import type { ErrorObject, ValidateFunction } from 'ajv';
|
|
39
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
40
|
+
|
|
41
|
+
const SCHEMA_PATH = join(SCHEMAS_DIR, 'node-pack-manifest.schema.json');
|
|
42
|
+
|
|
43
|
+
const VOCABULARY = [
|
|
44
|
+
'net.dns',
|
|
45
|
+
'net.outbound',
|
|
46
|
+
'crypto',
|
|
47
|
+
'subprocess',
|
|
48
|
+
'fs.read',
|
|
49
|
+
'fs.write',
|
|
50
|
+
'env.read',
|
|
51
|
+
'clock',
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
function manifest(requires?: unknown) {
|
|
55
|
+
const runtime: Record<string, unknown> = { language: 'javascript', entry: 'index.mjs' };
|
|
56
|
+
if (requires !== undefined) runtime.requires = requires;
|
|
57
|
+
return {
|
|
58
|
+
name: 'vendor.example.http',
|
|
59
|
+
version: '1.0.0',
|
|
60
|
+
engines: { openwop: '>=1.1 <2.0.0' },
|
|
61
|
+
runtime,
|
|
62
|
+
nodes: [{ typeId: 'vendor.example.http.fetch', version: '1.0.0', category: 'integration', role: 'side-effect' }],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('category: runtime.requires vocabulary + shape (RFC 0076 §A)', () => {
|
|
67
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
68
|
+
addFormats(ajv);
|
|
69
|
+
// Register every schema first so cross-$refs resolve (node-pack-manifest
|
|
70
|
+
// references agent-manifest.schema.json for its agents[] branch). addSchema
|
|
71
|
+
// registers without compiling; the target compiles below.
|
|
72
|
+
for (const file of readdirSync(SCHEMAS_DIR)) {
|
|
73
|
+
if (!file.endsWith('.schema.json')) continue;
|
|
74
|
+
try {
|
|
75
|
+
ajv.addSchema(JSON.parse(readFileSync(join(SCHEMAS_DIR, file), 'utf8')) as Record<string, unknown>);
|
|
76
|
+
} catch {
|
|
77
|
+
/* duplicate/already-registered — the target is compiled below */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
|
|
81
|
+
const validate = (ajv.getSchema(schema['$id'] as string) ?? ajv.compile(schema)) as ValidateFunction;
|
|
82
|
+
|
|
83
|
+
const errorsOn = (m: unknown): ErrorObject[] => {
|
|
84
|
+
expect(validate(m)).toBe(false);
|
|
85
|
+
return validate.errors ?? [];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
it('positive: a manifest declaring valid primitives validates cleanly', () => {
|
|
89
|
+
const ok = validate(manifest(['net.dns', 'net.outbound']));
|
|
90
|
+
expect(
|
|
91
|
+
ok,
|
|
92
|
+
`node-packs.md §"Runtime platform requirements": a well-formed runtime.requires MUST validate. Errors: ${JSON.stringify(validate.errors)}`,
|
|
93
|
+
).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('positive: runtime.requires is OPTIONAL — a manifest omitting it validates (additive)', () => {
|
|
97
|
+
expect(
|
|
98
|
+
validate(manifest(undefined)),
|
|
99
|
+
'node-pack-manifest.schema.json: runtime.requires is additive/OPTIONAL — packs predating RFC 0076 validate unchanged',
|
|
100
|
+
).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('positive: an empty requires[] validates (equivalent to omission per §A)', () => {
|
|
104
|
+
expect(
|
|
105
|
+
validate(manifest([])),
|
|
106
|
+
'node-packs.md §"Runtime platform requirements": runtime.requires:[] is valid and equivalent to omission',
|
|
107
|
+
).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('positive: every vocabulary token individually validates', () => {
|
|
111
|
+
for (const token of VOCABULARY) {
|
|
112
|
+
expect(
|
|
113
|
+
validate(manifest([token])),
|
|
114
|
+
`node-pack-manifest.schema.json: "${token}" is in the RFC 0076 §A vocabulary. Errors: ${JSON.stringify(validate.errors)}`,
|
|
115
|
+
).toBe(true);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('negative: a raw builtin name (node:dns/promises) is rejected (→ invalid_manifest)', () => {
|
|
120
|
+
const errs = errorsOn(manifest(['node:dns/promises']));
|
|
121
|
+
expect(
|
|
122
|
+
errs.some((e) => e.instancePath.includes('/runtime/requires')),
|
|
123
|
+
'node-packs.md §"Runtime platform requirements": raw language builtin names are NOT in the closed vocabulary — the abstract net.dns is the portable equivalent; the registry/host surfaces this as invalid_manifest',
|
|
124
|
+
).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('negative: a duplicate token is rejected (uniqueItems)', () => {
|
|
128
|
+
const errs = errorsOn(manifest(['net.dns', 'net.dns']));
|
|
129
|
+
expect(
|
|
130
|
+
errs.some((e) => e.keyword === 'uniqueItems'),
|
|
131
|
+
'node-pack-manifest.schema.json: runtime.requires has uniqueItems:true',
|
|
132
|
+
).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-provided safe-fetch behavior — `host-capabilities.md` §host.http
|
|
3
|
+
* (`ctx.http.safeFetch`) + RFC 0076 §B.
|
|
4
|
+
*
|
|
5
|
+
* Seam-gated behavioral scenarios for the pack-facing `ctx.http.safeFetch`. When
|
|
6
|
+
* a host advertises `capabilities.httpClient.safeFetch.supported`, the
|
|
7
|
+
* host-mediated fetch MUST apply the §host.http SSRF guard (resolve→pin→connect)
|
|
8
|
+
* so a pack can do outbound HTTP without reaching for `node:dns` / raw sockets:
|
|
9
|
+
*
|
|
10
|
+
* 1. SSRF block — a loopback / RFC 1918 / cloud-metadata target ⇒
|
|
11
|
+
* `{ outcome: "blocked", blocked: "ssrf" }`; the host MUST NOT connect.
|
|
12
|
+
* 2. DNS-rebinding — a public name re-resolving to a blocked address
|
|
13
|
+
* (`simulateRebindTo`) ⇒ also blocked (the resolved IP is pinned).
|
|
14
|
+
* 3. Connection-upgrade refusal — `Connection: upgrade` ⇒
|
|
15
|
+
* `{ outcome: "blocked", blocked: "upgrade" }` (no 101 socket-hijack escape).
|
|
16
|
+
* 4. Audit-when-both — when `toolHooks.prePostEvents` is also advertised, a
|
|
17
|
+
* fetched call emits the `agent.toolCalled` / `agent.toolReturned` pair
|
|
18
|
+
* (`transport: "http"`).
|
|
19
|
+
*
|
|
20
|
+
* All drive `POST /v1/host/sample/http/safe-fetch` and soft-skip when the host
|
|
21
|
+
* doesn't advertise `safeFetch` or doesn't wire the seam (404). Behavior grade
|
|
22
|
+
* is `host-pending` until a `safeFetch` host lights it up. The SSRF *guarantee*
|
|
23
|
+
* reuses the `http-client-ssrf-guard` SECURITY invariant — no new invariant.
|
|
24
|
+
*
|
|
25
|
+
* @see spec/v1/host-capabilities.md §host.http
|
|
26
|
+
* @see spec/v1/host-sample-test-seams.md §"Open seams"
|
|
27
|
+
* @see RFCS/0076-pack-runtime-requirements-and-host-safe-fetch.md §B
|
|
28
|
+
* @see SECURITY/invariants.yaml id: http-client-ssrf-guard
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { driver } from '../lib/driver.js';
|
|
33
|
+
import { isSafeFetchSupported, isToolHookAuditOn, safeFetch } from '../lib/safeFetch.js';
|
|
34
|
+
|
|
35
|
+
describe('safefetch-behavior (RFC 0076 §B / §host.http)', () => {
|
|
36
|
+
it('blocks a metadata-endpoint target (SSRF guard)', async () => {
|
|
37
|
+
if (!(await isSafeFetchSupported())) return; // capability absent — soft-skip
|
|
38
|
+
const res = await safeFetch({ url: 'http://169.254.169.254/latest/meta-data/' });
|
|
39
|
+
if (res === null) return; // seam absent — soft-skip
|
|
40
|
+
expect(
|
|
41
|
+
res.outcome,
|
|
42
|
+
driver.describe('host-capabilities.md §host.http', 'safeFetch MUST NOT connect to a cloud-metadata address'),
|
|
43
|
+
).toBe('blocked');
|
|
44
|
+
expect(
|
|
45
|
+
res.blocked,
|
|
46
|
+
driver.describe('host-capabilities.md §host.http', 'a blocked SSRF target reports blocked:"ssrf" (http-client-ssrf-guard invariant)'),
|
|
47
|
+
).toBe('ssrf');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('blocks a loopback target (SSRF guard)', async () => {
|
|
51
|
+
if (!(await isSafeFetchSupported())) return;
|
|
52
|
+
const res = await safeFetch({ url: 'http://127.0.0.1:6379/' });
|
|
53
|
+
if (res === null) return;
|
|
54
|
+
expect(
|
|
55
|
+
res.outcome,
|
|
56
|
+
driver.describe('host-capabilities.md §host.http', 'safeFetch MUST NOT connect to loopback'),
|
|
57
|
+
).toBe('blocked');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('blocks DNS-rebinding (resolved IP is pinned for the connection)', async () => {
|
|
61
|
+
if (!(await isSafeFetchSupported())) return;
|
|
62
|
+
const res = await safeFetch({ url: 'http://example.com/', simulateRebindTo: '169.254.169.254' });
|
|
63
|
+
if (res === null) return;
|
|
64
|
+
expect(
|
|
65
|
+
res.outcome,
|
|
66
|
+
driver.describe('host-capabilities.md §host.http', 'a public name that re-resolves to a blocked address MUST be blocked (rebinding defeat)'),
|
|
67
|
+
).toBe('blocked');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('refuses a Connection: upgrade request (no 101 socket-hijack escape)', async () => {
|
|
71
|
+
if (!(await isSafeFetchSupported())) return;
|
|
72
|
+
const res = await safeFetch({ url: 'https://example.com/', init: { headers: { Connection: 'upgrade' } } });
|
|
73
|
+
if (res === null) return;
|
|
74
|
+
expect(
|
|
75
|
+
res.outcome,
|
|
76
|
+
driver.describe('host-capabilities.md §host.http', 'safeFetch MUST refuse a connection-upgrade attempt'),
|
|
77
|
+
).toBe('blocked');
|
|
78
|
+
expect(
|
|
79
|
+
res.blocked,
|
|
80
|
+
driver.describe('host-capabilities.md §host.http', 'a refused upgrade reports blocked:"upgrade"'),
|
|
81
|
+
).toBe('upgrade');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('emits the tool-hooks audit pair when prePostEvents is also advertised', async () => {
|
|
85
|
+
if (!(await isSafeFetchSupported())) return;
|
|
86
|
+
if (!(await isToolHookAuditOn())) return; // audit MUST applies only when both advertised
|
|
87
|
+
const res = await safeFetch({ url: 'https://example.com/' });
|
|
88
|
+
if (res === null) return;
|
|
89
|
+
if (res.outcome !== 'fetched') return; // only a completed call carries the pair
|
|
90
|
+
expect(
|
|
91
|
+
res.toolCalled !== undefined && res.toolReturned !== undefined,
|
|
92
|
+
driver.describe('host-capabilities.md §host.http', 'when toolHooks.prePostEvents + safeFetch are both advertised, a safeFetch call MUST emit the agent.toolCalled/agent.toolReturned pair'),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
expect(
|
|
95
|
+
(res.toolCalled as { transport?: string } | undefined)?.transport,
|
|
96
|
+
driver.describe('host-capabilities.md §host.http', 'the audit pair carries transport:"http"'),
|
|
97
|
+
).toBe('http');
|
|
98
|
+
});
|
|
99
|
+
});
|