pqcheck 0.16.21 → 0.16.23

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 +222 -38
  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.21** — `ship_decision` now folds posture into the headline routing decision (worst-of drift + posture) closes a footgun where a clean-drift F-posture deploy would have shipped under the original "`pass` announce" protocol. Posture rubric recalibrated for partial credit: a site with valid HSTS preload + everything else missing now scores ~29 / grade D, not 0 / F. Fix snippets now emit inline in a `CIPHERWAKE_POSTURE_FIXES` block no JSON round-trip needed to read them. Empty legacy `grade=` field dropped from the AI block. [Full changelog →](./CHANGELOG.md)
11
+ > **Latest: v0.16.23** — Closes the silent-no-op flag class. `--strict` now aliases `--strict-posture` in scan / deploy-check / trust-diff (audit caught the typo silently degrading to drift-only). And unknown flags now reject loudly with a closest-match suggestion + non-zero exit `--stict-posture` (typo) `error: did you mean --strict-posture?` instead of silently no-op'ing. Same principle applied to ourselves that Cipherwake exists to enforce for customers: a security tool can never silently proceed with weaker behaviour on an unrecognized signal. [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))
@@ -290,17 +298,29 @@ async function main() {
290
298
  const fresh = args.includes("--fresh") || args.includes("--force");
291
299
  const aiMode = parseAiMode(args);
292
300
  const verbose = isVerboseMode(args); // v0.16.0 — opt-in detailed panel
301
+ // R86.4 (2026-06-03) — strict-posture opt-in. Default ship_decision is
302
+ // drift-only (per-deploy regression gate); --strict-posture folds the
303
+ // absolute posture grade into ship_decision. Recommended ONLY after a
304
+ // site reaches A/B posture — opting in earlier produces cry-wolf gating
305
+ // on every deploy since most AI-coded sites grade D/F out of the box.
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");
293
313
 
294
314
  // One-shot scan(s)
295
315
  let worstExit = 0;
296
316
  for (const domain of domains) {
297
- const exit = await runOneScan({ domain, format, quiet, threshold, webhookUrl, multi: domains.length > 1, fresh, aiMode, verbose });
317
+ const exit = await runOneScan({ domain, format, quiet, threshold, webhookUrl, multi: domains.length > 1, fresh, aiMode, verbose, strictPosture });
298
318
  if (exit > worstExit) worstExit = exit;
299
319
  }
300
320
  process.exit(worstExit);
301
321
  }
302
322
 
