@dorigjo/besa 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +345 -0
- package/dist/admit.d.ts +18 -0
- package/dist/admit.js +76 -0
- package/dist/crypto.d.ts +12 -0
- package/dist/crypto.js +48 -0
- package/dist/grant.d.ts +15 -0
- package/dist/grant.js +101 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +285 -0
- package/dist/manifest.d.ts +8 -0
- package/dist/manifest.js +106 -0
- package/dist/sdk.d.ts +6 -0
- package/dist/sdk.js +6 -0
- package/dist/signing.d.ts +21 -0
- package/dist/signing.js +100 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +1 -0
- package/examples/grants.yaml +4 -0
- package/examples/manifest.yaml +34 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dorigjo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# Besa
|
|
2
|
+
|
|
3
|
+
Signed trust infrastructure for AI-agent tools.
|
|
4
|
+
|
|
5
|
+
> **Alpha / developer preview — not production-ready.**
|
|
6
|
+
>
|
|
7
|
+
> Besa is currently an early alpha (`0.1.0-alpha.0`). APIs, file formats, receipt formats, and behavior may change without notice.
|
|
8
|
+
>
|
|
9
|
+
> Do not use Besa to protect production systems, production secrets, customer data, or real signing keys yet.
|
|
10
|
+
>
|
|
11
|
+
> The key under `.besa/` is a local demo key.
|
|
12
|
+
|
|
13
|
+
Besa signs MCP-style tool manifests, verifies them before use, admits or denies tool calls against policy, and issues signed tamper-evident receipts.
|
|
14
|
+
|
|
15
|
+
Besa is the trust layer for AI-agent tools.
|
|
16
|
+
|
|
17
|
+
## What it does
|
|
18
|
+
|
|
19
|
+
* Signs tool manifests with Ed25519.
|
|
20
|
+
* Verifies signed manifests before runtime use.
|
|
21
|
+
* Allows or denies tool calls with reason codes.
|
|
22
|
+
* Blocks destructive high-risk tools by default.
|
|
23
|
+
* Tracks local per-tool usage with a mini ActionMeter.
|
|
24
|
+
* Creates signed receipts for admission decisions.
|
|
25
|
+
|
|
26
|
+
Flow:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
manifest.yaml -> sign -> verify -> admit -> receipt
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Why it matters
|
|
33
|
+
|
|
34
|
+
AI agents increasingly call external tools, APIs, MCP servers, and internal systems.
|
|
35
|
+
|
|
36
|
+
The important question is not only whether an agent can call a tool.
|
|
37
|
+
|
|
38
|
+
The important questions are:
|
|
39
|
+
|
|
40
|
+
* Which tool is the agent allowed to call?
|
|
41
|
+
* Who signed the declared capability?
|
|
42
|
+
* Has the manifest changed?
|
|
43
|
+
* Was the call allowed or denied?
|
|
44
|
+
* Is there a receipt proving the decision?
|
|
45
|
+
|
|
46
|
+
Besa turns those answers into signed artifacts.
|
|
47
|
+
|
|
48
|
+
## Quickstart
|
|
49
|
+
|
|
50
|
+
Install dependencies:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Build:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm run build
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Run tests:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm test
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Run the smoke test:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm run smoke
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The smoke test runs the full CLI flow: build, load, sign, verify, admit allow, admit deny, and receipt creation.
|
|
75
|
+
|
|
76
|
+
## CLI commands
|
|
77
|
+
|
|
78
|
+
Load a manifest:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
node dist/index.js load examples/manifest.yaml
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Sign a manifest:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
node dist/index.js sign examples/manifest.yaml
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Verify a signed manifest:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
node dist/index.js verify examples/manifest.signed.json
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Admit a safe tool:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
node dist/index.js admit examples/manifest.signed.json crm.lookup
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Deny a dangerous tool:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
node dist/index.js admit examples/manifest.signed.json crm.delete
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Create a signed receipt:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
node dist/index.js receipt crm.lookup
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Expected behavior:
|
|
115
|
+
|
|
116
|
+
* `crm.lookup` -> allow / `ALLOWED`
|
|
117
|
+
* `crm.delete` -> deny / `RISK_BLOCKED`
|
|
118
|
+
|
|
119
|
+
### Grant-aware admission (optional)
|
|
120
|
+
|
|
121
|
+
Besa can scope a tool call to a specific agent. Add a `grants.yaml` listing which `agentId` may use which tools:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
grants:
|
|
125
|
+
- agentId: agent-alpha
|
|
126
|
+
tools:
|
|
127
|
+
- crm.lookup
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Then pass `--agent` and `--grants` to `admit` or `receipt`:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
node dist/index.js admit examples/manifest.signed.json crm.lookup --agent agent-alpha --grants examples/grants.yaml
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- `--agent <id>`: the id of the calling agent.
|
|
137
|
+
- `--grants <file>`: the grants file to check against.
|
|
138
|
+
- If the agent is not granted the tool, admission is denied (`TOOL_NOT_GRANTED`, or `AGENT_NOT_FOUND` for an unknown agent), and the receipt records `agentId` and `grantReasonCode`.
|
|
139
|
+
|
|
140
|
+
Grants are **optional and backward-compatible**: without `--grants`, admission behaves exactly as before.
|
|
141
|
+
|
|
142
|
+
## Core concepts
|
|
143
|
+
|
|
144
|
+
### Tool Manifest
|
|
145
|
+
|
|
146
|
+
A YAML or JSON file that declares a tool server and its tools.
|
|
147
|
+
|
|
148
|
+
Each tool has:
|
|
149
|
+
|
|
150
|
+
* name
|
|
151
|
+
* description
|
|
152
|
+
* capability
|
|
153
|
+
* risk
|
|
154
|
+
* scopes
|
|
155
|
+
* budgetLimit
|
|
156
|
+
* inputSchema
|
|
157
|
+
|
|
158
|
+
Capabilities:
|
|
159
|
+
|
|
160
|
+
* read
|
|
161
|
+
* write
|
|
162
|
+
* destructive
|
|
163
|
+
|
|
164
|
+
Risk levels:
|
|
165
|
+
|
|
166
|
+
* low
|
|
167
|
+
* medium
|
|
168
|
+
* high
|
|
169
|
+
|
|
170
|
+
### Signed Manifest
|
|
171
|
+
|
|
172
|
+
A manifest signed with Ed25519.
|
|
173
|
+
|
|
174
|
+
The signed manifest includes:
|
|
175
|
+
|
|
176
|
+
* manifest
|
|
177
|
+
* manifestHash
|
|
178
|
+
* algorithm
|
|
179
|
+
* publicKey
|
|
180
|
+
* publicKeyId
|
|
181
|
+
* signature
|
|
182
|
+
* signedAt
|
|
183
|
+
|
|
184
|
+
### Admission Decision
|
|
185
|
+
|
|
186
|
+
Besa evaluates whether a tool call should be allowed or denied.
|
|
187
|
+
|
|
188
|
+
Reason codes include:
|
|
189
|
+
|
|
190
|
+
* `ALLOWED`
|
|
191
|
+
* `TOOL_NOT_FOUND`
|
|
192
|
+
* `RISK_BLOCKED`
|
|
193
|
+
* `BUDGET_EXCEEDED`
|
|
194
|
+
|
|
195
|
+
### Mini ActionMeter
|
|
196
|
+
|
|
197
|
+
Besa tracks local call counts per tool.
|
|
198
|
+
|
|
199
|
+
This allows simple budget enforcement through `budgetLimit`.
|
|
200
|
+
|
|
201
|
+
### Signed Receipt
|
|
202
|
+
|
|
203
|
+
A receipt proves what decision was made.
|
|
204
|
+
|
|
205
|
+
A receipt includes:
|
|
206
|
+
|
|
207
|
+
* receiptId
|
|
208
|
+
* manifestHash
|
|
209
|
+
* toolName
|
|
210
|
+
* decision
|
|
211
|
+
* reasonCode
|
|
212
|
+
* timestamp
|
|
213
|
+
* requestHash
|
|
214
|
+
* publicKeyId
|
|
215
|
+
* algorithm
|
|
216
|
+
* signature
|
|
217
|
+
|
|
218
|
+
## SDK usage
|
|
219
|
+
|
|
220
|
+
Import Besa from the SDK:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import {
|
|
224
|
+
loadManifest,
|
|
225
|
+
generateKeyPair,
|
|
226
|
+
signManifest,
|
|
227
|
+
verifySignedManifest,
|
|
228
|
+
admit,
|
|
229
|
+
createReceipt,
|
|
230
|
+
verifyReceipt,
|
|
231
|
+
} from "besa";
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Basic flow:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
const manifest = loadManifest("examples/manifest.yaml");
|
|
238
|
+
|
|
239
|
+
const keypair = generateKeyPair();
|
|
240
|
+
|
|
241
|
+
const signed = signManifest(manifest, keypair);
|
|
242
|
+
|
|
243
|
+
const verified = verifySignedManifest(signed);
|
|
244
|
+
|
|
245
|
+
if (!verified.valid) {
|
|
246
|
+
throw new Error(verified.reasonCode);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const decision = admit(signed, "crm.lookup");
|
|
250
|
+
|
|
251
|
+
const receipt = createReceipt(signed, decision, keypair);
|
|
252
|
+
|
|
253
|
+
const receiptResult = verifyReceipt(receipt);
|
|
254
|
+
|
|
255
|
+
if (!receiptResult.valid) {
|
|
256
|
+
throw new Error(receiptResult.reasonCode);
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Security
|
|
261
|
+
|
|
262
|
+
Never commit `.besa/`.
|
|
263
|
+
|
|
264
|
+
The `.besa/` folder contains local trust artifacts, including the Ed25519 private key.
|
|
265
|
+
|
|
266
|
+
Ignored local artifacts:
|
|
267
|
+
|
|
268
|
+
* `.besa/`
|
|
269
|
+
* `.besa/key.json`
|
|
270
|
+
* `.besa/meter.json`
|
|
271
|
+
* `.besa/receipts/`
|
|
272
|
+
* `examples/manifest.signed.json`
|
|
273
|
+
|
|
274
|
+
The local key generated by this MVP is a demo key. Rotate keys before real usage.
|
|
275
|
+
|
|
276
|
+
See:
|
|
277
|
+
|
|
278
|
+
* [SECURITY.md](SECURITY.md)
|
|
279
|
+
* [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md)
|
|
280
|
+
|
|
281
|
+
## MVP limitations
|
|
282
|
+
|
|
283
|
+
This is an MVP and alpha developer preview.
|
|
284
|
+
|
|
285
|
+
Current limitations:
|
|
286
|
+
|
|
287
|
+
* local key storage only
|
|
288
|
+
* local JSON meter only
|
|
289
|
+
* no hosted registry
|
|
290
|
+
* no SaaS backend
|
|
291
|
+
* no dashboard
|
|
292
|
+
* no remote verifier API
|
|
293
|
+
* no hosted receipts API
|
|
294
|
+
* no distributed replay protection
|
|
295
|
+
* no key rotation
|
|
296
|
+
* no key revocation
|
|
297
|
+
* one default policy
|
|
298
|
+
|
|
299
|
+
Default policy:
|
|
300
|
+
|
|
301
|
+
* destructive + high risk = denied
|
|
302
|
+
|
|
303
|
+
## What Besa is not
|
|
304
|
+
|
|
305
|
+
Besa is currently an alpha trust layer for AI-agent tool control and evidence.
|
|
306
|
+
|
|
307
|
+
It is not:
|
|
308
|
+
|
|
309
|
+
* a hosted SaaS
|
|
310
|
+
* a dashboard or UI
|
|
311
|
+
* a full MCP gateway
|
|
312
|
+
* production key management
|
|
313
|
+
* a compliance certification product
|
|
314
|
+
* a replacement for identity, authorization, audit storage, or security monitoring
|
|
315
|
+
* ready for production secrets or production systems
|
|
316
|
+
|
|
317
|
+
## Release docs
|
|
318
|
+
|
|
319
|
+
* [SECURITY.md](SECURITY.md) — security policy, key handling, and vulnerability reporting
|
|
320
|
+
* [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md) — assets, threats, mitigations, and current MVP limitations
|
|
321
|
+
* [docs/RELEASE_CHECKLIST.md](docs/RELEASE_CHECKLIST.md) — pre-release gates before tagging or publishing
|
|
322
|
+
* [CHANGELOG.md](CHANGELOG.md) — notable changes by version
|
|
323
|
+
|
|
324
|
+
## Roadmap
|
|
325
|
+
|
|
326
|
+
Planned next layers:
|
|
327
|
+
|
|
328
|
+
* hosted key management
|
|
329
|
+
* remote verifier API
|
|
330
|
+
* policy packs
|
|
331
|
+
* MCP gateway integration
|
|
332
|
+
* enterprise audit export
|
|
333
|
+
* receipts API
|
|
334
|
+
* usage-based ActionMeter
|
|
335
|
+
* organization-level trust registry
|
|
336
|
+
|
|
337
|
+
## Positioning
|
|
338
|
+
|
|
339
|
+
Besa is signed trust infrastructure for AI-agent tools.
|
|
340
|
+
|
|
341
|
+
It is not another chatbot.
|
|
342
|
+
|
|
343
|
+
It is not another dashboard.
|
|
344
|
+
|
|
345
|
+
It is a trust layer for agentic execution.
|
package/dist/admit.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AdmissionDecision, Manifest, ToolDefinition } from "./types.js";
|
|
2
|
+
export declare const REASON: {
|
|
3
|
+
readonly ALLOWED: "ALLOWED";
|
|
4
|
+
readonly TOOL_NOT_FOUND: "TOOL_NOT_FOUND";
|
|
5
|
+
readonly RISK_BLOCKED: "RISK_BLOCKED";
|
|
6
|
+
readonly BUDGET_EXCEEDED: "BUDGET_EXCEEDED";
|
|
7
|
+
};
|
|
8
|
+
export interface AdmissionPolicy {
|
|
9
|
+
denyDestructiveHighRisk: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare const DEFAULT_POLICY: AdmissionPolicy;
|
|
12
|
+
export type MeterState = Record<string, number>;
|
|
13
|
+
export declare function findTool(manifest: Manifest, toolName: string): ToolDefinition | undefined;
|
|
14
|
+
export declare function admit(manifest: Manifest, toolName: string, callCount: number, policy?: AdmissionPolicy): AdmissionDecision;
|
|
15
|
+
export declare function loadMeter(path: string): MeterState;
|
|
16
|
+
export declare function saveMeter(path: string, state: MeterState): void;
|
|
17
|
+
export declare function getCount(state: MeterState, toolName: string): number;
|
|
18
|
+
export declare function increment(state: MeterState, toolName: string): MeterState;
|
package/dist/admit.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
export const REASON = {
|
|
4
|
+
ALLOWED: "ALLOWED",
|
|
5
|
+
TOOL_NOT_FOUND: "TOOL_NOT_FOUND",
|
|
6
|
+
RISK_BLOCKED: "RISK_BLOCKED",
|
|
7
|
+
BUDGET_EXCEEDED: "BUDGET_EXCEEDED"
|
|
8
|
+
};
|
|
9
|
+
export const DEFAULT_POLICY = {
|
|
10
|
+
denyDestructiveHighRisk: true
|
|
11
|
+
};
|
|
12
|
+
export function findTool(manifest, toolName) {
|
|
13
|
+
return manifest.tools.find((tool) => tool.name === toolName);
|
|
14
|
+
}
|
|
15
|
+
export function admit(manifest, toolName, callCount, policy = DEFAULT_POLICY) {
|
|
16
|
+
const tool = findTool(manifest, toolName);
|
|
17
|
+
if (!tool) {
|
|
18
|
+
return deny(toolName, REASON.TOOL_NOT_FOUND, `tool '${toolName}' is not declared in the manifest`);
|
|
19
|
+
}
|
|
20
|
+
if (policy.denyDestructiveHighRisk &&
|
|
21
|
+
tool.capability === "destructive" &&
|
|
22
|
+
tool.risk === "high") {
|
|
23
|
+
return deny(toolName, REASON.RISK_BLOCKED, "destructive high-risk tool is blocked by policy");
|
|
24
|
+
}
|
|
25
|
+
if (callCount >= tool.budgetLimit) {
|
|
26
|
+
return deny(toolName, REASON.BUDGET_EXCEEDED, `call count ${callCount} has reached budget limit ${tool.budgetLimit}`);
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
decision: "allow",
|
|
30
|
+
reasonCode: REASON.ALLOWED,
|
|
31
|
+
toolName,
|
|
32
|
+
detail: "tool call admitted"
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function deny(toolName, reasonCode, detail) {
|
|
36
|
+
return {
|
|
37
|
+
decision: "deny",
|
|
38
|
+
reasonCode,
|
|
39
|
+
toolName,
|
|
40
|
+
detail
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function loadMeter(path) {
|
|
44
|
+
if (!existsSync(path)) {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
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;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function saveMeter(path, state) {
|
|
65
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
66
|
+
writeFileSync(path, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
67
|
+
}
|
|
68
|
+
export function getCount(state, toolName) {
|
|
69
|
+
return state[toolName] ?? 0;
|
|
70
|
+
}
|
|
71
|
+
export function increment(state, toolName) {
|
|
72
|
+
return {
|
|
73
|
+
...state,
|
|
74
|
+
[toolName]: getCount(state, toolName) + 1
|
|
75
|
+
};
|
|
76
|
+
}
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type KeyObject } from "node:crypto";
|
|
2
|
+
export interface KeyPair {
|
|
3
|
+
publicKeyDer: string;
|
|
4
|
+
privateKeyDer: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function canonicalize(value: unknown): string;
|
|
7
|
+
export declare function sha256Hex(data: string): string;
|
|
8
|
+
export declare function hashObject(value: unknown): string;
|
|
9
|
+
export declare function generateKeyPair(): KeyPair;
|
|
10
|
+
export declare function publicKeyFromDer(publicKeyDer: string): KeyObject;
|
|
11
|
+
export declare function privateKeyFromDer(privateKeyDer: string): KeyObject;
|
|
12
|
+
export declare function publicKeyId(publicKeyDer: string): string;
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
|
|
2
|
+
function sortValue(value) {
|
|
3
|
+
if (Array.isArray(value)) {
|
|
4
|
+
return value.map(sortValue);
|
|
5
|
+
}
|
|
6
|
+
if (value !== null && typeof value === "object") {
|
|
7
|
+
const input = value;
|
|
8
|
+
const output = {};
|
|
9
|
+
for (const key of Object.keys(input).sort()) {
|
|
10
|
+
output[key] = sortValue(input[key]);
|
|
11
|
+
}
|
|
12
|
+
return output;
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
export function canonicalize(value) {
|
|
17
|
+
return JSON.stringify(sortValue(value));
|
|
18
|
+
}
|
|
19
|
+
export function sha256Hex(data) {
|
|
20
|
+
return createHash("sha256").update(data, "utf8").digest("hex");
|
|
21
|
+
}
|
|
22
|
+
export function hashObject(value) {
|
|
23
|
+
return sha256Hex(canonicalize(value));
|
|
24
|
+
}
|
|
25
|
+
export function generateKeyPair() {
|
|
26
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
27
|
+
return {
|
|
28
|
+
publicKeyDer: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
|
|
29
|
+
privateKeyDer: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64")
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function publicKeyFromDer(publicKeyDer) {
|
|
33
|
+
return createPublicKey({
|
|
34
|
+
key: Buffer.from(publicKeyDer, "base64"),
|
|
35
|
+
type: "spki",
|
|
36
|
+
format: "der"
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export function privateKeyFromDer(privateKeyDer) {
|
|
40
|
+
return createPrivateKey({
|
|
41
|
+
key: Buffer.from(privateKeyDer, "base64"),
|
|
42
|
+
type: "pkcs8",
|
|
43
|
+
format: "der"
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export function publicKeyId(publicKeyDer) {
|
|
47
|
+
return sha256Hex(publicKeyDer).slice(0, 16);
|
|
48
|
+
}
|
package/dist/grant.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Grant, GrantDecision, GrantSet } from "./types.js";
|
|
2
|
+
export declare const GRANT_REASON: {
|
|
3
|
+
readonly GRANTED: "GRANT_OK";
|
|
4
|
+
readonly AGENT_NOT_FOUND: "AGENT_NOT_FOUND";
|
|
5
|
+
readonly TOOL_NOT_GRANTED: "TOOL_NOT_GRANTED";
|
|
6
|
+
};
|
|
7
|
+
export interface GrantValidationResult {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
grantSet?: GrantSet;
|
|
10
|
+
errors: string[];
|
|
11
|
+
}
|
|
12
|
+
export declare function validateGrantSet(raw: unknown): GrantValidationResult;
|
|
13
|
+
export declare function loadGrants(path: string): GrantSet;
|
|
14
|
+
export declare function findGrant(grantSet: GrantSet, agentId: string): Grant | undefined;
|
|
15
|
+
export declare function checkGrant(grantSet: GrantSet, agentId: string, toolName: string): GrantDecision;
|
package/dist/grant.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
export const GRANT_REASON = {
|
|
5
|
+
GRANTED: "GRANT_OK",
|
|
6
|
+
AGENT_NOT_FOUND: "AGENT_NOT_FOUND",
|
|
7
|
+
TOOL_NOT_GRANTED: "TOOL_NOT_GRANTED",
|
|
8
|
+
};
|
|
9
|
+
function isObject(value) {
|
|
10
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function isNonEmptyString(value) {
|
|
13
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
14
|
+
}
|
|
15
|
+
export function validateGrantSet(raw) {
|
|
16
|
+
const errors = [];
|
|
17
|
+
if (!isObject(raw)) {
|
|
18
|
+
return {
|
|
19
|
+
ok: false,
|
|
20
|
+
errors: ["grant set must be an object"],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (!Array.isArray(raw.grants) || raw.grants.length === 0) {
|
|
24
|
+
errors.push("grants must be a non-empty array");
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const seenAgents = new Set();
|
|
28
|
+
raw.grants.forEach((grant, index) => {
|
|
29
|
+
const path = `grants[${index}]`;
|
|
30
|
+
if (!isObject(grant)) {
|
|
31
|
+
errors.push(`${path} must be an object`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!isNonEmptyString(grant.agentId)) {
|
|
35
|
+
errors.push(`${path}.agentId must be a non-empty string`);
|
|
36
|
+
}
|
|
37
|
+
else if (seenAgents.has(grant.agentId)) {
|
|
38
|
+
errors.push(`${path}.agentId must be unique`);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
seenAgents.add(grant.agentId);
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(grant.tools) ||
|
|
44
|
+
grant.tools.length === 0 ||
|
|
45
|
+
!grant.tools.every((tool) => isNonEmptyString(tool))) {
|
|
46
|
+
errors.push(`${path}.tools must be a non-empty array of non-empty strings`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (errors.length > 0) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
errors,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
grantSet: raw,
|
|
59
|
+
errors: [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function loadGrants(path) {
|
|
63
|
+
const source = readFileSync(path, "utf8");
|
|
64
|
+
const raw = extname(path).toLowerCase() === ".json" ? JSON.parse(source) : parseYaml(source);
|
|
65
|
+
const result = validateGrantSet(raw);
|
|
66
|
+
if (!result.ok || !result.grantSet) {
|
|
67
|
+
throw new Error(`Invalid grant set:\n - ${result.errors.join("\n - ")}`);
|
|
68
|
+
}
|
|
69
|
+
return result.grantSet;
|
|
70
|
+
}
|
|
71
|
+
export function findGrant(grantSet, agentId) {
|
|
72
|
+
return grantSet.grants.find((grant) => grant.agentId === agentId);
|
|
73
|
+
}
|
|
74
|
+
export function checkGrant(grantSet, agentId, toolName) {
|
|
75
|
+
const grant = findGrant(grantSet, agentId);
|
|
76
|
+
if (!grant) {
|
|
77
|
+
return {
|
|
78
|
+
granted: false,
|
|
79
|
+
reasonCode: GRANT_REASON.AGENT_NOT_FOUND,
|
|
80
|
+
agentId,
|
|
81
|
+
toolName,
|
|
82
|
+
detail: `no grant found for agent '${agentId}'`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (!grant.tools.includes(toolName)) {
|
|
86
|
+
return {
|
|
87
|
+
granted: false,
|
|
88
|
+
reasonCode: GRANT_REASON.TOOL_NOT_GRANTED,
|
|
89
|
+
agentId,
|
|
90
|
+
toolName,
|
|
91
|
+
detail: `agent '${agentId}' is not granted tool '${toolName}'`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
granted: true,
|
|
96
|
+
reasonCode: GRANT_REASON.GRANTED,
|
|
97
|
+
agentId,
|
|
98
|
+
toolName,
|
|
99
|
+
detail: `agent '${agentId}' is granted tool '${toolName}'`,
|
|
100
|
+
};
|
|
101
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { generateKeyPair } from "./crypto.js";
|
|
5
|
+
import { loadManifest } from "./manifest.js";
|
|
6
|
+
import { createReceipt, signManifest, verifySignedManifest } from "./signing.js";
|
|
7
|
+
import { admit, getCount, increment, loadMeter, saveMeter } from "./admit.js";
|
|
8
|
+
import { checkGrant, loadGrants } from "./grant.js";
|
|
9
|
+
const BESA_DIR = ".besa";
|
|
10
|
+
const KEY_PATH = join(BESA_DIR, "key.json");
|
|
11
|
+
const METER_PATH = join(BESA_DIR, "meter.json");
|
|
12
|
+
const ACTIVE_MANIFEST_PATH = join(BESA_DIR, "active-manifest.json");
|
|
13
|
+
const RECEIPTS_DIR = join(BESA_DIR, "receipts");
|
|
14
|
+
function readJson(path) {
|
|
15
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
16
|
+
}
|
|
17
|
+
function writeJson(path, value) {
|
|
18
|
+
writeFileSync(path, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
19
|
+
}
|
|
20
|
+
function ensureBesaDir() {
|
|
21
|
+
mkdirSync(BESA_DIR, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
function loadOrCreateKeyPair() {
|
|
24
|
+
ensureBesaDir();
|
|
25
|
+
if (existsSync(KEY_PATH)) {
|
|
26
|
+
return readJson(KEY_PATH);
|
|
27
|
+
}
|
|
28
|
+
const keypair = generateKeyPair();
|
|
29
|
+
writeJson(KEY_PATH, keypair);
|
|
30
|
+
return keypair;
|
|
31
|
+
}
|
|
32
|
+
function printJson(label, value) {
|
|
33
|
+
console.log("");
|
|
34
|
+
console.log(label + ":");
|
|
35
|
+
console.log(JSON.stringify(value, null, 2));
|
|
36
|
+
}
|
|
37
|
+
function signedOutPath(manifestPath) {
|
|
38
|
+
if (manifestPath.endsWith(".yaml")) {
|
|
39
|
+
return manifestPath.slice(0, -5) + ".signed.json";
|
|
40
|
+
}
|
|
41
|
+
if (manifestPath.endsWith(".yml")) {
|
|
42
|
+
return manifestPath.slice(0, -4) + ".signed.json";
|
|
43
|
+
}
|
|
44
|
+
if (manifestPath.endsWith(".json")) {
|
|
45
|
+
return manifestPath.slice(0, -5) + ".signed.json";
|
|
46
|
+
}
|
|
47
|
+
return manifestPath + ".signed.json";
|
|
48
|
+
}
|
|
49
|
+
function flagValue(name) {
|
|
50
|
+
const index = process.argv.indexOf(name);
|
|
51
|
+
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
52
|
+
}
|
|
53
|
+
function positionals(args) {
|
|
54
|
+
const values = [];
|
|
55
|
+
const flagsWithValues = new Set(["--agent", "--grants"]);
|
|
56
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
57
|
+
const value = args[index];
|
|
58
|
+
if (value && flagsWithValues.has(value)) {
|
|
59
|
+
index += 1;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (value) {
|
|
63
|
+
values.push(value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return values;
|
|
67
|
+
}
|
|
68
|
+
function cmdKeys() {
|
|
69
|
+
const keypair = loadOrCreateKeyPair();
|
|
70
|
+
printJson("keypair", {
|
|
71
|
+
publicKeyDer: keypair.publicKeyDer,
|
|
72
|
+
privateKeyDerPath: KEY_PATH,
|
|
73
|
+
});
|
|
74
|
+
console.log("");
|
|
75
|
+
console.log("OK: keypair ready at " + KEY_PATH);
|
|
76
|
+
}
|
|
77
|
+
function cmdLoad(file) {
|
|
78
|
+
const manifest = loadManifest(file);
|
|
79
|
+
printJson("manifest", manifest);
|
|
80
|
+
console.log("");
|
|
81
|
+
console.log("OK: loaded " + String(manifest.tools.length) + " tool(s) from " + file);
|
|
82
|
+
}
|
|
83
|
+
function cmdSign(file) {
|
|
84
|
+
const manifest = loadManifest(file);
|
|
85
|
+
const keypair = loadOrCreateKeyPair();
|
|
86
|
+
const signed = signManifest(manifest, keypair);
|
|
87
|
+
const out = signedOutPath(file);
|
|
88
|
+
writeJson(out, signed);
|
|
89
|
+
ensureBesaDir();
|
|
90
|
+
writeJson(ACTIVE_MANIFEST_PATH, signed);
|
|
91
|
+
printJson("signedManifest", signed);
|
|
92
|
+
console.log("");
|
|
93
|
+
console.log("OK: signed -> " + out + " with publicKeyId " + signed.publicKeyId);
|
|
94
|
+
}
|
|
95
|
+
function cmdVerify(file) {
|
|
96
|
+
const signed = readJson(file);
|
|
97
|
+
const result = verifySignedManifest(signed);
|
|
98
|
+
printJson("verify", result);
|
|
99
|
+
if (!result.valid) {
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
console.log("");
|
|
102
|
+
console.log("DENY: " + result.reasonCode);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
console.log("");
|
|
106
|
+
console.log("OK: " + result.detail);
|
|
107
|
+
}
|
|
108
|
+
function denyFromVerification(toolName, reasonCode, detail) {
|
|
109
|
+
return {
|
|
110
|
+
decision: "deny",
|
|
111
|
+
reasonCode,
|
|
112
|
+
toolName,
|
|
113
|
+
detail,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export function grantGate(toolName) {
|
|
117
|
+
const grantsPath = flagValue("--grants");
|
|
118
|
+
if (!grantsPath) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
const agentId = flagValue("--agent") ?? "";
|
|
122
|
+
const grant = checkGrant(loadGrants(grantsPath), agentId, toolName);
|
|
123
|
+
return {
|
|
124
|
+
decision: grant.granted ? "allow" : "deny",
|
|
125
|
+
reasonCode: grant.reasonCode,
|
|
126
|
+
toolName,
|
|
127
|
+
detail: grant.detail,
|
|
128
|
+
agentId,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function cmdAdmit(file, toolName) {
|
|
132
|
+
const signed = readJson(file);
|
|
133
|
+
const verified = verifySignedManifest(signed);
|
|
134
|
+
if (!verified.valid) {
|
|
135
|
+
const denied = denyFromVerification(toolName, verified.reasonCode, verified.detail);
|
|
136
|
+
printJson("admission", denied);
|
|
137
|
+
process.exitCode = 1;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const grantDecision = grantGate(toolName);
|
|
141
|
+
if (grantDecision && grantDecision.decision === "deny") {
|
|
142
|
+
printJson("admission", grantDecision);
|
|
143
|
+
process.exitCode = 1;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const meter = loadMeter(METER_PATH);
|
|
147
|
+
const count = getCount(meter, toolName);
|
|
148
|
+
const decision = admit(signed.manifest, toolName, count);
|
|
149
|
+
if (grantDecision?.agentId) {
|
|
150
|
+
decision.agentId = grantDecision.agentId;
|
|
151
|
+
}
|
|
152
|
+
printJson("admission", decision);
|
|
153
|
+
if (decision.decision === "deny") {
|
|
154
|
+
process.exitCode = 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function cmdReceipt(toolName, file) {
|
|
158
|
+
const signedPath = file ?? ACTIVE_MANIFEST_PATH;
|
|
159
|
+
if (!existsSync(signedPath)) {
|
|
160
|
+
throw new Error("no signed manifest found at " + signedPath + "; run besa sign <manifest> first");
|
|
161
|
+
}
|
|
162
|
+
const signed = readJson(signedPath);
|
|
163
|
+
const keypair = loadOrCreateKeyPair();
|
|
164
|
+
const verified = verifySignedManifest(signed);
|
|
165
|
+
let decision;
|
|
166
|
+
let grantReasonCode;
|
|
167
|
+
if (!verified.valid) {
|
|
168
|
+
decision = denyFromVerification(toolName, verified.reasonCode, verified.detail);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const grantDecision = grantGate(toolName);
|
|
172
|
+
grantReasonCode = grantDecision?.reasonCode;
|
|
173
|
+
if (grantDecision && grantDecision.decision === "deny") {
|
|
174
|
+
decision = grantDecision;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const meter = loadMeter(METER_PATH);
|
|
178
|
+
decision = admit(signed.manifest, toolName, getCount(meter, toolName));
|
|
179
|
+
if (grantDecision?.agentId) {
|
|
180
|
+
decision.agentId = grantDecision.agentId;
|
|
181
|
+
}
|
|
182
|
+
if (decision.decision === "allow") {
|
|
183
|
+
saveMeter(METER_PATH, increment(meter, toolName));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const receipt = createReceipt({
|
|
188
|
+
manifestHash: signed.manifestHash,
|
|
189
|
+
toolName,
|
|
190
|
+
decision: decision.decision,
|
|
191
|
+
reasonCode: decision.reasonCode,
|
|
192
|
+
request: {
|
|
193
|
+
toolName,
|
|
194
|
+
signedManifest: signedPath,
|
|
195
|
+
},
|
|
196
|
+
agentId: decision.agentId,
|
|
197
|
+
grantReasonCode,
|
|
198
|
+
}, keypair);
|
|
199
|
+
mkdirSync(RECEIPTS_DIR, { recursive: true });
|
|
200
|
+
const receiptPath = join(RECEIPTS_DIR, receipt.receiptId + ".json");
|
|
201
|
+
writeJson(receiptPath, receipt);
|
|
202
|
+
printJson("receipt", receipt);
|
|
203
|
+
console.log("");
|
|
204
|
+
console.log(decision.decision.toUpperCase() + ": " + decision.reasonCode + " -> " + receiptPath);
|
|
205
|
+
if (decision.decision === "deny") {
|
|
206
|
+
process.exitCode = 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function usage() {
|
|
210
|
+
console.log([
|
|
211
|
+
"Besa - signed trust infrastructure for AI-agent tools",
|
|
212
|
+
"",
|
|
213
|
+
"Usage:",
|
|
214
|
+
" besa keys",
|
|
215
|
+
" besa load <manifest.yaml>",
|
|
216
|
+
" besa sign <manifest.yaml>",
|
|
217
|
+
" besa verify <manifest.signed.json>",
|
|
218
|
+
" besa admit <manifest.signed.json> <tool-name> [--agent <agent-id> --grants <grants.yaml>]",
|
|
219
|
+
" besa receipt <tool-name> [manifest.signed.json] [--agent <agent-id> --grants <grants.yaml>]",
|
|
220
|
+
"",
|
|
221
|
+
"Examples:",
|
|
222
|
+
" besa keys",
|
|
223
|
+
" besa load examples/manifest.yaml",
|
|
224
|
+
" besa sign examples/manifest.yaml",
|
|
225
|
+
" besa verify examples/manifest.signed.json",
|
|
226
|
+
" besa admit examples/manifest.signed.json crm.lookup",
|
|
227
|
+
" besa admit examples/manifest.signed.json crm.lookup --agent agent-alpha --grants examples/grants.yaml",
|
|
228
|
+
" besa admit examples/manifest.signed.json crm.delete --agent agent-alpha --grants examples/grants.yaml",
|
|
229
|
+
" besa receipt crm.lookup examples/manifest.signed.json",
|
|
230
|
+
" besa receipt crm.lookup examples/manifest.signed.json --agent agent-alpha --grants examples/grants.yaml",
|
|
231
|
+
].join("\n"));
|
|
232
|
+
}
|
|
233
|
+
function requireArgs(args, expected, command) {
|
|
234
|
+
if (args.length < expected) {
|
|
235
|
+
throw new Error(command + " requires " + String(expected) + " argument(s)");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function main(argv) {
|
|
239
|
+
const command = argv[0] ?? "";
|
|
240
|
+
const args = positionals(argv.slice(1));
|
|
241
|
+
try {
|
|
242
|
+
switch (command) {
|
|
243
|
+
case "keys":
|
|
244
|
+
cmdKeys();
|
|
245
|
+
break;
|
|
246
|
+
case "load":
|
|
247
|
+
requireArgs(args, 1, command);
|
|
248
|
+
cmdLoad(args[0] ?? "");
|
|
249
|
+
break;
|
|
250
|
+
case "sign":
|
|
251
|
+
requireArgs(args, 1, command);
|
|
252
|
+
cmdSign(args[0] ?? "");
|
|
253
|
+
break;
|
|
254
|
+
case "verify":
|
|
255
|
+
requireArgs(args, 1, command);
|
|
256
|
+
cmdVerify(args[0] ?? "");
|
|
257
|
+
break;
|
|
258
|
+
case "admit":
|
|
259
|
+
requireArgs(args, 2, command);
|
|
260
|
+
cmdAdmit(args[0] ?? "", args[1] ?? "");
|
|
261
|
+
break;
|
|
262
|
+
case "receipt":
|
|
263
|
+
requireArgs(args, 1, command);
|
|
264
|
+
cmdReceipt(args[0] ?? "", args[1]);
|
|
265
|
+
break;
|
|
266
|
+
case "":
|
|
267
|
+
case "help":
|
|
268
|
+
case "--help":
|
|
269
|
+
case "-h":
|
|
270
|
+
usage();
|
|
271
|
+
break;
|
|
272
|
+
default:
|
|
273
|
+
console.error("Unknown command: " + command);
|
|
274
|
+
usage();
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
281
|
+
console.error("Error: " + message);
|
|
282
|
+
process.exitCode = 1;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
main(process.argv.slice(2));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Manifest } from "./types.js";
|
|
2
|
+
export interface ValidationResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
manifest?: Manifest;
|
|
5
|
+
errors: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare function validateManifest(raw: unknown): ValidationResult;
|
|
8
|
+
export declare function loadManifest(path: string): Manifest;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
const CAPABILITIES = ["read", "write", "destructive"];
|
|
5
|
+
const RISKS = ["low", "medium", "high"];
|
|
6
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
7
|
+
function isObject(value) {
|
|
8
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function isNonEmptyString(value) {
|
|
11
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
12
|
+
}
|
|
13
|
+
function isHttpUrl(value) {
|
|
14
|
+
if (typeof value !== "string")
|
|
15
|
+
return false;
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(value);
|
|
18
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function isIsoDate(value) {
|
|
25
|
+
return (typeof value === "string" &&
|
|
26
|
+
ISO_DATE.test(value) &&
|
|
27
|
+
!Number.isNaN(Date.parse(value)));
|
|
28
|
+
}
|
|
29
|
+
export function validateManifest(raw) {
|
|
30
|
+
const errors = [];
|
|
31
|
+
if (!isObject(raw)) {
|
|
32
|
+
return { ok: false, errors: ["manifest must be an object"] };
|
|
33
|
+
}
|
|
34
|
+
if (!isNonEmptyString(raw.serverName)) {
|
|
35
|
+
errors.push("serverName must be a non-empty string");
|
|
36
|
+
}
|
|
37
|
+
if (!isNonEmptyString(raw.serverVersion)) {
|
|
38
|
+
errors.push("serverVersion must be a non-empty string");
|
|
39
|
+
}
|
|
40
|
+
if (!isHttpUrl(raw.serverUrl)) {
|
|
41
|
+
errors.push("serverUrl must be a valid http(s) URL");
|
|
42
|
+
}
|
|
43
|
+
if (!isIsoDate(raw.createdAt)) {
|
|
44
|
+
errors.push("createdAt must be a valid ISO-8601 date-time");
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(raw.tools) || raw.tools.length === 0) {
|
|
47
|
+
errors.push("tools must be a non-empty array");
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
raw.tools.forEach((tool, index) => {
|
|
52
|
+
validateTool(tool, index, errors);
|
|
53
|
+
if (isObject(tool) && typeof tool.name === "string") {
|
|
54
|
+
if (seen.has(tool.name)) {
|
|
55
|
+
errors.push(`tools[${index}].name '${tool.name}' is a duplicate tool name`);
|
|
56
|
+
}
|
|
57
|
+
seen.add(tool.name);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (errors.length > 0) {
|
|
62
|
+
return { ok: false, errors };
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, manifest: raw, errors: [] };
|
|
65
|
+
}
|
|
66
|
+
function validateTool(tool, index, errors) {
|
|
67
|
+
const path = `tools[${index}]`;
|
|
68
|
+
if (!isObject(tool)) {
|
|
69
|
+
errors.push(`${path} must be an object`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!isNonEmptyString(tool.name)) {
|
|
73
|
+
errors.push(`${path}.name must be a non-empty string`);
|
|
74
|
+
}
|
|
75
|
+
if (typeof tool.description !== "string") {
|
|
76
|
+
errors.push(`${path}.description must be a string`);
|
|
77
|
+
}
|
|
78
|
+
if (!CAPABILITIES.includes(tool.capability)) {
|
|
79
|
+
errors.push(`${path}.capability must be one of ${CAPABILITIES.join(", ")}`);
|
|
80
|
+
}
|
|
81
|
+
if (!RISKS.includes(tool.risk)) {
|
|
82
|
+
errors.push(`${path}.risk must be one of ${RISKS.join(", ")}`);
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(tool.scopes) ||
|
|
85
|
+
tool.scopes.length === 0 ||
|
|
86
|
+
!tool.scopes.every((scope) => isNonEmptyString(scope))) {
|
|
87
|
+
errors.push(`${path}.scopes must be a non-empty array of non-empty strings`);
|
|
88
|
+
}
|
|
89
|
+
if (typeof tool.budgetLimit !== "number" ||
|
|
90
|
+
!Number.isSafeInteger(tool.budgetLimit) ||
|
|
91
|
+
tool.budgetLimit < 0) {
|
|
92
|
+
errors.push(`${path}.budgetLimit must be a safe non-negative integer`);
|
|
93
|
+
}
|
|
94
|
+
if (!isObject(tool.inputSchema)) {
|
|
95
|
+
errors.push(`${path}.inputSchema must be an object`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function loadManifest(path) {
|
|
99
|
+
const source = readFileSync(path, "utf8");
|
|
100
|
+
const raw = extname(path) === ".json" ? JSON.parse(source) : parseYaml(source);
|
|
101
|
+
const result = validateManifest(raw);
|
|
102
|
+
if (!result.ok || !result.manifest) {
|
|
103
|
+
throw new Error(`Invalid manifest:\n - ${result.errors.join("\n - ")}`);
|
|
104
|
+
}
|
|
105
|
+
return result.manifest;
|
|
106
|
+
}
|
package/dist/sdk.d.ts
ADDED
package/dist/sdk.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Decision, Manifest, Receipt, SignedManifest } from "./types.js";
|
|
2
|
+
import { type KeyPair } from "./crypto.js";
|
|
3
|
+
export interface VerifyResult {
|
|
4
|
+
valid: boolean;
|
|
5
|
+
reasonCode: string;
|
|
6
|
+
detail: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ReceiptInput {
|
|
9
|
+
manifestHash: string;
|
|
10
|
+
toolName: string;
|
|
11
|
+
decision: Decision;
|
|
12
|
+
reasonCode: string;
|
|
13
|
+
request: unknown;
|
|
14
|
+
agentId?: string;
|
|
15
|
+
grantReasonCode?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function hashManifest(manifest: Manifest): string;
|
|
18
|
+
export declare function signManifest(manifest: Manifest, keypair: KeyPair): SignedManifest;
|
|
19
|
+
export declare function verifySignedManifest(signed: SignedManifest): VerifyResult;
|
|
20
|
+
export declare function createReceipt(input: ReceiptInput, keypair: KeyPair): Receipt;
|
|
21
|
+
export declare function verifyReceipt(receipt: Receipt, publicKeyDer: string): boolean;
|
package/dist/signing.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { randomUUID, sign as ed25519Sign, verify as ed25519Verify } from "node:crypto";
|
|
2
|
+
import { canonicalize, hashObject, privateKeyFromDer, publicKeyFromDer, publicKeyId } from "./crypto.js";
|
|
3
|
+
export function hashManifest(manifest) {
|
|
4
|
+
return hashObject(manifest);
|
|
5
|
+
}
|
|
6
|
+
export function signManifest(manifest, keypair) {
|
|
7
|
+
const canonical = canonicalize(manifest);
|
|
8
|
+
const signature = ed25519Sign(null, Buffer.from(canonical, "utf8"), privateKeyFromDer(keypair.privateKeyDer));
|
|
9
|
+
return {
|
|
10
|
+
manifest,
|
|
11
|
+
manifestHash: hashManifest(manifest),
|
|
12
|
+
algorithm: "ed25519",
|
|
13
|
+
publicKey: keypair.publicKeyDer,
|
|
14
|
+
publicKeyId: publicKeyId(keypair.publicKeyDer),
|
|
15
|
+
signature: signature.toString("base64"),
|
|
16
|
+
signedAt: new Date().toISOString()
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function verifySignedManifest(signed) {
|
|
20
|
+
if (signed.algorithm !== "ed25519") {
|
|
21
|
+
return {
|
|
22
|
+
valid: false,
|
|
23
|
+
reasonCode: "E_ALGORITHM_UNSUPPORTED",
|
|
24
|
+
detail: "only ed25519 signed manifests are supported"
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const canonical = canonicalize(signed.manifest);
|
|
28
|
+
const expectedHash = hashManifest(signed.manifest);
|
|
29
|
+
if (expectedHash !== signed.manifestHash) {
|
|
30
|
+
return {
|
|
31
|
+
valid: false,
|
|
32
|
+
reasonCode: "E_MANIFEST_HASH_MISMATCH",
|
|
33
|
+
detail: "manifest content does not match stored hash"
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (publicKeyId(signed.publicKey) !== signed.publicKeyId) {
|
|
37
|
+
return {
|
|
38
|
+
valid: false,
|
|
39
|
+
reasonCode: "E_PUBLIC_KEY_ID_MISMATCH",
|
|
40
|
+
detail: "publicKeyId does not match publicKey"
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const valid = ed25519Verify(null, Buffer.from(canonical, "utf8"), publicKeyFromDer(signed.publicKey), Buffer.from(signed.signature, "base64"));
|
|
45
|
+
if (!valid) {
|
|
46
|
+
return {
|
|
47
|
+
valid: false,
|
|
48
|
+
reasonCode: "E_SIGNATURE_INVALID",
|
|
49
|
+
detail: "signature does not verify against the public key"
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
valid: true,
|
|
54
|
+
reasonCode: "OK",
|
|
55
|
+
detail: "manifest signature is valid"
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return {
|
|
60
|
+
valid: false,
|
|
61
|
+
reasonCode: "E_SIGNATURE_CHECK_FAILED",
|
|
62
|
+
detail: "signature verification failed"
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function createReceipt(input, keypair) {
|
|
67
|
+
const body = {
|
|
68
|
+
receiptId: "rcpt_" + randomUUID(),
|
|
69
|
+
manifestHash: input.manifestHash,
|
|
70
|
+
toolName: input.toolName,
|
|
71
|
+
decision: input.decision,
|
|
72
|
+
reasonCode: input.reasonCode,
|
|
73
|
+
timestamp: new Date().toISOString(),
|
|
74
|
+
requestHash: hashObject(input.request ?? {}),
|
|
75
|
+
agentId: input.agentId,
|
|
76
|
+
grantReasonCode: input.grantReasonCode,
|
|
77
|
+
publicKeyId: publicKeyId(keypair.publicKeyDer),
|
|
78
|
+
algorithm: "ed25519"
|
|
79
|
+
};
|
|
80
|
+
const signature = ed25519Sign(null, Buffer.from(canonicalize(body), "utf8"), privateKeyFromDer(keypair.privateKeyDer));
|
|
81
|
+
return {
|
|
82
|
+
...body,
|
|
83
|
+
signature: signature.toString("base64")
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function verifyReceipt(receipt, publicKeyDer) {
|
|
87
|
+
if (receipt.algorithm !== "ed25519") {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (publicKeyId(publicKeyDer) !== receipt.publicKeyId) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const { signature, ...body } = receipt;
|
|
94
|
+
try {
|
|
95
|
+
return ed25519Verify(null, Buffer.from(canonicalize(body), "utf8"), publicKeyFromDer(publicKeyDer), Buffer.from(signature, "base64"));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type CapabilityType = "read" | "write" | "destructive";
|
|
2
|
+
export type RiskLevel = "low" | "medium" | "high";
|
|
3
|
+
export type Decision = "allow" | "deny";
|
|
4
|
+
export interface ToolDefinition {
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
capability: CapabilityType;
|
|
8
|
+
risk: RiskLevel;
|
|
9
|
+
scopes: string[];
|
|
10
|
+
budgetLimit: number;
|
|
11
|
+
inputSchema: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
export interface Manifest {
|
|
14
|
+
serverName: string;
|
|
15
|
+
serverVersion: string;
|
|
16
|
+
serverUrl: string;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
tools: ToolDefinition[];
|
|
19
|
+
}
|
|
20
|
+
export interface SignedManifest {
|
|
21
|
+
manifest: Manifest;
|
|
22
|
+
manifestHash: string;
|
|
23
|
+
algorithm: "ed25519";
|
|
24
|
+
publicKey: string;
|
|
25
|
+
publicKeyId: string;
|
|
26
|
+
signature: string;
|
|
27
|
+
signedAt: string;
|
|
28
|
+
}
|
|
29
|
+
export interface AdmissionDecision {
|
|
30
|
+
decision: Decision;
|
|
31
|
+
reasonCode: string;
|
|
32
|
+
toolName: string;
|
|
33
|
+
detail: string;
|
|
34
|
+
agentId?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface Receipt {
|
|
37
|
+
receiptId: string;
|
|
38
|
+
manifestHash: string;
|
|
39
|
+
toolName: string;
|
|
40
|
+
decision: Decision;
|
|
41
|
+
reasonCode: string;
|
|
42
|
+
timestamp: string;
|
|
43
|
+
requestHash: string;
|
|
44
|
+
publicKeyId: string;
|
|
45
|
+
algorithm: "ed25519";
|
|
46
|
+
agentId?: string;
|
|
47
|
+
grantReasonCode?: string;
|
|
48
|
+
signature: string;
|
|
49
|
+
}
|
|
50
|
+
export interface Grant {
|
|
51
|
+
agentId: string;
|
|
52
|
+
tools: string[];
|
|
53
|
+
}
|
|
54
|
+
export interface GrantSet {
|
|
55
|
+
grants: Grant[];
|
|
56
|
+
}
|
|
57
|
+
export interface GrantDecision {
|
|
58
|
+
granted: boolean;
|
|
59
|
+
reasonCode: string;
|
|
60
|
+
agentId: string;
|
|
61
|
+
toolName: string;
|
|
62
|
+
detail: string;
|
|
63
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
serverName: acme-crm
|
|
2
|
+
serverVersion: 1.0.0
|
|
3
|
+
serverUrl: https://tools.acme.example/mcp
|
|
4
|
+
createdAt: 2026-06-12T00:00:00Z
|
|
5
|
+
tools:
|
|
6
|
+
- name: crm.lookup
|
|
7
|
+
description: Look up a customer record by id or email.
|
|
8
|
+
capability: read
|
|
9
|
+
risk: low
|
|
10
|
+
scopes:
|
|
11
|
+
- crm:read
|
|
12
|
+
budgetLimit: 100
|
|
13
|
+
inputSchema:
|
|
14
|
+
type: object
|
|
15
|
+
properties:
|
|
16
|
+
query:
|
|
17
|
+
type: string
|
|
18
|
+
required:
|
|
19
|
+
- query
|
|
20
|
+
- name: crm.delete
|
|
21
|
+
description: Permanently delete a customer record.
|
|
22
|
+
capability: destructive
|
|
23
|
+
risk: high
|
|
24
|
+
scopes:
|
|
25
|
+
- crm:write
|
|
26
|
+
- crm:admin
|
|
27
|
+
budgetLimit: 5
|
|
28
|
+
inputSchema:
|
|
29
|
+
type: object
|
|
30
|
+
properties:
|
|
31
|
+
id:
|
|
32
|
+
type: string
|
|
33
|
+
required:
|
|
34
|
+
- id
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dorigjo/besa",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Signed trust infrastructure for AI-agent tools.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/sdk.js",
|
|
7
|
+
"types": "./dist/sdk.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/sdk.d.ts",
|
|
11
|
+
"default": "./dist/sdk.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"besa": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"test": "npm run build && node --test dist/tests/*.test.js",
|
|
20
|
+
"smoke": "node scripts/smoke.mjs"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"ai-agents",
|
|
25
|
+
"trust",
|
|
26
|
+
"ed25519",
|
|
27
|
+
"signing",
|
|
28
|
+
"manifest",
|
|
29
|
+
"receipt"
|
|
30
|
+
],
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/dorigjo/besa.git"
|
|
34
|
+
},
|
|
35
|
+
"author": "dorigjo",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"yaml": "^2.5.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^24.0.0",
|
|
42
|
+
"typescript": "^5.6.2"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20"
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"dist/*.js",
|
|
49
|
+
"dist/*.d.ts",
|
|
50
|
+
"examples/manifest.yaml",
|
|
51
|
+
"examples/grants.yaml"
|
|
52
|
+
],
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/dorigjo/besa/issues"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/dorigjo/besa#readme"
|
|
57
|
+
}
|