@openwop/openwop-conformance 1.125.0 → 1.128.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/README.md +2 -2
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/workload-identity.schema.json +7 -1
- package/src/scenarios/a2a-1-0-task-roundtrip.test.ts +3 -1
- package/src/scenarios/capability-example-root-layout.test.ts +159 -0
- package/src/scenarios/core-manifest-and-extension-registry.test.ts +40 -14
- package/src/scenarios/workload-identity-behavior.test.ts +1 -1
- package/src/scenarios/workload-identity-chain-bounds.test.ts +141 -0
- package/src/scenarios/workload-identity-profile.test.ts +9 -0
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of this vendored schemas/ copy. See conformance/README.md \u00a7\"Resolving the contract\". Compare against the stamp in your installed @openwop/openwop-conformance to detect a stale hand-copied contract.",
|
|
3
|
-
"suiteVersion": "1.
|
|
4
|
-
"corpusCommit": "
|
|
3
|
+
"suiteVersion": "1.128.0",
|
|
4
|
+
"corpusCommit": "04f59e91eb096ce1782b16722599a9d2cfa7aae1"
|
|
5
5
|
}
|
|
@@ -52,7 +52,13 @@
|
|
|
52
52
|
"required": ["subject"],
|
|
53
53
|
"properties": {
|
|
54
54
|
"subject": { "type": "string", "minLength": 1 },
|
|
55
|
-
"issuer": { "type": "string", "minLength": 1 }
|
|
55
|
+
"issuer": { "type": "string", "minLength": 1 },
|
|
56
|
+
"scopes": {
|
|
57
|
+
"type": "array",
|
|
58
|
+
"uniqueItems": true,
|
|
59
|
+
"items": { "type": "string", "minLength": 1 },
|
|
60
|
+
"description": "RFC 0154 §B — the effective scopes VERIFIED for this hop from its proof (RFC 0049 scope grammar). OPTIONAL and provenance, not authorization: the host still evaluates the resolved principal's own scopes. When present on consecutive hops, a later hop's scopes MUST NOT exceed the previous hop's (`auth.md` §\"Bounds\" — scope amplification is refused: `delegation_scope_amplified`)."
|
|
61
|
+
}
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
64
|
},
|
|
@@ -53,7 +53,9 @@ async function claims10(): Promise<boolean> {
|
|
|
53
53
|
async function jsonrpc10Url(): Promise<string | null> {
|
|
54
54
|
const caps = await a2a();
|
|
55
55
|
if (typeof caps?.agentCardUrl !== 'string') return null;
|
|
56
|
-
|
|
56
|
+
// S18 (#1028): a header-less card GET returns the 0.3 shape while `a2a-0.3-legacy`
|
|
57
|
+
// is advertised; a 1.0 client asks for the 1.0 card explicitly (a2a-integration.md §C).
|
|
58
|
+
const res = await fetch(caps.agentCardUrl, { headers: { accept: 'application/json', 'A2A-Version': '1.0' } });
|
|
57
59
|
if (res.status !== 200) return null;
|
|
58
60
|
const card = (await res.json()) as { supportedInterfaces?: Array<{ url?: string; protocolBinding?: string; protocolVersion?: string }> };
|
|
59
61
|
const iface = (card.supportedInterfaces ?? []).find((i) => i.protocolBinding === 'JSONRPC' && i.protocolVersion === '1.0');
|
|
@@ -21,6 +21,27 @@
|
|
|
21
21
|
* otherwise legal unknown server-emitted property, and nothing here reads a
|
|
22
22
|
* host.
|
|
23
23
|
*
|
|
24
|
+
* **Canonical-typo leg (RFC 0149 §B, second bullet; UQ2 decided 2026-08-16).** A
|
|
25
|
+
* root key within edit distance ONE of a canonical family, in a discovery-shaped
|
|
26
|
+
* example, that is not itself canonical and not vendor-namespaced, is a typo
|
|
27
|
+
* (`compensaton`, `interupts`) — an implementer copying it advertises nothing.
|
|
28
|
+
* UQ2 asked what rule avoids false positives on legitimate extension names; the
|
|
29
|
+
* answer was measured, not guessed. Over every fenced root object in `spec/v1` +
|
|
30
|
+
* `RFCS/` (218 on 2026-08-16), plain distance-one produced six near-misses —
|
|
31
|
+
* `ts`/`fs`, `agent`/`agents`, `secret`/`secrets`, `context`/`content`,
|
|
32
|
+
* `prompt`/`prompts`, `schemaVersion`/`schemaVersions` — every one of them a key
|
|
33
|
+
* of an EVENT or RUN object, not a discovery document. So the predicate is
|
|
34
|
+
* *discovery-shaped*: every root key is canonical, vendor-namespaced
|
|
35
|
+
* (`host-extensions.md` §"Canonical prefixes"), the legacy `capabilities`
|
|
36
|
+
* wrapper, or within distance one of a canonical family — and at least one key
|
|
37
|
+
* is canonical-or-near. That excludes events (`ts`/`type`/`payload` are none of
|
|
38
|
+
* those) while still catching a typo-only snippet whose single key is misspelt.
|
|
39
|
+
* Scope: `spec/v1` and RFCs numbered >= 0149 (the rule's own RFC); older RFCs
|
|
40
|
+
* are the dated record, on the same boundary logic as the wrapper leg's 0073.
|
|
41
|
+
* Under that predicate and scope the corpus measured 53 discovery-shaped
|
|
42
|
+
* objects and 0 findings; the one out-of-scope near-miss is RFC 0109's
|
|
43
|
+
* `{ "agent": … }` payload fragment.
|
|
44
|
+
*
|
|
24
45
|
* `spec/v1/` and `RFCS/` ship in the repository, NOT in the published tarball,
|
|
25
46
|
* so this self-skips under the published layout. That asymmetry has produced
|
|
26
47
|
* three defects in this corpus — the `CORPUS-STAMP` gate, the link-checker's
|
|
@@ -66,6 +87,97 @@ function fencedExamples(dir: string): FencedExample[] {
|
|
|
66
87
|
return found;
|
|
67
88
|
}
|
|
68
89
|
|
|
90
|
+
/** RFC 0149 introduced the typo lint; examples in earlier RFCs are historical record. */
|
|
91
|
+
const TYPO_LINT_RFC = 149;
|
|
92
|
+
|
|
93
|
+
const SCHEMA_PATH =
|
|
94
|
+
V1_DIR === null ? null : pathResolve(V1_DIR, '..', '..', 'schemas', 'capabilities.schema.json');
|
|
95
|
+
|
|
96
|
+
/** The canonical families, read from the schema rather than hand-listed. */
|
|
97
|
+
function canonicalFamilies(): Set<string> {
|
|
98
|
+
if (SCHEMA_PATH === null) return new Set();
|
|
99
|
+
const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')) as { properties?: Record<string, unknown> };
|
|
100
|
+
return new Set(Object.keys(schema.properties ?? {}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `host-extensions.md` §"Canonical prefixes". */
|
|
104
|
+
function isVendorKey(key: string): boolean {
|
|
105
|
+
return /^x-host-/.test(key) || /^(vendor|private)\./.test(key);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Levenshtein distance exactly one (one substitution, insertion, or deletion). */
|
|
109
|
+
export function withinOne(a: string, b: string): boolean {
|
|
110
|
+
if (a === b) return false;
|
|
111
|
+
if (Math.abs(a.length - b.length) > 1) return false;
|
|
112
|
+
if (a.length === b.length) {
|
|
113
|
+
let diff = 0;
|
|
114
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i] && ++diff > 1) return false;
|
|
115
|
+
return diff === 1;
|
|
116
|
+
}
|
|
117
|
+
const [short, long] = a.length < b.length ? [a, b] : [b, a];
|
|
118
|
+
let i = 0;
|
|
119
|
+
let j = 0;
|
|
120
|
+
let skipped = false;
|
|
121
|
+
while (i < short.length && j < long.length) {
|
|
122
|
+
if (short[i] === long[j]) {
|
|
123
|
+
i++;
|
|
124
|
+
j++;
|
|
125
|
+
} else if (skipped) {
|
|
126
|
+
return false;
|
|
127
|
+
} else {
|
|
128
|
+
skipped = true;
|
|
129
|
+
j++;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface TypoFinding {
|
|
136
|
+
readonly key: string;
|
|
137
|
+
readonly near: readonly string[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The UQ2 rule. Returns the near-miss root keys of a DISCOVERY-SHAPED object, or
|
|
142
|
+
* `null` when the object is not discovery-shaped (and so is out of scope: an
|
|
143
|
+
* event, a run body, a manifest). Exported so the predicate is pinned below.
|
|
144
|
+
*/
|
|
145
|
+
export function canonicalTypos(value: unknown, families: Set<string>): TypoFinding[] | null {
|
|
146
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
147
|
+
const keys = Object.keys(value as Record<string, unknown>);
|
|
148
|
+
if (keys.length === 0) return null;
|
|
149
|
+
const near = new Map<string, string[]>();
|
|
150
|
+
for (const k of keys) {
|
|
151
|
+
if (families.has(k) || isVendorKey(k) || k === 'capabilities') continue;
|
|
152
|
+
const n = [...families].filter((f) => withinOne(k, f));
|
|
153
|
+
if (n.length === 0) return null; // a key that is none of the four kinds ⇒ not discovery-shaped
|
|
154
|
+
near.set(k, n);
|
|
155
|
+
}
|
|
156
|
+
if (!keys.some((k) => families.has(k) || near.has(k))) return null;
|
|
157
|
+
return [...near.entries()].map(([key, n]) => ({ key, near: n }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Every parseable fenced json/jsonc root object under `dir`, with its source line. */
|
|
161
|
+
function fencedObjects(dir: string): { file: string; line: number; value: unknown }[] {
|
|
162
|
+
const out: { file: string; line: number; value: unknown }[] = [];
|
|
163
|
+
for (const name of readdirSync(dir).filter((f) => f.endsWith('.md')).sort()) {
|
|
164
|
+
const lines = readFileSync(join(dir, name), 'utf8').split('\n');
|
|
165
|
+
for (let i = 0; i < lines.length; i++) {
|
|
166
|
+
if (!/^```(json|jsonc)\s*$/.test(lines[i]!.trim())) continue;
|
|
167
|
+
const body: string[] = [];
|
|
168
|
+
let j = i + 1;
|
|
169
|
+
while (j < lines.length && lines[j]!.trim() !== '```') body.push(lines[j++]!);
|
|
170
|
+
try {
|
|
171
|
+
out.push({ file: name, line: i + 2, value: JSON.parse(body.join('\n')) });
|
|
172
|
+
} catch {
|
|
173
|
+
// Unparseable blocks are RFC 0150 §D's problem, not this gate's.
|
|
174
|
+
}
|
|
175
|
+
i = j;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
69
181
|
describe.skipIf(V1_DIR === null)('RFC 0149 §B — discovery examples use the document-root layout', () => {
|
|
70
182
|
const v1Dir = V1_DIR as string;
|
|
71
183
|
|
|
@@ -110,4 +222,51 @@ describe.skipIf(V1_DIR === null)('RFC 0149 §B — discovery examples use the do
|
|
|
110
222
|
late.join('\n '),
|
|
111
223
|
).toEqual([]);
|
|
112
224
|
});
|
|
225
|
+
it('the UQ2 predicate is pinned: a typo-only snippet is flagged, an event object is out of scope, a vendor key is exempt', () => {
|
|
226
|
+
const fams = new Set(['compensation', 'interrupts', 'fs', 'agents', 'content']);
|
|
227
|
+
// A misspelt single-key discovery snippet: all keys near-canonical ⇒ in scope, flagged.
|
|
228
|
+
expect(canonicalTypos({ compensaton: { supported: true } }, fams)).toEqual([{ key: 'compensaton', near: ['compensation'] }]);
|
|
229
|
+
// Canonical + a typo ⇒ flagged.
|
|
230
|
+
expect(canonicalTypos({ compensation: {}, interupts: {} }, fams)).toEqual([{ key: 'interupts', near: ['interrupts'] }]);
|
|
231
|
+
// An event: `ts` is one from `fs` but `type`/`payload` are none of the four kinds ⇒ not discovery-shaped.
|
|
232
|
+
expect(canonicalTypos({ ts: 1, type: 'x', payload: {} }, fams)).toBeNull();
|
|
233
|
+
// Vendor-namespaced keys are exempt (RFC 0149 §B) and the legacy wrapper is the wrapper leg's business.
|
|
234
|
+
expect(canonicalTypos({ compensation: {}, 'x-host-acme-agent': {} }, fams)).toEqual([]);
|
|
235
|
+
expect(canonicalTypos({ capabilities: {} }, fams)).toBeNull();
|
|
236
|
+
// Distance exactly one, both directions.
|
|
237
|
+
expect(withinOne('agent', 'agents')).toBe(true);
|
|
238
|
+
expect(withinOne('agents', 'agent')).toBe(true);
|
|
239
|
+
expect(withinOne('agent', 'agentz')).toBe(true);
|
|
240
|
+
expect(withinOne('agent', 'agenzs')).toBe(false);
|
|
241
|
+
expect(withinOne('agents', 'agents')).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('no discovery-shaped example in spec/v1 or a post-0149 RFC has a root key within one edit of a canonical family', () => {
|
|
245
|
+
const families = canonicalFamilies();
|
|
246
|
+
expect(families.size, 'capabilities.schema.json MUST declare families').toBeGreaterThan(50);
|
|
247
|
+
const findings: string[] = [];
|
|
248
|
+
let shaped = 0;
|
|
249
|
+
const scan = (dir: string, rel: string, minRfc: number | null): void => {
|
|
250
|
+
for (const { file, line, value } of fencedObjects(dir)) {
|
|
251
|
+
if (minRfc !== null) {
|
|
252
|
+
const n = Number.parseInt(file.slice(0, 4), 10);
|
|
253
|
+
if (!Number.isFinite(n) || n < minRfc) continue;
|
|
254
|
+
}
|
|
255
|
+
const typos = canonicalTypos(value, families);
|
|
256
|
+
if (typos === null) continue;
|
|
257
|
+
shaped++;
|
|
258
|
+
for (const t of typos) findings.push(`${rel}/${file}:${line} → \`${t.key}\` (did you mean ${t.near.map((x) => '`' + x + '`').join(' / ')}?)`);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
scan(v1Dir, 'spec/v1', null);
|
|
262
|
+
if (RFCS_DIR !== null && existsSync(RFCS_DIR)) scan(RFCS_DIR, 'RFCS', TYPO_LINT_RFC);
|
|
263
|
+
expect(shaped, 'the scan MUST find discovery-shaped examples, or the leg is vacuous').toBeGreaterThan(20);
|
|
264
|
+
expect(
|
|
265
|
+
findings,
|
|
266
|
+
'RFC 0149 §B: a root key within one edit of a canonical family, in a discovery-shaped example, is a ' +
|
|
267
|
+
'typo — an implementer copying it advertises nothing. Vendor surface goes under `x-host-*` / ' +
|
|
268
|
+
'`vendor.*` / `private.*` (host-extensions.md).\n ' +
|
|
269
|
+
findings.join('\n '),
|
|
270
|
+
).toEqual([]);
|
|
271
|
+
});
|
|
113
272
|
});
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { describe, it, expect } from 'vitest';
|
|
27
|
-
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
28
28
|
import { join } from 'node:path';
|
|
29
29
|
import { V1_DIR } from '../lib/paths.js';
|
|
30
30
|
import { PROFILE_FLOOR_SCENARIOS } from '../lib/profiles.js';
|
|
@@ -36,7 +36,7 @@ type Maturity = (typeof MATURITIES)[number];
|
|
|
36
36
|
interface Extension {
|
|
37
37
|
readonly id: string;
|
|
38
38
|
readonly maturity: Maturity;
|
|
39
|
-
readonly owningRfc: string;
|
|
39
|
+
readonly owningRfc: string | null;
|
|
40
40
|
readonly capabilityPath: string;
|
|
41
41
|
readonly dependsOn: readonly string[];
|
|
42
42
|
readonly securityTier: string;
|
|
@@ -190,35 +190,61 @@ describe.skipIf(V1_DIR === null)('RFC 0155 §C — extension registry', () => {
|
|
|
190
190
|
// either a core predicate field, covered by a record's capabilityPath, or
|
|
191
191
|
// listed as uncovered — and nothing is in two buckets.
|
|
192
192
|
const reg = registry as unknown as {
|
|
193
|
-
coverage?: {
|
|
193
|
+
coverage?: {
|
|
194
|
+
familiesTotal: number;
|
|
195
|
+
coreFields: string[];
|
|
196
|
+
metadataFields?: string[];
|
|
197
|
+
metadataRationale?: Record<string, string>;
|
|
198
|
+
covered: string[];
|
|
199
|
+
uncovered: string[];
|
|
200
|
+
};
|
|
194
201
|
extensions: Extension[];
|
|
195
202
|
};
|
|
196
203
|
expect(reg.coverage, 'RFC 0155 §C: the registry MUST carry a derived `coverage` block').toBeDefined();
|
|
197
204
|
const cov = reg.coverage as NonNullable<typeof reg.coverage>;
|
|
205
|
+
const metadata = cov.metadataFields ?? [];
|
|
198
206
|
const families = Object.keys((caps().properties as Record<string, unknown>) ?? {}).sort();
|
|
199
207
|
expect(cov.familiesTotal).toBe(families.length);
|
|
200
|
-
const all = [...cov.coreFields, ...cov.covered, ...cov.uncovered].sort();
|
|
201
|
-
expect(all, 'core + covered + uncovered MUST partition the family set exactly').toEqual(families);
|
|
208
|
+
const all = [...cov.coreFields, ...metadata, ...cov.covered, ...cov.uncovered].sort();
|
|
209
|
+
expect(all, 'core + metadata + covered + uncovered MUST partition the family set exactly').toEqual(families);
|
|
202
210
|
expect(new Set(all).size, 'no family may sit in two buckets').toBe(all.length);
|
|
203
211
|
const reached = new Set(reg.extensions.map((e) => e.capabilityPath.split('.')[0]));
|
|
204
212
|
for (const f of cov.covered) expect(reached.has(f), `${f} listed as covered MUST be reached by a record`).toBe(true);
|
|
205
213
|
for (const f of cov.uncovered) expect(reached.has(f), `${f} listed as uncovered MUST NOT be reached by a record`).toBe(false);
|
|
214
|
+
// Metadata is the one bucket a family can be moved INTO by hand, so it is
|
|
215
|
+
// the one that could hide an extension: every entry MUST carry a stated
|
|
216
|
+
// rationale, and no metadata key may carry a `supported` flag — a key that
|
|
217
|
+
// gates behaviour is a family, not a description of the document.
|
|
218
|
+
for (const f of metadata) {
|
|
219
|
+
expect(typeof cov.metadataRationale?.[f], `${f}: a metadata field MUST state why it is not an extension`).toBe('string');
|
|
220
|
+
const props = (caps().properties as Record<string, { properties?: Record<string, unknown> }>)[f]?.properties ?? {};
|
|
221
|
+
expect('supported' in props, `${f} is listed as metadata but carries a \`supported\` flag — that is an extension family`).toBe(false);
|
|
222
|
+
}
|
|
206
223
|
// The honest number, asserted so it cannot silently shrink by deletion of the
|
|
207
224
|
// uncovered list rather than by adding records.
|
|
208
|
-
expect(cov.uncovered.length + cov.covered.length + cov.coreFields.length).toBe(families.length);
|
|
225
|
+
expect(cov.uncovered.length + cov.covered.length + cov.coreFields.length + metadata.length).toBe(families.length);
|
|
209
226
|
});
|
|
210
227
|
|
|
211
|
-
it('every record names the RFC that owns it', () => {
|
|
228
|
+
it('every record names the RFC — or, for a v1 base advertisement, the spec document — that owns it', () => {
|
|
212
229
|
// Vendor extensions may not use an `openwop-*` id without an accepted RFC
|
|
213
|
-
// (§F). The owning RFC is what makes that checkable.
|
|
230
|
+
// (§F). The owning RFC is what makes that checkable. Six advertisements
|
|
231
|
+
// predate the RFC process (they shipped in the v1 base corpus: `secrets`,
|
|
232
|
+
// `webhooks`, `i18n`, `aiProviders`, `envelopeContracts`, `envelopeStrictness`);
|
|
233
|
+
// those carry `owningRfc: null` and an `owningDoc` under spec/v1/ that MUST
|
|
234
|
+
// exist — the steward's own corpus is the RFC-equivalent authority for them.
|
|
214
235
|
for (const e of (registry as NonNullable<typeof registry>).extensions) {
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
expect(
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
236
|
+
const rec = e as Extension & { owningDoc?: string; securityTier?: string };
|
|
237
|
+
if (rec.owningRfc === null) {
|
|
238
|
+
expect(typeof rec.owningDoc, `${e.id}: \`owningRfc: null\` requires an \`owningDoc\``).toBe('string');
|
|
239
|
+
expect(rec.owningDoc, `${e.id}: owningDoc MUST be a spec/v1 document`).toMatch(/^spec\/v1\/[a-z0-9-]+\.md$/);
|
|
240
|
+
if (V1_DIR !== null) {
|
|
241
|
+
const file = join(V1_DIR, (rec.owningDoc as string).replace(/^spec\/v1\//, ''));
|
|
242
|
+
expect(existsSync(file), `${e.id}: owningDoc ${rec.owningDoc} MUST exist`).toBe(true);
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
expect(e.owningRfc, `${e.id} MUST name an owning RFC`).toMatch(/^\d{4}$/);
|
|
221
246
|
}
|
|
247
|
+
expect(['high', 'medium', 'low'], `${e.id}: securityTier is a closed enum`).toContain(rec.securityTier);
|
|
222
248
|
}
|
|
223
249
|
});
|
|
224
250
|
});
|
|
@@ -167,7 +167,7 @@ describe('RFC 0154 §A — workload identity resolution (capability-gated behavi
|
|
|
167
167
|
),
|
|
168
168
|
).toBe(false);
|
|
169
169
|
expect(
|
|
170
|
-
['identity_unverified', 'identity_unresolvable', 'audience_mismatch', 'delegation_expired', 'sender_constraint_missing'],
|
|
170
|
+
['identity_unverified', 'identity_unresolvable', 'audience_mismatch', 'delegation_expired', 'sender_constraint_missing', 'delegation_chain_too_long', 'delegation_chain_cyclic', 'delegation_scope_amplified'],
|
|
171
171
|
driver.describe('RFCS/0154 §A', 'failures use a closed reason vocabulary'),
|
|
172
172
|
).toContain(readErrorCode(r.json));
|
|
173
173
|
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0154 §B "Bounds" — the delegation chain is bounded, acyclic, and cannot
|
|
3
|
+
* amplify scope hop-to-hop (`auth.md` §"Workload identity and delegated actor
|
|
4
|
+
* chain" → Bounds; threat model A4 "chain launderer").
|
|
5
|
+
*
|
|
6
|
+
* `workload-identity-behavior.test.ts` proves the resolver verifies audience and
|
|
7
|
+
* expiry. It does not touch the chain's SHAPE as a source of authority, and that
|
|
8
|
+
* is where laundering lives: a chain that is one hop longer than the host said
|
|
9
|
+
* it would accept, a chain that loops back through a subject it already passed,
|
|
10
|
+
* or a chain whose second hop claims a scope its first hop never held. Each is a
|
|
11
|
+
* verified-looking presentation that a passthrough resolver accepts and a real
|
|
12
|
+
* one refuses, which is what makes the legs non-vacuous — `resolved: true` on
|
|
13
|
+
* any of them is the finding.
|
|
14
|
+
*
|
|
15
|
+
* All three go through the §20 seam (`host-sample-test-seams.md`) with the
|
|
16
|
+
* three closed reasons added for them: `delegation_chain_too_long`,
|
|
17
|
+
* `delegation_chain_cyclic`, `delegation_scope_amplified`. Every leg is gated on
|
|
18
|
+
* `auth.workloadIdentity.delegation.supported` via `behaviorGate` (a host doing
|
|
19
|
+
* §A-only identity resolution has not claimed §B) — soft-skips without it,
|
|
20
|
+
* HARD-FAILS under `OPENWOP_REQUIRE_BEHAVIOR=true`, and records the RFC 0148 §A
|
|
21
|
+
* disposition either way. A seam that answers 404/403 is `blocked`, not a pass.
|
|
22
|
+
*
|
|
23
|
+
* Per-hop `scopes` on the wire are OPTIONAL (`workload-identity.schema.json`,
|
|
24
|
+
* 2026-08-16); the amplification leg presents them explicitly, so a host that
|
|
25
|
+
* ignores the field will resolve the chain and fail the leg — which is the
|
|
26
|
+
* honest outcome, because a host that cannot see hop scopes cannot enforce the
|
|
27
|
+
* MUST NOT either.
|
|
28
|
+
*
|
|
29
|
+
* Registered invariants: `delegation-chain-acyclic`,
|
|
30
|
+
* `delegation-no-scope-amplification` (`SECURITY/invariants.yaml`), and the
|
|
31
|
+
* length half of `delegation-chain-bounded`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect } from 'vitest';
|
|
35
|
+
import { readErrorCode, readRetriable } from '../lib/error-envelope.js';
|
|
36
|
+
import { driver } from '../lib/driver.js';
|
|
37
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
38
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
39
|
+
import { seamAbsent } from '../lib/soft-skip.js';
|
|
40
|
+
|
|
41
|
+
const DELEGATION_PROFILE = 'openwop-workload-identity-delegation';
|
|
42
|
+
const SEAM = '/v1/host/sample/test/workload-identity/resolve';
|
|
43
|
+
|
|
44
|
+
interface AuthCaps {
|
|
45
|
+
readonly workloadIdentity?: {
|
|
46
|
+
readonly supported?: boolean;
|
|
47
|
+
readonly delegation?: { readonly supported?: boolean; readonly maxChainDepth?: number };
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function delegationCaps(): Promise<{ supported: boolean; maxChainDepth: number | null }> {
|
|
52
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
53
|
+
const d = capabilityFamily<AuthCaps>(disco.json, 'auth')?.workloadIdentity?.delegation;
|
|
54
|
+
return {
|
|
55
|
+
supported: d?.supported === true,
|
|
56
|
+
maxChainDepth: typeof d?.maxChainDepth === 'number' && Number.isInteger(d.maxChainDepth) && d.maxChainDepth > 0 ? d.maxChainDepth : null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function resolve(body: Record<string, unknown>): Promise<{ status: number; json: unknown } | null> {
|
|
61
|
+
const r = await driver.post(SEAM, body);
|
|
62
|
+
if (r.status === 404 || r.status === 403) {
|
|
63
|
+
seamAbsent(`host advertises \`auth.workloadIdentity.delegation.supported: true\` but ${SEAM} answered ${r.status} — RFC 0154 §B chain bounds are unobservable (host-sample-test-seams.md §20)`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return { status: r.status, json: r.json };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const IDENTITY = { scheme: 'spiffe', subject: 'spiffe://example/dispatcher', issuer: 'spiffe://example', audience: 'openwop-host' };
|
|
70
|
+
const LIVE = '2099-01-01T00:00:00Z';
|
|
71
|
+
|
|
72
|
+
function hop(n: number, scopes?: readonly string[]): Record<string, unknown> {
|
|
73
|
+
const h: Record<string, unknown> = { subject: `spiffe://example/hop-${n}`, issuer: 'spiffe://example' };
|
|
74
|
+
if (scopes !== undefined) h['scopes'] = [...scopes];
|
|
75
|
+
return h;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A refusal: 4xx, non-retriable, the named closed reason. */
|
|
79
|
+
function expectRefusal(r: { status: number; json: unknown }, reason: string, why: string): void {
|
|
80
|
+
expect(r.status >= 400, driver.describe('RFCS/0154 §B', why)).toBe(true);
|
|
81
|
+
expect(readErrorCode(r.json), driver.describe('spec/v1/host-sample-test-seams.md §20', `closed reason \`${reason}\``)).toBe(reason);
|
|
82
|
+
expect(readRetriable(r.json), driver.describe('spec/v1/host-sample-test-seams.md §20', 'a chain the host refuses will be refused on retry')).toBe(false);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('RFC 0154 §B — delegation chain bounds (capability-gated behavior)', () => {
|
|
86
|
+
it('a chain longer than the advertised maxChainDepth is refused', async () => {
|
|
87
|
+
const caps = await delegationCaps();
|
|
88
|
+
if (!behaviorGate(DELEGATION_PROFILE, caps.supported)) return;
|
|
89
|
+
expect(
|
|
90
|
+
caps.maxChainDepth,
|
|
91
|
+
driver.describe('spec/v1/auth.md §Bounds', 'a host advertising delegation MUST advertise a positive integer `maxChainDepth`'),
|
|
92
|
+
).not.toBeNull();
|
|
93
|
+
const depth = caps.maxChainDepth as number;
|
|
94
|
+
const chain = Array.from({ length: depth + 1 }, (_, i) => hop(i + 1));
|
|
95
|
+
const r = await resolve({ identity: { ...IDENTITY, delegation: { chain, audience: 'openwop-host', expiresAt: LIVE } }, expectedAudience: 'openwop-host' });
|
|
96
|
+
if (r === null) return;
|
|
97
|
+
expectRefusal(r, 'delegation_chain_too_long', `a chain of ${depth + 1} hops exceeds the advertised bound of ${depth} — each hop is another party the host trusts transitively`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('a chain that revisits a subject is refused as cyclic', async () => {
|
|
101
|
+
const caps = await delegationCaps();
|
|
102
|
+
if (!behaviorGate(DELEGATION_PROFILE, caps.supported)) return;
|
|
103
|
+
// Two distinct hops then the first subject again: length 3, so on any
|
|
104
|
+
// host with maxChainDepth >= 3 the ONLY reason to refuse it is the cycle.
|
|
105
|
+
// On a host with maxChainDepth < 3 the too-long leg already covers refusal
|
|
106
|
+
// and this leg accepts either reason, saying so.
|
|
107
|
+
const chain = [hop(1), hop(2), hop(1)];
|
|
108
|
+
const r = await resolve({ identity: { ...IDENTITY, delegation: { chain, audience: 'openwop-host', expiresAt: LIVE } }, expectedAudience: 'openwop-host' });
|
|
109
|
+
if (r === null) return;
|
|
110
|
+
const bound = caps.maxChainDepth ?? Number.POSITIVE_INFINITY;
|
|
111
|
+
if (bound < 3) {
|
|
112
|
+
expect(r.status >= 400, driver.describe('RFCS/0154 §B', 'a cyclic chain is refused')).toBe(true);
|
|
113
|
+
expect(['delegation_chain_cyclic', 'delegation_chain_too_long'], driver.describe('spec/v1/host-sample-test-seams.md §20', 'cyclic or too-long — the bound is below the cycle length')).toContain(readErrorCode(r.json));
|
|
114
|
+
expect(readRetriable(r.json)).toBe(false);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
expectRefusal(r, 'delegation_chain_cyclic', 'a subject appearing twice is a chain that loops back through authority it already spent — unbounded laundering with a bounded length');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('a later hop claiming a scope the previous hop did not hold is refused', async () => {
|
|
121
|
+
const caps = await delegationCaps();
|
|
122
|
+
if (!behaviorGate(DELEGATION_PROFILE, caps.supported)) return;
|
|
123
|
+
const chain = [hop(1, ['runs:read']), hop(2, ['runs:read', 'runs:write'])];
|
|
124
|
+
const r = await resolve({ identity: { ...IDENTITY, delegation: { chain, audience: 'openwop-host', expiresAt: LIVE } }, expectedAudience: 'openwop-host' });
|
|
125
|
+
if (r === null) return;
|
|
126
|
+
expectRefusal(r, 'delegation_scope_amplified', 'the effective scopes at any hop MUST NOT exceed the hop before it — a chain is provenance, and provenance cannot mint `runs:write` from `runs:read`');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('a well-formed bounded, acyclic, non-amplifying chain still resolves (the negatives are not a blanket refusal)', async () => {
|
|
130
|
+
const caps = await delegationCaps();
|
|
131
|
+
if (!behaviorGate(DELEGATION_PROFILE, caps.supported)) return;
|
|
132
|
+
const chain = [hop(1, ['runs:read', 'manifest:read']), hop(2, ['runs:read'])];
|
|
133
|
+
const r = await resolve({ identity: { ...IDENTITY, delegation: { chain, audience: 'openwop-host', expiresAt: LIVE } }, expectedAudience: 'openwop-host' });
|
|
134
|
+
if (r === null) return;
|
|
135
|
+
// A host that refuses EVERY chain passes the three negatives vacuously; this
|
|
136
|
+
// is the positive that keeps them honest. Scopes narrow hop-to-hop, which
|
|
137
|
+
// is the one direction §B permits.
|
|
138
|
+
expect(r.status, driver.describe('RFCS/0154 §B', 'a compliant chain resolves; the bounds refuse laundering, not delegation')).toBe(200);
|
|
139
|
+
expect((r.json as { resolved?: unknown }).resolved).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -151,6 +151,15 @@ describe('RFC 0154 §B — delegated actor chain', () => {
|
|
|
151
151
|
it('a chain hop cannot carry extra fields', () => {
|
|
152
152
|
expect(validate({ ...base, delegation: { chain: [{ subject: 'x', token: 'y' }], audience: 'h' } })).toBe(false);
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
it('a chain hop MAY carry its verified scopes, as a set of non-empty strings', () => {
|
|
156
|
+
// RFC 0154 §B "Bounds": scope amplification is only observable if a hop can
|
|
157
|
+
// state the scopes its proof carried. Provenance, not authorization.
|
|
158
|
+
expect(validate({ ...base, delegation: { chain: [{ subject: 'x', scopes: ['runs:read'] }, { subject: 'y', scopes: ['runs:read'] }], audience: 'h' } })).toBe(true);
|
|
159
|
+
expect(validate({ ...base, delegation: { chain: [{ subject: 'x', scopes: 'runs:read' }], audience: 'h' } })).toBe(false);
|
|
160
|
+
expect(validate({ ...base, delegation: { chain: [{ subject: 'x', scopes: ['runs:read', 'runs:read'] }], audience: 'h' } })).toBe(false);
|
|
161
|
+
expect(validate({ ...base, delegation: { chain: [{ subject: 'x', scopes: [''] }], audience: 'h' } })).toBe(false);
|
|
162
|
+
});
|
|
154
163
|
});
|
|
155
164
|
|
|
156
165
|
describe.skipIf(RFCS_DIR === null)('RFC 0154 — what this does NOT establish', () => {
|