@xaccefy/pi-casefile 0.10.0 → 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 +4 -3
- package/package.json +1 -1
- package/src/confirmation.ts +226 -4
- package/src/evidence.ts +127 -0
- package/src/harness-verify.ts +19 -42
- package/src/index.ts +182 -417
- package/src/ledger-internal.ts +118 -4
- package/src/ledger.ts +451 -120
- package/src/poc-runner.ts +41 -12
- package/src/scratchpad.ts +88 -143
- package/src/workflow.ts +6 -2
package/README.md
CHANGED
|
@@ -26,10 +26,11 @@ Designed for **human + AI workflows**: every confirmed finding carries a reprodu
|
|
|
26
26
|
|
|
27
27
|
| Tool | Purpose |
|
|
28
28
|
|---|---|
|
|
29
|
-
| `CaseAdd` / `CaseList` / `CaseUpdate` / `CaseContext` | case lifecycle and context
|
|
29
|
+
| `CaseAdd` / `CaseList` / `CaseSearch` / `CaseGet` / `CaseUpdate` / `CaseLink` / `CaseUnlink` / `CaseContext` | case lifecycle, search, links, and report context |
|
|
30
30
|
| `EvidenceAdd` | attach raw evidence to a case |
|
|
31
|
+
| `CoverageAdd` | record tested (asset × class) cells — found or clean |
|
|
31
32
|
| `PromoteFinding` → harness replay → `ConfirmFinding` | gated finding pipeline |
|
|
32
|
-
|
|
|
33
|
+
| `ScratchpadWrite` / `ScratchpadRead` / `ScratchpadClear` | working notes, resume-safe (no pipeline orchestration) |
|
|
33
34
|
|
|
34
35
|
## Install
|
|
35
36
|
|
|
@@ -43,7 +44,7 @@ Peer-depends on a Pi-compatible agent host (`@earendil-works/pi-coding-agent`, `
|
|
|
43
44
|
|
|
44
45
|
```bash
|
|
45
46
|
bun install
|
|
46
|
-
bun test --isolate
|
|
47
|
+
bun test --isolate
|
|
47
48
|
bun run typecheck
|
|
48
49
|
```
|
|
49
50
|
|
package/package.json
CHANGED
package/src/confirmation.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { createHash } from "node:crypto";
|
|
20
|
-
import { readFileSync } from "node:fs";
|
|
20
|
+
import { lstatSync, readFileSync, statSync } from "node:fs";
|
|
21
21
|
|
|
22
22
|
import { basename } from "node:path";
|
|
23
23
|
|
|
@@ -25,8 +25,11 @@ import {
|
|
|
25
25
|
evidenceNonceMatches,
|
|
26
26
|
type MainAgentVerdict,
|
|
27
27
|
normalizeEvidence,
|
|
28
|
+
panelQuorumReached,
|
|
28
29
|
parsePoCEvidence,
|
|
30
|
+
scanArtifactForSecrets,
|
|
29
31
|
validateMainAgentVerdict,
|
|
32
|
+
validatePanelVotes,
|
|
30
33
|
} from "./evidence.ts";
|
|
31
34
|
import { type HarnessVerifyResult, sameRequest, verifyUrlBindingError } from "./harness-verify.ts";
|
|
32
35
|
import type {
|
|
@@ -41,9 +44,11 @@ import type {
|
|
|
41
44
|
} from "./ledger.ts";
|
|
42
45
|
import { getCaseById, readWorkspaceArtifact } from "./ledger.ts";
|
|
43
46
|
import {
|
|
47
|
+
appendCaseEvent,
|
|
44
48
|
buildRecord,
|
|
45
49
|
getDb,
|
|
46
50
|
insertEvidenceItem,
|
|
51
|
+
stableShortId,
|
|
47
52
|
upsertCase,
|
|
48
53
|
validateCase,
|
|
49
54
|
withImmediateTransaction,
|
|
@@ -58,10 +63,159 @@ const PROCESS_STARTED_AS_SUBAGENT = process.env.PI_SUBAGENT_CHILD === "1";
|
|
|
58
63
|
/** Pending confirmation expires after 1h — re-run PromoteFinding for a fresh bundle. */
|
|
59
64
|
export const PENDING_CONFIRM_TTL_MS = 60 * 60 * 1000;
|
|
60
65
|
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
// ── Report contract gate (confirmed → reported) ──────────────────────
|
|
67
|
+
|
|
68
|
+
/** Typed, fail-closed error for an invalid report contract. */
|
|
69
|
+
export class ReportContractError extends Error {
|
|
70
|
+
readonly code = "REPORT_CONTRACT_INVALID";
|
|
71
|
+
readonly violations: string[];
|
|
72
|
+
|
|
73
|
+
constructor(violations: string[]) {
|
|
74
|
+
super(
|
|
75
|
+
`report contract invalid (${violations.length} violation(s)):\n- ${violations.join("\n- ")}`,
|
|
76
|
+
);
|
|
77
|
+
this.name = "ReportContractError";
|
|
78
|
+
this.violations = violations;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The closed-schema contract companion path for a report markdown path. */
|
|
83
|
+
export function reportContractPathFor(reportPath: string): string {
|
|
84
|
+
return reportPath.replace(/\.md$/i, ".contract.json");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Hard cap on the contract document — it is metadata, not a report carrier. */
|
|
88
|
+
const REPORT_CONTRACT_MAX_BYTES = 64 * 1024;
|
|
89
|
+
|
|
90
|
+
/** Keys the closed schema accepts; anything else is a violation. */
|
|
91
|
+
const REPORT_CONTRACT_KEYS = new Set([
|
|
92
|
+
"case_id",
|
|
93
|
+
"title",
|
|
94
|
+
"severity",
|
|
95
|
+
"summary",
|
|
96
|
+
"impact",
|
|
97
|
+
"remediation",
|
|
98
|
+
"steps",
|
|
99
|
+
"evidence_ids",
|
|
100
|
+
"coverage_refs",
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Validate the closed-schema report contract for a confirmed case:
|
|
105
|
+
* - a regular, non-symlink JSON file of bounded size exists at contractPath;
|
|
106
|
+
* - only schema keys are present, and the required text fields are non-empty;
|
|
107
|
+
* - evidence_ids reference ONLY evidence items that exist on this case, and
|
|
108
|
+
* include at least one observation and one reproduction item;
|
|
109
|
+
* - coverage_refs reference ONLY (asset, class) cells recorded on this case.
|
|
110
|
+
*
|
|
111
|
+
* Throws ReportContractError (fail closed) on any violation.
|
|
112
|
+
*/
|
|
113
|
+
export function validateReportContract(record: CaseRecord, contractPath: string): void {
|
|
114
|
+
const violations: string[] = [];
|
|
115
|
+
let stat: ReturnType<typeof statSync>;
|
|
116
|
+
try {
|
|
117
|
+
stat = statSync(contractPath);
|
|
118
|
+
} catch {
|
|
119
|
+
throw new ReportContractError([
|
|
120
|
+
`report contract not found: ${basename(contractPath)} (write the closed-schema JSON contract next to the report, then retry status='reported')`,
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
123
|
+
if (!stat.isFile()) violations.push("report contract path is not a regular file");
|
|
124
|
+
if (lstatSync(contractPath).isSymbolicLink()) {
|
|
125
|
+
violations.push("report contract must not be a symbolic link");
|
|
126
|
+
}
|
|
127
|
+
if (stat.size > REPORT_CONTRACT_MAX_BYTES) {
|
|
128
|
+
violations.push(
|
|
129
|
+
`report contract too large (${stat.size} bytes; max ${REPORT_CONTRACT_MAX_BYTES})`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (violations.length > 0) throw new ReportContractError(violations);
|
|
133
|
+
|
|
134
|
+
let doc: unknown;
|
|
135
|
+
try {
|
|
136
|
+
doc = JSON.parse(readFileSync(contractPath, "utf8"));
|
|
137
|
+
} catch (e) {
|
|
138
|
+
throw new ReportContractError([`report contract is not valid JSON: ${(e as Error).message}`]);
|
|
139
|
+
}
|
|
140
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
|
|
141
|
+
throw new ReportContractError(["report contract must be a JSON object"]);
|
|
142
|
+
}
|
|
143
|
+
const contract = doc as Record<string, unknown>;
|
|
144
|
+
for (const key of Object.keys(contract)) {
|
|
145
|
+
if (!REPORT_CONTRACT_KEYS.has(key)) {
|
|
146
|
+
violations.push(`unknown key "${key}" — the report contract schema is closed`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const required of ["case_id", "title", "severity", "summary", "impact", "remediation"]) {
|
|
150
|
+
const v = contract[required];
|
|
151
|
+
if (typeof v !== "string" || v.trim().length === 0) {
|
|
152
|
+
violations.push(`"${required}" must be a non-empty string`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (contract.case_id !== record.id) {
|
|
156
|
+
violations.push(`"case_id" must be ${record.id} (got ${String(contract.case_id)})`);
|
|
157
|
+
}
|
|
158
|
+
const SEVERITIES = ["info", "low", "medium", "high", "critical"];
|
|
159
|
+
if (
|
|
160
|
+
typeof contract.severity === "string" &&
|
|
161
|
+
!(record.severity
|
|
162
|
+
? contract.severity === record.severity
|
|
163
|
+
: SEVERITIES.includes(contract.severity))
|
|
164
|
+
) {
|
|
165
|
+
violations.push(
|
|
166
|
+
`"severity" must match the case severity (${record.severity ?? "unset"}) or be a valid severity`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (!Array.isArray(contract.steps) || contract.steps.length === 0) {
|
|
170
|
+
violations.push('"steps" must be a non-empty array of reproduction steps');
|
|
171
|
+
} else if (!contract.steps.every((s: unknown) => typeof s === "string" && s.trim().length > 0)) {
|
|
172
|
+
violations.push('"steps" entries must be non-empty strings');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const items = record.evidenceItems ?? [];
|
|
176
|
+
const knownIds = new Set(items.map((i) => i.id));
|
|
177
|
+
const evidenceIds = contract.evidence_ids;
|
|
178
|
+
if (!Array.isArray(evidenceIds) || evidenceIds.length === 0) {
|
|
179
|
+
violations.push('"evidence_ids" must be a non-empty array of evidence item ids');
|
|
180
|
+
} else {
|
|
181
|
+
if (!evidenceIds.every((id: unknown) => typeof id === "string" && knownIds.has(id))) {
|
|
182
|
+
violations.push('"evidence_ids" references evidence items that do not exist on this case');
|
|
183
|
+
}
|
|
184
|
+
const referenced = items.filter((i) => evidenceIds.includes(i.id));
|
|
185
|
+
if (!referenced.some((i) => i.role === "observation")) {
|
|
186
|
+
violations.push('"evidence_ids" must include at least one observation item');
|
|
187
|
+
}
|
|
188
|
+
if (!referenced.some((i) => i.role === "reproduction")) {
|
|
189
|
+
violations.push('"evidence_ids" must include at least one reproduction item');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const coverageRefs = contract.coverage_refs;
|
|
194
|
+
if (coverageRefs !== undefined && !Array.isArray(coverageRefs)) {
|
|
195
|
+
violations.push('"coverage_refs" must be an array of { asset, class } objects');
|
|
196
|
+
} else if (Array.isArray(coverageRefs)) {
|
|
197
|
+
const cells = new Set((record.coverageItems ?? []).map((c) => `${c.asset}\n${c.class}`));
|
|
198
|
+
for (const [index, ref] of coverageRefs.entries()) {
|
|
199
|
+
if (
|
|
200
|
+
typeof ref !== "object" ||
|
|
201
|
+
ref === null ||
|
|
202
|
+
typeof (ref as Record<string, unknown>).asset !== "string" ||
|
|
203
|
+
typeof (ref as Record<string, unknown>).class !== "string"
|
|
204
|
+
) {
|
|
205
|
+
violations.push(`"coverage_refs[${index}]" must be an { asset, class } object`);
|
|
206
|
+
} else if (
|
|
207
|
+
!cells.has(`${(ref as { asset: string }).asset}\n${(ref as { class: string }).class}`)
|
|
208
|
+
) {
|
|
209
|
+
violations.push(
|
|
210
|
+
`"coverage_refs[${index}]" references a coverage cell not recorded on this case`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (violations.length > 0) throw new ReportContractError(violations);
|
|
64
217
|
}
|
|
218
|
+
|
|
65
219
|
function validateRunEvidence(run: PocEvidenceRun, label: string): void {
|
|
66
220
|
if (!run.completed) {
|
|
67
221
|
throw new Error(`${label} did not complete; a crash is not evidence`);
|
|
@@ -392,9 +546,21 @@ export function storePendingConfirmation(id: string, bundle: PendingConfirmation
|
|
|
392
546
|
);
|
|
393
547
|
}
|
|
394
548
|
if (bundle.caseId !== id) throw new Error("Pending confirmation caseId mismatch");
|
|
549
|
+
// Panel vote shape is machine-checked at store time — a malformed panel
|
|
550
|
+
// must never silently count toward a quorum later.
|
|
551
|
+
if (bundle.panelVotes !== undefined) {
|
|
552
|
+
const votes = validatePanelVotes(bundle.panelVotes);
|
|
553
|
+
if (!votes.ok) throw new Error(`Pending confirmation panel invalid: ${votes.error}`);
|
|
554
|
+
}
|
|
395
555
|
if (bundle.mode === "intra_target") {
|
|
396
556
|
const next = validateIntraTargetBundle(current, id, bundle);
|
|
397
557
|
upsertCase(db, next);
|
|
558
|
+
appendCaseEvent(db, {
|
|
559
|
+
actor: "harness",
|
|
560
|
+
caseId: id,
|
|
561
|
+
eventType: "promotion_pending",
|
|
562
|
+
payload: { mode: "intra_target", evidence_sha256: bundle.targetRuns[0].evidenceSha256 },
|
|
563
|
+
});
|
|
398
564
|
return next;
|
|
399
565
|
}
|
|
400
566
|
// Control-run requirements key off controlRun PRESENCE, not the OOB flag:
|
|
@@ -493,6 +659,15 @@ export function storePendingConfirmation(id: string, bundle: PendingConfirmation
|
|
|
493
659
|
const next = buildRecord({ pendingConfirmation: bundle }, current);
|
|
494
660
|
validateCase(next);
|
|
495
661
|
upsertCase(db, next);
|
|
662
|
+
appendCaseEvent(db, {
|
|
663
|
+
actor: "harness",
|
|
664
|
+
caseId: id,
|
|
665
|
+
eventType: "promotion_pending",
|
|
666
|
+
payload: {
|
|
667
|
+
mode: bundle.callbackVerified?.attempted ? "oob" : "inter_host",
|
|
668
|
+
evidence_sha256: bundle.targetRuns[0].evidenceSha256,
|
|
669
|
+
},
|
|
670
|
+
});
|
|
496
671
|
return next;
|
|
497
672
|
});
|
|
498
673
|
}
|
|
@@ -557,6 +732,18 @@ export function applyConfirmationResult(
|
|
|
557
732
|
"CONFIRMED canary mismatch: this evidence has no canary template; record canary_assessment=not_applicable and explain why",
|
|
558
733
|
);
|
|
559
734
|
}
|
|
735
|
+
// Quorum panel pre-gate: CONFIRMED needs a 2/3 exploit panel or an
|
|
736
|
+
// explicit override note recording why the panel was skipped (or
|
|
737
|
+
// overruled). Votes are advisory — the main agent still commits — but a
|
|
738
|
+
// non-quorum CONFIRMED without a note is refused.
|
|
739
|
+
const quorum = panelQuorumReached(bundle.panelVotes);
|
|
740
|
+
if (!quorum.quorum && !verdict.panel_override_note?.trim()) {
|
|
741
|
+
throw new Error(
|
|
742
|
+
`PANEL QUORUM REQUIRED: CONFIRMED needs either a 2/3 exploit panel (got ${quorum.exploit} exploit / ${quorum.total} vote(s)) ` +
|
|
743
|
+
"or an explicit panel_override_note recording why the panel was skipped or overruled. " +
|
|
744
|
+
"Re-run PromoteFinding with panel_votes, or justify the solo confirmation in panel_override_note.",
|
|
745
|
+
);
|
|
746
|
+
}
|
|
560
747
|
}
|
|
561
748
|
const recorded: MainAgentVerdictRecord = {
|
|
562
749
|
...verdict,
|
|
@@ -594,6 +781,12 @@ export function applyConfirmationResult(
|
|
|
594
781
|
next.pendingConfirmation = undefined;
|
|
595
782
|
validateCase(next);
|
|
596
783
|
upsertCase(db, next);
|
|
784
|
+
appendCaseEvent(db, {
|
|
785
|
+
caseId: id,
|
|
786
|
+
actor: "main_agent",
|
|
787
|
+
eventType: "confirmation_verdict",
|
|
788
|
+
payload: { verdict: verdict.verdict, model: verdict.model ?? null },
|
|
789
|
+
});
|
|
597
790
|
return { record: next, changed: true };
|
|
598
791
|
}
|
|
599
792
|
|
|
@@ -665,6 +858,22 @@ export function applyConfirmationResult(
|
|
|
665
858
|
summary: `PoC evidence accepted (2 target runs + ${isIntra ? "same-host baseline" : "control"}; ${recorded.proofStrength}) — main agent semantic confirmation${verdict.model ? ` (${verdict.model})` : ""}`,
|
|
666
859
|
createdAt: targetRun.ranAt,
|
|
667
860
|
};
|
|
861
|
+
// Defense in depth: the run's evidence.json may embed secrets in
|
|
862
|
+
// observations/claim text — flag it like any other artifact.
|
|
863
|
+
if (targetRun.evidencePath) {
|
|
864
|
+
try {
|
|
865
|
+
const secretFindings = scanArtifactForSecrets(
|
|
866
|
+
readWorkspaceArtifact(targetRun.evidencePath).bytes,
|
|
867
|
+
);
|
|
868
|
+
if (secretFindings.length > 0) {
|
|
869
|
+
reproductionItem.containsSecret = true;
|
|
870
|
+
reproductionItem.secretFindings = secretFindings;
|
|
871
|
+
}
|
|
872
|
+
} catch {
|
|
873
|
+
// validateRunEvidence already proved the artifact readable; a scan
|
|
874
|
+
// failure never blocks the confirmation itself.
|
|
875
|
+
}
|
|
876
|
+
}
|
|
668
877
|
|
|
669
878
|
const newEvidence =
|
|
670
879
|
(current.evidence ? `${current.evidence}\n\n` : "") +
|
|
@@ -723,6 +932,19 @@ export function applyConfirmationResult(
|
|
|
723
932
|
validateCase(next);
|
|
724
933
|
insertEvidenceItem(db, reproductionItem);
|
|
725
934
|
upsertCase(db, next);
|
|
935
|
+
appendCaseEvent(db, {
|
|
936
|
+
caseId: id,
|
|
937
|
+
actor: "main_agent",
|
|
938
|
+
eventType: "case_confirmed",
|
|
939
|
+
payload: {
|
|
940
|
+
verdict: "CONFIRMED",
|
|
941
|
+
proof_strength: recorded.proofStrength ?? null,
|
|
942
|
+
model: verdict.model ?? null,
|
|
943
|
+
panel: panelQuorumReached(bundle.panelVotes),
|
|
944
|
+
override: verdict.panel_override_note ? true : false,
|
|
945
|
+
reproduction_evidence_id: reproductionItem.id,
|
|
946
|
+
},
|
|
947
|
+
});
|
|
726
948
|
next.evidenceItems = [...(next.evidenceItems ?? []), reproductionItem];
|
|
727
949
|
return { record: next, changed: true };
|
|
728
950
|
});
|
package/src/evidence.ts
CHANGED
|
@@ -385,6 +385,48 @@ export function normalizeEvidence(e: PoCEvidence): string {
|
|
|
385
385
|
return JSON.stringify({ claim: e.claim, verify: e.verify, baseline: e.baseline });
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
// ── Artifact secret scanning (defense in depth) ──────────────────────
|
|
389
|
+
//
|
|
390
|
+
// Evidence artifacts are raw target responses and logs — they routinely
|
|
391
|
+
// contain live credentials. The gate never blocks storage (an engaged
|
|
392
|
+
// finding must keep its proof), it FLAGS the item so every later view can
|
|
393
|
+
// redact or warn. Best-effort pattern matching only: labels are recorded,
|
|
394
|
+
// matched VALUES are never persisted by the scanner itself.
|
|
395
|
+
|
|
396
|
+
/** Label → pattern. Linear regexes only (artifacts reach 10 MiB). */
|
|
397
|
+
const SECRET_PATTERNS: ReadonlyArray<{ label: string; pattern: RegExp }> = [
|
|
398
|
+
{ label: "aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
399
|
+
{ label: "google-api-key", pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
400
|
+
{ label: "github-token", pattern: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g },
|
|
401
|
+
{ label: "slack-token", pattern: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
|
|
402
|
+
{
|
|
403
|
+
label: "private-key-block",
|
|
404
|
+
pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
|
|
405
|
+
},
|
|
406
|
+
{ label: "bearer-token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/gi },
|
|
407
|
+
{ label: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
408
|
+
{
|
|
409
|
+
label: "credential-assignment",
|
|
410
|
+
pattern:
|
|
411
|
+
/\b(?:api[_-]?key|apikey|secret|token|passwd|password)\b["']?\s*[:=]\s*["'][^"'\s]{12,}["']/gi,
|
|
412
|
+
},
|
|
413
|
+
];
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Scan artifact bytes for embedded secret material. Returns the LABELS of the
|
|
417
|
+
* patterns that matched (deduplicated, order of first match) — never the
|
|
418
|
+
* matched values themselves.
|
|
419
|
+
*/
|
|
420
|
+
export function scanArtifactForSecrets(bytes: Buffer): string[] {
|
|
421
|
+
const text = bytes.toString("utf8");
|
|
422
|
+
const labels: string[] = [];
|
|
423
|
+
for (const { label, pattern } of SECRET_PATTERNS) {
|
|
424
|
+
pattern.lastIndex = 0;
|
|
425
|
+
if (pattern.test(text)) labels.push(label);
|
|
426
|
+
}
|
|
427
|
+
return labels;
|
|
428
|
+
}
|
|
429
|
+
|
|
388
430
|
// ── Main-agent confirmation verdict ─────────────────────────────────
|
|
389
431
|
|
|
390
432
|
// INCONCLUSIVE is the fail-safe verdict: the reviewer could neither reproduce
|
|
@@ -404,6 +446,82 @@ export type ConfirmDifferential = (typeof CONFIRM_DIFFERENTIAL_VALUES)[number];
|
|
|
404
446
|
export const SEVERITY_MATCH_VALUES = ["under", "over", "ok"] as const;
|
|
405
447
|
export const CANARY_ASSESSMENT_VALUES = ["verified", "not_applicable"] as const;
|
|
406
448
|
|
|
449
|
+
// ── Quorum panel votes (advisory, CONFIRMED-blocking) ───────────────
|
|
450
|
+
|
|
451
|
+
export const PANEL_VERDICT_VALUES = ["exploit", "not_exploit", "inconclusive"] as const;
|
|
452
|
+
export type PanelVote = {
|
|
453
|
+
verdict: (typeof PANEL_VERDICT_VALUES)[number];
|
|
454
|
+
rationale: string;
|
|
455
|
+
model: string;
|
|
456
|
+
at?: string;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/** Bounded panel: enough voices for 2/3 quorum, small enough to stay cheap. */
|
|
460
|
+
const MAX_PANEL_VOTES = 5;
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Validate panel votes recorded on a promotion bundle. Votes are advisory —
|
|
464
|
+
* they gate only the CONFIRMED commit (quorum or explicit override note) —
|
|
465
|
+
* but their SHAPE is machine-checked so a malformed panel cannot silently
|
|
466
|
+
* count as a quorum.
|
|
467
|
+
*/
|
|
468
|
+
export function validatePanelVotes(
|
|
469
|
+
raw: unknown,
|
|
470
|
+
): { ok: true; votes: PanelVote[] } | { ok: false; error: string } {
|
|
471
|
+
if (!Array.isArray(raw)) return { ok: false, error: "panel_votes must be an array" };
|
|
472
|
+
if (raw.length === 0) return { ok: false, error: "panel_votes must not be empty when provided" };
|
|
473
|
+
if (raw.length > MAX_PANEL_VOTES) {
|
|
474
|
+
return { ok: false, error: `panel_votes exceeds ${MAX_PANEL_VOTES} entries` };
|
|
475
|
+
}
|
|
476
|
+
for (const [index, v] of raw.entries()) {
|
|
477
|
+
if (!isRecord(v)) return { ok: false, error: `panel_votes[${index}] must be an object` };
|
|
478
|
+
if (
|
|
479
|
+
!nonEmptyString(v.verdict) ||
|
|
480
|
+
!(PANEL_VERDICT_VALUES as readonly string[]).includes(v.verdict)
|
|
481
|
+
) {
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
error: `panel_votes[${index}].verdict must be one of ${PANEL_VERDICT_VALUES.join(" | ")}`,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
if (!nonEmptyString(v.rationale)) {
|
|
488
|
+
return { ok: false, error: `panel_votes[${index}].rationale must be a non-empty string` };
|
|
489
|
+
}
|
|
490
|
+
if (!nonEmptyString(v.model)) {
|
|
491
|
+
return { ok: false, error: `panel_votes[${index}].model must be a non-empty string` };
|
|
492
|
+
}
|
|
493
|
+
if (v.at !== undefined) {
|
|
494
|
+
if (!nonEmptyString(v.at) || !Number.isFinite(Date.parse(v.at))) {
|
|
495
|
+
return { ok: false, error: `panel_votes[${index}].at must be a parseable timestamp` };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return { ok: true, votes: raw as unknown as PanelVote[] };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Quorum rule: a panel of at least 3 votes with at least 2 exploit verdicts
|
|
504
|
+
* and exploit strictly ahead of not_exploit. Anything else — no panel, a tied
|
|
505
|
+
* panel, or a dissenting majority — requires the main agent's explicit
|
|
506
|
+
* override note to CONFIRM.
|
|
507
|
+
*/
|
|
508
|
+
export function panelQuorumReached(votes: PanelVote[] | undefined): {
|
|
509
|
+
quorum: boolean;
|
|
510
|
+
exploit: number;
|
|
511
|
+
notExploit: number;
|
|
512
|
+
total: number;
|
|
513
|
+
} {
|
|
514
|
+
const list = votes ?? [];
|
|
515
|
+
const exploit = list.filter((v) => v.verdict === "exploit").length;
|
|
516
|
+
const notExploit = list.filter((v) => v.verdict === "not_exploit").length;
|
|
517
|
+
return {
|
|
518
|
+
quorum: list.length >= 3 && exploit >= 2 && exploit > notExploit,
|
|
519
|
+
exploit,
|
|
520
|
+
notExploit,
|
|
521
|
+
total: list.length,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
407
525
|
export type MainAgentVerdict = {
|
|
408
526
|
verdict: ConfirmVerdict;
|
|
409
527
|
reasoning: string;
|
|
@@ -421,6 +539,12 @@ export type MainAgentVerdict = {
|
|
|
421
539
|
canary_assessment?: (typeof CANARY_ASSESSMENT_VALUES)[number];
|
|
422
540
|
/** Why no meaningful canary oracle exists for this exploit class. */
|
|
423
541
|
canary_reason?: string;
|
|
542
|
+
/**
|
|
543
|
+
* Why CONFIRMED proceeds without a 2/3 exploit panel quorum (no panel
|
|
544
|
+
* provisioned, panel unavailable, or documented disagreement). Required for
|
|
545
|
+
* CONFIRMED whenever quorum was not reached.
|
|
546
|
+
*/
|
|
547
|
+
panel_override_note?: string;
|
|
424
548
|
/** Which model judged (recorded for the accuracy ledger). */
|
|
425
549
|
model?: string;
|
|
426
550
|
};
|
|
@@ -472,6 +596,9 @@ export function validateMainAgentVerdict(
|
|
|
472
596
|
if (raw.canary_reason !== undefined && !nonEmptyString(raw.canary_reason)) {
|
|
473
597
|
return { ok: false, error: "verdict canary_reason must be a non-empty string" };
|
|
474
598
|
}
|
|
599
|
+
if (raw.panel_override_note !== undefined && !nonEmptyString(raw.panel_override_note)) {
|
|
600
|
+
return { ok: false, error: "verdict panel_override_note must be a non-empty string" };
|
|
601
|
+
}
|
|
475
602
|
if (
|
|
476
603
|
raw.severity_match !== undefined &&
|
|
477
604
|
!SEVERITY_MATCH_VALUES.includes(raw.severity_match as never)
|
package/src/harness-verify.ts
CHANGED
|
@@ -76,8 +76,16 @@ const REGEX_TIMEOUT_MS = 250;
|
|
|
76
76
|
|
|
77
77
|
type ResolvedAddress = { address: string; family: 4 | 6 };
|
|
78
78
|
|
|
79
|
+
/** Comparison-normalize a hostname: lowercase, strip IPv6 brackets and any trailing root dot. */
|
|
80
|
+
function normHost(hostname: string): string {
|
|
81
|
+
return hostname
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.replace(/^\[|\]$/g, "")
|
|
84
|
+
.replace(/\.$/, "");
|
|
85
|
+
}
|
|
86
|
+
|
|
79
87
|
async function resolveHost(hostname: string): Promise<ResolvedAddress[]> {
|
|
80
|
-
const host = hostname
|
|
88
|
+
const host = normHost(hostname);
|
|
81
89
|
const literalFamily = isIP(host);
|
|
82
90
|
if (literalFamily) return [{ address: host, family: literalFamily as 4 | 6 }];
|
|
83
91
|
const lookup = dnsLookup(host, { all: true, verbatim: true }) as Promise<ResolvedAddress[]>;
|
|
@@ -116,10 +124,7 @@ function sameTargetIdentity(left: string, right: string): boolean {
|
|
|
116
124
|
const a = parseNetworkTarget(left);
|
|
117
125
|
const b = parseNetworkTarget(right);
|
|
118
126
|
if (!a || !b) return false;
|
|
119
|
-
if (
|
|
120
|
-
a.url.hostname.toLowerCase().replace(/\.$/, "") !==
|
|
121
|
-
b.url.hostname.toLowerCase().replace(/\.$/, "")
|
|
122
|
-
) {
|
|
127
|
+
if (normHost(a.url.hostname) !== normHost(b.url.hostname)) {
|
|
123
128
|
return false;
|
|
124
129
|
}
|
|
125
130
|
if ((a.url.port || b.url.port) && effectivePort(a.url) !== effectivePort(b.url)) return false;
|
|
@@ -161,8 +166,8 @@ export function verifyUrlBindingError(verifyUrl: string, target: string): string
|
|
|
161
166
|
return `verify.url is not parseable: ${verifyUrl}`;
|
|
162
167
|
}
|
|
163
168
|
if (!declared) return `target is not an HTTP network target: ${target}`;
|
|
164
|
-
const declaredHost = declared.url.hostname
|
|
165
|
-
const observedHost = observed.hostname
|
|
169
|
+
const declaredHost = normHost(declared.url.hostname);
|
|
170
|
+
const observedHost = normHost(observed.hostname);
|
|
166
171
|
if (declaredHost !== observedHost) {
|
|
167
172
|
return `verify.url host ${observedHost} does not match run target ${declaredHost}`;
|
|
168
173
|
}
|
|
@@ -383,7 +388,7 @@ async function replayRequest(
|
|
|
383
388
|
};
|
|
384
389
|
}
|
|
385
390
|
const signal = AbortSignal.timeout(opts?.timeoutMs ?? TIMEOUT_MS);
|
|
386
|
-
const lockedHostname = url.hostname
|
|
391
|
+
const lockedHostname = normHost(url.hostname);
|
|
387
392
|
const fetchImpl = opts?.fetchImpl ?? harnessFetchForTest;
|
|
388
393
|
|
|
389
394
|
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
|
@@ -415,13 +420,11 @@ async function replayRequest(
|
|
|
415
420
|
note: `request errored (DNS): ${url.hostname} resolved to no addresses`,
|
|
416
421
|
};
|
|
417
422
|
}
|
|
418
|
-
} else
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
},
|
|
424
|
-
];
|
|
423
|
+
} else {
|
|
424
|
+
const host = normHost(url.hostname);
|
|
425
|
+
if (isIP(host)) {
|
|
426
|
+
addresses = [{ address: host, family: isIP(host) as 4 | 6 }];
|
|
427
|
+
}
|
|
425
428
|
}
|
|
426
429
|
if (!opts?.allowPrivate && addresses.some((address) => !isPublicIpAddress(address.address))) {
|
|
427
430
|
return {
|
|
@@ -470,7 +473,7 @@ async function replayRequest(
|
|
|
470
473
|
note: `redirected to disallowed protocol ${next.protocol}`,
|
|
471
474
|
};
|
|
472
475
|
}
|
|
473
|
-
if (next.hostname
|
|
476
|
+
if (normHost(next.hostname) !== lockedHostname) {
|
|
474
477
|
await res.body?.cancel().catch(() => undefined);
|
|
475
478
|
await fetched.close().catch(() => undefined);
|
|
476
479
|
closeFetched = undefined;
|
|
@@ -609,32 +612,6 @@ function canaryResult(
|
|
|
609
612
|
};
|
|
610
613
|
}
|
|
611
614
|
|
|
612
|
-
/**
|
|
613
|
-
* Re-send the evidence's verify request with the harness's own client and
|
|
614
|
-
* judge the response against verify.expect. Never throws — the outcome is a
|
|
615
|
-
* structured result the ledger gate interprets.
|
|
616
|
-
*/
|
|
617
|
-
export async function replayVerify(
|
|
618
|
-
evidence: PoCEvidence,
|
|
619
|
-
opts?: ReplayOptions,
|
|
620
|
-
): Promise<HarnessVerifyResult> {
|
|
621
|
-
const token = evidence.verify.canary
|
|
622
|
-
? `poc_canary_${randomBytes(24).toString("hex")}`
|
|
623
|
-
: undefined;
|
|
624
|
-
const verify = injectCanary(evidence.verify, token);
|
|
625
|
-
const target = await replayRequest(verify, verify.expect, token, opts);
|
|
626
|
-
const canary = canaryResult(token, target);
|
|
627
|
-
return {
|
|
628
|
-
attempted: target.attempted,
|
|
629
|
-
pass: target.matched === true,
|
|
630
|
-
status: target.status,
|
|
631
|
-
target,
|
|
632
|
-
canary,
|
|
633
|
-
proofStrength: canary?.pass ? "canary_differential" : "predicate_differential",
|
|
634
|
-
note: `harness replay: ${target.note}${canary ? `; ${canary.note}` : ""}`,
|
|
635
|
-
};
|
|
636
|
-
}
|
|
637
|
-
|
|
638
615
|
/**
|
|
639
616
|
* Combine the two observations into the differential verdict. Shared by the
|
|
640
617
|
* inter-host (target vs control host) and intra-target (attack vs same-host
|