@xaccefy/pi-casefile 0.8.1 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +3 -5
- package/src/index.ts +206 -77
- package/src/ledger.ts +565 -243
- package/src/pipeline-submit.ts +212 -14
- package/src/poc-runner.ts +210 -24
- package/src/scratchpad.ts +78 -12
- package/src/workflow.ts +14 -12
package/README.md
CHANGED
|
@@ -62,11 +62,11 @@ hypothesis → investigating → confirmed → reported
|
|
|
62
62
|
| `CaseAdd` | Open a case (`title` + `disproveIf` required; start as `hypothesis` or `investigating`) |
|
|
63
63
|
| `CaseUpdate` | Evidence, impact, severity, status (not direct confirm) |
|
|
64
64
|
| `EvidenceAdd` | Role-typed, hashed evidence item on a case (refutation justifies kills; cleanup tracks cleanup) |
|
|
65
|
-
| `PromoteFinding` | Run on-disk PoC (Docker sandbox by default; `local:true`
|
|
65
|
+
| `PromoteFinding` | Run on-disk PoC (Docker sandbox by default; `local:true` = host-network sandbox, host execution operator-gated via `PI_POC_ALLOW_LOCAL=1`) → confirm on exit 0 + marker; `control_path` + `control_liveness_marker` REQUIRED for every promotion |
|
|
66
66
|
| `CaseGet` / `CaseList` / `CaseSearch` | Read / filter / search |
|
|
67
67
|
| `CaseLink` / `CaseUnlink` | Bidirectional exploit chains |
|
|
68
68
|
| `ChainSuggest` | Scan cases for exploitable chain combinations (credential+endpoint→ATO, redirect+OAuth→token theft, XSS+state-change→CSRF, IDOR+user-data, SSTI→RCE, race+payment, info-disclosure+SSRF), ranked by confidence |
|
|
69
|
-
| `CoverageAdd` | Record a tested (asset × attack-class) cell — `scope: wide` (deployment-wide verdict, applies to every later asset) or `local`; both found and clean results count |
|
|
69
|
+
| `CoverageAdd` | Record a tested (asset × attack-class) cell — `scope: wide` (deployment-wide verdict, applies to every later asset) or `local`; both found and clean results count. Optionally link the cell to an artifact-backed evidence item (`evidence_item_id`); unbacked cells render as ⚠ unbacked in the report |
|
|
70
70
|
| `CoverageReport` | Render the machine-checkable coverage matrix (which classes are tested where; plateau claims must match it) |
|
|
71
71
|
| `CaseContext` | Context bundle for a confirmed/reported case (full record, logs, links, artifacts) + report path |
|
|
72
72
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xaccefy/pi-casefile",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Offensive security case tracker for Pi Agent — bug bounties, CTFs, security audits",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -51,9 +51,6 @@
|
|
|
51
51
|
"./skills"
|
|
52
52
|
]
|
|
53
53
|
},
|
|
54
|
-
"dependencies": {
|
|
55
|
-
"@sinclair/typebox": "^0.32.34"
|
|
56
|
-
},
|
|
57
54
|
"devDependencies": {
|
|
58
55
|
"@types/bun": "^1.3.14",
|
|
59
56
|
"typescript": "^6.0.3"
|
|
@@ -61,6 +58,7 @@
|
|
|
61
58
|
"peerDependencies": {
|
|
62
59
|
"@earendil-works/pi-ai": "*",
|
|
63
60
|
"@earendil-works/pi-coding-agent": "*",
|
|
64
|
-
"@earendil-works/pi-tui": "*"
|
|
61
|
+
"@earendil-works/pi-tui": "*",
|
|
62
|
+
"typebox": "*"
|
|
65
63
|
}
|
|
66
64
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* Event: before_agent_start — injects cyber workflow once per session, refreshes the active case list per prompt
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
9
10
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
11
|
import { dirname, join } from "node:path";
|
|
11
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
13
|
import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
13
|
-
import { Type } from "
|
|
14
|
+
import { Type } from "typebox";
|
|
14
15
|
|
|
15
16
|
import {
|
|
16
17
|
addCaseResult,
|
|
@@ -55,7 +56,7 @@ import {
|
|
|
55
56
|
writeCaseContext,
|
|
56
57
|
} from "./ledger.ts";
|
|
57
58
|
import { pipeline_submit, SUBMIT_STAGES, type SubmitStage } from "./pipeline-submit.ts";
|
|
58
|
-
import { type PocRun, runPoc } from "./poc-runner.ts";
|
|
59
|
+
import { type PocRun, type PocRunOptions, runPoc } from "./poc-runner.ts";
|
|
59
60
|
import {
|
|
60
61
|
PHASE_ORDER,
|
|
61
62
|
type ScratchpadPhase,
|
|
@@ -108,6 +109,11 @@ const CommonFields = {
|
|
|
108
109
|
"Falsification conditions — what would disprove this hypothesis (REQUIRED on CaseAdd)",
|
|
109
110
|
}),
|
|
110
111
|
),
|
|
112
|
+
disconfirmation: Type.Optional(
|
|
113
|
+
Type.String({
|
|
114
|
+
description: "Documented attempt to disprove the finding before confirmation",
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
111
117
|
};
|
|
112
118
|
|
|
113
119
|
// ── Tool: CaseAdd ─────────────────────────────────────────────────────
|
|
@@ -168,19 +174,30 @@ const PromoteSchema = Type.Object(
|
|
|
168
174
|
description:
|
|
169
175
|
"Unique string the PoC must print AFTER verifying the exploit worked (data extracted, callback received, payload reflected). The gate checks output contains this marker — exit code 0 alone is NOT sufficient; the marker prevents fluke exit 0 and mocked PoCs. Example: 'VULN_CONFIRMED_<case-id>'. Never print it unconditionally or before the exploit check.",
|
|
170
176
|
}),
|
|
171
|
-
disconfirmation_path: Type.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
disconfirmation_path: Type.String({
|
|
178
|
+
description:
|
|
179
|
+
"REQUIRED for EVERY promotion: absolute path to a disconfirmation script that tries to disprove the finding; it must complete and exit non-zero (finding survived disproof).",
|
|
180
|
+
}),
|
|
181
|
+
control_path: Type.String({
|
|
182
|
+
description:
|
|
183
|
+
"REQUIRED for EVERY promotion: absolute path to the SAME script as poc_path (sha256-enforced). The harness runs it in control mode against control_target and blocks if verification_marker appears.",
|
|
184
|
+
}),
|
|
185
|
+
control_target: Type.String({
|
|
186
|
+
minLength: 1,
|
|
187
|
+
description:
|
|
188
|
+
"REQUIRED for EVERY promotion: target passed to the same PoC in control mode. Must be distinct from the case target and lack the vulnerability (patched replica, second account, baseline endpoint).",
|
|
189
|
+
}),
|
|
190
|
+
control_liveness_marker: Type.String({
|
|
191
|
+
minLength: 1,
|
|
192
|
+
description:
|
|
193
|
+
"REQUIRED for EVERY promotion: a unique string the control script must print AFTER successfully reaching/exercising the control target (e.g. 'CONTROL_REACHED_<case-id>'). The harness blocks promotion if the control output lacks it — a control that never reached its target (unreachable host, wrong port, early exit) is not a clean verdict. Must differ from verification_marker.",
|
|
194
|
+
}),
|
|
195
|
+
local: Type.Optional(
|
|
196
|
+
Type.Boolean({
|
|
179
197
|
description:
|
|
180
|
-
"
|
|
198
|
+
"Run with network access (Docker sandbox with --network host — still read-only FS, dropped capabilities, unprivileged user) instead of the isolated --network none sandbox. True host execution is NOT agent-selectable: it requires the operator to set PI_POC_ALLOW_LOCAL=1, and is used only as a fallback when Docker is unavailable.",
|
|
181
199
|
}),
|
|
182
200
|
),
|
|
183
|
-
local: Type.Optional(Type.Boolean({ description: "Run locally instead of in Docker sandbox" })),
|
|
184
201
|
},
|
|
185
202
|
{ additionalProperties: false },
|
|
186
203
|
);
|
|
@@ -683,16 +700,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
683
700
|
) {
|
|
684
701
|
hint = `\n\nHint: A database access error occurred on the casefile SQLite ledger.\nTo troubleshoot:\n 1. Check filesystem read/write permissions for the database path: ${getCasefilePath()}.\n 2. If using a locked folder, you can override the ledger location by setting:\n export PI_CASEFILE_PATH=/your/writable/directory/casefile.db`;
|
|
685
702
|
}
|
|
686
|
-
|
|
687
|
-
content: [
|
|
688
|
-
{
|
|
689
|
-
type: "text" as const,
|
|
690
|
-
text: `${spec.name} failed: ${message}${hint}`,
|
|
691
|
-
},
|
|
692
|
-
],
|
|
693
|
-
isError: true,
|
|
694
|
-
details: { error: message },
|
|
695
|
-
};
|
|
703
|
+
throw new Error(`${spec.name} failed: ${message}${hint}`, { cause: err });
|
|
696
704
|
}
|
|
697
705
|
};
|
|
698
706
|
originalRegisterTool(spec);
|
|
@@ -890,6 +898,12 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
890
898
|
description:
|
|
891
899
|
"Short note of the tests ACTUALLY RUN and the verdict: techniques tried · result · key gap. A verdict guessed without testing can hide a real issue.",
|
|
892
900
|
}),
|
|
901
|
+
evidence_item_id: Type.Optional(
|
|
902
|
+
Type.String({
|
|
903
|
+
description:
|
|
904
|
+
"Evidence item id backing this tested verdict (must be an artifact-backed EvidenceAdd item on this case). Cells without a backing item render as 'unbacked' in CoverageReport — 'tested' claims must be machine-checkable, not prose-only.",
|
|
905
|
+
}),
|
|
906
|
+
),
|
|
893
907
|
},
|
|
894
908
|
{ additionalProperties: false },
|
|
895
909
|
);
|
|
@@ -914,6 +928,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
914
928
|
class: params.class as string,
|
|
915
929
|
scope: (params.scope ?? "local") as CoverageScope,
|
|
916
930
|
note: params.note as string,
|
|
931
|
+
evidenceItemId: params.evidence_item_id as string | undefined,
|
|
917
932
|
});
|
|
918
933
|
const record = getCaseById(params.case_id as string)!;
|
|
919
934
|
return {
|
|
@@ -979,7 +994,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
979
994
|
lines.push(`\n## ${asset}`);
|
|
980
995
|
for (const cell of summary.byAsset[asset] ?? []) {
|
|
981
996
|
lines.push(
|
|
982
|
-
`- [${cell.scope}] ${cell.class} — ${cell.note}${cell.testedBy ? ` (by ${cell.testedBy})` : ""}
|
|
997
|
+
`- [${cell.scope}] ${cell.class} — ${cell.note}${cell.testedBy ? ` (by ${cell.testedBy})` : ""}` +
|
|
998
|
+
(cell.evidenceItemId
|
|
999
|
+
? ""
|
|
1000
|
+
: " ⚠ unbacked (link an artifact-backed evidence item via CoverageAdd evidence_item_id)"),
|
|
983
1001
|
);
|
|
984
1002
|
}
|
|
985
1003
|
}
|
|
@@ -1009,15 +1027,16 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1009
1027
|
name: "PromoteFinding",
|
|
1010
1028
|
label: "Promote Finding",
|
|
1011
1029
|
description:
|
|
1012
|
-
"Run an on-disk PoC script (Docker sandbox or
|
|
1030
|
+
"Run an on-disk PoC script (Docker sandbox or host-network sandbox) and, on exit 0 + verification marker present in output, promote an investigating case to confirmed. The verification_marker proves the exploit worked — exit code 0 alone is NOT sufficient. EVERY promotion REQUIRES disconfirmation_path, same-script control_path, distinct control_target, and control_liveness_marker. The harness blocks promotion if output capture is incomplete, if the control prints the vuln marker, if liveness is absent, or if control/disconfirmation crash. Host execution is never agent-selectable — local:true uses a host-network Docker sandbox; true host runs need PI_POC_ALLOW_LOCAL=1.",
|
|
1013
1031
|
promptSnippet: "Run a PoC and promote an investigating case to confirmed",
|
|
1014
1032
|
promptGuidelines: [
|
|
1015
1033
|
"Use PromoteFinding when an investigating case has a concrete PoC script on disk and you are ready to prove it.",
|
|
1016
|
-
"Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, disconfirmation, plus an EvidenceAdd 'observation' item on the case (the initial signal) — the PoC gate auto-records the reproduction item.",
|
|
1017
|
-
"Default sandbox: docker run --rm --network none. Use local:true for network-dependent bugs.",
|
|
1034
|
+
"Prerequisites: status='investigating' and non-empty poc, evidence, impact, severity, target, disconfirmation, plus an artifact-backed EvidenceAdd 'observation' item on the case (the initial signal, with artifact_path) — the PoC gate auto-records the reproduction item.",
|
|
1035
|
+
"Default sandbox: docker run --rm --network none. Use local:true for network-dependent bugs (host-network sandbox; host execution needs operator PI_POC_ALLOW_LOCAL=1).",
|
|
1018
1036
|
"Gate: exit 0 AND verification_marker in the PoC output. The marker (e.g. 'VULN_CONFIRMED_<case-id>') must be printed only AFTER the exploit is verified (data extracted, callback received, payload reflected) — never unconditionally or before the exploit check. The marker check prevents fluke exit 0 (script crashed early) and mocked PoCs (target faked) from passing.",
|
|
1019
|
-
"control_path (REQUIRED
|
|
1020
|
-
"
|
|
1037
|
+
"control_path (REQUIRED): the SAME script as poc_path (sha256-equality is ENFORCED). The harness runs it with PI_POC_MODE=control and PI_POC_TARGET=control_target. The control must not print the verification marker and must print the liveness marker.",
|
|
1038
|
+
"control_target + control_liveness_marker (REQUIRED): control_target is the distinct baseline target. The liveness marker is printed only AFTER reaching/exercising it; absent liveness blocks promotion.",
|
|
1039
|
+
"disconfirmation_path: a script that tries to disprove the finding; if it exits 0, promotion is blocked. REQUIRED for EVERY promotion — the prose disconfirmation field is not enough at any severity (a case filed low/medium must not skip the run and be re-raised afterwards).",
|
|
1021
1040
|
"Never CaseUpdate status='confirmed' directly — it is rejected. Always use PromoteFinding.",
|
|
1022
1041
|
],
|
|
1023
1042
|
parameters: PromoteSchema,
|
|
@@ -1025,17 +1044,16 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1025
1044
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
1026
1045
|
// Validate promotability BEFORE running the PoC — a sandboxed run can take
|
|
1027
1046
|
// 30s (plus first-time image pull), so fail cheap when the case can't
|
|
1028
|
-
// advance anyway (missing, wrong status, missing required fields
|
|
1047
|
+
// advance anyway (missing, wrong status, missing required fields, missing
|
|
1048
|
+
// artifact-backed observation evidence).
|
|
1029
1049
|
const caseId = params.id as string;
|
|
1030
|
-
assertPromotable(caseId);
|
|
1050
|
+
const current = assertPromotable(caseId);
|
|
1031
1051
|
|
|
1032
1052
|
// Shared blocked-promotion shape: the case stays investigating and the
|
|
1033
1053
|
// caller gets the record back for context.
|
|
1034
|
-
const fail = (text: string,
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
details: { record: getCaseById(caseId), ...extra },
|
|
1038
|
-
});
|
|
1054
|
+
const fail = (text: string, _extra?: Record<string, unknown>): never => {
|
|
1055
|
+
throw new Error(text);
|
|
1056
|
+
};
|
|
1039
1057
|
|
|
1040
1058
|
// Reject empty/whitespace markers BEFORE any PoC run — it's a param
|
|
1041
1059
|
// error, so fail cheap instead of burning a (up to 30s) sandboxed run.
|
|
@@ -1048,20 +1066,113 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1048
1066
|
);
|
|
1049
1067
|
}
|
|
1050
1068
|
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
// sandboxed
|
|
1054
|
-
|
|
1069
|
+
// Control-target anti-cheat is mandatory for EVERY promotion — sandboxed
|
|
1070
|
+
// and live alike. It is the only deterministic check that the marker is
|
|
1071
|
+
// target-dependent; skipping it for the default sandboxed mode would let
|
|
1072
|
+
// an unconditional-marker PoC pass untouched. Check BEFORE paying for the
|
|
1073
|
+
// PoC run.
|
|
1074
|
+
const controlPath = (params.control_path as string | undefined)?.trim();
|
|
1075
|
+
if (!controlPath) {
|
|
1055
1076
|
return fail(
|
|
1056
|
-
"
|
|
1057
|
-
"against a control lacking the vuln
|
|
1058
|
-
"absent from the control run's output — that is what
|
|
1059
|
-
"Write the control script and retry.",
|
|
1077
|
+
"control_path is REQUIRED for every promotion (sandboxed and live alike): a script that runs " +
|
|
1078
|
+
"the SAME PoC against a control lacking the vuln (patched replica, second account, baseline endpoint). " +
|
|
1079
|
+
"The harness verifies the verification_marker is absent from the control run's output — that is what " +
|
|
1080
|
+
"proves the marker is target-dependent. Write the control script and retry.",
|
|
1060
1081
|
{ missingControl: true },
|
|
1061
1082
|
);
|
|
1062
1083
|
}
|
|
1063
1084
|
|
|
1064
|
-
const
|
|
1085
|
+
const controlTarget = (params.control_target as string | undefined)?.trim();
|
|
1086
|
+
if (!controlTarget) {
|
|
1087
|
+
return fail(
|
|
1088
|
+
"control_target is REQUIRED for every promotion: a distinct baseline target that lacks the vulnerability.",
|
|
1089
|
+
{ missingControlTarget: true },
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
if (controlTarget === current.target) {
|
|
1093
|
+
return fail(
|
|
1094
|
+
"control_target must differ from the case target; a control run against the vulnerable target proves nothing.",
|
|
1095
|
+
{ controlTargetEqualsCaseTarget: true },
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// The control must ALSO prove it reached its target: without a liveness
|
|
1100
|
+
// marker, a control pointed at an unreachable host / wrong port / early
|
|
1101
|
+
// exit would show "marker absent" for reasons unrelated to the vuln.
|
|
1102
|
+
const livenessMarker = (params.control_liveness_marker as string | undefined)?.trim();
|
|
1103
|
+
if (!livenessMarker) {
|
|
1104
|
+
return fail(
|
|
1105
|
+
"control_liveness_marker is REQUIRED for every promotion: a unique string the control script " +
|
|
1106
|
+
"prints ONLY AFTER reaching/exercising the control target (e.g. 'CONTROL_REACHED_<case-id>'). " +
|
|
1107
|
+
"The harness blocks promotion if the control output lacks it — a control that never reached its " +
|
|
1108
|
+
"target proves nothing. Must differ from verification_marker.",
|
|
1109
|
+
{ missingLivenessMarker: true },
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
if (livenessMarker === marker) {
|
|
1113
|
+
return fail(
|
|
1114
|
+
"control_liveness_marker must differ from verification_marker — the liveness marker proves the " +
|
|
1115
|
+
"control reached its target, the verification marker proves the vuln fired. Use distinct strings.",
|
|
1116
|
+
{ livenessEqualsMarker: true },
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// EXECUTED disconfirmation is required for EVERY promotion — the prose
|
|
1121
|
+
// `disconfirmation` field cannot carry the disprove-attempt. Making it
|
|
1122
|
+
// unconditional (not severity-keyed) also kills the ordering attack:
|
|
1123
|
+
// a case filed as low/medium could previously skip the run, promote,
|
|
1124
|
+
// then be re-raised to high/critical with no executed disproof.
|
|
1125
|
+
const disconfirmationPath = (params.disconfirmation_path as string | undefined)?.trim();
|
|
1126
|
+
if (!disconfirmationPath) {
|
|
1127
|
+
return fail(
|
|
1128
|
+
"disconfirmation_path is REQUIRED for every promotion: a script that tries to " +
|
|
1129
|
+
"disprove the finding and must exit non-zero (the finding survived the attempt). " +
|
|
1130
|
+
"The prose disconfirmation field is not enough. Write the disconfirmation script and retry.",
|
|
1131
|
+
{ missingDisconfirmation: true },
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Same-file contract (anti-cheat): the control must be the SAME script as
|
|
1136
|
+
// the PoC, differing only via the harness-set PI_POC_MODE env var. Two
|
|
1137
|
+
// agent-written files (poc prints the marker unconditionally, control
|
|
1138
|
+
// prints the liveness marker) pass every string check — the "same script"
|
|
1139
|
+
// recommendation in the docs is now enforced: sha256(control) must equal
|
|
1140
|
+
// sha256(poc). Hash BEFORE any run so a mismatch fails cheap.
|
|
1141
|
+
const pocPath = (params.poc_path as string | undefined)?.trim() ?? "";
|
|
1142
|
+
let pocHash: string | undefined;
|
|
1143
|
+
let controlHash: string | undefined;
|
|
1144
|
+
try {
|
|
1145
|
+
pocHash = createHash("sha256").update(readFileSync(pocPath)).digest("hex");
|
|
1146
|
+
controlHash = createHash("sha256").update(readFileSync(controlPath)).digest("hex");
|
|
1147
|
+
} catch (e) {
|
|
1148
|
+
return fail(
|
|
1149
|
+
`Cannot read PoC/control scripts for the same-file check: ${(e as Error).message}`,
|
|
1150
|
+
{ sameFileCheckFailed: true },
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
if (pocHash !== controlHash) {
|
|
1154
|
+
return fail(
|
|
1155
|
+
"CONTROL CHECK FAILED: control_path must be the SAME script as poc_path " +
|
|
1156
|
+
"(sha256 mismatch). The control run is only meaningful as the same PoC " +
|
|
1157
|
+
"pointed at a distinct control_target via PI_POC_MODE=control and PI_POC_TARGET. " +
|
|
1158
|
+
"A separately written control file proves nothing. Case remains investigating.",
|
|
1159
|
+
{ run: undefined, controlHashMismatch: true },
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// local:true now means "network access": with the operator's
|
|
1164
|
+
// PI_POC_ALLOW_LOCAL=1 opt-in the run executes on the host; WITHOUT the
|
|
1165
|
+
// opt-in it uses a Docker sandbox with --network host (same FS/cap/user
|
|
1166
|
+
// isolation) and fails closed when Docker is unavailable too. Host
|
|
1167
|
+
// execution is never agent-selectable on its own.
|
|
1168
|
+
const runOptions = (pocMode: string, target: string): PocRunOptions => ({
|
|
1169
|
+
network: params.local === true ? "host" : "none",
|
|
1170
|
+
local: params.local === true,
|
|
1171
|
+
env: { PI_POC_MODE: pocMode, PI_POC_TARGET: target },
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
const caseTarget = current.target ?? "";
|
|
1175
|
+
const run = runPoc(pocPath, runOptions("poc", caseTarget));
|
|
1065
1176
|
|
|
1066
1177
|
// Fail closed without throwing: non-zero PoC must leave the case investigating.
|
|
1067
1178
|
if (run.exitCode !== 0) {
|
|
@@ -1073,19 +1184,20 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1073
1184
|
|
|
1074
1185
|
// Defense-in-depth: exit 0 implies the run completed (sandbox wrapper /
|
|
1075
1186
|
// local spawn semantics), but never trust a run the runner says crashed.
|
|
1076
|
-
if (!run.completed) {
|
|
1187
|
+
if (!run.completed || !run.outputComplete) {
|
|
1077
1188
|
return fail(
|
|
1078
|
-
`PoC did NOT complete
|
|
1189
|
+
`PoC did NOT complete or output capture was incomplete. Case remains investigating.\nOutput:\n${run.output}`,
|
|
1079
1190
|
{ run, pocCrashed: true },
|
|
1080
1191
|
);
|
|
1081
1192
|
}
|
|
1082
1193
|
|
|
1083
1194
|
// Verification marker check: exit code 0 alone is NOT sufficient.
|
|
1084
|
-
// The PoC must print the verification_marker
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
|
|
1195
|
+
// The PoC must print the verification_marker, proving the exploit
|
|
1196
|
+
// actually worked — not just that the script ran. The check runs on
|
|
1197
|
+
// rawOutput (untruncated) so a script printing its marker past the
|
|
1198
|
+
// 4000-char display window cannot hide it.
|
|
1199
|
+
const pocOut = run.rawOutput ?? run.output;
|
|
1200
|
+
if (!pocOut.includes(marker)) {
|
|
1089
1201
|
return fail(
|
|
1090
1202
|
`PoC exited 0 but the verification marker "${marker}" was NOT found in the output.\n` +
|
|
1091
1203
|
`This means the script ran but did not prove exploitation. The marker must be printed only AFTER the PoC verifies the exploit worked (data extracted, callback received, payload reflected, etc.).\n` +
|
|
@@ -1094,11 +1206,11 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1094
1206
|
);
|
|
1095
1207
|
}
|
|
1096
1208
|
|
|
1097
|
-
// Run disconfirmation script
|
|
1209
|
+
// Run disconfirmation script — must exit NON-0 (finding survived the attempt to disprove).
|
|
1098
1210
|
let disconfirmationRun: PocRun | undefined;
|
|
1099
|
-
if (
|
|
1100
|
-
disconfirmationRun = runPoc(
|
|
1101
|
-
if (!disconfirmationRun.completed) {
|
|
1211
|
+
if (disconfirmationPath) {
|
|
1212
|
+
disconfirmationRun = runPoc(disconfirmationPath, runOptions("disconfirmation", caseTarget));
|
|
1213
|
+
if (!disconfirmationRun.completed || !disconfirmationRun.outputComplete) {
|
|
1102
1214
|
return fail(
|
|
1103
1215
|
`Disconfirmation script did NOT complete (spawn error, killed, or timeout — no completion marker). ` +
|
|
1104
1216
|
`A crash is not a survived disproof: fix the disconfirmation script and retry.\n` +
|
|
@@ -1118,30 +1230,47 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1118
1230
|
// Control-target anti-cheat check: the same PoC pointed at a control that
|
|
1119
1231
|
// lacks the vuln must NOT print the marker. The control script is
|
|
1120
1232
|
// agent-written, but the marker-absence check is harness-side and
|
|
1121
|
-
// deterministic — the model cannot pass it by asserting success.
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1233
|
+
// deterministic — the model cannot pass it by asserting success. The
|
|
1234
|
+
// liveness-marker check closes the "control pointed at an unreachable
|
|
1235
|
+
// host / exited early" hole: the control must prove it reached its target.
|
|
1236
|
+
const controlRun = runPoc(controlPath, runOptions("control", controlTarget));
|
|
1237
|
+
if (!controlRun.completed || !controlRun.outputComplete) {
|
|
1238
|
+
return fail(
|
|
1239
|
+
`CONTROL CHECK FAILED: the control-target script did NOT complete (spawn error, killed, or timeout). ` +
|
|
1240
|
+
`A control run that never executed proves nothing about the marker — fix the control script and retry.\n` +
|
|
1241
|
+
`Control output:\n${controlRun.output}`,
|
|
1242
|
+
{ run, controlRun, controlCrashed: true },
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
// Marker-absence + liveness checks run on the UNTRUNCATED control output.
|
|
1246
|
+
const controlOut = controlRun.rawOutput ?? controlRun.output;
|
|
1247
|
+
if (controlOut.includes(marker)) {
|
|
1248
|
+
return fail(
|
|
1249
|
+
`CONTROL CHECK FAILED: the verification marker "${marker}" appeared in the control-target run. ` +
|
|
1250
|
+
`The PoC prints the marker without the vulnerable condition — a cheating PoC (unconditional marker) ` +
|
|
1251
|
+
`or a broken check. Case remains investigating.\nControl output:\n${controlRun.output}`,
|
|
1252
|
+
{ run, controlRun, controlCheated: true },
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
if (!controlOut.includes(livenessMarker)) {
|
|
1256
|
+
return fail(
|
|
1257
|
+
`CONTROL CHECK FAILED: the control-target run completed but the control_liveness_marker "${livenessMarker}" ` +
|
|
1258
|
+
`was NOT found in its output. The control must print the liveness marker only AFTER reaching/exercising ` +
|
|
1259
|
+
`the control target — an unreachable host, wrong port, or early exit is not a valid control verdict. ` +
|
|
1260
|
+
`Case remains investigating.\nControl output:\n${controlRun.output}`,
|
|
1261
|
+
{ run, controlRun, controlLivenessMissing: true },
|
|
1262
|
+
);
|
|
1141
1263
|
}
|
|
1142
1264
|
|
|
1143
1265
|
// PocRun is structurally a PocVerification — pass the runs straight through.
|
|
1144
|
-
const result = promoteFindingResult(
|
|
1266
|
+
const result = promoteFindingResult(
|
|
1267
|
+
caseId,
|
|
1268
|
+
run,
|
|
1269
|
+
disconfirmationRun,
|
|
1270
|
+
controlRun,
|
|
1271
|
+
marker,
|
|
1272
|
+
livenessMarker,
|
|
1273
|
+
);
|
|
1145
1274
|
const record = result.record;
|
|
1146
1275
|
return {
|
|
1147
1276
|
content: [
|
|
@@ -1541,9 +1670,9 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1541
1670
|
: result.verdict === "repair"
|
|
1542
1671
|
? `REPAIR (attempt ${result.repair_attempt}/2) — fix these and re-submit:\n - ${result.errors.join("\n - ")}`
|
|
1543
1672
|
: `REJECTED — ${result.errors.join("\n")}`;
|
|
1673
|
+
if (result.verdict !== "accepted") throw new Error(statusLine);
|
|
1544
1674
|
return {
|
|
1545
1675
|
content: [{ type: "text", text: statusLine }],
|
|
1546
|
-
isError: result.verdict !== "accepted",
|
|
1547
1676
|
details: result as unknown as Record<string, unknown>,
|
|
1548
1677
|
};
|
|
1549
1678
|
},
|