@dorigjo/besa 0.1.0-alpha.2 → 0.1.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/admit.js CHANGED
@@ -1,18 +1,46 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { dirname } from "node:path";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { validateManifest } from "./manifest.js";
5
+ import { readJsonFile, writeJsonAtomic } from "./io.js";
3
6
  export const REASON = {
4
7
  ALLOWED: "ALLOWED",
5
8
  TOOL_NOT_FOUND: "TOOL_NOT_FOUND",
6
9
  RISK_BLOCKED: "RISK_BLOCKED",
7
- BUDGET_EXCEEDED: "BUDGET_EXCEEDED"
10
+ BUDGET_EXCEEDED: "BUDGET_EXCEEDED",
11
+ INVALID_MANIFEST: "INVALID_MANIFEST",
12
+ INVALID_TOOL_NAME: "INVALID_TOOL_NAME",
13
+ INVALID_CALL_COUNT: "INVALID_CALL_COUNT",
14
+ INVALID_POLICY: "INVALID_POLICY",
8
15
  };
16
+ const SHA256_HEX = /^[a-f0-9]{64}$/;
17
+ const TOOL_NAME_RE = /^[a-zA-Z0-9._-]{1,256}$/;
18
+ function isValidToolName(name) {
19
+ return typeof name === "string" && TOOL_NAME_RE.test(name);
20
+ }
9
21
  export const DEFAULT_POLICY = {
10
- denyDestructiveHighRisk: true
22
+ denyDestructiveHighRisk: true,
11
23
  };
12
24
  export function findTool(manifest, toolName) {
13
25
  return manifest.tools.find((tool) => tool.name === toolName);
14
26
  }
15
27
  export function admit(manifest, toolName, callCount, policy = DEFAULT_POLICY) {
28
+ if (!isValidToolName(toolName)) {
29
+ return deny("", REASON.INVALID_TOOL_NAME, "tool name is invalid");
30
+ }
31
+ if (!Number.isSafeInteger(callCount) || callCount < 0) {
32
+ return deny(toolName, REASON.INVALID_CALL_COUNT, "call count must be a safe non-negative integer");
33
+ }
34
+ if (!policy ||
35
+ typeof policy !== "object" ||
36
+ policy.denyDestructiveHighRisk !== true &&
37
+ policy.denyDestructiveHighRisk !== false) {
38
+ return deny(toolName, REASON.INVALID_POLICY, "admission policy is invalid");
39
+ }
40
+ const manifestValidation = validateManifest(manifest);
41
+ if (!manifestValidation.ok) {
42
+ return deny(toolName, REASON.INVALID_MANIFEST, "manifest failed runtime validation");
43
+ }
16
44
  const tool = findTool(manifest, toolName);
17
45
  if (!tool) {
18
46
  return deny(toolName, REASON.TOOL_NOT_FOUND, `tool '${toolName}' is not declared in the manifest`);
@@ -29,7 +57,7 @@ export function admit(manifest, toolName, callCount, policy = DEFAULT_POLICY) {
29
57
  decision: "allow",
30
58
  reasonCode: REASON.ALLOWED,
31
59
  toolName,
32
- detail: "tool call admitted"
60
+ detail: "tool call admitted",
33
61
  };
34
62
  }
35
63
  function deny(toolName, reasonCode, detail) {
@@ -37,40 +65,172 @@ function deny(toolName, reasonCode, detail) {
37
65
  decision: "deny",
38
66
  reasonCode,
39
67
  toolName,
40
- detail
68
+ detail,
41
69
  };
42
70
  }
71
+ export function meterKey(manifestHash, toolName) {
72
+ if (!SHA256_HEX.test(manifestHash)) {
73
+ throw new Error("manifestHash must be a lowercase SHA-256 hex digest");
74
+ }
75
+ if (!isValidToolName(toolName)) {
76
+ throw new Error("toolName is invalid");
77
+ }
78
+ return `${manifestHash}:${toolName}`;
79
+ }
43
80
  export function loadMeter(path) {
44
81
  if (!existsSync(path)) {
45
82
  return {};
46
83
  }
84
+ let raw;
47
85
  try {
48
- const raw = JSON.parse(readFileSync(path, "utf8"));
49
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
50
- return {};
51
- }
52
- const state = {};
53
- for (const [key, value] of Object.entries(raw)) {
54
- if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
55
- state[key] = value;
56
- }
57
- }
58
- return state;
86
+ raw = readJsonFile(path);
59
87
  }
