pqcheck 0.16.4 → 0.16.10

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
@@ -177,7 +177,16 @@ Sample output:
177
177
  Tier: free · policy: report
178
178
  ```
179
179
 
180
- Flags: `--preview <URL>` · `--production <URL>` · `--compare-transport` · `--fail-on <severity>` (default `high`; `none` for report-only) · `--format pretty|json`. CSP weakening detection diffs `script-src` / `default-src` / `object-src` / `frame-ancestors` / `base-uri` / `style-src` for newly-permissive tokens (`*`, `'unsafe-inline'`, `'unsafe-eval'`, `data:`, `blob:`).
180
+ Flags: `--preview <URL>` · `--production <URL>` · `--compare-transport` · `--fail-on <severity>` (default `high`; `none` for report-only) · `--format pretty|json` · `--protected-path <PATH>` (repeatable) · `--first-party-host <HOSTNAME>` (repeatable). CSP weakening detection diffs `script-src` / `default-src` / `object-src` / `frame-ancestors` / `base-uri` / `style-src` for newly-permissive tokens (`*`, `'unsafe-inline'`, `'unsafe-eval'`, `data:`, `blob:`).
181
+
182
+ **First-party hosts.** Subdomains of the scanned hostname are auto-promoted to first-party (PSL-backed: scanning `acme.com` makes `api.acme.com` first-party, but `acme.co.uk` does NOT auto-promote `evil.co.uk`). If your owned hosts span different registrable domains (`acme.com` + `acmecdn.net`), add them via `--first-party-host` or persist in `.cipherwake/config.json`:
183
+
184
+ ```json
185
+ {
186
+ "firstPartyHosts": ["acmecdn.net", "static.acme.io"],
187
+ "protectedPaths": ["/api/admin/export", "/internal/billing"]
188
+ }
189
+ ```
181
190
 
182
191
  **Diffing a local dev build against prod?** Cipherwake runs the comparison server-side, so `--preview http://localhost:3000` is rejected (we'd be reaching for *our* loopback, not yours). Expose your dev build via a public tunnel:
183
192
 
@@ -18,13 +18,45 @@
18
18
  // Code calls it on every turn. Reads a single file, formats one line, exits.
19
19
  // =============================================================================
20
20
 
21
- import { readFileSync } from "node:fs";
22
- import { join } from "node:path";
21
+ import { readFileSync, existsSync } from "node:fs";
22
+ import { join, dirname } from "node:path";
23
23
  import { homedir } from "node:os";
24
24
 
25
- const STATE_FILE = join(homedir(), ".config", "cipherwake", "last-scan.json");
25
+ const GLOBAL_STATE_FILE = join(homedir(), ".config", "cipherwake", "last-scan.json");
26
26
  const STALE_THRESHOLD_HOURS = 24;
27
27
 
