@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.
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { createHash } from "node:crypto";
21
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
22
22
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
23
23
  import { getRunDir, getScratchpadRoot, scratchpad_write } from "./scratchpad.ts";
24
24
 
@@ -204,19 +204,43 @@ function statePath(runId: string): string {
204
204
  function readState(runId: string): SubmitState {
205
205
  const p = statePath(runId);
206
206
  if (!existsSync(p)) return { repairs: {}, accepted_findings: [] };
207
- try {
208
- const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SubmitState>;
209
- return {
210
- repairs: raw.repairs ?? {},
211
- accepted_findings: raw.accepted_findings ?? [],
212
- };
213
- } catch {
214
- return { repairs: {}, accepted_findings: [] };
207
+ const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SubmitState>;
208
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
209
+ throw new Error(`Corrupt pipeline-submit state for ${runId}: root must be an object`);
210
+ }
211
+ const repairs = raw.repairs ?? {};
212
+ if (typeof repairs !== "object" || repairs === null || Array.isArray(repairs)) {
213
+ throw new Error(`Corrupt pipeline-submit state for ${runId}: repairs must be an object`);
214
+ }
215
+ for (const [k, v] of Object.entries(repairs)) {
216
+ if (typeof k !== "string" || typeof v !== "number" || !Number.isInteger(v) || v < 0) {
217
+ throw new Error(`Corrupt pipeline-submit state for ${runId}: invalid repair counter`);
218
+ }
219
+ }
220
+ const accepted = raw.accepted_findings ?? [];
221
+ if (!Array.isArray(accepted)) {
222
+ throw new Error(
223
+ `Corrupt pipeline-submit state for ${runId}: accepted_findings must be an array`,
224
+ );
215
225
  }
226
+ for (const [i, item] of accepted.entries()) {
227
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
228
+ throw new Error(
229
+ `Corrupt pipeline-submit state for ${runId}: accepted_findings[${i}] invalid`,
230
+ );
231
+ }
232
+ }
233
+ return {
234
+ repairs: repairs as Record<string, number>,
235
+ accepted_findings: accepted as FindingRef[],
236
+ };
216
237
  }
217
238
 
218
239
  function writeState(runId: string, state: SubmitState): void {
219
- writeFileSync(statePath(runId), JSON.stringify(state, null, 2), "utf8");
240
+ const p = statePath(runId);
241
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
242
+ writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
243
+ renameSync(tmp, p);
220
244
  }
221
245
 
222
246
  /** Project root containing the scratchpad (file-existence checks resolve here). */
@@ -272,6 +296,109 @@ function isNonEmptyString(v: unknown): v is string {
272
296
  return typeof v === "string" && v.trim().length > 0;
273
297
  }
274
298
 
