@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.
- package/LICENSE +202 -0
- package/NOTICE +42 -0
- package/README.md +341 -0
- package/action/README.md +100 -0
- package/action/action.yml +134 -0
- package/action/report.mjs +144 -0
- package/bin/cirvix.mjs +1073 -0
- package/package.json +60 -0
- package/src/commands/demo.mjs +315 -0
- package/src/commands/init.mjs +558 -0
- package/src/commands/policy.mjs +345 -0
- package/src/commands/sarif.mjs +176 -0
- package/src/commands/scan.mjs +210 -0
- package/src/commands/status.mjs +208 -0
- package/src/commands/upgrade.mjs +162 -0
- package/src/core/approvals.mjs +388 -0
- package/src/core/audit.mjs +181 -0
- package/src/core/canonical.mjs +316 -0
- package/src/core/daemon.mjs +352 -0
- package/src/core/decisions.mjs +253 -0
- package/src/core/delegation.mjs +658 -0
- package/src/core/detect.mjs +337 -0
- package/src/core/entitlement-gate.mjs +100 -0
- package/src/core/entitlements.mjs +285 -0
- package/src/core/format.mjs +33 -0
- package/src/core/gateway.mjs +959 -0
- package/src/core/guard.mjs +568 -0
- package/src/core/http-transport.mjs +505 -0
- package/src/core/journal.mjs +419 -0
- package/src/core/jsonrpc.mjs +152 -0
- package/src/core/meter.mjs +225 -0
- package/src/core/normalize.mjs +516 -0
- package/src/core/notices.mjs +80 -0
- package/src/core/pipeline.mjs +629 -0
- package/src/core/policy-dsl.mjs +611 -0
- package/src/core/policy.mjs +710 -0
- package/src/core/prompts.mjs +146 -0
- package/src/core/risk.mjs +509 -0
- package/src/core/sanitize.mjs +279 -0
- package/src/core/secret-detect.mjs +533 -0
- package/src/core/secrets.mjs +312 -0
- package/src/core/uds.mjs +383 -0
- package/src/core/vault.mjs +530 -0
- package/src/index.mjs +143 -0
- 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 `.aws` 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
|
+
}
|