60
- catch {
61
- return {};
88
+ catch (error) {
89
+ const message = error instanceof Error ? error.message : String(error);
90
+ throw new Error(`invalid meter state at ${path}: ${message}`);
91
+ }
92
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
93
+ throw new Error(`invalid meter state at ${path}: expected a JSON object`);
94
+ }
95
+ const state = Object.create(null);
96
+ for (const [key, value] of Object.entries(raw)) {
97
+ if (typeof value !== "number" ||
98
+ !Number.isSafeInteger(value) ||
99
+ value < 0) {
100
+ throw new Error(`invalid meter state at ${path}: '${key}' must be a safe non-negative integer`);
101
+ }
102
+ state[key] = value;
62
103
  }
104
+ return state;
63
105
  }
64
106
  export function saveMeter(path, state) {
107
+ writeJsonAtomic(path, state, 0o600);
108
+ }
109
+ export function getCount(state, key) {
110
+ return state[key] ?? 0;
111
+ }
112
+ export function increment(state, key) {
113
+ const count = getCount(state, key);
114
+ if (!Number.isSafeInteger(count) || count < 0 || count === Number.MAX_SAFE_INTEGER) {
115
+ throw new Error(`meter count for '${key}' cannot be incremented safely`);
116
+ }
117
+ return Object.assign(Object.create(null), state, {
118
+ [key]: count + 1,
119
+ });
120
+ }
121
+ function sleep(milliseconds) {
122
+ const signal = new Int32Array(new SharedArrayBuffer(4));
123
+ Atomics.wait(signal, 0, 0, milliseconds);
124
+ }
125
+ function acquireMeterLock(path, options) {
126
+ const lockPath = `${path}.lock`;
127
+ const timeoutMs = options.timeoutMs ?? 5_000;
128
+ const staleMs = options.staleMs ?? 30_000;
129
+ const retryMs = options.retryMs ?? 10;
130
+ const startedAt = Date.now();
131
+ if (!Number.isFinite(timeoutMs) ||
132
+ !Number.isFinite(staleMs) ||
133
+ !Number.isFinite(retryMs) ||
134
+ timeoutMs < 0 ||
135
+ staleMs <= 0 ||
136
+ retryMs <= 0) {
137
+ throw new Error("meter lock timings must be positive");
138
+ }
65
139
  mkdirSync(dirname(path), { recursive: true });
66
- writeFileSync(path, JSON.stringify(state, null, 2) + "\n", "utf8");
140
+ while (true) {
141
+ try {
142
+ const descriptor = openSync(lockPath, "wx", 0o600);
143
+ const token = randomUUID();
144
+ try {
145
+ writeFileSync(descriptor, JSON.stringify({
146
+ pid: process.pid,
147
+ token,
148
+ createdAt: new Date().toISOString(),
149
+ }) + "\n", "utf8");
150
+ fsyncSync(descriptor);
151
+ }
152
+ catch (error) {
153
+ closeSync(descriptor);
154
+ unlinkSync(lockPath);
155
+ throw error;
156
+ }
157
+ return () => {
158
+ closeSync(descriptor);
159
+ try {
160
+ const lock = readJsonFile(lockPath);
161
+ if (lock.token === token) {
162
+ unlinkSync(lockPath);
163
+ }
164
+ }
165
+ catch (error) {
166
+ if (error.code !== "ENOENT") {
167
+ throw error;
168
+ }
169
+ }
170
+ };
171
+ }
172
+ catch (error) {
173
+ const code = error.code;
174
+ if (code !== "EEXIST") {
175
+ throw error;
176
+ }
177
+ try {
178
+ if (Date.now() - statSync(lockPath).mtimeMs > staleMs &&
179
+ !lockOwnerIsAlive(lockPath)) {
180
+ const stalePath = `${lockPath}.${String(process.pid)}.${randomUUID()}.stale`;
181
+ renameSync(lockPath, stalePath);
182
+ unlinkSync(stalePath);
183
+ continue;
184
+ }
185
+ }
186
+ catch (statError) {
187
+ if (statError.code === "ENOENT") {
188
+ continue;
189
+ }
190
+ throw statError;
191
+ }
192
+ if (Date.now() - startedAt >= timeoutMs) {
193
+ throw new Error(`timed out waiting for meter lock at ${lockPath}`);
194
+ }
195
+ sleep(retryMs);
196
+ }
197
+ }
67
198
  }