299
+ function requireStringArray(errors: string[], path: string, value: unknown, minItems = 0): void {
300
+ if (!Array.isArray(value)) {
301
+ errors.push(`${path}: missing or not an array`);
302
+ return;
303
+ }
304
+ if (value.length < minItems) errors.push(`${path}: needs at least ${minItems} item(s)`);
305
+ value.forEach((item, i) => {
306
+ if (!isNonEmptyString(item)) errors.push(`${path}[${i}]: missing or empty string`);
307
+ });
308
+ }
309
+
310
+ function asObject(value: unknown): Record<string, unknown> | undefined {
311
+ return typeof value === "object" && value !== null && !Array.isArray(value)
312
+ ? (value as Record<string, unknown>)
313
+ : undefined;
314
+ }
315
+
316
+ function validateReport(errors: string[], obj: Record<string, unknown>): void {
317
+ const findingSeverities = ["info", "low", "medium", "high", "critical"];
318
+ if (Array.isArray(obj.findings)) {
319
+ obj.findings.forEach((item, i) => {
320
+ const finding = asObject(item);
321
+ if (!finding) {
322
+ errors.push(`findings[${i}]: not an object`);
323
+ return;
324
+ }
325
+ for (const field of ["id", "vuln_class", "severity", "status"] as const) {
326
+ if (!isNonEmptyString(finding[field]))
327
+ errors.push(`findings[${i}].${field}: missing or empty`);
328
+ }
329
+ if (!isNonEmptyString(finding.file) && !isNonEmptyString(finding.endpoint)) {
330
+ errors.push(`findings[${i}]: provide file or endpoint`);
331
+ }
332
+ if (isNonEmptyString(finding.severity) && !findingSeverities.includes(finding.severity)) {
333
+ errors.push(`findings[${i}].severity: invalid value`);
334
+ }
335
+ if (isNonEmptyString(finding.status) && !["confirmed", "reported"].includes(finding.status)) {
336
+ errors.push(`findings[${i}].status: invalid value`);
337
+ }
338
+ if (finding.chain_with !== undefined)
339
+ requireStringArray(errors, `findings[${i}].chain_with`, finding.chain_with);
340
+ });
341
+ }
342
+
343
+ const coverage = asObject(obj.coverage);
344
+ if (coverage) {
345
+ const allowed = ["COVERED", "SKIPPED", "NOT_FOUND", "INCOMPLETE"];
346
+ for (const [key, value] of Object.entries(coverage)) {
347
+ if (!/^[a-z-]+$/.test(key)) errors.push(`coverage.${key}: invalid class key`);
348
+ if (typeof value !== "string" || !allowed.includes(value)) {
349
+ errors.push(`coverage.${key}: must be one of { ${allowed.join(" | ")} }`);
350
+ }
351
+ }
352
+ }
353
+
354
+ if (obj.chains !== undefined) {
355
+ if (!Array.isArray(obj.chains)) {
356
+ errors.push("chains: not an array");
357
+ } else {
358
+ obj.chains.forEach((item, i) => {
359
+ const chain = asObject(item);
360
+ if (!chain) {
361
+ errors.push(`chains[${i}]: not an object`);
362
+ return;
363
+ }
364
+ if (chain.title !== undefined && !isNonEmptyString(chain.title))
365
+ errors.push(`chains[${i}].title: missing or empty`);
366
+ if (chain.steps !== undefined)
367
+ requireStringArray(errors, `chains[${i}].steps`, chain.steps);
368
+ if (
369
+ chain.severity !== undefined &&
370
+ (!isNonEmptyString(chain.severity) ||
371
+ !(CHAIN_SEVERITIES as readonly string[]).includes(chain.severity))
372
+ ) {
373
+ errors.push(`chains[${i}].severity: invalid value`);
374
+ }
375
+ if (chain.blocked_by_controls !== undefined) {
376
+ requireStringArray(errors, `chains[${i}].blocked_by_controls`, chain.blocked_by_controls);
377
+ }
378
+ });
379
+ }
380
+ }
381
+
382
+ if (obj.patches_applied !== undefined) {
383
+ if (!Array.isArray(obj.patches_applied)) {
384
+ errors.push("patches_applied: not an array");
385
+ } else {
386
+ obj.patches_applied.forEach((item, i) => {
387
+ const patch = asObject(item);
388
+ if (!patch) {
389
+ errors.push(`patches_applied[${i}]: not an object`);
390
+ return;
391
+ }
392
+ for (const field of ["finding_id", "diff_summary", "re_attack_result"] as const) {
393
+ if (patch[field] !== undefined && !isNonEmptyString(patch[field])) {
394
+ errors.push(`patches_applied[${i}].${field}: missing or empty`);
395
+ }
396
+ }
397
+ });
398
+ }
399
+ }
400
+ }
401
+
275
402
  function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string[] {
276
403
  const spec = SPECS[stage];
277
404
  const errors: string[] = [];
@@ -334,10 +461,78 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
334
461
  }
335
462
  }
336
463
 
