great-cto 3.76.1 → 3.78.0

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.
@@ -0,0 +1,167 @@
1
+ // scripts/lib/exceptions.mjs — signed gate-exception registry (NaCl-inspired governance).
2
+ //
3
+ // Replaces ad-hoc `--admin` / `--no-verify` bypasses with an AUDITABLE, SIGNED, EXPIRING
4
+ // record. To override a gate you create a signed exception: who, why, which gate, scope,
5
+ // and an expiry. Gates check the registry — a bypass is sanctioned only if a valid active
6
+ // exception covers it, and every bypass leaves a tamper-evident trail.
7
+ //
8
+ // Store: <project>/.great_cto/exceptions/EXC-*.json (JSON — no YAML dependency).
9
+ //
10
+ // The signature is a sha256 over the IMMUTABLE fields, so editing reason/scope/expiry after
11
+ // signing invalidates it. `status` is mutable (active → revoked) and is NOT signed.
12
+
13
+ import { createHash } from 'node:crypto';
14
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { isMainModule } from './is-main.mjs';
17
+
18
+ export function exceptionsRoot(opts = {}) {
19
+ return opts.root || process.env.GREAT_CTO_EXCEPTIONS_ROOT || join(process.cwd(), '.great_cto', 'exceptions');
20
+ }
21
+
22
+ // Fields covered by the signature (order fixed → deterministic).
23
+ const SIGNED_FIELDS = ['id', 'created_at', 'created_by', 'gate', 'scope', 'reason', 'expires_at', 'risk'];
24
+
25
+ /** Deterministic canonical string of the signed fields. */
26
+ export function canonicalString(exc) {
27
+ return JSON.stringify(SIGNED_FIELDS.map((k) => [k, exc[k] ?? null]));
28
+ }
29
+
30
+ /** sha256 signature over the immutable fields. */
31
+ export function computeSignature(exc) {
32
+ return createHash('sha256').update(canonicalString(exc), 'utf8').digest('hex');
33
+ }
34
+
35
+ function slug(s) {
36
+ return String(s || 'exception').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'exception';
37
+ }
38
+
39
+ /**
40
+ * Build a signed exception object (no I/O). Pass `now` (ISO string) for deterministic tests.
41
+ * @returns {object} the exception incl. `signature` and `status: 'active'`.
42
+ */
43
+ export function create({ gate, scope = '', reason, createdBy, expiresInDays = 30, risk = 'medium', id, now } = {}) {
44
+ if (!gate) throw new Error('exception requires a gate (e.g. "gate:ship", "ci", "pre-push", or "*")');
45
+ if (!reason) throw new Error('exception requires a reason');
46
+ const nowDate = now ? new Date(now) : new Date();
47
+ const created_at = nowDate.toISOString();
48
+ const expires_at = new Date(nowDate.getTime() + expiresInDays * 86400000).toISOString();
49
+ const exc = {
50
+ id: id || `EXC-${created_at.slice(0, 10)}-${slug(scope || reason)}`,
51
+ created_at,
52
+ created_by: createdBy || process.env.USER || 'unknown',
53
+ gate,
54
+ scope,
55
+ reason,
56
+ expires_at,
57
+ risk,
58
+ status: 'active',
59
+ };
60
+ exc.signature = computeSignature(exc);
61
+ return exc;
62
+ }
63
+
64
+ /**
65
+ * Verify an exception: signature integrity + active + not expired + required fields.
66
+ * @returns {{ valid: boolean, reasons: string[] }}
67
+ */
68
+ export function verify(exc, { now } = {}) {
69
+ const reasons = [];
70
+ if (!exc || typeof exc !== 'object') return { valid: false, reasons: ['not an object'] };
71
+ for (const f of SIGNED_FIELDS) if (exc[f] === undefined || exc[f] === null || exc[f] === '') {
72
+ if (f === 'scope') continue; // scope may be empty (whole-gate exception)
73
+ reasons.push(`missing ${f}`);
74
+ }
75
+ if (exc.signature !== computeSignature(exc)) reasons.push('signature mismatch (tampered or unsigned)');
76
+ if (exc.status && exc.status !== 'active') reasons.push(`status is ${exc.status}`);
77
+ const nowMs = now ? new Date(now).getTime() : Date.now();
78
+ if (exc.expires_at && new Date(exc.expires_at).getTime() <= nowMs) reasons.push('expired');
79
+ return { valid: reasons.length === 0, reasons };
80
+ }
81
+
82
+ // ── registry I/O ──────────────────────────────────────────────────────────────
83
+
84
+ export function write(exc, opts = {}) {
85
+ const root = exceptionsRoot(opts);
86
+ mkdirSync(root, { recursive: true });
87
+ const path = join(root, `${exc.id}.json`);
88
+ writeFileSync(path, JSON.stringify(exc, null, 2));
89
+ return path;
90
+ }
91
+
92
+ /** Read all exception records. Skips unparseable files. */
93
+ export function list({ root } = {}) {
94
+ const dir = exceptionsRoot({ root });
95
+ if (!existsSync(dir)) return [];
96
+ const out = [];
97
+ for (const f of readdirSync(dir).filter((f) => f.startsWith('EXC-') && f.endsWith('.json'))) {
98
+ try { out.push(JSON.parse(readFileSync(join(dir, f), 'utf8'))); } catch { /* skip */ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * Find a VALID active exception covering `gate` (and, if given, whose scope substring-matches).
105
+ * A stored gate of "*" matches any gate. Returns the exception or null.
106
+ */
107
+ export function find(gate, { scope, root, now } = {}) {
108
+ for (const exc of list({ root })) {
109
+ if (!(exc.gate === gate || exc.gate === '*')) continue;
110
+ if (scope && exc.scope && !String(scope).includes(exc.scope) && !exc.scope.includes(String(scope))) continue;
111
+ if (verify(exc, { now }).valid) return exc;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /** Is `gate` covered by a valid signed exception? */
117
+ export function isCovered(gate, opts = {}) {
118
+ return find(gate, opts) !== null;
119
+ }
120
+
121
+ // ── CLI ──────────────────────────────────────────────────────────────────────
122
+
123
+ function parseArgs(argv) {
124
+ const o = {};
125
+ for (let i = 0; i < argv.length; i++) {
126
+ const a = argv[i];
127
+ if (a.startsWith('--')) o[a.slice(2)] = (argv[i + 1] && !argv[i + 1].startsWith('--')) ? argv[++i] : true;
128
+ else if (!o._cmd) o._cmd = a;
129
+ else if (!o._arg) o._arg = a;
130
+ }
131
+ return o;
132
+ }
133
+
134
+ function main() {
135
+ const a = parseArgs(process.argv.slice(2));
136
+ const cmd = a._cmd;
137
+ if (cmd === 'create') {
138
+ if (!a.gate || !a.reason) { process.stderr.write('Usage: exceptions.mjs create --gate <g> --reason "<why>" [--scope S] [--days N] [--risk low|medium|high]\n'); process.exit(2); }
139
+ const exc = create({ gate: a.gate, scope: a.scope || '', reason: a.reason, expiresInDays: a.days ? parseInt(a.days, 10) : 30, risk: a.risk || 'medium' });
140
+ const p = write(exc);
141
+ process.stdout.write(`signed exception created: ${exc.id}\n gate=${exc.gate} expires=${exc.expires_at} by=${exc.created_by}\n ${p}\n`);
142
+ } else if (cmd === 'list') {
143
+ const rows = list();
144
+ for (const e of rows) {
145
+ const v = verify(e);
146
+ process.stdout.write(`${v.valid ? '✓' : '✗'} ${e.id} gate=${e.gate} expires=${(e.expires_at || '').slice(0, 10)} ${v.valid ? '' : '(' + v.reasons.join('; ') + ')'}\n`);
147
+ }
148
+ if (rows.length === 0) process.stdout.write('(no exceptions)\n');
149
+ } else if (cmd === 'verify') {
150
+ const dir = exceptionsRoot({});
151
+ const e = a._arg ? JSON.parse(readFileSync(join(dir, `${a._arg}.json`), 'utf8')) : null;
152
+ const v = e ? verify(e) : { valid: false, reasons: ['not found'] };
153
+ process.stdout.write(`${v.valid ? 'VALID' : 'INVALID'}${v.valid ? '' : ' — ' + v.reasons.join('; ')}\n`);
154
+ process.exit(v.valid ? 0 : 1);
155
+ } else if (cmd === 'check') {
156
+ // check <gate> [--scope S] → exit 0 if covered (prints id), exit 1 if not
157
+ const exc = find(a._arg, { scope: a.scope });
158
+ if (exc) { process.stdout.write(`covered by ${exc.id} (expires ${exc.expires_at.slice(0, 10)})\n`); process.exit(0); }
159
+ process.stdout.write(`NOT covered — gate "${a._arg}" has no valid signed exception\n`); process.exit(1);
160
+ } else {
161
+ process.stderr.write('Usage: exceptions.mjs <create|list|verify <id>|check <gate>> [flags]\n');
162
+ process.exit(2);
163
+ }
164
+ }
165
+
166
+ const isMain = isMainModule(import.meta.url);
167
+ if (isMain) main();