pqcheck 0.16.22 → 0.16.24
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 +1 -1
- package/bin/pqcheck.js +305 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
[](https://www.npmjs.com/package/pqcheck)
|
|
9
9
|
[](./LICENSE)
|
|
10
10
|
|
|
11
|
-
> **Latest: v0.16.
|
|
11
|
+
> **Latest: v0.16.24** — **Route Assertions (R87).** Closes the gap that left Cipherwake silent on backend/admin-heavy deploys. Declare your private routes in `.cipherwake.json` and Cipherwake asserts they're still gated on every deploy. Any critical failure (declared `protected`, actually `exposed`) blocks the deploy unconditionally — the catastrophic "/admin became public" case. Three sources merge: customer config + nearly-universal defaults + auto-detected from `robots.txt` and homepage. New `/methodology/route-assertions` documents the feature; `/methodology/why-not-authenticated-crawling` explains the design decision to never hold customer credentials. [Full changelog →](./CHANGELOG.md)
|
|
12
12
|
|
|
13
13
|
## Two ways to use it
|
|
14
14
|
|
package/bin/pqcheck.js
CHANGED
|
@@ -248,6 +248,14 @@ async function main() {
|
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
// R86.7 — reject unknown flags loudly. Catches typo'd safety-critical
|
|
252
|
+
// flags like `--strict` (was intended --strict-posture) that previously
|
|
253
|
+
// silently no-op'd, leaving customers with a false sense of security.
|
|
254
|
+
const unknown = assertKnownFlags(args, "pqcheck <domain>");
|
|
255
|
+
if (unknown) {
|
|
256
|
+
process.exit(3);
|
|
257
|
+
}
|
|
258
|
+
|
|
251
259
|
const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
|
|
252
260
|
const domains = [...positional, ...fileDomains]
|
|
253
261
|
.map((a) => normalizeDomain(a))
|
|
@@ -295,7 +303,13 @@ async function main() {
|
|
|
295
303
|
// absolute posture grade into ship_decision. Recommended ONLY after a
|
|
296
304
|
// site reaches A/B posture — opting in earlier produces cry-wolf gating
|
|
297
305
|
// on every deploy since most AI-coded sites grade D/F out of the box.
|
|
298
|
-
|
|
306
|
+
// Accept both `--strict-posture` (explicit) and `--strict` (short alias).
|
|
307
|
+
// Audit caught a footgun where customers typed `--strict` expecting the
|
|
308
|
+
// posture gate to fire and got silent drift-only mode instead. The
|
|
309
|
+
// `onboard` subcommand uses `--strict` for a different purpose (gate exit
|
|
310
|
+
// code on step failure) but that's a separate command — the alias is
|
|
311
|
+
// scoped to scan / deploy-check / trust-diff only.
|
|
312
|
+
const strictPosture = args.includes("--strict-posture") || args.includes("--strict");
|
|
299
313
|
|
|
300
314
|
// One-shot scan(s)
|
|
301
315
|
let worstExit = 0;
|
|
@@ -747,6 +761,100 @@ function parseAiMode(args) {
|
|
|
747
761
|
return args.includes("--ai") || args.includes("--agent");
|
|
748
762
|
}
|
|
749
763
|
|
|
764
|
+
// R86.7 (2026-06-04) — close the "silent no-op on unrecognized flag" class
|
|
765
|
+
// of footguns. Audit caught --strict (intended --strict-posture) silently
|
|
766
|
+
// degrading to drift-only mode without warning — the user believed they had
|
|
767
|
+
// the hard posture gate on. Same family as false-green pins: looks like it's
|
|
768
|
+
// doing something, silently isn't. The fix: validate every --foo token in
|
|
769
|
+
// args against a known-flags whitelist and reject loudly on unknown flags
|
|
770
|
+
// with a closest-match suggestion. For a security gate, "unknown flag →
|
|
771
|
+
// silently proceed with weaker behaviour" must never happen.
|
|
772
|
+
//
|
|
773
|
+
// Scope: universal whitelist applied at scan / deploy-check / trust-diff /
|
|
774
|
+
// preview-diff entry points. False acceptance of cross-command flags (e.g.
|
|
775
|
+
// passing --preview to deploy-check) is the SAME failure mode as today
|
|
776
|
+
// (silent ignore) — typo detection is the win. Per-command whitelists
|
|
777
|
+
// would be more correct but the universal list covers the audit footgun.
|
|
778
|
+
const KNOWN_FLAGS = new Set([
|
|
779
|
+
// Global / output mode
|
|
780
|
+
"--ai", "--agent", "--verbose", "--quiet", "--help", "--version", "--debug-network",
|
|
781
|
+
// Multi-domain
|
|
782
|
+
"--file", "--watch",
|
|
783
|
+
// Scan behaviour
|
|
784
|
+
"--fresh", "--force", "--threshold", "--webhook", "--json", "--csv", "--markdown",
|
|
785
|
+
"--sarif", "--gh-action", "--multi", "--lock", "--explain", "--plan", "--stdout",
|
|
786
|
+
// Posture gating (the audit catch)
|
|
787
|
+
"--strict", "--strict-posture",
|
|
788
|
+
// Format / output format
|
|
789
|
+
"--format",
|
|
790
|
+
// Trust-diff / deploy-check / preview-diff
|
|
791
|
+
"--baseline", "--fail-on", "--fail-on-new", "--guards", "--compare-transport",
|
|
792
|
+
"--write-baseline", "--preview", "--production",
|
|
793
|
+
// Setup / onboard / install consent
|
|
794
|
+
"--auto", "--manual", "--yes", "--no-open", "--domain", "--invoked-by",
|
|
795
|
+
"--consent-phrase", "--scope",
|
|
796
|
+
"--skip-checklist", "--skip-hook", "--skip-protocol", "--skip-scan",
|
|
797
|
+
"--skip-statusline", "--skip-vendors", "--skip-vscode", "--skip-workflow",
|
|
798
|
+
]);
|
|
799
|
+
|
|
800
|
+
function levenshtein(a, b) {
|
|
801
|
+
if (a === b) return 0;
|
|
802
|
+
const m = a.length, n = b.length;
|
|
803
|
+
if (m === 0) return n;
|
|
804
|
+
if (n === 0) return m;
|
|
805
|
+
const row = new Array(m + 1);
|
|
806
|
+
for (let i = 0; i <= m; i++) row[i] = i;
|
|
807
|
+
for (let j = 1; j <= n; j++) {
|
|
808
|
+
let prev = row[0];
|
|
809
|
+
row[0] = j;
|
|
810
|
+
for (let i = 1; i <= m; i++) {
|
|
811
|
+
const tmp = row[i];
|
|
812
|
+
row[i] = a[i - 1] === b[j - 1]
|
|
813
|
+
? prev
|
|
814
|
+
: 1 + Math.min(prev, row[i], row[i - 1]);
|
|
815
|
+
prev = tmp;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return row[m];
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function suggestFlag(unknown) {
|
|
822
|
+
let best = null, bestDist = Infinity;
|
|
823
|
+
for (const known of KNOWN_FLAGS) {
|
|
824
|
+
const d = levenshtein(unknown, known);
|
|
825
|
+
if (d < bestDist) { bestDist = d; best = known; }
|
|
826
|
+
}
|
|
827
|
+
// Only suggest if reasonably close (≤ 3 edits or ≤ half the unknown's length)
|
|
828
|
+
const threshold = Math.max(3, Math.floor(unknown.length / 2));
|
|
829
|
+
return bestDist <= threshold ? best : null;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function assertKnownFlags(args, cmdName) {
|
|
833
|
+
const unknown = [];
|
|
834
|
+
for (const tok of args) {
|
|
835
|
+
if (typeof tok !== "string" || !tok.startsWith("--") || tok === "--") continue;
|
|
836
|
+
// Strip `--foo=bar` → `--foo` so equals-value forms validate against the bare flag
|
|
837
|
+
const flag = tok.includes("=") ? tok.slice(0, tok.indexOf("=")) : tok;
|
|
838
|
+
if (!KNOWN_FLAGS.has(flag)) unknown.push(flag);
|
|
839
|
+
}
|
|
840
|
+
if (unknown.length === 0) return null;
|
|
841
|
+
// Emit error to stderr with closest-match suggestion, return non-null
|
|
842
|
+
// sentinel so the caller can exit non-zero. We do NOT process.exit here
|
|
843
|
+
// because the AI-mode caller needs to emit a structured guard block
|
|
844
|
+
// before exiting (so AI agents parsing the block don't see a missing
|
|
845
|
+
// CIPHERWAKE_AI_GUARD_RESULT and fall through to a different error path).
|
|
846
|
+
for (const u of unknown) {
|
|
847
|
+
const sug = suggestFlag(u);
|
|
848
|
+
process.stderr.write(color("red", `error: unknown flag ${u} for ${cmdName}\n`));
|
|
849
|
+
if (sug) {
|
|
850
|
+
process.stderr.write(color("yellow", ` did you mean ${sug}?\n`));
|
|
851
|
+
} else {
|
|
852
|
+
process.stderr.write(color("dim", ` run \`npx pqcheck --help\` for the flag list.\n`));
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return unknown;
|
|
856
|
+
}
|
|
857
|
+
|
|
750
858
|
function severityRank(s) {
|
|
751
859
|
const map = { critical: 4, high: 3, medium: 2, low: 1, info: 0, none: -1 };
|
|
752
860
|
return map[String(s || "none").toLowerCase()] ?? 0;
|
|
@@ -799,6 +907,147 @@ function combineShipDecision(driftDecision, postureDecision, { strict = false }
|
|
|
799
907
|
return a >= b ? driftDecision : postureDecision;
|
|
800
908
|
}
|
|
801
909
|
|
|
910
|
+
// R87 (2026-06-05) — read .cipherwake.json from cwd OR a path the customer
|
|
911
|
+
// pointed us at. Returns the parsed routeAssertions config or null.
|
|
912
|
+
//
|
|
913
|
+
// Customer-declared assertions are the wedge that makes Cipherwake fire on
|
|
914
|
+
// EVERY deploy of a backend/admin-heavy app — even when the public landing
|
|
915
|
+
// page doesn't drift. The CLI reads this file once + forwards it as the
|
|
916
|
+
// `routeAssertionsConfig` field in the trust-diff/scan request body.
|
|
917
|
+
//
|
|
918
|
+
// We DO NOT read credentials or sensitive headers from this file. Just
|
|
919
|
+
// route paths + expected classification. See /methodology/route-assertions
|
|
920
|
+
// for the schema + /methodology/why-not-authenticated-crawling for the
|
|
921
|
+
// strategic reasoning behind keeping this credential-free.
|
|
922
|
+
// R87.4 (2026-06-05) — surface a clear warning when .cipherwake.json has
|
|
923
|
+
// malformed JSON, an invalid shape, or an obviously-wrong path declaration.
|
|
924
|
+
// Without these warnings, the CLI silently dropped the config and the user
|
|
925
|
+
// saw `sources_customer=0` with no explanation. Wrong-shape configs are
|
|
926
|
+
// the second-most-common UX failure after typo'd flags.
|
|
927
|
+
function _warnConfigIssue(msg) {
|
|
928
|
+
process.stderr.write(color("yellow", `⚠ .cipherwake.json: ${msg}\n`));
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function readRouteAssertionsConfig() {
|
|
932
|
+
try {
|
|
933
|
+
const { readFileSync, existsSync } = await import("node:fs");
|
|
934
|
+
const { join, dirname } = await import("node:path");
|
|
935
|
+
const cwd = process.cwd();
|
|
936
|
+
// Walk up from cwd looking for .cipherwake.json — supports running
|
|
937
|
+
// from any subdirectory of the repo. Cap at 5 levels to bound IO.
|
|
938
|
+
let dir = cwd;
|
|
939
|
+
for (let i = 0; i < 5; i++) {
|
|
940
|
+
const candidate = join(dir, ".cipherwake.json");
|
|
941
|
+
if (existsSync(candidate)) {
|
|
942
|
+
const body = readFileSync(candidate, "utf8");
|
|
943
|
+
let parsed;
|
|
944
|
+
try {
|
|
945
|
+
parsed = JSON.parse(body);
|
|
946
|
+
} catch (e) {
|
|
947
|
+
_warnConfigIssue(`malformed JSON in ${candidate} (${e.message}). Customer assertions will not be sent.`);
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
// Tolerant shape: either { routeAssertions: {...} } or { assertions: [...] }
|
|
951
|
+
// at the top level. Both forms are documented in the methodology page.
|
|
952
|
+
const cfg = parsed?.routeAssertions || (Array.isArray(parsed?.assertions) ? { assertions: parsed.assertions, replace_defaults: parsed.replace_defaults } : null);
|
|
953
|
+
if (!cfg) {
|
|
954
|
+
_warnConfigIssue(`${candidate} found but missing required field "routeAssertions.assertions" (or top-level "assertions" array). See https://cipherwake.io/methodology/route-assertions for the schema.`);
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
if (!Array.isArray(cfg.assertions)) {
|
|
958
|
+
_warnConfigIssue(`"assertions" must be an array of {path, expect, ...} objects.`);
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
// R87.4 — normalize + validate each assertion. Catch common typos:
|
|
962
|
+
// missing leading slash, unknown `expect` value, duplicate paths.
|
|
963
|
+
const seen = new Set();
|
|
964
|
+
const cleaned = [];
|
|
965
|
+
for (let idx = 0; idx < cfg.assertions.length; idx++) {
|
|
966
|
+
const a = cfg.assertions[idx];
|
|
967
|
+
if (!a || typeof a !== "object") {
|
|
968
|
+
_warnConfigIssue(`assertion #${idx + 1} is not an object — skipped.`);
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (typeof a.path !== "string" || !a.path) {
|
|
972
|
+
_warnConfigIssue(`assertion #${idx + 1} missing "path" field — skipped.`);
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
// Normalize leading slash
|
|
976
|
+
let normPath = a.path.trim();
|
|
977
|
+
if (!normPath.startsWith("/")) {
|
|
978
|
+
_warnConfigIssue(`assertion path "${a.path}" missing leading "/" — auto-normalized to "/${normPath}".`);
|
|
979
|
+
normPath = "/" + normPath;
|
|
980
|
+
}
|
|
981
|
+
if (normPath.includes("?")) {
|
|
982
|
+
_warnConfigIssue(`assertion path "${normPath}" contains "?" — query strings are not supported in route assertions. Path probed without query.`);
|
|
983
|
+
normPath = normPath.split("?")[0];
|
|
984
|
+
}
|
|
985
|
+
const validExpects = ["protected", "exposed", "missing"];
|
|
986
|
+
if (!validExpects.includes(a.expect)) {
|
|
987
|
+
_warnConfigIssue(`assertion #${idx + 1} (path "${normPath}") has invalid "expect": ${JSON.stringify(a.expect)}. Must be one of: ${validExpects.join(", ")}. Skipped.`);
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
if (seen.has(normPath)) {
|
|
991
|
+
_warnConfigIssue(`duplicate path "${normPath}" in assertions — second occurrence ignored.`);
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
seen.add(normPath);
|
|
995
|
+
cleaned.push({ ...a, path: normPath });
|
|
996
|
+
}
|
|
997
|
+
if (cleaned.length === 0) {
|
|
998
|
+
_warnConfigIssue(`no valid assertions found after validation — customer config will not be sent.`);
|
|
999
|
+
return null;
|
|
1000
|
+
}
|
|
1001
|
+
return { ...cfg, assertions: cleaned };
|
|
1002
|
+
}
|
|
1003
|
+
const parent = dirname(dir);
|
|
1004
|
+
if (parent === dir) break;
|
|
1005
|
+
dir = parent;
|
|
1006
|
+
}
|
|
1007
|
+
return null;
|
|
1008
|
+
} catch (e) {
|
|
1009
|
+
_warnConfigIssue(`unexpected error reading config: ${e.message}`);
|
|
1010
|
+
return null;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// R87 — fold customer route-assertion failures into ship_decision. Any
|
|
1015
|
+
// CRITICAL failure (declared `protected` route that is now `exposed`) blocks
|
|
1016
|
+
// the deploy unconditionally — this is the catastrophic "admin became
|
|
1017
|
+
// public" case the feature exists to catch. High/medium failures promote
|
|
1018
|
+
// to `review`.
|
|
1019
|
+
function shipDecisionFromAssertions(summary) {
|
|
1020
|
+
if (!summary) return null;
|
|
1021
|
+
if (summary.criticalFailures && summary.criticalFailures.length > 0) return "block";
|
|
1022
|
+
if (summary.failed > 0) return "review";
|
|
1023
|
+
return "pass";
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// R87 — emit per-assertion outcomes in a parseable block. Stable format so
|
|
1027
|
+
// AI coders can route on the data; one line per assertion with
|
|
1028
|
+
// expected/actual + path so failures are obvious at a glance.
|
|
1029
|
+
function formatRouteAssertionsBlock(summary) {
|
|
1030
|
+
if (!summary || !Array.isArray(summary.results) || summary.results.length === 0) return "";
|
|
1031
|
+
const lines = ["", "CIPHERWAKE_ROUTE_ASSERTIONS"];
|
|
1032
|
+
lines.push(`total=${summary.total}`);
|
|
1033
|
+
lines.push(`passed=${summary.passed}`);
|
|
1034
|
+
lines.push(`failed=${summary.failed}`);
|
|
1035
|
+
lines.push(`critical_failures=${summary.criticalFailures.length}`);
|
|
1036
|
+
lines.push(`sources_customer=${summary.sources.customer}`);
|
|
1037
|
+
lines.push(`sources_default=${summary.sources.default}`);
|
|
1038
|
+
lines.push(`sources_auto=${summary.sources.auto}`);
|
|
1039
|
+
lines.push(`sources_auto_suppressed=${summary.sources.autoSuppressed || 0}`);
|
|
1040
|
+
for (const r of summary.results) {
|
|
1041
|
+
const status = r.passed ? "PASS" : "FAIL";
|
|
1042
|
+
const sev = r.passed ? "info" : r.severity;
|
|
1043
|
+
const why = r.why ? ` — ${r.why}` : "";
|
|
1044
|
+
const errReason = r.errorReason ? ` reason=${r.errorReason}` : "";
|
|
1045
|
+
lines.push(`${status} [${sev}] ${r.source}:${r.path} expected=${r.expected} actual=${r.actual} status=${r.status === null ? "?" : r.status}${errReason}${why}`);
|
|
1046
|
+
}
|
|
1047
|
+
lines.push("END_CIPHERWAKE_ROUTE_ASSERTIONS");
|
|
1048
|
+
return color("dim", lines.join("\n"));
|
|
1049
|
+
}
|
|
1050
|
+
|
|
802
1051
|
// R86.3 (2026-06-03) — emit per-finding fix snippets in a separate parseable
|
|
803
1052
|
// block after the AI guard block. The guard block stays compact key=value;
|
|
804
1053
|
// fixes are multi-line code so they get their own block with explicit
|
|
@@ -3365,10 +3614,16 @@ async function runScanBasedDeployCheck(domain, args) {
|
|
|
3365
3614
|
}
|
|
3366
3615
|
|
|
3367
3616
|
async function runTrustDiffCommand(args) {
|
|
3617
|
+
// R86.7 — reject unknown flags before any other parsing. Same rationale
|
|
3618
|
+
// as the bare-scan path: typo'd safety-critical flags must fail loud,
|
|
3619
|
+
// not silently degrade.
|
|
3620
|
+
if (assertKnownFlags(args, "pqcheck trust-diff")) {
|
|
3621
|
+
process.exit(3);
|
|
3622
|
+
}
|
|
3368
3623
|
const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
|
|
3369
3624
|
if (positional.length === 0) {
|
|
3370
3625
|
console.error(color("red", "error: pqcheck trust-diff requires a domain"));
|
|
3371
|
-
console.error(color("dim", "Usage: npx pqcheck trust-diff <domain> [--baseline last-week] [--fail-on high] [--format pretty|json|sarif|github]"));
|
|
3626
|
+
console.error(color("dim", "Usage: npx pqcheck trust-diff <domain> [--baseline last-week] [--fail-on high] [--format pretty|json|sarif|github] [--strict-posture]"));
|
|
3372
3627
|
process.exit(3);
|
|
3373
3628
|
}
|
|
3374
3629
|
const domain = normalizeDomain(positional[0]);
|
|
@@ -3384,7 +3639,13 @@ async function runTrustDiffCommand(args) {
|
|
|
3384
3639
|
// is drift-only (per-deploy regression gate); --strict-posture opts into
|
|
3385
3640
|
// worst-of(drift, posture). Recommended only after a site reaches A/B
|
|
3386
3641
|
// posture to lock that in.
|
|
3387
|
-
|
|
3642
|
+
// Accept both `--strict-posture` (explicit) and `--strict` (short alias).
|
|
3643
|
+
// Audit caught a footgun where customers typed `--strict` expecting the
|
|
3644
|
+
// posture gate to fire and got silent drift-only mode instead. The
|
|
3645
|
+
// `onboard` subcommand uses `--strict` for a different purpose (gate exit
|
|
3646
|
+
// code on step failure) but that's a separate command — the alias is
|
|
3647
|
+
// scoped to scan / deploy-check / trust-diff only.
|
|
3648
|
+
const strictPosture = args.includes("--strict-posture") || args.includes("--strict");
|
|
3388
3649
|
|
|
3389
3650
|
// Build headers conditionally — Authorization is set ONLY if the user has
|
|
3390
3651
|
// an API key. Without it, the server's applyRepoQuota falls through to the
|
|
@@ -3399,10 +3660,11 @@ async function runTrustDiffCommand(args) {
|
|
|
3399
3660
|
|
|
3400
3661
|
let resp;
|
|
3401
3662
|
try {
|
|
3663
|
+
const _routeCfg = await readRouteAssertionsConfig();
|
|
3402
3664
|
resp = await fetch(`${API_BASE}/api/trust-diff`, {
|
|
3403
3665
|
method: "POST",
|
|
3404
3666
|
headers,
|
|
3405
|
-
body: JSON.stringify({ domain, baseline, fail_on: failOn }),
|
|
3667
|
+
body: JSON.stringify({ domain, baseline, fail_on: failOn, routeAssertionsConfig: _routeCfg }),
|
|
3406
3668
|
});
|
|
3407
3669
|
} catch (err) {
|
|
3408
3670
|
console.error(color("red", `error: network failure calling /api/trust-diff: ${err.message}`));
|
|
@@ -3536,17 +3798,41 @@ async function runTrustDiffCommand(args) {
|
|
|
3536
3798
|
// R86.5 — surface posture advisory line in default (non-strict) mode.
|
|
3537
3799
|
const postureAdvisory = formatPostureAdvisoryLine(posture, strictPosture);
|
|
3538
3800
|
if (postureAdvisory) console.log(postureAdvisory);
|
|
3801
|
+
// R87 (2026-06-05) — fold route-assertion failures into ship_decision.
|
|
3802
|
+
// Customer-declared assertions are surfaced regardless; their critical
|
|
3803
|
+
// failures (declared `protected` route that's now `exposed`) ALWAYS
|
|
3804
|
+
// promote ship_decision to block — this is the catastrophic case the
|
|
3805
|
+
// feature exists to catch ("admin went public") and it does not require
|
|
3806
|
+
// --strict-posture or any opt-in. Default + auto-detected assertions
|
|
3807
|
+
// follow the same gating rules.
|
|
3808
|
+
const routeAssertions = currentReport?.routeAssertions || null;
|
|
3809
|
+
const assertionShipImpact = shipDecisionFromAssertions(routeAssertions);
|
|
3810
|
+
const effectiveShipWithAssertions = assertionShipImpact
|
|
3811
|
+
? (SHIP_DECISION_RANK[assertionShipImpact] > SHIP_DECISION_RANK[effectiveShip] ? assertionShipImpact : effectiveShip)
|
|
3812
|
+
: effectiveShip;
|
|
3813
|
+
const assertionFields = routeAssertions ? {
|
|
3814
|
+
assertions_total: routeAssertions.total,
|
|
3815
|
+
assertions_passed: routeAssertions.passed,
|
|
3816
|
+
assertions_failed: routeAssertions.failed,
|
|
3817
|
+
assertions_critical_failures: routeAssertions.criticalFailures.length,
|
|
3818
|
+
assertions_sources: `customer=${routeAssertions.sources.customer},default=${routeAssertions.sources.default},auto=${routeAssertions.sources.auto},auto_suppressed=${routeAssertions.sources.autoSuppressed || 0}`,
|
|
3819
|
+
assertion_top_failure: routeAssertions.criticalFailures[0]
|
|
3820
|
+
? `${routeAssertions.criticalFailures[0].path}: expected ${routeAssertions.criticalFailures[0].expected}, got ${routeAssertions.criticalFailures[0].actual}`
|
|
3821
|
+
: undefined,
|
|
3822
|
+
} : {};
|
|
3823
|
+
|
|
3539
3824
|
console.log(formatAiFooterBlock({
|
|
3540
|
-
status:
|
|
3825
|
+
status: effectiveShipWithAssertions,
|
|
3541
3826
|
domain,
|
|
3542
3827
|
kind: "trust-diff",
|
|
3543
3828
|
baseline,
|
|
3544
3829
|
verdict,
|
|
3545
3830
|
delta_count: deltas.length,
|
|
3546
3831
|
max_severity: maxSev,
|
|
3547
|
-
ship_decision:
|
|
3832
|
+
ship_decision: effectiveShipWithAssertions,
|
|
3548
3833
|
ship_decision_drift: shipDecision,
|
|
3549
3834
|
ship_decision_posture: posture?.decision,
|
|
3835
|
+
ship_decision_assertions: assertionShipImpact,
|
|
3550
3836
|
ship_decision_mode: strictPosture ? "strict_posture" : "drift_only",
|
|
3551
3837
|
top_issue: topDelta?.id || topDelta?.type || "none",
|
|
3552
3838
|
top_issue_title: topDelta?.title || undefined,
|
|
@@ -3556,12 +3842,14 @@ async function runTrustDiffCommand(args) {
|
|
|
3556
3842
|
quota_limit: result.quota?.monthly_limit ?? undefined,
|
|
3557
3843
|
scanned_at: new Date().toISOString(),
|
|
3558
3844
|
advisory_only: "true",
|
|
3559
|
-
scope: strictPosture ? "
|
|
3560
|
-
scope_note: strictPosture
|
|
3561
|
-
|
|
3562
|
-
: "ship_decision reflects DRIFT only by default (per-deploy regression gate). Posture grade is surfaced via posture_decision / ship_decision_posture as advisory — D/F posture does NOT auto-block. Once your site reaches A/B posture, pass --strict-posture to lock that in. Cipherwake does NOT verify app functionality.",
|
|
3845
|
+
scope: strictPosture ? "trust_surface_drift_plus_absolute_posture_plus_route_assertions" : "trust_surface_drift_plus_route_assertions",
|
|
3846
|
+
scope_note: "ship_decision = worst-of(drift, route_assertions" + (strictPosture ? ", absolute_posture)" : ")") + ". Route assertions verify your declared private routes (e.g. /api/admin) are still gated; any critical failure (protected route now exposed) blocks the deploy unconditionally. Posture is " + (strictPosture ? "also gated due to --strict-posture." : "advisory unless --strict-posture is set.") + " Cipherwake does NOT verify app functionality.",
|
|
3847
|
+
...assertionFields,
|
|
3563
3848
|
...postureFields,
|
|
3564
3849
|
}));
|
|
3850
|
+
if (routeAssertions) {
|
|
3851
|
+
console.log(formatRouteAssertionsBlock(routeAssertions));
|
|
3852
|
+
}
|
|
3565
3853
|
if (posture && Array.isArray(posture.fixes) && posture.fixes.length > 0) {
|
|
3566
3854
|
console.log(formatPostureFixesBlock(posture.fixes));
|
|
3567
3855
|
}
|
|
@@ -3573,13 +3861,13 @@ async function runTrustDiffCommand(args) {
|
|
|
3573
3861
|
score: typeof result.current_score === "number" ? result.current_score : null,
|
|
3574
3862
|
grade: result.current_grade || null,
|
|
3575
3863
|
max_severity: maxSev,
|
|
3576
|
-
ship_decision:
|
|
3864
|
+
ship_decision: effectiveShipWithAssertions,
|
|
3577
3865
|
baseline,
|
|
3578
3866
|
delta_count: deltas.length,
|
|
3579
3867
|
top_issue: topDelta?.id || topDelta?.title || null,
|
|
3580
3868
|
});
|
|
3581
3869
|
|
|
3582
|
-
process.exit(shipDecisionExitCode(
|
|
3870
|
+
process.exit(shipDecisionExitCode(effectiveShipWithAssertions));
|
|
3583
3871
|
}
|
|
3584
3872
|
|
|
3585
3873
|
// Format output
|
|
@@ -3881,6 +4169,11 @@ async function runGuardsRunCommand(args) {
|
|
|
3881
4169
|
}
|
|
3882
4170
|
|
|
3883
4171
|
async function runPreviewDiffCommand(args) {
|
|
4172
|
+
// R86.7 — reject unknown flags before parsing. Closes the silent-no-op
|
|
4173
|
+
// class of footguns for the preview-diff command too.
|
|
4174
|
+
if (assertKnownFlags(args, "pqcheck preview-diff")) {
|
|
4175
|
+
process.exit(3);
|
|
4176
|
+
}
|
|
3884
4177
|
const previewUrl = parseFlag(args, "--preview");
|
|
3885
4178
|
const productionUrl = parseFlag(args, "--production");
|
|
3886
4179
|
if (!previewUrl || !productionUrl) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pqcheck",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.24",
|
|
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",
|