@openwop/openwop-conformance 2.32.0 → 2.33.1
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 +22 -0
- package/README.md +5 -4
- package/coverage.md +2 -1
- package/dist/cli.js +4 -2
- package/dist/lib/certification-bundle-v3.js +12 -2
- package/dist/lib/v2-profiles.js +66 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +58 -14
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +8 -8
- package/src/cli.ts +4 -2
- package/src/lib/a2a-fake-peer.ts +16 -3
- package/src/lib/certification-bundle-v3.ts +11 -2
- package/src/lib/durability-watch.ts +53 -0
- package/src/lib/mcp-fake-server.ts +13 -0
- package/src/lib/v2-profiles.ts +66 -0
- package/src/lib/webhook-receiver.ts +24 -5
- package/src/scenarios/a2a-1-0-agent-card.test.ts +1 -1
- package/src/scenarios/a2a-peer-authority.test.ts +1 -1
- package/src/scenarios/a2a-task-roundtrip.test.ts +1 -1
- package/src/scenarios/a2a-version-negotiation.test.ts +3 -3
- package/src/scenarios/mcp-extension-opacity.test.ts +1 -1
- package/src/scenarios/mcp-mrtr-roundtrip.test.ts +1 -1
- package/src/scenarios/mcp-tool-roundtrip.test.ts +1 -1
- package/src/scenarios/mcp-version-negotiation.test.ts +3 -3
- package/src/scenarios/v2-durability-recovery.test.ts +13 -21
- package/src/scenarios/v2-minimum-version-refused.test.ts +2 -2
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
- package/src/scenarios/v2-negotiation-authenticated.test.ts +2 -2
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +3 -3
- package/src/scenarios/v2-relaxation-recorded.test.ts +37 -2
- package/src/scenarios/v2-webhook-delivery-shape.test.ts +45 -8
- package/src/scenarios/v2-webhook-egress-refusal.test.ts +115 -0
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, type KeyObject } from 'node:crypto';
|
|
21
21
|
import { profileDerivable, type DiscoveryPayload } from './profiles.js';
|
|
22
|
-
import { v2RegistryAvailable } from './v2-profiles.js';
|
|
22
|
+
import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2RegistryAvailable } from './v2-profiles.js';
|
|
23
23
|
|
|
24
24
|
export type BundleV3Result = 'executed-pass' | 'executed-fail' | 'skipped' | 'inapplicable' | 'blocked';
|
|
25
25
|
|
|
@@ -179,9 +179,18 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
|
|
|
179
179
|
}
|
|
180
180
|
// Relaxations: a relaxed obligation's profile cannot certify (RFC 0173 §A.2).
|
|
181
181
|
const relaxed = new Set((bundle.host?.relaxations ?? []).map((r) => r.obligation.split('.')[0]));
|
|
182
|
+
// Ownership comes from the profile registry, not from the profile's name — see profilesRelaxedBy.
|
|
183
|
+
const relaxedProfiles = profilesRelaxedBy((bundle.host?.relaxations ?? []).map((r) => r.obligation), (bundle.claimedProfiles ?? []).map((p) => p.id));
|
|
184
|
+
// An OBSERVED relaxation nobody declared: the bundle's own results show the
|
|
185
|
+
// host accepting a destination its egress guard MUST refuse. Same denial as a
|
|
186
|
+
// declared one, so declaring nothing is not a way around the rule.
|
|
187
|
+
const observed = profilesDeniedByObservedRelaxation(bundle.results?.requirements ?? [], (bundle.claimedProfiles ?? []).map((p) => p.id));
|
|
188
|
+
for (const p of bundle.claimedProfiles ?? []) {
|
|
189
|
+
if (p.certified && observed.profiles.has(p.id) && !relaxedProfiles.has(p.id)) rejections.push({ kind: 'undeclared-relaxation-observed', profile: p.id, detail: `${p.id} is marked certified, but this bundle's own results show the host running relaxed on ${observed.obligations.join(', ')} with no host.relaxations[] entry recording it (security-defaults.md §Relaxations: every relaxation a host runs under MUST be recorded, and its profile MUST NOT certify)` });
|
|
190
|
+
}
|
|
182
191
|
const certifiedProfiles: string[] = [];
|
|
183
192
|
for (const p of bundle.claimedProfiles ?? []) {
|
|
184
|
-
if (p.certified &&
|
|
193
|
+
if (p.certified && relaxedProfiles.has(p.id)) rejections.push({ kind: 'relaxed-profile-certified', profile: p.id, detail: `${p.id} is marked certified while a relaxation on ${[...relaxed].join(', ')} is recorded (RFC 0173 §A.2)` });
|
|
185
194
|
else if (p.certified) certifiedProfiles.push(p.id);
|
|
186
195
|
}
|
|
187
196
|
if (expected.blocked > 0 && certifiedProfiles.length > 0) rejections.push({ kind: 'blocked-certified', detail: `${expected.blocked} blocked row(s): a bundle with blocked > 0 does not certify (RFC 0168 §E.1)` });
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0158 §E item 11 — watching a run across a real process death.
|
|
3
|
+
*
|
|
4
|
+
* Two facts are wanted from one watch: (a) did resumption appear within the
|
|
5
|
+
* budget, and (b) was the run EVER observable as `completed` without having
|
|
6
|
+
* been re-executed. (b) is a statement about two pieces of host state — the
|
|
7
|
+
* status and the log — and they come from two requests, so the ORDER of the
|
|
8
|
+
* reads decides whether a conclusion is sound.
|
|
9
|
+
*
|
|
10
|
+
* Until 2.33.1 each iteration read the LOG, then the STATUS, and latched (b)
|
|
11
|
+
* when the status said `completed` and the (older) log showed no resumption. A
|
|
12
|
+
* host that re-dispatched between the two requests therefore read as "completed
|
|
13
|
+
* un-re-executed" while being neither: the log read predated the re-execution,
|
|
14
|
+
* the status read followed its completion. The staged work is `conformance-noop`,
|
|
15
|
+
* which re-executes in milliseconds, so the window is real — per run roughly
|
|
16
|
+
* (gap between the two requests) / (poll interval), a few percent. Measured on
|
|
17
|
+
* a tier-1 host: after a genuine SIGKILL and a correct recovery by lease expiry
|
|
18
|
+
* (727 s, inside a declared 750 s bound), the row failed with the message
|
|
19
|
+
* "read status completed with 2 run.started" — it printed the re-execution it
|
|
20
|
+
* was denying, because the message used a LATER read than the latch did. It had
|
|
21
|
+
* passed on the two previous runs of the same host and code.
|
|
22
|
+
*
|
|
23
|
+
* The rule: STATUS FIRST, LOG SECOND. A run's log is append-only, so a log read
|
|
24
|
+
* taken AFTER a `completed` status can only show MORE than the status implied.
|
|
25
|
+
* If that later log still shows no resumption, the run really was observable as
|
|
26
|
+
* completed un-re-executed. If it shows resumption, nothing was wrong. If it is
|
|
27
|
+
* unreadable, nothing is concluded — an unreadable log is never evidence.
|
|
28
|
+
*
|
|
29
|
+
* Readers are injected so the ordering itself is testable without a host.
|
|
30
|
+
*/
|
|
31
|
+
export interface Observation { readonly readable: boolean; readonly runStarted: number; readonly nodeStarted: number; readonly restored: number }
|
|
32
|
+
export interface Watch { readonly resumedAfterMs: number | null; readonly last: Observation; readonly completedUnresumed: boolean; readonly waitedMs: number }
|
|
33
|
+
export interface WatchIo {
|
|
34
|
+
readStatus(): Promise<string | null>;
|
|
35
|
+
readLog(): Promise<Observation>;
|
|
36
|
+
now(): number;
|
|
37
|
+
sleep(ms: number): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function watchForResumption(io: WatchIo, budgetMs: number, resumed: (o: Observation) => boolean, pollMs = 500): Promise<Watch> {
|
|
41
|
+
const t0 = io.now();
|
|
42
|
+
let completedUnresumed = false;
|
|
43
|
+
for (;;) {
|
|
44
|
+
const status = await io.readStatus(); // FIRST
|
|
45
|
+
const last = await io.readLog(); // SECOND — never older than the status it is judged against
|
|
46
|
+
const waitedMs = io.now() - t0;
|
|
47
|
+
const isResumed = last.readable && resumed(last);
|
|
48
|
+
if (status === 'completed' && last.readable && !isResumed) completedUnresumed = true;
|
|
49
|
+
if (isResumed) return { resumedAfterMs: waitedMs, last, completedUnresumed, waitedMs };
|
|
50
|
+
if (waitedMs >= budgetMs) return { resumedAfterMs: null, last, completedUnresumed, waitedMs };
|
|
51
|
+
await io.sleep(pollMs);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* @see SECURITY/threat-model-prompt-injection.md §"UNTRUSTED marker"
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
+
import { resolvePublicFront } from './webhook-receiver.js';
|
|
37
38
|
import { createServer, type Server } from 'node:http';
|
|
38
39
|
import type { AddressInfo } from 'node:net';
|
|
39
40
|
|
|
@@ -149,6 +150,18 @@ export class McpFakeServer {
|
|
|
149
150
|
return `http://127.0.0.1:${this._boundPort}`;
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
/**
|
|
154
|
+
* The address to hand THE HOST UNDER TEST — `OPENWOP_MCP_FAKE_SERVER_URL` when the operator fronts
|
|
155
|
+
* this server publicly (https, publicly resolvable; validated loudly, same rule
|
|
156
|
+
* as the webhook receiver), else `endpoint()`. The suite's own requests to its
|
|
157
|
+
* own fake keep using `endpoint()`: they need no tunnel and must not depend on
|
|
158
|
+
* one. Pin the listener with the matching `_PORT` variable so the front has a
|
|
159
|
+
* fixed port to forward to.
|
|
160
|
+
*/
|
|
161
|
+
hostFacingEndpoint(): string {
|
|
162
|
+
return resolvePublicFront('OPENWOP_MCP_FAKE_SERVER_URL', this.endpoint()).url;
|
|
163
|
+
}
|
|
164
|
+
|
|
152
165
|
invocations(): readonly McpInvocation[] {
|
|
153
166
|
return this._invocations;
|
|
154
167
|
}
|
package/src/lib/v2-profiles.ts
CHANGED
|
@@ -110,3 +110,69 @@ export function v2ProfileIds(doc: DiscoveryPayload): readonly string[] | null {
|
|
|
110
110
|
export function v2ProfileDerivable(doc: DiscoveryPayload, profile: string): boolean {
|
|
111
111
|
return v2ProfileIds(doc)?.includes(profile) ?? false;
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Which of `profileIds` a relaxed obligation denies (RFC 0173 §A.2;
|
|
116
|
+
* `security-defaults.md` §Relaxations: "a bundle that records a relaxation MUST
|
|
117
|
+
* NOT certify the profile the relaxed obligation belongs to").
|
|
118
|
+
*
|
|
119
|
+
* An obligation is `<family>.<name>`. A profile OWNS a family when its registry
|
|
120
|
+
* predicate lists it — `openwop-core-standard` is built on `interrupt`, `replay`,
|
|
121
|
+
* `webhooks`, `idempotency`, `eventLog`, so a relaxation on any of those denies
|
|
122
|
+
* it. That ownership was always stated in `spec/v2/profiles.json`; nothing read
|
|
123
|
+
* it. Until 2.33.0 the verifier tested whether the profile ID contained the
|
|
124
|
+
* family name, and the emitter tested whether it contained the WHOLE obligation
|
|
125
|
+
* string — two different rules under a comment saying they matched — and since
|
|
126
|
+
* no real v2 profile id contains a family name, a declared relaxation denied
|
|
127
|
+
* nothing on any real host. The one scenario pinning the rule used a fixture
|
|
128
|
+
* profile called `openwop-webhooks`, which is why it stayed green.
|
|
129
|
+
*
|
|
130
|
+
* The id-substring rule is KEPT as a second arm, not replaced: it is how a
|
|
131
|
+
* non-registry profile id (a v1 alias, a fixture) names its family, and
|
|
132
|
+
* dropping it would un-deny something that is denied today. When the registry
|
|
133
|
+
* is unavailable only that arm can run; the caller already records that layout
|
|
134
|
+
* as `derivabilityChecked: false`.
|
|
135
|
+
*
|
|
136
|
+
* ONE function, called by the emitter and the verifier, so the file and the
|
|
137
|
+
* verdict on the file cannot disagree again.
|
|
138
|
+
*/
|
|
139
|
+
export function profilesRelaxedBy(obligations: readonly string[], profileIds: readonly string[]): ReadonlySet<string> {
|
|
140
|
+
const families = new Set(obligations.map((o) => o.split('.')[0] ?? '').filter((f) => f.length > 0));
|
|
141
|
+
const out = new Set<string>();
|
|
142
|
+
if (families.size === 0) return out;
|
|
143
|
+
const registry = readRegistry();
|
|
144
|
+
const owned = new Map<string, readonly string[]>();
|
|
145
|
+
for (const p of registry ?? []) {
|
|
146
|
+
if (typeof p.id === 'string') owned.set(p.id, Array.isArray(p.predicate?.families) ? (p.predicate.families as unknown[]).map(String) : []);
|
|
147
|
+
}
|
|
148
|
+
for (const id of profileIds) {
|
|
149
|
+
const ownsOne = (owned.get(id) ?? []).some((f) => families.has(f));
|
|
150
|
+
const namesOne = [...families].some((f) => id.includes(f));
|
|
151
|
+
if (ownsOne || namesOne) out.add(id);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The requirement whose FAILURE is an observed, undeclared relaxation.
|
|
158
|
+
*
|
|
159
|
+
* `conformance/README.md` said of `OPENWOP_HOST_RELAXATIONS` that "the suite
|
|
160
|
+
* cannot detect an undeclared one". For the egress guard that was never true:
|
|
161
|
+
* a host that ACCEPTS `https://127.0.0.1/…` as a webhook destination has shown
|
|
162
|
+
* on the wire that its guard is open (`webhooks.md`: "a host MUST reject (400
|
|
163
|
+
* webhook_url_rejected) … RFC 1918 and loopback and link-local ranges").
|
|
164
|
+
* `v2-webhook-egress-refusal` records that as `executed-fail` on this id unless
|
|
165
|
+
* the operator DECLARED the relaxation — in which case {@link profilesRelaxedBy}
|
|
166
|
+
* already denies the profile. Either way a profile built on `webhooks` does not
|
|
167
|
+
* certify on a host whose guard is open; this closes the path where it did,
|
|
168
|
+
* silently, because nobody declared anything.
|
|
169
|
+
*/
|
|
170
|
+
export const UNDECLARED_RELAXATION_WITNESSES: ReadonlyArray<{ readonly requirementId: string; readonly obligation: string }> = [
|
|
171
|
+
{ requirementId: 'openwop.requirement.0171.webhook-egress-refused', obligation: 'webhooks.egress-guard' },
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
/** Profiles that MUST NOT certify because a row above is `executed-fail` in this bundle's own results. */
|
|
175
|
+
export function profilesDeniedByObservedRelaxation(rows: ReadonlyArray<{ readonly id: string; readonly result: string }>, profileIds: readonly string[]): { readonly profiles: ReadonlySet<string>; readonly obligations: readonly string[] } {
|
|
176
|
+
const obligations = UNDECLARED_RELAXATION_WITNESSES.filter((w) => rows.some((r) => r.id === w.requirementId && r.result === 'executed-fail')).map((w) => w.obligation);
|
|
177
|
+
return { profiles: profilesRelaxedBy(obligations, profileIds), obligations };
|
|
178
|
+
}
|
|
@@ -235,7 +235,26 @@ export async function discoverOwnedTenant(
|
|
|
235
235
|
* "the host could not be exercised".
|
|
236
236
|
*/
|
|
237
237
|
export function resolveRegistrationUrl(localUrl: string): { url: string; tunnelled: boolean } {
|
|
238
|
-
|
|
238
|
+
return resolvePublicFront('OPENWOP_WEBHOOK_RECEIVER_URL', localUrl);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The same rule, for ANY suite fixture the host under test must reach — the
|
|
243
|
+
* webhook receiver, the A2A fake peer, the MCP fake server. `envName` names the
|
|
244
|
+
* operator's public front for that one fixture; unset means the local address.
|
|
245
|
+
*
|
|
246
|
+
* Generalised in 2.33.0. Until then only the webhook receiver had a front, so a
|
|
247
|
+
* host that advertised `a2a` or `mcp` could be measured ONLY with its egress
|
|
248
|
+
* guard relaxed — those fakes advertised `http://127.0.0.1:<port>` and nothing
|
|
249
|
+
* else — and a relaxed guard is a relaxation the bundle must declare and cannot
|
|
250
|
+
* certify under (`security-defaults.md` §Relaxations). The two production hosts
|
|
251
|
+
* cut relaxation-free only because they advertise neither family. The rule made
|
|
252
|
+
* a host that implements MORE of the protocol LESS able to certify; found when
|
|
253
|
+
* the steward's own reference host turned out to be certifying under an
|
|
254
|
+
* undeclared one.
|
|
255
|
+
*/
|
|
256
|
+
export function resolvePublicFront(envName: string, localUrl: string): { url: string; tunnelled: boolean } {
|
|
257
|
+
const raw = process.env[envName]?.trim();
|
|
239
258
|
if (!raw) return { url: localUrl, tunnelled: false };
|
|
240
259
|
|
|
241
260
|
let parsed: URL;
|
|
@@ -243,7 +262,7 @@ export function resolveRegistrationUrl(localUrl: string): { url: string; tunnell
|
|
|
243
262
|
parsed = new URL(raw);
|
|
244
263
|
} catch {
|
|
245
264
|
throw new Error(
|
|
246
|
-
|
|
265
|
+
`${envName} is not a valid URL: ${JSON.stringify(raw)}`,
|
|
247
266
|
);
|
|
248
267
|
}
|
|
249
268
|
|
|
@@ -252,7 +271,7 @@ export function resolveRegistrationUrl(localUrl: string): { url: string; tunnell
|
|
|
252
271
|
// ALLOW_PRIVATE flag insufficient in the first place.
|
|
253
272
|
if (parsed.protocol !== 'https:') {
|
|
254
273
|
throw new Error(
|
|
255
|
-
|
|
274
|
+
`${envName} MUST be https: (got ${parsed.protocol}). ` +
|
|
256
275
|
'A plain-http front cannot clear the scheme gate, so it cannot witness this scenario.',
|
|
257
276
|
);
|
|
258
277
|
}
|
|
@@ -272,13 +291,13 @@ export function resolveRegistrationUrl(localUrl: string): { url: string; tunnell
|
|
|
272
291
|
/^(fc|fd)/.test(host);
|
|
273
292
|
if (isLoopback || isPrivate) {
|
|
274
293
|
throw new Error(
|
|
275
|
-
|
|
294
|
+
`${envName} MUST be a publicly-resolvable host (got ${parsed.hostname}). ` +
|
|
276
295
|
'It is the PUBLIC front for the local receiver — a tunnel or TLS-terminating proxy — ' +
|
|
277
296
|
'not the receiver address itself.',
|
|
278
297
|
);
|
|
279
298
|
}
|
|
280
299
|
|
|
281
|
-
return { url: raw, tunnelled: true };
|
|
300
|
+
return { url: envName === 'OPENWOP_WEBHOOK_RECEIVER_URL' ? raw : raw.replace(/\/+$/, ''), tunnelled: true };
|
|
282
301
|
}
|
|
283
302
|
|
|
284
303
|
/**
|
|
@@ -62,7 +62,7 @@ describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', ()
|
|
|
62
62
|
expect(ifaces.map((i) => i.protocolVersion)).toEqual(['1.0', '0.3']);
|
|
63
63
|
for (const i of ifaces) {
|
|
64
64
|
expect(i.protocolBinding).toBe('JSONRPC');
|
|
65
|
-
expect(i.url.startsWith(peer.
|
|
65
|
+
expect(i.url.startsWith(peer.hostFacingEndpoint())).toBe(true);
|
|
66
66
|
}
|
|
67
67
|
expect(card['url'], req('openwop.it.a2a-1-0-agent-card.the-agent-card-is-1-0-shaped-supportedinterfaces-no-top-level-url-protocolversio', 'RFC 0152 §A/§B/§C/§D', '1.0 removed top-level url')).toBeUndefined();
|
|
68
68
|
expect(card['protocolVersion'], req('openwop.it.a2a-1-0-agent-card.the-agent-card-is-1-0-shaped-supportedinterfaces-no-top-level-url-protocolversio', 'RFC 0152 §A/§B/§C/§D', '1.0 removed top-level protocolVersion (it is per interface)')).toBeUndefined();
|
|
@@ -64,7 +64,7 @@ describe('RFC 0152 §E — a2a-peer-no-authority-escalation (host as A2A client,
|
|
|
64
64
|
peer.reset();
|
|
65
65
|
peer.setNextPeerAssertsAuthority(true);
|
|
66
66
|
const drive = await driver.post('/v1/host/sample/a2a/invoke', {
|
|
67
|
-
peerUrl: peer.
|
|
67
|
+
peerUrl: peer.hostFacingEndpoint(),
|
|
68
68
|
scenario: 'peer-asserts-authority',
|
|
69
69
|
});
|
|
70
70
|
if (drive.status === 404 || drive.status === 403) {
|
|
@@ -66,7 +66,7 @@ function probePeer(): { url: string; isReal: boolean } | null {
|
|
|
66
66
|
const real = process.env.OPENWOP_A2A_REAL_PEER_URL;
|
|
67
67
|
if (real && real.length > 0) return { url: real.replace(/\/$/, ''), isReal: true };
|
|
68
68
|
const fake = getA2AFakePeer();
|
|
69
|
-
if (fake) return { url: fake.
|
|
69
|
+
if (fake) return { url: fake.hostFacingEndpoint(), isReal: false };
|
|
70
70
|
return null;
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -78,7 +78,7 @@ describe('RFC 0152 §B — A2A version negotiation', () => {
|
|
|
78
78
|
const peer = getA2AFakePeer();
|
|
79
79
|
if (peer === null) return softSkip('blocked', 'precondition not met — `peer === null` returned early (no fake peer wired in this run) (seam, prior step, or fixture unavailable)'); // no fake peer wired in this run
|
|
80
80
|
peer.reset();
|
|
81
|
-
const drive = await driver.post('/v1/host/sample/a2a/invoke', { peerUrl: peer.
|
|
81
|
+
const drive = await driver.post('/v1/host/sample/a2a/invoke', { peerUrl: peer.hostFacingEndpoint() });
|
|
82
82
|
if (drive.status === 404 || drive.status === 403) {
|
|
83
83
|
// Seam absent. RFC 0148 §A: unobservable resolves to `blocked`, not a pass.
|
|
84
84
|
expect(
|
|
@@ -114,7 +114,7 @@ describe('RFC 0152 §B — A2A version negotiation', () => {
|
|
|
114
114
|
const preferred = caps?.preferredVersion;
|
|
115
115
|
peer.reset();
|
|
116
116
|
const drive = await driver.post('/v1/host/sample/a2a/invoke', {
|
|
117
|
-
peerUrl: peer.
|
|
117
|
+
peerUrl: peer.hostFacingEndpoint(),
|
|
118
118
|
authenticated: true,
|
|
119
119
|
peerOffersOnly: '0.3',
|
|
120
120
|
});
|
|
@@ -151,7 +151,7 @@ describe('RFC 0152 §B — A2A version negotiation', () => {
|
|
|
151
151
|
const peer = getA2AFakePeer();
|
|
152
152
|
if (peer === null) return softSkip('blocked', 'precondition not met — `peer === null` returned early (seam, prior step, or fixture unavailable)');
|
|
153
153
|
const drive = await driver.post('/v1/host/sample/a2a/invoke', {
|
|
154
|
-
peerUrl: peer.
|
|
154
|
+
peerUrl: peer.hostFacingEndpoint(),
|
|
155
155
|
requestVersion: '99.0',
|
|
156
156
|
});
|
|
157
157
|
if (drive.status === 404 || drive.status === 403) return softSkip('blocked', 'precondition not met — `drive.status === 404 || drive.status === 403` returned early (seam, prior step, or fixture unavailable)');
|
|
@@ -47,7 +47,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §D — mcp-extension-o
|
|
|
47
47
|
if (server === null) return softSkip('blocked', 'the suite MCP fake server is not started in this run — the host-as-client leg cannot be driven');
|
|
48
48
|
server.reset();
|
|
49
49
|
server.setNextResultAssertsAuthority(true);
|
|
50
|
-
const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.
|
|
50
|
+
const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.hostFacingEndpoint(), tool: 'echo', arguments: { text: 'opaque' }, scenario: 'extension-asserts-authority' });
|
|
51
51
|
if (drive.status === 404 || drive.status === 403) {
|
|
52
52
|
// Advertised mcp-2026-07-28 but the invoke seam answered {drive.status}: not observable here.
|
|
53
53
|
// Default mode records `blocked` (RFC 0148 §A); OPENWOP_REQUIRE_BEHAVIOR=true fails
|
|
@@ -58,7 +58,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §C — mcp-mrtr-roundt
|
|
|
58
58
|
if (server === null) return softSkip('blocked', 'the suite MCP fake server is not started in this run — the client half cannot be driven');
|
|
59
59
|
server.reset();
|
|
60
60
|
const drive = await driver.post('/v1/host/sample/mcp/invoke', {
|
|
61
|
-
serverUrl: server.
|
|
61
|
+
serverUrl: server.hostFacingEndpoint(),
|
|
62
62
|
tool: 'needs_input',
|
|
63
63
|
clientCapabilities: { elicitation: {} },
|
|
64
64
|
elicitationAnswer: { name: 'Ada' },
|
|
@@ -179,7 +179,7 @@ function probeEndpoint(): { url: string; isReal: boolean } | null {
|
|
|
179
179
|
const real = process.env.OPENWOP_MCP_REAL_SERVER_URL;
|
|
180
180
|
if (real && real.length > 0) return { url: real.replace(/\/$/, ''), isReal: true };
|
|
181
181
|
const fake = getMcpFakeServer();
|
|
182
|
-
if (fake) return { url: fake.
|
|
182
|
+
if (fake) return { url: fake.hostFacingEndpoint(), isReal: false };
|
|
183
183
|
return null;
|
|
184
184
|
}
|
|
185
185
|
|
|
@@ -86,7 +86,7 @@ describe('RFC 0153 §A/§B — MCP revision negotiation', () => {
|
|
|
86
86
|
const server = getMcpFakeServer();
|
|
87
87
|
if (server === null) return softSkip('blocked', 'precondition not met — `server === null` returned early (seam, prior step, or fixture unavailable)');
|
|
88
88
|
server.reset();
|
|
89
|
-
const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.
|
|
89
|
+
const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.hostFacingEndpoint() });
|
|
90
90
|
if (drive.status === 404 || drive.status === 403) {
|
|
91
91
|
expect(
|
|
92
92
|
drive.status,
|
|
@@ -125,7 +125,7 @@ describe('RFC 0153 §A/§B — MCP revision negotiation', () => {
|
|
|
125
125
|
if (server === null) return softSkip('blocked', 'precondition not met — `server === null` returned early (seam, prior step, or fixture unavailable)');
|
|
126
126
|
const caps = await mcp();
|
|
127
127
|
server.reset();
|
|
128
|
-
const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.
|
|
128
|
+
const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.hostFacingEndpoint() });
|
|
129
129
|
if (drive.status === 404 || drive.status === 403) return seamAbsent(`host advertises mcp version negotiation but the invoke seam /v1/host/sample/mcp/invoke answered ${drive.status} — the host-as-client legs are unobservable (host-sample-test-seams.md)`);
|
|
130
130
|
// it never advertised has made its own discovery document unreliable, which
|
|
131
131
|
// is worse than advertising nothing — a consumer that read it made a
|
|
@@ -148,7 +148,7 @@ describe('RFC 0153 §A/§B — MCP revision negotiation', () => {
|
|
|
148
148
|
const server = getMcpFakeServer();
|
|
149
149
|
if (server === null) return softSkip('blocked', 'precondition not met — `server === null` returned early (seam, prior step, or fixture unavailable)');
|
|
150
150
|
const drive = await driver.post('/v1/host/sample/mcp/invoke', {
|
|
151
|
-
serverUrl: server.
|
|
151
|
+
serverUrl: server.hostFacingEndpoint(),
|
|
152
152
|
requestVersion: '1999-01-01',
|
|
153
153
|
});
|
|
154
154
|
if (drive.status === 404 || drive.status === 403) return seamAbsent(`host advertises mcp version negotiation but the invoke seam /v1/host/sample/mcp/invoke answered ${drive.status} — the host-as-client legs are unobservable (host-sample-test-seams.md)`);
|
|
@@ -73,6 +73,7 @@ import { softSkip } from '../lib/soft-skip.js';
|
|
|
73
73
|
import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
|
|
74
74
|
import { req } from '../lib/requirement-ids.js';
|
|
75
75
|
import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
|
|
76
|
+
import { watchForResumption, type Observation, type Watch } from '../lib/durability-watch.js';
|
|
76
77
|
|
|
77
78
|
const FIXTURE = 'conformance-noop';
|
|
78
79
|
const FAILURE_FIXTURE = 'conformance-failure';
|
|
@@ -156,7 +157,6 @@ async function waitBack(deadlineMs: number): Promise<number | null> {
|
|
|
156
157
|
* minted for it is not failed for declining to re-emit `run.started`.
|
|
157
158
|
*/
|
|
158
159
|
const RESTORED_TYPES = new Set(['workflow.restored', 'run.restored-from-snapshot']);
|
|
159
|
-
interface Observation { readable: boolean; runStarted: number; nodeStarted: number; restored: number }
|
|
160
160
|
async function observe(runId: string): Promise<Observation> {
|
|
161
161
|
const events = await runEvents(runId);
|
|
162
162
|
if (events === null) return { readable: false, runStarted: 0, nodeStarted: 0, restored: 0 };
|
|
@@ -214,26 +214,18 @@ async function declaredBoundMs(fired: unknown): Promise<{ ms: number; declared:
|
|
|
214
214
|
: { ms: UNDECLARED_BOUND_FALLBACK_MS, declared: false };
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
interface Watch { resumedAfterMs: number | null; last: Observation; completedUnresumed: boolean; waitedMs: number }
|
|
218
217
|
/**
|
|
219
|
-
* Observe `runId`
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* is a state a later observation can paper over, so it is checked at every read.
|
|
218
|
+
* Observe `runId` until `resumed` holds or `budgetMs` elapses. The loop itself —
|
|
219
|
+
* and the reason the STATUS is read before the LOG — lives in
|
|
220
|
+
* `lib/durability-watch.ts`, where the ordering is unit-tested without a host.
|
|
223
221
|
*/
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (last.readable && resumed(last)) return { resumedAfterMs: waitedMs, last, completedUnresumed, waitedMs };
|
|
232
|
-
const snap = await driver.get(`/runs/${encodeURIComponent(runId)}`);
|
|
233
|
-
if ((snap.json as { status?: unknown } | null)?.status === 'completed' && !(last.readable && resumed(last))) completedUnresumed = true;
|
|
234
|
-
if (waitedMs >= budgetMs) return { resumedAfterMs: null, last, completedUnresumed, waitedMs };
|
|
235
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
236
|
-
}
|
|
222
|
+
function watch(runId: string, budgetMs: number, resumed: (o: Observation) => boolean): Promise<Watch> {
|
|
223
|
+
return watchForResumption({
|
|
224
|
+
readStatus: async () => { const snap = await driver.get(`/runs/${encodeURIComponent(runId)}`); const st = (snap.json as { status?: unknown } | null)?.status; return typeof st === 'string' ? st : null; },
|
|
225
|
+
readLog: () => observe(runId),
|
|
226
|
+
now: () => Date.now(),
|
|
227
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
228
|
+
}, budgetMs, resumed);
|
|
237
229
|
}
|
|
238
230
|
|
|
239
231
|
/**
|
|
@@ -293,7 +285,7 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
293
285
|
// host's OWN declared bound elapses, never once at the instant of return.
|
|
294
286
|
const bound = await declaredBoundMs(fired.json);
|
|
295
287
|
const budget = Math.min(bound.ms, OBSERVATION_CEILING_MS);
|
|
296
|
-
const w = await
|
|
288
|
+
const w = await watch(runId, budget, (o) => o.runStarted >= 1 || o.nodeStarted >= 1 || o.restored >= 1);
|
|
297
289
|
if (!w.last.readable) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer after the restart — resumption is unobservable, and an unreadable log must not read as "nothing resumed"');
|
|
298
290
|
if (w.resumedAfterMs === null && bound.ms > OBSERVATION_CEILING_MS) {
|
|
299
291
|
return softSkip('blocked', `no dispatch observed in ${w.waitedMs}ms, but the host declares a ${bound.ms}ms recovery bound and this run observes for at most ${OBSERVATION_CEILING_MS}ms — a bound longer than the observation ceiling is conformant (§B.6) and is neither witnessed nor refuted here; the operator precondition for this row is OPENWOP_DURABILITY_OBSERVATION_CEILING_MS >= the declared bound`);
|
|
@@ -336,7 +328,7 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
|
|
|
336
328
|
// further one, or the registry's own recovery event.
|
|
337
329
|
const bound = await declaredBoundMs(fired.json);
|
|
338
330
|
const budget = Math.min(bound.ms, OBSERVATION_CEILING_MS);
|
|
339
|
-
const w = await
|
|
331
|
+
const w = await watch(runId, budget, (o) => o.runStarted > 1 || o.restored >= 1);
|
|
340
332
|
if (!w.last.readable) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer after the restart — resumption is unobservable, and an unreadable log must not read as "nothing resumed"');
|
|
341
333
|
expect(
|
|
342
334
|
w.completedUnresumed,
|
|
@@ -96,7 +96,7 @@ describe('RFC 0175 §D.2 — minimum-version-refused (gated on a2a/mcp + seams)'
|
|
|
96
96
|
const peer = new A2AFakePeer({ protocolVersions: ['0.3'] });
|
|
97
97
|
await peer.start();
|
|
98
98
|
try {
|
|
99
|
-
const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.
|
|
99
|
+
const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.hostFacingEndpoint(), authenticated: true, peerOffersOnly: A2A_LOWEST });
|
|
100
100
|
if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises a2a but ${SEAMS_PREFIX}/sample/a2a/invoke answered ${res.status} (host-sample-test-seams.md §22)`);
|
|
101
101
|
await assertRefused('openwop.requirement.0175.minimum-version-refused', 'a2a', floor, res);
|
|
102
102
|
// The wire leg: the host MUST NOT have spoken the below-floor version to the peer.
|
|
@@ -124,7 +124,7 @@ describe('RFC 0175 §D.2 — minimum-version-refused (gated on a2a/mcp + seams)'
|
|
|
124
124
|
const server = new McpFakeServer({ protocolVersions: ['2025-06-18'] });
|
|
125
125
|
await server.start();
|
|
126
126
|
try {
|
|
127
|
-
const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.
|
|
127
|
+
const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.hostFacingEndpoint(), requestVersion: MCP_LOWEST });
|
|
128
128
|
if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises mcp but ${SEAMS_PREFIX}/sample/mcp/invoke answered ${res.status} (host-sample-test-seams.md §23)`);
|
|
129
129
|
await assertRefused('openwop.requirement.0175.minimum-version-refused.mcp', 'mcp', floor, res);
|
|
130
130
|
for (const c of server.invocations()) {
|
|
@@ -73,7 +73,7 @@ describe('RFC 0175 §E.1 — mrtr-rounds-ceiling (gated on mcp + mrtr)', () => {
|
|
|
73
73
|
// input_required for `arguments.rounds` retries before completing.
|
|
74
74
|
const rounds = (maxRounds as number) + 1;
|
|
75
75
|
const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, {
|
|
76
|
-
serverUrl: server.
|
|
76
|
+
serverUrl: server.hostFacingEndpoint(),
|
|
77
77
|
tool: LOOP_TOOL,
|
|
78
78
|
arguments: { rounds },
|
|
79
79
|
clientCapabilities: { elicitation: {} },
|
|
@@ -99,7 +99,7 @@ describe('RFC 0175 §D.1 — negotiation-authenticated (gated on a2a/mcp + seams
|
|
|
99
99
|
const peer = new A2AFakePeer({ protocolVersions: ['0.3'] });
|
|
100
100
|
await peer.start();
|
|
101
101
|
try {
|
|
102
|
-
const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.
|
|
102
|
+
const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.hostFacingEndpoint(), authenticated: false, peerOffersOnly: A2A_LOWER });
|
|
103
103
|
if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises a2a but ${SEAMS_PREFIX}/sample/a2a/invoke answered ${res.status} (host-sample-test-seams.md §22)`);
|
|
104
104
|
const wire = peer.invocations().filter((i) => i.method !== 'GET').map((i) => String(i.headers['a2a-version'] ?? ''));
|
|
105
105
|
await assertNotLowered('openwop.requirement.0175.negotiation-authenticated', preferred, A2A_LOWER, res, wire);
|
|
@@ -119,7 +119,7 @@ describe('RFC 0175 §D.1 — negotiation-authenticated (gated on a2a/mcp + seams
|
|
|
119
119
|
const server = new McpFakeServer({ protocolVersions: ['2025-06-18'] });
|
|
120
120
|
await server.start();
|
|
121
121
|
try {
|
|
122
|
-
const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.
|
|
122
|
+
const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.hostFacingEndpoint(), authenticated: false });
|
|
123
123
|
if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises mcp but ${SEAMS_PREFIX}/sample/mcp/invoke answered ${res.status} (host-sample-test-seams.md §23)`);
|
|
124
124
|
const wire = server.invocations().map((i) => String(i.headers['mcp-protocol-version'] ?? ''));
|
|
125
125
|
await assertNotLowered('openwop.requirement.0175.negotiation-authenticated.mcp', preferred, MCP_LOWER, res, wire);
|
|
@@ -95,7 +95,7 @@ async function leg(protocol: 'a2a' | 'mcp', id: string): Promise<void> {
|
|
|
95
95
|
const validate = payloadValidator();
|
|
96
96
|
if (!validate) return softSkip('blocked', 'run-event-payloads.schema.json#/$defs/negotiationDecided not readable from SCHEMAS_DIR');
|
|
97
97
|
|
|
98
|
-
const driven = await drive(protocol, peer.
|
|
98
|
+
const driven = await drive(protocol, peer.hostFacingEndpoint());
|
|
99
99
|
if (!driven) return softSkip('blocked', 'invoke seam unavailable (reason recorded above)');
|
|
100
100
|
const runId = driven.body['runId'];
|
|
101
101
|
if (typeof runId !== 'string') {
|
|
@@ -117,11 +117,11 @@ async function leg(protocol: 'a2a' | 'mcp', id: string): Promise<void> {
|
|
|
117
117
|
expect(
|
|
118
118
|
p['peerDigest'],
|
|
119
119
|
req(id, 'interop.md §The audit event', 'peerDigest MUST be the SHA-256 of the peer origin — never the origin in clear (RFC 0175 §D.3)'),
|
|
120
|
-
).toBe(originDigest(peer.
|
|
120
|
+
).toBe(originDigest(peer.hostFacingEndpoint()));
|
|
121
121
|
}
|
|
122
122
|
for (const v of Object.values(p)) {
|
|
123
123
|
expect(
|
|
124
|
-
typeof v === 'string' && v.includes(new URL(peer.
|
|
124
|
+
typeof v === 'string' && v.includes(new URL(peer.hostFacingEndpoint()).host),
|
|
125
125
|
req(id, 'interop.md §The audit event', 'the event is content-free: the peer origin MUST NOT appear in clear in any field'),
|
|
126
126
|
).toBe(false);
|
|
127
127
|
}
|
|
@@ -28,6 +28,8 @@ import { generateKeyPairSync } from 'node:crypto';
|
|
|
28
28
|
import { signBundleV3, verifyBundleV3, witnessDigest, type BundleV3, type BundleV3Relaxation } from '../lib/certification-bundle-v3.js';
|
|
29
29
|
import { v2Validator } from '../lib/v2.js';
|
|
30
30
|
import { req } from '../lib/requirement-ids.js';
|
|
31
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
32
|
+
import { v2RegistryAvailable } from '../lib/v2-profiles.js';
|
|
31
33
|
|
|
32
34
|
const host = generateKeyPairSync('ed25519');
|
|
33
35
|
const hostPem = host.privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
|
|
@@ -38,7 +40,7 @@ const ROWS: BundleV3['results']['requirements'] = [
|
|
|
38
40
|
];
|
|
39
41
|
|
|
40
42
|
/** A schema-valid, verifier-clean bundle v3; `relaxations` and the claimed profile vary per leg. */
|
|
41
|
-
function bundle(opts: { relaxations?: BundleV3Relaxation[]; certified: boolean }): BundleV3 {
|
|
43
|
+
function bundle(opts: { relaxations?: BundleV3Relaxation[]; certified: boolean; profileId?: string }): BundleV3 {
|
|
42
44
|
const unsigned: Omit<BundleV3, 'signature'> = {
|
|
43
45
|
bundleVersion: '3',
|
|
44
46
|
generatedAt: '2026-09-03T00:00:00Z',
|
|
@@ -52,7 +54,7 @@ function bundle(opts: { relaxations?: BundleV3Relaxation[]; certified: boolean }
|
|
|
52
54
|
},
|
|
53
55
|
discovery: { url: 'https://fixture.invalid/.well-known/openwop', sha256: 'a'.repeat(64), protocolVersions: ['2.0'], preferredVersion: '2.0' },
|
|
54
56
|
claimedProfiles: [
|
|
55
|
-
{ id: 'openwop-webhooks', evidenceTier: 'self', witnessCount: 1, certified: opts.certified },
|
|
57
|
+
{ id: opts.profileId ?? 'openwop-webhooks', evidenceTier: 'self', witnessCount: 1, certified: opts.certified },
|
|
56
58
|
{ id: 'openwop-discovery-core', evidenceTier: 'self', witnessCount: 1, certified: true },
|
|
57
59
|
],
|
|
58
60
|
results: { totals: { executedPass: ROWS.length, executedFail: 0, skipped: 0, inapplicable: 0, blocked: 0 }, requirements: ROWS },
|
|
@@ -105,6 +107,39 @@ describe('RFC 0173 §A.2 — relaxation-recorded (unaided, fixture bundle)', ()
|
|
|
105
107
|
).not.toContain('relaxed-profile-certified');
|
|
106
108
|
});
|
|
107
109
|
|
|
110
|
+
// Until 2.33.0 every leg above used the FIXTURE profile `openwop-webhooks`, and
|
|
111
|
+
// the verifier matched a relaxation to a profile by testing whether the profile
|
|
112
|
+
// ID contained the family name. No real v2 profile id contains one —
|
|
113
|
+
// `openwop-discovery-core`, `openwop-core-standard`,
|
|
114
|
+
// `openwop-conformance-seams-v2` — so on every real host a DECLARED relaxation
|
|
115
|
+
// denied nothing, and this file stayed green because its fixture was the one
|
|
116
|
+
// id the rule could match. Ownership is what `spec/v2/profiles.json` states:
|
|
117
|
+
// `openwop-core-standard`'s predicate is built on `webhooks`.
|
|
118
|
+
it('a relaxation denies the REAL profile that owns its family, not only one named after it', () => {
|
|
119
|
+
if (!v2RegistryAvailable()) return softSkip('inapplicable', 'spec/v2/profiles.json is absent from this layout — profile ownership cannot be read, so only the id-named arm of the rule can run');
|
|
120
|
+
const EGRESS: BundleV3Relaxation = { obligation: 'webhooks.egress-guard', durability: 'session', reason: 'conformance cut against loopback fixtures' };
|
|
121
|
+
const control = verifyBundleV3(bundle({ certified: true, profileId: 'openwop-core-standard' }));
|
|
122
|
+
expect(
|
|
123
|
+
control.certifiedProfiles,
|
|
124
|
+
req('openwop.requirement.0173.relaxation-recorded.owned-by-registry', 'security-defaults.md §Relaxations', 'control: with no relaxation recorded, the real profile certifies — so the rejection below is the relaxation\'s'),
|
|
125
|
+
).toContain('openwop-core-standard');
|
|
126
|
+
const relaxed = verifyBundleV3(bundle({ relaxations: [EGRESS], certified: true, profileId: 'openwop-core-standard' }));
|
|
127
|
+
expect(
|
|
128
|
+
relaxed.rejections.find((r) => r.kind === 'relaxed-profile-certified')?.profile,
|
|
129
|
+
req('openwop.requirement.0173.relaxation-recorded.owned-by-registry', 'security-defaults.md §Relaxations', 'a relaxation on `webhooks.*` MUST deny `openwop-core-standard`, whose registry predicate is built on the `webhooks` family — ownership is read from spec/v2/profiles.json, never from the spelling of the profile id'),
|
|
130
|
+
).toBe('openwop-core-standard');
|
|
131
|
+
expect(
|
|
132
|
+
relaxed.certifiedProfiles,
|
|
133
|
+
req('openwop.requirement.0173.relaxation-recorded.owned-by-registry', 'security-defaults.md §Relaxations', 'the owning profile is not certified, and a profile that does not own the family still is'),
|
|
134
|
+
).toEqual(['openwop-discovery-core']);
|
|
135
|
+
// A family no claimed profile owns denies nothing — the relaxation is still recorded, which is the obligation.
|
|
136
|
+
const unowned = verifyBundleV3(bundle({ relaxations: [{ ...EGRESS, obligation: 'mcp.egress-guard' }], certified: true, profileId: 'openwop-core-standard' }));
|
|
137
|
+
expect(
|
|
138
|
+
unowned.certifiedProfiles,
|
|
139
|
+
req('openwop.requirement.0173.relaxation-recorded.owned-by-registry', 'security-defaults.md §Relaxations', 'a relaxation on a family outside every claimed profile\'s predicate is scoped to nothing claimed — it does not poison an unrelated profile'),
|
|
140
|
+
).toContain('openwop-core-standard');
|
|
141
|
+
});
|
|
142
|
+
|
|
108
143
|
it('durability is a closed set: session | deployment | persisted', () => {
|
|
109
144
|
const validate = v2Validator('certification-bundle');
|
|
110
145
|
const withDurability = (durability: string): unknown => {
|