@melra/policy-core 0.3.0-alpha.11 → 0.3.0-alpha.13
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/README.md +6 -3
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +236 -62
- package/dist/index.js.map +1 -1
- package/dist/network-policy.d.ts +13 -3
- package/dist/network-policy.d.ts.map +1 -1
- package/dist/network-policy.js +67 -6
- package/dist/network-policy.js.map +1 -1
- package/package.json +3 -3
- package/dist/credentials.test.d.ts +0 -2
- package/dist/credentials.test.d.ts.map +0 -1
- package/dist/credentials.test.js +0 -103
- package/dist/credentials.test.js.map +0 -1
- package/dist/index.test.d.ts +0 -2
- package/dist/index.test.d.ts.map +0 -1
- package/dist/index.test.js +0 -655
- package/dist/index.test.js.map +0 -1
- package/dist/network-policy.test.d.ts +0 -2
- package/dist/network-policy.test.d.ts.map +0 -1
- package/dist/network-policy.test.js +0 -39
- package/dist/network-policy.test.js.map +0 -1
package/dist/index.test.js
DELETED
|
@@ -1,655 +0,0 @@
|
|
|
1
|
-
// Copyright 2026 XAGI Labs Private Limited
|
|
2
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { TaskRequestSchema } from "@melra/protocol";
|
|
5
|
-
import { classifyCommand, classifyOperation, createDefaultPolicy, defaultEvidenceFor, evaluatePolicy, validateApproval, } from "./index.js";
|
|
6
|
-
const root = "/tmp/melra-policy-test";
|
|
7
|
-
describe("local policy", () => {
|
|
8
|
-
it("allows a read-only file operation", () => {
|
|
9
|
-
const request = TaskRequestSchema.parse({
|
|
10
|
-
goal: "Read the changelog",
|
|
11
|
-
operation: { kind: "file", action: "read", path: "CHANGELOG.md" },
|
|
12
|
-
});
|
|
13
|
-
const result = evaluatePolicy("4c5a0130-71a5-4c48-b22d-c7901f12688f", request, createDefaultPolicy(root));
|
|
14
|
-
expect(result.decision.outcome).toBe("allow");
|
|
15
|
-
expect(result.challenge).toBeUndefined();
|
|
16
|
-
});
|
|
17
|
-
it("requires evidence and approval for mutation", () => {
|
|
18
|
-
const missingEvidence = TaskRequestSchema.parse({
|
|
19
|
-
goal: "Write a file",
|
|
20
|
-
operation: {
|
|
21
|
-
kind: "file",
|
|
22
|
-
action: "write",
|
|
23
|
-
path: "result.txt",
|
|
24
|
-
content: "verified",
|
|
25
|
-
},
|
|
26
|
-
});
|
|
27
|
-
expect(evaluatePolicy("b47dded2-4e20-4d96-a993-fdf6b0034664", missingEvidence, createDefaultPolicy(root)).decision.reason).toBe("mutation_requires_evidence");
|
|
28
|
-
const request = TaskRequestSchema.parse({
|
|
29
|
-
...missingEvidence,
|
|
30
|
-
requiredEvidence: [{ type: "file_exists", path: "result.txt" }],
|
|
31
|
-
});
|
|
32
|
-
const result = evaluatePolicy("b47dded2-4e20-4d96-a993-fdf6b0034664", request, createDefaultPolicy(root));
|
|
33
|
-
expect(result.decision.outcome).toBe("confirm");
|
|
34
|
-
expect(result.challenge?.phrase).toMatch(/^APPROVE [a-f0-9]{12}$/);
|
|
35
|
-
});
|
|
36
|
-
it("denies shells even if added to the allowlist", () => {
|
|
37
|
-
const request = TaskRequestSchema.parse({
|
|
38
|
-
goal: "Run an unrestricted shell",
|
|
39
|
-
operation: {
|
|
40
|
-
kind: "terminal",
|
|
41
|
-
action: "run",
|
|
42
|
-
command: "bash",
|
|
43
|
-
args: ["-lc", "echo unsafe"],
|
|
44
|
-
},
|
|
45
|
-
});
|
|
46
|
-
const policy = createDefaultPolicy(root);
|
|
47
|
-
policy.allowedCommands.push("bash");
|
|
48
|
-
expect(evaluatePolicy("71942756-af2a-407d-806e-73d7bb4fe5d0", request, policy).decision.reason).toBe("command_not_allowlisted");
|
|
49
|
-
});
|
|
50
|
-
it("allows computer inspection but gates input behind evidence and approval", () => {
|
|
51
|
-
const inspect = TaskRequestSchema.parse({
|
|
52
|
-
goal: "Inspect local computer-use support",
|
|
53
|
-
operation: { kind: "computer", action: "capabilities" },
|
|
54
|
-
});
|
|
55
|
-
expect(evaluatePolicy("c4e05bc0-c809-4ba3-a2a4-69102cdcf688", inspect, createDefaultPolicy(root)).decision.outcome).toBe("allow");
|
|
56
|
-
const click = TaskRequestSchema.parse({
|
|
57
|
-
goal: "Click a verified desktop target",
|
|
58
|
-
operation: {
|
|
59
|
-
kind: "computer",
|
|
60
|
-
action: "click",
|
|
61
|
-
coordinateSpace: "normalized",
|
|
62
|
-
x: 0.5,
|
|
63
|
-
y: 0.5,
|
|
64
|
-
},
|
|
65
|
-
requiredEvidence: [
|
|
66
|
-
{ type: "result_equals", path: "success", value: true },
|
|
67
|
-
],
|
|
68
|
-
});
|
|
69
|
-
const decision = evaluatePolicy("1aa2432a-a584-4b3f-b256-9ac3a6887630", click, createDefaultPolicy(root));
|
|
70
|
-
expect(decision.decision.outcome).toBe("confirm");
|
|
71
|
-
expect(decision.decision.risk).toBe("high");
|
|
72
|
-
});
|
|
73
|
-
it("validates the scoped approval phrase", () => {
|
|
74
|
-
const request = TaskRequestSchema.parse({
|
|
75
|
-
goal: "Store a memory",
|
|
76
|
-
operation: {
|
|
77
|
-
kind: "memory",
|
|
78
|
-
action: "put",
|
|
79
|
-
scope: "workspace",
|
|
80
|
-
key: "project",
|
|
81
|
-
value: "MELRA",
|
|
82
|
-
},
|
|
83
|
-
requiredEvidence: [
|
|
84
|
-
{ type: "result_equals", path: "stored", value: true },
|
|
85
|
-
],
|
|
86
|
-
});
|
|
87
|
-
const result = evaluatePolicy("db65d7da-9d59-46a7-86f7-aa1bd205382c", request, createDefaultPolicy(root));
|
|
88
|
-
const challenge = result.challenge;
|
|
89
|
-
expect(challenge).toBeDefined();
|
|
90
|
-
expect(validateApproval(challenge, {
|
|
91
|
-
approvalId: challenge.approvalId,
|
|
92
|
-
phrase: challenge.phrase,
|
|
93
|
-
})).toEqual({ ok: true });
|
|
94
|
-
});
|
|
95
|
-
it("never silently ignores caller constraints or forbidden effects", () => {
|
|
96
|
-
const constrained = TaskRequestSchema.parse({
|
|
97
|
-
goal: "Read with an unenforceable natural-language constraint",
|
|
98
|
-
operation: { kind: "file", action: "read", path: "README.md" },
|
|
99
|
-
constraints: ["only if the file is recent"],
|
|
100
|
-
});
|
|
101
|
-
expect(evaluatePolicy("ae8e57dd-e9f7-4821-82d7-ce50dc896008", constrained, createDefaultPolicy(root)).decision.reason).toBe("freeform_constraints_not_enforceable");
|
|
102
|
-
const forbidden = TaskRequestSchema.parse({
|
|
103
|
-
goal: "Do not permit a mutation",
|
|
104
|
-
operation: {
|
|
105
|
-
kind: "file",
|
|
106
|
-
action: "write",
|
|
107
|
-
path: "result.txt",
|
|
108
|
-
content: "blocked",
|
|
109
|
-
},
|
|
110
|
-
forbiddenEffects: ["mutate"],
|
|
111
|
-
requiredEvidence: [{ type: "file_exists", path: "result.txt" }],
|
|
112
|
-
});
|
|
113
|
-
expect(evaluatePolicy("e299a821-8dbf-4784-8ecf-f79d6812cc64", forbidden, createDefaultPolicy(root)).decision.reason).toBe("effect_forbidden_by_request");
|
|
114
|
-
});
|
|
115
|
-
it("allows a browser navigation with the shipped defaults", () => {
|
|
116
|
-
// Regression: allowedDomains defaulted to [] and denied every navigation
|
|
117
|
-
// out of the box, which is what made browser use unusable on a fresh install.
|
|
118
|
-
const request = TaskRequestSchema.parse({
|
|
119
|
-
goal: "Open a docs page",
|
|
120
|
-
operation: {
|
|
121
|
-
kind: "browser",
|
|
122
|
-
action: "navigate",
|
|
123
|
-
url: "https://example.com/docs",
|
|
124
|
-
},
|
|
125
|
-
});
|
|
126
|
-
expect(evaluatePolicy("0f9e6d2a-3c41-4b8e-9a77-1d5b8c2e4f60", request, createDefaultPolicy(root)).decision.outcome).toBe("allow");
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
describe("default evidence", () => {
|
|
130
|
-
it("derives the obvious post-condition per file action", () => {
|
|
131
|
-
expect(defaultEvidenceFor({
|
|
132
|
-
kind: "file",
|
|
133
|
-
action: "write",
|
|
134
|
-
path: "out.txt",
|
|
135
|
-
encoding: "utf8",
|
|
136
|
-
recursive: false,
|
|
137
|
-
})).toEqual([{ type: "file_exists", path: "out.txt" }]);
|
|
138
|
-
expect(defaultEvidenceFor({
|
|
139
|
-
kind: "file",
|
|
140
|
-
action: "delete",
|
|
141
|
-
path: "out.txt",
|
|
142
|
-
encoding: "utf8",
|
|
143
|
-
recursive: false,
|
|
144
|
-
})).toEqual([{ type: "file_absent", path: "out.txt" }]);
|
|
145
|
-
expect(defaultEvidenceFor({
|
|
146
|
-
kind: "file",
|
|
147
|
-
action: "move",
|
|
148
|
-
path: "a.txt",
|
|
149
|
-
destination: "b.txt",
|
|
150
|
-
encoding: "utf8",
|
|
151
|
-
recursive: false,
|
|
152
|
-
})).toEqual([
|
|
153
|
-
{ type: "file_absent", path: "a.txt" },
|
|
154
|
-
{ type: "file_exists", path: "b.txt" },
|
|
155
|
-
]);
|
|
156
|
-
});
|
|
157
|
-
it("derives nothing for a terminal run, so it still denies", () => {
|
|
158
|
-
// The request says nothing about what the command should leave behind, so
|
|
159
|
-
// there is no honest post-condition to synthesize.
|
|
160
|
-
expect(defaultEvidenceFor({
|
|
161
|
-
kind: "terminal",
|
|
162
|
-
action: "run",
|
|
163
|
-
command: "npm",
|
|
164
|
-
args: ["run", "build"],
|
|
165
|
-
interactive: false,
|
|
166
|
-
appendNewline: true,
|
|
167
|
-
timeoutMs: 30_000,
|
|
168
|
-
maxOutputChars: 100_000,
|
|
169
|
-
})).toEqual([]);
|
|
170
|
-
});
|
|
171
|
-
it("derives nothing for a read, which already has a fallback", () => {
|
|
172
|
-
expect(defaultEvidenceFor({
|
|
173
|
-
kind: "file",
|
|
174
|
-
action: "read",
|
|
175
|
-
path: "a.txt",
|
|
176
|
-
encoding: "utf8",
|
|
177
|
-
recursive: false,
|
|
178
|
-
})).toEqual([]);
|
|
179
|
-
});
|
|
180
|
-
it("matches the field each memory action actually reports", () => {
|
|
181
|
-
expect(defaultEvidenceFor({
|
|
182
|
-
kind: "memory",
|
|
183
|
-
action: "put",
|
|
184
|
-
scope: "workspace",
|
|
185
|
-
key: "k",
|
|
186
|
-
value: "v",
|
|
187
|
-
confidence: 1,
|
|
188
|
-
tags: [],
|
|
189
|
-
includeSuperseded: false,
|
|
190
|
-
limit: 20,
|
|
191
|
-
})).toEqual([{ type: "result_equals", path: "stored", value: true }]);
|
|
192
|
-
// `clear` reports a count, not a boolean, so nothing is derivable.
|
|
193
|
-
expect(defaultEvidenceFor({
|
|
194
|
-
kind: "memory",
|
|
195
|
-
action: "clear",
|
|
196
|
-
scope: "workspace",
|
|
197
|
-
confidence: 1,
|
|
198
|
-
tags: [],
|
|
199
|
-
includeSuperseded: false,
|
|
200
|
-
limit: 20,
|
|
201
|
-
})).toEqual([]);
|
|
202
|
-
});
|
|
203
|
-
it("holds a browser mutation to the flag the runtime actually reports", () => {
|
|
204
|
-
// This predicate is only honest because `BrowserRuntime.execute` stamps
|
|
205
|
-
// `success: true` on every result. It did not, so a click with no declared
|
|
206
|
-
// evidence executed, mutated the page, and then verified as `partial`
|
|
207
|
-
// because the derived predicate read a key nobody wrote.
|
|
208
|
-
expect(defaultEvidenceFor({
|
|
209
|
-
kind: "browser",
|
|
210
|
-
action: "click",
|
|
211
|
-
target: { selector: "#go" },
|
|
212
|
-
fullPage: false,
|
|
213
|
-
timeoutMs: 30_000,
|
|
214
|
-
settleQuietMs: 180,
|
|
215
|
-
settleTimeoutMs: 1_500,
|
|
216
|
-
maxChars: 20_000,
|
|
217
|
-
delayMs: 0,
|
|
218
|
-
clearFirst: true,
|
|
219
|
-
pixels: 600,
|
|
220
|
-
})).toEqual([{ type: "result_equals", path: "success", value: true }]);
|
|
221
|
-
});
|
|
222
|
-
});
|
|
223
|
-
describe("Windows command spellings", () => {
|
|
224
|
-
const plan = (command, args = []) => evaluatePolicy("00000000-0000-4000-8000-000000000000", TaskRequestSchema.parse({
|
|
225
|
-
goal: "Run an allowlisted program",
|
|
226
|
-
operation: { kind: "terminal", action: "run", command, args },
|
|
227
|
-
requiredEvidence: [{ type: "exit_code", value: 0 }],
|
|
228
|
-
}), createDefaultPolicy(root)).decision;
|
|
229
|
-
it("accepts the extension-qualified spelling Windows actually runs", () => {
|
|
230
|
-
// The deadlock this closes: `npm` passed policy but is a `.cmd` shim that
|
|
231
|
-
// cannot be spawned, while `npm.cmd` could be spawned but was denied — so on
|
|
232
|
-
// Windows no spelling of an allowlisted command worked at all.
|
|
233
|
-
expect(plan("npm").reason).not.toBe("command_not_allowlisted");
|
|
234
|
-
expect(plan("npm.cmd").reason).not.toBe("command_not_allowlisted");
|
|
235
|
-
expect(plan("C:\\Program Files\\nodejs\\npm.cmd").reason).not.toBe("command_not_allowlisted");
|
|
236
|
-
});
|
|
237
|
-
it("still denies a shell however it is spelled", () => {
|
|
238
|
-
// Normalisation must apply to the deny list too, or stripping the extension
|
|
239
|
-
// would have turned it into a bypass.
|
|
240
|
-
for (const command of [
|
|
241
|
-
"powershell",
|
|
242
|
-
"powershell.exe",
|
|
243
|
-
"PowerShell.EXE",
|
|
244
|
-
"C:\\Windows\\System32\\cmd.exe",
|
|
245
|
-
"pwsh.exe",
|
|
246
|
-
"/bin/bash",
|
|
247
|
-
]) {
|
|
248
|
-
expect(plan(command).reason).toBe("command_not_allowlisted");
|
|
249
|
-
}
|
|
250
|
-
});
|
|
251
|
-
it("does not strip a suffix that is merely part of the name", () => {
|
|
252
|
-
// `python3.11` ends in a dot-segment but not an executable suffix.
|
|
253
|
-
expect(plan("python3.11").reason).toBe("command_not_allowlisted");
|
|
254
|
-
});
|
|
255
|
-
it("classifies a Windows-spelled read command without demanding approval", () => {
|
|
256
|
-
expect(plan("git.exe", ["status"]).effect).toBe("read");
|
|
257
|
-
expect(plan("git.exe", ["push"]).effect).toBe("mutate");
|
|
258
|
-
expect(plan("npm.cmd", ["install"]).risk).toBe("high");
|
|
259
|
-
});
|
|
260
|
-
});
|
|
261
|
-
describe("interactive terminal input", () => {
|
|
262
|
-
const send = (jobId) => evaluatePolicy("00000000-0000-4000-8000-000000000000", TaskRequestSchema.parse({
|
|
263
|
-
goal: "Answer a prompt",
|
|
264
|
-
operation: {
|
|
265
|
-
kind: "terminal",
|
|
266
|
-
action: "send",
|
|
267
|
-
...(jobId === undefined ? {} : { jobId }),
|
|
268
|
-
input: "y",
|
|
269
|
-
},
|
|
270
|
-
// What `defaultEvidenceFor` supplies on the controller's path; spelled
|
|
271
|
-
// out here because `evaluatePolicy` does not derive it.
|
|
272
|
-
requiredEvidence: [{ type: "result_equals", path: "sent", value: true }],
|
|
273
|
-
}), createDefaultPolicy(root));
|
|
274
|
-
it("gates on the job id, since the command was allowlisted at start", () => {
|
|
275
|
-
// `send` carries no command, so the allowlist has nothing to match. Falling
|
|
276
|
-
// through to the command branch would deny every prompt answer outright.
|
|
277
|
-
expect(send().decision.reason).toBe("command_not_allowlisted");
|
|
278
|
-
const decision = send("d1a4c4de-1b0e-4e17-9f4d-2f5c9b6a71e2").decision;
|
|
279
|
-
expect(decision.outcome).toBe("confirm");
|
|
280
|
-
expect(decision.effect).toBe("mutate");
|
|
281
|
-
});
|
|
282
|
-
it("derives the post-condition the runtime actually reports", () => {
|
|
283
|
-
// Without this, a `send` with no declared evidence is denied for missing
|
|
284
|
-
// evidence and interactive commands are unusable through the default path.
|
|
285
|
-
expect(defaultEvidenceFor({
|
|
286
|
-
kind: "terminal",
|
|
287
|
-
action: "send",
|
|
288
|
-
jobId: "d1a4c4de-1b0e-4e17-9f4d-2f5c9b6a71e2",
|
|
289
|
-
input: "y",
|
|
290
|
-
interactive: false,
|
|
291
|
-
appendNewline: true,
|
|
292
|
-
timeoutMs: 30_000,
|
|
293
|
-
maxOutputChars: 100_000,
|
|
294
|
-
})).toEqual([{ type: "result_equals", path: "sent", value: true }]);
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
describe("unhinged mode", () => {
|
|
298
|
-
const unhinged = { ...createDefaultPolicy(root), unhinged: true };
|
|
299
|
-
const plan = (request, policy = unhinged) => evaluatePolicy("00000000-0000-4000-8000-000000000000", TaskRequestSchema.parse(request), policy);
|
|
300
|
-
it("is off unless something explicitly turns it on", () => {
|
|
301
|
-
expect(createDefaultPolicy(root).unhinged).toBe(false);
|
|
302
|
-
});
|
|
303
|
-
it("allows a shell, an unlisted command, and evidence-free destruction", () => {
|
|
304
|
-
for (const operation of [
|
|
305
|
-
{ kind: "terminal", action: "run", command: "bash", args: ["-c", "id"] },
|
|
306
|
-
{ kind: "terminal", action: "run", command: "curl", args: ["example.com"] },
|
|
307
|
-
{ kind: "file", action: "delete", path: "everything" },
|
|
308
|
-
]) {
|
|
309
|
-
const result = plan({ goal: "Do it anyway", operation });
|
|
310
|
-
expect(result.decision.outcome).toBe("allow");
|
|
311
|
-
expect(result.decision.reason).toBe("unhinged_mode_no_guardrails");
|
|
312
|
-
// No challenge means no approval step: nothing to echo back, nothing to
|
|
313
|
-
// expire. That is the whole point of the mode and the whole danger of it.
|
|
314
|
-
expect(result.challenge).toBeUndefined();
|
|
315
|
-
}
|
|
316
|
-
});
|
|
317
|
-
it("reports the real effect and risk rather than flattening them", () => {
|
|
318
|
-
// Allowing everything is not the same as pretending everything is harmless:
|
|
319
|
-
// the receipt still has to say a destructive operation was destructive.
|
|
320
|
-
const result = plan({
|
|
321
|
-
goal: "Delete it",
|
|
322
|
-
operation: { kind: "file", action: "delete", path: "everything" },
|
|
323
|
-
});
|
|
324
|
-
expect(result.decision.effect).toBe("destructive");
|
|
325
|
-
expect(result.decision.risk).toBe("high");
|
|
326
|
-
});
|
|
327
|
-
it("still honours limits the caller declared on its own request", () => {
|
|
328
|
-
// `forbiddenEffects` and `constraints` are the caller bounding its own task,
|
|
329
|
-
// not MELRA imposing a guardrail, so unhinged mode does not overrule them.
|
|
330
|
-
expect(plan({
|
|
331
|
-
goal: "Delete it",
|
|
332
|
-
operation: { kind: "file", action: "delete", path: "everything" },
|
|
333
|
-
forbiddenEffects: ["destructive"],
|
|
334
|
-
}).decision.reason).toBe("effect_forbidden_by_request");
|
|
335
|
-
expect(plan({
|
|
336
|
-
goal: "Delete it",
|
|
337
|
-
operation: { kind: "file", action: "delete", path: "everything" },
|
|
338
|
-
constraints: ["be careful"],
|
|
339
|
-
}).decision.reason).toBe("freeform_constraints_not_enforceable");
|
|
340
|
-
});
|
|
341
|
-
it("changes nothing for a policy that did not ask for it", () => {
|
|
342
|
-
expect(plan({
|
|
343
|
-
goal: "Run a shell",
|
|
344
|
-
operation: { kind: "terminal", action: "run", command: "bash" },
|
|
345
|
-
requiredEvidence: [{ type: "exit_code", value: 0 }],
|
|
346
|
-
}, createDefaultPolicy(root)).decision.reason).toBe("command_not_allowlisted");
|
|
347
|
-
});
|
|
348
|
-
});
|
|
349
|
-
describe("capability traits", () => {
|
|
350
|
-
const terminal = (command, args = []) => classifyOperation({
|
|
351
|
-
kind: "terminal",
|
|
352
|
-
action: "run",
|
|
353
|
-
command,
|
|
354
|
-
args,
|
|
355
|
-
env: {},
|
|
356
|
-
timeoutMs: 30_000,
|
|
357
|
-
maxOutputChars: 10_000,
|
|
358
|
-
});
|
|
359
|
-
it("tells a package manager's subcommands apart", () => {
|
|
360
|
-
// The old rule was the command's name alone, so all three of these were
|
|
361
|
-
// high-risk mutations — including the one that only reads.
|
|
362
|
-
expect(terminal("npm", ["ls"]).effect).toBe("read");
|
|
363
|
-
expect(terminal("npm", ["run", "build"])).toMatchObject({
|
|
364
|
-
effect: "mutate",
|
|
365
|
-
risk: "medium",
|
|
366
|
-
traits: [],
|
|
367
|
-
});
|
|
368
|
-
expect(terminal("npm", ["install", "left-pad"])).toMatchObject({
|
|
369
|
-
effect: "mutate",
|
|
370
|
-
risk: "high",
|
|
371
|
-
traits: ["package-install", "network"],
|
|
372
|
-
});
|
|
373
|
-
});
|
|
374
|
-
it("reads past flags to find the subcommand", () => {
|
|
375
|
-
expect(classifyCommand("npm", ["--silent", "install"]).traits).toContain("package-install");
|
|
376
|
-
expect(classifyCommand("git", ["-c", "core.pager=cat", "push"])).toEqual({
|
|
377
|
-
read: false,
|
|
378
|
-
traits: ["network"],
|
|
379
|
-
});
|
|
380
|
-
});
|
|
381
|
-
it("marks commands that reach another host", () => {
|
|
382
|
-
expect(classifyCommand("curl", ["https://example.com"]).traits).toEqual([
|
|
383
|
-
"network",
|
|
384
|
-
]);
|
|
385
|
-
expect(classifyCommand("git", ["status"])).toEqual({
|
|
386
|
-
read: true,
|
|
387
|
-
traits: [],
|
|
388
|
-
});
|
|
389
|
-
expect(classifyCommand("npx", ["cowsay"]).traits).toEqual([
|
|
390
|
-
"package-install",
|
|
391
|
-
"network",
|
|
392
|
-
]);
|
|
393
|
-
// Browsing is a network act too, so denying `network` denies it as well.
|
|
394
|
-
expect(classifyOperation({
|
|
395
|
-
kind: "browser",
|
|
396
|
-
action: "navigate",
|
|
397
|
-
url: "https://example.com",
|
|
398
|
-
}).traits).toEqual(["network"]);
|
|
399
|
-
});
|
|
400
|
-
it("refuses a denied trait before the allowlist is consulted", () => {
|
|
401
|
-
const policy = createDefaultPolicy(root);
|
|
402
|
-
policy.deniedTraits = ["package-install"];
|
|
403
|
-
const run = (args) => evaluatePolicy("4c5a0130-71a5-4c48-b22d-c7901f12688f", TaskRequestSchema.parse({
|
|
404
|
-
goal: "Use npm",
|
|
405
|
-
operation: { kind: "terminal", action: "run", command: "npm", args },
|
|
406
|
-
requiredEvidence: [{ type: "exit_code", value: 0 }],
|
|
407
|
-
}), policy).decision;
|
|
408
|
-
// `allowedCommands` matches on the basename, so it cannot express this.
|
|
409
|
-
expect(run(["install", "left-pad"])).toMatchObject({
|
|
410
|
-
outcome: "deny",
|
|
411
|
-
reason: "trait_denied:package-install",
|
|
412
|
-
});
|
|
413
|
-
expect(run(["run", "test"]).outcome).toBe("confirm");
|
|
414
|
-
});
|
|
415
|
-
it("allows a denied trait when there are no guardrails at all", () => {
|
|
416
|
-
const policy = { ...createDefaultPolicy(root), unhinged: true };
|
|
417
|
-
policy.deniedTraits = ["network"];
|
|
418
|
-
expect(evaluatePolicy("4c5a0130-71a5-4c48-b22d-c7901f12688f", TaskRequestSchema.parse({
|
|
419
|
-
goal: "Fetch a page",
|
|
420
|
-
operation: { kind: "browser", action: "navigate", url: "https://example.com" },
|
|
421
|
-
}), policy).decision.reason).toBe("unhinged_mode_no_guardrails");
|
|
422
|
-
});
|
|
423
|
-
});
|
|
424
|
-
describe("capability grants", () => {
|
|
425
|
-
const taskId = "4c5a0130-71a5-4c48-b22d-c7901f12688f";
|
|
426
|
-
const identity = {
|
|
427
|
-
principal: { kind: "agent", id: "claude-code" },
|
|
428
|
-
onBehalfOf: [
|
|
429
|
-
{ kind: "organization", id: "acme" },
|
|
430
|
-
{ kind: "human", id: "dheeraj" },
|
|
431
|
-
],
|
|
432
|
-
};
|
|
433
|
-
const read = (extra = {}) => TaskRequestSchema.parse({
|
|
434
|
-
goal: "Read the changelog",
|
|
435
|
-
operation: { kind: "file", action: "read", path: "CHANGELOG.md" },
|
|
436
|
-
...extra,
|
|
437
|
-
});
|
|
438
|
-
const grant = (overrides = {}) => ({
|
|
439
|
-
id: "g1",
|
|
440
|
-
capability: "file.*",
|
|
441
|
-
effects: ["read"],
|
|
442
|
-
target: "*",
|
|
443
|
-
principal: "agent:claude-code",
|
|
444
|
-
...overrides,
|
|
445
|
-
});
|
|
446
|
-
const decide = (request, capabilities) => evaluatePolicy(taskId, request, {
|
|
447
|
-
...createDefaultPolicy(root),
|
|
448
|
-
capabilities: capabilities,
|
|
449
|
-
}).decision;
|
|
450
|
-
it("leaves policy alone when no grants are issued", () => {
|
|
451
|
-
expect(decide(read({ identity }), []).outcome).toBe("allow");
|
|
452
|
-
});
|
|
453
|
-
it("denies an effect no grant covers", () => {
|
|
454
|
-
expect(decide(read({ identity }), [grant({ capability: "browser.*" })])).toMatchObject({
|
|
455
|
-
outcome: "deny",
|
|
456
|
-
reason: "capability_not_granted:file.read",
|
|
457
|
-
});
|
|
458
|
-
});
|
|
459
|
-
it("matches the capability, effect, target, and holder", () => {
|
|
460
|
-
expect(decide(read({ identity }), [grant()]).outcome).toBe("allow");
|
|
461
|
-
// Same grant, different holder.
|
|
462
|
-
expect(decide(read({ identity }), [grant({ principal: "agent:other" })]).reason).toBe("capability_not_granted:file.read");
|
|
463
|
-
// Same holder, target outside the grant.
|
|
464
|
-
expect(decide(read({ identity }), [grant({ target: "/elsewhere/*" })]).reason).toBe("capability_not_granted:file.read");
|
|
465
|
-
});
|
|
466
|
-
it("refuses a grant that has expired or names another policy version", () => {
|
|
467
|
-
expect(decide(read({ identity }), [grant({ validUntil: "2020-01-01T00:00:00.000Z" })])).toMatchObject({ outcome: "deny", reason: "capability_expired:g1" });
|
|
468
|
-
expect(decide(read({ identity }), [grant({ policyVersion: "99" })]).reason).toBe("capability_policy_version_mismatch:g1");
|
|
469
|
-
});
|
|
470
|
-
it("falls back to the local principal when the caller declares none", () => {
|
|
471
|
-
expect(decide(read(), [grant({ principal: "agent:local" })]).outcome).toBe("allow");
|
|
472
|
-
expect(decide(read(), [grant()]).reason).toBe("capability_not_granted:file.read");
|
|
473
|
-
});
|
|
474
|
-
it("grants nothing when there are no guardrails at all", () => {
|
|
475
|
-
expect(evaluatePolicy(taskId, read({ identity }), {
|
|
476
|
-
...createDefaultPolicy(root),
|
|
477
|
-
unhinged: true,
|
|
478
|
-
capabilities: [grant({ capability: "browser.*" })],
|
|
479
|
-
}).decision.reason).toBe("unhinged_mode_no_guardrails");
|
|
480
|
-
});
|
|
481
|
-
});
|
|
482
|
-
describe("http destinations", () => {
|
|
483
|
-
const request = (fields) => TaskRequestSchema.parse({
|
|
484
|
-
goal: "Call an API",
|
|
485
|
-
operation: { kind: "http", action: "request", ...fields },
|
|
486
|
-
requiredEvidence: [{ type: "result_equals", path: "success", value: true }],
|
|
487
|
-
});
|
|
488
|
-
const taskId = "b4b6f2a1-6f2e-4d5a-9c3e-8a1d0f7c2b34";
|
|
489
|
-
it("classifies by method, not by kind", () => {
|
|
490
|
-
expect(classifyOperation({
|
|
491
|
-
kind: "http",
|
|
492
|
-
action: "request",
|
|
493
|
-
method: "GET",
|
|
494
|
-
url: "https://api.example.com/v1/orders?page=2",
|
|
495
|
-
maxResponseBytes: 1_000,
|
|
496
|
-
timeoutMs: 1_000,
|
|
497
|
-
})).toMatchObject({
|
|
498
|
-
effect: "read",
|
|
499
|
-
capability: "http.get",
|
|
500
|
-
// The query is dropped so a capability pattern matches the resource, not
|
|
501
|
-
// whatever token happened to be stapled to the URL.
|
|
502
|
-
target: "https://api.example.com/v1/orders",
|
|
503
|
-
traits: ["network"],
|
|
504
|
-
});
|
|
505
|
-
expect(classifyOperation({
|
|
506
|
-
kind: "http",
|
|
507
|
-
action: "request",
|
|
508
|
-
method: "POST",
|
|
509
|
-
url: "https://api.example.com/v1/orders",
|
|
510
|
-
maxResponseBytes: 1_000,
|
|
511
|
-
timeoutMs: 1_000,
|
|
512
|
-
})).toMatchObject({ effect: "mutate", capability: "http.post" });
|
|
513
|
-
});
|
|
514
|
-
it("holds an HTTP mutation to the same evidence rule as any other", () => {
|
|
515
|
-
const missing = TaskRequestSchema.parse({
|
|
516
|
-
goal: "Create an order",
|
|
517
|
-
operation: {
|
|
518
|
-
kind: "http",
|
|
519
|
-
action: "request",
|
|
520
|
-
method: "POST",
|
|
521
|
-
url: "https://api.example.com/v1/orders",
|
|
522
|
-
},
|
|
523
|
-
});
|
|
524
|
-
expect(evaluatePolicy(taskId, missing, createDefaultPolicy(root)).decision.reason).toBe("mutation_requires_evidence");
|
|
525
|
-
expect(evaluatePolicy(taskId, request({ method: "POST", url: "https://api.example.com/v1/orders" }), createDefaultPolicy(root)).decision.outcome).toBe("confirm");
|
|
526
|
-
});
|
|
527
|
-
it("applies the domain allowlist to HTTP, not only to the browser", () => {
|
|
528
|
-
const policy = {
|
|
529
|
-
...createDefaultPolicy(root),
|
|
530
|
-
allowedDomains: ["api.example.com"],
|
|
531
|
-
};
|
|
532
|
-
expect(evaluatePolicy(taskId, request({ url: "https://api.example.com/v1/orders" }), policy).decision.outcome).toBe("allow");
|
|
533
|
-
expect(evaluatePolicy(taskId, request({ url: "https://attacker.test/" }), policy)
|
|
534
|
-
.decision.reason).toBe("destination_domain_not_allowed");
|
|
535
|
-
});
|
|
536
|
-
it("refuses an HTTP call when the network trait is denied", () => {
|
|
537
|
-
expect(evaluatePolicy(taskId, request({ url: "https://api.example.com/" }), {
|
|
538
|
-
...createDefaultPolicy(root),
|
|
539
|
-
deniedTraits: ["network"],
|
|
540
|
-
}).decision.reason).toBe("trait_denied:network");
|
|
541
|
-
});
|
|
542
|
-
it("derives the response-status post-condition for a mutation", () => {
|
|
543
|
-
expect(defaultEvidenceFor({
|
|
544
|
-
kind: "http",
|
|
545
|
-
action: "request",
|
|
546
|
-
method: "POST",
|
|
547
|
-
url: "https://api.example.com/v1/orders",
|
|
548
|
-
maxResponseBytes: 1_000,
|
|
549
|
-
timeoutMs: 1_000,
|
|
550
|
-
})).toEqual([{ type: "result_equals", path: "success", value: true }]);
|
|
551
|
-
});
|
|
552
|
-
});
|
|
553
|
-
describe("metered capability grants", () => {
|
|
554
|
-
const taskId = "9f1d5b3a-4c2e-4a71-9d80-2b6f0c4e18aa";
|
|
555
|
-
const spend = (amount) => TaskRequestSchema.parse({
|
|
556
|
-
goal: "Refund a charge",
|
|
557
|
-
operation: {
|
|
558
|
-
kind: "http",
|
|
559
|
-
action: "request",
|
|
560
|
-
method: "POST",
|
|
561
|
-
url: "https://api.acme-pay.test/v1/refunds",
|
|
562
|
-
spend: { provider: "acme-pay", amount, currency: "USD" },
|
|
563
|
-
},
|
|
564
|
-
requiredEvidence: [{ type: "result_equals", path: "success", value: true }],
|
|
565
|
-
});
|
|
566
|
-
const policyWith = (grant) => ({
|
|
567
|
-
...createDefaultPolicy(root),
|
|
568
|
-
capabilities: [
|
|
569
|
-
{
|
|
570
|
-
id: "refunds",
|
|
571
|
-
capability: "http.post",
|
|
572
|
-
effects: ["mutate"],
|
|
573
|
-
target: "*",
|
|
574
|
-
principal: "*",
|
|
575
|
-
...grant,
|
|
576
|
-
},
|
|
577
|
-
],
|
|
578
|
-
});
|
|
579
|
-
/** A grant that has already drawn down this much. */
|
|
580
|
-
const drawn = (operations, amountToday) => () => ({
|
|
581
|
-
operations,
|
|
582
|
-
amountToday,
|
|
583
|
-
});
|
|
584
|
-
it("carries the grant it authorised against, so the commit knows what to meter", () => {
|
|
585
|
-
expect(evaluatePolicy(taskId, spend(1_000), policyWith({ maxOperations: 5, provider: { name: "acme-pay" } }), drawn(0, 0)).grantId).toBe("refunds");
|
|
586
|
-
});
|
|
587
|
-
it("names the missing declaration rather than a missing grant", () => {
|
|
588
|
-
// The grant covers `http.post` for this principal and is turned away on the
|
|
589
|
-
// money block alone. `capability_not_granted` would send the caller looking
|
|
590
|
-
// for a grant it already holds; these two say which fix applies.
|
|
591
|
-
const undeclared = TaskRequestSchema.parse({
|
|
592
|
-
goal: "Refund without saying what it moves",
|
|
593
|
-
operation: {
|
|
594
|
-
kind: "http",
|
|
595
|
-
action: "request",
|
|
596
|
-
method: "POST",
|
|
597
|
-
url: "https://api.acme-pay.test/v1/refunds",
|
|
598
|
-
},
|
|
599
|
-
requiredEvidence: [{ type: "result_equals", path: "success", value: true }],
|
|
600
|
-
});
|
|
601
|
-
const policy = policyWith({ provider: { name: "acme-pay" } });
|
|
602
|
-
expect(evaluatePolicy(taskId, undeclared, policy).decision.reason).toBe("capability_spend_not_declared:http.post");
|
|
603
|
-
expect(evaluatePolicy(taskId, spend(100), policyWith({ provider: { name: "other-pay" } }))
|
|
604
|
-
.decision.reason).toBe("capability_provider_mismatch:acme-pay");
|
|
605
|
-
});
|
|
606
|
-
it("refuses when the rolling daily total would go over", () => {
|
|
607
|
-
// exhausted is the day, which no other bound can see.
|
|
608
|
-
expect(evaluatePolicy(taskId, spend(3_000), policyWith({
|
|
609
|
-
provider: { name: "acme-pay", amountMax: 5_000, dailyMax: 8_000 },
|
|
610
|
-
}), drawn(2, 6_000)).decision.reason).toBe("capability_daily_limit_exceeded:refunds");
|
|
611
|
-
});
|
|
612
|
-
it("refuses a metered grant when nothing can count it", () => {
|
|
613
|
-
// No reader. A budget nobody can count is not a budget, and treating an
|
|
614
|
-
// uncountable one as full would make the metered grant the loosest kind.
|
|
615
|
-
expect(evaluatePolicy(taskId, spend(1_000), policyWith({ maxOperations: 1 }))
|
|
616
|
-
.decision.reason).toBe("capability_usage_unmeterable:refunds");
|
|
617
|
-
});
|
|
618
|
-
it("spends an exhausted grant's sibling rather than refusing", () => {
|
|
619
|
-
// Two grants, one spent out. A grant list is a set of authorities, not a
|
|
620
|
-
// single budget.
|
|
621
|
-
const policy = {
|
|
622
|
-
...createDefaultPolicy(root),
|
|
623
|
-
capabilities: [
|
|
624
|
-
{
|
|
625
|
-
id: "small",
|
|
626
|
-
capability: "http.post",
|
|
627
|
-
effects: ["mutate"],
|
|
628
|
-
target: "*",
|
|
629
|
-
principal: "*",
|
|
630
|
-
maxOperations: 1,
|
|
631
|
-
},
|
|
632
|
-
{
|
|
633
|
-
id: "large",
|
|
634
|
-
capability: "http.post",
|
|
635
|
-
effects: ["mutate"],
|
|
636
|
-
target: "*",
|
|
637
|
-
principal: "*",
|
|
638
|
-
maxOperations: 100,
|
|
639
|
-
},
|
|
640
|
-
],
|
|
641
|
-
};
|
|
642
|
-
const evaluation = evaluatePolicy(taskId, spend(1_000), policy, (id) => ({
|
|
643
|
-
operations: id === "small" ? 1 : 0,
|
|
644
|
-
amountToday: 0,
|
|
645
|
-
}));
|
|
646
|
-
expect(evaluation.decision.outcome).toBe("confirm");
|
|
647
|
-
expect(evaluation.grantId).toBe("large");
|
|
648
|
-
});
|
|
649
|
-
it("leaves an unmetered grant alone", () => {
|
|
650
|
-
// No `maxOperations`, no `provider` — nothing to count, so nothing needs a
|
|
651
|
-
// reader and the grant behaves exactly as it did before metering existed.
|
|
652
|
-
expect(evaluatePolicy(taskId, spend(1_000), policyWith({})).decision.outcome).toBe("confirm");
|
|
653
|
-
});
|
|
654
|
-
});
|
|
655
|
-
//# sourceMappingURL=index.test.js.map
|