@xaccefy/pi-casefile 0.10.1 → 0.11.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/src/ledger.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  writeFileSync,
24
24
  } from "node:fs";
25
25
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
26
- import type { MainAgentVerdict, PanelVote, PoCEvidence } from "./evidence.ts";
26
+ import type { MainAgentVerdict, PoCEvidence } from "./evidence.ts";
27
27
  import { scanArtifactForSecrets } from "./evidence.ts";
28
28
  import type { HarnessVerifyResult } from "./harness-verify.ts";
29
29
  import {
@@ -343,7 +343,7 @@ export type RetryPolicy = {
343
343
 
344
344
  /** One harness-observed PoC run with its validated, nonce-bound evidence. */
345
345
  export type PocEvidenceRun = {
346
- mode: "poc" | "control";
346
+ mode: "poc";
347
347
  target: string;
348
348
  /** The run's PI_POC_NONCE — evidence.nonce must equal it (binds evidence to the run). */
349
349
  nonce: string;
@@ -362,80 +362,23 @@ export type PocEvidenceRun = {
362
362
  evidencePath?: string;
363
363
  };
364
364
 
365
- /** Harness-observed out-of-band interactions (Tier 1, docs/poc-trust-model.md). */
366
- export type OobVerification = {
367
- attempted: boolean;
368
- targetHits: number;
369
- controlHits: number;
370
- /** True only when the PoC runner cannot directly reach the listener. */
371
- sourceSeparated?: boolean;
372
- note: string;
373
- };
374
-
375
365
  export type PendingConfirmation = {
376
366
  caseId: string;
377
367
  ranAt: string;
378
368
  pocPath: string;
379
369
  /** SHA-256 of the PoC script AT RUN TIME — re-hashed at confirm to catch edits. */
380
370
  pocSha256: string;
381
- /**
382
- * Differential shape. Absent/"inter_host" (default) = same request to target
383
- * vs a distinct patched control host, proven by a separate control run +
384
- * `replayDifferential`. "intra_target" = attack vs a legitimate same-host
385
- * `baseline` request inside each run's evidence, proven by `replayIntraTarget`
386
- * — no control run or control target (access-control / business-logic classes).
387
- */
388
- mode?: "inter_host" | "intra_target";
389
- /** inter_host only. */
390
- controlPath?: string;
391
- /** inter_host only. */
392
- controlTarget?: string;
393
371
  targetRuns: [PocEvidenceRun, PocEvidenceRun];
394
- /** inter_host only the same PoC run against the control target. */
395
- controlRun?: PocEvidenceRun;
396
- /** Harness's own replay of evidence.verify (public targets). Absent = legacy bundle. */
372
+ /** Harness's own attack-vs-baseline replay of the evidence contract. */
397
373
  harnessVerified?: HarnessVerifyResult;
398
- /** OOB-only bundles: per-run oracle tokens so phase-2 can re-poll freshly.
399
- * Stored raw deliberately: the oracle is operator-owned and bearer-gated,
400
- * so a ledger reader without oracle write access cannot fabricate hits. */
401
- oobTokens?: { targetToken: string; controlToken: string };
402
- /** Harness-owned OOB listener log for the run (opt-in blind classes). */
403
- callbackVerified?: OobVerification;
404
- /**
405
- * Optional pre-gate panel votes (advisory). CONFIRMED additionally requires
406
- * a 2/3 exploit quorum or an explicit override note on the verdict; votes
407
- * never commit anything — the main agent still owns the verdict.
408
- */
409
- panelVotes?: PanelVote[];
410
- };
411
-
412
- /**
413
- * Fresh machine transcript produced inside the main agent's ConfirmFinding call.
414
- *
415
- * BOUNDARY NOTE: the ledger enforces the STRUCTURAL floor on this object —
416
- * valid timestamp newer than phase 1 and ≤5 minutes old, target/control
417
- * binding, conclusive `target_only` differential, canary transcript when
418
- * requested (see assertMainAgentVerification). What it cannot enforce at this
419
- * API boundary is WHO executed the replay: in production the only caller is
420
- * the PromoteFinding/ConfirmFinding tool layer in index.ts, which runs the
421
- * replay itself before calling applyConfirmationResult. A second integration
422
- * calling applyConfirmationResult directly owns the provenance of the
423
- * transcript it passes. Cross-process identity limits are documented in
424
- * docs/confirmation-design.md §7 (honest limits).
425
- */
426
- export type MainAgentVerification = {
427
- at: string;
428
- result: HarnessVerifyResult;
429
374
  };
430
375
 
431
376
  /** Persisted main-agent verdict; `confirmer` naming is retained for DB compatibility. */
432
377
  export type MainAgentVerdictRecord = MainAgentVerdict & {
433
378
  at: string;
434
379
  reviewer: "main_agent";
435
- /** Harness-owned phase-2 replay bound to this verdict. */
436
- phase2Verification?: MainAgentVerification;
437
- /** What the machine actually established; semantic vulnerability judgment remains main-agent-owned. */
438
- proofStrength?: "predicate_differential" | "canary_differential";
380
+ /** What the machine established (predicate differential); the semantic judgment remains main-agent-owned. */
381
+ proofStrength?: "predicate_differential";
439
382
  };
440
383
 
441
384
  /** @deprecated Compatibility alias for the legacy database/API field name. */
@@ -2464,11 +2407,8 @@ const MAX_TOTAL_ARTIFACT_CHARS = 400_000;
2464
2407
 
2465
2408
  /**
2466
2409
  * Recursively redact sensitive values in a serialized object:
2467
- * - local filesystem paths (path/pocPath/controlPath/evidencePath) → basename
2410
+ * - local filesystem paths (path/pocPath/evidencePath) → basename
2468
2411
  * (the context bundle must never leak the researcher's local paths);
2469
- * - OOB oracle tokens (targetToken/controlToken) → sha256 prefix — the raw
2470
- * tokens are bearer credentials against the oracle and stay DB-only for the
2471
- * phase-2 re-poll; every rendered view must show a fingerprint instead.
2472
2412
  */
2473
2413
  function redactPaths(value: unknown, seen = new Set<object>()): unknown {
2474
2414
  if (Array.isArray(value)) return value.map((v) => redactPaths(v, seen));
@@ -2477,13 +2417,22 @@ function redactPaths(value: unknown, seen = new Set<object>()): unknown {
2477
2417
  seen.add(value);
2478
2418
  const out: Record<string, unknown> = {};
2479
2419
  for (const [k, v] of Object.entries(value)) {
2480
- if (typeof v === "string" && (k === "targetToken" || k === "controlToken")) {
2481
- out[k] = `sha256:${createHash("sha256").update(v).digest("hex").slice(0, 12)}`;
2482
- } else if (
2420
+ if (
2483
2421
  typeof v === "string" &&
2484
2422
  (k === "path" || k === "pocPath" || k === "controlPath" || k === "evidencePath")
2485
2423
  ) {
2424
+ // Local paths → basename: rendered views must never leak the
2425
+ // researcher's local tree. controlPath matters for legacy inter-host
2426
+ // bundles; harmless for new ones.
2486
2427
  out[k] = basename(v) || v;
2428
+ } else if (
2429
+ (k === "targetToken" || k === "controlToken") &&
2430
+ typeof v === "string" &&
2431
+ v.length > 0
2432
+ ) {
2433
+ // Legacy OOB oracle tokens are bearer credentials — fingerprint, never
2434
+ // render raw. Pre-0.11 ledgers can still carry them in pending bundles.
2435
+ out[k] = `sha256:${createHash("sha256").update(v).digest("hex").slice(0, 12)} (redacted)`;
2487
2436
  } else {
2488
2437
  out[k] = redactPaths(v, seen);
2489
2438
  }
@@ -2696,8 +2645,12 @@ export function writeCaseContext(id: string): CaseContextResult {
2696
2645
  : undefined,
2697
2646
  current.controlVerified
2698
2647
  ? mdSection(
2699
- "Control-Target Check (anti-cheat)",
2700
- `### Control Run Verification\n- **Timestamp:** ${current.controlVerified.ranAt}\n- **Script:** \`${basename(current.controlVerified.path)}\`\n- **Sandbox:** ${current.controlVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.controlVerified.exitCode}\n- **Control target:** ${current.controlVerified.target ?? "not recorded"}\n- **Differential (machine-checked):** control evidence differs from the target runs' evidence — the claimed impact is target-dependent (assertEvidenceDifferential, re-checked at confirm).\n- **Note:** zero exit is necessary run integrity, never vulnerability proof; output markers are diagnostic only. The machine floor is the harness differential plus main-agent review.\n\n#### Output\n\`\`\`\n${current.controlVerified.output ?? ""}\n\`\`\``,
2648
+ "Same-Host Baseline Check (anti-cheat)",
2649
+ `### Baseline Replay Verification\n- **Timestamp:** ${current.controlVerified.ranAt}\n- **Script:** \`${basename(current.controlVerified.path)}\`\n- **Sandbox:** ${current.controlVerified.sandbox ? "yes" : "no"}\n- **Exit Code:** ${current.controlVerified.exitCode}\n- **Recorded target:** ${current.controlVerified.target ?? "not recorded"}\n${
2650
+ current.controlVerified.mode === "control"
2651
+ ? "- **Inter-host control run (pre-0.11 pipeline):** recorded as-is; the inter-host model was retired in 0.11. See Complete Case Record for the raw fields.\n"
2652
+ : "- **Determinism (machine-checked):** both target runs produced identical evidence (assertEvidenceDifferential, re-checked at confirm).\n- **Differential (machine-checked):** the harness replayed the evidence's attack request and its legitimate same-host baseline request — the attack predicate matched on attack only (attack/baseline replay, recorded at promote and re-validated at confirm).\n"
2653
+ }- **Note:** zero exit is necessary run integrity, never vulnerability proof; output markers are diagnostic only. The machine floor is the harness differential plus main-agent review.\n\n#### Output\n\`\`\`\n${current.controlVerified.output ?? ""}\n\`\`\``,
2701
2654
  )
2702
2655
  : undefined,
2703
2656
  mdSection("Disconfirmation Attempt", current.disconfirmation),
package/src/poc-runner.ts CHANGED
@@ -78,7 +78,7 @@ export type PocRunOptions = {
78
78
  local?: boolean;
79
79
  /**
80
80
  * Extra environment variables merged into the run. The harness sets
81
- * `PI_POC_MODE` ("poc" | "control" | "disconfirmation") and `PI_POC_TARGET`
81
+ * `PI_POC_MODE` ("poc") and `PI_POC_TARGET`
82
82
  * (the case target) so PoCs can be written once and parameterized per run.
83
83
  */
84
84
  env?: Record<string, string>;
@@ -694,7 +694,7 @@ function runLocal(pocPath: string, language: PocLanguage, env?: Record<string, s
694
694
  // Host runs get a MINIMAL env: OS locale/temp vars so interpreters
695
695
  // resolve and run, plus the harness env contract — never the operator's
696
696
  // ambient process env. Proxy URLs can embed credentials and PI_*
697
- // carries operator secrets (e.g. PI_OOB_ORACLE_TOKEN bearer), and the
697
+ // carries operator secrets (e.g. oracle bearer tokens), and the
698
698
  // PoC script is untrusted agent-authored code. An operator who needs a
699
699
  // specific non-secret value for a local run injects it explicitly via
700
700
  // the run env. The sandboxed path was already minimal (explicit -e
@@ -763,7 +763,7 @@ export function runPoc(pocPath: string, options?: PocRunOptions): PocRun {
763
763
 
764
764
  // Operator/test-harness escape: PI_POC_FORCE_LOCAL=1 together with the
765
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
766
+ // Docker entirely — including default (network:"none") runs, not just
767
767
  // local:true ones. Both flags are operator env (never agent-supplied), so this
768
768
  // cannot be triggered by a finding. Without them, execution falls through to
769
769
  // the isolated sandbox as before.
package/src/oob-oracle.ts DELETED
@@ -1,279 +0,0 @@
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
- }