@xaccefy/pi-casefile 0.8.3 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/package.json +13 -4
- package/skills/casefile/SKILL.md +4 -3
- package/src/evidence.ts +501 -0
- package/src/harness-verify.ts +693 -0
- package/src/index.ts +391 -224
- package/src/ledger-worker-entry.ts +35 -0
- package/src/ledger-worker.ts +77 -0
- package/src/ledger.ts +853 -233
- package/src/pipeline-submit.ts +153 -43
- package/src/poc-runner.ts +216 -65
- package/src/safe-state.ts +108 -0
- package/src/scratchpad.ts +67 -28
- package/src/workflow.ts +91 -23
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;
|
|
@@ -86,6 +95,18 @@ const VULN_CLASSES = [
|
|
|
86
95
|
export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
87
96
|
// schemas/stage-finding.json
|
|
88
97
|
hunt: {
|
|
98
|
+
allowed: [
|
|
99
|
+
"vuln_class",
|
|
100
|
+
"file",
|
|
101
|
+
"line",
|
|
102
|
+
"endpoint",
|
|
103
|
+
"sink",
|
|
104
|
+
"entry_point",
|
|
105
|
+
"confidence",
|
|
106
|
+
"evidence",
|
|
107
|
+
"attacker_model",
|
|
108
|
+
"subsystem",
|
|
109
|
+
],
|
|
89
110
|
required: [
|
|
90
111
|
{ name: "vuln_class", type: "string", enum: VULN_CLASSES },
|
|
91
112
|
{ name: "sink", type: "string" },
|
|
@@ -98,6 +119,15 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
|
98
119
|
},
|
|
99
120
|
// schemas/stage-trace.json
|
|
100
121
|
trace: {
|
|
122
|
+
allowed: [
|
|
123
|
+
"trace_result",
|
|
124
|
+
"entry_point",
|
|
125
|
+
"call_chain",
|
|
126
|
+
"defenses_checked",
|
|
127
|
+
"attacker_model",
|
|
128
|
+
"impact_if_reachable",
|
|
129
|
+
"unreachable_reason",
|
|
130
|
+
],
|
|
101
131
|
required: [
|
|
102
132
|
{ name: "trace_result", type: "string", enum: ["REACHABLE", "UNREACHABLE"] },
|
|
103
133
|
{ name: "entry_point", type: "string" },
|
|
@@ -112,6 +142,14 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
|
112
142
|
},
|
|
113
143
|
// schemas/stage-skeptic.json
|
|
114
144
|
skeptic: {
|
|
145
|
+
allowed: [
|
|
146
|
+
"finding_id",
|
|
147
|
+
"verdict",
|
|
148
|
+
"reasoning",
|
|
149
|
+
"evidence_reviewed",
|
|
150
|
+
"disconfirmation_attempt",
|
|
151
|
+
"disproval_reason",
|
|
152
|
+
],
|
|
115
153
|
required: [
|
|
116
154
|
{ name: "finding_id", type: "string" },
|
|
117
155
|
{ name: "verdict", type: "string", enum: ["CONFIRMED", "DISPROVEN"] },
|
|
@@ -120,19 +158,41 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
|
120
158
|
],
|
|
121
159
|
conditional: [
|
|
122
160
|
{ when: { field: "verdict", equals: "DISPROVEN" }, require: ["disproval_reason"] },
|
|
161
|
+
{
|
|
162
|
+
// A CONFIRMED verdict must carry the skeptic's own failed disproof —
|
|
163
|
+
// the workflow makes it the case's disconfirmation. "Could not
|
|
164
|
+
// disprove" alone is not an attempt.
|
|
165
|
+
when: { field: "verdict", equals: "CONFIRMED" },
|
|
166
|
+
require: ["disconfirmation_attempt"],
|
|
167
|
+
},
|
|
123
168
|
],
|
|
124
169
|
},
|
|
125
170
|
// schemas/stage-validation.json
|
|
126
171
|
validate: {
|
|
172
|
+
allowed: [
|
|
173
|
+
"finding_id",
|
|
174
|
+
"status",
|
|
175
|
+
"technique_used",
|
|
176
|
+
"detection_method",
|
|
177
|
+
"poc_path",
|
|
178
|
+
"run_log",
|
|
179
|
+
"evidence_extracted",
|
|
180
|
+
"kill_reason",
|
|
181
|
+
"refinement_attempts",
|
|
182
|
+
],
|
|
127
183
|
required: [
|
|
128
184
|
{ name: "finding_id", type: "string" },
|
|
129
|
-
{
|
|
185
|
+
{
|
|
186
|
+
name: "status",
|
|
187
|
+
type: "string",
|
|
188
|
+
enum: ["pending_confirmation", "killed", "reported"],
|
|
189
|
+
},
|
|
130
190
|
{ name: "technique_used", type: "string" },
|
|
131
191
|
{ name: "detection_method", type: "string" },
|
|
132
192
|
],
|
|
133
193
|
conditional: [
|
|
134
194
|
{
|
|
135
|
-
when: { field: "status", equals: "
|
|
195
|
+
when: { field: "status", equals: "pending_confirmation" },
|
|
136
196
|
require: ["poc_path", "run_log", "evidence_extracted"],
|
|
137
197
|
},
|
|
138
198
|
{ when: { field: "status", equals: "killed" }, require: ["kill_reason"] },
|
|
@@ -140,6 +200,7 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
|
140
200
|
},
|
|
141
201
|
// schemas/stage-chain.json
|
|
142
202
|
chain: {
|
|
203
|
+
allowed: ["chains", "summary", "tokens_input", "tokens_output"],
|
|
143
204
|
required: [
|
|
144
205
|
{ name: "chains", type: "array" },
|
|
145
206
|
{ name: "summary", type: "string" },
|
|
@@ -147,6 +208,16 @@ export const SPECS: Record<SubmitStage, StageSpec> = {
|
|
|
147
208
|
},
|
|
148
209
|
// schemas/stage-report.json
|
|
149
210
|
report: {
|
|
211
|
+
allowed: [
|
|
212
|
+
"target",
|
|
213
|
+
"pipeline_status",
|
|
214
|
+
"total_tokens",
|
|
215
|
+
"findings",
|
|
216
|
+
"chains",
|
|
217
|
+
"coverage",
|
|
218
|
+
"summary",
|
|
219
|
+
"patches_applied",
|
|
220
|
+
],
|
|
150
221
|
required: [
|
|
151
222
|
{ name: "target", type: "string" },
|
|
152
223
|
{ name: "pipeline_status", type: "string", enum: ["complete", "partial", "aborted"] },
|
|
@@ -189,6 +260,7 @@ type FindingRef = {
|
|
|
189
260
|
key: string;
|
|
190
261
|
file: string;
|
|
191
262
|
line?: number;
|
|
263
|
+
endpoint?: string;
|
|
192
264
|
vuln_class: string;
|
|
193
265
|
};
|
|
194
266
|
|
|
@@ -204,7 +276,11 @@ function statePath(runId: string): string {
|
|
|
204
276
|
function readState(runId: string): SubmitState {
|
|
205
277
|
const p = statePath(runId);
|
|
206
278
|
if (!existsSync(p)) return { repairs: {}, accepted_findings: [] };
|
|
207
|
-
|
|
279
|
+
assertSafeStateDirectory(projectRoot(), [".scratchpad", basename(dirname(p))]);
|
|
280
|
+
assertSafeRegularFile(p, "Pipeline state");
|
|
281
|
+
const raw = JSON.parse(
|
|
282
|
+
readSafeFile(p, "Pipeline state").toString("utf8"),
|
|
283
|
+
) as Partial<SubmitState>;
|
|
208
284
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
209
285
|
throw new Error(`Corrupt pipeline-submit state for ${runId}: root must be an object`);
|
|
210
286
|
}
|
|
@@ -238,9 +314,8 @@ function readState(runId: string): SubmitState {
|
|
|
238
314
|
|
|
239
315
|
function writeState(runId: string, state: SubmitState): void {
|
|
240
316
|
const p = statePath(runId);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
renameSync(tmp, p);
|
|
317
|
+
assertSafeStateDirectory(projectRoot(), [".scratchpad", basename(dirname(p))]);
|
|
318
|
+
writeSafeFileAtomic(p, JSON.stringify(state, null, 2));
|
|
244
319
|
}
|
|
245
320
|
|
|
246
321
|
/** Project root containing the scratchpad (file-existence checks resolve here). */
|
|
@@ -403,6 +478,11 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
|
|
|
403
478
|
const spec = SPECS[stage];
|
|
404
479
|
const errors: string[] = [];
|
|
405
480
|
|
|
481
|
+
const allowedFields = new Set(spec.allowed);
|
|
482
|
+
for (const name of Object.keys(obj)) {
|
|
483
|
+
if (!allowedFields.has(name)) errors.push(`${name}: unknown top-level field`);
|
|
484
|
+
}
|
|
485
|
+
|
|
406
486
|
for (const field of spec.required) {
|
|
407
487
|
const v = obj[field.name];
|
|
408
488
|
if (field.type === "string") {
|
|
@@ -487,34 +567,31 @@ function validateStage(stage: SubmitStage, obj: Record<string, unknown>): string
|
|
|
487
567
|
if (stage === "skeptic") {
|
|
488
568
|
requireStringArray(errors, "evidence_reviewed", obj.evidence_reviewed, 1);
|
|
489
569
|
if (obj.verdict === "DISPROVEN" && isNonEmptyString(obj.disproval_reason)) {
|
|
490
|
-
|
|
491
|
-
"
|
|
492
|
-
|
|
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");
|
|
570
|
+
if (!(KILL_REASON_VALUES as readonly string[]).includes(obj.disproval_reason)) {
|
|
571
|
+
errors.push("disproval_reason: invalid value");
|
|
572
|
+
}
|
|
502
573
|
}
|
|
503
574
|
}
|
|
504
575
|
|
|
505
576
|
if (stage === "validate") {
|
|
506
577
|
if (obj.status === "killed" && isNonEmptyString(obj.kill_reason)) {
|
|
507
|
-
|
|
508
|
-
"
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
578
|
+
if (!(KILL_REASON_VALUES as readonly string[]).includes(obj.kill_reason)) {
|
|
579
|
+
errors.push("kill_reason: invalid value");
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (obj.status === "pending_confirmation" && isNonEmptyString(obj.poc_path)) {
|
|
583
|
+
// A phase-1 validation submission must point at a PoC file
|
|
584
|
+
// that actually exists in the project — same file-existence filter hunt
|
|
585
|
+
// findings get. Otherwise fabricated run logs pass the stage gate.
|
|
586
|
+
const raw = obj.poc_path as string;
|
|
587
|
+
const normalized = raw.replace(/^\.?\//, "");
|
|
588
|
+
const abs = isAbsolute(normalized) ? resolve(normalized) : resolve(projectRoot(), normalized);
|
|
589
|
+
const rel = relative(projectRoot(), abs);
|
|
590
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
591
|
+
errors.push("poc_path: must resolve inside the project root");
|
|
592
|
+
} else if (!existsSync(abs)) {
|
|
593
|
+
errors.push(`poc_path: "${raw}" does not exist under the project root`);
|
|
594
|
+
}
|
|
518
595
|
}
|
|
519
596
|
if (
|
|
520
597
|
obj.refinement_attempts !== undefined &&
|
|
@@ -579,6 +656,23 @@ function prefilterHunt(obj: Record<string, unknown>): string | null {
|
|
|
579
656
|
`Findings must reference files inside the target repository.`
|
|
580
657
|
);
|
|
581
658
|
}
|
|
659
|
+
// Symlink containment (same defense as the PoC runner): resolve() is
|
|
660
|
+
// lexical, and existsSync() dereferences symlinks — a workspace symlink to
|
|
661
|
+
// /etc (ln -s /etc etc-link) would otherwise pass both checks and let a
|
|
662
|
+
// "finding" point at host paths outside the project.
|
|
663
|
+
let real: string;
|
|
664
|
+
try {
|
|
665
|
+
real = realpathSync(abs);
|
|
666
|
+
} catch {
|
|
667
|
+
return `file-existence filter: "${file}" cannot be resolved under the project root (${root}).`;
|
|
668
|
+
}
|
|
669
|
+
const realRel = relative(root, real);
|
|
670
|
+
if (realRel.startsWith("..") || isAbsolute(realRel)) {
|
|
671
|
+
return (
|
|
672
|
+
`containment filter: "${file}" resolves through a symlink to outside the project root ` +
|
|
673
|
+
`(${real}). Symlinked files outside ${root} are rejected.`
|
|
674
|
+
);
|
|
675
|
+
}
|
|
582
676
|
if (!existsSync(abs)) {
|
|
583
677
|
return (
|
|
584
678
|
`file-existence filter: "${file}" does not exist under the project root ` +
|
|
@@ -590,12 +684,19 @@ function prefilterHunt(obj: Record<string, unknown>): string | null {
|
|
|
590
684
|
|
|
591
685
|
function dedupHunt(state: SubmitState, obj: Record<string, unknown>): { duplicateOf?: string } {
|
|
592
686
|
const file = typeof obj.file === "string" ? obj.file.replace(/^\.?\//, "") : undefined;
|
|
687
|
+
const endpoint = typeof obj.endpoint === "string" ? obj.endpoint.trim() : undefined;
|
|
593
688
|
const vulnClass = typeof obj.vuln_class === "string" ? obj.vuln_class : undefined;
|
|
594
689
|
const line = typeof obj.line === "number" ? obj.line : undefined;
|
|
595
|
-
if (!
|
|
690
|
+
if (!vulnClass) return {};
|
|
596
691
|
for (const accepted of state.accepted_findings) {
|
|
597
692
|
if (accepted.vuln_class !== vulnClass) continue;
|
|
598
|
-
|
|
693
|
+
// Live locator: same endpoint + class is the same finding (re-submissions
|
|
694
|
+
// after a repair must not be accepted repeatedly).
|
|
695
|
+
if (!file && endpoint !== undefined) {
|
|
696
|
+
if (accepted.endpoint === endpoint) return { duplicateOf: accepted.key };
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
if (!file || accepted.file !== file) continue;
|
|
599
700
|
if (
|
|
600
701
|
line !== undefined &&
|
|
601
702
|
accepted.line !== undefined &&
|
|
@@ -672,24 +773,33 @@ export function pipeline_submit(runId: string, stage: SubmitStage, output: unkno
|
|
|
672
773
|
duplicate_of: duplicateOf,
|
|
673
774
|
};
|
|
674
775
|
}
|
|
675
|
-
if (typeof obj.
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
776
|
+
if (typeof obj.vuln_class === "string") {
|
|
777
|
+
const isFileFinding = typeof obj.file === "string";
|
|
778
|
+
const isEndpointFinding = !isFileFinding && typeof obj.endpoint === "string";
|
|
779
|
+
if (isFileFinding || isEndpointFinding) {
|
|
780
|
+
state.accepted_findings.push({
|
|
781
|
+
key,
|
|
782
|
+
file: isFileFinding ? (obj.file as string).replace(/^\.?\//, "") : "",
|
|
783
|
+
line: typeof obj.line === "number" ? obj.line : undefined,
|
|
784
|
+
endpoint: isEndpointFinding ? (obj.endpoint as string).trim() : undefined,
|
|
785
|
+
vuln_class: obj.vuln_class,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
682
788
|
writeState(runId, state);
|
|
683
789
|
}
|
|
684
790
|
}
|
|
685
791
|
|
|
686
792
|
// Submit stages are a subset of scratchpad phases, so the stage name IS
|
|
687
|
-
// the phase directory.
|
|
793
|
+
// the phase directory. The filename gets a content hash: distinct findings
|
|
794
|
+
// sharing one plausible id (the stable repair-bucket key) must not clobber
|
|
795
|
+
// each other's accepted artifact.
|
|
796
|
+
const json = JSON.stringify(obj, null, 2);
|
|
797
|
+
const contentHash = createHash("sha1").update(json).digest("hex").slice(0, 8);
|
|
688
798
|
const artifact = scratchpad_write(
|
|
689
799
|
runId,
|
|
690
800
|
stage,
|
|
691
|
-
`${key.replace(/[^a-zA-Z0-9._:-]/g, "_")}.json`,
|
|
692
|
-
|
|
801
|
+
`${key.replace(/[^a-zA-Z0-9._:-]/g, "_")}-${contentHash}.json`,
|
|
802
|
+
json,
|
|
693
803
|
);
|
|
694
804
|
return { verdict: "accepted", stage, errors: [], key, artifact };
|
|
695
805
|
}
|