@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.
@@ -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 artifact store for pipeline runs.
2
+ * Scratchpad — intermediate working-notes store for a run.
3
3
  *
4
4
  * The casefile owns state transitions; the scratchpad owns artifacts.
5
- * Agents write their outputs here (recon maps, trace outputs, verification
6
- * logs) instead of stuffing everything into casefile text fields or relying
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 pipeline run (one subdir per phase — see PHASE_DIRS):
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 swarm runs.
76
+ // scheduled by ScratchpadResume for new runs.
78
77
  export const SCRATCHPAD_PHASES: ScratchpadPhase[] = [
79
78
  "recon",
80
79
  "hunt",