@vellumai/credential-executor 0.4.55
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/Dockerfile +55 -0
- package/bun.lock +37 -0
- package/package.json +32 -0
- package/src/__tests__/command-executor.test.ts +1333 -0
- package/src/__tests__/command-validator.test.ts +708 -0
- package/src/__tests__/command-workspace.test.ts +997 -0
- package/src/__tests__/grant-store.test.ts +467 -0
- package/src/__tests__/http-executor.test.ts +1251 -0
- package/src/__tests__/http-policy.test.ts +970 -0
- package/src/__tests__/local-materializers.test.ts +826 -0
- package/src/__tests__/managed-materializers.test.ts +961 -0
- package/src/__tests__/toolstore.test.ts +539 -0
- package/src/__tests__/transport.test.ts +388 -0
- package/src/audit/store.ts +188 -0
- package/src/commands/auth-adapters.ts +169 -0
- package/src/commands/executor.ts +840 -0
- package/src/commands/output-scan.ts +157 -0
- package/src/commands/profiles.ts +282 -0
- package/src/commands/validator.ts +438 -0
- package/src/commands/workspace.ts +512 -0
- package/src/grants/index.ts +17 -0
- package/src/grants/persistent-store.ts +247 -0
- package/src/grants/rpc-handlers.ts +269 -0
- package/src/grants/temporary-store.ts +219 -0
- package/src/http/audit.ts +84 -0
- package/src/http/executor.ts +540 -0
- package/src/http/path-template.ts +179 -0
- package/src/http/policy.ts +256 -0
- package/src/http/response-filter.ts +233 -0
- package/src/index.ts +106 -0
- package/src/main.ts +263 -0
- package/src/managed-main.ts +420 -0
- package/src/materializers/local.ts +300 -0
- package/src/materializers/managed-platform.ts +270 -0
- package/src/paths.ts +137 -0
- package/src/server.ts +636 -0
- package/src/subjects/local.ts +177 -0
- package/src/subjects/managed.ts +290 -0
- package/src/toolstore/integrity.ts +94 -0
- package/src/toolstore/manifest.ts +154 -0
- package/src/toolstore/publish.ts +342 -0
- package/tsconfig.json +20 -0
|
@@ -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/ces-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/ces-contracts";
|
|
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,290 @@
|
|
|
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/ces-contracts";
|
|
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
|
+
export interface PlatformCatalogEntry {
|
|
88
|
+
id: string;
|
|
89
|
+
provider: string;
|
|
90
|
+
account_info?: string | null;
|
|
91
|
+
granted_scopes?: string[];
|
|
92
|
+
status?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Resolution errors
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
export class SubjectResolutionError extends Error {
|
|
100
|
+
readonly code: string;
|
|
101
|
+
|
|
102
|
+
constructor(code: string, message: string) {
|
|
103
|
+
super(message);
|
|
104
|
+
this.name = "SubjectResolutionError";
|
|
105
|
+
this.code = code;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Resolution options
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
export interface ManagedSubjectResolverOptions {
|
|
114
|
+
/**
|
|
115
|
+
* Platform base URL (without trailing slash).
|
|
116
|
+
* e.g. "https://api.vellum.ai"
|
|
117
|
+
*/
|
|
118
|
+
platformBaseUrl: string;
|
|
119
|
+
/**
|
|
120
|
+
* Assistant API key for authenticating with the platform.
|
|
121
|
+
*/
|
|
122
|
+
assistantApiKey: string;
|
|
123
|
+
/**
|
|
124
|
+
* Optional custom fetch implementation (for testing).
|
|
125
|
+
*/
|
|
126
|
+
fetch?: typeof globalThis.fetch;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Resolution result
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
export type ResolveResult =
|
|
134
|
+
| { ok: true; subject: ManagedSubject }
|
|
135
|
+
| { ok: false; error: SubjectResolutionError };
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Resolver implementation
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve a `platform_oauth:<connection_id>` handle into a managed subject
|
|
143
|
+
* by looking up the connection in the platform's CES catalog.
|
|
144
|
+
*
|
|
145
|
+
* Fail-closed: if the platform cannot be reached, returns an error rather
|
|
146
|
+
* than proceeding without credential validation.
|
|
147
|
+
*/
|
|
148
|
+
export async function resolveManagedSubject(
|
|
149
|
+
handle: string,
|
|
150
|
+
options: ManagedSubjectResolverOptions,
|
|
151
|
+
): Promise<ResolveResult> {
|
|
152
|
+
// -- Parse handle ---------------------------------------------------------
|
|
153
|
+
const parsed = parseHandle(handle);
|
|
154
|
+
if (!parsed.ok) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: new SubjectResolutionError("INVALID_HANDLE", parsed.error),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (parsed.handle.type !== HandleType.PlatformOAuth) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
error: new SubjectResolutionError(
|
|
165
|
+
"WRONG_HANDLE_TYPE",
|
|
166
|
+
`Expected platform_oauth handle, got ${parsed.handle.type}`,
|
|
167
|
+
),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const platformHandle = parsed.handle as PlatformOAuthHandle;
|
|
172
|
+
|
|
173
|
+
// -- Validate prerequisites -----------------------------------------------
|
|
174
|
+
if (!options.platformBaseUrl) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
error: new SubjectResolutionError(
|
|
178
|
+
"MISSING_PLATFORM_URL",
|
|
179
|
+
"Platform base URL is required for managed subject resolution",
|
|
180
|
+
),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!options.assistantApiKey) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
error: new SubjectResolutionError(
|
|
188
|
+
"MISSING_API_KEY",
|
|
189
|
+
"Assistant API key is required for managed subject resolution",
|
|
190
|
+
),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// -- Fetch catalog entry --------------------------------------------------
|
|
195
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
196
|
+
const catalogUrl = `${options.platformBaseUrl}/v1/ces/catalog`;
|
|
197
|
+
|
|
198
|
+
let response: Response;
|
|
199
|
+
try {
|
|
200
|
+
response = await fetchFn(catalogUrl, {
|
|
201
|
+
method: "GET",
|
|
202
|
+
headers: {
|
|
203
|
+
Authorization: `Api-Key ${options.assistantApiKey}`,
|
|
204
|
+
Accept: "application/json",
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
} catch (err) {
|
|
208
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
error: new SubjectResolutionError(
|
|
212
|
+
"PLATFORM_UNREACHABLE",
|
|
213
|
+
`Failed to reach platform CES catalog: ${sanitizeError(message)}`,
|
|
214
|
+
),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!response.ok) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
error: new SubjectResolutionError(
|
|
222
|
+
`PLATFORM_HTTP_${response.status}`,
|
|
223
|
+
`Platform CES catalog returned HTTP ${response.status}`,
|
|
224
|
+
),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// -- Parse response -------------------------------------------------------
|
|
229
|
+
let body: { connections?: PlatformCatalogEntry[] };
|
|
230
|
+
try {
|
|
231
|
+
body = (await response.json()) as { connections?: PlatformCatalogEntry[] };
|
|
232
|
+
} catch {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
error: new SubjectResolutionError(
|
|
236
|
+
"INVALID_CATALOG_RESPONSE",
|
|
237
|
+
"Platform CES catalog returned invalid JSON",
|
|
238
|
+
),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!body.connections || !Array.isArray(body.connections)) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
error: new SubjectResolutionError(
|
|
246
|
+
"INVALID_CATALOG_RESPONSE",
|
|
247
|
+
"Platform CES catalog returned unexpected response format",
|
|
248
|
+
),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// -- Find matching connection ---------------------------------------------
|
|
253
|
+
const entry = body.connections.find(
|
|
254
|
+
(c) => c.id === platformHandle.connectionId,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
if (!entry) {
|
|
258
|
+
return {
|
|
259
|
+
ok: false,
|
|
260
|
+
error: new SubjectResolutionError(
|
|
261
|
+
"CONNECTION_NOT_FOUND",
|
|
262
|
+
`Connection ${platformHandle.connectionId} not found in platform catalog`,
|
|
263
|
+
),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// -- Build managed subject ------------------------------------------------
|
|
268
|
+
const subject: ManagedSubject = {
|
|
269
|
+
source: "managed",
|
|
270
|
+
handle,
|
|
271
|
+
provider: entry.provider,
|
|
272
|
+
connectionId: entry.id,
|
|
273
|
+
accountInfo: entry.account_info ?? null,
|
|
274
|
+
grantedScopes: entry.granted_scopes ?? [],
|
|
275
|
+
status: entry.status ?? "unknown",
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
return { ok: true, subject };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Helpers
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Sanitize error messages to avoid leaking secrets (defensive).
|
|
287
|
+
*/
|
|
288
|
+
function sanitizeError(message: string): string {
|
|
289
|
+
return message.replace(/Api-Key\s+\S+/gi, "Api-Key [REDACTED]");
|
|
290
|
+
}
|
|
@@ -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
|
+
}
|