eval-quality 0.3.0 → 1.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 +14 -9
- package/dist/adapters/command-line-adapter.d.ts +44 -0
- package/dist/adapters/command-line-adapter.js +284 -0
- package/dist/adapters/command-target-policy.d.ts +36 -0
- package/dist/adapters/command-target-policy.js +33 -0
- package/dist/adapters/index.d.ts +2 -0
- package/dist/adapters/index.js +1 -0
- package/dist/core/schemas/probe-policy.d.ts +36 -0
- package/dist/core/schemas/probe-policy.js +44 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/testing/conformance.d.ts +2 -1
- package/dist/testing/conformance.js +1 -0
- package/dist/testing/index.d.ts +3 -3
- package/dist/testing/index.js +1 -1
- package/dist/testing/probe-conformance.d.ts +56 -34
- package/dist/testing/probe-conformance.js +162 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -267,7 +267,7 @@ import spec from 'eval-quality/schemas/eval-contract.schema.json' with { type: '
|
|
|
267
267
|
The import attribute is required: ESM on Node 22 and 24 both throw `ERR_IMPORT_ATTRIBUTE_MISSING`
|
|
268
268
|
without it. The development corpus ships the same way, at `eval-quality/corpus/dev/`, so an adopter
|
|
269
269
|
can read real compiled contracts and one compiled-and-sealed pair without cloning this repository.
|
|
270
|
-
`eval-quality/adapters` is one of the five published subpaths, holding the
|
|
270
|
+
`eval-quality/adapters` is one of the five published subpaths, holding the four reference adapters
|
|
271
271
|
the conformance suite runs against.
|
|
272
272
|
|
|
273
273
|
Eleven of the twelve published schemas carry a `schemaVersion`. `artifact-reference` is exempt: it
|
|
@@ -378,10 +378,10 @@ names as that artifact's producer, which today are `src/core/seal/seal.ts`,
|
|
|
378
378
|
The `eval-quality/conformance` subpath publishes the port boundary: the four port types, the message
|
|
379
379
|
shapes they carry, the AD-28 `RUNTIME_FAULT_CODES` registry and `RuntimeFaultCode` type a conforming
|
|
380
380
|
adapter throws against, and an executable conformance suite. An adapter is conforming when
|
|
381
|
-
`runCorpusPortConformance`, `runClockPortConformance`, `runFileSystemPortConformance`,
|
|
382
|
-
`runEnvironmentProbePortConformance` returns a report whose
|
|
383
|
-
each returns a report, so the suite carries no test
|
|
384
|
-
already use.
|
|
381
|
+
`runCorpusPortConformance`, `runClockPortConformance`, `runFileSystemPortConformance`,
|
|
382
|
+
`runEnvironmentProbePortConformance`, or `runCommandLineProbeConformance` returns a report whose
|
|
383
|
+
`passed` is true, which is the definition; each returns a report, so the suite carries no test
|
|
384
|
+
framework and runs under whichever one you already use.
|
|
385
385
|
|
|
386
386
|
```ts
|
|
387
387
|
import { runCorpusPortConformance, type CorpusPort } from 'eval-quality/conformance'
|
|
@@ -390,10 +390,15 @@ import { runCorpusPortConformance, type CorpusPort } from 'eval-quality/conforma
|
|
|
390
390
|
The suite drives a subject through four scenarios and checks six assertions per port method: a
|
|
391
391
|
mechanism failure is a typed fault, exactly one underlying call happens on success and on failure, an
|
|
392
392
|
aborted signal rejects promptly, an in-band error value is thrown as a fault, and a
|
|
393
|
-
successful call returns a response the published schema accepts.
|
|
394
|
-
thirteen more from AD-35's
|
|
395
|
-
|
|
396
|
-
|
|
393
|
+
successful call returns a response the published schema accepts. `EnvironmentProbePort` adds a second
|
|
394
|
+
arm per mechanism: `runEnvironmentProbePortConformance` (`api`) adds thirteen more from AD-35's
|
|
395
|
+
default-deny target policy, and `runCommandLineProbeConformance` (`cli`) adds nine, once
|
|
396
|
+
`CommandTargetPolicy` gave that mechanism something to authorize — an unmapped interface, an
|
|
397
|
+
unmapped executable, an unauthorized subcommand path, a non-zero exit read as an observation, a
|
|
398
|
+
shell-metacharacter argument proven to reach the process as one literal token, a declared artifact
|
|
399
|
+
captured, and both caps enforced. `npm run test:conformance` runs the suite against the four
|
|
400
|
+
adapters this package ships and against two in-repository probe subjects, one per mechanism, that
|
|
401
|
+
exist only as the suite's own subjects.
|
|
397
402
|
|
|
398
403
|
`docs/ad21-verdict-decision.generated.md` holds AD-21's two published verdict ladders, production and
|
|
399
404
|
contract-scoring, emitted from the rule tables in `src/core/score/ladder.ts` together with the
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { CommandProbeRequest } from '../core/schemas/port-messages.ts';
|
|
2
|
+
import type { CommandTargetPolicy } from '../core/schemas/probe-policy.ts';
|
|
3
|
+
import type { EnvironmentProbePort } from '../ports/environment-probe-port.ts';
|
|
4
|
+
/** One process run, already reduced to what an observation needs. No truncation flag: exceeding `maxOutputBytes` rejects with `budget-exhausted` rather than resolving with a partial stream, so a resolved run's output is always the whole thing. */
|
|
5
|
+
export type CommandRunResult = {
|
|
6
|
+
readonly exitCode: number;
|
|
7
|
+
readonly stdout: string;
|
|
8
|
+
readonly stderr: string;
|
|
9
|
+
};
|
|
10
|
+
export type CommandRunRequest = {
|
|
11
|
+
readonly target: string;
|
|
12
|
+
readonly subcommandPath: readonly string[];
|
|
13
|
+
readonly argv: readonly string[];
|
|
14
|
+
readonly env: Readonly<Record<string, string>>;
|
|
15
|
+
readonly stdin: {
|
|
16
|
+
readonly kind: 'json' | 'text' | 'absent';
|
|
17
|
+
readonly value?: unknown;
|
|
18
|
+
};
|
|
19
|
+
readonly cwd: string;
|
|
20
|
+
readonly maxElapsedMs: number;
|
|
21
|
+
readonly maxOutputBytes: number;
|
|
22
|
+
};
|
|
23
|
+
export type ArtifactRead = {
|
|
24
|
+
readonly present: boolean;
|
|
25
|
+
readonly text: string;
|
|
26
|
+
readonly truncated: boolean;
|
|
27
|
+
};
|
|
28
|
+
/** Swappable so the conformance subject can spy on calls and script every scenario, matching the other three shipped adapters. */
|
|
29
|
+
export type CommandMechanism = {
|
|
30
|
+
readonly run: (request: CommandRunRequest, signal: AbortSignal) => Promise<CommandRunResult>;
|
|
31
|
+
readonly readArtifact: (path: string, maxBytes: number) => Promise<ArtifactRead>;
|
|
32
|
+
};
|
|
33
|
+
/** Options first as `--{key}`, positionals after, both in the record's own key order. Exported for its own unit tests: this is the one place shell-injection safety is decided. */
|
|
34
|
+
export declare function buildArgv(channels: CommandProbeRequest['channels']): string[];
|
|
35
|
+
/**
|
|
36
|
+
* The real mechanism: an actual child process, an actual file read. Exported,
|
|
37
|
+
* unlike the other three shipped adapters' defaults, because AD-37's own
|
|
38
|
+
* conformance subject for this port has to exercise real process behaviour
|
|
39
|
+
* (a real timeout, a real output cap, a real argv) the way the `api` arm's
|
|
40
|
+
* subject exercises a real loopback server; a synthetic mechanism would prove
|
|
41
|
+
* nothing about the one thing this adapter exists to get right.
|
|
42
|
+
*/
|
|
43
|
+
export declare const nodeCommandMechanism: CommandMechanism;
|
|
44
|
+
export declare function createCommandLineAdapter(policy: CommandTargetPolicy, mechanism?: CommandMechanism): EnvironmentProbePort;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment-probe port over a real child process, for the `cli`
|
|
3
|
+
* mechanism, which `probe-conformance.ts` had documented as unauthorizable
|
|
4
|
+
* and unshipped. `evaluateCommandTarget` closes the authorization half; this
|
|
5
|
+
* file closes the execution half.
|
|
6
|
+
*
|
|
7
|
+
* Every rule below is what "what's a command allowed to do" resolves to, a
|
|
8
|
+
* decision settled here in the implementation rather than as an architecture
|
|
9
|
+
* revision:
|
|
10
|
+
*
|
|
11
|
+
* 1. `target` is spawned directly with an argv array (`shell: false`,
|
|
12
|
+
* Node's own default, stated anyway since it is the one thing this
|
|
13
|
+
* adapter must never turn off). No channel value is ever concatenated
|
|
14
|
+
* into a shell string; a value containing `;`, `$(...)`, or a quote
|
|
15
|
+
* reaches the child as one literal argv element, not a metacharacter.
|
|
16
|
+
* 2. `argument` and `option` build the argv the same way a well-behaved CLI
|
|
17
|
+
* parser reads one: options first as `--{key}` (a boolean `true` is a
|
|
18
|
+
* bare flag, `false` is omitted, anything else gets one value token),
|
|
19
|
+
* positionals after in the record's own key order. `environment` passes
|
|
20
|
+
* through as declared, plus the host's own `PATH` so a `target` naming a
|
|
21
|
+
* bare command still resolves; a declared `PATH` key wins over that
|
|
22
|
+
* default. `stdin` is written and the stream is closed; `absent` closes
|
|
23
|
+
* it with nothing written.
|
|
24
|
+
* 3. `maxElapsedMs` and `maxOutputBytes` are enforced by this adapter, not
|
|
25
|
+
* borrowed from `AbortSignal`: exceeding either kills the process with
|
|
26
|
+
* `SIGKILL` and throws `budget-exhausted`, exactly as an HTTP cap does.
|
|
27
|
+
* `maxOutputBytes` applies independently to stdout, to stderr, and to
|
|
28
|
+
* each artifact file read back after exit.
|
|
29
|
+
* 4. A non-zero exit is an observation, never a fault, matching AD-10's rule
|
|
30
|
+
* for an HTTP status. Only a policy denial, a cap, an abort, or a failure
|
|
31
|
+
* to start the process throws. `exitCode` is negative when a signal
|
|
32
|
+
* ended the process, the same convention `CommandProbeObservation`
|
|
33
|
+
* documents for itself.
|
|
34
|
+
*/
|
|
35
|
+
import { spawn } from 'node:child_process';
|
|
36
|
+
import { open } from 'node:fs/promises';
|
|
37
|
+
import { constants as osConstants } from 'node:os';
|
|
38
|
+
import { isAbsolute, resolve as resolvePath } from 'node:path';
|
|
39
|
+
import { RuntimeFault } from '../core/schemas/faults.js';
|
|
40
|
+
import { probeParsers } from '../ports/environment-probe-port.js';
|
|
41
|
+
import { evaluateCommandTarget } from './command-target-policy.js';
|
|
42
|
+
import { runPortMethod } from './port-boundary.js';
|
|
43
|
+
function stringifyScalar(value) {
|
|
44
|
+
if (typeof value === 'string')
|
|
45
|
+
return value;
|
|
46
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
47
|
+
return String(value);
|
|
48
|
+
return JSON.stringify(value);
|
|
49
|
+
}
|
|
50
|
+
/** Options first as `--{key}`, positionals after, both in the record's own key order. Exported for its own unit tests: this is the one place shell-injection safety is decided. */
|
|
51
|
+
export function buildArgv(channels) {
|
|
52
|
+
const optionTokens = [];
|
|
53
|
+
for (const [key, value] of Object.entries(channels.option)) {
|
|
54
|
+
if (value === false)
|
|
55
|
+
continue;
|
|
56
|
+
optionTokens.push(`--${key}`);
|
|
57
|
+
if (value !== true)
|
|
58
|
+
optionTokens.push(stringifyScalar(value));
|
|
59
|
+
}
|
|
60
|
+
const argumentTokens = Object.values(channels.argument).map(stringifyScalar);
|
|
61
|
+
return [...optionTokens, ...argumentTokens];
|
|
62
|
+
}
|
|
63
|
+
function buildEnv(declared) {
|
|
64
|
+
const base = {};
|
|
65
|
+
if (process.env.PATH !== undefined)
|
|
66
|
+
base.PATH = process.env.PATH;
|
|
67
|
+
return { ...base, ...declared };
|
|
68
|
+
}
|
|
69
|
+
function buildStdin(stdin) {
|
|
70
|
+
if (stdin.kind === 'absent')
|
|
71
|
+
return { kind: 'absent' };
|
|
72
|
+
if (stdin.kind === 'json')
|
|
73
|
+
return { kind: 'json', value: stdin.value };
|
|
74
|
+
return { kind: 'text', value: stdin.value };
|
|
75
|
+
}
|
|
76
|
+
/** A heuristic, not a declared content type: the port carries none for a process stream. JSON-shaped text reads as `json`; anything else as `text`. */
|
|
77
|
+
function bodyFromText(text) {
|
|
78
|
+
if (text.length === 0)
|
|
79
|
+
return { kind: 'text', value: text };
|
|
80
|
+
try {
|
|
81
|
+
return { kind: 'json', value: JSON.parse(text) };
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return { kind: 'text', value: text };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function capped(detail) {
|
|
88
|
+
return new RuntimeFault('budget-exhausted', 'CommandProbeRequest', detail);
|
|
89
|
+
}
|
|
90
|
+
function forbidden(detail) {
|
|
91
|
+
return new RuntimeFault('forbidden-target', 'ProbeRequest', detail);
|
|
92
|
+
}
|
|
93
|
+
function writeStdin(child, stdin) {
|
|
94
|
+
const stream = child.stdin;
|
|
95
|
+
if (stream === null)
|
|
96
|
+
return;
|
|
97
|
+
// A child that exits before reading stdin closes its end of the pipe, and
|
|
98
|
+
// the pending `end()` write below then raises EPIPE on this stream, not on
|
|
99
|
+
// `child` itself. With no listener that is an unhandled 'error' and crashes
|
|
100
|
+
// the host process; the run itself is unaffected, since `close` still fires
|
|
101
|
+
// and carries the exit code this adapter already reads it from.
|
|
102
|
+
stream.on('error', () => { });
|
|
103
|
+
if (stdin.kind === 'absent') {
|
|
104
|
+
stream.end();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
stream.end(stdin.kind === 'json' ? JSON.stringify(stdin.value) : String(stdin.value));
|
|
108
|
+
}
|
|
109
|
+
/** `-signalNumber` when a signal ended the process, matching `CommandProbeObservation.exitCode`'s own documented convention. */
|
|
110
|
+
function exitCodeOf(code, signalName) {
|
|
111
|
+
if (code !== null)
|
|
112
|
+
return code;
|
|
113
|
+
if (signalName === null)
|
|
114
|
+
return -1;
|
|
115
|
+
const numeric = osConstants.signals[signalName];
|
|
116
|
+
return numeric === undefined ? -1 : -numeric;
|
|
117
|
+
}
|
|
118
|
+
async function runChildProcess(request, signal) {
|
|
119
|
+
return new Promise((settlePromise, rejectPromise) => {
|
|
120
|
+
const child = spawn(request.target, [...request.subcommandPath, ...request.argv], {
|
|
121
|
+
cwd: request.cwd,
|
|
122
|
+
env: request.env,
|
|
123
|
+
shell: false,
|
|
124
|
+
signal,
|
|
125
|
+
});
|
|
126
|
+
let stdout = '';
|
|
127
|
+
let stderr = '';
|
|
128
|
+
let settled = false;
|
|
129
|
+
let timedOut = false;
|
|
130
|
+
let overCap = false;
|
|
131
|
+
const finish = (action) => {
|
|
132
|
+
if (settled)
|
|
133
|
+
return;
|
|
134
|
+
settled = true;
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
action();
|
|
137
|
+
};
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
timedOut = true;
|
|
140
|
+
child.kill('SIGKILL');
|
|
141
|
+
}, request.maxElapsedMs);
|
|
142
|
+
const capture = (chunk, append, currentLength) => {
|
|
143
|
+
if (currentLength() > request.maxOutputBytes)
|
|
144
|
+
return;
|
|
145
|
+
append(chunk.toString('utf8'));
|
|
146
|
+
if (currentLength() > request.maxOutputBytes) {
|
|
147
|
+
overCap = true;
|
|
148
|
+
child.kill('SIGKILL');
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
child.stdout?.on('data', (chunk) => capture(chunk, (next) => {
|
|
152
|
+
stdout += next;
|
|
153
|
+
}, () => Buffer.byteLength(stdout, 'utf8')));
|
|
154
|
+
child.stderr?.on('data', (chunk) => capture(chunk, (next) => {
|
|
155
|
+
stderr += next;
|
|
156
|
+
}, () => Buffer.byteLength(stderr, 'utf8')));
|
|
157
|
+
child.once('error', (error) => {
|
|
158
|
+
finish(() => rejectPromise(error));
|
|
159
|
+
});
|
|
160
|
+
child.once('close', (code, signalName) => {
|
|
161
|
+
finish(() => {
|
|
162
|
+
if (timedOut) {
|
|
163
|
+
rejectPromise(capped(`exceeded maxElapsedMs (${request.maxElapsedMs}ms) and was killed`));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (overCap) {
|
|
167
|
+
rejectPromise(capped(`stdout or stderr exceeded maxOutputBytes (${request.maxOutputBytes}) and the process was killed`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
settlePromise({
|
|
171
|
+
exitCode: exitCodeOf(code, signalName),
|
|
172
|
+
stdout,
|
|
173
|
+
stderr,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
writeStdin(child, request.stdin);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/** Reads at most `maxBytes + 1` bytes rather than the whole file: an oversize artifact is capped before its bytes are loaded, not after, so a run that wrote past the limit cannot make this adapter allocate the excess first. */
|
|
181
|
+
async function readArtifactFile(path, maxBytes) {
|
|
182
|
+
let handle;
|
|
183
|
+
try {
|
|
184
|
+
handle = await open(path, 'r');
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (error.code === 'ENOENT') {
|
|
188
|
+
return { present: false, text: '', truncated: false };
|
|
189
|
+
}
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
194
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, 0);
|
|
195
|
+
const truncated = bytesRead > maxBytes;
|
|
196
|
+
const text = buffer
|
|
197
|
+
.subarray(0, Math.min(bytesRead, maxBytes))
|
|
198
|
+
.toString('utf8');
|
|
199
|
+
return { present: true, text, truncated };
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
await handle.close();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The real mechanism: an actual child process, an actual file read. Exported,
|
|
207
|
+
* unlike the other three shipped adapters' defaults, because AD-37's own
|
|
208
|
+
* conformance subject for this port has to exercise real process behaviour
|
|
209
|
+
* (a real timeout, a real output cap, a real argv) the way the `api` arm's
|
|
210
|
+
* subject exercises a real loopback server; a synthetic mechanism would prove
|
|
211
|
+
* nothing about the one thing this adapter exists to get right.
|
|
212
|
+
*/
|
|
213
|
+
export const nodeCommandMechanism = {
|
|
214
|
+
run: runChildProcess,
|
|
215
|
+
readArtifact: readArtifactFile,
|
|
216
|
+
};
|
|
217
|
+
export function createCommandLineAdapter(policy, mechanism = nodeCommandMechanism) {
|
|
218
|
+
return {
|
|
219
|
+
probe: (request, signal) => runPortMethod({
|
|
220
|
+
request,
|
|
221
|
+
requestParser: probeParsers.request,
|
|
222
|
+
responseParser: probeParsers.response,
|
|
223
|
+
requestPath: 'ProbeRequest',
|
|
224
|
+
responsePath: 'ProbeObservation',
|
|
225
|
+
signal,
|
|
226
|
+
mechanism: async (parsed, innerSignal) => {
|
|
227
|
+
if (parsed.kind !== 'cli') {
|
|
228
|
+
// This adapter authorizes no `api` target, so an `api` request
|
|
229
|
+
// meets the same "the mapping names nothing" denial an
|
|
230
|
+
// unmapped interfaceId would, before any process spawns.
|
|
231
|
+
throw forbidden('this adapter runs cli requests only; no api target is ever authorized');
|
|
232
|
+
}
|
|
233
|
+
const decision = evaluateCommandTarget(policy, {
|
|
234
|
+
interfaceId: parsed.interfaceId,
|
|
235
|
+
executable: parsed.executable,
|
|
236
|
+
subcommandPath: parsed.subcommandPath,
|
|
237
|
+
});
|
|
238
|
+
if (!decision.allowed)
|
|
239
|
+
throw forbidden(decision.detail);
|
|
240
|
+
const { authorization } = decision;
|
|
241
|
+
const runResult = await mechanism.run({
|
|
242
|
+
target: authorization.target,
|
|
243
|
+
subcommandPath: parsed.subcommandPath,
|
|
244
|
+
argv: buildArgv(parsed.channels),
|
|
245
|
+
env: buildEnv(parsed.channels.environment),
|
|
246
|
+
stdin: buildStdin(parsed.channels.stdin),
|
|
247
|
+
cwd: authorization.cwd,
|
|
248
|
+
maxElapsedMs: authorization.maxElapsedMs,
|
|
249
|
+
maxOutputBytes: authorization.maxOutputBytes,
|
|
250
|
+
}, innerSignal);
|
|
251
|
+
const artifactEntries = await Promise.all(Object.entries(authorization.artifacts).map(async ([artifactId, relativePath]) => {
|
|
252
|
+
const absolute = isAbsolute(relativePath)
|
|
253
|
+
? relativePath
|
|
254
|
+
: resolvePath(authorization.cwd, relativePath);
|
|
255
|
+
const read = await mechanism.readArtifact(absolute, authorization.maxOutputBytes);
|
|
256
|
+
if (read.truncated) {
|
|
257
|
+
throw capped(`artifact "${artifactId}" exceeded maxOutputBytes (${authorization.maxOutputBytes})`);
|
|
258
|
+
}
|
|
259
|
+
return [artifactId, read];
|
|
260
|
+
}));
|
|
261
|
+
return { parsed, runResult, artifactEntries };
|
|
262
|
+
},
|
|
263
|
+
assemble: (raw) => {
|
|
264
|
+
const { parsed, runResult, artifactEntries } = raw;
|
|
265
|
+
const observation = {
|
|
266
|
+
kind: 'cli',
|
|
267
|
+
probeId: parsed.probeId,
|
|
268
|
+
interfaceId: parsed.interfaceId,
|
|
269
|
+
operationId: parsed.operationId,
|
|
270
|
+
exitCode: runResult.exitCode,
|
|
271
|
+
stdout: bodyFromText(runResult.stdout),
|
|
272
|
+
stderr: bodyFromText(runResult.stderr),
|
|
273
|
+
artifacts: Object.fromEntries(artifactEntries.map(([id, read]) => [
|
|
274
|
+
id,
|
|
275
|
+
read.present
|
|
276
|
+
? bodyFromText(read.text)
|
|
277
|
+
: { kind: 'absent' },
|
|
278
|
+
])),
|
|
279
|
+
};
|
|
280
|
+
return observation;
|
|
281
|
+
},
|
|
282
|
+
}),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AD-35's default-deny decision for the `cli` mechanism, as a pure function.
|
|
3
|
+
*
|
|
4
|
+
* Lives under `adapters/` rather than beside `core/probe/target-policy.ts`
|
|
5
|
+
* (the `api` evaluator), even though the two are otherwise siblings: the
|
|
6
|
+
* repository's mechanically-enforced dependency direction permits
|
|
7
|
+
* `adapters/` to import `core/schemas` but not `core/`, so a
|
|
8
|
+
* target-authorization decision an adapter calls has to be adapter-owned
|
|
9
|
+
* infrastructure, not a `core/` module. `target-policy.ts` itself is never
|
|
10
|
+
* imported from `src/adapters/` for the same reason — no shipped `api`
|
|
11
|
+
* adapter exists to call it, which is the gap this file closes for `cli`.
|
|
12
|
+
*/
|
|
13
|
+
import type { CommandTargetAuthorization, CommandTargetPolicy } from '../core/schemas/probe-policy.ts';
|
|
14
|
+
/** Why a command target was denied. Thrown as the single AD-28 `forbidden-target` fault, same as the HTTP reasons. */
|
|
15
|
+
export declare const COMMAND_DENIAL_REASONS: readonly ['interface-not-authorized', 'executable-not-authorized', 'subcommand-not-authorized'];
|
|
16
|
+
export type CommandDenialReason = (typeof COMMAND_DENIAL_REASONS)[number];
|
|
17
|
+
export type CommandResolvedTarget = {
|
|
18
|
+
readonly interfaceId: string;
|
|
19
|
+
readonly executable: string;
|
|
20
|
+
readonly subcommandPath: readonly string[];
|
|
21
|
+
};
|
|
22
|
+
export type CommandPolicyDecision = {
|
|
23
|
+
readonly allowed: true;
|
|
24
|
+
readonly authorization: CommandTargetAuthorization;
|
|
25
|
+
} | {
|
|
26
|
+
readonly allowed: false;
|
|
27
|
+
readonly reason: CommandDenialReason;
|
|
28
|
+
readonly detail: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The interface-and-executable check runs first, so an unmapped pair never
|
|
32
|
+
* reaches the subcommand comparison. Where several authorizations name one
|
|
33
|
+
* pair, each is tried in declaration order and the first that allows wins;
|
|
34
|
+
* mirrors `evaluateTarget`'s own ordering rule for the same reason.
|
|
35
|
+
*/
|
|
36
|
+
export declare function evaluateCommandTarget(policy: CommandTargetPolicy, target: CommandResolvedTarget): CommandPolicyDecision;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Why a command target was denied. Thrown as the single AD-28 `forbidden-target` fault, same as the HTTP reasons. */
|
|
2
|
+
export const COMMAND_DENIAL_REASONS = [
|
|
3
|
+
'interface-not-authorized',
|
|
4
|
+
'executable-not-authorized',
|
|
5
|
+
'subcommand-not-authorized',
|
|
6
|
+
];
|
|
7
|
+
function subcommandPathsMatch(a, b) {
|
|
8
|
+
return (a.length === b.length && a.every((segment, index) => segment === b[index]));
|
|
9
|
+
}
|
|
10
|
+
function deny(reason, detail) {
|
|
11
|
+
return { allowed: false, reason, detail };
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The interface-and-executable check runs first, so an unmapped pair never
|
|
15
|
+
* reaches the subcommand comparison. Where several authorizations name one
|
|
16
|
+
* pair, each is tried in declaration order and the first that allows wins;
|
|
17
|
+
* mirrors `evaluateTarget`'s own ordering rule for the same reason.
|
|
18
|
+
*/
|
|
19
|
+
export function evaluateCommandTarget(policy, target) {
|
|
20
|
+
const matchingPair = policy.authorizations.filter((authorization) => authorization.interfaceId === target.interfaceId &&
|
|
21
|
+
authorization.executable === target.executable);
|
|
22
|
+
if (matchingPair.length === 0) {
|
|
23
|
+
return deny(policy.authorizations.some((authorization) => authorization.interfaceId === target.interfaceId)
|
|
24
|
+
? 'executable-not-authorized'
|
|
25
|
+
: 'interface-not-authorized', `no authorization names interface "${target.interfaceId}" and executable "${target.executable}" together`);
|
|
26
|
+
}
|
|
27
|
+
for (const authorization of matchingPair) {
|
|
28
|
+
if (authorization.permittedSubcommandPaths.some((permitted) => subcommandPathsMatch(permitted, target.subcommandPath))) {
|
|
29
|
+
return { allowed: true, authorization };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return deny('subcommand-not-authorized', `subcommand path [${target.subcommandPath.join(', ')}] is not among the authorized paths for interface "${target.interfaceId}" and executable "${target.executable}"`);
|
|
33
|
+
}
|
package/dist/adapters/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* subpath and the root barrel keeps the `root -> adapters` edge the matrix
|
|
5
5
|
* does not grant.
|
|
6
6
|
*/
|
|
7
|
+
export type { CommandMechanism, CommandRunRequest, CommandRunResult, } from './command-line-adapter.ts';
|
|
8
|
+
export { createCommandLineAdapter, nodeCommandMechanism, } from './command-line-adapter.ts';
|
|
7
9
|
export type { CorpusMechanism } from './local-corpus-adapter.ts';
|
|
8
10
|
export { createLocalCorpusAdapter } from './local-corpus-adapter.ts';
|
|
9
11
|
export type { FileSystemMechanism } from './node-file-system-adapter.ts';
|
package/dist/adapters/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { createCommandLineAdapter, nodeCommandMechanism, } from './command-line-adapter.js';
|
|
1
2
|
export { createLocalCorpusAdapter } from './local-corpus-adapter.js';
|
|
2
3
|
export { createNodeFileSystemAdapter } from './node-file-system-adapter.js';
|
|
3
4
|
export { createSystemClockAdapter } from './system-clock-adapter.js';
|
|
@@ -74,3 +74,39 @@ export declare const ProbeTargetPolicy: z.ZodObject<{
|
|
|
74
74
|
}, z.core.$strict>;
|
|
75
75
|
export type ProbeTargetAuthorization = z.infer<typeof ProbeTargetAuthorization>;
|
|
76
76
|
export type ProbeTargetPolicy = z.infer<typeof ProbeTargetPolicy>;
|
|
77
|
+
/**
|
|
78
|
+
* One authorized command target, AD-35's mapping for the `cli` mechanism.
|
|
79
|
+
* `probe-conformance.ts`'s own published note had recorded that this shape
|
|
80
|
+
* did not exist: `ProbeTargetPolicy` above is entirely HTTP-shaped, and
|
|
81
|
+
* "a command interface cannot be authorized at all" without one.
|
|
82
|
+
*
|
|
83
|
+
* Keyed by `(interfaceId, executable)` rather than `interfaceId` alone,
|
|
84
|
+
* because `CommandInvocation` is declared per operation (AD-19), not per
|
|
85
|
+
* interface: two operations under one CLI interface may name two different
|
|
86
|
+
* logical executables. Everything else about the pair is shared the way host
|
|
87
|
+
* and port are shared across an HTTP authorization's methods.
|
|
88
|
+
*/
|
|
89
|
+
export declare const CommandTargetAuthorization: z.ZodObject<{
|
|
90
|
+
interfaceId: z.ZodString;
|
|
91
|
+
executable: z.ZodString;
|
|
92
|
+
target: z.ZodString;
|
|
93
|
+
permittedSubcommandPaths: z.ZodArray<z.ZodArray<z.ZodString>>;
|
|
94
|
+
cwd: z.ZodString;
|
|
95
|
+
artifacts: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
96
|
+
maxElapsedMs: z.ZodInt;
|
|
97
|
+
maxOutputBytes: z.ZodInt;
|
|
98
|
+
}, z.core.$strict>;
|
|
99
|
+
export declare const CommandTargetPolicy: z.ZodObject<{
|
|
100
|
+
authorizations: z.ZodArray<z.ZodObject<{
|
|
101
|
+
interfaceId: z.ZodString;
|
|
102
|
+
executable: z.ZodString;
|
|
103
|
+
target: z.ZodString;
|
|
104
|
+
permittedSubcommandPaths: z.ZodArray<z.ZodArray<z.ZodString>>;
|
|
105
|
+
cwd: z.ZodString;
|
|
106
|
+
artifacts: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
107
|
+
maxElapsedMs: z.ZodInt;
|
|
108
|
+
maxOutputBytes: z.ZodInt;
|
|
109
|
+
}, z.core.$strict>>;
|
|
110
|
+
}, z.core.$strict>;
|
|
111
|
+
export type CommandTargetAuthorization = z.infer<typeof CommandTargetAuthorization>;
|
|
112
|
+
export type CommandTargetPolicy = z.infer<typeof CommandTargetPolicy>;
|
|
@@ -31,3 +31,47 @@ export const ProbeTargetPolicy = z.strictObject({
|
|
|
31
31
|
.array(ProbeTargetAuthorization)
|
|
32
32
|
.describe('An empty array is legal and authorizes nothing, which is the default-deny base case and must stay representable.'),
|
|
33
33
|
});
|
|
34
|
+
/**
|
|
35
|
+
* One authorized command target, AD-35's mapping for the `cli` mechanism.
|
|
36
|
+
* `probe-conformance.ts`'s own published note had recorded that this shape
|
|
37
|
+
* did not exist: `ProbeTargetPolicy` above is entirely HTTP-shaped, and
|
|
38
|
+
* "a command interface cannot be authorized at all" without one.
|
|
39
|
+
*
|
|
40
|
+
* Keyed by `(interfaceId, executable)` rather than `interfaceId` alone,
|
|
41
|
+
* because `CommandInvocation` is declared per operation (AD-19), not per
|
|
42
|
+
* interface: two operations under one CLI interface may name two different
|
|
43
|
+
* logical executables. Everything else about the pair is shared the way host
|
|
44
|
+
* and port are shared across an HTTP authorization's methods.
|
|
45
|
+
*/
|
|
46
|
+
export const CommandTargetAuthorization = z.strictObject({
|
|
47
|
+
interfaceId: Identifier.describe('The logical interface identifier the contract names.'),
|
|
48
|
+
executable: Identifier.describe('The logical executable name a CommandInvocation declares. Paired with interfaceId since one interface may declare more than one.'),
|
|
49
|
+
target: z
|
|
50
|
+
.string()
|
|
51
|
+
.min(1)
|
|
52
|
+
.describe('The real, spawnable command: an absolute path, or a name the adapter resolves through its own PATH. Never taken from the contract, which never carries one (AD-35).'),
|
|
53
|
+
permittedSubcommandPaths: z
|
|
54
|
+
.array(z.array(Identifier))
|
|
55
|
+
.min(1)
|
|
56
|
+
.describe('The exact subcommand paths this authorization allows, compared literally the way AD-40 compares them. An empty inner array authorizes invoking target with no subcommand. A path the request declares that matches none of these is denied before target is ever spawned, the same role methods plays on the HTTP side.'),
|
|
57
|
+
cwd: z
|
|
58
|
+
.string()
|
|
59
|
+
.min(1)
|
|
60
|
+
.describe('The working directory every invocation runs from. Declared rather than inherited from the adapter process, so a relative artifact path below resolves against a target the mapping names rather than wherever the host process happened to start.'),
|
|
61
|
+
artifacts: z
|
|
62
|
+
.record(Identifier, z.string().min(1))
|
|
63
|
+
.describe("Where each declared artifact identifier reads from on disk, relative to cwd unless absolute. `CommandProbeObservation.artifacts` is keyed by these identifiers; one this run did not write resolves `absent` rather than missing the key, since the record itself is mandatory. An identifier absent from this map can never appear in an observation, which is the same disclosure boundary AD-35 draws around target itself: an operation may declare an artifact the mapping has no path for, and that operation's pre-flight runs with that artifact permanently absent until the mapping is extended."),
|
|
64
|
+
maxElapsedMs: z
|
|
65
|
+
.int()
|
|
66
|
+
.min(1)
|
|
67
|
+
.describe('Wall-clock budget for one invocation. Exceeding it kills the process and throws budget-exhausted, the same fault an HTTP cap throws.'),
|
|
68
|
+
maxOutputBytes: z
|
|
69
|
+
.int()
|
|
70
|
+
.min(1)
|
|
71
|
+
.describe('Applies independently to stdout, to stderr, and to each artifact file read back. The first channel to cross it kills the process (for stdout/stderr, mid-run) or fails the read (for an artifact, after exit) with budget-exhausted.'),
|
|
72
|
+
});
|
|
73
|
+
export const CommandTargetPolicy = z.strictObject({
|
|
74
|
+
authorizations: z
|
|
75
|
+
.array(CommandTargetAuthorization)
|
|
76
|
+
.describe('An empty array is legal and authorizes nothing, the same default-deny base case as ProbeTargetPolicy.'),
|
|
77
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -12,4 +12,4 @@ export type { ScoringPolicy } from './core/schemas/scoring-policy.ts';
|
|
|
12
12
|
export type { SealedEvaluatorBrief } from './core/schemas/sealed-evaluator-brief.ts';
|
|
13
13
|
export type { SealedRunRecord } from './core/schemas/sealed-run-record.ts';
|
|
14
14
|
export type { FixtureReset, ManifestationWitness, SensitivityWitness, SensitivityWitnessLeg, WitnessChannel, WitnessInputs, } from './core/schemas/sensitivity-witness.ts';
|
|
15
|
-
export declare const VERSION = "0.
|
|
15
|
+
export declare const VERSION = "1.0.0";
|
package/dist/index.js
CHANGED
|
@@ -7,13 +7,14 @@ export type ConformanceOutcome = {
|
|
|
7
7
|
readonly passed: boolean;
|
|
8
8
|
readonly detail: string;
|
|
9
9
|
};
|
|
10
|
-
export type ConformancePort = 'corpus' | 'clock' | 'file-system' | 'environment-probe';
|
|
10
|
+
export type ConformancePort = 'corpus' | 'clock' | 'file-system' | 'environment-probe' | 'command-probe';
|
|
11
11
|
/** how many outcomes a complete run of each port produces. Asserted as literals by fixture 58. */
|
|
12
12
|
export declare const CONFORMANCE_OUTCOME_COUNTS: {
|
|
13
13
|
readonly corpus: 6;
|
|
14
14
|
readonly clock: 6;
|
|
15
15
|
readonly 'file-system': 12;
|
|
16
16
|
readonly 'environment-probe': 19;
|
|
17
|
+
readonly 'command-probe': 15;
|
|
17
18
|
};
|
|
18
19
|
export type ConformanceReport = {
|
|
19
20
|
readonly subject: string;
|
|
@@ -19,6 +19,7 @@ export const CONFORMANCE_OUTCOME_COUNTS = {
|
|
|
19
19
|
clock: 6,
|
|
20
20
|
'file-system': 12,
|
|
21
21
|
'environment-probe': 19,
|
|
22
|
+
'command-probe': 15,
|
|
22
23
|
};
|
|
23
24
|
/** "Promptly" with no bound is unfalsifiable. One second clears any real adapter's abort latency and sits well inside a default test timeout. */
|
|
24
25
|
export const DEFAULT_ABORT_BUDGET_MS = 1000;
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
export type { RuntimeFaultCode } from '../core/schemas/faults.ts';
|
|
14
14
|
export { RUNTIME_FAULT_CODES, RuntimeFault } from '../core/schemas/faults.ts';
|
|
15
15
|
export type { ClockReadRequest, ClockReadResponse, CorpusResolveRequest, CorpusResolveResponse, FileReadRequest, FileReadResponse, FileWriteRequest, FileWriteResponse, ProbeObservation, ProbeObservedBody, ProbeRequest, ProbeRequestBody, } from '../core/schemas/port-messages.ts';
|
|
16
|
-
export type { ProbeTargetAuthorization, ProbeTargetPolicy, } from '../core/schemas/probe-policy.ts';
|
|
16
|
+
export type { CommandTargetAuthorization, CommandTargetPolicy, ProbeTargetAuthorization, ProbeTargetPolicy, } from '../core/schemas/probe-policy.ts';
|
|
17
17
|
export type { ClockPort } from '../ports/clock-port.ts';
|
|
18
18
|
export { clockReadParsers } from '../ports/clock-port.ts';
|
|
19
19
|
export type { CorpusPort } from '../ports/corpus-port.ts';
|
|
@@ -24,5 +24,5 @@ export type { FileSystemPort } from '../ports/file-system-port.ts';
|
|
|
24
24
|
export { fileReadParsers, fileWriteParsers, } from '../ports/file-system-port.ts';
|
|
25
25
|
export type { BuiltSubject, ConformanceOutcome, ConformancePort, ConformanceReport, PortSubject, ScenarioKind, } from './conformance.ts';
|
|
26
26
|
export { CONFORMANCE_OUTCOME_COUNTS, formatConformanceReport, runClockPortConformance, runCorpusPortConformance, runFileSystemPortConformance, } from './conformance.ts';
|
|
27
|
-
export type { ProbeSubject } from './probe-conformance.ts';
|
|
28
|
-
export { runEnvironmentProbePortConformance } from './probe-conformance.ts';
|
|
27
|
+
export type { CommandProbeSubject, ProbeSubject } from './probe-conformance.ts';
|
|
28
|
+
export { runCommandLineProbeConformance, runEnvironmentProbePortConformance, } from './probe-conformance.ts';
|
package/dist/testing/index.js
CHANGED
|
@@ -16,4 +16,4 @@ export { corpusResolveParsers } from '../ports/corpus-port.js';
|
|
|
16
16
|
export { probeParsers } from '../ports/environment-probe-port.js';
|
|
17
17
|
export { fileReadParsers, fileWriteParsers, } from '../ports/file-system-port.js';
|
|
18
18
|
export { CONFORMANCE_OUTCOME_COUNTS, formatConformanceReport, runClockPortConformance, runCorpusPortConformance, runFileSystemPortConformance, } from './conformance.js';
|
|
19
|
-
export { runEnvironmentProbePortConformance } from './probe-conformance.js';
|
|
19
|
+
export { runCommandLineProbeConformance, runEnvironmentProbePortConformance, } from './probe-conformance.js';
|
|
@@ -1,43 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AD-35's
|
|
3
|
-
* subject supplies its policy and one request per denial,
|
|
4
|
-
* how its own interface-to-target mapping is wired.
|
|
2
|
+
* AD-35's extra assertions for the environment-probe port, one arm per
|
|
3
|
+
* mechanism. The subject supplies its policy and one request per denial,
|
|
4
|
+
* since only it knows how its own interface-to-target mapping is wired.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* `runEnvironmentProbePortConformance` is the `api` arm: thirteen assertions,
|
|
7
|
+
* every scenario HTTP (redirects, methods, schemes, an anomalous status).
|
|
8
|
+
* `runCommandLineProbeConformance` is the `cli` arm, added once
|
|
9
|
+
* `CommandTargetPolicy` gave the mechanism something to authorize: an
|
|
10
|
+
* unmapped interface, an unmapped executable, an unauthorized subcommand path,
|
|
11
|
+
* a non-zero exit read as an observation, a shell-metacharacter argument
|
|
12
|
+
* proven to reach the process as one literal token rather than a shell
|
|
13
|
+
* expansion, a declared artifact captured, and both caps enforced. The two
|
|
14
|
+
* arms are separate functions rather than one, because their subjects need
|
|
15
|
+
* disjoint fixtures (an HTTP redirect chain has no command analogue, and a
|
|
16
|
+
* subcommand allowlist has no HTTP one) and a subject presenting for one
|
|
17
|
+
* mechanism is not asked to fake the other's scenarios.
|
|
18
|
+
*
|
|
19
|
+
* Each assertion in either arm builds its own `'resolves'` subject and reads
|
|
7
20
|
* `underlyingCalls()` from a counter starting at zero, so every count below is
|
|
8
21
|
* absolute.
|
|
9
|
-
*
|
|
10
|
-
* **This suite certifies an adapter for the `api` mechanism and says nothing
|
|
11
|
-
* about the `cli` one.** Every scenario is HTTP: redirects, methods, schemes,
|
|
12
|
-
* an anomalous status. `ProbeSubject` requires an unauthorized-method request,
|
|
13
|
-
* a redirecting request and a not-found request, none of which a command
|
|
14
|
-
* adapter has. So an adapter that runs commands cannot present a subject here,
|
|
15
|
-
* and an adapter that passes all nineteen has been shown nothing about whether
|
|
16
|
-
* it can run one safely: it may deny an unmapped executable or execute a shell
|
|
17
|
-
* string, and this suite cannot tell the difference.
|
|
18
|
-
*
|
|
19
|
-
* That is a real gap in the published surface and it is wider than this suite.
|
|
20
|
-
* `ProbeTargetPolicy` has one authorization shape and every field in it is
|
|
21
|
-
* HTTP: scheme, host, port, resolved addresses, methods, safe methods,
|
|
22
|
-
* redirects, request and response byte caps. **A command interface cannot be
|
|
23
|
-
* authorized at all**, so AD-35's "an adapter denies by default and permits
|
|
24
|
-
* only what that mapping names" has nothing to name for the mechanism this
|
|
25
|
-
* release adds, and a conformance arm built on top of a policy that cannot
|
|
26
|
-
* express a command authorization would certify against nothing.
|
|
27
|
-
*
|
|
28
|
-
* Closing it is a declaration first and a suite second: a command
|
|
29
|
-
* authorization naming a permitted executable, the subcommand paths and
|
|
30
|
-
* environment keys it may carry, and its own elapsed and output-byte caps;
|
|
31
|
-
* then the denials to certify — an executable no mapping names, a refusal to
|
|
32
|
-
* accept a pre-built argument vector, a non-zero exit as an observation rather
|
|
33
|
-
* than a fault, each cap enforced. That is a design addition to the published
|
|
34
|
-
* surface rather than a repair, and it is not made here. Until it exists an
|
|
35
|
-
* adopter writing a command adapter has no policy to declare and no harness to
|
|
36
|
-
* run, and this comment is here so nobody concludes from a green run that they
|
|
37
|
-
* have either.
|
|
38
22
|
*/
|
|
39
23
|
import type { ProbeRequest } from '../core/schemas/port-messages.ts';
|
|
40
|
-
import type { ProbeTargetPolicy } from '../core/schemas/probe-policy.ts';
|
|
24
|
+
import type { CommandTargetPolicy, ProbeTargetPolicy } from '../core/schemas/probe-policy.ts';
|
|
41
25
|
import type { ConformanceReport, PortSubject } from './conformance.ts';
|
|
42
26
|
export type ProbeSubject = PortSubject<ProbeRequest> & {
|
|
43
27
|
readonly policy: ProbeTargetPolicy;
|
|
@@ -72,3 +56,41 @@ export type ProbeSubject = PortSubject<ProbeRequest> & {
|
|
|
72
56
|
* stays declared and adapter-enforced.
|
|
73
57
|
*/
|
|
74
58
|
export declare function runEnvironmentProbePortConformance(subject: ProbeSubject): Promise<ConformanceReport>;
|
|
59
|
+
/**
|
|
60
|
+
* The `cli` arm's subject. Every field is a request `evaluateCommandTarget`
|
|
61
|
+
* decides one specific way against the subject's own `policy`, the same
|
|
62
|
+
* division `ProbeSubject` draws for the `api` arm: the subject knows how its
|
|
63
|
+
* mapping is wired, the suite only knows what each request should produce.
|
|
64
|
+
*/
|
|
65
|
+
export type CommandProbeSubject = PortSubject<ProbeRequest> & {
|
|
66
|
+
readonly policy: CommandTargetPolicy;
|
|
67
|
+
/** a request the policy ALLOWS, run against the subject's own fixture executable. */
|
|
68
|
+
readonly authorizedRequest: ProbeRequest;
|
|
69
|
+
/** an interfaceId no authorization names. */
|
|
70
|
+
readonly unmappedInterfaceRequest: ProbeRequest;
|
|
71
|
+
/** an interfaceId the policy names, paired with an executable it never names. */
|
|
72
|
+
readonly unmappedExecutableRequest: ProbeRequest;
|
|
73
|
+
/** an interface-executable pair the policy names, with a subcommandPath no authorization for it permits. */
|
|
74
|
+
readonly unauthorizedSubcommandRequest: ProbeRequest;
|
|
75
|
+
/** authorized, and answered by the fixture exiting non-zero. */
|
|
76
|
+
readonly nonZeroExitRequest: ProbeRequest;
|
|
77
|
+
/** authorized, carrying a shell-metacharacter value on one argument channel key. */
|
|
78
|
+
readonly injectionRequest: ProbeRequest;
|
|
79
|
+
/** the exact literal value `injectionRequest` carries, so the assertion can confirm the fixture received it byte for byte. */
|
|
80
|
+
readonly injectionArgumentValue: string;
|
|
81
|
+
/** authorized, and its authorization declares one artifact identifier the fixture writes when run. */
|
|
82
|
+
readonly artifactRequest: ProbeRequest;
|
|
83
|
+
readonly artifactId: string;
|
|
84
|
+
readonly artifactExpectedText: string;
|
|
85
|
+
/** authorized against a maxElapsedMs the fixture is made to sleep past. */
|
|
86
|
+
readonly overElapsedRequest: ProbeRequest;
|
|
87
|
+
/** authorized against a maxOutputBytes the fixture is made to write past. */
|
|
88
|
+
readonly overOutputRequest: ProbeRequest;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Fifteen outcomes: the six shared assertions plus the nine above. Unlike the
|
|
92
|
+
* `api` arm, every cap here has an assertion: a command subject's fixture
|
|
93
|
+
* script can be told to overrun a byte cap directly, with no oversize-request
|
|
94
|
+
* problem to work around.
|
|
95
|
+
*/
|
|
96
|
+
export declare function runCommandLineProbeConformance(subject: CommandProbeSubject): Promise<ConformanceReport>;
|
|
@@ -150,6 +150,7 @@ function echoMismatch(request, observation) {
|
|
|
150
150
|
}
|
|
151
151
|
return undefined;
|
|
152
152
|
}
|
|
153
|
+
/** Reads only `id` and `title`, so both arms' assertion shapes satisfy it structurally. */
|
|
153
154
|
function checkRejected(assertion, expectedCode, error) {
|
|
154
155
|
const view = faultView(error);
|
|
155
156
|
if (view === undefined) {
|
|
@@ -213,3 +214,164 @@ export async function runEnvironmentProbePortConformance(subject) {
|
|
|
213
214
|
}
|
|
214
215
|
return reportOf(subject.name, 'environment-probe', [...shared, ...additional]);
|
|
215
216
|
}
|
|
217
|
+
const COMMAND_ASSERTIONS = [
|
|
218
|
+
{
|
|
219
|
+
id: 'command/allow-authorized-invocation',
|
|
220
|
+
title: 'an explicitly authorized command runs and is observed',
|
|
221
|
+
request: (subject) => subject.authorizedRequest,
|
|
222
|
+
expectation: { kind: 'resolves' },
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: 'command/observe-nonzero-exit',
|
|
226
|
+
title: 'a non-zero exit from an authorized command is an observation, not a fault',
|
|
227
|
+
request: (subject) => subject.nonZeroExitRequest,
|
|
228
|
+
expectation: {
|
|
229
|
+
kind: 'resolves',
|
|
230
|
+
check: (observation) => observation.kind === 'cli' && observation.exitCode !== 0
|
|
231
|
+
? undefined
|
|
232
|
+
: `observed ${observation.kind === 'cli' ? `exit code ${observation.exitCode}` : 'an api observation'}, expected a non-zero exit`,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: 'command/deny-unmapped-interface',
|
|
237
|
+
title: 'an interface no authorization names is refused before a process spawns',
|
|
238
|
+
request: (subject) => subject.unmappedInterfaceRequest,
|
|
239
|
+
expectation: { kind: 'rejects', code: DENIED },
|
|
240
|
+
expectedCalls: () => 0,
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: 'command/deny-unmapped-executable',
|
|
244
|
+
title: 'an executable the interface is never paired with is refused before a process spawns',
|
|
245
|
+
request: (subject) => subject.unmappedExecutableRequest,
|
|
246
|
+
expectation: { kind: 'rejects', code: DENIED },
|
|
247
|
+
expectedCalls: () => 0,
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: 'command/deny-unauthorized-subcommand',
|
|
251
|
+
title: 'a subcommand path outside the authorized set is refused before a process spawns',
|
|
252
|
+
request: (subject) => subject.unauthorizedSubcommandRequest,
|
|
253
|
+
expectation: { kind: 'rejects', code: DENIED },
|
|
254
|
+
expectedCalls: () => 0,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
// The strongest available proof that no shell ever reads a channel value:
|
|
258
|
+
// an argument built to look like a command substitution, checked to reach
|
|
259
|
+
// the process as the one, unmodified literal token it was declared as.
|
|
260
|
+
id: 'command/argument-passed-literally',
|
|
261
|
+
title: 'a shell-metacharacter argument reaches the process as one unmodified literal token, never a shell expansion',
|
|
262
|
+
request: (subject) => subject.injectionRequest,
|
|
263
|
+
expectation: {
|
|
264
|
+
kind: 'resolves',
|
|
265
|
+
check: (observation, subject) => {
|
|
266
|
+
if (observation.kind !== 'cli' || observation.stdout.kind !== 'json') {
|
|
267
|
+
return 'the observation carries no parsed stdout to check the received argument against';
|
|
268
|
+
}
|
|
269
|
+
const argv = observation.stdout.value.argv;
|
|
270
|
+
const received = Array.isArray(argv) ? argv : [];
|
|
271
|
+
return received.length === 1 &&
|
|
272
|
+
received[0] === subject.injectionArgumentValue
|
|
273
|
+
? undefined
|
|
274
|
+
: `the process observed argv ${JSON.stringify(received)}, expected exactly one element equal to the declared literal`;
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: 'command/capture-declared-artifact',
|
|
280
|
+
title: "a declared artifact identifier reads the file the run wrote, keyed by the operation's own identifier",
|
|
281
|
+
request: (subject) => subject.artifactRequest,
|
|
282
|
+
expectation: {
|
|
283
|
+
kind: 'resolves',
|
|
284
|
+
check: (observation, subject) => {
|
|
285
|
+
if (observation.kind !== 'cli') {
|
|
286
|
+
return 'the observation is not a command observation';
|
|
287
|
+
}
|
|
288
|
+
const artifact = observation.artifacts[subject.artifactId];
|
|
289
|
+
return artifact?.kind === 'text' &&
|
|
290
|
+
artifact.value === subject.artifactExpectedText
|
|
291
|
+
? undefined
|
|
292
|
+
: `artifacts["${subject.artifactId}"] was ${JSON.stringify(artifact)}, expected the fixture's declared text`;
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
id: 'command/cap-elapsed',
|
|
298
|
+
title: 'a process past maxElapsedMs is capped, not left to finish',
|
|
299
|
+
request: (subject) => subject.overElapsedRequest,
|
|
300
|
+
expectation: { kind: 'rejects', code: CAPPED },
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: 'command/cap-output-bytes',
|
|
304
|
+
title: 'output past maxOutputBytes is capped, not silently truncated and returned',
|
|
305
|
+
request: (subject) => subject.overOutputRequest,
|
|
306
|
+
expectation: { kind: 'rejects', code: CAPPED },
|
|
307
|
+
},
|
|
308
|
+
];
|
|
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
|
+
/**
|
|
365
|
+
* Fifteen outcomes: the six shared assertions plus the nine above. Unlike the
|
|
366
|
+
* `api` arm, every cap here has an assertion: a command subject's fixture
|
|
367
|
+
* script can be told to overrun a byte cap directly, with no oversize-request
|
|
368
|
+
* problem to work around.
|
|
369
|
+
*/
|
|
370
|
+
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));
|
|
375
|
+
}
|
|
376
|
+
return reportOf(subject.name, 'command-probe', [...shared, ...additional]);
|
|
377
|
+
}
|
package/package.json
CHANGED