@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +2 -2
  3. package/api/asyncapi.yaml +70 -0
  4. package/api/openapi.yaml +268 -1
  5. package/coverage.md +33 -2
  6. package/fixtures/oauth-providers/synthetic.json +38 -0
  7. package/fixtures.md +10 -0
  8. package/package.json +1 -1
  9. package/schemas/README.md +12 -0
  10. package/schemas/agent-deployment-transition.schema.json +49 -0
  11. package/schemas/agent-deployment.schema.json +54 -0
  12. package/schemas/agent-eval-suite.schema.json +140 -0
  13. package/schemas/agent-inventory-response.schema.json +25 -0
  14. package/schemas/agent-manifest.schema.json +5 -0
  15. package/schemas/agent-org-chart.schema.json +82 -0
  16. package/schemas/agent-ref.schema.json +12 -2
  17. package/schemas/agent-roster-entry.schema.json +81 -0
  18. package/schemas/agent-roster-response.schema.json +21 -0
  19. package/schemas/budget-policy.schema.json +18 -0
  20. package/schemas/capabilities.schema.json +277 -0
  21. package/schemas/credential-provenance.schema.json +18 -0
  22. package/schemas/eval-summary.schema.json +92 -0
  23. package/schemas/node-pack-manifest.schema.json +17 -0
  24. package/schemas/org-chart-responsibility-view.schema.json +26 -0
  25. package/schemas/run-event-payloads.schema.json +286 -3
  26. package/schemas/run-event.schema.json +19 -0
  27. package/schemas/tool-descriptor.schema.json +63 -0
  28. package/schemas/trigger-subscription.schema.json +26 -0
  29. package/src/lib/agentOrgChart.ts +82 -0
  30. package/src/lib/agentRoster.ts +76 -0
  31. package/src/lib/liveRuntime.ts +59 -0
  32. package/src/lib/profiles.ts +157 -0
  33. package/src/lib/runtimeRequires.ts +38 -0
  34. package/src/lib/safeFetch.ts +87 -0
  35. package/src/lib/triggerBridge.ts +74 -0
  36. package/src/scenarios/agent-deployment-shape.test.ts +139 -0
  37. package/src/scenarios/agent-eval-suite-shape.test.ts +167 -0
  38. package/src/scenarios/agent-live-allowlist-enforced.test.ts +53 -0
  39. package/src/scenarios/agent-live-invocation-bracket.test.ts +98 -0
  40. package/src/scenarios/agent-live-runtime-shape.test.ts +98 -0
  41. package/src/scenarios/agent-live-structured-output.test.ts +58 -0
  42. package/src/scenarios/agent-org-chart-scoping.test.ts +137 -0
  43. package/src/scenarios/agent-org-chart-shape.test.ts +127 -0
  44. package/src/scenarios/agent-platform-profile.test.ts +158 -0
  45. package/src/scenarios/agent-roster-attribution.test.ts +179 -0
  46. package/src/scenarios/agent-roster-shape.test.ts +146 -0
  47. package/src/scenarios/budget-policy-shape.test.ts +136 -0
  48. package/src/scenarios/egress-provenance-shape.test.ts +137 -0
  49. package/src/scenarios/memory-capability-model-shape.test.ts +186 -0
  50. package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +145 -0
  51. package/src/scenarios/org-position-no-authority-escalation.test.ts +78 -0
  52. package/src/scenarios/runtime-requires-install-gate.test.ts +92 -0
  53. package/src/scenarios/runtime-requires-shape.test.ts +134 -0
  54. package/src/scenarios/safefetch-behavior.test.ts +99 -0
  55. package/src/scenarios/safefetch-live-audit.test.ts +175 -0
  56. package/src/scenarios/spec-corpus-validity.test.ts +19 -3
  57. package/src/scenarios/tool-descriptor-shape.test.ts +133 -0
  58. package/src/scenarios/trigger-bridge-delivery.test.ts +126 -0
  59. package/src/scenarios/trigger-bridge-shape.test.ts +135 -0
  60. 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
+ });