@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,337 @@
1
+ /**
2
+ * Runtime detection.
3
+ *
4
+ * Finds the agent runtimes, MCP server configurations, and credential files
5
+ * that are actually present on this machine. Everything here is READ-ONLY —
6
+ * `scan` must never write, never phone home, and never require an account.
7
+ * That property is the entire reason a developer will run it, so it is
8
+ * enforced structurally: this module imports no network API and opens nothing
9
+ * for writing.
10
+ *
11
+ * Detection is filesystem inspection, not magic. Each probe declares the exact
12
+ * path it looks at so a reader can verify what was touched.
13
+ */
14
+
15
+ import { readFile, readdir, stat } from "node:fs/promises";
16
+ import { homedir } from "node:os";
17
+ import { join, resolve, sep } from "node:path";
18
+
19
+ /** Best-effort read; a missing or unreadable file is simply "not present". */
20
+ async function readJson(path) {
21
+ try {
22
+ return JSON.parse(await readFile(path, "utf8"));
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ async function exists(path) {
29
+ try {
30
+ await stat(path);
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ const HOME = homedir();
38
+
39
+ /* -------------------------------------------------------------------------- */
40
+ /* Agent runtimes */
41
+ /* -------------------------------------------------------------------------- */
42
+
43
+ /**
44
+ * Where each runtime keeps its configuration. `mcpKey` names the property that
45
+ * holds MCP server definitions, which differs between tools.
46
+ */
47
+ const RUNTIME_PROBES = [
48
+ {
49
+ id: "claude-code",
50
+ label: "Claude Code",
51
+ paths: [join(HOME, ".claude", "settings.json"), join(HOME, ".claude.json")],
52
+ mcpKey: "mcpServers",
53
+ },
54
+ {
55
+ id: "cursor",
56
+ label: "Cursor",
57
+ paths: [join(HOME, ".cursor", "mcp.json")],
58
+ mcpKey: "mcpServers",
59
+ },
60
+ {
61
+ id: "windsurf",
62
+ label: "Windsurf",
63
+ paths: [join(HOME, ".codeium", "windsurf", "mcp_config.json")],
64
+ mcpKey: "mcpServers",
65
+ },
66
+ {
67
+ id: "cline",
68
+ label: "Cline",
69
+ paths: [
70
+ join(HOME, "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
71
+ join(HOME, "AppData", "Roaming", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
72
+ ],
73
+ mcpKey: "mcpServers",
74
+ },
75
+ {
76
+ id: "vscode",
77
+ label: "VS Code (MCP)",
78
+ paths: [
79
+ join(HOME, "Library", "Application Support", "Code", "User", "mcp.json"),
80
+ join(HOME, "AppData", "Roaming", "Code", "User", "mcp.json"),
81
+ ],
82
+ mcpKey: "servers",
83
+ },
84
+ ];
85
+
86
+ /** Project-level markers that indicate an agent framework in the tree. */
87
+ const FRAMEWORK_MARKERS = [
88
+ { id: "langchain", label: "LangChain", deps: ["langchain", "@langchain/core", "langgraph"] },
89
+ { id: "crewai", label: "CrewAI", deps: ["crewai"] },
90
+ { id: "openai-agents", label: "OpenAI Agents SDK", deps: ["@openai/agents", "openai-agents"] },
91
+ { id: "vercel-ai", label: "Vercel AI SDK", deps: ["ai"] },
92
+ { id: "anthropic", label: "Anthropic SDK", deps: ["@anthropic-ai/sdk"] },
93
+ { id: "mcp-sdk", label: "MCP SDK", deps: ["@modelcontextprotocol/sdk"] },
94
+ ];
95
+
96
+ export async function detectRuntimes() {
97
+ const found = [];
98
+
99
+ for (const probe of RUNTIME_PROBES) {
100
+ // Merge across every config path a runtime uses rather than stopping at
101
+ // the first that exists. Claude Code, for example, has both
102
+ // ~/.claude/settings.json and ~/.claude.json, and MCP servers may live in
103
+ // either — breaking early reports "0 MCP servers" for a machine that has
104
+ // several, which is exactly the false clean bill this tool must not give.
105
+ const paths = [];
106
+ const servers = {};
107
+
108
+ for (const path of probe.paths) {
109
+ if (!(await exists(path))) continue;
110
+ paths.push(path);
111
+ const config = await readJson(path);
112
+ Object.assign(servers, config?.[probe.mcpKey] ?? {});
113
+ }
114
+
115
+ if (paths.length === 0) continue;
116
+
117
+ found.push({
118
+ id: probe.id,
119
+ label: probe.label,
120
+ path: paths[0],
121
+ paths,
122
+ governed: isGoverned(servers),
123
+ serverCount: Object.keys(servers).length,
124
+ servers,
125
+ });
126
+ }
127
+
128
+ return found;
129
+ }
130
+
131
+ /** A runtime is governed when its MCP traffic routes through the gateway. */
132
+ function isGoverned(servers) {
133
+ return Object.entries(servers).some(
134
+ ([name, def]) =>
135
+ name === "cirvix" ||
136
+ (typeof def?.command === "string" && def.command.includes("cirvix")),
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Detects agent frameworks declared in the project at `cwd`. Reads
142
+ * package.json and requirements.txt / pyproject.toml — declaration files only,
143
+ * never source.
144
+ */
145
+ export async function detectFrameworks(cwd) {
146
+ const found = [];
147
+
148
+ const pkg = await readJson(join(cwd, "package.json"));
149
+ if (pkg) {
150
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
151
+ for (const marker of FRAMEWORK_MARKERS) {
152
+ const hit = marker.deps.find((d) => d in deps);
153
+ if (hit) found.push({ id: marker.id, label: marker.label, via: `package.json → ${hit}` });
154
+ }
155
+ }
156
+
157
+ for (const file of ["requirements.txt", "pyproject.toml"]) {
158
+ const path = join(cwd, file);
159
+ if (!(await exists(path))) continue;
160
+ let text = "";
161
+ try {
162
+ text = await readFile(path, "utf8");
163
+ } catch {
164
+ continue;
165
+ }
166
+ for (const marker of FRAMEWORK_MARKERS) {
167
+ if (found.some((f) => f.id === marker.id)) continue;
168
+ const hit = marker.deps.find((d) => new RegExp(`(^|[\\s"'=])${escapeRe(d)}\\b`, "m").test(text));
169
+ if (hit) found.push({ id: marker.id, label: marker.label, via: `${file} → ${hit}` });
170
+ }
171
+ }
172
+
173
+ return found;
174
+ }
175
+
176
+ function escapeRe(s) {
177
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
178
+ }
179
+
180
+ /* -------------------------------------------------------------------------- */
181
+ /* MCP servers */
182
+ /* -------------------------------------------------------------------------- */
183
+
184
+ /**
185
+ * Flattens every MCP server across every detected runtime into one list,
186
+ * deduplicated by name+command. Each entry keeps the runtimes that reference
187
+ * it, because the same server configured in three editors is one trust
188
+ * boundary with three doors.
189
+ */
190
+ export function collectMcpServers(runtimes) {
191
+ const byKey = new Map();
192
+
193
+ for (const runtime of runtimes) {
194
+ for (const [name, def] of Object.entries(runtime.servers ?? {})) {
195
+ if (name === "cirvix") continue; // the gateway itself
196
+ const transport = def?.url ? "http" : "stdio";
197
+ const command = def?.command ?? def?.url ?? "";
198
+ const key = `${name}::${command}`;
199
+ const existing = byKey.get(key);
200
+ if (existing) {
201
+ existing.runtimes.push(runtime.label);
202
+ continue;
203
+ }
204
+ byKey.set(key, {
205
+ name,
206
+ transport,
207
+ command,
208
+ args: def?.args ?? [],
209
+ runtimes: [runtime.label],
210
+ // Env blocks in MCP config frequently carry raw API keys.
211
+ envKeys: Object.keys(def?.env ?? {}),
212
+ scope: inferScope(def),
213
+ });
214
+ }
215
+ }
216
+
217
+ return [...byKey.values()];
218
+ }
219
+
220
+ /**
221
+ * Filesystem-style servers are commonly pointed at a whole home directory or
222
+ * `/`, which grants an agent far more reach than the person configuring it
223
+ * usually intends. Flagging that is one of the scanner's most useful outputs.
224
+ */
225
+ function inferScope(def) {
226
+ const args = def?.args ?? [];
227
+ const paths = args.filter((a) => typeof a === "string" && (a.startsWith("/") || /^[A-Za-z]:[\\/]/.test(a)));
228
+ if (paths.length === 0) return null;
229
+ const widest = paths.find((p) => p === "/" || p === HOME || /^[A-Za-z]:[\\/]?$/.test(p));
230
+ return { paths, broad: Boolean(widest), widest: widest ?? null };
231
+ }
232
+
233
+ /* -------------------------------------------------------------------------- */
234
+ /* Credentials reachable from agent context */
235
+ /* -------------------------------------------------------------------------- */
236
+
237
+ /** Files whose presence means an agent with file read can obtain secrets. */
238
+ const CREDENTIAL_PROBES = [
239
+ { path: join(HOME, ".aws", "credentials"), label: "AWS credentials", severity: "high" },
240
+ { path: join(HOME, ".ssh", "id_rsa"), label: "SSH private key", severity: "high" },
241
+ { path: join(HOME, ".ssh", "id_ed25519"), label: "SSH private key", severity: "high" },
242
+ { path: join(HOME, ".kube", "config"), label: "Kubernetes config", severity: "high" },
243
+ { path: join(HOME, ".docker", "config.json"), label: "Docker registry auth", severity: "medium" },
244
+ { path: join(HOME, ".npmrc"), label: "npm token", severity: "medium" },
245
+ { path: join(HOME, ".netrc"), label: "netrc credentials", severity: "medium" },
246
+ { path: join(HOME, ".config", "gcloud", "credentials.db"), label: "gcloud credentials", severity: "high" },
247
+ ];
248
+
249
+ /** Keys that look like live secret material rather than config. */
250
+ const SECRET_KEY_RE =
251
+ /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|API_KEY|APIKEY|ACCESS_KEY|CLIENT_SECRET|DSN|CREDENTIAL)/i;
252
+
253
+ /** Values that are obviously placeholders shouldn't be reported as secrets. */
254
+ const PLACEHOLDER_RE =
255
+ /^(|x{3,}|\.{3,}|<.*>|\$\{.*\}|your[-_ ]|changeme|placeholder|todo|example|test|dummy|none|null|undefined)$/i;
256
+
257
+ export async function detectCredentials(cwd) {
258
+ const findings = [];
259
+
260
+ for (const probe of CREDENTIAL_PROBES) {
261
+ if (await exists(probe.path)) {
262
+ findings.push({
263
+ kind: "credential-file",
264
+ path: probe.path,
265
+ label: probe.label,
266
+ severity: probe.severity,
267
+ detail: "On disk and readable by any agent with filesystem access",
268
+ });
269
+ }
270
+ }
271
+
272
+ // .env files in the working tree — the most common real exposure.
273
+ let entries = [];
274
+ try {
275
+ entries = await readdir(cwd, { withFileTypes: true });
276
+ } catch {
277
+ entries = [];
278
+ }
279
+
280
+ for (const entry of entries) {
281
+ if (!entry.isFile()) continue;
282
+ if (!/^\.env($|\.)/.test(entry.name)) continue;
283
+ if (/\.example$|\.sample$|\.template$/.test(entry.name)) continue;
284
+
285
+ const path = join(cwd, entry.name);
286
+ const keys = await readSecretKeys(path);
287
+ if (keys.length === 0) continue;
288
+
289
+ findings.push({
290
+ kind: "dotenv",
291
+ path,
292
+ label: entry.name,
293
+ severity: /production|prod/.test(entry.name) ? "high" : "medium",
294
+ // Key NAMES only. The scanner never records, prints, or transmits a
295
+ // secret VALUE — reporting a leak by leaking it would be absurd.
296
+ detail: `${keys.length} secret-shaped ${keys.length === 1 ? "key" : "keys"}: ${keys.slice(0, 4).join(", ")}${keys.length > 4 ? "…" : ""}`,
297
+ keys,
298
+ });
299
+ }
300
+
301
+ return findings;
302
+ }
303
+
304
+ async function readSecretKeys(path) {
305
+ let text = "";
306
+ try {
307
+ const info = await stat(path);
308
+ if (info.size > 512 * 1024) return []; // not a real .env
309
+ text = await readFile(path, "utf8");
310
+ } catch {
311
+ return [];
312
+ }
313
+
314
+ const keys = [];
315
+ for (const line of text.split(/\r?\n/)) {
316
+ const trimmed = line.trim();
317
+ if (!trimmed || trimmed.startsWith("#")) continue;
318
+ const eq = trimmed.indexOf("=");
319
+ if (eq <= 0) continue;
320
+ const key = trimmed.slice(0, eq).trim().replace(/^export\s+/, "");
321
+ const value = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
322
+ if (!SECRET_KEY_RE.test(key)) continue;
323
+ if (PLACEHOLDER_RE.test(value)) continue;
324
+ if (value.length < 8) continue;
325
+ keys.push(key);
326
+ }
327
+ return keys;
328
+ }
329
+
330
+ /* -------------------------------------------------------------------------- */
331
+
332
+ /** True when `child` resolves inside `parent` — used by the path guard. */
333
+ export function isInside(parent, child) {
334
+ const p = resolve(parent);
335
+ const c = resolve(child);
336
+ return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep);
337
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The commercial gate, in one place.
3
+ *
4
+ * WHY THIS FILE EXISTS
5
+ *
6
+ * It used to live inline in `Pipeline.submit()` and nowhere else. `Guard` —
7
+ * the shared decision core behind both `guard.wrap()` and the MCP gateway —
8
+ * had no entitlement gate at all, so a Free-tier user going through either of
9
+ * those paths was never metered. `Pipeline` had the gate but the CLI never
10
+ * passed it a licence or a meter, so it never fired there either.
11
+ *
12
+ * The net effect was that the published Free limits were not enforced on any
13
+ * path, and the upgrade prompt that the pricing depends on could not fire. The
14
+ * cause was the same one this codebase has hit before: two decision cores, one
15
+ * of which quietly grew a rule the other did not have.
16
+ *
17
+ * So the rule lives here, both cores call it, and neither can answer the
18
+ * question differently.
19
+ *
20
+ * IT IS AN OVERRIDE, NOT AN EARLY RETURN
21
+ *
22
+ * The call is still parsed, classified and evaluated against policy first, and
23
+ * the refusal is layered on top of that decision. That costs microseconds and
24
+ * buys a record showing what policy WOULD have said, which is what an operator
25
+ * wants when they discover they ran out mid-session.
26
+ *
27
+ * AN EXHAUSTED QUOTA DENIES
28
+ *
29
+ * It does not pass the call through unchecked. A security control that stops
30
+ * enforcing when a counter runs out is not a degraded product, it is an absent
31
+ * one, and the absence would be invisible exactly when it mattered.
32
+ *
33
+ * A GATED CALL IS NOT COUNTED
34
+ *
35
+ * Counting refusals would mean a user who hit the limit could never get back
36
+ * under it.
37
+ */
38
+
39
+ import { GATE, checkAgents, checkQuota } from "./entitlements.mjs";
40
+ import { DECISION } from "./decisions.mjs";
41
+
42
+ /**
43
+ * Applies the quota and concurrent-agent gates to an already-made decision.
44
+ *
45
+ * All three of `licence`, `meter` and `agents` are optional. Supplying none of
46
+ * them — which is what the conformance fixture, the test suite and any
47
+ * embedding library caller do — returns the decision untouched, so this cannot
48
+ * change policy semantics.
49
+ *
50
+ * @param {object} decision the decision policy produced
51
+ * @param {object} ctx
52
+ * @param {object|null} ctx.licence
53
+ * @param {object|null} ctx.meter Meter — consulted, then incremented on a pass
54
+ * @param {object|null} ctx.agents AgentRegistry — concurrent agent tracking
55
+ * @param {string} ctx.agent the agent making this call
56
+ * @returns {object} the decision, overridden if a commercial limit was hit
57
+ */
58
+ export function applyEntitlements(decision, { licence, meter, agents, agent }) {
59
+ if (licence && meter) {
60
+ const quota = checkQuota(licence, meter.used());
61
+ if (quota.ok) {
62
+ // Counted only once a real policy decision has been produced: that is
63
+ // the thing being sold.
64
+ meter.count(1);
65
+ } else {
66
+ return {
67
+ ...decision,
68
+ verdict: "deny",
69
+ decision: DECISION.DENY,
70
+ rule: "quota-exhausted",
71
+ reason: quota.reason,
72
+ remediation: quota.remediation,
73
+ gate: GATE.QUOTA_EXHAUSTED,
74
+ quota: { used: quota.used, allowance: quota.allowance, tier: quota.tier },
75
+ };
76
+ }
77
+ }
78
+
79
+ // Only an agent this session has not already seen can be a new one, or a
80
+ // permitted agent's second call would read as a second agent and every tier
81
+ // would be a one-call tier.
82
+ if (licence && agents && !agents.has(agent)) {
83
+ const seats = checkAgents(licence, agents.size());
84
+ if (seats.ok) {
85
+ agents.register(agent);
86
+ } else {
87
+ return {
88
+ ...decision,
89
+ verdict: "deny",
90
+ decision: DECISION.DENY,
91
+ rule: "agent-limit",
92
+ reason: seats.reason,
93
+ remediation: seats.remediation,
94
+ gate: GATE.AGENT_LIMIT,
95
+ };
96
+ }
97
+ }
98
+
99
+ return decision;
100
+ }