pqcheck 0.15.1 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -64,44 +64,72 @@ let state;
64
64
  try {
65
65
  state = JSON.parse(readFileSync(STATE_FILE, "utf8"));
66
66
  } catch {
67
- // No scan yet — render an onboarding hint so first-time users learn the
68
- // command. Use the dim color so it doesn't dominate the status line.
67
+ // No scan yet — anchor on the brand so first-time users see Cipherwake
68
+ // every turn (and learn what the status line refers to), then nudge to
69
+ // the command. Dim so it doesn't dominate.
69
70
  process.stdout.write(
70
- c(C.dim, "◆ cipherwake · no scan yet · ") + c(C.cyan, "npx pqcheck <domain> --ai")
71
+ c(C.dim, "◆ Cipherwake · no scan yet ") + c(C.cyan, "npx pqcheck <domain> --ai")
71
72
  );
72
73
  process.exit(0);
73
74
  }
74
75
 
75
- const { domain, score, grade, ship_decision, written_at, max_severity, kind } = state;
76
+ const { domain, score, grade, ship_decision, written_at, max_severity, unreachable } = state;
76
77
  const age = ageHours(written_at);
77
78
 
78
79
  if (age > STALE_THRESHOLD_HOURS) {
79
- const stalePart = c(C.dim, `◆ ${domain || "cipherwake"} · stale (${formatAge(written_at)})`);
80
- const hintPart = c(C.cyan, `npx pqcheck ${domain || "<domain>"} --ai`);
81
- process.stdout.write(`${stalePart} · ${hintPart}`);
80
+ // Stale = the cached check is too old to anchor a deploy on. Use ◌
81
+ // (dotted circle) to signal "needs refresh" without alarming. Stays
82
+ // muted so an old check doesn't pretend to be active state.
83
+ process.stdout.write(
84
+ c(C.dim, `◆ Cipherwake · ${domain || "—"} ◌ STALE · last checked ${formatAge(written_at)} — `) +
85
+ c(C.cyan, `npx pqcheck ${domain || "<domain>"} --ai`)
86
+ );
82
87
  process.exit(0);
83
88
  }
84
89
 
85
- const symbolByDecision = { pass: "", review: "⚠", block: "✗" };
90
+ // Brand-anchored layout "Cipherwake" is always the first word after the
91
+ // diamond, so the customer (and their AI agent) can identify the status
92
+ // line's source at a glance. Trailing segments depend on what we have:
93
+ //
94
+ // pass (no DBR): ◆ Cipherwake · pinnedai.dev ✓ PASS · just now
95
+ // pass (w/ DBR): ◆ Cipherwake · pinnedai.dev ✓ PASS · DBR 8.7 A · just now
96
+ // review: ◆ Cipherwake · pinnedai.dev ⚠ REVIEW · DBR 4.1 C · HIGH · 1h ago
97
+ // block: ◆ Cipherwake · pinnedai.dev ⛔ BLOCK · HIGH · now
98
+ // unreachable: ◆ Cipherwake · pinnedai.dev ⊘ UNREACHABLE · now
99
+ //
100
+ // "Unreachable" is a distinct visual + label even though ship_decision=block,
101
+ // because the failure mode is different (we couldn't measure the trust
102
+ // surface at all, vs. we found a critical finding). The AI agent still
103
+ // halts on block-routing — same protocol behavior, clearer customer
104
+ // messaging.
105
+ const isUnreachable = !!unreachable;
106
+ const symbolByDecision = { pass: "✓", review: "⚠", block: "⛔" };
86
107
  const colorByDecision = { pass: C.green, review: C.yellow, block: C.red };
87
- const symbol = symbolByDecision[ship_decision] || "·";
88
- const cdec = colorByDecision[ship_decision] || C.dim;
108
+ const symbol = isUnreachable ? "⊘" : (symbolByDecision[ship_decision] || "·");
109
+ const cdec = isUnreachable ? C.red : (colorByDecision[ship_decision] || C.dim);
110
+ const labelWord = isUnreachable
111
+ ? "UNREACHABLE"
112
+ : (ship_decision || "—").toUpperCase();
89
113
 
