@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/README.md +304 -337
- package/dist/admit.d.ts +13 -2
- package/dist/admit.js +186 -26
- package/dist/crypto.d.ts +4 -1
- package/dist/crypto.js +127 -19
- package/dist/grant.js +50 -5
- package/dist/index.js +418 -57
- package/dist/io.d.ts +5 -0
- package/dist/io.js +97 -0
- package/dist/keystore.d.ts +16 -0
- package/dist/keystore.js +117 -0
- package/dist/manifest.js +83 -17
- package/dist/sdk.d.ts +2 -0
- package/dist/sdk.js +2 -0
- package/dist/signing.d.ts +16 -2
- package/dist/signing.js +317 -31
- package/dist/trust.d.ts +17 -0
- package/dist/trust.js +466 -0
- package/dist/types.d.ts +25 -0
- package/examples/request.json +3 -0
- package/package.json +66 -57
- package/scripts/postinstall.mjs +30 -0
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
50
|
+
finally {
|
|
51
|
+
state.seen.delete(value);
|
|
52
|
+
}
|
|
15
53
|
}
|
|
16
54
|
export function canonicalize(value) {
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
29
|
-
|
|
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
|
-
|
|
34
|
-
key:
|
|
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
|
-
|
|
41
|
-
key:
|
|
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
|
|
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" &&
|
|
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
|
|
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 =
|
|
64
|
-
const
|
|
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 - ")}`);
|