@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.
- package/BUSINESS_LICENSE.md +70 -0
- package/CHANGELOG.md +254 -0
- package/LICENSE +0 -0
- package/README.md +451 -67
- package/README.md.bak-v1.0.0-stale-MIT +59 -0
- package/SUPPORT.md +75 -0
- package/TIER_DEFINITIONS.md +161 -0
- package/dist/config/defaults.d.ts +22 -1
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +56 -8
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/loader.d.ts +0 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +23 -5
- package/dist/config/loader.js.map +1 -1
- package/dist/engine/backend-client.d.ts +58 -0
- package/dist/engine/backend-client.d.ts.map +1 -0
- package/dist/engine/backend-client.js +287 -0
- package/dist/engine/backend-client.js.map +1 -0
- package/dist/engine/hmac-verifier.d.ts +52 -0
- package/dist/engine/hmac-verifier.d.ts.map +1 -0
- package/dist/engine/hmac-verifier.js +159 -0
- package/dist/engine/hmac-verifier.js.map +1 -0
- package/dist/engine/immutability.d.ts +59 -0
- package/dist/engine/immutability.d.ts.map +1 -0
- package/dist/engine/immutability.js +129 -0
- package/dist/engine/immutability.js.map +1 -0
- package/dist/engine/pattern-matcher.d.ts +13 -0
- package/dist/engine/pattern-matcher.d.ts.map +1 -1
- package/dist/engine/pattern-matcher.js +85 -17
- package/dist/engine/pattern-matcher.js.map +1 -1
- package/dist/engine/rule-engine.d.ts +62 -1
- package/dist/engine/rule-engine.d.ts.map +1 -1
- package/dist/engine/rule-engine.js +224 -12
- package/dist/engine/rule-engine.js.map +1 -1
- package/dist/engine/session-state.d.ts +0 -0
- package/dist/engine/session-state.d.ts.map +1 -1
- package/dist/engine/session-state.js +8 -2
- package/dist/engine/session-state.js.map +1 -1
- package/dist/engine/ship-confidence-gate.d.ts +232 -0
- package/dist/engine/ship-confidence-gate.d.ts.map +1 -0
- package/dist/engine/ship-confidence-gate.js +768 -0
- package/dist/engine/ship-confidence-gate.js.map +1 -0
- package/dist/index.d.ts +0 -0
- package/dist/index.js +293 -2
- package/dist/rules/categories.json +0 -0
- package/dist/rules/presets.json +0 -0
- package/dist/rules/rules.json +132 -64
- package/dist/tools/audit.d.ts +6 -0
- package/dist/tools/audit.d.ts.map +1 -1
- package/dist/tools/audit.js +43 -6
- package/dist/tools/audit.js.map +1 -1
- package/dist/tools/bypass.d.ts +0 -0
- package/dist/tools/bypass.d.ts.map +1 -1
- package/dist/tools/bypass.js +50 -6
- package/dist/tools/bypass.js.map +1 -1
- package/dist/tools/export-attestation.d.ts +45 -0
- package/dist/tools/export-attestation.d.ts.map +1 -0
- package/dist/tools/export-attestation.js +152 -0
- package/dist/tools/export-attestation.js.map +1 -0
- package/dist/tools/rules.d.ts +0 -0
- package/dist/tools/rules.d.ts.map +0 -0
- package/dist/tools/rules.js +0 -0
- package/dist/tools/rules.js.map +0 -0
- package/dist/tools/ship-confidence.d.ts +17 -0
- package/dist/tools/ship-confidence.d.ts.map +1 -0
- package/dist/tools/ship-confidence.js +42 -0
- package/dist/tools/ship-confidence.js.map +1 -0
- package/dist/tools/update.d.ts +0 -0
- package/dist/tools/update.d.ts.map +1 -1
- package/dist/tools/update.js +45 -9
- package/dist/tools/update.js.map +1 -1
- package/dist/tools/validate.d.ts +0 -0
- package/dist/tools/validate.d.ts.map +1 -1
- package/dist/tools/validate.js +56 -4
- package/dist/tools/validate.js.map +1 -1
- package/dist/types/backend.d.ts +69 -0
- package/dist/types/backend.d.ts.map +1 -0
- package/dist/types/backend.js +18 -0
- package/dist/types/backend.js.map +1 -0
- package/package.json +83 -65
- package/dist/index.d.ts.map +0 -1
- 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
|