337
- // Chain items have their own inner contract (≥2 steps, severity enum).
464
+ if (stage === "trace") {
465
+ requireStringArray(errors, "call_chain", obj.call_chain, 1);
466
+ if (Array.isArray(obj.defenses_checked)) {
467
+ obj.defenses_checked.forEach((item, i) => {
468
+ const defense = asObject(item);
469
+ if (!defense) {
470
+ errors.push(`defenses_checked[${i}]: not an object`);
471
+ return;
472
+ }
473
+ if (!isNonEmptyString(defense.defense))
474
+ errors.push(`defenses_checked[${i}].defense: missing or empty`);
475
+ if (!isNonEmptyString(defense.location))
476
+ errors.push(`defenses_checked[${i}].location: missing or empty`);
477
+ if (
478
+ !isNonEmptyString(defense.verdict) ||
479
+ !["bypassed", "blocked", "not-present"].includes(defense.verdict)
480
+ ) {
481
+ errors.push(`defenses_checked[${i}].verdict: must be bypassed, blocked, or not-present`);
482
+ }
483
+ });
484
+ }
485
+ }
486
+
487
+ if (stage === "skeptic") {
488
+ requireStringArray(errors, "evidence_reviewed", obj.evidence_reviewed, 1);
489
+ if (obj.verdict === "DISPROVEN" && isNonEmptyString(obj.disproval_reason)) {
490
+ const allowed = [
491
+ "unreachable",
492
+ "framework_protection",
493
+ "input_validation_blocks",
494
+ "requires_privilege_attacker_lacks",
495
+ "intended_behavior",
496
+ "overstated_impact",
497
+ "duplicate",
498
+ "test_artifact",
499
+ "out_of_scope",
500
+ ];
501
+ if (!allowed.includes(obj.disproval_reason)) errors.push("disproval_reason: invalid value");
502
+ }
503
+ }
504
+
505
+ if (stage === "validate") {
506
+ if (obj.status === "killed" && isNonEmptyString(obj.kill_reason)) {
507
+ const allowed = [
508
+ "unreachable",
509
+ "framework_protection",
510
+ "input_validation_blocks",
511
+ "requires_privilege_attacker_lacks",
512
+ "poc_failed_3x",
513
+ "no_real_impact",
514
+ "intended_behavior",
515
+ "duplicate",
516
+ ];
517
+ if (!allowed.includes(obj.kill_reason)) errors.push("kill_reason: invalid value");
518
+ }
519
+ if (
520
+ obj.refinement_attempts !== undefined &&
521
+ (!Number.isInteger(obj.refinement_attempts) ||
522
+ (obj.refinement_attempts as number) < 1 ||
523
+ (obj.refinement_attempts as number) > 3)
524
+ ) {
525
+ errors.push("refinement_attempts: must be an integer from 1 to 3");
526
+ }
527
+ }
528
+
338
529
  if (stage === "chain" && Array.isArray(obj.chains)) {
339
530
  obj.chains.forEach((c, i) => {
340
- const chain = c as Record<string, unknown>;
531
+ const chain = asObject(c);
532
+ if (!chain) {
533
+ errors.push(`chains[${i}]: not an object`);
534
+ return;
535
+ }
341
536
  if (!isNonEmptyString(chain.title)) errors.push(`chains[${i}].title: missing or empty`);
342
537
  if (
343
538
  !isNonEmptyString(chain.severity) ||
@@ -345,14 +540,17 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
345
540
  ) {
346
541
  errors.push(`chains[${i}].severity: must be one of { ${CHAIN_SEVERITIES.join(" | ")} }`);
347
542
  }
348
- if (!Array.isArray(chain.steps) || chain.steps.length < 2) {
349
- errors.push(`chains[${i}].steps: needs at least 2 case IDs`);
543
+ requireStringArray(errors, `chains[${i}].steps`, chain.steps, 2);
544
+ if (chain.blocked_by_controls !== undefined) {
545
+ requireStringArray(errors, `chains[${i}].blocked_by_controls`, chain.blocked_by_controls);
350
546
  }
351
547
  if (!isNonEmptyString(chain.narrative))
352
548
  errors.push(`chains[${i}].narrative: missing or empty`);
353
549
  });
354
550
  }
355
551
 
552
+ if (stage === "report") validateReport(errors, obj);
553
+
356
554
  return errors;
357
555
  }
