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.
- package/lib/local/entitlement.js +81 -0
- package/lib/local/local.js +54 -2
- package/lib/local/tools.js +113 -0
- package/main.js +22 -5
- package/package.json +1 -1
- package/renderer/app.js +94 -8
- package/renderer/transcript-view.js +70 -5
- package/vendor/aegis.js +118 -6
- package/vendor/agent-gate.mjs +1063 -0
|
@@ -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());
|