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 +10 -1
- package/bin/cipherwake-statusline.js +70 -92
- package/bin/pqcheck.js +351 -7
- package/package.json +1 -1
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
|
|
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
|
|
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.
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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.6 — Single-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.
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
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 &&
|
|
197
|
-
const dms = Date.now() - new Date(
|
|
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,
|
|
189
|
+
c(C.bold, displayDomain) +
|
|
212
190
|
" " +
|
|
213
191
|
c(cdec, `${symbol} ${labelWord}`) +
|
|
214
|
-
c(C.dim, `${dbrSegment}${
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|