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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/bin/pqcheck.js +305 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  [![npm downloads](https://img.shields.io/npm/dm/pqcheck.svg?style=flat-square&color=06b6d4)](https://www.npmjs.com/package/pqcheck)
9
9
  [![license](https://img.shields.io/npm/l/pqcheck.svg?style=flat-square&color=06b6d4)](./LICENSE)
10
10
 
11
- > **Latest: v0.16.22** — Drift gates, posture advises. Default `ship_decision` reverts to drift-only (the right per-deploy regression gate); posture grade is surfaced as a one-line advisory + ready-to-paste fix snippets, NOT as an auto-block. A site that's been D-posture for months hasn't regressed on today's deploy; gating it every time trains people to ignore the gate. Once your site reaches A/B posture, pass `--strict-posture` as the "lock it in, prevent backsliding" gate. New `ship_decision_drift` / `ship_decision_posture` / `ship_decision_mode` fields make both signals visible. [Full changelog →](./CHANGELOG.md)
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
- const strictPosture = args.includes("--strict-posture");
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
- const strictPosture = args.includes("--strict-posture");
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: effectiveShip,
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: effectiveShip,
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 ? "trust_surface_drift_plus_absolute_posture" : "trust_surface_drift",
3560
- scope_note: strictPosture
3561
- ? "ship_decision = worst-of(drift, absolute posture) because --strict-posture is set. pass means BOTH no drift AND posture grade A+/A. ship_decision_drift and ship_decision_posture expose the two inputs separately. Cipherwake does NOT verify app functionality — pair with Playwright e2e for full deploy safety."
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: effectiveShip,
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(effectiveShip));
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.22",
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",