@xaccefy/pi-casefile 0.9.0 → 0.9.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/LICENSE +1 -1
- package/README.md +18 -11
- package/package.json +13 -4
- package/skills/casefile/SKILL.md +5 -5
- package/src/evidence.ts +502 -0
- package/src/harness-verify.ts +693 -0
- package/src/index.ts +449 -321
- package/src/ledger-worker-entry.ts +35 -0
- package/src/ledger-worker.ts +77 -0
- package/src/ledger.ts +503 -71
- package/src/pipeline-submit.ts +122 -75
- package/src/poc-runner.ts +67 -20
- package/src/safe-state.ts +108 -0
- package/src/scratchpad.ts +57 -29
- package/src/workflow.ts +51 -46
package/src/pipeline-submit.ts
CHANGED
|
@@ -18,8 +18,15 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
|
-
import { existsSync,
|
|
22
|
-
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
21
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
22
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
|
+
import { KILL_REASON_VALUES } from "./ledger.ts";
|
|
24
|
+
import {
|
|
25
|
+
assertSafeRegularFile,
|
|
26
|
+
assertSafeStateDirectory,
|
|
27
|
+
readSafeFile,
|
|
28
|
+
writeSafeFileAtomic,
|
|
29
|
+
} from "./safe-state.ts";
|
|
23
30
|
import { getRunDir, getScratchpadRoot, scratchpad_write } from "./scratchpad.ts";
|
|
24
31
|
|
|
25
32
|
// ── Types ────────────────────────────────────────────────────────────
|
|
@@ -45,6 +52,8 @@ export type SubmitResult = {
|
|
|
45
52
|
};
|
|
46
53
|
|
|
47
54
|
type StageSpec = {
|
|
55
|
+
/** Exact top-level field allowlist; mirrors additionalProperties:false. */
|
|
56
|
+
allowed: readonly string[];
|
|
48
57
|
/** Fields that must be present and non-empty. */
|
|
49
58
|
required: {
|
|
50
59
|
name: string;
|
|
@@ -60,34 +69,25 @@ type StageSpec = {
|
|
|
60
69
|
|
|
61
70
|
// ── Stage specs (mirror of schemas/*.json semantics) ─────────────────
|
|
62
71
|
|
|
63
|
-
const VULN_CLASSES = [
|
|
64
|
-
"injection",
|
|
65
|
-
"xss",
|
|
66
|
-
"idor",
|
|
67
|
-
"bola",
|
|
68
|
-
"path-traversal",
|
|
69
|
-
"ssrf",
|
|
70
|
-
"command-injection",
|
|
71
|
-
"deserialization",
|
|
72
|
-
"auth-bypass",
|
|
73
|
-
"privilege-escalation",
|
|
74
|
-
"business-logic",
|
|
75
|
-
"race-condition",
|
|
76
|
-
"xxe",
|
|
77
|
-
"ssti",
|
|
78
|
-
"open-redirect",
|
|
79
|
-
"information-disclosure",
|
|
80
|
-
"crypto-weakness",
|
|
81
|
-
"other",
|
|
82
|
-
] as const;
|
|
83
|
-
|
|
84
72
|
// Exported for test/pipeline-submit-schema-parity.test.ts (drift guard
|
|
85
73
|
// against schemas/*.json — the two are kept as mirrors of each other).
|
|
86
74
|
export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
87
75
|
// schemas/stage-finding.json
|
|
88
76
|
hunt: {
|
|
77
|
+
allowed: [
|
|
78
|
+
"vuln_class",
|
|
79
|
+
"file",
|
|
80
|
+
"line",
|
|
81
|
+
"endpoint",
|
|
82
|
+
"sink",
|
|
83
|
+
"entry_point",
|
|
84
|
+
"confidence",
|
|
85
|
+
"evidence",
|
|
86
|
+
"attacker_model",
|
|
87
|
+
"subsystem",
|
|
88
|
+
],
|
|
89
89
|
required: [
|
|
90
|
-
{ name: "vuln_class", type: "string"
|
|
90
|
+
{ name: "vuln_class", type: "string" },
|
|
91
91
|
{ name: "sink", type: "string" },
|
|
92
92
|
{ name: "entry_point", type: "string" },
|
|
93
93
|
{ name: "confidence", type: "string", enum: ["low", "medium", "high"] },
|
|
@@ -98,8 +98,18 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
|
98
98
|
},
|
|
99
99
|
// schemas/stage-trace.json
|
|
100
100
|
trace: {
|
|
101
|
+
allowed: [
|
|
102
|
+
"trace_result",
|
|
103
|
+
"entry_point",
|
|
104
|
+
"call_chain",
|
|
105
|
+
"defenses_checked",
|
|
106
|
+
"attacker_model",
|
|
107
|
+
"impact_if_reachable",
|
|
108
|
+
"unreachable_reason",
|
|
109
|
+
"uncertainty_reason",
|
|
110
|
+
],
|
|
101
111
|
required: [
|
|
102
|
-
{ name: "trace_result", type: "string", enum: ["REACHABLE", "UNREACHABLE"] },
|
|
112
|
+
{ name: "trace_result", type: "string", enum: ["REACHABLE", "UNREACHABLE", "UNDETERMINED"] },
|
|
103
113
|
{ name: "entry_point", type: "string" },
|
|
104
114
|
{ name: "call_chain", type: "array", minItems: 1 },
|
|
105
115
|
{ name: "defenses_checked", type: "array" },
|
|
@@ -108,13 +118,23 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
|
108
118
|
conditional: [
|
|
109
119
|
{ when: { field: "trace_result", equals: "REACHABLE" }, require: ["impact_if_reachable"] },
|
|
110
120
|
{ when: { field: "trace_result", equals: "UNREACHABLE" }, require: ["unreachable_reason"] },
|
|
121
|
+
{ when: { field: "trace_result", equals: "UNDETERMINED" }, require: ["uncertainty_reason"] },
|
|
111
122
|
],
|
|
112
123
|
},
|
|
113
124
|
// schemas/stage-skeptic.json
|
|
114
125
|
skeptic: {
|
|
126
|
+
allowed: [
|
|
127
|
+
"finding_id",
|
|
128
|
+
"verdict",
|
|
129
|
+
"reasoning",
|
|
130
|
+
"evidence_reviewed",
|
|
131
|
+
"disconfirmation_attempt",
|
|
132
|
+
"disproval_reason",
|
|
133
|
+
"uncertainty_reason",
|
|
134
|
+
],
|
|
115
135
|
required: [
|
|
116
136
|
{ name: "finding_id", type: "string" },
|
|
117
|
-
{ name: "verdict", type: "string", enum: ["CONFIRMED", "DISPROVEN"] },
|
|
137
|
+
{ name: "verdict", type: "string", enum: ["CONFIRMED", "DISPROVEN", "UNDETERMINED"] },
|
|
118
138
|
{ name: "reasoning", type: "string" },
|
|
119
139
|
{ name: "evidence_reviewed", type: "array", minItems: 1 },
|
|
120
140
|
],
|
|
@@ -127,19 +147,35 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
|
127
147
|
when: { field: "verdict", equals: "CONFIRMED" },
|
|
128
148
|
require: ["disconfirmation_attempt"],
|
|
129
149
|
},
|
|
150
|
+
{ when: { field: "verdict", equals: "UNDETERMINED" }, require: ["uncertainty_reason"] },
|
|
130
151
|
],
|
|
131
152
|
},
|
|
132
153
|
// schemas/stage-validation.json
|
|
133
154
|
validate: {
|
|
155
|
+
allowed: [
|
|
156
|
+
"finding_id",
|
|
157
|
+
"status",
|
|
158
|
+
"technique_used",
|
|
159
|
+
"detection_method",
|
|
160
|
+
"poc_path",
|
|
161
|
+
"run_log",
|
|
162
|
+
"evidence_extracted",
|
|
163
|
+
"kill_reason",
|
|
164
|
+
"refinement_attempts",
|
|
165
|
+
],
|
|
134
166
|
required: [
|
|
135
167
|
{ name: "finding_id", type: "string" },
|
|
136
|
-
{
|
|
168
|
+
{
|
|
169
|
+
name: "status",
|
|
170
|
+
type: "string",
|
|
171
|
+
enum: ["pending_confirmation", "killed", "reported"],
|
|
172
|
+
},
|
|
137
173
|
{ name: "technique_used", type: "string" },
|
|
138
174
|
{ name: "detection_method", type: "string" },
|
|
139
175
|
],
|
|
140
176
|
conditional: [
|
|
141
177
|
{
|
|
142
|
-
when: { field: "status", equals: "
|
|
178
|
+
when: { field: "status", equals: "pending_confirmation" },
|
|
143
179
|
require: ["poc_path", "run_log", "evidence_extracted"],
|
|
144
180
|
},
|
|
145
181
|
{ when: { field: "status", equals: "killed" }, require: ["kill_reason"] },
|
|
@@ -147,6 +183,7 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
|
147
183
|
},
|
|
148
184
|
// schemas/stage-chain.json
|
|
149
185
|
chain: {
|
|
186
|
+
allowed: ["chains", "summary", "tokens_input", "tokens_output"],
|
|
150
187
|
required: [
|
|
151
188
|
{ name: "chains", type: "array" },
|
|
152
189
|
{ name: "summary", type: "string" },
|
|
@@ -154,6 +191,16 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
|
154
191
|
},
|
|
155
192
|
// schemas/stage-report.json
|
|
156
193
|
report: {
|
|
194
|
+
allowed: [
|
|
195
|
+
"target",
|
|
196
|
+
"pipeline_status",
|
|
197
|
+
"total_tokens",
|
|
198
|
+
"findings",
|
|
199
|
+
"chains",
|
|
200
|
+
"coverage",
|
|
201
|
+
"summary",
|
|
202
|
+
"patches_applied",
|
|
203
|
+
],
|
|
157
204
|
required: [
|
|
158
205
|
{ name: "target", type: "string" },
|
|
159
206
|
{ name: "pipeline_status", type: "string", enum: ["complete", "partial", "aborted"] },
|
|
@@ -183,7 +230,7 @@ const CHAIN_SEVERITIES = ["low", "medium", "high", "critical"] as const;
|
|
|
183
230
|
/**
|
|
184
231
|
* Test/mock/example paths carry no real findings (mirrors VVAH S5). Exception
|
|
185
232
|
* from VVAH deliberately not copied: hardcoded-creds-in-test-files — the
|
|
186
|
-
* auditor can submit those under
|
|
233
|
+
* auditor can submit those under the precise class it decides fits the issue;
|
|
187
234
|
* the gate errs on filtering noise.
|
|
188
235
|
*/
|
|
189
236
|
|
|
@@ -212,7 +259,11 @@ function statePath(runId: string): string {
|
|
|
212
259
|
function readState(runId: string): SubmitState {
|
|
213
260
|
const p = statePath(runId);
|
|
214
261
|
if (!existsSync(p)) return { repairs: {}, accepted_findings: [] };
|
|
215
|
-
|
|
262
|
+
assertSafeStateDirectory(projectRoot(), [".scratchpad", basename(dirname(p))]);
|
|
263
|
+
assertSafeRegularFile(p, "Pipeline state");
|
|
264
|
+
const raw = JSON.parse(
|
|
265
|
+
readSafeFile(p, "Pipeline state").toString("utf8"),
|
|
266
|
+
) as Partial<SubmitState>;
|
|
216
267
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
217
268
|
throw new Error(`Corrupt pipeline-submit state for ${runId}: root must be an object`);
|
|
218
269
|
}
|
|
@@ -246,9 +297,8 @@ function readState(runId: string): SubmitState {
|
|
|
246
297
|
|
|
247
298
|
function writeState(runId: string, state: SubmitState): void {
|
|
248
299
|
const p = statePath(runId);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
renameSync(tmp, p);
|
|
300
|
+
assertSafeStateDirectory(projectRoot(), [".scratchpad", basename(dirname(p))]);
|
|
301
|
+
writeSafeFileAtomic(p, JSON.stringify(state, null, 2));
|
|
252
302
|
}
|
|
253
303
|
|
|
254
304
|
/** Project root containing the scratchpad (file-existence checks resolve here). */
|
|
@@ -352,7 +402,7 @@ function validateReport(errors: string[], obj: Record<string, unknown>): void {
|
|
|
352
402
|
if (coverage) {
|
|
353
403
|
const allowed = ["COVERED", "SKIPPED", "NOT_FOUND", "INCOMPLETE"];
|
|
354
404
|
for (const [key, value] of Object.entries(coverage)) {
|
|
355
|
-
if (
|
|
405
|
+
if (!key.trim() || /[\r\n]/.test(key)) errors.push(`coverage.${key}: invalid class key`);
|
|
356
406
|
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
357
407
|
errors.push(`coverage.${key}: must be one of { ${allowed.join(" | ")} }`);
|
|
358
408
|
}
|
|
@@ -407,10 +457,23 @@ function validateReport(errors: string[], obj: Record<string, unknown>): void {
|
|
|
407
457
|
}
|
|
408
458
|
}
|
|
409
459
|
|
|
460
|
+
function resolveProjectPath(input: string): { abs: string; rel: string } {
|
|
461
|
+
const root = projectRoot();
|
|
462
|
+
const trimmed = input.trim();
|
|
463
|
+
const relativeInput = trimmed.replace(/^\.\//, "");
|
|
464
|
+
const abs = isAbsolute(trimmed) ? resolve(trimmed) : resolve(root, relativeInput);
|
|
465
|
+
return { abs, rel: relative(root, abs) };
|
|
466
|
+
}
|
|
467
|
+
|
|
410
468
|
function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string[] {
|
|
411
469
|
const spec = SPECS[stage];
|
|
412
470
|
const errors: string[] = [];
|
|
413
471
|
|
|
472
|
+
const allowedFields = new Set(spec.allowed);
|
|
473
|
+
for (const name of Object.keys(obj)) {
|
|
474
|
+
if (!allowedFields.has(name)) errors.push(`${name}: unknown top-level field`);
|
|
475
|
+
}
|
|
476
|
+
|
|
414
477
|
for (const field of spec.required) {
|
|
415
478
|
const v = obj[field.name];
|
|
416
479
|
if (field.type === "string") {
|
|
@@ -495,43 +558,24 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
|
|
|
495
558
|
if (stage === "skeptic") {
|
|
496
559
|
requireStringArray(errors, "evidence_reviewed", obj.evidence_reviewed, 1);
|
|
497
560
|
if (obj.verdict === "DISPROVEN" && isNonEmptyString(obj.disproval_reason)) {
|
|
498
|
-
|
|
499
|
-
"
|
|
500
|
-
|
|
501
|
-
"input_validation_blocks",
|
|
502
|
-
"requires_privilege_attacker_lacks",
|
|
503
|
-
"intended_behavior",
|
|
504
|
-
"overstated_impact",
|
|
505
|
-
"duplicate",
|
|
506
|
-
"test_artifact",
|
|
507
|
-
"out_of_scope",
|
|
508
|
-
];
|
|
509
|
-
if (!allowed.includes(obj.disproval_reason)) errors.push("disproval_reason: invalid value");
|
|
561
|
+
if (!(KILL_REASON_VALUES as readonly string[]).includes(obj.disproval_reason)) {
|
|
562
|
+
errors.push("disproval_reason: invalid value");
|
|
563
|
+
}
|
|
510
564
|
}
|
|
511
565
|
}
|
|
512
566
|
|
|
513
567
|
if (stage === "validate") {
|
|
514
568
|
if (obj.status === "killed" && isNonEmptyString(obj.kill_reason)) {
|
|
515
|
-
|
|
516
|
-
"
|
|
517
|
-
|
|
518
|
-
"input_validation_blocks",
|
|
519
|
-
"requires_privilege_attacker_lacks",
|
|
520
|
-
"poc_failed_3x",
|
|
521
|
-
"no_real_impact",
|
|
522
|
-
"intended_behavior",
|
|
523
|
-
"duplicate",
|
|
524
|
-
];
|
|
525
|
-
if (!allowed.includes(obj.kill_reason)) errors.push("kill_reason: invalid value");
|
|
569
|
+
if (!(KILL_REASON_VALUES as readonly string[]).includes(obj.kill_reason)) {
|
|
570
|
+
errors.push("kill_reason: invalid value");
|
|
571
|
+
}
|
|
526
572
|
}
|
|
527
|
-
if (obj.status === "
|
|
528
|
-
// A
|
|
573
|
+
if (obj.status === "pending_confirmation" && isNonEmptyString(obj.poc_path)) {
|
|
574
|
+
// A phase-1 validation submission must point at a PoC file
|
|
529
575
|
// that actually exists in the project — same file-existence filter hunt
|
|
530
576
|
// findings get. Otherwise fabricated run logs pass the stage gate.
|
|
531
577
|
const raw = obj.poc_path as string;
|
|
532
|
-
const
|
|
533
|
-
const abs = isAbsolute(normalized) ? resolve(normalized) : resolve(projectRoot(), normalized);
|
|
534
|
-
const rel = relative(projectRoot(), abs);
|
|
578
|
+
const { abs, rel } = resolveProjectPath(raw);
|
|
535
579
|
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
536
580
|
errors.push("poc_path: must resolve inside the project root");
|
|
537
581
|
} else if (!existsSync(abs)) {
|
|
@@ -581,26 +625,24 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
|
|
|
581
625
|
function prefilterHunt(obj: Record<string, unknown>): string | null {
|
|
582
626
|
const file = typeof obj.file === "string" ? obj.file : undefined;
|
|
583
627
|
if (!file) return null; // live target: endpoint locator, nothing to filter
|
|
584
|
-
const normalized = file.replace(/^\.?\//, "");
|
|
585
|
-
const segments = normalized.split("/");
|
|
586
|
-
if (segments.some((s) => TEST_SEGMENT_RE.test(s)) || TEST_FILE_RE.test(normalized)) {
|
|
587
|
-
return (
|
|
588
|
-
`test-path filter: "${file}" matches test/fixture/mock paths — findings in ` +
|
|
589
|
-
`test code are noise. If this is a deliberately-shipped test credential, ` +
|
|
590
|
-
`re-submit documenting why it ships to production.`
|
|
591
|
-
);
|
|
592
|
-
}
|
|
593
628
|
const root = projectRoot();
|
|
594
|
-
const abs
|
|
629
|
+
const { abs, rel } = resolveProjectPath(file);
|
|
595
630
|
// Containment: resolved path must stay inside the project, otherwise a
|
|
596
631
|
// "finding" can point at ../ or absolute files outside the target repo.
|
|
597
|
-
const rel = relative(root, abs);
|
|
598
632
|
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
599
633
|
return (
|
|
600
634
|
`containment filter: "${file}" resolves outside the project root (${root}). ` +
|
|
601
635
|
`Findings must reference files inside the target repository.`
|
|
602
636
|
);
|
|
603
637
|
}
|
|
638
|
+
const segments = rel.split("/");
|
|
639
|
+
if (segments.some((s) => TEST_SEGMENT_RE.test(s)) || TEST_FILE_RE.test(rel)) {
|
|
640
|
+
return (
|
|
641
|
+
`test-path filter: "${file}" matches test/fixture/mock paths — findings in ` +
|
|
642
|
+
`test code are noise. If this is a deliberately-shipped test credential, ` +
|
|
643
|
+
`re-submit documenting why it ships to production.`
|
|
644
|
+
);
|
|
645
|
+
}
|
|
604
646
|
// Symlink containment (same defense as the PoC runner): resolve() is
|
|
605
647
|
// lexical, and existsSync() dereferences symlinks — a workspace symlink to
|
|
606
648
|
// /etc (ln -s /etc etc-link) would otherwise pass both checks and let a
|
|
@@ -628,7 +670,10 @@ function prefilterHunt(obj: Record<string, unknown>): string | null {
|
|
|
628
670
|
}
|
|
629
671
|
|
|
630
672
|
function dedupHunt(state: SubmitState, obj: Record<string, unknown>): { duplicateOf?: string } {
|
|
631
|
-
const file =
|
|
673
|
+
const file =
|
|
674
|
+
typeof obj.file === "string"
|
|
675
|
+
? resolveProjectPath(obj.file).rel.replace(/^\.\//, "")
|
|
676
|
+
: undefined;
|
|
632
677
|
const endpoint = typeof obj.endpoint === "string" ? obj.endpoint.trim() : undefined;
|
|
633
678
|
const vulnClass = typeof obj.vuln_class === "string" ? obj.vuln_class : undefined;
|
|
634
679
|
const line = typeof obj.line === "number" ? obj.line : undefined;
|
|
@@ -724,7 +769,9 @@ export function pipeline_submit(runId: string, stage: SubmitStage, output: unkno
|
|
|
724
769
|
if (isFileFinding || isEndpointFinding) {
|
|
725
770
|
state.accepted_findings.push({
|
|
726
771
|
key,
|
|
727
|
-
file: isFileFinding
|
|
772
|
+
file: isFileFinding
|
|
773
|
+
? resolveProjectPath(obj.file as string).rel.replace(/^\.\//, "")
|
|
774
|
+
: "",
|
|
728
775
|
line: typeof obj.line === "number" ? obj.line : undefined,
|
|
729
776
|
endpoint: isEndpointFinding ? (obj.endpoint as string).trim() : undefined,
|
|
730
777
|
vuln_class: obj.vuln_class,
|
package/src/poc-runner.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import {
|
|
4
4
|
copyFileSync,
|
|
5
5
|
existsSync,
|
|
6
|
+
lstatSync,
|
|
6
7
|
mkdirSync,
|
|
7
8
|
mkdtempSync,
|
|
8
9
|
readFileSync,
|
|
@@ -14,6 +15,7 @@ import { tmpdir } from "node:os";
|
|
|
14
15
|
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
15
16
|
|
|
16
17
|
import { evidenceNonceMatches, type PoCEvidence, parsePoCEvidence } from "./evidence.ts";
|
|
18
|
+
import { ensureSafeStateDirectory, writeSafeFileExclusive } from "./safe-state.ts";
|
|
17
19
|
import { findWorkspaceRoot } from "./scratchpad.ts";
|
|
18
20
|
|
|
19
21
|
export type PocRun = {
|
|
@@ -84,6 +86,7 @@ export type PocRunOptions = {
|
|
|
84
86
|
|
|
85
87
|
/** Operator-only opt-in for host execution (never agent-supplied). */
|
|
86
88
|
const LOCAL_EXEC_ENV = "PI_POC_ALLOW_LOCAL";
|
|
89
|
+
const EVIDENCE_MAX_BYTES = 256 * 1024;
|
|
87
90
|
|
|
88
91
|
export type PocLanguage = {
|
|
89
92
|
/** Docker image used when running inside the sandbox. */
|
|
@@ -128,11 +131,11 @@ const OUTPUT_MAX_CHARS = 4000;
|
|
|
128
131
|
const TIMEOUT_MS = 30_000;
|
|
129
132
|
/** Completion sentinel echoed after the PoC command inside the sandbox shell. */
|
|
130
133
|
function makeSentinel(): string {
|
|
131
|
-
return `__PI_POC_DONE_${
|
|
134
|
+
return `__PI_POC_DONE_${randomBytes(16).toString("hex")}__`;
|
|
132
135
|
}
|
|
133
136
|
/** Per-run random nonce the PoC must echo in evidence.json (binds evidence to its run). */
|
|
134
137
|
function makeNonce(): string {
|
|
135
|
-
return `poc_${
|
|
138
|
+
return `poc_${randomBytes(24).toString("hex")}`;
|
|
136
139
|
}
|
|
137
140
|
/** First-use image downloads are slow — pull outside the run timeout. */
|
|
138
141
|
const PULL_TIMEOUT_MS = 300_000;
|
|
@@ -311,16 +314,37 @@ function splitOutput(raw: string): { rawOutput: string; output: string; truncate
|
|
|
311
314
|
function readEvidence(
|
|
312
315
|
evidenceDir: string,
|
|
313
316
|
nonce: string,
|
|
314
|
-
): {
|
|
317
|
+
): {
|
|
318
|
+
evidence?: PoCEvidence;
|
|
319
|
+
evidenceSha256?: string;
|
|
320
|
+
evidenceBytes?: Buffer;
|
|
321
|
+
evidenceError?: string;
|
|
322
|
+
} {
|
|
315
323
|
const p = join(evidenceDir, "evidence.json");
|
|
316
324
|
if (!existsSync(p)) {
|
|
317
325
|
return {
|
|
318
326
|
evidenceError: `evidence.json missing in ${evidenceDir} — the PoC must write it to $PI_POC_EVIDENCE_DIR ({"nonce", "claim", "verify", "observations"})`,
|
|
319
327
|
};
|
|
320
328
|
}
|
|
329
|
+
const file = lstatSync(p);
|
|
330
|
+
if (file.isSymbolicLink() || !file.isFile()) {
|
|
331
|
+
return { evidenceError: "evidence.json must be a regular, non-symlink file" };
|
|
332
|
+
}
|
|
333
|
+
if (file.size > EVIDENCE_MAX_BYTES) {
|
|
334
|
+
return {
|
|
335
|
+
evidenceError: `evidence.json too large (${file.size} bytes; max ${EVIDENCE_MAX_BYTES})`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
let bytes: Buffer;
|
|
321
339
|
let raw: unknown;
|
|
322
340
|
try {
|
|
323
|
-
|
|
341
|
+
bytes = readFileSync(p);
|
|
342
|
+
if (bytes.byteLength > EVIDENCE_MAX_BYTES) {
|
|
343
|
+
return {
|
|
344
|
+
evidenceError: `evidence.json too large (${bytes.byteLength} bytes; max ${EVIDENCE_MAX_BYTES})`,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
raw = JSON.parse(bytes.toString("utf8"));
|
|
324
348
|
} catch (e) {
|
|
325
349
|
return { evidenceError: `evidence.json unparseable: ${(e as Error).message}` };
|
|
326
350
|
}
|
|
@@ -334,7 +358,8 @@ function readEvidence(
|
|
|
334
358
|
}
|
|
335
359
|
return {
|
|
336
360
|
evidence: parsed.evidence,
|
|
337
|
-
evidenceSha256: createHash("sha256").update(
|
|
361
|
+
evidenceSha256: createHash("sha256").update(bytes).digest("hex"),
|
|
362
|
+
evidenceBytes: bytes,
|
|
338
363
|
};
|
|
339
364
|
}
|
|
340
365
|
|
|
@@ -349,21 +374,47 @@ function runProvenance(env?: Record<string, string>): Pick<PocRun, "mode" | "tar
|
|
|
349
374
|
* "artifact-backed" evidence item hashes a file that no longer exists.
|
|
350
375
|
* Returns the preserved path, or undefined when the file is missing.
|
|
351
376
|
*/
|
|
352
|
-
function preserveEvidence(
|
|
353
|
-
const source = join(evidenceDir, "evidence.json");
|
|
354
|
-
if (!existsSync(source)) return undefined;
|
|
377
|
+
function preserveEvidence(bytes: Buffer, nonce: string): string | undefined {
|
|
355
378
|
try {
|
|
356
|
-
const
|
|
357
|
-
|
|
379
|
+
const projectRoot = getProjectRoot();
|
|
380
|
+
const durableDir = ensureSafeStateDirectory(projectRoot, [".pi", "poc-evidence"]);
|
|
358
381
|
const dest = join(durableDir, `${nonce}.evidence.json`);
|
|
359
|
-
|
|
382
|
+
writeSafeFileExclusive(dest, bytes);
|
|
360
383
|
return dest;
|
|
361
384
|
} catch {
|
|
362
|
-
//
|
|
385
|
+
// Preservation is part of the gate: readAndPreserveEvidence surfaces this
|
|
386
|
+
// as evidenceError, so confirmation cannot depend on an ephemeral file.
|
|
363
387
|
return undefined;
|
|
364
388
|
}
|
|
365
389
|
}
|
|
366
390
|
|
|
391
|
+
function readAndPreserveEvidence(
|
|
392
|
+
evidenceDir: string,
|
|
393
|
+
nonce: string,
|
|
394
|
+
): {
|
|
395
|
+
evidence?: PoCEvidence;
|
|
396
|
+
evidenceSha256?: string;
|
|
397
|
+
evidencePath?: string;
|
|
398
|
+
evidenceError?: string;
|
|
399
|
+
} {
|
|
400
|
+
const read = readEvidence(evidenceDir, nonce);
|
|
401
|
+
if (!read.evidence || !read.evidenceSha256 || !read.evidenceBytes) {
|
|
402
|
+
return { evidenceError: read.evidenceError ?? "evidence.json validation failed" };
|
|
403
|
+
}
|
|
404
|
+
const evidencePath = preserveEvidence(read.evidenceBytes, nonce);
|
|
405
|
+
if (!evidencePath) {
|
|
406
|
+
return {
|
|
407
|
+
evidenceError:
|
|
408
|
+
"evidence.json was valid but could not be preserved in the durable evidence store",
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
evidence: read.evidence,
|
|
413
|
+
evidenceSha256: read.evidenceSha256,
|
|
414
|
+
evidencePath,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
367
418
|
function outputWasComplete(result: { error?: Error; signal: string | null }): boolean {
|
|
368
419
|
return !result.error && result.signal === null;
|
|
369
420
|
}
|
|
@@ -496,7 +547,7 @@ function runSandboxed(
|
|
|
496
547
|
const workspaceDir = mkdtempSync(resolve(tmpdir(), "poc-runner-"));
|
|
497
548
|
// Named container so a timed-out / killed client can still be cleaned up —
|
|
498
549
|
// `--rm` alone leaks the container when the CLI dies before the child exits.
|
|
499
|
-
const containerName = `poc-runner-${process.pid}-${
|
|
550
|
+
const containerName = `poc-runner-${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
500
551
|
// Evidence contract: harness-owned dir + per-run nonce. The PoC writes
|
|
501
552
|
// evidence.json into $PI_POC_EVIDENCE_DIR (/workspace/evidence inside the
|
|
502
553
|
// container); the nonce binds the file to this run.
|
|
@@ -554,8 +605,7 @@ function runSandboxed(
|
|
|
554
605
|
const raw = (result.stdout ?? "") + (result.stderr ?? "") + spawnErr;
|
|
555
606
|
const completed = raw.includes(sentinel);
|
|
556
607
|
const { rawOutput, output, truncated } = splitOutput(sanitizeOutput(raw.replace(sentinel, "")));
|
|
557
|
-
const
|
|
558
|
-
const evidence = readEvidence(evidenceDir, nonce);
|
|
608
|
+
const evidence = readAndPreserveEvidence(evidenceDir, nonce);
|
|
559
609
|
return {
|
|
560
610
|
path: pocPath,
|
|
561
611
|
exitCode: spawnExitCode(result),
|
|
@@ -568,7 +618,6 @@ function runSandboxed(
|
|
|
568
618
|
outputComplete: outputWasComplete(result),
|
|
569
619
|
nonce,
|
|
570
620
|
...evidence,
|
|
571
|
-
evidencePath: evidence.evidence ? preserved : undefined,
|
|
572
621
|
...runProvenance(env),
|
|
573
622
|
};
|
|
574
623
|
} finally {
|
|
@@ -628,8 +677,7 @@ function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, s
|
|
|
628
677
|
const { rawOutput, output, truncated } = splitOutput(
|
|
629
678
|
sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr),
|
|
630
679
|
);
|
|
631
|
-
const
|
|
632
|
-
const evidence = readEvidence(evidenceDir, nonce);
|
|
680
|
+
const evidence = readAndPreserveEvidence(evidenceDir, nonce);
|
|
633
681
|
return {
|
|
634
682
|
path: pocPath,
|
|
635
683
|
exitCode: spawnExitCode(result),
|
|
@@ -642,7 +690,6 @@ function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, s
|
|
|
642
690
|
outputComplete: outputWasComplete(result),
|
|
643
691
|
nonce,
|
|
644
692
|
...evidence,
|
|
645
|
-
evidencePath: evidence.evidence ? preserved : undefined,
|
|
646
693
|
...runProvenance(env),
|
|
647
694
|
};
|
|
648
695
|
} finally {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
13
|
+
|
|
14
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
15
|
+
const rel = relative(root, candidate);
|
|
16
|
+
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function validateComponent(component: string): void {
|
|
20
|
+
if (!component || component === "." || component === ".." || basename(component) !== component) {
|
|
21
|
+
throw new Error(`Unsafe state path component: ${component}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function verifyDirectory(path: string, canonicalRoot: string): void {
|
|
26
|
+
const stat = lstatSync(path);
|
|
27
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
28
|
+
throw new Error(`State directory must be a real directory, not a symlink: ${path}`);
|
|
29
|
+
}
|
|
30
|
+
const canonical = realpathSync(path);
|
|
31
|
+
if (!isWithin(canonicalRoot, canonical)) {
|
|
32
|
+
throw new Error(`State directory resolves outside its workspace: ${path}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create and verify a state-directory chain beneath a trusted workspace root.
|
|
38
|
+
* Every child component is checked with lstat so a pre-planted symlink cannot
|
|
39
|
+
* redirect ledger, evidence, or scratchpad writes outside the workspace.
|
|
40
|
+
*/
|
|
41
|
+
export function ensureSafeStateDirectory(root: string, components: string[]): string {
|
|
42
|
+
const canonicalRoot = realpathSync(root);
|
|
43
|
+
let current = resolve(root);
|
|
44
|
+
for (const component of components) {
|
|
45
|
+
validateComponent(component);
|
|
46
|
+
current = join(current, component);
|
|
47
|
+
if (!existsSync(current)) {
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(current, { mode: 0o700 });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// A concurrent creator is acceptable only if the postcondition below
|
|
52
|
+
// proves it created a real in-workspace directory.
|
|
53
|
+
if (!existsSync(current)) throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
verifyDirectory(current, canonicalRoot);
|
|
57
|
+
}
|
|
58
|
+
return current;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Verify an existing state-directory chain without creating anything. */
|
|
62
|
+
export function assertSafeStateDirectory(root: string, components: string[]): string {
|
|
63
|
+
const canonicalRoot = realpathSync(root);
|
|
64
|
+
let current = resolve(root);
|
|
65
|
+
for (const component of components) {
|
|
66
|
+
validateComponent(component);
|
|
67
|
+
current = join(current, component);
|
|
68
|
+
if (!existsSync(current)) throw new Error(`State directory does not exist: ${current}`);
|
|
69
|
+
verifyDirectory(current, canonicalRoot);
|
|
70
|
+
}
|
|
71
|
+
return current;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Return false for a missing file; reject symlinks and special files. */
|
|
75
|
+
export function assertSafeRegularFile(path: string, label = "State file"): boolean {
|
|
76
|
+
if (!existsSync(path)) return false;
|
|
77
|
+
const stat = lstatSync(path);
|
|
78
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
79
|
+
throw new Error(`${label} must be a regular, non-symlink file: ${path}`);
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function readSafeFile(path: string, label = "State file"): Buffer {
|
|
85
|
+
if (!assertSafeRegularFile(path, label)) throw new Error(`${label} does not exist: ${path}`);
|
|
86
|
+
return readFileSync(path);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Write through an exclusive temporary file and atomically rename it into
|
|
91
|
+
* place. An existing symlink is rejected, and rename replaces the directory
|
|
92
|
+
* entry rather than following a destination changed after the check.
|
|
93
|
+
*/
|
|
94
|
+
export function writeSafeFileAtomic(path: string, data: string | Buffer): void {
|
|
95
|
+
assertSafeRegularFile(path);
|
|
96
|
+
const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(tmp, data, { flag: "wx", mode: 0o600 });
|
|
99
|
+
renameSync(tmp, path);
|
|
100
|
+
} finally {
|
|
101
|
+
if (existsSync(tmp)) rmSync(tmp, { force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Create a new immutable state file; collisions and symlinks fail closed. */
|
|
106
|
+
export function writeSafeFileExclusive(path: string, data: string | Buffer): void {
|
|
107
|
+
writeFileSync(path, data, { flag: "wx", mode: 0o600 });
|
|
108
|
+
}
|