eval-quality 1.4.2 → 3.0.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 (110) hide show
  1. package/README.md +6 -6
  2. package/corpus/dev/README.md +24 -13
  3. package/corpus/dev/compile-seal-example/brief.json +1 -1
  4. package/corpus/dev/compile-seal-example/contract.json +1 -1
  5. package/corpus/dev/contracts/absent-collection-locations.json +1 -1
  6. package/corpus/dev/contracts/absent-sibling-groups.json +1 -1
  7. package/corpus/dev/contracts/absent-success-indicator.json +1 -1
  8. package/corpus/dev/contracts/captured-read-back.json +1 -0
  9. package/corpus/dev/contracts/checklist-selection.json +1 -0
  10. package/corpus/dev/contracts/empty-channel-roles.json +1 -1
  11. package/corpus/dev/contracts/empty-collection-locations.json +1 -1
  12. package/corpus/dev/contracts/empty-request-shapes.json +1 -1
  13. package/corpus/dev/contracts/empty-sibling-groups.json +1 -1
  14. package/corpus/dev/contracts/fragment-selection.json +1 -1
  15. package/corpus/dev/contracts/no-collection-quantifier.json +1 -1
  16. package/corpus/dev/contracts/no-operation-inventory.json +1 -1
  17. package/corpus/dev/contracts/no-read-back-relation.json +1 -1
  18. package/corpus/dev/contracts/no-state-change-marker.json +1 -1
  19. package/corpus/dev/contracts/no-type-violating-step.json +1 -1
  20. package/corpus/dev/contracts/notes-tool-server.json +1 -0
  21. package/corpus/dev/contracts/per-key-split-oracles.json +1 -1
  22. package/corpus/dev/contracts/review-corpus.json +1 -1
  23. package/corpus/dev/contracts/satisfied-declarations.json +1 -1
  24. package/corpus/dev/contracts/single-required-response-key.json +1 -1
  25. package/corpus/dev/contracts/split-indicator-oracle.json +1 -1
  26. package/corpus/dev/contracts/unaddressed-parameter-sibling.json +1 -1
  27. package/corpus/dev/contracts/unnamed-reference-set.json +1 -1
  28. package/corpus/dev/contracts/wrong-cardinality-form.json +1 -1
  29. package/corpus/dev/index.json +1 -1
  30. package/dist/adapters/command-line-adapter.js +44 -6
  31. package/dist/adapters/index.d.ts +2 -0
  32. package/dist/adapters/index.js +1 -0
  33. package/dist/adapters/mcp-adapter.d.ts +32 -0
  34. package/dist/adapters/mcp-adapter.js +357 -0
  35. package/dist/adapters/mcp-target-policy.d.ts +39 -0
  36. package/dist/adapters/mcp-target-policy.js +30 -0
  37. package/dist/cli/render.d.ts +8 -3
  38. package/dist/cli/render.js +13 -6
  39. package/dist/cli/run.js +2 -1
  40. package/dist/core/compile/bindings.d.ts +8 -18
  41. package/dist/core/compile/bindings.js +13 -10
  42. package/dist/core/compile/compile.js +6 -1
  43. package/dist/core/compile/interface-inventory.d.ts +81 -3
  44. package/dist/core/compile/interface-inventory.js +134 -23
  45. package/dist/core/compile/reachability.d.ts +73 -1
  46. package/dist/core/compile/reachability.js +134 -19
  47. package/dist/core/compile/schema-version.d.ts +15 -2
  48. package/dist/core/compile/schema-version.js +11 -3
  49. package/dist/core/compile/sensitivity-witness.d.ts +22 -10
  50. package/dist/core/compile/sensitivity-witness.js +101 -13
  51. package/dist/core/coverage/operations.d.ts +1 -1
  52. package/dist/core/coverage/operations.js +1 -1
  53. package/dist/core/coverage/relevance.d.ts +3 -3
  54. package/dist/core/coverage/relevance.js +3 -3
  55. package/dist/core/declared-inputs.d.ts +21 -9
  56. package/dist/core/declared-inputs.js +51 -16
  57. package/dist/core/evaluate/evidence-resolution.d.ts +8 -7
  58. package/dist/core/evaluate/evidence-resolution.js +25 -18
  59. package/dist/core/preflight/plan.js +43 -6
  60. package/dist/core/preflight/projection.d.ts +10 -1
  61. package/dist/core/preflight/projection.js +9 -5
  62. package/dist/core/preflight/reduce.js +6 -2
  63. package/dist/core/preflight/witness-evidence.js +31 -9
  64. package/dist/core/schemas/artifact.d.ts +145 -49
  65. package/dist/core/schemas/defect-signature.d.ts +280 -23
  66. package/dist/core/schemas/defect-signature.js +65 -37
  67. package/dist/core/schemas/eval-contract.d.ts +33 -48
  68. package/dist/core/schemas/eval-contract.js +3 -3
  69. package/dist/core/schemas/interface.d.ts +193 -55
  70. package/dist/core/schemas/interface.js +82 -15
  71. package/dist/core/schemas/plan.d.ts +45 -1
  72. package/dist/core/schemas/plan.js +13 -2
  73. package/dist/core/schemas/pointer.d.ts +23 -9
  74. package/dist/core/schemas/pointer.js +25 -11
  75. package/dist/core/schemas/port-messages.d.ts +81 -0
  76. package/dist/core/schemas/port-messages.js +50 -3
  77. package/dist/core/schemas/primitives.d.ts +18 -0
  78. package/dist/core/schemas/primitives.js +29 -0
  79. package/dist/core/schemas/probe-policy.d.ts +41 -0
  80. package/dist/core/schemas/probe-policy.js +61 -1
  81. package/dist/core/schemas/probe.d.ts +156 -2
  82. package/dist/core/schemas/probe.js +45 -2
  83. package/dist/core/schemas/sealed-run-record.d.ts +11 -5
  84. package/dist/core/schemas/sealed-run-record.js +21 -9
  85. package/dist/core/schemas/sensitivity-witness.d.ts +31 -7
  86. package/dist/core/schemas/sensitivity-witness.js +32 -9
  87. package/dist/core/score/bindings.d.ts +1 -1
  88. package/dist/core/score/bindings.js +4 -4
  89. package/dist/core/score/qualification.d.ts +7 -5
  90. package/dist/core/score/qualification.js +92 -23
  91. package/dist/core/score/score.d.ts +1 -1
  92. package/dist/core/score/score.js +24 -1
  93. package/dist/core/seal/derived-reference.js +20 -17
  94. package/dist/core/seal/plan-index.d.ts +18 -9
  95. package/dist/core/seal/plan-index.js +79 -37
  96. package/dist/index.d.ts +1 -1
  97. package/dist/index.js +1 -1
  98. package/dist/ports/environment-probe-port.d.ts +50 -17
  99. package/dist/ports/environment-probe-port.js +26 -17
  100. package/dist/testing/conformance.d.ts +3 -2
  101. package/dist/testing/conformance.js +2 -1
  102. package/dist/testing/index.d.ts +3 -3
  103. package/dist/testing/index.js +1 -1
  104. package/dist/testing/probe-conformance.d.ts +90 -13
  105. package/dist/testing/probe-conformance.js +375 -160
  106. package/package.json +4 -2
  107. package/schemas/eval-contract.schema.json +571 -18
  108. package/schemas/probe.schema.json +152 -12
  109. package/schemas/rubric.schema.json +1 -1
  110. package/schemas/sealed-run-record.schema.json +24 -17
