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 +3 -1
- package/bin/cipherwake-prompt-hook.js +111 -0
- package/bin/cipherwake-statusline.js +48 -20
- package/bin/pqcheck.js +280 -57
- package/package.json +17 -8
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 +
|
|
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 —
|
|
68
|
-
//
|
|
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, "◆
|
|
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,
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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,
|
|
127
|
+
c(C.bold, "Cipherwake") +
|
|
128
|
+
" " +
|
|
129
|
+
c(C.dim, "·") +
|
|
103
130
|
" " +
|
|
104
|
-
c(
|
|
131
|
+
c(C.bold, domain || "—") +
|
|
105
132
|
" " +
|
|
106
|
-
c(
|
|
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.
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
|
637
|
-
// persistent ambient state in their AI coder's
|
|
638
|
-
//
|
|
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
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
2395
|
+
let shipDecision = computeShipDecision({ maxSeverity: maxSev });
|
|
2316
2396
|
const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
7
|
-
"
|
|
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/",
|