68
- export function getCount(state, toolName) {
69
- return state[toolName] ?? 0;
199
+ function lockOwnerIsAlive(lockPath) {
200
+ try {
201
+ const value = readJsonFile(lockPath);
202
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
203
+ return false;
204
+ }
205
+ const pid = value.pid;
206
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
207
+ return false;
208
+ }
209
+ try {
210
+ process.kill(pid, 0);
211
+ return true;
212
+ }
213
+ catch (error) {
214
+ const code = error.code;
215
+ return code !== "ESRCH";
216
+ }
217
+ }
218
+ catch {
219
+ return false;
220
+ }
70
221
  }
71
- export function increment(state, toolName) {
72
- return {
73
- ...state,
74
- [toolName]: getCount(state, toolName) + 1
75
- };
222
+ export function admitAndConsume(path, manifestHash, manifest, toolName, policy = DEFAULT_POLICY, lockOptions = {}) {
223
+ const release = acquireMeterLock(path, lockOptions);
224
+ try {
225
+ const state = loadMeter(path);
226
+ const key = meterKey(manifestHash, toolName);
227
+ const decision = admit(manifest, toolName, getCount(state, key), policy);
228
+ if (decision.decision === "allow") {
229
+ saveMeter(path, increment(state, key));
230
+ }
231
+ return decision;
232
+ }
233
+ finally {
234
+ release();
235
+ }
76
236
  }
package/dist/crypto.d.ts CHANGED
@@ -4,9 +4,12 @@ export interface KeyPair {
4
4
  privateKeyDer: string;
5
5
  }
6
6
  export declare function canonicalize(value: unknown): string;
7
- export declare function sha256Hex(data: string): string;
7
+ export declare function sha256Hex(data: string | Uint8Array): string;
8
8
  export declare function hashObject(value: unknown): string;
9
9
  export declare function generateKeyPair(): KeyPair;
10
10
  export declare function publicKeyFromDer(publicKeyDer: string): KeyObject;
11
11
  export declare function privateKeyFromDer(privateKeyDer: string): KeyObject;
12
12
  export declare function publicKeyId(publicKeyDer: string): string;
13
+ export declare function isCanonicalBase64(value: unknown): value is string;
14
+ export declare function signatureMessage(domain: string, value: unknown): Buffer;
15
+ export declare function validateKeyPair(value: unknown): value is KeyPair;
package/dist/crypto.js CHANGED
@@ -1,23 +1,74 @@
1
- import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
2
- function sortValue(value) {
3
- if (Array.isArray(value)) {
4
- return value.map(sortValue);
1
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, timingSafeEqual, } from "node:crypto";
2
+ const MAX_CANONICAL_DEPTH = 64;
3
+ const MAX_CANONICAL_NODES = 100_000;
4
+ const MAX_CANONICAL_BYTES = 1_048_576;
5
+ function sortValue(value, path, depth, state) {
6
+ state.nodes += 1;
7
+ if (state.nodes > MAX_CANONICAL_NODES) {
8
+ throw new TypeError("value exceeds the canonical JSON node limit");
5
9
  }
6
- if (value !== null && typeof value === "object") {
10
+ if (depth > MAX_CANONICAL_DEPTH) {
11
+ throw new TypeError(`${path} exceeds the canonical JSON depth limit`);
12
+ }
13
+ if (value === null ||
14
+ typeof value === "string" ||
15
+ typeof value === "boolean") {
16
+ return value;
17
+ }
18
+ if (typeof value === "number") {
19
+ if (!Number.isFinite(value)) {
20
+ throw new TypeError(`${path} must be a finite JSON number`);
21
+ }
22
+ return value;
23
+ }
24
+ if (typeof value !== "object") {
25
+ throw new TypeError(`${path} contains a non-JSON value`);
26
+ }
27
+ if (state.seen.has(value)) {
28
+ throw new TypeError(`${path} contains a circular reference`);
29
+ }
30
+ state.seen.add(value);
31
+ try {
32
+ if (Array.isArray(value)) {
33
+ return value.map((item, index) => sortValue(item, `${path}[${String(index)}]`, depth + 1, state));
34
+ }
35
+ const prototype = Object.getPrototypeOf(value);
36
+ if (prototype !== Object.prototype && prototype !== null) {
37
+ throw new TypeError(`${path} must contain only plain JSON objects`);
38
+ }
7
39
  const input = value;
8
- const output = {};
40
+ const output = Object.create(null);
9
41
  for (const key of Object.keys(input).sort()) {
10
- output[key] = sortValue(input[key]);
42
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
43
+ if (!descriptor || !("value" in descriptor)) {
44
+ throw new TypeError(`${path}.${key} must be a JSON data property`);
45
+ }
46
+ output[key] = sortValue(descriptor.value, `${path}.${key.slice(0, 64)}`, depth + 1, state);
11
47
  }
12
48
  return output;
13
49
  }
14
- return value;
50
+ finally {
51
+ state.seen.delete(value);
52
+ }
15
53
  }
16
54
  export function canonicalize(value) {
17
- return JSON.stringify(sortValue(value));
55
+ const canonical = JSON.stringify(sortValue(value, "$", 0, {
56
+ nodes: 0,
57
+ seen: new WeakSet(),
58
+ }));
59
+ if (canonical === undefined) {
60
+ throw new TypeError("value cannot be canonicalized");
61
+ }
62
+ if (Buffer.byteLength(canonical, "utf8") > MAX_CANONICAL_BYTES) {
63
+ throw new TypeError("value exceeds the canonical JSON byte limit");
64
+ }
65
+ return canonical;
18
66
  }
19
67
  export function sha256Hex(data) {
20
- return createHash("sha256").update(data, "utf8").digest("hex");
68
+ const hash = createHash("sha256");
69
+ return typeof data === "string"
70
+ ? hash.update(data, "utf8").digest("hex")
71
+ : hash.update(data).digest("hex");
21
72
  }
22
73
  export function hashObject(value) {
23
74
  return sha256Hex(canonicalize(value));
@@ -25,24 +76,81 @@ export function hashObject(value) {
25
76
  export function generateKeyPair() {
26
77
  const { publicKey, privateKey } = generateKeyPairSync("ed25519");
27
78
  return {
28
- publicKeyDer: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
29
- privateKeyDer: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64")
79
+ publicKeyDer: publicKey
80
+ .export({ type: "spki", format: "der" })
81
+ .toString("base64"),
82
+ privateKeyDer: privateKey
83
+ .export({ type: "pkcs8", format: "der" })
84
+ .toString("base64"),
30
85
  };
31
86
  }
32
87
  export function publicKeyFromDer(publicKeyDer) {
33
- return createPublicKey({
34
- key: Buffer.from(publicKeyDer, "base64"),
88
+ const key = createPublicKey({
89
+ key: decodeCanonicalBase64(publicKeyDer, "public key"),
35
90
  type: "spki",
36
- format: "der"
91
+ format: "der",
37
92
  });
93
+ if (key.asymmetricKeyType !== "ed25519") {
94
+ throw new TypeError("public key must be Ed25519");
95
+ }
96
+ return key;
38
97
  }
39
98
  export function privateKeyFromDer(privateKeyDer) {
40
- return createPrivateKey({
41
- key: Buffer.from(privateKeyDer, "base64"),
99
+ const key = createPrivateKey({
100
+ key: decodeCanonicalBase64(privateKeyDer, "private key"),
42
101
  type: "pkcs8",
43
- format: "der"
102
+ format: "der",
44
103
  });
104
+ if (key.asymmetricKeyType !== "ed25519") {
105
+ throw new TypeError("private key must be Ed25519");
106
+ }
107
+ return key;
45
108
  }
46
109
  export function publicKeyId(publicKeyDer) {
47
- return sha256Hex(publicKeyDer).slice(0, 16);
110
+ return sha256Hex(decodeCanonicalBase64(publicKeyDer, "public key"));
111
+ }
112
+ export function isCanonicalBase64(value) {
113
+ if (typeof value !== "string" || value.length === 0) {
114
+ return false;
115
+ }
116
+ try {
117
+ return Buffer.from(value, "base64").toString("base64") === value;
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
123
+ function decodeCanonicalBase64(value, label) {
124
+ if (!isCanonicalBase64(value)) {
125
+ throw new TypeError(`${label} must be canonical base64`);
126
+ }
127
+ return Buffer.from(value, "base64");
128
+ }
129
+ export function signatureMessage(domain, value) {
130
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(domain)) {
131
+ throw new TypeError("signature domain is invalid");
132
+ }
133
+ return Buffer.from(`besa:${domain}:v1\0${canonicalize(value)}`, "utf8");
134
+ }
135
+ export function validateKeyPair(value) {
136
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
137
+ return false;
138
+ }
139
+ const candidate = value;
140
+ if (typeof candidate.publicKeyDer !== "string" ||
141
+ typeof candidate.privateKeyDer !== "string") {
142
+ return false;
143
+ }
144
+ try {
145
+ const privateKey = privateKeyFromDer(candidate.privateKeyDer);
146
+ const derivedPublicKeyDer = createPublicKey(privateKey)
147
+ .export({ type: "spki", format: "der" });
148
+ publicKeyFromDer(candidate.publicKeyDer);
149
+ const storedPublicKeyDer = Buffer.from(candidate.publicKeyDer, "base64");
150
+ return (derivedPublicKeyDer.length === storedPublicKeyDer.length &&
151
+ timingSafeEqual(derivedPublicKeyDer, storedPublicKeyDer));
152
+ }
153
+ catch {
154
+ return false;
155
+ }
48
156
  }
package/dist/grant.js CHANGED
@@ -1,16 +1,23 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { extname } from "node:path";
3
2
  import { parse as parseYaml } from "yaml";
3
+ import { canonicalize } from "./crypto.js";
4
+ import { readUtf8File } from "./io.js";
4
5
  export const GRANT_REASON = {
5
6
  GRANTED: "GRANT_OK",
6
7
  AGENT_NOT_FOUND: "AGENT_NOT_FOUND",
7
8
  TOOL_NOT_GRANTED: "TOOL_NOT_GRANTED",
8
9
  };
10
+ const MAX_GRANTS = 256;
11
+ const MAX_TOOLS_PER_GRANT = 256;
9
12
  function isObject(value) {
10
13
  return value !== null && typeof value === "object" && !Array.isArray(value);
11
14
  }
12
15
  function isNonEmptyString(value) {
13
- return typeof value === "string" && value.trim().length > 0;
16
+ return (typeof value === "string" &&
17
+ value.length <= 256 &&
18
+ value.trim().length > 0 &&
19
+ value.trim() === value &&
20
+ !/[\u0000-\u001f\u007f]/.test(value));
14
21
  }
15
22
  export function validateGrantSet(raw) {
16
23
  const errors = [];
@@ -20,9 +27,17 @@ export function validateGrantSet(raw) {
20
27
  errors: ["grant set must be an object"],
21
28
  };
22
29
  }
30
+ for (const field of Object.keys(raw)) {
31
+ if (field !== "grants") {
32
+ errors.push(`unexpected grant set field '${field}'`);
33
+ }
34
+ }
23
35
  if (!Array.isArray(raw.grants) || raw.grants.length === 0) {
24
36
  errors.push("grants must be a non-empty array");
25
37
  }
38
+ else if (raw.grants.length > MAX_GRANTS) {
39
+ errors.push(`grants must contain at most ${String(MAX_GRANTS)} entries`);
40
+ }
26
41
  else {
27
42
  const seenAgents = new Set();
28
43
  raw.grants.forEach((grant, index) => {
@@ -31,6 +46,11 @@ export function validateGrantSet(raw) {
31
46
  errors.push(`${path} must be an object`);
32
47
  return;
33
48
  }
49
+ for (const field of Object.keys(grant)) {
50
+ if (field !== "agentId" && field !== "tools") {
51
+ errors.push(`unexpected ${path} field '${field}'`);
52
+ }
53
+ }
34
54
  if (!isNonEmptyString(grant.agentId)) {
35
55
  errors.push(`${path}.agentId must be a non-empty string`);
36
56
  }
@@ -42,11 +62,22 @@ export function validateGrantSet(raw) {
42
62
  }
43
63
  if (!Array.isArray(grant.tools) ||
44
64
  grant.tools.length === 0 ||
65
+ grant.tools.length > MAX_TOOLS_PER_GRANT ||
45
66
  !grant.tools.every((tool) => isNonEmptyString(tool))) {
46
- errors.push(`${path}.tools must be a non-empty array of non-empty strings`);
67
+ errors.push(`${path}.tools must contain 1-${String(MAX_TOOLS_PER_GRANT)} valid tool names`);
68
+ }
69
+ else if (new Set(grant.tools).size !== grant.tools.length) {
70
+ errors.push(`${path}.tools must not contain duplicate tool names`);
47
71
  }
48
72
  });
49
73
  }
74
+ try {
75
+ canonicalize(raw);
76
+ }
77
+ catch (error) {
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ errors.push(`grant set must contain only bounded JSON values: ${message}`);
80
+ }
50
81
  if (errors.length > 0) {
51
82
  return {
52
83
  ok: false,
@@ -60,8 +91,22 @@ export function validateGrantSet(raw) {
60
91
  };
61
92
  }
62
93
  export function loadGrants(path) {
63
- const source = readFileSync(path, "utf8");
64
- const raw = extname(path).toLowerCase() === ".json" ? JSON.parse(source) : parseYaml(source);
94
+ const source = readUtf8File(path);
95
+ const extension = extname(path).toLowerCase();
96
+ let raw;
97
+ if (extension === ".json") {
98
+ raw = JSON.parse(source);
99
+ }
100
+ else if (extension === ".yaml" || extension === ".yml") {
101
+ raw = parseYaml(source, {
102
+ maxAliasCount: 50,
103
+ strict: true,
104
+ uniqueKeys: true,
105
+ });
106
+ }
107
+ else {
108
+ throw new Error("grant path must end in .json, .yaml, or .yml");
109
+ }
65
110
  const result = validateGrantSet(raw);
66
111
  if (!result.ok || !result.grantSet) {
67
112
  throw new Error(`Invalid grant set:\n - ${result.errors.join("\n - ")}`);