@openwop/openwop-conformance 2.31.1 → 2.33.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 (34) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +5 -4
  3. package/coverage.md +2 -1
  4. package/dist/cli.js +7 -4
  5. package/dist/lib/certification-bundle-v3.js +12 -2
  6. package/dist/lib/child-env.js +29 -0
  7. package/dist/lib/v2-profiles.js +66 -0
  8. package/dist/spec-artifacts.lock.json +2 -2
  9. package/package.json +2 -2
  10. package/requirements.json +130 -12
  11. package/scenario-majors.json +5 -2
  12. package/schemas/CORPUS-STAMP.json +17 -17
  13. package/src/cli.ts +7 -4
  14. package/src/lib/a2a-fake-peer.ts +16 -3
  15. package/src/lib/certification-bundle-v3.ts +11 -2
  16. package/src/lib/child-env.ts +29 -0
  17. package/src/lib/mcp-fake-server.ts +13 -0
  18. package/src/lib/v2-profiles.ts +66 -0
  19. package/src/lib/webhook-receiver.ts +24 -5
  20. package/src/scenarios/a2a-1-0-agent-card.test.ts +1 -1
  21. package/src/scenarios/a2a-peer-authority.test.ts +1 -1
  22. package/src/scenarios/a2a-task-roundtrip.test.ts +1 -1
  23. package/src/scenarios/a2a-version-negotiation.test.ts +3 -3
  24. package/src/scenarios/mcp-extension-opacity.test.ts +1 -1
  25. package/src/scenarios/mcp-mrtr-roundtrip.test.ts +1 -1
  26. package/src/scenarios/mcp-tool-roundtrip.test.ts +1 -1
  27. package/src/scenarios/mcp-version-negotiation.test.ts +3 -3
  28. package/src/scenarios/v2-durability-recovery.test.ts +245 -55
  29. package/src/scenarios/v2-minimum-version-refused.test.ts +2 -2
  30. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
  31. package/src/scenarios/v2-negotiation-authenticated.test.ts +2 -2
  32. package/src/scenarios/v2-negotiation-decided-emitted.test.ts +3 -3
  33. package/src/scenarios/v2-relaxation-recorded.test.ts +37 -2
  34. package/src/scenarios/v2-webhook-egress-refusal.test.ts +115 -0
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
3
3
  "package": "@openwop/spec-artifacts",
4
- "version": "2.31.1",
5
- "corpusTag": "v2.31.0",
4
+ "version": "2.33.0",
5
+ "corpusTag": "v2.33.0",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
8
8
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "dfecf104a518efca7b9b915578c4198c6a9e1f8965d8e84fc300bbbafc5503e7",
13
- "api/v2/asyncapi.yaml": "577bd2252f10d8c3dceaac0dc7855925cc466e06fd6bf1613b8e451869fcdc54",
14
- "api/v2/openapi.yaml": "82b884b23460438059a5b38b47b5f3e883d03365013ed8c2632b664417c0f699",
12
+ "api/seams-v2.yaml": "975b6cd87fa679857df9fcdc60b6a2bed98510b831e82a5eff5a114e3b263f6e",
13
+ "api/v2/asyncapi.yaml": "96ba13176119bbf54a94bd41d789ebaa919f67c81d529bdc3c66ffb3f419ee36",
14
+ "api/v2/openapi.yaml": "428ab881d0a8d08d388dd89d4b4457f3454cd0433b665674180d3e85821b978d",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
@@ -112,19 +112,19 @@
112
112
  "schemas/v2/ai-envelope.schema.json": "0dc7dfbf032a37239ffd81fc2d67f999a40782ff5074866a56e414164cdee809",
113
113
  "schemas/v2/annotation-create.schema.json": "ec32c22a243b343eeb6956a23be2d76ba5b50c8018ec3ec9d6efa5a1d9b0c792",
114
114
  "schemas/v2/annotation.schema.json": "eb4813cdaa8e325412cfcaeeee5c8a28f3751c946dec64e546c7ba0ad3aad8d4",
115
- "schemas/v2/artifact-type-pack-manifest.schema.json": "85630d1b760d0b41cf5a6fad2acfada39abaf7f0befd95cc4334e1e883e9615d",
115
+ "schemas/v2/artifact-type-pack-manifest.schema.json": "f77d8dcc3112afec8b2a8e67e56752d5811deb2c2455b0d6d4c6489eb4c4a829",
116
116
  "schemas/v2/audit-verify-result.schema.json": "a748dfadb5155adfde8a91045e76b0db6695005fe559035d71670fdd55edb3ba",
