@xaccefy/pi-casefile 0.8.1 → 0.8.3

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/src/scratchpad.ts CHANGED
@@ -24,7 +24,16 @@
24
24
  * `--fresh` clears it via scratchpad_clear().
25
25
  */
26
26
 
27
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
27
+ import { createHash } from "node:crypto";
28
+ import {
29
+ existsSync,
30
+ mkdirSync,
31
+ readdirSync,
32
+ readFileSync,
33
+ renameSync,
34
+ rmSync,
35
+ writeFileSync,
36
+ } from "node:fs";
28
37
  import { dirname, join, resolve } from "node:path";
29
38
 
30
39
  // ── Types ────────────────────────────────────────────────────────────
@@ -152,9 +161,21 @@ function sanitizeName(name: string, label: string): string {
152
161
  return safe;
153
162
  }
154
163
 
164
+ function runDirName(runId: string): string {
165
+ const safe = sanitizeName(runId, "run_id");
166
+ if (safe === runId) return safe;
167
+ const suffix = createHash("sha256").update(runId).digest("hex").slice(0, 12);
168
+ return `${safe.slice(0, 80)}-${suffix}`;
169
+ }
170
+
171
+ /** Pre-hash-suffix naming used by older scratchpad versions (sanitize only). */
172
+ function legacyRunDirName(runId: string): string {
173
+ return sanitizeName(runId, "run_id");
174
+ }
175
+
155
176
  /** The directory for a specific run. */
156
177
  export function getRunDir(runId: string, projectRoot?: string): string {
157
- return join(getScratchpadRoot(projectRoot), sanitizeName(runId, "run_id"));
178
+ return join(getScratchpadRoot(projectRoot), runDirName(runId));
158
179
  }
159
180
 
160
181
  /** The state.json path for a run. */
