@melra/policy-core 0.3.0-alpha.10 → 0.3.0-alpha.12
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 +19 -3
- package/dist/credentials.d.ts +29 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +112 -0
- package/dist/credentials.js.map +1 -0
- package/dist/index.d.ts +78 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +157 -28
- package/dist/index.js.map +1 -1
- package/dist/network-policy.d.ts +30 -0
- package/dist/network-policy.d.ts.map +1 -0
- package/dist/network-policy.js +130 -0
- package/dist/network-policy.js.map +1 -0
- package/package.json +3 -3
- package/dist/index.test.d.ts +0 -2
- package/dist/index.test.d.ts.map +0 -1
- package/dist/index.test.js +0 -482
- package/dist/index.test.js.map +0 -1
package/README.md
CHANGED
|
@@ -30,12 +30,22 @@ never reaches a runtime.
|
|
|
30
30
|
- Terminal commands must be allowlisted by basename. Shell interpreters and
|
|
31
31
|
`sudo`/`su` are denied unconditionally. `git` counts as a read only for a small
|
|
32
32
|
read-only subcommand set; `npm`, `npx`, and `pnpm` are high-risk mutations.
|
|
33
|
-
- Browser destinations default to `allowedDomains: ["*"]` with localhost
|
|
34
|
-
because the allowlist is a narrowing control, not the safety boundary
|
|
35
|
-
|
|
33
|
+
- Browser and HTTP destinations default to `allowedDomains: ["*"]` with localhost
|
|
34
|
+
allowed, because the allowlist is a narrowing control, not the safety boundary
|
|
35
|
+
— `assertSafeDestination`, exported from this package, is what blocks private
|
|
36
|
+
and metadata destinations, and it holds even with `allowedDomains: ["*"]`.
|
|
36
37
|
- `classifyOperation` is the single place effect and risk are decided. An action
|
|
37
38
|
that is not classified there is mis-classified, not unclassified.
|
|
38
39
|
|
|
40
|
+
## The destination boundary
|
|
41
|
+
|
|
42
|
+
`assertSafeDestination` lives here rather than in a runtime so every adapter that
|
|
43
|
+
opens a socket shares one implementation. It rejects non-`http(s)` protocols and
|
|
44
|
+
URL credentials, resolves DNS first and checks **every** answer, and blocks
|
|
45
|
+
private, loopback, link-local, and cloud-metadata ranges in IPv4, IPv6, and
|
|
46
|
+
IPv4-mapped IPv6 form. It returns the address it checked so the caller can pin
|
|
47
|
+
the socket to it instead of letting the name resolve a second time.
|
|
48
|
+
|
|
39
49
|
## Capability grants
|
|
40
50
|
|
|
41
51
|
`policy.capabilities` is authority; everything above is rules. It is empty by
|
|
@@ -72,6 +82,12 @@ rules it was not written for.
|
|
|
72
82
|
caller's own `forbiddenEffects` and `constraints` are honoured, because those are
|
|
73
83
|
the caller bounding its own task rather than a guardrail MELRA imposes.
|
|
74
84
|
|
|
85
|
+
`mode` is `developer` or `enforced` and is not about a request at all: it records
|
|
86
|
+
how this kernel is deployed, and therefore what it refuses about its own setup
|
|
87
|
+
rather than about a caller. `deploymentMode` parses it from the flag, the
|
|
88
|
+
environment, and the policy file through one function, so a typo fails loudly
|
|
89
|
+
instead of leaving a machine permissive with a config that claims otherwise.
|
|
90
|
+
|
|
75
91
|
Requires Node.js 22 or newer. Full documentation:
|
|
76
92
|
[github.com/XAGI-Lab/melra](https://github.com/XAGI-Lab/melra)
|
|
77
93
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CredentialDefinition } from "@melra/protocol";
|
|
2
|
+
export interface BrokeredHeaders {
|
|
3
|
+
/** Headers to merge into the outgoing request, lowercased. */
|
|
4
|
+
headers: Record<string, string>;
|
|
5
|
+
/** Names of the credentials that were injected — never their values. */
|
|
6
|
+
used: string[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Holds the secrets so the agent does not have to.
|
|
10
|
+
*
|
|
11
|
+
* An adapter asks for the headers one operation may carry and gets back either
|
|
12
|
+
* a header it can send or nothing at all. It never receives the secret as a
|
|
13
|
+
* value it could log, return, or persist, and the broker never writes one down:
|
|
14
|
+
* every call re-reads the source, so rotating a key takes effect on the next
|
|
15
|
+
* request rather than on the next restart.
|
|
16
|
+
*/
|
|
17
|
+
export declare class CredentialBroker {
|
|
18
|
+
private readonly definitions;
|
|
19
|
+
private readonly environment;
|
|
20
|
+
constructor(definitions?: Record<string, CredentialDefinition>, environment?: NodeJS.ProcessEnv);
|
|
21
|
+
/** True when nothing is configured, so a caller can skip the await. */
|
|
22
|
+
get empty(): boolean;
|
|
23
|
+
headersFor(request: {
|
|
24
|
+
host: string;
|
|
25
|
+
capability: string;
|
|
26
|
+
target: string;
|
|
27
|
+
}): Promise<BrokeredHeaders>;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=credentials.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAE5D,MAAM,WAAW,eAAe;IAC9B,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,wEAAwE;IACxE,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB;AA4DD;;;;;;;;GAQG;AACH,qBAAa,gBAAgB;IAEzB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,WAAW;gBADX,WAAW,GAAE,MAAM,CAAC,MAAM,EAAE,oBAAoB,CAAM,EACtD,WAAW,GAAE,MAAM,CAAC,UAAwB;IAG/D,uEAAuE;IACvE,IAAI,KAAK,IAAI,OAAO,CAEnB;IAEK,UAAU,CAAC,OAAO,EAAE;QACxB,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC;QACnB,MAAM,EAAE,MAAM,CAAC;KAChB,GAAG,OAAO,CAAC,eAAe,CAAC;CAkC7B"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Copyright 2026 XAGI Labs Private Limited
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { lstat, open } from "node:fs/promises";
|
|
5
|
+
function patternMatches(pattern, value) {
|
|
6
|
+
if (pattern === "*")
|
|
7
|
+
return true;
|
|
8
|
+
const escaped = pattern
|
|
9
|
+
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
10
|
+
.replaceAll("\\*", ".*");
|
|
11
|
+
return new RegExp(`^${escaped}$`).test(value);
|
|
12
|
+
}
|
|
13
|
+
function errorCode(error) {
|
|
14
|
+
return error !== null && typeof error === "object" && "code" in error
|
|
15
|
+
? String(error.code)
|
|
16
|
+
: undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Read a secret out of a file the same way the payload key is read: refuse a
|
|
20
|
+
* symlink, refuse anything but a regular file, and refuse a mode any other user
|
|
21
|
+
* can read. A secret in a world-readable file is one the agent could have read
|
|
22
|
+
* for itself, which would make the whole broker decorative.
|
|
23
|
+
*/
|
|
24
|
+
async function readSecretFile(path) {
|
|
25
|
+
const before = await lstat(path);
|
|
26
|
+
if (before.isSymbolicLink()) {
|
|
27
|
+
throw new Error("credential_file_must_not_be_symlink");
|
|
28
|
+
}
|
|
29
|
+
let handle;
|
|
30
|
+
try {
|
|
31
|
+
handle = await open(path, constants.O_RDONLY |
|
|
32
|
+
(process.platform === "win32" ? 0 : constants.O_NOFOLLOW));
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (errorCode(error) === "ELOOP") {
|
|
36
|
+
throw new Error("credential_file_must_not_be_symlink");
|
|
37
|
+
}
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const metadata = await handle.stat();
|
|
42
|
+
if (!metadata.isFile() ||
|
|
43
|
+
metadata.dev !== before.dev ||
|
|
44
|
+
metadata.ino !== before.ino) {
|
|
45
|
+
throw new Error("credential_file_not_regular_file");
|
|
46
|
+
}
|
|
47
|
+
if (process.platform !== "win32" && (metadata.mode & 0o077) !== 0) {
|
|
48
|
+
throw new Error("credential_file_permissions_too_open");
|
|
49
|
+
}
|
|
50
|
+
const secret = (await handle.readFile("utf8")).trim();
|
|
51
|
+
if (secret === "")
|
|
52
|
+
throw new Error("credential_file_empty");
|
|
53
|
+
return secret;
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await handle.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Holds the secrets so the agent does not have to.
|
|
61
|
+
*
|
|
62
|
+
* An adapter asks for the headers one operation may carry and gets back either
|
|
63
|
+
* a header it can send or nothing at all. It never receives the secret as a
|
|
64
|
+
* value it could log, return, or persist, and the broker never writes one down:
|
|
65
|
+
* every call re-reads the source, so rotating a key takes effect on the next
|
|
66
|
+
* request rather than on the next restart.
|
|
67
|
+
*/
|
|
68
|
+
export class CredentialBroker {
|
|
69
|
+
definitions;
|
|
70
|
+
environment;
|
|
71
|
+
constructor(definitions = {}, environment = process.env) {
|
|
72
|
+
this.definitions = definitions;
|
|
73
|
+
this.environment = environment;
|
|
74
|
+
}
|
|
75
|
+
/** True when nothing is configured, so a caller can skip the await. */
|
|
76
|
+
get empty() {
|
|
77
|
+
return Object.keys(this.definitions).length === 0;
|
|
78
|
+
}
|
|
79
|
+
async headersFor(request) {
|
|
80
|
+
const headers = {};
|
|
81
|
+
const used = [];
|
|
82
|
+
const operation = `${request.capability}:${request.target}`;
|
|
83
|
+
const host = request.host.toLowerCase();
|
|
84
|
+
for (const [name, definition] of Object.entries(this.definitions)) {
|
|
85
|
+
if (!definition.hosts.some((pattern) => patternMatches(pattern.toLowerCase(), host))) {
|
|
86
|
+
// Not this credential's host. The request still goes out, just
|
|
87
|
+
// unauthenticated — which is what stops a URL the caller chose from
|
|
88
|
+
// walking somebody's bearer token to an origin it was never issued for.
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!patternMatches(definition.capability, operation)) {
|
|
92
|
+
// Refused before the source is touched. The credential could perform
|
|
93
|
+
// this call; the delegation did not say it may, and that gap is the
|
|
94
|
+
// entire difference between holding a credential and holding authority.
|
|
95
|
+
throw new Error(`credential_capability_not_covered:${name}`);
|
|
96
|
+
}
|
|
97
|
+
const secret = "env" in definition.source
|
|
98
|
+
? this.environment[definition.source.env]
|
|
99
|
+
: await readSecretFile(definition.source.file);
|
|
100
|
+
if (secret === undefined || secret === "") {
|
|
101
|
+
throw new Error(`credential_source_missing:${name}`);
|
|
102
|
+
}
|
|
103
|
+
headers[definition.inject.header.toLowerCase()] =
|
|
104
|
+
definition.inject.scheme === undefined
|
|
105
|
+
? secret
|
|
106
|
+
: `${definition.inject.scheme} ${secret}`;
|
|
107
|
+
used.push(name);
|
|
108
|
+
}
|
|
109
|
+
return { headers, used };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credentials.js","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApprovalChallenge, ApprovalResponse, CapabilityGrant, CapabilityTrait, Effect, EvidencePredicate, Identity, Operation, PolicyDecision, Risk, TaskRequest } from "@melra/protocol";
|
|
1
|
+
import type { ApprovalChallenge, ApprovalResponse, CapabilityGrant, CapabilityTrait, CredentialDefinition, DeploymentMode, Effect, EvidencePredicate, HttpOperation, Identity, Operation, PolicyDecision, Risk, TaskRequest } from "@melra/protocol";
|
|
2
2
|
export interface LocalPolicy {
|
|
3
3
|
version: string;
|
|
4
4
|
workspaceRoot: string;
|
|
@@ -53,6 +53,16 @@ export interface LocalPolicy {
|
|
|
53
53
|
* holding authority over one directory rather than over the whole workspace.
|
|
54
54
|
*/
|
|
55
55
|
capabilities: CapabilityGrant[];
|
|
56
|
+
/**
|
|
57
|
+
* Secrets the kernel injects on the caller's behalf, keyed by a name the
|
|
58
|
+
* caller may see in a receipt. Empty by default.
|
|
59
|
+
*
|
|
60
|
+
* The agent never holds one. It names an operation; if a definition's `hosts`
|
|
61
|
+
* cover the destination *and* its `capability` covers the operation, the
|
|
62
|
+
* kernel reads the secret at send time and puts it on the wire. See
|
|
63
|
+
* `CredentialBroker` for why those two lists behave differently on a miss.
|
|
64
|
+
*/
|
|
65
|
+
credentials: Record<string, CredentialDefinition>;
|
|
56
66
|
/**
|
|
57
67
|
* Stops a task from re-running an operation whose target keeps failing.
|
|
58
68
|
*
|
|
@@ -79,10 +89,32 @@ export interface LocalPolicy {
|
|
|
79
89
|
* derives it from another setting.
|
|
80
90
|
*/
|
|
81
91
|
unhinged: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* How this kernel is deployed, and therefore what it refuses about its own
|
|
94
|
+
* setup rather than about a caller's request.
|
|
95
|
+
*
|
|
96
|
+
* `developer` is the default and changes nothing: MELRA governs the effects it
|
|
97
|
+
* is asked for, and the operator accepts that the harness may have another way
|
|
98
|
+
* to the same disk. `enforced` is the operator asserting there is no other way
|
|
99
|
+
* — the harness is sandboxed, holds no privileged secrets, and reaches systems
|
|
100
|
+
* only through here. MELRA cannot verify that assertion, so what it does
|
|
101
|
+
* instead is remove every door it owns that would contradict it: no
|
|
102
|
+
* `--unsafe-local`, no bind outside loopback, no client registering itself.
|
|
103
|
+
*
|
|
104
|
+
* Named on every receipt, because "was this effect governed by the only door
|
|
105
|
+
* or by one of several" is not something an auditor should have to infer.
|
|
106
|
+
*/
|
|
107
|
+
mode: DeploymentMode;
|
|
82
108
|
}
|
|
83
109
|
export interface PolicyEvaluation {
|
|
84
110
|
decision: PolicyDecision;
|
|
85
111
|
challenge?: ApprovalChallenge;
|
|
112
|
+
/**
|
|
113
|
+
* The grant that authorised this, when `policy.capabilities` is non-empty.
|
|
114
|
+
* What the commit meters against — absent means no grants are configured and
|
|
115
|
+
* there is nothing to draw down.
|
|
116
|
+
*/
|
|
117
|
+
grantId?: string;
|
|
86
118
|
}
|
|
87
119
|
export declare function normalizeCommandName(command: string): string;
|
|
88
120
|
/**
|
|
@@ -103,6 +135,7 @@ export declare function normalizeCommandName(command: string): string;
|
|
|
103
135
|
* network at all, not "no network except the browser".
|
|
104
136
|
*/
|
|
105
137
|
export type { CapabilityTrait } from "@melra/protocol";
|
|
138
|
+
export { CredentialBroker, type BrokeredHeaders, } from "./credentials.js";
|
|
106
139
|
/**
|
|
107
140
|
* Classify a terminal command by what it does, not just by its name.
|
|
108
141
|
*
|
|
@@ -122,6 +155,8 @@ export declare function classifyOperation(operation: Operation): {
|
|
|
122
155
|
capability: string;
|
|
123
156
|
target: string;
|
|
124
157
|
traits: CapabilityTrait[];
|
|
158
|
+
/** What the caller declared this operation moves, when it declared one. */
|
|
159
|
+
spend?: DeclaredSpend;
|
|
125
160
|
};
|
|
126
161
|
/**
|
|
127
162
|
* Evidence a mutation must satisfy when the caller declared none.
|
|
@@ -140,16 +175,51 @@ export declare function classifyOperation(operation: Operation): {
|
|
|
140
175
|
*/
|
|
141
176
|
export declare function defaultEvidenceFor(operation: Operation): EvidencePredicate[];
|
|
142
177
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
178
|
+
* What a grant has already drawn down, read from wherever commits are counted.
|
|
179
|
+
*
|
|
180
|
+
* A lookup rather than a number because policy is evaluated more than once per
|
|
181
|
+
* task and a grant may be metered by any of several bounds; the caller that
|
|
182
|
+
* owns the durable count decides how to answer.
|
|
146
183
|
*/
|
|
147
|
-
|
|
184
|
+
/**
|
|
185
|
+
* What an operation declared it moves. Taken from the schema that defines it
|
|
186
|
+
* rather than restated, so a field added there cannot go unnoticed here.
|
|
187
|
+
*/
|
|
188
|
+
export type DeclaredSpend = NonNullable<HttpOperation["spend"]>;
|
|
189
|
+
export type CapabilityUsageReader = (grantId: string) => {
|
|
190
|
+
/** Operations committed against this grant, ever. */
|
|
191
|
+
operations: number;
|
|
192
|
+
/** Declared amounts committed against it in the last 24 hours. */
|
|
193
|
+
amountToday: number;
|
|
194
|
+
};
|
|
195
|
+
/** What the issued grants say about one effect. */
|
|
196
|
+
export interface CapabilityCheck {
|
|
197
|
+
/** Why they do not cover it, or `undefined` if they do. */
|
|
198
|
+
refusal?: string;
|
|
199
|
+
/** The grant it will be metered against, when a grant was needed at all. */
|
|
200
|
+
grantId?: string;
|
|
201
|
+
}
|
|
202
|
+
type ClassifiedForGrant = {
|
|
148
203
|
effect: Effect;
|
|
149
204
|
capability: string;
|
|
150
205
|
target: string;
|
|
151
|
-
|
|
152
|
-
|
|
206
|
+
spend?: DeclaredSpend;
|
|
207
|
+
};
|
|
208
|
+
/** Milliseconds in the rolling window `provider.dailyMax` is measured over. */
|
|
209
|
+
export declare const CAPABILITY_DAY_MS: number;
|
|
210
|
+
/**
|
|
211
|
+
* Which issued grant covers this effect, or why none does.
|
|
212
|
+
*
|
|
213
|
+
* An empty grant list is not a closed world — it means the operator has not
|
|
214
|
+
* issued grants at all, and the rest of policy decides on its own.
|
|
215
|
+
*/
|
|
216
|
+
export declare function capabilityCheck(classified: ClassifiedForGrant, identity: Identity, policy: LocalPolicy, usage?: CapabilityUsageReader): CapabilityCheck;
|
|
217
|
+
/**
|
|
218
|
+
* Why the issued capabilities do not cover this effect, or `undefined` if they
|
|
219
|
+
* do. `capabilityCheck` also reports which grant covered it.
|
|
220
|
+
*/
|
|
221
|
+
export declare function capabilityRefusal(classified: ClassifiedForGrant, identity: Identity, policy: LocalPolicy, usage?: CapabilityUsageReader): string | undefined;
|
|
222
|
+
export declare function evaluatePolicy(taskId: string, request: TaskRequest, policy: LocalPolicy, usage?: CapabilityUsageReader): PolicyEvaluation;
|
|
153
223
|
export declare function digestOperation(taskId: string, operation: Operation): string;
|
|
154
224
|
export declare function validateApproval(challenge: ApprovalChallenge | undefined, response: ApprovalResponse | undefined): {
|
|
155
225
|
ok: true;
|
|
@@ -157,4 +227,5 @@ export declare function validateApproval(challenge: ApprovalChallenge | undefine
|
|
|
157
227
|
ok: false;
|
|
158
228
|
reason: string;
|
|
159
229
|
};
|
|
230
|
+
export * from "./network-policy.js";
|
|
160
231
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { basename, resolve } from "node:path";
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
-
import {
|
|
6
|
+
import { hostOf } from "./network-policy.js";
|
|
7
|
+
import { CapabilityGrantSchema, CredentialsSchema, deploymentMode, LOCAL_IDENTITY, principalRef, } from "@melra/protocol";
|
|
7
8
|
const READ_ONLY_GIT_ACTIONS = new Set([
|
|
8
9
|
"branch",
|
|
9
10
|
"diff",
|
|
@@ -29,15 +30,15 @@ const ALWAYS_DENIED_COMMANDS = new Set([
|
|
|
29
30
|
* Defaults tuned so a fresh install is usable without editing `policy.json`.
|
|
30
31
|
*
|
|
31
32
|
* `allowedDomains: ["*"]` is not the SSRF boundary and never was. `assertSafeUrl`
|
|
32
|
-
* in
|
|
33
|
+
* in `./network-policy.js` independently rejects non-http(s) protocols, URL
|
|
33
34
|
* credentials, private ranges, and cloud metadata (169.254/16), and it resolves
|
|
34
35
|
* DNS before allowing a navigation so a public name cannot be rebound to a
|
|
35
36
|
* private address. The domain list is a *narrowing* control on top of that guard,
|
|
36
37
|
* 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
|
+
* what stops a browser or an HTTP call from reaching the loopback interface.
|
|
38
39
|
*
|
|
39
40
|
* Defaulting it to `[]` made every single navigation fail with
|
|
40
|
-
* `
|
|
41
|
+
* `destination_domain_not_allowed` out of the box, which is why browser use was
|
|
41
42
|
* reported as unusable. Operators who want an allowlist set one explicitly.
|
|
42
43
|
*/
|
|
43
44
|
/**
|
|
@@ -57,6 +58,7 @@ export function normalizeCommandName(command) {
|
|
|
57
58
|
const suffix = EXECUTABLE_SUFFIXES.find((item) => name.endsWith(item));
|
|
58
59
|
return suffix === undefined ? name : name.slice(0, -suffix.length);
|
|
59
60
|
}
|
|
61
|
+
export { CredentialBroker, } from "./credentials.js";
|
|
60
62
|
/**
|
|
61
63
|
* Subcommands that install, per package manager, and the ones that only read.
|
|
62
64
|
*
|
|
@@ -257,8 +259,10 @@ export function createDefaultPolicy(workspaceRoot) {
|
|
|
257
259
|
memoryRetention: { maxAgeDays: 30, maxPerScope: 0 },
|
|
258
260
|
deniedTraits: [],
|
|
259
261
|
capabilities: [],
|
|
262
|
+
credentials: {},
|
|
260
263
|
circuitBreaker: { threshold: 3, cooldownMs: 60_000 },
|
|
261
264
|
unhinged: false,
|
|
265
|
+
mode: "developer",
|
|
262
266
|
};
|
|
263
267
|
}
|
|
264
268
|
export async function loadPolicy(path, workspaceRoot) {
|
|
@@ -283,6 +287,16 @@ export async function loadPolicy(path, workspaceRoot) {
|
|
|
283
287
|
capabilities: parsed.capabilities === undefined
|
|
284
288
|
? defaults.capabilities
|
|
285
289
|
: CapabilityGrantSchema.array().max(200).parse(parsed.capabilities),
|
|
290
|
+
// Same reasoning as `capabilities`: a malformed credential definition must
|
|
291
|
+
// not land as an object whose `hosts` happen to match nothing — an operator
|
|
292
|
+
// would read that as "scoped" when it is "silently absent".
|
|
293
|
+
credentials: parsed.credentials === undefined
|
|
294
|
+
? defaults.credentials
|
|
295
|
+
: CredentialsSchema.parse(parsed.credentials),
|
|
296
|
+
// Through the same parser the environment variable and the flag go through,
|
|
297
|
+
// so `"mode": "enfroced"` in a policy file fails loudly instead of leaving a
|
|
298
|
+
// machine in developer mode with a file that claims otherwise.
|
|
299
|
+
mode: deploymentMode(parsed.mode),
|
|
286
300
|
};
|
|
287
301
|
}
|
|
288
302
|
export function classifyOperation(operation) {
|
|
@@ -412,6 +426,24 @@ export function classifyOperation(operation) {
|
|
|
412
426
|
traits: [],
|
|
413
427
|
};
|
|
414
428
|
}
|
|
429
|
+
case "http": {
|
|
430
|
+
// The method is the classification. A GET that a server treats as a write
|
|
431
|
+
// is that server misusing HTTP, and MELRA cannot see it — which is why a
|
|
432
|
+
// read still gets no approval but also no retry-into-a-duplicate: the
|
|
433
|
+
// guarantee comes from the effect, and the effect comes from here.
|
|
434
|
+
const read = operation.method === "GET" || operation.method === "HEAD";
|
|
435
|
+
const target = new URL(operation.url);
|
|
436
|
+
target.search = "";
|
|
437
|
+
target.hash = "";
|
|
438
|
+
return {
|
|
439
|
+
effect: read ? "read" : "mutate",
|
|
440
|
+
risk: read ? "low" : "medium",
|
|
441
|
+
capability: `http.${operation.method.toLowerCase()}`,
|
|
442
|
+
target: target.toString(),
|
|
443
|
+
traits: ["network"],
|
|
444
|
+
...(operation.spend === undefined ? {} : { spend: operation.spend }),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
415
447
|
case "system":
|
|
416
448
|
return {
|
|
417
449
|
effect: "read",
|
|
@@ -473,8 +505,11 @@ export function defaultEvidenceFor(operation) {
|
|
|
473
505
|
: [];
|
|
474
506
|
case "browser":
|
|
475
507
|
case "computer":
|
|
508
|
+
case "http":
|
|
476
509
|
// 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.
|
|
510
|
+
// rather than letting a silent no-op pass as a completed action. For HTTP
|
|
511
|
+
// that flag is the response status, which is the far end's own word — the
|
|
512
|
+
// independent re-read that would be stronger is a caller's to declare.
|
|
478
513
|
return [{ type: "result_equals", path: "success", value: true }];
|
|
479
514
|
case "terminal":
|
|
480
515
|
// `run`/`start` say nothing about what the command should leave behind,
|
|
@@ -509,12 +544,18 @@ function isCommandAllowed(operation, policy) {
|
|
|
509
544
|
return policy.allowedCommands.some((allowed) => normalizeCommandName(allowed) === command);
|
|
510
545
|
}
|
|
511
546
|
function domainAllowed(operation, policy) {
|
|
512
|
-
|
|
547
|
+
// Every kind that names a destination is checked by the same allowlist. The
|
|
548
|
+
// browser and the HTTP adapter reach the same hosts; a policy that let one
|
|
549
|
+
// through and not the other would only describe which package made the call.
|
|
550
|
+
const raw = operation.kind === "browser" || operation.kind === "http"
|
|
551
|
+
? operation.url
|
|
552
|
+
: undefined;
|
|
553
|
+
if (raw === undefined)
|
|
513
554
|
return true;
|
|
514
|
-
const url = new URL(
|
|
555
|
+
const url = new URL(raw);
|
|
515
556
|
if (!["http:", "https:"].includes(url.protocol))
|
|
516
557
|
return false;
|
|
517
|
-
const host = url
|
|
558
|
+
const host = hostOf(url);
|
|
518
559
|
if (host === "localhost" || host.endsWith(".localhost"))
|
|
519
560
|
return policy.allowLocalhost;
|
|
520
561
|
return policy.allowedDomains.some((domain) => domain === "*" ||
|
|
@@ -535,35 +576,118 @@ function patternMatches(pattern, value) {
|
|
|
535
576
|
return new RegExp(`^${escaped}$`).test(value);
|
|
536
577
|
}
|
|
537
578
|
/**
|
|
538
|
-
*
|
|
539
|
-
*
|
|
579
|
+
* Whether a provider-shaped grant covers what this operation declared.
|
|
580
|
+
*
|
|
581
|
+
* Exact comparison, not patterns: `stripe` and `stripe-test` are different
|
|
582
|
+
* providers, and a grant meaning one must not quietly cover the other. A grant
|
|
583
|
+
* with money bounds and an operation that declared nothing do not match — an
|
|
584
|
+
* undeclared spend is an ungranted one, not a free one.
|
|
585
|
+
*/
|
|
586
|
+
function providerCovers(grant, spend) {
|
|
587
|
+
if (grant.provider === undefined)
|
|
588
|
+
return true;
|
|
589
|
+
if (spend === undefined)
|
|
590
|
+
return false;
|
|
591
|
+
return (grant.provider.name === spend.provider &&
|
|
592
|
+
(grant.provider.account === undefined ||
|
|
593
|
+
grant.provider.account === spend.account));
|
|
594
|
+
}
|
|
595
|
+
/** Milliseconds in the rolling window `provider.dailyMax` is measured over. */
|
|
596
|
+
export const CAPABILITY_DAY_MS = 24 * 60 * 60 * 1000;
|
|
597
|
+
/**
|
|
598
|
+
* Why a matching, unexpired grant still cannot pay for this effect.
|
|
599
|
+
*
|
|
600
|
+
* Fails closed when a metered grant is evaluated without a way to read its
|
|
601
|
+
* count: a budget nobody can count is not a budget, and treating an
|
|
602
|
+
* uncountable one as full would make the metered grant the loosest kind there
|
|
603
|
+
* is.
|
|
604
|
+
*/
|
|
605
|
+
function meteringRefusal(grant, spend, usage) {
|
|
606
|
+
const { amountMax, dailyMax } = grant.provider ?? {};
|
|
607
|
+
if (grant.maxOperations === undefined &&
|
|
608
|
+
amountMax === undefined &&
|
|
609
|
+
dailyMax === undefined) {
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
if (usage === undefined)
|
|
613
|
+
return `capability_usage_unmeterable:${grant.id}`;
|
|
614
|
+
const amount = spend?.amount ?? 0;
|
|
615
|
+
if (amountMax !== undefined && amount > amountMax) {
|
|
616
|
+
return `capability_amount_exceeded:${grant.id}`;
|
|
617
|
+
}
|
|
618
|
+
const drawn = usage(grant.id);
|
|
619
|
+
if (grant.maxOperations !== undefined &&
|
|
620
|
+
drawn.operations >= grant.maxOperations) {
|
|
621
|
+
return `capability_usage_exhausted:${grant.id}`;
|
|
622
|
+
}
|
|
623
|
+
if (dailyMax !== undefined && drawn.amountToday + amount > dailyMax) {
|
|
624
|
+
return `capability_daily_limit_exceeded:${grant.id}`;
|
|
625
|
+
}
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Which issued grant covers this effect, or why none does.
|
|
630
|
+
*
|
|
631
|
+
* An empty grant list is not a closed world — it means the operator has not
|
|
540
632
|
* issued grants at all, and the rest of policy decides on its own.
|
|
541
633
|
*/
|
|
542
|
-
export function
|
|
634
|
+
export function capabilityCheck(classified, identity, policy, usage) {
|
|
543
635
|
if (policy.capabilities.length === 0)
|
|
544
|
-
return
|
|
636
|
+
return {};
|
|
545
637
|
const holder = principalRef(identity.principal);
|
|
546
|
-
const
|
|
638
|
+
const candidates = policy.capabilities.filter((grant) => patternMatches(grant.capability, classified.capability) &&
|
|
547
639
|
grant.effects.includes(classified.effect) &&
|
|
548
640
|
patternMatches(grant.target, classified.target) &&
|
|
549
641
|
patternMatches(grant.principal, holder));
|
|
642
|
+
const matching = candidates.filter((grant) => providerCovers(grant, classified.spend));
|
|
550
643
|
if (matching.length === 0) {
|
|
551
|
-
|
|
644
|
+
// A grant did cover the effect and was turned away on the money block
|
|
645
|
+
// alone. Saying `not_granted` there would send the caller looking for a
|
|
646
|
+
// grant it already holds, so name the two fixes apart: declare a spend, or
|
|
647
|
+
// declare the right one.
|
|
648
|
+
if (candidates.some((grant) => grant.provider !== undefined)) {
|
|
649
|
+
return {
|
|
650
|
+
refusal: classified.spend === undefined
|
|
651
|
+
? `capability_spend_not_declared:${classified.capability}`
|
|
652
|
+
: `capability_provider_mismatch:${classified.spend.provider}`,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
return { refusal: `capability_not_granted:${classified.capability}` };
|
|
552
656
|
}
|
|
553
657
|
const usable = matching.filter((grant) => (grant.validUntil === undefined ||
|
|
554
658
|
Date.parse(grant.validUntil) > Date.now()) &&
|
|
555
659
|
(grant.policyVersion === undefined ||
|
|
556
660
|
grant.policyVersion === policy.version));
|
|
557
|
-
if (usable.length
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
661
|
+
if (usable.length === 0) {
|
|
662
|
+
// Every candidate matched the effect and was refused for a reason the
|
|
663
|
+
// holder can act on, so name it rather than reporting a missing grant.
|
|
664
|
+
const stale = matching.find((grant) => grant.policyVersion !== policy.version);
|
|
665
|
+
return {
|
|
666
|
+
refusal: stale?.policyVersion !== undefined
|
|
667
|
+
? `capability_policy_version_mismatch:${stale.id}`
|
|
668
|
+
: `capability_expired:${matching[0]?.id ?? classified.capability}`,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
// A grant list is a set of authorities, not a single budget: one that is
|
|
672
|
+
// spent out does not refuse work another still covers. Only when every
|
|
673
|
+
// usable grant is exhausted does the first one's reason become the answer.
|
|
674
|
+
let exhausted;
|
|
675
|
+
for (const grant of usable) {
|
|
676
|
+
const refusal = meteringRefusal(grant, classified.spend, usage);
|
|
677
|
+
if (refusal === undefined)
|
|
678
|
+
return { grantId: grant.id };
|
|
679
|
+
exhausted ??= refusal;
|
|
680
|
+
}
|
|
681
|
+
return exhausted === undefined ? {} : { refusal: exhausted };
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Why the issued capabilities do not cover this effect, or `undefined` if they
|
|
685
|
+
* do. `capabilityCheck` also reports which grant covered it.
|
|
686
|
+
*/
|
|
687
|
+
export function capabilityRefusal(classified, identity, policy, usage) {
|
|
688
|
+
return capabilityCheck(classified, identity, policy, usage).refusal;
|
|
565
689
|
}
|
|
566
|
-
export function evaluatePolicy(taskId, request, policy) {
|
|
690
|
+
export function evaluatePolicy(taskId, request, policy, usage) {
|
|
567
691
|
const classified = classifyOperation(request.operation);
|
|
568
692
|
// Every decision reports the same classification it was made from, including
|
|
569
693
|
// the traits, so an approver reading a `confirm` sees that the command
|
|
@@ -596,10 +720,13 @@ export function evaluatePolicy(taskId, request, policy) {
|
|
|
596
720
|
// Authority before rules: a caller that was never granted this effect is
|
|
597
721
|
// refused without consulting the allowlists, which describe what the grant
|
|
598
722
|
// holder may do, not whether they hold one.
|
|
599
|
-
const
|
|
600
|
-
if (
|
|
601
|
-
return { decision: decide("deny",
|
|
723
|
+
const granted = capabilityCheck(classified, request.identity ?? LOCAL_IDENTITY, policy, usage);
|
|
724
|
+
if (granted.refusal !== undefined) {
|
|
725
|
+
return { decision: decide("deny", granted.refusal, "critical") };
|
|
602
726
|
}
|
|
727
|
+
// Carried on every non-deny outcome so the commit knows what to meter. A deny
|
|
728
|
+
// never reaches an adapter, so it never draws anything down.
|
|
729
|
+
const metered = granted.grantId === undefined ? {} : { grantId: granted.grantId };
|
|
603
730
|
const deniedTrait = classified.traits.find((trait) => policy.deniedTraits.includes(trait));
|
|
604
731
|
if (deniedTrait !== undefined) {
|
|
605
732
|
return { decision: decide("deny", `trait_denied:${deniedTrait}`) };
|
|
@@ -611,14 +738,14 @@ export function evaluatePolicy(taskId, request, policy) {
|
|
|
611
738
|
}
|
|
612
739
|
if (!domainAllowed(request.operation, policy)) {
|
|
613
740
|
return {
|
|
614
|
-
decision: decide("deny", "
|
|
741
|
+
decision: decide("deny", "destination_domain_not_allowed", "high"),
|
|
615
742
|
};
|
|
616
743
|
}
|
|
617
744
|
if (classified.effect !== "read" && request.requiredEvidence.length === 0) {
|
|
618
745
|
return { decision: decide("deny", "mutation_requires_evidence") };
|
|
619
746
|
}
|
|
620
747
|
if (classified.effect === "read") {
|
|
621
|
-
return { decision: decide("allow", "read_only_operation") };
|
|
748
|
+
return { decision: decide("allow", "read_only_operation"), ...metered };
|
|
622
749
|
}
|
|
623
750
|
if (policy.mutations === "deny") {
|
|
624
751
|
return { decision: decide("deny", "mutations_disabled") };
|
|
@@ -628,6 +755,7 @@ export function evaluatePolicy(taskId, request, policy) {
|
|
|
628
755
|
const phrase = `APPROVE ${digest.slice(0, 12)}`;
|
|
629
756
|
return {
|
|
630
757
|
decision: decide("confirm", "explicit_approval_required"),
|
|
758
|
+
...metered,
|
|
631
759
|
challenge: {
|
|
632
760
|
approvalId,
|
|
633
761
|
taskId,
|
|
@@ -668,4 +796,5 @@ export function validateApproval(challenge, response) {
|
|
|
668
796
|
}
|
|
669
797
|
return { ok: true };
|
|
670
798
|
}
|
|
799
|
+
export * from "./network-policy.js";
|
|
671
800
|
//# sourceMappingURL=index.js.map
|