@vellumai/credential-executor 0.10.7-dev.202607102035.64f07ea → 0.10.7-staging.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.
Files changed (65) hide show
  1. package/Dockerfile +1 -1
  2. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  3. package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +2 -0
  4. package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +686 -0
  5. package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +5 -3
  6. package/node_modules/@vellumai/service-contracts/src/grants.ts +184 -0
  7. package/node_modules/@vellumai/service-contracts/src/index.ts +4 -2
  8. package/node_modules/@vellumai/service-contracts/src/rendering.ts +135 -0
  9. package/node_modules/@vellumai/service-contracts/src/rpc.ts +447 -4
  10. package/package.json +3 -2
  11. package/src/__tests__/bulk-set-credentials.test.ts +1 -1
  12. package/src/__tests__/command-executor.test.ts +1879 -0
  13. package/src/__tests__/command-validator.test.ts +1405 -0
  14. package/src/__tests__/command-workspace.test.ts +1050 -0
  15. package/src/__tests__/grant-store.test.ts +689 -0
  16. package/src/__tests__/http-executor.test.ts +1336 -0
  17. package/src/__tests__/http-policy.test.ts +1069 -0
  18. package/src/__tests__/local-materializers.test.ts +860 -0
  19. package/src/__tests__/local-standalone.test.ts +36 -5
  20. package/src/__tests__/local-token-refresh.test.ts +361 -0
  21. package/src/__tests__/manage-secure-command-tool.test.ts +134 -0
  22. package/src/__tests__/managed-integration.test.ts +91 -112
  23. package/src/__tests__/managed-lazy-getters.test.ts +359 -0
  24. package/src/__tests__/managed-materializers.test.ts +1028 -0
  25. package/src/__tests__/managed-reconnect.test.ts +2 -2
  26. package/src/__tests__/managed-rejection.test.ts +43 -0
  27. package/src/__tests__/toolstore.test.ts +773 -0
  28. package/src/__tests__/transport.test.ts +27 -23
  29. package/src/audit/store.ts +188 -0
  30. package/src/cli.ts +1 -1
  31. package/src/commands/auth-adapters.ts +169 -0
  32. package/src/commands/egress-hooks.ts +203 -0
  33. package/src/commands/executor.ts +1155 -0
  34. package/src/commands/output-scan.ts +157 -0
  35. package/src/commands/profiles.ts +286 -0
  36. package/src/commands/validator.ts +702 -0
  37. package/src/commands/workspace.ts +550 -0
  38. package/src/grants/index.ts +17 -0
  39. package/src/grants/persistent-store.ts +309 -0
  40. package/src/grants/rpc-handlers.ts +293 -0
  41. package/src/grants/temporary-store.ts +289 -0
  42. package/src/http/audit.ts +84 -0
  43. package/src/http/executor.ts +684 -0
  44. package/src/http/path-template.ts +245 -0
  45. package/src/http/policy.ts +238 -0
  46. package/src/http/response-filter.ts +233 -0
  47. package/src/index.ts +88 -8
  48. package/src/main.ts +340 -228
  49. package/src/managed-errors.ts +9 -0
  50. package/src/managed-lazy-getters.ts +106 -0
  51. package/src/managed-main.ts +822 -0
  52. package/src/materializers/local-oauth-lookup.ts +98 -0
  53. package/src/materializers/local-token-refresh.ts +287 -0
  54. package/src/materializers/local.ts +316 -0
  55. package/src/materializers/managed-platform.ts +295 -0
  56. package/src/paths.ts +20 -4
  57. package/src/server.ts +469 -52
  58. package/src/subjects/local.ts +177 -0
  59. package/src/subjects/managed.ts +311 -0
  60. package/src/subjects/policy.ts +79 -0
  61. package/src/toolstore/integrity.ts +94 -0
  62. package/src/toolstore/manifest.ts +154 -0
  63. package/src/toolstore/publish.ts +571 -0
  64. package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +0 -104
  65. package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +0 -118