@@ -1,10 +1,115 @@
1
1
  import { probeParsers } from '../ports/environment-probe-port.js';
2
2
  import { buildScenario, countCalls, describeThrown, disposeScenario, faultView, reportOf, runSharedAssertions, settle, titledOutcome, withDispose, } from './conformance.js';
3
+ const DENIED = 'forbidden-target';
4
+ const CAPPED = 'budget-exhausted';
5
+ /**
6
+ * Which of the four echoed fields the observation failed to return unchanged,
7
+ * or `undefined` when it answered the question it was asked.
8
+ */
9
+ function echoMismatch(request, observation) {
10
+ const echoed = [
11
+ ['kind', request.kind, observation.kind],
12
+ ['probeId', request.probeId, observation.probeId],
13
+ ['interfaceId', request.interfaceId, observation.interfaceId],
14
+ ['operationId', request.operationId, observation.operationId],
15
+ ];
16
+ for (const [field, asked, answered] of echoed) {
17
+ if (asked === answered)
18
+ continue;
19
+ return `observed ${field} "${answered}" for a request carrying "${asked}", so the answer does not correlate with the question`;
20
+ }
21
+ return undefined;
22
+ }
23
+ function checkRejected(assertion, expectedCode, error) {
24
+ const view = faultView(error);
25
+ if (view === undefined) {
26
+ return titledOutcome(assertion.id, assertion.title, false, `rejected with ${describeThrown(error)}, which carries no declared AD-28 code`);
27
+ }
28
+ return titledOutcome(assertion.id, assertion.title, view.code === expectedCode, `rejected with code "${view.code}", expected "${expectedCode}"`);
29
+ }
30
+ /**
31
+ * Schema, then correlation, then the assertion's own predicate. Generic over
32
+ * the subject rather than written once per arm: a function parameter is
33
+ * checked contravariantly, so an arm's `check` cannot be widened across
34
+ * subject types, and inferring the subject here costs the assertion records no
35
+ * type parameter of their own.
36
+ */
37
+ function checkResolvedFor(assertion, check, value, request, subject) {
38
+ const parsed = probeParsers.response.safeParse(value);
39
+ if (!parsed.success) {
40
+ return titledOutcome(assertion.id, assertion.title, false, 'the resolved value is not a schema-valid ProbeObservation');
41
+ }
42
+ // Schema validity is not correlation. Both messages are unions, so an
43
+ // adapter can answer a command request or a tool call with a schema-valid
44
+ // HTTP observation and satisfy every assertion below without running a
45
+ // command, opening a session, or consulting a policy. The four echoed
46
+ // fields are what tie one answer to one question, and `ProbeRequest.probeId`'s
47
+ // own description already says the port returns them unchanged.
48
+ const mismatch = echoMismatch(request, parsed.data);
49
+ if (mismatch !== undefined) {
50
+ return titledOutcome(assertion.id, assertion.title, false, mismatch);
51
+ }
52
+ const complaint = check?.(parsed.data, subject);
53
+ return titledOutcome(assertion.id, assertion.title, complaint === undefined, complaint ?? '');
54
+ }
55
+ /** Generic for `checkResolvedFor`'s reason: `expectedCalls` names its own arm's subject type. */
56
+ function checkCallCount(expectedCalls, subject, built, base) {
57
+ if (expectedCalls === undefined)
58
+ return base;
59
+ // Every branch keeps whatever the assertion's own check already said. An
60
+ // outcome that failed its predicate AND hit a subject-side count problem
61
+ // used to report only the second, which hid the reason the assertion was
62
+ // looked at in the first place.
63
+ const also = (complaint) => ({
64
+ ...base,
65
+ passed: false,
66
+ detail: base.passed ? complaint : `${base.detail}; ${complaint}`,
67
+ });
68
+ const expected = expectedCalls(subject);
69
+ if (typeof expected === 'string')
70
+ return also(expected);
71
+ const actual = countCalls(built);
72
+ if (typeof actual === 'string')
73
+ return also(actual);
74
+ if (actual === expected)
75
+ return base;
76
+ return also(`underlyingCalls() was ${actual}, expected ${expected}`);
77
+ }
78
+ async function runArmAssertion(assertion, subject) {
79
+ const run = await buildScenario(subject, 'resolves');
80
+ if (!run.ok) {
81
+ return titledOutcome(assertion.id, assertion.title, false, run.detail);
82
+ }
83
+ const request = assertion.request(subject);
84
+ const settled = await settle(run.built.port(request, new AbortController().signal));
85
+ let result;
86
+ if (assertion.expectation.kind === 'resolves') {
87
+ result =
88
+ settled.kind === 'resolved'
89
+ ? checkResolvedFor(assertion, assertion.expectation.check, settled.value, request, subject)
90
+ : titledOutcome(assertion.id, assertion.title, false, `rejected with ${describeThrown(settled.error)} instead of returning an observation`);
91
+ }
92
+ else {
93
+ result =
94
+ settled.kind === 'rejected'
95
+ ? checkRejected(assertion, assertion.expectation.code, settled.error)
96
+ : titledOutcome(assertion.id, assertion.title, false, `resolved instead of rejecting with "${assertion.expectation.code}"`);
97
+ }
98
+ result = checkCallCount(assertion.expectedCalls, subject, run.built, result);
99
+ return withDispose(result, await disposeScenario(run.built));
100
+ }
101
+ /** The six shared assertions, then the arm's own, in list order. */
102
+ async function runArm(subject, port, assertions) {
103
+ const shared = await runSharedAssertions('probe', subject, probeParsers.response);
104
+ const additional = [];
105
+ for (const assertion of assertions) {
106
+ additional.push(await runArmAssertion(assertion, subject));
107
+ }
108
+ return reportOf(subject.name, port, [...shared, ...additional]);
109
+ }
3
110
  function authorizationFor(subject, request) {
4
111
  return subject.policy.authorizations.find((each) => each.interfaceId === request.interfaceId);
5
112
  }
