@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,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
+ }
@@ -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
+ }