@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,558 @@
1
+ /**
2
+ * `cirvix init` — from nothing to protected, in one command.
3
+ *
4
+ * ✓ Cirvix runtime installed
5
+ * ✓ MCP servers detected 6 servers across 2 runtimes
6
+ * ✓ Agent configuration detected Claude Code, Cursor
7
+ * ✓ Security policy initialized cirvix.policy · 17 rules
8
+ * ✓ Secret protection enabled 4 credential sources vaulted
9
+ * ✓ Audit logging enabled .cirvix/audit.jsonl
10
+ *
11
+ * Cirvix is protecting your agent.
12
+ *
13
+ * WHAT IT WILL AND WILL NOT TOUCH
14
+ *
15
+ * `init` writes inside the workspace and nowhere else: a `.cirvix/` state
16
+ * directory and a `cirvix.policy` file. It does NOT edit the user's editor
17
+ * configuration, because rewriting `~/.cursor/mcp.json` on their behalf is
18
+ * precisely the kind of unrequested action this product exists to prevent an
19
+ * agent from taking. The command that would do it prints the exact change for
20
+ * the operator to apply, and `--wire` performs it only when they ask.
21
+ *
22
+ * `init` is also idempotent and never destructive. An existing policy file is
23
+ * kept, reported as kept, and never overwritten — the second run of a setup
24
+ * command must not silently discard the rules somebody wrote after the first.
25
+ */
26
+
27
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
28
+ import { join } from "node:path";
29
+
30
+ import {
31
+ collectMcpServers,
32
+ detectCredentials,
33
+ detectFrameworks,
34
+ detectRuntimes,
35
+ } from "../core/detect.mjs";
36
+ import { compile } from "../core/policy-dsl.mjs";
37
+ import { writeToken, defaultEndpoint } from "../core/uds.mjs";
38
+ import { bold, dim, green, amber, blue, plural } from "../core/format.mjs";
39
+
40
+ const STARTER_POLICY = `# Cirvix policy
41
+ #
42
+ # Ordered rules, evaluated most-authoritative-first:
43
+ # deny always wins — a permit can never punch through it
44
+ # require_approval — outranks allow; a human decides
45
+ # sanitize — outranks allow; the call proceeds, cleaned
46
+ # allow — permits
47
+ # audit_only — records a match, authorizes nothing
48
+ #
49
+ # Anything no rule permits is denied. That is the point.
50
+ #
51
+ # Check it: cirvix policy check
52
+ # Test it: cirvix policy test
53
+ # Explain: cirvix policy explain --tool shell.exec --command "rm -rf /"
54
+
55
+ # ---------------------------------------------------------------- prohibited
56
+
57
+ deny:
58
+ name = deny-credential-files
59
+ tool = filesystem.read
60
+ path = **/.env
61
+ reason = "Reading .env is the shortest path from a prompt injection to a live credential."
62
+ remediation = "Request the value as a handle: secrets.get(\\"NAME\\")"
63
+
64
+ # .env.production and friends hold the credentials that matter most, and the
65
+ # pattern above does not cover them: a glob ending in ".env" does not match
66
+ # ".env.production". The adversarial corpus caught this as a live bypass.
67
+ #
68
+ # The variants are named individually rather than matched with a wildcard.
69
+ # ".env.example", ".env.sample", and ".env.template" are committed to source
70
+ # control by design, hold placeholder values, and are read constantly during
71
+ # setup — denying them is the kind of false positive that gets a security tool
72
+ # switched off. A deny always wins, so an exemption cannot be expressed as an
73
+ # allow; it has to be expressed as a narrower deny.
74
+ deny:
75
+ name = deny-dotenv-production
76
+ tool = filesystem.read
77
+ path = **/.env.production
78
+ reason = "Production credentials."
79
+ remediation = "Request the value as a handle: secrets.get(\\"NAME\\")"
80
+
81
+ deny:
82
+ name = deny-dotenv-local
83
+ tool = filesystem.read
84
+ path = **/.env.local
85
+ reason = "Local credentials, which are real credentials."
86
+
87
+ deny:
88
+ name = deny-dotenv-development
89
+ tool = filesystem.read
90
+ path = **/.env.development
91
+ reason = "Development credentials are still credentials."
92
+
93
+ deny:
94
+ name = deny-dotenv-staging
95
+ tool = filesystem.read
96
+ path = **/.env.staging
97
+ reason = "Staging credentials are still credentials."
98
+
99
+ deny:
100
+ name = deny-dotenv-test-env
101
+ tool = filesystem.read
102
+ path = **/.env.test
103
+ reason = "Test environments hold real keys more often than anyone admits."
104
+
105
+ deny:
106
+ name = deny-credential-directories
107
+ tool = filesystem.read
108
+ path = **/.aws/**
109
+ reason = "Cloud credentials are never readable by an agent."
110
+
111
+ deny:
112
+ name = deny-ssh-keys
113
+ tool = filesystem.read
114
+ path = **/.ssh/**
115
+ reason = "SSH private keys are never readable by an agent."
116
+
117
+ deny:
118
+ name = deny-cloud-metadata
119
+ network.destination = 169.254.169.254
120
+ reason = "The cloud instance-metadata endpoint returns live role credentials to anything that can reach it."
121
+
122
+ deny:
123
+ name = deny-metadata-hostname
124
+ network.destination = metadata.google.internal
125
+ reason = "Cloud metadata by hostname resolves to the same link-local address."
126
+
127
+ # The rest of the metadata endpoints. Every cloud has one, and a policy that
128
+ # blocks only AWS's leaves the same attack working on three other providers —
129
+ # which the generated corpus demonstrated by simply trying them.
130
+ deny:
131
+ name = deny-metadata-goog-short
132
+ network.destination = metadata.goog
133
+ reason = "The short form of the Google metadata endpoint."
134
+
135
+ deny:
136
+ name = deny-metadata-alibaba
137
+ network.destination = 100.100.100.200
138
+ reason = "The same instance-metadata attack, on Alibaba Cloud."
139
+
140
+ deny:
141
+ name = deny-metadata-ecs-task
142
+ network.destination = 169.254.170.2
143
+ reason = "ECS task metadata returns the task role's credentials."
144
+
145
+ deny:
146
+ name = deny-link-local
147
+ network.destination = 169.254.
148
+ reason = "The whole link-local range exists for host-local services that were never designed to authenticate."
149
+
150
+ deny:
151
+ name = deny-destructive-shell
152
+ tool = shell.exec
153
+ command = "rm -rf"
154
+ reason = "Recursive force-delete destroys state rather than changing it."
155
+
156
+ # The blanket rule, and the most important line in this file.
157
+ #
158
+ # Anything the risk engine calls CRITICAL — remote code execution, disk
159
+ # overwrite, privilege escalation, force-push, DROP TABLE, history wipe,
160
+ # reading a credential through a shell — is denied outright rather than held.
161
+ # Without this, each of those needed its own named rule, and the corpus found
162
+ # six that had none.
163
+ deny:
164
+ name = deny-critical-shell
165
+ tool = shell.exec
166
+ risk >= CRITICAL
167
+ reason = "A CRITICAL command must be permitted by a rule that names it, never held for a rubber stamp."
168
+ remediation = "If this specific command is intended, add an explicit allow rule naming it."
169
+
170
+ deny:
171
+ name = deny-remote-code-execution
172
+ tool = shell.exec
173
+ command = "curl"
174
+ risk >= CRITICAL
175
+ reason = "Downloading code and executing it in one step means nothing inspects it in between."
176
+
177
+ deny:
178
+ name = deny-workspace-escape
179
+ tool = filesystem.write
180
+ workspace = false
181
+ reason = "Writes outside the workspace root are outside what this run was scoped to change."
182
+
183
+ # An agent may reach the network. It may not put a credential in the request.
184
+ # This is the rule that turns "read the secret, then post it somewhere" from two
185
+ # individually-plausible calls into a blocked one.
186
+ deny:
187
+ name = deny-egress-carrying-secrets
188
+ tool = network.request
189
+ secrets >= 1
190
+ reason = "This request carries credential material. Send a scoped handle instead — the broker substitutes it on the wire and the value never enters the model's context."
191
+ remediation = "secrets.get(\\"NAME\\") returns a handle. Pass the handle where you would have passed the key."
192
+
193
+ deny:
194
+ name = deny-egress-after-secret-read
195
+ tool = network.request
196
+ touched_secret = true
197
+ reason = "This session read secret-shaped material, so outbound requests are blocked for the remainder of it."
198
+
199
+ deny:
200
+ name = deny-write-ci-config
201
+ tool = filesystem.write
202
+ path = ./.github/workflows/**
203
+ reason = "A workflow file is code that runs in CI with CI's credentials, reviewed by nobody before it runs."
204
+
205
+ deny:
206
+ name = deny-write-git-internals
207
+ tool = filesystem.write
208
+ path = ./.git/**
209
+ reason = "Writing into .git rewrites history without a commit, so nothing in the log reflects the change."
210
+
211
+ # ------------------------------------------------------------------ approval
212
+ #
213
+ # An agent that can rewrite its own instructions, its own tool list, or its own
214
+ # policy is not governed by any of them. Held rather than denied, because
215
+ # changing them is legitimate — with a person watching.
216
+
217
+ require_approval:
218
+ name = approve-policy-change
219
+ tool = filesystem.write
220
+ path = ./cirvix.policy
221
+ approvers = platform-oncall
222
+ reason = "This is the policy governing the agent. An agent that can edit it is not governed by it."
223
+
224
+ require_approval:
225
+ name = approve-mcp-config-change
226
+ tool = filesystem.write
227
+ path = ./.mcp.json
228
+ approvers = developer
229
+ reason = "This file decides which tools the agent can reach at all."
230
+
231
+ require_approval:
232
+ name = approve-agent-instructions-change
233
+ tool = filesystem.write
234
+ path = ./CLAUDE.md
235
+ approvers = developer
236
+ reason = "This file instructs the agent. Changing it changes what future runs believe they were told."
237
+
238
+ require_approval:
239
+ name = approve-agents-md-change
240
+ tool = filesystem.write
241
+ path = ./AGENTS.md
242
+ approvers = developer
243
+ reason = "This file instructs the agent."
244
+
245
+ require_approval:
246
+ name = approve-claude-dir-change
247
+ tool = filesystem.write
248
+ path = ./.claude/**
249
+ approvers = developer
250
+ reason = "Agent configuration. Changing it changes what future runs are allowed to do."
251
+
252
+ require_approval:
253
+ name = approve-database-write
254
+ tool = database.write
255
+ approvers = platform-oncall
256
+ reason = "Mutates persistent state that other systems read."
257
+
258
+ require_approval:
259
+ name = approve-high-risk-shell
260
+ tool = shell.exec
261
+ risk >= HIGH
262
+ approvers = platform-oncall
263
+ reason = "Arbitrary command execution reaches past every per-tool rule."
264
+
265
+ require_approval:
266
+ name = approve-production-deploy
267
+ tool = deploy.apply
268
+ env = production
269
+ approvers = platform-oncall
270
+ reason = "Changes what is serving live traffic."
271
+
272
+ # ----------------------------------------------------------------- sanitize
273
+
274
+ sanitize:
275
+ name = sanitize-fetched-content
276
+ tool = network.request
277
+ targets = result
278
+ reason = "Fetched content is data. Instructions inside it are not addressed to the model."
279
+
280
+ # -------------------------------------------------------------------- allow
281
+
282
+ allow:
283
+ name = allow-version-control-read
284
+ tool = git.status
285
+
286
+ allow:
287
+ name = allow-git-log
288
+ tool = git.log
289
+
290
+ allow:
291
+ name = allow-git-diff
292
+ tool = git.diff
293
+
294
+ allow:
295
+ name = allow-git-branch
296
+ tool = git.branch
297
+
298
+ allow:
299
+ name = allow-workspace-read
300
+ tool = filesystem.read
301
+ workspace = true
302
+
303
+ allow:
304
+ name = allow-workspace-list
305
+ tool = filesystem.list
306
+ workspace = true
307
+
308
+ allow:
309
+ name = allow-workspace-search
310
+ tool = filesystem.search
311
+ workspace = true
312
+
313
+ allow:
314
+ name = allow-workspace-write
315
+ tool = filesystem.write
316
+ workspace = true
317
+
318
+ allow:
319
+ name = allow-shell-low-risk
320
+ tool = shell.exec
321
+ risk <= MEDIUM
322
+
323
+ allow:
324
+ name = allow-database-read
325
+ tool = database.query
326
+
327
+ # Outbound requests are permitted and their results are sanitized on the way
328
+ # back. What stops exfiltration is not blocking the network — it is the two
329
+ # deny rules above, which forbid a request that carries a secret and any
330
+ # request at all once the session has touched one.
331
+ allow:
332
+ name = allow-network-request
333
+ tool = network.request
334
+
335
+ # ---------------------------------------------------------------- test cases
336
+ #
337
+ # These run under \`cirvix policy test\`. A policy file that ships its own tests
338
+ # is one an operator can change safely.
339
+
340
+ test "a workspace file is readable":
341
+ tool = filesystem.read
342
+ path = ./src/app.ts
343
+ expect allow
344
+
345
+ test "dotenv is not readable":
346
+ tool = filesystem.read
347
+ path = .env
348
+ expect deny
349
+
350
+ test "aws credentials are not readable":
351
+ tool = filesystem.read
352
+ path = ~/.aws/credentials
353
+ expect deny
354
+
355
+ test "cloud metadata is unreachable":
356
+ tool = network.request
357
+ url = http://169.254.169.254/latest/meta-data/
358
+ expect deny
359
+
360
+ test "recursive delete is denied":
361
+ tool = shell.exec
362
+ command = rm -rf /
363
+ expect deny
364
+
365
+ test "running tests is allowed":
366
+ tool = shell.exec
367
+ command = npm test
368
+ expect allow
369
+
370
+ test "git status is allowed":
371
+ tool = git.status
372
+ expect allow
373
+
374
+ test "a database write waits for a human":
375
+ tool = database.write
376
+ expect require_approval
377
+
378
+ test "fetching a page is allowed, and sanitized":
379
+ tool = network.request
380
+ url = https://docs.example.com/guide
381
+ expect sanitize
382
+
383
+ test "writing outside the workspace is denied":
384
+ tool = filesystem.write
385
+ path = /etc/hosts
386
+ expect deny
387
+ `;
388
+
389
+ async function exists(path) {
390
+ try {
391
+ await access(path);
392
+ return true;
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
397
+
398
+ /**
399
+ * @param {object} opts
400
+ * @param {string} opts.cwd
401
+ * @param {boolean} [opts.json]
402
+ * @param {boolean} [opts.force] overwrite an existing policy file
403
+ * @returns {Promise<{result:object, output:string}>}
404
+ */
405
+ export async function init({ cwd = process.cwd(), json = false, force = false } = {}) {
406
+ const steps = [];
407
+ const stateDir = join(cwd, ".cirvix");
408
+ const policyPath = join(cwd, "cirvix.policy");
409
+
410
+ /* ------------------------------------------------------------ 1. runtime */
411
+ await mkdir(stateDir, { recursive: true });
412
+ const token = await writeToken(stateDir);
413
+ const endpoint = defaultEndpoint(stateDir);
414
+ steps.push({
415
+ id: "runtime",
416
+ label: "Cirvix runtime installed",
417
+ ok: true,
418
+ detail: `state in ./.cirvix · control socket ${endpoint}`,
419
+ });
420
+
421
+ /* -------------------------------------------------------- 2. MCP servers */
422
+ const runtimes = await detectRuntimes();
423
+ const servers = collectMcpServers(runtimes);
424
+ steps.push({
425
+ id: "mcp",
426
+ label: "MCP servers detected",
427
+ ok: servers.length > 0,
428
+ detail: servers.length
429
+ ? `${plural(servers.length, "server")} across ${plural(runtimes.length, "runtime")}`
430
+ : "none found — the gateway will govern whatever you point it at",
431
+ });
432
+
433
+ /* ------------------------------------------------------------- 3. agents */
434
+ const frameworks = await detectFrameworks(cwd);
435
+ const agentNames = [...runtimes.map((r) => r.label), ...frameworks.map((f) => f.label)];
436
+ steps.push({
437
+ id: "agents",
438
+ label: "Agent configuration detected",
439
+ ok: agentNames.length > 0,
440
+ detail: agentNames.length ? agentNames.join(", ") : "none found in this workspace",
441
+ });
442
+
443
+ /* ------------------------------------------------------------- 4. policy */
444
+ const policyExisted = await exists(policyPath);
445
+ if (!policyExisted || force) {
446
+ await writeFile(policyPath, STARTER_POLICY, "utf8");
447
+ }
448
+ const source = await readFile(policyPath, "utf8");
449
+ let ruleCount = 0;
450
+ let testCount = 0;
451
+ let policyError = null;
452
+ try {
453
+ const compiled = compile(source, { cwd, origin: "cirvix.policy" });
454
+ ruleCount = compiled.rules.length;
455
+ testCount = compiled.tests.length;
456
+ } catch (err) {
457
+ policyError = err.message;
458
+ }
459
+ steps.push({
460
+ id: "policy",
461
+ label: "Security policy initialized",
462
+ ok: !policyError,
463
+ detail: policyError
464
+ ? policyError
465
+ : `cirvix.policy · ${plural(ruleCount, "rule")}, ${plural(testCount, "test")}` +
466
+ (policyExisted && !force ? " (existing file kept)" : ""),
467
+ });
468
+
469
+ /* ------------------------------------------------------------ 5. secrets */
470
+ const credentials = await detectCredentials(cwd);
471
+ steps.push({
472
+ id: "secrets",
473
+ label: "Secret protection enabled",
474
+ ok: true,
475
+ detail: credentials.length
476
+ ? `${plural(credentials.length, "credential source")} found and covered by deny rules; run \`cirvix vault load\` to broker them as handles`
477
+ : "no credential files in this workspace; detection is active on every call",
478
+ });
479
+
480
+ /* -------------------------------------------------------------- 6. audit */
481
+ const auditPath = join(stateDir, "audit.jsonl");
482
+ if (!(await exists(auditPath))) await writeFile(auditPath, "", "utf8");
483
+ steps.push({
484
+ id: "audit",
485
+ label: "Audit logging enabled",
486
+ ok: true,
487
+ detail: ".cirvix/audit.jsonl · hash-chained, verify with `cirvix audit verify`",
488
+ });
489
+
490
+ const result = {
491
+ ok: steps.every((s) => s.ok || s.id === "mcp" || s.id === "agents"),
492
+ cwd,
493
+ stateDir,
494
+ policyPath,
495
+ endpoint,
496
+ tokenPath: join(stateDir, "socket.token"),
497
+ rules: ruleCount,
498
+ tests: testCount,
499
+ mcpServers: servers.length,
500
+ runtimes: runtimes.map((r) => ({ id: r.id, label: r.label, governed: r.governed, path: r.path })),
501
+ credentials: credentials.length,
502
+ steps,
503
+ // Never printed, never logged — returned so a caller that just created the
504
+ // session can use it without reading the file back.
505
+ token,
506
+ };
507
+
508
+ if (json) {
509
+ const { token: _hidden, ...safe } = result;
510
+ return { result, output: JSON.stringify(safe, null, 2) };
511
+ }
512
+ return { result, output: render(result, { runtimes }) };
513
+ }
514
+
515
+ /* -------------------------------------------------------------------------- */
516
+
517
+ function render(result, { runtimes }) {
518
+ const lines = ["", ` ${bold("CIRVIX")} ${dim("· initializing")}`, ""];
519
+
520
+ const width = Math.max(...result.steps.map((s) => s.label.length));
521
+ for (const step of result.steps) {
522
+ const tick = step.ok ? green("✓") : amber("○");
523
+ lines.push(` ${tick} ${step.label.padEnd(width)} ${dim(step.detail)}`);
524
+ }
525
+
526
+ lines.push("");
527
+ lines.push(` ${green(bold("Cirvix is protecting your agent."))}`);
528
+ lines.push("");
529
+
530
+ // The one thing init deliberately does not do for you.
531
+ const ungoverned = runtimes.filter((r) => !r.governed);
532
+ if (ungoverned.length) {
533
+ lines.push(` ${amber("One step left.")} ${dim(`${plural(ungoverned.length, "runtime")} still calls tools directly:`)}`);
534
+ lines.push("");
535
+ for (const r of ungoverned) {
536
+ lines.push(` ${bold(r.label)} ${dim(r.path)}`);
537
+ }
538
+ lines.push("");
539
+ lines.push(` ${dim("Route them through the gateway — Cirvix does not edit your editor config for you:")}`);
540
+ lines.push("");
541
+ lines.push(` ${blue(`cirvix gateway --servers ${ungoverned[0].path}`)}`);
542
+ lines.push("");
543
+ lines.push(` ${dim("or add this to that file's mcpServers block:")}`);
544
+ lines.push("");
545
+ lines.push(dim(` "cirvix": { "command": "cirvix", "args": ["gateway", "--servers", "${ungoverned[0].path.replace(/\\/g, "/")}"] }`));
546
+ lines.push("");
547
+ }
548
+
549
+ lines.push(` ${dim("Next")}`);
550
+ lines.push(` ${blue("cirvix policy test")} ${dim("run the policy's own test cases")}`);
551
+ lines.push(` ${blue("cirvix demo")} ${dim("watch an injected exfiltration attempt get stopped")}`);
552
+ lines.push(` ${blue("cirvix status")} ${dim("what is protected right now")}`);
553
+ lines.push("");
554
+
555
+ return lines.join("\n");
556
+ }
557
+
558
+ export { STARTER_POLICY };