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.
- package/lib/local/entitlement.js +81 -0
- package/lib/local/tools.js +113 -0
- package/package.json +1 -1
- package/vendor/agent-gate.mjs +1063 -0
package/lib/local/entitlement.js
CHANGED
|
@@ -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,
|
package/lib/local/tools.js
CHANGED
|
@@ -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.
|
|
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());
|