@cirvix_ai/agent-control 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +42 -0
  3. package/README.md +341 -0
  4. package/action/README.md +100 -0
  5. package/action/action.yml +134 -0
  6. package/action/report.mjs +144 -0
  7. package/bin/cirvix.mjs +1073 -0
  8. package/package.json +60 -0
  9. package/src/commands/demo.mjs +315 -0
  10. package/src/commands/init.mjs +558 -0
  11. package/src/commands/policy.mjs +345 -0
  12. package/src/commands/sarif.mjs +176 -0
  13. package/src/commands/scan.mjs +210 -0
  14. package/src/commands/status.mjs +208 -0
  15. package/src/commands/upgrade.mjs +162 -0
  16. package/src/core/approvals.mjs +388 -0
  17. package/src/core/audit.mjs +181 -0
  18. package/src/core/canonical.mjs +316 -0
  19. package/src/core/daemon.mjs +352 -0
  20. package/src/core/decisions.mjs +253 -0
  21. package/src/core/delegation.mjs +658 -0
  22. package/src/core/detect.mjs +337 -0
  23. package/src/core/entitlement-gate.mjs +100 -0
  24. package/src/core/entitlements.mjs +285 -0
  25. package/src/core/format.mjs +33 -0
  26. package/src/core/gateway.mjs +959 -0
  27. package/src/core/guard.mjs +568 -0
  28. package/src/core/http-transport.mjs +505 -0
  29. package/src/core/journal.mjs +419 -0
  30. package/src/core/jsonrpc.mjs +152 -0
  31. package/src/core/meter.mjs +225 -0
  32. package/src/core/normalize.mjs +516 -0
  33. package/src/core/notices.mjs +80 -0
  34. package/src/core/pipeline.mjs +629 -0
  35. package/src/core/policy-dsl.mjs +611 -0
  36. package/src/core/policy.mjs +710 -0
  37. package/src/core/prompts.mjs +146 -0
  38. package/src/core/risk.mjs +509 -0
  39. package/src/core/sanitize.mjs +279 -0
  40. package/src/core/secret-detect.mjs +533 -0
  41. package/src/core/secrets.mjs +312 -0
  42. package/src/core/uds.mjs +383 -0
  43. package/src/core/vault.mjs +530 -0
  44. package/src/index.mjs +143 -0
  45. package/src/testing.mjs +145 -0
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Canonicalization — collapsing every spelling of a thing to one string.
3
+ *
4
+ * This module exists because two adversarial findings had the same root cause:
5
+ * the policy engine matched rules against the string an agent *wrote*, while
6
+ * the risk engine and the operating system resolved it to something else.
7
+ * Wherever those two disagree, a rule that reads correctly does not fire.
8
+ *
9
+ * FINDING 1 — ALTERNATE IP REPRESENTATIONS
10
+ *
11
+ * deny: network.destination = 169.254.169.254
12
+ *
13
+ * did not match `http://2852039166/latest/meta-data/`, nor the octal, hex, or
14
+ * dotted-hex forms. All four resolve to the same address, and every HTTP client
15
+ * in existence connects to it. The risk engine already scored them CRITICAL —
16
+ * because it read the hostname through `new URL()`, which normalizes them —
17
+ * but the policy condition tested the raw string, so the deny rule watched the
18
+ * attack go past.
19
+ *
20
+ * FINDING 2 — PERCENT-ENCODED PATHS
21
+ *
22
+ * `~%2F.aws%2Fcredentials` contains no literal separator, so path
23
+ * canonicalization treated it as a bare filename, `insideWorkspace` returned
24
+ * true, and a workspace-read rule permitted a credential read.
25
+ *
26
+ * THE PRINCIPLE
27
+ *
28
+ * Canonicalize toward what the *receiving system* will do, not toward what the
29
+ * string looks like. An MCP server handed `~%2F.aws%2Fcredentials` may well
30
+ * decode it; an HTTP client handed `http://0xA9FEA9FE/` certainly connects to
31
+ * link-local. When a spelling is ambiguous, the safe reading is the one that
32
+ * reaches the sensitive resource — because that is the reading an attacker is
33
+ * relying on.
34
+ */
35
+
36
+ import { homedir } from "node:os";
37
+
38
+ /* -------------------------------------------------------------------------- */
39
+ /* Hosts and URLs */
40
+ /* -------------------------------------------------------------------------- */
41
+
42
+ /**
43
+ * Normalizes a URL to its canonical form.
44
+ *
45
+ * Uses the WHATWG parser rather than hand-rolled parsing, because the whole
46
+ * point is to agree with what an HTTP client does, and `new URL()` *is* the
47
+ * algorithm those clients implement. It resolves every IPv4 spelling —
48
+ * decimal, octal, hex, dotted-hex, and mixed — lowercases the host, strips a
49
+ * trailing dot, drops the fragment, and normalizes the path.
50
+ *
51
+ * Credentials in the authority (`http://evil.com@169.254.169.254/`) are
52
+ * DROPPED, not preserved: the userinfo is not where the request goes, and
53
+ * leaving it in lets a destination rule be defeated by prefixing a host that
54
+ * looks innocuous.
55
+ *
56
+ * @returns {string|null} the canonical URL, or null if it is not one
57
+ */
58
+ export function canonicalUrl(value) {
59
+ if (typeof value !== "string" || !/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return null;
60
+ let url;
61
+ try {
62
+ url = new URL(value);
63
+ } catch {
64
+ return null;
65
+ }
66
+
67
+ const host = canonicalHost(url.hostname) ?? url.hostname.toLowerCase();
68
+ const port = url.port ? `:${url.port}` : "";
69
+ const path = url.pathname.replace(/\/$/, "");
70
+ return `${url.protocol.toLowerCase()}//${host}${port}${path}${url.search}`;
71
+ }
72
+
73
+ /**
74
+ * Normalizes a hostname.
75
+ *
76
+ * `new URL()` already collapses numeric IPv4 forms, so the heavy lifting is
77
+ * done by the time this sees the value. What remains is the handful of things
78
+ * the URL parser preserves and a policy should not distinguish:
79
+ *
80
+ * - a trailing dot (`metadata.google.internal.` is the same name)
81
+ * - bracketed IPv6, which is syntax rather than identity
82
+ * - IPv4-mapped IPv6 (`::ffff:169.254.169.254`), which routes to the v4 address
83
+ *
84
+ * @returns {string|null}
85
+ */
86
+ export function canonicalHost(value) {
87
+ if (typeof value !== "string" || !value) return null;
88
+ let host = value.trim().toLowerCase();
89
+
90
+ // A fully-qualified name with the root label spelled out.
91
+ host = host.replace(/\.+$/, "");
92
+
93
+ // IPv6 literals arrive bracketed from a URL and bare from a config.
94
+ const bare = host.replace(/^\[|\]$/g, "");
95
+
96
+ // IPv4-mapped and IPv4-compatible IPv6 route to the embedded v4 address, so a
97
+ // rule naming that address must match them.
98
+ const mapped = bare.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i) ?? bare.match(/^::(\d{1,3}(?:\.\d{1,3}){3})$/);
99
+ if (mapped) return mapped[1];
100
+
101
+ // The same address after a URL parser has been at it.
102
+ //
103
+ // `new URL("http://[::ffff:169.254.169.254]/")` yields the hostname
104
+ // `::ffff:a9fe:a9fe` — the dotted form is gone by the time this is called
105
+ // from `canonicalUrl`, so matching only the dotted spelling above left the
106
+ // bracketed URL form as a live bypass.
107
+ const hexMapped = bare.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
108
+ if (hexMapped) {
109
+ const high = parseInt(hexMapped[1], 16);
110
+ const low = parseInt(hexMapped[2], 16);
111
+ return [(high >> 8) & 255, high & 255, (low >> 8) & 255, low & 255].join(".");
112
+ }
113
+
114
+ // A numeric form the URL parser did not see because it arrived as a bare
115
+ // host rather than inside a URL.
116
+ const numeric = numericToIpv4(bare);
117
+ if (numeric) return numeric;
118
+
119
+ return bare;
120
+ }
121
+
122
+ /**
123
+ * Resolves a numeric host spelling to dotted-quad, or null.
124
+ *
125
+ * Implements the historical `inet_aton` forms that every resolver still
126
+ * accepts: one 32-bit number, two parts, three parts, or four — each part in
127
+ * decimal, octal (leading zero), or hex (leading 0x).
128
+ *
129
+ * Written out rather than delegated to `new URL()` because a bare host string
130
+ * from a config file or a `host:port` argument never passes through a URL
131
+ * parser, and that is exactly where a destination rule is written.
132
+ */
133
+ export function numericToIpv4(value) {
134
+ const s = String(value ?? "").trim();
135
+ if (!/^[0-9a-fx.]+$/i.test(s) || s === "") return null;
136
+
137
+ const parts = s.split(".");
138
+ if (parts.length > 4 || parts.some((p) => p === "")) return null;
139
+
140
+ const nums = [];
141
+ for (const part of parts) {
142
+ let n;
143
+ if (/^0x[0-9a-f]+$/i.test(part)) n = parseInt(part, 16);
144
+ else if (/^0[0-7]+$/.test(part)) n = parseInt(part, 8);
145
+ else if (/^\d+$/.test(part)) n = parseInt(part, 10);
146
+ else return null;
147
+ if (!Number.isFinite(n) || n < 0) return null;
148
+ nums.push(n);
149
+ }
150
+
151
+ // A dotted-quad of plain decimals is already canonical; returning it
152
+ // unchanged keeps `canonicalHost` idempotent.
153
+ if (nums.length === 4 && nums.every((n) => n <= 255) && /^\d+(\.\d+){3}$/.test(s)) {
154
+ return nums.join(".");
155
+ }
156
+
157
+ // The inet_aton packing rules: the last part absorbs the remaining octets.
158
+ const maxLast = 2 ** (8 * (4 - nums.length + 1));
159
+ if (nums.slice(0, -1).some((n) => n > 255)) return null;
160
+ if (nums[nums.length - 1] >= maxLast) return null;
161
+
162
+ let packed = 0;
163
+ for (let i = 0; i < nums.length - 1; i++) packed |= nums[i] << (8 * (3 - i));
164
+ packed = (packed >>> 0) + nums[nums.length - 1];
165
+ packed = packed >>> 0;
166
+
167
+ return [(packed >>> 24) & 255, (packed >>> 16) & 255, (packed >>> 8) & 255, packed & 255].join(".");
168
+ }
169
+
170
+ /* -------------------------------------------------------------------------- */
171
+ /* Paths */
172
+ /* -------------------------------------------------------------------------- */
173
+
174
+ /** How many times to decode. Bounded so a decode bomb cannot spin. */
175
+ const MAX_DECODE_PASSES = 3;
176
+
177
+ /**
178
+ * Percent-decodes a path until it stops changing.
179
+ *
180
+ * Repeated because double-encoding is the obvious next move once single
181
+ * encoding is handled: `%252e%252f` decodes to `%2e%2f` decodes to `./`.
182
+ * Bounded at three passes — deeper nesting is not a spelling anyone uses by
183
+ * accident, and an unbounded loop on attacker-controlled input is its own bug.
184
+ *
185
+ * A value that fails to decode is returned as-is rather than thrown on: a
186
+ * stray `%` in a legitimate filename is far more common than an attack, and
187
+ * refusing to canonicalize is not the same as refusing the call.
188
+ */
189
+ export function decodePath(value) {
190
+ let current = String(value ?? "");
191
+ for (let i = 0; i < MAX_DECODE_PASSES; i++) {
192
+ if (!/%[0-9a-f]{2}/i.test(current)) break;
193
+ let next;
194
+ try {
195
+ next = decodeURIComponent(current);
196
+ } catch {
197
+ // Malformed escapes: decode what we can, character by character, so a
198
+ // single bad sequence does not shield the rest of the string.
199
+ next = current.replace(/%([0-9a-f]{2})/gi, (m, hex) => {
200
+ const code = parseInt(hex, 16);
201
+ return code === 0 ? "" : String.fromCharCode(code);
202
+ });
203
+ }
204
+ if (next === current) break;
205
+ current = next;
206
+ }
207
+ return current;
208
+ }
209
+
210
+ /**
211
+ * Unicode look-alikes for the characters that matter in a path.
212
+ *
213
+ * A policy matching `.aws` should not be defeated by writing it with U+FF0E
214
+ * FULLWIDTH FULL STOP or U+2024 ONE DOT LEADER. These are folded to their
215
+ * ASCII equivalents *for matching only* — the resource recorded in the audit
216
+ * chain keeps the folded form so the record and the decision agree, and the
217
+ * original is never silently rewritten anywhere it would be executed.
218
+ */
219
+ const HOMOGLYPHS = new Map([
220
+ [".", "."], // fullwidth full stop
221
+ ["․", "."], // one dot leader
222
+ ["。", "."], // ideographic full stop
223
+ ["/", "/"], // fullwidth solidus
224
+ ["∕", "/"], // division slash
225
+ ["⧸", "/"], // big solidus
226
+ ["\", "\\"], // fullwidth reverse solidus
227
+ ["∖", "\\"], // set minus
228
+ ["~", "~"], // fullwidth tilde
229
+ ["∼", "~"], // tilde operator
230
+ ]);
231
+
232
+ /** Characters removed outright: they are invisible and never legitimate here. */
233
+ const INVISIBLE = /[\u200B-\u200F\u2028\u2029\u202A-\u202E\u2060-\u2064\uFEFF\u00AD]/g;
234
+
235
+ /**
236
+ * Folds a path to a comparable form.
237
+ *
238
+ * Normalization order matters and is not arbitrary:
239
+ *
240
+ * 1. NFKC first, which folds most compatibility variants in one step.
241
+ * 2. Explicit homoglyph replacement for the separators NFKC leaves alone —
242
+ * U+2024 ONE DOT LEADER survives NFKC, and it is the one an attacker uses.
243
+ * 3. Invisible characters removed, so `.a​ws` and `.aws` are one string.
244
+ * 4. Percent-decoding last, because decoding can introduce new separators
245
+ * that steps 1–3 should then see.
246
+ */
247
+ export function foldPath(value) {
248
+ let s = String(value ?? "");
249
+
250
+ try {
251
+ s = s.normalize("NFKC");
252
+ } catch {
253
+ /* an unpaired surrogate; keep going with what we have */
254
+ }
255
+
256
+ let folded = "";
257
+ for (const ch of s) folded += HOMOGLYPHS.get(ch) ?? ch;
258
+
259
+ folded = folded.replace(INVISIBLE, "");
260
+ folded = decodePath(folded);
261
+
262
+ // A second fold: decoding may have produced homoglyphs or invisibles of its
263
+ // own, and one pass would leave them in place.
264
+ let second = "";
265
+ for (const ch of folded) second += HOMOGLYPHS.get(ch) ?? ch;
266
+ second = second.replace(INVISIBLE, "");
267
+
268
+ return stripTrailingPunctuation(second);
269
+ }
270
+
271
+ /**
272
+ * Strips trailing dots and spaces from each path segment.
273
+ *
274
+ * Windows does this when opening a file: `".env "` and `".env."` both open
275
+ * `.env`. So a rule naming `.env` had a live bypass — the agent appends one
276
+ * space, the matcher sees a different string, and NTFS opens the credential
277
+ * file anyway. The generated corpus found 378 of them at once.
278
+ *
279
+ * Applied on every platform, not only Windows. Policy files are shared across a
280
+ * fleet, and a rule that protects a Linux CI runner and not a developer's
281
+ * laptop is a rule nobody can reason about. On POSIX a filename really may end
282
+ * in a space, so this can over-match — the cost is denying access to a very
283
+ * strangely-named file, which is the right direction to be wrong in.
284
+ *
285
+ * `.` and `..` are preserved intact: stripping their dots would erase the
286
+ * traversal semantics that `resolvePath` depends on.
287
+ */
288
+ function stripTrailingPunctuation(value) {
289
+ if (!/[. ]([/\\]|$)/.test(value)) return value;
290
+
291
+ return value
292
+ .split(/([/\\])/)
293
+ .map((part) => {
294
+ if (part === "/" || part === String.fromCharCode(92)) return part;
295
+ if (part === "." || part === "..") return part;
296
+ return part.replace(/[. ]+$/, "");
297
+ })
298
+ .join("");
299
+ }
300
+
301
+ /** Expands `~` and the common home-directory environment variables. */
302
+ export function expandHome(value) {
303
+ const s = String(value ?? "");
304
+ const home = homedir().replace(/\\/g, "/").replace(/\/+$/, "");
305
+
306
+ if (s === "~" || s.startsWith("~/") || s.startsWith("~\\")) {
307
+ return home + s.slice(1).replace(/\\/g, "/");
308
+ }
309
+ // `$HOME`, `${HOME}`, `%USERPROFILE%`, `$env:USERPROFILE`. These are expanded
310
+ // by the shell or the tool before the path is opened, so a rule written
311
+ // against the real path must match them.
312
+ return s.replace(
313
+ /^(\$\{?HOME\}?|%USERPROFILE%|\$env:USERPROFILE|%HOMEPATH%|\$HOMEPATH)(?=[/\\]|$)/i,
314
+ home,
315
+ );
316
+ }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * The endpoint daemon.
3
+ *
4
+ * A long-running service on every governed machine. It registers with the
5
+ * control plane, heartbeats, pulls policy when it falls behind, and ships
6
+ * decision telemetry in batches.
7
+ *
8
+ * OFFLINE-FIRST, AND THAT IS THE WHOLE DESIGN.
9
+ *
10
+ * A control plane that stops enforcing when the network blips is worse than no
11
+ * control plane, because the failure is invisible: agents keep working and
12
+ * nobody learns that governance stopped. So:
13
+ *
14
+ * - Policy is cached on disk and enforced from the cache. The daemon never
15
+ * needs the network to make a decision.
16
+ * - Telemetry is buffered on disk and drained when connectivity returns.
17
+ * A day offline produces a backlog, not a gap in the audit record.
18
+ * - A failed sync is logged and retried with backoff. It never blocks, and
19
+ * it never escalates into a request being permitted that would otherwise
20
+ * have been denied.
21
+ *
22
+ * The daemon holds NO inbound port. It polls outward, which is what makes it
23
+ * deployable on a laptop behind NAT and inside a locked-down VPC without a
24
+ * firewall exception.
25
+ */
26
+
27
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
28
+ import { hostname, platform } from "node:os";
29
+ import { dirname, join } from "node:path";
30
+
31
+ const VERSION = "0.1.0";
32
+
33
+ /** Exponential backoff with a ceiling — a dead control plane must not become
34
+ * a self-inflicted denial-of-service against itself. */
35
+ function backoff(attempt) {
36
+ return Math.min(30_000 * 2 ** Math.min(attempt, 5), 15 * 60_000);
37
+ }
38
+
39
+ export class Daemon {
40
+ #timer = null;
41
+ #stopping = false;
42
+ #attempt = 0;
43
+
44
+ /**
45
+ * @param {object} opts
46
+ * @param {string} opts.apiUrl control-plane base URL
47
+ * @param {string} opts.apiKey `cvx_…`
48
+ * @param {string} opts.stateDir where policy cache + spool live
49
+ * @param {number} [opts.intervalMs]
50
+ * @param {(m:string,extra?:object)=>void} [opts.log]
51
+ * @param {typeof fetch} [opts.fetchImpl] injected for tests
52
+ */
53
+ constructor({
54
+ apiUrl,
55
+ apiKey,
56
+ stateDir,
57
+ intervalMs = 30_000,
58
+ log = () => {},
59
+ fetchImpl = globalThis.fetch,
60
+ }) {
61
+ this.apiUrl = apiUrl.replace(/\/$/, "");
62
+ this.apiKey = apiKey;
63
+ this.stateDir = stateDir;
64
+ this.intervalMs = intervalMs;
65
+ this.log = log;
66
+ this.fetch = fetchImpl;
67
+
68
+ this.policyPath = join(stateDir, "policy.json");
69
+ this.spoolPath = join(stateDir, "spool.jsonl");
70
+ this.identityPath = join(stateDir, "endpoint.json");
71
+
72
+ this.policy = { version: 0, rules: [] };
73
+ this.endpointId = null;
74
+ /** The session currently being recorded, if a control plane opened one. */
75
+ this.runId = null;
76
+ this.online = false;
77
+ this.stats = { syncs: 0, failures: 0, shipped: 0, spooled: 0 };
78
+ }
79
+
80
+ /* -- lifecycle ---------------------------------------------------------- */
81
+
82
+ async start() {
83
+ await mkdir(this.stateDir, { recursive: true });
84
+ await this.#loadIdentity();
85
+ await this.#loadPolicy();
86
+
87
+ // Enforce immediately from cache; do not wait for the first successful
88
+ // sync. A daemon that is useless until it reaches the network is a daemon
89
+ // that is useless exactly when the network is the problem.
90
+ this.log(`daemon started · policy v${this.policy.version} · ${this.policy.rules.length} rules`);
91
+
92
+ await this.tick();
93
+ this.#schedule();
94
+ return this;
95
+ }
96
+
97
+ stop() {
98
+ this.#stopping = true;
99
+ if (this.#timer) clearTimeout(this.#timer);
100
+ this.#timer = null;
101
+ }
102
+
103
+ /**
104
+ * Stops and makes a final attempt to ship whatever is spooled.
105
+ *
106
+ * Without this, a short-lived process — a CI job, a one-off `cirvix gateway`, a
107
+ * developer closing their editor — exits between ticks and its decisions sit
108
+ * on disk until the next time a daemon happens to start in that state
109
+ * directory. The records are not lost, but they arrive late or never, and an
110
+ * audit trail that arrives late is one nobody trusts.
111
+ *
112
+ * Always resolves: a failed flush leaves the spool intact for next time,
113
+ * which is the correct direction to err.
114
+ */
115
+ async shutdown() {
116
+ this.stop();
117
+ try {
118
+ if (!this.endpointId) return false;
119
+ await this.#drainSpool();
120
+ return true;
121
+ } catch (err) {
122
+ this.log(`final flush failed, ${this.stats.spooled - this.stats.shipped} records remain spooled: ${err.message}`);
123
+ return false;
124
+ }
125
+ }
126
+
127
+ #schedule() {
128
+ if (this.#stopping) return;
129
+ const delay = this.online ? this.intervalMs : backoff(this.#attempt);
130
+ this.#timer = setTimeout(async () => {
131
+ await this.tick();
132
+ this.#schedule();
133
+ }, delay);
134
+ // Never hold the process open on our account.
135
+ this.#timer.unref?.();
136
+ }
137
+
138
+ /** One sync cycle. Always resolves — a throw here would kill the loop. */
139
+ async tick() {
140
+ try {
141
+ if (!this.endpointId) await this.#register();
142
+ const hb = await this.#heartbeat();
143
+ if (hb?.policyStale) await this.#pullPolicy();
144
+ await this.#drainSpool();
145
+ this.online = true;
146
+ this.#attempt = 0;
147
+ this.stats.syncs++;
148
+ } catch (err) {
149
+ this.online = false;
150
+ this.#attempt++;
151
+ this.stats.failures++;
152
+ this.log(`sync failed (attempt ${this.#attempt}): ${err.message}`, { offline: true });
153
+ }
154
+ }
155
+
156
+ /* -- control-plane calls ------------------------------------------------ */
157
+
158
+ async #api(method, path, body) {
159
+ const res = await this.fetch(this.apiUrl + path, {
160
+ method,
161
+ headers: {
162
+ authorization: `Bearer ${this.apiKey}`,
163
+ ...(body ? { "content-type": "application/json" } : {}),
164
+ },
165
+ body: body ? JSON.stringify(body) : undefined,
166
+ signal: AbortSignal.timeout(10_000),
167
+ });
168
+ if (!res.ok) {
169
+ const text = await res.text().catch(() => "");
170
+ throw new Error(`${method} ${path} → ${res.status} ${text.slice(0, 200)}`);
171
+ }
172
+ return res.status === 204 ? null : res.json();
173
+ }
174
+
175
+ /**
176
+ * Registers the agent identity this endpoint is running.
177
+ *
178
+ * Without this the console shows decisions attributed to an agent that does
179
+ * not appear in the inventory — "4 calls, 0 agents" — which reads as a bug
180
+ * and undermines the one claim the Agents screen makes.
181
+ *
182
+ * Best-effort: a failure here must never stop enforcement.
183
+ */
184
+ async registerAgent(agent) {
185
+ try {
186
+ const created = await this.#api("POST", "/v1/agents", {
187
+ name: agent.name,
188
+ framework: agent.framework ?? "mcp-gateway",
189
+ environment: agent.environment ?? "local",
190
+ endpointId: this.endpointId,
191
+ });
192
+ this.agentId = created?.id ?? null;
193
+ this.log(`registered agent ${agent.name}`);
194
+ return created;
195
+ } catch (err) {
196
+ this.log(`agent registration failed (enforcement unaffected): ${err.message}`);
197
+ return null;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Opens a run and returns its id.
203
+ *
204
+ * Best-effort, like agent registration: a control plane that is unreachable
205
+ * must not stop the gateway enforcing. The cost of failing is that this
206
+ * session's decisions arrive without a run to belong to — they are still
207
+ * individually valid, which is the right way to degrade.
208
+ */
209
+ async openRun({ agent, environment }) {
210
+ try {
211
+ const run = await this.#api("POST", "/v1/runs", {
212
+ agent,
213
+ environment,
214
+ endpointId: this.endpointId,
215
+ });
216
+ this.runId = run?.id ?? null;
217
+ if (this.runId) this.log(`run ${this.runId} started`);
218
+ return this.runId;
219
+ } catch (err) {
220
+ this.log(`could not open a run (enforcement unaffected): ${err.message}`);
221
+ return null;
222
+ }
223
+ }
224
+
225
+ /** Closes the run with its final counts. Never throws. */
226
+ async closeRun(counts = {}) {
227
+ if (!this.runId) return false;
228
+ try {
229
+ await this.#api("POST", `/v1/runs/${this.runId}/close`, counts);
230
+ return true;
231
+ } catch (err) {
232
+ // A run left open is swept to `abandoned` by the control plane, which is
233
+ // the honest outcome for a session that stopped reporting.
234
+ this.log(`could not close run ${this.runId}: ${err.message}`);
235
+ return false;
236
+ }
237
+ }
238
+
239
+ async #register() {
240
+ const ep = await this.#api("POST", "/v1/endpoints", {
241
+ hostname: hostname(),
242
+ platform: platform(),
243
+ version: VERSION,
244
+ });
245
+ this.endpointId = ep.id;
246
+ await this.#atomicWrite(this.identityPath, JSON.stringify({ endpointId: ep.id }, null, 2));
247
+ this.log(`registered endpoint ${ep.id}`);
248
+ }
249
+
250
+ async #heartbeat() {
251
+ return this.#api("POST", `/v1/endpoints/${this.endpointId}/heartbeat`, {
252
+ policyVersion: this.policy.version,
253
+ status: "healthy",
254
+ });
255
+ }
256
+
257
+ async #pullPolicy() {
258
+ const policy = await this.#api("GET", "/v1/policy");
259
+ if (!Array.isArray(policy?.rules)) throw new Error("Control plane returned a malformed policy.");
260
+ // Only adopt a policy that is newer. A control plane that rolls back
261
+ // should do so by publishing a new version, never by us accepting a
262
+ // lower one — that would let a replayed response weaken enforcement.
263
+ if (policy.version <= this.policy.version) return;
264
+ this.policy = policy;
265
+ await this.#atomicWrite(this.policyPath, JSON.stringify(policy, null, 2));
266
+ this.log(`policy updated to v${policy.version} (${policy.rules.length} rules)`);
267
+ }
268
+
269
+ /* -- telemetry ---------------------------------------------------------- */
270
+
271
+ /**
272
+ * Records a decision. Always spools to disk first, then ships. Ship-then-
273
+ * persist would lose the record on a crash mid-flight, and the record is the
274
+ * product.
275
+ */
276
+ async record(decision) {
277
+ await mkdir(this.stateDir, { recursive: true }).catch(() => {});
278
+ const { appendFile } = await import("node:fs/promises");
279
+ await appendFile(this.spoolPath, JSON.stringify(decision) + "\n", "utf8");
280
+ this.stats.spooled++;
281
+ }
282
+
283
+ async #drainSpool() {
284
+ let text = "";
285
+ try {
286
+ text = await readFile(this.spoolPath, "utf8");
287
+ } catch {
288
+ return; // nothing spooled
289
+ }
290
+ const lines = text.split("\n").filter(Boolean);
291
+ if (lines.length === 0) return;
292
+
293
+ // Batches are capped to match the API's limit; the remainder stays
294
+ // spooled and goes out on the next tick.
295
+ const batch = lines.slice(0, 500);
296
+ const decisions = [];
297
+ for (const line of batch) {
298
+ try {
299
+ decisions.push(JSON.parse(line));
300
+ } catch {
301
+ /* drop a corrupt line rather than wedge the queue forever */
302
+ }
303
+ }
304
+
305
+ await this.#api("POST", "/v1/decisions", { decisions });
306
+
307
+ // Only truncate what was accepted. If this write fails the records are
308
+ // re-sent next tick — at-least-once, which for an audit trail is the
309
+ // correct direction to err.
310
+ const remaining = lines.slice(batch.length);
311
+ await this.#atomicWrite(this.spoolPath, remaining.length ? remaining.join("\n") + "\n" : "");
312
+ this.stats.shipped += decisions.length;
313
+ if (decisions.length) this.log(`shipped ${decisions.length} decisions`);
314
+ }
315
+
316
+ /* -- disk --------------------------------------------------------------- */
317
+
318
+ async #loadPolicy() {
319
+ try {
320
+ const cached = JSON.parse(await readFile(this.policyPath, "utf8"));
321
+ if (Array.isArray(cached?.rules)) this.policy = cached;
322
+ } catch {
323
+ /* first run */
324
+ }
325
+ }
326
+
327
+ async #loadIdentity() {
328
+ try {
329
+ const saved = JSON.parse(await readFile(this.identityPath, "utf8"));
330
+ if (saved?.endpointId) this.endpointId = saved.endpointId;
331
+ } catch {
332
+ /* first run */
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Write-then-rename. A crash partway through a direct write leaves a
338
+ * truncated policy file, and a truncated policy file is an enforcement
339
+ * outage on the next boot.
340
+ */
341
+ async #atomicWrite(path, contents) {
342
+ await mkdir(dirname(path), { recursive: true }).catch(() => {});
343
+ const tmp = `${path}.${process.pid}.tmp`;
344
+ await writeFile(tmp, contents, "utf8");
345
+ await rename(tmp, path);
346
+ }
347
+
348
+ /** The rules the gateway should enforce right now. */
349
+ currentRules() {
350
+ return this.policy.rules;
351
+ }
352
+ }