@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,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The secrets edge — substitution at the boundary, and the return path.
|
|
3
|
+
*
|
|
4
|
+
* The control plane holds the material and decides who may spend a handle.
|
|
5
|
+
* This module is the half that runs next to the agent, and it exists so the
|
|
6
|
+
* credential passes through exactly one place: the moment a permitted call is
|
|
7
|
+
* written to an upstream.
|
|
8
|
+
*
|
|
9
|
+
* agent ─▶ Authorization: Bearer sec_handle_a89f… (opaque, in context)
|
|
10
|
+
* edge ─▶ resolve(handle, destination) ─▶ control plane
|
|
11
|
+
* edge ─▶ Authorization: Bearer rk_live_… (real, on the wire)
|
|
12
|
+
* result ◀─ scanned for resolved material, redacted back to the handle
|
|
13
|
+
*
|
|
14
|
+
* FOUR DECISIONS WORTH THE COMMENT:
|
|
15
|
+
*
|
|
16
|
+
* 1. AN UNRESOLVABLE HANDLE FAILS THE CALL. It is never forwarded as a literal
|
|
17
|
+
* string. Forwarding it would hand the handle to an upstream that has no
|
|
18
|
+
* business seeing it, and would produce a 401 the agent cannot diagnose —
|
|
19
|
+
* a silent failure where a legible refusal belongs.
|
|
20
|
+
*
|
|
21
|
+
* 2. SUBSTITUTION DOES NOT TAINT THE SESSION. The `touchedSecret` flag exists
|
|
22
|
+
* because an agent that *read* raw credential material can exfiltrate it.
|
|
23
|
+
* An agent holding a handle never held the material, which is the entire
|
|
24
|
+
* point, so brokering must not trip the egress rule that the raw-read path
|
|
25
|
+
* trips. Tainting here would make the documented flow — resolve, call the
|
|
26
|
+
* API, call it again — fail on the second call.
|
|
27
|
+
*
|
|
28
|
+
* 3. LEAK DETECTION COVERS MATERIAL THIS SESSION RESOLVED, AND SAYS SO.
|
|
29
|
+
* The edge cannot recognise a credential it was never told about. That is
|
|
30
|
+
* stated in the product's own limits, and this implementation matches it
|
|
31
|
+
* rather than implying broader coverage.
|
|
32
|
+
*
|
|
33
|
+
* 4. REDACTION PUTS THE HANDLE BACK, NOT A PLACEHOLDER. Replacing a leaked
|
|
34
|
+
* value with `[REDACTED]` tells the model something was removed and nothing
|
|
35
|
+
* about what to do next. Replacing it with the handle it already holds
|
|
36
|
+
* leaves the run coherent.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The canonical handle shape. The control plane imports these; nothing
|
|
41
|
+
* redefines them.
|
|
42
|
+
*
|
|
43
|
+
* Two bodies, one format. A brokered handle is 32 hex characters, minted by the
|
|
44
|
+
* control plane and unguessable. A locally-vaulted handle is a short ordinal —
|
|
45
|
+
* `sec_handle_01` — because it never leaves the machine, and because an
|
|
46
|
+
* operator watching a demo has to be able to read it off the screen and see
|
|
47
|
+
* that it is not a credential.
|
|
48
|
+
*
|
|
49
|
+
* The short form is not a weaker secret: it is not a secret at all. A handle's
|
|
50
|
+
* security comes from the broker refusing to resolve it off-path, not from
|
|
51
|
+
* being hard to type. Making the local form guessable is therefore free, and
|
|
52
|
+
* making it legible is worth something.
|
|
53
|
+
*/
|
|
54
|
+
export const HANDLE_PREFIX = "sec_handle_";
|
|
55
|
+
const HANDLE_BODY = "(?:[0-9a-f]{32}|[0-9]{2,8}(?![0-9a-f]))";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Values shorter than this are never used for return-path matching.
|
|
59
|
+
*
|
|
60
|
+
* A short secret appears inside unrelated text by coincidence, and redacting
|
|
61
|
+
* on a coincidence corrupts a tool result the agent depends on. Substitution
|
|
62
|
+
* is unaffected — this guards only the scan direction.
|
|
63
|
+
*/
|
|
64
|
+
const MIN_SCANNABLE_LENGTH = 8;
|
|
65
|
+
|
|
66
|
+
/** Depth cap on the structure walk. MCP payloads are JSON; this is insurance. */
|
|
67
|
+
const MAX_DEPTH = 12;
|
|
68
|
+
|
|
69
|
+
export const isHandle = (value) =>
|
|
70
|
+
typeof value === "string" && new RegExp(`^${HANDLE_PREFIX}${HANDLE_BODY}$`).test(value);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Every handle appearing anywhere in a value, including inside longer strings
|
|
74
|
+
* (`"Bearer sec_handle_…"`), which is where they usually appear.
|
|
75
|
+
*
|
|
76
|
+
* Builds a fresh regex per call: a shared `/g` regex carries `lastIndex`
|
|
77
|
+
* between calls and silently skips matches on every other invocation.
|
|
78
|
+
*/
|
|
79
|
+
export function findHandles(value, depth = 0) {
|
|
80
|
+
const found = new Set();
|
|
81
|
+
if (depth > MAX_DEPTH) return found;
|
|
82
|
+
|
|
83
|
+
if (typeof value === "string") {
|
|
84
|
+
for (const m of value.matchAll(new RegExp(`${HANDLE_PREFIX}${HANDLE_BODY}`, "g"))) {
|
|
85
|
+
found.add(m[0]);
|
|
86
|
+
}
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
if (Array.isArray(value)) {
|
|
90
|
+
for (const item of value) for (const h of findHandles(item, depth + 1)) found.add(h);
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
if (value && typeof value === "object") {
|
|
94
|
+
for (const item of Object.values(value)) for (const h of findHandles(item, depth + 1)) found.add(h);
|
|
95
|
+
}
|
|
96
|
+
return found;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Rewrites every string in a structure, returning a copy. */
|
|
100
|
+
function mapStrings(value, fn, depth = 0) {
|
|
101
|
+
if (depth > MAX_DEPTH) return value;
|
|
102
|
+
if (typeof value === "string") return fn(value);
|
|
103
|
+
if (Array.isArray(value)) return value.map((v) => mapStrings(v, fn, depth + 1));
|
|
104
|
+
if (value && typeof value === "object") {
|
|
105
|
+
return Object.fromEntries(
|
|
106
|
+
Object.entries(value).map(([k, v]) => [k, mapStrings(v, fn, depth + 1)]),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* -------------------------------------------------------------------------- */
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @typedef {object} Substitution
|
|
116
|
+
* @property {boolean} ok
|
|
117
|
+
* @property {any} value the arguments with handles replaced
|
|
118
|
+
* @property {string[]} substituted names of the secrets that were spent
|
|
119
|
+
* @property {string} [reason] why it failed, safe to show the agent
|
|
120
|
+
* @property {string} [outcome] the control plane's refusal outcome
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
export class SecretsClient {
|
|
124
|
+
/** handle → { value, secretId, name }, for the life of this session only. */
|
|
125
|
+
#resolved = new Map();
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {object} opts
|
|
129
|
+
* @param {string} opts.apiUrl control-plane base URL
|
|
130
|
+
* @param {string} opts.apiKey `cvx_…`
|
|
131
|
+
* @param {string} [opts.agent] recorded against every resolution
|
|
132
|
+
* @param {typeof fetch} [opts.fetchImpl] injected for tests
|
|
133
|
+
* @param {(m:string,extra?:object)=>void} [opts.log]
|
|
134
|
+
*/
|
|
135
|
+
constructor({ apiUrl, apiKey, agent = "local", fetchImpl = globalThis.fetch, log = () => {} }) {
|
|
136
|
+
this.apiUrl = String(apiUrl ?? "").replace(/\/$/, "");
|
|
137
|
+
this.apiKey = apiKey;
|
|
138
|
+
this.agent = agent;
|
|
139
|
+
this.fetch = fetchImpl;
|
|
140
|
+
this.log = log;
|
|
141
|
+
this.stats = { requested: 0, substituted: 0, refused: 0, leaksCaught: 0 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async #api(path, body) {
|
|
145
|
+
const res = await this.fetch(this.apiUrl + path, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { authorization: `Bearer ${this.apiKey}`, "content-type": "application/json" },
|
|
148
|
+
body: JSON.stringify(body),
|
|
149
|
+
signal: AbortSignal.timeout(10_000),
|
|
150
|
+
});
|
|
151
|
+
const payload = await res.json().catch(() => ({}));
|
|
152
|
+
return { status: res.status, payload };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Asks the control plane for a handle to a named secret.
|
|
157
|
+
*
|
|
158
|
+
* Returns the opaque string the agent is allowed to hold. This is what
|
|
159
|
+
* `secrets.get("STRIPE_RESTRICTED_KEY")` returns to a caller.
|
|
160
|
+
*/
|
|
161
|
+
async get(name, { ttlSeconds, maxUses } = {}) {
|
|
162
|
+
this.stats.requested++;
|
|
163
|
+
const { status, payload } = await this.#api("/v1/secrets/handles", {
|
|
164
|
+
name,
|
|
165
|
+
agent: this.agent,
|
|
166
|
+
ttlSeconds,
|
|
167
|
+
maxUses,
|
|
168
|
+
});
|
|
169
|
+
if (status !== 201) {
|
|
170
|
+
throw new Error(payload.error ?? `Could not issue a handle for "${name}".`);
|
|
171
|
+
}
|
|
172
|
+
return payload.handle;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Replaces every handle in `args` with the material it stands for.
|
|
177
|
+
*
|
|
178
|
+
* Fails closed: if any handle in the payload cannot be resolved for this
|
|
179
|
+
* destination, nothing is substituted and the caller must refuse the call.
|
|
180
|
+
* Partial substitution would send a real credential alongside a literal
|
|
181
|
+
* handle, which is the worst of both.
|
|
182
|
+
*
|
|
183
|
+
* @returns {Promise<Substitution>}
|
|
184
|
+
*/
|
|
185
|
+
async substitute(args, { destination } = {}) {
|
|
186
|
+
const handles = [...findHandles(args)];
|
|
187
|
+
if (handles.length === 0) return { ok: true, value: args, substituted: [] };
|
|
188
|
+
|
|
189
|
+
if (!destination) {
|
|
190
|
+
this.stats.refused++;
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
value: args,
|
|
194
|
+
substituted: [],
|
|
195
|
+
outcome: "no_destination",
|
|
196
|
+
reason:
|
|
197
|
+
"This call carries a secret handle but names no destination the broker can authorize. A handle resolves only against a destination.",
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const replacements = new Map();
|
|
202
|
+
const names = [];
|
|
203
|
+
for (const handle of handles) {
|
|
204
|
+
const resolved = await this.#resolve(handle, destination);
|
|
205
|
+
if (!resolved.ok) {
|
|
206
|
+
this.stats.refused++;
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
value: args,
|
|
210
|
+
substituted: [],
|
|
211
|
+
outcome: resolved.outcome,
|
|
212
|
+
reason: resolved.reason,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
replacements.set(handle, resolved.value);
|
|
216
|
+
names.push(resolved.name);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const value = mapStrings(args, (s) => {
|
|
220
|
+
let out = s;
|
|
221
|
+
for (const [handle, real] of replacements) out = out.split(handle).join(real);
|
|
222
|
+
return out;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
this.stats.substituted += replacements.size;
|
|
226
|
+
return { ok: true, value, substituted: names };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async #resolve(handle, destination) {
|
|
230
|
+
const { status, payload } = await this.#api("/v1/secrets/resolve", {
|
|
231
|
+
handle,
|
|
232
|
+
destination,
|
|
233
|
+
agent: this.agent,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (status !== 200 || typeof payload.value !== "string") {
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
outcome: payload.outcome ?? "unresolved",
|
|
240
|
+
reason: payload.error ?? "This handle does not resolve for that destination.",
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Remembered so the return path can recognise it coming back. Held for the
|
|
245
|
+
// life of the session and nowhere else — never written to disk, never in a
|
|
246
|
+
// decision record, never in the audit chain.
|
|
247
|
+
this.#resolved.set(handle, {
|
|
248
|
+
value: payload.value,
|
|
249
|
+
secretId: payload.secretId,
|
|
250
|
+
name: payload.name,
|
|
251
|
+
});
|
|
252
|
+
return { ok: true, value: payload.value, name: payload.name };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Finds material this session resolved appearing in a return payload.
|
|
257
|
+
*
|
|
258
|
+
* A well-behaved upstream never echoes a credential back. A misbehaving or
|
|
259
|
+
* compromised one does, and if that reaches the model the handle indirection
|
|
260
|
+
* has bought nothing.
|
|
261
|
+
*/
|
|
262
|
+
scan(payload) {
|
|
263
|
+
const findings = [];
|
|
264
|
+
if (this.#resolved.size === 0) return findings;
|
|
265
|
+
|
|
266
|
+
const text = typeof payload === "string" ? payload : JSON.stringify(payload ?? "");
|
|
267
|
+
for (const [handle, entry] of this.#resolved) {
|
|
268
|
+
if (entry.value.length < MIN_SCANNABLE_LENGTH) continue;
|
|
269
|
+
if (text.includes(entry.value)) {
|
|
270
|
+
findings.push({ handle, secretId: entry.secretId, name: entry.name });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return findings;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Returns `payload` with any resolved material swapped back to its handle.
|
|
278
|
+
*
|
|
279
|
+
* @returns {{payload:any, findings:Array<{handle:string,secretId:string,name:string}>}}
|
|
280
|
+
*/
|
|
281
|
+
redact(payload) {
|
|
282
|
+
const findings = this.scan(payload);
|
|
283
|
+
if (findings.length === 0) return { payload, findings };
|
|
284
|
+
|
|
285
|
+
this.stats.leaksCaught += findings.length;
|
|
286
|
+
const redacted = mapStrings(payload, (s) => {
|
|
287
|
+
let out = s;
|
|
288
|
+
for (const finding of findings) {
|
|
289
|
+
const entry = this.#resolved.get(finding.handle);
|
|
290
|
+
if (entry) out = out.split(entry.value).join(finding.handle);
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
});
|
|
294
|
+
return { payload: redacted, findings };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Drops every resolved value.
|
|
299
|
+
*
|
|
300
|
+
* Called when a session ends. The material is process memory for the life of
|
|
301
|
+
* a run and no longer — a laptop with a root-level compromise is outside any
|
|
302
|
+
* userspace product's threat model, but a long-lived process holding every
|
|
303
|
+
* credential it ever brokered is a self-inflicted one.
|
|
304
|
+
*/
|
|
305
|
+
forget() {
|
|
306
|
+
this.#resolved.clear();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
get held() {
|
|
310
|
+
return this.#resolved.size;
|
|
311
|
+
}
|
|
312
|
+
}
|
package/src/core/uds.mjs
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local control socket.
|
|
3
|
+
*
|
|
4
|
+
* A Unix Domain Socket (a named pipe on Windows) speaking JSON-RPC, so any
|
|
5
|
+
* process on this machine can ask Cirvix "may I do this" without speaking MCP
|
|
6
|
+
* and without linking the SDK. It is what makes the runtime agent-neutral in
|
|
7
|
+
* practice rather than in principle: a Python agent, a Go CLI, a shell wrapper,
|
|
8
|
+
* or an editor plugin all get the same decision from the same rule set.
|
|
9
|
+
*
|
|
10
|
+
* client ──▶ {"method":"cirvix/authorize","params":{"tool":"shell.exec",…}}
|
|
11
|
+
* cirvix ──▶ {"result":{"decision":"deny","policy":"deny-destructive",…}}
|
|
12
|
+
*
|
|
13
|
+
* WHY A SOCKET RATHER THAN A LOCAL HTTP PORT
|
|
14
|
+
*
|
|
15
|
+
* A TCP port on localhost is reachable by every process on the machine, by
|
|
16
|
+
* every container sharing the network namespace, and — via DNS rebinding — by a
|
|
17
|
+
* web page the user has open. A UDS is a filesystem object with an owner and a
|
|
18
|
+
* mode, so "who may ask for decisions" becomes a question the operating system
|
|
19
|
+
* already knows how to answer.
|
|
20
|
+
*
|
|
21
|
+
* THIS SOCKET IS A SECURITY BOUNDARY, NOT A CONVENIENCE
|
|
22
|
+
*
|
|
23
|
+
* Whatever can talk to it can ask for secret substitution. Three things
|
|
24
|
+
* therefore hold, and each is enforced rather than documented:
|
|
25
|
+
*
|
|
26
|
+
* 1. The socket file is created with mode 0600 and in a directory the
|
|
27
|
+
* current user owns. On POSIX that is the whole access-control story.
|
|
28
|
+
* 2. Every connection must present the session token from `--state`, which
|
|
29
|
+
* is written 0600. This is what carries the property to Windows, where
|
|
30
|
+
* named pipes do not inherit filesystem permissions and the default DACL
|
|
31
|
+
* is more generous than it looks. Without the token nothing is served.
|
|
32
|
+
* 3. `cirvix/vault.issue` is not exposed. A client may spend a handle it was
|
|
33
|
+
* given; it may never mint one, and it may never read material back. The
|
|
34
|
+
* socket cannot be used to exfiltrate the vault, because there is no
|
|
35
|
+
* method that returns a secret.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { createServer } from "node:net";
|
|
39
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
40
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
41
|
+
import { dirname, join } from "node:path";
|
|
42
|
+
import { tmpdir } from "node:os";
|
|
43
|
+
|
|
44
|
+
import { MessageFramer, serialize } from "./jsonrpc.mjs";
|
|
45
|
+
import { DECISION } from "./decisions.mjs";
|
|
46
|
+
import { SOURCE } from "./normalize.mjs";
|
|
47
|
+
|
|
48
|
+
/** JSON-RPC error codes this server returns. */
|
|
49
|
+
export const UDS_ERROR = {
|
|
50
|
+
UNAUTHORIZED: -32004,
|
|
51
|
+
METHOD_NOT_FOUND: -32601,
|
|
52
|
+
INVALID_PARAMS: -32602,
|
|
53
|
+
INTERNAL: -32603,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The default endpoint for a state directory.
|
|
58
|
+
*
|
|
59
|
+
* On Windows a named pipe is not a filesystem path, so the socket lives in the
|
|
60
|
+
* pipe namespace and only the token file is on disk. The pipe name is derived
|
|
61
|
+
* from the state directory so two projects on one machine do not collide.
|
|
62
|
+
*/
|
|
63
|
+
export function defaultEndpoint(stateDir) {
|
|
64
|
+
if (process.platform === "win32") {
|
|
65
|
+
const slug = String(stateDir).replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(-64);
|
|
66
|
+
return `\\\\.\\pipe\\cirvix-${slug || "default"}`;
|
|
67
|
+
}
|
|
68
|
+
// A socket path is capped near 104 bytes on macOS and BSD. A deep project
|
|
69
|
+
// directory blows past that, and the failure is an opaque EINVAL at bind
|
|
70
|
+
// time — so a long path falls back to the temp directory with a stable name.
|
|
71
|
+
const preferred = join(stateDir, "cirvix.sock");
|
|
72
|
+
if (Buffer.byteLength(preferred) < 100) return preferred;
|
|
73
|
+
const slug = String(stateDir).replace(/[^A-Za-z0-9]+/g, "-").slice(-32);
|
|
74
|
+
return join(tmpdir(), `cirvix-${slug}.sock`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function tokenPath(stateDir) {
|
|
78
|
+
return join(stateDir, "socket.token");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Mints and persists the session token, 0600. */
|
|
82
|
+
export async function writeToken(stateDir) {
|
|
83
|
+
const token = randomBytes(32).toString("hex");
|
|
84
|
+
const path = tokenPath(stateDir);
|
|
85
|
+
await mkdir(dirname(path), { recursive: true }).catch(() => {});
|
|
86
|
+
await writeFile(path, token, "utf8");
|
|
87
|
+
await chmod(path, 0o600).catch(() => {});
|
|
88
|
+
return token;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function readToken(stateDir) {
|
|
92
|
+
return (await readFile(tokenPath(stateDir), "utf8")).trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sameToken(a, b) {
|
|
96
|
+
const x = Buffer.from(String(a ?? ""));
|
|
97
|
+
const y = Buffer.from(String(b ?? ""));
|
|
98
|
+
if (x.length !== y.length || x.length === 0) return false;
|
|
99
|
+
return timingSafeEqual(x, y);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* -------------------------------------------------------------------------- */
|
|
103
|
+
|
|
104
|
+
export class UdsServer {
|
|
105
|
+
#connections = new Set();
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {object} opts
|
|
109
|
+
* @param {import("./pipeline.mjs").Pipeline} opts.pipeline
|
|
110
|
+
* @param {string} opts.endpoint
|
|
111
|
+
* @param {string} opts.token
|
|
112
|
+
* @param {(m:string,extra?:object)=>void} [opts.log]
|
|
113
|
+
* @param {() => object} [opts.status] supplies `cirvix/status`
|
|
114
|
+
* @param {() => Promise<Array>} [opts.recent] supplies `cirvix/logs`
|
|
115
|
+
*/
|
|
116
|
+
constructor({ pipeline, endpoint, token, log = () => {}, status = () => ({}), recent = async () => [] }) {
|
|
117
|
+
this.pipeline = pipeline;
|
|
118
|
+
this.endpoint = endpoint;
|
|
119
|
+
this.token = token;
|
|
120
|
+
this.log = log;
|
|
121
|
+
this.status = status;
|
|
122
|
+
this.recent = recent;
|
|
123
|
+
this.server = null;
|
|
124
|
+
this.stats = { connections: 0, requests: 0, rejected: 0 };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async start() {
|
|
128
|
+
// A socket file left behind by a killed process makes bind fail with
|
|
129
|
+
// EADDRINUSE even though nothing is listening. Removing it is safe here
|
|
130
|
+
// because the mode-0600 parent directory means only this user could have
|
|
131
|
+
// created it.
|
|
132
|
+
if (process.platform !== "win32") {
|
|
133
|
+
await rm(this.endpoint, { force: true }).catch(() => {});
|
|
134
|
+
await mkdir(dirname(this.endpoint), { recursive: true }).catch(() => {});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.server = createServer((socket) => this.#onConnection(socket));
|
|
138
|
+
|
|
139
|
+
await new Promise((resolve, reject) => {
|
|
140
|
+
this.server.once("error", reject);
|
|
141
|
+
this.server.listen(this.endpoint, () => {
|
|
142
|
+
this.server.removeListener("error", reject);
|
|
143
|
+
resolve();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (process.platform !== "win32") {
|
|
148
|
+
await chmod(this.endpoint, 0o600).catch(() => {});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.log(`control socket listening on ${this.endpoint}`);
|
|
152
|
+
return this;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#onConnection(socket) {
|
|
156
|
+
this.stats.connections++;
|
|
157
|
+
this.#connections.add(socket);
|
|
158
|
+
|
|
159
|
+
/** Per-connection: a client is unauthenticated until it presents the token. */
|
|
160
|
+
let authenticated = false;
|
|
161
|
+
|
|
162
|
+
const write = (message) => {
|
|
163
|
+
if (socket.writable) socket.write(serialize(message));
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const framer = new MessageFramer({
|
|
167
|
+
onMessage: (message) => {
|
|
168
|
+
void (async () => {
|
|
169
|
+
try {
|
|
170
|
+
const response = await this.#dispatch(message, {
|
|
171
|
+
authenticated,
|
|
172
|
+
authenticate: () => {
|
|
173
|
+
authenticated = true;
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
if (response) write(response);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
write({
|
|
179
|
+
jsonrpc: "2.0",
|
|
180
|
+
id: message?.id ?? null,
|
|
181
|
+
error: { code: UDS_ERROR.INTERNAL, message: err.message },
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
})();
|
|
185
|
+
},
|
|
186
|
+
onInvalid: (line) => this.log(`control socket: unparseable frame (${line.length} bytes)`),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
socket.on("data", (chunk) => framer.push(chunk));
|
|
190
|
+
socket.on("error", () => {});
|
|
191
|
+
socket.on("close", () => this.#connections.delete(socket));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async #dispatch(message, session) {
|
|
195
|
+
const id = message?.id ?? null;
|
|
196
|
+
const ok = (result) => ({ jsonrpc: "2.0", id, result });
|
|
197
|
+
const fail = (code, msg) => ({ jsonrpc: "2.0", id, error: { code, message: msg } });
|
|
198
|
+
|
|
199
|
+
// Notifications get no reply, by JSON-RPC rule.
|
|
200
|
+
if (id === null && message?.method) return null;
|
|
201
|
+
|
|
202
|
+
this.stats.requests++;
|
|
203
|
+
|
|
204
|
+
if (message?.method === "initialize" || message?.method === "cirvix/hello") {
|
|
205
|
+
if (!sameToken(message.params?.token, this.token)) {
|
|
206
|
+
this.stats.rejected++;
|
|
207
|
+
this.log("control socket: rejected a connection with a bad or missing token");
|
|
208
|
+
return fail(
|
|
209
|
+
UDS_ERROR.UNAUTHORIZED,
|
|
210
|
+
"This socket requires the session token from <state>/socket.token.",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
session.authenticate();
|
|
214
|
+
return ok({
|
|
215
|
+
protocol: "cirvix/1",
|
|
216
|
+
server: "cirvix-uds",
|
|
217
|
+
methods: ["cirvix/authorize", "cirvix/result", "cirvix/status", "cirvix/logs"],
|
|
218
|
+
mode: this.pipeline.mode,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!session.authenticated) {
|
|
223
|
+
this.stats.rejected++;
|
|
224
|
+
return fail(UDS_ERROR.UNAUTHORIZED, "Call initialize with the session token first.");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
switch (message.method) {
|
|
228
|
+
/* ----------------------------------------------------------------- */
|
|
229
|
+
case "cirvix/authorize": {
|
|
230
|
+
const params = message.params ?? {};
|
|
231
|
+
if (!params.tool) return fail(UDS_ERROR.INVALID_PARAMS, "authorize needs a tool name.");
|
|
232
|
+
|
|
233
|
+
const { event, decision, arguments: outgoing } = await this.pipeline.submit(
|
|
234
|
+
{ tool: params.tool, server: params.server ?? null, arguments: params.arguments ?? {} },
|
|
235
|
+
{
|
|
236
|
+
agent: params.agent,
|
|
237
|
+
source: params.source ?? SOURCE.UDS,
|
|
238
|
+
environment: params.environment,
|
|
239
|
+
/*
|
|
240
|
+
* The delegation the caller is acting under, if any.
|
|
241
|
+
*
|
|
242
|
+
* This was not forwarded, and the effect was not a missing feature.
|
|
243
|
+
* Delegation only ever NARROWS, so dropping it here handed every
|
|
244
|
+
* caller the full authority policy allowed — an agent delegated
|
|
245
|
+
* `fs.read` could write the database over this socket, and the
|
|
246
|
+
* audit record showed an ordinary permitted write with no chain on
|
|
247
|
+
* it. Presenting a grant is the only way an agent can ask to be
|
|
248
|
+
* held to less than policy permits, and the request was being
|
|
249
|
+
* discarded.
|
|
250
|
+
*
|
|
251
|
+
* Forging it buys nothing: the grant is signed, and `resolve`
|
|
252
|
+
* refuses one presented by anybody but its subject.
|
|
253
|
+
*/
|
|
254
|
+
delegation: params.delegation ?? null,
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return ok({
|
|
259
|
+
request_id: event.request_id,
|
|
260
|
+
decision: event.decision,
|
|
261
|
+
// `allowed` is the one-bit answer a shell wrapper needs; everything
|
|
262
|
+
// else is for a client that can render more.
|
|
263
|
+
allowed: event.decision === DECISION.ALLOW || event.decision === DECISION.SANITIZE || event.decision === DECISION.AUDIT_ONLY,
|
|
264
|
+
risk: event.risk,
|
|
265
|
+
policy: event.policy,
|
|
266
|
+
reason: event.reason,
|
|
267
|
+
remediation: decision.remediation ?? null,
|
|
268
|
+
approval_id: event.approval_id ?? null,
|
|
269
|
+
approvers: decision.approvers ?? [],
|
|
270
|
+
latency_ms: event.latency_ms,
|
|
271
|
+
enforced: event.enforced,
|
|
272
|
+
// Substituted arguments go back so the client sends what Cirvix
|
|
273
|
+
// authorized rather than what it proposed.
|
|
274
|
+
arguments: outgoing,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* ----------------------------------------------------------------- */
|
|
279
|
+
case "cirvix/result": {
|
|
280
|
+
const params = message.params ?? {};
|
|
281
|
+
const scrubbed = this.pipeline.scrubResult(params.result, params.decision ?? {});
|
|
282
|
+
return ok({
|
|
283
|
+
request_id: params.request_id ?? null,
|
|
284
|
+
result: scrubbed.payload,
|
|
285
|
+
findings: scrubbed.findings.map((f) => ({
|
|
286
|
+
kind: f.kind,
|
|
287
|
+
detector: f.detector ?? f.rule ?? null,
|
|
288
|
+
path: f.path ?? null,
|
|
289
|
+
masked: f.masked ?? null,
|
|
290
|
+
})),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* ----------------------------------------------------------------- */
|
|
295
|
+
case "cirvix/status":
|
|
296
|
+
return ok(await this.status());
|
|
297
|
+
|
|
298
|
+
case "cirvix/logs": {
|
|
299
|
+
const limit = Number(message.params?.limit ?? 50);
|
|
300
|
+
const risk = message.params?.risk ?? null;
|
|
301
|
+
const events = await this.recent({ limit, risk });
|
|
302
|
+
return ok({ events });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
default:
|
|
306
|
+
return fail(UDS_ERROR.METHOD_NOT_FOUND, `Unknown method "${message.method}".`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async stop() {
|
|
311
|
+
for (const socket of this.#connections) socket.destroy();
|
|
312
|
+
this.#connections.clear();
|
|
313
|
+
await new Promise((resolve) => (this.server ? this.server.close(resolve) : resolve()));
|
|
314
|
+
if (process.platform !== "win32") await rm(this.endpoint, { force: true }).catch(() => {});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* -------------------------------------------------------------------------- */
|
|
319
|
+
/* Client */
|
|
320
|
+
/* -------------------------------------------------------------------------- */
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* A minimal client, used by `cirvix status`, the demo, and the tests.
|
|
324
|
+
*
|
|
325
|
+
* Deliberately one-shot: connect, ask, close. A pooled connection to a local
|
|
326
|
+
* socket saves microseconds and costs a whole class of lifecycle bug.
|
|
327
|
+
*/
|
|
328
|
+
export class UdsClient {
|
|
329
|
+
constructor({ endpoint, token, timeoutMs = 10_000 }) {
|
|
330
|
+
this.endpoint = endpoint;
|
|
331
|
+
this.token = token;
|
|
332
|
+
this.timeoutMs = timeoutMs;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async call(method, params = {}) {
|
|
336
|
+
const { connect } = await import("node:net");
|
|
337
|
+
|
|
338
|
+
return new Promise((resolve, reject) => {
|
|
339
|
+
const socket = connect(this.endpoint);
|
|
340
|
+
let nextId = 1;
|
|
341
|
+
let settled = false;
|
|
342
|
+
|
|
343
|
+
const finish = (fn, value) => {
|
|
344
|
+
if (settled) return;
|
|
345
|
+
settled = true;
|
|
346
|
+
clearTimeout(timer);
|
|
347
|
+
socket.destroy();
|
|
348
|
+
fn(value);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const timer = setTimeout(
|
|
352
|
+
() => finish(reject, new Error(`Timed out talking to ${this.endpoint}.`)),
|
|
353
|
+
this.timeoutMs,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const pending = new Map();
|
|
357
|
+
const framer = new MessageFramer({
|
|
358
|
+
onMessage: (m) => {
|
|
359
|
+
const entry = pending.get(m.id);
|
|
360
|
+
if (!entry) return;
|
|
361
|
+
pending.delete(m.id);
|
|
362
|
+
if (m.error) finish(reject, new Error(m.error.message));
|
|
363
|
+
else entry(m.result);
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const send = (m, onResult) => {
|
|
368
|
+
const id = nextId++;
|
|
369
|
+
pending.set(id, onResult);
|
|
370
|
+
socket.write(serialize({ jsonrpc: "2.0", id, ...m }));
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
socket.on("data", (c) => framer.push(c));
|
|
374
|
+
socket.on("error", (err) => finish(reject, err));
|
|
375
|
+
|
|
376
|
+
socket.on("connect", () => {
|
|
377
|
+
send({ method: "initialize", params: { token: this.token } }, () => {
|
|
378
|
+
send({ method, params }, (result) => finish(resolve, result));
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|