358
556
 
package/src/poc-runner.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
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,53 @@ 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
+ /** True iff child output capture was complete. False on maxBuffer/timeouts/spawn failures. */
38
+ outputComplete?: boolean;
39
+ /** Harness mode used for this run. */
40
+ mode?: string;
41
+ /** Harness target used for this run. */
42
+ target?: string;
43
+ /**
44
+ * True when the run never started because of harness infrastructure
45
+ * failure (e.g. sandbox image pull failed) — set only by the runner,
46
+ * never derived from PoC-controlled output text.
47
+ */
48
+ infraError?: boolean;
49
+ };
50
+
51
+ /**
52
+ * Run-mode options for `runPoc`. Host execution is NEVER selectable by the
53
+ * agent alone: `local: true` only takes effect when the OPERATOR has set
54
+ * `PI_POC_ALLOW_LOCAL=1` (host is a fallback when Docker is unavailable;
55
+ * `PI_POC_FORCE_LOCAL=1` + ALLOW skips Docker on purpose). The default is a
56
+ * Docker sandbox; `network: "host"` gives the sandbox host networking while
57
+ * keeping the read-only FS / dropped caps / unprivileged user / resource limits.
58
+ */
59
+ export type PocRunOptions = {
60
+ /** Docker sandbox networking: "none" (default) or "host" (live/network-dependent findings). */
61
+ network?: "none" | "host";
62
+ /** True to run on the host (no Docker). Requires operator opt-in PI_POC_ALLOW_LOCAL=1. */
63
+ local?: boolean;
64
+ /**
65
+ * Extra environment variables merged into the run. The harness sets
66
+ * `PI_POC_MODE` ("poc" | "control" | "disconfirmation") and `PI_POC_TARGET`
67
+ * (the case target) so PoCs can be written once and parameterized per run.
68
+ */
69
+ env?: Record<string, string>;
20
70
  };
21
71
 
