pqcheck 0.16.22 → 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.
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.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))
@@ -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;
@@ -3365,10 +3473,16 @@ async function runScanBasedDeployCheck(domain, args) {
3365
3473
  }
3366
3474
 
3367
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
+ }
3368
3482
  const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
3369
3483
  if (positional.length === 0) {
3370
3484
  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]"));
3485
+ console.error(color("dim", "Usage: npx pqcheck trust-diff <domain> [--baseline last-week] [--fail-on high] [--format pretty|json|sarif|github] [--strict-posture]"));
3372
3486
  process.exit(3);
3373
3487
  }
3374
3488
  const domain = normalizeDomain(positional[0]);
@@ -3384,7 +3498,13 @@ async function runTrustDiffCommand(args) {
3384
3498
  // is drift-only (per-deploy regression gate); --strict-posture opts into
3385
3499
  // worst-of(drift, posture). Recommended only after a site reaches A/B
3386
3500
  // posture to lock that in.
3387
- const strictPosture = args.includes("--strict-posture");
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");
3388
3508
 
3389
3509
  // Build headers conditionally — Authorization is set ONLY if the user has
3390
3510
  // an API key. Without it, the server's applyRepoQuota falls through to the
@@ -3881,6 +4001,11 @@ async function runGuardsRunCommand(args) {
3881
4001
  }
3882
4002
 
3883
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
+ }
3884
4009
  const previewUrl = parseFlag(args, "--preview");
3885
4010
  const productionUrl = parseFlag(args, "--production");
3886
4011
  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.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",