@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 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 allowed,
34
- because the allowlist is a narrowing control, not the safety boundary — the
35
- browser runtime independently blocks private and metadata destinations.
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":"AAAA,2CAA2C;AAC3C,sCAAsC;AAEtC,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,KAAK,EAAE,IAAI,EAAmB,MAAM,kBAAkB,CAAC;AAUhE,SAAS,cAAc,CAAC,OAAe,EAAE,KAAa;IACpD,IAAI,OAAO,KAAK,GAAG;QAAE,OAAO,IAAI,CAAC;IACjC,MAAM,OAAO,GAAG,OAAO;SACpB,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC;SACtC,UAAU,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;IAC3B,OAAO,IAAI,MAAM,CAAC,IAAI,OAAO,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAChD,CAAC;AAED,SAAS,SAAS,CAAC,KAAc;IAC/B,OAAO,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,IAAI,KAAK;QACnE,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC;QACpB,CAAC,CAAC,SAAS,CAAC;AAChB,CAAC;AAED;;;;;GAKG;AACH,KAAK,UAAU,cAAc,CAAC,IAAY;IACxC,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC;IACjC,IAAI,MAAM,CAAC,cAAc,EAAE,EAAE,CAAC;QAC5B,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;IACzD,CAAC;IACD,IAAI,MAAkB,CAAC;IACvB,IAAI,CAAC;QACH,MAAM,GAAG,MAAM,IAAI,CACjB,IAAI,EACJ,SAAS,CAAC,QAAQ;YAChB,CAAC,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,UAAU,CAAC,CAC5D,CAAC;IACJ,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,SAAS,CAAC,KAAK,CAAC,KAAK,OAAO,EAAE,CAAC;YACjC,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QACzD,CAAC;QACD,MAAM,KAAK,CAAC;IACd,CAAC;IACD,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QACrC,IACE,CAAC,QAAQ,CAAC,MAAM,EAAE;YAClB,QAAQ,CAAC,GAAG,KAAK,MAAM,CAAC,GAAG;YAC3B,QAAQ,CAAC,GAAG,KAAK,MAAM,CAAC,GAAG,EAC3B,CAAC;YACD,MAAM,IAAI,KAAK,CAAC,kCAAkC,CAAC,CAAC;QACtD,CAAC;QACD,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC;YAClE,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAC;QAC1D,CAAC;QACD,MAAM,MAAM,GAAG,CAAC,MAAM,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QACtD,IAAI,MAAM,KAAK,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAC;QAC5D,OAAO,MAAM,CAAC;IAChB,CAAC;YAAS,CAAC;QACT,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;IACvB,CAAC;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,OAAO,gBAAgB;IAER;IACA;IAFnB,YACmB,cAAoD,EAAE,EACtD,cAAiC,OAAO,CAAC,GAAG;QAD5C,gBAAW,GAAX,WAAW,CAA2C;QACtD,gBAAW,GAAX,WAAW,CAAiC;IAC5D,CAAC;IAEJ,uEAAuE;IACvE,IAAI,KAAK;QACP,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC;IACpD,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,OAIhB;QACC,MAAM,OAAO,GAA2B,EAAE,CAAC;QAC3C,MAAM,IAAI,GAAa,EAAE,CAAC;QAC1B,MAAM,SAAS,GAAG,GAAG,OAAO,CAAC,UAAU,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;QAC5D,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;QAExC,KAAK,MAAM,CAAC,IAAI,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC;YAClE,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,cAAc,CAAC,OAAO,CAAC,WAAW,EAAE,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC;gBACrF,+DAA+D;gBAC/D,oEAAoE;gBACpE,wEAAwE;gBACxE,SAAS;YACX,CAAC;YACD,IAAI,CAAC,cAAc,CAAC,UAAU,CAAC,UAAU,EAAE,SAAS,CAAC,EAAE,CAAC;gBACtD,qEAAqE;gBACrE,oEAAoE;gBACpE,wEAAwE;gBACxE,MAAM,IAAI,KAAK,CAAC,qCAAqC,IAAI,EAAE,CAAC,CAAC;YAC/D,CAAC;YACD,MAAM,MAAM,GACV,KAAK,IAAI,UAAU,CAAC,MAAM;gBACxB,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC;gBACzC,CAAC,CAAC,MAAM,cAAc,CAAC,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YACnD,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,EAAE,EAAE,CAAC;gBAC1C,MAAM,IAAI,KAAK,CAAC,6BAA6B,IAAI,EAAE,CAAC,CAAC;YACvD,CAAC;YACD,OAAO,CAAC,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC;gBAC7C,UAAU,CAAC,MAAM,CAAC,MAAM,KAAK,SAAS;oBACpC,CAAC,CAAC,MAAM;oBACR,CAAC,CAAC,GAAG,UAAU,CAAC,MAAM,CAAC,MAAM,IAAI,MAAM,EAAE,CAAC;YAC9C,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAClB,CAAC;QACD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;IAC3B,CAAC;CACF"}
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
- * Why the issued capabilities do not cover this effect, or `undefined` if they
144
- * do. An empty grant list is not a closed world — it means the operator has not
145
- * issued grants at all, and the rest of policy decides on its own.
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
- export declare function capabilityRefusal(classified: {
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
- }, identity: Identity, policy: LocalPolicy): string | undefined;
152
- export declare function evaluatePolicy(taskId: string, request: TaskRequest, policy: LocalPolicy): PolicyEvaluation;
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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
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 { CapabilityGrantSchema, LOCAL_IDENTITY, principalRef, } from "@melra/protocol";
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 `@melra/browser-runtime` independently rejects non-http(s) protocols, URL
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
- * `browser_domain_not_allowed` out of the box, which is why browser use was
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
- if (operation.kind !== "browser" || operation.url === undefined)
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(operation.url);
555
+ const url = new URL(raw);
515
556
  if (!["http:", "https:"].includes(url.protocol))
516
557
  return false;
517
- const host = url.hostname.toLowerCase();
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
- * 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
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 capabilityRefusal(classified, identity, policy) {
634
+ export function capabilityCheck(classified, identity, policy, usage) {
543
635
  if (policy.capabilities.length === 0)
544
- return undefined;
636
+ return {};
545
637
  const holder = principalRef(identity.principal);
546
- const matching = policy.capabilities.filter((grant) => patternMatches(grant.capability, classified.capability) &&
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
- return `capability_not_granted:${classified.capability}`;
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 > 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}`;
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 ungranted = capabilityRefusal(classified, request.identity ?? LOCAL_IDENTITY, policy);
600
- if (ungranted !== undefined) {
601
- return { decision: decide("deny", ungranted, "critical") };
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", "browser_domain_not_allowed", "high"),
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