@xaccefy/pi-casefile 0.9.3 → 0.10.0
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 +31 -67
- package/package.json +14 -17
- package/src/confirmation.ts +729 -0
- package/src/evidence.ts +4 -4
- package/src/index.ts +189 -293
- package/src/ledger-internal.ts +321 -0
- package/src/ledger.ts +75 -1142
- package/src/oob-oracle.ts +279 -0
- package/src/poc-runner.ts +10 -0
- package/src/scratchpad.ts +5 -6
- package/src/workflow.ts +46 -327
- package/skills/casefile/SKILL.md +0 -44
- package/src/ledger-worker-entry.ts +0 -35
- package/src/ledger-worker.ts +0 -77
- package/src/pipeline-submit.ts +0 -797
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator-owned OOB oracle client — Tier 1 of docs/poc-trust-model.md.
|
|
3
|
+
*
|
|
4
|
+
* Blind/OOB classes (SSRF, blind XSS, XXE, DNS exfil) cannot be confirmed by
|
|
5
|
+
* response differentials: the effect lands on a callback channel. The trust
|
|
6
|
+
* model requires the judge to own the evidence channel and the secret:
|
|
7
|
+
*
|
|
8
|
+
* - The HARNESS generates a per-run random token and provisions a callback
|
|
9
|
+
* domain embedding it via the operator-run oracle service. The value does
|
|
10
|
+
* not exist when the PoC script is written, so it cannot be pre-printed.
|
|
11
|
+
* - The PoC causes the TARGET to interact with that domain; the oracle's own
|
|
12
|
+
* interaction log — read back by the harness, never by the PoC — is the
|
|
13
|
+
* evidence.
|
|
14
|
+
* - Differential shape: the target run gets one token, the control run a
|
|
15
|
+
* DIFFERENT token. Proof requires target-token interactions AND zero
|
|
16
|
+
* control-token interactions.
|
|
17
|
+
*
|
|
18
|
+
* Source separation (the PoC must not be able to fake the interaction):
|
|
19
|
+
* - Hard guarantee (three-box model): only when the operator attests the
|
|
20
|
+
* network topology separates PoC egress from oracle reachability via
|
|
21
|
+
* PI_OOB_SOURCE_SEPARATED=1. Without it, verification stays diagnostic
|
|
22
|
+
* and the ledger gate refuses promotion.
|
|
23
|
+
* - Self-interaction filtering: interactions originating from PI_OOB_SELF_IPS
|
|
24
|
+
* (the sandbox/host egress addresses) are rejected as self-caused and never
|
|
25
|
+
* counted as target hits.
|
|
26
|
+
*
|
|
27
|
+
* Everything fails closed: no oracle configured -> OOB promotion impossible.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { randomBytes } from "node:crypto";
|
|
31
|
+
|
|
32
|
+
export type OobInteraction = {
|
|
33
|
+
protocol?: string;
|
|
34
|
+
src_ip?: string;
|
|
35
|
+
ts?: string;
|
|
36
|
+
raw?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type OobOracleConfig = {
|
|
40
|
+
baseUrl: string;
|
|
41
|
+
bearer?: string;
|
|
42
|
+
sourceSeparated: boolean;
|
|
43
|
+
selfIps: string[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Read the operator's oracle configuration; error text explains what's missing. */
|
|
47
|
+
export function readOobOracleConfig(env: NodeJS.ProcessEnv = process.env): {
|
|
48
|
+
config?: OobOracleConfig;
|
|
49
|
+
error?: string;
|
|
50
|
+
} {
|
|
51
|
+
const raw = (env.PI_OOB_ORACLE_URL ?? "").trim();
|
|
52
|
+
if (!raw) {
|
|
53
|
+
return {
|
|
54
|
+
error:
|
|
55
|
+
"no OOB oracle configured. Set PI_OOB_ORACLE_URL to an operator-run oracle service " +
|
|
56
|
+
"(POST /provision {token} -> {domain}; GET /interactions?token= -> {interactions}). " +
|
|
57
|
+
"Declare PI_OOB_SOURCE_SEPARATED=1 only when the network topology truly prevents the " +
|
|
58
|
+
"PoC sandbox from reaching the oracle directly.",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
let baseUrl: URL;
|
|
62
|
+
try {
|
|
63
|
+
baseUrl = new URL(raw);
|
|
64
|
+
} catch {
|
|
65
|
+
return { error: `PI_OOB_ORACLE_URL is not a valid URL: ${raw}` };
|
|
66
|
+
}
|
|
67
|
+
if (baseUrl.protocol !== "http:" && baseUrl.protocol !== "https:") {
|
|
68
|
+
return { error: `PI_OOB_ORACLE_URL must be http(s), got ${baseUrl.protocol}` };
|
|
69
|
+
}
|
|
70
|
+
const selfIps = (env.PI_OOB_SELF_IPS ?? "")
|
|
71
|
+
.split(",")
|
|
72
|
+
.map((v) => v.trim())
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
return {
|
|
75
|
+
config: {
|
|
76
|
+
baseUrl: raw.replace(/\/+$/, ""),
|
|
77
|
+
bearer: env.PI_OOB_ORACLE_TOKEN?.trim() || undefined,
|
|
78
|
+
sourceSeparated: env.PI_OOB_SOURCE_SEPARATED === "1",
|
|
79
|
+
selfIps,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
85
|
+
|
|
86
|
+
let oracleFetchForTest: FetchLike | undefined;
|
|
87
|
+
|
|
88
|
+
/** Test seam; production uses global fetch. */
|
|
89
|
+
export function setOobOracleFetchForTest(impl: FetchLike | undefined): void {
|
|
90
|
+
oracleFetchForTest = impl;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function makeToken(): string {
|
|
94
|
+
return randomBytes(16).toString("hex");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function oracleFetch(
|
|
98
|
+
config: OobOracleConfig,
|
|
99
|
+
path: string,
|
|
100
|
+
init?: RequestInit,
|
|
101
|
+
): Promise<Response> {
|
|
102
|
+
const headers: Record<string, string> = {
|
|
103
|
+
accept: "application/json",
|
|
104
|
+
...((init?.headers as Record<string, string>) ?? {}),
|
|
105
|
+
};
|
|
106
|
+
if (config.bearer) headers.authorization = `Bearer ${config.bearer}`;
|
|
107
|
+
const fetchImpl = oracleFetchForTest ?? fetch;
|
|
108
|
+
// Bounded: an unresponsive oracle must fail the run, not hang the tool.
|
|
109
|
+
return fetchImpl(`${config.baseUrl}${path}`, {
|
|
110
|
+
...init,
|
|
111
|
+
headers,
|
|
112
|
+
signal: AbortSignal.timeout(30_000),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type ProvisionedCallback = {
|
|
117
|
+
/** Harness-generated secret embedded in the provisioned domain. */
|
|
118
|
+
token: string;
|
|
119
|
+
/** Domain the payload must make the target interact with. */
|
|
120
|
+
domain: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Provision one callback identity. The token is generated HERE (harness owns
|
|
125
|
+
* the secret); the oracle returns a domain that embeds it.
|
|
126
|
+
*/
|
|
127
|
+
export async function provisionCallback(config: OobOracleConfig): Promise<ProvisionedCallback> {
|
|
128
|
+
const token = makeToken();
|
|
129
|
+
let res: Response;
|
|
130
|
+
try {
|
|
131
|
+
res = await oracleFetch(config, "/provision", {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: { "content-type": "application/json" },
|
|
134
|
+
body: JSON.stringify({ token }),
|
|
135
|
+
});
|
|
136
|
+
} catch (e) {
|
|
137
|
+
throw new Error(`OOB oracle unreachable (${config.baseUrl}): ${(e as Error).message}`);
|
|
138
|
+
}
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
throw new Error(`OOB oracle /provision failed: HTTP ${res.status}`);
|
|
141
|
+
}
|
|
142
|
+
let body: { domain?: unknown };
|
|
143
|
+
try {
|
|
144
|
+
body = (await res.json()) as { domain?: unknown };
|
|
145
|
+
} catch {
|
|
146
|
+
throw new Error("OOB oracle /provision returned a non-JSON body");
|
|
147
|
+
}
|
|
148
|
+
if (typeof body.domain !== "string" || !body.domain.includes(token)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
"OOB oracle /provision returned a domain that does not embed the harness token — refusing an oracle that invents its own secrets",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return { token, domain: body.domain };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type OobPollResult = {
|
|
157
|
+
interactions: OobInteraction[];
|
|
158
|
+
/** Interactions NOT counted (self-IP matches) — recorded honestly. */
|
|
159
|
+
selfInteractions: OobInteraction[];
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Fetch (not wait-and-retry — callers own the polling loop) current interactions for a token. */
|
|
163
|
+
export async function fetchInteractions(
|
|
164
|
+
config: OobOracleConfig,
|
|
165
|
+
token: string,
|
|
166
|
+
): Promise<OobPollResult> {
|
|
167
|
+
let res: Response;
|
|
168
|
+
try {
|
|
169
|
+
res = await oracleFetch(config, `/interactions?token=${encodeURIComponent(token)}`);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
throw new Error(`OOB oracle unreachable (${config.baseUrl}): ${(e as Error).message}`);
|
|
172
|
+
}
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
throw new Error(`OOB oracle /interactions failed: HTTP ${res.status}`);
|
|
175
|
+
}
|
|
176
|
+
let body: { interactions?: unknown };
|
|
177
|
+
try {
|
|
178
|
+
body = (await res.json()) as { interactions?: unknown };
|
|
179
|
+
} catch {
|
|
180
|
+
throw new Error("OOB oracle /interactions returned a non-JSON body");
|
|
181
|
+
}
|
|
182
|
+
if (!Array.isArray(body.interactions)) {
|
|
183
|
+
throw new Error("OOB oracle /interactions response missing interactions array");
|
|
184
|
+
}
|
|
185
|
+
const all = body.interactions.filter(
|
|
186
|
+
(i): i is OobInteraction => typeof i === "object" && i !== null,
|
|
187
|
+
);
|
|
188
|
+
const self = config.selfIps;
|
|
189
|
+
// Fail closed on provenance: an interaction WITHOUT a source IP cannot be
|
|
190
|
+
// attributed to the target (a PoC could fabricate one on any channel it
|
|
191
|
+
// reaches), so it is never counted as a hit — surfaced separately instead.
|
|
192
|
+
const interactions = all.filter(
|
|
193
|
+
(i) => typeof i.src_ip === "string" && i.src_ip.length > 0 && !self.includes(i.src_ip),
|
|
194
|
+
);
|
|
195
|
+
const selfInteractions = all.filter(
|
|
196
|
+
(i) => !i.src_ip || (typeof i.src_ip === "string" && self.includes(i.src_ip)),
|
|
197
|
+
);
|
|
198
|
+
return { interactions, selfInteractions };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Poll for interactions on both run tokens. Once ANY token observes an
|
|
203
|
+
* interaction, polling continues for a full settle window so a DELAYED
|
|
204
|
+
* control-token hit (the false-positive shape: target fires at t=1s, a
|
|
205
|
+
* cheating/self-caused control hit lands at t=3s after an early exit)
|
|
206
|
+
* cannot be missed. Evaluation happens after settle or at deadline.
|
|
207
|
+
*/
|
|
208
|
+
export async function verifyOobDifferential(
|
|
209
|
+
opts: {
|
|
210
|
+
targetToken: string;
|
|
211
|
+
controlToken: string;
|
|
212
|
+
pollMs?: number;
|
|
213
|
+
intervalMs?: number;
|
|
214
|
+
/** Keep polling this long after the first observed interaction. */
|
|
215
|
+
settleMs?: number;
|
|
216
|
+
},
|
|
217
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
218
|
+
): Promise<{ verification: import("./ledger.ts").OobVerification }> {
|
|
219
|
+
const { config, error } = readOobOracleConfig(env);
|
|
220
|
+
if (!config) throw new Error(error ?? "OOB oracle not configured");
|
|
221
|
+
// Env overrides are operator tuning: clamp to sane minimums so a typo like
|
|
222
|
+
// PI_OOB_POLL_MS=-5 cannot produce an inverted deadline or a zero window.
|
|
223
|
+
const pollMs = opts.pollMs ?? Math.max(Number(env.PI_OOB_POLL_MS) || 30_000, 1_000);
|
|
224
|
+
const intervalMs = opts.intervalMs ?? Math.max(Number(env.PI_OOB_INTERVAL_MS) || 2_000, 100);
|
|
225
|
+
const settleMs =
|
|
226
|
+
opts.settleMs ?? Math.max(Number(env.PI_OOB_SETTLE_MS) || Math.max(intervalMs, 5_000), 250);
|
|
227
|
+
const deadline = Date.now() + pollMs;
|
|
228
|
+
|
|
229
|
+
let target: OobPollResult = { interactions: [], selfInteractions: [] };
|
|
230
|
+
let control: OobPollResult = { interactions: [], selfInteractions: [] };
|
|
231
|
+
let firstHitAt: number | undefined;
|
|
232
|
+
for (;;) {
|
|
233
|
+
target = await fetchInteractions(config, opts.targetToken);
|
|
234
|
+
control = await fetchInteractions(config, opts.controlToken);
|
|
235
|
+
if (target.interactions.length > 0 || control.interactions.length > 0) {
|
|
236
|
+
firstHitAt ??= Date.now();
|
|
237
|
+
// Settled: kept watching past the first hit long enough to catch
|
|
238
|
+
// trailing control hits.
|
|
239
|
+
if (Date.now() - firstHitAt >= settleMs) break;
|
|
240
|
+
}
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
if (now >= deadline) break;
|
|
243
|
+
await new Promise((r) => setTimeout(r, Math.min(intervalMs, deadline - now)));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const notes: string[] = [];
|
|
247
|
+
if (target.selfInteractions.length + control.selfInteractions.length > 0) {
|
|
248
|
+
notes.push(
|
|
249
|
+
`${target.selfInteractions.length} target-run / ${control.selfInteractions.length} ` +
|
|
250
|
+
"control-run unattributed-or-self-source interaction(s) rejected (missing src_ip or PI_OOB_SELF_IPS)",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
notes.push(
|
|
254
|
+
config.sourceSeparated
|
|
255
|
+
? "operator attests source separation (PI_OOB_SOURCE_SEPARATED=1)"
|
|
256
|
+
: "source separation NOT attested — diagnostic only",
|
|
257
|
+
);
|
|
258
|
+
// Tokens are stored raw in the ledger (see PendingConfirmation.oobTokens);
|
|
259
|
+
// an oracle reachable without a bearer token makes those tokens pollable by
|
|
260
|
+
// anyone with ledger read access. Surface it where the operator's attention
|
|
261
|
+
// already is — the verification note.
|
|
262
|
+
if (!config.bearer) {
|
|
263
|
+
notes.push(
|
|
264
|
+
"oracle has no PI_OOB_ORACLE_TOKEN — stored run tokens are pollable by anyone with oracle network access; set a bearer token or restrict the oracle endpoint",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
verification: {
|
|
270
|
+
attempted: true,
|
|
271
|
+
targetHits: target.interactions.length,
|
|
272
|
+
controlHits: control.interactions.length,
|
|
273
|
+
sourceSeparated: config.sourceSeparated,
|
|
274
|
+
note:
|
|
275
|
+
`target-token ${target.interactions.length} interaction(s), control-token ${control.interactions.length}` +
|
|
276
|
+
(notes.length ? `; ${notes.join("; ")}` : ""),
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
package/src/poc-runner.ts
CHANGED
|
@@ -727,6 +727,16 @@ export function runPoc(pocPath: string, options?: PocRunOptions): PocRun {
|
|
|
727
727
|
|
|
728
728
|
const opts: PocRunOptions = options ?? {};
|
|
729
729
|
|
|
730
|
+
// Operator/test-harness escape: PI_POC_FORCE_LOCAL=1 together with the
|
|
731
|
+
// operator opt-in PI_POC_ALLOW_LOCAL=1 runs EVERY PoC on the host, skipping
|
|
732
|
+
// Docker entirely — including default (network:"none") and OOB runs, not just
|
|
733
|
+
// local:true ones. Both flags are operator env (never agent-supplied), so this
|
|
734
|
+
// cannot be triggered by a finding. Without them, execution falls through to
|
|
735
|
+
// the isolated sandbox as before.
|
|
736
|
+
if (process.env.PI_POC_FORCE_LOCAL === "1" && process.env[LOCAL_EXEC_ENV] === "1") {
|
|
737
|
+
return runLocal(normalized, language, opts.env);
|
|
738
|
+
}
|
|
739
|
+
|
|
730
740
|
// Host execution is gated by the OPERATOR, never by an agent-supplied flag.
|
|
731
741
|
// `local: true` means "network access needed":
|
|
732
742
|
// 1. Prefer a host-network Docker sandbox (isolation retained).
|
package/src/scratchpad.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scratchpad — intermediate
|
|
2
|
+
* Scratchpad — intermediate working-notes store for a run.
|
|
3
3
|
*
|
|
4
4
|
* The casefile owns state transitions; the scratchpad owns artifacts.
|
|
5
|
-
*
|
|
6
|
-
* logs) instead of stuffing everything into casefile text fields
|
|
7
|
-
* on each other's output streams (which creates an echo chamber).
|
|
5
|
+
* The agent writes its outputs here (recon maps, trace outputs, verification
|
|
6
|
+
* logs) instead of stuffing everything into casefile text fields.
|
|
8
7
|
*
|
|
9
|
-
* Directory layout per
|
|
8
|
+
* Directory layout per run (one subdir per phase — see PHASE_DIRS):
|
|
10
9
|
* {project_root}/.scratchpad/{run_id}/
|
|
11
10
|
* recon/ — fingerprints, tech detection, surface maps
|
|
12
11
|
* hunt/ — per-class findings
|
|
@@ -74,7 +73,7 @@ export interface ScratchpadResume {
|
|
|
74
73
|
// ── Constants ────────────────────────────────────────────────────────
|
|
75
74
|
|
|
76
75
|
// All accepted artifact buckets. Some are legacy/manual-only and should not be
|
|
77
|
-
// scheduled by ScratchpadResume for new
|
|
76
|
+
// scheduled by ScratchpadResume for new runs.
|
|
78
77
|
export const SCRATCHPAD_PHASES: ScratchpadPhase[] = [
|
|
79
78
|
"recon",
|
|
80
79
|
"hunt",
|