90
- const dbrStr = typeof score === "number" ? `DBR ${score.toFixed(1)}` : "";
91
- const gradeStr = grade ? grade : "";
92
- const sevStr = max_severity && max_severity !== "none"
93
- ? ${String(max_severity).toUpperCase()}`
114
+ // When unreachable, suppress DBR/severity trailing segments they aren't
115
+ // meaningful (no score was computed) and would clutter the line. Just the
116
+ // glyph + UNREACHABLE label + age is enough.
117
+ const dbrSegment = (!isUnreachable && typeof score === "number")
118
+ ? ` · DBR ${score.toFixed(1)}${grade ? " " + grade : ""}`
119
+ : "";
120
+ const sevSegment = (!isUnreachable && max_severity && max_severity !== "none")
121
+ ? ` · ${String(max_severity).toUpperCase()}`
94
122
  : "";
95
- const kindStr = kind && kind !== "scan" ? `· ${kind}` : "";
96
123
 
97
- // Final layout (color-coded; ANSI stripped under --no-color / NO_COLOR=1):
98
- // ◆ <domain> ✓|⚠|✗ ship_decision · DBR X.X grade · SEVERITY · kind · age
99
124
  process.stdout.write(
100
125
  c(cdec, "◆") +
101
126
  " " +
102
- c(C.bold, domain || "cipherwake") +
127
+ c(C.bold, "Cipherwake") +
128
+ " " +
129
+ c(C.dim, "·") +
103
130
  " " +
104
- c(cdec, `${symbol} ${(ship_decision || "—").toUpperCase()}`) +
131
+ c(C.bold, domain || "—") +
105
132
  " " +
106
- c(C.dim, `· ${dbrStr}${gradeStr ? " " + gradeStr : ""} ${sevStr} ${kindStr} · ${formatAge(written_at)}`)
133
+ c(cdec, `${symbol} ${labelWord}`) +
134
+ c(C.dim, `${dbrSegment}${sevSegment} · ${formatAge(written_at)}`)
107
135
  );
package/bin/pqcheck.js CHANGED
@@ -24,7 +24,7 @@
24
24
  })();
25
25
 
26
26
  const API_BASE = process.env.PQCHECK_API_BASE || "https://cipherwake.io";
27
- const VERSION = "0.15.1";
27
+ const VERSION = "0.15.2";
28
28
 
29
29
  // API-key support — paid tiers (Starter $29 / Growth $79 / Scale $199) get
30
30
  // per-account monthly quotas instead of the per-IP rate limit. Set via:
@@ -296,26 +296,78 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
296
296
  console.error(`pqcheck: ⚠ ${domain} — using cached score (live probe failed: ${report._meta.degradedReason || "unknown"}; last verified ${report._meta.lastUpdated || "?"})`);
297
297
  }
298
298
 
299
+ // Compute ship-decision once — needed both for AI-mode footer AND for the
300
+ // per-user/per-repo state files that statusline/chat-hook/prompt-hook read.
301
+ // State files must update on every successful scan, regardless of --ai, so
302
+ // downstream agents see the same scan the human just ran.
303
+ const findings = Array.isArray(report.findings) ? report.findings : [];
304
+ const maxSev = highestSeverity(findings);
305
+ let shipDecision = computeShipDecision({ maxSeverity: maxSev });
306
+ const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
307
+
308
+ // Unreachable / degraded → force "block". A scan that couldn't actually
309
+ // reach the domain (no DNS, TLS handshake failed, deploy hadn't propagated
310
+ // yet, typo in the domain) is categorically different from "trust drift
311
+ // detected" — we couldn't evaluate the trust surface AT ALL. "review"
312
+ // would be too soft (the AI agent might shrug and ship); "block" properly
313
+ // halts announcement per the AI Coder Protocol until the human confirms
314
+ // the unreachability was expected (e.g., they know DNS is still
315
+ // propagating) or fixes the deploy. This matches the protocol's "STOP,
316
+ // wait for explicit override" routing.
317
+ // Only treat literal "reachable === false" as unreachable. _meta.degraded
318
+ // is too broad — it also fires for fingerprint disagreement / cache
319
+ // fallback / mid-scan state changes on reachable domains (e.g. stripe.com
320
+ // mid-deploy), and those scans returned a real score we shouldn't hide
321
+ // behind an UNREACHABLE label.
322
+ const unreachable = report?.reachable === false;
323
+ if (unreachable) {
324
+ shipDecision = "block";
325
+ }
326
+
327
+ // Capture reachability AFTER ship_decision override so state files reflect
328
+ // the truth: ship_decision=block + unreachable=true means "we couldn't
329
+ // reach it, treat as block." Downstream surfaces (statusline / VS Code
330
+ // extension / AI banner) display the "UNREACHABLE" label when
331
+ // unreachable=true instead of the generic "BLOCK" — more informative,
332
+ // same protocol routing.
333
+ await writeLastScanFile({
334
+ domain,
335
+ kind: "scan",
336
+ score: typeof report.score === "number" ? report.score : null,
337
+ grade: report.grade || null,
338
+ max_severity: maxSev,
339
+ ship_decision: shipDecision,
340
+ unreachable: unreachable || false,
341
+ top_issue: topFinding?.id || topFinding?.title || null,
342
+ });
343
+
299
344
  // AI Coder Mode — three-layer output for Claude Code / Cursor / Aider.
300
345
  // Overrides --format when --ai is set; emits the structured block at the
301
346
  // bottom so downstream agents can parse ship_decision deterministically.
302
347
  if (aiMode) {
303
- const findings = Array.isArray(report.findings) ? report.findings : [];
304
- const maxSev = highestSeverity(findings);
305
- const shipDecision = computeShipDecision({ maxSeverity: maxSev });
306
- const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
307
-
308
- const topIssue = topFinding
309
- ? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
310
- : "No findings at or above LOW severity.";
311
- const whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk. Findings reflect public-surface signals only.";
312
- const nextActions = shipDecision === "pass"
313
- ? [`Domain looks healthy. View full report: ${API_BASE}/r/${domain}`]
314
- : [
315
- `Review finding above and decide if it was intentional.`,
316
- `View full report: ${API_BASE}/r/${domain}`,
317
- `Re-scan with --fresh after fix: npx pqcheck ${domain} --fresh --ai`,
318
- ];
348
+ let topIssue, whyMatters, nextActions;
349
+ if (unreachable) {
350
+ topIssue = "[REACHABILITY] Domain unreachable — TLS probe failed or DNS unresolved";
351
+ whyMatters = report?._meta?.degradedReason || "The scanner couldn't reach the domain on port 443. Either DNS hasn't propagated, the deploy hasn't completed, or this domain isn't deployed at the address we expected.";
352
+ nextActions = [
353
+ `Check the domain is correct (typo?): ${domain}`,
354
+ `Verify DNS: dig +short ${domain}`,
355
+ `Verify deploy completed and TLS is live on https://${domain}`,
356
+ `Re-run after fix: npx pqcheck deploy-check ${domain} --ai`,
357
+ ];
358
+ } else {
359
+ topIssue = topFinding
360
+ ? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
361
+ : "No findings at or above LOW severity.";
362
+ whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk. Findings reflect public-surface signals only.";
363
+ nextActions = shipDecision === "pass"
364
+ ? [`Domain looks healthy. View full report: ${API_BASE}/r/${domain}`]
365
+ : [
366
+ `Review finding above and decide if it was intentional.`,
367
+ `View full report: ${API_BASE}/r/${domain}`,
368
+ `Re-scan with --fresh after fix: npx pqcheck ${domain} --fresh --ai`,
369
+ ];
370
+ }
319
371
 
