@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/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