303
- async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi, fresh, aiMode, verbose }) {
323
+ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi, fresh, aiMode, verbose, strictPosture }) {
304
324
  if (!quiet && format === "text") process.stderr.write(color("dim", `Scanning ${domain}${fresh ? " (forcing fresh)" : ""} ...`));
305
325
  let report;
306
326
  try {
@@ -511,7 +531,13 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
511
531
  posture_findings_count: (posture.findings || []).length,
512
532
  posture_fixes_count: (posture.fixes || []).length,
513
533
  } : {};
514
- const effectiveShip = combineShipDecision(shipDecision, posture?.decision);
534
+ // R86.2 (2026-06-03) — see combineShipDecision: posture is advisory by
535
+ // default, gated when --strict-posture is passed.
536
+ const effectiveShip = combineShipDecision(shipDecision, posture?.decision, { strict: strictPosture });
537
+ // R86.5 — surface posture advisory line so D/F posture is never silently
538
+ // blessed under the drift-only default.
539
+ const postureAdvisory = formatPostureAdvisoryLine(posture, strictPosture);
540
+ if (postureAdvisory) console.log(postureAdvisory);
515
541
  console.log(formatAiFooterBlock({
516
542
  status: effectiveShip,
517
543
  domain,
@@ -522,14 +548,17 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
522
548
  ship_decision: effectiveShip,
523
549
  ship_decision_drift: shipDecision,
524
550
  ship_decision_posture: posture?.decision,
551
+ ship_decision_mode: strictPosture ? "strict_posture" : "drift_only",
525
552
  unreachable: unreachable ? "true" : "false",
526
553
  top_issue: topFinding?.id || topFinding?.title || "none",
527
554
  findings_high: findings.filter((f) => severityRank(f.severity) === 3).length,
528
555
  findings_critical: findings.filter((f) => severityRank(f.severity) === 4).length,
529
556
  scanned_at: new Date().toISOString(),
530
557
  advisory_only: "true",
531
- scope: "trust_surface_drift_plus_absolute_posture",
532
- scope_note: "ship_decision = worst-of(drift, absolute posture). 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.",
558
+ scope: strictPosture ? "trust_surface_drift_plus_absolute_posture" : "trust_surface_drift",
559
+ scope_note: strictPosture
560
+ ? "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."
561
+ : "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.",
533
562
  ...postureFields,
534
563
  }));
535
564
  if (posture && Array.isArray(posture.fixes) && posture.fixes.length > 0) {
@@ -573,6 +602,14 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
573
602
  } else {
574
603
  if (multi) console.log(color("dim", `\n──── ${domain} ────`));
575
604
  printReport(report);
605
+ // R86.5 — posture advisory line so D/F posture is never silently blessed
606
+ // in human output either. Posture is advisory, not gating.
607
+ const postureAdvisory = formatPostureAdvisoryLine(report.posture, strictPosture);
608
+ if (postureAdvisory) {
609
+ console.log("");
610
+ console.log(postureAdvisory);
611
+ console.log(color("dim", " See /methodology/posture-grading for the rubric + fix snippets."));
612
+ }
576
613
  }
577
614
 
578
615
  if (threshold !== null && typeof report.score === "number" && report.score >= threshold) {
@@ -724,6 +761,100 @@ function parseAiMode(args) {
724
761
  return args.includes("--ai") || args.includes("--agent");
725
762
  }
726
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
+
727
858
  function severityRank(s) {
728
859
  const map = { critical: 4, high: 3, medium: 2, low: 1, info: 0, none: -1 };
729
860
  return map[String(s || "none").toLowerCase()] ?? 0;
@@ -738,45 +869,49 @@ function highestSeverity(findings) {
738
869
  return best;
739
870
  }
740
871
 
741
- // Compute ship_decision: pass | review | block.
742
- // Used in three contexts:
743
- // - one-shot scan: severity-only, no diff baseline
744
- // - trust-diff / preview-diff: severity + diff-since-baseline
745
- // - deploy-check: same as trust-diff (it's an alias)
746
- //
747
- // Decision rules (advisory only):
748
- // * `block` — critical severity present
749
- // * `review` high severity OR diff introduced new high-severity OR DBR drop ≥1.0
750
- // * `pass` — everything else
751
- function computeShipDecision({ maxSeverity, hasUnexpectedDiff, scoreDelta }) {
752
- const sev = String(maxSeverity || "none").toLowerCase();
753
- if (sev === "critical") return "block";
754
- if (sev === "high") return "review";
755
- if (hasUnexpectedDiff) return "review";
756
- if (typeof scoreDelta === "number" && scoreDelta <= -1.0) return "review";
757
- return "pass";
872
+ // R86.5 (2026-06-03) posture advisory line. Posture is a STANDING property,
873
+ // not a per-deploy regression signal, so gating every deploy on it is cry-wolf
874
+ // by construction. The drift-only ship_decision is the right default gate
875
+ // (fires on regression). This helper surfaces posture grade + a one-line fix
876
+ // nudge alongside the gate result customer sees the grade, sees the fix
877
+ // path, is NOT blocked on every deploy. Only customers who have reached A/B
878
+ // posture opt into --strict-posture to lock that in.
879
+ function formatPostureAdvisoryLine(posture, strictPosture) {
880
+ if (!posture) return "";
881
+ if (strictPosture) return ""; // strict mode already gates; redundant
882
+ const decision = posture.decision;
883
+ if (decision !== "block" && decision !== "review") return ""; // A+/A — no advisory
884
+ const grade = posture.grade || "?";
885
+ const score = typeof posture.score === "number" ? posture.score : "?";
886
+ const fixCount = Array.isArray(posture.fixes) ? posture.fixes.length : 0;
887
+ const colorName = decision === "block" ? "red" : "yellow";
888
+ const icon = decision === "block" ? "●" : "○";
889
+ const nudge = fixCount > 0
890
+ ? `${fixCount} ready-to-paste fix${fixCount === 1 ? "" : "es"} in CIPHERWAKE_POSTURE_FIXES`
891
+ : "see /methodology/posture-grading";
892
+ return color(colorName, ` ${icon} Posture: ${grade} (score ${score}) — advisory, not gating. ${nudge}.`);
758
893
  }
759
894
 
760
- // R86.2 (2026-06-03) — fold absolute posture grade into ship_decision so an
761
- // AI agent following the original "ship_decision=pass safe to announce"
762
- // protocol does NOT ship a site with F-grade posture just because nothing
763
- // drifted since last scan. The headline decision must reflect both drift
764
- // and absolute state.
765
- //
766
- // Order: block > review > pass. Worst-of-both wins.
895
+ // R86.2 / R86.4 (2026-06-03) — combine drift + posture into ship_decision.
896
+ // Default (strict=false): drift only. Posture grade is surfaced via separate
897
+ // fields and the advisory line but does NOT promote drift's decision
898
+ // because most AI-coded sites grade D/F out of the box, making posture-gating
899
+ // the default would cry-wolf on every deploy of every header-less site,
900
+ // training people to ignore the gate. --strict-posture opts in for teams
901
+ // who've already fixed posture and want to prevent backsliding.
767
902
  const SHIP_DECISION_RANK = { pass: 0, review: 1, block: 2 };
768
- function combineShipDecision(driftDecision, postureDecision) {
769
- if (!postureDecision) return driftDecision;
903
+ function combineShipDecision(driftDecision, postureDecision, { strict = false } = {}) {
904
+ if (!postureDecision || !strict) return driftDecision;
770
905
  const a = SHIP_DECISION_RANK[driftDecision] ?? 0;
771
906
  const b = SHIP_DECISION_RANK[postureDecision] ?? 0;
772
907
  return a >= b ? driftDecision : postureDecision;
773
908
  }
774
909
 
775
910
  // R86.3 (2026-06-03) — emit per-finding fix snippets in a separate parseable
776
- // block after the AI guard block. The guard block stays compact key=value; the
911
+ // block after the AI guard block. The guard block stays compact key=value;
777
912
  // fixes are multi-line code so they get their own block with explicit
778
- // start/end markers + per-fix delimiters. Agents can parse the fixes
779
- // independently and apply them without round-tripping to the JSON response.
913
+ // start/end markers + per-fix delimiters. Agents parse + apply without
914
+ // round-tripping to JSON.
780
915
  function formatPostureFixesBlock(fixes) {
781
916
  if (!Array.isArray(fixes) || fixes.length === 0) return "";
782
917
  const lines = ["", "CIPHERWAKE_POSTURE_FIXES"];
@@ -797,6 +932,25 @@ function formatPostureFixesBlock(fixes) {
797
932
  return color("dim", lines.join("\n"));
798
933
  }
799
934
 
935
+ // Compute ship_decision: pass | review | block.
936
+ // Used in three contexts:
937
+ // - one-shot scan: severity-only, no diff baseline
938
+ // - trust-diff / preview-diff: severity + diff-since-baseline
939
+ // - deploy-check: same as trust-diff (it's an alias)
940
+ //
941
+ // Decision rules (advisory only):
942
+ // * `block` — critical severity present
943
+ // * `review` — high severity OR diff introduced new high-severity OR DBR drop ≥1.0
944
+ // * `pass` — everything else
945
+ function computeShipDecision({ maxSeverity, hasUnexpectedDiff, scoreDelta }) {
946
+ const sev = String(maxSeverity || "none").toLowerCase();
947
+ if (sev === "critical") return "block";
948
+ if (sev === "high") return "review";
949
+ if (hasUnexpectedDiff) return "review";
950
+ if (typeof scoreDelta === "number" && scoreDelta <= -1.0) return "review";
951
+ return "pass";
952
+ }
953
+
800
954
  function aiBannerColor(shipDecision) {
801
955
  if (shipDecision === "pass") return "green";
802
956
  if (shipDecision === "block") return "red";
@@ -3319,10 +3473,16 @@ async function runScanBasedDeployCheck(domain, args) {
3319
3473
  }
3320
3474
 
3321
3475
  async function runTrustDiffCommand(args) {
3476
+ // R86.7 — reject unknown flags before any other parsing. Same rationale
3477
+ // as the bare-scan path: typo'd safety-critical flags must fail loud,
3478
+ // not silently degrade.
3479
+ if (assertKnownFlags(args, "pqcheck trust-diff")) {
3480
+ process.exit(3);
3481
+ }
3322
3482
  const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
3323
3483
  if (positional.length === 0) {
3324
3484
  console.error(color("red", "error: pqcheck trust-diff requires a domain"));
3325
- console.error(color("dim", "Usage: npx pqcheck trust-diff <domain> [--baseline last-week] [--fail-on high] [--format pretty|json|sarif|github]"));
3485
+ console.error(color("dim", "Usage: npx pqcheck trust-diff <domain> [--baseline last-week] [--fail-on high] [--format pretty|json|sarif|github] [--strict-posture]"));
3326
3486
  process.exit(3);
3327
3487
  }
3328
3488
  const domain = normalizeDomain(positional[0]);
@@ -3334,6 +3494,17 @@ async function runTrustDiffCommand(args) {
3334
3494
  const baseline = parseFlag(args, "--baseline") || "last-week";
3335
3495
  const failOn = parseFlag(args, "--fail-on") || "high";
3336
3496
  const format = parseFlag(args, "--format") || "pretty";
3497
+ // R86.4 (2026-06-03) — see runOneScan for rationale. Default ship_decision
3498
+ // is drift-only (per-deploy regression gate); --strict-posture opts into
3499
+ // worst-of(drift, posture). Recommended only after a site reaches A/B
3500
+ // posture to lock that in.
3501
+ // Accept both `--strict-posture` (explicit) and `--strict` (short alias).
3502
+ // Audit caught a footgun where customers typed `--strict` expecting the
3503
+ // posture gate to fire and got silent drift-only mode instead. The
3504
+ // `onboard` subcommand uses `--strict` for a different purpose (gate exit
3505
+ // code on step failure) but that's a separate command — the alias is
3506
+ // scoped to scan / deploy-check / trust-diff only.
3507
+ const strictPosture = args.includes("--strict-posture") || args.includes("--strict");
3337
3508
 
3338
3509
  // Build headers conditionally — Authorization is set ONLY if the user has
3339
3510
  // an API key. Without it, the server's applyRepoQuota falls through to the
@@ -3479,7 +3650,12 @@ async function runTrustDiffCommand(args) {
3479
3650
  posture_findings_count: (posture.findings || []).length,
3480
3651
  posture_fixes_count: (posture.fixes || []).length,
3481
3652
  } : {};
3482
- const effectiveShip = combineShipDecision(shipDecision, posture?.decision);
3653
+ // R86.2 / R86.4 — see combineShipDecision: posture is advisory by default,
3654
+ // gated only when --strict-posture is passed.
3655
+ const effectiveShip = combineShipDecision(shipDecision, posture?.decision, { strict: strictPosture });
3656
+ // R86.5 — surface posture advisory line in default (non-strict) mode.
3657
+ const postureAdvisory = formatPostureAdvisoryLine(posture, strictPosture);
3658
+ if (postureAdvisory) console.log(postureAdvisory);
3483
3659
  console.log(formatAiFooterBlock({
3484
3660
  status: effectiveShip,
3485
3661
  domain,
@@ -3491,6 +3667,7 @@ async function runTrustDiffCommand(args) {
3491
3667
  ship_decision: effectiveShip,
3492
3668
  ship_decision_drift: shipDecision,
3493
3669
  ship_decision_posture: posture?.decision,
3670
+ ship_decision_mode: strictPosture ? "strict_posture" : "drift_only",
3494
3671
  top_issue: topDelta?.id || topDelta?.type || "none",
3495
3672
  top_issue_title: topDelta?.title || undefined,
3496
3673
  dbr: typeof result.current_score === "number" ? result.current_score.toFixed(1) : undefined,
@@ -3499,8 +3676,10 @@ async function runTrustDiffCommand(args) {
3499
3676
  quota_limit: result.quota?.monthly_limit ?? undefined,
3500
3677
  scanned_at: new Date().toISOString(),
3501
3678
  advisory_only: "true",
3502
- scope: "trust_surface_drift_plus_absolute_posture",
3503
- scope_note: "ship_decision = worst-of(drift, absolute posture). 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.",
3679
+ scope: strictPosture ? "trust_surface_drift_plus_absolute_posture" : "trust_surface_drift",
3680
+ scope_note: strictPosture
3681
+ ? "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."
3682
+ : "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.",
3504
3683
  ...postureFields,
3505
3684
  }));
3506
3685
  if (posture && Array.isArray(posture.fixes) && posture.fixes.length > 0) {
@@ -3822,6 +4001,11 @@ async function runGuardsRunCommand(args) {
3822
4001
  }
3823
4002
 
3824
4003
  async function runPreviewDiffCommand(args) {
4004
+ // R86.7 — reject unknown flags before parsing. Closes the silent-no-op
4005
+ // class of footguns for the preview-diff command too.
4006
+ if (assertKnownFlags(args, "pqcheck preview-diff")) {
4007
+ process.exit(3);
4008
+ }
3825
4009
  const previewUrl = parseFlag(args, "--preview");
3826
4010
  const productionUrl = parseFlag(args, "--production");
3827
4011
  if (!previewUrl || !productionUrl) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.16.21",
3
+ "version": "0.16.23",
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",