pqcheck 0.16.23 → 0.16.28

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 CHANGED
@@ -8,7 +8,7 @@
8
8
  [![npm downloads](https://img.shields.io/npm/dm/pqcheck.svg?style=flat-square&color=06b6d4)](https://www.npmjs.com/package/pqcheck)
9
9
  [![license](https://img.shields.io/npm/l/pqcheck.svg?style=flat-square&color=06b6d4)](./LICENSE)
10
10
 
11
- > **Latest: v0.16.23** — Closes the silent-no-op flag class. `--strict` now aliases `--strict-posture` in scan / deploy-check / trust-diff (audit caught the typo silently degrading to drift-only). And unknown flags now reject loudly with a closest-match suggestion + non-zero exit `--stict-posture` (typo) `error: did you mean --strict-posture?` instead of silently no-op'ing. Same principle applied to ourselves that Cipherwake exists to enforce for customers: a security tool can never silently proceed with weaker behaviour on an unrecognized signal. [Full changelog →](./CHANGELOG.md)
11
+ > **Latest: v0.16.28** — Cipherwake is now the independent gate that fires on every deploy. Catches: broken deploys (5xx / framework error / blank / landmark missing), leaked secrets (AWS / GitHub / Stripe / OpenAI / Supabase service-role JWTs with FP-discrimination that ignores `NEXT_PUBLIC_` / `pk_*` / anon JWTs), dropped cookie flags (HttpOnly / Secure / SameSite), missing required HTTP headers, sensitive-file leaks (`.env` / `.git/config` / `/api/debug`), **new public routes since last deploy** (AI accidentally shipped `/api/internal`), **new third-party scripts** (supply-chain alert), and **TLS cert expiring soon**. Blocks the deploy before the AI announces it. Local stats (`pqcheck stats`) your results stay on your machine, no telemetry. All in-lane: public surface, no credentials, provider-neutral. [Full changelog →](./CHANGELOG.md)
12
12
 
13
13
  ## Two ways to use it
14
14
 
package/bin/pqcheck.js CHANGED
@@ -188,6 +188,17 @@ async function main() {
188
188
  if (args[0] === "init") {
189
189
  return runInitCommand(args.slice(1));
190
190
  }
191
+ // R89.LOCAL — local-only stats commands. Read/write .cipherwake/stats.json
192
+ // in the repo root. ZERO network requests. Privacy-by-design.
193
+ if (args[0] === "stats") {
194
+ return runStatsCommand(args.slice(1));
195
+ }
196
+ if (args[0] === "dismiss") {
197
+ return runDismissCommand(args.slice(1));
198
+ }
199
+ if (args[0] === "confirm") {
200
+ return runConfirmCommand(args.slice(1));
201
+ }
191
202
  if (args[0] === "deploy-check") {
192
203
  return runDeployCheckCommand(args.slice(1));
193
204
  }
@@ -907,6 +918,271 @@ function combineShipDecision(driftDecision, postureDecision, { strict = false }
907
918
  return a >= b ? driftDecision : postureDecision;
908
919
  }
909
920
 
921
+ // R87 (2026-06-05) — read .cipherwake.json from cwd OR a path the customer
922
+ // pointed us at. Returns the parsed routeAssertions config or null.
923
+ //
924
+ // Customer-declared assertions are the wedge that makes Cipherwake fire on
925
+ // EVERY deploy of a backend/admin-heavy app — even when the public landing
926
+ // page doesn't drift. The CLI reads this file once + forwards it as the
927
+ // `routeAssertionsConfig` field in the trust-diff/scan request body.
928
+ //
929
+ // We DO NOT read credentials or sensitive headers from this file. Just
930
+ // route paths + expected classification. See /methodology/route-assertions
931
+ // for the schema + /methodology/why-not-authenticated-crawling for the
932
+ // strategic reasoning behind keeping this credential-free.
933
+ // R87.4 (2026-06-05) — surface a clear warning when .cipherwake.json has
934
+ // malformed JSON, an invalid shape, or an obviously-wrong path declaration.
935
+ // Without these warnings, the CLI silently dropped the config and the user
936
+ // saw `sources_customer=0` with no explanation. Wrong-shape configs are
937
+ // the second-most-common UX failure after typo'd flags.
938
+ function _warnConfigIssue(msg) {
939
+ process.stderr.write(color("yellow", `⚠ .cipherwake.json: ${msg}\n`));
940
+ }
941
+
942
+ async function readRouteAssertionsConfig() {
943
+ try {
944
+ const { readFileSync, existsSync } = await import("node:fs");
945
+ const { join, dirname } = await import("node:path");
946
+ const cwd = process.cwd();
947
+ // Walk up from cwd looking for .cipherwake.json — supports running
948
+ // from any subdirectory of the repo. Cap at 5 levels to bound IO.
949
+ let dir = cwd;
950
+ for (let i = 0; i < 5; i++) {
951
+ const candidate = join(dir, ".cipherwake.json");
952
+ if (existsSync(candidate)) {
953
+ const body = readFileSync(candidate, "utf8");
954
+ let parsed;
955
+ try {
956
+ parsed = JSON.parse(body);
957
+ } catch (e) {
958
+ _warnConfigIssue(`malformed JSON in ${candidate} (${e.message}). Customer assertions will not be sent.`);
959
+ return null;
960
+ }
961
+ // Tolerant shape: either { routeAssertions: {...} } or { assertions: [...] }
962
+ // at the top level. Both forms are documented in the methodology page.
963
+ const cfg = parsed?.routeAssertions || (Array.isArray(parsed?.assertions) ? { assertions: parsed.assertions, replace_defaults: parsed.replace_defaults } : null);
964
+ if (!cfg) {
965
+ _warnConfigIssue(`${candidate} found but missing required field "routeAssertions.assertions" (or top-level "assertions" array). See https://cipherwake.io/methodology/route-assertions for the schema.`);
966
+ return null;
967
+ }
968
+ if (!Array.isArray(cfg.assertions)) {
969
+ _warnConfigIssue(`"assertions" must be an array of {path, expect, ...} objects.`);
970
+ return null;
971
+ }
972
+ // R87.4 — normalize + validate each assertion. Catch common typos:
973
+ // missing leading slash, unknown `expect` value, duplicate paths.
974
+ const seen = new Set();
975
+ const cleaned = [];
976
+ for (let idx = 0; idx < cfg.assertions.length; idx++) {
977
+ const a = cfg.assertions[idx];
978
+ if (!a || typeof a !== "object") {
979
+ _warnConfigIssue(`assertion #${idx + 1} is not an object — skipped.`);
980
+ continue;
981
+ }
982
+ if (typeof a.path !== "string" || !a.path) {
983
+ _warnConfigIssue(`assertion #${idx + 1} missing "path" field — skipped.`);
984
+ continue;
985
+ }
986
+ // Normalize leading slash
987
+ let normPath = a.path.trim();
988
+ if (!normPath.startsWith("/")) {
989
+ _warnConfigIssue(`assertion path "${a.path}" missing leading "/" — auto-normalized to "/${normPath}".`);
990
+ normPath = "/" + normPath;
991
+ }
992
+ if (normPath.includes("?")) {
993
+ _warnConfigIssue(`assertion path "${normPath}" contains "?" — query strings are not supported in route assertions. Path probed without query.`);
994
+ normPath = normPath.split("?")[0];
995
+ }
996
+ const validExpects = ["protected", "exposed", "missing"];
997
+ if (!validExpects.includes(a.expect)) {
998
+ _warnConfigIssue(`assertion #${idx + 1} (path "${normPath}") has invalid "expect": ${JSON.stringify(a.expect)}. Must be one of: ${validExpects.join(", ")}. Skipped.`);
999
+ continue;
1000
+ }
1001
+ if (seen.has(normPath)) {
1002
+ _warnConfigIssue(`duplicate path "${normPath}" in assertions — second occurrence ignored.`);
1003
+ continue;
1004
+ }
1005
+ // R87.6 — validate bodyContains / bodyAbsent if present.
1006
+ if (a.bodyContains !== undefined && typeof a.bodyContains !== "string" && !(Array.isArray(a.bodyContains) && a.bodyContains.every((s) => typeof s === "string"))) {
1007
+ _warnConfigIssue(`assertion #${idx + 1} (path "${normPath}") has invalid "bodyContains" — must be a string or array of strings. Body check disabled for this assertion.`);
1008
+ delete a.bodyContains;
1009
+ }
1010
+ if (a.bodyAbsent !== undefined && typeof a.bodyAbsent !== "string" && !(Array.isArray(a.bodyAbsent) && a.bodyAbsent.every((s) => typeof s === "string"))) {
1011
+ _warnConfigIssue(`assertion #${idx + 1} (path "${normPath}") has invalid "bodyAbsent" — must be a string or array of strings. Body check disabled for this assertion.`);
1012
+ delete a.bodyAbsent;
1013
+ }
1014
+ if ((a.bodyContains !== undefined || a.bodyAbsent !== undefined) && a.expect !== "protected") {
1015
+ _warnConfigIssue(`assertion #${idx + 1} (path "${normPath}") has body assertions but expect=${JSON.stringify(a.expect)}. Body checks only run on expect=protected.`);
1016
+ }
1017
+ seen.add(normPath);
1018
+ cleaned.push({ ...a, path: normPath });
1019
+ }
1020
+ if (cleaned.length === 0) {
1021
+ _warnConfigIssue(`no valid assertions found after validation — customer config will not be sent.`);
1022
+ return null;
1023
+ }
1024
+ return { ...cfg, assertions: cleaned };
1025
+ }
1026
+ const parent = dirname(dir);
1027
+ if (parent === dir) break;
1028
+ dir = parent;
1029
+ }
1030
+ return null;
1031
+ } catch (e) {
1032
+ _warnConfigIssue(`unexpected error reading config: ${e.message}`);
1033
+ return null;
1034
+ }
1035
+ }
1036
+
1037
+ // R87 — fold customer route-assertion failures into ship_decision. Any
1038
+ // CRITICAL failure (declared `protected` route that is now `exposed`) blocks
1039
+ // the deploy unconditionally — this is the catastrophic "admin became
1040
+ // public" case the feature exists to catch. High/medium failures promote
1041
+ // to `review`.
1042
+ // R89.G (2026-06-05) — narrative line for every scan. The reason "drift-only"
1043
+ // feels not-worth-paying-for is the silent-on-pass UX. A one-liner narrative
1044
+ // on every scan ("Public surface since last deploy: 0 routes changed, 0
1045
+ // headers regressed, deploy healthy") makes the gate FEEL alive even when
1046
+ // it's green. The AI coder reads this in the response and can surface it
1047
+ // to the user in plain English.
1048
+ function buildTrustDiffNarrative({ deltaCount, assertionsFailedCount, assertionsCriticalCount, deployStatus, secretsCriticalCount, secretsFindingsTotal, cookieFailedCount, headerFailedCount, posture }) {
1049
+ const bits = [];
1050
+ // Lead with the catastrophic items first
1051
+ if (secretsCriticalCount > 0) bits.push(`${secretsCriticalCount} leaked secret${secretsCriticalCount === 1 ? "" : "s"} in JS bundle`);
1052
+ if (deployStatus && deployStatus !== "healthy" && deployStatus !== "unreachable") {
1053
+ bits.push(`deploy ${deployStatus.replace(/_/g, " ")}`);
1054
+ }
1055
+ if (assertionsCriticalCount > 0) bits.push(`${assertionsCriticalCount} declared route${assertionsCriticalCount === 1 ? "" : "s"} regressed`);
1056
+ if (cookieFailedCount > 0) bits.push(`${cookieFailedCount} cookie flag${cookieFailedCount === 1 ? "" : "s"} weakened`);
1057
+ if (headerFailedCount > 0) bits.push(`${headerFailedCount} required header${headerFailedCount === 1 ? "" : "s"} missing`);
1058
+ if (deltaCount > 0) bits.push(`${deltaCount} trust-surface change${deltaCount === 1 ? "" : "s"} since baseline`);
1059
+ if (assertionsFailedCount > 0 && assertionsCriticalCount === 0) bits.push(`${assertionsFailedCount} route assertion${assertionsFailedCount === 1 ? "" : "s"} for review`);
1060
+ if (secretsFindingsTotal > secretsCriticalCount) bits.push(`${secretsFindingsTotal - secretsCriticalCount} non-critical secret finding${(secretsFindingsTotal - secretsCriticalCount) === 1 ? "" : "s"}`);
1061
+ if (bits.length === 0) {
1062
+ return `Public surface since last deploy: no drift, all declared invariants pass${deployStatus === "healthy" ? ", deploy healthy" : ""}${posture ? `, posture ${posture}` : ""}.`;
1063
+ }
1064
+ return `Public surface since last deploy: ${bits.join("; ")}${deployStatus === "healthy" ? " (homepage healthy)" : ""}.`;
1065
+ }
1066
+
1067
+ function shipDecisionFromAssertions(summary) {
1068
+ if (!summary) return null;
1069
+ // R89/R88 wave 2 + R90 — fold in deploy health + secrets + cookies + headers + TLS.
1070
+ // Critical case auto-blocks; any non-critical failure promotes to review.
1071
+ const deployBroken = summary.deployHealth && summary.deployHealth.status !== "healthy" && summary.deployHealth.status !== "unreachable";
1072
+ const secretsLeaked = summary.secrets && summary.secrets.criticalCount > 0;
1073
+ const cookieCritical = (summary.cookieCriticalFailures || 0) > 0;
1074
+ const tlsCritical = summary.tlsExpiry && !summary.tlsExpiry.passed && summary.tlsExpiry.severity === "critical";
1075
+ if ((summary.criticalFailures && summary.criticalFailures.length > 0) ||
1076
+ (summary.headerCriticalFailures && summary.headerCriticalFailures.length > 0) ||
1077
+ deployBroken || secretsLeaked || cookieCritical || tlsCritical) {
1078
+ return "block";
1079
+ }
1080
+ const headerFailed = (summary.headerResults || []).filter((r) => !r.passed).length;
1081
+ const cookieFailed = (summary.cookieResults || []).filter((r) => !r.passed).length;
1082
+ const secretsFound = summary.secrets && summary.secrets.findings && summary.secrets.findings.length > 0;
1083
+ const tlsFailed = summary.tlsExpiry && !summary.tlsExpiry.passed;
1084
+ if (summary.failed > 0 || headerFailed > 0 || cookieFailed > 0 || secretsFound || tlsFailed) return "review";
1085
+ return "pass";
1086
+ }
1087
+
1088
+ // R87 — emit per-assertion outcomes in a parseable block. Stable format so
1089
+ // AI coders can route on the data; one line per assertion with
1090
+ // expected/actual + path so failures are obvious at a glance.
1091
+ function formatRouteAssertionsBlock(summary) {
1092
+ if (!summary || !Array.isArray(summary.results) || summary.results.length === 0) return "";
1093
+ const lines = ["", "CIPHERWAKE_ROUTE_ASSERTIONS"];
1094
+ lines.push(`total=${summary.total}`);
1095
+ lines.push(`passed=${summary.passed}`);
1096
+ lines.push(`failed=${summary.failed}`);
1097
+ lines.push(`critical_failures=${summary.criticalFailures.length}`);
1098
+ lines.push(`sources_customer=${summary.sources.customer}`);
1099
+ lines.push(`sources_default=${summary.sources.default}`);
1100
+ lines.push(`sources_auto=${summary.sources.auto}`);
1101
+ lines.push(`sources_auto_suppressed=${summary.sources.autoSuppressed || 0}`);
1102
+ lines.push(`sources_dismissed=${summary.sources.dismissed || 0}`);
1103
+ // R88 — header invariants block alongside route assertions
1104
+ if (Array.isArray(summary.headerResults) && summary.headerResults.length > 0) {
1105
+ lines.push("--- HEADER INVARIANTS ---");
1106
+ lines.push(`header_total=${summary.headerResults.length}`);
1107
+ lines.push(`header_passed=${summary.headerResults.filter((r) => r.passed).length}`);
1108
+ lines.push(`header_failed=${summary.headerResults.filter((r) => !r.passed).length}`);
1109
+ lines.push(`header_critical_failures=${(summary.headerCriticalFailures || []).length}`);
1110
+ for (const h of summary.headerResults) {
1111
+ const status = h.passed ? "PASS" : "FAIL";
1112
+ const sev = h.passed ? "info" : h.severity;
1113
+ const wantVal = h.expectedValue ? `="${h.expectedValue}"` : "";
1114
+ const gotVal = h.actualValue !== null && h.actualValue !== undefined ? `, got "${String(h.actualValue).slice(0, 80)}"` : ", got <absent>";
1115
+ const why = h.why ? ` — ${h.why}` : "";
1116
+ lines.push(`${status} [${sev}] header:${h.header} expect=${h.expect}${wantVal}${gotVal}${why}`);
1117
+ }
1118
+ }
1119
+ // R89 — deploy-health check
1120
+ if (summary.deployHealth) {
1121
+ const dh = summary.deployHealth;
1122
+ lines.push("--- DEPLOY HEALTH ---");
1123
+ lines.push(`deploy_status=${dh.status}`);
1124
+ lines.push(`deploy_http_status=${dh.httpStatus === null ? "?" : dh.httpStatus}`);
1125
+ lines.push(`deploy_body_bytes=${dh.bodyBytes}`);
1126
+ if (dh.matchedErrorMarker) lines.push(`deploy_matched_error_marker=${dh.matchedErrorMarker}`);
1127
+ if (dh.missingLandmark) lines.push(`deploy_missing_landmark=${dh.missingLandmark}`);
1128
+ if (dh.errorReason) lines.push(`deploy_error_reason=${dh.errorReason}`);
1129
+ lines.push(`deploy_summary=${dh.summary}`);
1130
+ }
1131
+ // R88 wave 2 — secret scanner
1132
+ if (summary.secrets) {
1133
+ const s = summary.secrets;
1134
+ lines.push("--- SECRET SCAN ---");
1135
+ lines.push(`secrets_scanned=${s.scanned}`);
1136
+ lines.push(`secrets_bundles_fetched=${s.bundlesFetched}`);
1137
+ lines.push(`secrets_findings_total=${s.findings.length}`);
1138
+ lines.push(`secrets_critical_count=${s.criticalCount}`);
1139
+ for (const f of s.findings) {
1140
+ lines.push(`FAIL [${f.severity}] secret:${f.patternId} source=${f.source} redacted=${f.redactedSample} — ${f.name}`);
1141
+ }
1142
+ }
1143
+ // R90 — TLS cert expiry invariant
1144
+ if (summary.tlsExpiry) {
1145
+ const t = summary.tlsExpiry;
1146
+ lines.push("--- TLS EXPIRY ---");
1147
+ lines.push(`tls_checked=${t.checked}`);
1148
+ lines.push(`tls_days_remaining=${t.daysRemaining === null ? "?" : t.daysRemaining}`);
1149
+ lines.push(`tls_min_required=${t.minRequired}`);
1150
+ lines.push(`tls_passed=${t.passed}`);
1151
+ lines.push(`tls_summary=${t.summary}`);
1152
+ }
1153
+ // R88 wave 2 — cookie invariants
1154
+ if (Array.isArray(summary.cookieResults) && summary.cookieResults.length > 0) {
1155
+ lines.push("--- COOKIE INVARIANTS ---");
1156
+ lines.push(`cookie_total=${summary.cookieResults.length}`);
1157
+ lines.push(`cookie_passed=${summary.cookieResults.filter((r) => r.passed).length}`);
1158
+ lines.push(`cookie_failed=${summary.cookieResults.filter((r) => !r.passed).length}`);
1159
+ lines.push(`cookie_critical_failures=${summary.cookieCriticalFailures || 0}`);
1160
+ for (const c of summary.cookieResults) {
1161
+ const status = c.passed ? "PASS" : "FAIL";
1162
+ const sev = c.passed ? "info" : c.severity;
1163
+ const matched = c.matchedCookies.length > 0 ? c.matchedCookies.join(",") : "<no matching cookies>";
1164
+ const why = c.why ? ` — ${c.why}` : "";
1165
+ if (c.failures.length === 0) {
1166
+ lines.push(`${status} [${sev}] cookie:${c.namePattern} matched=${matched}${why}`);
1167
+ } else {
1168
+ for (const f of c.failures) {
1169
+ lines.push(`FAIL [${sev}] cookie:${c.namePattern} cookie=${f.cookieName} reason=${f.reason}${why}`);
1170
+ }
1171
+ }
1172
+ }
1173
+ }
1174
+ for (const r of summary.results) {
1175
+ const status = r.passed ? "PASS" : "FAIL";
1176
+ const sev = r.passed ? "info" : r.severity;
1177
+ const why = r.why ? ` — ${r.why}` : "";
1178
+ const errReason = r.errorReason ? ` reason=${r.errorReason}` : "";
1179
+ const bodyTag = r.bodyCheck && r.bodyCheck !== "body_check_skipped" ? ` body=${r.bodyCheck}` : "";
1180
+ lines.push(`${status} [${sev}] ${r.source}:${r.path} expected=${r.expected} actual=${r.actual} status=${r.status === null ? "?" : r.status}${errReason}${bodyTag}${why}`);
1181
+ }
1182
+ lines.push("END_CIPHERWAKE_ROUTE_ASSERTIONS");
1183
+ return color("dim", lines.join("\n"));
1184
+ }
1185
+
910
1186
  // R86.3 (2026-06-03) — emit per-finding fix snippets in a separate parseable
911
1187
  // block after the AI guard block. The guard block stays compact key=value;
912
1188
  // fixes are multi-line code so they get their own block with explicit
@@ -3519,10 +3795,11 @@ async function runTrustDiffCommand(args) {
3519
3795
 
3520
3796
  let resp;
3521
3797
  try {
3798
+ const _routeCfg = await readRouteAssertionsConfig();
3522
3799
  resp = await fetch(`${API_BASE}/api/trust-diff`, {
3523
3800
  method: "POST",
3524
3801
  headers,
3525
- body: JSON.stringify({ domain, baseline, fail_on: failOn }),
3802
+ body: JSON.stringify({ domain, baseline, fail_on: failOn, routeAssertionsConfig: _routeCfg }),
3526
3803
  });
3527
3804
  } catch (err) {
3528
3805
  console.error(color("red", `error: network failure calling /api/trust-diff: ${err.message}`));
@@ -3656,17 +3933,67 @@ async function runTrustDiffCommand(args) {
3656
3933
  // R86.5 — surface posture advisory line in default (non-strict) mode.
3657
3934
  const postureAdvisory = formatPostureAdvisoryLine(posture, strictPosture);
3658
3935
  if (postureAdvisory) console.log(postureAdvisory);
3936
+ // R87 (2026-06-05) — fold route-assertion failures into ship_decision.
3937
+ // Customer-declared assertions are surfaced regardless; their critical
3938
+ // failures (declared `protected` route that's now `exposed`) ALWAYS
3939
+ // promote ship_decision to block — this is the catastrophic case the
3940
+ // feature exists to catch ("admin went public") and it does not require
3941
+ // --strict-posture or any opt-in. Default + auto-detected assertions
3942
+ // follow the same gating rules.
3943
+ const routeAssertions = currentReport?.routeAssertions || null;
3944
+ const assertionShipImpact = shipDecisionFromAssertions(routeAssertions);
3945
+ const effectiveShipWithAssertions = assertionShipImpact
3946
+ ? (SHIP_DECISION_RANK[assertionShipImpact] > SHIP_DECISION_RANK[effectiveShip] ? assertionShipImpact : effectiveShip)
3947
+ : effectiveShip;
3948
+ // R88 wave 2 + R89 — fold in deploy health, secrets, cookies.
3949
+ const dh = routeAssertions?.deployHealth || null;
3950
+ const sc = routeAssertions?.secrets || null;
3951
+ const cr2 = routeAssertions?.cookieResults || null;
3952
+ const assertionFields = routeAssertions ? {
3953
+ assertions_total: routeAssertions.total,
3954
+ assertions_passed: routeAssertions.passed,
3955
+ assertions_failed: routeAssertions.failed,
3956
+ assertions_critical_failures: routeAssertions.criticalFailures.length,
3957
+ assertions_sources: `customer=${routeAssertions.sources.customer},default=${routeAssertions.sources.default},auto=${routeAssertions.sources.auto},auto_suppressed=${routeAssertions.sources.autoSuppressed || 0},dismissed=${routeAssertions.sources.dismissed || 0}`,
3958
+ assertion_top_failure: routeAssertions.criticalFailures[0]
3959
+ ? `${routeAssertions.criticalFailures[0].path}: expected ${routeAssertions.criticalFailures[0].expected}, got ${routeAssertions.criticalFailures[0].actual}`
3960
+ : undefined,
3961
+ // Deploy health
3962
+ deploy_status: dh?.status,
3963
+ deploy_http_status: dh?.httpStatus,
3964
+ deploy_summary: dh?.summary?.slice(0, 200),
3965
+ ship_decision_health: dh && dh.status !== "healthy" && dh.status !== "unreachable" ? "block" : (dh ? "pass" : undefined),
3966
+ // Secret scanner
3967
+ secrets_scanned: sc?.scanned,
3968
+ secrets_findings_total: sc?.findings?.length,
3969
+ secrets_critical_count: sc?.criticalCount,
3970
+ ship_decision_secrets: sc && sc.criticalCount > 0 ? "block" : (sc && sc.findings.length > 0 ? "review" : (sc ? "pass" : undefined)),
3971
+ // Cookie invariants
3972
+ cookie_failed: cr2 ? cr2.filter((c) => !c.passed).length : undefined,
3973
+ cookie_critical_failures: routeAssertions.cookieCriticalFailures || 0,
3974
+ ship_decision_cookies: cr2 && cr2.length > 0
3975
+ ? (routeAssertions.cookieCriticalFailures > 0 ? "block" : (cr2.filter((c) => !c.passed).length > 0 ? "review" : "pass"))
3976
+ : undefined,
3977
+ // R90 — TLS expiry
3978
+ tls_days_remaining: routeAssertions.tlsExpiry?.daysRemaining,
3979
+ tls_passed: routeAssertions.tlsExpiry?.passed,
3980
+ ship_decision_tls: routeAssertions.tlsExpiry?.checked
3981
+ ? (routeAssertions.tlsExpiry.passed ? "pass" : (routeAssertions.tlsExpiry.severity === "critical" ? "block" : "review"))
3982
+ : undefined,
3983
+ } : {};
3984
+
3659
3985
  console.log(formatAiFooterBlock({
3660
- status: effectiveShip,
3986
+ status: effectiveShipWithAssertions,
3661
3987
  domain,
3662
3988
  kind: "trust-diff",
3663
3989
  baseline,
3664
3990
  verdict,
3665
3991
  delta_count: deltas.length,
3666
3992
  max_severity: maxSev,
3667
- ship_decision: effectiveShip,
3993
+ ship_decision: effectiveShipWithAssertions,
3668
3994
  ship_decision_drift: shipDecision,
3669
3995
  ship_decision_posture: posture?.decision,
3996
+ ship_decision_assertions: assertionShipImpact,
3670
3997
  ship_decision_mode: strictPosture ? "strict_posture" : "drift_only",
3671
3998
  top_issue: topDelta?.id || topDelta?.type || "none",
3672
3999
  top_issue_title: topDelta?.title || undefined,
@@ -3676,12 +4003,64 @@ async function runTrustDiffCommand(args) {
3676
4003
  quota_limit: result.quota?.monthly_limit ?? undefined,
3677
4004
  scanned_at: new Date().toISOString(),
3678
4005
  advisory_only: "true",
3679
- scope: strictPosture ? "trust_surface_drift_plus_absolute_posture" : "trust_surface_drift",
3680
- scope_note: strictPosture
3681
- ? "ship_decision = worst-of(drift, absolute posture) because --strict-posture is set. pass means BOTH no drift AND posture grade A+/A. ship_decision_drift and ship_decision_posture expose the two inputs separately. Cipherwake does NOT verify app functionality — pair with Playwright e2e for full deploy safety."
3682
- : "ship_decision reflects DRIFT only by default (per-deploy regression gate). Posture grade is surfaced via posture_decision / ship_decision_posture as advisory — D/F posture does NOT auto-block. Once your site reaches A/B posture, pass --strict-posture to lock that in. Cipherwake does NOT verify app functionality.",
4006
+ scope: strictPosture ? "trust_surface_drift_plus_absolute_posture_plus_route_assertions_plus_health" : "trust_surface_drift_plus_route_assertions_plus_health",
4007
+ scope_note: "ship_decision = worst-of(drift, route_assertions, deploy_health, secret_scan, cookie_invariants" + (strictPosture ? ", absolute_posture)" : ")") + ". Cipherwake checked the public trust surface independently of what your AI coder claims; this is the gate that should fire before the AI announces a deploy. Cipherwake does NOT verify app functionality.",
4008
+ narrative: routeAssertions
4009
+ ? buildTrustDiffNarrative({
4010
+ deltaCount: deltas.length,
4011
+ assertionsFailedCount: routeAssertions.failed,
4012
+ assertionsCriticalCount: routeAssertions.criticalFailures.length,
4013
+ deployStatus: routeAssertions.deployHealth?.status,
4014
+ secretsCriticalCount: routeAssertions.secrets?.criticalCount || 0,
4015
+ secretsFindingsTotal: routeAssertions.secrets?.findings?.length || 0,
4016
+ cookieFailedCount: (routeAssertions.cookieResults || []).filter((c) => !c.passed).length,
4017
+ headerFailedCount: (routeAssertions.headerResults || []).filter((r) => !r.passed).length,
4018
+ posture: posture?.grade,
4019
+ })
4020
+ : undefined,
4021
+ ...assertionFields,
3683
4022
  ...postureFields,
3684
4023
  }));
4024
+ if (routeAssertions) {
4025
+ console.log(formatRouteAssertionsBlock(routeAssertions));
4026
+ // R89.LOCAL — persist local stats. ZERO network requests.
4027
+ // R90 — also record surface snapshot for B/C (new-routes / new-scripts diff)
4028
+ try {
4029
+ const { recordResults, recordSurfaceSnapshot } = await import(new URL("./statsTracker.js", import.meta.url).href);
4030
+ await recordResults(extractStatsEntries(routeAssertions));
4031
+ // Extract publicRoutes + thirdPartyHosts from the report for snapshot
4032
+ const publicRoutes = Array.isArray(currentReport?.publicRoutes?.paths)
4033
+ ? currentReport.publicRoutes.paths.filter((p) => p.classification === "public").map((p) => p.path)
4034
+ : [];
4035
+ const thirdPartyHosts = Array.isArray(currentReport?.publicDeps?.thirdParties)
4036
+ ? [...new Set(currentReport.publicDeps.thirdParties.map((t) => t.host).filter(Boolean))]
4037
+ : [];
4038
+ const certDaysToExpiry = typeof currentReport?.publicSurface?.daysUntilCertExpiry === "number"
4039
+ ? currentReport.publicSurface.daysUntilCertExpiry
4040
+ : null;
4041
+ const surfaceDiff = await recordSurfaceSnapshot({ domain, publicRoutes, thirdPartyHosts, certDaysToExpiry });
4042
+ if (surfaceDiff && (surfaceDiff.newRoutes.length > 0 || surfaceDiff.newHosts.length > 0 || surfaceDiff.removedRoutes.length > 0 || surfaceDiff.removedHosts.length > 0)) {
4043
+ console.log("");
4044
+ console.log(color("dim", "CIPHERWAKE_SURFACE_DIFF"));
4045
+ console.log(color("dim", `prev_snapshot_at=${surfaceDiff.prevSnapshotAt}`));
4046
+ for (const r of surfaceDiff.newRoutes) {
4047
+ console.log(color("yellow", `NEW_PUBLIC_ROUTE: ${r} — was not publicly reachable in last deploy. Review intent.`));
4048
+ }
4049
+ for (const r of surfaceDiff.removedRoutes) {
4050
+ console.log(color("dim", `REMOVED_PUBLIC_ROUTE: ${r} — was publicly reachable in last deploy.`));
4051
+ }
4052
+ for (const h of surfaceDiff.newHosts) {
4053
+ console.log(color("red", `NEW_THIRD_PARTY_SCRIPT: ${h} — supply-chain alert: a new third-party script appeared on your homepage since last deploy. Verify intent (vendor add) or investigate (injection / compromise).`));
4054
+ }
4055
+ for (const h of surfaceDiff.removedHosts) {
4056
+ console.log(color("dim", `REMOVED_THIRD_PARTY_SCRIPT: ${h} — third-party script removed since last deploy.`));
4057
+ }
4058
+ console.log(color("dim", "END_CIPHERWAKE_SURFACE_DIFF"));
4059
+ }
4060
+ } catch {
4061
+ // never block on stats failures
4062
+ }
4063
+ }
3685
4064
  if (posture && Array.isArray(posture.fixes) && posture.fixes.length > 0) {
3686
4065
  console.log(formatPostureFixesBlock(posture.fixes));
3687
4066
  }
@@ -3693,13 +4072,13 @@ async function runTrustDiffCommand(args) {
3693
4072
  score: typeof result.current_score === "number" ? result.current_score : null,
3694
4073
  grade: result.current_grade || null,
3695
4074
  max_severity: maxSev,
3696
- ship_decision: effectiveShip,
4075
+ ship_decision: effectiveShipWithAssertions,
3697
4076
  baseline,
3698
4077
  delta_count: deltas.length,
3699
4078
  top_issue: topDelta?.id || topDelta?.title || null,
3700
4079
  });
3701
4080
 
3702
- process.exit(shipDecisionExitCode(effectiveShip));
4081
+ process.exit(shipDecisionExitCode(effectiveShipWithAssertions));
3703
4082
  }
3704
4083
 
3705
4084
  // Format output
@@ -4789,6 +5168,132 @@ function renderReleaseChecklist(domain, opts = {}) {
4789
5168
  const VALID_FAIL_ON = ["any", "low", "medium", "high", "critical"];
4790
5169
  const VALID_BASELINES = ["last-week", "last-month", "last-scan"];
4791
5170
 
5171
+ // R89.LOCAL — `pqcheck stats` reads .cipherwake/stats.json + prints
5172
+ // per-check stats. ZERO network requests. Privacy-by-design.
5173
+ async function runStatsCommand(args) {
5174
+ const { loadStats, formatStatsTable } = await import(new URL("./statsTracker.js", import.meta.url).href);
5175
+ const stats = await loadStats();
5176
+ console.log(formatStatsTable(stats));
5177
+ console.log("");
5178
+ console.log(color("dim", "Stats are local-only. They never leave your machine. Add .cipherwake/ to .gitignore if you don't want to commit them."));
5179
+ }
5180
+
5181
+ async function runDismissCommand(args) {
5182
+ const id = args[0];
5183
+ if (!id) {
5184
+ console.error(color("red", "error: pqcheck dismiss requires a check id"));
5185
+ console.error(color("dim", "Usage: pqcheck dismiss route:/admin/ideas OR pqcheck dismiss header:Strict-Transport-Security"));
5186
+ process.exit(3);
5187
+ }
5188
+ const { markDismissed } = await import(new URL("./statsTracker.js", import.meta.url).href);
5189
+ const path = await markDismissed(id);
5190
+ if (path) {
5191
+ console.log(color("green", `✓ Marked "${id}" as dismissed-intentional in ${path}`));
5192
+ } else {
5193
+ console.error(color("red", `error: could not persist dismissal for "${id}"`));
5194
+ process.exit(1);
5195
+ }
5196
+ }
5197
+
5198
+ async function runConfirmCommand(args) {
5199
+ const id = args[0];
5200
+ if (!id) {
5201
+ console.error(color("red", "error: pqcheck confirm requires a check id"));
5202
+ console.error(color("dim", "Usage: pqcheck confirm route:/admin/ideas — marks as a confirmed real catch (a regression was fixed)"));
5203
+ process.exit(3);
5204
+ }
5205
+ const { markConfirmed } = await import(new URL("./statsTracker.js", import.meta.url).href);
5206
+ const path = await markConfirmed(id);
5207
+ if (path) {
5208
+ console.log(color("green", `✓ Marked "${id}" as confirmed-real in ${path}`));
5209
+ } else {
5210
+ console.error(color("red", `error: could not persist confirmation for "${id}"`));
5211
+ process.exit(1);
5212
+ }
5213
+ }
5214
+
5215
+ // R89.LOCAL — extract per-check entries from a trust-diff response for
5216
+ // stats recording. Records ZERO network requests beyond what the probe
5217
+ // already made.
5218
+ function extractStatsEntries(routeAssertions) {
5219
+ const entries = [];
5220
+ if (!routeAssertions) return entries;
5221
+ // Route assertions
5222
+ for (const r of routeAssertions.results || []) {
5223
+ entries.push({
5224
+ id: `route:${r.path}`,
5225
+ result: r.passed ? "pass" : "fail",
5226
+ status: r.status,
5227
+ severity: r.severity,
5228
+ source: r.source,
5229
+ });
5230
+ }
5231
+ // Header invariants
5232
+ for (const h of routeAssertions.headerResults || []) {
5233
+ entries.push({
5234
+ id: `header:${h.header}`,
5235
+ result: h.passed ? "pass" : "fail",
5236
+ status: null,
5237
+ severity: h.severity,
5238
+ source: "customer",
5239
+ });
5240
+ }
5241
+ // Cookie invariants
5242
+ for (const c of routeAssertions.cookieResults || []) {
5243
+ entries.push({
5244
+ id: `cookie:${c.namePattern}`,
5245
+ result: c.passed ? "pass" : "fail",
5246
+ status: null,
5247
+ severity: c.severity,
5248
+ source: "customer",
5249
+ });
5250
+ }
5251
+ // Secret scanner findings (each finding is its own check)
5252
+ if (routeAssertions.secrets) {
5253
+ if (routeAssertions.secrets.findings.length === 0 && routeAssertions.secrets.scanned) {
5254
+ entries.push({
5255
+ id: "secret:scan",
5256
+ result: "pass",
5257
+ status: null,
5258
+ severity: "info",
5259
+ source: "secret",
5260
+ });
5261
+ } else {
5262
+ for (const f of routeAssertions.secrets.findings) {
5263
+ entries.push({
5264
+ id: `secret:${f.patternId}`,
5265
+ result: "fail",
5266
+ status: null,
5267
+ severity: f.severity,
5268
+ source: "secret",
5269
+ });
5270
+ }
5271
+ }
5272
+ }
5273
+ // Deploy health
5274
+ if (routeAssertions.deployHealth) {
5275
+ const dh = routeAssertions.deployHealth;
5276
+ entries.push({
5277
+ id: "health:homepage",
5278
+ result: dh.status === "healthy" ? "pass" : "fail",
5279
+ status: dh.httpStatus,
5280
+ severity: dh.status === "healthy" ? "info" : "critical",
5281
+ source: "health",
5282
+ });
5283
+ }
5284
+ // R90 — TLS expiry
5285
+ if (routeAssertions.tlsExpiry && routeAssertions.tlsExpiry.checked) {
5286
+ entries.push({
5287
+ id: "tls:expiry",
5288
+ result: routeAssertions.tlsExpiry.passed ? "pass" : "fail",
5289
+ status: routeAssertions.tlsExpiry.daysRemaining,
5290
+ severity: routeAssertions.tlsExpiry.severity,
5291
+ source: "tls",
5292
+ });
5293
+ }
5294
+ return entries;
5295
+ }
5296
+
4792
5297
  async function runInitCommand(args) {
4793
5298
  const fs = await import("node:fs/promises");
4794
5299
  const path = await import("node:path");
@@ -0,0 +1,257 @@
1
+ // =============================================================================
2
+ // Local-only check stats (R89.LOCAL — 2026-06-05)
3
+ // =============================================================================
4
+ // Persists per-assertion / per-check stats to .cipherwake/stats.json in the
5
+ // customer's repo. Records every result the CIPHERWAKE_* blocks already
6
+ // print to stdout — pass/fail, status, severity, source, timestamp — so the
7
+ // customer can later see (via `pqcheck stats`) which checks actually
8
+ // catch real things vs. sit silent or false-flag.
9
+ //
10
+ // HARD RULE — privacy guarantee:
11
+ // This module emits ZERO network requests. It only writes to a local file.
12
+ // No telemetry, no analytics, no cross-repo aggregation. The customer's
13
+ // results stay on their machine. This matches Cipherwake's "no credentials"
14
+ // stance — "no credentials and now no data exhaust either."
15
+ //
16
+ // Cross-repo aggregation is a SEPARATE opt-in feature (paid tier hosted
17
+ // analytics) that requires explicit account configuration. That path is
18
+ // not implemented in this CLI module.
19
+ // =============================================================================
20
+
21
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
22
+ import { existsSync } from "node:fs";
23
+ import { join, dirname } from "node:path";
24
+
25
+ const STATS_DIR = ".cipherwake";
26
+ const STATS_FILE = "stats.json";
27
+
28
+ /**
29
+ * Stats entry shape per check id. Check ids follow a stable scheme:
30
+ * route:<path> — route assertion
31
+ * header:<header-name> — header invariant
32
+ * cookie:<name-pattern> — cookie invariant
33
+ * secret:<pattern-id> — secret scanner finding
34
+ * deployHealth — deploy-not-broken
35
+ *
36
+ * @typedef {Object} CheckStats
37
+ * @property {number} runs
38
+ * @property {number} passed
39
+ * @property {number} failed
40
+ * @property {number} confirmedReal — incremented when a previously-failing check now passes (inferred fix)
41
+ * @property {number} dismissedIntentional — incremented when the customer marks the failure as intentional
42
+ * @property {"pass"|"fail"|"unknown"} lastResult
43
+ * @property {number|null} lastStatus
44
+ * @property {string} lastSeenAt — ISO timestamp
45
+ * @property {string} severity — last severity seen
46
+ * @property {string} source — "customer" | "default" | "auto" | "health" | "secret"
47
+ */
48
+
49
+ /**
50
+ * Walk up from cwd to find the nearest repo root (.cipherwake.json or .git).
51
+ * Stats file lives in <repo-root>/.cipherwake/stats.json.
52
+ */
53
+ async function findStatsDir() {
54
+ let dir = process.cwd();
55
+ for (let i = 0; i < 8; i++) {
56
+ if (existsSync(join(dir, ".cipherwake.json")) || existsSync(join(dir, ".git"))) {
57
+ return join(dir, STATS_DIR);
58
+ }
59
+ const parent = dirname(dir);
60
+ if (parent === dir) break;
61
+ dir = parent;
62
+ }
63
+ // Fallback: write to cwd
64
+ return join(process.cwd(), STATS_DIR);
65
+ }
66
+
67
+ /**
68
+ * Load stats.json from disk. Returns empty stats object if missing or
69
+ * malformed (we never crash the deploy-check on stats issues).
70
+ */
71
+ export async function loadStats() {
72
+ try {
73
+ const statsDir = await findStatsDir();
74
+ const path = join(statsDir, STATS_FILE);
75
+ if (!existsSync(path)) return { version: 1, checks: {} };
76
+ const raw = await readFile(path, "utf8");
77
+ const parsed = JSON.parse(raw);
78
+ if (!parsed || typeof parsed !== "object" || !parsed.checks) return { version: 1, checks: {} };
79
+ return parsed;
80
+ } catch {
81
+ return { version: 1, checks: {} };
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Persist stats.json. Creates .cipherwake/ if missing.
87
+ */
88
+ async function persistStats(stats) {
89
+ try {
90
+ const statsDir = await findStatsDir();
91
+ if (!existsSync(statsDir)) {
92
+ await mkdir(statsDir, { recursive: true });
93
+ }
94
+ const path = join(statsDir, STATS_FILE);
95
+ await writeFile(path, JSON.stringify(stats, null, 2), "utf8");
96
+ return path;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Apply a batch of check outcomes from a single deploy-check run. Each entry:
104
+ * { id, result: "pass"|"fail", status, severity, source }
105
+ *
106
+ * Inferred confirmedReal: when the previous lastResult was "fail" and the
107
+ * new result is "pass", increment confirmedReal. This catches the "I fixed
108
+ * the regression Cipherwake flagged" event.
109
+ */
110
+ export async function recordResults(entries) {
111
+ if (!Array.isArray(entries) || entries.length === 0) return null;
112
+ const stats = await loadStats();
113
+ const now = new Date().toISOString();
114
+ for (const e of entries) {
115
+ if (!e || typeof e.id !== "string") continue;
116
+ const cur = stats.checks[e.id] || {
117
+ runs: 0,
118
+ passed: 0,
119
+ failed: 0,
120
+ confirmedReal: 0,
121
+ dismissedIntentional: 0,
122
+ lastResult: "unknown",
123
+ lastStatus: null,
124
+ lastSeenAt: now,
125
+ severity: e.severity || "low",
126
+ source: e.source || "default",
127
+ };
128
+ cur.runs += 1;
129
+ if (e.result === "pass") {
130
+ cur.passed += 1;
131
+ if (cur.lastResult === "fail") cur.confirmedReal += 1;
132
+ cur.lastResult = "pass";
133
+ } else if (e.result === "fail") {
134
+ cur.failed += 1;
135
+ cur.lastResult = "fail";
136
+ }
137
+ cur.lastStatus = (typeof e.status === "number" || e.status === null) ? e.status : cur.lastStatus;
138
+ cur.lastSeenAt = now;
139
+ cur.severity = e.severity || cur.severity;
140
+ cur.source = e.source || cur.source;
141
+ stats.checks[e.id] = cur;
142
+ }
143
+ return await persistStats(stats);
144
+ }
145
+
146
+ /**
147
+ * Mark a check as dismissed-intentional. Customer ran `pqcheck dismiss <id>`
148
+ * after reviewing it as a false positive / intentional state.
149
+ */
150
+ export async function markDismissed(id) {
151
+ if (typeof id !== "string" || !id) return null;
152
+ const stats = await loadStats();
153
+ const cur = stats.checks[id] || { runs: 0, passed: 0, failed: 0, confirmedReal: 0, dismissedIntentional: 0, lastResult: "unknown", lastStatus: null, lastSeenAt: new Date().toISOString(), severity: "low", source: "customer" };
154
+ cur.dismissedIntentional += 1;
155
+ cur.lastSeenAt = new Date().toISOString();
156
+ stats.checks[id] = cur;
157
+ return await persistStats(stats);
158
+ }
159
+
160
+ /**
161
+ * Mark a check as a confirmed real catch (used by `pqcheck confirm <id>`
162
+ * when the customer wants to record it explicitly rather than relying on
163
+ * the inferred-from-fix mechanism).
164
+ */
165
+ export async function markConfirmed(id) {
166
+ if (typeof id !== "string" || !id) return null;
167
+ const stats = await loadStats();
168
+ const cur = stats.checks[id] || { runs: 0, passed: 0, failed: 0, confirmedReal: 0, dismissedIntentional: 0, lastResult: "unknown", lastStatus: null, lastSeenAt: new Date().toISOString(), severity: "low", source: "customer" };
169
+ cur.confirmedReal += 1;
170
+ cur.lastSeenAt = new Date().toISOString();
171
+ stats.checks[id] = cur;
172
+ return await persistStats(stats);
173
+ }
174
+
175
+ /**
176
+ * R90 (2026-06-05) — record snapshot of "what's on the public surface" so
177
+ * the next deploy-check can diff it. Captures:
178
+ * - publicRoutes: list of /privacy, /terms, etc. paths returning 200
179
+ * - thirdPartyHosts: list of third-party script hosts loaded by the homepage
180
+ * - certDaysToExpiry: TLS cert remaining days
181
+ *
182
+ * Compared against the prior snapshot at next-deploy time to emit
183
+ * "new since last deploy" diffs (B and C in the final build brief).
184
+ * Snapshots live in .cipherwake/stats.json (local, no transmission).
185
+ *
186
+ * Returns the diff vs the prior snapshot (or null if this is the first run).
187
+ */
188
+ export async function recordSurfaceSnapshot(snapshot) {
189
+ try {
190
+ const stats = await loadStats();
191
+ if (!stats.surfaceSnapshots) stats.surfaceSnapshots = {};
192
+ const now = new Date().toISOString();
193
+ const prev = stats.surfaceSnapshots[snapshot.domain] || null;
194
+ const diff = computeSurfaceDiff(prev, snapshot);
195
+ stats.surfaceSnapshots[snapshot.domain] = {
196
+ capturedAt: now,
197
+ publicRoutes: snapshot.publicRoutes || [],
198
+ thirdPartyHosts: snapshot.thirdPartyHosts || [],
199
+ certDaysToExpiry: snapshot.certDaysToExpiry ?? null,
200
+ };
201
+ await persistStats(stats);
202
+ return diff;
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ function computeSurfaceDiff(prev, current) {
209
+ if (!prev) return null;
210
+ const prevRoutes = new Set(prev.publicRoutes || []);
211
+ const currRoutes = new Set(current.publicRoutes || []);
212
+ const prevHosts = new Set(prev.thirdPartyHosts || []);
213
+ const currHosts = new Set(current.thirdPartyHosts || []);
214
+ return {
215
+ newRoutes: [...currRoutes].filter((r) => !prevRoutes.has(r)),
216
+ removedRoutes: [...prevRoutes].filter((r) => !currRoutes.has(r)),
217
+ newHosts: [...currHosts].filter((h) => !prevHosts.has(h)),
218
+ removedHosts: [...prevHosts].filter((h) => !currHosts.has(h)),
219
+ prevSnapshotAt: prev.capturedAt,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Format stats as a human-readable table for `pqcheck stats`.
225
+ */
226
+ export function formatStatsTable(stats) {
227
+ const ids = Object.keys(stats.checks || {}).sort();
228
+ if (ids.length === 0) return "No check results recorded yet. Run `pqcheck deploy-check` to start.";
229
+ const lines = [];
230
+ lines.push("Cipherwake check stats (local, never transmitted)");
231
+ lines.push("=" .repeat(96));
232
+ lines.push("CHECK".padEnd(40) + " RUNS PASS FAIL CONFIRMED DISMISSED LAST SEVERITY");
233
+ lines.push("-" .repeat(96));
234
+ for (const id of ids) {
235
+ const s = stats.checks[id];
236
+ const last = s.lastResult || "?";
237
+ lines.push(
238
+ id.slice(0, 39).padEnd(40) +
239
+ String(s.runs).padStart(5) +
240
+ String(s.passed).padStart(6) +
241
+ String(s.failed).padStart(6) +
242
+ String(s.confirmedReal).padStart(11) +
243
+ String(s.dismissedIntentional).padStart(11) +
244
+ " " + last.padEnd(5) +
245
+ " " + (s.severity || "?")
246
+ );
247
+ }
248
+ lines.push("-" .repeat(96));
249
+ const totalRuns = ids.reduce((a, id) => a + (stats.checks[id].runs || 0), 0);
250
+ const totalFailed = ids.reduce((a, id) => a + (stats.checks[id].failed || 0), 0);
251
+ const totalConfirmed = ids.reduce((a, id) => a + (stats.checks[id].confirmedReal || 0), 0);
252
+ const totalDismissed = ids.reduce((a, id) => a + (stats.checks[id].dismissedIntentional || 0), 0);
253
+ const catchRate = totalFailed > 0 ? ((totalConfirmed / totalFailed) * 100).toFixed(0) : "—";
254
+ lines.push(`TOTAL: ${ids.length} checks tracked, ${totalRuns} runs, ${totalFailed} failures, ${totalConfirmed} confirmed real, ${totalDismissed} dismissed.`);
255
+ lines.push(`Confirmed-catch rate: ${catchRate}% of failures were confirmed real (a fix landed afterward).`);
256
+ return lines.join("\n");
257
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.16.23",
3
+ "version": "0.16.28",
4
4
  "description": "Deploy gate for AI-coded web apps. `pqcheck deploy-check --ai` returns ship_decision=pass|review|block for Claude Code / Cursor / Copilot / Aider to gate deploys before they ship. Anonymous, no signup, free for first use.",
5
5
  "keywords": [
6
6
  "ai-coder",