@xaccefy/pi-casefile 0.8.0 → 0.8.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/README.md +2 -2
- package/package.json +1 -1
- package/src/index.ts +176 -52
- package/src/ledger.ts +455 -80
- package/src/poc-runner.ts +193 -24
- package/src/workflow.ts +8 -6
package/src/poc-runner.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
} from "node:fs";
|
|
3
11
|
import { tmpdir } from "node:os";
|
|
4
12
|
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
13
|
|
|
@@ -17,8 +25,47 @@ export type PocRun = {
|
|
|
17
25
|
* a crash is NOT a verdict, and callers must not treat it as one.
|
|
18
26
|
*/
|
|
19
27
|
completed: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Sanitized but UNTRUNCATED output (capped only by the spawn maxBuffer).
|
|
30
|
+
* Marker presence/absence checks MUST run on this, never on `output`,
|
|
31
|
+
* which is sliced for display — a cheating script can print its marker
|
|
32
|
+
* past the 4000-char display window. Never persisted to the ledger.
|
|
33
|
+
*/
|
|
34
|
+
rawOutput?: string;
|
|
35
|
+
/** True when `output` was truncated for display (rawOutput has more). */
|
|
36
|
+
truncated?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* True when the run never started because of harness infrastructure
|
|
39
|
+
* failure (e.g. sandbox image pull failed) — set only by the runner,
|
|
40
|
+
* never derived from PoC-controlled output text.
|
|
41
|
+
*/
|
|
42
|
+
infraError?: boolean;
|
|
20
43
|
};
|
|
21
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Run-mode options for `runPoc`. Host execution is NEVER selectable by the
|
|
47
|
+
* agent alone: `local: true` only takes effect when the OPERATOR has set
|
|
48
|
+
* `PI_POC_ALLOW_LOCAL=1` (host is a fallback when Docker is unavailable;
|
|
49
|
+
* `PI_POC_FORCE_LOCAL=1` + ALLOW skips Docker on purpose). The default is a
|
|
50
|
+
* Docker sandbox; `network: "host"` gives the sandbox host networking while
|
|
51
|
+
* keeping the read-only FS / dropped caps / unprivileged user / resource limits.
|
|
52
|
+
*/
|
|
53
|
+
export type PocRunOptions = {
|
|
54
|
+
/** Docker sandbox networking: "none" (default) or "host" (live/network-dependent findings). */
|
|
55
|
+
network?: "none" | "host";
|
|
56
|
+
/** True to run on the host (no Docker). Requires operator opt-in PI_POC_ALLOW_LOCAL=1. */
|
|
57
|
+
local?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Extra environment variables merged into the run. The harness sets
|
|
60
|
+
* `PI_POC_MODE` ("poc" | "control" | "disconfirmation") and `PI_POC_TARGET`
|
|
61
|
+
* (the case target) so PoCs can be written once and parameterized per run.
|
|
62
|
+
*/
|
|
63
|
+
env?: Record<string, string>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Operator-only opt-in for host execution (never agent-supplied). */
|
|
67
|
+
const LOCAL_EXEC_ENV = "PI_POC_ALLOW_LOCAL";
|
|
68
|
+
|
|
22
69
|
export type PocLanguage = {
|
|
23
70
|
/** Docker image used when running inside the sandbox. */
|
|
24
71
|
image: string;
|
|
@@ -59,6 +106,8 @@ const EXTENSION_MAP: Record<string, string> = {
|
|
|
59
106
|
};
|
|
60
107
|
|
|
61
108
|
const OUTPUT_MAX_CHARS = 4000;
|
|
109
|
+
/** Sanitized output is kept whole (for marker checks) up to this size. */
|
|
110
|
+
const RAW_OUTPUT_MAX_CHARS = 4 * 1024 * 1024;
|
|
62
111
|
const TIMEOUT_MS = 30_000;
|
|
63
112
|
/** Completion sentinel echoed after the PoC command inside the sandbox shell. */
|
|
64
113
|
function makeSentinel(): string {
|
|
@@ -160,27 +209,57 @@ function validatePocPath(pocPath: string): string {
|
|
|
160
209
|
}
|
|
161
210
|
|
|
162
211
|
const root = getProjectRoot();
|
|
212
|
+
// Operator escape hatch: PI_POC_ALLOW_ABSOLUTE=1 disables BOTH the lexical
|
|
213
|
+
// and the realpath containment checks (agent cannot set it).
|
|
214
|
+
const allowAbsolute = process.env.PI_POC_ALLOW_ABSOLUTE === "1";
|
|
163
215
|
// Use path.relative so prefix-sibling escapes like /tmp/proj vs /tmp/proj-evil are rejected.
|
|
164
216
|
// startsWith(`${root}/`) would accept /tmp/proj-evil when root is /tmp/proj.
|
|
165
217
|
const rel = relative(root, normalized);
|
|
166
218
|
const outsideWorkspace = rel === "" ? false : rel.startsWith("..") || isAbsolute(rel);
|
|
167
|
-
if (outsideWorkspace) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
`Set PI_POC_ALLOW_ABSOLUTE=1 to allow arbitrary absolute paths.`,
|
|
173
|
-
);
|
|
174
|
-
}
|
|
219
|
+
if (outsideWorkspace && !allowAbsolute) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`PoC path must be under the project workspace (${root}). ` +
|
|
222
|
+
`Set PI_POC_ALLOW_ABSOLUTE=1 to allow arbitrary absolute paths.`,
|
|
223
|
+
);
|
|
175
224
|
}
|
|
176
225
|
|
|
177
226
|
if (!existsSync(normalized)) {
|
|
178
227
|
throw new Error(`PoC not found on disk: ${pocPath}`);
|
|
179
228
|
}
|
|
180
229
|
|
|
230
|
+
// Symlink containment: the lexical checks above are defeated by a
|
|
231
|
+
// workspace file that is a symlink to a host path (e.g. $HOME/.env) —
|
|
232
|
+
// existsSync/copyFileSync/readFileSync all dereference. Resolve the real
|
|
233
|
+
// path and re-run the containment check on it, then require a regular
|
|
234
|
+
// file (FIFOs/devices/sockets are rejected).
|
|
235
|
+
let real: string;
|
|
236
|
+
try {
|
|
237
|
+
real = realpathSync(normalized);
|
|
238
|
+
} catch {
|
|
239
|
+
throw new Error(`PoC path cannot be resolved: ${pocPath}`);
|
|
240
|
+
}
|
|
241
|
+
if (!allowAbsolute) {
|
|
242
|
+
const realRel = relative(root, real);
|
|
243
|
+
const realOutside = realRel === "" ? false : realRel.startsWith("..") || isAbsolute(realRel);
|
|
244
|
+
if (realOutside) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`PoC path resolves outside the project workspace (${real}). ` +
|
|
247
|
+
`Symlinked files outside ${root} are rejected.`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const realStat = statSync(real);
|
|
252
|
+
if (!realStat.isFile()) {
|
|
253
|
+
throw new Error(`PoC path must resolve to a regular file (got ${pocPath})`);
|
|
254
|
+
}
|
|
255
|
+
|
|
181
256
|
return normalized;
|
|
182
257
|
}
|
|
183
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Strip control chars / ANSI escapes from PoC output. Does NOT slice —
|
|
261
|
+
* truncation is display-only and must never hide content from marker checks.
|
|
262
|
+
*/
|
|
184
263
|
function sanitizeOutput(output: string): string {
|
|
185
264
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional
|
|
186
265
|
const nulls = /\x00/g;
|
|
@@ -194,8 +273,30 @@ function sanitizeOutput(output: string): string {
|
|
|
194
273
|
.replace(/\r/g, "\n")
|
|
195
274
|
.replace(nulls, "")
|
|
196
275
|
.replace(ansi, "")
|
|
197
|
-
.replace(ctrl, "")
|
|
198
|
-
|
|
276
|
+
.replace(ctrl, "");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Split sanitized output into the raw (whole) and display (sliced) halves. */
|
|
280
|
+
function splitOutput(raw: string): { rawOutput: string; output: string; truncated: boolean } {
|
|
281
|
+
const rawOutput = raw.slice(0, RAW_OUTPUT_MAX_CHARS);
|
|
282
|
+
const truncated = raw.length > OUTPUT_MAX_CHARS;
|
|
283
|
+
return { rawOutput, output: raw.slice(0, OUTPUT_MAX_CHARS), truncated };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Reject control characters in harness-supplied PoC env values. */
|
|
287
|
+
function sanitizePocEnv(env: Record<string, string>): Record<string, string> {
|
|
288
|
+
const out: Record<string, string> = {};
|
|
289
|
+
for (const [key, value] of Object.entries(env)) {
|
|
290
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
291
|
+
throw new Error(`Invalid PoC env key: ${key}`);
|
|
292
|
+
}
|
|
293
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional
|
|
294
|
+
if (/[\x00-\x1f\x7f]/.test(value)) {
|
|
295
|
+
throw new Error(`PoC env value for ${key} contains control characters`);
|
|
296
|
+
}
|
|
297
|
+
out[key] = value;
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
199
300
|
}
|
|
200
301
|
|
|
201
302
|
function buildDockerArgs(
|
|
@@ -203,14 +304,23 @@ function buildDockerArgs(
|
|
|
203
304
|
command: string,
|
|
204
305
|
workspaceDir: string,
|
|
205
306
|
containerName: string,
|
|
307
|
+
network: "none" | "host",
|
|
308
|
+
env?: Record<string, string>,
|
|
206
309
|
): string[] {
|
|
310
|
+
const envArgs: string[] = [];
|
|
311
|
+
for (const [key, value] of Object.entries(sanitizePocEnv(env ?? {}))) {
|
|
312
|
+
// Values are single tokens from the harness (PI_POC_MODE / PI_POC_TARGET);
|
|
313
|
+
// pass them as separate -e args so no shell quoting is involved.
|
|
314
|
+
envArgs.push("-e", `${key}=${value}`);
|
|
315
|
+
}
|
|
207
316
|
return [
|
|
208
317
|
"run",
|
|
209
318
|
"--rm",
|
|
210
319
|
"--name",
|
|
211
320
|
containerName,
|
|
212
321
|
"--network",
|
|
213
|
-
|
|
322
|
+
network,
|
|
323
|
+
...envArgs,
|
|
214
324
|
"--read-only",
|
|
215
325
|
"--cap-drop",
|
|
216
326
|
"ALL",
|
|
@@ -290,7 +400,12 @@ function ensureImage(image: string): void {
|
|
|
290
400
|
}
|
|
291
401
|
}
|
|
292
402
|
|
|
293
|
-
function runSandboxed(
|
|
403
|
+
function runSandboxed(
|
|
404
|
+
pocPath: string,
|
|
405
|
+
language: PocLanguage,
|
|
406
|
+
network: "none" | "host" = "none",
|
|
407
|
+
env?: Record<string, string>,
|
|
408
|
+
): PocRun {
|
|
294
409
|
const ranAt = new Date().toISOString();
|
|
295
410
|
const sourceName = basename(pocPath);
|
|
296
411
|
const workspaceDir = mkdtempSync(resolve(tmpdir(), "poc-runner-"));
|
|
@@ -311,6 +426,7 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
|
311
426
|
ranAt,
|
|
312
427
|
sandbox: true,
|
|
313
428
|
completed: false,
|
|
429
|
+
infraError: true,
|
|
314
430
|
};
|
|
315
431
|
}
|
|
316
432
|
copyFileSync(pocPath, `${workspaceDir}/${sourceName}`);
|
|
@@ -326,7 +442,7 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
|
326
442
|
|
|
327
443
|
const result = spawnSync(
|
|
328
444
|
"docker",
|
|
329
|
-
buildDockerArgs(language.image, wrapped, workspaceDir, containerName),
|
|
445
|
+
buildDockerArgs(language.image, wrapped, workspaceDir, containerName, network, env),
|
|
330
446
|
{
|
|
331
447
|
encoding: "utf8",
|
|
332
448
|
timeout: TIMEOUT_MS,
|
|
@@ -337,11 +453,13 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
|
337
453
|
const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
|
|
338
454
|
const raw = (result.stdout ?? "") + (result.stderr ?? "") + spawnErr;
|
|
339
455
|
const completed = raw.includes(sentinel);
|
|
340
|
-
const output = sanitizeOutput(raw.replace(sentinel, ""));
|
|
456
|
+
const { rawOutput, output, truncated } = splitOutput(sanitizeOutput(raw.replace(sentinel, "")));
|
|
341
457
|
return {
|
|
342
458
|
path: pocPath,
|
|
343
459
|
exitCode: spawnExitCode(result),
|
|
344
460
|
output,
|
|
461
|
+
rawOutput,
|
|
462
|
+
truncated,
|
|
345
463
|
ranAt,
|
|
346
464
|
sandbox: true,
|
|
347
465
|
completed,
|
|
@@ -361,7 +479,7 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
|
|
|
361
479
|
}
|
|
362
480
|
}
|
|
363
481
|
|
|
364
|
-
function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
482
|
+
function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, string>): PocRun {
|
|
365
483
|
const ranAt = new Date().toISOString();
|
|
366
484
|
|
|
367
485
|
// The run template is `<interpreter> [flags...] {{file}}`. Split the static
|
|
@@ -378,6 +496,11 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
|
378
496
|
encoding: "utf8",
|
|
379
497
|
timeout: TIMEOUT_MS,
|
|
380
498
|
maxBuffer: MAX_BUFFER,
|
|
499
|
+
// Host runs get the harness env contract merged over the operator env;
|
|
500
|
+
// the spawn env is explicitly provided so PI_POC_MODE / PI_POC_TARGET
|
|
501
|
+
// reach the script without leaking through a shell. Same control-char
|
|
502
|
+
// rejection as the sandboxed path.
|
|
503
|
+
env: env ? { ...process.env, ...sanitizePocEnv(env) } : undefined,
|
|
381
504
|
});
|
|
382
505
|
|
|
383
506
|
// Local runs stay shell-free (space-containing paths stay single args), so
|
|
@@ -386,11 +509,15 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
|
386
509
|
// means the script never ran to completion — fail closed on those.
|
|
387
510
|
const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
|
|
388
511
|
const completed = !result.error && result.signal === null;
|
|
389
|
-
const
|
|
512
|
+
const { rawOutput, output, truncated } = splitOutput(
|
|
513
|
+
sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr),
|
|
514
|
+
);
|
|
390
515
|
return {
|
|
391
516
|
path: pocPath,
|
|
392
517
|
exitCode: spawnExitCode(result),
|
|
393
518
|
output,
|
|
519
|
+
rawOutput,
|
|
520
|
+
truncated,
|
|
394
521
|
ranAt,
|
|
395
522
|
sandbox: false,
|
|
396
523
|
completed,
|
|
@@ -408,17 +535,59 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
|
|
|
408
535
|
*
|
|
409
536
|
* Security:
|
|
410
537
|
* - PoC paths must be absolute and under the project workspace by default.
|
|
411
|
-
* - Docker sandbox runs with
|
|
412
|
-
*
|
|
413
|
-
*
|
|
538
|
+
* - Docker sandbox runs with read-only root FS, dropped caps, no new
|
|
539
|
+
* privileges, an unprivileged user, and resource limits. Networking is
|
|
540
|
+
* `none` by default; `network: "host"` adds host networking for live
|
|
541
|
+
* findings WITHOUT giving up the FS/cap/user isolation.
|
|
542
|
+
* - Host execution (local: true) is NOT agent-selectable: the default is a
|
|
543
|
+
* host-network Docker sandbox. Bare host requires the operator's
|
|
544
|
+
* `PI_POC_ALLOW_LOCAL=1` and is used only when Docker is unavailable
|
|
545
|
+
* (or when `PI_POC_FORCE_LOCAL=1` is also set). Without ALLOW the run
|
|
546
|
+
* fails closed if Docker cannot start.
|
|
547
|
+
*
|
|
548
|
+
* Back-compat: `runPoc(path, true)` == sandboxed, `runPoc(path, false)` ==
|
|
549
|
+
* `{ local: true }` (host-network sandbox; host if operator-gated).
|
|
414
550
|
*/
|
|
415
|
-
export function runPoc(pocPath: string,
|
|
551
|
+
export function runPoc(pocPath: string, options?: PocRunOptions | boolean): PocRun {
|
|
416
552
|
const normalized = validatePocPath(pocPath);
|
|
417
553
|
const { language } = resolveLanguage(normalized);
|
|
418
554
|
|
|
419
|
-
|
|
420
|
-
|
|
555
|
+
const opts: PocRunOptions =
|
|
556
|
+
typeof options === "boolean"
|
|
557
|
+
? options
|
|
558
|
+
? { network: "none" }
|
|
559
|
+
: { local: true }
|
|
560
|
+
: (options ?? {});
|
|
561
|
+
|
|
562
|
+
// Host execution is gated by the OPERATOR, never by an agent-supplied flag.
|
|
563
|
+
// `local: true` means "network access needed":
|
|
564
|
+
// 1. Prefer a host-network Docker sandbox (isolation retained).
|
|
565
|
+
// 2. Fall back to bare host ONLY when Docker/image is unavailable AND the
|
|
566
|
+
// operator set PI_POC_ALLOW_LOCAL=1.
|
|
567
|
+
// 3. PI_POC_FORCE_LOCAL=1 + ALLOW lets the operator (or test harness)
|
|
568
|
+
// skip Docker and run on the host deliberately — still never agent-only.
|
|
569
|
+
if (opts.local === true) {
|
|
570
|
+
const allowLocal = process.env[LOCAL_EXEC_ENV] === "1";
|
|
571
|
+
const forceLocal = process.env.PI_POC_FORCE_LOCAL === "1";
|
|
572
|
+
if (forceLocal && allowLocal) {
|
|
573
|
+
return runLocal(normalized, language, opts.env);
|
|
574
|
+
}
|
|
575
|
+
const sandboxed = runSandboxed(normalized, language, "host", opts.env);
|
|
576
|
+
if (!sandboxed.infraError) {
|
|
577
|
+
return sandboxed;
|
|
578
|
+
}
|
|
579
|
+
if (allowLocal) {
|
|
580
|
+
return runLocal(normalized, language, opts.env);
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
...sandboxed,
|
|
584
|
+
output:
|
|
585
|
+
sandboxed.output +
|
|
586
|
+
`\n[host execution blocked] local:true cannot run on the host without the operator's ` +
|
|
587
|
+
`${LOCAL_EXEC_ENV}=1 — an agent-supplied local flag alone cannot enable host execution. ` +
|
|
588
|
+
"Ask the operator to opt in, or use the default (isolated) sandbox if the PoC does not need network.",
|
|
589
|
+
};
|
|
421
590
|
}
|
|
422
591
|
|
|
423
|
-
return
|
|
592
|
+
return runSandboxed(normalized, language, opts.network ?? "none", opts.env);
|
|
424
593
|
}
|
package/src/workflow.ts
CHANGED
|
@@ -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
|
|
136
|
+
**Control-target check (anti-cheat, REQUIRED for EVERY promotion — sandboxed and live alike):** write \`control_path\` — the SAME script as the PoC (the harness enforces sha256 equality; a separately written control file is rejected — the same actor writes both files, so only one parameterized script counts). The script reads the target from the \`PI_POC_TARGET\` env var and branches on \`PI_POC_MODE\` (\`poc\` | \`control\` | \`disconfirmation\`) — the control run is literally the same script in control mode against a control lacking the vulnerability (patched replica, second account, baseline endpoint, WAF-blocked path). The harness blocks promotion if the verification_marker appears in the control output (checked on the UNTRUNCATED output — a script printing its marker past the 4000-char display window is caught). That is what proves the marker is target-dependent, not an unconditional print. Also pass \`control_liveness_marker\`: a unique string the control prints ONLY AFTER reaching/exercising the control target (e.g. \`CONTROL_REACHED_<case-id>\`); the harness blocks promotion if the control output lacks it — a control pointed at an unreachable host, wrong port, or exiting before the check is NOT a clean verdict. 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. **Local/live findings:** \`local:true\` runs in the Docker sandbox with \`--network host\` (still read-only FS, dropped capabilities, unprivileged user). True host execution is NOT agent-selectable: it requires the operator to set \`PI_POC_ALLOW_LOCAL=1\`, and is only a fallback when Docker is unavailable.
|
|
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
|
|
|
@@ -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**. **
|
|
280
|
+
- **Confirmed requires** evidence + poc + impact + severity + target + disconfirmation, and a PoC that exited 0 **with the verification_marker in the output**. **Every promotion also requires control_path + control_liveness_marker**: the same PoC run against a control lacking the vuln must NOT print the marker AND must print the liveness marker (harness-side checks) — this is what stops unconditional-marker, mock-target, and dead-control cheats. \`local:true\` uses a host-network sandbox; host execution needs the operator's \`PI_POC_ALLOW_LOCAL=1\`. No mocks for the exploitation step.
|
|
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.
|