@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,710 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The policy engine.
|
|
3
|
+
*
|
|
4
|
+
* A deterministic evaluator over an ordered rule set. Given a request
|
|
5
|
+
* (agent, action, resource, context) it returns a decision with the rule that
|
|
6
|
+
* produced it — the explanation is a first-class output, not a log line,
|
|
7
|
+
* because a refusal an agent cannot read is a refusal it cannot recover from.
|
|
8
|
+
*
|
|
9
|
+
* Three properties are load-bearing and tested:
|
|
10
|
+
*
|
|
11
|
+
* 1. FORBID ALWAYS WINS. A `forbid` match cannot be overridden by any
|
|
12
|
+
* `permit`, regardless of order or specificity. This is what makes a
|
|
13
|
+
* rule set safe to extend: adding a permissive rule can never silently
|
|
14
|
+
* punch a hole through an existing prohibition.
|
|
15
|
+
*
|
|
16
|
+
* 2. DEFAULT DENY. A request that matches nothing is denied. Fail-open is
|
|
17
|
+
* how a control plane becomes decorative the first time a rule file
|
|
18
|
+
* fails to parse.
|
|
19
|
+
*
|
|
20
|
+
* 3. RESOURCES ARE CANONICALIZED BEFORE MATCHING. `./x/../.env`,
|
|
21
|
+
* `.env`, and an absolute path to the same file are one resource. Rules
|
|
22
|
+
* that match on raw strings are bypassed by the first attacker who tries
|
|
23
|
+
* a traversal, and by the first agent that happens to use a relative
|
|
24
|
+
* path.
|
|
25
|
+
*
|
|
26
|
+
* 4. AN OBSERVATION IS NOT AN AUTHORIZATION. `audit_only` rules match, get
|
|
27
|
+
* recorded, and contribute nothing to the verdict — so adding one can
|
|
28
|
+
* never punch a hole through a `forbid`. See `./decisions.mjs`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
|
|
33
|
+
import { canonicalUrl, expandHome, foldPath } from "./canonical.mjs";
|
|
34
|
+
import {
|
|
35
|
+
DECISION,
|
|
36
|
+
EFFECT,
|
|
37
|
+
VERDICT,
|
|
38
|
+
effectRank,
|
|
39
|
+
toDecision,
|
|
40
|
+
} from "./decisions.mjs";
|
|
41
|
+
|
|
42
|
+
/** @typedef {"permit"|"forbid"|"hold"|"sanitize"|"audit_only"} Effect */
|
|
43
|
+
|
|
44
|
+
// Re-exported rather than redefined: two definitions of "what is a forbid" is
|
|
45
|
+
// two policy engines. Importers of `policy.mjs` keep working unchanged.
|
|
46
|
+
export { EFFECT, VERDICT, DECISION };
|
|
47
|
+
|
|
48
|
+
/* -------------------------------------------------------------------------- */
|
|
49
|
+
/* Matching */
|
|
50
|
+
/* -------------------------------------------------------------------------- */
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Glob matching for tool and resource patterns.
|
|
54
|
+
*
|
|
55
|
+
* `*` matches within a segment, `**` matches across segments, `?` matches one
|
|
56
|
+
* character that is not a separator. Matching is case-insensitive, and every
|
|
57
|
+
* other character is a literal — a `.` in a rule means a dot, not "any
|
|
58
|
+
* character", or `deny **\/.env` would also match `xenv`.
|
|
59
|
+
*
|
|
60
|
+
* DELIBERATELY NOT A REGULAR EXPRESSION.
|
|
61
|
+
*
|
|
62
|
+
* The obvious implementation compiles the glob to a RegExp, and it is what
|
|
63
|
+
* this was. It is also exponential: `*a*a*a*a*b` against a long run of `a`
|
|
64
|
+
* makes the engine explore every way of splitting the input between the
|
|
65
|
+
* wildcards, and JavaScript's backtracking matcher will sit there for minutes.
|
|
66
|
+
* That matters here more than it does in most places, because the pattern
|
|
67
|
+
* comes from a policy rule and the *input* comes from whatever resource an
|
|
68
|
+
* agent named — so a caller on the far side of the enforcement boundary picks
|
|
69
|
+
* the input that triggers it, and a hung evaluator is a hung gateway.
|
|
70
|
+
*
|
|
71
|
+
* This is the standard two-pointer wildcard match instead: it remembers the
|
|
72
|
+
* most recent wildcard and resumes there on a mismatch, which is O(n·m) in the
|
|
73
|
+
* worst case and has no pathological input at all.
|
|
74
|
+
*/
|
|
75
|
+
export function matchGlob(pattern, value) {
|
|
76
|
+
if (pattern === "*" || pattern === "**") return true;
|
|
77
|
+
|
|
78
|
+
const p = String(pattern).toLowerCase();
|
|
79
|
+
const v = String(value).toLowerCase();
|
|
80
|
+
|
|
81
|
+
/*
|
|
82
|
+
* Tokenized and matched with dynamic programming, NOT with the two-pointer
|
|
83
|
+
* algorithm this used to use.
|
|
84
|
+
*
|
|
85
|
+
* The two-pointer match remembers exactly one star position, which is correct
|
|
86
|
+
* when every star has the same semantics. Here they do not: `**` crosses `/`
|
|
87
|
+
* and `*` does not. Once the matcher committed to an inner `*`, it had
|
|
88
|
+
* forgotten the outer `**` and could never backtrack far enough — so
|
|
89
|
+
* `matchGlob("**\/*", "/workspace/src/app.ts")` returned FALSE.
|
|
90
|
+
*
|
|
91
|
+
* That is a fail-open bug, not a cosmetic one. A rule written
|
|
92
|
+
* `path = **\/*` — the natural way to say "any file at all" — loaded,
|
|
93
|
+
* validated, appeared in `cirvix policy list`, and matched nothing. The
|
|
94
|
+
* delegation tests found it.
|
|
95
|
+
*
|
|
96
|
+
* The DP is O(n·m) in time and O(m) in space, with no pathological input:
|
|
97
|
+
* the same complexity guarantee the two-pointer version was chosen for, and
|
|
98
|
+
* unlike a backtracking regex there is nothing an attacker can pick to make
|
|
99
|
+
* it explore exponentially.
|
|
100
|
+
*/
|
|
101
|
+
const tokens = [];
|
|
102
|
+
for (let i = 0; i < p.length; i++) {
|
|
103
|
+
if (p[i] === "*") {
|
|
104
|
+
const doubled = p[i + 1] === "*";
|
|
105
|
+
tokens.push(doubled ? "**" : "*");
|
|
106
|
+
if (doubled) i++;
|
|
107
|
+
} else if (p[i] === "?") tokens.push("?");
|
|
108
|
+
else tokens.push(p[i]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const T = tokens.length;
|
|
112
|
+
const isStar = (t) => t === "*" || t === "**";
|
|
113
|
+
|
|
114
|
+
// dp[t] — the first `t` tokens match the value consumed so far.
|
|
115
|
+
let dp = new Array(T + 1).fill(false);
|
|
116
|
+
dp[0] = true;
|
|
117
|
+
// A star may match nothing, so it passes the mark straight through.
|
|
118
|
+
for (let t = 0; t < T; t++) if (dp[t] && isStar(tokens[t])) dp[t + 1] = true;
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < v.length; i++) {
|
|
121
|
+
const c = v[i];
|
|
122
|
+
const next = new Array(T + 1).fill(false);
|
|
123
|
+
|
|
124
|
+
for (let t = 0; t < T; t++) {
|
|
125
|
+
const token = tokens[t];
|
|
126
|
+
if (token === "**") {
|
|
127
|
+
// Open here, or already open and consuming another character.
|
|
128
|
+
if (dp[t] || dp[t + 1]) next[t + 1] = true;
|
|
129
|
+
} else if (token === "*") {
|
|
130
|
+
if ((dp[t] || dp[t + 1]) && c !== "/") next[t + 1] = true;
|
|
131
|
+
} else if (token === "?") {
|
|
132
|
+
if (dp[t] && c !== "/") next[t + 1] = true;
|
|
133
|
+
} else if (dp[t] && c === token) {
|
|
134
|
+
next[t + 1] = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Stars that matched nothing at this position.
|
|
139
|
+
for (let t = 0; t < T; t++) if (next[t] && isStar(tokens[t])) next[t + 1] = true;
|
|
140
|
+
|
|
141
|
+
dp = next;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return dp[T];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function matchAny(patterns, value) {
|
|
148
|
+
if (patterns === undefined || patterns === "*") return true;
|
|
149
|
+
const list = Array.isArray(patterns) ? patterns : [patterns];
|
|
150
|
+
if (list.length === 0) return true;
|
|
151
|
+
return list.some((p) => matchGlob(p, value));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Canonicalizes a resource so equivalent references collapse to one string.
|
|
156
|
+
* Filesystem paths resolve against `cwd`; URLs normalize host and lowercase.
|
|
157
|
+
*/
|
|
158
|
+
export function canonicalizeResource(resource, cwd = process.cwd()) {
|
|
159
|
+
if (typeof resource !== "string" || resource.length === 0) return "";
|
|
160
|
+
|
|
161
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(resource)) {
|
|
162
|
+
// Delegated so URLs canonicalize identically everywhere: alternate IPv4
|
|
163
|
+
// spellings, IPv4-mapped IPv6, trailing dots, and userinfo all collapse.
|
|
164
|
+
// Hand-rolled URL handling here is how `169.254.169.254` and
|
|
165
|
+
// `http://2852039166/` became two different resources to the same rule.
|
|
166
|
+
return canonicalUrl(resource) ?? resource;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/*
|
|
170
|
+
* Fold before resolving.
|
|
171
|
+
*
|
|
172
|
+
* `~%2F.aws%2Fcredentials` contains no literal separator, so without this it
|
|
173
|
+
* fell through every branch below, stayed a bare token, was judged "inside
|
|
174
|
+
* the workspace" because it had no path to escape it, and a workspace-read
|
|
175
|
+
* rule permitted a credential read. Percent-encoding, Unicode homoglyph
|
|
176
|
+
* separators, and zero-width characters are all normalized here so the rule
|
|
177
|
+
* sees the path the tool will actually open.
|
|
178
|
+
*/
|
|
179
|
+
const folded = expandHome(foldPath(resource));
|
|
180
|
+
if (folded !== resource) {
|
|
181
|
+
// Re-entered once with the folded form. One level only: `foldPath` is
|
|
182
|
+
// idempotent by construction, so a second pass cannot change the answer and
|
|
183
|
+
// an unbounded recursion on attacker-controlled input would be its own bug.
|
|
184
|
+
return canonicalizeResource(folded, cwd);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// `~` is a real path, and agents write it constantly.
|
|
188
|
+
//
|
|
189
|
+
// Left unexpanded it resolved against the WORKSPACE — `~/.aws/credentials`
|
|
190
|
+
// became `<cwd>/~/.aws/credentials`, a directory that does not exist. A rule
|
|
191
|
+
// written against the absolute home path then failed to match the single most
|
|
192
|
+
// common way an agent names a credential file. The glob rules happened to
|
|
193
|
+
// catch it; the exact-path rules did not, which is the worse half of a
|
|
194
|
+
// near-miss.
|
|
195
|
+
if (resource === "~" || resource.startsWith("~/") || resource.startsWith("~\\")) {
|
|
196
|
+
const home = homedir().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
197
|
+
return resolvePath(home + resource.slice(1).replace(/\\/g, "/"), cwd);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Anything else that looks like a path gets resolved.
|
|
201
|
+
if (/[/\\]/.test(resource) || resource.startsWith(".")) {
|
|
202
|
+
return resolvePath(resource, cwd);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return resource;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolves a path the same way on every platform.
|
|
210
|
+
*
|
|
211
|
+
* DELIBERATELY NOT `path.resolve`. That function is platform-aware, and on
|
|
212
|
+
* Windows it prepends the current drive to a drive-less absolute path — so
|
|
213
|
+
* `/etc/passwd` canonicalized to `C:/etc/passwd` there and `/etc/passwd`
|
|
214
|
+
* everywhere else. The consequence is not cosmetic: a rule written
|
|
215
|
+
* `resources: ["/etc/**"]` matched on a Linux runner and silently did not
|
|
216
|
+
* match on a developer's Windows laptop, which is the machine the rule was
|
|
217
|
+
* most likely written to protect.
|
|
218
|
+
*
|
|
219
|
+
* The conformance suite caught this. It is exactly the class of bug two
|
|
220
|
+
* implementations would have disagreed about forever.
|
|
221
|
+
*/
|
|
222
|
+
function resolvePath(resource, cwd) {
|
|
223
|
+
const norm = (value) => String(value ?? "").replace(/\\/g, "/");
|
|
224
|
+
const target = norm(resource);
|
|
225
|
+
|
|
226
|
+
const isAbsolute = target.startsWith("/") || /^[A-Za-z]:\//.test(target);
|
|
227
|
+
const combined = isAbsolute ? target : `${norm(cwd).replace(/\/+$/, "")}/${target}`;
|
|
228
|
+
|
|
229
|
+
// A drive letter is carried through untouched rather than invented.
|
|
230
|
+
const drive = combined.match(/^([A-Za-z]:)(\/.*)$/);
|
|
231
|
+
const body = drive ? drive[2] : combined;
|
|
232
|
+
|
|
233
|
+
const parts = [];
|
|
234
|
+
for (const segment of body.split("/")) {
|
|
235
|
+
if (segment === "" || segment === ".") continue;
|
|
236
|
+
if (segment === "..") {
|
|
237
|
+
parts.pop();
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
parts.push(segment);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return (drive ? drive[1] : "") + (body.startsWith("/") ? "/" : "") + parts.join("/");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* -------------------------------------------------------------------------- */
|
|
247
|
+
/* Conditions */
|
|
248
|
+
/* -------------------------------------------------------------------------- */
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Conditions are plain data, never expressions.
|
|
252
|
+
*
|
|
253
|
+
* Deliberately NOT `eval` or `new Function`: a policy file is exactly the kind
|
|
254
|
+
* of thing that gets templated by a script, and turning it into an execution
|
|
255
|
+
* surface would make the security product the vulnerability. Comparators are
|
|
256
|
+
* an explicit, closed set.
|
|
257
|
+
*/
|
|
258
|
+
const COMPARATORS = {
|
|
259
|
+
eq: (a, b) => a === b,
|
|
260
|
+
ne: (a, b) => a !== b,
|
|
261
|
+
in: (a, b) => Array.isArray(b) && b.includes(a),
|
|
262
|
+
nin: (a, b) => Array.isArray(b) && !b.includes(a),
|
|
263
|
+
gt: (a, b) => typeof a === "number" && a > b,
|
|
264
|
+
gte: (a, b) => typeof a === "number" && a >= b,
|
|
265
|
+
lt: (a, b) => typeof a === "number" && a < b,
|
|
266
|
+
lte: (a, b) => typeof a === "number" && a <= b,
|
|
267
|
+
matches: (a, b) => typeof a === "string" && matchGlob(String(b), a),
|
|
268
|
+
exists: (a, b) => (b ? a !== undefined && a !== null : a === undefined || a === null),
|
|
269
|
+
contains: (a, b) => Array.isArray(a) && a.includes(b),
|
|
270
|
+
supersetOf: (a, b) =>
|
|
271
|
+
Array.isArray(a) && Array.isArray(b) && b.every((x) => a.includes(x)),
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
function readPath(obj, path) {
|
|
275
|
+
return path.split(".").reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Every condition must hold. Unknown comparators fail closed. */
|
|
279
|
+
function conditionsHold(conditions, context) {
|
|
280
|
+
if (!conditions || conditions.length === 0) return true;
|
|
281
|
+
return conditions.every((cond) => {
|
|
282
|
+
const cmp = COMPARATORS[cond.op];
|
|
283
|
+
if (!cmp) return false;
|
|
284
|
+
return cmp(readPath(context, cond.path), cond.value);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* -------------------------------------------------------------------------- */
|
|
289
|
+
/* Evaluation */
|
|
290
|
+
/* -------------------------------------------------------------------------- */
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Evaluate a request against a rule set.
|
|
294
|
+
*
|
|
295
|
+
* @returns {{
|
|
296
|
+
* verdict: "permit"|"deny"|"hold",
|
|
297
|
+
* rule: string|null,
|
|
298
|
+
* reason: string,
|
|
299
|
+
* remediation?: string,
|
|
300
|
+
* approvers?: string[],
|
|
301
|
+
* considered: Array<{rule: string, effect: string, matched: boolean}>,
|
|
302
|
+
* resource: string
|
|
303
|
+
* }}
|
|
304
|
+
*/
|
|
305
|
+
export function evaluate(request, rules, options = {}) {
|
|
306
|
+
const cwd = options.cwd ?? process.cwd();
|
|
307
|
+
const resource = canonicalizeResource(request.resource, cwd);
|
|
308
|
+
const context = { ...request.context, agent: request.agent, action: request.action };
|
|
309
|
+
|
|
310
|
+
const considered = [];
|
|
311
|
+
let firstPermit = null;
|
|
312
|
+
let firstHold = null;
|
|
313
|
+
/** Every matching sanitize rule, not just the first — sanitizers compose. */
|
|
314
|
+
const sanitizers = [];
|
|
315
|
+
/** Matching `audit_only` rules. Recorded, never authorizing. */
|
|
316
|
+
const observed = [];
|
|
317
|
+
|
|
318
|
+
/*
|
|
319
|
+
* `rules ?? []`, not `rules`.
|
|
320
|
+
*
|
|
321
|
+
* A missing rule set must default-deny, not throw. It throwing was a real
|
|
322
|
+
* defect: a policy that failed to load, a caller that passed `undefined`, or
|
|
323
|
+
* a config path that resolved to nothing all produced a TypeError inside the
|
|
324
|
+
* decision path rather than a refusal — and an exception in an enforcement
|
|
325
|
+
* hot path is a denial of service against the control plane at best, and an
|
|
326
|
+
* accidental allow at worst, depending on which caller catches it.
|
|
327
|
+
*
|
|
328
|
+
* The Python engine already read `rules or []`. This is also the two engines
|
|
329
|
+
* agreeing again.
|
|
330
|
+
*/
|
|
331
|
+
for (const rule of rules ?? []) {
|
|
332
|
+
if (!rule || typeof rule !== "object") continue;
|
|
333
|
+
|
|
334
|
+
const matched =
|
|
335
|
+
matchAny(rule.agents, request.agent) &&
|
|
336
|
+
matchAny(rule.actions, request.action) &&
|
|
337
|
+
matchAny(rule.resources, resource) &&
|
|
338
|
+
conditionsHold(rule.when, context);
|
|
339
|
+
|
|
340
|
+
considered.push({ rule: rule.name, effect: rule.effect, matched });
|
|
341
|
+
|
|
342
|
+
if (!matched) continue;
|
|
343
|
+
|
|
344
|
+
// forbid short-circuits — nothing after it can change the outcome.
|
|
345
|
+
if (rule.effect === EFFECT.FORBID) {
|
|
346
|
+
return finish({
|
|
347
|
+
verdict: VERDICT.DENY,
|
|
348
|
+
decision: DECISION.DENY,
|
|
349
|
+
rule: rule.name,
|
|
350
|
+
reason: rule.reason ?? `Denied by ${rule.name}.`,
|
|
351
|
+
remediation: rule.remediation,
|
|
352
|
+
explicit: true,
|
|
353
|
+
considered,
|
|
354
|
+
resource,
|
|
355
|
+
observed,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
if (rule.effect === EFFECT.HOLD && !firstHold) firstHold = rule;
|
|
359
|
+
if (rule.effect === EFFECT.SANITIZE) sanitizers.push(rule);
|
|
360
|
+
if (rule.effect === EFFECT.PERMIT && !firstPermit) firstPermit = rule;
|
|
361
|
+
// `audit_only` lands here and nowhere else: it is recorded and contributes
|
|
362
|
+
// nothing. A matching observation must not be able to authorize a call.
|
|
363
|
+
if (rule.effect === EFFECT.AUDIT_ONLY) {
|
|
364
|
+
observed.push({ rule: rule.name, reason: rule.reason ?? null });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// A hold outranks a permit: if any rule says a human must see this, the
|
|
369
|
+
// presence of some other permissive rule must not quietly skip them.
|
|
370
|
+
if (firstHold) {
|
|
371
|
+
return finish({
|
|
372
|
+
verdict: VERDICT.HOLD,
|
|
373
|
+
decision: DECISION.REQUIRE_APPROVAL,
|
|
374
|
+
rule: firstHold.name,
|
|
375
|
+
reason: firstHold.reason ?? `Held by ${firstHold.name} pending approval.`,
|
|
376
|
+
approvers: firstHold.approvers ?? [],
|
|
377
|
+
explicit: true,
|
|
378
|
+
considered,
|
|
379
|
+
resource,
|
|
380
|
+
observed,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Sanitize outranks permit for the reason given in decisions.mjs: a rule
|
|
385
|
+
// saying "clean this first" must not be skipped because some other rule also
|
|
386
|
+
// said the call was fine. It still requires the call to be permitted at all —
|
|
387
|
+
// a sanitizer alone is not an authorization, so a lone sanitize rule with no
|
|
388
|
+
// permit falls through to default-deny below.
|
|
389
|
+
if (sanitizers.length && firstPermit) {
|
|
390
|
+
return finish({
|
|
391
|
+
verdict: VERDICT.PERMIT,
|
|
392
|
+
decision: DECISION.SANITIZE,
|
|
393
|
+
rule: sanitizers[0].name,
|
|
394
|
+
reason:
|
|
395
|
+
sanitizers[0].reason ??
|
|
396
|
+
`Permitted by ${firstPermit.name}, with sanitization required by ${sanitizers[0].name}.`,
|
|
397
|
+
explicit: true,
|
|
398
|
+
sanitize: sanitizers.map((r) => ({
|
|
399
|
+
rule: r.name,
|
|
400
|
+
/** What to clean: `arguments`, `result`, or both. Defaults to both. */
|
|
401
|
+
targets: normalizeTargets(r.sanitize?.targets),
|
|
402
|
+
/** Which sanitizer families to run. Empty means "all applicable". */
|
|
403
|
+
strategies: Array.isArray(r.sanitize?.strategies) ? r.sanitize.strategies : [],
|
|
404
|
+
reason: r.reason ?? null,
|
|
405
|
+
})),
|
|
406
|
+
permittedBy: firstPermit.name,
|
|
407
|
+
considered,
|
|
408
|
+
resource,
|
|
409
|
+
observed,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (firstPermit) {
|
|
414
|
+
return finish({
|
|
415
|
+
verdict: VERDICT.PERMIT,
|
|
416
|
+
decision: DECISION.ALLOW,
|
|
417
|
+
rule: firstPermit.name,
|
|
418
|
+
reason: firstPermit.reason ?? `Permitted by ${firstPermit.name}.`,
|
|
419
|
+
explicit: true,
|
|
420
|
+
considered,
|
|
421
|
+
resource,
|
|
422
|
+
observed,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return finish({
|
|
427
|
+
verdict: VERDICT.DENY,
|
|
428
|
+
decision: DECISION.DENY,
|
|
429
|
+
rule: null,
|
|
430
|
+
reason:
|
|
431
|
+
sanitizers.length > 0
|
|
432
|
+
? `No rule permits this call. ${sanitizers[0].name} would have sanitized it, but a sanitizer cleans an authorized call — it does not authorize one.`
|
|
433
|
+
: "No rule permits this call. The policy set is default-deny: an action must be explicitly allowed.",
|
|
434
|
+
remediation:
|
|
435
|
+
"Add a permit rule for this action, or run the engine in audit mode to log without enforcing.",
|
|
436
|
+
// Not explicit: nothing named this call, so the risk floor may escalate it.
|
|
437
|
+
explicit: false,
|
|
438
|
+
considered,
|
|
439
|
+
resource,
|
|
440
|
+
observed,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Drops empty optional fields so a decision record stays legible. */
|
|
445
|
+
function finish(decision) {
|
|
446
|
+
if (!decision.observed?.length) delete decision.observed;
|
|
447
|
+
return decision;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function normalizeTargets(targets) {
|
|
451
|
+
const all = ["arguments", "result"];
|
|
452
|
+
if (!targets) return all;
|
|
453
|
+
const list = (Array.isArray(targets) ? targets : [targets]).filter((t) => all.includes(t));
|
|
454
|
+
return list.length ? list : all;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/* -------------------------------------------------------------------------- */
|
|
458
|
+
/* Starter policy */
|
|
459
|
+
/* -------------------------------------------------------------------------- */
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The default rule set — what you get with no `--policy`. Chosen so that a
|
|
463
|
+
* developer working normally is not interrupted, while the handful of actions
|
|
464
|
+
* that actually cause incidents are stopped or held.
|
|
465
|
+
*
|
|
466
|
+
* Ordering is irrelevant to correctness (forbid always wins, hold outranks
|
|
467
|
+
* permit) but rules are grouped for readability.
|
|
468
|
+
*/
|
|
469
|
+
export const STARTER_RULES = [
|
|
470
|
+
{
|
|
471
|
+
name: "deny-dotenv-read",
|
|
472
|
+
effect: EFFECT.FORBID,
|
|
473
|
+
actions: ["fs.read", "fs.*"],
|
|
474
|
+
resources: ["**/.env", "**/.env.*"],
|
|
475
|
+
reason:
|
|
476
|
+
"Reading .env files is denied outside an approved secrets flow. This is the single most common path from a prompt injection to a live credential.",
|
|
477
|
+
remediation: 'Request the value as a handle: secrets.get("STRIPE_KEY")',
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
name: "deny-credential-files",
|
|
481
|
+
effect: EFFECT.FORBID,
|
|
482
|
+
actions: ["fs.read", "fs.*"],
|
|
483
|
+
resources: [
|
|
484
|
+
"**/.aws/**",
|
|
485
|
+
"**/.ssh/**",
|
|
486
|
+
"**/.kube/config",
|
|
487
|
+
"**/.npmrc",
|
|
488
|
+
"**/.netrc",
|
|
489
|
+
"**/.docker/config.json",
|
|
490
|
+
],
|
|
491
|
+
reason: "Cloud, SSH, and registry credentials are never readable by an agent.",
|
|
492
|
+
remediation: "Use a scoped secret handle instead of the credential file.",
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
name: "deny-workspace-escape",
|
|
496
|
+
effect: EFFECT.FORBID,
|
|
497
|
+
actions: ["fs.*"],
|
|
498
|
+
resources: ["*"],
|
|
499
|
+
when: [{ path: "path.insideWorkspace", op: "eq", value: false }],
|
|
500
|
+
reason:
|
|
501
|
+
"The resolved path is outside the workspace root. Traversal and symlinks are resolved before this check.",
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: "require-approval-destructive",
|
|
505
|
+
effect: EFFECT.HOLD,
|
|
506
|
+
actions: ["fs.delete", "db.write", "db.migrate", "k8s.apply", "shell.exec"],
|
|
507
|
+
resources: ["*"],
|
|
508
|
+
when: [{ path: "environment", op: "in", value: ["production", "prod"] }],
|
|
509
|
+
approvers: ["platform-oncall"],
|
|
510
|
+
reason:
|
|
511
|
+
"Destructive or state-changing action in production. Held for a named human; the call waits rather than failing.",
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
name: "deny-external-egress-after-secret",
|
|
515
|
+
effect: EFFECT.FORBID,
|
|
516
|
+
actions: ["http.request", "net.*"],
|
|
517
|
+
resources: ["*"],
|
|
518
|
+
when: [
|
|
519
|
+
{ path: "egress.external", op: "eq", value: true },
|
|
520
|
+
{ path: "session.touchedSecret", op: "eq", value: true },
|
|
521
|
+
],
|
|
522
|
+
reason:
|
|
523
|
+
"This session read secret material, so outbound requests to external destinations are blocked for the remainder of it.",
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
name: "allow-workspace-read",
|
|
527
|
+
effect: EFFECT.PERMIT,
|
|
528
|
+
actions: ["fs.read", "fs.list", "fs.stat"],
|
|
529
|
+
resources: ["*"],
|
|
530
|
+
when: [{ path: "path.insideWorkspace", op: "eq", value: true }],
|
|
531
|
+
reason: "Read inside the workspace root.",
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
name: "allow-workspace-write",
|
|
535
|
+
effect: EFFECT.PERMIT,
|
|
536
|
+
actions: ["fs.write"],
|
|
537
|
+
resources: ["*"],
|
|
538
|
+
when: [{ path: "path.insideWorkspace", op: "eq", value: true }],
|
|
539
|
+
reason: "Write inside the workspace root.",
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
name: "allow-allowlisted-egress",
|
|
543
|
+
effect: EFFECT.PERMIT,
|
|
544
|
+
actions: ["http.request", "net.*"],
|
|
545
|
+
resources: ["*"],
|
|
546
|
+
when: [{ path: "egress.allowlisted", op: "eq", value: true }],
|
|
547
|
+
reason: "Destination is on the egress allowlist.",
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
name: "allow-read-only-tools",
|
|
551
|
+
effect: EFFECT.PERMIT,
|
|
552
|
+
actions: ["*.read", "*.list", "*.search", "*.get", "*.query"],
|
|
553
|
+
resources: ["*"],
|
|
554
|
+
reason: "Read-only tool call.",
|
|
555
|
+
},
|
|
556
|
+
];
|
|
557
|
+
|
|
558
|
+
/** Rules serialize to JSON — the on-disk format is `cirvix.policy.json`. */
|
|
559
|
+
export function parseRules(json) {
|
|
560
|
+
const rules = Array.isArray(json) ? json : json?.rules;
|
|
561
|
+
if (!Array.isArray(rules)) {
|
|
562
|
+
throw new Error("Policy file must be an array of rules, or { rules: [...] }.");
|
|
563
|
+
}
|
|
564
|
+
for (const rule of rules) {
|
|
565
|
+
if (!rule.name) throw new Error("Every rule needs a name.");
|
|
566
|
+
if (!Object.values(EFFECT).includes(rule.effect)) {
|
|
567
|
+
throw new Error(
|
|
568
|
+
`Rule "${rule.name}" has effect "${rule.effect}"; expected ${Object.values(EFFECT).join(", ")}.`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
for (const cond of rule.when ?? []) {
|
|
572
|
+
if (!COMPARATORS[cond.op]) {
|
|
573
|
+
throw new Error(`Rule "${rule.name}" uses unknown operator "${cond.op}".`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return rules;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/* -------------------------------------------------------------------------- */
|
|
581
|
+
/* Validation */
|
|
582
|
+
/* -------------------------------------------------------------------------- */
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Structured validation for `cirvix policy check`.
|
|
586
|
+
*
|
|
587
|
+
* Distinct from `parseRules`, which throws on the first structural problem
|
|
588
|
+
* because it is the load path and a half-parsed rule set must never reach the
|
|
589
|
+
* engine. This returns everything at once, including things that parse fine but
|
|
590
|
+
* are probably a mistake — an operator fixing a policy file wants the whole
|
|
591
|
+
* list, not one error per run.
|
|
592
|
+
*
|
|
593
|
+
* The severity split is the point: `error` means the file will not load or the
|
|
594
|
+
* rule cannot match; `warning` means it loads and does something the author
|
|
595
|
+
* likely did not intend.
|
|
596
|
+
*
|
|
597
|
+
* @returns {{ok:boolean, errors:Array, warnings:Array, rules:number}}
|
|
598
|
+
*/
|
|
599
|
+
export function validateRules(json) {
|
|
600
|
+
const errors = [];
|
|
601
|
+
const warnings = [];
|
|
602
|
+
const at = (rule, i) => rule?.name ?? `rule #${i + 1}`;
|
|
603
|
+
|
|
604
|
+
const rules = Array.isArray(json) ? json : json?.rules;
|
|
605
|
+
if (!Array.isArray(rules)) {
|
|
606
|
+
return {
|
|
607
|
+
ok: false,
|
|
608
|
+
rules: 0,
|
|
609
|
+
errors: [{ rule: null, message: "Policy must be an array of rules, or { rules: [...] }." }],
|
|
610
|
+
warnings: [],
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const seen = new Map();
|
|
615
|
+
|
|
616
|
+
rules.forEach((rule, i) => {
|
|
617
|
+
const where = at(rule, i);
|
|
618
|
+
|
|
619
|
+
if (!rule || typeof rule !== "object") {
|
|
620
|
+
errors.push({ rule: where, message: "Rule is not an object." });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (!rule.name) errors.push({ rule: where, message: "Rule has no name." });
|
|
624
|
+
if (!Object.values(EFFECT).includes(rule.effect)) {
|
|
625
|
+
errors.push({
|
|
626
|
+
rule: where,
|
|
627
|
+
message: `Unknown effect "${rule.effect}". Expected one of: ${Object.values(EFFECT).join(", ")}.`,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (rule.name) {
|
|
632
|
+
if (seen.has(rule.name)) {
|
|
633
|
+
errors.push({
|
|
634
|
+
rule: where,
|
|
635
|
+
message: `Duplicate rule name — also defined at position ${seen.get(rule.name) + 1}. Names appear in decision records and must identify one rule.`,
|
|
636
|
+
});
|
|
637
|
+
} else seen.set(rule.name, i);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
for (const cond of rule.when ?? []) {
|
|
641
|
+
if (!cond || typeof cond !== "object") {
|
|
642
|
+
errors.push({ rule: where, message: "Condition is not an object." });
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (!COMPARATORS[cond.op]) {
|
|
646
|
+
errors.push({ rule: where, message: `Unknown operator "${cond.op}".` });
|
|
647
|
+
}
|
|
648
|
+
if (typeof cond.path !== "string" || !cond.path) {
|
|
649
|
+
errors.push({ rule: where, message: "Condition has no path." });
|
|
650
|
+
}
|
|
651
|
+
if (["in", "nin"].includes(cond.op) && !Array.isArray(cond.value)) {
|
|
652
|
+
errors.push({
|
|
653
|
+
rule: where,
|
|
654
|
+
message: `Operator "${cond.op}" needs an array value; got ${typeof cond.value}. It would never match.`,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Warnings: loads fine, probably not what was meant.
|
|
660
|
+
if (rule.effect === EFFECT.SANITIZE && !rule.sanitize) {
|
|
661
|
+
warnings.push({
|
|
662
|
+
rule: where,
|
|
663
|
+
message:
|
|
664
|
+
"Sanitize rule declares no `sanitize` block, so it defaults to cleaning both arguments and results with every applicable strategy.",
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
if (rule.effect === EFFECT.HOLD && !(rule.approvers ?? []).length) {
|
|
668
|
+
warnings.push({
|
|
669
|
+
rule: where,
|
|
670
|
+
message: "Hold rule names no approvers, so the call waits on nobody in particular.",
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
if (rule.effect === EFFECT.PERMIT && isUnbounded(rule)) {
|
|
674
|
+
warnings.push({
|
|
675
|
+
rule: where,
|
|
676
|
+
message:
|
|
677
|
+
"Permit rule matches every agent, action, and resource with no conditions. It makes the rest of the rule set decorative for anything a forbid does not catch.",
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
if (rule.effect === EFFECT.AUDIT_ONLY) {
|
|
681
|
+
warnings.push({
|
|
682
|
+
rule: where,
|
|
683
|
+
message:
|
|
684
|
+
"audit_only records matches and never authorizes. If this rule was meant to allow the call, it needs effect `permit`.",
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
if (rule.effect === EFFECT.FORBID && rule.approvers?.length) {
|
|
688
|
+
warnings.push({
|
|
689
|
+
rule: where,
|
|
690
|
+
message: "Forbid rule names approvers, but a forbid cannot be approved. Use `hold` instead.",
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// A rule set with no forbid at all is legal and almost never intended.
|
|
696
|
+
if (rules.length && !rules.some((r) => r?.effect === EFFECT.FORBID)) {
|
|
697
|
+
warnings.push({
|
|
698
|
+
rule: null,
|
|
699
|
+
message:
|
|
700
|
+
"No forbid rule in this policy set. Default-deny still applies, but nothing is explicitly prohibited.",
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return { ok: errors.length === 0, errors, warnings, rules: rules.length };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function isUnbounded(rule) {
|
|
708
|
+
const wild = (v) => v === undefined || v === "*" || (Array.isArray(v) && (!v.length || v.includes("*")));
|
|
709
|
+
return wild(rule.agents) && wild(rule.actions) && wild(rule.resources) && !(rule.when ?? []).length;
|
|
710
|
+
}
|