320
372
  console.log("");
321
373
  console.log(formatAiBanner({
@@ -325,6 +377,7 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
325
377
  grade: report.grade,
326
378
  maxSeverity: maxSev,
327
379
  shipDecision,
380
+ unreachable,
328
381
  }));
329
382
  console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
330
383
  console.log(formatAiFooterBlock({
@@ -343,16 +396,6 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
343
396
  }));
344
397
  console.log("");
345
398
 
346
- await writeLastScanFile({
347
- domain,
348
- kind: "scan",
349
- score: typeof report.score === "number" ? report.score : null,
350
- grade: report.grade || null,
351
- max_severity: maxSev,
352
- ship_decision: shipDecision,
353
- top_issue: topFinding?.id || topFinding?.title || null,
354
- });
355
-
356
399
  // Threshold check still applies under --ai (script-pipeable). Otherwise
357
400
  // exit code reflects ship_decision so the caller can route on it.
358
401
  if (threshold !== null && typeof report.score === "number" && report.score >= threshold) {
@@ -585,14 +628,30 @@ function aiStatusEmoji(shipDecision) {
585
628
  return "⚠";
586
629
  }
587
630
 
588
- function formatAiBanner({ domain, kind, dbr, grade, maxSeverity, shipDecision }) {
589
- const emoji = aiStatusEmoji(shipDecision);
590
- const statusWord = ({ pass: "PASS", review: "REVIEW", block: "BLOCK" })[shipDecision] || "REVIEW";
591
- const c = aiBannerColor(shipDecision);
592
- const dbrStr = typeof dbr === "number" ? dbr.toFixed(1) : "—";
593
- const gradeStr = grade ? ` ${grade}` : "";
594
- const sevStr = maxSeverity && maxSeverity !== "none" ? ` · ${String(maxSeverity).toUpperCase()}` : "";
595
- return color(c, `◆ cipherwake · ${kind} · ${emoji} ${statusWord} · ${domain} · DBR ${dbrStr}${gradeStr}${sevStr} · ship_decision=${shipDecision}`);
631
+ function formatAiBanner({ domain, kind, dbr, grade, maxSeverity, shipDecision, unreachable }) {
632
+ // When the scanner couldn't reach the domain we render "UNREACHABLE"
633
+ // (with a distinct glyph) instead of the generic "BLOCK" semantically
634
+ // clearer for the customer: their site isn't deployed / isn't responding,
635
+ // not that we found a critical security finding.
636
+ //
637
+ // Suppress DBR + severity trailing segments when unreachable: even if the
638
+ // API returned a stale cached score on a degraded scan, surfacing it next
639
+ // to "UNREACHABLE" reads as contradictory ("how can it score X if you
640
+ // couldn't reach it?"). The age + banner already convey the situation.
641
+ const isUnreachable = !!unreachable;
642
+ const emoji = isUnreachable ? "⊘" : aiStatusEmoji(shipDecision);
643
+ const statusWord = isUnreachable
644
+ ? "UNREACHABLE"
645
+ : (({ pass: "PASS", review: "REVIEW", block: "BLOCK" })[shipDecision] || "REVIEW");
646
+ const c = aiBannerColor(isUnreachable ? "block" : shipDecision);
647
+ const dbrSegment = (!isUnreachable && typeof dbr === "number")
648
+ ? ` · DBR ${dbr.toFixed(1)}${grade ? " " + grade : ""}`
649
+ : "";
650
+ const sevSegment = (!isUnreachable && maxSeverity && maxSeverity !== "none")
651
+ ? ` · ${String(maxSeverity).toUpperCase()}`
652
+ : "";
653
+ const kindSegment = (kind && kind !== "scan") ? ` · ${kind}` : "";
654
+ return color(c, `◆ Cipherwake · ${domain} ${emoji} ${statusWord}${dbrSegment}${sevSegment}${kindSegment}`);
596
655
  }
597
656
 
598
657
  function formatAiBody({ topIssue, whyMatters, nextActions }) {
@@ -2333,19 +2392,49 @@ async function runScanBasedDeployCheck(domain, args) {
2333
2392
  const report = await resp.json();
2334
2393
  const findings = Array.isArray(report.findings) ? report.findings : [];
2335
2394
  const maxSev = highestSeverity(findings);
2336
- const shipDecision = computeShipDecision({ maxSeverity: maxSev });
2395
+ let shipDecision = computeShipDecision({ maxSeverity: maxSev });
2337
2396
  const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
2338
- const topIssue = topFinding
2339
- ? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
2340
- : "No findings at or above LOW severity.";
2341
- const whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk on the public TLS surface.";
2342
- const nextActions = shipDecision === "pass"
2343
- ? [`Domain looks healthy on first scan. View full report: ${API_BASE}/r/${domain}`]
2344
- : [
2345
- `Review finding above and decide if it was intentional.`,
2346
- `View full report: ${API_BASE}/r/${domain}`,
2347
- `Subsequent deploy-checks will diff against this scan as baseline.`,
2348
- ];
2397
+
2398
+ // Unreachable / degraded → force "block" with an UNREACHABLE display label
2399
+ // downstream. See main scan path for the rationale: an undeployed-or-broken
2400
+ // domain can't be evaluated, so the AI agent must halt announcement and ask
2401
+ // the human (was this expected? did the deploy fail?). The `unreachable`
2402
+ // field travels in the state file + structured block so statusline /
2403
+ // VS Code ext / chat-hook can render "UNREACHABLE" instead of generic
2404
+ // "BLOCK" semantically clearer for this specific failure mode.
2405
+ // Only treat literal "reachable === false" as unreachable. _meta.degraded
2406
+ // is too broad it also fires for fingerprint disagreement / cache
2407
+ // fallback / mid-scan state changes on reachable domains (e.g. stripe.com
2408
+ // mid-deploy), and those scans returned a real score we shouldn't hide
2409
+ // behind an UNREACHABLE label.
2410
+ const unreachable = report?.reachable === false;
2411
+ if (unreachable) {
2412
+ shipDecision = "block";
2413
+ }
2414
+
2415
+ let topIssue, whyMatters, nextActions;
2416
+ if (unreachable) {
2417
+ topIssue = "[REACHABILITY] Domain unreachable — TLS probe failed or DNS unresolved";
2418
+ whyMatters = report?._meta?.degradedReason || "The scanner couldn't reach the domain on port 443. Either DNS hasn't propagated, the deploy hasn't completed, or this domain isn't deployed at the address we expected.";
2419
+ nextActions = [
2420
+ `Check the domain is correct (typo?): ${domain}`,
2421
+ `Verify DNS: dig +short ${domain}`,
2422
+ `Verify deploy completed and TLS is live on https://${domain}`,
2423
+ `Re-run after fix: npx pqcheck deploy-check ${domain} --ai`,
2424
+ ];
2425
+ } else {
2426
+ topIssue = topFinding
2427
+ ? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
2428
+ : "No findings at or above LOW severity.";
2429
+ whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk on the public TLS surface.";
2430
+ nextActions = shipDecision === "pass"
2431
+ ? [`Domain looks healthy on first scan. View full report: ${API_BASE}/r/${domain}`]
2432
+ : [
2433
+ `Review finding above and decide if it was intentional.`,
2434
+ `View full report: ${API_BASE}/r/${domain}`,
2435
+ `Subsequent deploy-checks will diff against this scan as baseline.`,
2436
+ ];
2437
+ }
2349
2438
 
2350
2439
  console.log("");
2351
2440
  console.log(color("dim", ` ℹ first deploy-check for ${domain} — using current scan state (no baseline yet to diff against)`));
@@ -2357,23 +2446,46 @@ async function runScanBasedDeployCheck(domain, args) {
2357
2446
  grade: report.grade,
2358
2447
  maxSeverity: maxSev,
2359
2448
  shipDecision,
2449
+ unreachable,
2360
2450
  }));
2361
2451
  console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
2362
2452
  console.log(formatAiFooterBlock({
2363
- status: shipDecision === "pass" ? "pass" : "review",
2453
+ status: shipDecision,
2364
2454
  domain,
2365
2455
  kind: "scan",
2366
2456
  dbr: report.score,
2367
2457
  grade: report.grade,
2368
2458
  max_severity: maxSev,
2369
2459
  ship_decision: shipDecision,
2370
- top_issue: topFinding ? `findings.${topFinding.id || "unknown"}` : "none",
2460
+ unreachable: unreachable ? "true" : "false",
2461
+ top_issue: unreachable
2462
+ ? "findings.reachability.unreachable"
2463
+ : (topFinding ? `findings.${topFinding.id || "unknown"}` : "none"),
2371
2464
  findings_high: findings.filter((f) => severityRank(f.severity) >= severityRank("high")).length,
2372
2465
  findings_critical: findings.filter((f) => severityRank(f.severity) >= severityRank("critical")).length,
2373
2466
  scanned_at: new Date().toISOString(),
2374
2467
  advisory_only: true,
2375
- note: "first-deploy: no baseline yet, scored on current state",
2468
+ note: unreachable
2469
+ ? "domain unreachable on port 443; deploy may have failed or DNS not propagated"
2470
+ : "first-deploy: no baseline yet, scored on current state",
2376
2471
  }));
2472
+
2473
+ await writeLastScanFile({
2474
+ domain,
2475
+ kind: "scan",
2476
+ score: typeof report.score === "number" ? report.score : null,
2477
+ grade: report.grade || null,
2478
+ max_severity: maxSev,
2479
+ ship_decision: shipDecision,
2480
+ unreachable: unreachable || false,
2481
+ top_issue: unreachable
2482
+ ? "findings.reachability.unreachable"
2483
+ : (topFinding?.id || topFinding?.title || null),
2484
+ note: unreachable
2485
+ ? "domain unreachable on port 443; deploy may have failed or DNS not propagated"
2486
+ : "first-deploy: no baseline yet, scored on current state",
2487
+ });
2488
+
2377
2489
  // Exit code: 0 on pass, non-zero on review/block (matches deploy-check contract)
2378
2490
  process.exit(shipDecision === "pass" ? 0 : 1);
2379
2491
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
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",