@sunaiva/gate 1.0.0 → 1.1.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.
Files changed (83) hide show
  1. package/BUSINESS_LICENSE.md +70 -0
  2. package/CHANGELOG.md +254 -0
  3. package/LICENSE +0 -0
  4. package/README.md +451 -67
  5. package/README.md.bak-v1.0.0-stale-MIT +59 -0
  6. package/SUPPORT.md +75 -0
  7. package/TIER_DEFINITIONS.md +161 -0
  8. package/dist/config/defaults.d.ts +22 -1
  9. package/dist/config/defaults.d.ts.map +1 -1
  10. package/dist/config/defaults.js +56 -8
  11. package/dist/config/defaults.js.map +1 -1
  12. package/dist/config/loader.d.ts +0 -0
  13. package/dist/config/loader.d.ts.map +1 -1
  14. package/dist/config/loader.js +23 -5
  15. package/dist/config/loader.js.map +1 -1
  16. package/dist/engine/backend-client.d.ts +58 -0
  17. package/dist/engine/backend-client.d.ts.map +1 -0
  18. package/dist/engine/backend-client.js +287 -0
  19. package/dist/engine/backend-client.js.map +1 -0
  20. package/dist/engine/hmac-verifier.d.ts +52 -0
  21. package/dist/engine/hmac-verifier.d.ts.map +1 -0
  22. package/dist/engine/hmac-verifier.js +159 -0
  23. package/dist/engine/hmac-verifier.js.map +1 -0
  24. package/dist/engine/immutability.d.ts +59 -0
  25. package/dist/engine/immutability.d.ts.map +1 -0
  26. package/dist/engine/immutability.js +129 -0
  27. package/dist/engine/immutability.js.map +1 -0
  28. package/dist/engine/pattern-matcher.d.ts +13 -0
  29. package/dist/engine/pattern-matcher.d.ts.map +1 -1
  30. package/dist/engine/pattern-matcher.js +85 -17
  31. package/dist/engine/pattern-matcher.js.map +1 -1
  32. package/dist/engine/rule-engine.d.ts +62 -1
  33. package/dist/engine/rule-engine.d.ts.map +1 -1
  34. package/dist/engine/rule-engine.js +224 -12
  35. package/dist/engine/rule-engine.js.map +1 -1
  36. package/dist/engine/session-state.d.ts +0 -0
  37. package/dist/engine/session-state.d.ts.map +1 -1
  38. package/dist/engine/session-state.js +8 -2
  39. package/dist/engine/session-state.js.map +1 -1
  40. package/dist/engine/ship-confidence-gate.d.ts +232 -0
  41. package/dist/engine/ship-confidence-gate.d.ts.map +1 -0
  42. package/dist/engine/ship-confidence-gate.js +768 -0
  43. package/dist/engine/ship-confidence-gate.js.map +1 -0
  44. package/dist/index.d.ts +0 -0
  45. package/dist/index.js +293 -2
  46. package/dist/rules/categories.json +0 -0
  47. package/dist/rules/presets.json +0 -0
  48. package/dist/rules/rules.json +132 -64
  49. package/dist/tools/audit.d.ts +6 -0
  50. package/dist/tools/audit.d.ts.map +1 -1
  51. package/dist/tools/audit.js +43 -6
  52. package/dist/tools/audit.js.map +1 -1
  53. package/dist/tools/bypass.d.ts +0 -0
  54. package/dist/tools/bypass.d.ts.map +1 -1
  55. package/dist/tools/bypass.js +50 -6
  56. package/dist/tools/bypass.js.map +1 -1
  57. package/dist/tools/export-attestation.d.ts +45 -0
  58. package/dist/tools/export-attestation.d.ts.map +1 -0
  59. package/dist/tools/export-attestation.js +152 -0
  60. package/dist/tools/export-attestation.js.map +1 -0
  61. package/dist/tools/rules.d.ts +0 -0
  62. package/dist/tools/rules.d.ts.map +0 -0
  63. package/dist/tools/rules.js +0 -0
  64. package/dist/tools/rules.js.map +0 -0
  65. package/dist/tools/ship-confidence.d.ts +17 -0
  66. package/dist/tools/ship-confidence.d.ts.map +1 -0
  67. package/dist/tools/ship-confidence.js +42 -0
  68. package/dist/tools/ship-confidence.js.map +1 -0
  69. package/dist/tools/update.d.ts +0 -0
  70. package/dist/tools/update.d.ts.map +1 -1
  71. package/dist/tools/update.js +45 -9
  72. package/dist/tools/update.js.map +1 -1
  73. package/dist/tools/validate.d.ts +0 -0
  74. package/dist/tools/validate.d.ts.map +1 -1
  75. package/dist/tools/validate.js +56 -4
  76. package/dist/tools/validate.js.map +1 -1
  77. package/dist/types/backend.d.ts +69 -0
  78. package/dist/types/backend.d.ts.map +1 -0
  79. package/dist/types/backend.js +18 -0
  80. package/dist/types/backend.js.map +1 -0
  81. package/package.json +83 -65
  82. package/dist/index.d.ts.map +0 -1
  83. package/dist/index.js.map +0 -1
