@openwop/openwop-conformance 1.6.1 → 1.11.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 +44 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +127 -0
- package/api/openapi.yaml +518 -1
- package/coverage.md +44 -2
- package/fixtures/conformance-run-duration-breach.json +33 -0
- package/fixtures/oauth-providers/synthetic.json +38 -0
- package/fixtures.md +29 -0
- package/package.json +1 -1
- package/schemas/README.md +22 -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 +115 -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/ai-envelope.schema.json +28 -0
- package/schemas/artifact-type-pack-manifest.schema.json +160 -0
- package/schemas/budget-policy.schema.json +18 -0
- package/schemas/capabilities.schema.json +448 -4
- package/schemas/chat-card-pack-manifest.schema.json +158 -0
- package/schemas/credential-provenance.schema.json +18 -0
- package/schemas/envelopes/media.audio.schema.json +38 -0
- package/schemas/envelopes/media.file.schema.json +37 -0
- package/schemas/envelopes/media.image.schema.json +33 -0
- package/schemas/eval-summary.schema.json +92 -0
- package/schemas/heartbeat-evaluated.schema.json +14 -0
- package/schemas/heartbeat-state-changed.schema.json +14 -0
- package/schemas/node-pack-manifest.schema.json +33 -1
- package/schemas/org-chart-responsibility-view.schema.json +26 -0
- package/schemas/run-event-payloads.schema.json +380 -6
- package/schemas/run-event.schema.json +23 -0
- package/schemas/tool-descriptor.schema.json +63 -0
- package/schemas/trigger-subscription.schema.json +26 -0
- package/schemas/workflow-definition.schema.json +5 -0
- package/schemas/workspace-file-create.schema.json +20 -0
- package/schemas/workspace-file.schema.json +39 -0
- package/src/lib/agentLoop.ts +44 -0
- package/src/lib/agentRoster.ts +76 -0
- package/src/lib/agentRuntime.ts +45 -0
- package/src/lib/artifactTypes.ts +96 -0
- package/src/lib/cardPacks.ts +52 -0
- package/src/lib/discovery-capabilities.ts +50 -0
- package/src/lib/distillation.ts +38 -0
- package/src/lib/feedback.ts +3 -3
- package/src/lib/heartbeat.ts +31 -0
- package/src/lib/liveRuntime.ts +59 -0
- package/src/lib/memoryAttribution.ts +48 -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/subRunAttestation.ts +35 -0
- package/src/lib/toolHooks.ts +33 -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-loop-iteration-monotonic.test.ts +33 -0
- package/src/scenarios/agent-loop-stateful-resume.test.ts +28 -0
- package/src/scenarios/agent-loop-version5-shape.test.ts +41 -0
- package/src/scenarios/agent-loop-workspace-snapshot.test.ts +33 -0
- package/src/scenarios/agent-manifest-runtime.test.ts +85 -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/ai-envelope-shape.test.ts +14 -18
- package/src/scenarios/aiEnvelope.capBreached.test.ts +2 -1
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +2 -1
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +2 -1
- package/src/scenarios/approval-gate-flow.test.ts +4 -6
- package/src/scenarios/artifact-schema-compile-bounded.test.ts +126 -0
- package/src/scenarios/artifact-type-pack-install.test.ts +78 -0
- package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +140 -0
- package/src/scenarios/artifact-type-store-without-render.test.ts +54 -0
- package/src/scenarios/audit-log-integrity.test.ts +3 -2
- package/src/scenarios/auth-api-key-rotation.test.ts +2 -1
- package/src/scenarios/auth-mtls.test.ts +2 -1
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +2 -1
- package/src/scenarios/auth-oidc-user-bearer.test.ts +2 -1
- package/src/scenarios/auth-saml-profile.test.ts +2 -1
- package/src/scenarios/auth-scim-profile.test.ts +2 -1
- package/src/scenarios/authorization-fail-closed.test.ts +2 -1
- package/src/scenarios/authorization-roles-shape.test.ts +2 -1
- package/src/scenarios/budget-policy-shape.test.ts +136 -0
- package/src/scenarios/byok-auth-modes.test.ts +141 -0
- package/src/scenarios/chat-card-pack-execution.test.ts +56 -0
- package/src/scenarios/chat-card-pack-manifest-validation.test.ts +128 -0
- package/src/scenarios/commitment-fired.test.ts +83 -0
- package/src/scenarios/credential-payload-redaction.test.ts +2 -1
- package/src/scenarios/credentials-capability-shape.test.ts +2 -1
- package/src/scenarios/cross-engine-append-ordering.test.ts +2 -1
- package/src/scenarios/cross-host-ancestry-endpoint.test.ts +3 -2
- package/src/scenarios/cross-host-causation-shape.test.ts +3 -2
- package/src/scenarios/deadletter-capability-shape.test.ts +2 -1
- package/src/scenarios/deadletter-retry-exhaustion.test.ts +2 -1
- package/src/scenarios/distillation-index-roundtrip.test.ts +35 -0
- package/src/scenarios/distillation-secret-carryforward.test.ts +35 -0
- package/src/scenarios/distillation-shape.test.ts +41 -0
- package/src/scenarios/distillation-stable-archive.test.ts +37 -0
- package/src/scenarios/distillation-token-budget.test.ts +45 -0
- package/src/scenarios/egress-provenance-shape.test.ts +137 -0
- package/src/scenarios/envelope-completion-distinguishes-truncation.test.ts +4 -3
- package/src/scenarios/envelope-reasoning-secret-redaction.test.ts +5 -4
- package/src/scenarios/envelope-reasoning-shape.test.ts +3 -2
- package/src/scenarios/envelope-refusal-shape.test.ts +3 -2
- package/src/scenarios/envelope-rendering-hint.test.ts +95 -0
- package/src/scenarios/envelope-retry-attempted.test.ts +2 -1
- package/src/scenarios/envelope-tier-one-subset-static.test.ts +3 -2
- package/src/scenarios/exec-not-protocol-tier.test.ts +137 -0
- package/src/scenarios/experimental-tier-shape.test.ts +5 -4
- package/src/scenarios/fs-path-traversal.test.ts +2 -1
- package/src/scenarios/heartbeat-capability-shape.test.ts +35 -0
- package/src/scenarios/heartbeat-fires-once-per-tick.test.ts +28 -0
- package/src/scenarios/heartbeat-idempotent-no-spam.test.ts +43 -0
- package/src/scenarios/heartbeat-runtime-bound.test.ts +30 -0
- package/src/scenarios/http-client-ssrf.test.ts +10 -13
- package/src/scenarios/mcp-toolcall-redaction.test.ts +3 -2
- package/src/scenarios/media-url-inline-cap.test.ts +167 -0
- package/src/scenarios/memory-attribution-emits-on-write.test.ts +54 -0
- package/src/scenarios/memory-attribution-no-content.test.ts +45 -0
- package/src/scenarios/memory-attribution-replay-stable.test.ts +60 -0
- package/src/scenarios/memory-attribution-shape.test.ts +28 -0
- package/src/scenarios/memory-attribution-tenant-scoped.test.ts +44 -0
- package/src/scenarios/memory-capability-model-shape.test.ts +186 -0
- package/src/scenarios/memory-compaction-event-emitted.test.ts +2 -1
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +2 -1
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +2 -1
- package/src/scenarios/memory-consolidation-idempotent.test.ts +77 -0
- package/src/scenarios/memory-consolidation-shape.test.ts +90 -0
- package/src/scenarios/model-capability-substituted.test.ts +2 -1
- package/src/scenarios/multi-agent-confidence-escalation.test.ts +5 -4
- package/src/scenarios/multi-agent-handoff-state-machine.test.ts +6 -5
- package/src/scenarios/multi-agent-memory-lifecycle.test.ts +4 -3
- package/src/scenarios/multi-region-idempotency.test.ts +10 -10
- package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +145 -0
- package/src/scenarios/oauth-capability-shape.test.ts +2 -1
- package/src/scenarios/oauth-connector-redaction.test.ts +2 -1
- package/src/scenarios/pause-resume.test.ts +3 -3
- package/src/scenarios/production-backpressure.test.ts +2 -2
- package/src/scenarios/production-retention-expiry.test.ts +2 -2
- package/src/scenarios/prompt-all-four-kinds-events.test.ts +2 -1
- package/src/scenarios/prompt-composed-secret-redaction.test.ts +2 -1
- package/src/scenarios/prompt-composed-trust-marker.test.ts +2 -1
- package/src/scenarios/prompt-end-to-end-events.test.ts +2 -1
- package/src/scenarios/prompt-list-and-fetch.test.ts +2 -1
- package/src/scenarios/prompt-mutable-lifecycle.test.ts +2 -1
- package/src/scenarios/prompt-mutation-workspace-membership-enforced.test.ts +2 -1
- package/src/scenarios/prompt-pack-install.test.ts +2 -1
- package/src/scenarios/prompt-read-workspace-membership-enforced.test.ts +2 -1
- package/src/scenarios/prompt-render-deterministic.test.ts +2 -1
- package/src/scenarios/prompt-resolution-chain-agent-intrinsic.test.ts +2 -1
- package/src/scenarios/prompt-resolution-chain-fallback-cascade.test.ts +2 -1
- package/src/scenarios/prompt-resolution-chain-node-wins.test.ts +2 -1
- package/src/scenarios/prompt-template-shape.test.ts +2 -1
- package/src/scenarios/provider-usage.test.ts +2 -1
- package/src/scenarios/replay-divergence-at-refusal.test.ts +4 -3
- package/src/scenarios/replay-fork-arbitrary.test.ts +3 -1
- package/src/scenarios/replay-llm-cache-key-portable.test.ts +2 -1
- package/src/scenarios/replayDeterminism.test.ts +3 -1
- package/src/scenarios/run-execution-bounds-shape.test.ts +133 -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/sandbox-memory-cap.test.ts +2 -1
- package/src/scenarios/sandbox-mvp-behavior.test.ts +2 -1
- package/src/scenarios/sandbox-no-host-fs-escape.test.ts +2 -1
- package/src/scenarios/sandbox-timeout-cap.test.ts +2 -1
- package/src/scenarios/scheduling-capability-shape.test.ts +2 -1
- package/src/scenarios/scheduling-cron-fires-once.test.ts +2 -1
- package/src/scenarios/secret-leakage-otel-attribute.test.ts +7 -6
- package/src/scenarios/spec-corpus-validity.test.ts +20 -4
- package/src/scenarios/subrun-approval-fail-closed.test.ts +33 -0
- package/src/scenarios/subrun-approval-gate.test.ts +35 -0
- package/src/scenarios/subrun-attestation-shape.test.ts +30 -0
- package/src/scenarios/subrun-checksum-stable.test.ts +43 -0
- package/src/scenarios/tool-descriptor-shape.test.ts +133 -0
- package/src/scenarios/tool-hooks-authorization-fail-closed.test.ts +39 -0
- package/src/scenarios/tool-hooks-content-free.test.ts +40 -0
- package/src/scenarios/tool-hooks-rate-limit.test.ts +32 -0
- package/src/scenarios/tool-hooks-secret-redaction.test.ts +34 -0
- package/src/scenarios/tool-hooks-shape.test.ts +34 -0
- package/src/scenarios/trigger-bridge-shape.test.ts +135 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +3 -10
- package/src/scenarios/wasm-pack-invoke-completed.test.ts +2 -2
- package/src/scenarios/wasm-pack-invoke-suspended.test.ts +2 -2
- package/src/scenarios/wasm-pack-load.test.ts +2 -2
- package/src/scenarios/wasm-pack-memory-cap.test.ts +3 -6
- package/src/scenarios/wasm-pack-replay-determinism.test.ts +2 -2
- package/src/scenarios/workflow-primary-output-annotation.test.ts +142 -0
- package/src/scenarios/workspace-behavior.test.ts +134 -0
- package/src/scenarios/workspace-capability-shape.test.ts +73 -0
- package/src/scenarios/workspace-cross-tenant-isolation.test.ts +84 -0
- package/src/scenarios/x-openwop-form-pack-manifest.test.ts +155 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run-execution-bounds-shape — RFC 0058 advertisement-shape + breach-contract
|
|
3
|
+
* verification for the two run-scoped execution bounds.
|
|
4
|
+
*
|
|
5
|
+
* Status: ACTIVE. RFC 0058 (run execution bounds) is `Active`. The
|
|
6
|
+
* `capabilities.limits.{maxRunDurationMs,maxLoopIterations}` fields and the
|
|
7
|
+
* `run-duration` / `loop-iterations` kinds on `cap.breached` have landed in
|
|
8
|
+
* `schemas/capabilities.schema.json` + `schemas/run-event-payloads.schema.json`.
|
|
9
|
+
*
|
|
10
|
+
* Always runs (shape-only): when the host advertises either limit, its value
|
|
11
|
+
* MUST be well-formed. Behavior is capability- AND fixture-gated. The
|
|
12
|
+
* `run-duration` (wall-clock timeout) block is now enforced + green against the
|
|
13
|
+
* in-memory reference host. The `loop-iterations` block stays soft-skipped until
|
|
14
|
+
* an execution-loop host advertises `multiAgent.executionModel` (RFC 0061),
|
|
15
|
+
* mirroring the RFC 0052 scheduling pattern.
|
|
16
|
+
*
|
|
17
|
+
* What this scenario asserts:
|
|
18
|
+
* 1. `capabilities.limits.maxRunDurationMs`, when present, is an integer ≥ 1000.
|
|
19
|
+
* 2. `capabilities.limits.maxLoopIterations`, when present, is an integer ≥ 1.
|
|
20
|
+
* 3. (gated) A run with `configurable.runTimeoutMs` below its real duration
|
|
21
|
+
* reaches terminal `failed` with `error.code = "run_timeout"` and emits
|
|
22
|
+
* `cap.breached { kind: "run-duration" }` whose `observed > limit`.
|
|
23
|
+
*
|
|
24
|
+
* @see RFCS/0058-run-execution-bounds.md
|
|
25
|
+
* @see spec/v1/run-options.md §Reserved keys (runTimeoutMs / maxLoopIterations)
|
|
26
|
+
* @see spec/v1/capabilities.md §"Engine-enforced limits and the cap.breached event"
|
|
27
|
+
* @see schemas/run-event-payloads.schema.json §capBreached
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { driver } from '../lib/driver.js';
|
|
32
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
33
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
34
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
35
|
+
|
|
36
|
+
interface DiscoveryLimits {
|
|
37
|
+
maxRunDurationMs?: number;
|
|
38
|
+
maxLoopIterations?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface DiscoveryDoc {
|
|
42
|
+
capabilities?: { limits?: DiscoveryLimits };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface RunEvent {
|
|
46
|
+
readonly type: string;
|
|
47
|
+
readonly sequence: number;
|
|
48
|
+
readonly payload?: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TIMEOUT_FIXTURE = 'conformance-run-duration-breach';
|
|
52
|
+
|
|
53
|
+
async function readLimits(): Promise<DiscoveryLimits | null> {
|
|
54
|
+
const res = await driver.get('/.well-known/openwop');
|
|
55
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
56
|
+
return capabilityFamily(body, 'limits') ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('run-execution-bounds-shape: advertisement shape (RFC 0058)', () => {
|
|
60
|
+
it('maxRunDurationMs is an integer >= 1000 when present', async () => {
|
|
61
|
+
const limits = await readLimits();
|
|
62
|
+
if (limits?.maxRunDurationMs === undefined) return; // not advertised
|
|
63
|
+
expect(
|
|
64
|
+
Number.isInteger(limits.maxRunDurationMs) && limits.maxRunDurationMs >= 1000,
|
|
65
|
+
driver.describe(
|
|
66
|
+
'capabilities.schema.json §limits.maxRunDurationMs',
|
|
67
|
+
`capabilities.limits.maxRunDurationMs MUST be an integer >= 1000, got: ${limits.maxRunDurationMs}`,
|
|
68
|
+
),
|
|
69
|
+
).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('maxLoopIterations is an integer >= 1 when present', async () => {
|
|
73
|
+
const limits = await readLimits();
|
|
74
|
+
if (limits?.maxLoopIterations === undefined) return; // not advertised
|
|
75
|
+
expect(
|
|
76
|
+
Number.isInteger(limits.maxLoopIterations) && limits.maxLoopIterations >= 1,
|
|
77
|
+
driver.describe(
|
|
78
|
+
'capabilities.schema.json §limits.maxLoopIterations',
|
|
79
|
+
`capabilities.limits.maxLoopIterations MUST be an integer >= 1, got: ${limits.maxLoopIterations}`,
|
|
80
|
+
),
|
|
81
|
+
).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Behavior: capability- AND fixture-gated. Skips on hosts that do not enforce
|
|
86
|
+
// run-duration timeouts (incl. the reference hosts) until one wires the seam.
|
|
87
|
+
const SKIP_TIMEOUT = !isFixtureAdvertised(TIMEOUT_FIXTURE);
|
|
88
|
+
|
|
89
|
+
describe.skipIf(SKIP_TIMEOUT)('run-execution-bounds: run-duration breach (RFC 0058)', () => {
|
|
90
|
+
it('a run with runTimeoutMs below its real duration fails with run_timeout + cap.breached{run-duration}', async () => {
|
|
91
|
+
const create = await driver.post('/v1/runs', {
|
|
92
|
+
workflowId: TIMEOUT_FIXTURE,
|
|
93
|
+
configurable: { runTimeoutMs: 1000 },
|
|
94
|
+
});
|
|
95
|
+
expect(create.status, driver.describe(
|
|
96
|
+
'rest-endpoints.md POST /v1/runs',
|
|
97
|
+
'run creation MUST accept a runTimeoutMs override',
|
|
98
|
+
)).toBe(201);
|
|
99
|
+
const runId = (create.json as { runId: string }).runId;
|
|
100
|
+
|
|
101
|
+
const terminal = await pollUntilTerminal(runId);
|
|
102
|
+
expect(terminal.status, driver.describe(
|
|
103
|
+
'run-options.md §runTimeoutMs',
|
|
104
|
+
'a run exceeding its runTimeoutMs MUST reach terminal `failed`',
|
|
105
|
+
)).toBe('failed');
|
|
106
|
+
expect(terminal.error?.code, driver.describe(
|
|
107
|
+
'rest-endpoints.md §run_timeout',
|
|
108
|
+
'RunSnapshot.error.code MUST equal "run_timeout" on wall-clock timeout',
|
|
109
|
+
)).toBe('run_timeout');
|
|
110
|
+
|
|
111
|
+
const eventsRes = await driver.get(
|
|
112
|
+
`/v1/runs/${encodeURIComponent(runId)}/events/poll?lastSequence=0&timeout=1`,
|
|
113
|
+
);
|
|
114
|
+
const events = (eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? [];
|
|
115
|
+
const breach = events.find((e) => e.type === 'cap.breached');
|
|
116
|
+
expect(breach, driver.describe(
|
|
117
|
+
'capabilities.md §Engine-enforced limits',
|
|
118
|
+
'a cap.breached event MUST be emitted on run-duration breach',
|
|
119
|
+
)).toBeDefined();
|
|
120
|
+
const payload = breach!.payload as { kind?: string; limit?: number; observed?: number } | undefined;
|
|
121
|
+
expect(payload?.kind, driver.describe(
|
|
122
|
+
'run-event-payloads.schema.json §capBreached.kind',
|
|
123
|
+
'cap.breached payload MUST carry kind="run-duration"',
|
|
124
|
+
)).toBe('run-duration');
|
|
125
|
+
expect(
|
|
126
|
+
typeof payload?.observed === 'number' && typeof payload?.limit === 'number' && payload!.observed > payload!.limit,
|
|
127
|
+
driver.describe(
|
|
128
|
+
'run-event-payloads.schema.json §capBreached.observed',
|
|
129
|
+
'observed (elapsedMs) MUST be strictly greater than limit (resolved timeout)',
|
|
130
|
+
),
|
|
131
|
+
).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-run safe-fetch audit emission — `host-capabilities.md` §host.http
|
|
3
|
+
* (`ctx.http.safeFetch`) + RFC 0076 §B + RFC 0064 §B.
|
|
4
|
+
*
|
|
5
|
+
* Closes the seam-vs-production gap left by `safefetch-behavior.test.ts`. That
|
|
6
|
+
* scenario drives `POST /v1/host/sample/http/safe-fetch` and reads the audit
|
|
7
|
+
* pair the SEAM returns INLINE — it never proves the *production* per-ctx
|
|
8
|
+
* `ctx.http.safeFetch` (the client injected into a real run) emits anything. A
|
|
9
|
+
* host can co-advertise `toolHooks.prePostEvents` + `httpClient.safeFetch`,
|
|
10
|
+
* pass the seam, and still ship a production `createSafeFetch()` with no audit
|
|
11
|
+
* hooks — the "quiet bypass" §host.http line "centralizing egress in the host
|
|
12
|
+
* must increase auditability, not become a quiet bypass" forbids.
|
|
13
|
+
*
|
|
14
|
+
* The normative MUST (host-capabilities.md §host.http; RFC 0076 §B):
|
|
15
|
+
* When `toolHooks.prePostEvents: true` AND `httpClient.safeFetch.supported:
|
|
16
|
+
* true` are BOTH advertised, the host MUST emit the `agent.toolCalled` /
|
|
17
|
+
* `agent.toolReturned` pair (`transport: "http"`) **for every `safeFetch`
|
|
18
|
+
* invocation** — including a *refused* one (a blocked egress attempt is
|
|
19
|
+
* exactly the security-relevant event the audit log must capture).
|
|
20
|
+
*
|
|
21
|
+
* This scenario verifies that MUST against the DURABLE run event log, not the
|
|
22
|
+
* seam's inline echo, and does so **without depending on outbound egress** so
|
|
23
|
+
* the bar can never pass vacuously:
|
|
24
|
+
* 1. EGRESS-FREE FLOOR (required): drive one `ctx.http.safeFetch` to a
|
|
25
|
+
* guaranteed-blocked link-local / cloud-metadata URL inside a REAL run via
|
|
26
|
+
* `POST /v1/host/sample/http/safe-fetch-run`. A conformant SSRF guard
|
|
27
|
+
* refuses it on every host with zero connectivity, yet the production
|
|
28
|
+
* injection + auditHooks path is still exercised, so the durable pair MUST
|
|
29
|
+
* be present. This removes the "no public egress ⇒ green-but-proves-nothing"
|
|
30
|
+
* hole that a `fetched`-only assertion left.
|
|
31
|
+
* 2. SUCCESS-PATH COVERAGE (best-effort): drive a public URL; when it actually
|
|
32
|
+
* `fetched`, assert the same durable pair (catches a host that audits only
|
|
33
|
+
* the reject path). Skipped — not failed — where the environment has no
|
|
34
|
+
* public egress; the floor already proved emission.
|
|
35
|
+
* 3. Read each run's persisted events via the test event-log seam
|
|
36
|
+
* (`GET /v1/host/sample/test/runs/:runId/events`) and assert a `callId`-
|
|
37
|
+
* paired `agent.toolCalled` (`transport:"http"`) / `agent.toolReturned`.
|
|
38
|
+
*
|
|
39
|
+
* Gating: `behaviorGate('openwop-safefetch-live-audit', <both flags>)` — NOT an
|
|
40
|
+
* inline soft-skip. So it skips-with-reason in default mode but FAILS under
|
|
41
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true` when a host advertises both flags yet does not
|
|
42
|
+
* emit. This is the RFC 0076 §B → Accepted bar a non-steward host validates
|
|
43
|
+
* against. The run seam itself (`safe-fetch-run`) is host-pending: a 404 from a
|
|
44
|
+
* not-yet-wired seam soft-skips even in strict mode (the seam is test-only
|
|
45
|
+
* infrastructure, distinct from the advertised production capability).
|
|
46
|
+
* The SSRF guarantee reuses the existing `http-client-ssrf-guard` invariant —
|
|
47
|
+
* no new SECURITY invariant; the audit MUST is RFC 0064's existing posture.
|
|
48
|
+
*
|
|
49
|
+
* @see spec/v1/host-capabilities.md §host.http
|
|
50
|
+
* @see spec/v1/host-sample-test-seams.md §"Open seams" (safe-fetch-run)
|
|
51
|
+
* @see RFCS/0076-pack-runtime-requirements-and-host-safe-fetch.md §B
|
|
52
|
+
* @see RFCS/0064-tool-invocation-hooks-and-authorization.md §B
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { describe, it, expect } from 'vitest';
|
|
56
|
+
import { driver } from '../lib/driver.js';
|
|
57
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
58
|
+
import { isSafeFetchLiveAuditAdvertised, safeFetchViaRun } from '../lib/safeFetch.js';
|
|
59
|
+
import { queryTestEvents } from '../lib/event-log-query.js';
|
|
60
|
+
|
|
61
|
+
const PROFILE = 'openwop-safefetch-live-audit';
|
|
62
|
+
const CITE = 'host-capabilities.md §host.http';
|
|
63
|
+
|
|
64
|
+
// A link-local / cloud-metadata URL the SSRF guard MUST refuse — reachable on
|
|
65
|
+
// EVERY host regardless of outbound egress, so the durable-pair assertion never
|
|
66
|
+
// passes vacuously. Per §host.http the audit MUST is per-invocation: a *blocked*
|
|
67
|
+
// safeFetch still emits the agent.toolCalled/agent.toolReturned pair (the
|
|
68
|
+
// toolReturned carries the forbidden status). cf. `http-client-ssrf-guard`.
|
|
69
|
+
const BLOCKED_URL = 'http://169.254.169.254/latest/meta-data/';
|
|
70
|
+
// A public URL the guard SHOULD allow — best-effort coverage of the *success*
|
|
71
|
+
// path; skipped (not failed) where the environment has no public egress.
|
|
72
|
+
const FETCH_URL = 'https://example.com/';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the durable run event log for `runId` and assert a `callId`-paired
|
|
76
|
+
* `agent.toolCalled` (`transport:"http"`) / `agent.toolReturned` exists, with
|
|
77
|
+
* the RFC 0002 §B causation chain tolerated when the host surfaces it. Returns
|
|
78
|
+
* `false` (caller treats as host-pending soft-skip) only when the event-log
|
|
79
|
+
* query seam is unavailable; otherwise asserts and returns `true`.
|
|
80
|
+
*/
|
|
81
|
+
async function assertDurableHttpPair(runId: string, label: string): Promise<boolean> {
|
|
82
|
+
const calledQ = await queryTestEvents(runId, { type: 'agent.toolCalled' });
|
|
83
|
+
const returnedQ = await queryTestEvents(runId, { type: 'agent.toolReturned' });
|
|
84
|
+
if (!calledQ.ok || !returnedQ.ok) {
|
|
85
|
+
// eslint-disable-next-line no-console
|
|
86
|
+
console.warn(`[${PROFILE}] event-log query seam unavailable; host-pending — skipping`);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The HTTP-transport tool call: a durable agent.toolCalled with transport:"http".
|
|
91
|
+
const httpCall = calledQ.events.find((e) => (e.payload as { transport?: string }).transport === 'http');
|
|
92
|
+
expect(
|
|
93
|
+
httpCall !== undefined,
|
|
94
|
+
driver.describe(
|
|
95
|
+
CITE,
|
|
96
|
+
`(${label}) when toolHooks.prePostEvents + safeFetch are both advertised, a production ctx.http.safeFetch call MUST persist an agent.toolCalled with transport:"http" to the durable run event log (not just the seam echo), for EVERY invocation incl. blocked ones`,
|
|
97
|
+
),
|
|
98
|
+
).toBe(true);
|
|
99
|
+
if (!httpCall) return true;
|
|
100
|
+
|
|
101
|
+
const callId = (httpCall.payload as { callId?: string }).callId;
|
|
102
|
+
expect(
|
|
103
|
+
typeof callId === 'string' && callId.length > 0,
|
|
104
|
+
driver.describe(CITE, `(${label}) the persisted agent.toolCalled MUST carry the required callId (run-event-payloads.schema.json §agentToolCalled)`),
|
|
105
|
+
).toBe(true);
|
|
106
|
+
|
|
107
|
+
// The paired agent.toolReturned — matched by the required callId (RFC 0002 §B pairing).
|
|
108
|
+
const paired = returnedQ.events.find((e) => (e.payload as { callId?: string }).callId === callId);
|
|
109
|
+
expect(
|
|
110
|
+
paired !== undefined,
|
|
111
|
+
driver.describe(CITE, `(${label}) the agent.toolCalled MUST be followed by a callId-paired agent.toolReturned in the durable log (no quiet bypass)`),
|
|
112
|
+
).toBe(true);
|
|
113
|
+
|
|
114
|
+
// Stricter, when the host surfaces causation: RFC 0002 §B says
|
|
115
|
+
// toolReturned.causationId === the paired toolCalled.eventId. Tolerate
|
|
116
|
+
// hosts that omit causationId (callId pairing already proven above).
|
|
117
|
+
if (paired && typeof paired.causationId === 'string') {
|
|
118
|
+
expect(
|
|
119
|
+
paired.causationId,
|
|
120
|
+
driver.describe('RFC 0002 §B', 'agent.toolReturned.causationId MUST equal the paired agent.toolCalled.eventId when surfaced'),
|
|
121
|
+
).toBe(httpCall.eventId);
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe('safefetch-live-audit (RFC 0076 §B / RFC 0064 §B — production path, durable log)', () => {
|
|
127
|
+
it('a BLOCKED real-run safeFetch emits the durable agent.toolCalled/agent.toolReturned pair (transport:"http") — egress-free floor', async () => {
|
|
128
|
+
const advertised = await isSafeFetchLiveAuditAdvertised();
|
|
129
|
+
if (!behaviorGate(PROFILE, advertised)) return; // default-skip; strict-fail when both flags advertised
|
|
130
|
+
|
|
131
|
+
// Run seam is host-pending infrastructure — soft-skip (even in strict mode)
|
|
132
|
+
// until a safeFetch host wires it. behaviorGate above already enforced the
|
|
133
|
+
// capability co-advertisement; this only gates on the test vehicle.
|
|
134
|
+
const run = await safeFetchViaRun({ url: BLOCKED_URL });
|
|
135
|
+
if (run === null) {
|
|
136
|
+
// eslint-disable-next-line no-console
|
|
137
|
+
console.warn(`[${PROFILE}] safe-fetch-run seam unwired (404); host-pending — skipping`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The metadata IP MUST be refused by a conformant SSRF guard
|
|
142
|
+
// (http-client-ssrf.test.ts owns that contract). Regardless of the exact
|
|
143
|
+
// outcome, the production injection path ran, so the durable audit pair MUST
|
|
144
|
+
// exist — this is the egress-independent floor that makes the bar non-vacuous.
|
|
145
|
+
expect(
|
|
146
|
+
typeof run.runId === 'string' && (run.runId as string).length > 0,
|
|
147
|
+
driver.describe(CITE, 'the safe-fetch-run seam MUST return the runId of the real run it executed the safeFetch in'),
|
|
148
|
+
).toBe(true);
|
|
149
|
+
await assertDurableHttpPair(run.runId as string, 'blocked');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('a FETCHED real-run safeFetch also emits the durable pair (success-path coverage — skipped without public egress)', async () => {
|
|
153
|
+
const advertised = await isSafeFetchLiveAuditAdvertised();
|
|
154
|
+
if (!behaviorGate(PROFILE, advertised)) return;
|
|
155
|
+
|
|
156
|
+
const run = await safeFetchViaRun({ url: FETCH_URL });
|
|
157
|
+
if (run === null) return; // seam unwired — already warned by the floor test
|
|
158
|
+
|
|
159
|
+
if (run.outcome !== 'fetched') {
|
|
160
|
+
// No public egress in this environment — the blocked-path floor already
|
|
161
|
+
// proved the production audit path emits. Skip success-path coverage
|
|
162
|
+
// rather than fail; this is coverage, not the floor.
|
|
163
|
+
// eslint-disable-next-line no-console
|
|
164
|
+
console.warn(
|
|
165
|
+
`[${PROFILE}] ${FETCH_URL} did not fetch (outcome=${run.outcome ?? 'n/a'}); no public egress — success-path coverage skipped (the blocked floor covers emission)`,
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
expect(
|
|
170
|
+
typeof run.runId === 'string' && (run.runId as string).length > 0,
|
|
171
|
+
driver.describe(CITE, 'the safe-fetch-run seam MUST return the runId of the real run it executed the fetch in'),
|
|
172
|
+
).toBe(true);
|
|
173
|
+
await assertDurableHttpPair(run.runId as string, 'fetched');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { describe, it, expect } from 'vitest';
|
|
17
17
|
import { driver } from '../lib/driver.js';
|
|
18
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
18
19
|
|
|
19
20
|
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
20
21
|
|
|
@@ -26,7 +27,7 @@ async function readSandbox(): Promise<{ supported: boolean; memoryLimitBytes?: n
|
|
|
26
27
|
try {
|
|
27
28
|
const r = await driver.get('/.well-known/openwop');
|
|
28
29
|
if (r.status !== 200) return null;
|
|
29
|
-
const sb = (r.json as D)
|
|
30
|
+
const sb = capabilityFamily((r.json as D), 'sandbox');
|
|
30
31
|
if (!sb || sb.supported !== true) return null;
|
|
31
32
|
return {
|
|
32
33
|
supported: true,
|