6
- const DENIED = 'forbidden-target';
7
- const CAPPED = 'budget-exhausted';
8
113
  const PROBE_ASSERTIONS = [
9
114
  {
10
115
  // Without this one, a subject that denies everything scores twelve of
@@ -18,6 +123,13 @@ const PROBE_ASSERTIONS = [
18
123
  {
19
124
  // A subject that throws on 5xx fails here and nowhere else, and it makes
20
125
  // AD-10's seeded-fault check unimplementable.
126
+ //
127
+ // The non-`api` arm of the detail below names the kind it observed. It
128
+ // is reached when the subject declares a `faultingRequest` of another
129
+ // kind and answers it correlated, which `ProbeSubject` admits because
130
+ // the field is typed over the whole request union. Substituting only
131
+ // the observation's kind does not reach it: `echoMismatch` compares
132
+ // `kind` among the four echoed fields and short-circuits first.
21
133
  id: 'probe/observe-anomalous-status',
22
134
  title: 'a 500 from an authorized target is an observation, not a fault',
23
135
  request: (subject) => subject.faultingRequest,
@@ -25,7 +137,7 @@ const PROBE_ASSERTIONS = [
25
137
  kind: 'resolves',
26
138
  check: (observation) => observation.kind === 'api' && observation.status === 500
27
139
  ? undefined
28
- : `observed ${observation.kind === 'api' ? `status ${observation.status}` : 'a command observation'}, expected status 500`,
140
+ : `observed ${observation.kind === 'api' ? `status ${observation.status}` : `an observation of kind "${observation.kind}"`}, expected status 500`,
29
141
  },
30
142
  },
31
143
  {
@@ -114,92 +226,6 @@ const PROBE_ASSERTIONS = [
114
226
  expectation: { kind: 'rejects', code: CAPPED },
115
227
  },
116
228
  ];
117
- function checkResolved(assertion, expectation, value, request) {
118
- const parsed = probeParsers.response.safeParse(value);
119
- if (!parsed.success) {
120
- return titledOutcome(assertion.id, assertion.title, false, 'the resolved value is not a schema-valid ProbeObservation');
121
- }
122
- // Schema validity is not correlation. Both messages are unions now, so an
123
- // adapter can answer a command request with a schema-valid HTTP observation
124
- // and satisfy every assertion below without running a command or consulting
125
- // a policy. The four echoed fields are what tie one answer to one question,
126
- // and `ProbeRequest.probeId`'s own description already says the port returns
127
- // them unchanged.
128
- const mismatch = echoMismatch(request, parsed.data);
129
- if (mismatch !== undefined) {
130
- return titledOutcome(assertion.id, assertion.title, false, mismatch);
131
- }
132
- const complaint = expectation.check?.(parsed.data);
133
- return titledOutcome(assertion.id, assertion.title, complaint === undefined, complaint ?? '');
134
- }
135
- /**
136
- * Which of the four echoed fields the observation failed to return unchanged,
137
- * or `undefined` when it answered the question it was asked.
138
- */
139
- function echoMismatch(request, observation) {
140
- const echoed = [
141
- ['kind', request.kind, observation.kind],
142
- ['probeId', request.probeId, observation.probeId],
143
- ['interfaceId', request.interfaceId, observation.interfaceId],
144
- ['operationId', request.operationId, observation.operationId],
145
- ];
146
- for (const [field, asked, answered] of echoed) {
147
- if (asked === answered)
148
- continue;
149
- return `observed ${field} "${answered}" for a request carrying "${asked}", so the answer does not correlate with the question`;
150
- }
151
- return undefined;
152
- }
153
- /** Reads only `id` and `title`, so both arms' assertion shapes satisfy it structurally. */
154
- function checkRejected(assertion, expectedCode, error) {
155
- const view = faultView(error);
156
- if (view === undefined) {
157
- return titledOutcome(assertion.id, assertion.title, false, `rejected with ${describeThrown(error)}, which carries no declared AD-28 code`);
158
- }
159
- return titledOutcome(assertion.id, assertion.title, view.code === expectedCode, `rejected with code "${view.code}", expected "${expectedCode}"`);
160
- }
161
- function checkCalls(assertion, subject, built, base) {
162
- if (assertion.expectedCalls === undefined)
163
- return base;
164
- const expected = assertion.expectedCalls(subject);
165
- if (typeof expected === 'string') {
166
- return { ...base, passed: false, detail: expected };
167
- }
168
- const actual = countCalls(built);
169
- if (typeof actual === 'string') {
170
- return { ...base, passed: false, detail: actual };
171
- }
172
- if (actual === expected)
173
- return base;
174
- return {
175
- ...base,
176
- passed: false,
177
- detail: `${base.passed ? '' : `${base.detail}; `}underlyingCalls() was ${actual}, expected ${expected}`,
178
- };
179
- }
180
- async function runProbeAssertion(assertion, subject) {
181
- const run = await buildScenario(subject, 'resolves');
182
- if (!run.ok) {
183
- return titledOutcome(assertion.id, assertion.title, false, run.detail);
184
- }
185
- const request = assertion.request(subject);
186
- const settled = await settle(run.built.port(request, new AbortController().signal));
187
- let result;
188
- if (assertion.expectation.kind === 'resolves') {
189
- result =
190
- settled.kind === 'resolved'
191
- ? checkResolved(assertion, assertion.expectation, settled.value, request)
192
- : titledOutcome(assertion.id, assertion.title, false, `rejected with ${describeThrown(settled.error)} instead of returning an observation`);
193
- }
194
- else {
195
- result =
196
- settled.kind === 'rejected'
197
- ? checkRejected(assertion, assertion.expectation.code, settled.error)
198
- : titledOutcome(assertion.id, assertion.title, false, `resolved instead of rejecting with "${assertion.expectation.code}"`);
199
- }
200
- result = checkCalls(assertion, subject, run.built, result);
201
- return withDispose(result, await disposeScenario(run.built));
202
- }
203
229
  /**
204
230
  * Nineteen outcomes: the six shared assertions plus AD-35's thirteen.
205
231
  * `maxRequestBytes` is the one cap with no assertion; the request shape is the
@@ -207,12 +233,24 @@ async function runProbeAssertion(assertion, subject) {
207
233
  * stays declared and adapter-enforced.
208
234
  */
209
235
  export async function runEnvironmentProbePortConformance(subject) {
210
- const shared = await runSharedAssertions('probe', subject, probeParsers.response);
211
- const additional = [];
212
- for (const assertion of PROBE_ASSERTIONS) {
213
- additional.push(await runProbeAssertion(assertion, subject));
214
- }
215
- return reportOf(subject.name, 'environment-probe', [...shared, ...additional]);
236
+ return runArm(subject, 'environment-probe', PROBE_ASSERTIONS);
237
+ }
238
+ /**
239
+ * The authorization the subject's own command policy resolves this request to.
240
+ * Keyed by the pair, since `CommandTargetAuthorization` is.
241
+ *
242
+ * The two denial assertions that call it read it for the reason
243
+ * `mcpAuthorizationFor` exists: a subject whose "unmapped" request is in fact
244
+ * mapped passes its own denial for the wrong reason.
245
+ *
246
+ * It takes the narrowed request, so each caller states what it does about a
247
+ * subject that declared another kind there rather than inheriting a silent
248
+ * `undefined` from here. `mcpAuthorizationFor` needs no such narrowing because
249
+ * it reads `interfaceId`, which every member of the request union carries.
250
+ */
251
+ function commandAuthorizationFor(subject, request) {
252
+ return subject.policy.authorizations.find((each) => each.interfaceId === request.interfaceId &&
253
+ each.executable === request.executable);
216
254
  }
217
255
  const COMMAND_ASSERTIONS = [
218
256
  {
@@ -222,6 +260,11 @@ const COMMAND_ASSERTIONS = [
222
260
  expectation: { kind: 'resolves' },
223
261
  },
224
262
  {
263
+ // The non-`cli` arm of the detail below is reached the same way the
264
+ // `api` arm's is: `nonZeroExitRequest` is typed over the whole request
265
+ // union, so a subject may declare another kind there and answer it
266
+ // correlated. It names the observed kind; the binary ternary it
267
+ // replaced called every one of them "an api observation".
225
268
  id: 'command/observe-nonzero-exit',
226
269
  title: 'a non-zero exit from an authorized command is an observation, not a fault',
227
270
  request: (subject) => subject.nonZeroExitRequest,
@@ -229,7 +272,7 @@ const COMMAND_ASSERTIONS = [
229
272
  kind: 'resolves',
230
273
  check: (observation) => observation.kind === 'cli' && observation.exitCode !== 0
231
274
  ? undefined
232
- : `observed ${observation.kind === 'cli' ? `exit code ${observation.exitCode}` : 'an api observation'}, expected a non-zero exit`,
275
+ : `observed ${observation.kind === 'cli' ? `exit code ${observation.exitCode}` : `an observation of kind "${observation.kind}"`}, expected a non-zero exit`,
233
276
  },
234
277
  },
235
278
  {
@@ -237,21 +280,71 @@ const COMMAND_ASSERTIONS = [
237
280
  title: 'an interface no authorization names is refused before a process spawns',
238
281
  request: (subject) => subject.unmappedInterfaceRequest,
239
282
  expectation: { kind: 'rejects', code: DENIED },
240
- expectedCalls: () => 0,
283
+ expectedCalls: (subject) => subject.policy.authorizations.some((each) => each.interfaceId === subject.unmappedInterfaceRequest.interfaceId)
284
+ ? "the subject's policy names an authorization for unmappedInterfaceRequest.interfaceId, so the request it presents as unmapped is mapped"
285
+ : 0,
241
286
  },
242
287
  {
243
288
  id: 'command/deny-unmapped-executable',
244
289
  title: 'an executable the interface is never paired with is refused before a process spawns',
245
290
  request: (subject) => subject.unmappedExecutableRequest,
246
291
  expectation: { kind: 'rejects', code: DENIED },
247
- expectedCalls: () => 0,
292
+ expectedCalls: (subject) => {
293
+ const request = subject.unmappedExecutableRequest;
294
+ if (request.kind !== 'cli') {
295
+ return `unmappedExecutableRequest declares a "${request.kind}" request, which names no executable for the policy to refuse`;
296
+ }
297
+ return commandAuthorizationFor(subject, request) === undefined
298
+ ? 0
299
+ : "the subject's policy pairs unmappedExecutableRequest's interface with its executable, so the pair it presents as unmapped is mapped";
300
+ },
248
301
  },
249
302
  {
250
303
  id: 'command/deny-unauthorized-subcommand',
251
304
  title: 'a subcommand path outside the authorized set is refused before a process spawns',
252
305
  request: (subject) => subject.unauthorizedSubcommandRequest,
253
306
  expectation: { kind: 'rejects', code: DENIED },
254
- expectedCalls: () => 0,
307
+ expectedCalls: (subject) => {
308
+ const request = subject.unauthorizedSubcommandRequest;
309
+ if (request.kind !== 'cli') {
310
+ return `unauthorizedSubcommandRequest declares a "${request.kind}" request, which names no subcommand path for the policy to refuse`;
311
+ }
312
+ const authorization = commandAuthorizationFor(subject, request);
313
+ if (authorization === undefined) {
314
+ return "the subject's policy names no authorization for unauthorizedSubcommandRequest's interface and executable, so this case cannot tell an unmapped pair from an unauthorized subcommand";
315
+ }
316
+ return authorization.permittedSubcommandPaths.some((permitted) => permitted.length === request.subcommandPath.length &&
317
+ permitted.every((segment, at) => segment === request.subcommandPath[at]))
318
+ ? `the subject's policy permits subcommand path ${JSON.stringify(request.subcommandPath)}, so the request it presents as unauthorized is authorized`
319
+ : 0;
320
+ },
321
+ },
322
+ {
323
+ // The environment channel is the one a contract author declares and an
324
+ // operator has to bound. This is the assertion that holds an adapter to
325
+ // bounding it: without `permittedEnvironmentKeys` enforced, any key the
326
+ // contract names reaches the process.
327
+ id: 'command/deny-unauthorized-environment-key',
328
+ title: 'an environment key the authorization does not permit is refused before a process spawns',
329
+ request: (subject) => subject.unauthorizedEnvironmentKeyRequest,
330
+ expectation: { kind: 'rejects', code: DENIED },
331
+ expectedCalls: (subject) => {
332
+ const request = subject.unauthorizedEnvironmentKeyRequest;
333
+ if (request.kind !== 'cli') {
334
+ return `unauthorizedEnvironmentKeyRequest declares a "${request.kind}" request, which carries no environment channel for the policy to refuse`;
335
+ }
336
+ const authorization = commandAuthorizationFor(subject, request);
337
+ if (authorization === undefined) {
338
+ return "the subject's policy names no authorization for unauthorizedEnvironmentKeyRequest's interface and executable, so this case cannot tell an unmapped pair from an unauthorized environment key";
339
+ }
340
+ const declared = Object.keys(request.channels.environment);
341
+ if (declared.length === 0) {
342
+ return 'unauthorizedEnvironmentKeyRequest declares no environment key, so nothing about the allowlist is exercised';
343
+ }
344
+ return declared.every((key) => authorization.permittedEnvironmentKeys.includes(key))
345
+ ? `the subject's policy permits every environment key unauthorizedEnvironmentKeyRequest declares (${JSON.stringify(declared)}), so the request it presents as unauthorized is authorized`
346
+ : 0;
347
+ },
255
348
  },
256
349
  {
257
350
  // The strongest available proof that no shell ever reads a channel value:
@@ -306,72 +399,194 @@ const COMMAND_ASSERTIONS = [
306
399
  expectation: { kind: 'rejects', code: CAPPED },
307
400
  },
308
401
  ];
309
- function checkCommandResolved(assertion, expectation, value, request, subject) {
310
- const parsed = probeParsers.response.safeParse(value);
311
- if (!parsed.success) {
312
- return titledOutcome(assertion.id, assertion.title, false, 'the resolved value is not a schema-valid ProbeObservation');
313
- }
314
- const mismatch = echoMismatch(request, parsed.data);
315
- if (mismatch !== undefined) {
316
- return titledOutcome(assertion.id, assertion.title, false, mismatch);
317
- }
318
- const complaint = expectation.check?.(parsed.data, subject);
319
- return titledOutcome(assertion.id, assertion.title, complaint === undefined, complaint ?? '');
320
- }
321
- /** `checkCalls`'s own logic, typed against `CommandAssertion`/`CommandProbeSubject` rather than the `api` arm's pair: a function parameter type is checked contravariantly, so the two assertion shapes cannot share one checker. */
322
- function checkCommandCalls(assertion, subject, built, base) {
323
- if (assertion.expectedCalls === undefined)
324
- return base;
325
- const expected = assertion.expectedCalls(subject);
326
- if (typeof expected === 'string') {
327
- return { ...base, passed: false, detail: expected };
328
- }
329
- const actual = countCalls(built);
330
- if (typeof actual === 'string') {
331
- return { ...base, passed: false, detail: actual };
332
- }
333
- if (actual === expected)
334
- return base;
335
- return {
336
- ...base,
337
- passed: false,
338
- detail: `${base.passed ? '' : `${base.detail}; `}underlyingCalls() was ${actual}, expected ${expected}`,
339
- };
340
- }
341
- async function runCommandAssertion(assertion, subject) {
342
- const run = await buildScenario(subject, 'resolves');
343
- if (!run.ok) {
344
- return titledOutcome(assertion.id, assertion.title, false, run.detail);
345
- }
346
- const request = assertion.request(subject);
347
- const settled = await settle(run.built.port(request, new AbortController().signal));
348
- let result;
349
- if (assertion.expectation.kind === 'resolves') {
350
- result =
351
- settled.kind === 'resolved'
352
- ? checkCommandResolved(assertion, assertion.expectation, settled.value, request, subject)
353
- : titledOutcome(assertion.id, assertion.title, false, `rejected with ${describeThrown(settled.error)} instead of returning an observation`);
354
- }
355
- else {
356
- result =
357
- settled.kind === 'rejected'
358
- ? checkRejected(assertion, assertion.expectation.code, settled.error)
359
- : titledOutcome(assertion.id, assertion.title, false, `resolved instead of rejecting with "${assertion.expectation.code}"`);
360
- }
361
- result = checkCommandCalls(assertion, subject, run.built, result);
362
- return withDispose(result, await disposeScenario(run.built));
363
- }
364
402
  /**
365
- * Fifteen outcomes: the six shared assertions plus the nine above. Unlike the
403
+ * Sixteen outcomes: the six shared assertions plus the ten above. Unlike the
366
404
  * `api` arm, every cap here has an assertion: a command subject's fixture
367
405
  * script can be told to overrun a byte cap directly, with no oversize-request
368
406
  * problem to work around.
369
407
  */
370
408
  export async function runCommandLineProbeConformance(subject) {
371
- const shared = await runSharedAssertions('probe', subject, probeParsers.response);
372
- const additional = [];
373
- for (const assertion of COMMAND_ASSERTIONS) {
374
- additional.push(await runCommandAssertion(assertion, subject));
409
+ return runArm(subject, 'command-probe', COMMAND_ASSERTIONS);
410
+ }
411
+ /**
412
+ * The authorization the subject's own policy resolves this request to.
413
+ *
414
+ * The two denial assertions read it to check the subject is internally
415
+ * consistent: a subject whose "unmapped" request is in fact mapped, or whose
416
+ * "unauthorized" tool is in fact on the allowlist, would pass its own denial
417
+ * for the wrong reason and certify an adapter that never refused anything.
418
+ */
419
+ function mcpAuthorizationFor(subject, request) {
420
+ return subject.policy.authorizations.find((each) => each.interfaceId === request.interfaceId);
421
+ }
422
+ /** The tool's structured result as a key map, or `undefined` when the observation carries no JSON object there. */
423
+ function structuredResultOf(observation) {
424
+ if (observation.kind !== 'mcp' || observation.result.kind !== 'json') {
425
+ return undefined;
375
426
  }
376
- return reportOf(subject.name, 'command-probe', [...shared, ...additional]);
427
+ const { value } = observation.result;
428
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
429
+ ? value
430
+ : undefined;
431
+ }
432
+ const MCP_ASSERTIONS = [
433
+ {
434
+ id: 'mcp/allow-authorized-tool-call',
435
+ title: 'an explicitly authorized tool call reaches its server and is observed',
436
+ request: (subject) => subject.authorizedRequest,
437
+ expectation: { kind: 'resolves' },
438
+ },
439
+ {
440
+ // The rule a tool-use adapter breaks first. A tool's own error result is
441
+ // the payload AD-10's seeded-fault check reads, so an adapter that
442
+ // throws on it makes every probe written to catch that refusal invisible.
443
+ //
444
+ // The non-`mcp` arm of the detail is reachable the way the other two
445
+ // arms' are: `errorResultRequest` is typed over the whole request union,
446
+ // so a subject may declare another kind there and answer it correlated,
447
+ // which is what gets past `echoMismatch` to this check.
448
+ id: 'mcp/observe-error-result',
449
+ title: 'a tool-reported error from an authorized server is an observation, not a fault',
450
+ request: (subject) => subject.errorResultRequest,
451
+ expectation: {
452
+ kind: 'resolves',
453
+ check: (observation) => {
454
+ if (observation.kind !== 'mcp') {
455
+ return `observed an observation of kind "${observation.kind}", expected the envelope's error flag set`;
456
+ }
457
+ if (!observation.isError) {
458
+ return "observed a tool call reporting no error, expected the envelope's error flag set";
459
+ }
460
+ // The flag alone is half the rule. An adapter that reads the
461
+ // envelope and drops what the tool said about the failure passes a
462
+ // flag-only check, and the seeded-fault oracle downstream then has
463
+ // the error announced and nothing to assert on.
464
+ //
465
+ // The detail names the subject's obligation rather than accusing
466
+ // the adapter, because the two are indistinguishable from here: a
467
+ // tool reporting a failure through a prose `content` array and no
468
+ // structured content produces the same absent channel, and the
469
+ // kind's first version describes structured results only.
470
+ return observation.result.kind === 'absent'
471
+ ? 'the error came back on an absent result channel; errorResultRequest has to name a tool whose failure carries structured content, since a tool that publishes none looks the same here as an adapter that dropped it'
472
+ : undefined;
473
+ },
474
+ },
475
+ },
476
+ {
477
+ id: 'mcp/deny-unmapped-interface',
478
+ title: 'an interface no authorization names is refused before a server process starts',
479
+ request: (subject) => subject.unmappedInterfaceRequest,
480
+ expectation: { kind: 'rejects', code: DENIED },
481
+ expectedCalls: (subject) => mcpAuthorizationFor(subject, subject.unmappedInterfaceRequest) ===
482
+ undefined
483
+ ? 0
484
+ : "the subject's policy names an authorization for unmappedInterfaceRequest.interfaceId, so the request it presents as unmapped is mapped",
485
+ },
486
+ {
487
+ // The tool allowlist is the second and last authorization-scoped field,
488
+ // and it draws AD-35's disclosure boundary inside the server: a tool the
489
+ // list omits is unreachable even when the server publishes it.
490
+ id: 'mcp/deny-unauthorized-tool',
491
+ title: 'a tool outside the authorized list is refused before a server process starts',
492
+ request: (subject) => subject.unauthorizedToolRequest,
493
+ expectation: { kind: 'rejects', code: DENIED },
494
+ expectedCalls: (subject) => {
495
+ const request = subject.unauthorizedToolRequest;
496
+ if (request.kind !== 'mcp') {
497
+ return `unauthorizedToolRequest declares a "${request.kind}" request, which names no tool for the policy to refuse`;
498
+ }
499
+ const authorization = mcpAuthorizationFor(subject, request);
500
+ if (authorization === undefined) {
501
+ return "the subject's policy names no authorization for unauthorizedToolRequest.interfaceId, so this case cannot tell an unmapped interface from an unauthorized tool";
502
+ }
503
+ return authorization.tools.includes(request.toolName)
504
+ ? `the subject's policy permits tool "${request.toolName}" on interface "${request.interfaceId}", so the request it presents as unauthorized is authorized`
505
+ : 0;
506
+ },
507
+ },
508
+ {
509
+ // The tool-call twin of the command arm's literal-argument proof. A
510
+ // declared value crosses a JSON-RPC frame here rather than a command
511
+ // line, so what this catches is a value re-encoded, coerced, or
512
+ // truncated in framing, and the metacharacters are what make a
513
+ // re-encoding visible in the echo.
514
+ id: 'mcp/arguments-passed-as-declared',
515
+ title: 'a declared argument value reaches the tool byte for byte',
516
+ request: (subject) => subject.argumentEchoRequest,
517
+ expectation: {
518
+ kind: 'resolves',
519
+ check: (observation, subject) => {
520
+ if (observation.kind !== 'mcp') {
521
+ return `observed an observation of kind "${observation.kind}", expected a tool call`;
522
+ }
523
+ const result = structuredResultOf(observation);
524
+ if (result === undefined) {
525
+ return 'the observation carries no structured result to read the received argument from';
526
+ }
527
+ const received = result[subject.argumentEchoResultKey];
528
+ return received === subject.argumentEchoValue
529
+ ? undefined
530
+ : `the tool reported receiving ${JSON.stringify(received)} on "${subject.argumentEchoResultKey}", expected the declared literal`;
531
+ },
532
+ },
533
+ },
534
+ {
535
+ // An adapter that reads the envelope and drops `structuredContent`
536
+ // still resolves and still correlates, and every oracle over the result
537
+ // then resolves absent. That reads as a contract nothing satisfies
538
+ // rather than as an adapter that answered nowhere, which is the failure
539
+ // this assertion exists to name.
540
+ id: 'mcp/observe-declared-result-channel',
541
+ title: "the tool's structured result is carried on the channel its descriptor describes",
542
+ request: (subject) => subject.structuredResultRequest,
543
+ expectation: {
544
+ kind: 'resolves',
545
+ check: (observation, subject) => {
546
+ if (observation.kind !== 'mcp') {
547
+ return `observed an observation of kind "${observation.kind}", expected a tool call`;
548
+ }
549
+ const result = structuredResultOf(observation);
550
+ if (result === undefined) {
551
+ return `the result channel carried ${JSON.stringify(observation.result)}, expected the structured result carrying ${JSON.stringify(subject.structuredResultKeys)}`;
552
+ }
553
+ const missing = subject.structuredResultKeys.filter((key) => !(key in result));
554
+ return missing.length === 0
555
+ ? undefined
556
+ : `the result channel is missing ${JSON.stringify(missing)}`;
557
+ },
558
+ },
559
+ },
560
+ {
561
+ id: 'mcp/cap-elapsed',
562
+ title: 'a session past maxElapsedMs is capped, not left running for the caller',
563
+ request: (subject) => subject.overElapsedRequest,
564
+ expectation: { kind: 'rejects', code: CAPPED },
565
+ },
566
+ {
567
+ id: 'mcp/cap-result-bytes',
568
+ title: 'a result past maxOutputBytes is capped, not returned in part',
569
+ request: (subject) => subject.overResultBytesRequest,
570
+ expectation: { kind: 'rejects', code: CAPPED },
571
+ },
572
+ ];
573
+ /**
574
+ * Fourteen outcomes: the six shared assertions plus the eight above. Fewer
575
+ * than either other arm because a tool-server authorization scopes two fields:
576
+ * one session is opened against one server and every tool it offers belongs to
577
+ * that server, so the interface identifier is the server identity and the tool
578
+ * allowlist is the only thing left to deny on.
579
+ *
580
+ * Two rules the reference adapter follows are outside what a green run
581
+ * certifies, and an author reading this should know which. A JSON-RPC error
582
+ * answering `tools/call` is an observation on the same terms a tool-reported
583
+ * error is, and the byte cap applies to the server's own stderr as well as its
584
+ * stdout. Neither has an assertion here, because the derivation above gives
585
+ * each arm one denial per authorization-scoped field and one assertion per cap
586
+ * the suite can make a subject exceed, and both of these are a second spelling
587
+ * of an assertion the arm already carries. The reference adapter's own tests
588
+ * cover both.
589
+ */
590
+ export async function runMcpProbeConformance(subject) {
591
+ return runArm(subject, 'mcp-probe', MCP_ASSERTIONS);
377
592
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eval-quality",
3
- "version": "1.4.2",
3
+ "version": "3.0.0",
4
4
  "description": "Compile disciplined Behavioral Evaluation Contracts and score their ability to catch known defects.",
5
5
  "author": "Murat Ozcan",
6
6
  "license": "Apache-2.0",
@@ -73,6 +73,8 @@
73
73
  "format": "biome format --write .",
74
74
  "check:docs": "node scripts/check-docs.mjs",
75
75
  "check:doc-invocations": "node scripts/check-doc-invocations.mjs",
76
+ "check:doc-counts": "node scripts/check-doc-counts.ts",
77
+ "check:doc-claims": "node scripts/check-doc-claims.ts",
76
78
  "lint:spine": "python3 scripts/spine-lint/lint_spine.py --registry-ad 5 --workspace-root . --fail-on high",
77
79
  "test:spine-lint": "uv run --with pytest pytest scripts/spine-lint/tests -q",
78
80
  "build:shareable": "node scripts/build-shareable.mjs",
@@ -110,7 +112,7 @@
110
112
  "release:major": "gh workflow run publish.yml --ref main -f bump=major",
111
113
  "release:prepare": "node scripts/release-prepare.mjs",
112
114
  "release:publish": "gh workflow run publish.yml --ref main -f bump=none",
113
- "validate": "npm run build && npm run typecheck && npm run lint && npm run check:docs && npm run check:doc-invocations && npm run check:shareable && npm run lint:spine && npm run check:vectors && npm run check:schemas && npm run check:ad5-registry && npm run check:ad28-registry && npm run check:ad31-table && npm run check:ad33-table && npm run check:ad21-table && npm run check:layers && npm run check:lineage && npm run check:boundary && npm run check:corpus && npm run check:worked-example && npm run check:website-deps && npm run test:coverage",
115
+ "validate": "npm run build && npm run typecheck && npm run lint && npm run check:docs && npm run check:doc-invocations && npm run check:shareable && npm run lint:spine && npm run check:vectors && npm run check:schemas && npm run check:ad5-registry && npm run check:ad28-registry && npm run check:ad31-table && npm run check:ad33-table && npm run check:ad21-table && npm run check:layers && npm run check:lineage && npm run check:boundary && npm run check:corpus && npm run check:doc-counts && npm run check:doc-claims && npm run check:worked-example && npm run check:website-deps && npm run test:coverage",
114
116
  "prepack": "npm run clean && npm run build",
115
117
  "prepublishOnly": "node scripts/assert-publish-authorized.mjs"
116
118
  },