117
117
  "schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
118
118
  "schemas/v2/capabilities.schema.json": "e62d44f23c21c8f64b273fcdb083636e801c0de976cbd1f93333f99928dd1025",
119
119
  "schemas/v2/certification-bundle.schema.json": "ab9204e37f9e0bba79857275cd38be2da98e54d26b4ecb97469829efe6ebea28",
120
120
  "schemas/v2/channel-presence-payload.schema.json": "1c20ad810cb311d47aa0b95d928490826b2468fd161f884cd29e867053e6782f",
121
121
  "schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
122
- "schemas/v2/chat-card-pack-manifest.schema.json": "8df0d9d273ae22efc6876d5586833f1d4c2e551edc3ac6cf6e8f0719329f03dd",
122
+ "schemas/v2/chat-card-pack-manifest.schema.json": "f7cf09b30d3e1d2251620d84ea9f65541438d015ab44aec10d1dcfe68d8bd496",
123
123
  "schemas/v2/compact-tool-descriptor.schema.json": "b5c418d72082d5b713bf318212a83397e5be98e56d25ca056302b41b8dd8273b",
124
124
  "schemas/v2/compensation-policy.schema.json": "b3c1142637f5bf980f2b0bb67adb47e8cb7abb99a0405a4b6816ccf5d9c089d6",
125
125
  "schemas/v2/compensation-projection.schema.json": "f46977c7076347b5473faa8c4bbe73d8321c33b33c98fff2f26ee7cbada4ed98",
126
126
  "schemas/v2/configurable.schema.json": "33a6884047e2490f723733bf8b74d3b9164e00374cfea6dc3eac5a70dc368861",
127
- "schemas/v2/connection-pack-manifest.schema.json": "7410a3011b28829b286e3ff764bdb283854dca69ea4b71a8441f8c1c4f1cc948",
127
+ "schemas/v2/connection-pack-manifest.schema.json": "4300e815cf376959f8250ed25ba0545ae8bd525e449c6442326934f451070cc9",
128
128
  "schemas/v2/conversation-event.schema.json": "e87b7fb36ab4ca0afe9f77993654065b28b67e5f24b20175907f90e238942025",
129
129
  "schemas/v2/conversation-turn.schema.json": "a15b43dc15dbcc6aaf216bfab7424c540b514d7e40e661a732384aad45387006",
130
130
  "schemas/v2/credential-provenance.schema.json": "f86b09e94b1c524dec7d1c6353965665ae8891e9517faef6e742d639c5578c99",
@@ -144,7 +144,7 @@
144
144
  "schemas/v2/error-envelope.schema.json": "bb3e6c487dafa951a56ff224a7c029c462f0f1b754a7f51f6729609b4f43a438",
145
145
  "schemas/v2/eval-summary.schema.json": "9d31025752d0f67ad3b8a32e82a5314972c530ef9f780dd82e484ae8c5131198",
146
146
  "schemas/v2/export-bundle.schema.json": "bd28905a7729437ab7f2ad8bb33e13a371b2bf5605a7eb2962a0d246de312d69",
147
- "schemas/v2/form-content-pack-manifest.schema.json": "461529e4287ec3b15a9fbcde57226a1c6f17ea3ae71b33b418e6f595ff0619fe",
147
+ "schemas/v2/form-content-pack-manifest.schema.json": "8ec157cf51241d4df800398e811d2c35addd076b929c58be3ce3dcd671c67641",
148
148
  "schemas/v2/frontend-plugin-manifest.schema.json": "fed6b8ddb3056c53b1bfc91525410a4445a3c37a8bf9eb9f6f9964ffea26d993",
149
149
  "schemas/v2/goal.schema.json": "146c4182e36d3538778f947e668a92cdb69ec42ee0820104b00063b035e342d7",
150
150
  "schemas/v2/heartbeat-evaluated.schema.json": "4086b1ba1d0547c08b038dfb263ac89416a3e406430db1250156fb2f9b74f8d6",
