@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.
@@ -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
- const SPECS: Record<SubmitStage, StageSpec> = {
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
- STAGE_TO_PHASE[stage],
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, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
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?: string;
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
- /** Minimal built-in defaults. Users can override/extend via env. */
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 can be supplied by the user. */
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
- const envRoot = process.env.PI_POC_ROOT?.trim();
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(languages: Record<string, PocLanguage>): string | undefined {
75
+ function detectProjectType(): string | undefined {
107
76
  const root = getProjectRoot();
108
- for (const [key, lang] of Object.entries(languages)) {
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(languages)) {
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, languages);
160
- if (shebangKey) return { key: shebangKey, language: languages[shebangKey] };
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 && languages[extKey]) {
166
- return { key: extKey, language: languages[extKey] };
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(languages);
171
- if (projectType && languages[projectType]) {
172
- return { key: projectType, language: languages[projectType] };
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 && languages[envDefault]) {
178
- return { key: envDefault, language: languages[envDefault] };
135
+ if (envDefault && BUILTIN_LANGUAGES[envDefault]) {
136
+ return { key: envDefault, language: BUILTIN_LANGUAGES[envDefault] };
179
137
  }
180
138
 
181
- const supported = Object.keys(languages).sort().join(", ");
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/adapted languages: ${supported}. ` +
186
- `Add a shebang, use a known extension, or set PI_POC_DEFAULT_LANGUAGE or PI_POC_LANGUAGES.`,
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 every substitution so a hostile filename
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/${sourceName}`) : pocPath;
291
- const binPath = inSandbox ? shq("/workspace/poc") : join(dirname(pocPath), "poc");
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
- let command: string;
368
- if (language.buildRun) {
369
- command = renderCommand(language.buildRun, pocPath, true);
370
- } else if (language.run) {
371
- command = renderCommand(language.run, pocPath, true);
372
- } else {
373
- throw new Error("Language config has no run or buildRun command");
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, command, workspaceDir, containerName),
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 output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr);
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 (the template is trusted config, not user input),
426
- // then render placeholders within each token. Passing the tokens to spawnSync
427
- // with NO shell keeps a space-containing PoC path as one arg and keeps extra
428
- // flags (e.g. `node --experimental-vm-modules {{file}}`) as separate args.
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
- * verify/ PoC logs, run outputs
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
- * Detect the workspace root by walking up for a .git dir or package.json,
94
- * matching the ledger's detectWorkspaceRoot() heuristic.
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 detectWorkspaceRoot(): string {
97
- if (scratchpadRootOverride) return scratchpadRootOverride;
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 (existsSync(join(curr, ".git")) || existsSync(join(curr, "package.json"))) return curr;
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 a run_id into a single safe directory name. Unlike artifact names,
130
- * run_ids arrive from the agent and were never sanitized — `..`/`/` would let
131
- * join() escape .scratchpad, turning ScratchpadClear("..") into a recursive
132
- * delete of the project root. Same allowlist as artifact names, plus rejection
133
- * of dot-only results (.", "..", or a sanitized empty string).
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 sanitizeRunId(runId: string): string {
136
- const safe = runId.replace(/[^a-zA-Z0-9._-]/g, "_");
137
- if (!safe || /^\.*$/.test(safe)) {
138
- throw new Error(`Invalid run_id: "${runId}" — nothing left after sanitization`);
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), sanitizeRunId(runId));
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 = sanitizeArtifactName(artifactName);
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 = sanitizeArtifactName(artifactName);
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
- ## Case Lifecycle (State Machine)
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
- +--- KILLED (dead end, documented why)
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: intended_behavior / duplicate / framework_protection / exploit_unreliable / insufficient_impact / environmental_issue / not_applicable (true bug, no realistic attacker value). Documenting kills prevents re-opening dead ends. Cases with unresolved unknowns stay INVESTIGATING, not killed.
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: intended_behavior / duplicate / framework_protection / exploit_unreliable / insufficient_impact / environmental_issue / not_applicable. Documenting kills prevents re-opening dead ends. Cases with unresolved unknowns stay INVESTIGATING, not killed.
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();