@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,530 @@
1
+ /**
2
+ * The local credential vault.
3
+ *
4
+ * The demo everyone remembers is this one: the agent asks for a secret, gets
5
+ * `sec_handle_01`, uses it, the call succeeds — and the agent's context window
6
+ * never contains the credential. Not redacted afterwards. Never present.
7
+ *
8
+ * OPENAI_API_KEY ──issue──▶ sec_handle_01 ← what the agent holds
9
+ * │
10
+ * (vault, in this process)
11
+ * │
12
+ * permitted call ──substitute──▶ sk-proj-… ← what goes on the wire
13
+ * tool result ──redact─────▶ sec_handle_01 ← what comes back
14
+ *
15
+ * WHY THIS EXISTS WHEN `SecretsClient` ALREADY DOES THIS
16
+ *
17
+ * `SecretsClient` brokers against the control plane: the material lives on a
18
+ * server, resolution is audited centrally, and a handle can be revoked
19
+ * organisation-wide. It is the right answer for a team, and it needs an
20
+ * account, a deployment, and a network round trip per resolution.
21
+ *
22
+ * This is the same contract with the server removed. It runs on one machine,
23
+ * holds material in process memory for the life of a run, and requires nothing.
24
+ * A developer gets the property that matters — *the agent never receives the
25
+ * raw value* — during `cirvix init`, not after procurement.
26
+ *
27
+ * The two are deliberately interface-compatible (`get`, `substitute`, `redact`,
28
+ * `forget`, `held`). `Guard` takes either without knowing which, so moving from
29
+ * local to brokered is a configuration change and not a code change.
30
+ *
31
+ * WHAT THIS DOES NOT CLAIM
32
+ *
33
+ * Plaintext lives in ordinary process memory while a call is in flight. Node
34
+ * cannot `mlock`, cannot prevent a core dump, and cannot stop a debugger
35
+ * attached to its own process. Anything with code execution as this user has
36
+ * already won. The threat this actually addresses is the one that keeps
37
+ * happening: a credential entering a model's context, then a transcript, a
38
+ * trace, a support ticket, and a training set.
39
+ */
40
+
41
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
42
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
43
+ import { dirname } from "node:path";
44
+
45
+ import { HANDLE_PREFIX, findHandles } from "./secrets.mjs";
46
+ import { redact as redactSecrets } from "./secret-detect.mjs";
47
+
48
+ /** Handles issued locally are short and readable: `sec_handle_01`. */
49
+ const LOCAL_HANDLE_DIGITS = 2;
50
+
51
+ /** Values shorter than this are never used for return-path matching. */
52
+ const MIN_SCANNABLE_LENGTH = 8;
53
+
54
+ const MAX_DEPTH = 12;
55
+
56
+ /* -------------------------------------------------------------------------- */
57
+
58
+ /** Rewrites every string in a structure, returning a copy. */
59
+ function mapStrings(value, fn, depth = 0) {
60
+ if (depth > MAX_DEPTH) return value;
61
+ if (typeof value === "string") return fn(value);
62
+ if (Array.isArray(value)) return value.map((v) => mapStrings(v, fn, depth + 1));
63
+ if (value && typeof value === "object") {
64
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, mapStrings(v, fn, depth + 1)]));
65
+ }
66
+ return value;
67
+ }
68
+
69
+ /** Constant-time string compare, for handle lookup. */
70
+ function sameString(a, b) {
71
+ const x = Buffer.from(String(a));
72
+ const y = Buffer.from(String(b));
73
+ if (x.length !== y.length) return false;
74
+ return timingSafeEqual(x, y);
75
+ }
76
+
77
+ /* -------------------------------------------------------------------------- */
78
+ /* Vault */
79
+ /* -------------------------------------------------------------------------- */
80
+
81
+ export class Vault {
82
+ /** handle → entry. The only place raw material is held. */
83
+ #entries = new Map();
84
+ /** name → handle, so `get` is idempotent. */
85
+ #byName = new Map();
86
+ #next = 1;
87
+
88
+ /**
89
+ * @param {object} [opts]
90
+ * @param {string} [opts.agent] recorded against every resolution
91
+ * @param {(m:string,extra?:object)=>void} [opts.log]
92
+ * @param {(event:object)=>void} [opts.onEvent] resolution telemetry
93
+ */
94
+ constructor({ agent = "local", log = () => {}, onEvent = () => {} } = {}) {
95
+ this.agent = agent;
96
+ this.log = log;
97
+ this.onEvent = onEvent;
98
+ this.stats = { issued: 0, requested: 0, substituted: 0, refused: 0, leaksCaught: 0 };
99
+ }
100
+
101
+ /**
102
+ * Puts material under a handle.
103
+ *
104
+ * @param {string} name what the operator calls it
105
+ * @param {string} value the material — never leaves this object
106
+ * @param {object} [opts]
107
+ * @param {string[]} [opts.destinations] hosts this handle resolves for; empty means any
108
+ * @param {number} [opts.maxUses] resolutions before it stops working
109
+ * @param {number} [opts.ttlSeconds] lifetime from issue
110
+ * @param {string} [opts.subject] the only agent that may spend it
111
+ * @returns {string} the handle
112
+ */
113
+ issue(name, value, { destinations = [], maxUses = Infinity, ttlSeconds = null, subject = null } = {}) {
114
+ if (typeof value !== "string" || !value.length) {
115
+ throw new Error(`Cannot issue a handle for "${name}": no value.`);
116
+ }
117
+
118
+ // Re-issuing a name returns the existing handle rather than minting a
119
+ // second one for the same material. Two handles for one secret means the
120
+ // return-path scan reports whichever it happens to match and the operator
121
+ // cannot tell they are the same credential.
122
+ //
123
+ // The subject must match too: returning a handle bound to one agent because
124
+ // a different agent asked for the same name would hand over the binding
125
+ // along with the handle.
126
+ const existing = this.#byName.get(name);
127
+ const previous = existing ? this.#entries.get(existing) : null;
128
+ if (previous && previous.value === value && (previous.subject ?? null) === (subject ?? null)) {
129
+ return existing;
130
+ }
131
+
132
+ const handle = `${HANDLE_PREFIX}${String(this.#next++).padStart(LOCAL_HANDLE_DIGITS, "0")}`;
133
+ this.#entries.set(handle, {
134
+ name,
135
+ value,
136
+ destinations: destinations.map((d) => String(d).toLowerCase()),
137
+ maxUses,
138
+ uses: 0,
139
+ subject: subject === null || subject === undefined ? null : String(subject),
140
+ expiresAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : null,
141
+ issuedAt: Date.now(),
142
+ });
143
+ this.#byName.set(name, handle);
144
+ this.stats.issued++;
145
+ this.log(`vault: issued ${handle} for ${name}`);
146
+ return handle;
147
+ }
148
+
149
+ /**
150
+ * The handle for a named secret — the call an agent makes.
151
+ *
152
+ * Async and returning only the handle, matching `SecretsClient.get`, so the
153
+ * two are substitutable at the call site.
154
+ */
155
+ async get(name) {
156
+ this.stats.requested++;
157
+ const handle = this.#byName.get(name);
158
+ if (!handle) throw new Error(`No secret named "${name}" is loaded in this vault.`);
159
+ return handle;
160
+ }
161
+
162
+ /** Every handle this vault has issued, with no material attached. */
163
+ inventory() {
164
+ return [...this.#entries.entries()].map(([handle, e]) => ({
165
+ handle,
166
+ name: e.name,
167
+ destinations: e.destinations,
168
+ uses: e.uses,
169
+ maxUses: e.maxUses === Infinity ? null : e.maxUses,
170
+ expiresAt: e.expiresAt ? new Date(e.expiresAt).toISOString() : null,
171
+ expired: this.#isExpired(e),
172
+ }));
173
+ }
174
+
175
+ #isExpired(entry) {
176
+ return Boolean(entry.expiresAt && Date.now() > entry.expiresAt);
177
+ }
178
+
179
+ /**
180
+ * Whether a handle may be spent against a destination.
181
+ *
182
+ * Scoping is the property that makes a leaked handle far less useful than a
183
+ * leaked key: a handle bound to `api.stripe.com` sent to `attacker.example`
184
+ * resolves to nothing. An unscoped handle (empty `destinations`) is
185
+ * deliberately allowed, because forcing scope at issue time makes people skip
186
+ * the vault entirely — but `cirvix status` reports how many are unscoped.
187
+ */
188
+ #authorize(entry, destination, subject) {
189
+ if (this.#isExpired(entry)) {
190
+ return { ok: false, outcome: "expired", reason: "This handle has expired." };
191
+ }
192
+ if (entry.uses >= entry.maxUses) {
193
+ return { ok: false, outcome: "exhausted", reason: "This handle has been used the maximum number of times." };
194
+ }
195
+
196
+ /*
197
+ * POSSESSION OF A HANDLE IS NOT AUTHORITY TO SPEND IT.
198
+ *
199
+ * A handle is deliberately not a secret — that is the whole design. It goes
200
+ * in arguments, it is printed in audit records, it is safe to paste into a
201
+ * ticket, and it can come back inside a tool result another agent reads.
202
+ *
203
+ * Which means that without a subject check, a handle appearing in any
204
+ * shared surface IS the credential, laundered. Every property the vault
205
+ * claims would still hold — the material never enters a model's context —
206
+ * for the wrong agent. In a multi-agent deployment that is the whole attack:
207
+ * `payments-agent` holds the Stripe key, `summariser` reads a transcript
208
+ * containing `sec_handle_01`, and charges cards.
209
+ *
210
+ * Binding is per handle and opt-in, because a single-agent install has no
211
+ * boundary to enforce and must not be made to declare one.
212
+ */
213
+ if (entry.subject !== null && String(subject ?? "") !== entry.subject) {
214
+ return {
215
+ ok: false,
216
+ outcome: "wrong_subject",
217
+ reason:
218
+ `This handle was issued to ${entry.subject} and was presented by ` +
219
+ `${subject ? String(subject) : "an unidentified caller"}. Holding a handle is not authority to spend it.`,
220
+ };
221
+ }
222
+
223
+ if (!entry.destinations.length) return { ok: true };
224
+
225
+ let host = null;
226
+ try {
227
+ host = new URL(destination).hostname.toLowerCase();
228
+ } catch {
229
+ return {
230
+ ok: false,
231
+ outcome: "no_destination",
232
+ reason: "A scoped handle resolves only against an absolute http(s) destination, and this call names none.",
233
+ };
234
+ }
235
+ const allowed = entry.destinations.some((d) => host === d || host.endsWith(`.${d}`));
236
+ return allowed
237
+ ? { ok: true }
238
+ : {
239
+ ok: false,
240
+ outcome: "destination_not_allowed",
241
+ reason: `This handle is scoped to ${entry.destinations.join(", ")} and this call targets ${host}.`,
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Replaces every handle in `args` with the material it stands for.
247
+ *
248
+ * Fails closed: if any handle cannot be resolved for this destination,
249
+ * NOTHING is substituted and the caller must refuse the call. Partial
250
+ * substitution would put a real credential on the wire alongside a literal
251
+ * handle string — the worst of both outcomes.
252
+ */
253
+ async substitute(args, { destination, subject = null } = {}) {
254
+ const handles = [...findHandles(args)];
255
+ if (handles.length === 0) return { ok: true, value: args, substituted: [] };
256
+
257
+ const replacements = new Map();
258
+ const names = [];
259
+
260
+ for (const handle of handles) {
261
+ const entry = this.#lookup(handle);
262
+ if (!entry) {
263
+ this.stats.refused++;
264
+ return {
265
+ ok: false,
266
+ value: args,
267
+ substituted: [],
268
+ outcome: "unknown_handle",
269
+ reason: `${handle} is not a handle this vault issued. It was not forwarded as a literal string.`,
270
+ };
271
+ }
272
+ const authorized = this.#authorize(entry, destination, subject);
273
+ if (!authorized.ok) {
274
+ this.stats.refused++;
275
+ this.onEvent({
276
+ kind: "secret_refused",
277
+ handle,
278
+ name: entry.name,
279
+ outcome: authorized.outcome,
280
+ subject: subject ?? null,
281
+ });
282
+ return { ok: false, value: args, substituted: [], ...authorized };
283
+ }
284
+ replacements.set(handle, entry.value);
285
+ names.push(entry.name);
286
+ entry.uses++;
287
+ }
288
+
289
+ const value = mapStrings(args, (s) => {
290
+ let out = s;
291
+ for (const [handle, real] of replacements) out = out.split(handle).join(real);
292
+ return out;
293
+ });
294
+
295
+ this.stats.substituted += replacements.size;
296
+ this.onEvent({ kind: "secret_substituted", names, destination: destination ?? null });
297
+ return { ok: true, value, substituted: names };
298
+ }
299
+
300
+ #lookup(handle) {
301
+ for (const [h, entry] of this.#entries) {
302
+ if (sameString(h, handle)) return entry;
303
+ }
304
+ return null;
305
+ }
306
+
307
+ /**
308
+ * Finds material this vault holds appearing in a payload.
309
+ *
310
+ * Unlike `SecretsClient`, which can only recognise what a session resolved,
311
+ * the vault knows everything it holds — so it catches a credential echoed
312
+ * back even on a call that never spent a handle.
313
+ */
314
+ scan(payload) {
315
+ const findings = [];
316
+ if (this.#entries.size === 0) return findings;
317
+ const text = typeof payload === "string" ? payload : JSON.stringify(payload ?? "");
318
+ for (const [handle, entry] of this.#entries) {
319
+ if (entry.value.length < MIN_SCANNABLE_LENGTH) continue;
320
+ if (text.includes(entry.value)) findings.push({ handle, name: entry.name });
321
+ }
322
+ return findings;
323
+ }
324
+
325
+ /**
326
+ * Returns `payload` with any held material swapped back to its handle.
327
+ *
328
+ * Two passes, and the second one matters. The first puts handles back over
329
+ * material this vault knows. The second runs the pattern detectors over
330
+ * what remains, so a credential the vault never held — one the tool result
331
+ * happened to contain — is still masked before it reaches the model.
332
+ */
333
+ redact(payload) {
334
+ const known = this.scan(payload);
335
+ let out = payload;
336
+
337
+ if (known.length) {
338
+ this.stats.leaksCaught += known.length;
339
+ out = mapStrings(payload, (s) => {
340
+ let text = s;
341
+ for (const finding of known) {
342
+ const entry = this.#lookup(finding.handle);
343
+ if (entry) text = text.split(entry.value).join(finding.handle);
344
+ }
345
+ return text;
346
+ });
347
+ }
348
+
349
+ // The second pass, and it must REDACT rather than merely report.
350
+ //
351
+ // An earlier version scanned for pattern-detected credentials and returned
352
+ // the findings alongside the untouched payload — so a key the vault never
353
+ // held was faithfully listed in the log and forwarded to the model anyway.
354
+ // Detection without redaction on the return path is a leak with a receipt.
355
+ const swept = redactSecrets(out);
356
+ if (swept.findings.length) this.stats.leaksCaught += swept.findings.length;
357
+
358
+ return {
359
+ payload: swept.value,
360
+ findings: known.map((k) => ({ ...k, source: "vault" })),
361
+ detected: swept.findings,
362
+ };
363
+ }
364
+
365
+ /** Drops every value. Called when a session ends. */
366
+ forget() {
367
+ this.#entries.clear();
368
+ this.#byName.clear();
369
+ }
370
+
371
+ get held() {
372
+ return this.#entries.size;
373
+ }
374
+
375
+ /* ------------------------------------------------------------------------ */
376
+ /* Loading */
377
+ /* ------------------------------------------------------------------------ */
378
+
379
+ /**
380
+ * Loads credential-shaped environment variables.
381
+ *
382
+ * Only variables whose *name* claims credential are taken, and the value is
383
+ * moved into the vault rather than copied: `process.env[name]` is replaced
384
+ * with the handle. A child process the agent spawns therefore inherits the
385
+ * handle, not the key — which is the difference between protecting the agent
386
+ * and protecting everything the agent starts.
387
+ *
388
+ * @param {object} [opts]
389
+ * @param {boolean} [opts.replaceEnv] default true — see above
390
+ * @returns {Array<{name:string, handle:string}>}
391
+ */
392
+ loadFromEnv({ env = process.env, replaceEnv = true, pattern = /(API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|CREDENTIAL)/i } = {}) {
393
+ const loaded = [];
394
+ for (const [name, value] of Object.entries(env)) {
395
+ if (!pattern.test(name)) continue;
396
+ if (typeof value !== "string" || value.length < 8) continue;
397
+ // Never vault our own control-plane key: the daemon needs it literally.
398
+ if (/^CIRVIX_/.test(name)) continue;
399
+ const handle = this.issue(name, value);
400
+ if (replaceEnv) env[name] = handle;
401
+ loaded.push({ name, handle });
402
+ }
403
+ return loaded;
404
+ }
405
+
406
+ /**
407
+ * Loads a `.env`-shaped file without the values ever reaching a log.
408
+ *
409
+ * Deliberately does not use `process.env` as an intermediary — writing them
410
+ * there first, even briefly, means anything that dumps the environment in
411
+ * between captures the lot.
412
+ */
413
+ async loadFromFile(path, { pattern } = {}) {
414
+ let text;
415
+ try {
416
+ text = await readFile(path, "utf8");
417
+ } catch {
418
+ return [];
419
+ }
420
+ const loaded = [];
421
+ for (const line of text.split(/\r?\n/)) {
422
+ const trimmed = line.trim();
423
+ if (!trimmed || trimmed.startsWith("#")) continue;
424
+ const eq = trimmed.indexOf("=");
425
+ if (eq === -1) continue;
426
+ const name = trimmed.slice(0, eq).trim().replace(/^export\s+/, "");
427
+ let value = trimmed.slice(eq + 1).trim();
428
+ if (
429
+ (value.startsWith('"') && value.endsWith('"')) ||
430
+ (value.startsWith("'") && value.endsWith("'"))
431
+ ) {
432
+ value = value.slice(1, -1);
433
+ }
434
+ if (!value || value.length < 8) continue;
435
+ if (pattern && !pattern.test(name)) continue;
436
+ loaded.push({ name, handle: this.issue(name, value) });
437
+ }
438
+ return loaded;
439
+ }
440
+
441
+ /* ------------------------------------------------------------------------ */
442
+ /* Sealing */
443
+ /* ------------------------------------------------------------------------ */
444
+
445
+ /**
446
+ * Writes the vault to disk sealed with AES-256-GCM.
447
+ *
448
+ * The passphrase is stretched with scrypt. GCM's tag is verified on open, so
449
+ * a tampered file fails to decrypt rather than yielding altered material —
450
+ * which matters here because the "material" is what gets sent to a bank's
451
+ * API.
452
+ *
453
+ * Persisting is opt-in. The default vault holds material for the life of a
454
+ * process and writes nothing, because a file full of credentials is a new
455
+ * asset to defend and most runs do not need one.
456
+ */
457
+ async seal(path, passphrase) {
458
+ if (typeof passphrase !== "string" || passphrase.length < 12) {
459
+ throw new Error("Sealing needs a passphrase of at least 12 characters.");
460
+ }
461
+ const salt = randomBytes(16);
462
+ const iv = randomBytes(12);
463
+ const key = scryptSync(passphrase, salt, 32);
464
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
465
+
466
+ const plain = JSON.stringify(
467
+ [...this.#entries.entries()].map(([handle, e]) => ({
468
+ handle,
469
+ name: e.name,
470
+ value: e.value,
471
+ destinations: e.destinations,
472
+ maxUses: e.maxUses === Infinity ? null : e.maxUses,
473
+ expiresAt: e.expiresAt,
474
+ })),
475
+ );
476
+
477
+ const body = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
478
+ const payload = {
479
+ version: 1,
480
+ kdf: "scrypt",
481
+ cipher: "aes-256-gcm",
482
+ salt: salt.toString("base64"),
483
+ iv: iv.toString("base64"),
484
+ tag: cipher.getAuthTag().toString("base64"),
485
+ body: body.toString("base64"),
486
+ };
487
+
488
+ await mkdir(dirname(path), { recursive: true }).catch(() => {});
489
+ await writeFile(path, JSON.stringify(payload), "utf8");
490
+ // Best effort: a no-op on Windows, and the file is sealed regardless.
491
+ await chmod(path, 0o600).catch(() => {});
492
+ return { path, entries: this.#entries.size };
493
+ }
494
+
495
+ /** Opens a sealed vault. A wrong passphrase fails the GCM tag check. */
496
+ async unseal(path, passphrase) {
497
+ const payload = JSON.parse(await readFile(path, "utf8"));
498
+ if (payload.version !== 1) throw new Error(`Unsupported vault version ${payload.version}.`);
499
+
500
+ const key = scryptSync(passphrase, Buffer.from(payload.salt, "base64"), 32);
501
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(payload.iv, "base64"));
502
+ decipher.setAuthTag(Buffer.from(payload.tag, "base64"));
503
+
504
+ let plain;
505
+ try {
506
+ plain = Buffer.concat([
507
+ decipher.update(Buffer.from(payload.body, "base64")),
508
+ decipher.final(),
509
+ ]).toString("utf8");
510
+ } catch {
511
+ throw new Error("Could not open the vault: wrong passphrase, or the file has been altered.");
512
+ }
513
+
514
+ for (const e of JSON.parse(plain)) {
515
+ this.#entries.set(e.handle, {
516
+ name: e.name,
517
+ value: e.value,
518
+ destinations: e.destinations ?? [],
519
+ maxUses: e.maxUses ?? Infinity,
520
+ uses: 0,
521
+ expiresAt: e.expiresAt ?? null,
522
+ issuedAt: Date.now(),
523
+ });
524
+ this.#byName.set(e.name, e.handle);
525
+ const n = Number(String(e.handle).slice(HANDLE_PREFIX.length));
526
+ if (Number.isFinite(n) && n >= this.#next) this.#next = n + 1;
527
+ }
528
+ return { entries: this.#entries.size };
529
+ }
530
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,143 @@
1
+ /** Public entry point for @cirvix_ai/agent-control. */
2
+
3
+ /* Policy engine ----------------------------------------------------------- */
4
+ export {
5
+ evaluate,
6
+ matchGlob,
7
+ parseRules,
8
+ validateRules,
9
+ canonicalizeResource,
10
+ STARTER_RULES,
11
+ EFFECT,
12
+ VERDICT,
13
+ DECISION,
14
+ } from "./core/policy.mjs";
15
+
16
+ /* Decision vocabulary ----------------------------------------------------- */
17
+ export {
18
+ MODE,
19
+ applyMode,
20
+ decisionLabel,
21
+ escalateForRisk,
22
+ isAppealable,
23
+ isForwarded,
24
+ toDecision,
25
+ toVerdict,
26
+ } from "./core/decisions.mjs";
27
+
28
+ /* Policy DSL -------------------------------------------------------------- */
29
+ export {
30
+ compile as compilePolicy,
31
+ parse as parsePolicySource,
32
+ toSource as policyToSource,
33
+ PolicySyntaxError,
34
+ } from "./core/policy-dsl.mjs";
35
+
36
+ /* Risk -------------------------------------------------------------------- */
37
+ export {
38
+ RISK,
39
+ RISK_ORDER,
40
+ RISK_RULES,
41
+ DEFAULT_POSTURE,
42
+ classify,
43
+ isKnownSafeCommand,
44
+ maxRisk,
45
+ riskAtLeast,
46
+ riskLabel,
47
+ riskRank,
48
+ } from "./core/risk.mjs";
49
+
50
+ /* Normalization ----------------------------------------------------------- */
51
+ export {
52
+ SOURCE,
53
+ TAXONOMY,
54
+ canonicalAction,
55
+ classifyTool,
56
+ extractCommand,
57
+ extractDestination,
58
+ extractResource,
59
+ normalize,
60
+ policyContext,
61
+ policyRequest,
62
+ publicToolName,
63
+ requestId,
64
+ } from "./core/normalize.mjs";
65
+
66
+ /* The runtime ------------------------------------------------------------- */
67
+ export { Pipeline } from "./core/pipeline.mjs";
68
+ export { UdsClient, UdsServer, defaultEndpoint, readToken, tokenPath, writeToken } from "./core/uds.mjs";
69
+
70
+ /* Transports -------------------------------------------------------------- */
71
+ export { Gateway, fingerprintTool } from "./core/gateway.mjs";
72
+ export { HttpGatewayServer, HttpUpstream, assertAllowedEndpoint } from "./core/http-transport.mjs";
73
+ export { Daemon } from "./core/daemon.mjs";
74
+
75
+ /* SDK --------------------------------------------------------------------- */
76
+ export {
77
+ CirvixDenied,
78
+ CirvixHeld,
79
+ Guard,
80
+ actionForTool,
81
+ destinationFor,
82
+ guard,
83
+ resourceForCall,
84
+ wrap,
85
+ } from "./core/guard.mjs";
86
+
87
+ /* Secrets ----------------------------------------------------------------- */
88
+ export { SecretsClient, HANDLE_PREFIX, findHandles, isHandle } from "./core/secrets.mjs";
89
+ export { Vault } from "./core/vault.mjs";
90
+ export {
91
+ DETECTORS,
92
+ SEVERITY,
93
+ entropy,
94
+ fingerprint as secretFingerprint,
95
+ hasSecrets,
96
+ mask,
97
+ redact as redactSecrets,
98
+ scan as scanSecrets,
99
+ summarize as summarizeSecrets,
100
+ } from "./core/secret-detect.mjs";
101
+
102
+ /* Sanitization ------------------------------------------------------------ */
103
+ export {
104
+ INJECTION_RULES,
105
+ hasInjection,
106
+ scan as scanInjection,
107
+ stripInjection,
108
+ } from "./core/sanitize.mjs";
109
+
110
+ /* History ----------------------------------------------------------------- */
111
+ export { AuditChain, canonicalJson, hashRecord } from "./core/audit.mjs";
112
+ export {
113
+ byRun,
114
+ decideNow,
115
+ find as findDecision,
116
+ query as queryDecisions,
117
+ read as readJournal,
118
+ renderLine,
119
+ renderTree,
120
+ replay,
121
+ replayOne,
122
+ summarize as summarizeDecisions,
123
+ } from "./core/journal.mjs";
124
+
125
+ /* Delegation -------------------------------------------------------------- */
126
+ export {
127
+ DELEGATION_ERROR,
128
+ DelegationBroker,
129
+ intersectScopes,
130
+ isNarrowing,
131
+ normalizeScope,
132
+ scopePermits,
133
+ } from "./core/delegation.mjs";
134
+
135
+ /* Approvals --------------------------------------------------------------- */
136
+ export { ApprovalStore, STATE as APPROVAL_STATE, approvalFingerprint } from "./core/approvals.mjs";
137
+
138
+ /* Commands ---------------------------------------------------------------- */
139
+ export { scan } from "./commands/scan.mjs";
140
+ export { init, STARTER_POLICY } from "./commands/init.mjs";
141
+ export { status } from "./commands/status.mjs";
142
+ export { demo } from "./commands/demo.mjs";
143
+ export { check as policyCheck, explain as policyExplain, list as policyList, loadPolicyFile, test as policyTest } from "./commands/policy.mjs";