pqcheck 0.16.33 → 0.17.0-beta.2
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 +31 -5
- package/bin/pqcheck.js +858 -142
- package/package.json +1 -1
package/bin/pqcheck.js
CHANGED
|
@@ -81,6 +81,27 @@ function apiHeaders(extra = {}) {
|
|
|
81
81
|
return h;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// R96 — fetch with a hard timeout for the core API calls (/api/scan,
|
|
85
|
+
// /api/trust-diff, /api/preview-diff). Without it, a hung request stalled
|
|
86
|
+
// CI indefinitely; vendors/debug-network already had AbortController
|
|
87
|
+
// timeouts but the three load-bearing calls did not. 90s covers the
|
|
88
|
+
// worst server-side path (fresh full scan, maxDuration 60s) with margin.
|
|
89
|
+
const API_FETCH_TIMEOUT_MS = 90_000;
|
|
90
|
+
async function fetchWithTimeout(url, opts = {}, timeoutMs = API_FETCH_TIMEOUT_MS) {
|
|
91
|
+
const ctrl = new AbortController();
|
|
92
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
93
|
+
try {
|
|
94
|
+
return await fetch(url, { ...opts, signal: ctrl.signal });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (err?.name === "AbortError") {
|
|
97
|
+
throw new Error(`request timed out after ${Math.round(timeoutMs / 1000)}s: ${url}`);
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
} finally {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
84
105
|
// Helpful messaging when the server tells us auth/quota failed.
|
|
85
106
|
async function handleAuthError(resp) {
|
|
86
107
|
if (resp.status === 401) {
|
|
@@ -202,6 +223,13 @@ async function main() {
|
|
|
202
223
|
if (args[0] === "deploy-check") {
|
|
203
224
|
return runDeployCheckCommand(args.slice(1));
|
|
204
225
|
}
|
|
226
|
+
if (args[0] === "last") {
|
|
227
|
+
// R96 feedback #3 — `pqcheck last [domain] [--remote]` reuses a recent
|
|
228
|
+
// verdict (local state file or GitHub Actions CI run) instead of
|
|
229
|
+
// forcing a duplicate live deploy-check. Advisory-only: exit 3 means
|
|
230
|
+
// "no reusable signal, run deploy-check".
|
|
231
|
+
return runLastCommand(args.slice(1));
|
|
232
|
+
}
|
|
205
233
|
if (args[0] === "vendors") {
|
|
206
234
|
return runVendorsCommand(args.slice(1));
|
|
207
235
|
}
|
|
@@ -341,7 +369,7 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
|
|
|
341
369
|
// the server side; if exceeded, the server silently downgrades to a
|
|
342
370
|
// cached scan and returns that instead of erroring.
|
|
343
371
|
const qs = fresh ? `?domain=${encodeURIComponent(domain)}&force=1` : `?domain=${encodeURIComponent(domain)}`;
|
|
344
|
-
const resp = await
|
|
372
|
+
const resp = await fetchWithTimeout(`${API_BASE}/api/scan${qs}`, {
|
|
345
373
|
method: "GET",
|
|
346
374
|
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION}${CI_ACTION_SUFFIX} (scan)` }),
|
|
347
375
|
});
|
|
@@ -441,13 +469,21 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
|
|
|
441
469
|
// extension / AI banner) display the "UNREACHABLE" label when
|
|
442
470
|
// unreachable=true instead of the generic "BLOCK" — more informative,
|
|
443
471
|
// same protocol routing.
|
|
472
|
+
// R96 — the state file used to record the pre-posture drift decision
|
|
473
|
+
// while the AI footer + exit code used effectiveShip (worst-of with
|
|
474
|
+
// posture under --strict-posture), so statusline / prompt-hook could
|
|
475
|
+
// disagree with the actual gate. Compute once, record the same value
|
|
476
|
+
// everywhere.
|
|
477
|
+
const posture = report.posture || null;
|
|
478
|
+
const effectiveShip = combineShipDecision(shipDecision, posture?.decision, { strict: strictPosture });
|
|
479
|
+
|
|
444
480
|
await writeLastScanFile({
|
|
445
481
|
domain,
|
|
446
482
|
kind: "scan",
|
|
447
483
|
score: typeof report.score === "number" ? report.score : null,
|
|
448
484
|
grade: report.grade || null,
|
|
449
485
|
max_severity: maxSev,
|
|
450
|
-
ship_decision:
|
|
486
|
+
ship_decision: effectiveShip,
|
|
451
487
|
unreachable: unreachable || false,
|
|
452
488
|
top_issue: topFinding?.id || topFinding?.title || null,
|
|
453
489
|
});
|
|
@@ -532,7 +568,6 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
|
|
|
532
568
|
// posture_decision separates the absolute-state routing signal from the
|
|
533
569
|
// drift-based ship_decision so a weak-but-unchanged site gets a review
|
|
534
570
|
// nudge distinct from drift.
|
|
535
|
-
const posture = report.posture || null;
|
|
536
571
|
const postureFields = posture ? {
|
|
537
572
|
posture_grade: posture.grade,
|
|
538
573
|
posture_score: posture.score,
|
|
@@ -542,9 +577,6 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
|
|
|
542
577
|
posture_findings_count: (posture.findings || []).length,
|
|
543
578
|
posture_fixes_count: (posture.fixes || []).length,
|
|
544
579
|
} : {};
|
|
545
|
-
// R86.2 (2026-06-03) — see combineShipDecision: posture is advisory by
|
|
546
|
-
// default, gated when --strict-posture is passed.
|
|
547
|
-
const effectiveShip = combineShipDecision(shipDecision, posture?.decision, { strict: strictPosture });
|
|
548
580
|
// R86.5 — surface posture advisory line so D/F posture is never silently
|
|
549
581
|
// blessed under the drift-only default.
|
|
550
582
|
const postureAdvisory = formatPostureAdvisoryLine(posture, strictPosture);
|
|
@@ -600,9 +632,6 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
|
|
|
600
632
|
console.log(JSON.stringify(report, null, 2));
|
|
601
633
|
}
|
|
602
634
|
} else if (format === "csv") {
|
|
603
|
-
if (multi && this && !this.csvHeaderPrinted) {
|
|
604
|
-
// Header only once, before first row
|
|
605
|
-
}
|
|
606
635
|
printCsvRow(report);
|
|
607
636
|
} else if (format === "markdown") {
|
|
608
637
|
printMarkdown(report, multi);
|
|
@@ -707,12 +736,20 @@ function isFlagValue(args, val) {
|
|
|
707
736
|
const idx = args.indexOf(val);
|
|
708
737
|
if (idx <= 0) return false;
|
|
709
738
|
const prev = args[idx - 1];
|
|
710
|
-
|
|
739
|
+
// R96 — added --baseline/--fail-on: without them, `trust-diff acme.com
|
|
740
|
+
// --baseline last-week` misparsed "last-week" as the domain positional.
|
|
741
|
+
return prev === "--threshold" || prev === "--format" || prev === "--watch" || prev === "--webhook" || prev === "--file" || prev === "-o" || prev === "--allowlist" || prev === "--baseline" || prev === "--fail-on" || prev === "--max-age";
|
|
711
742
|
}
|
|
712
743
|
|
|
713
744
|
function parseFormat(args) {
|
|
714
745
|
if (args.includes("--json")) return "json"; // back-compat alias
|
|
715
746
|
if (args.includes("--gh-action")) return "gh-action"; // GitHub Actions annotation format
|
|
747
|
+
// R96 — these were whitelisted in KNOWN_FLAGS but parsed by nothing,
|
|
748
|
+
// so `pqcheck acme.com --csv` was a silent no-op (the exact footgun
|
|
749
|
+
// class R86.7 exists to prevent). Wire them as aliases like --json.
|
|
750
|
+
if (args.includes("--csv")) return "csv";
|
|
751
|
+
if (args.includes("--markdown")) return "markdown";
|
|
752
|
+
if (args.includes("--sarif")) return "sarif";
|
|
716
753
|
const i = args.indexOf("--format");
|
|
717
754
|
if (i === -1) return "text";
|
|
718
755
|
const v = (args[i + 1] || "").toLowerCase();
|
|
@@ -793,7 +830,7 @@ const KNOWN_FLAGS = new Set([
|
|
|
793
830
|
"--file", "--watch",
|
|
794
831
|
// Scan behaviour
|
|
795
832
|
"--fresh", "--force", "--threshold", "--webhook", "--json", "--csv", "--markdown",
|
|
796
|
-
"--sarif", "--gh-action", "--
|
|
833
|
+
"--sarif", "--gh-action", "--lock", "--explain", "--plan", "--stdout",
|
|
797
834
|
// Posture gating (the audit catch)
|
|
798
835
|
"--strict", "--strict-posture",
|
|
799
836
|
// Format / output format
|
|
@@ -801,6 +838,7 @@ const KNOWN_FLAGS = new Set([
|
|
|
801
838
|
// Trust-diff / deploy-check / preview-diff
|
|
802
839
|
"--baseline", "--fail-on", "--fail-on-new", "--guards", "--compare-transport",
|
|
803
840
|
"--write-baseline", "--preview", "--production",
|
|
841
|
+
"--protected-path", "--first-party-host",
|
|
804
842
|
// Setup / onboard / install consent
|
|
805
843
|
"--auto", "--manual", "--yes", "--no-open", "--domain", "--invoked-by",
|
|
806
844
|
"--consent-phrase", "--scope",
|
|
@@ -962,6 +1000,10 @@ async function readRouteAssertionsConfig() {
|
|
|
962
1000
|
// at the top level. Both forms are documented in the methodology page.
|
|
963
1001
|
const cfg = parsed?.routeAssertions || (Array.isArray(parsed?.assertions) ? { assertions: parsed.assertions, replace_defaults: parsed.replace_defaults } : null);
|
|
964
1002
|
if (!cfg) {
|
|
1003
|
+
// R96 — a domain-only config (written by `pqcheck setup`/`init` for
|
|
1004
|
+
// the deploy-check domain default) is a legitimate shape; don't
|
|
1005
|
+
// warn about missing assertions on it.
|
|
1006
|
+
if (typeof parsed?.domain === "string" && parsed.domain.trim()) return null;
|
|
965
1007
|
_warnConfigIssue(`${candidate} found but missing required field "routeAssertions.assertions" (or top-level "assertions" array). See https://cipherwake.io/methodology/route-assertions for the schema.`);
|
|
966
1008
|
return null;
|
|
967
1009
|
}
|
|
@@ -1034,6 +1076,67 @@ async function readRouteAssertionsConfig() {
|
|
|
1034
1076
|
}
|
|
1035
1077
|
}
|
|
1036
1078
|
|
|
1079
|
+
// R96 (dogfood feedback #2) — `pqcheck setup`/`init` persist the monitored
|
|
1080
|
+
// domain into .cipherwake.json so `deploy-check` / `guard` can omit the
|
|
1081
|
+
// domain argument on subsequent runs. Same 5-level walk-up as
|
|
1082
|
+
// readRouteAssertionsConfig. Returns a validated hostname or null. Malformed
|
|
1083
|
+
// JSON returns null silently — readRouteAssertionsConfig already warns on it.
|
|
1084
|
+
async function readCipherwakeConfigDomain() {
|
|
1085
|
+
try {
|
|
1086
|
+
const { readFileSync, existsSync } = await import("node:fs");
|
|
1087
|
+
const { join, dirname } = await import("node:path");
|
|
1088
|
+
let dir = process.cwd();
|
|
1089
|
+
for (let i = 0; i < 5; i++) {
|
|
1090
|
+
const candidate = join(dir, ".cipherwake.json");
|
|
1091
|
+
if (existsSync(candidate)) {
|
|
1092
|
+
try {
|
|
1093
|
+
const parsed = JSON.parse(readFileSync(candidate, "utf8"));
|
|
1094
|
+
if (typeof parsed?.domain !== "string" || !parsed.domain.trim()) return null;
|
|
1095
|
+
const d = normalizeDomain(parsed.domain.trim());
|
|
1096
|
+
return isValidDomain(d) ? d : null;
|
|
1097
|
+
} catch {
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
const parent = dirname(dir);
|
|
1102
|
+
if (parent === dir) break;
|
|
1103
|
+
dir = parent;
|
|
1104
|
+
}
|
|
1105
|
+
return null;
|
|
1106
|
+
} catch {
|
|
1107
|
+
return null;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// R96 — merge {"domain": <D>} into ./.cipherwake.json (cwd, where setup/init
|
|
1112
|
+
// run). Never clobbers a malformed or unexpected-shape file: the same file
|
|
1113
|
+
// carries customer route assertions, and destroying those to save a domain
|
|
1114
|
+
// field would be a terrible trade.
|
|
1115
|
+
async function writeDomainToCipherwakeConfig(domain) {
|
|
1116
|
+
const fs = await import("node:fs/promises");
|
|
1117
|
+
const path = await import("node:path");
|
|
1118
|
+
const cfgPath = path.join(process.cwd(), ".cipherwake.json");
|
|
1119
|
+
let parsed = {};
|
|
1120
|
+
let existed = false;
|
|
1121
|
+
try {
|
|
1122
|
+
const raw = await fs.readFile(cfgPath, "utf8");
|
|
1123
|
+
existed = true;
|
|
1124
|
+
try {
|
|
1125
|
+
parsed = JSON.parse(raw);
|
|
1126
|
+
} catch {
|
|
1127
|
+
return { status: "skipped-malformed", path: cfgPath };
|
|
1128
|
+
}
|
|
1129
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1130
|
+
return { status: "skipped-unexpected-shape", path: cfgPath };
|
|
1131
|
+
}
|
|
1132
|
+
} catch { /* file doesn't exist — will create */ }
|
|
1133
|
+
if (parsed.domain === domain) return { status: "already-set", path: cfgPath };
|
|
1134
|
+
const prior = typeof parsed.domain === "string" ? parsed.domain : null;
|
|
1135
|
+
parsed.domain = domain;
|
|
1136
|
+
await fs.writeFile(cfgPath, JSON.stringify(parsed, null, 2) + "\n", "utf8");
|
|
1137
|
+
return { status: existed ? (prior ? "updated" : "merged") : "created", prior, path: cfgPath };
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1037
1140
|
// R87 — fold customer route-assertion failures into ship_decision. Any
|
|
1038
1141
|
// CRITICAL failure (declared `protected` route that is now `exposed`) blocks
|
|
1039
1142
|
// the deploy unconditionally — this is the catastrophic "admin became
|
|
@@ -1068,7 +1171,13 @@ function shipDecisionFromAssertions(summary) {
|
|
|
1068
1171
|
if (!summary) return null;
|
|
1069
1172
|
// R89/R88 wave 2 + R90 — fold in deploy health + secrets + cookies + headers + TLS.
|
|
1070
1173
|
// Critical case auto-blocks; any non-critical failure promotes to review.
|
|
1071
|
-
|
|
1174
|
+
// R94.3 — waf_blocked is advisory (customer's own WAF blocked the scanner
|
|
1175
|
+
// UA, deploy most likely healthy) so it must not flip the gate to block,
|
|
1176
|
+
// matching the ship_decision_health field's treatment.
|
|
1177
|
+
const deployBroken = summary.deployHealth
|
|
1178
|
+
&& summary.deployHealth.status !== "healthy"
|
|
1179
|
+
&& summary.deployHealth.status !== "unreachable"
|
|
1180
|
+
&& summary.deployHealth.status !== "waf_blocked";
|
|
1072
1181
|
const secretsLeaked = summary.secrets && summary.secrets.criticalCount > 0;
|
|
1073
1182
|
const cookieCritical = (summary.cookieCriticalFailures || 0) > 0;
|
|
1074
1183
|
const tlsCritical = summary.tlsExpiry && !summary.tlsExpiry.passed && summary.tlsExpiry.severity === "critical";
|
|
@@ -1299,7 +1408,10 @@ function formatAiFooterBlock(fields) {
|
|
|
1299
1408
|
lines.push(`${safeK}=${safeV}`);
|
|
1300
1409
|
}
|
|
1301
1410
|
lines.push("END_CIPHERWAKE_AI_GUARD_RESULT");
|
|
1302
|
-
|
|
1411
|
+
// R96 — never colorize the machine block. On a TTY, color("dim", ...)
|
|
1412
|
+
// wrapped the END marker in ANSI escapes, breaking strict line-match
|
|
1413
|
+
// parsers in pty-based agents. The block is a wire format, not UI.
|
|
1414
|
+
return lines.join("\n");
|
|
1303
1415
|
}
|
|
1304
1416
|
|
|
1305
1417
|
// v0.16.13 — fail-loud AI guard for fetch/quota/server errors during
|
|
@@ -1311,7 +1423,9 @@ function formatAiFooterBlock(fields) {
|
|
|
1311
1423
|
// the agent can route on. Behaviour in non-AI text mode is unchanged.
|
|
1312
1424
|
function emitAiGuardReviewAndExit(args, errorDetail) {
|
|
1313
1425
|
if (parseAiMode(args)) {
|
|
1314
|
-
|
|
1426
|
+
// R96 — exclude flag VALUES, not just flags: `--baseline last-week
|
|
1427
|
+
// acme.com` used to label domain="last-week" in the error block.
|
|
1428
|
+
const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
|
|
1315
1429
|
const domain = positional[0] || "";
|
|
1316
1430
|
const baseline = parseFlag(args, "--baseline") || "last-scan";
|
|
1317
1431
|
try {
|
|
@@ -1362,6 +1476,37 @@ function emitAiGuardReviewAndExit(args, errorDetail) {
|
|
|
1362
1476
|
process.exit(errorDetail.exitCode ?? 3);
|
|
1363
1477
|
}
|
|
1364
1478
|
|
|
1479
|
+
// Same fail-loud contract for preview-diff --ai (v0.16.13 fixed this for
|
|
1480
|
+
// trust-diff only; preview-diff error paths previously exited 3 with no
|
|
1481
|
+
// block, so agents parsing for CIPHERWAKE_AI_GUARD_RESULT saw "no signal").
|
|
1482
|
+
function emitPreviewDiffAiGuardReviewAndExit(args, previewUrl, productionUrl, errorDetail) {
|
|
1483
|
+
if (parseAiMode(args)) {
|
|
1484
|
+
try {
|
|
1485
|
+
console.log("");
|
|
1486
|
+
console.log(color("yellow", " ⚠ REVIEW — Cipherwake preview-diff could not complete"));
|
|
1487
|
+
console.log(color("dim", ` ${errorDetail.message}`));
|
|
1488
|
+
console.log(color("dim", " Treating as REVIEW per fail-safe policy. Do NOT announce the deploy until you manually verify or rerun the check successfully."));
|
|
1489
|
+
console.log(formatAiFooterBlock({
|
|
1490
|
+
status: "review",
|
|
1491
|
+
kind: "preview-diff",
|
|
1492
|
+
preview_url: previewUrl || "",
|
|
1493
|
+
production_url: productionUrl || "",
|
|
1494
|
+
verdict: "review",
|
|
1495
|
+
ship_decision: "review",
|
|
1496
|
+
top_issue: errorDetail.code,
|
|
1497
|
+
top_issue_title: errorDetail.message,
|
|
1498
|
+
scanned_at: new Date().toISOString(),
|
|
1499
|
+
advisory_only: "true",
|
|
1500
|
+
error: errorDetail.code,
|
|
1501
|
+
}));
|
|
1502
|
+
console.log("");
|
|
1503
|
+
} catch {
|
|
1504
|
+
// even error path must not throw — fall through to exit
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
process.exit(errorDetail.exitCode ?? 3);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1365
1510
|
// v0.16.13 — opportunistic version check. Reads a small cache file on cold
|
|
1366
1511
|
// start; if a newer version is in cache AND we haven't already banner'd
|
|
1367
1512
|
// today, prints a banner to stderr. Separately, if cache is >24h old, fires
|
|
@@ -2198,6 +2343,7 @@ ${color("bold", "Commands:")}
|
|
|
2198
2343
|
npx pqcheck init Interactive scaffold for .github/workflows/cipherwake.yml
|
|
2199
2344
|
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
|
|
2200
2345
|
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.
|
|
2346
|
+
npx pqcheck last [domain] NEW: reuse a recent deploy-check verdict instead of re-scanning (local state; --remote checks your GitHub Actions CI run; --max-age <min> freshness window, default 60)
|
|
2201
2347
|
npx pqcheck protocol install NEW: install the AI Coder Protocol into your CLAUDE.md / .cursorrules (Rule 17 consent flow)
|
|
2202
2348
|
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
|
|
2203
2349
|
npx pqcheck release-checklist [domain] Print a pre-release trust checklist (markdown, offline)
|
|
@@ -3564,7 +3710,7 @@ async function runScanBasedDeployCheck(domain, args) {
|
|
|
3564
3710
|
|
|
3565
3711
|
let resp;
|
|
3566
3712
|
try {
|
|
3567
|
-
resp = await
|
|
3713
|
+
resp = await fetchWithTimeout(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, { headers });
|
|
3568
3714
|
} catch (err) {
|
|
3569
3715
|
// v0.16.17 — the v0.16.13 fail-loud AI guard fix was applied to the main
|
|
3570
3716
|
// trust-diff deploy-check path but missed THIS fallback path (no-baseline
|
|
@@ -3716,9 +3862,12 @@ async function runScanBasedDeployCheck(domain, args) {
|
|
|
3716
3862
|
max_severity: maxSev,
|
|
3717
3863
|
ship_decision: shipDecision,
|
|
3718
3864
|
unreachable: unreachable ? "true" : "false",
|
|
3865
|
+
// R96 — bare ids, matching the main scan path (which emits
|
|
3866
|
+
// `topFinding?.id` without a `findings.` prefix). Parsers shouldn't
|
|
3867
|
+
// need two formats for the same field.
|
|
3719
3868
|
top_issue: unreachable
|
|
3720
|
-
? "
|
|
3721
|
-
: (topFinding
|
|
3869
|
+
? "reachability.unreachable"
|
|
3870
|
+
: (topFinding?.id || topFinding?.title || "none"),
|
|
3722
3871
|
findings_high: findings.filter((f) => severityRank(f.severity) >= severityRank("high")).length,
|
|
3723
3872
|
findings_critical: findings.filter((f) => severityRank(f.severity) >= severityRank("critical")).length,
|
|
3724
3873
|
scanned_at: new Date().toISOString(),
|
|
@@ -3737,18 +3886,20 @@ async function runScanBasedDeployCheck(domain, args) {
|
|
|
3737
3886
|
ship_decision: shipDecision,
|
|
3738
3887
|
unreachable: unreachable || false,
|
|
3739
3888
|
top_issue: unreachable
|
|
3740
|
-
? "
|
|
3889
|
+
? "reachability.unreachable"
|
|
3741
3890
|
: (topFinding?.id || topFinding?.title || null),
|
|
3742
3891
|
note: unreachable
|
|
3743
3892
|
? "domain unreachable on port 443; deploy may have failed or DNS not propagated"
|
|
3744
3893
|
: "first-deploy: no baseline yet, scored on current state",
|
|
3745
3894
|
});
|
|
3746
3895
|
|
|
3747
|
-
// Exit code: 0
|
|
3748
|
-
|
|
3896
|
+
// Exit code: 0=pass, 1=review, 2=block (matches the deploy-check AI
|
|
3897
|
+
// contract). R96 — this previously exited 1 for block too, so the
|
|
3898
|
+
// pre-push hook (which refuses only on RC=2) would allow a blocked push.
|
|
3899
|
+
process.exit(shipDecision === "block" ? 2 : shipDecision === "pass" ? 0 : 1);
|
|
3749
3900
|
}
|
|
3750
3901
|
|
|
3751
|
-
async function runTrustDiffCommand(args) {
|
|
3902
|
+
async function runTrustDiffCommand(args, opts = {}) {
|
|
3752
3903
|
// R86.7 — reject unknown flags before any other parsing. Same rationale
|
|
3753
3904
|
// as the bare-scan path: typo'd safety-critical flags must fail loud,
|
|
3754
3905
|
// not silently degrade.
|
|
@@ -3802,7 +3953,7 @@ async function runTrustDiffCommand(args) {
|
|
|
3802
3953
|
let resp;
|
|
3803
3954
|
try {
|
|
3804
3955
|
const _routeCfg = await readRouteAssertionsConfig();
|
|
3805
|
-
resp = await
|
|
3956
|
+
resp = await fetchWithTimeout(`${API_BASE}/api/trust-diff`, {
|
|
3806
3957
|
method: "POST",
|
|
3807
3958
|
headers,
|
|
3808
3959
|
body: JSON.stringify({
|
|
@@ -3854,7 +4005,10 @@ async function runTrustDiffCommand(args) {
|
|
|
3854
4005
|
// through to /api/scan, populate the cache, and emit ship_decision based
|
|
3855
4006
|
// on the scan's current absolute state. Subsequent deploy-checks will
|
|
3856
4007
|
// have a baseline and produce a real drift verdict.
|
|
3857
|
-
|
|
4008
|
+
// R96 — README documents this fallback for deploy-check unconditionally,
|
|
4009
|
+
// but it used to fire only with --ai; a non-AI first run errored instead.
|
|
4010
|
+
// Now: any deploy-check invocation falls through, plus any --ai caller.
|
|
4011
|
+
if (resp.status === 404 && (opts.deployCheck || parseAiMode(args))) {
|
|
3858
4012
|
return await runScanBasedDeployCheck(domain, args);
|
|
3859
4013
|
}
|
|
3860
4014
|
if (!resp.ok) {
|
|
@@ -3879,9 +4033,9 @@ async function runTrustDiffCommand(args) {
|
|
|
3879
4033
|
const freshStatus = typeof result.fresh_status === "string" ? result.fresh_status : "not_requested";
|
|
3880
4034
|
if (fresh && freshStatus !== "applied" && freshStatus !== "not_requested") {
|
|
3881
4035
|
const why = freshStatus === "rate_limited"
|
|
3882
|
-
? "fresh-scan
|
|
4036
|
+
? "fresh-scan cap reached (20/hr per API key). The posture below is from the last cached scan — re-run after the cap window, or accept the cached read for now."
|
|
3883
4037
|
: freshStatus === "unauthenticated"
|
|
3884
|
-
? "fresh-scan requires an API key (free tier inclusive). Set CIPHERWAKE_API_KEY
|
|
4038
|
+
? "fresh-scan requires an API key (free tier inclusive). Set CIPHERWAKE_API_KEY; without it, --fresh silently downgrades to cached. Get a key at https://cipherwake.io/account#api-keys"
|
|
3885
4039
|
: freshStatus === "unavailable"
|
|
3886
4040
|
? "fresh-scan path failed mid-run. The posture below is from the last cached scan — re-run to retry."
|
|
3887
4041
|
: `fresh request returned status=${freshStatus}.`;
|
|
@@ -4017,7 +4171,12 @@ async function runTrustDiffCommand(args) {
|
|
|
4017
4171
|
deploy_status: dh?.status,
|
|
4018
4172
|
deploy_http_status: dh?.httpStatus,
|
|
4019
4173
|
deploy_summary: dh?.summary?.slice(0, 200),
|
|
4020
|
-
|
|
4174
|
+
// R94.3 — waf_blocked is advisory, not blocking. The deploy is most
|
|
4175
|
+
// likely healthy; the customer's WAF blocked our scanner UA. Same
|
|
4176
|
+
// class as R90.1 (WAF false-positive on route assertions). Treat
|
|
4177
|
+
// unreachable + waf_blocked as "review" rather than "block" so the
|
|
4178
|
+
// customer isn't gated by their own bot-protection.
|
|
4179
|
+
ship_decision_health: dh && dh.status !== "healthy" && dh.status !== "unreachable" && dh.status !== "waf_blocked" ? "block" : (dh ? "pass" : undefined),
|
|
4021
4180
|
// Secret scanner
|
|
4022
4181
|
secrets_scanned: sc?.scanned,
|
|
4023
4182
|
secrets_findings_total: sc?.findings?.length,
|
|
@@ -4037,6 +4196,12 @@ async function runTrustDiffCommand(args) {
|
|
|
4037
4196
|
: undefined,
|
|
4038
4197
|
} : {};
|
|
4039
4198
|
|
|
4199
|
+
// R96 feedback #4 — flake context: is the failing check a chronic flake /
|
|
4200
|
+
// previously-dismissed state or a first-ever failure? Sourced from local
|
|
4201
|
+
// .cipherwake/stats.json history; {} (silent) when nothing is failing or
|
|
4202
|
+
// there is no history for the failing check.
|
|
4203
|
+
const flakeFields = await buildFlakeContextFields(routeAssertions);
|
|
4204
|
+
|
|
4040
4205
|
console.log(formatAiFooterBlock({
|
|
4041
4206
|
status: effectiveShipWithAssertions,
|
|
4042
4207
|
domain,
|
|
@@ -4074,6 +4239,7 @@ async function runTrustDiffCommand(args) {
|
|
|
4074
4239
|
})
|
|
4075
4240
|
: undefined,
|
|
4076
4241
|
...assertionFields,
|
|
4242
|
+
...flakeFields,
|
|
4077
4243
|
...postureFields,
|
|
4078
4244
|
// R92 — every CIPHERWAKE_AI_GUARD_RESULT block now carries fresh_status
|
|
4079
4245
|
// so MCP servers / Aider / Cursor / Claude Code can route programmatically:
|
|
@@ -4502,7 +4668,7 @@ async function runPreviewDiffCommand(args) {
|
|
|
4502
4668
|
|
|
4503
4669
|
let resp;
|
|
4504
4670
|
try {
|
|
4505
|
-
resp = await
|
|
4671
|
+
resp = await fetchWithTimeout(`${API_BASE}/api/preview-diff`, {
|
|
4506
4672
|
method: "POST",
|
|
4507
4673
|
headers,
|
|
4508
4674
|
body: JSON.stringify({
|
|
@@ -4518,18 +4684,30 @@ async function runPreviewDiffCommand(args) {
|
|
|
4518
4684
|
});
|
|
4519
4685
|
} catch (err) {
|
|
4520
4686
|
console.error(color("red", `error: network failure calling /api/preview-diff: ${err.message}`));
|
|
4521
|
-
|
|
4687
|
+
emitPreviewDiffAiGuardReviewAndExit(args, previewUrl, productionUrl, {
|
|
4688
|
+
code: "network_failure",
|
|
4689
|
+
message: `network failure calling /api/preview-diff: ${err.message}`,
|
|
4690
|
+
exitCode: 3,
|
|
4691
|
+
});
|
|
4522
4692
|
}
|
|
4523
4693
|
|
|
4524
4694
|
if (resp.status === 401 || resp.status === 403) {
|
|
4525
4695
|
await handleAuthError(resp);
|
|
4526
|
-
|
|
4696
|
+
emitPreviewDiffAiGuardReviewAndExit(args, previewUrl, productionUrl, {
|
|
4697
|
+
code: "auth_error",
|
|
4698
|
+
message: `authentication failed (${resp.status}) calling /api/preview-diff`,
|
|
4699
|
+
exitCode: 3,
|
|
4700
|
+
});
|
|
4527
4701
|
}
|
|
4528
4702
|
if (resp.status === 429) {
|
|
4529
4703
|
const body = await safeJSON(resp);
|
|
4530
4704
|
console.error(color("red", "error: Preview Diff API quota exceeded for this month"));
|
|
4531
4705
|
if (body?.message) console.error(color("dim", body.message));
|
|
4532
|
-
|
|
4706
|
+
emitPreviewDiffAiGuardReviewAndExit(args, previewUrl, productionUrl, {
|
|
4707
|
+
code: "quota_exceeded",
|
|
4708
|
+
message: body?.message || "Preview Diff API quota exceeded for this month",
|
|
4709
|
+
exitCode: 3,
|
|
4710
|
+
});
|
|
4533
4711
|
}
|
|
4534
4712
|
if (!resp.ok) {
|
|
4535
4713
|
const body = await safeJSON(resp);
|
|
@@ -4539,7 +4717,11 @@ async function runPreviewDiffCommand(args) {
|
|
|
4539
4717
|
// / private-IP URLs surface the tunnel-options hint). Print it so the
|
|
4540
4718
|
// user knows what to do next instead of just seeing the rejection.
|
|
4541
4719
|
if (body?.hint) console.error(color("dim", body.hint));
|
|
4542
|
-
|
|
4720
|
+
emitPreviewDiffAiGuardReviewAndExit(args, previewUrl, productionUrl, {
|
|
4721
|
+
code: `server_error_${resp.status}`,
|
|
4722
|
+
message: body?.message || `/api/preview-diff returned ${resp.status}`,
|
|
4723
|
+
exitCode: 3,
|
|
4724
|
+
});
|
|
4543
4725
|
}
|
|
4544
4726
|
|
|
4545
4727
|
const result = await resp.json();
|
|
@@ -4838,6 +5020,15 @@ function trustDiffToSarif(result) {
|
|
|
4838
5020
|
}
|
|
4839
5021
|
|
|
4840
5022
|
function parseFlag(args, name) {
|
|
5023
|
+
// Supports both `--flag value` and `--flag=value` forms. The README
|
|
5024
|
+
// documents the equals form (e.g. `--trigger=deployment-status`), and
|
|
5025
|
+
// assertKnownFlags already validates it — so the parser must accept it
|
|
5026
|
+
// too or the flag silently no-ops (the R86.7 footgun class).
|
|
5027
|
+
for (const tok of args) {
|
|
5028
|
+
if (typeof tok === "string" && tok.startsWith(`${name}=`)) {
|
|
5029
|
+
return tok.slice(name.length + 1) || null;
|
|
5030
|
+
}
|
|
5031
|
+
}
|
|
4841
5032
|
const idx = args.indexOf(name);
|
|
4842
5033
|
if (idx === -1 || idx === args.length - 1) return null;
|
|
4843
5034
|
return args[idx + 1];
|
|
@@ -5198,7 +5389,7 @@ function renderReleaseChecklist(domain, opts = {}) {
|
|
|
5198
5389
|
`### How to verify`,
|
|
5199
5390
|
``,
|
|
5200
5391
|
`\`\`\`bash`,
|
|
5201
|
-
`# Trust posture vs last successful deploy (Free:
|
|
5392
|
+
`# Trust posture vs last successful deploy (Free: 100 calls/mo)`,
|
|
5202
5393
|
`npx pqcheck trust-diff ${domain} --baseline last-week --fail-on high`,
|
|
5203
5394
|
``,
|
|
5204
5395
|
`# Third-party origins on the page (vendor scripts)`,
|
|
@@ -5231,8 +5422,8 @@ function renderReleaseChecklist(domain, opts = {}) {
|
|
|
5231
5422
|
// --force Overwrite an existing workflow file without prompting
|
|
5232
5423
|
// --stdout Print the workflow to stdout instead of writing files
|
|
5233
5424
|
//
|
|
5234
|
-
// Free tier: no API call made by init itself. The generated workflow
|
|
5235
|
-
//
|
|
5425
|
+
// Free tier: no API call made by init itself. The generated workflow meters
|
|
5426
|
+
// per repo via GitHub OIDC (100 free Trust Diff calls/mo, no API key needed).
|
|
5236
5427
|
// =============================================================================
|
|
5237
5428
|
|
|
5238
5429
|
const VALID_FAIL_ON = ["any", "low", "medium", "high", "critical"];
|
|
@@ -5343,11 +5534,14 @@ function extractStatsEntries(routeAssertions) {
|
|
|
5343
5534
|
// Deploy health
|
|
5344
5535
|
if (routeAssertions.deployHealth) {
|
|
5345
5536
|
const dh = routeAssertions.deployHealth;
|
|
5537
|
+
// R94.3 — waf_blocked/unreachable are advisory, not gate failures;
|
|
5538
|
+
// recording them as critical would pollute the per-check flake stats.
|
|
5539
|
+
const advisory = dh.status === "waf_blocked" || dh.status === "unreachable";
|
|
5346
5540
|
entries.push({
|
|
5347
5541
|
id: "health:homepage",
|
|
5348
5542
|
result: dh.status === "healthy" ? "pass" : "fail",
|
|
5349
5543
|
status: dh.httpStatus,
|
|
5350
|
-
severity: dh.status === "healthy" ? "info" : "critical",
|
|
5544
|
+
severity: dh.status === "healthy" ? "info" : (advisory ? "low" : "critical"),
|
|
5351
5545
|
source: "health",
|
|
5352
5546
|
});
|
|
5353
5547
|
}
|
|
@@ -5364,6 +5558,63 @@ function extractStatsEntries(routeAssertions) {
|
|
|
5364
5558
|
return entries;
|
|
5365
5559
|
}
|
|
5366
5560
|
|
|
5561
|
+
// R96 feedback #4 — flake context for the AI guard block. When a check is
|
|
5562
|
+
// failing NOW, its local history (.cipherwake/stats.json) tells the agent
|
|
5563
|
+
// whether this is a first-ever failure (likely a real regression) or a
|
|
5564
|
+
// chronic / previously-dismissed one (likely flake or intentional state).
|
|
5565
|
+
// Called BEFORE recordResults() persists this run, so counts describe PRIOR
|
|
5566
|
+
// runs only. Silent (returns {}) when nothing is failing or the failing
|
|
5567
|
+
// check has no recorded history.
|
|
5568
|
+
async function buildFlakeContextFields(routeAssertions) {
|
|
5569
|
+
try {
|
|
5570
|
+
if (!routeAssertions) return {};
|
|
5571
|
+
const failing = [];
|
|
5572
|
+
for (const r of routeAssertions.results || []) {
|
|
5573
|
+
if (!r.passed) failing.push({ id: `route:${r.path}`, path: r.path });
|
|
5574
|
+
}
|
|
5575
|
+
for (const h of routeAssertions.headerResults || []) {
|
|
5576
|
+
if (!h.passed) failing.push({ id: `header:${h.header}` });
|
|
5577
|
+
}
|
|
5578
|
+
for (const c of routeAssertions.cookieResults || []) {
|
|
5579
|
+
if (!c.passed) failing.push({ id: `cookie:${c.namePattern}` });
|
|
5580
|
+
}
|
|
5581
|
+
for (const f of routeAssertions.secrets?.findings || []) {
|
|
5582
|
+
failing.push({ id: `secret:${f.patternId}` });
|
|
5583
|
+
}
|
|
5584
|
+
if (routeAssertions.deployHealth && routeAssertions.deployHealth.status !== "healthy") {
|
|
5585
|
+
failing.push({ id: "health:homepage" });
|
|
5586
|
+
}
|
|
5587
|
+
if (routeAssertions.tlsExpiry?.checked && !routeAssertions.tlsExpiry.passed) {
|
|
5588
|
+
failing.push({ id: "tls:expiry" });
|
|
5589
|
+
}
|
|
5590
|
+
if (failing.length === 0) return {};
|
|
5591
|
+
// Prefer the check behind the top critical failure so these fields
|
|
5592
|
+
// describe the same issue as assertion_top_failure.
|
|
5593
|
+
const topCriticalPath = routeAssertions.criticalFailures?.[0]?.path;
|
|
5594
|
+
const top = (topCriticalPath && failing.find((f) => f.path === topCriticalPath)) || failing[0];
|
|
5595
|
+
const { loadStats } = await import(new URL("./statsTracker.js", import.meta.url).href);
|
|
5596
|
+
const stats = await loadStats();
|
|
5597
|
+
const s = stats.checks?.[top.id];
|
|
5598
|
+
if (!s || !s.runs) return {};
|
|
5599
|
+
let hint;
|
|
5600
|
+
if (s.dismissedIntentional > 0) hint = "previously_dismissed";
|
|
5601
|
+
else if (s.failed === 0) hint = "first_failure";
|
|
5602
|
+
else if (s.runs >= 3 && s.failed / s.runs >= 0.5) hint = "frequently_failing";
|
|
5603
|
+
else hint = "recurring";
|
|
5604
|
+
const parts = [`failed ${s.failed} of ${s.runs} prior runs`];
|
|
5605
|
+
if (s.dismissedIntentional > 0) parts.push(`dismissed as intentional ${s.dismissedIntentional}x`);
|
|
5606
|
+
if (s.confirmedReal > 0) parts.push(`confirmed real ${s.confirmedReal}x`);
|
|
5607
|
+
return {
|
|
5608
|
+
top_failure_id: top.id,
|
|
5609
|
+
top_failure_history: parts.join("; "),
|
|
5610
|
+
flake_hint: hint,
|
|
5611
|
+
};
|
|
5612
|
+
} catch {
|
|
5613
|
+
// never block the gate on stats issues
|
|
5614
|
+
return {};
|
|
5615
|
+
}
|
|
5616
|
+
}
|
|
5617
|
+
|
|
5367
5618
|
async function runInitCommand(args) {
|
|
5368
5619
|
const fs = await import("node:fs/promises");
|
|
5369
5620
|
const path = await import("node:path");
|
|
@@ -5374,6 +5625,20 @@ async function runInitCommand(args) {
|
|
|
5374
5625
|
const flagDomain = readFlagValue(args, "--domain");
|
|
5375
5626
|
const flagFailOn = readFlagValue(args, "--fail-on");
|
|
5376
5627
|
const flagBaseline = readFlagValue(args, "--baseline");
|
|
5628
|
+
// R94.3 (2026-06-08) — stable-track default flipped to opt-in.
|
|
5629
|
+
// `push:main` is back-compat-safe for every platform (including
|
|
5630
|
+
// custom CD scripts + manual rollouts). `deployment-status` is the
|
|
5631
|
+
// smart Vercel/Netlify trigger (recommended, R93) but only fires
|
|
5632
|
+
// when a real `deployment_status` event arrives, which means
|
|
5633
|
+
// someone on a non-deployment-event platform gets ZERO runs.
|
|
5634
|
+
// Default is now `push` so initial install never silently does
|
|
5635
|
+
// nothing; explicit `--trigger=deployment-status` for Vercel/Netlify.
|
|
5636
|
+
const flagTrigger = readFlagValue(args, "--trigger") || "push";
|
|
5637
|
+
if (!["push", "deployment-status", "deployment_status"].includes(flagTrigger)) {
|
|
5638
|
+
console.error(color("red", `error: --trigger must be 'push' (default) or 'deployment-status'`));
|
|
5639
|
+
process.exit(1);
|
|
5640
|
+
}
|
|
5641
|
+
const triggerMode = flagTrigger === "deployment_status" ? "deployment-status" : flagTrigger;
|
|
5377
5642
|
|
|
5378
5643
|
console.log("");
|
|
5379
5644
|
console.log(` ${color("bold", "pqcheck init")} ${color("dim", "— scaffold a Cipherwake GitHub Action workflow")}`);
|
|
@@ -5413,7 +5678,7 @@ async function runInitCommand(args) {
|
|
|
5413
5678
|
process.exit(1);
|
|
5414
5679
|
}
|
|
5415
5680
|
|
|
5416
|
-
const workflow = renderTrustDiffWorkflow({ domain, failOn, baseline });
|
|
5681
|
+
const workflow = renderTrustDiffWorkflow({ domain, failOn, baseline, triggerMode });
|
|
5417
5682
|
|
|
5418
5683
|
if (stdout) {
|
|
5419
5684
|
console.log(workflow);
|
|
@@ -5461,27 +5726,47 @@ async function runInitCommand(args) {
|
|
|
5461
5726
|
const relPath = path.relative(cwd, workflowPath);
|
|
5462
5727
|
console.log("");
|
|
5463
5728
|
console.log(color("green", ` ✓ Wrote ${relPath}`));
|
|
5729
|
+
|
|
5730
|
+
// R96 (dogfood feedback #2) — persist the domain so deploy-check/guard
|
|
5731
|
+
// can default to it on subsequent runs without a domain argument.
|
|
5732
|
+
try {
|
|
5733
|
+
const cfgResult = await writeDomainToCipherwakeConfig(domain);
|
|
5734
|
+
if (cfgResult.status === "created") {
|
|
5735
|
+
console.log(color("green", ` ✓ Wrote .cipherwake.json (domain: ${domain} — deploy-check/guard can now omit the domain argument)`));
|
|
5736
|
+
} else if (cfgResult.status === "merged") {
|
|
5737
|
+
console.log(color("green", ` ✓ Added "domain": "${domain}" to .cipherwake.json`));
|
|
5738
|
+
} else if (cfgResult.status === "updated") {
|
|
5739
|
+
console.log(color("green", ` ✓ Updated .cipherwake.json domain: ${cfgResult.prior} → ${domain}`));
|
|
5740
|
+
} else if (cfgResult.status === "skipped-malformed" || cfgResult.status === "skipped-unexpected-shape") {
|
|
5741
|
+
console.log(color("yellow", ` ⚠ .cipherwake.json could not be updated (${cfgResult.status === "skipped-malformed" ? "malformed JSON" : "not a JSON object"}) — add "domain": "${domain}" by hand`));
|
|
5742
|
+
}
|
|
5743
|
+
} catch (err) {
|
|
5744
|
+
console.log(color("yellow", ` ⚠ .cipherwake.json domain write failed: ${err.message} (non-fatal)`));
|
|
5745
|
+
}
|
|
5464
5746
|
console.log("");
|
|
5465
5747
|
console.log(` ${color("bold", "Next steps:")}`);
|
|
5466
5748
|
console.log("");
|
|
5467
|
-
console.log(` ${color("dim", "1.")}
|
|
5468
|
-
console.log(` ${color("dim", "Free
|
|
5469
|
-
console.log("");
|
|
5470
|
-
console.log(` ${color("dim", "2.")} Add it as a repo secret:`);
|
|
5471
|
-
console.log(` ${color("dim", "Settings → Secrets and variables → Actions → New repository secret")}`);
|
|
5472
|
-
console.log(` ${color("dim", "Name: CIPHERWAKE_API_KEY")}`);
|
|
5473
|
-
console.log("");
|
|
5474
|
-
console.log(` ${color("dim", "3.")} Commit + push:`);
|
|
5749
|
+
console.log(` ${color("dim", "1.")} Commit + push — no API key or repo secret needed:`);
|
|
5750
|
+
console.log(` ${color("dim", "The workflow meters per repo via GitHub OIDC (Free: 100 Trust Diff calls/month).")}`);
|
|
5475
5751
|
console.log(` ${color("dim", "$")} git add ${relPath}`);
|
|
5476
5752
|
console.log(` ${color("dim", "$")} git commit -m "ci: add Cipherwake Trust Diff gate"`);
|
|
5477
5753
|
console.log(` ${color("dim", "$")} git push`);
|
|
5478
5754
|
console.log("");
|
|
5755
|
+
console.log(` ${color("dim", "2.")} ${color("dim", "Optional — higher limits:")} add a key from ${color("violet", "https://cipherwake.io/account#api-keys")} as the CIPHERWAKE_API_KEY repo secret.`);
|
|
5756
|
+
console.log("");
|
|
5479
5757
|
console.log(` Open a PR to see the gate run.`);
|
|
5480
5758
|
console.log("");
|
|
5481
5759
|
process.exit(0);
|
|
5482
5760
|
}
|
|
5483
5761
|
|
|
5484
5762
|
function readFlagValue(args, name) {
|
|
5763
|
+
// `--flag=value` form first (documented syntax for `init --trigger=...`),
|
|
5764
|
+
// then the space-separated `--flag value` form.
|
|
5765
|
+
for (const tok of args) {
|
|
5766
|
+
if (typeof tok === "string" && tok.startsWith(`${name}=`)) {
|
|
5767
|
+
return tok.slice(name.length + 1) || null;
|
|
5768
|
+
}
|
|
5769
|
+
}
|
|
5485
5770
|
const idx = args.indexOf(name);
|
|
5486
5771
|
if (idx === -1) return null;
|
|
5487
5772
|
const v = args[idx + 1];
|
|
@@ -5494,23 +5779,59 @@ function isValidBaseline(value) {
|
|
|
5494
5779
|
return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?Z?)?$/.test(value);
|
|
5495
5780
|
}
|
|
5496
5781
|
|
|
5497
|
-
function renderTrustDiffWorkflow({ domain, failOn, baseline }) {
|
|
5782
|
+
function renderTrustDiffWorkflow({ domain, failOn, baseline, triggerMode = "push" }) {
|
|
5783
|
+
const isDeploymentStatus = triggerMode === "deployment-status";
|
|
5784
|
+
|
|
5785
|
+
// The trigger YAML block + job-level `if:` guard differ between modes.
|
|
5786
|
+
// push (default, back-compat): fires on every push to main. Safe for any
|
|
5787
|
+
// platform but can race git-integrated deploys (Vercel/Netlify).
|
|
5788
|
+
// deployment-status (--trigger=deployment-status): fires AFTER a successful
|
|
5789
|
+
// Production deploy. Race-safe for Vercel/Netlify/Render/Railway. Does
|
|
5790
|
+
// nothing on platforms that don't emit the event (custom CD scripts).
|
|
5791
|
+
const triggerBlock = isDeploymentStatus
|
|
5792
|
+
? `on:
|
|
5793
|
+
pull_request:
|
|
5794
|
+
branches: [main]
|
|
5795
|
+
deployment_status:`
|
|
5796
|
+
: `on:
|
|
5797
|
+
pull_request:
|
|
5798
|
+
branches: [main]
|
|
5799
|
+
push:
|
|
5800
|
+
branches: [main]`;
|
|
5801
|
+
|
|
5802
|
+
const jobGuard = isDeploymentStatus
|
|
5803
|
+
? ` # Run on PRs OR on successful Production deployments.
|
|
5804
|
+
if: |
|
|
5805
|
+
github.event_name == 'pull_request' ||
|
|
5806
|
+
(github.event_name == 'deployment_status' &&
|
|
5807
|
+
github.event.deployment_status.state == 'success' &&
|
|
5808
|
+
(github.event.deployment.environment == 'production' ||
|
|
5809
|
+
github.event.deployment.environment == 'Production'))`
|
|
5810
|
+
: ` # Runs on PRs (advisory) and pushes to main (gate).`;
|
|
5811
|
+
|
|
5812
|
+
const triggerNote = isDeploymentStatus
|
|
5813
|
+
? `# Trigger: deployment_status (--trigger=deployment-status). The job fires
|
|
5814
|
+
# AFTER a successful Production deployment, so trust-diff sees the surface
|
|
5815
|
+
# that actually shipped — not the stale prior production. Recommended for
|
|
5816
|
+
# Vercel / Netlify / Render / Railway and other git-integrated platforms.
|
|
5817
|
+
# Switch to 'push' if your platform does NOT emit deployment_status events
|
|
5818
|
+
# (custom CD scripts, S3-sync deploys, manual rollouts).`
|
|
5819
|
+
: `# Trigger: push to main (default). Back-compat-safe on every platform.
|
|
5820
|
+
# If you're on Vercel / Netlify / Render / Railway, switch to
|
|
5821
|
+
# \`--trigger=deployment-status\` — the post-deploy gate is more accurate
|
|
5822
|
+
# because the push:main trigger can race git-integrated deploys, diffing
|
|
5823
|
+
# the previous production surface before the new one is live.`;
|
|
5824
|
+
|
|
5498
5825
|
return `# Cipherwake — Trust Diff gate
|
|
5499
5826
|
# Generated by \`pqcheck init\` (v${VERSION}).
|
|
5500
5827
|
# Runs:
|
|
5501
5828
|
# - on every PR (advisory diff against the production baseline)
|
|
5502
|
-
# - on every successful Production deployment (
|
|
5829
|
+
# - on every push to main / successful Production deployment (gate)
|
|
5503
5830
|
# Fails the build if your public trust posture regresses vs the baseline
|
|
5504
5831
|
# (cert / SPKI / vendor scripts / HSTS / CSP / DMARC / HNDL / declared
|
|
5505
5832
|
# route assertions).
|
|
5506
5833
|
#
|
|
5507
|
-
|
|
5508
|
-
# the post-deploy job, NOT push:main. Reason: on platforms with git-integrated
|
|
5509
|
-
# deploys (Vercel, Netlify, Render, Railway, etc.) the deploy fires from the
|
|
5510
|
-
# same push event — running on push:main would RACE the deploy and diff the
|
|
5511
|
-
# STALE production surface before the new deploy is live. deployment_status
|
|
5512
|
-
# fires AFTER the deploy lands, so trust-diff sees the surface that actually
|
|
5513
|
-
# shipped.
|
|
5834
|
+
${triggerNote}
|
|
5514
5835
|
#
|
|
5515
5836
|
# Free tier: 100 Trust Diff calls/month per repo (OIDC-metered).
|
|
5516
5837
|
# Methodology: https://cipherwake.io/methodology/
|
|
@@ -5518,10 +5839,7 @@ function renderTrustDiffWorkflow({ domain, failOn, baseline }) {
|
|
|
5518
5839
|
|
|
5519
5840
|
name: Cipherwake Trust Diff
|
|
5520
5841
|
|
|
5521
|
-
|
|
5522
|
-
pull_request:
|
|
5523
|
-
branches: [main]
|
|
5524
|
-
deployment_status:
|
|
5842
|
+
${triggerBlock}
|
|
5525
5843
|
|
|
5526
5844
|
permissions:
|
|
5527
5845
|
contents: read
|
|
@@ -5532,18 +5850,7 @@ permissions:
|
|
|
5532
5850
|
jobs:
|
|
5533
5851
|
trust-diff:
|
|
5534
5852
|
runs-on: ubuntu-latest
|
|
5535
|
-
|
|
5536
|
-
# PRs: github.event_name == "pull_request" — advisory diff for the change
|
|
5537
|
-
# Deployment: deployment_status.state == "success" — only after a deploy
|
|
5538
|
-
# actually succeeded; skips failed/error/pending events. Environment
|
|
5539
|
-
# filter on "production" / "Production" so preview deploys don't
|
|
5540
|
-
# trigger trust-diff against the prod domain (different surfaces).
|
|
5541
|
-
if: |
|
|
5542
|
-
github.event_name == 'pull_request' ||
|
|
5543
|
-
(github.event_name == 'deployment_status' &&
|
|
5544
|
-
github.event.deployment_status.state == 'success' &&
|
|
5545
|
-
(github.event.deployment.environment == 'production' ||
|
|
5546
|
-
github.event.deployment.environment == 'Production'))
|
|
5853
|
+
${jobGuard}
|
|
5547
5854
|
steps:
|
|
5548
5855
|
- name: Run Cipherwake Trust Diff
|
|
5549
5856
|
uses: cipherwakelabs/pqcheck@v4
|
|
@@ -5557,16 +5864,6 @@ jobs:
|
|
|
5557
5864
|
# OIDC token and meters per repo (100 calls/mo, no setup).
|
|
5558
5865
|
# If you want higher limits, link this repo to a paid Cipherwake
|
|
5559
5866
|
# account at https://cipherwake.io/account → Linked repos.
|
|
5560
|
-
#
|
|
5561
|
-
# If your platform does NOT emit deployment_status events (custom
|
|
5562
|
-
# CD scripts, S3-sync deploys, manual rollouts), replace the
|
|
5563
|
-
# deployment_status trigger above with:
|
|
5564
|
-
#
|
|
5565
|
-
# push:
|
|
5566
|
-
# branches: [main]
|
|
5567
|
-
#
|
|
5568
|
-
# AND add a delay/health-check step before this one so trust-diff
|
|
5569
|
-
# runs AFTER your deploy is live, not racing it.
|
|
5570
5867
|
`;
|
|
5571
5868
|
}
|
|
5572
5869
|
|
|
@@ -5599,14 +5896,25 @@ async function prompt(question) {
|
|
|
5599
5896
|
|
|
5600
5897
|
async function runDeployCheckCommand(args) {
|
|
5601
5898
|
const positional = args.filter((a) => !a.startsWith("-"));
|
|
5899
|
+
let forwarded = [...args];
|
|
5602
5900
|
if (positional.length === 0) {
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5901
|
+
// R96 (dogfood feedback #2) — no domain argument: fall back to the
|
|
5902
|
+
// `domain` field in .cipherwake.json (written by `pqcheck setup`/`init`)
|
|
5903
|
+
// so AI coders can run `npx pqcheck deploy-check --ai` without guessing
|
|
5904
|
+
// which domain this repo deploys to.
|
|
5905
|
+
const cfgDomain = await readCipherwakeConfigDomain();
|
|
5906
|
+
if (cfgDomain) {
|
|
5907
|
+
console.error(color("dim", ` ℹ no domain argument — using "${cfgDomain}" from .cipherwake.json`));
|
|
5908
|
+
forwarded = [cfgDomain, ...forwarded];
|
|
5909
|
+
} else {
|
|
5910
|
+
console.error(color("red", "error: pqcheck deploy-check requires a domain"));
|
|
5911
|
+
console.error(color("dim", "Usage: npx pqcheck deploy-check <domain> [--baseline last-scan|last-week|<ISO>] [--fail-on high|medium|low|any]"));
|
|
5912
|
+
console.error(color("dim", `Tip: run \`npx pqcheck setup --auto --domain <domain>\` once and the domain is saved to .cipherwake.json — after that, plain \`npx pqcheck deploy-check --ai\` works.`));
|
|
5913
|
+
process.exit(1);
|
|
5914
|
+
}
|
|
5606
5915
|
}
|
|
5607
5916
|
|
|
5608
5917
|
// Forward to trust-diff with deploy-tuned defaults if the user didn't specify.
|
|
5609
|
-
const forwarded = [...args];
|
|
5610
5918
|
if (!args.includes("--baseline")) forwarded.push("--baseline", "last-scan");
|
|
5611
5919
|
if (!args.includes("--fail-on")) forwarded.push("--fail-on", "high");
|
|
5612
5920
|
|
|
@@ -5620,7 +5928,296 @@ async function runDeployCheckCommand(args) {
|
|
|
5620
5928
|
console.log("");
|
|
5621
5929
|
}
|
|
5622
5930
|
|
|
5623
|
-
return runTrustDiffCommand(forwarded);
|
|
5931
|
+
return runTrustDiffCommand(forwarded, { deployCheck: true });
|
|
5932
|
+
}
|
|
5933
|
+
|
|
5934
|
+
// =============================================================================
|
|
5935
|
+
// `pqcheck last` — reuse the most recent gate result instead of re-scanning
|
|
5936
|
+
// =============================================================================
|
|
5937
|
+
// R96 (dogfood feedback #3). After CI already ran the deploy gate, an AI
|
|
5938
|
+
// coder following the protocol shouldn't burn a duplicate Trust Diff quota
|
|
5939
|
+
// call just to learn "the gate already passed." Two sources:
|
|
5940
|
+
//
|
|
5941
|
+
// pqcheck last [domain] — local state files written by every scan /
|
|
5942
|
+
// deploy-check: .cipherwake/last-status.json
|
|
5943
|
+
// (repo-local) else ~/.config/cipherwake/last-scan.json
|
|
5944
|
+
// pqcheck last --remote — the latest GitHub Actions run of the
|
|
5945
|
+
// cipherwake.yml workflow for this repo (the CI
|
|
5946
|
+
// gate installed by `pqcheck init`/`setup`).
|
|
5947
|
+
// Public GitHub API; GITHUB_TOKEN/GH_TOKEN used
|
|
5948
|
+
// when set. ZERO Trust Diff quota consumed.
|
|
5949
|
+
//
|
|
5950
|
+
// Honesty guards — a cached result can only bless an announce when it is
|
|
5951
|
+
// genuinely equivalent to running the check now:
|
|
5952
|
+
// • older than --max-age (default 60 min) → NOT reusable
|
|
5953
|
+
// • CI run for a different commit than local HEAD → NOT reusable
|
|
5954
|
+
// • CI still running / cancelled → NOT reusable
|
|
5955
|
+
// A failed CI run IS surfaced as review (conservative direction is safe).
|
|
5956
|
+
//
|
|
5957
|
+
// Exit codes (agent routing contract):
|
|
5958
|
+
// 0 = reusable result, gate passed — safe to skip the duplicate deploy-check
|
|
5959
|
+
// 1 = last result was review · 2 = block (fresh enough to trust as signal)
|
|
5960
|
+
// 3 = no reusable signal — run `npx pqcheck deploy-check --ai` instead
|
|
5961
|
+
// =============================================================================
|
|
5962
|
+
|
|
5963
|
+
async function runLastCommand(args) {
|
|
5964
|
+
const aiMode = parseAiMode(args);
|
|
5965
|
+
const remote = args.includes("--remote");
|
|
5966
|
+
const maxAgeRaw = parseFlag(args, "--max-age");
|
|
5967
|
+
const maxAgeMin = maxAgeRaw === null || maxAgeRaw === undefined ? 60 : Number(maxAgeRaw);
|
|
5968
|
+
if (!Number.isFinite(maxAgeMin) || maxAgeMin <= 0) {
|
|
5969
|
+
console.error(color("red", "error: --max-age requires a positive number of minutes"));
|
|
5970
|
+
process.exit(3);
|
|
5971
|
+
}
|
|
5972
|
+
const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
|
|
5973
|
+
const domainFilter = positional[0] ? normalizeDomain(positional[0]) : await readCipherwakeConfigDomain();
|
|
5974
|
+
|
|
5975
|
+
// Shared emitter: human lines + optional AI block + routing exit code.
|
|
5976
|
+
// exitCode 3 = "no reusable signal" — the block still says ship_decision=
|
|
5977
|
+
// review (fail-safe: agent must run the real check, never assume pass).
|
|
5978
|
+
const finish = ({ shipDecision, exitCode, fields, humanLines }) => {
|
|
5979
|
+
console.log("");
|
|
5980
|
+
for (const l of humanLines) console.log(l);
|
|
5981
|
+
if (aiMode) {
|
|
5982
|
+
console.log(formatAiFooterBlock({
|
|
5983
|
+
status: shipDecision,
|
|
5984
|
+
kind: "last",
|
|
5985
|
+
...fields,
|
|
5986
|
+
ship_decision: shipDecision,
|
|
5987
|
+
reusable: exitCode === 3 ? "false" : "true",
|
|
5988
|
+
max_age_minutes: maxAgeMin,
|
|
5989
|
+
advisory_only: "true",
|
|
5990
|
+
note: exitCode === 3
|
|
5991
|
+
? "No reusable result. Run: npx pqcheck deploy-check --ai"
|
|
5992
|
+
: "Cached gate result. Exit 0 = CI/last check passed and covers the current state; re-run deploy-check after any new deploy.",
|
|
5993
|
+
checked_at: new Date().toISOString(),
|
|
5994
|
+
}));
|
|
5995
|
+
}
|
|
5996
|
+
console.log("");
|
|
5997
|
+
process.exit(exitCode);
|
|
5998
|
+
};
|
|
5999
|
+
|
|
6000
|
+
// ---------------------------------------------------------------------------
|
|
6001
|
+
// --remote: latest cipherwake.yml GitHub Actions run for this repo
|
|
6002
|
+
// ---------------------------------------------------------------------------
|
|
6003
|
+
if (remote) {
|
|
6004
|
+
const { execFile } = await import("node:child_process");
|
|
6005
|
+
const git = (gitArgs) => new Promise((resolve) => {
|
|
6006
|
+
execFile("git", gitArgs, { timeout: 5000 }, (err, stdout) => resolve(err ? null : String(stdout).trim()));
|
|
6007
|
+
});
|
|
6008
|
+
const originUrl = await git(["remote", "get-url", "origin"]);
|
|
6009
|
+
if (!originUrl) {
|
|
6010
|
+
return finish({
|
|
6011
|
+
shipDecision: "review", exitCode: 3,
|
|
6012
|
+
fields: { source: "github_actions", top_issue: "no_git_origin" },
|
|
6013
|
+
humanLines: [color("red", " ✗ pqcheck last --remote needs a git repo with an `origin` remote"), color("dim", " Run inside the repo whose CI gate you want to read.")],
|
|
6014
|
+
});
|
|
6015
|
+
}
|
|
6016
|
+
const m = originUrl.match(/github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
|
|
6017
|
+
if (!m) {
|
|
6018
|
+
return finish({
|
|
6019
|
+
shipDecision: "review", exitCode: 3,
|
|
6020
|
+
fields: { source: "github_actions", top_issue: "origin_not_github" },
|
|
6021
|
+
humanLines: [color("red", ` ✗ --remote currently reads GitHub Actions only (origin: ${originUrl})`)],
|
|
6022
|
+
});
|
|
6023
|
+
}
|
|
6024
|
+
const owner = m[1];
|
|
6025
|
+
const repo = m[2];
|
|
6026
|
+
const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
6027
|
+
const localSha = await git(["rev-parse", "HEAD"]);
|
|
6028
|
+
|
|
6029
|
+
const branchParam = branch && branch !== "HEAD" ? `&branch=${encodeURIComponent(branch)}` : "";
|
|
6030
|
+
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/cipherwake.yml/runs?per_page=1${branchParam}`;
|
|
6031
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "";
|
|
6032
|
+
let resp;
|
|
6033
|
+
try {
|
|
6034
|
+
const ctrl = new AbortController();
|
|
6035
|
+
const tmr = setTimeout(() => ctrl.abort(), 10000);
|
|
6036
|
+
resp = await fetch(apiUrl, {
|
|
6037
|
+
headers: {
|
|
6038
|
+
accept: "application/vnd.github+json",
|
|
6039
|
+
"user-agent": `pqcheck/${VERSION}`,
|
|
6040
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
6041
|
+
},
|
|
6042
|
+
signal: ctrl.signal,
|
|
6043
|
+
});
|
|
6044
|
+
clearTimeout(tmr);
|
|
6045
|
+
} catch (err) {
|
|
6046
|
+
return finish({
|
|
6047
|
+
shipDecision: "review", exitCode: 3,
|
|
6048
|
+
fields: { source: "github_actions", top_issue: "github_api_unreachable" },
|
|
6049
|
+
humanLines: [color("red", ` ✗ GitHub API unreachable: ${err?.message || err}`)],
|
|
6050
|
+
});
|
|
6051
|
+
}
|
|
6052
|
+
if (resp.status === 404) {
|
|
6053
|
+
return finish({
|
|
6054
|
+
shipDecision: "review", exitCode: 3,
|
|
6055
|
+
fields: { source: "github_actions", top_issue: "no_cipherwake_workflow", repo: `${owner}/${repo}` },
|
|
6056
|
+
humanLines: [
|
|
6057
|
+
color("yellow", ` ⊝ ${owner}/${repo} has no cipherwake.yml workflow on GitHub`),
|
|
6058
|
+
color("dim", " Install the CI gate: npx pqcheck init --domain <domain> (then commit + push)"),
|
|
6059
|
+
],
|
|
6060
|
+
});
|
|
6061
|
+
}
|
|
6062
|
+
if (!resp.ok) {
|
|
6063
|
+
return finish({
|
|
6064
|
+
shipDecision: "review", exitCode: 3,
|
|
6065
|
+
fields: { source: "github_actions", top_issue: `github_api_http_${resp.status}` },
|
|
6066
|
+
humanLines: [
|
|
6067
|
+
color("red", ` ✗ GitHub API returned HTTP ${resp.status}`),
|
|
6068
|
+
...(resp.status === 403 && !token ? [color("dim", " Likely the anonymous rate limit (60/hr per IP) or a private repo — set GITHUB_TOKEN and retry.")] : []),
|
|
6069
|
+
],
|
|
6070
|
+
});
|
|
6071
|
+
}
|
|
6072
|
+
const data = await resp.json().catch(() => null);
|
|
6073
|
+
const run = data?.workflow_runs?.[0];
|
|
6074
|
+
if (!run) {
|
|
6075
|
+
return finish({
|
|
6076
|
+
shipDecision: "review", exitCode: 3,
|
|
6077
|
+
fields: { source: "github_actions", top_issue: "no_workflow_runs", repo: `${owner}/${repo}` },
|
|
6078
|
+
humanLines: [color("yellow", ` ⊝ cipherwake.yml exists but has no runs${branchParam ? ` on branch ${branch}` : ""} yet — push to trigger one`)],
|
|
6079
|
+
});
|
|
6080
|
+
}
|
|
6081
|
+
|
|
6082
|
+
const ranAt = Date.parse(run.updated_at || run.run_started_at || "");
|
|
6083
|
+
const ageMin = Number.isFinite(ranAt) ? Math.round((Date.now() - ranAt) / 60000) : null;
|
|
6084
|
+
const stale = ageMin === null || ageMin > maxAgeMin;
|
|
6085
|
+
const shaMatch = !!(localSha && run.head_sha && run.head_sha === localSha);
|
|
6086
|
+
const shortSha = String(run.head_sha || "").slice(0, 7);
|
|
6087
|
+
const baseFields = {
|
|
6088
|
+
source: "github_actions",
|
|
6089
|
+
...(domainFilter ? { domain: domainFilter } : {}),
|
|
6090
|
+
repo: `${owner}/${repo}`,
|
|
6091
|
+
ci_status: run.status,
|
|
6092
|
+
ci_conclusion: run.conclusion || "",
|
|
6093
|
+
head_sha: run.head_sha || "",
|
|
6094
|
+
head_sha_match: shaMatch ? "true" : "false",
|
|
6095
|
+
age_minutes: ageMin ?? "unknown",
|
|
6096
|
+
stale: stale ? "true" : "false",
|
|
6097
|
+
run_url: run.html_url || "",
|
|
6098
|
+
};
|
|
6099
|
+
const headerLines = [
|
|
6100
|
+
` ${color("bold", "◆ Cipherwake — last CI gate result")} ${color("dim", `(${owner}/${repo} · cipherwake.yml)`)}`,
|
|
6101
|
+
` ${color("dim", `Run: ${run.status}${run.conclusion ? ` / ${run.conclusion}` : ""} · commit ${shortSha}${shaMatch ? " (matches local HEAD)" : localSha ? ` (local HEAD is ${localSha.slice(0, 7)} — DIFFERENT)` : ""} · ${ageMin ?? "?"}m ago`)}`,
|
|
6102
|
+
` ${color("dim", `URL: ${run.html_url || "?"}`)}`,
|
|
6103
|
+
];
|
|
6104
|
+
|
|
6105
|
+
if (run.status !== "completed") {
|
|
6106
|
+
return finish({
|
|
6107
|
+
shipDecision: "review", exitCode: 3,
|
|
6108
|
+
fields: { ...baseFields, top_issue: "ci_run_in_progress" },
|
|
6109
|
+
humanLines: [...headerLines, color("yellow", " ⚠ CI run still in progress — wait for it, or run deploy-check directly")],
|
|
6110
|
+
});
|
|
6111
|
+
}
|
|
6112
|
+
if (run.conclusion === "failure") {
|
|
6113
|
+
// Conservative direction: a failed gate is a trustworthy "do not
|
|
6114
|
+
// announce" signal even when stale or for a different commit.
|
|
6115
|
+
return finish({
|
|
6116
|
+
shipDecision: "review", exitCode: 1,
|
|
6117
|
+
fields: { ...baseFields, top_issue: "ci_gate_failed" },
|
|
6118
|
+
humanLines: [...headerLines, color("yellow", " ⚠ REVIEW — the CI deploy gate FAILED on its last run. Inspect the run before announcing anything.")],
|
|
6119
|
+
});
|
|
6120
|
+
}
|
|
6121
|
+
if (run.conclusion !== "success") {
|
|
6122
|
+
return finish({
|
|
6123
|
+
shipDecision: "review", exitCode: 3,
|
|
6124
|
+
fields: { ...baseFields, top_issue: `ci_run_${run.conclusion || "unknown"}` },
|
|
6125
|
+
humanLines: [...headerLines, color("yellow", ` ⊝ CI run ended "${run.conclusion}" — no reusable verdict`)],
|
|
6126
|
+
});
|
|
6127
|
+
}
|
|
6128
|
+
if (stale) {
|
|
6129
|
+
return finish({
|
|
6130
|
+
shipDecision: "review", exitCode: 3,
|
|
6131
|
+
fields: { ...baseFields, top_issue: "ci_result_stale" },
|
|
6132
|
+
humanLines: [...headerLines, color("yellow", ` ⊝ CI gate passed but ${ageMin ?? "?"}m ago exceeds --max-age ${maxAgeMin}m — run a fresh deploy-check`)],
|
|
6133
|
+
});
|
|
6134
|
+
}
|
|
6135
|
+
if (!shaMatch) {
|
|
6136
|
+
return finish({
|
|
6137
|
+
shipDecision: "review", exitCode: 3,
|
|
6138
|
+
fields: { ...baseFields, top_issue: "ci_result_for_different_commit" },
|
|
6139
|
+
humanLines: [...headerLines, color("yellow", " ⊝ CI gate passed, but for a different commit than your local HEAD — its verdict doesn't cover your changes")],
|
|
6140
|
+
});
|
|
6141
|
+
}
|
|
6142
|
+
return finish({
|
|
6143
|
+
shipDecision: "pass", exitCode: 0,
|
|
6144
|
+
fields: { ...baseFields, top_issue: "none" },
|
|
6145
|
+
humanLines: [...headerLines, color("green", " ✓ PASS — CI deploy gate passed on this exact commit within the freshness window. Safe to reuse; no duplicate deploy-check needed.")],
|
|
6146
|
+
});
|
|
6147
|
+
}
|
|
6148
|
+
|
|
6149
|
+
// ---------------------------------------------------------------------------
|
|
6150
|
+
// Local: state files written by every scan / deploy-check
|
|
6151
|
+
// ---------------------------------------------------------------------------
|
|
6152
|
+
const fs = await import("node:fs/promises");
|
|
6153
|
+
const path = await import("node:path");
|
|
6154
|
+
const os = await import("node:os");
|
|
6155
|
+
const candidates = [
|
|
6156
|
+
{ source: "repo_state", file: path.join(process.cwd(), ".cipherwake", "last-status.json") },
|
|
6157
|
+
{ source: "user_state", file: path.join(os.homedir(), ".config", "cipherwake", "last-scan.json") },
|
|
6158
|
+
];
|
|
6159
|
+
let state = null;
|
|
6160
|
+
let source = null;
|
|
6161
|
+
let stateFile = null;
|
|
6162
|
+
for (const c of candidates) {
|
|
6163
|
+
try {
|
|
6164
|
+
const parsed = JSON.parse(await fs.readFile(c.file, "utf8"));
|
|
6165
|
+
if (domainFilter && normalizeDomain(String(parsed?.domain || "")) !== domainFilter) continue;
|
|
6166
|
+
state = parsed;
|
|
6167
|
+
source = c.source;
|
|
6168
|
+
stateFile = c.file;
|
|
6169
|
+
break;
|
|
6170
|
+
} catch { /* missing or malformed — try next source */ }
|
|
6171
|
+
}
|
|
6172
|
+
|
|
6173
|
+
if (!state) {
|
|
6174
|
+
return finish({
|
|
6175
|
+
shipDecision: "review", exitCode: 3,
|
|
6176
|
+
fields: { source: "local_state", ...(domainFilter ? { domain: domainFilter } : {}), top_issue: "no_recent_result" },
|
|
6177
|
+
humanLines: [
|
|
6178
|
+
` ${color("bold", "◆ Cipherwake — last result")}`,
|
|
6179
|
+
color("yellow", ` ⊝ no local result found${domainFilter ? ` for ${domainFilter}` : ""}`),
|
|
6180
|
+
color("dim", " Run: npx pqcheck deploy-check --ai (or `pqcheck last --remote` to read the CI gate)"),
|
|
6181
|
+
],
|
|
6182
|
+
});
|
|
6183
|
+
}
|
|
6184
|
+
|
|
6185
|
+
const writtenAt = Date.parse(state.written_at || "");
|
|
6186
|
+
const ageMin = Number.isFinite(writtenAt) ? Math.round((Date.now() - writtenAt) / 60000) : null;
|
|
6187
|
+
const stale = ageMin === null || ageMin > maxAgeMin;
|
|
6188
|
+
const storedShip = ["pass", "review", "block"].includes(state.ship_decision) ? state.ship_decision : "review";
|
|
6189
|
+
const fields = {
|
|
6190
|
+
source,
|
|
6191
|
+
domain: state.domain || domainFilter || "",
|
|
6192
|
+
last_kind: state.kind || "",
|
|
6193
|
+
last_ship_decision: storedShip,
|
|
6194
|
+
...(typeof state.score === "number" ? { dbr: state.score.toFixed(1) } : {}),
|
|
6195
|
+
...(state.grade ? { grade: state.grade } : {}),
|
|
6196
|
+
...(state.top_issue ? { last_top_issue: state.top_issue } : {}),
|
|
6197
|
+
age_minutes: ageMin ?? "unknown",
|
|
6198
|
+
stale: stale ? "true" : "false",
|
|
6199
|
+
state_file: stateFile,
|
|
6200
|
+
};
|
|
6201
|
+
const headerLines = [
|
|
6202
|
+
` ${color("bold", "◆ Cipherwake — last result")} ${color("dim", `(${source === "repo_state" ? ".cipherwake/last-status.json" : "~/.config/cipherwake/last-scan.json"})`)}`,
|
|
6203
|
+
` ${color("dim", `Domain: ${state.domain || "?"} · kind: ${state.kind || "?"} · ship_decision: ${storedShip} · ${ageMin ?? "?"}m ago`)}`,
|
|
6204
|
+
];
|
|
6205
|
+
if (stale) {
|
|
6206
|
+
return finish({
|
|
6207
|
+
shipDecision: "review", exitCode: 3,
|
|
6208
|
+
fields: { ...fields, top_issue: "stale_result" },
|
|
6209
|
+
humanLines: [...headerLines, color("yellow", ` ⊝ result is ${ageMin ?? "?"}m old — exceeds --max-age ${maxAgeMin}m. Run a fresh deploy-check.`)],
|
|
6210
|
+
});
|
|
6211
|
+
}
|
|
6212
|
+
const marker = storedShip === "pass" ? color("green", " ✓ PASS — last check passed within the freshness window.")
|
|
6213
|
+
: storedShip === "review" ? color("yellow", " ⚠ REVIEW — last check flagged a change. Surface it to the user before announcing.")
|
|
6214
|
+
: color("red", " ✗ BLOCK — last check returned block. Do not announce.");
|
|
6215
|
+
return finish({
|
|
6216
|
+
shipDecision: storedShip,
|
|
6217
|
+
exitCode: shipDecisionExitCode(storedShip),
|
|
6218
|
+
fields: { ...fields, top_issue: state.top_issue || "none" },
|
|
6219
|
+
humanLines: [...headerLines, marker],
|
|
6220
|
+
});
|
|
5624
6221
|
}
|
|
5625
6222
|
|
|
5626
6223
|
// =============================================================================
|
|
@@ -6158,7 +6755,7 @@ async function runOnboardCommand(args) {
|
|
|
6158
6755
|
console.log(` ${color("dim", "$")} git push`);
|
|
6159
6756
|
console.log("");
|
|
6160
6757
|
console.log(` ${color("dim", "2.")} ${color("bold", "Open a PR")}`);
|
|
6161
|
-
console.log(` ${color("dim", "Cipherwake will comment inline within ~60s of the workflow firing. The action uses GitHub OIDC to meter usage per repo (Free =
|
|
6758
|
+
console.log(` ${color("dim", "Cipherwake will comment inline within ~60s of the workflow firing. The action uses GitHub OIDC to meter usage per repo (Free = 100 calls/mo).")}`);
|
|
6162
6759
|
console.log("");
|
|
6163
6760
|
// R48 (post-R47 review MAJOR #6): the /account → "Linked repos" UI is
|
|
6164
6761
|
// not yet shipped (out of R47 scope). Pointing users to a nonexistent
|
|
@@ -6190,48 +6787,6 @@ function buildReleaseChecklistMarkdown(domain) {
|
|
|
6190
6787
|
return renderReleaseChecklist(domain, { generator: "onboard" });
|
|
6191
6788
|
}
|
|
6192
6789
|
|
|
6193
|
-
// Cross-platform browser launcher. Returns true if a launcher binary
|
|
6194
|
-
// dispatched successfully; false if no launcher is available (e.g. headless
|
|
6195
|
-
// server, sandboxed CI, broken xdg-open config).
|
|
6196
|
-
//
|
|
6197
|
-
// R41 fix #2 (locked 2026-05-16): use exit-event detection + longer timeout
|
|
6198
|
-
// so we don't falsely claim "(opened in your browser)" when xdg-open is
|
|
6199
|
-
// installed but the launcher exits non-zero (no graphical session, no
|
|
6200
|
-
// MIME handler). Previously a flat 200ms timeout resolved true even when
|
|
6201
|
-
// the launcher exited 3 because no display was available.
|
|
6202
|
-
async function tryOpenBrowser(url) {
|
|
6203
|
-
if (process.env.CI || process.env.CIPHERWAKE_NO_BROWSER) return false;
|
|
6204
|
-
const { spawn } = await import("node:child_process");
|
|
6205
|
-
const platform = process.platform;
|
|
6206
|
-
let cmd, cmdArgs;
|
|
6207
|
-
if (platform === "darwin") {
|
|
6208
|
-
cmd = "open"; cmdArgs = [url];
|
|
6209
|
-
} else if (platform === "win32") {
|
|
6210
|
-
cmd = "cmd"; cmdArgs = ["/c", "start", "", url];
|
|
6211
|
-
} else {
|
|
6212
|
-
cmd = "xdg-open"; cmdArgs = [url];
|
|
6213
|
-
}
|
|
6214
|
-
return await new Promise((resolve) => {
|
|
6215
|
-
let settled = false;
|
|
6216
|
-
let p;
|
|
6217
|
-
try {
|
|
6218
|
-
p = spawn(cmd, cmdArgs, { stdio: "ignore", detached: true });
|
|
6219
|
-
} catch {
|
|
6220
|
-
resolve(false);
|
|
6221
|
-
return;
|
|
6222
|
-
}
|
|
6223
|
-
p.on("error", () => { if (!settled) { settled = true; resolve(false); } });
|
|
6224
|
-
p.on("exit", (code) => { if (!settled) { settled = true; resolve(code === 0); } });
|
|
6225
|
-
p.unref();
|
|
6226
|
-
// Belt-and-suspenders: if the launcher takes >1s to exit AND no error
|
|
6227
|
-
// event has fired, assume it dispatched and went detached (open on
|
|
6228
|
-
// macOS does this — returns after AppleScript-asking Finder/Safari).
|
|
6229
|
-
setTimeout(() => {
|
|
6230
|
-
if (!settled) { settled = true; resolve(true); }
|
|
6231
|
-
}, 1000);
|
|
6232
|
-
});
|
|
6233
|
-
}
|
|
6234
|
-
|
|
6235
6790
|
// =============================================================================
|
|
6236
6791
|
// `pqcheck guard --domain X -- <deploy command>` — wrapper command
|
|
6237
6792
|
// =============================================================================
|
|
@@ -6257,15 +6812,24 @@ async function runGuardCommand(args) {
|
|
|
6257
6812
|
const ourArgs = sepIdx >= 0 ? args.slice(0, sepIdx) : args;
|
|
6258
6813
|
const deployCmd = sepIdx >= 0 ? args.slice(sepIdx + 1) : [];
|
|
6259
6814
|
|
|
6260
|
-
|
|
6815
|
+
let domain = parseFlag(ourArgs, "--domain");
|
|
6261
6816
|
const gateMode = parseFlag(ourArgs, "--gate-mode") || "balanced";
|
|
6262
6817
|
const bypassReason = parseFlag(ourArgs, "--bypass");
|
|
6263
6818
|
const noPostCheck = ourArgs.includes("--no-post-check");
|
|
6264
6819
|
|
|
6820
|
+
if (!domain) {
|
|
6821
|
+
// R96 (dogfood feedback #2) — fall back to the `domain` field in
|
|
6822
|
+
// .cipherwake.json (written by `pqcheck setup`/`init`).
|
|
6823
|
+
domain = await readCipherwakeConfigDomain();
|
|
6824
|
+
if (domain) {
|
|
6825
|
+
console.error(color("dim", ` ℹ no --domain flag — using "${domain}" from .cipherwake.json`));
|
|
6826
|
+
}
|
|
6827
|
+
}
|
|
6265
6828
|
if (!domain) {
|
|
6266
6829
|
console.error(color("red", "error: pqcheck guard requires --domain"));
|
|
6267
6830
|
console.error(color("dim", "Usage: npx pqcheck guard --domain example.com -- <deploy command>"));
|
|
6268
6831
|
console.error(color("dim", "Example: npx pqcheck guard --domain example.com -- vercel deploy --prod"));
|
|
6832
|
+
console.error(color("dim", "Tip: run `npx pqcheck setup --auto --domain <domain>` once and the domain is saved to .cipherwake.json — after that, `npx pqcheck guard -- <deploy-cmd>` works without the flag."));
|
|
6269
6833
|
process.exit(3);
|
|
6270
6834
|
}
|
|
6271
6835
|
if (deployCmd.length === 0) {
|
|
@@ -6342,7 +6906,15 @@ async function runGuardCommand(args) {
|
|
|
6342
6906
|
console.log(color("bold", ` Pre-deploy check returned: ship_decision=${shipDecision}`));
|
|
6343
6907
|
|
|
6344
6908
|
// Decide what to do.
|
|
6345
|
-
if (
|
|
6909
|
+
if (gateMode === "advisory" && shipDecision !== "pass") {
|
|
6910
|
+
// R96 — advisory mode promises "deploy never blocked", but --fail-on
|
|
6911
|
+
// none only downgrades FINDINGS-driven decisions; an assertion-driven
|
|
6912
|
+
// block (deploy unreachable, WAF) still arrived here as block/review
|
|
6913
|
+
// and hit process.exit below. Advisory means advisory: warn + proceed.
|
|
6914
|
+
console.log(color("yellow", ` ⚠ Advisory mode: ship_decision=${shipDecision} noted — proceeding with deploy anyway.`));
|
|
6915
|
+
console.log(color("dim", " Review the findings above. Advisory mode never blocks the deploy."));
|
|
6916
|
+
console.log("");
|
|
6917
|
+
} else if (shipDecision === "pass") {
|
|
6346
6918
|
console.log(color("green", " ✓ Posture stable — running deploy command."));
|
|
6347
6919
|
console.log("");
|
|
6348
6920
|
} else if (shipDecision === "review") {
|
|
@@ -6465,6 +7037,15 @@ Cipherwake deploy check and route on the result.
|
|
|
6465
7037
|
Reference: https://cipherwake.io/methodology/ai-coder-protocol
|
|
6466
7038
|
`;
|
|
6467
7039
|
|
|
7040
|
+
// R96 (dogfood feedback #2) — fill the <your-domain> placeholders with the
|
|
7041
|
+
// real monitored domain when known. A protocol block reading `deploy-check
|
|
7042
|
+
// cipherwake.io --ai` is directly executable by the AI coder; the literal
|
|
7043
|
+
// `<your-domain>` placeholder forced every AI session to guess or ask.
|
|
7044
|
+
function renderProtocolText(domain) {
|
|
7045
|
+
if (!domain) return AI_CODER_PROTOCOL_TEXT;
|
|
7046
|
+
return AI_CODER_PROTOCOL_TEXT.replaceAll("<your-domain>", domain);
|
|
7047
|
+
}
|
|
7048
|
+
|
|
6468
7049
|
async function runProtocolCommand(args) {
|
|
6469
7050
|
const sub = args[0];
|
|
6470
7051
|
if (sub !== "install") {
|
|
@@ -6511,6 +7092,21 @@ async function runProtocolCommand(args) {
|
|
|
6511
7092
|
process.exit(3);
|
|
6512
7093
|
}
|
|
6513
7094
|
|
|
7095
|
+
// R96 — resolve the monitored domain (--domain flag, else .cipherwake.json)
|
|
7096
|
+
// so the installed protocol text carries the real domain instead of the
|
|
7097
|
+
// <your-domain> placeholder.
|
|
7098
|
+
let protocolDomain = null;
|
|
7099
|
+
const domainFlag = parseFlag(args, "--domain");
|
|
7100
|
+
if (domainFlag) {
|
|
7101
|
+
const d = normalizeDomain(domainFlag);
|
|
7102
|
+
if (isValidDomain(d)) {
|
|
7103
|
+
protocolDomain = d;
|
|
7104
|
+
} else {
|
|
7105
|
+
console.error(color("yellow", `⚠ --domain "${domainFlag}" is not a valid hostname — protocol will keep the <your-domain> placeholder`));
|
|
7106
|
+
}
|
|
7107
|
+
}
|
|
7108
|
+
if (!protocolDomain) protocolDomain = await readCipherwakeConfigDomain();
|
|
7109
|
+
|
|
6514
7110
|
// Detect candidate files across major AI coders that:
|
|
6515
7111
|
// (a) read an instructions file at session start, AND
|
|
6516
7112
|
// (b) can run shell commands (so they can actually invoke pqcheck).
|
|
@@ -6548,6 +7144,10 @@ async function runProtocolCommand(args) {
|
|
|
6548
7144
|
if (consentPhrase) console.log(color("dim", `Consent phrase: "${consentPhrase}"`));
|
|
6549
7145
|
console.log("");
|
|
6550
7146
|
}
|
|
7147
|
+
if (protocolDomain) {
|
|
7148
|
+
console.log(color("dim", `Domain: ${protocolDomain} — the protocol's <your-domain> placeholders will be filled in.`));
|
|
7149
|
+
console.log("");
|
|
7150
|
+
}
|
|
6551
7151
|
console.log("Here's what would be added:");
|
|
6552
7152
|
console.log("");
|
|
6553
7153
|
if (detected.length === 0) {
|
|
@@ -6569,7 +7169,17 @@ async function runProtocolCommand(args) {
|
|
|
6569
7169
|
}
|
|
6570
7170
|
detected.push({ label: useGlobal ? "Claude Code (will create global)" : "Claude Code (will create project)", path: fallbackPath });
|
|
6571
7171
|
}
|
|
7172
|
+
// R96 — Claude Code reads BOTH ~/.claude/CLAUDE.md and ./CLAUDE.md, so
|
|
7173
|
+
// installing to both duplicates ~40 lines in every session's context.
|
|
7174
|
+
// When the global file is a target, the project CLAUDE.md is skipped.
|
|
7175
|
+
const globalClaudeMdPath = path.join(os.homedir(), ".claude", "CLAUDE.md");
|
|
7176
|
+
const projectClaudeMdPath = path.join(process.cwd(), "CLAUDE.md");
|
|
7177
|
+
const globalClaudeMdDetected = detected.some((d) => d.path === globalClaudeMdPath);
|
|
6572
7178
|
for (const d of detected) {
|
|
7179
|
+
if (globalClaudeMdDetected && d.path === projectClaudeMdPath) {
|
|
7180
|
+
console.log(` • ${color("dim", `Skip ${d.path} — covered by the global Claude Code install (Claude Code reads both files; one copy is enough)`)}`);
|
|
7181
|
+
continue;
|
|
7182
|
+
}
|
|
6573
7183
|
console.log(` • Append a ~30-line "## Pre-deploy verification with Cipherwake" section to ${color("bold", d.path)}`);
|
|
6574
7184
|
console.log(` ${color("dim", `(${d.label} — existing content preserved)`)}`);
|
|
6575
7185
|
}
|
|
@@ -6578,7 +7188,7 @@ async function runProtocolCommand(args) {
|
|
|
6578
7188
|
// --manual → print only, no install
|
|
6579
7189
|
if (manualFlag) {
|
|
6580
7190
|
console.log(color("bold", "── Cipherwake AI Coder Protocol — paste this into your AI coder's instructions ──"));
|
|
6581
|
-
console.log(
|
|
7191
|
+
console.log(renderProtocolText(protocolDomain));
|
|
6582
7192
|
console.log(color("bold", "── End of protocol ──"));
|
|
6583
7193
|
console.log("");
|
|
6584
7194
|
console.log(color("dim", `For target files, see: ${detected.map(d => d.path).join(", ")}`));
|
|
@@ -6593,6 +7203,7 @@ async function runProtocolCommand(args) {
|
|
|
6593
7203
|
mode: "auto-flag",
|
|
6594
7204
|
consent_phrase: consentPhrase,
|
|
6595
7205
|
invoked_by: invokedBy,
|
|
7206
|
+
domain: protocolDomain,
|
|
6596
7207
|
fs, path, os,
|
|
6597
7208
|
});
|
|
6598
7209
|
}
|
|
@@ -6631,7 +7242,7 @@ async function runProtocolCommand(args) {
|
|
|
6631
7242
|
if (choice === "m" || choice === "manual") {
|
|
6632
7243
|
console.log("");
|
|
6633
7244
|
console.log(color("bold", "── Cipherwake AI Coder Protocol — paste this into your AI coder's instructions ──"));
|
|
6634
|
-
console.log(
|
|
7245
|
+
console.log(renderProtocolText(protocolDomain));
|
|
6635
7246
|
console.log(color("bold", "── End of protocol ──"));
|
|
6636
7247
|
console.log("");
|
|
6637
7248
|
console.log(color("dim", `For target files, see: ${detected.map(d => d.path).join(", ")}`));
|
|
@@ -6643,6 +7254,7 @@ async function runProtocolCommand(args) {
|
|
|
6643
7254
|
mode: "interactive",
|
|
6644
7255
|
consent_phrase: "user typed [a] at the install prompt",
|
|
6645
7256
|
invoked_by: "human-at-terminal",
|
|
7257
|
+
domain: protocolDomain,
|
|
6646
7258
|
fs, path, os,
|
|
6647
7259
|
});
|
|
6648
7260
|
}
|
|
@@ -6655,8 +7267,22 @@ async function runProtocolCommand(args) {
|
|
|
6655
7267
|
// Writes the protocol to each detected file and records the audit trail.
|
|
6656
7268
|
async function performAutoInstall(detected, opts) {
|
|
6657
7269
|
const { fs, path, os } = opts;
|
|
7270
|
+
const protocolText = renderProtocolText(opts.domain || null);
|
|
6658
7271
|
const results = [];
|
|
7272
|
+
// R96 — cross-file dedupe: Claude Code reads BOTH ~/.claude/CLAUDE.md and
|
|
7273
|
+
// ./CLAUDE.md. Once the global file carries the protocol (pre-existing or
|
|
7274
|
+
// installed earlier in this loop — global is first in the candidates
|
|
7275
|
+
// order), the project CLAUDE.md is skipped instead of duplicating ~40
|
|
7276
|
+
// lines into every session's context.
|
|
7277
|
+
const globalClaudeMdPath = path.join(os.homedir(), ".claude", "CLAUDE.md");
|
|
7278
|
+
const projectClaudeMdPath = path.join(process.cwd(), "CLAUDE.md");
|
|
7279
|
+
let globalClaudeMdCovered = false;
|
|
6659
7280
|
for (const d of detected) {
|
|
7281
|
+
if (d.path === projectClaudeMdPath && globalClaudeMdCovered) {
|
|
7282
|
+
console.log(color("dim", ` ⊝ ${d.path} — covered by the global Claude Code install (~/.claude/CLAUDE.md), skipping.`));
|
|
7283
|
+
results.push({ path: d.path, label: d.label, status: "skipped-covered-by-global" });
|
|
7284
|
+
continue;
|
|
7285
|
+
}
|
|
6660
7286
|
try {
|
|
6661
7287
|
await fs.mkdir(path.dirname(d.path), { recursive: true });
|
|
6662
7288
|
let existing = "";
|
|
@@ -6664,12 +7290,14 @@ async function performAutoInstall(detected, opts) {
|
|
|
6664
7290
|
if (existing.includes("## Pre-deploy verification with Cipherwake")) {
|
|
6665
7291
|
console.log(color("dim", ` ⊝ ${d.path} — already contains the protocol, skipping.`));
|
|
6666
7292
|
results.push({ path: d.path, label: d.label, status: "skipped-already-present" });
|
|
7293
|
+
if (d.path === globalClaudeMdPath) globalClaudeMdCovered = true;
|
|
6667
7294
|
continue;
|
|
6668
7295
|
}
|
|
6669
|
-
const newContent = existing + "\n" +
|
|
7296
|
+
const newContent = existing + "\n" + protocolText + "\n";
|
|
6670
7297
|
await fs.writeFile(d.path, newContent, "utf8");
|
|
6671
|
-
console.log(color("green", ` ✓ ${d.path} — protocol appended (${
|
|
7298
|
+
console.log(color("green", ` ✓ ${d.path} — protocol appended (${protocolText.split("\n").length} lines)`));
|
|
6672
7299
|
results.push({ path: d.path, label: d.label, status: "installed" });
|
|
7300
|
+
if (d.path === globalClaudeMdPath) globalClaudeMdCovered = true;
|
|
6673
7301
|
} catch (err) {
|
|
6674
7302
|
console.log(color("red", ` ✗ ${d.path} — failed: ${err.message}`));
|
|
6675
7303
|
results.push({ path: d.path, label: d.label, status: "failed", error: String(err?.message || err) });
|
|
@@ -6921,6 +7549,12 @@ async function runSetupCommand(args) {
|
|
|
6921
7549
|
console.log("");
|
|
6922
7550
|
console.log(color("bold", "Files this install would touch:"));
|
|
6923
7551
|
const planEntries = [];
|
|
7552
|
+
{
|
|
7553
|
+
const cfgPath = path.join(process.cwd(), ".cipherwake.json");
|
|
7554
|
+
let cfgExists = false;
|
|
7555
|
+
try { await fs.access(cfgPath); cfgExists = true; } catch { /* */ }
|
|
7556
|
+
planEntries.push({ what: `.cipherwake.json domain default (${domain})`, to: cfgPath, op: cfgExists ? "merge domain field" : "create" });
|
|
7557
|
+
}
|
|
6924
7558
|
if (!skipWorkflow) planEntries.push({ what: "GitHub Action workflow", to: path.join(process.cwd(), ".github", "workflows", "cipherwake.yml"), op: "create" });
|
|
6925
7559
|
if (!skipProtocol) {
|
|
6926
7560
|
const candidates = [
|
|
@@ -6931,10 +7565,15 @@ async function runSetupCommand(args) {
|
|
|
6931
7565
|
{ label: "Aider", path: path.join(process.cwd(), ".aider.conf.yml") },
|
|
6932
7566
|
{ label: "AGENTS.md", path: path.join(process.cwd(), "AGENTS.md") },
|
|
6933
7567
|
];
|
|
7568
|
+
let globalClaudeMdExists = false;
|
|
7569
|
+
try { await fs.access(path.join(os.homedir(), ".claude", "CLAUDE.md")); globalClaudeMdExists = true; } catch { /* */ }
|
|
6934
7570
|
for (const c of candidates) {
|
|
6935
7571
|
let exists = false;
|
|
6936
7572
|
try { await fs.access(c.path); exists = true; } catch { /* */ }
|
|
6937
|
-
|
|
7573
|
+
let op = exists ? "append-markered" : "skip (file not present)";
|
|
7574
|
+
// R96 — Claude Code reads both global + project CLAUDE.md; one copy is enough.
|
|
7575
|
+
if (c.label === "Claude Code (project)" && globalClaudeMdExists) op = "skip (covered by global CLAUDE.md)";
|
|
7576
|
+
planEntries.push({ what: `AI Coder Protocol — ${c.label}`, to: c.path, op });
|
|
6938
7577
|
}
|
|
6939
7578
|
}
|
|
6940
7579
|
if (!skipHook) planEntries.push({ what: "git pre-push hook", to: path.join(process.cwd(), ".git", "hooks", "pre-push"), op: "create (if git repo)" });
|
|
@@ -7024,6 +7663,40 @@ async function runSetupCommand(args) {
|
|
|
7024
7663
|
|
|
7025
7664
|
const installSummary = [];
|
|
7026
7665
|
|
|
7666
|
+
// -------------------------------------------------------------------------
|
|
7667
|
+
// Component 0: persist the domain to ./.cipherwake.json (R96, feedback #2)
|
|
7668
|
+
// -------------------------------------------------------------------------
|
|
7669
|
+
// Once the domain lives in the config, `pqcheck deploy-check --ai` and
|
|
7670
|
+
// `pqcheck guard -- <cmd>` work without a domain argument — the AI coder
|
|
7671
|
+
// never has to guess which domain this repo deploys to.
|
|
7672
|
+
try {
|
|
7673
|
+
const cfgResult = await writeDomainToCipherwakeConfig(domain);
|
|
7674
|
+
switch (cfgResult.status) {
|
|
7675
|
+
case "created":
|
|
7676
|
+
console.log(color("green", ` ✓ wrote .cipherwake.json (domain: ${domain} — deploy-check/guard can now omit the domain argument)`));
|
|
7677
|
+
break;
|
|
7678
|
+
case "merged":
|
|
7679
|
+
console.log(color("green", ` ✓ added "domain": "${domain}" to .cipherwake.json`));
|
|
7680
|
+
break;
|
|
7681
|
+
case "updated":
|
|
7682
|
+
console.log(color("green", ` ✓ updated .cipherwake.json domain: ${cfgResult.prior} → ${domain}`));
|
|
7683
|
+
break;
|
|
7684
|
+
case "already-set":
|
|
7685
|
+
console.log(color("dim", ` ⊝ .cipherwake.json already sets domain ${domain} — skipping`));
|
|
7686
|
+
break;
|
|
7687
|
+
case "skipped-malformed":
|
|
7688
|
+
console.log(color("yellow", ` ⚠ .cipherwake.json has malformed JSON — not writing domain (fix the file, then re-run, or add "domain": "${domain}" by hand)`));
|
|
7689
|
+
break;
|
|
7690
|
+
case "skipped-unexpected-shape":
|
|
7691
|
+
console.log(color("yellow", ` ⚠ .cipherwake.json is not a JSON object — not writing domain`));
|
|
7692
|
+
break;
|
|
7693
|
+
}
|
|
7694
|
+
installSummary.push({ component: ".cipherwake.json domain", path: cfgResult.path, status: cfgResult.status });
|
|
7695
|
+
} catch (err) {
|
|
7696
|
+
console.log(color("red", ` ✗ .cipherwake.json domain write failed: ${err.message}`));
|
|
7697
|
+
installSummary.push({ component: ".cipherwake.json domain", status: "failed", error: String(err?.message || err) });
|
|
7698
|
+
}
|
|
7699
|
+
|
|
7027
7700
|
// -------------------------------------------------------------------------
|
|
7028
7701
|
// Component 1: GitHub Action workflow (.github/workflows/cipherwake.yml)
|
|
7029
7702
|
// -------------------------------------------------------------------------
|
|
@@ -7035,7 +7708,10 @@ async function runSetupCommand(args) {
|
|
|
7035
7708
|
console.log(color("dim", ` ⊝ workflow at .github/workflows/cipherwake.yml already exists — skipping`));
|
|
7036
7709
|
installSummary.push({ component: "GitHub Action workflow", path: workflowPath, status: "skipped-already-present" });
|
|
7037
7710
|
} catch {
|
|
7038
|
-
|
|
7711
|
+
// R94.3 — setup-time workflow inherits the same opt-in default
|
|
7712
|
+
// (push). Customers on Vercel/Netlify can re-run `pqcheck init
|
|
7713
|
+
// --trigger=deployment-status` to swap in the post-deploy variant.
|
|
7714
|
+
const workflowYaml = renderTrustDiffWorkflow({ domain, failOn, baseline, triggerMode: "push" });
|
|
7039
7715
|
await fs.mkdir(path.dirname(workflowPath), { recursive: true });
|
|
7040
7716
|
await fs.writeFile(workflowPath, workflowYaml, "utf8");
|
|
7041
7717
|
console.log(color("green", ` ✓ wrote .github/workflows/cipherwake.yml (CI hard-gate layer)`));
|
|
@@ -7083,7 +7759,22 @@ async function runSetupCommand(args) {
|
|
|
7083
7759
|
// user has written outside the markers.
|
|
7084
7760
|
const START_MARKER = "<!-- CIPHERWAKE_AI_CODER_PROTOCOL_START — managed by pqcheck setup; safe to delete this section between markers but do not edit by hand -->";
|
|
7085
7761
|
const END_MARKER = "<!-- CIPHERWAKE_AI_CODER_PROTOCOL_END -->";
|
|
7762
|
+
// R96 — setup always has --domain; render it into the protocol so the
|
|
7763
|
+
// installed text is directly executable (no <your-domain> placeholder).
|
|
7764
|
+
const protocolText = renderProtocolText(domain);
|
|
7765
|
+
// R96 — cross-file dedupe: Claude Code reads BOTH ~/.claude/CLAUDE.md and
|
|
7766
|
+
// ./CLAUDE.md. Global is first in the candidates order; once it carries
|
|
7767
|
+
// the protocol, skip the project CLAUDE.md instead of duplicating ~40
|
|
7768
|
+
// lines into every session's context.
|
|
7769
|
+
const globalClaudeMdPath = path.join(os.homedir(), ".claude", "CLAUDE.md");
|
|
7770
|
+
const projectClaudeMdPath = path.join(process.cwd(), "CLAUDE.md");
|
|
7771
|
+
let globalClaudeMdCovered = false;
|
|
7086
7772
|
for (const t of protocolTargets) {
|
|
7773
|
+
if (t.path === projectClaudeMdPath && globalClaudeMdCovered) {
|
|
7774
|
+
console.log(color("dim", ` ⊝ ${path.basename(t.path)} (${t.label}) — covered by the global Claude Code install (~/.claude/CLAUDE.md)`));
|
|
7775
|
+
installSummary.push({ component: "AI Coder Protocol", path: t.path, label: t.label, status: "skipped-covered-by-global" });
|
|
7776
|
+
continue;
|
|
7777
|
+
}
|
|
7087
7778
|
try {
|
|
7088
7779
|
await fs.mkdir(path.dirname(t.path), { recursive: true });
|
|
7089
7780
|
let existing = "";
|
|
@@ -7098,10 +7789,11 @@ async function runSetupCommand(args) {
|
|
|
7098
7789
|
const endIdx = existing.indexOf(END_MARKER) + END_MARKER.length;
|
|
7099
7790
|
const before = existing.slice(0, startIdx);
|
|
7100
7791
|
const after = existing.slice(endIdx);
|
|
7101
|
-
const next = `${before.replace(/\n+$/, "")}\n\n${START_MARKER}\n${
|
|
7792
|
+
const next = `${before.replace(/\n+$/, "")}\n\n${START_MARKER}\n${protocolText}\n${END_MARKER}\n${after.replace(/^\n+/, "")}`;
|
|
7102
7793
|
if (next === existing) {
|
|
7103
7794
|
console.log(color("dim", ` ⊝ ${path.basename(t.path)} (${t.label}) — protocol already current`));
|
|
7104
7795
|
installSummary.push({ component: "AI Coder Protocol", path: t.path, label: t.label, status: "skipped-already-present" });
|
|
7796
|
+
if (t.path === globalClaudeMdPath) globalClaudeMdCovered = true;
|
|
7105
7797
|
continue;
|
|
7106
7798
|
}
|
|
7107
7799
|
await fs.writeFile(t.path, next, "utf8");
|
|
@@ -7115,10 +7807,11 @@ async function runSetupCommand(args) {
|
|
|
7115
7807
|
} else {
|
|
7116
7808
|
// Fresh install — append with fenced markers.
|
|
7117
7809
|
const sep = existing.length > 0 ? "\n\n" : "";
|
|
7118
|
-
await fs.writeFile(t.path, `${existing}${sep}${START_MARKER}\n${
|
|
7810
|
+
await fs.writeFile(t.path, `${existing}${sep}${START_MARKER}\n${protocolText}\n${END_MARKER}\n`, "utf8");
|
|
7119
7811
|
console.log(color("green", ` ✓ appended protocol (markered) → ${path.basename(t.path)} (${t.label})`));
|
|
7120
7812
|
installSummary.push({ component: "AI Coder Protocol", path: t.path, label: t.label, status: "installed" });
|
|
7121
7813
|
}
|
|
7814
|
+
if (t.path === globalClaudeMdPath) globalClaudeMdCovered = true;
|
|
7122
7815
|
} catch (err) {
|
|
7123
7816
|
console.log(color("red", ` ✗ ${t.path} — failed: ${err.message}`));
|
|
7124
7817
|
installSummary.push({ component: "AI Coder Protocol", path: t.path, status: "failed", error: String(err?.message || err) });
|
|
@@ -7501,14 +8194,15 @@ async function runSetupCommand(args) {
|
|
|
7501
8194
|
console.log(color("bold", "◆ Setup complete — summary:"));
|
|
7502
8195
|
console.log("");
|
|
7503
8196
|
for (const s of installSummary) {
|
|
7504
|
-
const
|
|
8197
|
+
const okStatuses = ["installed", "installed-created", "installed-updated", "created", "merged", "updated"];
|
|
8198
|
+
const icon = okStatuses.includes(s.status)
|
|
7505
8199
|
? color("green", "✓")
|
|
7506
|
-
: s.status?.startsWith("skipped")
|
|
8200
|
+
: s.status?.startsWith("skipped") || s.status === "already-set"
|
|
7507
8201
|
? color("dim", "⊝")
|
|
7508
8202
|
: color("red", "✗");
|
|
7509
8203
|
const label = s.component;
|
|
7510
|
-
const detail =
|
|
7511
|
-
? color("dim",
|
|
8204
|
+
const detail = okStatuses.includes(s.status)
|
|
8205
|
+
? color("dim", `${s.status.startsWith("installed") ? "installed" : s.status}${s.path ? " → " + s.path.replace(os.homedir(), "~") : ""}`)
|
|
7512
8206
|
: color("dim", s.status?.replace(/-/g, " "));
|
|
7513
8207
|
console.log(` ${icon} ${label.padEnd(34, " ")} ${detail}`);
|
|
7514
8208
|
}
|
|
@@ -7521,5 +8215,27 @@ async function runSetupCommand(args) {
|
|
|
7521
8215
|
|
|
7522
8216
|
main().catch((err) => {
|
|
7523
8217
|
console.error(color("red", `fatal: ${err.message}`));
|
|
7524
|
-
|
|
8218
|
+
// R96 — exit 3 (error), NOT 2. Exit 2 means "block" in the AI contract,
|
|
8219
|
+
// so an internal CLI crash used to masquerade as a security block (and
|
|
8220
|
+
// the pre-push hook refused the push). In AI mode, also emit a guard
|
|
8221
|
+
// block so the agent gets ship_decision=review instead of "no signal".
|
|
8222
|
+
try {
|
|
8223
|
+
const argv = process.argv.slice(2);
|
|
8224
|
+
if (parseAiMode(argv)) {
|
|
8225
|
+
console.log(formatAiFooterBlock({
|
|
8226
|
+
status: "review",
|
|
8227
|
+
kind: "error",
|
|
8228
|
+
verdict: "review",
|
|
8229
|
+
ship_decision: "review",
|
|
8230
|
+
top_issue: "cli_internal_error",
|
|
8231
|
+
top_issue_title: "pqcheck crashed before producing a result",
|
|
8232
|
+
scanned_at: new Date().toISOString(),
|
|
8233
|
+
advisory_only: "true",
|
|
8234
|
+
error: String(err?.message || err).slice(0, 200),
|
|
8235
|
+
}));
|
|
8236
|
+
}
|
|
8237
|
+
} catch {
|
|
8238
|
+
// never let the crash handler crash
|
|
8239
|
+
}
|
|
8240
|
+
process.exit(3);
|
|
7525
8241
|
});
|