@@ -156,12 +156,12 @@
156
156
  "schemas/v2/localized-content-section.schema.json": "29c7bad0ad9ebc6c45211282e8afb3fdb35a4ca075ce0404b6e2c29f35fa5313",
157
157
  "schemas/v2/memory-entry.schema.json": "290576ae84f4fef4cdf975932b87963283b11c50ea3d2654b32cd96aa6537c3d",
158
158
  "schemas/v2/memory-list-options.schema.json": "f66703bccb68efb28391c273abb66aa117ee5b5ce475c477846ed700c336afcd",
159
- "schemas/v2/node-pack-manifest.schema.json": "0da826ca63a29026f7a9551748048104910e0d9962db6bad2b50b97a24a9ea30",
159
+ "schemas/v2/node-pack-manifest.schema.json": "47d122f7d4711eaa13464c5487c26ed36103b7c85adf1987004bf2d6842d663b",
160
160
  "schemas/v2/orchestrator-decision.schema.json": "2da17538e84cfbe70151eb118f1ccf670cff58b8697148e394074d32ac4af6b4",
161
161
  "schemas/v2/org-chart-responsibility-view.schema.json": "8a2565e4cdc6737a3946a70799f93642808b92f9c8d56dcdff3a0782053b89a1",
162
162
  "schemas/v2/pack-lockfile.schema.json": "2685417702c105146b780cd99154296a46578fbb10d81c8c713348ee26fea898",
163
163
  "schemas/v2/prompt-kind.schema.json": "67251c937a5a91fb97c0422050e808dc0c162a9b8561366814cf87b0a7f62e00",
164
- "schemas/v2/prompt-pack-manifest.schema.json": "8e77d23840250640d26f2f99c1c5902e79bf4a6974cae2d6c6637176236bddb9",
164
+ "schemas/v2/prompt-pack-manifest.schema.json": "4b1e510341cc6ba167b007ecae38ce0d71d5b89c751671777c5457da926e9744",
165
165
  "schemas/v2/prompt-ref.schema.json": "11538fa36a7a652c93100cac4f1857eb4130137b546e3d94890618636d44b729",
166
166
  "schemas/v2/prompt-template.schema.json": "494fc83028cb3af5dbb8a943d0e83b2348f7775e9fa02369162bcb58a6f81559",
167
167
  "schemas/v2/proposal.schema.json": "cb5ea4fcb54be94fd2a92cf2799357dc73335181a89f5766f432d69e21cc3a80",
@@ -189,7 +189,7 @@
189
189
  "schemas/v2/ui-plugin-message.schema.json": "879d65867c01656b3962d64283e66364dcb8f626ad26c5fcf2d2d55a79ae0ce9",
190
190
  "schemas/v2/webhook-dead-letter-page.schema.json": "543f5ccc26d8f777684a037172ab7b9fdf70735e5394eeb42e5fa6569503f528",
191
191
  "schemas/v2/webhook-delivery.schema.json": "4e8ab3b722ad1731adf0548c1217f0de62da40de14e8abf77533aee4ace4c079",
192
- "schemas/v2/workflow-chain-pack-manifest.schema.json": "b61cc945b4df1c7093a1058fa71ee59ef10bacb3433ae4d7ae7536136e616cc2",
192
+ "schemas/v2/workflow-chain-pack-manifest.schema.json": "58fa5b67c6e3a320f77aa1eb5d5fa7c6c9b3356afe8b75ad74a7cf082496e04b",
193
193
  "schemas/v2/workflow-definition.schema.json": "9aafddec32b84eb6aec3185be024850f1b9444c6193568771b0b636595fef136",
194
194
  "schemas/v2/workload-identity.schema.json": "1f589debfac6d70e0908da511550ec69a27b4bcc8314551cd843b6d923605efc",
195
195
  "schemas/v2/workspace-file-create.schema.json": "b6b5260857318c3b67de0231ed15e3a746f525880a763e7b36a7854178dd10d0",
@@ -201,7 +201,7 @@
201
201
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
202
202
  "spec/v1/alias-detectors.json": "40069d5976eeb6ba1384a648e57e5cfd673db3fce36175115fb8293bee9664d4",