@@ -0,0 +1,177 @@
1
+ /**
2
+ * CES local subject resolution.
3
+ *
4
+ * Resolves CES credential handles to their underlying storage subjects
5
+ * using the shared `@vellumai/credential-storage` primitives. This module
6
+ * is the CES-side counterpart to the assistant's credential resolver, but
7
+ * operates independently — it never imports from the assistant daemon.
8
+ *
9
+ * Subject resolution is the first phase of credential materialisation:
10
+ * 1. Parse the handle (via `@vellumai/service-contracts`)
11
+ * 2. Look up the metadata/connection record in local storage
12
+ * 3. Return a resolved subject that the materialiser can consume
13
+ *
14
+ * Both `local_static` and `local_oauth` handle types are supported.
15
+ * Unknown or disconnected handles fail before any outbound work starts.
16
+ */
17
+
18
+ import {
19
+ credentialKey,
20
+ type OAuthConnectionRecord,
21
+ type StaticCredentialRecord,
22
+ StaticCredentialMetadataStore,
23
+ } from "@vellumai/credential-storage";
24
+ import {
25
+ HandleType,
26
+ parseHandle,
27
+ type LocalOAuthHandle,
28
+ type LocalStaticHandle,
29
+ } from "@vellumai/service-contracts/credential-rpc";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Resolved subject types
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * A resolved local static credential subject. Contains the metadata record
37
+ * and the secure-key storage key needed to materialise the secret value.
38
+ */
39
+ export interface ResolvedStaticSubject {
40
+ type: typeof HandleType.LocalStatic;
41
+ /** The parsed handle. */
42
+ handle: LocalStaticHandle;
43
+ /** Non-secret metadata record from the credential store. */
44
+ metadata: StaticCredentialRecord;
45
+ /** Secure-key path where the secret value is stored. */
46
+ storageKey: string;
47
+ }
48
+
49
+ /**
50
+ * A resolved local OAuth credential subject. Contains the connection record
51
+ * and the connection ID needed to materialise the access token.
52
+ */
53
+ export interface ResolvedOAuthSubject {
54
+ type: typeof HandleType.LocalOAuth;
55
+ /** The parsed handle. */
56
+ handle: LocalOAuthHandle;
57
+ /** OAuth connection record from local persistence. */
58
+ connection: OAuthConnectionRecord;
59
+ }
60
+
61
+ export type ResolvedLocalSubject =
62
+ | ResolvedStaticSubject
63
+ | ResolvedOAuthSubject;
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Resolution result
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export type SubjectResolutionResult =
70
+ | { ok: true; subject: ResolvedLocalSubject }
71
+ | { ok: false; error: string };
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // OAuth connection lookup abstraction
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Abstraction for looking up local OAuth connection records.
79
+ *
80
+ * CES does not import the assistant's SQLite-backed oauth-store. Instead,
81
+ * callers provide a lightweight lookup interface that can be backed by
82
+ * any persistence mechanism (JSON file, SQLite, in-memory map).
83
+ */
84
+ export interface OAuthConnectionLookup {
85
+ /** Look up a connection by its ID. Returns undefined if not found. */
86
+ getById(connectionId: string): OAuthConnectionRecord | undefined;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Local subject resolver
91
+ // ---------------------------------------------------------------------------
92
+
93
+ export interface LocalSubjectResolverDeps {
94
+ /** Metadata store for local static credentials. */
95
+ metadataStore: StaticCredentialMetadataStore;
96
+ /** Lookup for local OAuth connections. */
97
+ oauthConnections: OAuthConnectionLookup;
98
+ }
99
+
100
+ /**
101
+ * Resolve a CES credential handle to a local subject.
102
+ *
103
+ * Supports `local_static` and `local_oauth` handle types. Returns a
104
+ * discriminated result so callers can inspect errors without catching
105
+ * exceptions.
106
+ *
107
+ * Resolution is fail-closed: unknown handle types, missing metadata,
108
+ * and disconnected OAuth connections all return errors before any
109
+ * outbound work starts.
110
+ */
111
+ export function resolveLocalSubject(
112
+ rawHandle: string,
113
+ deps: LocalSubjectResolverDeps,
114
+ ): SubjectResolutionResult {
115
+ const parseResult = parseHandle(rawHandle);
116
+ if (!parseResult.ok) {
117
+ return { ok: false, error: parseResult.error };
118
+ }
119
+
120
+ const parsed = parseResult.handle;
121
+
122
+ switch (parsed.type) {
123
+ case HandleType.LocalStatic: {
124
+ const metadata = deps.metadataStore.getByServiceField(
125
+ parsed.service,
126
+ parsed.field,
127
+ );
128
+ if (!metadata) {
129
+ return {
130
+ ok: false,
131
+ error: `No local static credential found for service="${parsed.service}", field="${parsed.field}"`,
132
+ };
133
+ }
134
+ const storageKey = credentialKey(parsed.service, parsed.field);
135
+ return {
136
+ ok: true,
137
+ subject: {
138
+ type: HandleType.LocalStatic,
139
+ handle: parsed,
140
+ metadata,
141
+ storageKey,
142
+ },
143
+ };
144
+ }
145
+
146
+ case HandleType.LocalOAuth: {
147
+ const connection = deps.oauthConnections.getById(parsed.connectionId);
148
+ if (!connection) {
149
+ return {
150
+ ok: false,
151
+ error: `No local OAuth connection found for connectionId="${parsed.connectionId}"`,
152
+ };
153
+ }
154
+ // Verify the provider key matches the connection's provider
155
+ if (connection.providerKey !== parsed.providerKey) {
156
+ return {
157
+ ok: false,
158
+ error: `OAuth connection "${parsed.connectionId}" has providerKey="${connection.providerKey}" but handle specifies "${parsed.providerKey}"`,
159
+ };
160
+ }
161
+ return {
162
+ ok: true,
163
+ subject: {
164
+ type: HandleType.LocalOAuth,
165
+ handle: parsed,
166
+ connection,
167
+ },
168
+ };
169
+ }
170
+
171
+ default:
172
+ return {
173
+ ok: false,
174
+ error: `Handle type "${parsed.type}" is not a local handle and cannot be resolved by the local subject resolver`,
175
+ };
176
+ }
177
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Managed subject resolution for platform OAuth handles.
3
+ *
4
+ * Resolves `platform_oauth:<connection_id>` handles into a normalized
5
+ * subject shape that the rest of CES can treat uniformly alongside local
6
+ * subjects. Managed subjects never carry raw tokens — they carry only
7
+ * the metadata needed to call the platform's token-materialization
8
+ * endpoint at execution time.
9
+ *
10
+ * Subject resolution is the first phase of a two-phase credential flow:
11
+ * 1. **Resolution** (this module) — parse the handle, validate the
12
+ * connection exists in the platform catalog, and return a subject
13
+ * descriptor with provider metadata.
14
+ * 2. **Materialization** (`materializers/managed-platform.ts`) — use
15
+ * the resolved subject to request a short-lived access token from
16
+ * the platform and inject it into the execution environment.
17
+ *
18
+ * The subject shape is intentionally slim and secret-free so it can be
19
+ * logged, cached in memory, and passed across internal boundaries without
20
+ * risk of leaking credentials.
21
+ */
22
+
23
+ import {
24
+ HandleType,
25
+ parseHandle,
26
+ type PlatformOAuthHandle,
27
+ } from "@vellumai/service-contracts/credential-rpc";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Common subject interface
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Source discriminator shared by all subject types.
35
+ *
36
+ * - `"local"` — credential lives in the local secure-key backend.
37
+ * - `"managed"` — credential is managed by the platform; tokens are
38
+ * obtained via the platform's CES token-materialization endpoint.
39
+ */
40
+ export type SubjectSource = "local" | "managed";
41
+
42
+ /**
43
+ * Common shape that all resolved subjects expose. CES execution paths
44
+ * (HTTP materializer, command materializer) can branch on `source`
45
+ * without knowing the full subject type.
46
+ */
47
+ export interface ResolvedSubject {
48
+ /** Source of the credential. */
49
+ source: SubjectSource;
50
+ /** The raw handle string that was resolved. */
51
+ handle: string;
52
+ /** Provider identifier (e.g. "google", "slack", "github"). */
53
+ provider: string;
54
+ /** Connection identifier on the platform (managed) or locally. */
55
+ connectionId: string;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Managed subject shape
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * A resolved managed subject — the output of resolving a
64
+ * `platform_oauth:<connection_id>` handle against the platform catalog.
65
+ *
66
+ * This shape carries zero secret material. It is safe to log, serialize,
67
+ * and pass across internal boundaries.
68
+ */
69
+ export interface ManagedSubject extends ResolvedSubject {
70
+ source: "managed";
71
+ /** Account info as reported by the platform catalog (e.g. email). */
72
+ accountInfo: string | null;
73
+ /** Granted OAuth scopes as reported by the platform catalog. */
74
+ grantedScopes: string[];
75
+ /** Connection status from the platform catalog (e.g. "active", "expired"). */
76
+ status: string;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Platform catalog entry (non-secret subset from the platform response)
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Shape of a single connection entry in the platform catalog response.
85
+ * Only non-secret fields are parsed; token values are never included.
86
+ *
87
+ * Field names match the platform's ManagedConnectionCatalogEntrySerializer:
88
+ * handle, connection_id, provider, account_label, scopes_granted, status
89
+ */
90
+ export interface PlatformCatalogEntry {
91
+ handle: string;
92
+ connection_id: string;
93
+ provider: string;
94
+ account_label?: string | null;
95
+ scopes_granted?: string[];
96
+ status?: string;
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Resolution errors
101
+ // ---------------------------------------------------------------------------
102
+
103
+ export class SubjectResolutionError extends Error {
104
+ readonly code: string;
105
+
106
+ constructor(code: string, message: string) {
107
+ super(message);
108
+ this.name = "SubjectResolutionError";
109
+ this.code = code;
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Resolution options
115
+ // ---------------------------------------------------------------------------
116
+
117
+ export interface ManagedSubjectResolverOptions {
118
+ /**
119
+ * Platform base URL (without trailing slash).
120
+ * e.g. "https://api.vellum.ai"
121
+ */
122
+ platformBaseUrl: string;
123
+ /**
124
+ * Assistant API key for authenticating with the platform.
125
+ */
126
+ assistantApiKey: string;
127
+ /**
128
+ * Platform-assigned assistant UUID. Required for building the
129
+ * platform catalog URL: /v1/assistants/<id>/oauth/managed/catalog/
130
+ */
131
+ assistantId: string;
132
+ /**
133
+ * Optional custom fetch implementation (for testing).
134
+ */
135
+ fetch?: typeof globalThis.fetch;
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Resolution result
140
+ // ---------------------------------------------------------------------------
141
+
142
+ export type ResolveResult =
143
+ | { ok: true; subject: ManagedSubject }
144
+ | { ok: false; error: SubjectResolutionError };
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Resolver implementation
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /**
151
+ * Resolve a `platform_oauth:<connection_id>` handle into a managed subject
152
+ * by looking up the connection in the platform's CES catalog.
153
+ *
154
+ * Fail-closed: if the platform cannot be reached, returns an error rather
155
+ * than proceeding without credential validation.
156
+ */
157
+ export async function resolveManagedSubject(
158
+ handle: string,
159
+ options: ManagedSubjectResolverOptions,
160
+ ): Promise<ResolveResult> {
161
+ // -- Parse handle ---------------------------------------------------------
162
+ const parsed = parseHandle(handle);
163
+ if (!parsed.ok) {
164
+ return {
165
+ ok: false,
166
+ error: new SubjectResolutionError("INVALID_HANDLE", parsed.error),
167
+ };
168
+ }
169
+
170
+ if (parsed.handle.type !== HandleType.PlatformOAuth) {
171
+ return {
172
+ ok: false,
173
+ error: new SubjectResolutionError(
174
+ "WRONG_HANDLE_TYPE",
175
+ `Expected platform_oauth handle, got ${parsed.handle.type}`,
176
+ ),
177
+ };
178
+ }
179
+
180
+ const platformHandle = parsed.handle as PlatformOAuthHandle;
181
+
182
+ // -- Validate prerequisites -----------------------------------------------
183
+ if (!options.platformBaseUrl) {
184
+ return {
185
+ ok: false,
186
+ error: new SubjectResolutionError(
187
+ "MISSING_PLATFORM_URL",
188
+ "Platform base URL is required for managed subject resolution",
189
+ ),
190
+ };
191
+ }
192
+
193
+ if (!options.assistantApiKey) {
194
+ return {
195
+ ok: false,
196
+ error: new SubjectResolutionError(
197
+ "MISSING_API_KEY",
198
+ "Assistant API key is required for managed subject resolution",
199
+ ),
200
+ };
201
+ }
202
+
203
+ if (!options.assistantId) {
204
+ return {
205
+ ok: false,
206
+ error: new SubjectResolutionError(
207
+ "MISSING_ASSISTANT_ID",
208
+ "Assistant ID is required for managed subject resolution",
209
+ ),
210
+ };
211
+ }
212
+
213
+ // -- Fetch catalog entry --------------------------------------------------
214
+ const fetchFn = options.fetch ?? globalThis.fetch;
215
+ const catalogUrl = `${options.platformBaseUrl}/v1/assistants/${encodeURIComponent(options.assistantId)}/oauth/managed/catalog/`;
216
+
217
+ let response: Response;
218
+ try {
219
+ response = await fetchFn(catalogUrl, {
220
+ method: "GET",
221
+ headers: {
222
+ Authorization: `Api-Key ${options.assistantApiKey}`,
223
+ Accept: "application/json",
224
+ },
225
+ });
226
+ } catch (err) {
227
+ const message = err instanceof Error ? err.message : String(err);
228
+ return {
229
+ ok: false,
230
+ error: new SubjectResolutionError(
231
+ "PLATFORM_UNREACHABLE",
232
+ `Failed to reach platform CES catalog: ${sanitizeError(message)}`,
233
+ ),
234
+ };
235
+ }
236
+
237
+ if (!response.ok) {
238
+ return {
239
+ ok: false,
240
+ error: new SubjectResolutionError(
241
+ `PLATFORM_HTTP_${response.status}`,
242
+ `Platform CES catalog returned HTTP ${response.status}`,
243
+ ),
244
+ };
245
+ }
246
+
247
+ // -- Parse response -------------------------------------------------------
248
+ // The platform returns a flat JSON array of catalog entries
249
+ // (serialized with many=True), not a wrapper object.
250
+ let entries: PlatformCatalogEntry[];
251
+ try {
252
+ entries = (await response.json()) as PlatformCatalogEntry[];
253
+ } catch {
254
+ return {
255
+ ok: false,
256
+ error: new SubjectResolutionError(
257
+ "INVALID_CATALOG_RESPONSE",
258
+ "Platform CES catalog returned invalid JSON",
259
+ ),
260
+ };
261
+ }
262
+
263
+ if (!Array.isArray(entries)) {
264
+ return {
265
+ ok: false,
266
+ error: new SubjectResolutionError(
267
+ "INVALID_CATALOG_RESPONSE",
268
+ "Platform CES catalog returned unexpected response format",
269
+ ),
270
+ };
271
+ }
272
+
273
+ // -- Find matching connection ---------------------------------------------
274
+ const entry = entries.find(
275
+ (c) => c.connection_id === platformHandle.connectionId,
276
+ );
277
+
278
+ if (!entry) {
279
+ return {
280
+ ok: false,
281
+ error: new SubjectResolutionError(
282
+ "CONNECTION_NOT_FOUND",
283
+ `Connection ${platformHandle.connectionId} not found in platform catalog`,
284
+ ),
285
+ };
286
+ }
287
+
288
+ // -- Build managed subject ------------------------------------------------
289
+ const subject: ManagedSubject = {
290
+ source: "managed",
291
+ handle,
292
+ provider: entry.provider,
293
+ connectionId: entry.connection_id,
294
+ accountInfo: entry.account_label ?? null,
295
+ grantedScopes: entry.scopes_granted ?? [],
296
+ status: entry.status ?? "unknown",
297
+ };
298
+
299
+ return { ok: true, subject };
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Helpers
304
+ // ---------------------------------------------------------------------------
305
+
306
+ /**
307
+ * Sanitize error messages to avoid leaking secrets (defensive).
308
+ */
309
+ function sanitizeError(message: string): string {
310
+ return message.replace(/Api-Key\s+\S+/gi, "Api-Key [REDACTED]");
311
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * CES credential policy enforcement.
3
+ *
4
+ * Enforces credential-level policies (allowedTools, allowedDomains) that were
5
+ * previously only checked by the pre-CES broker in the assistant daemon.
6
+ * Without these checks, CES would materialise credentials that the broker
7
+ * would have rejected — a security regression.
8
+ *
9
+ * Policy rules:
10
+ * - **allowedDomains** — credentials with domain restrictions are scoped to
11
+ * browser use on those domains and cannot be used server-side by CES.
12
+ * - **allowedTools** — if set (even if empty), only the listed tools may
13
+ * consume the credential. An empty array means deny-all. CES tool names
14
+ * ("make_authenticated_request", "run_authenticated_command") must be in
15
+ * the list.
16
+ */
17
+
18
+ import type { StaticCredentialRecord } from "@vellumai/credential-storage";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Result type
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface CredentialPolicyCheckResult {
25
+ ok: boolean;
26
+ error?: string;
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Policy check
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Check credential-level policies before materialisation.
35
+ *
36
+ * Returns `{ ok: true }` if the credential may be materialised for the
37
+ * given CES tool, or `{ ok: false, error }` with a human-readable
38
+ * rejection message.
39
+ *
40
+ * @param metadata Non-secret metadata record for a local static credential.
41
+ * @param cesToolName The CES tool requesting materialisation
42
+ * (e.g. "make_authenticated_request" or "run_authenticated_command").
43
+ */
44
+ export function checkCredentialPolicy(
45
+ metadata: StaticCredentialRecord,
46
+ cesToolName: string,
47
+ ): CredentialPolicyCheckResult {
48
+ // -- allowedDomains -------------------------------------------------------
49
+ // Credentials with domain restrictions are scoped to browser use on those
50
+ // domains. They cannot be used for server-side operations through CES.
51
+ if (metadata.allowedDomains && metadata.allowedDomains.length > 0) {
52
+ return {
53
+ ok: false,
54
+ error:
55
+ `Credential ${metadata.service}/${metadata.field} has domain restrictions ` +
56
+ `(${metadata.allowedDomains.join(", ")}) and cannot be used for server-side ` +
57
+ `operations through CES. Remove domain restrictions or use a separate credential.`,
58
+ };
59
+ }
60
+
61
+ // -- allowedTools ---------------------------------------------------------
62
+ // If set (even if empty), the CES tool must be in the allowed list.
63
+ // An empty allowedTools array means no tools are permitted (deny-all).
64
+ if (metadata.allowedTools) {
65
+ if (!metadata.allowedTools.includes(cesToolName)) {
66
+ return {
67
+ ok: false,
68
+ error:
69
+ metadata.allowedTools.length === 0
70
+ ? `Credential ${metadata.service}/${metadata.field} does not allow any tools.`
71
+ : `Tool "${cesToolName}" is not allowed to use credential ` +
72
+ `${metadata.service}/${metadata.field}. ` +
73
+ `Allowed tools: ${metadata.allowedTools.join(", ")}.`,
74
+ };
75
+ }
76
+ }
77
+
78
+ return { ok: true };
79
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Bundle integrity verification.
3
+ *
4
+ * Provides SHA-256 digest computation and verification for secure command
5
+ * bundles. Digests are computed over the raw bundle bytes and compared
6
+ * against the expected digest declared in the toolstore manifest.
7
+ *
8
+ * All digests are lowercase hex-encoded SHA-256 hashes (64 characters).
9
+ */
10
+
11
+ import { createHash, timingSafeEqual } from "node:crypto";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Digest computation
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Compute the SHA-256 hex digest of arbitrary bytes.
19
+ *
20
+ * Returns a lowercase 64-character hex string.
21
+ */
22
+ export function computeDigest(data: Buffer | Uint8Array): string {
23
+ return createHash("sha256").update(data).digest("hex");
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Digest verification
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export interface DigestVerificationResult {
31
+ /** Whether the computed digest matches the expected digest. */
32
+ valid: boolean;
33
+ /** The computed digest (always present). */
34
+ computedDigest: string;
35
+ /** The expected digest (always present). */
36
+ expectedDigest: string;
37
+ /** Human-readable error message when invalid (undefined when valid). */
38
+ error?: string;
39
+ }
40
+
41
+ /**
42
+ * Verify that the SHA-256 digest of `data` matches `expectedDigest`.
43
+ *
44
+ * Uses constant-time comparison via `timingSafeEqual` to prevent
45
+ * timing side-channel attacks on digest values.
46
+ */
47
+ export function verifyDigest(
48
+ data: Buffer | Uint8Array,
49
+ expectedDigest: string,
50
+ ): DigestVerificationResult {
51
+ const computedDigest = computeDigest(data);
52
+
53
+ // Use timing-safe comparison to prevent timing attacks
54
+ const computedBuf = Buffer.from(computedDigest, "hex");
55
+ const expectedBuf = Buffer.from(expectedDigest, "hex");
56
+
57
+ // If the expected digest is not valid hex (wrong length), fail
58
+ if (computedBuf.length !== expectedBuf.length || expectedBuf.length !== 32) {
59
+ return {
60
+ valid: false,
61
+ computedDigest,
62
+ expectedDigest,
63
+ error: `Digest mismatch: expected "${expectedDigest}" but computed "${computedDigest}". ` +
64
+ `The bundle contents do not match the declared digest.`,
65
+ };
66
+ }
67
+
68
+ const match = safeTimingEqual(computedBuf, expectedBuf);
69
+
70
+ if (!match) {
71
+ return {
72
+ valid: false,
73
+ computedDigest,
74
+ expectedDigest,
75
+ error: `Digest mismatch: expected "${expectedDigest}" but computed "${computedDigest}". ` +
76
+ `The bundle contents do not match the declared digest.`,
77
+ };
78
+ }
79
+
80
+ return {
81
+ valid: true,
82
+ computedDigest,
83
+ expectedDigest,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Constant-time buffer comparison. Wraps `crypto.timingSafeEqual`
89
+ * with a length guard (timingSafeEqual throws on length mismatch).
90
+ */
91
+ function safeTimingEqual(a: Buffer, b: Buffer): boolean {
92
+ if (a.length !== b.length) return false;
93
+ return timingSafeEqual(a, b);
94
+ }