pqcheck 0.15.0 → 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.
package/README.md CHANGED
@@ -27,7 +27,9 @@ The same scanner that powers [cipherwake.io](https://cipherwake.io), the browser
27
27
  | `npx pqcheck onboard <domain>` | One command: scan → scaffold the GitHub Action → capture a vendor lockfile → set a baseline → commit + push. Zero copy-paste from docs. |
28
28
  | **`npx pqcheck guard --domain <D> -- <cmd>`** 🆕 | **Deploy guard wrapper.** Wraps any deploy command. Runs `deploy-check` first; conditionally runs `<cmd>` based on `ship_decision`. Modes: `--gate-mode balanced` (default) / `advisory` / `strict`. ONE command instead of two — the strongest single artifact for AI-coder workflows because the AI never has to remember to chain check + deploy. |
29
29
  | **`npx pqcheck protocol install`** 🆕 | **Opt-in installer** for the AI Coder Protocol — appends the pre-deploy verification rule to your `CLAUDE.md` / `.cursorrules` / `.aider.conf.yml` with explicit consent (Rule 17). One upfront question (auto / manual / no). Never silent writes. |
30
- | **`npx pqcheck setup --auto --domain <D>`** 🆕 | **One-command full setup for every AI coder.** Installs (idempotently): GitHub Action workflow, AI Coder Protocol across all detected rules files (Claude / Cursor / Copilot / Aider / Windsurf / Continue / Cline / AGENTS.md) using fenced markers (`<!-- CIPHERWAKE_AI_CODER_PROTOCOL_START/END -->`), git pre-push hook, Claude Code statusLine + chat-hook (PostToolUse Bash). Skip flags available. Backups taken before any `~/.claude/settings.json` write. Audit trail at `~/.config/cipherwake/install-prefs.json`; install manifest at `~/.config/cipherwake/install-manifest.json`. |
30
+ | **`npx pqcheck setup --auto --domain <D>`** 🆕 | **One-command full setup for every AI coder.** Installs (idempotently): GitHub Action workflow, AI Coder Protocol across all detected rules files (Claude / Cursor / Copilot / Aider / Windsurf / Continue / Cline / AGENTS.md) using fenced markers (`<!-- CIPHERWAKE_AI_CODER_PROTOCOL_START/END -->`), git pre-push hook, Claude Code statusLine + 2 hooks (PostToolUse Bash + **UserPromptSubmit** ⓝ), per-repo `.cipherwake/last-status.json` for Cursor / Copilot / Continue to read as context. Skip flags available. Backups taken before any `~/.claude/settings.json` write. Audit trail at `~/.config/cipherwake/install-prefs.json`; install manifest at `~/.config/cipherwake/install-manifest.json`. |
31
+ | **`UserPromptSubmit` hook** (v0.15.1 ⓝ) | **Claude sees `ship_decision` before responding to every prompt.** When `pqcheck setup --auto` runs, it wires `cipherwake-prompt-hook` as a Claude Code UserPromptSubmit hook. On every user prompt, the hook injects `additionalContext` with the current scan's `ship_decision` IF it's `review`/`block` and the state is <24h old. Silent when state is missing, stale, or `pass`. Different timing from the PostToolUse chat-hook: this fires *before* Claude thinks (proactive), the chat-hook fires *after* a Bash command (reactive). |
32
+ | **Per-repo state file** `.cipherwake/last-status.json` (v0.15.1 ⓝ) | **Cursor / Copilot / Continue read this for workspace context.** Every `pqcheck` scan writes the same payload as the per-user file. Created by `setup --auto`; auto-added to `.gitignore` (per-developer state, not committable). Gives AI agents inside VS Code-family editors a repo-local artifact they pick up automatically when reading workspace files. |
31
33
  | **`npx pqcheck setup --plan --domain <D>`** 🆕 | **Dry-run mode.** Prints every file change `--auto` would make (target paths + operation type: create / append-markered / deep-merge / backup-first) without writing anything. Run this first when you're not sure what `--auto` will touch. |
32
34
  | **`npx pqcheck debug-network`** 🆕 | **Connectivity diagnostic.** Probes cipherwake.io API, homepage, crt.sh upstream, and the direct Vercel URL (bypassing Cloudflare). Reports HTTP status + timing per hop. Use when "scan hung" / "command not found" / corporate proxy issues come up — surfaces the actual broken hop with an actionable cause list. |
33
35
  | **`--ai` flag** (any of the above) | **AI Coder Mode** (0.15.0). Three-layer output (banner / body / structured `CIPHERWAKE_AI_GUARD_RESULT` block) tuned for Claude Code / Cursor / Aider / Zed. Includes a `ship_decision=pass\|review\|block` field your AI coworker parses to decide whether to announce the deploy, ask you, or revert. See [/methodology/ai-coder-mode](https://cipherwake.io/methodology/ai-coder-mode). |
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // cipherwake-prompt-hook — Claude Code UserPromptSubmit hook (v0.15.1 parity)
4
+ // =============================================================================
5
+ // Fires BEFORE Claude responds to every user prompt. Injects the latest scan
6
+ // state from ~/.config/cipherwake/last-scan.json into Claude's context, so
7
+ // the AI sees ship_decision proactively (e.g., when the user says "ok deploy
8
+ // this", Claude already knows the trust posture).
9
+ //
10
+ // Different timing from cipherwake-chat-hook (PostToolUse Bash) — that one
11
+ // fires AFTER a tool runs and pushes a chat message reactively. This one
12
+ // fires BEFORE the model thinks, by injecting additionalContext via the
13
+ // hookSpecificOutput shape.
14
+ //
15
+ // Silent (no injection) when:
16
+ // • state file missing
17
+ // • state file > 24h old (don't anchor on stale data)
18
+ // • ship_decision === "pass" (no need to spam the model with good news)
19
+ //
20
+ // Wire-up via `pqcheck setup --auto` writes the entry to
21
+ // ~/.claude/settings.json under `hooks.UserPromptSubmit`.
22
+ // =============================================================================
23
+
24
+ import { readFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { homedir } from "node:os";
27
+
28
+ const STATE_FILE = process.env.CIPHERWAKE_STATE_FILE
29
+ || join(homedir(), ".config", "cipherwake", "last-scan.json");
30
+
31
+ const MAX_STATE_AGE_MS = 24 * 60 * 60 * 1000; // 24h — older than this, don't inject
32
+
33
+ function readStdin() {
34
+ return new Promise((resolve) => {
35
+ let data = "";
36
+ process.stdin.setEncoding("utf8");
37
+ process.stdin.on("data", (chunk) => (data += chunk));
38
+ process.stdin.on("end", () => resolve(data));
39
+ // If no stdin (manual invocation), resolve empty immediately
40
+ if (process.stdin.isTTY) resolve("");
41
+ });
42
+ }
43
+
44
+ function silent() {
45
+ // Output nothing — Claude Code treats empty output as "no injection".
46
+ process.exit(0);
47
+ }
48
+
49
+ async function main() {
50
+ // Drain stdin (Claude Code sends a JSON event payload). We don't actually
51
+ // need the event content here — we just decide based on cached state.
52
+ try { await readStdin(); } catch { /* ignore */ }
53
+
54
+ let state;
55
+ try {
56
+ state = JSON.parse(readFileSync(STATE_FILE, "utf8"));
57
+ } catch {
58
+ silent();
59
+ return;
60
+ }
61
+
62
+ // Freshness: only inject if the last scan is recent. Anchoring Claude on
63
+ // a week-old REVIEW state would be misleading after the user has likely
64
+ // already fixed the issue.
65
+ const writtenAt = state.written_at ? new Date(state.written_at).getTime() : 0;
66
+ if (!writtenAt || Date.now() - writtenAt > MAX_STATE_AGE_MS) {
67
+ silent();
68
+ return;
69
+ }
70
+
71
+ // Only inject for non-pass states. Don't spam the model with good news on
72
+ // every prompt — Claude already trusts that no news is good news.
73
+ const shipDecision = String(state.ship_decision || "").toLowerCase();
74
+ if (shipDecision !== "review" && shipDecision !== "block") {
75
+ silent();
76
+ return;
77
+ }
78
+
79
+ const emoji = shipDecision === "block" ? "⛔" : "⚠";
80
+ const sevLabel = shipDecision === "block" ? "BLOCK" : "REVIEW";
81
+ const dbr = typeof state.score === "number" ? state.score.toFixed(1) : "?";
82
+ const grade = state.grade || "?";
83
+ const maxSev = String(state.max_severity || "").toUpperCase();
84
+ const top = state.top_issue || "—";
85
+ const ago = Math.floor((Date.now() - writtenAt) / 60000);
86
+ const ageStr = ago < 1 ? "just now" : ago < 60 ? `${ago}m ago` : `${Math.floor(ago / 60)}h ago`;
87
+
88
+ // Build the context-injection block. Brevity matters — this lands in every
89
+ // user prompt's context, so it should be a one-paragraph reminder, not a
90
+ // multi-screen status report.
91
+ const additionalContext =
92
+ `${emoji} Cipherwake — latest scan of ${state.domain || "configured domain"}: ship_decision=${shipDecision} ` +
93
+ `(${sevLabel}, DBR ${dbr} ${grade}, max ${maxSev}, last scanned ${ageStr}). ` +
94
+ `Top issue: ${top}. ` +
95
+ `Before announcing a deploy, run \`npx pqcheck deploy-check ${state.domain || "<domain>"} --ai\` to refresh. ` +
96
+ `If ship_decision is still ${shipDecision}, surface the change to the user per the AI Coder Protocol ` +
97
+ `(https://cipherwake.io/methodology/ai-coder-protocol) — don't auto-announce.`;
98
+
99
+ // Claude Code's UserPromptSubmit hook accepts hookSpecificOutput.additionalContext
100
+ // which is injected into the model's input on this turn. The hook returns
101
+ // synchronously; suppressOutput hides anything on stdout from the UI.
102
+ process.stdout.write(JSON.stringify({
103
+ hookSpecificOutput: {
104
+ hookEventName: "UserPromptSubmit",
105
+ additionalContext,
106
+ },
107
+ suppressOutput: true,
108
+ }));
109
+ }
110
+
111
+ main().catch(() => silent());
@@ -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.0";
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 }) {
@@ -633,18 +692,39 @@ function formatAiFooterBlock(fields) {
633
692
  }
634
693
 
635
694
  // Persist last-scan state to ~/.config/cipherwake/last-scan.json.
636
- // Feeds the optional cipherwake-statusline script (v0.16.0) so users get
637
- // persistent ambient state in their AI coder's status line. Best-effort —
638
- // never throws (a write failure here doesn't break the scan).
695
+ // Feeds the cipherwake-statusline + cipherwake-prompt-hook + cipherwake-chat-hook
696
+ // scripts so users get persistent ambient state in their AI coder's surfaces.
697
+ //
698
+ // v0.15.1 (2026-05-22): ALSO writes a per-repo state file at
699
+ // .cipherwake/last-status.json IF that directory exists in cwd (created by
700
+ // `pqcheck setup --auto`). This gives Cursor / Copilot / Continue / Cline
701
+ // agents a read-on-demand surface inside the repo — they see the latest
702
+ // trust posture for the customer's primary domain when scanning repo state.
703
+ //
704
+ // Best-effort — never throws (a write failure doesn't break the scan).
639
705
  async function writeLastScanFile(payload) {
640
706
  try {
641
707
  const os = await import("node:os");
642
708
  const path = await import("node:path");
643
709
  const fs = await import("node:fs/promises");
644
- const dir = path.join(os.homedir(), ".config", "cipherwake");
645
- await fs.mkdir(dir, { recursive: true });
646
- const file = path.join(dir, "last-scan.json");
647
- await fs.writeFile(file, JSON.stringify({ ...payload, written_at: new Date().toISOString() }, null, 2));
710
+ const enriched = { ...payload, written_at: new Date().toISOString() };
711
+
712
+ // Per-user state file (primary, always written)
713
+ const userDir = path.join(os.homedir(), ".config", "cipherwake");
714
+ await fs.mkdir(userDir, { recursive: true });
715
+ await fs.writeFile(path.join(userDir, "last-scan.json"), JSON.stringify(enriched, null, 2));
716
+
717
+ // Per-repo state file (secondary, only if .cipherwake/ exists in cwd
718
+ // — i.e., this repo went through `pqcheck setup --auto`). Gives Cursor/
719
+ // Copilot/Continue/Cline agents a repo-local artifact they pick up
720
+ // automatically when reading workspace state.
721
+ const repoDir = path.join(process.cwd(), ".cipherwake");
722
+ try {
723
+ await fs.access(repoDir);
724
+ await fs.writeFile(path.join(repoDir, "last-status.json"), JSON.stringify(enriched, null, 2));
725
+ } catch {
726
+ // .cipherwake/ doesn't exist here — that's fine, user didn't run setup --auto in this repo
727
+ }
648
728
  } catch {
649
729
  // best-effort
650
730
  }
@@ -2312,19 +2392,49 @@ async function runScanBasedDeployCheck(domain, args) {
2312
2392
  const report = await resp.json();
2313
2393
  const findings = Array.isArray(report.findings) ? report.findings : [];
2314
2394
  const maxSev = highestSeverity(findings);
2315
- const shipDecision = computeShipDecision({ maxSeverity: maxSev });
2395
+ let shipDecision = computeShipDecision({ maxSeverity: maxSev });
2316
2396
  const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
2317
- const topIssue = topFinding
2318
- ? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
2319
- : "No findings at or above LOW severity.";
2320
- const whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk on the public TLS surface.";
2321
- const nextActions = shipDecision === "pass"
2322
- ? [`Domain looks healthy on first scan. View full report: ${API_BASE}/r/${domain}`]
2323
- : [
2324
- `Review finding above and decide if it was intentional.`,
2325
- `View full report: ${API_BASE}/r/${domain}`,
2326
- `Subsequent deploy-checks will diff against this scan as baseline.`,
2327
- ];
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
+ }
2328
2438
 
2329
2439
  console.log("");
2330
2440
  console.log(color("dim", ` ℹ first deploy-check for ${domain} — using current scan state (no baseline yet to diff against)`));
@@ -2336,23 +2446,46 @@ async function runScanBasedDeployCheck(domain, args) {
2336
2446
  grade: report.grade,
2337
2447
  maxSeverity: maxSev,
2338
2448
  shipDecision,
2449
+ unreachable,
2339
2450
  }));
2340
2451
  console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
2341
2452
  console.log(formatAiFooterBlock({
2342
- status: shipDecision === "pass" ? "pass" : "review",
2453
+ status: shipDecision,
2343
2454
  domain,
2344
2455
  kind: "scan",
2345
2456
  dbr: report.score,
2346
2457
  grade: report.grade,
2347
2458
  max_severity: maxSev,
2348
2459
  ship_decision: shipDecision,
2349
- 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"),
2350
2464
  findings_high: findings.filter((f) => severityRank(f.severity) >= severityRank("high")).length,
2351
2465
  findings_critical: findings.filter((f) => severityRank(f.severity) >= severityRank("critical")).length,
2352
2466
  scanned_at: new Date().toISOString(),
2353
2467
  advisory_only: true,
2354
- 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",
2355
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
+
2356
2489
  // Exit code: 0 on pass, non-zero on review/block (matches deploy-check contract)
2357
2490
  process.exit(shipDecision === "pass" ? 0 : 1);
2358
2491
  }
@@ -4991,6 +5124,96 @@ async function runSetupCommand(args) {
4991
5124
  }
4992
5125
  }
4993
5126
 
5127
+ // -------------------------------------------------------------------------
5128
+ // Component 4c: Claude Code prompt-hook (UserPromptSubmit → cipherwake-prompt-hook)
5129
+ // v0.15.1 — pinnedai-parity item. Injects ship_decision into Claude's
5130
+ // context BEFORE Claude responds to every user prompt. Different timing
5131
+ // from 4b: chat-hook fires AFTER a tool ran (reactive), prompt-hook fires
5132
+ // BEFORE Claude responds (proactive). Silent when state is missing / stale
5133
+ // / ship_decision=pass (no spam).
5134
+ // -------------------------------------------------------------------------
5135
+ if (!skipStatusline) {
5136
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
5137
+ try {
5138
+ let settings = {};
5139
+ let existed = false;
5140
+ try {
5141
+ const raw = await fs.readFile(settingsPath, "utf8");
5142
+ settings = JSON.parse(raw);
5143
+ existed = true;
5144
+ } catch { /* will create */ }
5145
+ settings.hooks = settings.hooks || {};
5146
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit || [];
5147
+
5148
+ const cipherwakeHookCmd = "npx cipherwake-prompt-hook";
5149
+ const alreadyInstalled = settings.hooks.UserPromptSubmit.some(
5150
+ (entry) => Array.isArray(entry?.hooks) && entry.hooks.some(
5151
+ (h) => h?.type === "command" && typeof h?.command === "string" && h.command.includes("cipherwake-prompt-hook"),
5152
+ ),
5153
+ );
5154
+
5155
+ if (alreadyInstalled) {
5156
+ console.log(color("dim", ` ⊝ prompt-hook already configured in ~/.claude/settings.json UserPromptSubmit — skipping`));
5157
+ installSummary.push({ component: "Claude Code prompt-hook", path: settingsPath, status: "skipped-already-present" });
5158
+ } else {
5159
+ const backupPath = existed ? await backupSettingsJson(settingsPath) : null;
5160
+ if (backupPath) console.log(color("dim", ` backup: ${backupPath}`));
5161
+ settings.hooks.UserPromptSubmit.push({ hooks: [{ type: "command", command: cipherwakeHookCmd }] });
5162
+ await fs.mkdir(path.dirname(settingsPath), { recursive: true });
5163
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
5164
+ console.log(color("green", ` ✓ added prompt-hook (UserPromptSubmit) → ~/.claude/settings.json`));
5165
+ console.log(color("dim", ` Claude will see latest ship_decision in context on every prompt (when REVIEW/BLOCK)`));
5166
+ installSummary.push({ component: "Claude Code prompt-hook", path: settingsPath, status: existed ? "installed-updated" : "installed-created", backup: backupPath });
5167
+ }
5168
+ } catch (err) {
5169
+ console.log(color("red", ` ✗ prompt-hook install failed: ${err.message}`));
5170
+ installSummary.push({ component: "Claude Code prompt-hook", status: "failed", error: String(err?.message || err) });
5171
+ }
5172
+ }
5173
+
5174
+ // -------------------------------------------------------------------------
5175
+ // Component 4d: Per-repo state directory (.cipherwake/) for Cursor / Copilot
5176
+ // v0.15.1 — pinnedai-parity item. Cursor/Copilot/Continue/Cline read repo
5177
+ // state for context. Creating .cipherwake/ in the repo gives them a
5178
+ // read-on-demand surface that subsequent `pqcheck` runs (via the
5179
+ // writeLastScanFile path) populate with the latest scan state. Also adds
5180
+ // .cipherwake/ to .gitignore if not already there — per-developer state,
5181
+ // not committable.
5182
+ // -------------------------------------------------------------------------
5183
+ if (!skipStatusline) {
5184
+ try {
5185
+ const repoStateDir = path.join(process.cwd(), ".cipherwake");
5186
+ await fs.mkdir(repoStateDir, { recursive: true });
5187
+ // Write an initial placeholder so the file exists immediately. Subsequent
5188
+ // scans via writeLastScanFile will overwrite with real data.
5189
+ const placeholderPath = path.join(repoStateDir, "last-status.json");
5190
+ try {
5191
+ await fs.access(placeholderPath);
5192
+ // Already exists — preserve it.
5193
+ } catch {
5194
+ await fs.writeFile(placeholderPath, JSON.stringify({
5195
+ domain,
5196
+ ship_decision: "unknown",
5197
+ note: "Initial placeholder — run `npx pqcheck deploy-check " + domain + " --ai` to populate",
5198
+ written_at: new Date().toISOString(),
5199
+ }, null, 2));
5200
+ }
5201
+ // Add .cipherwake/ to .gitignore if missing (don't commit per-developer state).
5202
+ const gitignorePath = path.join(process.cwd(), ".gitignore");
5203
+ let gitignore = "";
5204
+ try { gitignore = await fs.readFile(gitignorePath, "utf8"); } catch { /* may not exist */ }
5205
+ if (!/^\.cipherwake\/?\s*$/m.test(gitignore)) {
5206
+ const appended = gitignore + (gitignore.endsWith("\n") || gitignore.length === 0 ? "" : "\n") + "\n# Cipherwake per-developer scan state (read-on-demand by AI coders)\n.cipherwake/\n";
5207
+ await fs.writeFile(gitignorePath, appended);
5208
+ }
5209
+ console.log(color("green", ` ✓ created .cipherwake/last-status.json (Cursor/Copilot/Continue read this for context)`));
5210
+ installSummary.push({ component: "Per-repo state file", path: placeholderPath, status: "installed" });
5211
+ } catch (err) {
5212
+ console.log(color("red", ` ✗ per-repo state install failed: ${err.message}`));
5213
+ installSummary.push({ component: "Per-repo state file", status: "failed", error: String(err?.message || err) });
5214
+ }
5215
+ }
5216
+
4994
5217
  // -------------------------------------------------------------------------
4995
5218
  // Component 5: VS Code / Cursor extension (via `code` CLI if available)
4996
5219
  // -------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,21 +1,29 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.15.0",
4
- "description": "HTTPS posture scanner with Preview Deploy Trust Diff for PRs, Trust Diff for CI, vendor lockfile + drift alerts, cross-tenant key map, and HNDL/quantum-decryption risk scoring. Free, no signup.",
3
+ "version": "0.15.2",
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
- "post-quantum",
7
- "cryptography",
6
+ "ai-coder",
7
+ "claude-code",
8
+ "cursor",
9
+ "copilot",
10
+ "aider",
11
+ "deploy-gate",
12
+ "deploy-check",
13
+ "ai-coder-mode",
14
+ "ship-decision",
15
+ "deploy-guard",
16
+ "ci",
8
17
  "security",
9
18
  "tls",
10
19
  "ssl",
11
20
  "scanner",
21
+ "post-quantum",
12
22
  "harvest-now-decrypt-later",
13
23
  "hndl",
14
24
  "blast-radius",
15
25
  "pqc",
16
- "quantum",
17
- "crypto-audit",
18
- "crypto-inventory"
26
+ "quantum"
19
27
  ],
20
28
  "homepage": "https://cipherwake.io",
21
29
  "bugs": "https://cipherwake.io",
@@ -33,7 +41,8 @@
33
41
  "bin": {
34
42
  "pqcheck": "./bin/pqcheck.js",
35
43
  "cipherwake-statusline": "./bin/cipherwake-statusline.js",
36
- "cipherwake-chat-hook": "./bin/cipherwake-chat-hook.js"
44
+ "cipherwake-chat-hook": "./bin/cipherwake-chat-hook.js",
45
+ "cipherwake-prompt-hook": "./bin/cipherwake-prompt-hook.js"
37
46
  },
38
47
  "files": [
39
48
  "bin/",