203
203
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
204
- "spec/v1/core-standard-manifest.json": "0640fb5fdced5af7a5e2ae2108c69da13d79bd3c5373128ee5039e9cd1af0e43",
204
+ "spec/v1/core-standard-manifest.json": "9f5d065a0a84cf4745f53957f395b1e868453f910531e030f2da4039ee2c9140",
205
205
  "spec/v1/deprecations.json": "307083ce29c23fd406015951f99a30d78d6187ff061d38dc9732f62191b40f3f",
206
206
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
207
207
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -228,7 +228,7 @@
228
228
  "spec/v2/core/interop.md": "6bd581556b9ab1a1509c01b9a1ec2bcb9e4a8300e68fb01570704417e70f20b9",
229
229
  "spec/v2/core/interrupt.md": "3a926d84b942d4ee7daccc4443c93dc29f6a69f2936ba813182f2c1336db2cbb",
230
230
  "spec/v2/core/overview.md": "7dc00ace32d4e4ce929a383651f0029fc4b7091ec87487b1bf26ead1be209e0e",
231
- "spec/v2/core/packs.md": "cc06de2d49dd08410eececd5c279996ec839680553b332b4f33d6b31989b5757",
231
+ "spec/v2/core/packs.md": "1c0919059760f9a3940a93264a76b7e30f9a0f6f27ff19753cc88f15753d8088",
232
232
  "spec/v2/core/persistence.md": "9e241f84259e38848f4a2ad1c21debb0dffd5f8aafbad26408aa6cc8969bf93b",
233
233
  "spec/v2/core/replay.md": "b980a66e2543107240e3dc4f58131b9f4ff52478737e79ade6d5777b26ad1119",
234
234
  "spec/v2/core/runs.md": "136319842bacbbb300178980e4b83444917d5b193cc5fdd92e39a4902ba8d3c0",
@@ -274,13 +274,13 @@
274
274
  "spec/v2/facets/schemaVersions.schema.json": "890d3416c4b66511d51f329abc68e6f467f4338f16807e489fe682999b2b56b9",
275
275
  "spec/v2/facets/supportedEnvelopes.schema.json": "644f22cc2236e30a611b5b7f1ba0458ff1925d68b94c514d9672583224e126bd",
276
276
  "spec/v2/facets/webhooks.schema.json": "4bf3f8eb3161a43910efac4c44ccaf9567060c9da52d4cf09bcf3888d1b92192",
277
- "spec/v2/facets/workflowChainPacks.schema.json": "cd30907ac92396866a524e4db8d8c1537e3818cfff455d780efd72bcd0ebdac1",
277
+ "spec/v2/facets/workflowChainPacks.schema.json": "ea625ce818fbeb746478f00f31f96ee9613d538e4409c258c1bfb517acdeaba9",
278
278
  "spec/v2/id-field-bindings.json": "e73c06f3055ad30173507ac817a458b96362c8f26209ba105c53653292cf63d3",
279
279
  "spec/v2/path-manifest.json": "c123c9fd77dc1b3f9c2e9346ace80cdae007e6511138894956abda01e61ec1c6",
280
280
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
281
281
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
282
- "spec/v2/release.json": "92131d62a7fcd12445d7e04eb588e2297f839b7e5e2a87f89ae9e61593f837cd",
282
+ "spec/v2/release.json": "94a3ea302324d9f7df87ce62a186b0b98936de6ce5ac5ec01a3fb3208c38efc3",
283
283
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
284
284
  },
285
- "corpusCommit": "691974ccf758a6ea42dad8c38ab6b5570e8f89f2"
285
+ "corpusCommit": "e9825b0da6ca8e2dbc76353e52aea73a56473393"
286
286
  }
package/src/cli.ts CHANGED
@@ -54,7 +54,8 @@ import {
54
54
  PROFILE_FLOOR_SCENARIOS,
55
55
  } from './lib/profiles.js';
56
56
  import { setV2ProfileFloors, v2ProfileFloorFiles } from './lib/requirement-registry.js';
57
- import { v2ProfileIds } from './lib/v2-profiles.js';
57
+ import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2ProfileIds } from './lib/v2-profiles.js';
58
+ import { childEnv } from './lib/child-env.js';
58
59
 
