aegis-desktop 0.8.13 → 0.8.14

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.
@@ -90,6 +90,84 @@ const LEGACY_PLAN_ALIASES = Object.freeze({
90
90
  const PRO_VALUE =
91
91
  'Pro puts the drain in the cloud, so the job keeps running after you close the laptop';
92
92
 
93
+ /**
94
+ * The honesty line — Phase 3's rule, and the second half of the same claim:
95
+ * everything that runs on the user's own machine stays free forever, and the
96
+ * cloud drain is the thing being sold. Both hosts that print a paid prompt
97
+ * print this beside it (the desktop keeps its own literal in
98
+ * `desktop/renderer/app.js`; the terminal reads it from here), because the
99
+ * promise without the honesty line is the four-bullet feature list again.
100
+ *
101
+ * Kept byte-identical to the renderer's `FREE_HONESTY`, and asserted verbatim
102
+ * by both hosts' tests, so the terminal and the GUI cannot come to disagree
103
+ * about what is free — a disagreement that would either overcharge a promise
104
+ * or undercut the offer, depending on which copy a user believed.
105
+ */
106
+ const FREE_HONESTY =
107
+ 'Everything on your machine is free, permanently. The cloud drain is what you pay for.';
108
+
109
+ /**
110
+ * The BYOK lane's price, in words — built from the SERVER's published rate.
111
+ *
112
+ * The desktop README says the byok lane is "billed a flat handling fee" and
113
+ * aegis1 charges exactly that (services/pricing.price_byok_call), but no client
114
+ * surface said so: a user who pasted their own Anthropic key into AEGIS read
115
+ * "your provider's cost" and never learned AEGIS adds a fee. That is the one
116
+ * place in this product where the bill and the pitch disagreed, and it is the
117
+ * copy the desktop session flagged as an unresolved honesty conflict.
118
+ *
119
+ * `fee` is aegis1's own `/api/v1/byok/providers` payload — a live payload, never
120
+ * a hardcoded number, for the reason the engine already states: a client that
121
+ * hardcodes a fee is a client that can disagree with the ledger. No published
122
+ * fee (old server, offline, unauthenticated read that returned nothing) returns
123
+ * '' and every host then says NOTHING about the price, which is the only honest
124
+ * alternative to inventing one.
125
+ *
126
+ * `require_account` / `anonymous_daily_calls` come from the same payload and
127
+ * describe enforcement, not price: with the relay requiring an AEGIS account the
128
+ * sentence has to say so, or the user meets a 402 the client never warned about
129
+ * — the exact failure tests/test_byok_fee.py::test_the_published_gate_warning_is
130
+ * _true_whenever_the_gate_can_402 exists to prevent.
131
+ */
132
+ const BYOK_FEE_NOTE =
133
+ 'Bring your own key is relayed through AEGIS, not dialled from here — the routing, prompt assembly and caching are billed as a flat handling fee, on top of your provider\'s own bill.';
134
+
135
+ /** Per-million-token rendering of a published per-1k rate. */
136
+ function ratePerMillion(usdPer1k) {
137
+ return `$${(Number(usdPer1k) * 1000).toFixed(2)}/M`;
138
+ }
139
+
140
+ function byokFeeLine(fee) {
141
+ const f = fee && typeof fee === 'object' ? fee : null;
142
+ if (!f) return '';
143
+ const inRate = Number(f.in_usd_per_1k);
144
+ const outRate = Number(f.out_usd_per_1k);
145
+ if (!Number.isFinite(inRate) || !Number.isFinite(outRate)) return '';
146
+ if (f.disabled === true) {
147
+ // The deployment switched the fee off; saying "free" is then the truth
148
+ // rather than a promise this client cannot keep.
149
+ return 'Bring your own key is relayed free on this deployment right now.';
150
+ }
151
+ // The price clause stands alone when the server published no enforcement
152
+ // detail — which is every server deployed before this change, and the
153
+ // default-off gate after it. Joined with ' — ' only when there IS a second
154
+ // clause: a sentence-ending dash with nothing after it ("…provider's bill —.")
155
+ // is what the first live run of this line actually printed.
156
+ const price = `Bring your own key: relayed through AEGIS, handled at `
157
+ + `${ratePerMillion(inRate)} in / ${ratePerMillion(outRate)} out, `
158
+ + `on top of your provider's bill`;
159
+ let clause = '';
160
+ if (f.require_account === true) {
161
+ clause = 'and needs an AEGIS account key (X-AEGIS-Key) for the fee to be billed';
162
+ } else {
163
+ const cap = Number(f.anonymous_daily_calls);
164
+ if (Number.isFinite(cap) && cap > 0) {
165
+ clause = `with ${cap} relayed turns/24h before an AEGIS account is required`;
166
+ }
167
+ }
168
+ return clause ? `${price} — ${clause}.` : `${price}.`;
169
+ }
170
+
93
171
  /** Campaign id for this prompt (Phase 1's fixed ids; aegis1 keeps the slug). */
94
172
  const CAMPAIGN = 'queue_cap';
95
173
 
@@ -478,6 +556,9 @@ module.exports = {
478
556
  BALANCE_CAMPAIGN,
479
557
  BALANCE_URL,
480
558
  PRO_VALUE,
559
+ FREE_HONESTY,
560
+ BYOK_FEE_NOTE,
561
+ byokFeeLine,
481
562
  DEFAULT_TTL_MS,
482
563
  // Phase 7: the host seam + the builder the CLI binds itself to.
483
564
  SIGNUP_SOURCES,
@@ -42,6 +42,7 @@ const { spawn } = require('node:child_process');
42
42
  const crypto = require('node:crypto');
43
43
  const fs = require('node:fs');
44
44
  const path = require('node:path');
45
+ const { pathToFileURL } = require('node:url');
45
46
  const { agentRoles } = require('./agents.js');
46
47
 
47
48
  const OUTPUT_CAP = 30_000; // chars fed back to the model per tool result
@@ -770,9 +771,121 @@ function isTool(name) {
770
771
  * all land on the error branch rather than rejecting. `ctx` carries per-turn
771
772
  * state (getShell, signal); omit it and exec falls back to a one-shot spawn.
772
773
  */
774
+ /**
775
+ * `tools/agent-gate.mjs` is ESM and this module is CJS, so it is imported
776
+ * dynamically and once. The candidates are tried in order; the first that
777
+ * resolves wins:
778
+ *
779
+ * 1. `../../../tools/agent-gate.mjs` — the repo checkout (dev / CI). This is
780
+ * the SAME file the git hooks execute, so "what is allowed" is computed
781
+ * once, by one implementation.
782
+ * 2. `<process.resourcesPath>/tools/agent-gate.mjs` — where electron-builder's
783
+ * `extraResources` puts it in a packaged build, as a real file OUTSIDE the
784
+ * asar. That placement is load-bearing, not cosmetic: the gate is loaded
785
+ * with dynamic `import()`, and Node's ESM loader does not go through
786
+ * Electron's asar-aware fs patch (electron/asar#249), so a copy living only
787
+ * inside the archive can fail to resolve — leaving the packaged app with no
788
+ * gate at all. This is the app's only dynamic ESM import, so nothing else
789
+ * here exercises that path. Conveniently this is also what candidate (1)
790
+ * resolves to from `resources/app.asar/lib/local/`, so dev and packaged
791
+ * builds agree on one file.
792
+ * 3. `../../vendor/agent-gate.mjs` — the staged copy inside the app dir, kept
793
+ * as the last resort for a build that is not archived (`asar: false`) or an
794
+ * npm-installed tree. It is the candidate that fails inside an asar, hence
795
+ * third and not first.
796
+ *
797
+ * Before (2) existed a packaged build had no gate at all: the manifest is found
798
+ * by climbing from the working directory, so the packaged app would open a
799
+ * guarded tree and every write through it succeeded while the same call through
800
+ * the repo copy was refused.
801
+ *
802
+ * If nothing resolves, that is a broken gate rather than a permissive one, and
803
+ * executeTool refuses mutating calls into a tree that declares a manifest.
804
+ */
805
+ let agentGatePromise;
806
+
807
+ function agentGateCandidates({ resourcesPath = process.resourcesPath, dirname = __dirname } = {}) {
808
+ const candidates = ['../../../tools/agent-gate.mjs'];
809
+ // process.resourcesPath is an Electron-provided value and is absent under
810
+ // plain node, so this is guarded rather than assumed. An absolute path must
811
+ // be a file URL to be imported.
812
+ if (typeof resourcesPath === 'string' && resourcesPath) {
813
+ candidates.push(pathToFileURL(path.join(resourcesPath, 'tools', 'agent-gate.mjs')).href);
814
+ }
815
+ candidates.push('../../vendor/agent-gate.mjs');
816
+ return candidates;
817
+ }
818
+
819
+ function loadAgentGate() {
820
+ if (!agentGatePromise) {
821
+ const candidates = agentGateCandidates();
822
+ const tryNext = (i) => (i >= candidates.length
823
+ ? null
824
+ : import(candidates[i]).catch(() => tryNext(i + 1)));
825
+ agentGatePromise = tryNext(0);
826
+ }
827
+ return agentGatePromise;
828
+ }
829
+
830
+ /**
831
+ * The nearest `.aegis/guard.json` at or above `start`, or null. The identical
832
+ * climb as findManifest() in tools/agent-gate.mjs and src/repoguard.js. It is
833
+ * used ONLY to decide whether a call that could not load the gate module is
834
+ * unsafe; when the module loads it is the sole authority.
835
+ */
836
+ function nearestManifest(start) {
837
+ let dir = path.resolve(start || process.cwd());
838
+ try { if (fs.statSync(dir).isFile()) dir = path.dirname(dir); } catch { /* keep as-is */ }
839
+ for (;;) {
840
+ const candidate = path.join(dir, '.aegis', 'guard.json');
841
+ if (fs.existsSync(candidate)) return candidate;
842
+ const parent = path.dirname(dir);
843
+ if (parent === dir) return null;
844
+ dir = parent;
845
+ }
846
+ }
847
+
773
848
  async function executeTool(name, args, ctx) {
774
849
  if (!isTool(name)) return fail(`unknown tool "${name}" (known: ${toolNames().join(', ')})`);
775
850
  const input = args && typeof args === 'object' ? args : {};
851
+ // ── agent-key gate (tools/agent-gate.mjs) ──────────────────────────────
852
+ // The in-process half of the lock whose other half is .githooks/. A tree
853
+ // that declares .aegis/guard.json must not be mutated through this app
854
+ // without the key, exactly as it cannot be mutated by the engine or
855
+ // committed by git. Reads are never gated. Refused — not warned — when the
856
+ // key is absent, the manifest is unreadable, or the key file is
857
+ // world-readable; AEGIS_GATE_SKIP=1 is the one bypass, and it is refused in
858
+ // an unattended run. The module is required lazily so a packaged app whose
859
+ // tree has no tools/ directory still starts and simply has no gate.
860
+ let gate = null;
861
+ try {
862
+ gate = await loadAgentGate();
863
+ } catch { /* not running from the repo: no manifest to honour */ }
864
+ if (gate) {
865
+ const verdict = gate.guardTool({
866
+ tool: name, args: input, cwd: (ctx && ctx.cwd) || process.cwd(),
867
+ env: process.env, unattended: Boolean(ctx && ctx.autonomous),
868
+ });
869
+ if (!verdict.allowed) return fail(gate.refusalText(verdict));
870
+ if (verdict.code === 'skipped') {
871
+ const res = await EXECUTORS[name](input, ctx || {});
872
+ return res && typeof res === 'object' ? { ...res, output: `⚠ ${verdict.reason}\n${res.output || ''}` } : res;
873
+ }
874
+ } else if (MUTATING_TOOLS.has(name)) {
875
+ // Neither the repo copy nor the staged one loaded. A broken gate must
876
+ // never silently allow (the hooks exit 4 for exactly this reason), so a
877
+ // mutating call into a tree that declares a manifest is refused with the
878
+ // path of the manifest that could not be honoured.
879
+ const man = nearestManifest((ctx && ctx.cwd) || process.cwd());
880
+ if (man) {
881
+ return fail(
882
+ `error: refused — ${man} declares an agent-gated tree, but the agent-key gate module could not be loaded `
883
+ + '(tried tools/agent-gate.mjs beside the app and vendor/agent-gate.mjs inside it), so this '
884
+ + `${name} cannot be authorised. A build that missed the staged copy is the usual cause: `
885
+ + 'run scripts/predist.mjs before packaging.',
886
+ );
887
+ }
888
+ }
776
889
  try {
777
890
  return await EXECUTORS[name](input, ctx || {});
778
891
  } catch (e) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.8.13",
4
+ "version": "0.8.14",
5
5
  "description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
@@ -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());