@xaccefy/pi-casefile 0.7.6 → 0.8.1
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 +13 -4
- package/package.json +1 -1
- package/src/index.ts +524 -305
- package/src/ledger.ts +910 -176
- package/src/pipeline-submit.ts +7 -17
- package/src/poc-runner.ts +63 -123
- package/src/scratchpad.ts +38 -39
- package/src/workflow.ts +36 -30
package/src/pipeline-submit.ts
CHANGED
|
@@ -20,12 +20,7 @@
|
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
21
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
22
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
|
-
import {
|
|
24
|
-
getRunDir,
|
|
25
|
-
getScratchpadRoot,
|
|
26
|
-
type ScratchpadPhase,
|
|
27
|
-
scratchpad_write,
|
|
28
|
-
} from "./scratchpad.ts";
|
|
23
|
+
import { getRunDir, getScratchpadRoot, scratchpad_write } from "./scratchpad.ts";
|
|
29
24
|
|
|
30
25
|
// ── Types ────────────────────────────────────────────────────────────
|
|
31
26
|
|
|
@@ -86,7 +81,9 @@ const VULN_CLASSES = [
|
|
|
86
81
|
"other",
|
|
87
82
|
] as const;
|
|
88
83
|
|
|
89
|
-
|
|
84
|
+
// Exported for test/pipeline-submit-schema-parity.test.ts (drift guard
|
|
85
|
+
// against schemas/*.json — the two are kept as mirrors of each other).
|
|
86
|
+
export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
90
87
|
// schemas/stage-finding.json
|
|
91
88
|
hunt: {
|
|
92
89
|
required: [
|
|
@@ -415,15 +412,6 @@ function dedupHunt(state: SubmitState, obj: Record<string, unknown>): { duplicat
|
|
|
415
412
|
|
|
416
413
|
// ── Public API ───────────────────────────────────────────────────────
|
|
417
414
|
|
|
418
|
-
const STAGE_TO_PHASE: Record<SubmitStage, ScratchpadPhase> = {
|
|
419
|
-
hunt: "hunt",
|
|
420
|
-
trace: "trace",
|
|
421
|
-
skeptic: "skeptic",
|
|
422
|
-
validate: "validate",
|
|
423
|
-
chain: "chain",
|
|
424
|
-
report: "report",
|
|
425
|
-
};
|
|
426
|
-
|
|
427
415
|
export function pipeline_submit(runId: string, stage: SubmitStage, output: unknown): SubmitResult {
|
|
428
416
|
const parsed = parseOutput(output);
|
|
429
417
|
if (parsed.error || !parsed.obj) {
|
|
@@ -497,9 +485,11 @@ export function pipeline_submit(runId: string, stage: SubmitStage, output: unkno
|
|
|
497
485
|
}
|
|
498
486
|
}
|
|
499
487
|
|
|
488
|
+
// Submit stages are a subset of scratchpad phases, so the stage name IS
|
|
489
|
+
// the phase directory.
|
|
500
490
|
const artifact = scratchpad_write(
|
|
501
491
|
runId,
|
|
502
|
-
|
|
492
|
+
stage,
|
|
503
493
|
`${key.replace(/[^a-zA-Z0-9._:-]/g, "_")}.json`,
|
|
504
494
|
JSON.stringify(obj, null, 2),
|
|
505
495
|
);
|
package/src/poc-runner.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { basename,
|
|
4
|
+
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { findWorkspaceRoot } from "./scratchpad.ts";
|
|
5
7
|
|
|
6
8
|
export type PocRun = {
|
|
7
9
|
path: string;
|
|
@@ -9,20 +11,24 @@ export type PocRun = {
|
|
|
9
11
|
output: string;
|
|
10
12
|
ranAt: string;
|
|
11
13
|
sandbox: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* True iff the script actually ran to completion. False when the process
|
|
16
|
+
* could not start (spawn error), was killed by a signal, or timed out —
|
|
17
|
+
* a crash is NOT a verdict, and callers must not treat it as one.
|
|
18
|
+
*/
|
|
19
|
+
completed: boolean;
|
|
12
20
|
};
|
|
13
21
|
|
|
14
22
|
export type PocLanguage = {
|
|
15
23
|
/** Docker image used when running inside the sandbox. */
|
|
16
24
|
image: string;
|
|
17
25
|
/** Shell command to run an interpreted PoC. {{file}} is replaced with the source path. */
|
|
18
|
-
run
|
|
19
|
-
/** Shell command to build and run a compiled PoC. {{file}}, {{bin}}, {{class}} replaced. */
|
|
20
|
-
buildRun?: string;
|
|
26
|
+
run: string;
|
|
21
27
|
/** Files that, when present in the project root, identify this project type. */
|
|
22
28
|
projectMarkers?: string[];
|
|
23
29
|
};
|
|
24
30
|
|
|
25
|
-
/**
|
|
31
|
+
/** Built-in interpreted languages. Compiled languages are unsupported on purpose. */
|
|
26
32
|
const BUILTIN_LANGUAGES: Record<string, PocLanguage> = {
|
|
27
33
|
python: {
|
|
28
34
|
image: "python:3.12-slim",
|
|
@@ -40,7 +46,7 @@ const BUILTIN_LANGUAGES: Record<string, PocLanguage> = {
|
|
|
40
46
|
},
|
|
41
47
|
};
|
|
42
48
|
|
|
43
|
-
/** Extension to language key. Unknown extensions
|
|
49
|
+
/** Extension to language key. Unknown extensions need a shebang or PI_POC_DEFAULT_LANGUAGE. */
|
|
44
50
|
const EXTENSION_MAP: Record<string, string> = {
|
|
45
51
|
".py": "python",
|
|
46
52
|
".js": "node",
|
|
@@ -54,58 +60,21 @@ const EXTENSION_MAP: Record<string, string> = {
|
|
|
54
60
|
|
|
55
61
|
const OUTPUT_MAX_CHARS = 4000;
|
|
56
62
|
const TIMEOUT_MS = 30_000;
|
|
63
|
+
/** Completion sentinel echoed after the PoC command inside the sandbox shell. */
|
|
64
|
+
function makeSentinel(): string {
|
|
65
|
+
return `__PI_POC_DONE_${Math.random().toString(36).slice(2, 12)}__`;
|
|
66
|
+
}
|
|
57
67
|
/** First-use image downloads are slow — pull outside the run timeout. */
|
|
58
68
|
const PULL_TIMEOUT_MS = 300_000;
|
|
59
69
|
const MAX_BUFFER = 8 * 1024 * 1024;
|
|
60
70
|
|
|
61
71
|
function getProjectRoot(): string {
|
|
62
|
-
|
|
63
|
-
if (envRoot) return resolve(envRoot);
|
|
64
|
-
|
|
65
|
-
let curr = resolve(process.cwd());
|
|
66
|
-
for (let i = 0; i < 20; i++) {
|
|
67
|
-
if (existsSync(join(curr, ".git"))) return curr;
|
|
68
|
-
const parent = dirname(curr);
|
|
69
|
-
if (parent === curr) break;
|
|
70
|
-
curr = parent;
|
|
71
|
-
}
|
|
72
|
-
return resolve(process.cwd());
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function loadLanguages(): Record<string, PocLanguage> {
|
|
76
|
-
const languages = { ...BUILTIN_LANGUAGES };
|
|
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
|
-
|
|
91
|
-
const envOverride = process.env.PI_POC_LANGUAGES?.trim();
|
|
92
|
-
if (envOverride) {
|
|
93
|
-
try {
|
|
94
|
-
const extra = JSON.parse(envOverride) as Record<string, PocLanguage>;
|
|
95
|
-
for (const [key, lang] of Object.entries(extra)) {
|
|
96
|
-
if (lang?.image) languages[key] = lang;
|
|
97
|
-
}
|
|
98
|
-
} catch {
|
|
99
|
-
// Malformed env JSON is ignored.
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return languages;
|
|
72
|
+
return findWorkspaceRoot(["PI_POC_ROOT"], [".git"]);
|
|
104
73
|
}
|
|
105
74
|
|
|
106
|
-
function detectProjectType(
|
|
75
|
+
function detectProjectType(): string | undefined {
|
|
107
76
|
const root = getProjectRoot();
|
|
108
|
-
for (const [key, lang] of Object.entries(
|
|
77
|
+
for (const [key, lang] of Object.entries(BUILTIN_LANGUAGES)) {
|
|
109
78
|
for (const marker of lang.projectMarkers ?? []) {
|
|
110
79
|
if (existsSync(join(root, marker))) return key;
|
|
111
80
|
}
|
|
@@ -130,60 +99,49 @@ function parseShebang(pocPath: string): string | undefined {
|
|
|
130
99
|
}
|
|
131
100
|
}
|
|
132
101
|
|
|
133
|
-
function interpreterToLanguage(
|
|
134
|
-
interpreter: string,
|
|
135
|
-
languages: Record<string, PocLanguage>,
|
|
136
|
-
): string | undefined {
|
|
102
|
+
function interpreterToLanguage(interpreter: string): string | undefined {
|
|
137
103
|
const bin = basename(interpreter).toLowerCase();
|
|
138
|
-
for (const [key, lang] of Object.entries(
|
|
139
|
-
if (lang.run)
|
|
140
|
-
const runBin = lang.run.split(" ")[0].toLowerCase();
|
|
141
|
-
if (runBin === bin) return key;
|
|
142
|
-
}
|
|
143
|
-
if (lang.buildRun) {
|
|
144
|
-
const buildBin = lang.buildRun.split(" ")[0].toLowerCase();
|
|
145
|
-
if (buildBin === bin) return key;
|
|
146
|
-
}
|
|
104
|
+
for (const [key, lang] of Object.entries(BUILTIN_LANGUAGES)) {
|
|
105
|
+
if (lang.run.split(" ")[0].toLowerCase() === bin) return key;
|
|
147
106
|
}
|
|
148
107
|
return undefined;
|
|
149
108
|
}
|
|
150
109
|
|
|
151
110
|
function resolveLanguage(pocPath: string): { key: string; language: PocLanguage } {
|
|
152
|
-
const languages = loadLanguages();
|
|
153
111
|
const ext = extname(pocPath).toLowerCase();
|
|
154
112
|
const extKey = EXTENSION_MAP[ext];
|
|
155
113
|
|
|
156
114
|
// 1. Shebang overrides everything.
|
|
157
115
|
const shebang = parseShebang(pocPath);
|
|
158
116
|
if (shebang) {
|
|
159
|
-
const shebangKey = interpreterToLanguage(shebang
|
|
160
|
-
if (shebangKey) return { key: shebangKey, language:
|
|
117
|
+
const shebangKey = interpreterToLanguage(shebang);
|
|
118
|
+
if (shebangKey) return { key: shebangKey, language: BUILTIN_LANGUAGES[shebangKey] };
|
|
161
119
|
}
|
|
162
120
|
|
|
163
121
|
// 2. Extension-based language (prefer the PoC file itself over ambient project markers).
|
|
164
122
|
// A .py PoC in a Node monorepo must still run under python, not node.
|
|
165
|
-
if (extKey &&
|
|
166
|
-
return { key: extKey, language:
|
|
123
|
+
if (extKey && BUILTIN_LANGUAGES[extKey]) {
|
|
124
|
+
return { key: extKey, language: BUILTIN_LANGUAGES[extKey] };
|
|
167
125
|
}
|
|
168
126
|
|
|
169
127
|
// 3. Project type detection only when the extension is unknown/unmapped.
|
|
170
|
-
const projectType = detectProjectType(
|
|
171
|
-
if (projectType &&
|
|
172
|
-
return { key: projectType, language:
|
|
128
|
+
const projectType = detectProjectType();
|
|
129
|
+
if (projectType && BUILTIN_LANGUAGES[projectType]) {
|
|
130
|
+
return { key: projectType, language: BUILTIN_LANGUAGES[projectType] };
|
|
173
131
|
}
|
|
174
132
|
|
|
175
133
|
// 4. Unknown extension: allow env override specifying a single language key.
|
|
176
134
|
const envDefault = process.env.PI_POC_DEFAULT_LANGUAGE?.trim();
|
|
177
|
-
if (envDefault &&
|
|
178
|
-
return { key: envDefault, language:
|
|
135
|
+
if (envDefault && BUILTIN_LANGUAGES[envDefault]) {
|
|
136
|
+
return { key: envDefault, language: BUILTIN_LANGUAGES[envDefault] };
|
|
179
137
|
}
|
|
180
138
|
|
|
181
|
-
const supported = Object.keys(
|
|
139
|
+
const supported = Object.keys(BUILTIN_LANGUAGES).sort().join(", ");
|
|
182
140
|
throw new Error(
|
|
183
141
|
`Cannot determine PoC language for "${pocPath}". ` +
|
|
184
142
|
`Detected extension: "${ext || "none"}". ` +
|
|
185
|
-
`Supported
|
|
186
|
-
`Add a shebang, use a known extension, or set PI_POC_DEFAULT_LANGUAGE
|
|
143
|
+
`Supported languages: ${supported}. ` +
|
|
144
|
+
`Add a shebang, use a known extension, or set PI_POC_DEFAULT_LANGUAGE.`,
|
|
187
145
|
);
|
|
188
146
|
}
|
|
189
147
|
|
|
@@ -281,20 +239,12 @@ function shq(s: string): string {
|
|
|
281
239
|
}
|
|
282
240
|
|
|
283
241
|
function renderCommand(template: string, pocPath: string, inSandbox: boolean): string {
|
|
284
|
-
const sourceName = basename(pocPath);
|
|
285
|
-
const className = sourceName.replace(/\.[^.]+$/i, "");
|
|
286
242
|
// In the sandbox the template runs under `sh -c`, and the PoC basename is
|
|
287
|
-
// agent-controlled — single-quote
|
|
243
|
+
// agent-controlled — single-quote the substitution so a hostile filename
|
|
288
244
|
// (e.g. `$(curl evil).py`) cannot inject shell into the container entrypoint.
|
|
289
245
|
// Local mode uses no shell (args passed verbatim), so quoting stays off there.
|
|
290
|
-
const targetPath = inSandbox ? shq(`/workspace/${
|
|
291
|
-
|
|
292
|
-
const cls = inSandbox ? shq(className) : className;
|
|
293
|
-
|
|
294
|
-
return template
|
|
295
|
-
.replace(/{{file}}/g, targetPath)
|
|
296
|
-
.replace(/{{bin}}/g, binPath)
|
|
297
|
-
.replace(/{{class}}/g, cls);
|
|
246
|
+
const targetPath = inSandbox ? shq(`/workspace/${basename(pocPath)}`) : pocPath;
|
|
247
|
+
return template.replace(/{{file}}/g, targetPath);
|
|
298
248
|
}
|
|
299
249
|
|
|
300
250
|
/**
|
|
@@ -360,22 +310,23 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
|
360
310
|
output: `[sandbox image error] ${(e as Error).message}`,
|
|
361
311
|
ranAt,
|
|
362
312
|
sandbox: true,
|
|
313
|
+
completed: false,
|
|
363
314
|
};
|
|
364
315
|
}
|
|
365
316
|
copyFileSync(pocPath, `${workspaceDir}/${sourceName}`);
|
|
366
317
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
318
|
+
const command = renderCommand(language.run, pocPath, true);
|
|
319
|
+
|
|
320
|
+
// Completion sentinel: wrap the command so the shell echoes a unique token
|
|
321
|
+
// AFTER the PoC exits, preserving its exit code. If the container is
|
|
322
|
+
// killed, times out, or the run never starts, the sentinel is absent —
|
|
323
|
+
// callers can then tell "script ran and failed" from "script crashed".
|
|
324
|
+
const sentinel = makeSentinel();
|
|
325
|
+
const wrapped = `${command}; rc=$?; echo '${sentinel}'; exit $rc`;
|
|
375
326
|
|
|
376
327
|
const result = spawnSync(
|
|
377
328
|
"docker",
|
|
378
|
-
buildDockerArgs(language.image,
|
|
329
|
+
buildDockerArgs(language.image, wrapped, workspaceDir, containerName),
|
|
379
330
|
{
|
|
380
331
|
encoding: "utf8",
|
|
381
332
|
timeout: TIMEOUT_MS,
|
|
@@ -384,13 +335,16 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
|
384
335
|
);
|
|
385
336
|
|
|
386
337
|
const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
|
|
387
|
-
const
|
|
338
|
+
const raw = (result.stdout ?? "") + (result.stderr ?? "") + spawnErr;
|
|
339
|
+
const completed = raw.includes(sentinel);
|
|
340
|
+
const output = sanitizeOutput(raw.replace(sentinel, ""));
|
|
388
341
|
return {
|
|
389
342
|
path: pocPath,
|
|
390
343
|
exitCode: spawnExitCode(result),
|
|
391
344
|
output,
|
|
392
345
|
ranAt,
|
|
393
346
|
sandbox: true,
|
|
347
|
+
completed,
|
|
394
348
|
};
|
|
395
349
|
} finally {
|
|
396
350
|
// Best-effort: remove any container still running after a timeout/kill.
|
|
@@ -410,31 +364,15 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
|
410
364
|
function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
411
365
|
const ranAt = new Date().toISOString();
|
|
412
366
|
|
|
413
|
-
if (language.buildRun) {
|
|
414
|
-
throw new Error(
|
|
415
|
-
"Compiled PoC languages require the Docker sandbox. " +
|
|
416
|
-
"Run PromoteFinding with local:false or use an interpreted PoC.",
|
|
417
|
-
);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (!language.run) {
|
|
421
|
-
throw new Error("Language config has no run command");
|
|
422
|
-
}
|
|
423
|
-
|
|
424
367
|
// The run template is `<interpreter> [flags...] {{file}}`. Split the static
|
|
425
|
-
// template on whitespace FIRST (
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
368
|
+
// template on whitespace FIRST (builtins only, not user input), then render
|
|
369
|
+
// placeholders within each token. Passing the tokens to spawnSync with NO
|
|
370
|
+
// shell keeps a space-containing PoC path as one arg and keeps extra flags
|
|
371
|
+
// (e.g. `node --experimental-vm-modules {{file}}`) as separate args.
|
|
429
372
|
// Splitting after rendering would re-split a space-containing path.
|
|
430
373
|
const tokens = language.run.trim().split(/\s+/).filter(Boolean);
|
|
431
374
|
const interpreter = tokens.shift() ?? language.run.trim();
|
|
432
|
-
const args = tokens.map((tok) =>
|
|
433
|
-
tok
|
|
434
|
-
.replace(/{{file}}/g, pocPath)
|
|
435
|
-
.replace(/{{bin}}/g, join(dirname(pocPath), "poc"))
|
|
436
|
-
.replace(/{{class}}/g, basename(pocPath).replace(/\.[^.]+$/i, "")),
|
|
437
|
-
);
|
|
375
|
+
const args = tokens.map((tok) => renderCommand(tok, pocPath, false));
|
|
438
376
|
|
|
439
377
|
const result = spawnSync(interpreter, args, {
|
|
440
378
|
encoding: "utf8",
|
|
@@ -442,7 +380,12 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
|
442
380
|
maxBuffer: MAX_BUFFER,
|
|
443
381
|
});
|
|
444
382
|
|
|
383
|
+
// Local runs stay shell-free (space-containing paths stay single args), so
|
|
384
|
+
// there is no sentinel echo: "completed" is derived from the spawn result.
|
|
385
|
+
// A spawn error (interpreter missing) or a signal kill (timeout, SIGKILL)
|
|
386
|
+
// means the script never ran to completion — fail closed on those.
|
|
445
387
|
const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
|
|
388
|
+
const completed = !result.error && result.signal === null;
|
|
446
389
|
const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr);
|
|
447
390
|
return {
|
|
448
391
|
path: pocPath,
|
|
@@ -450,6 +393,7 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
|
450
393
|
output,
|
|
451
394
|
ranAt,
|
|
452
395
|
sandbox: false,
|
|
396
|
+
completed,
|
|
453
397
|
};
|
|
454
398
|
}
|
|
455
399
|
|
|
@@ -460,11 +404,7 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
|
460
404
|
* 1. Shebang line in the PoC file.
|
|
461
405
|
* 2. File extension (a .py PoC in a Node repo still runs under python).
|
|
462
406
|
* 3. Project type markers in the workspace root (e.g., package.json, requirements.txt).
|
|
463
|
-
* 4. PI_POC_DEFAULT_LANGUAGE environment variable.
|
|
464
|
-
*
|
|
465
|
-
* Users can extend or override language definitions via:
|
|
466
|
-
* - `.pi/poc-languages.json` in the project root.
|
|
467
|
-
* - `PI_POC_LANGUAGES` environment variable (JSON object).
|
|
407
|
+
* 4. PI_POC_DEFAULT_LANGUAGE environment variable (a built-in language key).
|
|
468
408
|
*
|
|
469
409
|
* Security:
|
|
470
410
|
* - PoC paths must be absolute and under the project workspace by default.
|
package/src/scratchpad.ts
CHANGED
|
@@ -6,11 +6,17 @@
|
|
|
6
6
|
* logs) instead of stuffing everything into casefile text fields or relying
|
|
7
7
|
* on each other's output streams (which creates an echo chamber).
|
|
8
8
|
*
|
|
9
|
-
* Directory layout per pipeline run:
|
|
9
|
+
* Directory layout per pipeline run (one subdir per phase — see PHASE_DIRS):
|
|
10
10
|
* {project_root}/.scratchpad/{run_id}/
|
|
11
11
|
* recon/ — fingerprints, tech detection, surface maps
|
|
12
|
+
* hunt/ — per-class findings
|
|
13
|
+
* gapfil/ — gap-fill audit notes
|
|
12
14
|
* trace/ — per-finding reachability traces
|
|
13
|
-
*
|
|
15
|
+
* skeptic/ — adversarial disproof attempts
|
|
16
|
+
* verify/ — PoC logs, run outputs (validate phase)
|
|
17
|
+
* chain/ — exploit-chain analysis
|
|
18
|
+
* patch/ — remediation work
|
|
19
|
+
* report/ — report-writer context
|
|
14
20
|
* state.json — checkpoint file with phase completion + key IDs
|
|
15
21
|
*
|
|
16
22
|
* Resume re-reads scratchpad artifacts; it does not re-run completed phases
|
|
@@ -59,7 +65,7 @@ export interface ScratchpadResume {
|
|
|
59
65
|
|
|
60
66
|
// ── Constants ────────────────────────────────────────────────────────
|
|
61
67
|
|
|
62
|
-
const PHASE_ORDER: ScratchpadPhase[] = [
|
|
68
|
+
export const PHASE_ORDER: ScratchpadPhase[] = [
|
|
63
69
|
"recon",
|
|
64
70
|
"hunt",
|
|
65
71
|
"gapfil",
|
|
@@ -90,23 +96,20 @@ const SCRATCHPAD_DIR = ".scratchpad";
|
|
|
90
96
|
let scratchpadRootOverride: string | undefined;
|
|
91
97
|
|
|
92
98
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
99
|
+
* Walk up from cwd to the first directory containing any of `markers`.
|
|
100
|
+
* PWD is deliberately excluded (shell-set, can be stale/forged); explicit
|
|
101
|
+
* env overrides win, then the real cwd walk. Shared by ledger, scratchpad,
|
|
102
|
+
* and the PoC runner so the heuristic lives in one place.
|
|
95
103
|
*/
|
|
96
|
-
function
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
// PWD is deliberately excluded (shell-set, can be stale/forged); explicit
|
|
100
|
-
// overrides only, then walk up from the real cwd.
|
|
101
|
-
const envs = ["XPI_SCRATCHPAD_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE"];
|
|
102
|
-
for (const e of envs) {
|
|
103
|
-
const v = process.env[e];
|
|
104
|
+
export function findWorkspaceRoot(envNames: string[], markers: string[]): string {
|
|
105
|
+
for (const e of envNames) {
|
|
106
|
+
const v = process.env[e]?.trim();
|
|
104
107
|
if (v) return resolve(v);
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
let curr = resolve(process.cwd());
|
|
108
111
|
for (let i = 0; i < 20; i++) {
|
|
109
|
-
if (
|
|
112
|
+
if (markers.some((m) => existsSync(join(curr, m)))) return curr;
|
|
110
113
|
const parent = dirname(curr);
|
|
111
114
|
if (parent === curr) break;
|
|
112
115
|
curr = parent;
|
|
@@ -114,6 +117,15 @@ function detectWorkspaceRoot(): string {
|
|
|
114
117
|
return resolve(process.cwd());
|
|
115
118
|
}
|
|
116
119
|
|
|
120
|
+
/** Detect the scratchpad workspace root (override, env, then walk up). */
|
|
121
|
+
function detectWorkspaceRoot(): string {
|
|
122
|
+
if (scratchpadRootOverride) return scratchpadRootOverride;
|
|
123
|
+
return findWorkspaceRoot(
|
|
124
|
+
["XPI_SCRATCHPAD_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE"],
|
|
125
|
+
[".git", "package.json"],
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
117
129
|
/** Override the scratchpad root (for testing). Pass undefined to reset. */
|
|
118
130
|
export function setScratchpadRoot(path: string | undefined): void {
|
|
119
131
|
scratchpadRootOverride = path ? resolve(path) : undefined;
|
|
@@ -126,23 +138,23 @@ export function getScratchpadRoot(projectRoot?: string): string {
|
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
/**
|
|
129
|
-
* Sanitize
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
141
|
+
* Sanitize an agent-supplied name into a single safe path component. `..`/`/`
|
|
142
|
+
* would let join() escape its base directory (ScratchpadClear("..") would
|
|
143
|
+
* recursively delete the project root; an artifact named ".." points at the
|
|
144
|
+
* phase dir itself), so anything outside the allowlist becomes `_`, and a
|
|
145
|
+
* dot-only or empty result is rejected.
|
|
134
146
|
*/
|
|
135
|
-
function
|
|
136
|
-
const safe =
|
|
137
|
-
if (!safe ||
|
|
138
|
-
throw new Error(`Invalid
|
|
147
|
+
function sanitizeName(name: string, label: string): string {
|
|
148
|
+
const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
149
|
+
if (!safe || /^\.+$/.test(safe)) {
|
|
150
|
+
throw new Error(`Invalid ${label}: "${name}" — nothing left after sanitization`);
|
|
139
151
|
}
|
|
140
152
|
return safe;
|
|
141
153
|
}
|
|
142
154
|
|
|
143
155
|
/** The directory for a specific run. */
|
|
144
156
|
export function getRunDir(runId: string, projectRoot?: string): string {
|
|
145
|
-
return join(getScratchpadRoot(projectRoot),
|
|
157
|
+
return join(getScratchpadRoot(projectRoot), sanitizeName(runId, "run_id"));
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
/** The state.json path for a run. */
|
|
@@ -150,19 +162,6 @@ export function getStatePath(runId: string, projectRoot?: string): string {
|
|
|
150
162
|
return join(getRunDir(runId, projectRoot), "state.json");
|
|
151
163
|
}
|
|
152
164
|
|
|
153
|
-
/**
|
|
154
|
-
* Sanitize an artifact name into a safe filename. Same allowlist as run_ids,
|
|
155
|
-
* plus dot-only rejection: a name like `..` or `.` would otherwise let join()
|
|
156
|
-
* point at the phase/run directory itself (EISDIR crash on write/read).
|
|
157
|
-
*/
|
|
158
|
-
function sanitizeArtifactName(artifactName: string): string {
|
|
159
|
-
const safe = artifactName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
160
|
-
if (!safe || /^\.+$/.test(safe)) {
|
|
161
|
-
throw new Error(`Invalid artifact name: "${artifactName}" — nothing left after sanitization`);
|
|
162
|
-
}
|
|
163
|
-
return safe;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
165
|
function emptyCheckpoint(runId: string, projectRoot: string): ScratchpadCheckpoint {
|
|
167
166
|
const now = new Date().toISOString();
|
|
168
167
|
return {
|
|
@@ -243,7 +242,7 @@ export function scratchpad_write(
|
|
|
243
242
|
ensureRunDirs(runDir);
|
|
244
243
|
|
|
245
244
|
// Sanitize artifact name: no path traversal, no dot-only escape.
|
|
246
|
-
const safeName =
|
|
245
|
+
const safeName = sanitizeName(artifactName, "artifact name");
|
|
247
246
|
const dir = join(runDir, PHASE_DIRS[phase]);
|
|
248
247
|
const filePath = join(dir, safeName);
|
|
249
248
|
writeFileSync(filePath, content, "utf8");
|
|
@@ -260,7 +259,7 @@ export function scratchpad_read(
|
|
|
260
259
|
projectRoot?: string,
|
|
261
260
|
): string | null {
|
|
262
261
|
const root = projectRoot ?? detectWorkspaceRoot();
|
|
263
|
-
const safeName =
|
|
262
|
+
const safeName = sanitizeName(artifactName, "artifact name");
|
|
264
263
|
const filePath = join(getRunDir(runId, root), PHASE_DIRS[phase], safeName);
|
|
265
264
|
if (!existsSync(filePath)) return null;
|
|
266
265
|
return readFileSync(filePath, "utf8");
|
package/src/workflow.ts
CHANGED
|
@@ -7,6 +7,24 @@
|
|
|
7
7
|
* report-readiness criteria. Token-disciplined: every rule here is load-bearing;
|
|
8
8
|
* wording is compressed, nothing is dropped.
|
|
9
9
|
*/
|
|
10
|
+
import { KILL_REASON_VALUES } from "./ledger.ts";
|
|
11
|
+
|
|
12
|
+
const KILL_REASONS_TEXT = KILL_REASON_VALUES.join(" / ");
|
|
13
|
+
|
|
14
|
+
/** Case-lifecycle diagram, shared by the FULL and LITE workflows. */
|
|
15
|
+
const LIFECYCLE_DIAGRAM = `
|
|
16
|
+
\`\`\`
|
|
17
|
+
+--- KILLED (dead end, documented why)
|
|
18
|
+
|
|
|
19
|
+
RECON -> HYPOTHESIS --+
|
|
20
|
+
|
|
|
21
|
+
+--> INVESTIGATING --> CONFIRMED --> REPORTED
|
|
22
|
+
| ^ |
|
|
23
|
+
| | chain/primitive |
|
|
24
|
+
| +-----------------+
|
|
25
|
+
|
|
|
26
|
+
+--> KILLED (insufficient impact, duplicate, etc.)
|
|
27
|
+
\`\`\``;
|
|
10
28
|
export const STATIC_CYBER_WORKFLOW = `
|
|
11
29
|
# Cyber Workflow (Attacker-Oriented)
|
|
12
30
|
|
|
@@ -28,20 +46,10 @@ RECON (you, inline) → **HUNT** (auditor subagents, one per attack class, paral
|
|
|
28
46
|
|
|
29
47
|
**HARD GATE — after RECON:** record the entry-point inventory, then STOP all inline reading/probing. Your very next tool call MUST be \`subagent({ tasks: [...] })\` dispatching HUNT auditors. If you catch yourself mapping a sink, reading a handler, or probing an endpoint beyond the recon inventory — that is HUNT work; stop, note it as a hunt task, and dispatch. Recon that bleeds into hunting is a pipeline violation, not progress.
|
|
30
48
|
|
|
31
|
-
|
|
49
|
+
**Subagent crash handling:** a subagent that dies (SIGABRT, OOM, timeout) is a RETRY, not a verdict — re-dispatch the same task once with a stronger model (\`subagent({agent, model, task})\`); repetition-loop runs are a known failure mode on cheap models. Crash again → record \`blocked: <agent> crashed\` in the pipeline-run case and continue; never silently drop the stage.
|
|
32
50
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
|
36
|
-
RECON -> HYPOTHESIS --+
|
|
37
|
-
|
|
|
38
|
-
+--> INVESTIGATING --> CONFIRMED --> REPORTED
|
|
39
|
-
| ^ |
|
|
40
|
-
| | chain/primitive |
|
|
41
|
-
| +-----------------+
|
|
42
|
-
|
|
|
43
|
-
+--> KILLED (insufficient impact, duplicate, etc.)
|
|
44
|
-
\`\`\`
|
|
51
|
+
## Case Lifecycle (State Machine)
|
|
52
|
+
${LIFECYCLE_DIAGRAM}
|
|
45
53
|
|
|
46
54
|
### Phase → State map
|
|
47
55
|
|
|
@@ -121,7 +129,13 @@ An attempt: reproduce under different conditions (auth/config/network position);
|
|
|
121
129
|
Strong example: "Read /api/users/123 as user B after confirming user A owns 123 → 403. Repeated with X-Override-User header (seen in admin traffic) → user A's data returned. Protection bypassed via the admin header."
|
|
122
130
|
Weak: "Tried to disprove. Could not." — insufficient.
|
|
123
131
|
|
|
124
|
-
If the disconfirmation script (\`disconfirmation_path\`) exits 0, promotion is blocked. If you cannot write a meaningful disconfirmation script, you don't understand the finding well enough to promote it.
|
|
132
|
+
If the disconfirmation script (\`disconfirmation_path\`) exits 0, promotion is blocked. If you cannot write a meaningful disconfirmation script, you don't understand the finding well enough to promote it. A disconfirmation (or control) script that CRASHES — killed, timed out, interpreter missing — is blocked too: the harness detects the missing completion marker, and a crash is neither a survived disproof nor a clean control verdict.
|
|
133
|
+
|
|
134
|
+
**Evidence chain closure (before PromoteFinding):** promotion is rejected unless the case carries an \`observation\` evidence item (EvidenceAdd role=observation — the initial signal) in addition to the auto-recorded reproduction item. Record observations as you go, not at promote time.
|
|
135
|
+
|
|
136
|
+
**Control-target check (anti-cheat, REQUIRED for live findings):** for any finding tested against a live target (\`local:true\`), write \`control_path\` — a script that runs the SAME PoC against a control lacking the vulnerability (patched replica, second account, baseline endpoint, WAF-blocked path). The harness runs it and blocks promotion if the verification_marker appears in the control output. That is what proves the marker is target-dependent, not an unconditional print. If the PoC cannot be pointed at a control (no replica exists), say so in \`disconfirmation\` and downgrade confidence accordingly — do not skip the check for live findings.
|
|
137
|
+
|
|
138
|
+
**PoC audit (anti-cheat, before PromoteFinding):** have an independent eye on the PoC script itself. For \`confidence: high\` findings the skeptic agent re-reads the PoC file (not just the source) hunting for: unconditional marker prints, trivially-true checks (accepting any 200, grepping for always-present strings), hardcoded expected values, and local mocks of the target. Record the audit result as an EvidenceAdd \`observation\` item (or \`refutation\` if it found a cheat → kill). The model that writes the check must not be the only one that reads it.
|
|
125
139
|
|
|
126
140
|
### 2. Design & Runtime Check — non-intentionality gate (mandatory)
|
|
127
141
|
|
|
@@ -209,7 +223,7 @@ Reproduce at least twice or via two methods.
|
|
|
209
223
|
|
|
210
224
|
## KILLED cataloging
|
|
211
225
|
|
|
212
|
-
When a case is definitively dead (not "I don't know yet"), record the reason:
|
|
226
|
+
When a case is definitively dead (not "I don't know yet"), record the reason: ${KILL_REASONS_TEXT} (true bug, no realistic attacker value). Documenting kills prevents re-opening dead ends. Cases with unresolved unknowns stay INVESTIGATING, not killed.
|
|
213
227
|
`.trim();
|
|
214
228
|
|
|
215
229
|
/**
|
|
@@ -237,19 +251,7 @@ Think like a real external attacker, not a code reviewer. Technical bugs are che
|
|
|
237
251
|
**No subagent tool.** In lite mode you do not call \`subagent\`. All specialist work is yours.
|
|
238
252
|
|
|
239
253
|
## Case Lifecycle (State Machine)
|
|
240
|
-
|
|
241
|
-
\`\`\`
|
|
242
|
-
+--- KILLED (dead end, documented why)
|
|
243
|
-
|
|
|
244
|
-
RECON -> HYPOTHESIS --+
|
|
245
|
-
|
|
|
246
|
-
+--> INVESTIGATING --> CONFIRMED --> REPORTED
|
|
247
|
-
| ^ |
|
|
248
|
-
| | chain/primitive |
|
|
249
|
-
| +-----------------+
|
|
250
|
-
|
|
|
251
|
-
+--> KILLED (insufficient impact, duplicate, etc.)
|
|
252
|
-
\`\`\`
|
|
254
|
+
${LIFECYCLE_DIAGRAM}
|
|
253
255
|
|
|
254
256
|
## Stage discipline (all done by you, inline)
|
|
255
257
|
|
|
@@ -273,12 +275,16 @@ Write the final report as a self-contained markdown file at the report path Case
|
|
|
273
275
|
- **No finding is confirmed until its target is verified in scope** per the program's scope instruction. Out-of-scope findings are killed, not confirmed.
|
|
274
276
|
- **No finding is validated without a reachability trace** showing REACHABLE.
|
|
275
277
|
- **High-confidence findings: do your own adversarial disconfirmation.** No skeptic subagent in lite mode — actively try to disprove your own finding and document the attempt in \`disconfirmation\`. Failing to disprove is the expected outcome.
|
|
276
|
-
- **Confirmed requires** evidence + poc + impact + severity + target + disconfirmation, and a PoC that exited 0 **with the verification_marker in the output**. No mocks for the exploitation step.
|
|
278
|
+
- **Confirmed requires** evidence + poc + impact + severity + target + disconfirmation, and a PoC that exited 0 **with the verification_marker in the output**. **Live findings (local:true) also require control_path**: the same PoC run against a control lacking the vuln must NOT print the marker (harness-side check) — this is what stops unconditional-marker and mock-target cheats. No mocks for the exploitation step.
|
|
277
279
|
- **Severity is derived from proven PoC impact, not theory.** Under-claiming is safe; over-claiming gets the finding rejected at triage.
|
|
278
280
|
- **Evidence-first:** every claim must be traceable to observed/reproduced behavior, source code, or documented platform behavior.
|
|
279
281
|
- **Design & runtime check (mandatory before CONFIRMED):** actively search the target's docs, git history, changelog, and runtime/framework docs for evidence the behavior is BY DESIGN or already FIXED IN THE RUNTIME. Found it → KILL (\`intended_behavior\` / \`framework_protection\`), unless the documented intent is itself the flaw with real attacker impact. Not found → document the search in \`disconfirmation\` as non-intentionality proof.
|
|
280
282
|
|
|
281
283
|
## KILLED cataloging
|
|
282
284
|
|
|
283
|
-
When a case is definitively dead (not "I don't know yet"), record the reason:
|
|
285
|
+
When a case is definitively dead (not "I don't know yet"), record the reason: ${KILL_REASONS_TEXT}. **A kill without a reason is rejected by the ledger** — add an EvidenceAdd \`refutation\` item or state the reason token in assumptions/nextStep. Documenting kills prevents re-opening dead ends. Cases with unresolved unknowns stay INVESTIGATING, not killed.
|
|
286
|
+
|
|
287
|
+
## Stall rule (deferred)
|
|
288
|
+
|
|
289
|
+
3 rounds without new signal, new surface, or new techniques → CaseUpdate(status: 'blocked', blockers: ["deferred after 3 rounds — revisit when: <exact condition>"]). Blocked-with-revisit-condition is the deferred state; do not kill leads that are merely stalled.
|
|
284
290
|
`.trim();
|