@@ -187,23 +208,42 @@ function ensureRunDirs(runDir: string): void {
187
208
  function readCheckpointRaw(runId: string, projectRoot?: string): ScratchpadCheckpoint | null {
188
209
  const statePath = getStatePath(runId, projectRoot);
189
210
  if (!existsSync(statePath)) return null;
190
- try {
191
- const raw = readFileSync(statePath, "utf8");
192
- const cp = JSON.parse(raw) as ScratchpadCheckpoint;
193
- // Backfill maps for phases not yet checkpointed (defensive).
194
- if (!cp.phase_ids) cp.phase_ids = {} as Record<ScratchpadPhase, string[]>;
195
- if (!cp.phase_summaries) cp.phase_summaries = {} as Record<ScratchpadPhase, string>;
196
- return cp;
197
- } catch {
198
- return null;
211
+ const raw = readFileSync(statePath, "utf8");
212
+ const cp = JSON.parse(raw) as ScratchpadCheckpoint;
213
+ if (typeof cp !== "object" || cp === null || Array.isArray(cp)) {
214
+ throw new Error(`Corrupt scratchpad state for ${runId}: root must be an object`);
215
+ }
216
+ if (cp.run_id !== runId) {
217
+ throw new Error(`Corrupt scratchpad state for ${runId}: state belongs to ${cp.run_id}`);
218
+ }
219
+ if (!Array.isArray(cp.completed_phases)) {
220
+ throw new Error(`Corrupt scratchpad state for ${runId}: completed_phases must be an array`);
221
+ }
222
+ for (const phase of cp.completed_phases) {
223
+ if (!PHASE_ORDER.includes(phase)) {
224
+ throw new Error(`Corrupt scratchpad state for ${runId}: invalid phase ${phase}`);
225
+ }
226
+ }
227
+ if (!cp.phase_ids || typeof cp.phase_ids !== "object" || Array.isArray(cp.phase_ids)) {
228
+ cp.phase_ids = {} as Record<ScratchpadPhase, string[]>;
199
229
  }
230
+ if (
231
+ !cp.phase_summaries ||
232
+ typeof cp.phase_summaries !== "object" ||
233
+ Array.isArray(cp.phase_summaries)
234
+ ) {
235
+ cp.phase_summaries = {} as Record<ScratchpadPhase, string>;
236
+ }
237
+ return cp;
200
238
  }
201
239
 
202
240
  function writeCheckpointRaw(cp: ScratchpadCheckpoint, projectRoot?: string): void {
203
241
  cp.last_updated = new Date().toISOString();
204
242
  const statePath = getStatePath(cp.run_id, projectRoot);
205
243
  ensureRunDirs(getRunDir(cp.run_id, projectRoot));
206
- writeFileSync(statePath, JSON.stringify(cp, null, 2), "utf8");
244
+ const tmp = `${statePath}.${process.pid}.${Date.now()}.tmp`;
245
+ writeFileSync(tmp, JSON.stringify(cp, null, 2), "utf8");
246
+ renameSync(tmp, statePath);
207
247
  }
208
248
 
209
249
  // ── Public API ───────────────────────────────────────────────────────
@@ -268,6 +308,32 @@ export function scratchpad_read(
268
308
  /**
269
309
  * List all artifacts written for a phase.
270
310
  */
311
+ export function scratchpad_runs(projectRoot?: string): string[] {
312
+ const root = getScratchpadRoot(projectRoot);
313
+ if (!existsSync(root)) return [];
314
+ const out: string[] = [];
315
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
316
+ if (!entry.isDirectory()) continue;
317
+ const state = join(root, entry.name, "state.json");
318
+ if (!existsSync(state)) continue;
319
+ try {
320
+ const cp = JSON.parse(readFileSync(state, "utf8")) as { run_id?: unknown };
321
+ if (typeof cp.run_id !== "string") continue;
322
+ if (getRunDir(cp.run_id, projectRoot) === join(root, entry.name)) {
323
+ out.push(cp.run_id);
324
+ } else if (join(root, legacyRunDirName(cp.run_id)) === join(root, entry.name)) {
325
+ // Runs created before the hash-suffix naming used sanitizeName(runId)
326
+ // as the directory; keep surfacing them in bundle discovery. Safe ids
327
+ // were never suffixed, so only legacy unsafe ids can land here.
328
+ out.push(cp.run_id);
329
+ }
330
+ } catch {
331
+ // Corrupt runs are ignored during report bundle discovery; direct resume still fails closed.
332
+ }
333
+ }
334
+ return out;
335
+ }
336
+
271
337
  export function scratchpad_list(
272
338
  runId: string,
273
339
  phase: ScratchpadPhase,
package/src/workflow.ts CHANGED
@@ -38,15 +38,15 @@ Think like a real external attacker, not a code reviewer. Technical bugs are che
38
38
 
39
39
  **Web lookup (research):** web_search, web_fetch, exploit_search, context7, deepwiki, http_request
40
40
 
41
- **Subagent dispatch:** \`subagent({agent: "auditor"|"tracer"|"skeptic"|"exploit"|"chain"|"reporter", task: "..."})\` dispatch specialists; do NOT do the specialist work yourself.
41
+ **Subagent dispatch:** every launch uses \`subagent({ workflowScript: "return runs.run('stable-key', { agent: 'tracer', task: '...' })", context: 'fresh', async: true })\`. Parallel HUNT uses one workflowScript with \`return runs.all([{ key: 'run-class-attempt', agent: 'auditor', task: '...' }, ...])\`. Stable keys include run, stage, class/case, and attempt. Dispatch specialists; do NOT do their work yourself.
42
42
 
43
43
  ## Stage Machine (run in order — you are the coordinator)
44
44
 
45
45
  RECON (you, inline) → **HUNT** (auditor subagents, one per attack class, parallel) → TRACE (tracer) → SKEPTIC (high-confidence only) → VALIDATE (exploit) → CHAIN (chain) → REPORT (reporter)
46
46
 
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.
47
+ **HARD GATE — after RECON:** record the entry-point inventory, then STOP all inline reading/probing. Your next tool call MUST launch one async workflowScript whose \`runs.all([...])\` dispatches HUNT auditors. When its completion is delivered, submit each output through PipelineSubmit. If you catch yourself mapping a sink, reading a handler, or probing an endpoint beyond the recon inventory, stop and add it to a HUNT task.
48
48
 
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.
49
+ **Subagent crash handling:** a crash (SIGABRT, OOM, timeout) is a RETRY, not a verdict. Launch one new workflowScript with the same specialist task, a new stable attempt key, and a stronger model. Crash again → record \`blocked: <agent> crashed\` in the pipeline-run case and continue; never silently drop the stage.
50
50
 
51
51
  ## Case Lifecycle (State Machine)
52
52
  ${LIFECYCLE_DIAGRAM}
@@ -66,7 +66,7 @@ ${LIFECYCLE_DIAGRAM}
66
66
  | Advance To | Required Case Fields | On Disk |
67
67
  |-----------|---------------------|---------|
68
68
  | HYPOTHESIS → INVESTIGATING | evidence (observations), confidence | Notes on what was observed |
69
- | INVESTIGATING → **CONFIRMED** | evidence, poc, **impact** (content below), severity, **target**, **disconfirmation** (your documented disprove attempt) | PoC script, exit 0, **verification_marker in output** (proves the exploit ran, not just the script). Optional disconfirmation script exit non-0. |
69
+ | INVESTIGATING → **CONFIRMED** | evidence, poc, **impact** (content below), severity, **target**, **disconfirmation** (your documented disprove attempt) | PoC script exit 0 with verification_marker; same-script control at a distinct control_target completes with liveness but no vuln marker; disconfirmation script completed and exited non-0. |
70
70
  | Any → KILLED | assumptions (why it died) | — |
71
71
  | CONFIRMED → REPORTED | CaseContext(id) succeeded (records report path) AND the reporter agent wrote the report file | Context bundle + report file |
72
72
 
@@ -120,7 +120,7 @@ If you cannot name a concrete attacker who gains something they should not have
120
120
 
121
121
  The finding must survive an attempt to disprove it. Two tiers, gated on \`confidence\` (severity comes later, from the PoC):
122
122
 
123
- **\`confidence: high\` → skeptic subagent (MANDATORY):** dispatch \`subagent({agent: "skeptic", task: "..."})\` BEFORE the exploit agent. It independently re-reads the source (or re-probes live), verifies scope, and tries to disprove. Its \`disconfirmation_attempt\` becomes the case's \`disconfirmation\` — stronger than self-disconfirmation. DISPROVEN → killed directly, no tie-breaker. Do NOT skip; do NOT self-disconfirm high-confidence findings.
123
+ **\`confidence: high\` → skeptic subagent (MANDATORY):** dispatch it BEFORE the exploit agent with \`subagent({ workflowScript: "return runs.run('skeptic-<case>-1', { agent: 'skeptic', task: '...' })", context: 'fresh', async: true })\`. It independently re-reads the source (or re-probes live), verifies scope, and tries to disprove. Its \`disconfirmation_attempt\` becomes the case's \`disconfirmation\` — stronger than self-disconfirmation. DISPROVEN → killed directly, no tie-breaker. Do NOT skip; do NOT self-disconfirm high-confidence findings.
124
124
 
125
125
  **Below high → self-disconfirmation:** actively try to disprove your own finding; document it. Not a formality.
126
126
 
@@ -129,13 +129,13 @@ An attempt: reproduce under different conditions (auth/config/network position);
129
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."
130
130
  Weak: "Tried to disprove. Could not." — insufficient.
131
131
 
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.
132
+ If the disconfirmation script (\`disconfirmation_path\`) exits 0, promotion is blocked. The disconfirmation script is REQUIRED for **every** promotion — the prose \`disconfirmation\` field alone cannot carry the disprove-attempt at any severity (a case filed low/medium must not skip the run and be re-raised afterwards). 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
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.
134
+ **Evidence chain closure (before PromoteFinding):** promotion is rejected unless the case carries an **artifact-backed** \`observation\` evidence item (EvidenceAdd role=observation with \`artifact_path\` — the initial signal, stored with its SHA-256) in addition to the auto-recorded reproduction item. Record observations as you go, not at promote time.
135
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.
136
+ **Differential control check (REQUIRED for EVERY promotion):** pass \`control_path\` (the SAME bytes as the PoC), a distinct \`control_target\`, and \`control_liveness_marker\`. The harness runs the same script with \`PI_POC_MODE=poc\` and the case target, then \`PI_POC_MODE=control\` and \`control_target\`. Control must complete, print liveness after reaching the baseline, and omit the vulnerability marker. Checks use complete captured output; crashed or truncated output blocks promotion. **Local/live findings:** \`local:true\` uses the host-network Docker sandbox. Bare host execution still needs operator \`PI_POC_ALLOW_LOCAL=1\`.
137
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.
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. The deterministic backstops are code, not prompts: the control run (mandatory, marker-absence + liveness-presence checks) and, for high/critical, the executed disconfirmation run.
139
139
 
140
140
  ### 2. Design & Runtime Check — non-intentionality gate (mandatory)
141
141
 
@@ -161,7 +161,7 @@ Name the **specific target host/repo** in the target field. Dev-only with non-de
161
161
 
162
162
  ### 4. KILL at Validate stage
163
163
 
164
- Documented intended behavior · self-XSS/self-DoS only · requires admin/root role that already has the power · local-only/offline/impossible deployment · needs physical access or social engineering with no trust-boundary break · no C/I/A/financial effect for anyone but the attacker · PoC proves a code path but no victim asset · protections block the path and are not bypassed.
164
+ Documented intended behavior · self-XSS/self-DoS only · requires admin/root role that already has the power · local-only/offline/impossible deployment · needs physical access or social engineering with no trust-boundary break · no C/I/A/financial effect for anyone but the attacker · PoC proves a code path but no victim asset · protections block the path and are not bypassed. **Kill gate:** killing a case that reached \`investigating\`/\`confirmed\` requires a refutation evidence item (EvidenceAdd role=refutation — the disprove attempt that ended the lead); a keyword in free text is not enough once the case advanced past hypothesis.
165
165
 
166
166
  ### 5. Evidence-First Doctrine
167
167
 
@@ -210,7 +210,7 @@ Reproduce at least twice or via two methods.
210
210
  ## At REPORT
211
211
 
212
212
  1. **Run CaseContext(case_id)** — writes the context bundle (complete record, PoC + disconfirmation logs, links, pipeline artifacts) and records the report path.
213
- 2. **Dispatch the reporter subagent**: \`subagent({agent: "reporter", task: "Write the final report for case <id>. case_id=<id>, context_path=<path from CaseContext>, report_path=<path from CaseContext>, program_name=<program if known>. Apply the fixed report format rules in your prompt (title convention, body template, tone rules). Output: the report file written to report_path + CaseUpdate(status: 'reported')."})\`. It writes the polished report and flips the case to REPORTED.
213
+ 2. **Dispatch the reporter subagent** with \`subagent({ workflowScript: "return runs.run('report-<case>-1', { agent: 'reporter', task: 'Write the final report. case_id=<id>, context_path=<context path>, report_path=<report path>, program_name=<if known>.' })", context: 'fresh', async: true })\`. It writes the polished report and flips the case to REPORTED.
214
214
  3. **Report-readiness gate** (YOU check this on the reporter's output before accepting; on failure, re-dispatch with the gap list):
215
215
  - Deterministic reproduction by another researcher
216
216
  - Steps realistic in production
@@ -219,6 +219,8 @@ Reproduce at least twice or via two methods.
219
219
  - Attacker model + victim impact + target explicit
220
220
  - No internal identifiers: no case IDs, ledger paths, PoC filenames, or local paths in the report file
221
221
 
222
+ The ledger enforces a machine floor on the report file before accepting \`reported\`: non-trivial size, required section headings (Summary / Impact / Remediation), and a forbidden-identifier scan (case id, ledger/report paths, PoC/control/disconfirmation basenames). A report that fails the scan keeps the case CONFIRMED — fix the file, then retry the transition.
223
+
222
224
  ---
223
225
 
224
226
  ## KILLED cataloging
@@ -275,7 +277,7 @@ Write the final report as a self-contained markdown file at the report path Case
275
277
  - **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.
276
278
  - **No finding is validated without a reachability trace** showing REACHABLE.
277
279
  - **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.
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.
280
+ - **Confirmed requires** evidence + poc + impact + severity + target + disconfirmation. Every promotion also requires \`disconfirmation_path\`, same-script \`control_path\`, a distinct \`control_target\`, and \`control_liveness_marker\`. The PoC must print the verification marker; the completed control must print liveness and omit it. \`local:true\` uses a host-network sandbox; bare host execution needs operator \`PI_POC_ALLOW_LOCAL=1\`. No mocks.
279
281
  - **Severity is derived from proven PoC impact, not theory.** Under-claiming is safe; over-claiming gets the finding rejected at triage.
280
282
  - **Evidence-first:** every claim must be traceable to observed/reproduced behavior, source code, or documented platform behavior.
281
283
  - **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.