28
+ // v0.16.6 — project-aware state lookup. Walk up from CWD looking for a
29
+ // repo-local `.cipherwake/last-scan.json`. This way each project shows
30
+ // its own last scan, and switching projects doesn't bleed the previous
31
+ // project's state into the new one. Falls back to the global file when
32
+ // no project-local state exists (or when running outside a project).
33
+ //
34
+ // The walk stops at the first ancestor containing EITHER a `.git/` dir
35
+ // or a `package.json` — that's our project-root heuristic. We do not
36
+ // require both because non-Node projects (Python, Go) still have `.git/`
37
+ // but no package.json.
38
+ // pqcheck writes repo-local state to `.cipherwake/last-status.json` (writer
39
+ // uses that filename per writeLastScanFile in pqcheck.js — kept compatible).
40
+ function findProjectStateFile(startDir) {
41
+ let dir = startDir;
42
+ for (let i = 0; i < 20; i++) {
43
+ const candidate = join(dir, ".cipherwake", "last-status.json");
44
+ if (existsSync(candidate)) return candidate;
45
+ // Stop at the project root even if no .cipherwake/ — don't bleed across
46
+ // project boundaries by continuing up.
47
+ const isProjectRoot = existsSync(join(dir, ".git")) || existsSync(join(dir, "package.json"));
48
+ if (isProjectRoot) return null;
49
+ const parent = dirname(dir);
50
+ if (parent === dir) return null;
51
+ dir = parent;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ const cwd = process.env.PWD || process.cwd();
57
+ const projectStateFile = findProjectStateFile(cwd);
58
+ const STATE_FILE = projectStateFile || GLOBAL_STATE_FILE;
59
+
28
60
  const C = {
29
61
  reset: "\x1b[0m",
30
62
  bold: "\x1b[1m",
@@ -91,85 +123,32 @@ if (age > STALE_THRESHOLD_HOURS) {
91
123
  process.exit(0);
92
124
  }
93
125
 
94
- // v0.16.4preview-diff 4-line state.
95
- // User direction: when the last scan was `preview-diff` (two URLs compared),
96
- // render the high-yield block in the statusline. Other scan kinds keep the
97
- // 1-line compact format below — the bigger block would be too noisy when
98
- // it's just a routine domain scan with no diff context.
99
- if (kind === "preview-diff" && !unreachable) {
100
- // Line 1: brand header (hostname only).
101
- const head = c(C.bold, "◆ Cipherwake") + c(C.dim, " ") + c(C.bold, domain || "—");
102
- // Line 3: decision pulse, diff-flavored copy.
103
- let pulse;
104
- if (ship_decision === "pass") {
105
- pulse = c(C.green, "✓ PASS") + c(C.dim, " · no risky deploy-surface drift detected");
106
- } else if (ship_decision === "review") {
107
- pulse = c(C.yellow, "⚠ REVIEW") + c(C.dim, ` · ${delta_count || 0} public-surface change${delta_count === 1 ? "" : "s"}`);
108
- } else if (ship_decision === "block") {
109
- pulse = c(C.red, "⛔ BLOCK") + c(C.dim, ` · ${delta_count || 0} critical change${delta_count === 1 ? "" : "s"}`);
110
- } else {
111
- pulse = c(C.dim, no decision");
112
- }
113
- // Line 4: trust posture (DBR + percentile copy + stability).
114
- function formatPercentile(percentile, sector) {
115
- if (typeof percentile !== "number") return null;
116
- const s = sector || "industry";
117
- if (percentile <= 10) return `top 10% in ${s}`;
118
- if (percentile <= 25) return `top 25% in ${s}`;
119
- if (percentile <= 50) return `above median in ${s}`;
120
- if (percentile <= 75) return `below median in ${s}`;
121
- if (percentile <= 90) return `bottom 25% in ${s}`;
122
- return `bottom 10% in ${s}`;
123
- }
124
- function formatStability(iso) {
125
- if (!iso) return null;
126
- const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
127
- if (days <= 0) return "drifted today";
128
- if (days < 7) return `drifted ${days}d ago`;
129
- return `stable ${days}d`;
130
- }
131
- const trustParts = [];
132
- if (typeof score === "number") {
133
- trustParts.push(`DBR ${score.toFixed(1)}${grade ? " " + grade : ""}`);
134
- }
135
- const pct = formatPercentile(sector_ranking?.percentile, sector_ranking?.sectorLabel || sector_ranking?.sectorName || sector_ranking?.sector);
136
- if (pct) trustParts.push(pct);
137
- const stab = formatStability(last_changed);
138
- if (stab) trustParts.push(stab);
139
- const trustLine = trustParts.length > 0
140
- ? c(C.green, "✓ ") + c(C.bold, "Trust posture: ") + trustParts.join(c(C.dim, " · "))
141
- : null;
142
- // Line 5: verified signals (or finding line when there IS drift).
143
- let verifiedLine = null;
144
- if (Array.isArray(verified_signal_categories) && verified_signal_categories.length > 0) {
145
- const head = diff_no_change === true
146
- ? "Security-relevant surface unchanged"
147
- : `Verified ${verified_signal_categories.length} signal${verified_signal_categories.length === 1 ? "" : "s"}`;
148
- verifiedLine = c(C.green, "✓ ") + c(C.bold, head) + c(C.dim, " · " + verified_signal_categories.join(", "));
149
- }
150
- // Compose
151
- const lines = [head, "", pulse];
152
- if (trustLine) lines.push(trustLine);
153
- if (verifiedLine) lines.push(verifiedLine);
154
- process.stdout.write(lines.join("\n"));
155
- process.exit(0);
126
+ // v0.16.6Single-line canonical format for ALL scan kinds. Prior versions
127
+ // rendered a 4-line "high-yield block" for preview-diff state, but it's
128
+ // overkill: the persistent statusline is read at-a-glance, not as a report.
129
+ // One line that answers 4 questions:
130
+ // 1. Is Cipherwake on? → "◆ Cipherwake" anchor
131
+ // 2. Which domain is it watching? → cleaned hostname
132
+ // 3. Is the latest state good? → ✓ PASS / ⚠ REVIEW / ⛔ BLOCK
133
+ // 4. How fresh is the signal? → "12m ago"
134
+ //
135
+ // Example: ◆ Cipherwake · quantasyte.com ✓ PASS · DBR 4.7 C · stable 14d · 12m ago
136
+
137
+ // Vercel preview URLs aren't useful in the statusline extract the project
138
+ // name when the domain field is one. Otherwise return the domain unchanged.
139
+ function cleanDomain(d) {
140
+ if (!d) return "";
141
+ // Strip protocol if any leaked through.
142
+ let s = d.replace(/^https?:\/\//, "").replace(/\/$/, "");
143
+ // Vercel preview hostnames: <project>-<hash>-<org>.vercel.app
144
+ // Reduce to just <project> so the customer sees "back-in-play" not
145
+ // "back-in-play-30aoyitmn-michaels-projects-0b2351fa.vercel.app".
146
+ const vercel = s.match(/^([a-z0-9][a-z0-9-]*?)(?:-[a-z0-9]{6,})+-[a-z0-9-]+\.vercel\.app$/i);
147
+ if (vercel) return vercel[1] + " (preview)";
148
+ return s;
156
149
  }
150
+ const displayDomain = cleanDomain(domain);
157
151
 
158
- // Brand-anchored layout — "Cipherwake" is always the first word after the
159
- // diamond, so the customer (and their AI agent) can identify the status
160
- // line's source at a glance. Trailing segments depend on what we have:
161
- //
162
- // pass (no DBR): ◆ Cipherwake · pinnedai.dev ✓ PASS · just now
163
- // pass (w/ DBR): ◆ Cipherwake · pinnedai.dev ✓ PASS · DBR 8.7 A · just now
164
- // review: ◆ Cipherwake · pinnedai.dev ⚠ REVIEW · DBR 4.1 C · HIGH · 1h ago
165
- // block: ◆ Cipherwake · pinnedai.dev ⛔ BLOCK · HIGH · now
166
- // unreachable: ◆ Cipherwake · pinnedai.dev ⊘ UNREACHABLE · now
167
- //
168
- // "Unreachable" is a distinct visual + label even though ship_decision=block,
169
- // because the failure mode is different (we couldn't measure the trust
170
- // surface at all, vs. we found a critical finding). The AI agent still
171
- // halts on block-routing — same protocol behavior, clearer customer
172
- // messaging.
173
152
  const isUnreachable = !!unreachable;
174
153
  const symbolByDecision = { pass: "✓", review: "⚠", block: "⛔" };
175
154
  const colorByDecision = { pass: C.green, review: C.yellow, block: C.red };
@@ -180,21 +159,20 @@ const labelWord = isUnreachable
180
159
  : (ship_decision || "—").toUpperCase();
181
160
 
182
161
  // When unreachable, suppress DBR/severity trailing segments — they aren't
183
- // meaningful (no score was computed) and would clutter the line. Just the
184
- // glyph + UNREACHABLE label + age is enough.
162
+ // meaningful (no score was computed) and would clutter the line.
185
163
  const dbrSegment = (!isUnreachable && typeof score === "number")
186
164
  ? ` · DBR ${score.toFixed(1)}${grade ? " " + grade : ""}`
187
165
  : "";
188
- const sevSegment = (!isUnreachable && max_severity && max_severity !== "none")
189
- ? ` · ${String(max_severity).toUpperCase()}`
190
- : "";
166
+ // Severity is redundant with the PASS/REVIEW/BLOCK glyph (v0.16.6 dropped it
167
+ // from the default render). Keep variable for future use but omit from line.
168
+ const sevSegment = "";
191
169
 
192
- // v0.16.2 — drift narrative suffix. "stable 14d" / "drifted 2d ago" / "now".
193
- // Sourced from state.lastChanged (smartCache populates) so the customer
194
- // sees time-series anchor at a glance, not just a single-point snapshot.
170
+ // Drift narrative suffix. Sourced from lastChanged (preview-diff state uses
171
+ // last_changed; scan state uses state.lastChanged). "stable 14d" / "drifted Xd".
172
+ const stabilitySource = last_changed || state.lastChanged;
195
173
  let stabilitySegment = "";
196
- if (!isUnreachable && state.lastChanged) {
197
- const dms = Date.now() - new Date(state.lastChanged).getTime();
174
+ if (!isUnreachable && stabilitySource) {
175
+ const dms = Date.now() - new Date(stabilitySource).getTime();
198
176
  const days = Math.floor(dms / 86400000);
199
177
  if (days <= 0) stabilitySegment = " · drifted today";
200
178
  else if (days < 7) stabilitySegment = ` · drifted ${days}d ago`;
@@ -208,8 +186,8 @@ process.stdout.write(
208
186
  " " +
209
187
  c(C.dim, "·") +
210
188
  " " +
211
- c(C.bold, domain || "—") +
189
+ c(C.bold, displayDomain) +
212
190
  " " +
213
191
  c(cdec, `${symbol} ${labelWord}`) +
214
- c(C.dim, `${dbrSegment}${sevSegment} · ${formatAge(written_at)}`)
192
+ c(C.dim, `${dbrSegment}${stabilitySegment} · ${formatAge(written_at)}`)
215
193
  );
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.16.4";
27
+ const VERSION = "0.16.10";
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:
@@ -117,6 +117,11 @@ async function main() {
117
117
  // changes (new third-party scripts, header regressions, score drops).
118
118
  return runPreviewDiffCommand(args.slice(1));
119
119
  }
120
+ if (args[0] === "guards") {
121
+ // CLI v0.16.9 (2026-05-23, R80, EXPERIMENTAL): Site Guards beta.
122
+ // Subcommands manage `.cipherwake/guards.json` and run guards against a URL.
123
+ return runGuardsCommand(args.slice(1));
124
+ }
120
125
  if (args[0] === "history") {
121
126
  return runHistoryCommand(args.slice(1));
122
127
  }
@@ -950,8 +955,13 @@ function formatHighYieldVerbose(report) {
950
955
  function formatPreviewDiffPerSignal(prev, prod, options = {}) {
951
956
  if (!prev || !prod) return null;
952
957
  const verbose = options.verbose === true;
958
+ // v0.16.5 — caller may pass the application_surface.scripts diff so the
959
+ // Scripts row can name the specific hosts that were added/removed
960
+ // (preview-diff was rendering "Scripts | 2 → 3" with no indication of
961
+ // WHICH vendor changed; the answer was buried in summary_lines above).
962
+ const scriptDeltas = Array.isArray(options.scriptDeltas) ? options.scriptDeltas : [];
953
963
  const rows = [];
954
- const push = (name, l, r) => rows.push({ name, prev: l, prod: r, same: l === r });
964
+ const push = (name, l, r, extra) => rows.push({ name, prev: l, prod: r, same: l === r, extra: extra || null });
955
965
 
956
966
  if (typeof prev?.score === "number" || typeof prod?.score === "number") {
957
967
  const dbrL = typeof prev?.score === "number" ? `${prev.score.toFixed(1)}${prev.grade ? " " + prev.grade : ""}` : "—";
@@ -962,7 +972,18 @@ function formatPreviewDiffPerSignal(prev, prod, options = {}) {
962
972
  const prevScripts = prev?.publicDeps?.thirdPartyCount;
963
973
  const prodScripts = prod?.publicDeps?.thirdPartyCount;
964
974
  if (prevScripts !== undefined || prodScripts !== undefined) {
965
- push("Scripts", String(prevScripts ?? "—"), String(prodScripts ?? "—"));
975
+ // Sub-lines naming the specific hosts that changed (added/removed),
976
+ // when application_surface.scripts is available. A substitution
977
+ // (a.com → c.com, count unchanged) still surfaces because the diff
978
+ // is computed by host-set, not by count.
979
+ const scriptSubLines = scriptDeltas.length > 0
980
+ ? scriptDeltas.map((s) => {
981
+ const sigil = s.kind === "added" ? "+" : s.kind === "removed" ? "-" : "~";
982
+ const tone = s.kind === "added" ? "yellow" : s.kind === "removed" ? "red" : "dim";
983
+ return color(tone, `${sigil} ${s.host}`);
984
+ })
985
+ : null;
986
+ push("Scripts", String(prevScripts ?? "—"), String(prodScripts ?? "—"), scriptSubLines);
966
987
  }
967
988
 
968
989
  const hdrSummary = (h) => {
@@ -1043,7 +1064,18 @@ function formatPreviewDiffPerSignal(prev, prod, options = {}) {
1043
1064
  const sameCount = rows.filter((r) => r.same).length;
1044
1065
  const allMatch = sameCount === rows.length;
1045
1066
  const symbol = allMatch ? color("green", "✓ ") : color("yellow", "~ ");
1046
- return symbol + color("bold", `${sameCount}/${rows.length} signals match`) + color("dim", " (preview ↔ production)");
1067
+ const head = symbol + color("bold", `${sameCount}/${rows.length} signals match`) + color("dim", " (preview ↔ production)");
1068
+ // v0.16.5 — if the Scripts row changed AND we have the host-level
1069
+ // diff, append a sub-line naming each added/removed host even in
1070
+ // compact mode. Keeps the customer from having to scroll up to
1071
+ // summary_lines to find out WHICH vendor changed.
1072
+ const scriptsRow = rows.find((r) => r.name === "Scripts");
1073
+ if (scriptsRow && scriptsRow.extra && scriptsRow.extra.length > 0) {
1074
+ const shown = scriptsRow.extra.slice(0, 5);
1075
+ const more = scriptsRow.extra.length > 5 ? color("dim", ` +${scriptsRow.extra.length - 5} more`) : "";
1076
+ return [head, ...shown.map((l) => " " + l)].join("\n") + (more ? "\n" + more : "");
1077
+ }
1078
+ return head;
1047
1079
  }
1048
1080
 
1049
1081
  // Verbose: per-row table
@@ -1062,6 +1094,13 @@ function formatPreviewDiffPerSignal(prev, prod, options = {}) {
1062
1094
  arrow + " " +
1063
1095
  color(valueColor, r.prod)
1064
1096
  );
1097
+ // v0.16.5 — sub-lines under Scripts row naming added/removed hosts.
1098
+ // Indent past the name+value columns so the per-host info reads as
1099
+ // a nested detail of the parent row, not a peer signal.
1100
+ if (r.extra && r.extra.length > 0) {
1101
+ const indent = " " + " ".repeat(nameWidth) + " " + " ".repeat(prevWidth) + " ";
1102
+ for (const sub of r.extra) lines.push(indent + sub);
1103
+ }
1065
1104
  }
1066
1105
  return lines.join("\n");
1067
1106
  }
@@ -1454,6 +1493,7 @@ ${color("bold", "Commands:")}
1454
1493
  npx pqcheck deploy-check <domain> Pre-deploy trust gate (Trust Diff vs last scan; deploy-friendly framing) — see also: AI Coder Protocol at https://cipherwake.io/methodology/ai-coder-protocol
1455
1494
  npx pqcheck guard --domain <D> -- <cmd> NEW: wrap any deploy command. Runs deploy-check first; conditionally runs <cmd> based on ship_decision. The strongest single artifact for AI-coder workflows.
1456
1495
  npx pqcheck protocol install NEW: install the AI Coder Protocol into your CLAUDE.md / .cursorrules (Rule 17 consent flow)
1496
+ npx pqcheck guards <init|list|run> EXPERIMENTAL (BETA): manage Site Guards (.cipherwake/guards.json) — runtime policies for source-maps / mixed-content / approved-hosts / protected-paths / cookie-flags / link-integrity. See: https://cipherwake.io/methodology/site-guards
1457
1497
  npx pqcheck release-checklist [domain] Print a pre-release trust checklist (markdown, offline)
1458
1498
  npx pqcheck vendors export <domain> Write cipherwake.vendors.json from observed third-party scripts
1459
1499
  npx pqcheck vendors check <domain> Compare current scan to lockfile; exit 4 on new origins (Free CI gate)
@@ -3199,12 +3239,238 @@ async function runTrustDiffCommand(args) {
3199
3239
  * been removed; server applyRepoQuota handles all 3 auth paths, including
3200
3240
  * anonymous per-IP rate limit for frictionless first use.
3201
3241
  */
3242
+ // v0.16.6 R76 — collect custom protected paths from flags + config file.
3243
+ // Returns a sanitized array (max 20 entries, each ≤200 chars, starts with "/").
3244
+ // Server applies the same sanitizer, so anything that gets past this still
3245
+ // gets filtered server-side — defense in depth.
3246
+ async function loadCustomProtectedPaths(args) {
3247
+ const out = [];
3248
+ // Source 1: --protected-path flag, repeatable
3249
+ for (let i = 0; i < args.length; i++) {
3250
+ if (args[i] === "--protected-path" && i + 1 < args.length) {
3251
+ out.push(args[i + 1]);
3252
+ }
3253
+ }
3254
+ // Source 2: .cipherwake/config.json `protectedPaths`
3255
+ try {
3256
+ const fs = await import("node:fs/promises");
3257
+ const path = await import("node:path");
3258
+ const cfgPath = path.join(process.cwd(), ".cipherwake", "config.json");
3259
+ const raw = await fs.readFile(cfgPath, "utf8");
3260
+ const cfg = JSON.parse(raw);
3261
+ if (Array.isArray(cfg.protectedPaths)) {
3262
+ for (const p of cfg.protectedPaths) {
3263
+ if (typeof p === "string") out.push(p);
3264
+ }
3265
+ }
3266
+ } catch {
3267
+ // No config file or invalid JSON — that's fine, fall back to flag-only.
3268
+ }
3269
+ // Sanitize + dedupe + cap
3270
+ const seen = new Set();
3271
+ const sanitized = [];
3272
+ for (const p of out) {
3273
+ const trimmed = String(p).trim();
3274
+ if (trimmed.length === 0 || trimmed.length > 200) continue;
3275
+ if (!trimmed.startsWith("/")) continue;
3276
+ if (seen.has(trimmed)) continue;
3277
+ seen.add(trimmed);
3278
+ sanitized.push(trimmed);
3279
+ if (sanitized.length >= 20) break;
3280
+ }
3281
+ return sanitized;
3282
+ }
3283
+
3284
+ // v0.16.9 R80 — EXPERIMENTAL Site Guards. Load .cipherwake/guards.json
3285
+ // (when --guards flag is present). Returns a {version, guards[]} object or
3286
+ // null when --guards is absent / file missing / invalid. Sanitization runs
3287
+ // AGAIN server-side via sanitizeGuardsConfig — both layers run so the API
3288
+ // never trusts arbitrary structure that landed in the request body.
3289
+ async function loadSiteGuards(args) {
3290
+ if (!args.includes("--guards")) return null;
3291
+ try {
3292
+ const fs = await import("node:fs/promises");
3293
+ const path = await import("node:path");
3294
+ const cfgPath = path.join(process.cwd(), ".cipherwake", "guards.json");
3295
+ const raw = await fs.readFile(cfgPath, "utf8");
3296
+ const cfg = JSON.parse(raw);
3297
+ if (!cfg || typeof cfg !== "object" || !Array.isArray(cfg.guards)) return null;
3298
+ // Pass through more-or-less verbatim; server sanitizes. We don't try to
3299
+ // duplicate the full sanitizer here — just sanity-check the shape so we
3300
+ // don't ship obviously bad payloads.
3301
+ return {
3302
+ version: typeof cfg.version === "number" ? cfg.version : 1,
3303
+ guards: cfg.guards.slice(0, 50),
3304
+ };
3305
+ } catch {
3306
+ return null;
3307
+ }
3308
+ }
3309
+
3310
+ // v0.16.8 — collect first-party host overrides from flags + config file.
3311
+ // PSL-backed subdomain auto-promote happens server-side regardless (scanning
3312
+ // quantasyte.com always treats *.quantasyte.com as first-party). This list
3313
+ // is the escape hatch for multi-domain shops whose owned hosts live under
3314
+ // DIFFERENT registrable domains (acme.com + acmecdn.net). Up to 50 entries,
3315
+ // each must pass a strict hostname regex (lowercase alphanumeric + hyphen
3316
+ // labels separated by dots, no leading/trailing/double dots, ≤253 chars).
3317
+ // Server applies the same sanitizer — defense in depth.
3318
+ //
3319
+ // Source priority:
3320
+ // 1. --first-party-host flag, repeatable (e.g. --first-party-host api.acme.com)
3321
+ // 2. .cipherwake/config.json `firstPartyHosts: string[]` in CWD
3322
+ const FIRST_PARTY_HOST_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/;
3323
+ async function loadFirstPartyHosts(args) {
3324
+ const out = [];
3325
+ for (let i = 0; i < args.length; i++) {
3326
+ if (args[i] === "--first-party-host" && i + 1 < args.length) {
3327
+ out.push(args[i + 1]);
3328
+ }
3329
+ }
3330
+ try {
3331
+ const fs = await import("node:fs/promises");
3332
+ const path = await import("node:path");
3333
+ const cfgPath = path.join(process.cwd(), ".cipherwake", "config.json");
3334
+ const raw = await fs.readFile(cfgPath, "utf8");
3335
+ const cfg = JSON.parse(raw);
3336
+ if (Array.isArray(cfg.firstPartyHosts)) {
3337
+ for (const h of cfg.firstPartyHosts) {
3338
+ if (typeof h === "string") out.push(h);
3339
+ }
3340
+ }
3341
+ } catch {
3342
+ // No config file or invalid JSON — fall back to flag-only.
3343
+ }
3344
+ const seen = new Set();
3345
+ const sanitized = [];
3346
+ for (const h of out) {
3347
+ const norm = String(h).trim().toLowerCase();
3348
+ if (norm.length === 0 || norm.length > 253) continue;
3349
+ if (!FIRST_PARTY_HOST_RE.test(norm)) continue;
3350
+ if (seen.has(norm)) continue;
3351
+ seen.add(norm);
3352
+ sanitized.push(norm);
3353
+ if (sanitized.length >= 50) break;
3354
+ }
3355
+ return sanitized;
3356
+ }
3357
+
3358
+ // v0.16.9 R80 — EXPERIMENTAL Site Guards subcommand surface.
3359
+ // Subcommands:
3360
+ // pqcheck guards init [--domain D] — create .cipherwake/guards.json with default guard set
3361
+ // pqcheck guards list — show configured guards + mode/severity
3362
+ // pqcheck guards run --preview URL — run guards against a URL via preview-diff
3363
+ // --production URL (required because guards run as part of the same scan path)
3364
+ //
3365
+ // The full activate/observe/disable/approve-host/add-protected-path surface from
3366
+ // the original spec is intentionally deferred — those are JSON edits the user
3367
+ // can do by hand against `.cipherwake/guards.json`. We can add the editor sugar
3368
+ // once the core flow has soaked in real use.
3369
+ async function runGuardsCommand(args) {
3370
+ const sub = args[0];
3371
+ if (!sub || sub === "--help" || sub === "-h") {
3372
+ console.log(`${color("bold", "pqcheck guards")} — Site Guards beta (EXPERIMENTAL)`);
3373
+ console.log("");
3374
+ console.log("Subcommands:");
3375
+ console.log(` ${color("cyan", "init")} [--domain D] Create .cipherwake/guards.json with default guards`);
3376
+ console.log(` ${color("cyan", "list")} Show configured guards from .cipherwake/guards.json`);
3377
+ console.log(` ${color("cyan", "run")} --preview URL --production URL`);
3378
+ console.log(` Run guards against a URL (uses preview-diff path)`);
3379
+ console.log("");
3380
+ console.log("Or pass " + color("bold", "--guards") + " to " + color("bold", "pqcheck preview-diff") + " to run guards alongside the diff.");
3381
+ console.log("");
3382
+ console.log(color("yellow", "BETA: Site Guards are experimental. New guards default to observe mode."));
3383
+ console.log(color("dim", "Docs: https://cipherwake.io/methodology/site-guards"));
3384
+ return;
3385
+ }
3386
+ if (sub === "init") return runGuardsInitCommand(args.slice(1));
3387
+ if (sub === "list") return runGuardsListCommand(args.slice(1));
3388
+ if (sub === "run") return runGuardsRunCommand(args.slice(1));
3389
+ console.error(color("red", `error: unknown guards subcommand: ${sub}`));
3390
+ console.error(color("dim", "Run `pqcheck guards --help` for usage."));
3391
+ process.exit(1);
3392
+ }
3393
+
3394
+ async function runGuardsInitCommand(args) {
3395
+ const fs = await import("node:fs/promises");
3396
+ const path = await import("node:path");
3397
+ const domain = parseFlag(args, "--domain") || "your-domain.com";
3398
+ const force = args.includes("--force");
3399
+ const cfgDir = path.join(process.cwd(), ".cipherwake");
3400
+ const cfgPath = path.join(cfgDir, "guards.json");
3401
+ try {
3402
+ await fs.access(cfgPath);
3403
+ if (!force) {
3404
+ console.error(color("yellow", `warn: ${cfgPath} already exists. Pass --force to overwrite.`));
3405
+ process.exit(1);
3406
+ }
3407
+ } catch { /* file doesn't exist, fine */ }
3408
+ // Default set mirrors lib/siteGuards.ts defaultGuardsConfig — duplicated here
3409
+ // to avoid an import dependency from the CLI bundle on the TS lib at runtime.
3410
+ const defaults = {
3411
+ version: 1,
3412
+ domain,
3413
+ firstPartyHosts: [],
3414
+ guards: [
3415
+ { id: "no-source-maps", type: "source_map_exposure", mode: "observe", severity: "review" },
3416
+ { id: "no-mixed-content", type: "mixed_content", mode: "observe", severity: "review" },
3417
+ { id: "approved-hosts", type: "approved_hosts", mode: "observe", severity: "review", approvedHosts: [] },
3418
+ { id: "protected-admin", type: "protected_path", path: "/admin", expect: "401_or_302", mode: "observe", severity: "block" },
3419
+ { id: "session-cookie-flags", type: "cookie_flags", cookieNamePattern: "(?:session|sess|auth|token|sid|csrf|jwt)", require: ["Secure", "HttpOnly"], sameSiteMinimum: "Lax", mode: "observe", severity: "block" },
3420
+ { id: "primary-links-resolve", type: "link_integrity", mode: "observe", severity: "review" },
3421
+ ],
3422
+ };
3423
+ await fs.mkdir(cfgDir, { recursive: true });
3424
+ await fs.writeFile(cfgPath, JSON.stringify(defaults, null, 2) + "\n");
3425
+ console.log(color("green", `✓ wrote ${cfgPath}`));
3426
+ console.log(color("dim", " 6 guards configured · all in observe mode (default)"));
3427
+ console.log(color("dim", " Edit guards.json to flip to active mode or seed approvedHosts."));
3428
+ console.log(color("dim", " Then: npx pqcheck preview-diff --preview <URL> --production <URL> --guards"));
3429
+ }
3430
+
3431
+ async function runGuardsListCommand(_args) {
3432
+ const fs = await import("node:fs/promises");
3433
+ const path = await import("node:path");
3434
+ const cfgPath = path.join(process.cwd(), ".cipherwake", "guards.json");
3435
+ let cfg;
3436
+ try {
3437
+ cfg = JSON.parse(await fs.readFile(cfgPath, "utf8"));
3438
+ } catch (err) {
3439
+ console.error(color("red", `error: could not read ${cfgPath}: ${err.message}`));
3440
+ console.error(color("dim", "Run `pqcheck guards init` to create one."));
3441
+ process.exit(1);
3442
+ }
3443
+ const guards = Array.isArray(cfg.guards) ? cfg.guards : [];
3444
+ if (guards.length === 0) {
3445
+ console.log(color("dim", "(no guards configured)"));
3446
+ return;
3447
+ }
3448
+ console.log(`${color("bold", `${guards.length} guard${guards.length === 1 ? "" : "s"}`)}${cfg.domain ? color("dim", ` · ${cfg.domain}`) : ""}`);
3449
+ for (const g of guards) {
3450
+ const mode = g.mode ?? "observe";
3451
+ const modeChip = mode === "active" ? color("cyan", `[${mode}]`)
3452
+ : mode === "disabled" ? color("dim", `[${mode}]`)
3453
+ : color("yellow", `[${mode}]`);
3454
+ const sev = color("dim", `· ${g.severity ?? "review"}`);
3455
+ console.log(` ${modeChip} ${color("bold", g.id)} (${g.type}) ${sev}`);
3456
+ }
3457
+ console.log("");
3458
+ console.log(color("yellow", "BETA — Site Guards are experimental. Observe-mode guards never affect CI."));
3459
+ }
3460
+
3461
+ async function runGuardsRunCommand(args) {
3462
+ // Convenience wrapper — re-runs preview-diff with --guards forced on.
3463
+ // Useful for "just run my guards, don't think about preview-diff" UX.
3464
+ if (!args.includes("--guards")) args = ["--guards", ...args];
3465
+ return runPreviewDiffCommand(args);
3466
+ }
3467
+
3202
3468
  async function runPreviewDiffCommand(args) {
3203
3469
  const previewUrl = parseFlag(args, "--preview");
3204
3470
  const productionUrl = parseFlag(args, "--production");
3205
3471
  if (!previewUrl || !productionUrl) {
3206
3472
  console.error(color("red", "error: pqcheck preview-diff requires --preview and --production URLs"));
3207
- console.error(color("dim", "Usage: npx pqcheck preview-diff --preview https://preview-xyz.vercel.app --production https://example.com [--compare-transport] [--fail-on high] [--format pretty|json]"));
3473
+ console.error(color("dim", "Usage: npx pqcheck preview-diff --preview https://preview-xyz.vercel.app --production https://example.com [--compare-transport] [--fail-on high] [--format pretty|json] [--protected-path /api/admin/export]... [--first-party-host api.acme.com]..."));
3208
3474
  process.exit(3);
3209
3475
  }
3210
3476
 
@@ -3220,6 +3486,23 @@ async function runPreviewDiffCommand(args) {
3220
3486
  ? "report"
3221
3487
  : "fail";
3222
3488
 
3489
+ // v0.16.6 R76 — custom protected paths. Source priority:
3490
+ // 1. --protected-path flag (repeatable, e.g. --protected-path /api/admin/export)
3491
+ // 2. .cipherwake/config.json `protectedPaths: string[]` in CWD
3492
+ // Sanitized + capped server-side. Lets each customer probe their own auth-
3493
+ // gated routes alongside the universal default list.
3494
+ const customProtectedPaths = await loadCustomProtectedPaths(args);
3495
+
3496
+ // v0.16.8 — first-party host overrides. Server unconditionally treats
3497
+ // subdomains of the scanned hostname as first-party (PSL-backed); this
3498
+ // list adds owned hosts whose registrable domains differ.
3499
+ const customFirstPartyHosts = await loadFirstPartyHosts(args);
3500
+
3501
+ // v0.16.9 R80 — EXPERIMENTAL Site Guards. Only loaded when --guards flag
3502
+ // is present; otherwise the request includes no site_guards block and the
3503
+ // response's site_guards array stays empty.
3504
+ const siteGuardsPayload = await loadSiteGuards(args);
3505
+
3223
3506
  const headers = {
3224
3507
  "Content-Type": "application/json",
3225
3508
  "User-Agent": `pqcheck-cli/${VERSION}`,
@@ -3239,6 +3522,9 @@ async function runPreviewDiffCommand(args) {
3239
3522
  compare_transport: compareTransport,
3240
3523
  fail_on: failOn,
3241
3524
  policy_mode: policyMode,
3525
+ ...(customProtectedPaths.length > 0 ? { protected_paths: customProtectedPaths } : {}),
3526
+ ...(customFirstPartyHosts.length > 0 ? { first_party_hosts: customFirstPartyHosts } : {}),
3527
+ ...(siteGuardsPayload ? { site_guards: siteGuardsPayload } : {}),
3242
3528
  }),
3243
3529
  });
3244
3530
  } catch (err) {
@@ -3270,6 +3556,10 @@ async function runPreviewDiffCommand(args) {
3270
3556
  const result = await resp.json();
3271
3557
  const verdict = result?.result?.verdict || "pass";
3272
3558
  const summaryLines = result?.result?.summary_lines || [];
3559
+ // v0.16.6 R78 — plain-English "what this means" sentence per finding,
3560
+ // parallel to summaryLines. Renders as a dim sub-line under each tech
3561
+ // line so non-security readers see the consequence at a glance.
3562
+ const summaryLinesHuman = result?.result?.summary_lines_human || [];
3273
3563
 
3274
3564
  // AI Coder Mode — three-layer output (banner / body / structured block).
3275
3565
  if (parseAiMode(args)) {
@@ -3344,9 +3634,16 @@ async function runPreviewDiffCommand(args) {
3344
3634
  console.log(formatDecisionPulse({ shipDecision, alertCount: deltaLines.length, unreachable: false, diffContext: true }));
3345
3635
 
3346
3636
  if (deltaLines.length > 0) {
3637
+ // v0.16.6 R78 — find the matching human-readable line for each tech
3638
+ // line. summaryLinesHuman is parallel-indexed to summaryLines, so we
3639
+ // re-derive the index from the original (unfiltered) summaryLines
3640
+ // array.
3347
3641
  for (const dl of deltaLines.slice(0, 3)) {
3348
3642
  const sym = dl.startsWith("⛔") || dl.startsWith("-") ? "red" : dl.startsWith("⚠") || dl.startsWith("~") ? "yellow" : "dim";
3349
3643
  console.log(color(sym, dl));
3644
+ const idx = summaryLines.indexOf(dl);
3645
+ const human = idx >= 0 ? summaryLinesHuman[idx] : null;
3646
+ if (human) console.log(color("dim", ` → ${human}`));
3350
3647
  }
3351
3648
  if (deltaLines.length > 3) console.log(color("dim", ` +${deltaLines.length - 3} more (run with --verbose)`));
3352
3649
  }
@@ -3359,7 +3656,10 @@ async function runPreviewDiffCommand(args) {
3359
3656
  // - default: 1-line "9/9 signals match (preview ↔ production)" so the
3360
3657
  // customer sees the checks fired without scrolling
3361
3658
  // - --verbose: per-row table with both sides spelled out
3362
- const psl = formatPreviewDiffPerSignal(prevSig, prodSig, { verbose });
3659
+ const scriptDeltas = Array.isArray(result?.result?.application_surface?.scripts)
3660
+ ? result.result.application_surface.scripts
3661
+ : [];
3662
+ const psl = formatPreviewDiffPerSignal(prevSig, prodSig, { verbose, scriptDeltas });
3363
3663
  if (psl) console.log(psl);
3364
3664
  if (!verbose) {
3365
3665
  console.log(color("dim", " Run with --verbose to see per-signal breakdown."));
@@ -3436,12 +3736,56 @@ async function runPreviewDiffCommand(args) {
3436
3736
  if (summaryLines.length === 0 || (summaryLines.length === 1 && /no meaningful/i.test(summaryLines[0]))) {
3437
3737
  console.log(` ${color("green", "✓ No meaningful application-surface changes detected.")}`);
3438
3738
  } else {
3439
- for (const line of summaryLines) {
3739
+ // v0.16.6 R78 — pretty mode renders the tech line + dim layman sub-line.
3740
+ for (let i = 0; i < summaryLines.length; i++) {
3741
+ const line = summaryLines[i];
3742
+ const human = summaryLinesHuman[i];
3440
3743
  const c = line.startsWith("+") ? "yellow" : line.startsWith("-") ? "red" : "dim";
3441
3744
  console.log(` ${color(c, line)}`);
3745
+ if (human) console.log(` ${color("dim", "→ " + human)}`);
3442
3746
  }
3443
3747
  }
3444
3748
  console.log("");
3749
+ // v0.16.9 R80 — EXPERIMENTAL Site Guards rendering. Only shown when the
3750
+ // server returned a non-empty site_guards array. Renders status tally
3751
+ // (passed / failed / not_checked / probe_failed / not_applicable) then
3752
+ // per-guard one-liners. Observe-mode failures are labeled "observation"
3753
+ // so the customer reads them as informational. Active-mode failures use
3754
+ // the same prefix as preview-diff findings.
3755
+ const siteGuards = Array.isArray(result?.site_guards) ? result.site_guards : [];
3756
+ if (siteGuards.length > 0) {
3757
+ console.log(` ${color("bold", "Site Guards")} ${color("yellow", "(BETA)")}`);
3758
+ const tally = { pass: 0, fail: 0, not_checked: 0, probe_failed: 0, not_applicable: 0 };
3759
+ for (const g of siteGuards) {
3760
+ tally[g.status] = (tally[g.status] ?? 0) + 1;
3761
+ }
3762
+ const tallyParts = [];
3763
+ if (tally.pass > 0) tallyParts.push(color("green", `${tally.pass} passed`));
3764
+ if (tally.fail > 0) tallyParts.push(color("red", `${tally.fail} failed`));
3765
+ if (tally.not_checked > 0) tallyParts.push(color("dim", `${tally.not_checked} disabled`));
3766
+ if (tally.probe_failed > 0) tallyParts.push(color("yellow", `${tally.probe_failed} probe-failed`));
3767
+ if (tally.not_applicable > 0) tallyParts.push(color("dim", `${tally.not_applicable} n/a`));
3768
+ console.log(` ${siteGuards.length} guards checked: ${tallyParts.join(color("dim", " · "))}`);
3769
+ for (const g of siteGuards) {
3770
+ const isFail = g.status === "fail";
3771
+ const isPass = g.status === "pass";
3772
+ const isObserve = g.mode === "observe";
3773
+ const mark = isFail
3774
+ ? (isObserve ? color("yellow", "◌") : color("red", "⛔"))
3775
+ : isPass ? color("green", "✓")
3776
+ : g.status === "probe_failed" ? color("yellow", "?")
3777
+ : color("dim", "·");
3778
+ const label = isFail && isObserve
3779
+ ? color("dim", "observation:")
3780
+ : isFail ? color("red", "fail:")
3781
+ : color("dim", `${g.status}:`);
3782
+ console.log(` ${mark} ${color("bold", g.id)} ${label} ${g.message}`);
3783
+ if (g.human) {
3784
+ console.log(` ${color("dim", "→ " + g.human)}`);
3785
+ }
3786
+ }
3787
+ console.log("");
3788
+ }
3445
3789
  const transport = result?.result?.transport || {};
3446
3790
  if (transport.preview_is_edge_hosted) {
3447
3791
  console.log(` ${color("dim", `Transport: preview is edge-hosted (${transport.preview_cert_issuer ?? "unknown"}) — informational only.`)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.16.4",
3
+ "version": "0.16.10",
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",