72
+ /** Operator-only opt-in for host execution (never agent-supplied). */
73
+ const LOCAL_EXEC_ENV = "PI_POC_ALLOW_LOCAL";
74
+
22
75
  export type PocLanguage = {
23
76
  /** Docker image used when running inside the sandbox. */
24
77
  image: string;
@@ -160,27 +213,57 @@ function validatePocPath(pocPath: string): string {
160
213
  }
161
214
 
162
215
  const root = getProjectRoot();
216
+ // Operator escape hatch: PI_POC_ALLOW_ABSOLUTE=1 disables BOTH the lexical
217
+ // and the realpath containment checks (agent cannot set it).
218
+ const allowAbsolute = process.env.PI_POC_ALLOW_ABSOLUTE === "1";
163
219
  // Use path.relative so prefix-sibling escapes like /tmp/proj vs /tmp/proj-evil are rejected.
164
220
  // startsWith(`${root}/`) would accept /tmp/proj-evil when root is /tmp/proj.
165
221
  const rel = relative(root, normalized);
166
222
  const outsideWorkspace = rel === "" ? false : rel.startsWith("..") || isAbsolute(rel);
167
- if (outsideWorkspace) {
168
- const allowAbsolute = process.env.PI_POC_ALLOW_ABSOLUTE === "1";
169
- if (!allowAbsolute) {
170
- throw new Error(
171
- `PoC path must be under the project workspace (${root}). ` +
172
- `Set PI_POC_ALLOW_ABSOLUTE=1 to allow arbitrary absolute paths.`,
173
- );
174
- }
223
+ if (outsideWorkspace && !allowAbsolute) {
224
+ throw new Error(
225
+ `PoC path must be under the project workspace (${root}). ` +
226
+ `Set PI_POC_ALLOW_ABSOLUTE=1 to allow arbitrary absolute paths.`,
227
+ );
175
228
  }
176
229
 
177
230
  if (!existsSync(normalized)) {
178
231
  throw new Error(`PoC not found on disk: ${pocPath}`);
179
232
  }
180
233
 
234
+ // Symlink containment: the lexical checks above are defeated by a
235
+ // workspace file that is a symlink to a host path (e.g. $HOME/.env) —
236
+ // existsSync/copyFileSync/readFileSync all dereference. Resolve the real
237
+ // path and re-run the containment check on it, then require a regular
238
+ // file (FIFOs/devices/sockets are rejected).
239
+ let real: string;
240
+ try {
241
+ real = realpathSync(normalized);
242
+ } catch {
243
+ throw new Error(`PoC path cannot be resolved: ${pocPath}`);
244
+ }
245
+ if (!allowAbsolute) {
246
+ const realRel = relative(root, real);
247
+ const realOutside = realRel === "" ? false : realRel.startsWith("..") || isAbsolute(realRel);
248
+ if (realOutside) {
249
+ throw new Error(
250
+ `PoC path resolves outside the project workspace (${real}). ` +
251
+ `Symlinked files outside ${root} are rejected.`,
252
+ );
253
+ }
254
+ }
255
+ const realStat = statSync(real);
256
+ if (!realStat.isFile()) {
257
+ throw new Error(`PoC path must resolve to a regular file (got ${pocPath})`);
258
+ }
259
+
181
260
  return normalized;
182
261
  }
183
262
 
263
+ /**
264
+ * Strip control chars / ANSI escapes from PoC output. Does NOT slice —
265
+ * truncation is display-only and must never hide content from marker checks.
266
+ */
184
267
  function sanitizeOutput(output: string): string {
185
268
  // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional
186
269
  const nulls = /\x00/g;
@@ -194,8 +277,37 @@ function sanitizeOutput(output: string): string {
194
277
  .replace(/\r/g, "\n")
195
278
  .replace(nulls, "")
196
279
  .replace(ansi, "")
197
- .replace(ctrl, "")
198
- .slice(0, OUTPUT_MAX_CHARS);
280
+ .replace(ctrl, "");
281
+ }
282
+
283
+ /** Split sanitized output into the raw (whole) and display (sliced) halves. */
284
+ function splitOutput(raw: string): { rawOutput: string; output: string; truncated: boolean } {
285
+ const truncated = raw.length > OUTPUT_MAX_CHARS;
286
+ return { rawOutput: raw, output: raw.slice(0, OUTPUT_MAX_CHARS), truncated };
287
+ }
288
+
289
+ function runProvenance(env?: Record<string, string>): Pick<PocRun, "mode" | "target"> {
290
+ return { mode: env?.PI_POC_MODE, target: env?.PI_POC_TARGET };
291
+ }
292
+
293
+ function outputWasComplete(result: { error?: Error; signal: string | null }): boolean {
294
+ return !result.error && result.signal === null;
295
+ }
296
+
297
+ /** Reject control characters in harness-supplied PoC env values. */
298
+ function sanitizePocEnv(env: Record<string, string>): Record<string, string> {
299
+ const out: Record<string, string> = {};
300
+ for (const [key, value] of Object.entries(env)) {
301
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
302
+ throw new Error(`Invalid PoC env key: ${key}`);
303
+ }
304
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional
305
+ if (/[\x00-\x1f\x7f]/.test(value)) {
306
+ throw new Error(`PoC env value for ${key} contains control characters`);
307
+ }
308
+ out[key] = value;
309
+ }
310
+ return out;
199
311
  }
200
312
 
201
313
  function buildDockerArgs(
@@ -203,14 +315,23 @@ function buildDockerArgs(
203
315
  command: string,
204
316
  workspaceDir: string,
205
317
  containerName: string,
318
+ network: "none" | "host",
319
+ env?: Record<string, string>,
206
320
  ): string[] {
321
+ const envArgs: string[] = [];
322
+ for (const [key, value] of Object.entries(sanitizePocEnv(env ?? {}))) {
323
+ // Values are single tokens from the harness (PI_POC_MODE / PI_POC_TARGET);
324
+ // pass them as separate -e args so no shell quoting is involved.
325
+ envArgs.push("-e", `${key}=${value}`);
326
+ }
207
327
  return [
208
328
  "run",
209
329
  "--rm",
210
330
  "--name",
211
331
  containerName,
212
332
  "--network",
213
- "none",
333
+ network,
334
+ ...envArgs,
214
335
  "--read-only",
215
336
  "--cap-drop",
216
337
  "ALL",
@@ -290,7 +411,12 @@ function ensureImage(image: string): void {
290
411
  }
291
412
  }
292
413
 
293
- function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
414
+ function runSandboxed(
415
+ pocPath: string,
416
+ language: PocLanguage,
417
+ network: "none" | "host" = "none",
418
+ env?: Record<string, string>,
419
+ ): PocRun {
294
420
  const ranAt = new Date().toISOString();
295
421
  const sourceName = basename(pocPath);
296
422
  const workspaceDir = mkdtempSync(resolve(tmpdir(), "poc-runner-"));
@@ -311,6 +437,9 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
311
437
  ranAt,
312
438
  sandbox: true,
313
439
  completed: false,
440
+ infraError: true,
441
+ outputComplete: false,
442
+ ...runProvenance(env),
314
443
  };
315
444
  }
316
445
  copyFileSync(pocPath, `${workspaceDir}/${sourceName}`);
@@ -326,7 +455,7 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
326
455
 
327
456
  const result = spawnSync(
328
457
  "docker",
329
- buildDockerArgs(language.image, wrapped, workspaceDir, containerName),
458
+ buildDockerArgs(language.image, wrapped, workspaceDir, containerName, network, env),
330
459
  {
331
460
  encoding: "utf8",
332
461
  timeout: TIMEOUT_MS,
@@ -337,14 +466,18 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
337
466
  const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
338
467
  const raw = (result.stdout ?? "") + (result.stderr ?? "") + spawnErr;
339
468
  const completed = raw.includes(sentinel);
340
- const output = sanitizeOutput(raw.replace(sentinel, ""));
469
+ const { rawOutput, output, truncated } = splitOutput(sanitizeOutput(raw.replace(sentinel, "")));
341
470
  return {
342
471
  path: pocPath,
343
472
  exitCode: spawnExitCode(result),
344
473
  output,
474
+ rawOutput,
475
+ truncated,
345
476
  ranAt,
346
477
  sandbox: true,
347
478
  completed,
479
+ outputComplete: outputWasComplete(result),
480
+ ...runProvenance(env),
348
481
  };
349
482
  } finally {
350
483
  // Best-effort: remove any container still running after a timeout/kill.
@@ -361,7 +494,7 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
361
494
  }
362
495
  }
363
496
 
364
- function runLocal(pocPath: string, language: PocLanguage): PocRun {
497
+ function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, string>): PocRun {
365
498
  const ranAt = new Date().toISOString();
366
499
 
367
500
  // The run template is `<interpreter> [flags...] {{file}}`. Split the static
@@ -378,6 +511,11 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
378
511
  encoding: "utf8",
379
512
  timeout: TIMEOUT_MS,
380
513
  maxBuffer: MAX_BUFFER,
514
+ // Host runs get the harness env contract merged over the operator env;
515
+ // the spawn env is explicitly provided so PI_POC_MODE / PI_POC_TARGET
516
+ // reach the script without leaking through a shell. Same control-char
517
+ // rejection as the sandboxed path.
518
+ env: env ? { ...process.env, ...sanitizePocEnv(env) } : undefined,
381
519
  });
382
520
 
383
521
  // Local runs stay shell-free (space-containing paths stay single args), so
@@ -386,14 +524,20 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
386
524
  // means the script never ran to completion — fail closed on those.
387
525
  const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
388
526
  const completed = !result.error && result.signal === null;
389
- const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr);
527
+ const { rawOutput, output, truncated } = splitOutput(
528
+ sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr),
529
+ );
390
530
  return {
391
531
  path: pocPath,
392
532
  exitCode: spawnExitCode(result),
393
533
  output,
534
+ rawOutput,
535
+ truncated,
394
536
  ranAt,
395
537
  sandbox: false,
396
538
  completed,
539
+ outputComplete: outputWasComplete(result),
540
+ ...runProvenance(env),
397
541
  };