59
60
  interface ParsedArgs {
60
61
  readonly baseUrl: string | undefined;
@@ -525,7 +526,7 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
525
526
  // (and assertion count) here; the runner reads it after the run so bundle v2
526
527
  // rows come from what scenarios RECORDED, not from per-file pass/fail/skip.
527
528
  const ledgerFile = join(reportDir, 'requirement-ledger.jsonl');
528
- const env: NodeJS.ProcessEnv = { ...process.env };
529
+ const env: NodeJS.ProcessEnv = childEnv(process.env);
529
530
  env.OPENWOP_BASE_URL = baseUrl;
530
531
  env.OPENWOP_API_KEY = apiKey;
531
532
  env.OPENWOP_LEDGER_PATH = ledgerFile;
@@ -693,7 +694,9 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
693
694
  // belongs to. Matched the same way the verifier matches it, so the emitter
694
695
  // and `verifyBundleV3` cannot disagree about the same bundle.
695
696
  const relaxedObligations = new Set((relaxations ?? []).map((r) => r.obligation));
696
- const relaxedProfile = (p: string): boolean => [...relaxedObligations].some((o) => p.includes(o));
697
+ const relaxedProfileIds = profilesRelaxedBy([...relaxedObligations], claimedProfiles);
698
+ const observedRelaxed = profilesDeniedByObservedRelaxation(rows3, claimedProfiles).profiles;
699
+ const relaxedProfile = (p: string): boolean => relaxedProfileIds.has(p) || observedRelaxed.has(p);
697
700
  const claimed3 = claimedProfiles.filter((p) => !(p in DEPRECATED_PROFILE_ALIASES)).map((p) => ({ id: p, evidenceTier: args.evidenceTier, witnessCount: witnessCountFor(p), certified: !relaxedProfile(p) && (verdictFor(p)?.certifiable ?? false) && !notHeld.has(p) && !rejectedProfiles.some((v) => v.profile === p) && totals3.blocked === 0 }));
698
701
  const lockPath = resolvePath(conformanceRoot, 'dist', 'spec-artifacts.lock.json');
699
702
  const lock = existsSync(lockPath) ? (JSON.parse(readFileSync(lockPath, 'utf8')) as { version: string; stampSha256: string }) : undefined;
@@ -973,7 +976,7 @@ async function main(): Promise<never> {
973
976
 
974
977
  // Env vars OVERRIDE flags only when the flag was unset (consistent
975
978
  // with the rest of the harness — env wins on the absence of CLI input).
976
- const env: NodeJS.ProcessEnv = { ...process.env };
979
+ const env: NodeJS.ProcessEnv = childEnv(process.env);
977
980
  if (args.baseUrl) env.OPENWOP_BASE_URL = args.baseUrl;
978
981
  if (args.apiKey) env.OPENWOP_API_KEY = args.apiKey;
979
982
  if (args.impl) env.OPENWOP_IMPLEMENTATION_NAME = args.impl;
@@ -38,6 +38,7 @@
38
38
  * @see https://a2a-protocol.org/v0.3.0/specification
39
39
  */
40
40
 
41
+ import { resolvePublicFront } from './webhook-receiver.js';
41
42
  import { createServer, type Server } from 'node:http';
42
43
  import type { AddressInfo } from 'node:net';
43
44
 
@@ -198,6 +199,18 @@ export class A2AFakePeer {
198
199
  return `http://127.0.0.1:${this._boundPort}`;
199
200
  }
200
201
 
202
+ /**
203
+ * The address to hand THE HOST UNDER TEST — `OPENWOP_A2A_FAKE_PEER_URL` when the operator fronts
204
+ * this peer publicly (https, publicly resolvable; validated loudly, same rule
205
+ * as the webhook receiver), else `endpoint()`. The suite's own requests to its
206
+ * own fake keep using `endpoint()`: they need no tunnel and must not depend on
207
+ * one. Pin the listener with the matching `_PORT` variable so the front has a
208
+ * fixed port to forward to.
209
+ */
210
+ hostFacingEndpoint(): string {
211
+ return resolvePublicFront('OPENWOP_A2A_FAKE_PEER_URL', this.endpoint()).url;
212
+ }
213
+
201
214
  reset(): void {
202
215
  this._tasks.clear();
203
216
  this._invocations.length = 0;
@@ -293,7 +306,7 @@ export class A2AFakePeer {
293
306
  description: 'Synthetic A2A peer for openwop conformance suite (dual-era: 1.0 + 0.3-legacy)',
294
307
  version: '1.1.0',
295
308
  supportedInterfaces: [...this._protocolVersions].sort((a, b) => (a === '1.0' ? -1 : b === '1.0' ? 1 : 0)).map((v) => ({
296
- url: `${this.endpoint()}/a2a/jsonrpc`,
309
+ url: `${this.hostFacingEndpoint()}/a2a/jsonrpc`,
297
310
  protocolBinding: 'JSONRPC',
298
311
  protocolVersion: v,
299
312
  })),
@@ -310,13 +323,13 @@ export class A2AFakePeer {
310
323
  protocolVersion: '0.3.0',
311
324
  name: 'openwop-conformance-fake-a2a',
312
325
  description: 'Synthetic A2A peer for openwop conformance suite',
313
- url: `${this.endpoint()}/a2a/jsonrpc`,
326
+ url: `${this.hostFacingEndpoint()}/a2a/jsonrpc`,
314
327
  version: '1.0.0',
315
328
  capabilities: { streaming: false, pushNotifications: false },
316
329
  skills: [{ id: 'echo', name: 'echo', description: 'Returns input verbatim', tags: ['echo'] }],
317
330
  defaultInputModes: ['text'],
318
331
  defaultOutputModes: ['text'],
319
- additionalInterfaces: [{ url: `${this.endpoint()}/a2a/jsonrpc`, transport: 'JSONRPC' }],
332
+ additionalInterfaces: [{ url: `${this.hostFacingEndpoint()}/a2a/jsonrpc`, transport: 'JSONRPC' }],
320
333
  };
321
334
  }
322
335
 
@@ -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 && relaxed.size > 0 && [...relaxed].some((o) => p.id.includes(o))) 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)` });
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,29 @@
1
+ /**
2
+ * The environment handed to the vitest child the CLI spawns.
3
+ *
4
+ * `{ ...process.env }` handed the child EVERYTHING the operator exported,
5
+ * including `OPENWOP_BUNDLE_SIGNING_KEY` — the PKCS8 PEM of the key that signs
6
+ * the certification bundle. Nothing in the child reads it: the signer runs in
7
+ * the PARENT, after the run (`cli.ts` `--certify`). It was there only because
8
+ * nothing took it out.
9
+ *
10
+ * Why that is a leak and not a tidiness point. Measured by a host operator
11
+ * during a production cut (2026-09-20): on macOS `npm exec` rewrites
12
+ * `process.title`, and a process listing (`ps`, `pgrep -fl`) then spills into
13
+ * the child's environ — so listing processes mid-cut printed the signing key,
14
+ * in full, into the operator's terminal and transcript. Same-uid or root only,
15
+ * but that is exactly who is looking at the terminal. A scenario, a reporter, a
16
+ * vitest plugin or a crash dump in the child had the same access for no reason.
17
+ *
18
+ * The rule: material only the parent uses never enters the child. The key IDS
19
+ * are not secret and stay (a scenario may legitimately name the key it expects).
20
+ */
21
+ export const PARENT_ONLY_ENV: readonly string[] = [
22
+ 'OPENWOP_BUNDLE_SIGNING_KEY',
23
+ ];
24
+
25
+ export function childEnv(parent: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
26
+ const env: NodeJS.ProcessEnv = { ...parent };
27
+ for (const name of PARENT_ONLY_ENV) delete env[name];
28
+ return env;
29
+ }
@@ -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
  }
@@ -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
- const raw = process.env.OPENWOP_WEBHOOK_RECEIVER_URL?.trim();
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
- `OPENWOP_WEBHOOK_RECEIVER_URL is not a valid URL: ${JSON.stringify(raw)}`,
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
- `OPENWOP_WEBHOOK_RECEIVER_URL MUST be https: (got ${parsed.protocol}). ` +
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
- `OPENWOP_WEBHOOK_RECEIVER_URL MUST be a publicly-resolvable host (got ${parsed.hostname}). ` +
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.endpoint())).toBe(true);
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.endpoint(),
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.endpoint(), isReal: false };
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.endpoint() });
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.endpoint(),
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.endpoint(),
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.endpoint(), tool: 'echo', arguments: { text: 'opaque' }, scenario: 'extension-asserts-authority' });
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.endpoint(),
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.endpoint(), isReal: false };
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.endpoint() });
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.endpoint() });
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.endpoint(),
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)`);