dsh-plugin-coding-delegate 0.0.0-stage → 0.2.2
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/LICENSE +21 -0
- package/README.md +920 -2
- package/cordis.patch.example.yml +48 -0
- package/cordis.patch.yml +13 -0
- package/dist/attestation.d.ts +25 -0
- package/dist/attestation.js +44 -0
- package/dist/containment.d.ts +33 -0
- package/dist/containment.js +63 -0
- package/dist/context-quality.d.ts +79 -0
- package/dist/context-quality.js +181 -0
- package/dist/context.d.ts +39 -0
- package/dist/context.js +136 -0
- package/dist/contracts.d.ts +174 -0
- package/dist/contracts.js +415 -0
- package/dist/declaration-egress-hook.d.ts +45 -0
- package/dist/declaration-egress-hook.js +125 -0
- package/dist/declaration-egress.d.ts +37 -0
- package/dist/declaration-egress.js +169 -0
- package/dist/delegation.d.ts +339 -0
- package/dist/delegation.js +933 -0
- package/dist/emission.d.ts +70 -0
- package/dist/emission.js +310 -0
- package/dist/guard.d.ts +162 -0
- package/dist/guard.js +873 -0
- package/dist/host-events.d.ts +15 -0
- package/dist/host-events.js +27 -0
- package/dist/index.d.ts +560 -0
- package/dist/index.js +1398 -0
- package/dist/local-classifier.d.ts +52 -0
- package/dist/local-classifier.js +159 -0
- package/dist/logging.d.ts +21 -0
- package/dist/logging.js +90 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +83 -0
- package/dist/profiles.d.ts +35 -0
- package/dist/profiles.js +95 -0
- package/dist/retry-context.d.ts +52 -0
- package/dist/retry-context.js +97 -0
- package/dist/roles.d.ts +129 -0
- package/dist/roles.js +363 -0
- package/dist/routing.d.ts +35 -0
- package/dist/routing.js +88 -0
- package/dist/savings-tracker.d.ts +128 -0
- package/dist/savings-tracker.js +340 -0
- package/dist/session-events.d.ts +17 -0
- package/dist/session-events.js +31 -0
- package/dist/verification.d.ts +104 -0
- package/dist/verification.js +560 -0
- package/package.json +66 -4
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A failure described without source: enough to diagnose, not enough to leak code.
|
|
3
|
+
*/
|
|
4
|
+
export interface RedactedFailure {
|
|
5
|
+
kind: 'assertion' | 'compile' | 'timeout' | 'runtime' | 'unknown';
|
|
6
|
+
/** Subtest name, when the runner reports one. */
|
|
7
|
+
name?: string;
|
|
8
|
+
/** file:line or file:line:col. */
|
|
9
|
+
location?: string;
|
|
10
|
+
/** Runner or compiler error code, e.g. TS1434 or ERR_ASSERTION. */
|
|
11
|
+
code?: string;
|
|
12
|
+
/** The assertion label, kept only when it reads as prose rather than code. */
|
|
13
|
+
message?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface TestResults {
|
|
16
|
+
passed: number;
|
|
17
|
+
failed: number;
|
|
18
|
+
/** Redacted summary. Raw output never leaves the machine by default. */
|
|
19
|
+
output: string;
|
|
20
|
+
errorSummary?: string;
|
|
21
|
+
/** Structured failures: names, locations, error kinds. */
|
|
22
|
+
failures?: RedactedFailure[];
|
|
23
|
+
/** True when the output above was passed through the redactor. */
|
|
24
|
+
redacted?: boolean;
|
|
25
|
+
/** Where the FULL raw output was written locally, for the worker and the operator. */
|
|
26
|
+
rawOutputPath?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Reduce raw verification output to a source-free structure.
|
|
30
|
+
*
|
|
31
|
+
* Only named fields are ever copied out of a failure block; every other line inside it
|
|
32
|
+
* (stack frames, expected/actual, diff markers, quoted code) is discarded by omission
|
|
33
|
+
* rather than by pattern-matching each leak shape.
|
|
34
|
+
*/
|
|
35
|
+
export declare function redactVerificationOutput(raw: string): RedactedFailure[];
|
|
36
|
+
/** One line per failure: kind, name, location, code, prose message. Never source. */
|
|
37
|
+
export declare function describeFailures(failures: RedactedFailure[]): string[];
|
|
38
|
+
export declare function parseTestOutput(output: string, exitCode?: number, options?: {
|
|
39
|
+
redact?: boolean;
|
|
40
|
+
rawOutputPath?: string;
|
|
41
|
+
}): TestResults;
|
|
42
|
+
/**
|
|
43
|
+
* The bound on how long a verification command may run, in milliseconds.
|
|
44
|
+
*
|
|
45
|
+
* 30 s was the value hardcoded inside `runSandboxVerification`, and it is kept as the default so that an
|
|
46
|
+
* operator who configures nothing sees no change. It is exported because it is the answer to "how long
|
|
47
|
+
* do I have?", and previously that answer was only available by reading the source.
|
|
48
|
+
*/
|
|
49
|
+
export declare const DEFAULT_VERIFICATION_TIMEOUT_MS = 30000;
|
|
50
|
+
/**
|
|
51
|
+
* Coerce a configured timeout, falling back to the default rather than to *no bound*.
|
|
52
|
+
*
|
|
53
|
+
* The direction matters. "No timeout" on a command that is model-selected and runs with the DSH
|
|
54
|
+
* process's authority is not a permission, it is a hang — so a value that is not a positive finite
|
|
55
|
+
* number is refused and the default applies. A fractional value is floored to a whole millisecond
|
|
56
|
+
* because that is the unit the spawn takes.
|
|
57
|
+
*/
|
|
58
|
+
export declare function resolveVerificationTimeoutMs(value: unknown): number;
|
|
59
|
+
export interface VerificationPolicy {
|
|
60
|
+
mode: 'ask' | 'allow' | 'deny';
|
|
61
|
+
allowlist: string[];
|
|
62
|
+
allowInProcessFallback: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* How long the verification command may run. Never absent: there is always a bound, because the
|
|
65
|
+
* alternative is a command that never returns.
|
|
66
|
+
*/
|
|
67
|
+
timeoutMs: number;
|
|
68
|
+
/**
|
|
69
|
+
* The PROJECT's own check -- a build, a full suite -- run after the unit's contract, and given the
|
|
70
|
+
* power to void an otherwise passing unit.
|
|
71
|
+
*
|
|
72
|
+
* Operator configuration rather than model input. That is why it is deliberately absent from the tool
|
|
73
|
+
* schema and is not put through the approval seam: the operator wrote this string in their profile
|
|
74
|
+
* patch, exactly as they would write it in CI, so there is nothing for them to approve that they did
|
|
75
|
+
* not already write. `runVerification` is model-selected and is gated for precisely that reason.
|
|
76
|
+
*
|
|
77
|
+
* It exists because every other check in this plugin is per unit: `contractFiles` judges one unit's
|
|
78
|
+
* tests, verification runs one unit's command, and nothing asks whether the tree still works.
|
|
79
|
+
*/
|
|
80
|
+
coherenceVerification?: string;
|
|
81
|
+
}
|
|
82
|
+
export declare const DEFAULT_VERIFICATION_POLICY: VerificationPolicy;
|
|
83
|
+
/** The program a shell command would run, normalised for allowlist comparison. */
|
|
84
|
+
export declare function commandProgram(command: string): string;
|
|
85
|
+
/**
|
|
86
|
+
* Decide whether a model-supplied verification command may run. Pure, so the policy is
|
|
87
|
+
* testable without a server or an approval seam. `runVerification` is model-selected and
|
|
88
|
+
* executes with the DSH process's full authority, so silence is never consent: anything
|
|
89
|
+
* not explicitly permitted resolves to `ask`, and `ask` with no approver available is a
|
|
90
|
+
* refusal at the call site.
|
|
91
|
+
*/
|
|
92
|
+
export declare function evaluateVerificationPolicy(command: string, policy?: VerificationPolicy): {
|
|
93
|
+
kind: 'allow' | 'ask' | 'deny';
|
|
94
|
+
program: string;
|
|
95
|
+
reason: string;
|
|
96
|
+
};
|
|
97
|
+
export declare function runInProcessFallback(cmd: string, workspaceDir: string): string;
|
|
98
|
+
export declare function runSandboxVerification(verificationCommand: string, workspaceDir?: string, options?: {
|
|
99
|
+
redact?: boolean;
|
|
100
|
+
rawLogPath?: string;
|
|
101
|
+
allowInProcessFallback?: boolean;
|
|
102
|
+
/** Overrides the default bound; an unusable value falls back to it rather than removing it. */
|
|
103
|
+
timeoutMs?: number;
|
|
104
|
+
}): TestResults;
|
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DEFAULT_VERIFICATION_POLICY = exports.DEFAULT_VERIFICATION_TIMEOUT_MS = void 0;
|
|
37
|
+
exports.redactVerificationOutput = redactVerificationOutput;
|
|
38
|
+
exports.describeFailures = describeFailures;
|
|
39
|
+
exports.parseTestOutput = parseTestOutput;
|
|
40
|
+
exports.resolveVerificationTimeoutMs = resolveVerificationTimeoutMs;
|
|
41
|
+
exports.commandProgram = commandProgram;
|
|
42
|
+
exports.evaluateVerificationPolicy = evaluateVerificationPolicy;
|
|
43
|
+
exports.runInProcessFallback = runInProcessFallback;
|
|
44
|
+
exports.runSandboxVerification = runSandboxVerification;
|
|
45
|
+
const fs = __importStar(require("fs"));
|
|
46
|
+
const path = __importStar(require("path"));
|
|
47
|
+
const child_process = __importStar(require("child_process"));
|
|
48
|
+
const logging_1 = require("./logging");
|
|
49
|
+
const index_1 = require("./index");
|
|
50
|
+
/**
|
|
51
|
+
* Does this text look like source rather than a label? Assertion messages should be
|
|
52
|
+
* prose; anything code-shaped is dropped rather than forwarded.
|
|
53
|
+
*/
|
|
54
|
+
function looksLikeCode(text) {
|
|
55
|
+
return (/[{};]/.test(text) ||
|
|
56
|
+
/=>/.test(text) ||
|
|
57
|
+
/\b(?:function|const|let|var|return|import|export|class|def|public|private)\b/.test(text) ||
|
|
58
|
+
/\b(?:expected|actual)\b\s*[:=]/i.test(text));
|
|
59
|
+
}
|
|
60
|
+
function cleanMessage(raw) {
|
|
61
|
+
const collapsed = String(raw).trim().replace(/^['"]|['"]$/g, '').replace(/\s+/g, ' ');
|
|
62
|
+
if (!collapsed || /^\|-?$/.test(collapsed))
|
|
63
|
+
return undefined;
|
|
64
|
+
if (looksLikeCode(collapsed))
|
|
65
|
+
return undefined;
|
|
66
|
+
return collapsed.length > 160 ? collapsed.slice(0, 157) + '...' : collapsed;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Bound a retained field and strip anything the secret rules recognise. Entropy is on:
|
|
70
|
+
* these strings are exactly what leaves the machine, so a high-entropy blob in a test
|
|
71
|
+
* name must not ride along merely because it lacks a recognisable keyword.
|
|
72
|
+
*/
|
|
73
|
+
function sanitizeRetainedField(value, maxLength = 200) {
|
|
74
|
+
if (typeof value !== 'string')
|
|
75
|
+
return undefined;
|
|
76
|
+
const collapsed = value.replace(/\s+/g, ' ').trim();
|
|
77
|
+
if (!collapsed)
|
|
78
|
+
return undefined;
|
|
79
|
+
const scanned = (0, index_1.scanDLP)(collapsed);
|
|
80
|
+
if (scanned.hasSensitiveData)
|
|
81
|
+
return '[redacted: ' + scanned.violations.join(', ') + ']';
|
|
82
|
+
return collapsed.length > maxLength ? collapsed.slice(0, maxLength) + ' [truncated]' : collapsed;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Reduce raw verification output to a source-free structure.
|
|
86
|
+
*
|
|
87
|
+
* Only named fields are ever copied out of a failure block; every other line inside it
|
|
88
|
+
* (stack frames, expected/actual, diff markers, quoted code) is discarded by omission
|
|
89
|
+
* rather than by pattern-matching each leak shape.
|
|
90
|
+
*/
|
|
91
|
+
function redactVerificationOutput(raw) {
|
|
92
|
+
if (!raw)
|
|
93
|
+
return [];
|
|
94
|
+
const failures = [];
|
|
95
|
+
let current = null;
|
|
96
|
+
const flush = () => {
|
|
97
|
+
if (current && (current.name || current.location || current.code))
|
|
98
|
+
failures.push(current);
|
|
99
|
+
current = null;
|
|
100
|
+
};
|
|
101
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
102
|
+
const t = line.trim();
|
|
103
|
+
if (!t)
|
|
104
|
+
continue;
|
|
105
|
+
const compile = /^(.+?)\((\d+),(\d+)\):\s*error\s+(TS\d+):\s*(.*)$/.exec(t);
|
|
106
|
+
if (compile) {
|
|
107
|
+
flush();
|
|
108
|
+
failures.push({
|
|
109
|
+
kind: 'compile',
|
|
110
|
+
location: compile[1] + ':' + compile[2] + ':' + compile[3],
|
|
111
|
+
code: compile[4],
|
|
112
|
+
message: cleanMessage(compile[5]),
|
|
113
|
+
});
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const compile2 = /^error\s+(TS\d+):\s*(.*)$/.exec(t);
|
|
117
|
+
if (compile2) {
|
|
118
|
+
flush();
|
|
119
|
+
failures.push({ kind: 'compile', code: compile2[1], message: cleanMessage(compile2[2]) });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
// A passing test ends the block above it. Without this, every line up to the next `not ok` was read as
|
|
123
|
+
// part of the previous failure -- including the `ok` lines of tests named for a timeout, which is how an
|
|
124
|
+
// assertion failure came to be reported as [timeout].
|
|
125
|
+
if (/^ok\s+\d+/i.test(t)) {
|
|
126
|
+
flush();
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const notOk = /^not ok\s+(\d+)\s*-\s*(.*)$/i.exec(t);
|
|
130
|
+
if (notOk) {
|
|
131
|
+
flush();
|
|
132
|
+
current = { kind: 'assertion', name: (notOk[1] + '. ' + notOk[2]).replace(/\s+/g, ' ').trim() };
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (current) {
|
|
136
|
+
const loc = /^location:\s*(.+)$/.exec(t);
|
|
137
|
+
if (loc) {
|
|
138
|
+
current.location = loc[1].replace(/^['"]|['"]$/g, '').trim();
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const code = /^code:\s*(.+)$/.exec(t);
|
|
142
|
+
if (code) {
|
|
143
|
+
const value = code[1].replace(/^['"]|['"]$/g, '').trim();
|
|
144
|
+
current.code = value;
|
|
145
|
+
// A failed expectation is an assertion, and nothing later in the block may relabel it: the word
|
|
146
|
+
// "timeout" appears in this suite's own test names, and a stray mention used to win.
|
|
147
|
+
if (/^ERR_ASSERTION$/i.test(value))
|
|
148
|
+
current.kind = 'assertion';
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const err = /^error:\s*(.*)$/.exec(t);
|
|
152
|
+
if (err) {
|
|
153
|
+
const message = cleanMessage(err[1]);
|
|
154
|
+
if (message)
|
|
155
|
+
current.message = message;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (/timeout/i.test(t) && !/^ERR_ASSERTION$/i.test(String(current.code ?? ''))) {
|
|
159
|
+
current.kind = 'timeout';
|
|
160
|
+
}
|
|
161
|
+
// Everything else inside a failure block is deliberately not copied.
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (/AssertionError/.test(t)) {
|
|
165
|
+
const message = cleanMessage(t);
|
|
166
|
+
failures.push({ kind: 'assertion', ...(message ? { message } : {}) });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
flush();
|
|
170
|
+
// Names, locations and codes are test- and model-controlled text and they travel to the
|
|
171
|
+
// cloud. A TAP label can carry a credential as easily as a diff can, so every retained
|
|
172
|
+
// field is put through the same secret rules the DLP gate uses. Counts and structure
|
|
173
|
+
// still travel; only the offending value is replaced by a marker.
|
|
174
|
+
return failures
|
|
175
|
+
.map((failure) => {
|
|
176
|
+
const bounded = { kind: failure.kind };
|
|
177
|
+
const name = sanitizeRetainedField(failure.name);
|
|
178
|
+
const location = sanitizeRetainedField(failure.location);
|
|
179
|
+
const code = sanitizeRetainedField(failure.code, 40);
|
|
180
|
+
const message = sanitizeRetainedField(failure.message, 300);
|
|
181
|
+
if (name)
|
|
182
|
+
bounded.name = name;
|
|
183
|
+
if (location)
|
|
184
|
+
bounded.location = location;
|
|
185
|
+
if (code)
|
|
186
|
+
bounded.code = code;
|
|
187
|
+
if (message)
|
|
188
|
+
bounded.message = message;
|
|
189
|
+
return bounded;
|
|
190
|
+
})
|
|
191
|
+
.filter((failure) => Boolean(failure.name || failure.location || failure.code || failure.message));
|
|
192
|
+
}
|
|
193
|
+
/** One line per failure: kind, name, location, code, prose message. Never source. */
|
|
194
|
+
function describeFailures(failures) {
|
|
195
|
+
return failures.map((f) => {
|
|
196
|
+
const parts = ['- [' + f.kind + ']'];
|
|
197
|
+
if (f.name)
|
|
198
|
+
parts.push(f.name);
|
|
199
|
+
if (f.location)
|
|
200
|
+
parts.push('@ ' + f.location);
|
|
201
|
+
if (f.code)
|
|
202
|
+
parts.push('(' + f.code + ')');
|
|
203
|
+
if (f.message)
|
|
204
|
+
parts.push(': ' + f.message);
|
|
205
|
+
return parts.join(' ');
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
function parseTestOutput(output, exitCode, options = {}) {
|
|
209
|
+
if (!output) {
|
|
210
|
+
// A silent command is only a success when it also exited cleanly.
|
|
211
|
+
if (exitCode !== undefined && exitCode !== 0) {
|
|
212
|
+
return {
|
|
213
|
+
passed: 0,
|
|
214
|
+
failed: 1,
|
|
215
|
+
output: 'command exited with code ' + exitCode + ' and produced no output',
|
|
216
|
+
errorSummary: 'exit code ' + exitCode,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return { passed: 0, failed: 0, output: '' };
|
|
220
|
+
}
|
|
221
|
+
let passed = 0;
|
|
222
|
+
let failed = 0;
|
|
223
|
+
const lines = output.split('\n');
|
|
224
|
+
const failureLines = [];
|
|
225
|
+
const hasTap = lines.some((l) => /^ok\s+\d+|^not ok\s+\d+/i.test(l.trim()));
|
|
226
|
+
if (hasTap) {
|
|
227
|
+
for (const line of lines) {
|
|
228
|
+
const trimmed = line.trim();
|
|
229
|
+
if (/^not ok\s+/i.test(trimmed)) {
|
|
230
|
+
failed++;
|
|
231
|
+
failureLines.push(trimmed);
|
|
232
|
+
}
|
|
233
|
+
else if (/^ok\s+/i.test(trimmed)) {
|
|
234
|
+
passed++;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
for (const line of lines) {
|
|
240
|
+
const trimmed = line.trim();
|
|
241
|
+
if (!trimmed)
|
|
242
|
+
continue;
|
|
243
|
+
if (/✓|✔|PASSED/i.test(trimmed) && !/FAILED|not ok/i.test(trimmed)) {
|
|
244
|
+
passed++;
|
|
245
|
+
}
|
|
246
|
+
else if (/(?:✕|✖|FAILED|AssertionError|Error:)/i.test(trimmed)) {
|
|
247
|
+
failed++;
|
|
248
|
+
failureLines.push(trimmed);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// The exit code is authoritative. Unrecognized output must NEVER count as a pass:
|
|
253
|
+
// `tsc` reports failures as "error TS1234:", which matches none of the markers
|
|
254
|
+
// above, so the previous fallback turned a broken build into status SUCCESS.
|
|
255
|
+
if (exitCode !== undefined && exitCode !== 0) {
|
|
256
|
+
failed = Math.max(failed, 1);
|
|
257
|
+
if (failureLines.length === 0) {
|
|
258
|
+
failureLines.push(output.slice(0, 300) || 'command exited with code ' + exitCode);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else if (passed === 0 && failed === 0) {
|
|
262
|
+
if (output.includes('AssertionError') ||
|
|
263
|
+
output.includes('Error:') ||
|
|
264
|
+
output.includes('FAIL') ||
|
|
265
|
+
/\berror\b/i.test(output)) {
|
|
266
|
+
failed = 1;
|
|
267
|
+
failureLines.push(output.slice(0, 300));
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
passed = 1;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Raw output is a source-egress channel, so by default only structure is returned.
|
|
274
|
+
// The full text is on disk (rawOutputPath) for the delegated worker to work from.
|
|
275
|
+
const redact = options.redact !== false;
|
|
276
|
+
const failures = redact ? redactVerificationOutput(output) : [];
|
|
277
|
+
const described = describeFailures(failures);
|
|
278
|
+
const headline = failed + ' failed, ' + passed + ' passed' + (exitCode !== undefined ? ' (exit ' + exitCode + ')' : '');
|
|
279
|
+
const body = redact
|
|
280
|
+
? [
|
|
281
|
+
headline,
|
|
282
|
+
...(described.length > 0
|
|
283
|
+
? described.slice(0, 10)
|
|
284
|
+
: failed > 0
|
|
285
|
+
? ['- [unknown] no structured failure could be extracted; see the raw log']
|
|
286
|
+
: []),
|
|
287
|
+
...(options.rawOutputPath ? ['raw output: ' + options.rawOutputPath] : []),
|
|
288
|
+
].join('\n')
|
|
289
|
+
: output.slice(0, 2000);
|
|
290
|
+
const summarySource = redact
|
|
291
|
+
? described.slice(0, 5).join('; ') || (failed > 0 ? 'unstructured failure; see the raw log' : undefined)
|
|
292
|
+
: failureLines.length > 0
|
|
293
|
+
? failureLines.slice(0, 5).join('; ')
|
|
294
|
+
: undefined;
|
|
295
|
+
return {
|
|
296
|
+
passed,
|
|
297
|
+
failed,
|
|
298
|
+
output: body.slice(0, 2000),
|
|
299
|
+
...(summarySource ? { errorSummary: summarySource } : {}),
|
|
300
|
+
...(redact ? { failures, redacted: true } : { redacted: false }),
|
|
301
|
+
...(options.rawOutputPath ? { rawOutputPath: options.rawOutputPath } : {}),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The bound on how long a verification command may run, in milliseconds.
|
|
306
|
+
*
|
|
307
|
+
* 30 s was the value hardcoded inside `runSandboxVerification`, and it is kept as the default so that an
|
|
308
|
+
* operator who configures nothing sees no change. It is exported because it is the answer to "how long
|
|
309
|
+
* do I have?", and previously that answer was only available by reading the source.
|
|
310
|
+
*/
|
|
311
|
+
exports.DEFAULT_VERIFICATION_TIMEOUT_MS = 30000;
|
|
312
|
+
/**
|
|
313
|
+
* Coerce a configured timeout, falling back to the default rather than to *no bound*.
|
|
314
|
+
*
|
|
315
|
+
* The direction matters. "No timeout" on a command that is model-selected and runs with the DSH
|
|
316
|
+
* process's authority is not a permission, it is a hang — so a value that is not a positive finite
|
|
317
|
+
* number is refused and the default applies. A fractional value is floored to a whole millisecond
|
|
318
|
+
* because that is the unit the spawn takes.
|
|
319
|
+
*/
|
|
320
|
+
function resolveVerificationTimeoutMs(value) {
|
|
321
|
+
const n = Number(value);
|
|
322
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
323
|
+
return exports.DEFAULT_VERIFICATION_TIMEOUT_MS;
|
|
324
|
+
return Math.floor(n);
|
|
325
|
+
}
|
|
326
|
+
exports.DEFAULT_VERIFICATION_POLICY = {
|
|
327
|
+
mode: 'ask',
|
|
328
|
+
allowlist: [],
|
|
329
|
+
allowInProcessFallback: false,
|
|
330
|
+
timeoutMs: exports.DEFAULT_VERIFICATION_TIMEOUT_MS,
|
|
331
|
+
};
|
|
332
|
+
/** The program a shell command would run, normalised for allowlist comparison. */
|
|
333
|
+
function commandProgram(command) {
|
|
334
|
+
const text = String(command || '').trim();
|
|
335
|
+
if (!text)
|
|
336
|
+
return '';
|
|
337
|
+
// A quoted first token may contain spaces ("C:\Program Files\nodejs\node.exe"), so it
|
|
338
|
+
// must be taken whole; splitting on whitespace first would read it as "C:\Program".
|
|
339
|
+
const quoted = /^"([^"]+)"|^'([^']+)'/.exec(text);
|
|
340
|
+
const first = quoted ? quoted[1] ?? quoted[2] ?? '' : text.split(/\s+/)[0] || '';
|
|
341
|
+
const bare = first.replace(/^["']|["']$/g, '');
|
|
342
|
+
return path.basename(bare).toLowerCase().replace(/\.(?:exe|cmd|bat|ps1)$/, '');
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Decide whether a model-supplied verification command may run. Pure, so the policy is
|
|
346
|
+
* testable without a server or an approval seam. `runVerification` is model-selected and
|
|
347
|
+
* executes with the DSH process's full authority, so silence is never consent: anything
|
|
348
|
+
* not explicitly permitted resolves to `ask`, and `ask` with no approver available is a
|
|
349
|
+
* refusal at the call site.
|
|
350
|
+
*/
|
|
351
|
+
function evaluateVerificationPolicy(command, policy = exports.DEFAULT_VERIFICATION_POLICY) {
|
|
352
|
+
const program = commandProgram(command);
|
|
353
|
+
if (!program) {
|
|
354
|
+
return { kind: 'deny', program, reason: 'the verification command was empty' };
|
|
355
|
+
}
|
|
356
|
+
if (policy.mode === 'deny') {
|
|
357
|
+
return {
|
|
358
|
+
kind: 'deny',
|
|
359
|
+
program,
|
|
360
|
+
reason: "verificationApproval is 'deny', so no verification command is executed",
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
const allowlisted = policy.allowlist.some((entry) => commandProgram(String(entry)) === program);
|
|
364
|
+
if (policy.mode === 'allow' || allowlisted) {
|
|
365
|
+
return {
|
|
366
|
+
kind: 'allow',
|
|
367
|
+
program,
|
|
368
|
+
reason: allowlisted
|
|
369
|
+
? "program '" + program + "' is on verificationAllowlist"
|
|
370
|
+
: "verificationApproval is 'allow'",
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
kind: 'ask',
|
|
375
|
+
program,
|
|
376
|
+
reason: "the verification command '" + command + "' would run with the full authority of the DSH process " +
|
|
377
|
+
'and is not confined to the workspace',
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function runInProcessFallback(cmd, workspaceDir) {
|
|
381
|
+
// Identify a real module path in the command, ignoring flags: the previous regex
|
|
382
|
+
// captured "--test", "-e" and "--version" as if they were file paths, then
|
|
383
|
+
// reported a bogus "target test file not found" failure for them.
|
|
384
|
+
const candidates = cmd
|
|
385
|
+
.split(/\s+/)
|
|
386
|
+
.map((token) => token.replace(/^["']|["']$/g, ''))
|
|
387
|
+
.filter((token) => token && !token.startsWith('-') && /\.(?:[cm]?[jt]sx?)$/i.test(token));
|
|
388
|
+
const targetFile = candidates.find((token) => fs.existsSync(path.isAbsolute(token) ? token : path.resolve(workspaceDir, token))) ?? null;
|
|
389
|
+
if (!targetFile) {
|
|
390
|
+
// Fail closed: with no identifiable target module there is nothing to verify,
|
|
391
|
+
// and claiming success here would report an unverified command as passing.
|
|
392
|
+
return ('not ok 1 - In-process verification fallback could not identify a target module in command ' +
|
|
393
|
+
"'" + cmd + "'. Refusing to report success.");
|
|
394
|
+
}
|
|
395
|
+
const resolvedPath = path.isAbsolute(targetFile) ? targetFile : path.resolve(workspaceDir, targetFile);
|
|
396
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
397
|
+
return "not ok 1 - Target test file '" + targetFile + "' not found at '" + resolvedPath + "'.";
|
|
398
|
+
}
|
|
399
|
+
let capturedOutput = '';
|
|
400
|
+
const originalLog = console.log;
|
|
401
|
+
const originalError = console.error;
|
|
402
|
+
const originalExit = process.exit;
|
|
403
|
+
const originalExitCode = process.exitCode;
|
|
404
|
+
try {
|
|
405
|
+
console.log = (...args) => {
|
|
406
|
+
capturedOutput += args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n';
|
|
407
|
+
originalLog(...args);
|
|
408
|
+
};
|
|
409
|
+
console.error = (...args) => {
|
|
410
|
+
capturedOutput += args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n';
|
|
411
|
+
originalError(...args);
|
|
412
|
+
};
|
|
413
|
+
process.exit = (code) => {
|
|
414
|
+
throw new Error('test code called process.exit(' + (code ?? 0) + '); refusing to terminate the host process');
|
|
415
|
+
};
|
|
416
|
+
// The module signals failure through captured TAP text, so its exit code is noise
|
|
417
|
+
// here -- and leaving it set would misreport the HOST's own result.
|
|
418
|
+
process.exitCode = undefined;
|
|
419
|
+
delete require.cache[require.resolve(resolvedPath)];
|
|
420
|
+
require(resolvedPath);
|
|
421
|
+
capturedOutput =
|
|
422
|
+
capturedOutput || 'ok 1 - Executed ' + path.basename(resolvedPath) + ' in-process successfully.';
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
capturedOutput +=
|
|
426
|
+
'not ok 1 - In-Process Test Failure in ' + path.basename(resolvedPath) + '\n ' +
|
|
427
|
+
(err?.stack || err?.message || String(err)) + '\n';
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
console.log = originalLog;
|
|
431
|
+
console.error = originalError;
|
|
432
|
+
process.exit = originalExit;
|
|
433
|
+
process.exitCode = originalExitCode;
|
|
434
|
+
}
|
|
435
|
+
return capturedOutput;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Run a verification command and capture its output.
|
|
439
|
+
*
|
|
440
|
+
* Output is captured through FILE DESCRIPTORS rather than pipes, deliberately. DSH's confined
|
|
441
|
+
* sandbox modes refuse a piped spawn outright (`spawn EPERM`), which made the ordinary
|
|
442
|
+
* subprocess path unusable in the default configuration and left the in-process fallback as the
|
|
443
|
+
* only path that worked -- the wrong trade in every direction, since that fallback executes
|
|
444
|
+
* model-influenced code inside the server. A descriptor avoids the pipe, so verification runs
|
|
445
|
+
* as an ordinary child process for everyone, and the fallback is not needed at all.
|
|
446
|
+
*/
|
|
447
|
+
function captureCommandOutput(cmd, workspaceDir, timeoutMs) {
|
|
448
|
+
const dir = (0, logging_1.resolveDataDir)();
|
|
449
|
+
const unique = process.pid + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
|
450
|
+
const outPath = path.join(dir, 'verify-' + unique + '.stdout');
|
|
451
|
+
const errPath = path.join(dir, 'verify-' + unique + '.stderr');
|
|
452
|
+
let outFd;
|
|
453
|
+
let errFd;
|
|
454
|
+
let result = null;
|
|
455
|
+
try {
|
|
456
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
457
|
+
outFd = fs.openSync(outPath, 'w');
|
|
458
|
+
errFd = fs.openSync(errPath, 'w');
|
|
459
|
+
result = child_process.spawnSync(cmd, {
|
|
460
|
+
shell: true,
|
|
461
|
+
cwd: workspaceDir,
|
|
462
|
+
timeout: timeoutMs,
|
|
463
|
+
stdio: ['ignore', outFd, errFd],
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
result = { error: err, status: null };
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
for (const fd of [outFd, errFd]) {
|
|
471
|
+
if (typeof fd === 'number') {
|
|
472
|
+
try {
|
|
473
|
+
fs.closeSync(fd);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// already closed
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const readFile = (file) => {
|
|
482
|
+
try {
|
|
483
|
+
return fs.readFileSync(file, 'utf8');
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
return '';
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
const output = (readFile(outPath) + '\n' + readFile(errPath)).trim();
|
|
490
|
+
for (const file of [outPath, errPath]) {
|
|
491
|
+
try {
|
|
492
|
+
fs.rmSync(file, { force: true });
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
// Best effort: a leftover temp file is not worth failing a verification over.
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const spawnError = result?.error ?? null;
|
|
499
|
+
const exitCode = typeof result?.status === 'number' ? result.status : spawnError ? 1 : 0;
|
|
500
|
+
return {
|
|
501
|
+
output: output || (spawnError ? String(spawnError.message || spawnError) : ''),
|
|
502
|
+
exitCode,
|
|
503
|
+
spawnError,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function runSandboxVerification(verificationCommand, workspaceDir = process.cwd(), options = {}) {
|
|
507
|
+
if (!verificationCommand || !verificationCommand.trim()) {
|
|
508
|
+
return { passed: 0, failed: 0, output: 'No verification command specified.' };
|
|
509
|
+
}
|
|
510
|
+
const cmd = verificationCommand.trim();
|
|
511
|
+
let output = '';
|
|
512
|
+
let spawnError = null;
|
|
513
|
+
let exitCode = 0;
|
|
514
|
+
const captured = captureCommandOutput(cmd, workspaceDir, resolveVerificationTimeoutMs(options.timeoutMs));
|
|
515
|
+
output = captured.output;
|
|
516
|
+
spawnError = captured.spawnError;
|
|
517
|
+
exitCode = captured.exitCode;
|
|
518
|
+
if (spawnError &&
|
|
519
|
+
(spawnError.code === 'EPERM' ||
|
|
520
|
+
String(spawnError).includes('EPERM') ||
|
|
521
|
+
String(spawnError).includes('spawn EPERM'))) {
|
|
522
|
+
// Even a descriptor spawn was refused, so this host blocks verification entirely. The
|
|
523
|
+
// in-process fallback still exists, but it re-runs model-influenced code inside the
|
|
524
|
+
// SERVER process, so it is opt-in only: answering a denial by removing the sandbox
|
|
525
|
+
// inverts the control. Fail closed.
|
|
526
|
+
if (options.allowInProcessFallback === true) {
|
|
527
|
+
output = runInProcessFallback(cmd, workspaceDir);
|
|
528
|
+
return parseTestOutput(output, undefined, {
|
|
529
|
+
redact: options.redact,
|
|
530
|
+
rawOutputPath: persistRaw(output, options.rawLogPath),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
const refusal = 'not ok 1 - the sandbox refused to spawn the verification command (EPERM) and the ' +
|
|
534
|
+
'in-process fallback is disabled by default. Refusing to report success.\n' + output;
|
|
535
|
+
return parseTestOutput(refusal, exitCode, {
|
|
536
|
+
redact: options.redact,
|
|
537
|
+
rawOutputPath: persistRaw(refusal, options.rawLogPath),
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
return parseTestOutput(output, exitCode, {
|
|
541
|
+
redact: options.redact,
|
|
542
|
+
rawOutputPath: persistRaw(output, options.rawLogPath),
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Keep the full verification output on the local machine. The redacted structure is what
|
|
547
|
+
* travels; this is what a worker or an operator reads when they need the real thing.
|
|
548
|
+
*/
|
|
549
|
+
function persistRaw(output, rawLogPath) {
|
|
550
|
+
if (!rawLogPath)
|
|
551
|
+
return undefined;
|
|
552
|
+
try {
|
|
553
|
+
fs.mkdirSync(path.dirname(rawLogPath), { recursive: true });
|
|
554
|
+
fs.writeFileSync(rawLogPath, output, 'utf8');
|
|
555
|
+
return rawLogPath;
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
return undefined;
|
|
559
|
+
}
|
|
560
|
+
}
|