@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/README.md +2 -3
- package/package.json +1 -2
- package/src/confirmation.ts +45 -351
- package/src/evidence.ts +59 -220
- package/src/harness-verify.ts +36 -206
- package/src/index.ts +44 -366
- package/src/ledger.ts +24 -71
- package/src/poc-runner.ts +3 -3
- package/src/oob-oracle.ts +0 -279
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,
|
|
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"
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
436
|
-
|
|
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/
|
|
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 (
|
|
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
|
-
"
|
|
2700
|
-
`###
|
|
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"
|
|
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.
|
|
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")
|
|
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
|
-
}
|