@melra/policy-core 0.3.0-alpha.10
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/README.md +78 -0
- package/dist/index.d.ts +160 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +671 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +482 -0
- package/dist/index.test.js.map +1 -0
- package/package.json +32 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
// Copyright 2026 XAGI Labs Private Limited
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { basename, resolve } from "node:path";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { CapabilityGrantSchema, LOCAL_IDENTITY, principalRef, } from "@melra/protocol";
|
|
7
|
+
const READ_ONLY_GIT_ACTIONS = new Set([
|
|
8
|
+
"branch",
|
|
9
|
+
"diff",
|
|
10
|
+
"log",
|
|
11
|
+
"rev-parse",
|
|
12
|
+
"show",
|
|
13
|
+
"status",
|
|
14
|
+
"tag",
|
|
15
|
+
]);
|
|
16
|
+
const ALWAYS_DENIED_COMMANDS = new Set([
|
|
17
|
+
"bash",
|
|
18
|
+
"cmd",
|
|
19
|
+
"fish",
|
|
20
|
+
"osascript",
|
|
21
|
+
"powershell",
|
|
22
|
+
"pwsh",
|
|
23
|
+
"sh",
|
|
24
|
+
"sudo",
|
|
25
|
+
"su",
|
|
26
|
+
"zsh",
|
|
27
|
+
]);
|
|
28
|
+
/**
|
|
29
|
+
* Defaults tuned so a fresh install is usable without editing `policy.json`.
|
|
30
|
+
*
|
|
31
|
+
* `allowedDomains: ["*"]` is not the SSRF boundary and never was. `assertSafeUrl`
|
|
32
|
+
* in `@melra/browser-runtime` independently rejects non-http(s) protocols, URL
|
|
33
|
+
* credentials, private ranges, and cloud metadata (169.254/16), and it resolves
|
|
34
|
+
* DNS before allowing a navigation so a public name cannot be rebound to a
|
|
35
|
+
* private address. The domain list is a *narrowing* control on top of that guard,
|
|
36
|
+
* for operators who want to restrict which public sites are reachable — it is not
|
|
37
|
+
* what stops a browser from reaching the loopback interface.
|
|
38
|
+
*
|
|
39
|
+
* Defaulting it to `[]` made every single navigation fail with
|
|
40
|
+
* `browser_domain_not_allowed` out of the box, which is why browser use was
|
|
41
|
+
* reported as unusable. Operators who want an allowlist set one explicitly.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* Reduce a command to the name the allowlist is written in.
|
|
45
|
+
*
|
|
46
|
+
* On Windows an executable is spelled with an extension (`npm.cmd`, `node.exe`,
|
|
47
|
+
* `git.exe`), while allowlists are written bare. Comparing the raw basename made
|
|
48
|
+
* every extension-qualified spelling fail, so Windows users had no working
|
|
49
|
+
* spelling at all: bare `npm` passed policy but is a `.cmd` shim that does not
|
|
50
|
+
* spawn, and `npm.cmd` spawns but was denied. Only the executable suffixes in
|
|
51
|
+
* PATHEXT are stripped, so a command that merely contains a dot (`python3.11`)
|
|
52
|
+
* is left intact.
|
|
53
|
+
*/
|
|
54
|
+
const EXECUTABLE_SUFFIXES = [".exe", ".cmd", ".bat", ".com", ".ps1"];
|
|
55
|
+
export function normalizeCommandName(command) {
|
|
56
|
+
const name = basename(command.replaceAll("\\", "/")).toLowerCase();
|
|
57
|
+
const suffix = EXECUTABLE_SUFFIXES.find((item) => name.endsWith(item));
|
|
58
|
+
return suffix === undefined ? name : name.slice(0, -suffix.length);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Subcommands that install, per package manager, and the ones that only read.
|
|
62
|
+
*
|
|
63
|
+
* Anything unlisted falls in between: a mutation, but not an install — `npm
|
|
64
|
+
* run build` writes to the workspace and reaches nothing.
|
|
65
|
+
*/
|
|
66
|
+
const PACKAGE_MANAGERS = {
|
|
67
|
+
npm: {
|
|
68
|
+
install: new Set(["install", "i", "add", "ci", "update", "up", "publish"]),
|
|
69
|
+
read: new Set(["ls", "list", "view", "info", "outdated", "why", "config"]),
|
|
70
|
+
},
|
|
71
|
+
pnpm: {
|
|
72
|
+
install: new Set(["install", "i", "add", "update", "up", "publish", "dlx"]),
|
|
73
|
+
read: new Set(["ls", "list", "view", "info", "outdated", "why", "licenses"]),
|
|
74
|
+
},
|
|
75
|
+
yarn: {
|
|
76
|
+
install: new Set(["install", "add", "up", "upgrade", "publish", "dlx"]),
|
|
77
|
+
read: new Set(["info", "why", "list", "outdated"]),
|
|
78
|
+
},
|
|
79
|
+
pip: {
|
|
80
|
+
install: new Set(["install", "download", "wheel"]),
|
|
81
|
+
read: new Set(["list", "show", "freeze", "check"]),
|
|
82
|
+
},
|
|
83
|
+
pip3: {
|
|
84
|
+
install: new Set(["install", "download", "wheel"]),
|
|
85
|
+
read: new Set(["list", "show", "freeze", "check"]),
|
|
86
|
+
},
|
|
87
|
+
uv: {
|
|
88
|
+
install: new Set(["add", "sync", "install", "pip", "tool", "publish"]),
|
|
89
|
+
read: new Set(["tree", "lock", "version"]),
|
|
90
|
+
},
|
|
91
|
+
cargo: {
|
|
92
|
+
install: new Set(["install", "add", "update", "publish", "fetch"]),
|
|
93
|
+
read: new Set(["tree", "metadata", "search"]),
|
|
94
|
+
},
|
|
95
|
+
gem: {
|
|
96
|
+
install: new Set(["install", "update", "push"]),
|
|
97
|
+
read: new Set(["list", "info", "which"]),
|
|
98
|
+
},
|
|
99
|
+
brew: {
|
|
100
|
+
install: new Set(["install", "upgrade", "reinstall", "tap"]),
|
|
101
|
+
read: new Set(["list", "info", "search", "outdated"]),
|
|
102
|
+
},
|
|
103
|
+
apt: {
|
|
104
|
+
install: new Set(["install", "upgrade", "full-upgrade", "update"]),
|
|
105
|
+
read: new Set(["list", "show", "search", "policy"]),
|
|
106
|
+
},
|
|
107
|
+
"apt-get": {
|
|
108
|
+
install: new Set(["install", "upgrade", "dist-upgrade", "update"]),
|
|
109
|
+
read: new Set(["show", "download"]),
|
|
110
|
+
},
|
|
111
|
+
go: {
|
|
112
|
+
install: new Set(["install", "get", "mod"]),
|
|
113
|
+
read: new Set(["list", "version", "env", "vet"]),
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
/** `npx`/`pnpx` fetch and execute a package by name; there is no read form. */
|
|
117
|
+
const ALWAYS_INSTALLING = new Set(["npx", "pnpx", "uvx", "pipx"]);
|
|
118
|
+
/** Programs whose whole purpose is to talk to another host. */
|
|
119
|
+
const NETWORK_COMMANDS = new Set([
|
|
120
|
+
"curl",
|
|
121
|
+
"wget",
|
|
122
|
+
"scp",
|
|
123
|
+
"sftp",
|
|
124
|
+
"ssh",
|
|
125
|
+
"rsync",
|
|
126
|
+
"nc",
|
|
127
|
+
"ncat",
|
|
128
|
+
"telnet",
|
|
129
|
+
"ftp",
|
|
130
|
+
]);
|
|
131
|
+
/** `git` subcommands that contact a remote. */
|
|
132
|
+
const NETWORK_GIT_ACTIONS = new Set([
|
|
133
|
+
"clone",
|
|
134
|
+
"fetch",
|
|
135
|
+
"pull",
|
|
136
|
+
"push",
|
|
137
|
+
"submodule",
|
|
138
|
+
"ls-remote",
|
|
139
|
+
]);
|
|
140
|
+
/**
|
|
141
|
+
* Flags that take their value as the next argument, so that argument is not
|
|
142
|
+
* the subcommand. `git -c core.pager=cat push` pushes; a naive "first
|
|
143
|
+
* non-flag" scan reads `core.pager=cat` as the subcommand and calls it local.
|
|
144
|
+
*
|
|
145
|
+
* The list is short on purpose: it covers the value-taking flags of the
|
|
146
|
+
* commands this module classifies. An unlisted one costs an unrecognised
|
|
147
|
+
* subcommand, which falls back to `mutate`/`medium` — the cautious answer.
|
|
148
|
+
*/
|
|
149
|
+
const VALUE_TAKING_FLAGS = new Set([
|
|
150
|
+
"-c",
|
|
151
|
+
"-C",
|
|
152
|
+
"--config",
|
|
153
|
+
"--cwd",
|
|
154
|
+
"--dir",
|
|
155
|
+
"--prefix",
|
|
156
|
+
"--workspace",
|
|
157
|
+
"-w",
|
|
158
|
+
"--filter",
|
|
159
|
+
"--registry",
|
|
160
|
+
]);
|
|
161
|
+
function subcommandOf(args) {
|
|
162
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
163
|
+
const arg = args[index];
|
|
164
|
+
if (VALUE_TAKING_FLAGS.has(arg)) {
|
|
165
|
+
index += 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// `--filter=foo` carries its value inline, so nothing is skipped.
|
|
169
|
+
if (arg.startsWith("-"))
|
|
170
|
+
continue;
|
|
171
|
+
return arg.toLowerCase();
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Classify a terminal command by what it does, not just by its name.
|
|
177
|
+
*
|
|
178
|
+
* Exported because the accurate answer is worth having outside policy — the
|
|
179
|
+
* CLI's `policy test` prints it, and it is the only place `npm ls` and `npm
|
|
180
|
+
* install` are told apart.
|
|
181
|
+
*/
|
|
182
|
+
export function classifyCommand(command, args) {
|
|
183
|
+
const name = normalizeCommandName(command);
|
|
184
|
+
const subcommand = subcommandOf(args);
|
|
185
|
+
const traits = new Set();
|
|
186
|
+
if (NETWORK_COMMANDS.has(name))
|
|
187
|
+
traits.add("network");
|
|
188
|
+
if (name === "git" && subcommand !== undefined && NETWORK_GIT_ACTIONS.has(subcommand)) {
|
|
189
|
+
traits.add("network");
|
|
190
|
+
}
|
|
191
|
+
if (ALWAYS_INSTALLING.has(name)) {
|
|
192
|
+
traits.add("package-install");
|
|
193
|
+
traits.add("network");
|
|
194
|
+
}
|
|
195
|
+
const manager = PACKAGE_MANAGERS[name];
|
|
196
|
+
if (manager !== undefined && subcommand !== undefined) {
|
|
197
|
+
if (manager.install.has(subcommand)) {
|
|
198
|
+
traits.add("package-install");
|
|
199
|
+
traits.add("network");
|
|
200
|
+
}
|
|
201
|
+
if (manager.read.has(subcommand)) {
|
|
202
|
+
// A read subcommand of a package manager still queries the registry in
|
|
203
|
+
// the general case, so it keeps `network` while dropping the mutation.
|
|
204
|
+
return { read: true, traits: [...traits, "network"] };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const READ_COMMANDS = new Set([
|
|
208
|
+
"cat",
|
|
209
|
+
"findstr",
|
|
210
|
+
"head",
|
|
211
|
+
"ls",
|
|
212
|
+
"pwd",
|
|
213
|
+
"rg",
|
|
214
|
+
"tail",
|
|
215
|
+
"tasklist",
|
|
216
|
+
"wc",
|
|
217
|
+
"where",
|
|
218
|
+
]);
|
|
219
|
+
const read = READ_COMMANDS.has(name) ||
|
|
220
|
+
(name === "git" &&
|
|
221
|
+
subcommand !== undefined &&
|
|
222
|
+
READ_ONLY_GIT_ACTIONS.has(subcommand));
|
|
223
|
+
return { read, traits: [...traits] };
|
|
224
|
+
}
|
|
225
|
+
export function createDefaultPolicy(workspaceRoot) {
|
|
226
|
+
return {
|
|
227
|
+
version: "1",
|
|
228
|
+
workspaceRoot: resolve(workspaceRoot),
|
|
229
|
+
// One list for every platform. An entry naming a program the host does not
|
|
230
|
+
// have is inert — it fails at spawn with `terminal_command_not_found`, not
|
|
231
|
+
// at policy — so the Windows and POSIX read tools can both sit here rather
|
|
232
|
+
// than making the same policy mean different things on different machines.
|
|
233
|
+
allowedCommands: [
|
|
234
|
+
"cat",
|
|
235
|
+
"echo",
|
|
236
|
+
"findstr",
|
|
237
|
+
"git",
|
|
238
|
+
"head",
|
|
239
|
+
"ls",
|
|
240
|
+
"node",
|
|
241
|
+
"npm",
|
|
242
|
+
"npx",
|
|
243
|
+
"pnpm",
|
|
244
|
+
"pwd",
|
|
245
|
+
"rg",
|
|
246
|
+
"tail",
|
|
247
|
+
"tasklist",
|
|
248
|
+
"wc",
|
|
249
|
+
"where",
|
|
250
|
+
],
|
|
251
|
+
allowedDomains: ["*"],
|
|
252
|
+
allowLocalhost: true,
|
|
253
|
+
popups: "block",
|
|
254
|
+
mutations: "confirm",
|
|
255
|
+
approvalTtlMs: 5 * 60_000,
|
|
256
|
+
maxFileBytes: 10 * 1024 * 1024,
|
|
257
|
+
memoryRetention: { maxAgeDays: 30, maxPerScope: 0 },
|
|
258
|
+
deniedTraits: [],
|
|
259
|
+
capabilities: [],
|
|
260
|
+
circuitBreaker: { threshold: 3, cooldownMs: 60_000 },
|
|
261
|
+
unhinged: false,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
export async function loadPolicy(path, workspaceRoot) {
|
|
265
|
+
const defaults = createDefaultPolicy(workspaceRoot);
|
|
266
|
+
if (path === undefined)
|
|
267
|
+
return defaults;
|
|
268
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
269
|
+
return {
|
|
270
|
+
...defaults,
|
|
271
|
+
...parsed,
|
|
272
|
+
workspaceRoot: resolve(parsed.workspaceRoot ?? workspaceRoot),
|
|
273
|
+
allowedCommands: parsed.allowedCommands ?? defaults.allowedCommands,
|
|
274
|
+
allowedDomains: parsed.allowedDomains ?? defaults.allowedDomains,
|
|
275
|
+
// Spread, not replace: a file naming only `maxPerScope` should not silently
|
|
276
|
+
// drop `maxAgeDays` to `undefined` and make compaction delete everything.
|
|
277
|
+
memoryRetention: { ...defaults.memoryRetention, ...parsed.memoryRetention },
|
|
278
|
+
circuitBreaker: { ...defaults.circuitBreaker, ...parsed.circuitBreaker },
|
|
279
|
+
deniedTraits: parsed.deniedTraits ?? defaults.deniedTraits,
|
|
280
|
+
// Parsed, not spread: a grant is the file claiming authority exists, so a
|
|
281
|
+
// malformed one has to fail loudly rather than land as an object that
|
|
282
|
+
// matches nothing or — worse — everything.
|
|
283
|
+
capabilities: parsed.capabilities === undefined
|
|
284
|
+
? defaults.capabilities
|
|
285
|
+
: CapabilityGrantSchema.array().max(200).parse(parsed.capabilities),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
export function classifyOperation(operation) {
|
|
289
|
+
switch (operation.kind) {
|
|
290
|
+
case "file": {
|
|
291
|
+
const read = new Set(["list", "read", "stat", "hash"]).has(operation.action);
|
|
292
|
+
const destructive = operation.action === "delete";
|
|
293
|
+
return {
|
|
294
|
+
effect: destructive ? "destructive" : read ? "read" : "mutate",
|
|
295
|
+
risk: destructive ? "high" : read ? "low" : "medium",
|
|
296
|
+
capability: `file.${operation.action}`,
|
|
297
|
+
target: operation.path,
|
|
298
|
+
traits: [],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
case "terminal": {
|
|
302
|
+
if (operation.action === "status" || operation.action === "output") {
|
|
303
|
+
return {
|
|
304
|
+
effect: "read",
|
|
305
|
+
risk: "low",
|
|
306
|
+
capability: `terminal.${operation.action}`,
|
|
307
|
+
target: `job:${operation.jobId ?? "unknown"}`,
|
|
308
|
+
traits: [],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
// `send` writes to a running process's stdin. It carries no command of
|
|
312
|
+
// its own, so it cannot be classified by one: whatever the input does is
|
|
313
|
+
// the already-approved job's doing, and answering a prompt is a mutation.
|
|
314
|
+
if (operation.action === "send") {
|
|
315
|
+
return {
|
|
316
|
+
effect: "mutate",
|
|
317
|
+
risk: "medium",
|
|
318
|
+
capability: "terminal.send",
|
|
319
|
+
target: `job:${operation.jobId ?? "unknown"}`,
|
|
320
|
+
traits: [],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const command = normalizeCommandName(operation.command ?? "");
|
|
324
|
+
const { read, traits } = classifyCommand(operation.command ?? "", operation.args);
|
|
325
|
+
return {
|
|
326
|
+
effect: read ? "read" : "mutate",
|
|
327
|
+
// Installing third-party code is the high-risk case, not the package
|
|
328
|
+
// manager's name: `npm run build` is an ordinary mutation and `npm ls`
|
|
329
|
+
// is a read, which the old blanket rule called high-risk alongside it.
|
|
330
|
+
risk: traits.includes("package-install")
|
|
331
|
+
? "high"
|
|
332
|
+
: read
|
|
333
|
+
? "low"
|
|
334
|
+
: "medium",
|
|
335
|
+
capability: `terminal.${operation.action}`,
|
|
336
|
+
target: operation.action === "stop"
|
|
337
|
+
? `job:${operation.jobId ?? "unknown"}`
|
|
338
|
+
: `command:${command}`,
|
|
339
|
+
traits,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
case "browser": {
|
|
343
|
+
// Navigation and observation read the world; only actions that drive the
|
|
344
|
+
// page (click, type, upload, ...) change anything. `tab_new` and
|
|
345
|
+
// `tab_switch` are classified with `navigate` for the same reason it is:
|
|
346
|
+
// they move where we are looking, they do not act on a document. `wait`
|
|
347
|
+
// joins them — it blocks until the page reaches a state, and blocking is
|
|
348
|
+
// not acting.
|
|
349
|
+
const read = new Set([
|
|
350
|
+
"navigate",
|
|
351
|
+
"back",
|
|
352
|
+
"forward",
|
|
353
|
+
"reload",
|
|
354
|
+
"inspect",
|
|
355
|
+
"wait",
|
|
356
|
+
"screenshot",
|
|
357
|
+
"tabs",
|
|
358
|
+
"tab_new",
|
|
359
|
+
"tab_switch",
|
|
360
|
+
"scroll",
|
|
361
|
+
]).has(operation.action);
|
|
362
|
+
return {
|
|
363
|
+
effect: read ? "read" : "mutate",
|
|
364
|
+
risk: read ? "low" : "medium",
|
|
365
|
+
capability: `browser.${operation.action}`,
|
|
366
|
+
target: operation.url === undefined
|
|
367
|
+
? operation.target?.selector ??
|
|
368
|
+
operation.target?.name ??
|
|
369
|
+
"active-page"
|
|
370
|
+
: (() => {
|
|
371
|
+
const target = new URL(operation.url);
|
|
372
|
+
target.search = "";
|
|
373
|
+
target.hash = "";
|
|
374
|
+
return target.toString();
|
|
375
|
+
})(),
|
|
376
|
+
// Driving a page is a network act whether or not the caller named a
|
|
377
|
+
// URL: a click can fetch, and a stale tab is still a live connection.
|
|
378
|
+
traits: ["network"],
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
case "memory": {
|
|
382
|
+
const read = operation.action === "search" || operation.action === "list";
|
|
383
|
+
const destructive = operation.action === "delete" || operation.action === "clear";
|
|
384
|
+
return {
|
|
385
|
+
effect: destructive ? "destructive" : read ? "read" : "mutate",
|
|
386
|
+
risk: destructive ? "high" : read ? "low" : "medium",
|
|
387
|
+
capability: `memory.${operation.action}`,
|
|
388
|
+
target: `${operation.scope}:${operation.action}:${operation.id ?? operation.key ?? "*"}`,
|
|
389
|
+
traits: [],
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
case "computer": {
|
|
393
|
+
const read = operation.action === "capabilities" ||
|
|
394
|
+
operation.action === "inspect" ||
|
|
395
|
+
operation.action === "screenshot";
|
|
396
|
+
const positional = operation.action === "click" ||
|
|
397
|
+
operation.action === "move" ||
|
|
398
|
+
operation.action === "drag";
|
|
399
|
+
return {
|
|
400
|
+
effect: read ? "read" : "mutate",
|
|
401
|
+
risk: read ? "low" : "high",
|
|
402
|
+
capability: `computer.${operation.action}`,
|
|
403
|
+
// A named target is what the caller asked for, so it is what a
|
|
404
|
+
// capability pattern gets to match. Reporting the pixel it resolves to
|
|
405
|
+
// would name a coordinate the caller never wrote, and the resolution
|
|
406
|
+
// happens after policy anyway.
|
|
407
|
+
target: operation.target
|
|
408
|
+
? `element:${operation.target.role ?? "*"}:${operation.target.name ?? "*"}`
|
|
409
|
+
: positional
|
|
410
|
+
? `${operation.coordinateSpace}:${operation.x ?? "?"},${operation.y ?? "?"}`
|
|
411
|
+
: "active-desktop",
|
|
412
|
+
traits: [],
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
case "system":
|
|
416
|
+
return {
|
|
417
|
+
effect: "read",
|
|
418
|
+
risk: "low",
|
|
419
|
+
capability: "system.info",
|
|
420
|
+
target: "local-system",
|
|
421
|
+
traits: [],
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Evidence a mutation must satisfy when the caller declared none.
|
|
427
|
+
*
|
|
428
|
+
* Mutations still require evidence — that rule is the product's verification
|
|
429
|
+
* thesis and is not relaxed here. What changed is the failure mode: previously a
|
|
430
|
+
* caller who omitted `requiredEvidence` got a flat `mutation_requires_evidence`
|
|
431
|
+
* deny and had to guess the right predicate, so most callers simply gave up. Now
|
|
432
|
+
* the obvious post-condition is derived from the operation itself, and the task
|
|
433
|
+
* is held to it exactly as if the caller had written it.
|
|
434
|
+
*
|
|
435
|
+
* Returns `[]` when no post-condition is derivable from the request alone (for
|
|
436
|
+
* example a `terminal run`, whose effect depends on the command). Those still
|
|
437
|
+
* deny, because a mutation nobody can check is precisely what should not run
|
|
438
|
+
* unattended.
|
|
439
|
+
*/
|
|
440
|
+
export function defaultEvidenceFor(operation) {
|
|
441
|
+
// Reads are never denied for missing evidence and already fall back to a
|
|
442
|
+
// synthetic `operation_completed` item, so synthesizing a predicate for them
|
|
443
|
+
// would only invent a way for a successful read to verify as `partial`.
|
|
444
|
+
if (classifyOperation(operation).effect === "read")
|
|
445
|
+
return [];
|
|
446
|
+
switch (operation.kind) {
|
|
447
|
+
case "file":
|
|
448
|
+
switch (operation.action) {
|
|
449
|
+
case "write":
|
|
450
|
+
case "mkdir":
|
|
451
|
+
return [{ type: "file_exists", path: operation.path }];
|
|
452
|
+
case "delete":
|
|
453
|
+
return [{ type: "file_absent", path: operation.path }];
|
|
454
|
+
case "move":
|
|
455
|
+
return operation.destination === undefined
|
|
456
|
+
? []
|
|
457
|
+
: [
|
|
458
|
+
{ type: "file_absent", path: operation.path },
|
|
459
|
+
{ type: "file_exists", path: operation.destination },
|
|
460
|
+
];
|
|
461
|
+
default:
|
|
462
|
+
return [];
|
|
463
|
+
}
|
|
464
|
+
case "memory":
|
|
465
|
+
// Field names differ per action; each is the adapter's own report that the
|
|
466
|
+
// record actually changed, so a silent no-op cannot pass as a success.
|
|
467
|
+
// `clear` returns a count rather than a flag, so it has no boolean
|
|
468
|
+
// post-condition to assert.
|
|
469
|
+
return operation.action === "put"
|
|
470
|
+
? [{ type: "result_equals", path: "stored", value: true }]
|
|
471
|
+
: operation.action === "delete"
|
|
472
|
+
? [{ type: "result_equals", path: "deleted", value: true }]
|
|
473
|
+
: [];
|
|
474
|
+
case "browser":
|
|
475
|
+
case "computer":
|
|
476
|
+
// These adapters report an explicit `success` flag; hold the task to it
|
|
477
|
+
// rather than letting a silent no-op pass as a completed action.
|
|
478
|
+
return [{ type: "result_equals", path: "success", value: true }];
|
|
479
|
+
case "terminal":
|
|
480
|
+
// `run`/`start` say nothing about what the command should leave behind,
|
|
481
|
+
// so there is no honest post-condition. `send` does: the runtime reports
|
|
482
|
+
// whether the bytes reached a job that was still running and accepting
|
|
483
|
+
// input, and a write to a dead or non-interactive job throws instead.
|
|
484
|
+
return operation.action === "send"
|
|
485
|
+
? [{ type: "result_equals", path: "sent", value: true }]
|
|
486
|
+
: [];
|
|
487
|
+
default:
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function isCommandAllowed(operation, policy) {
|
|
492
|
+
if (operation.kind !== "terminal")
|
|
493
|
+
return true;
|
|
494
|
+
if (operation.action === "status" || operation.action === "output") {
|
|
495
|
+
return operation.jobId !== undefined;
|
|
496
|
+
}
|
|
497
|
+
// `stop` and `send` act on a job the allowlist already cleared when it
|
|
498
|
+
// started, so the job id is the whole gate — there is no command to match.
|
|
499
|
+
if (operation.action === "stop" || operation.action === "send") {
|
|
500
|
+
return operation.jobId !== undefined;
|
|
501
|
+
}
|
|
502
|
+
if (operation.command === undefined)
|
|
503
|
+
return false;
|
|
504
|
+
const command = normalizeCommandName(operation.command);
|
|
505
|
+
// Normalized on both sides so `powershell.exe` cannot slip past a deny entry
|
|
506
|
+
// written as `powershell`, and an operator's allowlist matches either spelling.
|
|
507
|
+
if (ALWAYS_DENIED_COMMANDS.has(command))
|
|
508
|
+
return false;
|
|
509
|
+
return policy.allowedCommands.some((allowed) => normalizeCommandName(allowed) === command);
|
|
510
|
+
}
|
|
511
|
+
function domainAllowed(operation, policy) {
|
|
512
|
+
if (operation.kind !== "browser" || operation.url === undefined)
|
|
513
|
+
return true;
|
|
514
|
+
const url = new URL(operation.url);
|
|
515
|
+
if (!["http:", "https:"].includes(url.protocol))
|
|
516
|
+
return false;
|
|
517
|
+
const host = url.hostname.toLowerCase();
|
|
518
|
+
if (host === "localhost" || host.endsWith(".localhost"))
|
|
519
|
+
return policy.allowLocalhost;
|
|
520
|
+
return policy.allowedDomains.some((domain) => domain === "*" ||
|
|
521
|
+
host === domain.toLowerCase() ||
|
|
522
|
+
host.endsWith(`.${domain.toLowerCase()}`));
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* `*` stands for any run of characters; everything else is literal. Enough for
|
|
526
|
+
* `file.*` and `/repo/build/*`, and small enough that a policy author can
|
|
527
|
+
* predict what it matches.
|
|
528
|
+
*/
|
|
529
|
+
function patternMatches(pattern, value) {
|
|
530
|
+
if (pattern === "*")
|
|
531
|
+
return true;
|
|
532
|
+
const escaped = pattern
|
|
533
|
+
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
534
|
+
.replaceAll("\\*", ".*");
|
|
535
|
+
return new RegExp(`^${escaped}$`).test(value);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Why the issued capabilities do not cover this effect, or `undefined` if they
|
|
539
|
+
* do. An empty grant list is not a closed world — it means the operator has not
|
|
540
|
+
* issued grants at all, and the rest of policy decides on its own.
|
|
541
|
+
*/
|
|
542
|
+
export function capabilityRefusal(classified, identity, policy) {
|
|
543
|
+
if (policy.capabilities.length === 0)
|
|
544
|
+
return undefined;
|
|
545
|
+
const holder = principalRef(identity.principal);
|
|
546
|
+
const matching = policy.capabilities.filter((grant) => patternMatches(grant.capability, classified.capability) &&
|
|
547
|
+
grant.effects.includes(classified.effect) &&
|
|
548
|
+
patternMatches(grant.target, classified.target) &&
|
|
549
|
+
patternMatches(grant.principal, holder));
|
|
550
|
+
if (matching.length === 0) {
|
|
551
|
+
return `capability_not_granted:${classified.capability}`;
|
|
552
|
+
}
|
|
553
|
+
const usable = matching.filter((grant) => (grant.validUntil === undefined ||
|
|
554
|
+
Date.parse(grant.validUntil) > Date.now()) &&
|
|
555
|
+
(grant.policyVersion === undefined ||
|
|
556
|
+
grant.policyVersion === policy.version));
|
|
557
|
+
if (usable.length > 0)
|
|
558
|
+
return undefined;
|
|
559
|
+
// Every candidate matched the effect and was refused for a reason the holder
|
|
560
|
+
// can act on, so name it rather than reporting a missing grant.
|
|
561
|
+
const stale = matching.find((grant) => grant.policyVersion !== policy.version);
|
|
562
|
+
return stale?.policyVersion !== undefined
|
|
563
|
+
? `capability_policy_version_mismatch:${stale.id}`
|
|
564
|
+
: `capability_expired:${matching[0]?.id ?? classified.capability}`;
|
|
565
|
+
}
|
|
566
|
+
export function evaluatePolicy(taskId, request, policy) {
|
|
567
|
+
const classified = classifyOperation(request.operation);
|
|
568
|
+
// Every decision reports the same classification it was made from, including
|
|
569
|
+
// the traits, so an approver reading a `confirm` sees that the command
|
|
570
|
+
// installs packages and reaches a registry before echoing the phrase back.
|
|
571
|
+
const decide = (outcome, reason, risk = classified.risk) => ({
|
|
572
|
+
outcome,
|
|
573
|
+
effect: classified.effect,
|
|
574
|
+
risk,
|
|
575
|
+
reason,
|
|
576
|
+
policyVersion: policy.version,
|
|
577
|
+
traits: classified.traits,
|
|
578
|
+
});
|
|
579
|
+
if (request.constraints.length > 0) {
|
|
580
|
+
return {
|
|
581
|
+
decision: decide("deny", "freeform_constraints_not_enforceable"),
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
if (request.forbiddenEffects.includes(classified.effect)) {
|
|
585
|
+
return { decision: decide("deny", "effect_forbidden_by_request") };
|
|
586
|
+
}
|
|
587
|
+
// Unhinged mode allows everything the operator did not forbid in this very
|
|
588
|
+
// request. It sits after the `forbiddenEffects` check on purpose: that field
|
|
589
|
+
// is the caller declaring a limit on its own task, not a guardrail MELRA
|
|
590
|
+
// imposes, and a caller that asked for no destructive effects should still get
|
|
591
|
+
// none. Everything below — allowlists, evidence, approval — is MELRA's own
|
|
592
|
+
// judgement, and unhinged mode is the operator saying they do not want it.
|
|
593
|
+
if (policy.unhinged) {
|
|
594
|
+
return { decision: decide("allow", "unhinged_mode_no_guardrails") };
|
|
595
|
+
}
|
|
596
|
+
// Authority before rules: a caller that was never granted this effect is
|
|
597
|
+
// refused without consulting the allowlists, which describe what the grant
|
|
598
|
+
// holder may do, not whether they hold one.
|
|
599
|
+
const ungranted = capabilityRefusal(classified, request.identity ?? LOCAL_IDENTITY, policy);
|
|
600
|
+
if (ungranted !== undefined) {
|
|
601
|
+
return { decision: decide("deny", ungranted, "critical") };
|
|
602
|
+
}
|
|
603
|
+
const deniedTrait = classified.traits.find((trait) => policy.deniedTraits.includes(trait));
|
|
604
|
+
if (deniedTrait !== undefined) {
|
|
605
|
+
return { decision: decide("deny", `trait_denied:${deniedTrait}`) };
|
|
606
|
+
}
|
|
607
|
+
if (!isCommandAllowed(request.operation, policy)) {
|
|
608
|
+
return {
|
|
609
|
+
decision: decide("deny", "command_not_allowlisted", "critical"),
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
if (!domainAllowed(request.operation, policy)) {
|
|
613
|
+
return {
|
|
614
|
+
decision: decide("deny", "browser_domain_not_allowed", "high"),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
if (classified.effect !== "read" && request.requiredEvidence.length === 0) {
|
|
618
|
+
return { decision: decide("deny", "mutation_requires_evidence") };
|
|
619
|
+
}
|
|
620
|
+
if (classified.effect === "read") {
|
|
621
|
+
return { decision: decide("allow", "read_only_operation") };
|
|
622
|
+
}
|
|
623
|
+
if (policy.mutations === "deny") {
|
|
624
|
+
return { decision: decide("deny", "mutations_disabled") };
|
|
625
|
+
}
|
|
626
|
+
const digest = digestOperation(taskId, request.operation);
|
|
627
|
+
const approvalId = randomUUID();
|
|
628
|
+
const phrase = `APPROVE ${digest.slice(0, 12)}`;
|
|
629
|
+
return {
|
|
630
|
+
decision: decide("confirm", "explicit_approval_required"),
|
|
631
|
+
challenge: {
|
|
632
|
+
approvalId,
|
|
633
|
+
taskId,
|
|
634
|
+
actionDigest: digest,
|
|
635
|
+
phrase,
|
|
636
|
+
expiresAt: new Date(Date.now() + policy.approvalTtlMs).toISOString(),
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
export function digestOperation(taskId, operation) {
|
|
641
|
+
return createHash("sha256")
|
|
642
|
+
.update(stableJson({ taskId, operation }))
|
|
643
|
+
.digest("hex");
|
|
644
|
+
}
|
|
645
|
+
function stableJson(value) {
|
|
646
|
+
if (value === null || typeof value !== "object")
|
|
647
|
+
return JSON.stringify(value) ?? "null";
|
|
648
|
+
if (Array.isArray(value))
|
|
649
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
650
|
+
const object = value;
|
|
651
|
+
return `{${Object.keys(object)
|
|
652
|
+
.sort()
|
|
653
|
+
.map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`)
|
|
654
|
+
.join(",")}}`;
|
|
655
|
+
}
|
|
656
|
+
export function validateApproval(challenge, response) {
|
|
657
|
+
if (challenge === undefined || response === undefined) {
|
|
658
|
+
return { ok: false, reason: "approval_required" };
|
|
659
|
+
}
|
|
660
|
+
if (challenge.approvalId !== response.approvalId) {
|
|
661
|
+
return { ok: false, reason: "approval_id_mismatch" };
|
|
662
|
+
}
|
|
663
|
+
if (challenge.phrase !== response.phrase) {
|
|
664
|
+
return { ok: false, reason: "approval_phrase_mismatch" };
|
|
665
|
+
}
|
|
666
|
+
if (Date.parse(challenge.expiresAt) <= Date.now()) {
|
|
667
|
+
return { ok: false, reason: "approval_expired" };
|
|
668
|
+
}
|
|
669
|
+
return { ok: true };
|
|
670
|
+
}
|
|
671
|
+
//# sourceMappingURL=index.js.map
|