@xaccefy/pi-casefile 0.9.0 → 0.9.2

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 x4cc3
3
+ Copyright (c) 2025 xaccefy
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -16,13 +16,17 @@ The attack-mode text stays **quiet by default** so your normal coding isn't buri
16
16
 
17
17
  | Control | Effect |
18
18
  |---------|--------|
19
- | `/xp` | Toggle ON/OFF |
20
- | `/xp on` / `/xp off` / `/xp lite` | Set explicitly |
21
- | `PI_XP_MODE=on` | Force ON for this process (overrides file) |
19
+ | `/xp` | Toggle SWARM/OFF |
20
+ | `/xp on` | Default enabled mode: SWARM |
21
+ | `/xp swarm` | Bounded multi-agent workflow: auditor/tracer/skeptic/chain only |
22
+ | `/xp lite` | Focused workflow: single-agent, no subagent dispatch |
23
+ | `/xp off` | Quiet mode explicitly |
24
+ | `PI_XP_MODE=on` | Force SWARM for this process (same as `swarm`) |
25
+ | `PI_XP_MODE=swarm` | Force SWARM for this process (overrides file) |
22
26
  | `PI_XP_MODE=lite` | Force LITE (single-agent, no subagent dispatch) |
23
27
  | `PI_XP_MODE=off` | Force OFF |
24
28
 
25
- When **ON**, every prompt gets the attacker-minded workflow plus any open cases. **LITE** is the same discipline done by the main agent alone — no `subagent` dispatch (CTF / single-shot engagements). When **OFF**, nothing is added; tools still work.
29
+ When **LITE**, every prompt gets the attacker-minded workflow plus any open cases, done by the main agent alone — no `subagent` dispatch (CTF / single-shot engagements). When **SWARM**, the bounded pipeline is injected and only auditor/tracer/skeptic/chain are delegated; validation, patching, reporting, and ConfirmFinding stay with the main agent. When **OFF**, nothing is added; tools still work.
26
30
 
27
31
  State is persisted next to the ledger as `xp-mode` (e.g. `.pi/xp-mode`).
28
32
 
@@ -32,6 +36,9 @@ State is persisted next to the ledger as `xp-mode` (e.g. `.pi/xp-mode`).
32
36
  |----------|---------|
33
37
  | `PI_CASEFILE_PATH` | Absolute path to the SQLite ledger file |
34
38
  | `CASEFILE_WORKSPACE_ROOT` / `PI_WORKSPACE_ROOT` | Override workspace root used to place `.pi/casefile.db` |
39
+ | `PI_POC_ALLOW_NETWORK=1` | Operator authorization for a networked PoC sandbox |
40
+ | `PI_POC_ALLOW_PRIVATE_REPLAY=1` | Operator authorization for harness replay to private/internal targets |
41
+ | `PI_POC_CONTROL_TARGETS` | Comma/newline-separated operator-approved control hosts/origins; agent-invented controls are rejected |
35
42
 
36
43
  Default DB path: `<workspace>/.pi/casefile.db`
37
44
 