398
542
  }
399
543
 
@@ -408,17 +552,59 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
408
552
  *
409
553
  * Security:
410
554
  * - PoC paths must be absolute and under the project workspace by default.
411
- * - Docker sandbox runs with no network, read-only root FS, dropped caps,
412
- * no new privileges, and an unprivileged user.
413
- * - Local execution is restricted to interpreted languages.
555
+ * - Docker sandbox runs with read-only root FS, dropped caps, no new
556
+ * privileges, an unprivileged user, and resource limits. Networking is
557
+ * `none` by default; `network: "host"` adds host networking for live
558
+ * findings WITHOUT giving up the FS/cap/user isolation.
559
+ * - Host execution (local: true) is NOT agent-selectable: the default is a
560
+ * host-network Docker sandbox. Bare host requires the operator's
561
+ * `PI_POC_ALLOW_LOCAL=1` and is used only when Docker is unavailable
562
+ * (or when `PI_POC_FORCE_LOCAL=1` is also set). Without ALLOW the run
563
+ * fails closed if Docker cannot start.
564
+ *
565
+ * Back-compat: `runPoc(path, true)` == sandboxed, `runPoc(path, false)` ==
566
+ * `{ local: true }` (host-network sandbox; host if operator-gated).
414
567
  */
415
- export function runPoc(pocPath: string, useSandbox = true): PocRun {
568
+ export function runPoc(pocPath: string, options?: PocRunOptions | boolean): PocRun {
416
569
  const normalized = validatePocPath(pocPath);
417
570
  const { language } = resolveLanguage(normalized);
418
571
 
419
- if (useSandbox) {
420
- return runSandboxed(normalized, language);
572
+ const opts: PocRunOptions =
573
+ typeof options === "boolean"
574
+ ? options
575
+ ? { network: "none" }
576
+ : { local: true }
577
+ : (options ?? {});
578
+
579
+ // Host execution is gated by the OPERATOR, never by an agent-supplied flag.
580
+ // `local: true` means "network access needed":
581
+ // 1. Prefer a host-network Docker sandbox (isolation retained).
582
+ // 2. Fall back to bare host ONLY when Docker/image is unavailable AND the
583
+ // operator set PI_POC_ALLOW_LOCAL=1.
584
+ // 3. PI_POC_FORCE_LOCAL=1 + ALLOW lets the operator (or test harness)
585
+ // skip Docker and run on the host deliberately — still never agent-only.
586
+ if (opts.local === true) {
587
+ const allowLocal = process.env[LOCAL_EXEC_ENV] === "1";
588
+ const forceLocal = process.env.PI_POC_FORCE_LOCAL === "1";
589
+ if (forceLocal && allowLocal) {
590
+ return runLocal(normalized, language, opts.env);
591
+ }
592
+ const sandboxed = runSandboxed(normalized, language, "host", opts.env);
593
+ if (!sandboxed.infraError) {
594
+ return sandboxed;
595
+ }
596
+ if (allowLocal) {
597
+ return runLocal(normalized, language, opts.env);
598
+ }
599
+ return {
600
+ ...sandboxed,
601
+ output:
602
+ sandboxed.output +
603
+ `\n[host execution blocked] local:true cannot run on the host without the operator's ` +
604
+ `${LOCAL_EXEC_ENV}=1 — an agent-supplied local flag alone cannot enable host execution. ` +
605
+ "Ask the operator to opt in, or use the default (isolated) sandbox if the PoC does not need network.",
606
+ };
421
607
  }
422
608
 
423
- return runLocal(normalized, language);
609
+ return runSandboxed(normalized, language, opts.network ?? "none", opts.env);
424
610
  }