@xaccefy/pi-casefile 0.9.4 → 0.10.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 +32 -67
- package/package.json +13 -16
- package/src/confirmation.ts +951 -0
- package/src/evidence.ts +131 -4
- package/src/harness-verify.ts +19 -42
- package/src/index.ts +362 -701
- package/src/ledger-internal.ts +435 -0
- package/src/ledger.ts +519 -1255
- package/src/oob-oracle.ts +279 -0
- package/src/poc-runner.ts +51 -12
- package/src/scratchpad.ts +92 -148
- package/src/workflow.ts +48 -325
- 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
|
@@ -419,6 +419,36 @@ function outputWasComplete(result: { error?: Error; signal: string | null }): bo
|
|
|
419
419
|
return !result.error && result.signal === null;
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Minimal OS env copied into local PoC spawns so interpreters still resolve
|
|
424
|
+
* and run (PATH lookup, HOME/TZ/locale/temp, Windows loader vars). Everything
|
|
425
|
+
* else — proxy URLs with embedded credentials, PI_* operator secrets — stays
|
|
426
|
+
* out; the harness contract is layered on top by the caller.
|
|
427
|
+
*/
|
|
428
|
+
function minimalLocalEnv(): Record<string, string> {
|
|
429
|
+
const allow = [
|
|
430
|
+
"PATH",
|
|
431
|
+
"HOME",
|
|
432
|
+
"LANG",
|
|
433
|
+
"LC_ALL",
|
|
434
|
+
"LC_CTYPE",
|
|
435
|
+
"TZ",
|
|
436
|
+
"TMPDIR",
|
|
437
|
+
"TEMP",
|
|
438
|
+
"TMP",
|
|
439
|
+
"SYSTEMROOT",
|
|
440
|
+
"WINDIR",
|
|
441
|
+
"COMSPEC",
|
|
442
|
+
"PATHEXT",
|
|
443
|
+
];
|
|
444
|
+
const out: Record<string, string> = {};
|
|
445
|
+
for (const key of allow) {
|
|
446
|
+
const value = process.env[key];
|
|
447
|
+
if (value !== undefined) out[key] = value;
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
|
|
422
452
|
/** Reject control characters in harness-supplied PoC env values. */
|
|
423
453
|
function sanitizePocEnv(env: Record<string, string>): Record<string, string> {
|
|
424
454
|
const out: Record<string, string> = {};
|
|
@@ -661,11 +691,15 @@ function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, s
|
|
|
661
691
|
encoding: "utf8",
|
|
662
692
|
timeout: TIMEOUT_MS,
|
|
663
693
|
maxBuffer: MAX_BUFFER,
|
|
664
|
-
// Host runs get
|
|
665
|
-
// the
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
694
|
+
// Host runs get a MINIMAL env: OS locale/temp vars so interpreters
|
|
695
|
+
// resolve and run, plus the harness env contract — never the operator's
|
|
696
|
+
// ambient process env. Proxy URLs can embed credentials and PI_*
|
|
697
|
+
// carries operator secrets (e.g. PI_OOB_ORACLE_TOKEN bearer), and the
|
|
698
|
+
// PoC script is untrusted agent-authored code. An operator who needs a
|
|
699
|
+
// specific non-secret value for a local run injects it explicitly via
|
|
700
|
+
// the run env. The sandboxed path was already minimal (explicit -e
|
|
701
|
+
// args only).
|
|
702
|
+
env: { ...minimalLocalEnv(), ...sanitizePocEnv(runEnv) },
|
|
669
703
|
});
|
|
670
704
|
|
|
671
705
|
// Local runs stay shell-free (space-containing paths stay single args), so
|
|
@@ -727,19 +761,24 @@ export function runPoc(pocPath: string, options?: PocRunOptions): PocRun {
|
|
|
727
761
|
|
|
728
762
|
const opts: PocRunOptions = options ?? {};
|
|
729
763
|
|
|
764
|
+
// Operator/test-harness escape: PI_POC_FORCE_LOCAL=1 together with the
|
|
765
|
+
// operator opt-in PI_POC_ALLOW_LOCAL=1 runs EVERY PoC on the host, skipping
|
|
766
|
+
// Docker entirely — including default (network:"none") and OOB runs, not just
|
|
767
|
+
// local:true ones. Both flags are operator env (never agent-supplied), so this
|
|
768
|
+
// cannot be triggered by a finding. Without them, execution falls through to
|
|
769
|
+
// the isolated sandbox as before.
|
|
770
|
+
if (process.env.PI_POC_FORCE_LOCAL === "1" && process.env[LOCAL_EXEC_ENV] === "1") {
|
|
771
|
+
return runLocal(normalized, language, opts.env);
|
|
772
|
+
}
|
|
773
|
+
|
|
730
774
|
// Host execution is gated by the OPERATOR, never by an agent-supplied flag.
|
|
731
775
|
// `local: true` means "network access needed":
|
|
732
776
|
// 1. Prefer a host-network Docker sandbox (isolation retained).
|
|
733
777
|
// 2. Fall back to bare host ONLY when Docker/image is unavailable AND the
|
|
734
|
-
// operator set PI_POC_ALLOW_LOCAL=1.
|
|
735
|
-
//
|
|
736
|
-
// skip Docker and run on the host deliberately — still never agent-only.
|
|
778
|
+
// operator set PI_POC_ALLOW_LOCAL=1. (FORCE_LOCAL+ALLOW never reaches
|
|
779
|
+
// here — the operator escape above returns before the sandbox path.)
|
|
737
780
|
if (opts.local === true) {
|
|
738
781
|
const allowLocal = process.env[LOCAL_EXEC_ENV] === "1";
|
|
739
|
-
const forceLocal = process.env.PI_POC_FORCE_LOCAL === "1";
|
|
740
|
-
if (forceLocal && allowLocal) {
|
|
741
|
-
return runLocal(normalized, language, opts.env);
|
|
742
|
-
}
|
|
743
782
|
const sandboxed = runSandboxed(normalized, language, "host", opts.env);
|
|
744
783
|
if (!sandboxed.infraError) {
|
|
745
784
|
return sandboxed;
|