@@ -44,16 +51,16 @@ hypothesis → investigating → confirmed → reported
44
51
  ```
45
52
 
46
53
  - **investigating** needs `evidence` + `confidence`
47
- - **confirmed** only through the two-phase gate — `PromoteFinding` (evidence bundle: PoC 2× target + 1× same-script control, every run completing with nonce-bound `evidence.json`; machine-checks nonce binding, determinism, target/control differential) confirmer subagent verdict `ConfirmFinding` (CONFIRMED requires re_executed + target_only + disconfirmation_attempt). You can't just set status to confirmed, and markers/exit codes are diagnostics. Promotion additionally requires an EvidenceAdd `observation` item (the initial signal) the ledger rejects a `confirmed` with no evidence chain.
48
- - **Every promotion requires `control_path`** (the same bytes as the PoC sha256 enforced) and a distinct `control_target`; a run whose control evidence matches the target's is blocked as not-target-dependent. The control run is stored as `controlVerified`. Runs that **crash** (killed / timeout / spawn error / missing evidence.json) are blocked too: a crash is not a verdict.
54
+ - **confirmed** only through the two-phase gate — `PromoteFinding` runs the PoC 2× target + 1× control, requires complete zero-exit runs and nonce-bound discriminating response-body evidence, then the harness performs a DNS-pinned identical replay and requires two conclusive responses with `target_only`. Reflection-capable requests may add a post-PoC harness-generated canary that must appear only on target. Status-only/trivial matchers and incomplete response capture are rejected. Exit zero is necessary but never proof. The main agent performs semantic review and calls `ConfirmFinding`, which captures a second fresh harness replay and binds it to the verdict; worker processes are rejected. Blind/OOB claims fail closed without a source-separated oracle.
55
+ - **Every promotion requires a distinct `control_target`** pre-approved by the operator in `PI_POC_CONTROL_TARGETS`; an agent cannot invent its own easy control. `control_path` defaults to `poc_path` and exists only as an override — if supplied, it must contain the same bytes as the PoC (sha256 enforced). The control run is stored as `controlVerified`. Crashes, transport-inconclusive controls, status-only evidence, and missing evidence all block promotion.
49
56
  - **New cases require `disproveIf`** — falsification conditions (what would disprove this hypothesis). A hypothesis that can't say what kills it isn't one yet.
50
- - **A kill must be justified**: either an EvidenceAdd `refutation` item, or a kill-reason token (intended_behavior, duplicate, framework_protection, out_of_scope, skeptic-disproven, no_attack_path, ...) in assumptions/nextStep. Bare `status: "killed"` is rejected.
51
- - **reported** needs `CaseContext` first (records the report path; the report writer produces the final file)
57
+ - **A kill must be justified**: either an EvidenceAdd `refutation` item, or a canonical kill-reason token (intended_behavior, duplicate, framework_protection, out_of_scope, insufficient_impact, no_attack_path, ...) in assumptions/nextStep. Bare `status: "killed"` is rejected.
58
+ - **reported** needs `CaseContext` first (records the report path; the main agent produces the final file)
52
59
  - **killed** / **reported** are final (no more edits)
53
60
 
54
61
  ## Evidence items
55
62
 
56
- `EvidenceAdd` records role-typed, artifact-backed evidence (observation / reproduction / impact / refutation / cleanup). Artifacts are stored as basename + SHA-256 the full path is never persisted. The PoC gate auto-records the `reproduction` item (the PoC file, hashed) at promotion, so a confirmed case always traces back to a real artifact. `cleanup` items track engagement cleanup; record and confirm them before REPORT for sanctioned engagements (advisory — not yet a hard gate).
63
+ `EvidenceAdd` records role-typed, artifact-backed evidence (observation / reproduction / impact / refutation / cleanup). Artifact reads are restricted to regular, non-symlink files inside the workspace. Bytes are copied durably and stored as basename + SHA-256; the full source path is never persisted. The PoC gate auto-records the `reproduction` item at promotion.
57
64
 
58
65
  ## Tools
59
66
 
@@ -62,8 +69,8 @@ hypothesis → investigating → confirmed → reported
62
69
  | `CaseAdd` | Open a case (`title` + `disproveIf` required; start as `hypothesis` or `investigating`) |
63
70
  | `CaseUpdate` | Evidence, impact, severity, status (not direct confirm) |
64
71
  | `EvidenceAdd` | Role-typed, hashed evidence item on a case (refutation justifies kills; cleanup tracks cleanup) |
65
- | `PromoteFinding` | Phase 1: run on-disk PoC (Docker sandbox by default; `local:true` = host-network sandbox, host execution operator-gated via `PI_POC_ALLOW_LOCAL=1`) target + control; validates nonce-bound evidence.json, determinism, differential; records the 1h pending bundle + confirmer dispatch instruction |
66
- | `ConfirmFinding` | Phase 2: commit the confirmer's verdict (CONFIRMED promotes; NOT_CONFIRMED keeps investigating no tie-breaker) |
72
+ | `PromoteFinding` | Phase 1: main-agent-only PoC target + operator-approved control, then DNS-pinned harness-owned replay requiring conclusive `target_only`; optional reflection canary upgrades the recorded proof strength when observed only on target. `local:true` and private replay are operator-gated; blind/OOB proof fails closed without source separation |
73
+ | `ConfirmFinding` | Phase 2: main-agent-only semantic decision plus a fresh harness-owned target/control replay (CONFIRMED promotes; NOT_CONFIRMED keeps investigating; worker/subagent gate calls are rejected) |
67
74
  | `CaseGet` / `CaseList` / `CaseSearch` | Read / filter / search |
68
75
  | `CaseLink` / `CaseUnlink` | Bidirectional exploit chains |
69
76
  | `ChainSuggest` | Scan cases for exploitable chain combinations (credential+endpoint→ATO, redirect+OAuth→token theft, XSS+state-change→CSRF, IDOR+user-data, SSTI→RCE, race+payment, info-disclosure+SSRF), ranked by confidence |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xaccefy/pi-casefile",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Offensive security case tracker for Pi Agent — bug bounties, CTFs, security audits",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -15,12 +15,12 @@
15
15
  "license": "MIT",
16
16
  "repository": {
17
17
  "type": "git",
18
- "url": "git+https://github.com/x4cc3/pi-xpi.git",
18
+ "url": "git+https://github.com/xaccefy/pi-xpi.git",
19
19
  "directory": "packages/pi-casefile"
20
20
  },
21
- "homepage": "https://github.com/x4cc3/pi-xpi/tree/main/packages/pi-casefile#readme",
21
+ "homepage": "https://github.com/xaccefy/pi-xpi/tree/main/packages/pi-casefile#readme",
22
22
  "bugs": {
23
- "url": "https://github.com/x4cc3/pi-xpi/issues"
23
+ "url": "https://github.com/xaccefy/pi-xpi/issues"
24
24
  },
25
25
  "scripts": {
26
26
  "test": "bun test test",
@@ -32,6 +32,11 @@
32
32
  },
33
33
  "files": [
34
34
  "src/index.ts",
35
+ "src/evidence.ts",
36
+ "src/harness-verify.ts",
37
+ "src/safe-state.ts",
38
+ "src/ledger-worker.ts",
39
+ "src/ledger-worker-entry.ts",
35
40
  "src/ledger.ts",
36
41
  "src/workflow.ts",
37
42
  "src/poc-runner.ts",
@@ -43,6 +48,10 @@
43
48
  "LICENSE"
44
49
  ],
45
50
  "main": "src/index.ts",
51
+ "dependencies": {
52
+ "@xaccefy/pi-shared": "0.9.2",
53
+ "undici": "^8.9.0"
54
+ },
46
55
  "pi": {
47
56
  "extensions": [
48
57
  "./src/index.ts"
@@ -13,9 +13,9 @@ Use Casefile to maintain durable security investigation state across agent turns
13
13
  1. Check existing cases before opening a new one with CaseList or CaseSearch.
14
14
  2. Open new leads with CaseAdd as `hypothesis` or `investigating`.
15
15
  3. Promote cases with CaseUpdate only after materially new evidence, proof, impact, blockers, remediation, or status changes.
16
- 4. Mark `confirmed` only via the two-phase gate — `PromoteFinding` (runs the PoC 2× against the target + 1× against a distinct control target; every run must write nonce-bound `evidence.json` and complete with captured output) → dispatch the `confirmer` subagent → commit its verdict with `ConfirmFinding`. Markers and exit codes are diagnostics, not gates.
16
+ 4. Mark `confirmed` only via the two-phase gate — main-agent `PromoteFinding` (PoC 2× target + 1× operator-approved control, nonce-bound body evidence, then a DNS-pinned conclusive `target_only` replay; use the post-PoC harness-generated canary for reflection-capable requests) → the main agent personally reviews `ConfirmFinding`, which captures a fresh second harness replay before commit. Never delegate validation or confirmation; worker/subagent gate calls are rejected. Exit zero is run integrity, never vulnerability proof. A predicate differential is evidence, not an automatic exploit verdict. Blind/OOB findings remain investigating without a source-separated oracle.
17
17
  5. Use CaseLink and CaseUnlink for exploit chains. Do not edit linked case IDs directly.
18
- 6. Use CaseContext only for confirmed or already reported cases: it writes the full context bundle (complete record, verification logs, links, pipeline artifacts) and records the report path. Then have the report written (reporter agent in the full pipeline; yourself in lite mode) and CaseUpdate status=`reported`.
18
+ 6. Use CaseContext only for confirmed or already reported cases: it writes the full context bundle (complete record, verification logs, links, pipeline artifacts) and records the report path. Then write the report and CaseUpdate status=`reported`.
19
19
  7. Use `killed` for disproven, duplicate, or dead-end leads, and include evidence, blockers, next step, or assumptions explaining why.
20
20
 
21
21
  ## State machine
@@ -34,11 +34,11 @@ hypothesis → investigating → confirmed → reported
34
34
 
35
35
  - `CaseAdd`: create a new case.
36
36
  - `CaseUpdate`: update an existing case.
37
- - `PromoteFinding`: phase 1 of confirmation run an on-disk PoC script (Docker sandbox or local) against the target plus a same-script control run; machine-validates nonce-bound evidence.json, determinism, and the target/control differential; records a 1h pending bundle and returns the confirmer dispatch instruction.
38
- - `ConfirmFinding`: phase 2 — commit the confirmer's verdict (CONFIRMED promotes to confirmed; NOT_CONFIRMED keeps investigating, no tie-breaker).
37
+ - `PromoteFinding`: phase 1 require a body predicate, bind the verify URL, pin DNS, and send the same request to target and an operator-approved `PI_POC_CONTROL_TARGETS` control. Both responses must be conclusive and only `target_only` passes. For reflection, place `{{PI_POC_CANARY}}` exactly once in the request and declare `verify.canary`; the harness creates the secret after the PoC exits and requires target-only reflection. Network/private access remains operator-gated; OOB fails closed without source separation.
38
+ - `ConfirmFinding`: phase 2 — main-agent-only review and fresh harness replay before commit (CONFIRMED promotes; NOT_CONFIRMED keeps investigating; worker/subagent calls are rejected). Record `canary_assessment=verified` when requested, otherwise `not_applicable` with a concrete reason.
39
39
  - `CaseGet`: read one case by ID.
40
40
  - `CaseList`: list cases with filters and pagination.
41
41
  - `CaseSearch`: search all fields or a scoped field.
42
42
  - `CaseLink`: bidirectionally link two cases.
43
43
  - `CaseUnlink`: remove a bidirectional case link.
44
- - `CaseContext`: write the case context bundle (complete record, PoC/disconfirmation logs, links, pipeline artifacts) for a confirmed or reported case and record the report path for the report writer.
44
+ - `CaseContext`: write the case context bundle (complete record, PoC/disconfirmation logs, links, pipeline artifacts) for a confirmed or reported case and record the report path for the main agent.
@@ -0,0 +1,502 @@
1
+ /**
2
+ * Evidence contract for PoC confirmation.
3
+ *
4
+ * A PoC must write `evidence.json` into $PI_POC_EVIDENCE_DIR: a nonce-bound,
5
+ * schema-validated record of what it claims and the request spec the harness
6
+ * executes against both target and control. The harness validates the file,
7
+ * binds it to the run via $PI_POC_NONCE, and acquires the responses itself.
8
+ * The main/coordinator agent remains the semantic reviewer after the machine differential.
9
+ *
10
+ * Exit zero is required run integrity, but never vulnerability proof.
11
+ */
12
+
13
+ // ── PoC evidence (written by the PoC script) ────────────────────────
14
+
15
+ export type VerifyExpect = {
16
+ /** Acceptable HTTP statuses for the verify request. */
17
+ status?: number[];
18
+ /** Substrings the verify response must contain. */
19
+ body_contains?: string[];
20
+ /** Regexes the verify response must match. */
21
+ body_regex?: string[];
22
+ };
23
+
24
+ /** Replaced only inside the harness replay, after the PoC process has exited. */
25
+ export const POC_CANARY_PLACEHOLDER = "{{PI_POC_CANARY}}";
26
+
27
+ export type VerifyCanary = {
28
+ /** Reflection is machine-checked: target must return the fresh token and control must not. */
29
+ mode: "reflection";
30
+ /** Fixed literal; callers cannot choose or predict the harness-generated token. */
31
+ placeholder: typeof POC_CANARY_PLACEHOLDER;
32
+ };
33
+
34
+ export type PoCEvidence = {
35
+ /** Must equal the run's PI_POC_NONCE (harness-verified). */
36
+ nonce: string;
37
+ /** What the exploit asserts, e.g. "read /etc/passwd of target". */
38
+ claim: string;
39
+ /** Request spec the harness executes in phase 1 and again from the main-agent phase-2 call. */
40
+ verify: {
41
+ method: string;
42
+ url: string;
43
+ headers?: Record<string, string>;
44
+ body?: string;
45
+ expect: VerifyExpect;
46
+ /** Optional stronger causality dimension, independent of the authored predicate. */
47
+ canary?: VerifyCanary;
48
+ };
49
+ /** What the script itself saw — corroboration only, never proof. */
50
+ observations: string[];
51
+ /** Optional baseline request for the main agent's differential review. */
52
+ baseline?: {
53
+ method: string;
54
+ url: string;
55
+ headers?: Record<string, string>;
56
+ body?: string;
57
+ body_contains?: string[];
58
+ };
59
+ };
60
+
61
+ const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
62
+ const MAX_CLAIM_CHARS = 2_000;
63
+ const MAX_URL_CHARS = 4_096;
64
+ const MAX_REQUEST_BODY_CHARS = 128 * 1024;
65
+ const MAX_HEADERS = 64;
66
+ const MAX_HEADER_CHARS = 8 * 1024;
67
+ const MAX_EXPECT_VALUES = 16;
68
+ const MAX_EXPECT_CHARS = 1_024;
69
+ const MAX_REGEX_VALUES = 8;
70
+ const MAX_REGEX_CHARS = 512;
71
+ const MAX_OBSERVATIONS = 64;
72
+ const MAX_OBSERVATION_CHARS = 4_096;
73
+ const FORBIDDEN_REQUEST_HEADERS = new Set([
74
+ "connection",
75
+ "content-length",
76
+ "host",
77
+ "keep-alive",
78
+ "proxy-authorization",
79
+ "proxy-connection",
80
+ "te",
81
+ "trailer",
82
+ "transfer-encoding",
83
+ "upgrade",
84
+ ]);
85
+
86
+ function isRecord(v: unknown): v is Record<string, unknown> {
87
+ return typeof v === "object" && v !== null && !Array.isArray(v);
88
+ }
89
+
90
+ function nonEmptyString(v: unknown): v is string {
91
+ return typeof v === "string" && v.trim().length > 0;
92
+ }
93
+
94
+ function boundedStringArray(v: unknown, maxItems: number, maxChars: number): v is string[] {
95
+ return (
96
+ Array.isArray(v) &&
97
+ v.length <= maxItems &&
98
+ v.every((x) => nonEmptyString(x) && x.length <= maxChars)
99
+ );
100
+ }
101
+
102
+ /** Bounded RFC-token header map with no CR/LF (injection/resource guard). */
103
+ function headerRecord(v: unknown): v is Record<string, string> {
104
+ if (!isRecord(v)) return false;
105
+ const entries = Object.entries(v);
106
+ return (
107
+ entries.length <= MAX_HEADERS &&
108
+ entries.every(
109
+ ([key, value]) =>
110
+ /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(key) &&
111
+ !FORBIDDEN_REQUEST_HEADERS.has(key.toLowerCase()) &&
112
+ key.length <= 256 &&
113
+ typeof value === "string" &&
114
+ value.length <= MAX_HEADER_CHARS &&
115
+ !/[\r\n]/.test(value),
116
+ )
117
+ );
118
+ }
119
+
120
+ /** http(s) URL with a non-empty host — prefix-only matches would accept garbage. */
121
+ function httpUrl(v: unknown): v is string {
122
+ if (!nonEmptyString(v) || v.length > MAX_URL_CHARS) return false;
123
+ let parsed: URL;
124
+ try {
125
+ parsed = new URL(v);
126
+ } catch {
127
+ return false;
128
+ }
129
+ return (
130
+ (parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.hostname.length > 0
131
+ );
132
+ }
133
+
134
+ /** body_regex entries must compile — evaluation is isolated and time-bounded by the harness. */
135
+ function validRegexArray(v: unknown): v is string[] {
136
+ return (
137
+ boundedStringArray(v, MAX_REGEX_VALUES, MAX_REGEX_CHARS) &&
138
+ v.every((re) => {
139
+ try {
140
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp -- compile-only validation; evaluation runs in a worker with a hard timeout.
141
+ new RegExp(re);
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ })
147
+ );
148
+ }
149
+
150
+ function hasMeaningfulExpectation(expect: Record<string, unknown>): boolean {
151
+ return [expect.status, expect.body_contains, expect.body_regex].some(
152
+ (value) => Array.isArray(value) && value.length > 0,
153
+ );
154
+ }
155
+
156
+ function hasBodyExpectation(expect: Record<string, unknown>): boolean {
157
+ return [expect.body_contains, expect.body_regex].some(
158
+ (value) => Array.isArray(value) && value.length > 0,
159
+ );
160
+ }
161
+
162
+ /** Reject predicates that are structurally incapable of carrying useful proof. */
163
+ function hasDiscriminatingBodyExpectation(expect: Record<string, unknown>): boolean {
164
+ const contains = Array.isArray(expect.body_contains) ? expect.body_contains : [];
165
+ if (contains.some((value) => typeof value === "string" && value.trim().length >= 4)) {
166
+ return true;
167
+ }
168
+ const regexes = Array.isArray(expect.body_regex) ? expect.body_regex : [];
169
+ return regexes.some(
170
+ (pattern) =>
171
+ typeof pattern === "string" &&
172
+ // Require a literal alphabetic anchor, e.g. `root:\\S+` or `is_admin`.
173
+ // Patterns such as `.`, `.*`, or `\\d+` match broad response classes and
174
+ // cannot independently identify the claimed vulnerability effect.
175
+ /[A-Za-z]{4,}/.test(pattern.replace(/\\[dDsSwWbB]/g, "")),
176
+ );
177
+ }
178
+
179
+ function countOccurrences(value: string, needle: string): number {
180
+ return value.split(needle).length - 1;
181
+ }
182
+
183
+ /**
184
+ * Parse + validate a PoC's evidence.json. Returns the validated object or a
185
+ * field-level error. Deliberately strict: an invalid evidence file means the
186
+ * run failed the contract, which blocks promotion.
187
+ */
188
+ export function parsePoCEvidence(
189
+ raw: unknown,
190
+ ): { ok: true; evidence: PoCEvidence } | { ok: false; error: string } {
191
+ if (!isRecord(raw)) return { ok: false, error: "evidence.json must be a JSON object" };
192
+ if (!nonEmptyString(raw.nonce)) return { ok: false, error: "evidence.json missing nonce" };
193
+ if (raw.nonce.length > 256) return { ok: false, error: "evidence.json nonce is too long" };
194
+ if (!nonEmptyString(raw.claim)) return { ok: false, error: "evidence.json missing claim" };
195
+ if (raw.claim.length > MAX_CLAIM_CHARS) {
196
+ return { ok: false, error: `evidence.json claim exceeds ${MAX_CLAIM_CHARS} characters` };
197
+ }
198
+ const verify = raw.verify;
199
+ if (!isRecord(verify)) return { ok: false, error: "evidence.json verify must be an object" };
200
+ if (!nonEmptyString(verify.method) || !HTTP_METHODS.includes(verify.method.toUpperCase())) {
201
+ return {
202
+ ok: false,
203
+ error: `evidence.json verify.method must be one of ${HTTP_METHODS.join("|")}`,
204
+ };
205
+ }
206
+ if (!httpUrl(verify.url)) {
207
+ return { ok: false, error: "evidence.json verify.url must be an http(s) URL with a host" };
208
+ }
209
+ if (verify.headers !== undefined && !headerRecord(verify.headers)) {
210
+ return {
211
+ ok: false,
212
+ error:
213
+ "evidence.json verify.headers must be bounded valid end-to-end HTTP headers; authority, framing, proxy, and hop-by-hop headers are forbidden",
214
+ };
215
+ }
216
+ if (
217
+ verify.body !== undefined &&
218
+ (typeof verify.body !== "string" || verify.body.length > MAX_REQUEST_BODY_CHARS)
219
+ ) {
220
+ return {
221
+ ok: false,
222
+ error: `evidence.json verify.body must be a string no longer than ${MAX_REQUEST_BODY_CHARS} characters`,
223
+ };
224
+ }
225
+ if (verify.canary !== undefined) {
226
+ if (
227
+ !isRecord(verify.canary) ||
228
+ verify.canary.mode !== "reflection" ||
229
+ verify.canary.placeholder !== POC_CANARY_PLACEHOLDER
230
+ ) {
231
+ return {
232
+ ok: false,
233
+ error: `evidence.json verify.canary must be {"mode":"reflection","placeholder":"${POC_CANARY_PLACEHOLDER}"}`,
234
+ };
235
+ }
236
+ const canaryLocations = [
237
+ verify.url,
238
+ typeof verify.body === "string" ? verify.body : "",
239
+ ...(isRecord(verify.headers)
240
+ ? Object.values(verify.headers).filter(
241
+ (value): value is string => typeof value === "string",
242
+ )
243
+ : []),
244
+ ];
245
+ const count = canaryLocations.reduce(
246
+ (total, value) => total + countOccurrences(value, POC_CANARY_PLACEHOLDER),
247
+ 0,
248
+ );
249
+ if (count !== 1) {
250
+ return {
251
+ ok: false,
252
+ error: `evidence.json verify.canary requires exactly one ${POC_CANARY_PLACEHOLDER} placeholder across url, body, or header values (got ${count})`,
253
+ };
254
+ }
255
+ }
256
+ const expect = verify.expect;
257
+ if (!isRecord(expect))
258
+ return { ok: false, error: "evidence.json verify.expect must be an object" };
259
+ if (
260
+ expect.status !== undefined &&
261
+ (!Array.isArray(expect.status) ||
262
+ expect.status.length === 0 ||
263
+ expect.status.length > MAX_EXPECT_VALUES ||
264
+ !expect.status.every((s) => Number.isInteger(s) && s >= 100 && s <= 599))
265
+ ) {
266
+ return {
267
+ ok: false,
268
+ error: "evidence.json verify.expect.status must be a non-empty array of HTTP status codes",
269
+ };
270
+ }
271
+ if (
272
+ expect.body_contains !== undefined &&
273
+ !boundedStringArray(expect.body_contains, MAX_EXPECT_VALUES, MAX_EXPECT_CHARS)
274
+ ) {
275
+ return {
276
+ ok: false,
277
+ error: `evidence.json verify.expect.body_contains must have at most ${MAX_EXPECT_VALUES} non-empty strings of at most ${MAX_EXPECT_CHARS} characters`,
278
+ };
279
+ }
280
+ if (expect.body_regex !== undefined && !validRegexArray(expect.body_regex)) {
281
+ return {
282
+ ok: false,
283
+ error: `evidence.json verify.expect.body_regex must have at most ${MAX_REGEX_VALUES} compilable patterns of at most ${MAX_REGEX_CHARS} characters`,
284
+ };
285
+ }
286
+ if (!hasMeaningfulExpectation(expect)) {
287
+ return {
288
+ ok: false,
289
+ error:
290
+ "evidence.json verify.expect must contain at least one non-empty status, body_contains, or body_regex assertion",
291
+ };
292
+ }
293
+ if (!hasBodyExpectation(expect)) {
294
+ return {
295
+ ok: false,
296
+ error:
297
+ "evidence.json verify.expect needs a non-empty body_contains or body_regex assertion; status-only differences are not vulnerability proof",
298
+ };
299
+ }
300
+ if (!hasDiscriminatingBodyExpectation(expect)) {
301
+ return {
302
+ ok: false,
303
+ error:
304
+ "evidence.json verify.expect needs a discriminating body predicate: body_contains must include at least 4 characters or body_regex must include a literal alphabetic anchor; trivial matchers are not vulnerability proof",
305
+ };
306
+ }
307
+ if (
308
+ !Array.isArray(raw.observations) ||
309
+ raw.observations.length > MAX_OBSERVATIONS ||
310
+ !raw.observations.every((o) => typeof o === "string" && o.length <= MAX_OBSERVATION_CHARS)
311
+ ) {
312
+ return { ok: false, error: "evidence.json observations exceed the bounded string-array limit" };
313
+ }
314
+ if (raw.baseline !== undefined) {
315
+ const b = raw.baseline;
316
+ if (!isRecord(b)) return { ok: false, error: "evidence.json baseline must be an object" };
317
+ if (
318
+ !nonEmptyString(b.method) ||
319
+ !HTTP_METHODS.includes(b.method.toUpperCase()) ||
320
+ !httpUrl(b.url)
321
+ ) {
322
+ return {
323
+ ok: false,
324
+ error: "evidence.json baseline needs an http(s) url and a valid HTTP method",
325
+ };
326
+ }
327
+ if (b.headers !== undefined && !headerRecord(b.headers)) {
328
+ return {
329
+ ok: false,
330
+ error:
331
+ "evidence.json baseline.headers must be bounded valid end-to-end HTTP headers; authority, framing, proxy, and hop-by-hop headers are forbidden",
332
+ };
333
+ }
334
+ if (
335
+ b.body !== undefined &&
336
+ (typeof b.body !== "string" || b.body.length > MAX_REQUEST_BODY_CHARS)
337
+ ) {
338
+ return {
339
+ ok: false,
340
+ error: `evidence.json baseline.body must be no longer than ${MAX_REQUEST_BODY_CHARS} characters`,
341
+ };
342
+ }
343
+ if (
344
+ b.body_contains !== undefined &&
345
+ !boundedStringArray(b.body_contains, MAX_EXPECT_VALUES, MAX_EXPECT_CHARS)
346
+ ) {
347
+ return { ok: false, error: "evidence.json baseline.body_contains exceeds limits" };
348
+ }
349
+ }
350
+ return { ok: true, evidence: raw as unknown as PoCEvidence };
351
+ }
352
+
353
+ /** Bind evidence to its run: the nonce must equal the harness-generated one. */
354
+ export function evidenceNonceMatches(evidence: PoCEvidence, nonce: string): boolean {
355
+ return evidence.nonce === nonce;
356
+ }
357
+
358
+ /**
359
+ * The comparator for determinism + differential checks. Strips the nonce
360
+ * (per-run by design) and observations (free-form, run-dependent) — the
361
+ * load-bearing shape is claim + verify + baseline.
362
+ */
363
+ export function normalizeEvidence(e: PoCEvidence): string {
364
+ return JSON.stringify({ claim: e.claim, verify: e.verify, baseline: e.baseline });
365
+ }
366
+
367
+ // ── Main-agent confirmation verdict ─────────────────────────────────
368
+
369
+ export const CONFIRM_VERDICT_VALUES = ["CONFIRMED", "NOT_CONFIRMED"] as const;
370
+ export type ConfirmVerdict = (typeof CONFIRM_VERDICT_VALUES)[number];
371
+
372
+ export const CONFIRM_DIFFERENTIAL_VALUES = [
373
+ "target_only",
374
+ "both",
375
+ "control_only",
376
+ "unclear",
377
+ ] as const;
378
+ export type ConfirmDifferential = (typeof CONFIRM_DIFFERENTIAL_VALUES)[number];
379
+
380
+ export const SEVERITY_MATCH_VALUES = ["under", "over", "ok"] as const;
381
+ export const CANARY_ASSESSMENT_VALUES = ["verified", "not_applicable"] as const;
382
+
383
+ export type MainAgentVerdict = {
384
+ verdict: ConfirmVerdict;
385
+ reasoning: string;
386
+ /** Files/evidence the main agent actually reviewed. */
387
+ evidence_reviewed: string[];
388
+ /** What the main agent observed during its review and fresh harness replay. */
389
+ re_execution_note?: string;
390
+ /** Target vs control evidence comparison. CONFIRMED requires target_only. */
391
+ differential: ConfirmDifferential;
392
+ /** Claimed severity vs what the evidence shows. */
393
+ severity_match?: (typeof SEVERITY_MATCH_VALUES)[number];
394
+ /** The main agent's own failed attempt to disprove — becomes the case's disconfirmation. */
395
+ disconfirmation_attempt?: string;
396
+ /** Whether the machine replay carried a harness-generated causal canary. */
397
+ canary_assessment?: (typeof CANARY_ASSESSMENT_VALUES)[number];
398
+ /** Why no meaningful canary oracle exists for this exploit class. */
399
+ canary_reason?: string;
400
+ /** Which model judged (recorded for the accuracy ledger). */
401
+ model?: string;
402
+ };
403
+
404
+ /**
405
+ * Validate the main-agent verdict. CONFIRMED additionally requires a target-only
406
+ * differential, a concrete review note, and a disconfirmation attempt. The
407
+ * ledger separately requires a fresh harness-owned phase-2 replay; there is no
408
+ * caller-supplied `re_executed` checkbox.
409
+ */
410
+ export function validateMainAgentVerdict(
411
+ raw: unknown,
412
+ ): { ok: true; verdict: MainAgentVerdict } | { ok: false; error: string } {
413
+ if (!isRecord(raw)) return { ok: false, error: "verdict must be a JSON object" };
414
+ if (!nonEmptyString(raw.verdict) || !CONFIRM_VERDICT_VALUES.includes(raw.verdict as never)) {
415
+ return {
416
+ ok: false,
417
+ error: `verdict must be one of ${CONFIRM_VERDICT_VALUES.join(" | ")}`,
418
+ };
419
+ }
420
+ if (!nonEmptyString(raw.reasoning)) return { ok: false, error: "verdict reasoning required" };
421
+ if (!Array.isArray(raw.evidence_reviewed) || raw.evidence_reviewed.length === 0) {
422
+ return { ok: false, error: "verdict evidence_reviewed must be a non-empty array" };
423
+ }
424
+ if (!raw.evidence_reviewed.every((e) => typeof e === "string")) {
425
+ return { ok: false, error: "verdict evidence_reviewed entries must be strings" };
426
+ }
427
+ if (raw.re_execution_note !== undefined && !nonEmptyString(raw.re_execution_note)) {
428
+ return { ok: false, error: "verdict re_execution_note must be a non-empty string" };
429
+ }
430
+ if (
431
+ !nonEmptyString(raw.differential) ||
432
+ !CONFIRM_DIFFERENTIAL_VALUES.includes(raw.differential as never)
433
+ ) {
434
+ return {
435
+ ok: false,
436
+ error: `verdict differential must be one of ${CONFIRM_DIFFERENTIAL_VALUES.join(" | ")}`,
437
+ };
438
+ }
439
+ if (
440
+ raw.canary_assessment !== undefined &&
441
+ !CANARY_ASSESSMENT_VALUES.includes(raw.canary_assessment as never)
442
+ ) {
443
+ return {
444
+ ok: false,
445
+ error: `verdict canary_assessment must be one of ${CANARY_ASSESSMENT_VALUES.join(" | ")}`,
446
+ };
447
+ }
448
+ if (raw.canary_reason !== undefined && !nonEmptyString(raw.canary_reason)) {
449
+ return { ok: false, error: "verdict canary_reason must be a non-empty string" };
450
+ }
451
+ if (
452
+ raw.severity_match !== undefined &&
453
+ !SEVERITY_MATCH_VALUES.includes(raw.severity_match as never)
454
+ ) {
455
+ return {
456
+ ok: false,
457
+ error: `verdict severity_match must be one of ${SEVERITY_MATCH_VALUES.join(" | ")}`,
458
+ };
459
+ }
460
+ if (raw.verdict === "CONFIRMED") {
461
+ if (raw.differential !== "target_only") {
462
+ return {
463
+ ok: false,
464
+ error:
465
+ 'CONFIRMED requires differential "target_only" — the control run must not demonstrate the claimed impact',
466
+ };
467
+ }
468
+ if (!nonEmptyString(raw.re_execution_note)) {
469
+ return {
470
+ ok: false,
471
+ error:
472
+ "CONFIRMED requires re_execution_note — record what the main agent observed during review and the fresh harness replay",
473
+ };
474
+ }
475
+ if (!nonEmptyString(raw.disconfirmation_attempt)) {
476
+ return {
477
+ ok: false,
478
+ error:
479
+ "CONFIRMED requires disconfirmation_attempt — the main agent's own failed attempt to disprove",
480
+ };
481
+ }
482
+ if (!CANARY_ASSESSMENT_VALUES.includes(raw.canary_assessment as never)) {
483
+ return {
484
+ ok: false,
485
+ error: `CONFIRMED requires canary_assessment (${CANARY_ASSESSMENT_VALUES.join(" | ")})`,
486
+ };
487
+ }
488
+ if (raw.canary_assessment === "not_applicable" && !nonEmptyString(raw.canary_reason)) {
489
+ return {
490
+ ok: false,
491
+ error: "CONFIRMED with canary_assessment not_applicable requires canary_reason",
492
+ };
493
+ }
494
+ }
495
+ return { ok: true, verdict: raw as unknown as MainAgentVerdict };
496
+ }
497
+
498
+ /** @deprecated Compatibility alias for integrations built before phase 2 became main-agent-only. */
499
+ export type ConfirmerVerdict = MainAgentVerdict;
500
+
501
+ /** @deprecated Use validateMainAgentVerdict. */
502
+ export const validateConfirmerVerdict = validateMainAgentVerdict;