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