@xaccefy/pi-casefile 0.9.4 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -67
- package/package.json +13 -16
- package/src/confirmation.ts +951 -0
- package/src/evidence.ts +131 -4
- package/src/harness-verify.ts +19 -42
- package/src/index.ts +362 -701
- package/src/ledger-internal.ts +435 -0
- package/src/ledger.ts +519 -1255
- package/src/oob-oracle.ts +279 -0
- package/src/poc-runner.ts +51 -12
- package/src/scratchpad.ts +92 -148
- package/src/workflow.ts +48 -325
- package/skills/casefile/SKILL.md +0 -44
- package/src/ledger-worker-entry.ts +0 -35
- package/src/ledger-worker.ts +0 -77
- package/src/pipeline-submit.ts +0 -797
package/src/evidence.ts
CHANGED
|
@@ -385,9 +385,54 @@ 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
|
|
433
|
+
// the finding nor positively disprove it. It preserves the case for manual
|
|
434
|
+
// review instead of dropping it. NOT_CONFIRMED means positively disproved.
|
|
435
|
+
export const CONFIRM_VERDICT_VALUES = ["CONFIRMED", "NOT_CONFIRMED", "INCONCLUSIVE"] as const;
|
|
391
436
|
export type ConfirmVerdict = (typeof CONFIRM_VERDICT_VALUES)[number];
|
|
392
437
|
|
|
393
438
|
export const CONFIRM_DIFFERENTIAL_VALUES = [
|
|
@@ -401,6 +446,82 @@ export type ConfirmDifferential = (typeof CONFIRM_DIFFERENTIAL_VALUES)[number];
|
|
|
401
446
|
export const SEVERITY_MATCH_VALUES = ["under", "over", "ok"] as const;
|
|
402
447
|
export const CANARY_ASSESSMENT_VALUES = ["verified", "not_applicable"] as const;
|
|
403
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
|
+
|
|
404
525
|
export type MainAgentVerdict = {
|
|
405
526
|
verdict: ConfirmVerdict;
|
|
406
527
|
reasoning: string;
|
|
@@ -418,6 +539,12 @@ export type MainAgentVerdict = {
|
|
|
418
539
|
canary_assessment?: (typeof CANARY_ASSESSMENT_VALUES)[number];
|
|
419
540
|
/** Why no meaningful canary oracle exists for this exploit class. */
|
|
420
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;
|
|
421
548
|
/** Which model judged (recorded for the accuracy ledger). */
|
|
422
549
|
model?: string;
|
|
423
550
|
};
|
|
@@ -469,6 +596,9 @@ export function validateMainAgentVerdict(
|
|
|
469
596
|
if (raw.canary_reason !== undefined && !nonEmptyString(raw.canary_reason)) {
|
|
470
597
|
return { ok: false, error: "verdict canary_reason must be a non-empty string" };
|
|
471
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
|
+
}
|
|
472
602
|
if (
|
|
473
603
|
raw.severity_match !== undefined &&
|
|
474
604
|
!SEVERITY_MATCH_VALUES.includes(raw.severity_match as never)
|
|
@@ -518,6 +648,3 @@ export function validateMainAgentVerdict(
|
|
|
518
648
|
|
|
519
649
|
/** @deprecated Compatibility alias for integrations built before phase 2 became main-agent-only. */
|
|
520
650
|
export type ConfirmerVerdict = MainAgentVerdict;
|
|
521
|
-
|
|
522
|
-
/** @deprecated Use validateMainAgentVerdict. */
|
|
523
|
-
export const validateConfirmerVerdict = validateMainAgentVerdict;
|
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
|