@@ -0,0 +1,768 @@
1
+ /**
2
+ * ShipConfidenceGate — TypeScript port of .claude/hooks/ship_confidence_gate.py v1.2.0
3
+ *
4
+ * HOOK_NAME = ship_confidence_gate
5
+ * HOOK_VERSION = 1.2.0 (ts-port)
6
+ * RULE = Rule 42 — Ship Confidence Gate (constitutional / HARD)
7
+ *
8
+ * DUAL-TIER AUTHORIZATION:
9
+ * PAID : Cryptographically-signed Verdict file at
10
+ * <genesis-root>/data/ship_confidence_verdicts/<artifact>.signed.json
11
+ * Verified against env $SHIP_CONFIDENCE_SIGNING_KEY (HMAC-SHA256 over
12
+ * canonical JSON, signature field excluded). Allow iff:
13
+ * - signature is valid (constant-time)
14
+ * - level == "GREEN"
15
+ * - signed_at within last 60 minutes
16
+ * Tagged in audit ledger as tier=paid.
17
+ *
18
+ * FREE : One-time approval token at
19
+ * <genesis-root>/data/deploy_queue/APPROVAL_TOKENS/<artifact>.json
20
+ * Token must be fresh (<1h). Tagged tier=free; includes upgrade hint.
21
+ *
22
+ * When NEITHER produces an allow, block-with-attribution message names BOTH
23
+ * paths so the operator knows the two ways to unblock.
24
+ *
25
+ * SELF-STAMPING:
26
+ * Every allow / block payload carries hook_name, hook_version, rule_name,
27
+ * rule_version, why, suggested_fix, artifact_id, command_preview, timestamp.
28
+ *
29
+ * ESCAPE HATCHES:
30
+ * DISABLE_SUNAIVA_GATE=1 — emergency bypass (allow + log bypass event)
31
+ * SUNAIVA_GATE_DRY_RUN=1 — warn-only (would-have-blocked notice, allow)
32
+ *
33
+ * AUDIT LEDGER (per Opus orchestrator decision [VERIFY] item 5):
34
+ * Single file: ~/.sunaiva/audit/audit.jsonl with `event_type` field for
35
+ * queryability. Event types written by this gate:
36
+ * - ship_confidence_allow
37
+ * - ship_confidence_block
38
+ * - ship_confidence_bypass
39
+ * - ship_confidence_dry_run
40
+ * - ship_confidence_error
41
+ *
42
+ * FAIL-OPEN:
43
+ * Any uncaught exception below the public check() boundary returns ALLOW
44
+ * with audit type=error. NEVER blocks on hook-internal bugs.
45
+ *
46
+ * Source of truth: .claude/hooks/ship_confidence_gate.py v1.2.0 (18/18 tests pass).
47
+ * Ported: 2026-05-12 (Wave 2 builder B2).
48
+ */
49
+ import { existsSync, mkdirSync, readFileSync, appendFileSync, readdirSync, unlinkSync, } from "node:fs";
50
+ import { dirname, join } from "node:path";
51
+ import { homedir } from "node:os";
52
+ import { canonicalJson, verifyHmac } from "./hmac-verifier.js";
53
+ // ---------------------------------------------------------------------------
54
+ // Self-stamp constants
55
+ // ---------------------------------------------------------------------------
56
+ export const HOOK_NAME = "ship_confidence_gate";
57
+ export const HOOK_VERSION = "1.2.0";
58
+ export const RULE_NAME = "Rule 42 — Ship Confidence Gate";
59
+ export const RULE_VERSION = "1.0";
60
+ // Defaults matching the Python source.
61
+ const DEFAULT_VERDICT_MAX_AGE_MINUTES = 60;
62
+ const DEFAULT_TOKEN_TTL_SECONDS = 3600;
63
+ const DEFAULT_ALLOWED_LEVELS = ["GREEN"];
64
+ const DEFAULT_SIGNING_KEY_ENV = "SHIP_CONFIDENCE_SIGNING_KEY";
65
+ // Audit log path (single file with event_type field per orchestrator decision).
66
+ const DEFAULT_AUDIT_DIR = join(homedir(), ".sunaiva", "audit");
67
+ const DEFAULT_AUDIT_PATH = join(DEFAULT_AUDIT_DIR, "audit.jsonl");
68
+ // ---------------------------------------------------------------------------
69
+ // Helpers
70
+ // ---------------------------------------------------------------------------
71
+ function nowIso() {
72
+ return new Date().toISOString();
73
+ }
74
+ /** Match the Python _sanitize: non-[A-Za-z0-9._-] → '-', strip dashes, max 128. */
75
+ export function sanitizeArtifactId(artifactId) {
76
+ return artifactId
77
+ .replace(/[^A-Za-z0-9._-]/g, "-")
78
+ .replace(/^-+|-+$/g, "")
79
+ .slice(0, 128);
80
+ }
81
+ /**
82
+ * Detect the genesis-system root, matching the Python _detect_genesis_root logic.
83
+ * Priority: env override → WSL path → Windows path → fall back to a path that
84
+ * may not exist (so log paths stay consistent).
85
+ */
86
+ function detectGenesisRoot(env) {
87
+ for (const k of ["GENESIS_ROOT", "CLAUDE_PROJECT_DIR"]) {
88
+ const v = (env[k] ?? "").trim();
89
+ if (v && existsSync(v))
90
+ return v;
91
+ }
92
+ const wsl = "/mnt/e/genesis-system";
93
+ const win = "E:/genesis-system";
94
+ if (existsSync(win))
95
+ return win;
96
+ if (existsSync(wsl))
97
+ return wsl;
98
+ return win;
99
+ }
100
+ /** Safe JSONL append — never throws. */
101
+ function safeAppendJsonl(path, record) {
102
+ try {
103
+ mkdirSync(dirname(path), { recursive: true });
104
+ appendFileSync(path, JSON.stringify(record) + "\n", { encoding: "utf-8" });
105
+ }
106
+ catch {
107
+ /* fail-open on log failure */
108
+ }
109
+ }
110
+ /** Parse signed_at ISO timestamps tolerantly (accepts 'Z' suffix). */
111
+ function parseSignedAt(raw) {
112
+ if (raw instanceof Date)
113
+ return raw;
114
+ if (typeof raw !== "string")
115
+ return null;
116
+ try {
117
+ const normalized = raw.endsWith("Z") ? raw.replace(/Z$/, "+00:00") : raw;
118
+ // Node Date parser handles both ISO-8601 with offset and the trailing-Z form.
119
+ const d = new Date(normalized);
120
+ if (isNaN(d.getTime()))
121
+ return null;
122
+ return d;
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ // ---------------------------------------------------------------------------
129
+ // Main gate class
130
+ // ---------------------------------------------------------------------------
131
+ export class ShipConfidenceGate {
132
+ verdictDir;
133
+ approvalDir;
134
+ maxAgeMinutes;
135
+ signingKeyEnv;
136
+ allowedLevels;
137
+ tokenTtlSeconds;
138
+ genesisRoot;
139
+ auditLogPath;
140
+ env;
141
+ constructor(opts = {}) {
142
+ this.env = opts.env ?? process.env;
143
+ this.genesisRoot = opts.genesisRoot ?? detectGenesisRoot(this.env);
144
+ this.verdictDir =
145
+ opts.verdictDir ?? join(this.genesisRoot, "data", "ship_confidence_verdicts");
146
+ this.approvalDir =
147
+ opts.approvalDir ?? join(this.genesisRoot, "data", "deploy_queue", "APPROVAL_TOKENS");
148
+ this.maxAgeMinutes = opts.maxAgeMinutes ?? DEFAULT_VERDICT_MAX_AGE_MINUTES;
149
+ this.signingKeyEnv = opts.signingKeyEnv ?? DEFAULT_SIGNING_KEY_ENV;
150
+ this.allowedLevels = opts.allowedLevels ?? DEFAULT_ALLOWED_LEVELS;
151
+ this.tokenTtlSeconds = opts.tokenTtlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS;
152
+ this.auditLogPath =
153
+ opts.auditLogPath ?? this.env.SUNAIVA_GATE_AUDIT_PATH ?? DEFAULT_AUDIT_PATH;
154
+ }
155
+ // -------------------------------------------------------------------------
156
+ // PAID TIER — signed verdict verification
157
+ // -------------------------------------------------------------------------
158
+ /**
159
+ * Look up and verify the signed Verdict for an artifact.
160
+ *
161
+ * Return-value semantics mirror the Python _check_signed_verdict:
162
+ * - "allow" : valid signature, allowed level, fresh → grant
163
+ * - "block" : verdict present but fails policy → HARD block
164
+ * - "absent" : no verdict file → caller tries free-tier fallback
165
+ * - "skip_key" : verdict present but signing key env unset → fall through
166
+ *
167
+ * NEVER throws. Any unexpected error → "absent" (the outer fail-open handler
168
+ * in check() preserves overall fail-OPEN semantics).
169
+ */
170
+ async checkSignedVerdict(artifactId) {
171
+ try {
172
+ if (!existsSync(this.verdictDir)) {
173
+ return { decision: "absent", reason: "verdict directory does not exist", evidence: null };
174
+ }
175
+ const sanitized = sanitizeArtifactId(artifactId);
176
+ let verdictPath = join(this.verdictDir, `${sanitized}.signed.json`);
177
+ if (!existsSync(verdictPath)) {
178
+ // Fallback: scan for any signed verdict whose payload's run_manifest
179
+ // product_name matches the requested artifact_id.
180
+ let found = false;
181
+ try {
182
+ const files = readdirSync(this.verdictDir).filter((f) => f.endsWith(".signed.json"));
183
+ for (const f of files) {
184
+ const candidate = join(this.verdictDir, f);
185
+ try {
186
+ const parsed = JSON.parse(readFileSync(candidate, "utf-8"));
187
+ const productName = parsed?.run_manifest?.product_name;
188
+ if (productName === artifactId) {
189
+ verdictPath = candidate;
190
+ found = true;
191
+ break;
192
+ }
193
+ }
194
+ catch {
195
+ // skip malformed files
196
+ }
197
+ }
198
+ }
199
+ catch {
200
+ /* ignore directory read failures */
201
+ }
202
+ if (!found) {
203
+ return {
204
+ decision: "absent",
205
+ reason: `no signed verdict for ${artifactId}`,
206
+ evidence: null,
207
+ };
208
+ }
209
+ }
210
+ // Read + parse verdict
211
+ let verdict;
212
+ try {
213
+ const raw = readFileSync(verdictPath, "utf-8");
214
+ const parsed = JSON.parse(raw);
215
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
216
+ return {
217
+ decision: "absent",
218
+ reason: "verdict file is not a JSON object",
219
+ evidence: null,
220
+ };
221
+ }
222
+ verdict = parsed;
223
+ }
224
+ catch (err) {
225
+ // Malformed file = probably wrong file, not an attack → fall through.
226
+ const kind = err instanceof Error ? err.constructor.name : "Unknown";
227
+ return {
228
+ decision: "absent",
229
+ reason: `verdict file unreadable (${kind})`,
230
+ evidence: null,
231
+ };
232
+ }
233
+ const signatureHex = verdict.signature;
234
+ if (typeof signatureHex !== "string" || signatureHex.length === 0) {
235
+ return {
236
+ decision: "absent",
237
+ reason: "verdict has no signature field",
238
+ evidence: null,
239
+ };
240
+ }
241
+ // Signing key
242
+ const signingKeyRaw = this.env[this.signingKeyEnv] ?? "";
243
+ if (!signingKeyRaw) {
244
+ return {
245
+ decision: "skip_key",
246
+ reason: `${this.signingKeyEnv} env var not set — cannot verify signature, falling back to free-tier approval token.`,
247
+ evidence: { verdict_path: verdictPath },
248
+ };
249
+ }
250
+ const keyBytes = Buffer.from(signingKeyRaw, "utf-8");
251
+ // Canonical payload = verdict minus signature field
252
+ const payloadDict = {};
253
+ for (const [k, v] of Object.entries(verdict)) {
254
+ if (k === "signature")
255
+ continue;
256
+ payloadDict[k] = v;
257
+ }
258
+ const payloadBytes = canonicalJson(payloadDict);
259
+ if (!verifyHmac(payloadBytes, signatureHex, keyBytes)) {
260
+ return {
261
+ decision: "block",
262
+ reason: "HMAC signature invalid — possible tampering of signed verdict",
263
+ evidence: {
264
+ verdict_path: verdictPath,
265
+ signature_algorithm: verdict.signature_algorithm,
266
+ verdict_id: verdict.verdict_id,
267
+ },
268
+ };
269
+ }
270
+ // Freshness check
271
+ const signedAt = parseSignedAt(verdict.signed_at);
272
+ if (signedAt === null) {
273
+ return {
274
+ decision: "block",
275
+ reason: "signed_at field missing or unparseable — verdict cannot be aged",
276
+ evidence: { verdict_path: verdictPath },
277
+ };
278
+ }
279
+ const ageMinutes = (Date.now() - signedAt.getTime()) / 60000;
280
+ if (ageMinutes > this.maxAgeMinutes) {
281
+ return {
282
+ decision: "block",
283
+ reason: `verdict is stale (${ageMinutes.toFixed(1)} min old, max ${this.maxAgeMinutes} min) — re-run sunaiva-ship-confidence skill`,
284
+ evidence: {
285
+ verdict_path: verdictPath,
286
+ verdict_id: verdict.verdict_id,
287
+ age_minutes: Number(ageMinutes.toFixed(2)),
288
+ },
289
+ };
290
+ }
291
+ // Level check
292
+ const level = (verdict.level ?? "UNKNOWN");
293
+ if (!this.allowedLevels.includes(level)) {
294
+ return {
295
+ decision: "block",
296
+ reason: `verdict is ${level} (paid tier requires ${this.allowedLevels.join("/")}) — fix findings or acknowledge caveats via free-tier approval token`,
297
+ evidence: {
298
+ verdict_path: verdictPath,
299
+ verdict_id: verdict.verdict_id,
300
+ level,
301
+ },
302
+ };
303
+ }
304
+ // All checks passed
305
+ return {
306
+ decision: "allow",
307
+ reason: `verdict ${level} + signature valid + fresh (${ageMinutes.toFixed(1)} min)`,
308
+ evidence: {
309
+ verdict_path: verdictPath,
310
+ verdict_id: verdict.verdict_id,
311
+ level,
312
+ signed_at: signedAt.toISOString(),
313
+ signature_algorithm: verdict.signature_algorithm,
314
+ age_minutes: Number(ageMinutes.toFixed(2)),
315
+ },
316
+ };
317
+ }
318
+ catch (err) {
319
+ // Unexpected error → fall through. The outer check() also has fail-open.
320
+ const kind = err instanceof Error ? err.constructor.name : "Unknown";
321
+ return {
322
+ decision: "absent",
323
+ reason: `paid-tier check raised ${kind}`,
324
+ evidence: null,
325
+ };
326
+ }
327
+ }
328
+ // -------------------------------------------------------------------------
329
+ // FREE TIER — approval token lookup
330
+ // -------------------------------------------------------------------------
331
+ /**
332
+ * Find a fresh approval token matching the artifact_id.
333
+ * Token must be JSON with at minimum {artifact_id, timestamp}.
334
+ */
335
+ async findApprovalToken(artifactId) {
336
+ try {
337
+ if (!existsSync(this.approvalDir))
338
+ return null;
339
+ const sanitized = sanitizeArtifactId(artifactId);
340
+ const candidates = [];
341
+ const exact = join(this.approvalDir, `${sanitized}.json`);
342
+ if (existsSync(exact))
343
+ candidates.push(exact);
344
+ try {
345
+ const files = readdirSync(this.approvalDir).filter((f) => f.endsWith(".json"));
346
+ for (const f of files) {
347
+ const p = join(this.approvalDir, f);
348
+ if (candidates.includes(p))
349
+ continue;
350
+ try {
351
+ const token = JSON.parse(readFileSync(p, "utf-8"));
352
+ if (token?.artifact_id === artifactId)
353
+ candidates.push(p);
354
+ }
355
+ catch {
356
+ // skip
357
+ }
358
+ }
359
+ }
360
+ catch {
361
+ /* dir read failure */
362
+ }
363
+ const nowMs = Date.now();
364
+ for (const p of candidates) {
365
+ try {
366
+ const token = JSON.parse(readFileSync(p, "utf-8"));
367
+ let ts = token?.timestamp;
368
+ if (typeof ts === "string") {
369
+ // ISO-8601 → epoch seconds (matching Python behaviour)
370
+ const dt = parseSignedAt(ts);
371
+ if (dt)
372
+ ts = dt.getTime() / 1000;
373
+ else
374
+ continue;
375
+ }
376
+ if (typeof ts !== "number")
377
+ continue;
378
+ // timestamp is in seconds (Python time.time() convention)
379
+ if (nowMs / 1000 - ts < this.tokenTtlSeconds)
380
+ return p;
381
+ }
382
+ catch {
383
+ continue;
384
+ }
385
+ }
386
+ return null;
387
+ }
388
+ catch {
389
+ return null;
390
+ }
391
+ }
392
+ /** Single-use token consumption. Never throws. */
393
+ async consumeToken(tokenPath) {
394
+ try {
395
+ unlinkSync(tokenPath);
396
+ }
397
+ catch {
398
+ /* fail-open */
399
+ }
400
+ }
401
+ // -------------------------------------------------------------------------
402
+ // Public output formatters (self-stamping helpers)
403
+ // -------------------------------------------------------------------------
404
+ /** Self-stamped allow payload (matches Python format_allow + _stamp). */
405
+ formatAllow(opts) {
406
+ const stamp = {
407
+ hook_name: HOOK_NAME,
408
+ hook_version: HOOK_VERSION,
409
+ rule_name: RULE_NAME,
410
+ rule_version: RULE_VERSION,
411
+ why: opts.why,
412
+ suggested_fix: "",
413
+ artifact_id: opts.artifactId,
414
+ command_preview: (opts.commandPreview ?? "").slice(0, 300),
415
+ timestamp: nowIso(),
416
+ label: opts.label,
417
+ tier: opts.tier,
418
+ };
419
+ const ctxPrefix = `[${HOOK_NAME} v${HOOK_VERSION}]`;
420
+ const tierTag = opts.tier ? `[tier=${opts.tier}] ` : "";
421
+ return {
422
+ continue: true,
423
+ additionalContext: `${ctxPrefix} ${tierTag}${opts.extraContext ?? opts.why}`,
424
+ _stamp: stamp,
425
+ };
426
+ }
427
+ /** Self-stamped block payload (matches Python format_block + _stamp). */
428
+ formatBlock(opts) {
429
+ const lines = [
430
+ `[${HOOK_NAME} v${HOOK_VERSION}] SHIP-CONFIDENCE GATE — ${RULE_NAME}`,
431
+ `BLOCKED: ${opts.label}`,
432
+ `Artifact: ${opts.artifactId}`,
433
+ `Why: ${opts.reason}`,
434
+ "",
435
+ `Suggested fix: ${opts.suggestedFix}`,
436
+ "",
437
+ "Override is NOT available — this gate is constitutional (Rule 42).",
438
+ "Emergency bypass: set DISABLE_SUNAIVA_GATE=1 (logged to audit.jsonl).",
439
+ "Test workflow: set SUNAIVA_GATE_DRY_RUN=1 (warn-only, never blocks).",
440
+ ];
441
+ const stamp = {
442
+ hook_name: HOOK_NAME,
443
+ hook_version: HOOK_VERSION,
444
+ rule_name: RULE_NAME,
445
+ rule_version: RULE_VERSION,
446
+ why: opts.reason,
447
+ suggested_fix: opts.suggestedFix,
448
+ artifact_id: opts.artifactId,
449
+ command_preview: (opts.commandPreview ?? "").slice(0, 300),
450
+ timestamp: nowIso(),
451
+ label: opts.label,
452
+ };
453
+ return {
454
+ continue: false,
455
+ decision: "block",
456
+ reason: `[${HOOK_NAME} v${HOOK_VERSION}] ${opts.label} — ${opts.reason}. Artifact=${opts.artifactId}. ${opts.suggestedFix}`,
457
+ stopReason: `[${HOOK_NAME}] ${opts.label}: ${opts.reason}`,
458
+ message: lines.join("\n"),
459
+ _stamp: stamp,
460
+ };
461
+ }
462
+ // -------------------------------------------------------------------------
463
+ // Public audit-write helpers
464
+ // -------------------------------------------------------------------------
465
+ /**
466
+ * Write an entry to the unified audit ledger. Matches the schema in §6.2 of
467
+ * BUILD_PLAN_1_1_0_WAVE2_BRIEFS.md and tags it with event_type so a single
468
+ * file is queryable (Opus orchestrator decision on [VERIFY] item 5).
469
+ */
470
+ writeAudit(entry) {
471
+ const stamped = {
472
+ timestamp: nowIso(),
473
+ gate_version: HOOK_VERSION,
474
+ hook_name: HOOK_NAME,
475
+ ...entry,
476
+ };
477
+ safeAppendJsonl(this.auditLogPath, stamped);
478
+ }
479
+ /** Expose the audit log path for tests and tooling. */
480
+ getAuditLogPath() {
481
+ return this.auditLogPath;
482
+ }
483
+ // -------------------------------------------------------------------------
484
+ // Top-level orchestrator
485
+ // -------------------------------------------------------------------------
486
+ /**
487
+ * Run the full gate logic for a given artifact_id + optional command preview.
488
+ * Returns a GateResult with a final allow/block decision and a self-stamped
489
+ * payload. Audit entries are written to the unified ledger.
490
+ *
491
+ * Behaviour order (matching Python main()):
492
+ * 0. Kill-switch (DISABLE_SUNAIVA_GATE=1) → allow, log bypass.
493
+ * 1. Dry-run (SUNAIVA_GATE_DRY_RUN=1) → probe both tiers, log dry-run, allow.
494
+ * 2. PAID tier → if allow, return allow tier=paid.
495
+ * If block, hard-block (never fall through).
496
+ * If absent/skip_key, continue to free tier.
497
+ * 3. FREE tier → token present and fresh → allow tier=free.
498
+ * Token missing → block with dual-path hint.
499
+ *
500
+ * FAIL-OPEN: any uncaught exception → allow with audit event_type=ship_confidence_error.
501
+ */
502
+ async check(artifactId, options = {}) {
503
+ const commandPreview = (options.commandPreview ?? "").slice(0, 300);
504
+ const label = options.label ?? "publish-class action";
505
+ try {
506
+ // 0. Kill-switch
507
+ if (this.env.DISABLE_SUNAIVA_GATE === "1") {
508
+ this.writeAudit({
509
+ event_type: "ship_confidence_bypass",
510
+ type: "bypass",
511
+ tier: null,
512
+ audit_status: "BYPASSED",
513
+ artifact_id: artifactId,
514
+ command_preview: commandPreview,
515
+ evidence: null,
516
+ reason: "DISABLE_SUNAIVA_GATE=1 — kill-switch active",
517
+ });
518
+ const allowPayload = this.formatAllow({
519
+ artifactId,
520
+ tier: null,
521
+ why: "BYPASSED via DISABLE_SUNAIVA_GATE=1 — event logged",
522
+ commandPreview,
523
+ label,
524
+ });
525
+ return {
526
+ decision: "allow",
527
+ tier: null,
528
+ reason: allowPayload.additionalContext,
529
+ evidence: {},
530
+ stamp: allowPayload._stamp,
531
+ bypassed: true,
532
+ };
533
+ }
534
+ // 1. Dry-run: probe both tiers but never enforce
535
+ const dryRun = this.env.SUNAIVA_GATE_DRY_RUN === "1";
536
+ // 2. PAID tier
537
+ const paid = await this.checkSignedVerdict(artifactId);
538
+ if (dryRun) {
539
+ // Probe free tier in parallel so the dry-run message is informative.
540
+ let dryReason;
541
+ let wouldHaveBeen = "block";
542
+ if (paid.decision === "allow") {
543
+ dryReason = `would have allowed (tier=paid: ${paid.reason})`;
544
+ wouldHaveBeen = "allow";
545
+ }
546
+ else if (paid.decision === "block") {
547
+ dryReason = `would have blocked (tier=paid: ${paid.reason})`;
548
+ wouldHaveBeen = "block";
549
+ }
550
+ else {
551
+ const tokenPath = await this.findApprovalToken(artifactId);
552
+ if (tokenPath) {
553
+ dryReason = "would have allowed (tier=free, approval token present)";
554
+ wouldHaveBeen = "allow";
555
+ }
556
+ else {
557
+ dryReason = `no signed verdict and no approval token (paid: ${paid.reason})`;
558
+ wouldHaveBeen = "block";
559
+ }
560
+ }
561
+ this.writeAudit({
562
+ event_type: "ship_confidence_dry_run",
563
+ type: wouldHaveBeen === "block" ? "dry_run_block" : "dry_run_pass",
564
+ tier: paid.decision === "allow" ? "paid" : null,
565
+ audit_status: "DRY_RUN",
566
+ dry_run: true,
567
+ artifact_id: artifactId,
568
+ command_preview: commandPreview,
569
+ evidence: paid.evidence,
570
+ reason: dryReason,
571
+ would_have_blocked: wouldHaveBeen === "block" ? [artifactId] : [],
572
+ });
573
+ const payload = this.formatAllow({
574
+ artifactId,
575
+ tier: null,
576
+ why: `DRY RUN — ${dryReason}`,
577
+ commandPreview,
578
+ label,
579
+ extraContext: `DRY RUN — ${dryReason}. Set SUNAIVA_GATE_DRY_RUN=0 to enforce.`,
580
+ });
581
+ return {
582
+ decision: "allow",
583
+ tier: null,
584
+ reason: payload.additionalContext,
585
+ evidence: paid.evidence ?? {},
586
+ stamp: payload._stamp,
587
+ would_have_been: wouldHaveBeen,
588
+ };
589
+ }
590
+ if (paid.decision === "allow") {
591
+ this.writeAudit({
592
+ event_type: "ship_confidence_allow",
593
+ type: "ship_confidence_allow",
594
+ tier: "paid",
595
+ audit_status: "SIGNED_VERDICT_GREEN",
596
+ artifact_id: artifactId,
597
+ command_preview: commandPreview,
598
+ evidence: paid.evidence,
599
+ reason: paid.reason,
600
+ });
601
+ const payload = this.formatAllow({
602
+ artifactId,
603
+ tier: "paid",
604
+ why: `signed verdict GREEN for ${artifactId} — ${paid.reason}`,
605
+ commandPreview,
606
+ label,
607
+ });
608
+ return {
609
+ decision: "allow",
610
+ tier: "paid",
611
+ reason: payload.additionalContext,
612
+ evidence: paid.evidence ?? {},
613
+ verdict_id: paid.evidence?.verdict_id,
614
+ level: paid.evidence?.level,
615
+ signed_at: paid.evidence?.signed_at,
616
+ age_minutes: paid.evidence?.age_minutes,
617
+ stamp: payload._stamp,
618
+ };
619
+ }
620
+ if (paid.decision === "block") {
621
+ const sanitized = sanitizeArtifactId(artifactId);
622
+ const suggestedFix = `Re-run sunaiva-ship-confidence skill to produce a fresh GREEN verdict at ` +
623
+ `data/ship_confidence_verdicts/${sanitized}.signed.json. ` +
624
+ `If the signature is invalid, the verdict file may have been modified post-signing.`;
625
+ const blockReason = `Signed verdict present but rejected: ${paid.reason}`;
626
+ const payload = this.formatBlock({
627
+ label,
628
+ reason: blockReason,
629
+ artifactId,
630
+ commandPreview,
631
+ suggestedFix,
632
+ });
633
+ this.writeAudit({
634
+ event_type: "ship_confidence_block",
635
+ type: "ship_confidence_block",
636
+ tier: "paid",
637
+ audit_status: "SIGNED_VERDICT_REJECTED",
638
+ artifact_id: artifactId,
639
+ command_preview: commandPreview,
640
+ evidence: paid.evidence,
641
+ reason: blockReason,
642
+ violations: ["rule-42"],
643
+ });
644
+ return {
645
+ decision: "block",
646
+ tier: "paid",
647
+ reason: payload.reason,
648
+ evidence: paid.evidence ?? {},
649
+ stamp: payload._stamp,
650
+ };
651
+ }
652
+ // paid.decision === "absent" or "skip_key" → free tier
653
+ const paidFallthrough = paid.reason;
654
+ // 3. FREE tier — approval token check
655
+ const tokenPath = await this.findApprovalToken(artifactId);
656
+ if (tokenPath === null) {
657
+ const sanitized = sanitizeArtifactId(artifactId);
658
+ const blockReason = `No signed verdict (paid) and no fresh (<1h) approval token (free) for ${artifactId}. ` +
659
+ `Paid path: ${paidFallthrough}`;
660
+ const suggestedFix = `(paid) Run sunaiva-ship-confidence skill to produce a signed verdict at ` +
661
+ `data/ship_confidence_verdicts/${sanitized}.signed.json ` +
662
+ `(requires ${this.signingKeyEnv} env var). ` +
663
+ `OR (free) Surface a Deploy Card to operator and write ` +
664
+ `data/deploy_queue/APPROVAL_TOKENS/${sanitized}.json.`;
665
+ const payload = this.formatBlock({
666
+ label,
667
+ reason: blockReason,
668
+ artifactId,
669
+ commandPreview,
670
+ suggestedFix,
671
+ });
672
+ this.writeAudit({
673
+ event_type: "ship_confidence_block",
674
+ type: "ship_confidence_block",
675
+ tier: null,
676
+ audit_status: "NO_AUTHORIZATION",
677
+ artifact_id: artifactId,
678
+ command_preview: commandPreview,
679
+ evidence: { paid_fallthrough_reason: paidFallthrough },
680
+ reason: blockReason,
681
+ violations: ["rule-42"],
682
+ });
683
+ return {
684
+ decision: "block",
685
+ tier: null,
686
+ reason: payload.reason,
687
+ evidence: { paid_fallthrough_reason: paidFallthrough },
688
+ stamp: payload._stamp,
689
+ };
690
+ }
691
+ // Token found — Phase 1: presence is sufficient (matches Python lenient mode).
692
+ this.writeAudit({
693
+ event_type: "ship_confidence_allow",
694
+ type: "ship_confidence_allow",
695
+ tier: "free",
696
+ audit_status: "ACCEPTED",
697
+ artifact_id: artifactId,
698
+ command_preview: commandPreview,
699
+ evidence: {
700
+ token_path: tokenPath,
701
+ paid_fallthrough_reason: paidFallthrough,
702
+ },
703
+ reason: "free-tier approval token accepted",
704
+ upgrade_hint: "Free tier — no cryptographic proof. Upgrade to sunaiva-ship-confidence for signed verdicts: https://sunaivacore.io/products/ship-confidence",
705
+ });
706
+ await this.consumeToken(tokenPath);
707
+ const payload = this.formatAllow({
708
+ artifactId,
709
+ tier: "free",
710
+ why: `approved publish: ${artifactId}`,
711
+ commandPreview,
712
+ label,
713
+ });
714
+ return {
715
+ decision: "allow",
716
+ tier: "free",
717
+ reason: payload.additionalContext,
718
+ evidence: { token_path: tokenPath, paid_fallthrough_reason: paidFallthrough },
719
+ stamp: payload._stamp,
720
+ };
721
+ }
722
+ catch (err) {
723
+ // FAIL-OPEN — never block on hook-internal bugs.
724
+ const errClass = err instanceof Error ? err.constructor.name : "Unknown";
725
+ const errMsg = err instanceof Error ? err.message : String(err);
726
+ this.writeAudit({
727
+ event_type: "ship_confidence_error",
728
+ type: "error",
729
+ tier: null,
730
+ audit_status: "FAIL_OPEN_ON_EXCEPTION",
731
+ artifact_id: artifactId,
732
+ command_preview: commandPreview,
733
+ evidence: null,
734
+ reason: `gate raised ${errClass}: ${errMsg}`,
735
+ error_class: errClass,
736
+ error_message: errMsg,
737
+ });
738
+ const payload = this.formatAllow({
739
+ artifactId,
740
+ tier: null,
741
+ why: `FAIL-OPEN on exception (${errClass}) — gate did not enforce`,
742
+ commandPreview,
743
+ label,
744
+ });
745
+ return {
746
+ decision: "allow",
747
+ tier: null,
748
+ reason: payload.additionalContext,
749
+ evidence: {},
750
+ stamp: payload._stamp,
751
+ };
752
+ }
753
+ }
754
+ }
755
+ // ---------------------------------------------------------------------------
756
+ // Internal helpers exposed for tests
757
+ // ---------------------------------------------------------------------------
758
+ export const __test = {
759
+ detectGenesisRoot,
760
+ parseSignedAt,
761
+ DEFAULT_AUDIT_PATH,
762
+ DEFAULT_AUDIT_DIR,
763
+ };
764
+ // Re-export the audit path resolver for tests/tooling
765
+ export function defaultAuditLogPath() {
766
+ return DEFAULT_AUDIT_PATH;
767
+ }
768
+ //# sourceMappingURL=ship-confidence-gate.js.map