aegis-desktop 0.8.13 → 0.8.15

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,1063 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * agent-gate.mjs — "only the holder of the agent key may manipulate this code".
6
+ *
7
+ * ## What this actually enforces, and what it does not
8
+ *
9
+ * This is one half of a two-half lock. Be clear about which half is which,
10
+ * because the honest answer is not "the repo is secure now".
11
+ *
12
+ * ENFORCED HERE (real, on-machine):
13
+ * • `.githooks/pre-commit` and `.githooks/pre-push` refuse to commit or
14
+ * push a change to a protected path unless a valid agent key is present
15
+ * AND this machine is bound in the manifest. The hook runs inside the
16
+ * real `git` process, so it also catches edits made by a host that
17
+ * bypasses the ÆGIS tool layer entirely (a bare editor, the `claude`
18
+ * CLI, a stray script).
19
+ * • The engine's own tool layer reads the SAME manifest before running
20
+ * Write/Edit/mutating Bash (see aegiscodex-dev `src/repoguard.js`), so
21
+ * an agent turn cannot edit this tree without the key either.
22
+ *
23
+ * NOT ENFORCED HERE, and cannot be by anything inside this repo:
24
+ * • Anyone with the bytes can delete this file, the manifest, or the
25
+ * hooks, and edit whatever they like. `git commit --no-verify` skips
26
+ * the hooks outright. A clean clone has no hooks at all (git does not
27
+ * clone `.githooks/` into `core.hooksPath`) until `install-hooks` runs.
28
+ * • A gate that lives inside the thing it protects is a *speed bump with
29
+ * a receipt*, not a cryptographic boundary. It stops the casual case
30
+ * (a copied tree, a borrowed laptop, a new collaborator) and it makes
31
+ * the deliberate case leave a trail.
32
+ *
33
+ * THE ACTUAL LOCK is server-side and lives at the remote: a GitHub ruleset
34
+ * / branch protection on `main` with required reviewers and no force-push.
35
+ * Nothing on this machine can be stronger than the remote's own policy.
36
+ * See SECURITY.md, and `agent-gate.mjs lock-status` for what to check.
37
+ *
38
+ * ## What is in the repo, and what is not
39
+ *
40
+ * `.aegis/guard.json` (committed) holds **only SHA-256 hashes**, never a key.
41
+ * The key itself lives outside the tree, at `$AEGIS_HOME/keys/agent.key`
42
+ * (mode 0600, `$AEGIS_HOME` defaults to `~/.aegiscode`), or in the
43
+ * `AEGIS_AGENT_KEY` environment variable. That placement is deliberate: a
44
+ * secret inside the repo is committed on the next `git add -A`, and this
45
+ * repo's own pre-commit guard scans for exactly that mistake. The module
46
+ * refuses outright to read a key file that is inside the repository it is
47
+ * guarding, or one that is group/other-readable.
48
+ *
49
+ * ## The derivation (the contract; both implementations must match)
50
+ *
51
+ * keyHash = sha256("aegiscode-agent-gate-v1\n" + key) → hex
52
+ * fingerprint = keyHash[0:12]
53
+ * machineFp = sha256("aegiscode-machine-v1\n" + salt + "\n" +
54
+ * hostname + "\n" + machineId + "\n" + username
55
+ * + "\n" + platform + "\n" + arch)[0:16]
56
+ *
57
+ * The domain-separation prefixes are not decoration: without them, a raw
58
+ * `sha256(key)` would be the same value as the φ(α) authorship derivation
59
+ * (`sha256('aegiscode-agent-v1\n' + secret)`) and the two key spaces could be
60
+ * confused for each other. The engine's `src/repoguard.js` re-implements this
61
+ * formula and `test/agent-gate.test.mjs` pins both against the same vector.
62
+ *
63
+ * The machine salt is a random per-install value at `$AEGIS_HOME/keys/machine.salt`
64
+ * (0600). Without it the fingerprint would be a hash of public facts
65
+ * (hostname, /etc/machine-id, username) and therefore forgeable by anyone who
66
+ * can guess them; with it, an allow-list entry is only reproducible on the
67
+ * install that minted it.
68
+ *
69
+ * ## Verbs
70
+ *
71
+ * status what this machine can currently do, and why
72
+ * check [--paths a b …] exit 0 allowed / 3 denied — for hooks and CI
73
+ * init [--cwd DIR] [--mode enforce|warn] [--protect GLOB,…] [--hooks]
74
+ * TURN THE GATE ON for a repository that has none:
75
+ * write .aegis/guard.json with the CURRENT key's
76
+ * hash and this machine bound, and with --hooks
77
+ * vendor lib-gate.sh + wire the pre-commit section.
78
+ * Refuses to run with no key, because enforcing an
79
+ * empty key list denies everyone including you.
80
+ * lock-status the server-side half: what to verify remotely
81
+ * hash --stdin hash a key (never pass a key as an argument)
82
+ * add-key --label <name> append a hash; key read from stdin or the key file
83
+ * rotate mint a NEW key, burn the old ones, print only
84
+ * the new fingerprint
85
+ * bind-machine add THIS machine's fingerprint to the allow-list
86
+ * install-hooks point git at .githooks (a fresh clone has none)
87
+ *
88
+ * Exit codes: 0 = allowed, 3 = denied, 4 = misconfiguration (unreadable or
89
+ * unparseable manifest/key). 3 and 4 are distinct so a hook can tell "the key
90
+ * is wrong" from "the gate is broken" — a broken gate must never silently
91
+ * allow, and it must be diagnosable without reading this file.
92
+ */
93
+
94
+ import crypto from 'node:crypto';
95
+ import fs from 'node:fs';
96
+ import os from 'node:os';
97
+ import path from 'node:path';
98
+ import { fileURLToPath } from 'node:url';
99
+
100
+ export const GATE_VERSION = 1;
101
+ export const KEY_DOMAIN = 'aegiscode-agent-gate-v1';
102
+ export const MACHINE_DOMAIN = 'aegiscode-machine-v1';
103
+ export const MANIFEST_REL = path.join('.aegis', 'guard.json');
104
+ export const KEY_FILE_REL = path.join('keys', 'agent.key');
105
+ export const SALT_FILE_REL = path.join('keys', 'machine.salt');
106
+
107
+ export const EXIT = { ALLOW: 0, DENY: 3, CONFIG: 4 };
108
+
109
+ /** 0600 / 0700, enforced after the write and re-checked — see authorship-key.js. */
110
+ const KEY_FILE_MODE = 0o600;
111
+ const DIR_MODE = 0o700;
112
+ const GROUP_OTHER_BITS = 0o077;
113
+
114
+ // ── derivations ─────────────────────────────────────────────────────────────
115
+
116
+ /** @param {string} key @returns {string} hex sha256 under the gate domain */
117
+ export function hashKey(key) {
118
+ return crypto.createHash('sha256').update(`${KEY_DOMAIN}\n${String(key)}`, 'utf8').digest('hex');
119
+ }
120
+
121
+ /** The 12 hex a human quotes to confirm two machines hold the same key. */
122
+ export function keyFingerprint(key) {
123
+ return hashKey(key).slice(0, 12);
124
+ }
125
+
126
+ /** Constant-time compare of two hex digests (length differs ⇒ false, no early exit on content). */
127
+ export function safeEqualHex(a, b) {
128
+ const ba = Buffer.from(String(a ?? ''), 'utf8');
129
+ const bb = Buffer.from(String(b ?? ''), 'utf8');
130
+ if (ba.length !== bb.length || ba.length === 0) return false;
131
+ return crypto.timingSafeEqual(ba, bb);
132
+ }
133
+
134
+ export function aegisHome(env = process.env) {
135
+ return env.AEGIS_HOME || path.join(os.homedir(), '.aegiscode');
136
+ }
137
+
138
+ function readFirstLine(file) {
139
+ try {
140
+ return fs.readFileSync(file, 'utf8').split('\n')[0].trim();
141
+ } catch {
142
+ return '';
143
+ }
144
+ }
145
+
146
+ /**
147
+ * The per-install random salt, created on first use at 0600. Returns '' only
148
+ * when it cannot be written (a read-only $AEGIS_HOME), in which case the
149
+ * fingerprint silently degrades to unsalted — the caller's `status` output
150
+ * reports that, so a degraded fingerprint is visible rather than assumed.
151
+ */
152
+ export function machineSalt(env = process.env) {
153
+ const file = path.join(aegisHome(env), SALT_FILE_REL);
154
+ const existing = readFirstLine(file);
155
+ if (existing) return existing;
156
+ try {
157
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: DIR_MODE });
158
+ const salt = crypto.randomBytes(16).toString('base64url');
159
+ fs.writeFileSync(file, `${salt}\n`, { mode: KEY_FILE_MODE });
160
+ fs.chmodSync(file, KEY_FILE_MODE);
161
+ return salt;
162
+ } catch {
163
+ return '';
164
+ }
165
+ }
166
+
167
+ /** Stable 16-hex identifier for THIS machine's install. See the header for the formula. */
168
+ export function machineFingerprint(env = process.env, overrides = {}) {
169
+ const hostname = overrides.hostname ?? os.hostname();
170
+ const username = overrides.username ?? (() => { try { return os.userInfo().username; } catch { return '?'; } })();
171
+ // `??` may not be mixed with `||` unparenthesised — the middle rung is
172
+ // spelled out rather than chained, because the file fallback has to be
173
+ // parenthesised anyway.
174
+ const machineId = overrides.machineId
175
+ ?? (readFirstLine('/etc/machine-id') || readFirstLine('/var/lib/dbus/machine-id'));
176
+ const platform = overrides.platform ?? process.platform;
177
+ const arch = overrides.arch ?? process.arch;
178
+ const salt = overrides.salt ?? machineSalt(env);
179
+ const body = [salt, hostname, machineId, username, platform, arch].join('\n');
180
+ return crypto.createHash('sha256').update(`${MACHINE_DOMAIN}\n${body}`, 'utf8').digest('hex').slice(0, 16);
181
+ }
182
+
183
+ // ── manifest ────────────────────────────────────────────────────────────────
184
+
185
+ /** Nearest `.aegis/guard.json` at or above `startPath`. */
186
+ export function findManifest(startPath) {
187
+ let dir = path.resolve(startPath || process.cwd());
188
+ try { if (fs.statSync(dir).isFile()) dir = path.dirname(dir); } catch { /* keep as-is */ }
189
+ for (;;) {
190
+ const candidate = path.join(dir, MANIFEST_REL);
191
+ if (fs.existsSync(candidate)) return candidate;
192
+ const parent = path.dirname(dir);
193
+ if (parent === dir) return null;
194
+ dir = parent;
195
+ }
196
+ }
197
+
198
+ /** Parse a manifest, or throw with the reason (never return a half-read policy). */
199
+ export function loadManifest(file) {
200
+ if (!file) throw new Error(`no ${MANIFEST_REL} found at or above this path`);
201
+ let raw;
202
+ try {
203
+ raw = fs.readFileSync(file, 'utf8');
204
+ } catch (e) {
205
+ throw new Error(`cannot read manifest ${file}: ${e.message}`);
206
+ }
207
+ let data;
208
+ try {
209
+ data = JSON.parse(raw);
210
+ } catch (e) {
211
+ throw new Error(`manifest ${file} is not valid JSON: ${e.message}`);
212
+ }
213
+ if (Number(data.version) !== GATE_VERSION) {
214
+ throw new Error(`manifest ${file} declares version ${data.version}; this gate speaks version ${GATE_VERSION}`);
215
+ }
216
+ data.keys = Array.isArray(data.keys) ? data.keys : [];
217
+ data.machines = Array.isArray(data.machines) ? data.machines : [];
218
+ data.protect = Array.isArray(data.protect) ? data.protect : ['**'];
219
+ data.__file = file;
220
+ data.__root = path.dirname(path.dirname(file));
221
+ return data;
222
+ }
223
+
224
+ /** Active key entries only — a burned entry is kept for the record, never honoured. */
225
+ export function activeKeys(manifest) {
226
+ return (manifest.keys || []).filter((k) => k && (k.status || 'active') === 'active' && k.sha256);
227
+ }
228
+
229
+ // ── the tool half (the desktop app's own tool layer) ────────────────────────
230
+
231
+ /**
232
+ * The app names its tools differently from the engine (`writeFile`/`editFile`/
233
+ * `exec` vs `Write`/`Edit`/`Bash`), and both are wired to THIS function so
234
+ * there is one decision and one manifest format across the hooks, the CLI, the
235
+ * app and the engine. Every alias of a mutating tool must be listed here or
236
+ * that tool silently escapes the gate — which is why the app's own names are
237
+ * spelled out rather than derived.
238
+ */
239
+ export const MUTATING_TOOL_NAMES = new Set([
240
+ 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', // engine + provider names
241
+ 'writeFile', 'editFile', // desktop/lib/local/tools.js
242
+ ]);
243
+
244
+ /** Bash/`exec` commands whose whole purpose is to write. Tested, not guessed. */
245
+ export const BASH_TOOL_NAMES = new Set(['Bash', 'exec']);
246
+ export const SKIP_ENV = 'AEGIS_GATE_SKIP';
247
+
248
+ const WRITEISH_BASH = [
249
+ /(^|[^<])>>?\s*\S/, // > file, >> file
250
+ /\btee\b/, /\bsed\s+-i/, /\btruncate\b/, /\bdd\b/,
251
+ /\b(rm|mv|cp|install|touch|mkdir|rmdir|chmod|chown|ln)\b/,
252
+ /\b(patch|applypatch)\b/, /\bpython[0-9.]*\s+-c\b.*\bopen\s*\(/, /\bperl\s+-i\b/,
253
+ /\bgit\s+(checkout|restore|reset|clean|stash|apply|revert|cherry-pick|merge|rebase)\b/,
254
+ /\bnpm\s+(install|ci|uninstall|update)\b/, /\byarn\s+(add|remove)\b/, /\bpip[0-9.]*\s+install\b/,
255
+ /\bAEGIS_GATE_SKIP\b/,
256
+ ];
257
+
258
+ /** Path-shaped tokens in a command line. Deliberately over-eager: see bashTargets. */
259
+ function pathTokens(command) {
260
+ const out = [];
261
+ const re = /(?:^|[\s'"=<>|&;(])((?:~|\.{1,2})?\/[^\s'"|&;)>]+|[A-Za-z0-9_.-]+\/[^\s'"|&;)>]+)/g;
262
+ let m;
263
+ while ((m = re.exec(String(command))) !== null) out.push(m[1]);
264
+ return out;
265
+ }
266
+
267
+ /**
268
+ * Does this command write, and where?
269
+ *
270
+ * A heuristic, and named one: a script that CALLS `cp`, or an interpreter that
271
+ * reads a write out of a file, is not seen. A bare relative target (`cp a b`)
272
+ * has no `/`, so the whole command is judged against `cwd` instead — the
273
+ * fail-closed direction, because the alternative is "unparseable, so allow".
274
+ *
275
+ * @returns {{ writeish: boolean, paths: string[] }}
276
+ */
277
+ export function bashTargets(command, cwd = process.cwd()) {
278
+ const cmd = String(command || '');
279
+ const writeish = WRITEISH_BASH.some((re) => re.test(cmd));
280
+ if (!writeish) return { writeish: false, paths: [] };
281
+ const paths = pathTokens(cmd);
282
+ return { writeish: true, paths: paths.length ? paths : [cwd] };
283
+ }
284
+
285
+ /**
286
+ * Paths (manifest-relative) that this call would touch. Unlike a git path
287
+ * list, a tool target may be a directory or the repo root itself (`rm -rf .`):
288
+ * a directory is treated as protected whenever the manifest protects anything,
289
+ * which over-blocks a non-holder writing to an unprotected subdirectory — the
290
+ * safe direction, and free for the holder, who is allowed through anyway.
291
+ */
292
+ function protectedUnder(manifest, absPaths) {
293
+ const root = manifest.__root;
294
+ const hasIncludes = manifest.protect.some((p) => {
295
+ const pat = String(p).trim();
296
+ return pat && !pat.startsWith('!');
297
+ });
298
+ const out = [];
299
+ for (const abs of absPaths) {
300
+ const rel = path.relative(root, abs);
301
+ if (path.isAbsolute(rel) || rel.startsWith('..')) continue;
302
+ if (rel === '') { if (hasIncludes) out.push('.'); continue; }
303
+ let isDir = false;
304
+ try { isDir = fs.statSync(abs).isDirectory(); } catch { isDir = false; }
305
+ if (isDir) { if (hasIncludes) out.push(rel); continue; }
306
+ if (isProtected(manifest, rel)) out.push(rel);
307
+ }
308
+ return out;
309
+ }
310
+
311
+
312
+ // ── the key ─────────────────────────────────────────────────────────────────
313
+
314
+ /**
315
+ * The key-resolution ladder, most explicit first:
316
+ * 1. an explicit `key` argument (tests, and the engine passing a key through)
317
+ * 2. `AEGIS_AGENT_KEY` in the environment
318
+ * 3. `$AEGIS_HOME/keys/agent.key` — where `rotate` writes
319
+ *
320
+ * A file is refused when it is group/other-readable (any bit of 0o077) or
321
+ * when it lives inside the repo being guarded. Both refusals exist because
322
+ * the alternative is a gate that passes for the wrong reason.
323
+ *
324
+ * @returns {{ key: string, source: string, error: string|null }}
325
+ */
326
+ export function resolveAgentKey({ env = process.env, manifestRoot = null, key = null } = {}) {
327
+ if (key) return { key: String(key).trim(), source: 'argument', error: null };
328
+
329
+ const fromEnv = env.AEGIS_AGENT_KEY;
330
+ if (fromEnv && String(fromEnv).trim()) {
331
+ return { key: String(fromEnv).trim(), source: 'env:AEGIS_AGENT_KEY', error: null };
332
+ }
333
+
334
+ const file = path.join(aegisHome(env), KEY_FILE_REL);
335
+ let st;
336
+ try {
337
+ st = fs.statSync(file);
338
+ } catch {
339
+ return { key: '', source: 'none', error: `no agent key at ${file} (and AEGIS_AGENT_KEY is unset)` };
340
+ }
341
+ let mode = null;
342
+ try { mode = st.mode & 0o777; } catch { /* non-POSIX */ }
343
+ if (mode !== null && (mode & GROUP_OTHER_BITS)) {
344
+ return {
345
+ key: '',
346
+ source: file,
347
+ error: `${file} is mode 0${mode.toString(8)} — readable by other users, refusing to treat it as a secret (chmod 600)`,
348
+ };
349
+ }
350
+ if (manifestRoot) {
351
+ const rel = path.relative(path.resolve(manifestRoot), path.resolve(file));
352
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
353
+ return { key: '', source: file, error: `${file} lives INSIDE the guarded repo — move it outside or it will be committed` };
354
+ }
355
+ }
356
+ const value = readFirstLine(file);
357
+ if (!value) return { key: '', source: file, error: `${file} is empty` };
358
+ return { key: value, source: file, error: null };
359
+ }
360
+
361
+ export function writeAgentKey(key, env = process.env) {
362
+ const file = path.join(aegisHome(env), KEY_FILE_REL);
363
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: DIR_MODE });
364
+ fs.writeFileSync(file, `${key}\n`, { mode: KEY_FILE_MODE });
365
+ fs.chmodSync(file, KEY_FILE_MODE); // writeFileSync's mode is masked by umask
366
+ const after = fs.statSync(file).mode & 0o777;
367
+ if (after !== KEY_FILE_MODE) throw new Error(`key file landed at mode 0${after.toString(8)}, expected 0600`);
368
+ return file;
369
+ }
370
+
371
+ // ── path scoping ────────────────────────────────────────────────────────────
372
+
373
+ /**
374
+ * Globs in `protect`. `**` matches everything; `*` does not cross `/` — same
375
+ * rule as .gitignore, because the people writing these entries think in
376
+ * .gitignore. `!`-prefixed entries are exceptions, evaluated after includes.
377
+ */
378
+ function globToRegex(pattern) {
379
+ let re = '^';
380
+ const src = String(pattern);
381
+ for (let i = 0; i < src.length; i++) {
382
+ const c = src[i];
383
+ if (c === '*') {
384
+ if (src[i + 1] === '*') {
385
+ // `**/` should also match zero directories
386
+ if (src[i + 2] === '/') { re += '(?:.*/)?'; i += 2; } else { re += '.*'; i += 1; }
387
+ } else {
388
+ re += '[^/]*';
389
+ }
390
+ } else if (c === '?') re += '[^/]';
391
+ else if (/[.+^${}()|[\]\\]/.test(c)) re += `\\${c}`;
392
+ else re += c;
393
+ }
394
+ return new RegExp(re + '$');
395
+ }
396
+
397
+ /** Is `relPath` (repo-relative, POSIX separators) covered by the manifest? */
398
+ export function isProtected(manifest, relPath) {
399
+ const rel = String(relPath).split(path.sep).join('/').replace(/^\.\//, '');
400
+ const negations = [];
401
+ let hit = false;
402
+ for (const entry of manifest.protect) {
403
+ const pat = String(entry).trim();
404
+ if (!pat) continue;
405
+ if (pat.startsWith('!')) {
406
+ const inner = pat.slice(1);
407
+ if (inner) negations.push(globToRegex(inner));
408
+ continue;
409
+ }
410
+ if (globToRegex(pat).test(rel)) hit = true;
411
+ }
412
+ // Negations are evaluated after all includes, so an exception line further
413
+ // up the list still wins — the order the entry was written does not have to
414
+ // be the order it takes effect, which is what .gitignore readers expect.
415
+ if (hit && negations.some((re) => re.test(rel))) return false;
416
+ return hit;
417
+ }
418
+
419
+ /** Split paths into protected / not, relative to the manifest root. */
420
+ export function partitionPaths(manifest, cwd, paths) {
421
+ const root = manifest.__root;
422
+ const protectedPaths = [];
423
+ const outside = [];
424
+ for (const p of paths || []) {
425
+ const abs = path.resolve(cwd || process.cwd(), p);
426
+ const rel = path.relative(root, abs);
427
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { outside.push(p); continue; }
428
+ if (isProtected(manifest, rel)) protectedPaths.push(rel);
429
+ else outside.push(p);
430
+ }
431
+ return { protectedPaths, outside };
432
+ }
433
+
434
+ // ── the verdict ─────────────────────────────────────────────────────────────
435
+
436
+ /**
437
+ * The single decision function. Everything above exists to feed it; every
438
+ * caller (the hooks, the CLI, the engine's repoguard) must go through it, so
439
+ * there is exactly one place where "allowed" is decided.
440
+ *
441
+ * @returns {{ ok: boolean, code: 'allow'|'deny', keyId: string|null,
442
+ * fingerprint: string|null, machine: string, reason: string,
443
+ * configError: string|null }}
444
+ */
445
+ export function verifyAgent({ cwd = process.cwd(), key = null, env = process.env, manifest = null, manifestFile = null, machine = null } = {}) {
446
+ let man = manifest;
447
+ let configError = null;
448
+ try {
449
+ if (!man) man = loadManifest(manifestFile || findManifest(cwd));
450
+ } catch (e) {
451
+ configError = e.message;
452
+ return {
453
+ ok: false, code: 'deny', keyId: null, fingerprint: null,
454
+ machine: machine || machineFingerprint(env), reason: configError, configError,
455
+ };
456
+ }
457
+
458
+ const machineFp = machine || machineFingerprint(env);
459
+ const { key: resolved, source, error } = resolveAgentKey({ env, manifestRoot: man.__root, key });
460
+
461
+ if (error) {
462
+ return {
463
+ ok: false, code: 'deny', keyId: null, fingerprint: null,
464
+ machine: machineFp, reason: error, configError: /inside the guarded repo|readable by other users/.test(error) ? error : null,
465
+ };
466
+ }
467
+
468
+ const presented = hashKey(resolved);
469
+ const match = activeKeys(man).find((k) => safeEqualHex(k.sha256, presented));
470
+
471
+ if (!match) {
472
+ return {
473
+ ok: false, code: 'deny', keyId: null, fingerprint: keyFingerprint(resolved),
474
+ machine: machineFp,
475
+ reason: `agent key rejected (presented fingerprint ${keyFingerprint(resolved)} from ${source}) — not in ${man.__file}`,
476
+ configError: null,
477
+ };
478
+ }
479
+
480
+ // Machine binding is checked only AFTER the key, so a wrong key and a wrong
481
+ // machine report differently (and neither reports the other's state).
482
+ if (man.machines.length > 0 && !man.machines.some((m) => safeEqualHex(String(m).toLowerCase(), machineFp.toLowerCase()))) {
483
+ return {
484
+ ok: false, code: 'deny', keyId: match.id || null, fingerprint: keyFingerprint(resolved),
485
+ machine: machineFp,
486
+ reason: `agent key "${match.id}" is valid but this machine (${machineFp}) is not bound in ${man.__file}`,
487
+ configError: null,
488
+ };
489
+ }
490
+
491
+ return {
492
+ ok: true, code: 'allow', keyId: match.id || null, fingerprint: keyFingerprint(resolved),
493
+ machine: machineFp, reason: `agent key "${match.id}" accepted${man.machines.length ? ' on a bound machine' : ' (no machine binding configured)'}`,
494
+ configError: null,
495
+ };
496
+ }
497
+
498
+ // ── the tool gate (the third caller, after the hooks and the CLI) ───────────
499
+
500
+ /**
501
+ * Decide whether a TOOL CALL may run against a gated tree. This is the
502
+ * in-process half of the same lock the hooks enforce at commit time, and the
503
+ * one the desktop app (`desktop/lib/local/tools.js`) and the engine
504
+ * (`aegiscodex-dev/src/repoguard.js`) both route through.
505
+ *
506
+ * Fail-closed, like the hooks: a gated path with no resolvable key, an
507
+ * unreadable manifest, or a key file that is world-readable is a refusal
508
+ * rather than a passthrough. Reads are never gated (`read: "open"`).
509
+ *
510
+ * `AEGIS_GATE_SKIP=1` is the single documented bypass — the same variable
511
+ * `.githooks/lib-gate.sh` honours — and it is REFUSED when `unattended` is
512
+ * true: an autonomous run has no one to answer for a bypass, so it cannot
513
+ * grant itself one.
514
+ *
515
+ * @returns {{ allowed: boolean, gated: boolean,
516
+ * code: 'allow'|'deny'|'not-gated'|'skipped',
517
+ * reason: string, tool: string, manifest: string|null, verdicts: object[] }}
518
+ */
519
+ export function guardTool({ tool, args = {}, cwd = process.cwd(), env = process.env, unattended = false } = {}) {
520
+ const base = { allowed: true, gated: false, code: 'not-gated', reason: 'no agent-gated tree in this call', tool, manifest: null, verdicts: [] };
521
+
522
+ if (env[SKIP_ENV] === '1') {
523
+ if (unattended) {
524
+ return { ...base, allowed: false, code: 'deny', reason: `refused: ${SKIP_ENV}=1 is not honoured in an unattended run — the gate is not skippable by an agent's own judgment` };
525
+ }
526
+ return { ...base, code: 'skipped', reason: `agent gate SKIPPED (${SKIP_ENV}=1) — this edit is unverified` };
527
+ }
528
+
529
+ let targets = [];
530
+ if (MUTATING_TOOL_NAMES.has(tool)) {
531
+ const target = args.file_path || args.notebook_path || args.path;
532
+ if (!target) return { ...base, reason: `no file path in this ${tool} call` };
533
+ targets = [path.resolve(cwd, String(target))];
534
+ } else if (BASH_TOOL_NAMES.has(tool)) {
535
+ const { writeish, paths } = bashTargets(args.command, cwd);
536
+ if (!writeish) return base;
537
+ targets = paths.map((p) => path.resolve(cwd, p));
538
+ } else {
539
+ return base;
540
+ }
541
+
542
+ const byManifest = new Map();
543
+ for (const abs of targets) {
544
+ const file = findManifest(abs);
545
+ if (!file) continue;
546
+ if (!byManifest.has(file)) byManifest.set(file, []);
547
+ byManifest.get(file).push(abs);
548
+ }
549
+ if (byManifest.size === 0) return base;
550
+
551
+ const verdicts = [];
552
+ let allowed = true;
553
+ let reason = '';
554
+ let manifestFile = null;
555
+ for (const [file, absPaths] of byManifest) {
556
+ let man;
557
+ try {
558
+ man = loadManifest(file);
559
+ } catch (e) {
560
+ return { ...base, allowed: false, gated: true, code: 'deny', configError: e.message, manifest: file, reason: `agent gate misconfigured: ${e.message}` };
561
+ }
562
+ const protectedPaths = protectedUnder(man, absPaths);
563
+ if (protectedPaths.length === 0) continue;
564
+ const verdict = verifyAgent({ manifest: man, cwd, env });
565
+ verdicts.push({ ...verdict, paths: protectedPaths });
566
+ manifestFile = man.__file;
567
+ if (verdict.ok) {
568
+ if (!reason) reason = `${verdict.reason} — ${protectedPaths.join(', ')}`;
569
+ continue;
570
+ }
571
+ if (man.mode === 'warn' && !verdict.configError) {
572
+ if (!reason) reason = `manifest mode is "warn" — reporting, not blocking: ${verdict.reason}`;
573
+ continue;
574
+ }
575
+ allowed = false;
576
+ reason = verdict.reason;
577
+ }
578
+
579
+ if (verdicts.length === 0) return base;
580
+ return { ...base, allowed, gated: true, code: allowed ? 'allow' : 'deny', reason: reason || 'agent-gated tree', manifest: manifestFile, verdicts };
581
+ }
582
+
583
+ /** Exit-code mapping for a `guardTool` result. 3 = denied, 4 = broken gate. */
584
+ export function exitCodeFor(verdict) {
585
+ if (verdict.allowed) return EXIT.ALLOW;
586
+ return (verdict.configError || verdict.verdicts?.some((v) => v.configError)) ? EXIT.CONFIG : EXIT.DENY;
587
+ }
588
+
589
+ /** The human-facing refusal, without the CLI's "how to fix" prose. */
590
+ export function refusalText(verdict) {
591
+ return `refused by the agent-key gate: ${verdict.reason}. This tree is agent-gated — a change to a protected path needs the agent key at $AEGIS_HOME/keys/agent.key (mode 600) or AEGIS_AGENT_KEY, on a machine bound in ${verdict.manifest || '.aegis/guard.json'}. Run \`node tools/agent-gate.mjs status\` to see what this machine can do.`;
592
+ }
593
+
594
+ // ── CLI ─────────────────────────────────────────────────────────────────────
595
+
596
+ const VERBS = ['status', 'check', 'init', 'lock-status', 'hash', 'add-key', 'rotate', 'bind-machine', 'import-key', 'install-hooks', 'help'];
597
+
598
+ // Flags that take a value. Anything else long-form is a boolean, so `--hooks`
599
+ // and `--force` work without a special case each.
600
+ const VALUE_FLAGS = new Set(['--cwd', '--manifest', '--label', '--mode', '--protect']);
601
+
602
+ function parseArgs(argv) {
603
+ const out = { verb: 'status', flags: {}, rest: [] };
604
+ const list = [...argv];
605
+ if (list.length && !list[0].startsWith('-')) out.verb = list.shift();
606
+ while (list.length) {
607
+ const a = list.shift();
608
+ if (a === '--paths') {
609
+ // Consumes the whole run of following non-flag arguments: `--paths a b c`
610
+ // is how both hooks call this, and a parser that ate only the first one
611
+ // would silently check one file and pass the rest through as prose.
612
+ const group = [];
613
+ while (list.length && !list[0].startsWith('--')) group.push(list.shift());
614
+ out.flags.paths = group.join(' ').split(/[\s,]+/).filter(Boolean);
615
+ } else if (VALUE_FLAGS.has(a)) {
616
+ out.flags[a.slice(2)] = list.shift();
617
+ } else if (a.startsWith('--')) out.flags[a.slice(2)] = true;
618
+ else out.rest.push(a);
619
+ }
620
+ return out;
621
+ }
622
+
623
+ function readStdin() {
624
+ try {
625
+ return fs.readFileSync(0, 'utf8').trim();
626
+ } catch {
627
+ return '';
628
+ }
629
+ }
630
+
631
+ function editManifest(file, mutate) {
632
+ const man = loadManifest(file);
633
+ const clean = { ...man };
634
+ delete clean.__file; delete clean.__root;
635
+ mutate(clean);
636
+ fs.writeFileSync(file, `${JSON.stringify(clean, null, 2)}\n`, 'utf8');
637
+ return clean;
638
+ }
639
+
640
+ // ── init: turn the gate ON for a repository that has none ───────────────────
641
+ //
642
+ // Every other verb edits a manifest that already exists. This is the one that
643
+ // creates one, and it exists because the alternative was hand-writing JSON from
644
+ // a template — which is how a repo ends up with a manifest whose key hash or
645
+ // machine entry is subtly wrong, i.e. a gate that denies its own owner. It
646
+ // therefore REFUSES to run without a resolvable key: `mode: "enforce"` with an
647
+ // empty key list denies every write, and the operator finds that out at the
648
+ // first commit rather than here.
649
+
650
+ const MANIFEST_COMMENT = [
651
+ 'The agent-key gate for this repository. Read tools/agent-gate.mjs\'s header before',
652
+ 'trusting anything here: this file is the RECEIPT, not the lock. It stops a copied',
653
+ 'tree, a borrowed laptop and a casual collaborator, and it makes a deliberate edit',
654
+ 'leave a trail. It cannot stop someone who has the bytes — they delete this file.',
655
+ 'The real lock is a GitHub ruleset on main: run `agent-gate.mjs lock-status`.',
656
+ '',
657
+ 'HASHES ONLY. Never put a key in this file: it is committed, and the repo\'s own',
658
+ 'pre-commit guard scans staged content for exactly that mistake. The key lives at',
659
+ '$AEGIS_HOME/keys/agent.key (mode 0600), outside the tree.',
660
+ '',
661
+ 'sha256 = sha256(\'aegiscode-agent-gate-v1\\n\' + key) — see KEY_DOMAIN in tools/agent-gate.mjs',
662
+ 'The engine re-implements this in aegiscodex-dev/src/repoguard.js; a shared test',
663
+ 'vector in aegiscode-plugin/test/agent-gate.test.mjs holds the two in step.',
664
+ ];
665
+
666
+ const HOOK_MARKER = '# ── agent-key gate';
667
+
668
+ function hookSection() {
669
+ // Self-contained on purpose: it defines nothing and calls nothing from the
670
+ // host hook except lib-gate.sh, so it drops into a pre-commit of any shape
671
+ // (this repo's uses `fail()`, aegis1's differs, a bare one has neither).
672
+ return [
673
+ '# ── agent-key gate (tools/agent-gate.mjs + .aegis/guard.json) ───────────────',
674
+ '# "Only the holder of the agent key may manipulate this code." Policy lives in',
675
+ '# .aegis/guard.json; the decision function lives in tools/agent-gate.mjs; this is',
676
+ '# the shell edge of it.',
677
+ '#',
678
+ '# LAST, deliberately: the path blocklist and the secret scan above are',
679
+ '# unconditional and must not become conditional on a key being present.',
680
+ '#',
681
+ '# Fail-closed: a missing gate CLI, missing node or unreadable manifest refuses.',
682
+ '# The one bypass is AEGIS_GATE_SKIP=1 — an env var, so it is greppable.',
683
+ '# shellcheck source=.githooks/lib-gate.sh',
684
+ '. "$(dirname "$0")/lib-gate.sh"',
685
+ 'if ! agent_gate_check_staged; then',
686
+ ' echo "✗ pre-commit blocked: the agent-key gate refused this commit (reason above)." >&2',
687
+ ' exit 1',
688
+ 'fi',
689
+ ].join('\n');
690
+ }
691
+
692
+ /** Vendor lib-gate.sh next to the hook and wire the section in, idempotently. */
693
+ function wireHook(root) {
694
+ const src = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '.githooks', 'lib-gate.sh');
695
+ if (!fs.existsSync(src)) throw new Error(`cannot vendor the hook library — ${src} does not exist`);
696
+ const hooksDir = path.join(root, '.githooks');
697
+ fs.mkdirSync(hooksDir, { recursive: true });
698
+ const lib = path.join(hooksDir, 'lib-gate.sh');
699
+ fs.copyFileSync(src, lib);
700
+ fs.chmodSync(lib, 0o755);
701
+
702
+ const hook = path.join(hooksDir, 'pre-commit');
703
+ let text = fs.existsSync(hook)
704
+ ? fs.readFileSync(hook, 'utf8')
705
+ : '#!/bin/sh\n# pre-commit guard — created by `agent-gate.mjs init --hooks`.\n\nset -u\n\nexit 0\n';
706
+ const already = text.includes(HOOK_MARKER);
707
+ if (!already) {
708
+ const lines = text.split('\n');
709
+ let at = -1;
710
+ for (let i = lines.length - 1; i >= 0; i -= 1) if (lines[i].trim() === 'exit 0') { at = i; break; }
711
+ const section = hookSection().split('\n');
712
+ if (at >= 0) lines.splice(at, 0, ...section, ''); else lines.push(...section, '');
713
+ text = lines.join('\n');
714
+ }
715
+ fs.writeFileSync(hook, text, 'utf8');
716
+ fs.chmodSync(hook, 0o755);
717
+ return { lib, hook, already };
718
+ }
719
+
720
+ function cmdInit({ flags }) {
721
+ const root = path.resolve(flags.cwd || process.cwd());
722
+ let stat = null;
723
+ try { stat = fs.statSync(root); } catch { /* reported below */ }
724
+ if (!stat || !stat.isDirectory()) { console.error(`✗ agent-gate: ${root} is not a directory`); return EXIT.CONFIG; }
725
+ const file = flags.manifest || path.join(root, MANIFEST_REL);
726
+ if (fs.existsSync(file) && !flags.force) {
727
+ console.error(`✗ agent-gate: ${file} already exists — refusing to overwrite it.`);
728
+ console.error(' Edit it with add-key / bind-machine / rotate, or pass --force to replace it outright.');
729
+ return EXIT.CONFIG;
730
+ }
731
+ const mode = flags.mode === undefined ? 'enforce' : String(flags.mode);
732
+ if (mode !== 'enforce' && mode !== 'warn') { console.error(`✗ agent-gate: --mode must be enforce or warn, got "${mode}"`); return EXIT.CONFIG; }
733
+ const protect = flags.protect ? String(flags.protect).split(/[\s,]+/).filter(Boolean) : ['**'];
734
+ if (protect.length === 0) protect.push('**');
735
+
736
+ const fromStdin = flags['key-stdin'] ? readStdin() : '';
737
+ const { key, source, error } = fromStdin
738
+ ? { key: fromStdin, source: 'stdin', error: null }
739
+ : resolveAgentKey({ env: process.env, manifestRoot: root });
740
+ if (!key) {
741
+ console.error(`✗ agent-gate: refusing to init without a key — ${error || 'no agent key found'}`);
742
+ console.error(' "enforce" with no active key denies EVERY write in the tree, mine and yours:');
743
+ console.error(' that is fail-closed, not something you should discover at the first commit.');
744
+ console.error(' Put a key in place first: agent-gate.mjs rotate (mints one, 0600, never printed)');
745
+ console.error(' or: agent-gate.mjs init --key-stdin < ~/.aegiscode/keys/agent.key');
746
+ return EXIT.CONFIG;
747
+ }
748
+
749
+ const id = flags.label || 'agent-key-1';
750
+ const machines = flags['no-bind'] ? [] : [machineFingerprint()];
751
+ const manifest = {
752
+ version: GATE_VERSION,
753
+ comment: MANIFEST_COMMENT,
754
+ mode,
755
+ read: 'open',
756
+ protect,
757
+ keys: [{
758
+ id,
759
+ label: flags.label || 'primary agent key',
760
+ sha256: hashKey(key),
761
+ status: 'active',
762
+ addedAt: new Date().toISOString(),
763
+ keySource: source,
764
+ }],
765
+ machines,
766
+ machinesNote: 'Machine binding is a speed bump, not identity: the fingerprint is a hash of public facts plus a per-install salt (keys/machine.salt), so it is reproducible on this install and not by guessing. Adding a machine is explicit (bind-machine); moving the key file does not carry the binding. `init` binds the machine that runs it, because the alternative is an operator locked out by their own manifest.',
767
+ };
768
+ fs.mkdirSync(path.dirname(file), { recursive: true });
769
+ fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
770
+
771
+ let hooks = null;
772
+ if (flags.hooks) {
773
+ try { hooks = wireHook(root); } catch (e) { console.error(`✗ agent-gate: ${e.message}`); return EXIT.CONFIG; }
774
+ }
775
+
776
+ console.error(`✓ agent-gate: wrote ${file}`);
777
+ console.error(` mode ${mode} · protects ${protect.join(' ')} · key "${id}" fingerprint ${keyFingerprint(key)} (${source})`);
778
+ console.error(machines.length
779
+ ? ` bound machine ${machines[0]}`
780
+ : ' NO machine bound (--no-bind): any machine holding the key file opens this gate');
781
+ if (hooks) console.error(`✓ vendored ${hooks.lib}${hooks.already ? ' (gate section already present)' : ` and inserted the gate section into ${hooks.hook}`}`);
782
+ else console.error(' hooks NOT wired — pass --hooks to vendor lib-gate.sh and wire pre-commit');
783
+ console.error(` finish: git -C ${root} config core.hooksPath .githooks`);
784
+ console.error(` verify: agent-gate.mjs status --cwd ${root} (expect ALLOW)`);
785
+ console.error(' prove: AEGIS_AGENT_KEY=wrong agent-gate.mjs check --cwd … (expect exit 3, not 0)');
786
+ return EXIT.ALLOW;
787
+ }
788
+
789
+ function cmdStatus({ flags }) {
790
+ const cwd = flags.cwd || process.cwd();
791
+ const verdict = verifyAgent({ cwd });
792
+ const file = verdict.configError ? (flags.manifest || findManifest(cwd)) : null;
793
+ let man = null;
794
+ try { man = loadManifest(file || findManifest(cwd)); } catch { /* reported below */ }
795
+
796
+ console.log(`agent-gate v${GATE_VERSION}`);
797
+ if (man) {
798
+ console.log(` manifest ${man.__file}`);
799
+ console.log(` root ${man.__root}`);
800
+ console.log(` protects ${man.protect.join(' ')}`);
801
+ console.log(` active keys ${activeKeys(man).map((k) => k.id || '(unnamed)').join(', ') || '(none)'}`);
802
+ const burned = man.keys.filter((k) => (k.status || 'active') !== 'active');
803
+ if (burned.length) console.log(` burned keys ${burned.map((k) => k.id || '(unnamed)').join(', ')}`);
804
+ console.log(` bound machines ${man.machines.length || '(none — any machine with the key)'}`);
805
+ } else {
806
+ console.log(` manifest none found at or above ${cwd}`);
807
+ }
808
+ console.log(` this machine ${verdict.machine}${machineSalt() ? '' : ' (UNSALTED — $AEGIS_HOME not writable)'}`);
809
+ console.log(` verdict ${verdict.ok ? 'ALLOW' : 'DENY'} — ${verdict.reason}`);
810
+ return verdict.ok ? EXIT.ALLOW : (verdict.configError ? EXIT.CONFIG : EXIT.DENY);
811
+ }
812
+
813
+ function cmdCheck({ flags }) {
814
+ const cwd = flags.cwd || process.cwd();
815
+ const file = flags.manifest || findManifest(cwd);
816
+ let man;
817
+ try {
818
+ man = loadManifest(file);
819
+ } catch (e) {
820
+ if (!flags.quiet) console.error(`✗ agent-gate: ${e.message}`);
821
+ return EXIT.CONFIG;
822
+ }
823
+
824
+ // Only protected paths gate the operation; a commit touching nothing
825
+ // protected (docs, a vendored file outside the globs) is not held hostage.
826
+ const paths = flags.paths && flags.paths.length ? flags.paths : null;
827
+ if (paths) {
828
+ const { protectedPaths } = partitionPaths(man, cwd, paths);
829
+ if (protectedPaths.length === 0) {
830
+ if (!flags.quiet) console.error('✓ agent-gate: no protected path in this change');
831
+ return EXIT.ALLOW;
832
+ }
833
+ }
834
+
835
+ const verdict = verifyAgent({ manifest: man, cwd });
836
+ if (verdict.ok) {
837
+ if (!flags.quiet) console.error(`✓ agent-gate: ${verdict.reason}`);
838
+ return EXIT.ALLOW;
839
+ }
840
+
841
+ // `mode: "warn"` is the rollout state: the gate reports and does not block,
842
+ // so a repo can adopt this without locking out a collaborator whose key has
843
+ // not been added yet. Policy lives HERE, not in the hooks, so the engine's
844
+ // repoguard inherits the identical rule instead of re-deriving it.
845
+ if (man.mode === 'warn' && !flags.strict && !verdict.configError) {
846
+ if (!flags.quiet) console.error(' (manifest mode is "warn" — reporting, not blocking; --strict to block anyway)');
847
+ return EXIT.ALLOW;
848
+ }
849
+
850
+ if (!flags.quiet) console.error(`✗ agent-gate: ${verdict.reason}`);
851
+ return verdict.configError ? EXIT.CONFIG : EXIT.DENY;
852
+ }
853
+
854
+ function cmdLockStatus() {
855
+ console.log(`agent-gate: the server-side half of the lock, which no local file can substitute.
856
+
857
+ Nothing in this repository can stop someone who has the bytes from editing
858
+ them. The only real lock is at the remote. Verify these, in this order:
859
+
860
+ 1. GitHub → Settings → Rules → Rulesets, target "main":
861
+ ☑ Restrict force pushes
862
+ ☑ Require a pull request before merging (≥1 approval)
863
+ ☑ Require signed commits (ties a push to a real key holder)
864
+ ☑ Block force pushes / deletions
865
+ 2. GitHub → Settings → Collaborators: who can push at all. Everyone in this
866
+ list can write the code regardless of the local gate.
867
+ 3. Deploy keys: read-only unless a push from CI is genuinely needed.
868
+ 4. Branch protection on any release branch, plus tag protection for v*.
869
+
870
+ A local clone that has never run \`agent-gate.mjs install-hooks\` has no
871
+ hooks and this gate does not run there at all. That is expected, and it is
872
+ exactly why (1) is the lock and this file is the receipt.`);
873
+ return EXIT.ALLOW;
874
+ }
875
+
876
+ function cmdHash({ flags }) {
877
+ const key = flags.stdin ? readStdin() : '';
878
+ if (!key) {
879
+ console.error('agent-gate: refusing to take a key from argv (it would land in `ps` and your shell history).\n echo -n "<key>" | agent-gate.mjs hash --stdin');
880
+ return EXIT.CONFIG;
881
+ }
882
+ console.log(JSON.stringify({ sha256: hashKey(key), fingerprint: keyFingerprint(key) }, null, 2));
883
+ return EXIT.ALLOW;
884
+ }
885
+
886
+ function cmdAddKey({ flags }) {
887
+ const file = flags.manifest || findManifest(flags.cwd || process.cwd());
888
+ let man;
889
+ try { man = loadManifest(file); } catch (e) { console.error(`✗ ${e.message}`); return EXIT.CONFIG; }
890
+
891
+ const fromStdin = readStdin();
892
+ const { key, source, error } = fromStdin
893
+ ? { key: fromStdin, source: 'stdin', error: null }
894
+ : resolveAgentKey({ env: process.env, manifestRoot: man.__root });
895
+ if (error) { console.error(`✗ ${error}`); return EXIT.CONFIG; }
896
+
897
+ const sha = hashKey(key);
898
+ if (activeKeys(man).some((k) => safeEqualHex(k.sha256, sha))) {
899
+ console.error(`= agent-gate: that key is already active (${keyFingerprint(key)})`);
900
+ return EXIT.ALLOW;
901
+ }
902
+ const id = flags.label || `key-${activeKeys(man).length + 1}`;
903
+ editManifest(file, (m) => {
904
+ m.keys.push({ id, sha256: sha, label: flags.label || id, status: 'active', addedAt: new Date().toISOString(), keySource: source });
905
+ });
906
+ console.error(`✓ agent-gate: added "${id}" (fingerprint ${keyFingerprint(key)}) to ${file}`);
907
+ return EXIT.ALLOW;
908
+ }
909
+
910
+ function cmdRotate({ flags }) {
911
+ const file = flags.manifest || findManifest(flags.cwd || process.cwd());
912
+ let man;
913
+ try { man = loadManifest(file); } catch (e) { console.error(`✗ ${e.message}`); return EXIT.CONFIG; }
914
+
915
+ const fresh = crypto.randomBytes(32).toString('base64url');
916
+ const written = writeAgentKey(fresh);
917
+ const sha = hashKey(fresh);
918
+
919
+ editManifest(file, (m) => {
920
+ for (const k of m.keys) if ((k.status || 'active') === 'active') { k.status = 'burned'; k.burnedAt = new Date().toISOString(); k.burnedReason = 'rotated'; }
921
+ m.keys.push({ id: flags.label || `key-${m.keys.length + 1}`, sha256: sha, label: 'rotated', status: 'active', addedAt: new Date().toISOString(), keySource: written });
922
+ if (m.machines.length === 0) m.machines.push(machineFingerprint());
923
+ });
924
+
925
+ // The new key is written to disk and never printed. Printing it would put a
926
+ // live credential into scrollback, shell history, CI logs and — the case
927
+ // that actually happened in this repo — a chat transcript.
928
+ console.error(`✓ agent-gate: rotated. New key at ${written} (mode 0600), fingerprint ${keyFingerprint(fresh)}.`);
929
+ console.error(` Previous active keys are marked burned in ${file}: the old value no longer opens the gate,`);
930
+ console.error(` including anywhere it was pasted or logged. To move the key to another machine, copy the FILE.`);
931
+ return EXIT.ALLOW;
932
+ }
933
+
934
+ function cmdBindMachine({ flags }) {
935
+ const file = flags.manifest || findManifest(flags.cwd || process.cwd());
936
+ try { loadManifest(file); } catch (e) { console.error(`✗ ${e.message}`); return EXIT.CONFIG; }
937
+ const fp = machineFingerprint();
938
+ editManifest(file, (m) => { if (!m.machines.some((x) => String(x).toLowerCase() === fp.toLowerCase())) m.machines.push(fp); });
939
+ console.error(`✓ agent-gate: bound machine ${fp} in ${file}`);
940
+ console.error(' A machine fingerprint is a hash of public facts plus a per-install salt: it identifies this');
941
+ console.error(' install, not a person. Moving the key FILE to another machine does not carry the binding —');
942
+ console.error(' that is the point, and it is why rotating on a new machine needs one explicit bind-machine.');
943
+ return EXIT.ALLOW;
944
+ }
945
+
946
+ /**
947
+ * Adopt a key from an arbitrary file — the point is a Downloads-folder drop
948
+ * ("I just moved this key to a new machine") without hand-running `cat | env`.
949
+ * Downloads is a STAGING spot, not where the live secret should sit: it's
950
+ * commonly cloud-synced, browser-permissioned, and indexed. So this installs
951
+ * the key at the real location ($AEGIS_HOME/keys/agent.key, mode 0600) and
952
+ * then deletes the source file — the opposite of making Downloads a place the
953
+ * gate trusts on an ongoing basis. `--keep` skips the delete for anyone who
954
+ * wants to move it manually instead.
955
+ */
956
+ function cmdImportKey({ flags, rest }) {
957
+ const src = (rest && rest[0]) ? path.resolve(rest[0]) : path.join(os.homedir(), 'Downloads', 'agent.key');
958
+ let raw;
959
+ try {
960
+ raw = fs.readFileSync(src, 'utf8');
961
+ } catch (e) {
962
+ console.error(`✗ agent-gate: cannot read ${src}: ${e.message}`);
963
+ console.error(' Pass a path explicitly: agent-gate.mjs import-key /path/to/agent.key');
964
+ return EXIT.CONFIG;
965
+ }
966
+ const key = raw.split('\n')[0].trim();
967
+ if (!key) { console.error(`✗ agent-gate: ${src} is empty`); return EXIT.CONFIG; }
968
+
969
+ let srcMode = null;
970
+ try { srcMode = fs.statSync(src).mode & 0o777; } catch { /* unreadable already reported above */ }
971
+ if (srcMode !== null && (srcMode & 0o077)) {
972
+ console.error(`⚠ agent-gate: ${src} was mode 0${srcMode.toString(8)} (group/other-readable) — importing anyway,`);
973
+ console.error(' but treat that key as potentially exposed to anything else with access to that folder.');
974
+ }
975
+
976
+ const dest = writeAgentKey(key);
977
+ console.error(`✓ agent-gate: installed key (fingerprint ${keyFingerprint(key)}) at ${dest} (mode 0600)`);
978
+
979
+ if (!flags.keep) {
980
+ try {
981
+ fs.unlinkSync(src);
982
+ console.error(`✓ agent-gate: removed ${src} — the live copy now lives only at ${dest}`);
983
+ } catch (e) {
984
+ console.error(`⚠ agent-gate: could not remove ${src} (${e.message}) — delete it yourself, it should not sit there long-term`);
985
+ }
986
+ } else {
987
+ console.error(` --keep set: left ${src} in place. Downloads is not a safe long-term home for this file.`);
988
+ }
989
+
990
+ const cwd = flags.cwd || process.cwd();
991
+ const verdict = verifyAgent({ cwd });
992
+ console.error(` this machine ${verdict.machine}`);
993
+ console.error(` verdict ${verdict.ok ? 'ALLOW' : 'DENY'} — ${verdict.reason}`);
994
+ if (!verdict.ok && !verdict.configError) {
995
+ console.error(` the key imported fine; this machine likely still needs: agent-gate.mjs bind-machine --cwd ${cwd}`);
996
+ }
997
+ return verdict.ok ? EXIT.ALLOW : (verdict.configError ? EXIT.CONFIG : EXIT.DENY);
998
+ }
999
+
1000
+ function cmdInstallHooks({ flags }) {
1001
+ const root = flags.cwd || process.cwd();
1002
+ const hooksDir = path.join(root, '.githooks');
1003
+ if (!fs.existsSync(path.join(hooksDir, 'pre-commit'))) {
1004
+ console.error(`✗ agent-gate: no .githooks/pre-commit under ${root}`);
1005
+ return EXIT.CONFIG;
1006
+ }
1007
+ for (const name of ['pre-commit', 'pre-push']) {
1008
+ const p = path.join(hooksDir, name);
1009
+ if (fs.existsSync(p)) fs.chmodSync(p, 0o755);
1010
+ }
1011
+ console.error(` run: git -C ${root} config core.hooksPath .githooks`);
1012
+ console.error(' (not run automatically: this tool does not mutate git config without being asked)');
1013
+ return EXIT.ALLOW;
1014
+ }
1015
+
1016
+ function cmdHelp() {
1017
+ console.log(`agent-gate — the agent-key gate for this repository.
1018
+
1019
+ agent-gate.mjs status [--cwd DIR] [--manifest FILE]
1020
+ agent-gate.mjs check [--cwd DIR] [--manifest FILE] [--paths a b c] [--quiet]
1021
+ agent-gate.mjs init [--cwd DIR] [--mode enforce|warn] [--protect GLOB,…]
1022
+ [--label NAME] [--key-stdin] [--no-bind] [--hooks] [--force]
1023
+ agent-gate.mjs lock-status
1024
+ agent-gate.mjs hash --stdin
1025
+ agent-gate.mjs add-key [--label NAME] (key from stdin, else the key file)
1026
+ agent-gate.mjs rotate
1027
+ agent-gate.mjs bind-machine
1028
+ agent-gate.mjs import-key [PATH] [--keep] [--cwd DIR]
1029
+ (default PATH: ~/Downloads/agent.key; installs it at
1030
+ $AEGIS_HOME/keys/agent.key mode 0600, then deletes PATH
1031
+ unless --keep, then prints this machine's verdict)
1032
+ agent-gate.mjs install-hooks [--cwd DIR]
1033
+
1034
+ Exit codes: 0 allowed, 3 denied, 4 misconfiguration.`);
1035
+ return EXIT.ALLOW;
1036
+ }
1037
+
1038
+ const COMMANDS = {
1039
+ status: cmdStatus, check: cmdCheck, init: cmdInit, 'lock-status': cmdLockStatus, hash: cmdHash,
1040
+ 'add-key': cmdAddKey, rotate: cmdRotate, 'bind-machine': cmdBindMachine,
1041
+ 'import-key': cmdImportKey, 'install-hooks': cmdInstallHooks, help: cmdHelp,
1042
+ };
1043
+
1044
+ /**
1045
+ * Run the CLI. Returns the exit code instead of calling process.exit, so the
1046
+ * test file can drive every verb in-process and assert the code.
1047
+ */
1048
+ export function run(argv = process.argv.slice(2)) {
1049
+ const parsed = parseArgs(argv);
1050
+ if (!VERBS.includes(parsed.verb)) {
1051
+ console.error(`✗ agent-gate: unknown verb "${parsed.verb}" — try "help"`);
1052
+ return EXIT.CONFIG;
1053
+ }
1054
+ try {
1055
+ return COMMANDS[parsed.verb](parsed);
1056
+ } catch (e) {
1057
+ console.error(`✗ agent-gate: ${e.message}`);
1058
+ return EXIT.CONFIG;
1059
+ }
1060
+ }
1061
+
1062
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
1063
+ if (invokedDirectly) process.exit(run());