skalpel 4.0.44 → 4.0.46
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/anchor-shadow.mjs +428 -0
- package/card.mjs +64 -12
- package/eval-harness.mjs +421 -1
- package/first-run.mjs +722 -0
- package/package.json +1 -1
- package/skalpel-setup.mjs +8 -0
- package/verify-shadow.mjs +231 -75
package/anchor-shadow.mjs
CHANGED
|
@@ -438,6 +438,434 @@ export async function runProbe(rawTarget) {
|
|
|
438
438
|
return { pass: null, evidence: "unknown target kind" };
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
+
// ===================================================================================================
|
|
442
|
+
// SHIP-STATUS v1 ("Remote-Truth"): STRICT kind↔probe reconstruction + read-only probes.
|
|
443
|
+
//
|
|
444
|
+
// Distinct from the doubt-spiral reconstructTarget/runProbe above (which allow claim-FAMILY fallbacks
|
|
445
|
+
// to resolve a user's re-verify spiral): the ship-status CATCH must be precision-first. So here EACH
|
|
446
|
+
// ship claim kind maps to EXACTLY ONE probe family, with NO cross-kind fallback — a `pushed` claim can
|
|
447
|
+
// NEVER fall back to a PR probe (the cross-kind false-gotcha that fires "PR #n is OPEN" against a TRUE
|
|
448
|
+
// push). No target of the RIGHT kind → null → the caller stays honestly silent (NO_TARGET, never a red).
|
|
449
|
+
//
|
|
450
|
+
// Every probe is READ-ONLY, execFile arg-array (never a shell), args re-validated against strict
|
|
451
|
+
// whitelists, bounded timeout, fail-open (any error → UNKNOWN, never a lie). The `pushed` probe is the
|
|
452
|
+
// new REMOTE-TRUTH read: one `git ls-remote` (the ONLY network read) resolves the REAL remote tip, then
|
|
453
|
+
// a purely-local `git merge-base --is-ancestor` decides reachability — so a push that never left the
|
|
454
|
+
// machine is caught, while a legitimate feature-branch push is never false-accused.
|
|
455
|
+
// ===================================================================================================
|
|
456
|
+
|
|
457
|
+
// The 1:1 map the caller asserts: each ship claim kind → its single allowed probe FAMILY. `live` &
|
|
458
|
+
// `deployed` share the URL probe; `released` & `published` share the registry/tag family; the rest are
|
|
459
|
+
// unique. reconstructShipTargetV1 ALWAYS returns a target whose family is SHIP_KIND_PROBE[claim], or null.
|
|
460
|
+
export const SHIP_KIND_PROBE = {
|
|
461
|
+
pushed: "git-remote",
|
|
462
|
+
merged: "pr",
|
|
463
|
+
live: "url",
|
|
464
|
+
deployed: "url",
|
|
465
|
+
released: "registry",
|
|
466
|
+
published: "registry",
|
|
467
|
+
ci: "gh-checks",
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// shipProbeFamily(targetKind) → the probe FAMILY a concrete target belongs to (npm & git-tag are both
|
|
471
|
+
// the registry/tag family). Used to assert reconstruction never crosses kinds.
|
|
472
|
+
export function shipProbeFamily(kind) {
|
|
473
|
+
if (kind === "npm" || kind === "git-tag") return "registry";
|
|
474
|
+
return kind; // git-remote | pr | url | gh-checks
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Strict arg whitelists (defense in depth — execFile already means no shell).
|
|
478
|
+
const SHIP_SAFE_REF = /^[A-Za-z0-9._@/+-]{1,120}$/; // git remote/branch/tag name
|
|
479
|
+
const SHIP_SHA_RE = /^[0-9a-f]{7,40}$/;
|
|
480
|
+
const SHIP_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
481
|
+
const SHIP_NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
|
|
482
|
+
const SHIP_NET_TIMEOUT_MS = 10000; // network probes (ls-remote / npm view / gh) get a touch more than 5s
|
|
483
|
+
|
|
484
|
+
// findPushBranch(text) → the LAST `git push [<remote>] [<branch>]` seen in context, as {remote, branch}
|
|
485
|
+
// (either may be null). Used ONLY to name the branch/remote whose remote tip the `pushed` probe reads —
|
|
486
|
+
// never executed. A refspec `local:remote` keeps the remote-side ref. Both fields strictly validated.
|
|
487
|
+
function findPushBranch(text) {
|
|
488
|
+
const re = /\bgit\s+push\b([^\n;&|]*)/gi;
|
|
489
|
+
let last = null;
|
|
490
|
+
let m;
|
|
491
|
+
while ((m = re.exec(text))) {
|
|
492
|
+
const pos = m[1]
|
|
493
|
+
.trim()
|
|
494
|
+
.split(/\s+/)
|
|
495
|
+
.filter((t) => t && !t.startsWith("-"));
|
|
496
|
+
if (pos.length >= 2) last = { remote: pos[0], branch: pos[1] };
|
|
497
|
+
else if (pos.length === 1) last = { remote: pos[0], branch: null };
|
|
498
|
+
else last = { remote: null, branch: null };
|
|
499
|
+
}
|
|
500
|
+
if (!last) return null;
|
|
501
|
+
let branch = last.branch;
|
|
502
|
+
if (branch && branch.includes(":")) branch = branch.split(":").pop(); // refspec local:remote → remote
|
|
503
|
+
const remote = last.remote && SHIP_SAFE_REF.test(last.remote) ? last.remote : null;
|
|
504
|
+
branch = branch && SHIP_SAFE_REF.test(branch) ? branch : null;
|
|
505
|
+
return { remote, branch };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// findTag(text) → a release tag (vX.Y.Z or a `git tag <t>` / `git push origin <t>` mention). Validated.
|
|
509
|
+
function findTag(text) {
|
|
510
|
+
const near = /\bgit\s+(?:tag|push[^\n;&|]*?)\s+(v?\d+\.\d+\.\d+[0-9A-Za-z.\-+]*)\b/i.exec(text);
|
|
511
|
+
if (near && SHIP_SAFE_REF.test(near[1])) return near[1];
|
|
512
|
+
const m = /\b(v\d+\.\d+\.\d+[0-9A-Za-z.\-+]*)\b/.exec(text);
|
|
513
|
+
return m && SHIP_SAFE_REF.test(m[1]) ? m[1] : null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// findNpmVersion(text) → a semver version string near a publish/version mention (strict semver). The
|
|
517
|
+
// npm target's NAME is read from the session's own package.json — never from claim/prompt content.
|
|
518
|
+
function findNpmVersion(text) {
|
|
519
|
+
const near =
|
|
520
|
+
/\b(?:publish|version|v)\s*@?\s*(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/i.exec(
|
|
521
|
+
text,
|
|
522
|
+
) || /@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/.exec(text);
|
|
523
|
+
if (near && SHIP_SEMVER_RE.test(near[1])) return near[1];
|
|
524
|
+
const m = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/.exec(text);
|
|
525
|
+
return m && SHIP_SEMVER_RE.test(m[1]) ? m[1] : null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// readPkgName(repoDir) → the "name" field of the session's own package.json, validated against npm's
|
|
529
|
+
// package-name rules, or null. This is the ONLY source of the published package name (never the claim).
|
|
530
|
+
function readPkgName(repoDir) {
|
|
531
|
+
try {
|
|
532
|
+
if (!repoDir) return null;
|
|
533
|
+
const name = JSON.parse(readFileSync(join(repoDir, "package.json"), "utf8"))?.name;
|
|
534
|
+
return typeof name === "string" && SHIP_NPM_NAME_RE.test(name) ? name : null;
|
|
535
|
+
} catch {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// reconstructShipTargetV1(claim, contextText, payload) → the STRICT, single-family target for a ship
|
|
541
|
+
// claim, or null. NO cross-kind fallback: a kind that can't reconstruct ITS OWN target returns null.
|
|
542
|
+
export function reconstructShipTargetV1(claim, contextText, payload) {
|
|
543
|
+
const text = String(contextText || "");
|
|
544
|
+
const repoDir = repoDirOf(payload);
|
|
545
|
+
const family = SHIP_KIND_PROBE[claim];
|
|
546
|
+
if (!family) return null;
|
|
547
|
+
switch (family) {
|
|
548
|
+
case "git-remote": {
|
|
549
|
+
// pushed → REMOTE-TRUTH. Needs the claimed sha + a repo. The branch (from the session's own
|
|
550
|
+
// `git push`, else resolved at probe time from the upstream) is optional here; ambiguity that
|
|
551
|
+
// can't be resolved becomes UNKNOWN in the probe, never a false red.
|
|
552
|
+
const sha = findSha(text);
|
|
553
|
+
if (!sha || !repoDir) return null;
|
|
554
|
+
const pb = findPushBranch(text) || {};
|
|
555
|
+
return {
|
|
556
|
+
kind: "git-remote",
|
|
557
|
+
sha,
|
|
558
|
+
remote: pb.remote || null,
|
|
559
|
+
branch: pb.branch || null,
|
|
560
|
+
repoDir,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
case "pr": {
|
|
564
|
+
// merged → the PR probe ONLY. No PR number in context → null (never a git/url fallback).
|
|
565
|
+
const n = findPr(text);
|
|
566
|
+
return n != null ? { kind: "pr", n, repoDir } : null;
|
|
567
|
+
}
|
|
568
|
+
case "url": {
|
|
569
|
+
// live/deployed → the URL probe ONLY.
|
|
570
|
+
const url = findUrl(text);
|
|
571
|
+
return url ? { kind: "url", url } : null;
|
|
572
|
+
}
|
|
573
|
+
case "registry": {
|
|
574
|
+
// released/published → the registry/tag family ONLY. Prefer npm when the session actually ran
|
|
575
|
+
// `npm publish` AND both the package name (from package.json) and a semver version reconstruct;
|
|
576
|
+
// else a git tag if one is present. Neither → null.
|
|
577
|
+
if (/\bnpm\s+publish\b/i.test(text)) {
|
|
578
|
+
const name = readPkgName(repoDir);
|
|
579
|
+
const version = findNpmVersion(text);
|
|
580
|
+
if (name && version) return { kind: "npm", name, version };
|
|
581
|
+
}
|
|
582
|
+
const tag = findTag(text);
|
|
583
|
+
if (tag && repoDir) {
|
|
584
|
+
const pb = findPushBranch(text) || {};
|
|
585
|
+
return { kind: "git-tag", tag, remote: pb.remote || "origin", repoDir };
|
|
586
|
+
}
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
case "gh-checks": {
|
|
590
|
+
// "CI green/passing" → gh checks ONLY (a PR's checks, else a commit's runs).
|
|
591
|
+
const n = findPr(text);
|
|
592
|
+
if (n != null) return { kind: "gh-checks", n, repoDir };
|
|
593
|
+
const sha = findSha(text);
|
|
594
|
+
if (sha) return { kind: "gh-checks", sha, repoDir };
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
default:
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// validateShipTargetV1(t) → re-validate every field of a ship-v1 target before it is passed to execFile
|
|
603
|
+
// (defense in depth). Returns a normalized target or null. Never throws.
|
|
604
|
+
function validateShipTargetV1(t) {
|
|
605
|
+
if (!t || typeof t !== "object") return null;
|
|
606
|
+
switch (t.kind) {
|
|
607
|
+
case "url":
|
|
608
|
+
return isSafeUrl(t.url) ? { kind: "url", url: t.url } : null;
|
|
609
|
+
case "pr": {
|
|
610
|
+
const n = Number(t.n);
|
|
611
|
+
if (!Number.isInteger(n) || n <= 0 || n >= 1e7) return null;
|
|
612
|
+
return { kind: "pr", n, repoDir: validDir(t.repoDir) };
|
|
613
|
+
}
|
|
614
|
+
case "git-remote": {
|
|
615
|
+
if (!SHIP_SHA_RE.test(String(t.sha || ""))) return null;
|
|
616
|
+
const repoDir = validDir(t.repoDir);
|
|
617
|
+
if (!repoDir) return null;
|
|
618
|
+
const remote = t.remote && SHIP_SAFE_REF.test(t.remote) ? t.remote : "origin";
|
|
619
|
+
const branch = t.branch && SHIP_SAFE_REF.test(t.branch) ? t.branch : null;
|
|
620
|
+
return { kind: "git-remote", sha: String(t.sha).toLowerCase(), remote, branch, repoDir };
|
|
621
|
+
}
|
|
622
|
+
case "npm": {
|
|
623
|
+
if (
|
|
624
|
+
!SHIP_NPM_NAME_RE.test(String(t.name || "")) ||
|
|
625
|
+
!SHIP_SEMVER_RE.test(String(t.version || ""))
|
|
626
|
+
)
|
|
627
|
+
return null;
|
|
628
|
+
return { kind: "npm", name: t.name, version: t.version };
|
|
629
|
+
}
|
|
630
|
+
case "git-tag": {
|
|
631
|
+
const repoDir = validDir(t.repoDir);
|
|
632
|
+
if (!repoDir || !SHIP_SAFE_REF.test(String(t.tag || ""))) return null;
|
|
633
|
+
const remote = t.remote && SHIP_SAFE_REF.test(t.remote) ? t.remote : "origin";
|
|
634
|
+
return { kind: "git-tag", tag: t.tag, remote, repoDir };
|
|
635
|
+
}
|
|
636
|
+
case "gh-checks": {
|
|
637
|
+
const repoDir = validDir(t.repoDir);
|
|
638
|
+
const n = t.n != null ? Number(t.n) : null;
|
|
639
|
+
const sha = t.sha ? String(t.sha).toLowerCase() : null;
|
|
640
|
+
if (n != null && Number.isInteger(n) && n > 0 && n < 1e7)
|
|
641
|
+
return { kind: "gh-checks", n, repoDir };
|
|
642
|
+
if (sha && SHIP_SHA_RE.test(sha)) return { kind: "gh-checks", sha, repoDir };
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
default:
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// classifyHttpVerdict(code) → the Defect-3 HTTP precision map. A responding server is REACHABLE; only a
|
|
651
|
+
// genuine "gone" (404/410) is a FAIL CANDIDATE (never an immediate red — the caller confirm-reprobes).
|
|
652
|
+
// 401/403/405 (auth-walled), 429 (rate-limit), 5xx (transient), and any other response → UNKNOWN.
|
|
653
|
+
export function classifyHttpVerdict(code) {
|
|
654
|
+
if (!Number.isFinite(code) || code === 0) return "UNKNOWN";
|
|
655
|
+
if (code >= 200 && code < 400) return "PASS";
|
|
656
|
+
if (code === 404 || code === 410) return "FAIL_CANDIDATE";
|
|
657
|
+
return "UNKNOWN";
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// parseLsRemoteTip(stdout, branch) → the 40-hex tip sha for refs/heads/<branch> (or the sole ref line),
|
|
661
|
+
// else null. `git ls-remote <remote> <branch>` prints `<sha>\t<ref>` lines.
|
|
662
|
+
function parseLsRemoteTip(stdout, branch) {
|
|
663
|
+
const lines = String(stdout || "").split("\n");
|
|
664
|
+
for (const line of lines) {
|
|
665
|
+
const mm = line.match(/^([0-9a-f]{40})\s+(\S+)/i);
|
|
666
|
+
if (mm && (mm[2] === `refs/heads/${branch}` || mm[2] === "HEAD")) return mm[1].toLowerCase();
|
|
667
|
+
}
|
|
668
|
+
const first = String(stdout || "").match(/^([0-9a-f]{40})\b/im);
|
|
669
|
+
return first ? first[1].toLowerCase() : null;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// runShipProbeV1(target, exec) → ONE read-only probe for the strict target. Returns
|
|
673
|
+
// { verdict, evidence } where verdict ∈ PASS | FAIL | FAIL_CANDIDATE | UNKNOWN. `exec` is injectable
|
|
674
|
+
// (defaults to the real read-only `run`) purely so the eval harness can drive it hermetically. Never throws.
|
|
675
|
+
export async function runShipProbeV1(rawTarget, exec = run) {
|
|
676
|
+
const t = validateShipTargetV1(rawTarget);
|
|
677
|
+
if (!t) return { verdict: "UNKNOWN", evidence: "rejected: target failed validation" };
|
|
678
|
+
try {
|
|
679
|
+
if (t.kind === "url") {
|
|
680
|
+
// curl -sS -m 5 -o /dev/null -w "%{http_code}" <url> (READ-ONLY). curl exits 0 on ANY HTTP
|
|
681
|
+
// status; a non-zero exit is a transport error (6=NXDOMAIN, 7=refused → fail candidate; else unknown).
|
|
682
|
+
const { err, stdout } = await exec(
|
|
683
|
+
"curl",
|
|
684
|
+
["-sS", "-m", "5", "-o", "/dev/null", "-w", "%{http_code}", t.url],
|
|
685
|
+
{ timeout: SHIP_NET_TIMEOUT_MS },
|
|
686
|
+
);
|
|
687
|
+
if (err) {
|
|
688
|
+
const c = err.code;
|
|
689
|
+
if (c === 6)
|
|
690
|
+
return { verdict: "FAIL_CANDIDATE", evidence: "curl: could not resolve host (NXDOMAIN)" };
|
|
691
|
+
if (c === 7) return { verdict: "FAIL_CANDIDATE", evidence: "curl: connection refused" };
|
|
692
|
+
return {
|
|
693
|
+
verdict: "UNKNOWN",
|
|
694
|
+
evidence: clip(`curl error: ${err.killed ? "timeout" : err.message || c}`),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
const code = parseInt(stdout, 10);
|
|
698
|
+
const verdict = classifyHttpVerdict(code);
|
|
699
|
+
const evidence =
|
|
700
|
+
Number.isFinite(code) && code !== 0 ? `http_code=${code}` : clip(`no http code: ${stdout}`);
|
|
701
|
+
return { verdict, evidence };
|
|
702
|
+
}
|
|
703
|
+
if (t.kind === "pr") {
|
|
704
|
+
// gh pr view <n> --json state,mergedAt (READ-ONLY, DECISIVE)
|
|
705
|
+
const opts = t.repoDir ? { cwd: t.repoDir } : {};
|
|
706
|
+
const { err, stdout } = await exec(
|
|
707
|
+
"gh",
|
|
708
|
+
["pr", "view", String(t.n), "--json", "state,mergedAt"],
|
|
709
|
+
opts,
|
|
710
|
+
);
|
|
711
|
+
if (err)
|
|
712
|
+
return {
|
|
713
|
+
verdict: "UNKNOWN",
|
|
714
|
+
evidence: clip(`gh error: ${err.killed ? "timeout" : err.message}`),
|
|
715
|
+
};
|
|
716
|
+
let j;
|
|
717
|
+
try {
|
|
718
|
+
j = JSON.parse(stdout);
|
|
719
|
+
} catch {
|
|
720
|
+
return { verdict: "UNKNOWN", evidence: clip(`gh non-json: ${stdout}`) };
|
|
721
|
+
}
|
|
722
|
+
const merged = j.state === "MERGED" || !!j.mergedAt;
|
|
723
|
+
return {
|
|
724
|
+
verdict: merged ? "PASS" : "FAIL",
|
|
725
|
+
evidence: clip(`state=${j.state} mergedAt=${j.mergedAt || "null"}`),
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
if (t.kind === "git-remote") {
|
|
729
|
+
// REMOTE-TRUTH. Resolve the branch (from the push, else the upstream / current branch), read the
|
|
730
|
+
// REAL remote tip with ONE `git ls-remote`, then decide reachability locally with merge-base.
|
|
731
|
+
const remote = t.remote || "origin";
|
|
732
|
+
let branch = t.branch;
|
|
733
|
+
if (!branch) {
|
|
734
|
+
const up = await exec(
|
|
735
|
+
"git",
|
|
736
|
+
["-C", t.repoDir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
|
737
|
+
{ timeout: SHIP_NET_TIMEOUT_MS },
|
|
738
|
+
);
|
|
739
|
+
if (!up.err) {
|
|
740
|
+
const ref = String(up.stdout || "").trim(); // e.g. origin/feat-x
|
|
741
|
+
const idx = ref.indexOf("/");
|
|
742
|
+
if (idx > 0) branch = ref.slice(idx + 1);
|
|
743
|
+
}
|
|
744
|
+
if (!branch) {
|
|
745
|
+
const cur = await exec("git", ["-C", t.repoDir, "symbolic-ref", "--short", "HEAD"], {
|
|
746
|
+
timeout: SHIP_NET_TIMEOUT_MS,
|
|
747
|
+
});
|
|
748
|
+
if (!cur.err) branch = String(cur.stdout || "").trim() || null;
|
|
749
|
+
}
|
|
750
|
+
if (!branch || !SHIP_SAFE_REF.test(branch))
|
|
751
|
+
return { verdict: "UNKNOWN", evidence: "git-remote: branch unresolved (ambiguous)" };
|
|
752
|
+
}
|
|
753
|
+
const ls = await exec("git", ["-C", t.repoDir, "ls-remote", remote, branch], {
|
|
754
|
+
timeout: SHIP_NET_TIMEOUT_MS,
|
|
755
|
+
});
|
|
756
|
+
if (ls.err)
|
|
757
|
+
return {
|
|
758
|
+
verdict: "UNKNOWN",
|
|
759
|
+
evidence: clip(`ls-remote error: ${ls.err.killed ? "timeout" : ls.err.message}`),
|
|
760
|
+
};
|
|
761
|
+
const tip = parseLsRemoteTip(ls.stdout, branch);
|
|
762
|
+
if (!tip)
|
|
763
|
+
return { verdict: "UNKNOWN", evidence: clip(`no remote tip for ${remote}/${branch}`) };
|
|
764
|
+
const mb = await exec("git", ["-C", t.repoDir, "merge-base", "--is-ancestor", t.sha, tip], {
|
|
765
|
+
timeout: SHIP_NET_TIMEOUT_MS,
|
|
766
|
+
});
|
|
767
|
+
const evid = `sha=${t.sha.slice(0, 12)} branch=${remote}/${branch} remoteTip=${tip.slice(0, 12)}`;
|
|
768
|
+
// exit 0 → reachable (PASS). exit 1 → both objects known AND not reachable (DECISIVE FAIL — it
|
|
769
|
+
// never left the machine). Any other exit → a commit is unknown locally → UNKNOWN (never a lie).
|
|
770
|
+
if (!mb.err) return { verdict: "PASS", evidence: clip(`${evid} ancestor`) };
|
|
771
|
+
if (mb.err.code === 1) return { verdict: "FAIL", evidence: clip(`${evid} not-ancestor`) };
|
|
772
|
+
return {
|
|
773
|
+
verdict: "UNKNOWN",
|
|
774
|
+
evidence: clip(`${evid} merge-base indeterminate (tip/sha unknown locally)`),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
if (t.kind === "npm") {
|
|
778
|
+
// npm view <name>@<version> version (READ-ONLY registry read, DECISIVE)
|
|
779
|
+
const { err, stdout, stderr } = await exec(
|
|
780
|
+
"npm",
|
|
781
|
+
["view", `${t.name}@${t.version}`, "version"],
|
|
782
|
+
{
|
|
783
|
+
timeout: SHIP_NET_TIMEOUT_MS,
|
|
784
|
+
},
|
|
785
|
+
);
|
|
786
|
+
if (!err) {
|
|
787
|
+
return String(stdout || "").trim()
|
|
788
|
+
? {
|
|
789
|
+
verdict: "PASS",
|
|
790
|
+
evidence: clip(`npm ${t.name}@${t.version} = ${String(stdout).trim()}`),
|
|
791
|
+
}
|
|
792
|
+
: { verdict: "UNKNOWN", evidence: clip(`npm empty for ${t.name}@${t.version}`) };
|
|
793
|
+
}
|
|
794
|
+
const combined = `${stderr || ""} ${stdout || ""}`;
|
|
795
|
+
if (/E404|No match found|is not in (?:this|the)/i.test(combined))
|
|
796
|
+
return { verdict: "FAIL", evidence: clip(`npm: ${t.name}@${t.version} not on registry`) };
|
|
797
|
+
return {
|
|
798
|
+
verdict: "UNKNOWN",
|
|
799
|
+
evidence: clip(`npm error: ${err.killed ? "timeout" : err.message}`),
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
if (t.kind === "git-tag") {
|
|
803
|
+
// git -C <repo> ls-remote --tags <remote> <tag> (READ-ONLY, DECISIVE)
|
|
804
|
+
const { err, stdout } = await exec(
|
|
805
|
+
"git",
|
|
806
|
+
["-C", t.repoDir, "ls-remote", "--tags", t.remote, t.tag],
|
|
807
|
+
{
|
|
808
|
+
timeout: SHIP_NET_TIMEOUT_MS,
|
|
809
|
+
},
|
|
810
|
+
);
|
|
811
|
+
if (err)
|
|
812
|
+
return {
|
|
813
|
+
verdict: "UNKNOWN",
|
|
814
|
+
evidence: clip(`ls-remote --tags error: ${err.killed ? "timeout" : err.message}`),
|
|
815
|
+
};
|
|
816
|
+
const esc = t.tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
817
|
+
const has = new RegExp(`\\brefs/tags/${esc}(?:\\^\\{\\})?$`, "m").test(String(stdout || ""));
|
|
818
|
+
return has
|
|
819
|
+
? { verdict: "PASS", evidence: clip(`tag ${t.remote}/${t.tag} present`) }
|
|
820
|
+
: { verdict: "FAIL", evidence: clip(`tag ${t.tag} not on ${t.remote}`) };
|
|
821
|
+
}
|
|
822
|
+
if (t.kind === "gh-checks") {
|
|
823
|
+
// gh pr checks <n> --json ... / gh run list --commit <sha> --json ... (READ-ONLY). gh prints
|
|
824
|
+
// JSON even on a non-zero exit (failing/pending), so we parse stdout regardless of err.
|
|
825
|
+
const opts = t.repoDir ? { cwd: t.repoDir } : {};
|
|
826
|
+
let err, stdout;
|
|
827
|
+
if (t.n != null) {
|
|
828
|
+
({ err, stdout } = await exec(
|
|
829
|
+
"gh",
|
|
830
|
+
["pr", "checks", String(t.n), "--json", "state,bucket"],
|
|
831
|
+
opts,
|
|
832
|
+
));
|
|
833
|
+
} else {
|
|
834
|
+
({ err, stdout } = await exec(
|
|
835
|
+
"gh",
|
|
836
|
+
["run", "list", "--commit", t.sha, "--json", "conclusion,status"],
|
|
837
|
+
opts,
|
|
838
|
+
));
|
|
839
|
+
}
|
|
840
|
+
const out = String(stdout || "");
|
|
841
|
+
if (err && !out.trim())
|
|
842
|
+
return {
|
|
843
|
+
verdict: "UNKNOWN",
|
|
844
|
+
evidence: clip(`gh checks error: ${err.killed ? "timeout" : err.message}`),
|
|
845
|
+
};
|
|
846
|
+
let arr;
|
|
847
|
+
try {
|
|
848
|
+
arr = JSON.parse(out);
|
|
849
|
+
} catch {
|
|
850
|
+
return { verdict: "UNKNOWN", evidence: clip(`gh checks non-json: ${out}`) };
|
|
851
|
+
}
|
|
852
|
+
if (!Array.isArray(arr) || !arr.length)
|
|
853
|
+
return { verdict: "UNKNOWN", evidence: "gh checks: none reported" };
|
|
854
|
+
const norm = arr.map((c) =>
|
|
855
|
+
String((c && (c.bucket || c.conclusion || c.state || c.status)) || "").toLowerCase(),
|
|
856
|
+
);
|
|
857
|
+
if (norm.some((s) => /fail|error|cancel|timed_out|action_required/.test(s)))
|
|
858
|
+
return { verdict: "FAIL", evidence: clip(`checks: ${norm.join(",")}`) };
|
|
859
|
+
if (norm.some((s) => s === "" || /pending|queued|in_progress|waiting|requested/.test(s)))
|
|
860
|
+
return { verdict: "UNKNOWN", evidence: clip(`checks pending: ${norm.join(",")}`) };
|
|
861
|
+
return { verdict: "PASS", evidence: clip(`checks: ${norm.join(",")}`) };
|
|
862
|
+
}
|
|
863
|
+
} catch (e) {
|
|
864
|
+
return { verdict: "UNKNOWN", evidence: clip(`probe threw: ${e}`) };
|
|
865
|
+
}
|
|
866
|
+
return { verdict: "UNKNOWN", evidence: "unknown ship target kind" };
|
|
867
|
+
}
|
|
868
|
+
|
|
441
869
|
// ---------------------------------------------------------------------------------------------------
|
|
442
870
|
// Logging — the ONLY side effect. Append-only NDJSON at ~/.skalpel/anchor-shadow.log. Never throws.
|
|
443
871
|
// ---------------------------------------------------------------------------------------------------
|
package/card.mjs
CHANGED
|
@@ -69,7 +69,20 @@ export function isGenuineCatch(row) {
|
|
|
69
69
|
// Ship-status catches (category:"ship", outcome SHIP_FAIL) record the check under probe_command, not
|
|
70
70
|
// proof_command — accept either so a genuine "you said deployed, it's 404" catch is not invisible.
|
|
71
71
|
const command = row.proof_command || row.probe_command;
|
|
72
|
-
|
|
72
|
+
if (!failed || !command || !row.claim_text) return false;
|
|
73
|
+
// RETRO catches (category:"retro") are RECORDED at claim time — never re-run. The whole indictment rests
|
|
74
|
+
// on the exit code the transcript already recorded, so a retro row is a genuine catch ONLY when it carries
|
|
75
|
+
// that recorded non-zero exit code; without it we can't render the "already failed" provenance truthfully.
|
|
76
|
+
if (row.category === "retro") {
|
|
77
|
+
return Number.isInteger(row.recorded_exit_code) && row.recorded_exit_code !== 0;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A retro row is RECORDED at claim time (from the user's own transcript), never re-run — so its wording
|
|
83
|
+
// must never borrow the live card's "skalpel re-ran its own proof" language.
|
|
84
|
+
function isRetro(row) {
|
|
85
|
+
return row && row.category === "retro";
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
// readCatches() → all genuine-catch rows in the shadow log, oldest→newest as written. Fail-open: any
|
|
@@ -133,9 +146,17 @@ export function extractCode(evidence) {
|
|
|
133
146
|
|
|
134
147
|
// verdictPhrase(row) → the honest one-line verdict. Always truthful for a catch row.
|
|
135
148
|
function verdictPhrase(row) {
|
|
136
|
-
const code = extractCode(row.evidence);
|
|
137
149
|
const failN = extractFailCount(row.evidence);
|
|
138
150
|
const count = failN != null ? ` · ${failN} failing` : "";
|
|
151
|
+
// RETRO: the exit code was RECORDED in the transcript at claim time — we never re-ran it, so the verdict
|
|
152
|
+
// must say "had already failed", never "re-ran". recorded_exit_code is a required field for a retro catch.
|
|
153
|
+
if (isRetro(row)) {
|
|
154
|
+
const n = Number.isInteger(row.recorded_exit_code)
|
|
155
|
+
? ` (recorded exit ${row.recorded_exit_code})`
|
|
156
|
+
: "";
|
|
157
|
+
return `FAIL — its own test had already failed when it said this${n}${count}`;
|
|
158
|
+
}
|
|
159
|
+
const code = extractCode(row.evidence);
|
|
139
160
|
if (code && code.kind === "http") return `FAIL — its own proof got HTTP ${code.value}${count}`;
|
|
140
161
|
if (code && code.kind === "exit") return `FAIL — its own proof exited ${code.value}${count}`;
|
|
141
162
|
return `FAIL — its own proof re-ran and exited non-zero${count}`;
|
|
@@ -152,13 +173,18 @@ export function renderPlainCard(row) {
|
|
|
152
173
|
const claim = clean(row.claim_text, 200) || "it's done";
|
|
153
174
|
const proof = clean(row.proof_command || row.probe_command, 200) || "its own proof";
|
|
154
175
|
const evidence = clean(row.evidence, 200);
|
|
176
|
+
const retro = isRetro(row);
|
|
155
177
|
const L = [];
|
|
156
|
-
L.push("🔬 skalpel — verified catch");
|
|
178
|
+
L.push(retro ? "🔬 skalpel — recorded catch" : "🔬 skalpel — verified catch");
|
|
157
179
|
L.push("");
|
|
158
180
|
L.push("The agent claimed:");
|
|
159
181
|
L.push(` "${claim}"`);
|
|
160
182
|
L.push("");
|
|
161
|
-
L.push(
|
|
183
|
+
L.push(
|
|
184
|
+
retro
|
|
185
|
+
? "Its own test had already failed when it said this — recorded in your transcript at claim time:"
|
|
186
|
+
: "skalpel re-ran the agent's OWN proof, out-of-band:",
|
|
187
|
+
);
|
|
162
188
|
L.push(` $ ${proof}`);
|
|
163
189
|
L.push(` → ${verdictPhrase(row)}`);
|
|
164
190
|
if (evidence) {
|
|
@@ -167,8 +193,16 @@ export function renderPlainCard(row) {
|
|
|
167
193
|
L.push(` ${evidence}`);
|
|
168
194
|
}
|
|
169
195
|
L.push("");
|
|
170
|
-
L.push(
|
|
171
|
-
|
|
196
|
+
L.push(
|
|
197
|
+
retro
|
|
198
|
+
? `recorded ${stamp(row.ts)} (your transcript, at claim time)`
|
|
199
|
+
: `caught ${stamp(row.ts)}`,
|
|
200
|
+
);
|
|
201
|
+
L.push(
|
|
202
|
+
retro
|
|
203
|
+
? "The agent said done. Its own test, recorded at that moment, had already failed."
|
|
204
|
+
: "The agent said done. Its own proof, re-run, disagreed.",
|
|
205
|
+
);
|
|
172
206
|
return L.join("\n");
|
|
173
207
|
}
|
|
174
208
|
|
|
@@ -177,13 +211,18 @@ export function renderMarkdownCard(row) {
|
|
|
177
211
|
const claim = clean(row.claim_text, 200) || "it's done";
|
|
178
212
|
const proof = clean(row.proof_command || row.probe_command, 200) || "its own proof";
|
|
179
213
|
const evidence = clean(row.evidence, 200);
|
|
214
|
+
const retro = isRetro(row);
|
|
180
215
|
const FENCE = "```";
|
|
181
216
|
const L = [];
|
|
182
|
-
L.push("**🔬 skalpel — verified catch**");
|
|
217
|
+
L.push(retro ? "**🔬 skalpel — recorded catch**" : "**🔬 skalpel — verified catch**");
|
|
183
218
|
L.push("");
|
|
184
219
|
L.push(`> The agent claimed: _"${claim}"_`);
|
|
185
220
|
L.push("");
|
|
186
|
-
L.push(
|
|
221
|
+
L.push(
|
|
222
|
+
retro
|
|
223
|
+
? "Its own test had already failed when it said this — recorded in your transcript at claim time:"
|
|
224
|
+
: "skalpel re-ran the agent's own proof, out-of-band:",
|
|
225
|
+
);
|
|
187
226
|
L.push(FENCE);
|
|
188
227
|
L.push(`$ ${proof}`);
|
|
189
228
|
L.push(`→ ${verdictPhrase(row)}`);
|
|
@@ -194,7 +233,11 @@ export function renderMarkdownCard(row) {
|
|
|
194
233
|
L.push(evidence);
|
|
195
234
|
L.push(FENCE);
|
|
196
235
|
}
|
|
197
|
-
L.push(
|
|
236
|
+
L.push(
|
|
237
|
+
retro
|
|
238
|
+
? `_Recorded ${stamp(row.ts)} in your transcript, at claim time. The agent said done; its own test had already failed._`
|
|
239
|
+
: `_Caught ${stamp(row.ts)}. The agent said done; its own proof, re-run, disagreed._`,
|
|
240
|
+
);
|
|
198
241
|
return L.join("\n");
|
|
199
242
|
}
|
|
200
243
|
|
|
@@ -203,14 +246,21 @@ function renderTerminalCard(row) {
|
|
|
203
246
|
const claim = clean(row.claim_text, 200) || "it's done";
|
|
204
247
|
const proof = clean(row.proof_command || row.probe_command, 200) || "its own proof";
|
|
205
248
|
const evidence = clean(row.evidence, 180);
|
|
249
|
+
const retro = isRetro(row);
|
|
206
250
|
const L = [];
|
|
207
251
|
L.push("");
|
|
208
|
-
L.push(
|
|
252
|
+
L.push(
|
|
253
|
+
` ${B}🔬 skalpel${X} ${D}·${X} ${B}${R}${retro ? "recorded catch" : "verified catch"}${X}`,
|
|
254
|
+
);
|
|
209
255
|
L.push(` ${D}${"─".repeat(70)}${X}`);
|
|
210
256
|
L.push(` ${D}The agent claimed:${X}`);
|
|
211
257
|
L.push(` ${B}"${claim}"${X}`);
|
|
212
258
|
L.push("");
|
|
213
|
-
L.push(
|
|
259
|
+
L.push(
|
|
260
|
+
retro
|
|
261
|
+
? ` ${D}Its own test had already failed when it said this — recorded at claim time:${X}`
|
|
262
|
+
: ` ${D}skalpel re-ran the agent's OWN proof, out-of-band:${X}`,
|
|
263
|
+
);
|
|
214
264
|
L.push(` ${D}$${X} ${proof}`);
|
|
215
265
|
L.push(` ${R}${B}→ ${verdictPhrase(row)}${X}`);
|
|
216
266
|
if (evidence) {
|
|
@@ -219,7 +269,9 @@ function renderTerminalCard(row) {
|
|
|
219
269
|
}
|
|
220
270
|
L.push(` ${D}${"─".repeat(70)}${X}`);
|
|
221
271
|
L.push(
|
|
222
|
-
|
|
272
|
+
retro
|
|
273
|
+
? ` ${D}recorded ${stamp(row.ts)} — the agent said done; its own test had already failed.${X}`
|
|
274
|
+
: ` ${D}caught ${stamp(row.ts)} — the agent said done; its own proof, re-run, disagreed.${X}`,
|
|
223
275
|
);
|
|
224
276
|
L.push("");
|
|
225
277
|
return L.join("\n");
|