@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,840 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CES authenticated command executor.
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates secure command execution through the following pipeline:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Bundle resolution** — Resolve the bundle digest from the toolstore
|
|
7
|
+
* and verify the secure command manifest is published and approved.
|
|
8
|
+
*
|
|
9
|
+
* 2. **Profile validation** — Validate the command argv against the
|
|
10
|
+
* manifest's allowed profiles, checking for denied binaries, denied
|
|
11
|
+
* subcommands, and denied flags.
|
|
12
|
+
*
|
|
13
|
+
* 3. **Grant enforcement** — Verify that an active grant covers this
|
|
14
|
+
* bundle-digest/profile pair and credential handle.
|
|
15
|
+
*
|
|
16
|
+
* 4. **Workspace staging** — Stage declared workspace inputs into a
|
|
17
|
+
* CES-private scratch directory.
|
|
18
|
+
*
|
|
19
|
+
* 5. **Auth materialization** — Materialize the credential through the
|
|
20
|
+
* declared auth adapter (env_var, temp_file, or credential_process).
|
|
21
|
+
*
|
|
22
|
+
* 6. **Egress proxy startup** — Start a CES-owned egress proxy session
|
|
23
|
+
* (when egressMode is `proxy_required`) to enforce network target
|
|
24
|
+
* allowlists.
|
|
25
|
+
*
|
|
26
|
+
* 7. **Command execution** — Run the command with clean config dirs,
|
|
27
|
+
* materialized credential env vars, and proxy env vars. The command
|
|
28
|
+
* runs in the scratch directory, never in the assistant workspace.
|
|
29
|
+
*
|
|
30
|
+
* 8. **Output copyback** — After exit, validate and copy declared output
|
|
31
|
+
* files from the scratch directory back into the workspace.
|
|
32
|
+
*
|
|
33
|
+
* 9. **Cleanup** — Stop the egress proxy session, remove temp files, and
|
|
34
|
+
* clean up the scratch directory.
|
|
35
|
+
*
|
|
36
|
+
* The executor is fail-closed: bundle mismatches, missing grants,
|
|
37
|
+
* adapter failures, egress failures, undeclared outputs, and scan
|
|
38
|
+
* violations all result in command rejection before or after execution.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
42
|
+
import { dirname, join } from "node:path";
|
|
43
|
+
import { mkdirSync, writeFileSync, unlinkSync, rmSync } from "node:fs";
|
|
44
|
+
import { tmpdir } from "node:os";
|
|
45
|
+
import {
|
|
46
|
+
SessionStore,
|
|
47
|
+
createSession,
|
|
48
|
+
startSession,
|
|
49
|
+
stopSession,
|
|
50
|
+
getSessionEnv,
|
|
51
|
+
type SessionStartHooks,
|
|
52
|
+
type ProxyEnvVars,
|
|
53
|
+
} from "@vellumai/egress-proxy";
|
|
54
|
+
|
|
55
|
+
import { readPublishedManifest, getBundleContentPath, isBundlePublished } from "../toolstore/publish.js";
|
|
56
|
+
import { getCesToolStoreDir, type CesMode } from "../paths.js";
|
|
57
|
+
import type { SecureCommandManifest, CommandProfile } from "./profiles.js";
|
|
58
|
+
import { isDeniedBinary, EgressMode } from "./profiles.js";
|
|
59
|
+
import { validateCommand, type CommandValidationResult } from "./validator.js";
|
|
60
|
+
import type { AuthAdapterConfig } from "./auth-adapters.js";
|
|
61
|
+
import { AuthAdapterType, validateAuthAdapterConfig } from "./auth-adapters.js";
|
|
62
|
+
import {
|
|
63
|
+
stageInputs,
|
|
64
|
+
copybackOutputs,
|
|
65
|
+
cleanupScratchDir,
|
|
66
|
+
type WorkspaceStageConfig,
|
|
67
|
+
type WorkspaceInput,
|
|
68
|
+
type WorkspaceOutput,
|
|
69
|
+
type CopybackResult,
|
|
70
|
+
} from "./workspace.js";
|
|
71
|
+
import type { PersistentGrantStore } from "../grants/persistent-store.js";
|
|
72
|
+
import type { TemporaryGrantStore } from "../grants/temporary-store.js";
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Types
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Request to execute an authenticated command through the CES pipeline.
|
|
80
|
+
*/
|
|
81
|
+
export interface ExecuteCommandRequest {
|
|
82
|
+
/** SHA-256 hex digest of the approved bundle. */
|
|
83
|
+
bundleDigest: string;
|
|
84
|
+
/** Name of the command profile to use within the manifest. */
|
|
85
|
+
profileName: string;
|
|
86
|
+
/** CES credential handle identifying which credential to inject. */
|
|
87
|
+
credentialHandle: string;
|
|
88
|
+
/** Argv tokens (command arguments, not including the binary path). */
|
|
89
|
+
argv: string[];
|
|
90
|
+
/** Absolute path to the assistant-visible workspace directory. */
|
|
91
|
+
workspaceDir: string;
|
|
92
|
+
/** Files to stage as read-only inputs in the scratch directory. */
|
|
93
|
+
inputs?: WorkspaceInput[];
|
|
94
|
+
/** Files to copy back from the scratch directory after execution. */
|
|
95
|
+
outputs?: WorkspaceOutput[];
|
|
96
|
+
/** Human-readable purpose for audit logging. */
|
|
97
|
+
purpose: string;
|
|
98
|
+
/** Explicit grant ID to consume, if the caller holds one. */
|
|
99
|
+
grantId?: string;
|
|
100
|
+
/** Conversation ID for thread-scoped temporary grants. */
|
|
101
|
+
conversationId?: string;
|
|
102
|
+
/** Session ID for the egress proxy. */
|
|
103
|
+
sessionId?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Result of a command execution attempt.
|
|
108
|
+
*/
|
|
109
|
+
export interface ExecuteCommandResult {
|
|
110
|
+
/** Whether the command executed successfully. */
|
|
111
|
+
success: boolean;
|
|
112
|
+
/** Process exit code (undefined if the command was never launched). */
|
|
113
|
+
exitCode?: number;
|
|
114
|
+
/** Combined stdout output (truncated for safety). */
|
|
115
|
+
stdout?: string;
|
|
116
|
+
/** Combined stderr output (truncated for safety). */
|
|
117
|
+
stderr?: string;
|
|
118
|
+
/** Copyback results for declared outputs. */
|
|
119
|
+
copybackResult?: CopybackResult;
|
|
120
|
+
/** Error message if execution failed. */
|
|
121
|
+
error?: string;
|
|
122
|
+
/** Audit-relevant metadata. */
|
|
123
|
+
auditId?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Credential materializer abstraction.
|
|
128
|
+
*
|
|
129
|
+
* The executor does not import materializer implementations directly.
|
|
130
|
+
* Callers provide a materializer function that resolves a credential
|
|
131
|
+
* handle into a raw secret value.
|
|
132
|
+
*/
|
|
133
|
+
export type MaterializeCredentialFn = (
|
|
134
|
+
credentialHandle: string,
|
|
135
|
+
) => Promise<MaterializeCredentialResult>;
|
|
136
|
+
|
|
137
|
+
export type MaterializeCredentialResult =
|
|
138
|
+
| { ok: true; value: string; handleType: string }
|
|
139
|
+
| { ok: false; error: string };
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Dependencies injected into the command executor.
|
|
143
|
+
*/
|
|
144
|
+
export interface CommandExecutorDeps {
|
|
145
|
+
/** Persistent grant store for checking bundle/profile approvals. */
|
|
146
|
+
persistentStore: PersistentGrantStore;
|
|
147
|
+
/** Temporary grant store for session-scoped approvals. */
|
|
148
|
+
temporaryStore: TemporaryGrantStore;
|
|
149
|
+
/** Credential materializer function. */
|
|
150
|
+
materializeCredential: MaterializeCredentialFn;
|
|
151
|
+
/** CES operating mode (for toolstore path resolution). */
|
|
152
|
+
cesMode?: CesMode;
|
|
153
|
+
/** Egress proxy session start hooks (for creating the proxy server). */
|
|
154
|
+
egressHooks?: SessionStartHooks;
|
|
155
|
+
/** Egress proxy session store (shared or isolated). */
|
|
156
|
+
egressSessionStore?: SessionStore;
|
|
157
|
+
/** Maximum stdout/stderr capture size in bytes. */
|
|
158
|
+
maxOutputBytes?: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Constants
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/** Maximum stdout/stderr capture (256 KB). */
|
|
166
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
167
|
+
|
|
168
|
+
/** Credential process helper timeout. */
|
|
169
|
+
const CREDENTIAL_PROCESS_TIMEOUT_MS = 10_000;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Banned binary names — checked at execution time as a defense-in-depth
|
|
173
|
+
* supplement to the manifest validator's static check.
|
|
174
|
+
*/
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Executor implementation
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Execute an authenticated command through the full CES pipeline.
|
|
182
|
+
*
|
|
183
|
+
* This is the top-level orchestrator. Each step is fail-closed: if any
|
|
184
|
+
* phase returns an error, the command is rejected and cleanup runs.
|
|
185
|
+
*/
|
|
186
|
+
export async function executeAuthenticatedCommand(
|
|
187
|
+
request: ExecuteCommandRequest,
|
|
188
|
+
deps: CommandExecutorDeps,
|
|
189
|
+
): Promise<ExecuteCommandResult> {
|
|
190
|
+
const auditId = randomUUID();
|
|
191
|
+
|
|
192
|
+
// -- 1. Resolve and validate the bundle -----------------------------------
|
|
193
|
+
const bundleResult = resolveBundle(request.bundleDigest, deps.cesMode);
|
|
194
|
+
if (!bundleResult.ok) {
|
|
195
|
+
return {
|
|
196
|
+
success: false,
|
|
197
|
+
error: bundleResult.error,
|
|
198
|
+
auditId,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const { manifest, toolstoreDir } = bundleResult;
|
|
203
|
+
|
|
204
|
+
// -- 2. Validate the command profile and argv -----------------------------
|
|
205
|
+
const profileResult = validateProfile(
|
|
206
|
+
manifest,
|
|
207
|
+
request.profileName,
|
|
208
|
+
request.argv,
|
|
209
|
+
);
|
|
210
|
+
if (!profileResult.ok) {
|
|
211
|
+
return {
|
|
212
|
+
success: false,
|
|
213
|
+
error: profileResult.error,
|
|
214
|
+
auditId,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// -- 3. Check grant enforcement -------------------------------------------
|
|
219
|
+
const grantResult = checkGrant(
|
|
220
|
+
request,
|
|
221
|
+
manifest,
|
|
222
|
+
request.profileName,
|
|
223
|
+
deps.persistentStore,
|
|
224
|
+
deps.temporaryStore,
|
|
225
|
+
);
|
|
226
|
+
if (!grantResult.ok) {
|
|
227
|
+
return {
|
|
228
|
+
success: false,
|
|
229
|
+
error: grantResult.error,
|
|
230
|
+
auditId,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// -- 4. Stage workspace inputs --------------------------------------------
|
|
235
|
+
const stageConfig: WorkspaceStageConfig = {
|
|
236
|
+
workspaceDir: request.workspaceDir,
|
|
237
|
+
inputs: request.inputs ?? [],
|
|
238
|
+
outputs: request.outputs ?? [],
|
|
239
|
+
secrets: new Set<string>(), // Populated after materialization
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
let scratchDir: string;
|
|
243
|
+
try {
|
|
244
|
+
const staged = stageInputs(stageConfig, deps.cesMode);
|
|
245
|
+
scratchDir = staged.scratchDir;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
return {
|
|
248
|
+
success: false,
|
|
249
|
+
error: `Input staging failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
250
|
+
auditId,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// -- 5. Materialize the credential ----------------------------------------
|
|
255
|
+
const matResult = await deps.materializeCredential(request.credentialHandle);
|
|
256
|
+
if (!matResult.ok) {
|
|
257
|
+
cleanupScratchDir(scratchDir);
|
|
258
|
+
return {
|
|
259
|
+
success: false,
|
|
260
|
+
error: `Credential materialization failed: ${matResult.error}`,
|
|
261
|
+
auditId,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Update the stage config with the materialized secret for output scanning
|
|
266
|
+
const secretSet = new Set<string>([matResult.value]);
|
|
267
|
+
const stageConfigWithSecrets: WorkspaceStageConfig = {
|
|
268
|
+
...stageConfig,
|
|
269
|
+
secrets: secretSet,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// -- 6. Build auth adapter environment ------------------------------------
|
|
273
|
+
let adapterEnv: Record<string, string>;
|
|
274
|
+
let tempFilePath: string | undefined;
|
|
275
|
+
try {
|
|
276
|
+
const adapterResult = await buildAuthAdapterEnv(
|
|
277
|
+
manifest.authAdapter,
|
|
278
|
+
matResult.value,
|
|
279
|
+
);
|
|
280
|
+
adapterEnv = adapterResult.env;
|
|
281
|
+
tempFilePath = adapterResult.tempFilePath;
|
|
282
|
+
} catch (err) {
|
|
283
|
+
cleanupScratchDir(scratchDir);
|
|
284
|
+
return {
|
|
285
|
+
success: false,
|
|
286
|
+
error: `Auth adapter materialization failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
287
|
+
auditId,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// -- 7. Start egress proxy (if proxy_required) ----------------------------
|
|
292
|
+
let proxyEnv: ProxyEnvVars | undefined;
|
|
293
|
+
let proxySessionId: string | undefined;
|
|
294
|
+
const sessionStore = deps.egressSessionStore ?? new SessionStore();
|
|
295
|
+
|
|
296
|
+
if (manifest.egressMode === EgressMode.ProxyRequired) {
|
|
297
|
+
if (!deps.egressHooks) {
|
|
298
|
+
cleanupAll(scratchDir, tempFilePath);
|
|
299
|
+
return {
|
|
300
|
+
success: false,
|
|
301
|
+
error: "Egress mode is proxy_required but no egress hooks were provided. " +
|
|
302
|
+
"Cannot enforce network policy without an egress proxy.",
|
|
303
|
+
auditId,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const conversationId = request.conversationId ?? `ces-cmd-${auditId}`;
|
|
309
|
+
const session = createSession(
|
|
310
|
+
sessionStore,
|
|
311
|
+
conversationId,
|
|
312
|
+
[request.credentialHandle],
|
|
313
|
+
);
|
|
314
|
+
const started = await startSession(
|
|
315
|
+
sessionStore,
|
|
316
|
+
session.id,
|
|
317
|
+
deps.egressHooks,
|
|
318
|
+
);
|
|
319
|
+
proxySessionId = started.id;
|
|
320
|
+
proxyEnv = getSessionEnv(sessionStore, started.id);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
cleanupAll(scratchDir, tempFilePath);
|
|
323
|
+
return {
|
|
324
|
+
success: false,
|
|
325
|
+
error: `Egress proxy startup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
326
|
+
auditId,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// -- 8. Build the execution environment -----------------------------------
|
|
332
|
+
const entrypointPath = join(
|
|
333
|
+
getBundleContentPath(toolstoreDir, request.bundleDigest),
|
|
334
|
+
"..",
|
|
335
|
+
manifest.entrypoint,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const commandEnv = buildCommandEnv(
|
|
339
|
+
adapterEnv,
|
|
340
|
+
proxyEnv,
|
|
341
|
+
manifest.cleanConfigDirs,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// -- 9. Execute the command -----------------------------------------------
|
|
345
|
+
const maxOutput = deps.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
346
|
+
let execResult: ExecuteCommandResult;
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
execResult = await runCommand(
|
|
350
|
+
entrypointPath,
|
|
351
|
+
request.argv,
|
|
352
|
+
scratchDir,
|
|
353
|
+
commandEnv,
|
|
354
|
+
maxOutput,
|
|
355
|
+
auditId,
|
|
356
|
+
);
|
|
357
|
+
} catch (err) {
|
|
358
|
+
execResult = {
|
|
359
|
+
success: false,
|
|
360
|
+
error: `Command execution failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
361
|
+
auditId,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// -- 10. Output copyback --------------------------------------------------
|
|
366
|
+
if (
|
|
367
|
+
request.outputs &&
|
|
368
|
+
request.outputs.length > 0 &&
|
|
369
|
+
execResult.exitCode !== undefined
|
|
370
|
+
) {
|
|
371
|
+
try {
|
|
372
|
+
const copybackResult = copybackOutputs(
|
|
373
|
+
stageConfigWithSecrets,
|
|
374
|
+
scratchDir,
|
|
375
|
+
);
|
|
376
|
+
execResult.copybackResult = copybackResult;
|
|
377
|
+
|
|
378
|
+
if (!copybackResult.allSucceeded) {
|
|
379
|
+
const failures = copybackResult.outputs
|
|
380
|
+
.filter((o) => !o.success)
|
|
381
|
+
.map((o) => `${o.scratchPath}: ${o.reason}`)
|
|
382
|
+
.join("; ");
|
|
383
|
+
execResult.error = execResult.error
|
|
384
|
+
? `${execResult.error}; Output copyback failures: ${failures}`
|
|
385
|
+
: `Output copyback failures: ${failures}`;
|
|
386
|
+
}
|
|
387
|
+
} catch (err) {
|
|
388
|
+
execResult.error = execResult.error
|
|
389
|
+
? `${execResult.error}; Output copyback error: ${err instanceof Error ? err.message : String(err)}`
|
|
390
|
+
: `Output copyback error: ${err instanceof Error ? err.message : String(err)}`;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// -- 11. Cleanup ----------------------------------------------------------
|
|
395
|
+
if (proxySessionId) {
|
|
396
|
+
try {
|
|
397
|
+
await stopSession(proxySessionId, sessionStore);
|
|
398
|
+
} catch {
|
|
399
|
+
// Best-effort proxy cleanup
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
cleanupAll(scratchDir, tempFilePath);
|
|
404
|
+
|
|
405
|
+
return execResult;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// Internal: Bundle resolution
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
type BundleResolutionResult =
|
|
413
|
+
| { ok: true; manifest: SecureCommandManifest; toolstoreDir: string }
|
|
414
|
+
| { ok: false; error: string };
|
|
415
|
+
|
|
416
|
+
function resolveBundle(
|
|
417
|
+
bundleDigest: string,
|
|
418
|
+
cesMode?: CesMode,
|
|
419
|
+
): BundleResolutionResult {
|
|
420
|
+
if (!isBundlePublished(bundleDigest, cesMode)) {
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
error: `Bundle with digest "${bundleDigest}" is not published in the CES toolstore. ` +
|
|
424
|
+
`Only approved bundles can be executed.`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const toolstoreManifest = readPublishedManifest(bundleDigest, cesMode);
|
|
429
|
+
if (!toolstoreManifest) {
|
|
430
|
+
return {
|
|
431
|
+
ok: false,
|
|
432
|
+
error: `Bundle manifest for digest "${bundleDigest}" could not be read from the toolstore.`,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const manifest = toolstoreManifest.secureCommandManifest;
|
|
437
|
+
|
|
438
|
+
// Defense-in-depth: re-check denied binary at execution time
|
|
439
|
+
if (isDeniedBinary(manifest.entrypoint)) {
|
|
440
|
+
return {
|
|
441
|
+
ok: false,
|
|
442
|
+
error: `Entrypoint "${manifest.entrypoint}" is a structurally denied binary. ` +
|
|
443
|
+
`Generic HTTP clients, interpreters, and shell trampolines cannot be executed.`,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (isDeniedBinary(manifest.bundleId)) {
|
|
448
|
+
return {
|
|
449
|
+
ok: false,
|
|
450
|
+
error: `Bundle ID "${manifest.bundleId}" matches a structurally denied binary name.`,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const toolstoreDir = getCesToolStoreDir(cesMode);
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
ok: true,
|
|
458
|
+
manifest,
|
|
459
|
+
toolstoreDir,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// Internal: Profile validation
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
interface ProfileValidationResult {
|
|
468
|
+
ok: boolean;
|
|
469
|
+
profile?: CommandProfile;
|
|
470
|
+
matchedPattern?: string;
|
|
471
|
+
error?: string;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function validateProfile(
|
|
475
|
+
manifest: SecureCommandManifest,
|
|
476
|
+
profileName: string,
|
|
477
|
+
argv: string[],
|
|
478
|
+
): ProfileValidationResult {
|
|
479
|
+
const profile = manifest.commandProfiles[profileName];
|
|
480
|
+
if (!profile) {
|
|
481
|
+
const available = Object.keys(manifest.commandProfiles).join(", ");
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
error: `Profile "${profileName}" not found in manifest for bundle "${manifest.bundleId}". ` +
|
|
485
|
+
`Available profiles: ${available}`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Validate the argv against the full manifest (checks denied subcommands/flags
|
|
490
|
+
// across all profiles, then matches against allowed patterns)
|
|
491
|
+
const cmdResult: CommandValidationResult = validateCommand(manifest, argv);
|
|
492
|
+
if (!cmdResult.allowed) {
|
|
493
|
+
return {
|
|
494
|
+
ok: false,
|
|
495
|
+
error: `Command validation failed: ${cmdResult.reason}`,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Ensure the matched profile is the requested one
|
|
500
|
+
if (cmdResult.matchedProfile !== profileName) {
|
|
501
|
+
return {
|
|
502
|
+
ok: false,
|
|
503
|
+
error: `Command argv matched profile "${cmdResult.matchedProfile}" but the requested ` +
|
|
504
|
+
`profile is "${profileName}". The command does not match any pattern in the requested profile.`,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
ok: true,
|
|
510
|
+
profile,
|
|
511
|
+
matchedPattern: cmdResult.matchedPattern,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// Internal: Grant enforcement
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
interface GrantCheckResult {
|
|
520
|
+
ok: boolean;
|
|
521
|
+
grantId?: string;
|
|
522
|
+
error?: string;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function checkGrant(
|
|
526
|
+
request: ExecuteCommandRequest,
|
|
527
|
+
manifest: SecureCommandManifest,
|
|
528
|
+
profileName: string,
|
|
529
|
+
persistentStore: PersistentGrantStore,
|
|
530
|
+
temporaryStore: TemporaryGrantStore,
|
|
531
|
+
): GrantCheckResult {
|
|
532
|
+
// If an explicit grantId is provided, check it directly
|
|
533
|
+
if (request.grantId) {
|
|
534
|
+
const grant = persistentStore.getById(request.grantId);
|
|
535
|
+
if (grant) {
|
|
536
|
+
return { ok: true, grantId: grant.id };
|
|
537
|
+
}
|
|
538
|
+
// Explicit grant not found — fall through to pattern matching
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Check persistent grants for a matching command grant
|
|
542
|
+
const allGrants = persistentStore.getAll();
|
|
543
|
+
for (const grant of allGrants) {
|
|
544
|
+
if (
|
|
545
|
+
grant.tool === "command" &&
|
|
546
|
+
grant.scope === request.credentialHandle &&
|
|
547
|
+
grantMatchesCommand(grant.pattern, manifest.bundleId, profileName)
|
|
548
|
+
) {
|
|
549
|
+
return { ok: true, grantId: grant.id };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Check temporary grants
|
|
554
|
+
const proposalHash = computeCommandProposalHash(
|
|
555
|
+
request.credentialHandle,
|
|
556
|
+
manifest.bundleId,
|
|
557
|
+
profileName,
|
|
558
|
+
);
|
|
559
|
+
const tempKind = temporaryStore.checkAny(
|
|
560
|
+
proposalHash,
|
|
561
|
+
request.conversationId,
|
|
562
|
+
);
|
|
563
|
+
if (tempKind) {
|
|
564
|
+
return { ok: true, grantId: `temp:${tempKind}:${proposalHash}` };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return {
|
|
568
|
+
ok: false,
|
|
569
|
+
error: `No active grant found for bundle="${manifest.bundleId}", ` +
|
|
570
|
+
`profile="${profileName}", credential="${request.credentialHandle}". ` +
|
|
571
|
+
`Approval is required before command execution.`,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Check if a persistent grant pattern matches a command invocation.
|
|
577
|
+
*
|
|
578
|
+
* Grant patterns for commands use the format: `<bundleId>/<profileName>`.
|
|
579
|
+
*/
|
|
580
|
+
function grantMatchesCommand(
|
|
581
|
+
pattern: string,
|
|
582
|
+
bundleId: string,
|
|
583
|
+
profileName: string,
|
|
584
|
+
): boolean {
|
|
585
|
+
return pattern === `${bundleId}/${profileName}`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Compute a deterministic hash for temporary grant lookup.
|
|
590
|
+
*/
|
|
591
|
+
function computeCommandProposalHash(
|
|
592
|
+
credentialHandle: string,
|
|
593
|
+
bundleId: string,
|
|
594
|
+
profileName: string,
|
|
595
|
+
): string {
|
|
596
|
+
const parts = ["command", credentialHandle, bundleId, profileName];
|
|
597
|
+
const canonical = JSON.stringify(parts);
|
|
598
|
+
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
// Internal: Auth adapter environment construction
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
interface AuthAdapterEnvResult {
|
|
606
|
+
/** Environment variables to inject into the command. */
|
|
607
|
+
env: Record<string, string>;
|
|
608
|
+
/** Path to a temp file that must be cleaned up (for temp_file adapter). */
|
|
609
|
+
tempFilePath?: string;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function buildAuthAdapterEnv(
|
|
613
|
+
adapter: AuthAdapterConfig,
|
|
614
|
+
credentialValue: string,
|
|
615
|
+
): Promise<AuthAdapterEnvResult> {
|
|
616
|
+
// Validate adapter config
|
|
617
|
+
const errors = validateAuthAdapterConfig(adapter);
|
|
618
|
+
if (errors.length > 0) {
|
|
619
|
+
throw new Error(
|
|
620
|
+
`Invalid auth adapter config: ${errors.join("; ")}`,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
switch (adapter.type) {
|
|
625
|
+
case AuthAdapterType.EnvVar: {
|
|
626
|
+
const value = adapter.valuePrefix
|
|
627
|
+
? `${adapter.valuePrefix}${credentialValue}`
|
|
628
|
+
: credentialValue;
|
|
629
|
+
return {
|
|
630
|
+
env: { [adapter.envVarName]: value },
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
case AuthAdapterType.TempFile: {
|
|
635
|
+
// Write credential to a temp file and set the env var to the path
|
|
636
|
+
const tempDir = join(tmpdir(), `ces-auth-${randomUUID()}`);
|
|
637
|
+
mkdirSync(tempDir, { recursive: true });
|
|
638
|
+
const ext = adapter.fileExtension ?? "";
|
|
639
|
+
const tempPath = join(tempDir, `credential${ext}`);
|
|
640
|
+
const mode = adapter.fileMode ?? 0o600;
|
|
641
|
+
writeFileSync(tempPath, credentialValue, { mode });
|
|
642
|
+
return {
|
|
643
|
+
env: { [adapter.envVarName]: tempPath },
|
|
644
|
+
tempFilePath: tempPath,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
case AuthAdapterType.CredentialProcess: {
|
|
649
|
+
// Run the helper command and capture its stdout
|
|
650
|
+
const timeoutMs = adapter.timeoutMs ?? CREDENTIAL_PROCESS_TIMEOUT_MS;
|
|
651
|
+
const helperResult = await runCredentialProcess(
|
|
652
|
+
adapter.helperCommand,
|
|
653
|
+
credentialValue,
|
|
654
|
+
timeoutMs,
|
|
655
|
+
);
|
|
656
|
+
if (!helperResult.ok) {
|
|
657
|
+
throw new Error(
|
|
658
|
+
`Credential process helper failed: ${helperResult.error}`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
env: { [adapter.envVarName]: helperResult.stdout },
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
default:
|
|
667
|
+
throw new Error(`Unknown auth adapter type: ${(adapter as AuthAdapterConfig).type}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Run a credential_process helper command inside CES.
|
|
673
|
+
*
|
|
674
|
+
* The helper receives the raw credential value on stdin and writes
|
|
675
|
+
* the transformed credential to stdout. It is never exposed to the
|
|
676
|
+
* subprocess directly.
|
|
677
|
+
*/
|
|
678
|
+
async function runCredentialProcess(
|
|
679
|
+
helperCommand: string,
|
|
680
|
+
_credentialValue: string,
|
|
681
|
+
timeoutMs: number,
|
|
682
|
+
): Promise<{ ok: true; stdout: string } | { ok: false; error: string }> {
|
|
683
|
+
try {
|
|
684
|
+
const proc = Bun.spawn(["sh", "-c", helperCommand], {
|
|
685
|
+
stdin: "pipe",
|
|
686
|
+
stdout: "pipe",
|
|
687
|
+
stderr: "pipe",
|
|
688
|
+
env: {}, // Clean environment — no secrets leaked
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// Close stdin immediately — the helper reads only from its argv/config
|
|
692
|
+
proc.stdin.end();
|
|
693
|
+
|
|
694
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
695
|
+
const exitCode = await Promise.race([
|
|
696
|
+
proc.exited,
|
|
697
|
+
new Promise<never>((_, reject) => {
|
|
698
|
+
timeoutSignal.addEventListener("abort", () => {
|
|
699
|
+
proc.kill();
|
|
700
|
+
reject(new Error(`Credential process timed out after ${timeoutMs}ms`));
|
|
701
|
+
});
|
|
702
|
+
}),
|
|
703
|
+
]);
|
|
704
|
+
|
|
705
|
+
const stdout = await new Response(proc.stdout).text();
|
|
706
|
+
const stderr = await new Response(proc.stderr).text();
|
|
707
|
+
|
|
708
|
+
if (exitCode !== 0) {
|
|
709
|
+
return {
|
|
710
|
+
ok: false,
|
|
711
|
+
error: `Helper exited with code ${exitCode}: ${stderr.trim()}`,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return { ok: true, stdout: stdout.trim() };
|
|
716
|
+
} catch (err) {
|
|
717
|
+
return {
|
|
718
|
+
ok: false,
|
|
719
|
+
error: err instanceof Error ? err.message : String(err),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ---------------------------------------------------------------------------
|
|
725
|
+
// Internal: Command environment construction
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Build the clean execution environment for the command.
|
|
730
|
+
*
|
|
731
|
+
* The environment contains:
|
|
732
|
+
* - Auth adapter env vars (credential injection)
|
|
733
|
+
* - Proxy env vars (when egress proxy is active)
|
|
734
|
+
* - HOME set to a temp directory (isolates config reads)
|
|
735
|
+
* - PATH preserved from the CES process
|
|
736
|
+
*
|
|
737
|
+
* The environment explicitly does NOT inherit the CES process env.
|
|
738
|
+
* Clean config dirs are handled by setting HOME to a temp directory.
|
|
739
|
+
*/
|
|
740
|
+
function buildCommandEnv(
|
|
741
|
+
adapterEnv: Record<string, string>,
|
|
742
|
+
proxyEnv?: ProxyEnvVars,
|
|
743
|
+
_cleanConfigDirs?: Record<string, string>,
|
|
744
|
+
): Record<string, string> {
|
|
745
|
+
const env: Record<string, string> = {
|
|
746
|
+
// Minimal baseline environment
|
|
747
|
+
PATH: process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin",
|
|
748
|
+
HOME: join(tmpdir(), `ces-home-${randomUUID()}`),
|
|
749
|
+
LANG: "en_US.UTF-8",
|
|
750
|
+
// Inject auth adapter env vars
|
|
751
|
+
...adapterEnv,
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
// Inject proxy env vars if the egress proxy is active
|
|
755
|
+
if (proxyEnv) {
|
|
756
|
+
env["HTTP_PROXY"] = proxyEnv.HTTP_PROXY;
|
|
757
|
+
env["HTTPS_PROXY"] = proxyEnv.HTTPS_PROXY;
|
|
758
|
+
env["NO_PROXY"] = proxyEnv.NO_PROXY;
|
|
759
|
+
env["http_proxy"] = proxyEnv.HTTP_PROXY;
|
|
760
|
+
env["https_proxy"] = proxyEnv.HTTPS_PROXY;
|
|
761
|
+
env["no_proxy"] = proxyEnv.NO_PROXY;
|
|
762
|
+
if (proxyEnv.NODE_EXTRA_CA_CERTS) {
|
|
763
|
+
env["NODE_EXTRA_CA_CERTS"] = proxyEnv.NODE_EXTRA_CA_CERTS;
|
|
764
|
+
}
|
|
765
|
+
if (proxyEnv.SSL_CERT_FILE) {
|
|
766
|
+
env["SSL_CERT_FILE"] = proxyEnv.SSL_CERT_FILE;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return env;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
774
|
+
// Internal: Command execution
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
|
|
777
|
+
async function runCommand(
|
|
778
|
+
entrypointPath: string,
|
|
779
|
+
argv: string[],
|
|
780
|
+
scratchDir: string,
|
|
781
|
+
env: Record<string, string>,
|
|
782
|
+
maxOutputBytes: number,
|
|
783
|
+
auditId: string,
|
|
784
|
+
): Promise<ExecuteCommandResult> {
|
|
785
|
+
// Ensure the HOME directory exists (for clean config dirs isolation)
|
|
786
|
+
if (env["HOME"]) {
|
|
787
|
+
mkdirSync(env["HOME"], { recursive: true });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const proc = Bun.spawn([entrypointPath, ...argv], {
|
|
791
|
+
cwd: scratchDir,
|
|
792
|
+
env,
|
|
793
|
+
stdin: "ignore",
|
|
794
|
+
stdout: "pipe",
|
|
795
|
+
stderr: "pipe",
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
const exitCode = await proc.exited;
|
|
799
|
+
|
|
800
|
+
// Capture stdout and stderr with size limits
|
|
801
|
+
const stdoutRaw = await new Response(proc.stdout).text();
|
|
802
|
+
const stderrRaw = await new Response(proc.stderr).text();
|
|
803
|
+
|
|
804
|
+
const stdout = stdoutRaw.length > maxOutputBytes
|
|
805
|
+
? stdoutRaw.slice(0, maxOutputBytes) + "\n[output truncated]"
|
|
806
|
+
: stdoutRaw;
|
|
807
|
+
|
|
808
|
+
const stderr = stderrRaw.length > maxOutputBytes
|
|
809
|
+
? stderrRaw.slice(0, maxOutputBytes) + "\n[output truncated]"
|
|
810
|
+
: stderrRaw;
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
success: exitCode === 0,
|
|
814
|
+
exitCode,
|
|
815
|
+
stdout,
|
|
816
|
+
stderr,
|
|
817
|
+
auditId,
|
|
818
|
+
...(exitCode !== 0 ? { error: `Command exited with code ${exitCode}` } : {}),
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ---------------------------------------------------------------------------
|
|
823
|
+
// Internal: Cleanup helpers
|
|
824
|
+
// ---------------------------------------------------------------------------
|
|
825
|
+
|
|
826
|
+
function cleanupAll(scratchDir: string, tempFilePath?: string): void {
|
|
827
|
+
// Clean up temp auth file
|
|
828
|
+
if (tempFilePath) {
|
|
829
|
+
try {
|
|
830
|
+
unlinkSync(tempFilePath);
|
|
831
|
+
// Also remove the parent temp directory
|
|
832
|
+
rmSync(dirname(tempFilePath), { recursive: true, force: true });
|
|
833
|
+
} catch {
|
|
834
|
+
// Best-effort cleanup
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Clean up scratch directory
|
|
839
|
+
cleanupScratchDir(scratchDir);
|
|
840
|
+
}
|