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.
- package/README.md +6 -6
- package/corpus/dev/README.md +24 -13
- package/corpus/dev/compile-seal-example/brief.json +1 -1
- package/corpus/dev/compile-seal-example/contract.json +1 -1
- package/corpus/dev/contracts/absent-collection-locations.json +1 -1
- package/corpus/dev/contracts/absent-sibling-groups.json +1 -1
- package/corpus/dev/contracts/absent-success-indicator.json +1 -1
- package/corpus/dev/contracts/captured-read-back.json +1 -0
- package/corpus/dev/contracts/checklist-selection.json +1 -0
- package/corpus/dev/contracts/empty-channel-roles.json +1 -1
- package/corpus/dev/contracts/empty-collection-locations.json +1 -1
- package/corpus/dev/contracts/empty-request-shapes.json +1 -1
- package/corpus/dev/contracts/empty-sibling-groups.json +1 -1
- package/corpus/dev/contracts/fragment-selection.json +1 -1
- package/corpus/dev/contracts/no-collection-quantifier.json +1 -1
- package/corpus/dev/contracts/no-operation-inventory.json +1 -1
- package/corpus/dev/contracts/no-read-back-relation.json +1 -1
- package/corpus/dev/contracts/no-state-change-marker.json +1 -1
- package/corpus/dev/contracts/no-type-violating-step.json +1 -1
- package/corpus/dev/contracts/notes-tool-server.json +1 -0
- package/corpus/dev/contracts/per-key-split-oracles.json +1 -1
- package/corpus/dev/contracts/review-corpus.json +1 -1
- package/corpus/dev/contracts/satisfied-declarations.json +1 -1
- package/corpus/dev/contracts/single-required-response-key.json +1 -1
- package/corpus/dev/contracts/split-indicator-oracle.json +1 -1
- package/corpus/dev/contracts/unaddressed-parameter-sibling.json +1 -1
- package/corpus/dev/contracts/unnamed-reference-set.json +1 -1
- package/corpus/dev/contracts/wrong-cardinality-form.json +1 -1
- package/corpus/dev/index.json +1 -1
- package/dist/adapters/command-line-adapter.js +44 -6
- package/dist/adapters/index.d.ts +2 -0
- package/dist/adapters/index.js +1 -0
- package/dist/adapters/mcp-adapter.d.ts +32 -0
- package/dist/adapters/mcp-adapter.js +357 -0
- package/dist/adapters/mcp-target-policy.d.ts +39 -0
- package/dist/adapters/mcp-target-policy.js +30 -0
- package/dist/cli/render.d.ts +8 -3
- package/dist/cli/render.js +13 -6
- package/dist/cli/run.js +2 -1
- package/dist/core/compile/bindings.d.ts +8 -18
- package/dist/core/compile/bindings.js +13 -10
- package/dist/core/compile/compile.js +6 -1
- package/dist/core/compile/interface-inventory.d.ts +81 -3
- package/dist/core/compile/interface-inventory.js +134 -23
- package/dist/core/compile/reachability.d.ts +73 -1
- package/dist/core/compile/reachability.js +134 -19
- package/dist/core/compile/schema-version.d.ts +15 -2
- package/dist/core/compile/schema-version.js +11 -3
- package/dist/core/compile/sensitivity-witness.d.ts +22 -10
- package/dist/core/compile/sensitivity-witness.js +101 -13
- package/dist/core/coverage/operations.d.ts +1 -1
- package/dist/core/coverage/operations.js +1 -1
- package/dist/core/coverage/relevance.d.ts +3 -3
- package/dist/core/coverage/relevance.js +3 -3
- package/dist/core/declared-inputs.d.ts +21 -9
- package/dist/core/declared-inputs.js +51 -16
- package/dist/core/evaluate/evidence-resolution.d.ts +8 -7
- package/dist/core/evaluate/evidence-resolution.js +25 -18
- package/dist/core/preflight/plan.js +43 -6
- package/dist/core/preflight/projection.d.ts +10 -1
- package/dist/core/preflight/projection.js +9 -5
- package/dist/core/preflight/reduce.js +6 -2
- package/dist/core/preflight/witness-evidence.js +31 -9
- package/dist/core/schemas/artifact.d.ts +145 -49
- package/dist/core/schemas/defect-signature.d.ts +280 -23
- package/dist/core/schemas/defect-signature.js +65 -37
- package/dist/core/schemas/eval-contract.d.ts +33 -48
- package/dist/core/schemas/eval-contract.js +3 -3
- package/dist/core/schemas/interface.d.ts +193 -55
- package/dist/core/schemas/interface.js +82 -15
- package/dist/core/schemas/plan.d.ts +45 -1
- package/dist/core/schemas/plan.js +13 -2
- package/dist/core/schemas/pointer.d.ts +23 -9
- package/dist/core/schemas/pointer.js +25 -11
- package/dist/core/schemas/port-messages.d.ts +81 -0
- package/dist/core/schemas/port-messages.js +50 -3
- package/dist/core/schemas/primitives.d.ts +18 -0
- package/dist/core/schemas/primitives.js +29 -0
- package/dist/core/schemas/probe-policy.d.ts +41 -0
- package/dist/core/schemas/probe-policy.js +61 -1
- package/dist/core/schemas/probe.d.ts +156 -2
- package/dist/core/schemas/probe.js +45 -2
- package/dist/core/schemas/sealed-run-record.d.ts +11 -5
- package/dist/core/schemas/sealed-run-record.js +21 -9
- package/dist/core/schemas/sensitivity-witness.d.ts +31 -7
- package/dist/core/schemas/sensitivity-witness.js +32 -9
- package/dist/core/score/bindings.d.ts +1 -1
- package/dist/core/score/bindings.js +4 -4
- package/dist/core/score/qualification.d.ts +7 -5
- package/dist/core/score/qualification.js +92 -23
- package/dist/core/score/score.d.ts +1 -1
- package/dist/core/score/score.js +24 -1
- package/dist/core/seal/derived-reference.js +20 -17
- package/dist/core/seal/plan-index.d.ts +18 -9
- package/dist/core/seal/plan-index.js +79 -37
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/ports/environment-probe-port.d.ts +50 -17
- package/dist/ports/environment-probe-port.js +26 -17
- package/dist/testing/conformance.d.ts +3 -2
- package/dist/testing/conformance.js +2 -1
- package/dist/testing/index.d.ts +3 -3
- package/dist/testing/index.js +1 -1
- package/dist/testing/probe-conformance.d.ts +90 -13
- package/dist/testing/probe-conformance.js +375 -160
- package/package.json +4 -2
- package/schemas/eval-contract.schema.json +571 -18
- package/schemas/probe.schema.json +152 -12
- package/schemas/rubric.schema.json +1 -1
- package/schemas/sealed-run-record.schema.json +24 -17
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment-probe port over one MCP tool call, for the `mcp` mechanism.
|
|
3
|
+
* `evaluateMcpTarget` closes the authorization half; this file closes the
|
|
4
|
+
* execution half.
|
|
5
|
+
*
|
|
6
|
+
* Every rule below is what "what's a tool server allowed to do" resolves to, a
|
|
7
|
+
* decision settled here in the implementation, with no architecture revision
|
|
8
|
+
* behind it:
|
|
9
|
+
*
|
|
10
|
+
* 1. stdio, and no other transport. MCP defines two: stdio launches the server
|
|
11
|
+
* as a subprocess and speaks JSON-RPC over its standard streams, and
|
|
12
|
+
* Streamable HTTP speaks the same JSON-RPC over HTTP to a URL. AD-2 is
|
|
13
|
+
* unconditional that no module in the package performs network I/O and that
|
|
14
|
+
* v0 ships no network adapter at all, and its own history says that
|
|
15
|
+
* exception was deleted rather than narrowed. Launching a subprocess is
|
|
16
|
+
* mechanically what `command-line-adapter.ts` already does and opens no
|
|
17
|
+
* socket. A server behind a URL is the caller's own `EnvironmentProbePort`,
|
|
18
|
+
* proven with AD-37's suite, which is the same division that leaves `api`
|
|
19
|
+
* with no reference adapter here.
|
|
20
|
+
* 2. `target` is spawned directly with an argv array (`shell: false`, Node's
|
|
21
|
+
* own default, stated anyway since it is the one thing this adapter must
|
|
22
|
+
* never turn off). The tool's arguments travel inside the JSON-RPC frame
|
|
23
|
+
* and never as argv, so no channel value reaches a command line at all.
|
|
24
|
+
* `serverEnvironment` passes through as declared, over the host's own
|
|
25
|
+
* `PATH` so a `target` naming a bare command still resolves; a declared
|
|
26
|
+
* `PATH` key wins over that default. Nothing else of the host environment
|
|
27
|
+
* reaches the server.
|
|
28
|
+
* 3. One session per port invocation, opened and torn down inside `callTool`.
|
|
29
|
+
* AD-37's `single-underlying-call-on-success` counts the underlying
|
|
30
|
+
* mechanism, so a session reused across invocations would make the count
|
|
31
|
+
* depend on which one ran first. AD-35's caps are per-invocation, and a
|
|
32
|
+
* session held across legs leaves a hung server with no cap to catch it on
|
|
33
|
+
* the legs that follow. AD-10's state-reset differential compares two legs
|
|
34
|
+
* that must describe the same fixture state, so adapter-side state carried
|
|
35
|
+
* between them is a confound on the one measurement it exists to make.
|
|
36
|
+
* `maxElapsedMs` bounds the whole invocation, from launch through
|
|
37
|
+
* `initialize`, `tools/call`, and teardown: a handshake that never
|
|
38
|
+
* completes and a tool call that never answers are the same event to the
|
|
39
|
+
* caller. `maxOutputBytes` applies to the server's stdout and, separately,
|
|
40
|
+
* to its stderr, which the stdio transport reserves for logging; an
|
|
41
|
+
* undrained stderr pipe deadlocks the server once the OS buffer fills, and
|
|
42
|
+
* a drained one with no cap is an unbounded allocation. Teardown closes the
|
|
43
|
+
* server's stdin and then kills its whole process group: `npx -y <server>`
|
|
44
|
+
* is the ordinary launch shape, so killing the direct child alone leaves
|
|
45
|
+
* the server it started running, which is the state this rule exists to
|
|
46
|
+
* prevent.
|
|
47
|
+
* 4. A tool result carrying `isError: true` is an observation, and so is a
|
|
48
|
+
* JSON-RPC error answering `tools/call`. The server answered, and a server
|
|
49
|
+
* refusing a tool the contract declares is precisely the defect an oracle
|
|
50
|
+
* should be able to assert on; throwing would make it invisible. A server
|
|
51
|
+
* that refuses the `initialize` handshake answered a different question:
|
|
52
|
+
* the session never opened, so nothing observed the system, and that throws
|
|
53
|
+
* `port-failure` alongside a failure to start and a malformed frame. Only a
|
|
54
|
+
* policy denial, a cap, an abort, or a failure to establish the session
|
|
55
|
+
* throws.
|
|
56
|
+
*/
|
|
57
|
+
import { spawn } from 'node:child_process';
|
|
58
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
59
|
+
import { RuntimeFault } from '../core/schemas/faults.js';
|
|
60
|
+
import { probeParsers } from '../ports/environment-probe-port.js';
|
|
61
|
+
import { evaluateMcpTarget } from './mcp-target-policy.js';
|
|
62
|
+
import { runPortMethod } from './port-boundary.js';
|
|
63
|
+
/** The protocol revision this client announces. A server free to answer with a different one has still opened the session; a server that answers with a JSON-RPC error has refused it, and rule 4 says what happens then. */
|
|
64
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
65
|
+
const INITIALIZE_ID = 1;
|
|
66
|
+
const CALL_TOOL_ID = 2;
|
|
67
|
+
function capped(detail) {
|
|
68
|
+
return new RuntimeFault('budget-exhausted', 'McpProbeRequest', detail);
|
|
69
|
+
}
|
|
70
|
+
function forbidden(detail) {
|
|
71
|
+
return new RuntimeFault('forbidden-target', 'ProbeRequest', detail);
|
|
72
|
+
}
|
|
73
|
+
function buildEnv(declared) {
|
|
74
|
+
const base = {};
|
|
75
|
+
if (process.env.PATH !== undefined)
|
|
76
|
+
base.PATH = process.env.PATH;
|
|
77
|
+
return { ...base, ...declared };
|
|
78
|
+
}
|
|
79
|
+
const isJsonObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
80
|
+
/**
|
|
81
|
+
* A detached child leads its own process group, so the negative pid reaches
|
|
82
|
+
* every process it started. Windows has no process groups and throws here, so
|
|
83
|
+
* the direct child is the fallback.
|
|
84
|
+
*/
|
|
85
|
+
function killProcessGroup(child) {
|
|
86
|
+
const { pid } = child;
|
|
87
|
+
if (pid === undefined) {
|
|
88
|
+
child.kill('SIGKILL');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
process.kill(-pid, 'SIGKILL');
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
child.kill('SIGKILL');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Launches the server and frames JSON-RPC over its standard streams: one
|
|
100
|
+
* message per line, which is what the stdio transport specifies.
|
|
101
|
+
*
|
|
102
|
+
* Every way the session can die reaches the caller through one rejected
|
|
103
|
+
* `failure` promise that each request races against, so a cap, a malformed
|
|
104
|
+
* frame, a spawn failure, and an early exit all surface at the await that was
|
|
105
|
+
* waiting on the server. A listener that rejected nothing would leave the call
|
|
106
|
+
* to run out its elapsed budget for a fault already known.
|
|
107
|
+
*/
|
|
108
|
+
function startSession(request, signal) {
|
|
109
|
+
const child = spawn(request.target, [...request.targetArgs], {
|
|
110
|
+
cwd: request.cwd,
|
|
111
|
+
env: { ...request.env },
|
|
112
|
+
shell: false,
|
|
113
|
+
signal,
|
|
114
|
+
detached: true,
|
|
115
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
116
|
+
});
|
|
117
|
+
// Keyed by the id as text: JSON-RPC admits a string id, and a server that
|
|
118
|
+
// echoes `1` back as `"1"` is correlating correctly.
|
|
119
|
+
const pending = new Map();
|
|
120
|
+
let closed = false;
|
|
121
|
+
let broken = false;
|
|
122
|
+
let phase = 'launch';
|
|
123
|
+
let rejectFailure = () => { };
|
|
124
|
+
const failure = new Promise((_resolve, reject) => {
|
|
125
|
+
rejectFailure = reject;
|
|
126
|
+
});
|
|
127
|
+
// The race is usually won by a response, which leaves this rejection
|
|
128
|
+
// unobserved; without the handler that is an unhandled rejection.
|
|
129
|
+
failure.catch(() => { });
|
|
130
|
+
const fail = (error) => {
|
|
131
|
+
if (broken)
|
|
132
|
+
return;
|
|
133
|
+
broken = true;
|
|
134
|
+
rejectFailure(error);
|
|
135
|
+
};
|
|
136
|
+
const timer = setTimeout(() => {
|
|
137
|
+
fail(capped(`the session exceeded maxElapsedMs (${request.maxElapsedMs}ms) during ${phase} and was torn down`));
|
|
138
|
+
}, request.maxElapsedMs);
|
|
139
|
+
let stdoutBytes = 0;
|
|
140
|
+
let stdoutBuffer = '';
|
|
141
|
+
let stderrBytes = 0;
|
|
142
|
+
// Chunk boundaries fall inside multi-byte characters, and decoding each
|
|
143
|
+
// chunk on its own replaces the split character with U+FFFD. The corruption
|
|
144
|
+
// survives `JSON.parse`, so it would reach the observation body an oracle
|
|
145
|
+
// asserts on and the fixture digest covers.
|
|
146
|
+
const decoder = new StringDecoder('utf8');
|
|
147
|
+
const handleLine = (line) => {
|
|
148
|
+
let message;
|
|
149
|
+
try {
|
|
150
|
+
message = JSON.parse(line);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
fail(new Error(`the server wrote bytes on stdout that are not a JSON-RPC message: ${line.slice(0, 200)}`));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!isJsonObject(message))
|
|
157
|
+
return;
|
|
158
|
+
const { id } = message;
|
|
159
|
+
if (typeof id !== 'number' && typeof id !== 'string')
|
|
160
|
+
return;
|
|
161
|
+
if (message.method !== undefined)
|
|
162
|
+
return;
|
|
163
|
+
const settle = pending.get(String(id));
|
|
164
|
+
if (settle === undefined)
|
|
165
|
+
return;
|
|
166
|
+
pending.delete(String(id));
|
|
167
|
+
settle({ result: message.result, error: message.error });
|
|
168
|
+
};
|
|
169
|
+
const drainLines = () => {
|
|
170
|
+
let newlineAt = stdoutBuffer.indexOf('\n');
|
|
171
|
+
while (newlineAt !== -1) {
|
|
172
|
+
const line = stdoutBuffer.slice(0, newlineAt);
|
|
173
|
+
stdoutBuffer = stdoutBuffer.slice(newlineAt + 1);
|
|
174
|
+
if (line.trim() !== '')
|
|
175
|
+
handleLine(line);
|
|
176
|
+
newlineAt = stdoutBuffer.indexOf('\n');
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
child.stdout?.on('data', (chunk) => {
|
|
180
|
+
stdoutBytes += chunk.byteLength;
|
|
181
|
+
if (stdoutBytes > request.maxOutputBytes) {
|
|
182
|
+
fail(capped(`the server wrote past maxOutputBytes (${request.maxOutputBytes}) on stdout and the session was torn down`));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
stdoutBuffer += decoder.write(chunk);
|
|
186
|
+
drainLines();
|
|
187
|
+
});
|
|
188
|
+
// A server that wrote a whole frame and closed without a trailing newline
|
|
189
|
+
// has answered, and dropping the residue would report a fault for a
|
|
190
|
+
// complete response.
|
|
191
|
+
child.stdout?.on('end', () => {
|
|
192
|
+
stdoutBuffer += decoder.end();
|
|
193
|
+
drainLines();
|
|
194
|
+
const residual = stdoutBuffer;
|
|
195
|
+
stdoutBuffer = '';
|
|
196
|
+
if (residual.trim() !== '')
|
|
197
|
+
handleLine(residual);
|
|
198
|
+
});
|
|
199
|
+
// Drained rather than ignored: the transport reserves stderr for logging and
|
|
200
|
+
// an unread pipe deadlocks the server once the OS buffer fills.
|
|
201
|
+
child.stderr?.on('data', (chunk) => {
|
|
202
|
+
stderrBytes += chunk.byteLength;
|
|
203
|
+
if (stderrBytes > request.maxOutputBytes) {
|
|
204
|
+
fail(capped(`the server wrote past maxOutputBytes (${request.maxOutputBytes}) on its own stderr and the session was torn down`));
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
// A server that exits before reading its stdin raises EPIPE on this stream
|
|
208
|
+
// rather than on `child`; with no listener that crashes the host process.
|
|
209
|
+
child.stdin?.on('error', () => { });
|
|
210
|
+
child.once('error', (error) => {
|
|
211
|
+
fail(error);
|
|
212
|
+
});
|
|
213
|
+
child.once('close', () => {
|
|
214
|
+
if (closed)
|
|
215
|
+
return;
|
|
216
|
+
fail(new Error(`the server exited during ${phase}`));
|
|
217
|
+
});
|
|
218
|
+
const send = (message) => {
|
|
219
|
+
child.stdin?.write(`${JSON.stringify(message)}\n`);
|
|
220
|
+
};
|
|
221
|
+
return {
|
|
222
|
+
request: (id, method, params) => {
|
|
223
|
+
phase = method;
|
|
224
|
+
return Promise.race([
|
|
225
|
+
new Promise((settle) => {
|
|
226
|
+
pending.set(String(id), settle);
|
|
227
|
+
send({ jsonrpc: '2.0', id, method, params });
|
|
228
|
+
}),
|
|
229
|
+
failure,
|
|
230
|
+
]);
|
|
231
|
+
},
|
|
232
|
+
notify: (method, params) => {
|
|
233
|
+
send({ jsonrpc: '2.0', method, params });
|
|
234
|
+
},
|
|
235
|
+
close: () => {
|
|
236
|
+
closed = true;
|
|
237
|
+
// Set here too, so a late over-cap chunk or a malformed residual line
|
|
238
|
+
// arriving after teardown cannot reject a settled session.
|
|
239
|
+
broken = true;
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
// The stdio transport's own teardown order: close the server's input
|
|
242
|
+
// stream first, since a well-behaved server exits when it ends.
|
|
243
|
+
child.stdin?.end();
|
|
244
|
+
killProcessGroup(child);
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/** Whether a JSON-RPC frame carried an error. An explicit `null` is the absence of one. */
|
|
249
|
+
const errorOf = (response) => response.error === undefined || response.error === null
|
|
250
|
+
? undefined
|
|
251
|
+
: response.error;
|
|
252
|
+
/** The result the tool published, or the error object the server answered with. */
|
|
253
|
+
function resultOf(response) {
|
|
254
|
+
const error = errorOf(response);
|
|
255
|
+
if (error !== undefined)
|
|
256
|
+
return { isError: true, structuredResult: error };
|
|
257
|
+
if (!isJsonObject(response.result))
|
|
258
|
+
return { isError: false };
|
|
259
|
+
const { structuredContent, isError } = response.result;
|
|
260
|
+
if (structuredContent === undefined)
|
|
261
|
+
return { isError: isError === true };
|
|
262
|
+
return { isError: isError === true, structuredResult: structuredContent };
|
|
263
|
+
}
|
|
264
|
+
async function callToolOverStdio(request, signal) {
|
|
265
|
+
const session = startSession(request, signal);
|
|
266
|
+
try {
|
|
267
|
+
const handshake = await session.request(INITIALIZE_ID, 'initialize', {
|
|
268
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
269
|
+
capabilities: {},
|
|
270
|
+
clientInfo: { name: 'eval-quality', version: '0' },
|
|
271
|
+
});
|
|
272
|
+
const refusal = errorOf(handshake);
|
|
273
|
+
if (refusal !== undefined) {
|
|
274
|
+
throw new Error(`the server refused the initialize handshake: ${JSON.stringify(refusal)}`);
|
|
275
|
+
}
|
|
276
|
+
// JSON-RPC requires exactly one of `result` and `error`. A frame with
|
|
277
|
+
// neither says nothing about whether the session opened, and reading it
|
|
278
|
+
// as consent is the same failure as reading a refusal that way.
|
|
279
|
+
if (handshake.result === undefined) {
|
|
280
|
+
throw new Error('the server answered the initialize handshake with neither a result nor an error');
|
|
281
|
+
}
|
|
282
|
+
session.notify('notifications/initialized', {});
|
|
283
|
+
const response = await session.request(CALL_TOOL_ID, 'tools/call', {
|
|
284
|
+
name: request.toolName,
|
|
285
|
+
arguments: { ...request.arguments },
|
|
286
|
+
});
|
|
287
|
+
return resultOf(response);
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
session.close();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The real mechanism: an actual server process over an actual stdio session.
|
|
295
|
+
* Exported for the same reason `nodeCommandMechanism` is: AD-37's conformance
|
|
296
|
+
* subject for this port has to exercise a real handshake, a real cap, and a
|
|
297
|
+
* real teardown, and a synthetic mechanism would prove nothing about the one
|
|
298
|
+
* thing this adapter exists to get right.
|
|
299
|
+
*/
|
|
300
|
+
export const nodeStdioMcpMechanism = {
|
|
301
|
+
callTool: callToolOverStdio,
|
|
302
|
+
};
|
|
303
|
+
const bodyOf = (structuredResult) => structuredResult === undefined
|
|
304
|
+
? { kind: 'absent' }
|
|
305
|
+
: { kind: 'json', value: structuredResult };
|
|
306
|
+
export function createMcpAdapter(policy, mechanism = nodeStdioMcpMechanism) {
|
|
307
|
+
return {
|
|
308
|
+
probe: (request, signal) => runPortMethod({
|
|
309
|
+
request,
|
|
310
|
+
requestParser: probeParsers.request,
|
|
311
|
+
responseParser: probeParsers.response,
|
|
312
|
+
requestPath: 'ProbeRequest',
|
|
313
|
+
responsePath: 'ProbeObservation',
|
|
314
|
+
signal,
|
|
315
|
+
mechanism: async (parsed, innerSignal) => {
|
|
316
|
+
if (parsed.kind !== 'mcp') {
|
|
317
|
+
// This adapter authorizes no target of any other kind, so such
|
|
318
|
+
// a request meets the same "the mapping names nothing" denial an
|
|
319
|
+
// unmapped interfaceId would, before any server starts. The
|
|
320
|
+
// message names the kind that arrived, because the request union
|
|
321
|
+
// carries more than one kind this adapter refuses.
|
|
322
|
+
throw forbidden(`this adapter runs mcp requests only; no ${parsed.kind} target is ever authorized`);
|
|
323
|
+
}
|
|
324
|
+
const decision = evaluateMcpTarget(policy, {
|
|
325
|
+
interfaceId: parsed.interfaceId,
|
|
326
|
+
toolName: parsed.toolName,
|
|
327
|
+
});
|
|
328
|
+
if (!decision.allowed)
|
|
329
|
+
throw forbidden(decision.detail);
|
|
330
|
+
const { authorization } = decision;
|
|
331
|
+
const callResult = await mechanism.callTool({
|
|
332
|
+
target: authorization.target,
|
|
333
|
+
targetArgs: authorization.targetArgs,
|
|
334
|
+
toolName: parsed.toolName,
|
|
335
|
+
arguments: parsed.channels.arguments,
|
|
336
|
+
env: buildEnv(authorization.serverEnvironment),
|
|
337
|
+
cwd: authorization.cwd,
|
|
338
|
+
maxElapsedMs: authorization.maxElapsedMs,
|
|
339
|
+
maxOutputBytes: authorization.maxOutputBytes,
|
|
340
|
+
}, innerSignal);
|
|
341
|
+
return { parsed, callResult };
|
|
342
|
+
},
|
|
343
|
+
assemble: (raw) => {
|
|
344
|
+
const { parsed, callResult } = raw;
|
|
345
|
+
const observation = {
|
|
346
|
+
kind: 'mcp',
|
|
347
|
+
probeId: parsed.probeId,
|
|
348
|
+
interfaceId: parsed.interfaceId,
|
|
349
|
+
operationId: parsed.operationId,
|
|
350
|
+
isError: callResult.isError,
|
|
351
|
+
result: bodyOf(callResult.structuredResult),
|
|
352
|
+
};
|
|
353
|
+
return observation;
|
|
354
|
+
},
|
|
355
|
+
}),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AD-35's default-deny decision for the `mcp` mechanism, as a pure function.
|
|
3
|
+
*
|
|
4
|
+
* Lives under `adapters/` for the reason `command-target-policy.ts` records
|
|
5
|
+
* for itself: the mechanically-enforced dependency direction permits
|
|
6
|
+
* `adapters/` to import `core/schemas` and never `core/`, so a
|
|
7
|
+
* target-authorization decision a shipped adapter calls has to be
|
|
8
|
+
* adapter-owned. The declared shapes stay in `core/schemas/probe-policy.ts`
|
|
9
|
+
* beside the other two, which keeps all three mappings comparable in one file.
|
|
10
|
+
*/
|
|
11
|
+
import type { McpTargetAuthorization, McpTargetPolicy } from '../core/schemas/probe-policy.ts';
|
|
12
|
+
/** Why a tool server target was denied. Thrown as the single AD-28 `forbidden-target` fault, same as the HTTP and command reasons. */
|
|
13
|
+
export declare const MCP_DENIAL_REASONS: readonly ['interface-not-authorized', 'tool-not-authorized'];
|
|
14
|
+
export type McpDenialReason = (typeof MCP_DENIAL_REASONS)[number];
|
|
15
|
+
export type McpResolvedTarget = {
|
|
16
|
+
readonly interfaceId: string;
|
|
17
|
+
readonly toolName: string;
|
|
18
|
+
};
|
|
19
|
+
export type McpPolicyDecision = {
|
|
20
|
+
readonly allowed: true;
|
|
21
|
+
readonly authorization: McpTargetAuthorization;
|
|
22
|
+
} | {
|
|
23
|
+
readonly allowed: false;
|
|
24
|
+
readonly reason: McpDenialReason;
|
|
25
|
+
readonly detail: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The interface check runs first, so an unmapped server never reaches the tool
|
|
29
|
+
* comparison and the two denials stay distinguishable.
|
|
30
|
+
*
|
|
31
|
+
* The first authorization naming the interface is the only one consulted, and
|
|
32
|
+
* `McpTargetPolicy` refuses a second entry naming it. For this mechanism the
|
|
33
|
+
* interface identifier is the server identity, so searching on past a
|
|
34
|
+
* non-matching tool list would let one logical interface resolve to a second
|
|
35
|
+
* binary depending on which tool was asked for. `evaluateCommandTarget` does
|
|
36
|
+
* search on, and can: its entries are keyed by `(interfaceId, executable)`, so
|
|
37
|
+
* every candidate it considers runs the same executable.
|
|
38
|
+
*/
|
|
39
|
+
export declare function evaluateMcpTarget(policy: McpTargetPolicy, target: McpResolvedTarget): McpPolicyDecision;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Why a tool server target was denied. Thrown as the single AD-28 `forbidden-target` fault, same as the HTTP and command reasons. */
|
|
2
|
+
export const MCP_DENIAL_REASONS = [
|
|
3
|
+
'interface-not-authorized',
|
|
4
|
+
'tool-not-authorized',
|
|
5
|
+
];
|
|
6
|
+
function deny(reason, detail) {
|
|
7
|
+
return { allowed: false, reason, detail };
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The interface check runs first, so an unmapped server never reaches the tool
|
|
11
|
+
* comparison and the two denials stay distinguishable.
|
|
12
|
+
*
|
|
13
|
+
* The first authorization naming the interface is the only one consulted, and
|
|
14
|
+
* `McpTargetPolicy` refuses a second entry naming it. For this mechanism the
|
|
15
|
+
* interface identifier is the server identity, so searching on past a
|
|
16
|
+
* non-matching tool list would let one logical interface resolve to a second
|
|
17
|
+
* binary depending on which tool was asked for. `evaluateCommandTarget` does
|
|
18
|
+
* search on, and can: its entries are keyed by `(interfaceId, executable)`, so
|
|
19
|
+
* every candidate it considers runs the same executable.
|
|
20
|
+
*/
|
|
21
|
+
export function evaluateMcpTarget(policy, target) {
|
|
22
|
+
const authorization = policy.authorizations.find((candidate) => candidate.interfaceId === target.interfaceId);
|
|
23
|
+
if (authorization === undefined) {
|
|
24
|
+
return deny('interface-not-authorized', `no authorization names interface "${target.interfaceId}"`);
|
|
25
|
+
}
|
|
26
|
+
if (authorization.tools.includes(target.toolName)) {
|
|
27
|
+
return { allowed: true, authorization };
|
|
28
|
+
}
|
|
29
|
+
return deny('tool-not-authorized', `tool "${target.toolName}" is not among the authorized tools for interface "${target.interfaceId}"`);
|
|
30
|
+
}
|
package/dist/cli/render.d.ts
CHANGED
|
@@ -23,9 +23,14 @@ export declare function renderError(error: unknown): string;
|
|
|
23
23
|
*/
|
|
24
24
|
export declare function renderQualificationFailure(failure: QualificationFailure): string;
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* The seven codes the binary can take, one line each: AD-21's six, plus
|
|
27
|
+
* `EX_USAGE`, which `exit-codes.ts` records as sitting outside AD-21.
|
|
28
|
+
*
|
|
29
|
+
* The `--help` output is this text, and `docs/reference/cli-commands.md`
|
|
30
|
+
* transcribes it. `tests/cli/render.test.ts` holds the README's markdown table
|
|
31
|
+
* against these rows, and `check:doc-claims` holds the transcription against
|
|
32
|
+
* this string, so neither copy can drift.
|
|
28
33
|
*/
|
|
29
|
-
export declare const EXIT_CODE_TABLE = "Exit codes (AD-21):\n 0 success, and every verdict other than FAIL or a promoted CONCERNS\n 1 CONCERNS promoted by --strict\n 2 FAIL\n 3 invalid: a failed pre-flight, or any other AD-21 invalidating condition\n 4 structural failure\n 5 runtime fault\n 64 usage error\n\n --strict never promotes a CONCERNS whose firing conditions are all evidence\n conditions: those report that the measurement fell short of the policy.\n 1 and 2 come from the score command's verdict ladder. 3 comes from a failed\n pre-flight, which the preflight command reports, or from any other\n invalidating condition score finds.";
|
|
34
|
+
export declare const EXIT_CODE_TABLE = "Exit codes (AD-21's six, plus 64 from sysexits.h):\n 0 success, and every verdict other than FAIL or a promoted CONCERNS\n 1 CONCERNS promoted by --strict\n 2 FAIL\n 3 invalid: a failed pre-flight, or any other AD-21 invalidating condition\n 4 structural failure\n 5 runtime fault\n 64 usage error\n\n --strict never promotes a CONCERNS whose firing conditions are all evidence\n conditions: those report that the measurement fell short of the policy.\n 1 and 2 come from the score command's verdict ladder. 3 comes from a failed\n pre-flight, which the preflight command reports, or from any other\n invalidating condition score finds.";
|
|
30
35
|
/** `eval-quality: usage: <message>` */
|
|
31
36
|
export declare function renderUsage(message: string): string;
|
package/dist/cli/render.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The four shapes the binary writes, and the exit-code table two documents
|
|
3
|
-
* share. Every line the CLI emits is produced here
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* share. Every line the CLI emits is produced here apart from what `main.ts`
|
|
4
|
+
* writes for a rethrown defect, which is that error's own stack or message, so a
|
|
5
|
+
* format change is one file. Five
|
|
6
|
+
* renderers over the four shapes: a qualification failure and an error both
|
|
7
|
+
* print `<code>: <artifactPath>: <detail>`.
|
|
6
8
|
*/
|
|
7
9
|
import { z } from 'zod';
|
|
8
10
|
import { RuntimeFault, StructuralFailure, serializeArtifact, } from '../application/index.js';
|
|
@@ -93,10 +95,15 @@ export function renderQualificationFailure(failure) {
|
|
|
93
95
|
return `${PREFIX}: ${failure.code}: ${failure.artifactPath}: ${failure.detail}`;
|
|
94
96
|
}
|
|
95
97
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
+
* The seven codes the binary can take, one line each: AD-21's six, plus
|
|
99
|
+
* `EX_USAGE`, which `exit-codes.ts` records as sitting outside AD-21.
|
|
100
|
+
*
|
|
101
|
+
* The `--help` output is this text, and `docs/reference/cli-commands.md`
|
|
102
|
+
* transcribes it. `tests/cli/render.test.ts` holds the README's markdown table
|
|
103
|
+
* against these rows, and `check:doc-claims` holds the transcription against
|
|
104
|
+
* this string, so neither copy can drift.
|
|
98
105
|
*/
|
|
99
|
-
export const EXIT_CODE_TABLE = `Exit codes (AD-21):
|
|
106
|
+
export const EXIT_CODE_TABLE = `Exit codes (AD-21's six, plus 64 from sysexits.h):
|
|
100
107
|
0 success, and every verdict other than FAIL or a promoted CONCERNS
|
|
101
108
|
1 CONCERNS promoted by --strict
|
|
102
109
|
2 FAIL
|
package/dist/cli/run.js
CHANGED
|
@@ -232,7 +232,8 @@ async function runCommand(invocation, environment, application) {
|
|
|
232
232
|
environment.writeDiagnostic(renderError(error));
|
|
233
233
|
return { outcome: { kind: 'fault' } };
|
|
234
234
|
}
|
|
235
|
-
// A defect in our own code
|
|
235
|
+
// A defect in our own code is rethrown, and `main.ts` writes its stack and
|
|
236
|
+
// takes exit 5 for it.
|
|
236
237
|
throw error;
|
|
237
238
|
}
|
|
238
239
|
}
|
|
@@ -2,28 +2,15 @@ import type { EvalContract } from '../schemas/eval-contract.ts';
|
|
|
2
2
|
import type { InteractionStep } from '../schemas/plan.ts';
|
|
3
3
|
import type { InputChannelName } from '../schemas/pointer.ts';
|
|
4
4
|
import { type EvidenceTarget } from '../seal/plan-index.ts';
|
|
5
|
-
/**
|
|
6
|
-
* The one channel a captured pointer may name: whichever channel the
|
|
7
|
-
* referenced operation's own response descriptor describes.
|
|
8
|
-
*
|
|
9
|
-
* `ResponseDescriptor` declares `requiredKeys`, `permittedKeys`, `types`,
|
|
10
|
-
* `successIndicator`, `channelRoles`, and `collectionLocations`, and every one
|
|
11
|
-
* of them is about the channel the operation nominates. Off an interface that
|
|
12
|
-
* speaks HTTP that is the response body; off a command it is the stream the
|
|
13
|
-
* operation names. `response-headers` and `response-status` are never it,
|
|
14
|
-
* because `Observation.responseHeaders` admits objects, arrays, numbers, and
|
|
15
|
-
* `null`, so a header capture compiled as a `string` could resolve to an
|
|
16
|
-
* object at score time.
|
|
17
|
-
*/
|
|
18
5
|
/** One `{ captured }` binding, resolved to the pointer target it addresses. */
|
|
19
6
|
export type CapturedBinding = {
|
|
20
|
-
readonly
|
|
7
|
+
readonly inputChannel: InputChannelName;
|
|
21
8
|
readonly key: string;
|
|
22
9
|
readonly pointer: string;
|
|
23
10
|
readonly target: EvidenceTarget;
|
|
24
11
|
};
|
|
25
12
|
/**
|
|
26
|
-
* Every captured binding one step declares, in fixed
|
|
13
|
+
* Every captured binding one step declares, in fixed input-channel order
|
|
27
14
|
* then by key name, so which binding a check reports never depends on a
|
|
28
15
|
* caller-keyed map's insertion order. Exported because `score/binding-order.ts`
|
|
29
16
|
* and `score/bindings.ts` need the same reading of which bindings are captures.
|
|
@@ -66,9 +53,12 @@ export declare function checkBindingCycle(contract: EvalContract): void;
|
|
|
66
53
|
* Every other channel is refused for a reason that does not depend on the
|
|
67
54
|
* kind: `call-inputs` addresses a step's own request, and `response-headers`,
|
|
68
55
|
* `response-status`, and `exit-code` have no declared structure to give a
|
|
69
|
-
* captured value a type.
|
|
70
|
-
*
|
|
71
|
-
*
|
|
56
|
+
* captured value a type. `response-headers` is the sharpest of the three,
|
|
57
|
+
* because `Observation.responseHeaders` admits objects, arrays, numbers, and
|
|
58
|
+
* `null`, so a header capture compiled as a `string` could resolve to an object
|
|
59
|
+
* at score time. On the `artifact` channel the identifier is compared too, so a
|
|
60
|
+
* capture from a file the operation writes but does not describe is refused
|
|
61
|
+
* alongside one from a file it never writes.
|
|
72
62
|
*/
|
|
73
63
|
export declare function checkCapturedChannel(contract: EvalContract): void;
|
|
74
64
|
/**
|
|
@@ -22,14 +22,14 @@ import { JsonTypeName } from '../schemas/primitives.js';
|
|
|
22
22
|
import { anyOperationOf, buildPlanIndex, parseEvidenceTarget, resolveStep, } from '../seal/plan-index.js';
|
|
23
23
|
import { evaluatePointerReachability } from './reachability.js';
|
|
24
24
|
/**
|
|
25
|
-
* Every captured binding one step declares, in fixed
|
|
25
|
+
* Every captured binding one step declares, in fixed input-channel order
|
|
26
26
|
* then by key name, so which binding a check reports never depends on a
|
|
27
27
|
* caller-keyed map's insertion order. Exported because `score/binding-order.ts`
|
|
28
28
|
* and `score/bindings.ts` need the same reading of which bindings are captures.
|
|
29
29
|
*/
|
|
30
30
|
export function capturedBindings(step) {
|
|
31
31
|
const captures = [];
|
|
32
|
-
for (const { channel:
|
|
32
|
+
for (const { channel: inputChannel, bound: map } of boundChannelsOf(step.inputBinding)) {
|
|
33
33
|
if (map === null)
|
|
34
34
|
continue;
|
|
35
35
|
for (const key of Object.keys(map).sort()) {
|
|
@@ -37,7 +37,7 @@ export function capturedBindings(step) {
|
|
|
37
37
|
if (value === undefined || !('captured' in value))
|
|
38
38
|
continue;
|
|
39
39
|
captures.push({
|
|
40
|
-
|
|
40
|
+
inputChannel,
|
|
41
41
|
key,
|
|
42
42
|
pointer: value.captured,
|
|
43
43
|
target: parseEvidenceTarget(value.captured),
|
|
@@ -50,7 +50,7 @@ export function capturedBindings(step) {
|
|
|
50
50
|
// (`interface-inventory.ts`'s undeclared-key throw), reused so the two new
|
|
51
51
|
// codes and the widened `undeclared-mandatory-input` agree on one spelling.
|
|
52
52
|
function bindingPath(step, capture) {
|
|
53
|
-
return `EvalContract.interactionPlan[stepId=${step.stepId}].inputBinding.${capture.
|
|
53
|
+
return `EvalContract.interactionPlan[stepId=${step.stepId}].inputBinding.${capture.inputChannel}[${JSON.stringify(capture.key)}]`;
|
|
54
54
|
}
|
|
55
55
|
// ---- binding-cycle -------------------------------------------------------
|
|
56
56
|
/**
|
|
@@ -166,9 +166,12 @@ function stronglyConnectedComponents(nodes, edgesFrom) {
|
|
|
166
166
|
* Every other channel is refused for a reason that does not depend on the
|
|
167
167
|
* kind: `call-inputs` addresses a step's own request, and `response-headers`,
|
|
168
168
|
* `response-status`, and `exit-code` have no declared structure to give a
|
|
169
|
-
* captured value a type.
|
|
170
|
-
*
|
|
171
|
-
*
|
|
169
|
+
* captured value a type. `response-headers` is the sharpest of the three,
|
|
170
|
+
* because `Observation.responseHeaders` admits objects, arrays, numbers, and
|
|
171
|
+
* `null`, so a header capture compiled as a `string` could resolve to an object
|
|
172
|
+
* at score time. On the `artifact` channel the identifier is compared too, so a
|
|
173
|
+
* capture from a file the operation writes but does not describe is refused
|
|
174
|
+
* alongside one from a file it never writes.
|
|
172
175
|
*/
|
|
173
176
|
export function checkCapturedChannel(contract) {
|
|
174
177
|
let index;
|
|
@@ -251,7 +254,7 @@ function boundParameterType(step, capture, index) {
|
|
|
251
254
|
const operation = anyOperationOf(index, step.operationId);
|
|
252
255
|
if (operation === undefined)
|
|
253
256
|
return null;
|
|
254
|
-
const shape = requestShapeOf(operation, capture.
|
|
257
|
+
const shape = requestShapeOf(operation, capture.inputChannel);
|
|
255
258
|
// A channel the operation does not accept input on declares no type for
|
|
256
259
|
// the key either, so there is nothing to compare and the check abstains
|
|
257
260
|
// for the same reason the undeclared-key branch below does.
|
|
@@ -269,7 +272,7 @@ function boundParameterType(step, capture, index) {
|
|
|
269
272
|
const declared = shape.types[capture.key];
|
|
270
273
|
if (declared === undefined || declared === null) {
|
|
271
274
|
return {
|
|
272
|
-
reason: `binds ${capture.
|
|
275
|
+
reason: `binds ${capture.inputChannel} parameter "${capture.key}", whose type operation "${operation.operationId}" ${declared === undefined ? 'does not declare' : 'declares indeterminate'}, so no type equality is decidable`,
|
|
273
276
|
};
|
|
274
277
|
}
|
|
275
278
|
return { type: declared };
|
|
@@ -327,7 +330,7 @@ export function checkCapturedReachability(contract) {
|
|
|
327
330
|
throw new StructuralFailure('unreachable-check-evidence', path, `captured pointer "${capture.pointer}" resolves to a declared "${captured.type}", but the step ${bound.reason}`);
|
|
328
331
|
}
|
|
329
332
|
if (bound.type !== captured.type) {
|
|
330
|
-
throw new StructuralFailure('unreachable-check-evidence', path, `captured pointer "${capture.pointer}" resolves to a declared "${captured.type}", which is not the "${bound.type}" the bound ${capture.
|
|
333
|
+
throw new StructuralFailure('unreachable-check-evidence', path, `captured pointer "${capture.pointer}" resolves to a declared "${captured.type}", which is not the "${bound.type}" the bound ${capture.inputChannel} parameter "${capture.key}" is declared as`);
|
|
331
334
|
}
|
|
332
335
|
}
|
|
333
336
|
}
|
|
@@ -55,7 +55,12 @@ export function compile(contract, options) {
|
|
|
55
55
|
// First, and before any check reads a declaration. AD-11 makes an unequal
|
|
56
56
|
// stamp a rejection rather than a degraded read, and every check below is
|
|
57
57
|
// written against this version's field shapes.
|
|
58
|
-
checkSchemaVersion(
|
|
58
|
+
checkSchemaVersion({
|
|
59
|
+
stamped: contract.schemaVersion,
|
|
60
|
+
accepted: EVAL_CONTRACT_SCHEMA_VERSION,
|
|
61
|
+
artifactPath: 'EvalContract.schemaVersion',
|
|
62
|
+
consequence: 'since its stale version would travel into the scoring version',
|
|
63
|
+
});
|
|
59
64
|
checkRequirementLinkage(contract);
|
|
60
65
|
checkObservableSuccessCriterion(contract);
|
|
61
66
|
// Ahead of reachability, because a pointer naming an artifact nothing
|