@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,146 @@
1
+ /**
2
+ * Standing agent roster — entry + capability + attribution-event shapes (RFC 0086).
3
+ *
4
+ * Always-on, server-free schema-shape probe. Verifies that:
5
+ * - `capabilities.agents.roster` is declared with its `supported` /
6
+ * `installScope` / `portfolioTriggerSources` sub-flags.
7
+ * - `agent-roster-entry.schema.json` compiles and round-trips a conforming
8
+ * entry, and rejects malformed ones (a non-`host:` rosterId; an `agentRef`
9
+ * carrying BOTH `version` and `channel` — the RFC 0082 §A XOR rule).
10
+ * - the `roster.run.initiated` payload $def validates a conforming
11
+ * content-free attribution record and requires its ids + persona.
12
+ * - `roster.run.initiated` is CONTENT-FREE: a payload carrying a work-item
13
+ * `body` or a `prompt` is rejected (`additionalProperties:false`). This is
14
+ * the public test for the protocol-tier SECURITY invariant
15
+ * `roster-attribution-no-content`.
16
+ * - the `AgentInventoryEntry` carries the additive optional `roster`
17
+ * portfolio projection (RFC 0086 §B).
18
+ * - `roster.run.initiated` appears in the RunEventType enum.
19
+ *
20
+ * Behavioral assertions (a scheduled portfolio fire emitting roster.run.initiated
21
+ * before agent.invocation.started; the work-item causation chain; the replay
22
+ * re-read; cross-tenant 404) are gated on `capabilities.agents.roster.supported`
23
+ * and land at Active → Accepted (reference-host roster store deferred per RFC 0086
24
+ * §Conformance). This scenario asserts the wire contract, not host behavior.
25
+ *
26
+ * Spec references:
27
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/agent-roster.md
28
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0086-standing-agent-roster-and-workflow-portfolio.md
29
+ * - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (roster-attribution-no-content)
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest';
33
+ import { readFileSync } from 'node:fs';
34
+ import { join } from 'node:path';
35
+ import Ajv2020 from 'ajv/dist/2020.js';
36
+ import addFormats from 'ajv-formats';
37
+ import { SCHEMAS_DIR } from '../lib/paths.js';
38
+
39
+ /** Server-free assertion-message helper. */
40
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
41
+
42
+ function loadSchema(name: string): Record<string, unknown> {
43
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
44
+ }
45
+
46
+ describe('agent-roster-shape: capability advertisement (RFC 0086, server-free)', () => {
47
+ it('the capabilities schema declares agents.roster with its sub-flags', () => {
48
+ const caps = loadSchema('capabilities.schema.json');
49
+ const agents = (caps.properties as Record<string, { properties?: Record<string, { properties?: Record<string, unknown> }> }>).agents;
50
+ const roster = agents?.properties?.roster;
51
+ expect(roster, why('capabilities.md §agents', 'agents.roster MUST be declared')).toBeDefined();
52
+ for (const flag of ['supported', 'installScope', 'portfolioTriggerSources']) {
53
+ expect(roster?.properties?.[flag], why('agent-roster.md §F', `agents.roster.${flag} MUST be declared`)).toBeDefined();
54
+ }
55
+ });
56
+ });
57
+
58
+ describe('agent-roster-shape: roster entry (RFC 0086 §A, server-free)', () => {
59
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
60
+ addFormats(ajv);
61
+ const entry = ajv.compile(loadSchema('agent-roster-entry.schema.json'));
62
+
63
+ it('AgentRosterEntry validates a conforming entry', () => {
64
+ const good = {
65
+ rosterId: 'host:sally-marketing',
66
+ persona: 'Sally',
67
+ agentRef: { agentId: 'core.openwop.agents.brief-writer', channel: 'stable' },
68
+ workflows: ['marketing-email-campaign'],
69
+ owner: { tenantId: 'acme', workspaceId: 'growth' },
70
+ enabled: true,
71
+ };
72
+ expect(entry(good), why('RFC 0086 §A', 'a conforming roster entry MUST validate')).toBe(true);
73
+ });
74
+
75
+ it('rejects a non-host: rosterId and an agentRef carrying both version and channel', () => {
76
+ const base = {
77
+ rosterId: 'host:sally-marketing',
78
+ persona: 'Sally',
79
+ agentRef: { agentId: 'core.openwop.agents.brief-writer' },
80
+ owner: { tenantId: 'acme' },
81
+ };
82
+ expect(entry({ ...base, rosterId: 'core.openwop.agents.sally' }), why('RFC 0086 §A', 'a non-`host:` rosterId MUST be rejected')).toBe(false);
83
+ expect(
84
+ entry({ ...base, agentRef: { agentId: 'core.x.y.z', version: '1.0.0', channel: 'stable' } }),
85
+ why('RFC 0082 §A', 'an agentRef with BOTH version and channel MUST be rejected'),
86
+ ).toBe(false);
87
+ expect(entry({ persona: 'x', agentRef: { agentId: 'core.x.y.z' }, owner: { tenantId: 'acme' } }), why('RFC 0086 §A', 'a roster entry without rosterId MUST be rejected')).toBe(false);
88
+ });
89
+ });
90
+
91
+ describe('agent-roster-shape: roster.run.initiated event (RFC 0086 §C, server-free)', () => {
92
+ const payloads = loadSchema('run-event-payloads.schema.json');
93
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
94
+ addFormats(ajv);
95
+ ajv.addSchema(payloads, 'payloads');
96
+ const initiated = ajv.getSchema('payloads#/$defs/rosterRunInitiated');
97
+
98
+ it('roster.run.initiated validates a content-free attribution record and requires its ids', () => {
99
+ expect(initiated, 'the rosterRunInitiated $def MUST exist').toBeTruthy();
100
+ expect(
101
+ initiated!({ rosterId: 'host:sally-marketing', persona: 'Sally', agentId: 'core.openwop.agents.brief-writer', workflowId: 'marketing-email-campaign', triggerSource: 'schedule' }),
102
+ why('RFC 0086 §C', 'a conforming roster.run.initiated payload MUST validate'),
103
+ ).toBe(true);
104
+ expect(initiated!({ rosterId: 'host:s', persona: 'S' }), why('RFC 0086 §C', 'roster.run.initiated without agentId/workflowId/triggerSource MUST be rejected')).toBe(false);
105
+ });
106
+
107
+ it('roster.run.initiated is content-free — a work-item body and a prompt are rejected (roster-attribution-no-content)', () => {
108
+ const base = { rosterId: 'host:s', persona: 'S', agentId: 'a.b.c.d', workflowId: 'wf', triggerSource: 'queue' };
109
+ expect(
110
+ initiated!({ ...base, body: 'the card description' }),
111
+ why('SECURITY invariant roster-attribution-no-content', 'roster.run.initiated MUST NOT carry the work-item body'),
112
+ ).toBe(false);
113
+ expect(
114
+ initiated!({ ...base, prompt: 'system: …' }),
115
+ why('SECURITY invariant roster-attribution-no-content', 'roster.run.initiated MUST NOT carry prompt content'),
116
+ ).toBe(false);
117
+ });
118
+ });
119
+
120
+ describe('agent-roster-shape: inventory projection + enum (RFC 0086 §B, server-free)', () => {
121
+ it('AgentInventoryEntry carries the additive optional roster portfolio projection', () => {
122
+ const inv = loadSchema('agent-inventory-response.schema.json');
123
+ const entry = (inv.$defs as Record<string, { properties?: Record<string, unknown> }>).AgentInventoryEntry?.properties ?? {};
124
+ expect(entry.roster, why('RFC 0086 §B', 'AgentInventoryEntry.roster (the portfolio projection) MUST be declared')).toBeDefined();
125
+ });
126
+
127
+ it('roster.run.initiated appears in the RunEventType enum', () => {
128
+ const runEvent = loadSchema('run-event.schema.json');
129
+ const enumVals = (runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum ?? [];
130
+ expect(enumVals).toContain('roster.run.initiated');
131
+ });
132
+
133
+ it('the GET /v1/agents/roster response schema validates + rejects extras (RFC 0086 §B)', () => {
134
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
135
+ addFormats(ajv);
136
+ ajv.addSchema(loadSchema('agent-roster-entry.schema.json'), 'https://openwop.dev/spec/v1/agent-roster-entry.schema.json');
137
+ const resp = ajv.compile(loadSchema('agent-roster-response.schema.json'));
138
+ const good = {
139
+ roster: [{ rosterId: 'host:sally', persona: 'Sally', agentRef: { agentId: 'core.x.y.z' }, owner: { tenantId: 'acme' } }],
140
+ total: 1,
141
+ };
142
+ expect(resp(good), why('RFC 0086 §B', 'a conforming GET /v1/agents/roster response MUST validate')).toBe(true);
143
+ expect(resp({ ...good, unexpected: true }), why('RFC 0086 §B', 'an extra top-level property MUST be rejected')).toBe(false);
144
+ expect(resp({ roster: [] }), why('RFC 0086 §B', 'the response MUST require `total`')).toBe(false);
145
+ });
146
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Budget, quota, and cost policy — policy + events + cap.breached kinds (RFC 0084).
3
+ *
4
+ * Always-on, server-free schema-shape probe. Verifies that:
5
+ * - `budget-policy.schema.json` round-trips a conforming `BudgetPolicy` and
6
+ * rejects the malformed (the §A orthogonality guard — a wall-time field is
7
+ * rejected by `additionalProperties:false`, because wall-time is RFC 0058's
8
+ * `runTimeoutMs`; a `thresholdPercent` out of 0..100; an out-of-enum
9
+ * `onExhaustion`).
10
+ * - the four `budget.{reserved,consumed,threshold.crossed,exhausted}` payload
11
+ * $defs validate conforming content-free records and reject malformed ones.
12
+ * - the four new `cap.breached.kind` values (`budget-tokens`/`budget-cost`/
13
+ * `budget-tool-calls`/`budget-retries`) are present in the enum.
14
+ * - the four `budget.*` event names appear in the RunEventType enum.
15
+ * - the `budget.*` payloads are CONTENT-FREE OF PRICING: none declares a
16
+ * rate-card / per-token-price / model-prose property (the public test for the
17
+ * protocol-tier SECURITY invariant `budget-no-pricing-leak`).
18
+ * - `capabilities.budget` + `limits.maxBudget{Tokens,CostUsd}` are declared.
19
+ *
20
+ * Behavioral assertions (accrue → threshold → exhaust → `cap.breached{budget-cost}`
21
+ * → `run.failed{budget_exhausted}`; `budget_model_denied`; the advisory no-stop
22
+ * path) are gated on `capabilities.budget.supported` + `enforce` and land in
23
+ * `budget-enforcement.test.ts` (deferred per RFC 0084 §Conformance — reference host
24
+ * deferred). This scenario asserts the wire contract, not host behavior.
25
+ *
26
+ * Spec references:
27
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/budget-policy.md
28
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0084-budget-quota-and-cost-policy.md
29
+ * - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (budget-no-pricing-leak)
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest';
33
+ import { readFileSync } from 'node:fs';
34
+ import { join } from 'node:path';
35
+ import Ajv2020 from 'ajv/dist/2020.js';
36
+ import addFormats from 'ajv-formats';
37
+ import { SCHEMAS_DIR } from '../lib/paths.js';
38
+
39
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
40
+
41
+ function loadSchema(name: string): Record<string, unknown> {
42
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
43
+ }
44
+
45
+ /** Property names that would betray pricing / credential prose leaking onto a budget event. */
46
+ const PRICING_PROP_NAMES = ['ratecard', 'pricepertoken', 'unitprice', 'pricing', 'rate', 'credential', 'apikey', 'model'];
47
+
48
+ describe('budget-policy-shape: BudgetPolicy (RFC 0084 §A, server-free)', () => {
49
+ const ajv = addFormats(new Ajv2020({ strict: false }));
50
+ const validate = ajv.compile(loadSchema('budget-policy.schema.json'));
51
+
52
+ it('a conforming budget policy validates', () => {
53
+ expect(
54
+ validate({ maxTokens: 200000, maxCostUsd: 1.0, maxToolCalls: 50, maxRetries: 10, modelAllow: ['claude-*'], modelDeny: ['gpt-4-32k'], thresholdPercent: 80, onExhaustion: 'fail' }),
55
+ why('budget-policy.md §A', 'a conforming BudgetPolicy MUST validate'),
56
+ ).toBe(true);
57
+ });
58
+
59
+ it('the orthogonality guard: a wall-time field is rejected (it is RFC 0058 runTimeoutMs)', () => {
60
+ expect(validate({ maxCostUsd: 1.0, maxWallTimeMs: 60000 }), why('budget-policy.md §A/§E', 'wall-time is NOT a budget dimension')).toBe(false);
61
+ });
62
+
63
+ it('rejects an out-of-range thresholdPercent and an out-of-enum onExhaustion', () => {
64
+ expect(validate({ thresholdPercent: 120 }), why('budget-policy.md §A', 'thresholdPercent MUST be 0..100')).toBe(false);
65
+ expect(validate({ onExhaustion: 'explode' }), why('budget-policy.md §A', 'onExhaustion is a closed enum')).toBe(false);
66
+ });
67
+ });
68
+
69
+ describe('budget-policy-shape: budget.* events + cap.breached kinds (RFC 0084 §C/§D, server-free)', () => {
70
+ const payloads = loadSchema('run-event-payloads.schema.json');
71
+ const ajv = addFormats(new Ajv2020({ strict: false }));
72
+ const compile = (defName: string) => ajv.compile({
73
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
74
+ $defs: (payloads as { $defs: Record<string, unknown> }).$defs,
75
+ $ref: `#/$defs/${defName}`,
76
+ } as Record<string, unknown>);
77
+
78
+ it('the four budget.* payloads validate conforming content-free records', () => {
79
+ expect(compile('budgetReserved')({ effectiveBudget: { maxCostUsd: 1.0 }, scope: 'run' }), why('budget-policy.md §C', 'budget.reserved MUST validate')).toBe(true);
80
+ expect(compile('budgetConsumed')({ dimension: 'cost', consumed: 0.7, limit: 1.0, remaining: 0.3 }), why('budget-policy.md §C', 'budget.consumed MUST validate')).toBe(true);
81
+ expect(compile('budgetThresholdCrossed')({ dimension: 'cost', consumed: 0.8, limit: 1.0, percent: 80 }), why('budget-policy.md §C', 'budget.threshold.crossed MUST validate')).toBe(true);
82
+ expect(compile('budgetExhausted')({ dimension: 'cost', consumed: 1.02, limit: 1.0 }), why('budget-policy.md §C', 'budget.exhausted MUST validate')).toBe(true);
83
+ });
84
+
85
+ it('rejects an out-of-enum dimension and a missing required field', () => {
86
+ expect(compile('budgetConsumed')({ dimension: 'vibes', consumed: 1, limit: 2 }), why('budget-policy.md §C', 'dimension is a closed enum')).toBe(false);
87
+ expect(compile('budgetExhausted')({ dimension: 'cost', consumed: 1.0 }), why('budget-policy.md §C', 'limit is REQUIRED')).toBe(false);
88
+ });
89
+
90
+ it('the cap.breached kind enum carries the four budget-* values', () => {
91
+ const kinds = ((payloads.$defs as Record<string, { properties?: Record<string, { enum?: string[] }> }>).capBreached.properties?.kind?.enum) ?? [];
92
+ for (const k of ['budget-tokens', 'budget-cost', 'budget-tool-calls', 'budget-retries']) {
93
+ expect(kinds.includes(k), why('budget-policy.md §D', `cap.breached.kind MUST include ${k}`)).toBe(true);
94
+ }
95
+ });
96
+
97
+ it('all four budget.* event names appear in the RunEventType enum', () => {
98
+ const runEvent = loadSchema('run-event.schema.json');
99
+ const enumVals = ((runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum) ?? [];
100
+ for (const name of ['budget.reserved', 'budget.consumed', 'budget.threshold.crossed', 'budget.exhausted']) {
101
+ expect(enumVals.includes(name), why('run-event.schema.json', `${name} MUST be in the RunEventType enum`)).toBe(true);
102
+ }
103
+ });
104
+
105
+ it('the budget.* payloads declare no pricing/credential property (budget-no-pricing-leak)', () => {
106
+ const defs = payloads.$defs as Record<string, { properties?: Record<string, unknown> }>;
107
+ for (const def of ['budgetReserved', 'budgetConsumed', 'budgetThresholdCrossed', 'budgetExhausted']) {
108
+ for (const p of Object.keys(defs[def].properties ?? {})) {
109
+ expect(PRICING_PROP_NAMES.includes(p.toLowerCase()), why('budget-no-pricing-leak', `${def} MUST NOT declare a pricing-bearing property (${p})`)).toBe(false);
110
+ }
111
+ }
112
+ });
113
+
114
+ it('the budget.* payloads are additionalProperties:false — a rate-card field on an INSTANCE is rejected', () => {
115
+ // The aggregate cost total (the user's own budget) is permitted; the host's per-unit rate card is not.
116
+ // additionalProperties:false makes the rejection structural, not just a declared-property check.
117
+ expect(compile('budgetConsumed')({ dimension: 'cost', consumed: 0.8, limit: 1.0 }), why('budget-policy.md §F', 'an aggregate cost total (the user budget) MUST validate')).toBe(true);
118
+ expect(
119
+ compile('budgetConsumed')({ dimension: 'cost', consumed: 0.8, limit: 1.0, ratePerToken: 0.000003 }),
120
+ why('budget-no-pricing-leak', 'a rate-card / per-token-price field MUST be rejected (additionalProperties:false)'),
121
+ ).toBe(false);
122
+ });
123
+ });
124
+
125
+ describe('budget-policy-shape: capability advertisement (RFC 0084 §E, server-free)', () => {
126
+ it('capabilities.budget + limits.maxBudget{Tokens,CostUsd} are declared', () => {
127
+ const caps = loadSchema('capabilities.schema.json');
128
+ const props = caps.properties as Record<string, { properties?: Record<string, unknown> }>;
129
+ for (const flag of ['supported', 'dimensions', 'enforce', 'scopes']) {
130
+ expect(props.budget?.properties?.[flag], why('budget-policy.md §E', `capabilities.budget.${flag} MUST be declared`)).toBeDefined();
131
+ }
132
+ for (const ceiling of ['maxBudgetTokens', 'maxBudgetCostUsd']) {
133
+ expect(props.limits?.properties?.[ceiling], why('budget-policy.md §E', `limits.${ceiling} MUST be declared`)).toBeDefined();
134
+ }
135
+ });
136
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Credential provenance + egress policy — descriptor + event + capability shapes (RFC 0079).
3
+ *
4
+ * Always-on, server-free schema-shape probe. Verifies that:
5
+ * - `credential-provenance.schema.json` compiles and round-trips a conforming
6
+ * `CredentialProvenance`, and rejects the malformed (`audiences: []` —
7
+ * `minItems:1`, a credential with no audience can't be bound to anything;
8
+ * a missing REQUIRED `credentialId`; an unknown property under
9
+ * `additionalProperties:false`).
10
+ * - the descriptor + the `egress.decided` payload are CONTENT-FREE OF THE
11
+ * SECRET: neither schema declares a secret-value property (the public test
12
+ * for the protocol-tier SECURITY invariant `egress-decision-no-secret-leak`).
13
+ * - the `egress.decided` payload $def validates a conforming content-free
14
+ * record and rejects an out-of-enum `decision` (`ok` is a `reason`, not a
15
+ * `decision` — the canonical allow value is `allowed`), and requires
16
+ * `decision` + `destination`.
17
+ * - `egress.decided` appears in the RunEventType enum.
18
+ * - `capabilities.httpClient.egressPolicy` is declared with `supported` /
19
+ * `decisions`.
20
+ *
21
+ * Behavioral assertions (the §C audience-binding MUST — a credential bound to
22
+ * audience A on an egress to B → denied/downgraded, never allowed-with-credential;
23
+ * fail-closed on unevaluable provenance) are gated on
24
+ * `capabilities.httpClient.egressPolicy.supported` and land in
25
+ * `egress-audience-binding.test.ts` + `egress-decision-content-free.test.ts`
26
+ * (deferred per RFC 0079 §Conformance — reference host deferred). That binding is
27
+ * tracked as the reference-impl-tier `egress-credential-audience-bound` invariant
28
+ * until a host wires it (RFC 0035 precedent). This scenario asserts the wire
29
+ * contract, not host behavior.
30
+ *
31
+ * Spec references:
32
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/host-capabilities.md (§"Credential provenance + egress policy")
33
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0079-credential-provenance-and-egress-policy.md
34
+ * - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (egress-decision-no-secret-leak)
35
+ */
36
+
37
+ import { describe, it, expect } from 'vitest';
38
+ import { readFileSync } from 'node:fs';
39
+ import { join } from 'node:path';
40
+ import Ajv2020 from 'ajv/dist/2020.js';
41
+ import addFormats from 'ajv-formats';
42
+ import { SCHEMAS_DIR } from '../lib/paths.js';
43
+
44
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
45
+
46
+ function loadSchema(name: string): Record<string, unknown> {
47
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
48
+ }
49
+
50
+ /** Property names that would betray a secret value leaking onto the wire. */
51
+ const SECRET_PROP_NAMES = ['secret', 'value', 'token', 'apiKey', 'authorization', 'password', 'credential'];
52
+
53
+ describe('egress-provenance-shape: CredentialProvenance (RFC 0079 §A, server-free)', () => {
54
+ const ajv = addFormats(new Ajv2020({ strict: false }));
55
+ const provenance = loadSchema('credential-provenance.schema.json');
56
+ const validate = ajv.compile(provenance);
57
+
58
+ it('a conforming descriptor validates', () => {
59
+ expect(
60
+ validate({ credentialId: 'cred-stripe-1', issuer: 'host', audiences: ['api.stripe.com'], expiresAt: '2026-12-01T00:00:00Z', scopes: ['egress:stripe:charge'], redactionPolicy: 'always' }),
61
+ why('host-capabilities.md §"Credential provenance + egress policy"', 'a conforming CredentialProvenance MUST validate'),
62
+ ).toBe(true);
63
+ });
64
+
65
+ it('audiences: [] is rejected (minItems:1 — a credential needs ≥1 audience to bind)', () => {
66
+ expect(validate({ credentialId: 'c', issuer: 'host', audiences: [] }), why('RFC 0079 §A', 'audiences MUST have ≥1 entry')).toBe(false);
67
+ });
68
+
69
+ it('a missing REQUIRED credentialId is rejected', () => {
70
+ expect(validate({ issuer: 'host', audiences: ['a'] }), why('RFC 0079 §A', 'credentialId is REQUIRED')).toBe(false);
71
+ });
72
+
73
+ it('an unknown property is rejected (additionalProperties:false)', () => {
74
+ expect(validate({ credentialId: 'c', issuer: 'host', audiences: ['a'], secretValue: 'sk-live-xxx' }), why('RFC 0079 §A', 'CredentialProvenance MUST be additionalProperties:false')).toBe(false);
75
+ });
76
+
77
+ it('declares no secret-value property (egress-decision-no-secret-leak)', () => {
78
+ const props = Object.keys((provenance.properties ?? {}) as Record<string, unknown>);
79
+ for (const p of props) {
80
+ expect(
81
+ SECRET_PROP_NAMES.includes(p.toLowerCase()),
82
+ why('SECURITY invariant egress-decision-no-secret-leak', `CredentialProvenance MUST NOT declare a secret-bearing property (${p})`),
83
+ ).toBe(false);
84
+ }
85
+ });
86
+ });
87
+
88
+ describe('egress-provenance-shape: egress.decided event (RFC 0079 §B, server-free)', () => {
89
+ const payloads = loadSchema('run-event-payloads.schema.json');
90
+ const ajv = addFormats(new Ajv2020({ strict: false }));
91
+ const validate = ajv.compile({
92
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
93
+ $defs: (payloads as { $defs: Record<string, unknown> }).$defs,
94
+ $ref: '#/$defs/egressDecided',
95
+ } as Record<string, unknown>);
96
+
97
+ it('validates a content-free decision and enforces the decision enum + required fields', () => {
98
+ expect(validate({ decision: 'allowed', destination: 'api.stripe.com', credentialId: 'cred-stripe-1', reason: 'ok' }), why('RFC 0079 §B', 'a conforming egress.decided MUST validate')).toBe(true);
99
+ expect(validate({ decision: 'denied', destination: 'attacker.example', credentialId: 'cred-stripe-1', reason: 'out-of-audience' }), why('RFC 0079 §B', 'a denied decision MUST validate')).toBe(true);
100
+ expect(validate({ decision: 'ok', destination: 'a' }), why('RFC 0079 §B', 'the canonical allow value is "allowed", not "ok"')).toBe(false);
101
+ expect(validate({ destination: 'a' }), why('RFC 0079 §B', 'decision is REQUIRED')).toBe(false);
102
+ expect(validate({ decision: 'allowed' }), why('RFC 0079 §B', 'destination is REQUIRED')).toBe(false);
103
+ });
104
+
105
+ it('reason is a CLOSED enum — a free-form / URL-bearing reason is rejected (egress-decision-no-secret-leak)', () => {
106
+ expect(validate({ decision: 'denied', destination: 'attacker.example', reason: 'out-of-audience' }), why('egress-decision-no-secret-leak', 'a conventional reason code MUST validate')).toBe(true);
107
+ expect(
108
+ validate({ decision: 'denied', destination: 'attacker.example', reason: 'https://attacker.example/leak?token=sk-live-xxx' }),
109
+ why('egress-decision-no-secret-leak', 'a free-form / URL-bearing reason MUST be rejected (it would defeat the content-free guarantee)'),
110
+ ).toBe(false);
111
+ });
112
+
113
+ it('the egressDecided $def declares no secret-value property', () => {
114
+ const def = ((payloads.$defs as Record<string, { properties?: Record<string, unknown> }>).egressDecided.properties) ?? {};
115
+ for (const p of Object.keys(def)) {
116
+ expect(SECRET_PROP_NAMES.includes(p.toLowerCase()), why('egress-decision-no-secret-leak', `egress.decided MUST NOT declare a secret-bearing property (${p})`)).toBe(false);
117
+ }
118
+ });
119
+
120
+ it('egress.decided is in the RunEventType enum', () => {
121
+ const runEvent = loadSchema('run-event.schema.json');
122
+ const enumVals = ((runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum) ?? [];
123
+ expect(enumVals.includes('egress.decided'), why('run-event.schema.json', 'egress.decided MUST be in the RunEventType enum')).toBe(true);
124
+ });
125
+ });
126
+
127
+ describe('egress-provenance-shape: capability advertisement (RFC 0079 §D, server-free)', () => {
128
+ it('capabilities.httpClient.egressPolicy is declared with its sub-flags', () => {
129
+ const caps = loadSchema('capabilities.schema.json');
130
+ const httpClient = (caps.properties as Record<string, { properties?: Record<string, { properties?: Record<string, unknown> }> }>).httpClient;
131
+ const egressPolicy = httpClient?.properties?.egressPolicy;
132
+ expect(egressPolicy, why('capabilities.md §httpClient', 'httpClient.egressPolicy MUST be declared')).toBeDefined();
133
+ for (const flag of ['supported', 'decisions']) {
134
+ expect(egressPolicy?.properties?.[flag], why('host-capabilities.md §"Credential provenance + egress policy"', `egressPolicy.${flag} MUST be declared`)).toBeDefined();
135
+ }
136
+ });
137
+ });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Memory capability model — reconciled dimensions + degraded-projection shapes (RFC 0080).
3
+ *
4
+ * Always-on, server-free schema-shape probe. Verifies that:
5
+ * - `capabilities.memory` declares the additive `writable` / `search` / `retention`
6
+ * dimensions (RFC 0080 §A), without disturbing the existing
7
+ * `supported` / `compaction` / `distillation` / `attribution` fields.
8
+ * - the `memory.search` / `memory.retention` sub-blocks validate conforming
9
+ * instances and reject malformed ones (`retention.ttl` non-boolean; an
10
+ * unknown `search.modes` enum value; an unknown property under
11
+ * `additionalProperties:false`).
12
+ * - `agent-inventory-response` declares the `memoryDegraded` (bool) +
13
+ * `degradedMemoryDimensions` (closed enum of the eight §A dimension names)
14
+ * inventory fields (RFC 0080 §C), and rejects an out-of-enum dimension.
15
+ * - the eight §A dimension names are stable (the `degradedMemoryDimensions` enum).
16
+ * - `deriveProfiles` surfaces `openwop-memory` for a read/write + long-term
17
+ * payload and withholds it for a `writable:false` payload (the §D predicate).
18
+ *
19
+ * Behavioral assertions (a live `GET /v1/agents` stamping `memoryDegraded` when an
20
+ * agent's `memoryShape` exceeds the host's reconciled model) are gated on
21
+ * `capabilities.agents.manifestRuntime` + `memory` and land in
22
+ * `memory-degraded-projection.test.ts` (deferred per RFC 0080 §Conformance — the
23
+ * degraded projection soft-skips until a reference host computes it). This scenario
24
+ * asserts the wire contract, not host behavior.
25
+ *
26
+ * Spec references:
27
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/agent-memory.md (§"Memory capability model")
28
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/profiles.md (§`openwop-memory`)
29
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0080-agent-memory-capability-reconciliation.md
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest';
33
+ import { readFileSync } from 'node:fs';
34
+ import { join } from 'node:path';
35
+ import Ajv2020 from 'ajv/dist/2020.js';
36
+ import addFormats from 'ajv-formats';
37
+ import { SCHEMAS_DIR } from '../lib/paths.js';
38
+ import { deriveProfiles } from '../lib/profiles.js';
39
+
40
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
41
+
42
+ function loadSchema(name: string): Record<string, unknown> {
43
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
44
+ }
45
+
46
+ /** The canonical eight RFC 0080 §A dimension names, in table order. */
47
+ const DIMENSIONS = [
48
+ 'read',
49
+ 'write',
50
+ 'search',
51
+ 'long-term-durability',
52
+ 'compaction',
53
+ 'attribution',
54
+ 'replay-snapshot',
55
+ 'retention',
56
+ ] as const;
57
+
58
+ describe('memory-capability-model-shape: reconciled dimensions (RFC 0080 §A, server-free)', () => {
59
+ const caps = loadSchema('capabilities.schema.json');
60
+ const memory = (caps.properties as Record<string, { properties?: Record<string, unknown> }>).memory;
61
+
62
+ it('capabilities.memory declares the additive writable / search / retention dimensions', () => {
63
+ for (const dim of ['writable', 'search', 'retention']) {
64
+ expect(
65
+ memory?.properties?.[dim],
66
+ why('agent-memory.md §"Memory capability model"', `capabilities.memory.${dim} MUST be declared (RFC 0080 §A)`),
67
+ ).toBeDefined();
68
+ }
69
+ });
70
+
71
+ it('the pre-existing memory fields are untouched (additive, no relocation)', () => {
72
+ for (const dim of ['supported', 'compaction', 'distillation', 'attribution']) {
73
+ expect(
74
+ memory?.properties?.[dim],
75
+ why('COMPATIBILITY.md §2.1', `capabilities.memory.${dim} MUST remain (RFC 0080 is additive)`),
76
+ ).toBeDefined();
77
+ }
78
+ });
79
+
80
+ it('memory.search / memory.retention validate conforming instances and reject malformed ones', () => {
81
+ const ajv = addFormats(new Ajv2020({ strict: false }));
82
+ // Wrap the extracted sub-block in a standalone schema (no external $refs in the block).
83
+ const validate = ajv.compile({
84
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
85
+ type: 'object',
86
+ additionalProperties: false,
87
+ properties: { memory },
88
+ } as Record<string, unknown>);
89
+
90
+ expect(
91
+ validate({ memory: { supported: true, writable: false, search: { supported: true, modes: ['semantic', 'filter'] }, retention: { ttl: true, forget: true } } }),
92
+ why('capabilities.md §memory', 'a full reconciled-memory advertisement MUST validate'),
93
+ ).toBe(true);
94
+
95
+ expect(
96
+ validate({ memory: { retention: { ttl: 'yes' } } }),
97
+ why('RFC 0080 §A', 'retention.ttl MUST be boolean'),
98
+ ).toBe(false);
99
+
100
+ expect(
101
+ validate({ memory: { search: { supported: true, modes: ['fuzzy'] } } }),
102
+ why('RFC 0080 §A', 'search.modes MUST be the closed enum [semantic, filter]'),
103
+ ).toBe(false);
104
+
105
+ expect(
106
+ validate({ memory: { search: { supported: true, unknownField: 1 } } }),
107
+ why('RFC 0080 §A', 'memory.search MUST be additionalProperties:false'),
108
+ ).toBe(false);
109
+ });
110
+ });
111
+
112
+ describe('memory-capability-model-shape: degraded projection (RFC 0080 §C, server-free)', () => {
113
+ const inventory = loadSchema('agent-inventory-response.schema.json');
114
+
115
+ it('agent-inventory-response declares memoryDegraded + degradedMemoryDimensions', () => {
116
+ const entry = ((inventory.$defs as Record<string, { properties?: Record<string, unknown> }>)
117
+ .AgentInventoryEntry).properties;
118
+ expect(
119
+ entry?.memoryDegraded,
120
+ why('agent-memory.md §C-1', 'memoryDegraded MUST be declared on the inventory entry'),
121
+ ).toBeDefined();
122
+ expect(
123
+ entry?.degradedMemoryDimensions,
124
+ why('agent-memory.md §C-1', 'degradedMemoryDimensions MUST be declared on the inventory entry'),
125
+ ).toBeDefined();
126
+ });
127
+
128
+ it('degradedMemoryDimensions enumerates exactly the eight §A dimension names', () => {
129
+ const entry = ((inventory.$defs as Record<string, { properties?: Record<string, { items?: { enum?: string[] } }> }>)
130
+ .AgentInventoryEntry).properties;
131
+ const enumVals = entry?.degradedMemoryDimensions?.items?.enum ?? [];
132
+ expect(
133
+ [...enumVals].sort(),
134
+ why('agent-memory.md §A', 'the degraded-dimension enum MUST be the eight reconciled dimensions'),
135
+ ).toEqual([...DIMENSIONS].sort());
136
+ });
137
+
138
+ it('the inventory schema round-trips a degraded entry and rejects an out-of-enum dimension', () => {
139
+ const ajv = addFormats(new Ajv2020({ strict: false }));
140
+ const validate = ajv.compile(inventory);
141
+ const base = {
142
+ agentId: 'a', persona: 'A', label: 'A', modelClass: 'standard',
143
+ packName: 'p', packVersion: '1.0.0', toolAllowlist: [], hasHandoffSchemas: false,
144
+ };
145
+ expect(
146
+ validate({ total: 1, agents: [{ ...base, memoryDegraded: true, degradedMemoryDimensions: ['write', 'long-term-durability'] }] }),
147
+ why('agent-memory.md §C-1', 'a degraded inventory entry MUST validate'),
148
+ ).toBe(true);
149
+ expect(
150
+ validate({ total: 1, agents: [{ ...base, memoryDegraded: true, degradedMemoryDimensions: ['telepathy'] }] }),
151
+ why('agent-memory.md §C-1', 'an out-of-enum degraded dimension MUST be rejected'),
152
+ ).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe('memory-capability-model-shape: openwop-memory derivation (RFC 0080 §D, server-free)', () => {
157
+ it('deriveProfiles surfaces openwop-memory for a read/write + long-term host', () => {
158
+ const c = {
159
+ protocolVersion: '1.0',
160
+ supportedEnvelopes: ['clarification.request'],
161
+ schemaVersions: {},
162
+ limits: { clarificationRounds: 1, schemaRounds: 1, envelopesPerTurn: 1 },
163
+ memory: { supported: true },
164
+ agents: { memoryBackends: ['long-term'] },
165
+ } as Record<string, unknown>;
166
+ expect(
167
+ deriveProfiles(c).includes('openwop-memory'),
168
+ why('profiles.md §openwop-memory', 'a read/write + long-term host MUST derive openwop-memory'),
169
+ ).toBe(true);
170
+ });
171
+
172
+ it('deriveProfiles withholds openwop-memory from a read-only (writable:false) host', () => {
173
+ const c = {
174
+ protocolVersion: '1.0',
175
+ supportedEnvelopes: ['clarification.request'],
176
+ schemaVersions: {},
177
+ limits: { clarificationRounds: 1, schemaRounds: 1, envelopesPerTurn: 1 },
178
+ memory: { supported: true, writable: false },
179
+ agents: { memoryBackends: ['long-term'] },
180
+ } as Record<string, unknown>;
181
+ expect(
182
+ deriveProfiles(c).includes('openwop-memory'),
183
+ why('profiles.md §openwop-memory', 'a read-only host MUST NOT derive openwop-memory'),
184
+ ).toBe(false);
185
+ });
186
+ });