@vellumai/credential-executor 0.10.7 → 0.10.8-dev.202607102228.5945895
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 +1 -1
- package/node_modules/@vellumai/service-contracts/package.json +1 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +104 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +0 -2
- package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +3 -5
- package/node_modules/@vellumai/service-contracts/src/index.ts +2 -4
- package/node_modules/@vellumai/service-contracts/src/rpc.ts +4 -447
- package/package.json +2 -3
- package/src/__tests__/bulk-set-credentials.test.ts +1 -1
- package/src/__tests__/local-standalone.test.ts +5 -36
- package/src/__tests__/managed-integration.test.ts +112 -91
- package/src/__tests__/managed-reconnect.test.ts +2 -2
- package/src/__tests__/transport.test.ts +23 -27
- package/src/cli.ts +1 -1
- package/src/index.ts +8 -88
- package/src/main.ts +228 -340
- package/src/paths.ts +4 -20
- package/src/server.ts +52 -469
- package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +0 -686
- package/node_modules/@vellumai/service-contracts/src/grants.ts +0 -184
- package/node_modules/@vellumai/service-contracts/src/rendering.ts +0 -135
- package/src/__tests__/command-executor.test.ts +0 -1879
- package/src/__tests__/command-validator.test.ts +0 -1405
- package/src/__tests__/command-workspace.test.ts +0 -1050
- package/src/__tests__/grant-store.test.ts +0 -689
- package/src/__tests__/http-executor.test.ts +0 -1336
- package/src/__tests__/http-policy.test.ts +0 -1069
- package/src/__tests__/local-materializers.test.ts +0 -860
- package/src/__tests__/local-token-refresh.test.ts +0 -361
- package/src/__tests__/manage-secure-command-tool.test.ts +0 -134
- package/src/__tests__/managed-lazy-getters.test.ts +0 -359
- package/src/__tests__/managed-materializers.test.ts +0 -1028
- package/src/__tests__/managed-rejection.test.ts +0 -43
- package/src/__tests__/toolstore.test.ts +0 -773
- package/src/audit/store.ts +0 -188
- package/src/commands/auth-adapters.ts +0 -169
- package/src/commands/egress-hooks.ts +0 -203
- package/src/commands/executor.ts +0 -1155
- package/src/commands/output-scan.ts +0 -157
- package/src/commands/profiles.ts +0 -286
- package/src/commands/validator.ts +0 -702
- package/src/commands/workspace.ts +0 -550
- package/src/grants/index.ts +0 -17
- package/src/grants/persistent-store.ts +0 -309
- package/src/grants/rpc-handlers.ts +0 -293
- package/src/grants/temporary-store.ts +0 -289
- package/src/http/audit.ts +0 -84
- package/src/http/executor.ts +0 -684
- package/src/http/path-template.ts +0 -245
- package/src/http/policy.ts +0 -238
- package/src/http/response-filter.ts +0 -233
- package/src/managed-errors.ts +0 -9
- package/src/managed-lazy-getters.ts +0 -106
- package/src/managed-main.ts +0 -822
- package/src/materializers/local-oauth-lookup.ts +0 -98
- package/src/materializers/local-token-refresh.ts +0 -287
- package/src/materializers/local.ts +0 -316
- package/src/materializers/managed-platform.ts +0 -295
- package/src/subjects/local.ts +0 -177
- package/src/subjects/managed.ts +0 -311
- package/src/subjects/policy.ts +0 -79
- package/src/toolstore/integrity.ts +0 -94
- package/src/toolstore/manifest.ts +0 -154
- package/src/toolstore/publish.ts +0 -571
package/src/commands/executor.ts
DELETED
|
@@ -1,1155 +0,0 @@
|
|
|
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. **Credential materialization** — Materialize the raw credential
|
|
20
|
-
* value from the credential store.
|
|
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. This happens BEFORE the auth adapter runs so that
|
|
25
|
-
* credential_process helpers also execute under egress control.
|
|
26
|
-
*
|
|
27
|
-
* 7. **Auth adapter construction** — Build the credential environment
|
|
28
|
-
* through the declared auth adapter (env_var, temp_file, or
|
|
29
|
-
* credential_process). For credential_process, the helper runs
|
|
30
|
-
* with proxy env vars injected.
|
|
31
|
-
*
|
|
32
|
-
* 8. **Command execution** — Run the command with clean config dirs,
|
|
33
|
-
* materialized credential env vars, and proxy env vars. The command
|
|
34
|
-
* runs in the scratch directory, never in the assistant workspace.
|
|
35
|
-
*
|
|
36
|
-
* 9. **Output copyback** — After exit, validate and copy declared output
|
|
37
|
-
* files from the scratch directory back into the workspace.
|
|
38
|
-
*
|
|
39
|
-
* 10. **Cleanup** — Stop the egress proxy session, remove temp files, and
|
|
40
|
-
* clean up the scratch directory.
|
|
41
|
-
*
|
|
42
|
-
* The executor is fail-closed: bundle mismatches, missing grants,
|
|
43
|
-
* adapter failures, egress failures, undeclared outputs, and scan
|
|
44
|
-
* violations all result in command rejection before or after execution.
|
|
45
|
-
*/
|
|
46
|
-
|
|
47
|
-
import { randomUUID } from "node:crypto";
|
|
48
|
-
import { dirname, join, resolve } from "node:path";
|
|
49
|
-
import { mkdirSync, writeFileSync, unlinkSync, rmSync, realpathSync } from "node:fs";
|
|
50
|
-
import { tmpdir } from "node:os";
|
|
51
|
-
import {
|
|
52
|
-
SessionStore,
|
|
53
|
-
createSession,
|
|
54
|
-
startSession,
|
|
55
|
-
stopSession,
|
|
56
|
-
getSessionEnv,
|
|
57
|
-
type SessionStartHooks,
|
|
58
|
-
type ProxyEnvVars,
|
|
59
|
-
} from "@vellumai/egress-proxy";
|
|
60
|
-
|
|
61
|
-
import { readPublishedManifest, getBundleContentPath, isBundlePublished } from "../toolstore/publish.js";
|
|
62
|
-
import { getCesToolStoreDir, type CesMode } from "../paths.js";
|
|
63
|
-
import type { SecureCommandManifest, CommandProfile } from "./profiles.js";
|
|
64
|
-
import { isDeniedBinary, EgressMode } from "./profiles.js";
|
|
65
|
-
import { validateCommand, extractShellBinary, containsShellMetacharacters, type CommandValidationResult } from "./validator.js";
|
|
66
|
-
import type { AuthAdapterConfig } from "./auth-adapters.js";
|
|
67
|
-
import { AuthAdapterType, validateAuthAdapterConfig } from "./auth-adapters.js";
|
|
68
|
-
import {
|
|
69
|
-
stageInputs,
|
|
70
|
-
copybackOutputs,
|
|
71
|
-
cleanupScratchDir,
|
|
72
|
-
type WorkspaceStageConfig,
|
|
73
|
-
type WorkspaceInput,
|
|
74
|
-
type WorkspaceOutput,
|
|
75
|
-
type CopybackResult,
|
|
76
|
-
} from "./workspace.js";
|
|
77
|
-
import { hashProposal, type AuditRecordSummary, type CommandGrantProposal } from "@vellumai/service-contracts/credential-rpc";
|
|
78
|
-
|
|
79
|
-
import type { AuditStore } from "../audit/store.js";
|
|
80
|
-
import type { PersistentGrantStore } from "../grants/persistent-store.js";
|
|
81
|
-
import type { TemporaryGrantStore } from "../grants/temporary-store.js";
|
|
82
|
-
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
// Types
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Request to execute an authenticated command through the CES pipeline.
|
|
89
|
-
*/
|
|
90
|
-
export interface ExecuteCommandRequest {
|
|
91
|
-
/** SHA-256 hex digest of the approved bundle. */
|
|
92
|
-
bundleDigest: string;
|
|
93
|
-
/** Name of the command profile to use within the manifest. */
|
|
94
|
-
profileName: string;
|
|
95
|
-
/** CES credential handle identifying which credential to inject. */
|
|
96
|
-
credentialHandle: string;
|
|
97
|
-
/** Argv tokens (command arguments, not including the binary path). */
|
|
98
|
-
argv: string[];
|
|
99
|
-
/** Absolute path to the assistant-visible workspace directory. */
|
|
100
|
-
workspaceDir: string;
|
|
101
|
-
/** Files to stage as read-only inputs in the scratch directory. */
|
|
102
|
-
inputs?: WorkspaceInput[];
|
|
103
|
-
/** Files to copy back from the scratch directory after execution. */
|
|
104
|
-
outputs?: WorkspaceOutput[];
|
|
105
|
-
/** Human-readable purpose for audit logging. */
|
|
106
|
-
purpose: string;
|
|
107
|
-
/** Explicit grant ID to consume, if the caller holds one. */
|
|
108
|
-
grantId?: string;
|
|
109
|
-
/** Conversation ID for conversation-scoped temporary grants. */
|
|
110
|
-
conversationId?: string;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Result of a command execution attempt.
|
|
115
|
-
*/
|
|
116
|
-
export interface ExecuteCommandResult {
|
|
117
|
-
/** Whether the command executed successfully. */
|
|
118
|
-
success: boolean;
|
|
119
|
-
/** Process exit code (undefined if the command was never launched). */
|
|
120
|
-
exitCode?: number;
|
|
121
|
-
/** Combined stdout output (truncated for safety). */
|
|
122
|
-
stdout?: string;
|
|
123
|
-
/** Combined stderr output (truncated for safety). */
|
|
124
|
-
stderr?: string;
|
|
125
|
-
/** Copyback results for declared outputs. */
|
|
126
|
-
copybackResult?: CopybackResult;
|
|
127
|
-
/** Error message if execution failed. */
|
|
128
|
-
error?: string;
|
|
129
|
-
/** Audit-relevant metadata. */
|
|
130
|
-
auditId?: string;
|
|
131
|
-
/**
|
|
132
|
-
* When the failure reason is a missing grant, this field contains the
|
|
133
|
-
* proposal metadata needed by the approval bridge. Present only when
|
|
134
|
-
* the error is an approval-required grant failure.
|
|
135
|
-
*/
|
|
136
|
-
approvalRequired?: {
|
|
137
|
-
credentialHandle: string;
|
|
138
|
-
bundleId: string;
|
|
139
|
-
bundleDigest: string;
|
|
140
|
-
profileName: string;
|
|
141
|
-
command: string;
|
|
142
|
-
purpose: string;
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Credential materializer abstraction.
|
|
148
|
-
*
|
|
149
|
-
* The executor does not import materializer implementations directly.
|
|
150
|
-
* Callers provide a materializer function that resolves a credential
|
|
151
|
-
* handle into a raw secret value.
|
|
152
|
-
*/
|
|
153
|
-
export type MaterializeCredentialFn = (
|
|
154
|
-
credentialHandle: string,
|
|
155
|
-
) => Promise<MaterializeCredentialResult>;
|
|
156
|
-
|
|
157
|
-
export type MaterializeCredentialResult =
|
|
158
|
-
| { ok: true; value: string; handleType: string }
|
|
159
|
-
| { ok: false; error: string };
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Dependencies injected into the command executor.
|
|
163
|
-
*/
|
|
164
|
-
export interface CommandExecutorDeps {
|
|
165
|
-
/** Persistent grant store for checking bundle/profile approvals. */
|
|
166
|
-
persistentStore: PersistentGrantStore;
|
|
167
|
-
/** Temporary grant store for session-scoped approvals. */
|
|
168
|
-
temporaryStore: TemporaryGrantStore;
|
|
169
|
-
/** Credential materializer function. */
|
|
170
|
-
materializeCredential: MaterializeCredentialFn;
|
|
171
|
-
/** Audit store for persisting token-free audit records. */
|
|
172
|
-
auditStore?: AuditStore;
|
|
173
|
-
/** Session ID for audit records, taken from the calling connection's SessionContext at dispatch time. */
|
|
174
|
-
sessionId?: string;
|
|
175
|
-
/** CES operating mode (for toolstore path resolution). */
|
|
176
|
-
cesMode?: CesMode;
|
|
177
|
-
/** Egress proxy session start hooks (for creating the proxy server). */
|
|
178
|
-
egressHooks?: SessionStartHooks;
|
|
179
|
-
/** Egress proxy session store (shared or isolated). */
|
|
180
|
-
egressSessionStore?: SessionStore;
|
|
181
|
-
/** Maximum stdout/stderr capture size in bytes. */
|
|
182
|
-
maxOutputBytes?: number;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ---------------------------------------------------------------------------
|
|
186
|
-
// Constants
|
|
187
|
-
// ---------------------------------------------------------------------------
|
|
188
|
-
|
|
189
|
-
/** Maximum stdout/stderr capture (256 KB). */
|
|
190
|
-
const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
191
|
-
|
|
192
|
-
/** Credential process helper timeout. */
|
|
193
|
-
const CREDENTIAL_PROCESS_TIMEOUT_MS = 10_000;
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Banned binary names — checked at execution time as a defense-in-depth
|
|
197
|
-
* supplement to the manifest validator's static check.
|
|
198
|
-
*/
|
|
199
|
-
|
|
200
|
-
// ---------------------------------------------------------------------------
|
|
201
|
-
// Executor implementation
|
|
202
|
-
// ---------------------------------------------------------------------------
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Execute an authenticated command through the full CES pipeline.
|
|
206
|
-
*
|
|
207
|
-
* This is the top-level orchestrator. Each step is fail-closed: if any
|
|
208
|
-
* phase returns an error, the command is rejected and cleanup runs.
|
|
209
|
-
*/
|
|
210
|
-
export async function executeAuthenticatedCommand(
|
|
211
|
-
request: ExecuteCommandRequest,
|
|
212
|
-
deps: CommandExecutorDeps,
|
|
213
|
-
): Promise<ExecuteCommandResult> {
|
|
214
|
-
const auditId = randomUUID();
|
|
215
|
-
|
|
216
|
-
// -- 1. Resolve and validate the bundle -----------------------------------
|
|
217
|
-
const bundleResult = resolveBundle(request.bundleDigest, deps.cesMode);
|
|
218
|
-
if (!bundleResult.ok) {
|
|
219
|
-
return {
|
|
220
|
-
success: false,
|
|
221
|
-
error: bundleResult.error,
|
|
222
|
-
auditId,
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const { manifest, toolstoreDir } = bundleResult;
|
|
227
|
-
|
|
228
|
-
// -- 2. Validate the command profile and argv -----------------------------
|
|
229
|
-
const profileResult = validateProfile(
|
|
230
|
-
manifest,
|
|
231
|
-
request.profileName,
|
|
232
|
-
request.argv,
|
|
233
|
-
);
|
|
234
|
-
if (!profileResult.ok) {
|
|
235
|
-
return {
|
|
236
|
-
success: false,
|
|
237
|
-
error: profileResult.error,
|
|
238
|
-
auditId,
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// -- 3. Check grant enforcement -------------------------------------------
|
|
243
|
-
const grantResult = checkGrant(
|
|
244
|
-
request,
|
|
245
|
-
manifest,
|
|
246
|
-
request.profileName,
|
|
247
|
-
deps.persistentStore,
|
|
248
|
-
deps.temporaryStore,
|
|
249
|
-
);
|
|
250
|
-
if (!grantResult.ok) {
|
|
251
|
-
return {
|
|
252
|
-
success: false,
|
|
253
|
-
error: grantResult.error,
|
|
254
|
-
auditId,
|
|
255
|
-
approvalRequired: {
|
|
256
|
-
credentialHandle: request.credentialHandle,
|
|
257
|
-
bundleId: manifest.bundleId,
|
|
258
|
-
bundleDigest: request.bundleDigest,
|
|
259
|
-
profileName: request.profileName,
|
|
260
|
-
command: `${request.bundleDigest}/${request.profileName} ${request.argv.join(" ")}`.trim(),
|
|
261
|
-
purpose: request.purpose,
|
|
262
|
-
},
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// -- 4. Stage workspace inputs --------------------------------------------
|
|
267
|
-
const stageConfig: WorkspaceStageConfig = {
|
|
268
|
-
workspaceDir: request.workspaceDir,
|
|
269
|
-
inputs: request.inputs ?? [],
|
|
270
|
-
outputs: request.outputs ?? [],
|
|
271
|
-
secrets: new Set<string>(), // Populated after materialization
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
let scratchDir: string;
|
|
275
|
-
try {
|
|
276
|
-
const staged = stageInputs(stageConfig, deps.cesMode);
|
|
277
|
-
scratchDir = staged.scratchDir;
|
|
278
|
-
} catch (err) {
|
|
279
|
-
return {
|
|
280
|
-
success: false,
|
|
281
|
-
error: `Input staging failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
282
|
-
auditId,
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// -- 5. Materialize the credential ----------------------------------------
|
|
287
|
-
const matResult = await deps.materializeCredential(request.credentialHandle);
|
|
288
|
-
if (!matResult.ok) {
|
|
289
|
-
cleanupScratchDir(scratchDir);
|
|
290
|
-
return {
|
|
291
|
-
success: false,
|
|
292
|
-
error: `Credential materialization failed: ${matResult.error}`,
|
|
293
|
-
auditId,
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Update the stage config with the materialized secret for output scanning
|
|
298
|
-
const secretSet = new Set<string>([matResult.value]);
|
|
299
|
-
const stageConfigWithSecrets: WorkspaceStageConfig = {
|
|
300
|
-
...stageConfig,
|
|
301
|
-
secrets: secretSet,
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
// -- 6. Start egress proxy (if proxy_required) ----------------------------
|
|
305
|
-
// The egress proxy must be started BEFORE the auth adapter runs, so that
|
|
306
|
-
// credential_process helpers execute under egress control (not in an
|
|
307
|
-
// uncontrolled network state).
|
|
308
|
-
let proxyEnv: ProxyEnvVars | undefined;
|
|
309
|
-
let proxySessionId: string | undefined;
|
|
310
|
-
const sessionStore = deps.egressSessionStore ?? new SessionStore();
|
|
311
|
-
|
|
312
|
-
if (manifest.egressMode === EgressMode.ProxyRequired) {
|
|
313
|
-
if (!deps.egressHooks) {
|
|
314
|
-
cleanupScratchDir(scratchDir);
|
|
315
|
-
return {
|
|
316
|
-
success: false,
|
|
317
|
-
error: "Egress mode is proxy_required but no egress hooks were provided. " +
|
|
318
|
-
"Cannot enforce network policy without an egress proxy.",
|
|
319
|
-
auditId,
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
try {
|
|
324
|
-
const conversationId = request.conversationId ?? `ces-cmd-${auditId}`;
|
|
325
|
-
// Carry the profile's allowedNetworkTargets into the session config
|
|
326
|
-
// so the egress proxy can enforce the allowlist.
|
|
327
|
-
const profile = manifest.commandProfiles[request.profileName];
|
|
328
|
-
const allowedTargets = profile?.allowedNetworkTargets?.map((t) => ({
|
|
329
|
-
host: t.hostPattern,
|
|
330
|
-
...(t.ports ? { ports: t.ports } : {}),
|
|
331
|
-
...(t.protocols ? { protocols: t.protocols } : {}),
|
|
332
|
-
}));
|
|
333
|
-
const session = createSession(
|
|
334
|
-
sessionStore,
|
|
335
|
-
conversationId,
|
|
336
|
-
[request.credentialHandle],
|
|
337
|
-
{ allowedTargets },
|
|
338
|
-
);
|
|
339
|
-
const started = await startSession(
|
|
340
|
-
sessionStore,
|
|
341
|
-
session.id,
|
|
342
|
-
deps.egressHooks,
|
|
343
|
-
);
|
|
344
|
-
proxySessionId = started.id;
|
|
345
|
-
proxyEnv = getSessionEnv(sessionStore, started.id);
|
|
346
|
-
} catch (err) {
|
|
347
|
-
cleanupScratchDir(scratchDir);
|
|
348
|
-
return {
|
|
349
|
-
success: false,
|
|
350
|
-
error: `Egress proxy startup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
351
|
-
auditId,
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// For no_network mode, block all outbound by pointing proxy vars at a
|
|
357
|
-
// non-existent address. This prevents subprocesses from making direct
|
|
358
|
-
// connections even without a running egress proxy.
|
|
359
|
-
let noNetworkEnv: Record<string, string> | undefined;
|
|
360
|
-
if (manifest.egressMode === EgressMode.NoNetwork) {
|
|
361
|
-
const blockedProxy = "http://127.0.0.1:0";
|
|
362
|
-
noNetworkEnv = {
|
|
363
|
-
HTTP_PROXY: blockedProxy,
|
|
364
|
-
HTTPS_PROXY: blockedProxy,
|
|
365
|
-
http_proxy: blockedProxy,
|
|
366
|
-
https_proxy: blockedProxy,
|
|
367
|
-
NO_PROXY: "",
|
|
368
|
-
no_proxy: "",
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// -- 7. Build auth adapter environment ------------------------------------
|
|
373
|
-
// Pass proxy/no-network env vars so credential_process helpers also run
|
|
374
|
-
// under egress control.
|
|
375
|
-
let adapterEnv: Record<string, string>;
|
|
376
|
-
let tempFilePath: string | undefined;
|
|
377
|
-
try {
|
|
378
|
-
const adapterResult = await buildAuthAdapterEnv(
|
|
379
|
-
manifest.authAdapter,
|
|
380
|
-
matResult.value,
|
|
381
|
-
proxyEnv,
|
|
382
|
-
noNetworkEnv,
|
|
383
|
-
);
|
|
384
|
-
adapterEnv = adapterResult.env;
|
|
385
|
-
tempFilePath = adapterResult.tempFilePath;
|
|
386
|
-
} catch (err) {
|
|
387
|
-
// Stop the proxy session before returning — it may already be running
|
|
388
|
-
if (proxySessionId) {
|
|
389
|
-
try {
|
|
390
|
-
await stopSession(proxySessionId, sessionStore);
|
|
391
|
-
} catch {
|
|
392
|
-
// Best-effort proxy cleanup
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
cleanupScratchDir(scratchDir);
|
|
396
|
-
return {
|
|
397
|
-
success: false,
|
|
398
|
-
error: `Auth adapter materialization failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
399
|
-
auditId,
|
|
400
|
-
};
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// -- 8. Build the execution environment -----------------------------------
|
|
404
|
-
const bundleDir = dirname(getBundleContentPath(toolstoreDir, request.bundleDigest));
|
|
405
|
-
const entrypointPath = resolve(bundleDir, manifest.entrypoint);
|
|
406
|
-
|
|
407
|
-
// Containment check: entrypoint must resolve inside the bundle directory
|
|
408
|
-
// (lexical check for path traversal via ../)
|
|
409
|
-
if (!entrypointPath.startsWith(bundleDir + "/") && entrypointPath !== bundleDir) {
|
|
410
|
-
// Stop the proxy session before returning — it may already be running
|
|
411
|
-
if (proxySessionId) {
|
|
412
|
-
try {
|
|
413
|
-
await stopSession(proxySessionId, sessionStore);
|
|
414
|
-
} catch {
|
|
415
|
-
// Best-effort proxy cleanup
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
cleanupAll(scratchDir, tempFilePath);
|
|
419
|
-
return {
|
|
420
|
-
success: false,
|
|
421
|
-
error: `Entrypoint "${manifest.entrypoint}" resolves outside the bundle directory. ` +
|
|
422
|
-
`Path traversal is not allowed.`,
|
|
423
|
-
auditId,
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// Symlink escape check: follow symlinks and verify the real path is
|
|
428
|
-
// still inside the bundle directory. A symlink entrypoint like
|
|
429
|
-
// `bin/tool -> /usr/bin/curl` passes the lexical check above but
|
|
430
|
-
// executes outside the bundle boundary.
|
|
431
|
-
let realEntrypointPath: string;
|
|
432
|
-
try {
|
|
433
|
-
realEntrypointPath = realpathSync(entrypointPath);
|
|
434
|
-
} catch {
|
|
435
|
-
// realpathSync fails if the file doesn't exist or is a broken symlink
|
|
436
|
-
if (proxySessionId) {
|
|
437
|
-
try {
|
|
438
|
-
await stopSession(proxySessionId, sessionStore);
|
|
439
|
-
} catch {
|
|
440
|
-
// Best-effort proxy cleanup
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
cleanupAll(scratchDir, tempFilePath);
|
|
444
|
-
return {
|
|
445
|
-
success: false,
|
|
446
|
-
error: `Entrypoint "${manifest.entrypoint}" could not be resolved (broken symlink or missing file).`,
|
|
447
|
-
auditId,
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
const realBundleDir = realpathSync(bundleDir);
|
|
451
|
-
if (!realEntrypointPath.startsWith(realBundleDir + "/") && realEntrypointPath !== realBundleDir) {
|
|
452
|
-
if (proxySessionId) {
|
|
453
|
-
try {
|
|
454
|
-
await stopSession(proxySessionId, sessionStore);
|
|
455
|
-
} catch {
|
|
456
|
-
// Best-effort proxy cleanup
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
cleanupAll(scratchDir, tempFilePath);
|
|
460
|
-
return {
|
|
461
|
-
success: false,
|
|
462
|
-
error: `Entrypoint "${manifest.entrypoint}" is a symlink that resolves to "${realEntrypointPath}", ` +
|
|
463
|
-
`which is outside the bundle directory. Symlink escape is not allowed.`,
|
|
464
|
-
auditId,
|
|
465
|
-
};
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// Generate HOME path before buildCommandEnv so we have a known-safe value
|
|
469
|
-
// for cleanup. buildCommandEnv sets HOME after spreading adapterEnv to
|
|
470
|
-
// prevent auth adapters from overriding the isolated home directory.
|
|
471
|
-
const generatedHomeDir = join(tmpdir(), `ces-home-${randomUUID()}`);
|
|
472
|
-
|
|
473
|
-
// Create the HOME directory and enforce cleanConfigDirs before building env
|
|
474
|
-
try {
|
|
475
|
-
mkdirSync(generatedHomeDir, { recursive: true });
|
|
476
|
-
enforceCleanConfigDirs(manifest, generatedHomeDir);
|
|
477
|
-
} catch (err) {
|
|
478
|
-
if (proxySessionId) {
|
|
479
|
-
try {
|
|
480
|
-
await stopSession(proxySessionId, sessionStore);
|
|
481
|
-
} catch {
|
|
482
|
-
// Best-effort proxy cleanup
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
cleanupAll(scratchDir, tempFilePath, generatedHomeDir);
|
|
486
|
-
return {
|
|
487
|
-
success: false,
|
|
488
|
-
error: `Clean config dirs setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
489
|
-
auditId,
|
|
490
|
-
};
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
const commandEnv = buildCommandEnv(
|
|
494
|
-
adapterEnv,
|
|
495
|
-
proxyEnv,
|
|
496
|
-
noNetworkEnv,
|
|
497
|
-
generatedHomeDir,
|
|
498
|
-
);
|
|
499
|
-
|
|
500
|
-
// -- 9. Execute the command -----------------------------------------------
|
|
501
|
-
const maxOutput = deps.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
502
|
-
let execResult: ExecuteCommandResult;
|
|
503
|
-
|
|
504
|
-
try {
|
|
505
|
-
execResult = await runCommand(
|
|
506
|
-
entrypointPath,
|
|
507
|
-
request.argv,
|
|
508
|
-
scratchDir,
|
|
509
|
-
commandEnv,
|
|
510
|
-
maxOutput,
|
|
511
|
-
auditId,
|
|
512
|
-
);
|
|
513
|
-
} catch (err) {
|
|
514
|
-
execResult = {
|
|
515
|
-
success: false,
|
|
516
|
-
error: `Command execution failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
517
|
-
auditId,
|
|
518
|
-
};
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// -- 10. Output copyback --------------------------------------------------
|
|
522
|
-
if (
|
|
523
|
-
request.outputs &&
|
|
524
|
-
request.outputs.length > 0 &&
|
|
525
|
-
execResult.exitCode !== undefined
|
|
526
|
-
) {
|
|
527
|
-
try {
|
|
528
|
-
const copybackResult = copybackOutputs(
|
|
529
|
-
stageConfigWithSecrets,
|
|
530
|
-
scratchDir,
|
|
531
|
-
);
|
|
532
|
-
execResult.copybackResult = copybackResult;
|
|
533
|
-
|
|
534
|
-
if (!copybackResult.allSucceeded) {
|
|
535
|
-
const failures = copybackResult.outputs
|
|
536
|
-
.filter((o) => !o.success)
|
|
537
|
-
.map((o) => `${o.scratchPath}: ${o.reason}`)
|
|
538
|
-
.join("; ");
|
|
539
|
-
execResult.error = execResult.error
|
|
540
|
-
? `${execResult.error}; Output copyback failures: ${failures}`
|
|
541
|
-
: `Output copyback failures: ${failures}`;
|
|
542
|
-
}
|
|
543
|
-
} catch (err) {
|
|
544
|
-
execResult.error = execResult.error
|
|
545
|
-
? `${execResult.error}; Output copyback error: ${err instanceof Error ? err.message : String(err)}`
|
|
546
|
-
: `Output copyback error: ${err instanceof Error ? err.message : String(err)}`;
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// -- 11. Cleanup ----------------------------------------------------------
|
|
551
|
-
if (proxySessionId) {
|
|
552
|
-
try {
|
|
553
|
-
await stopSession(proxySessionId, sessionStore);
|
|
554
|
-
} catch {
|
|
555
|
-
// Best-effort proxy cleanup
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
cleanupAll(scratchDir, tempFilePath, generatedHomeDir);
|
|
560
|
-
|
|
561
|
-
// -- 12. Persist audit record -----------------------------------------------
|
|
562
|
-
if (deps.auditStore) {
|
|
563
|
-
const auditRecord: AuditRecordSummary = {
|
|
564
|
-
auditId,
|
|
565
|
-
grantId: grantResult.grantId ?? "unknown",
|
|
566
|
-
credentialHandle: request.credentialHandle,
|
|
567
|
-
toolName: "command",
|
|
568
|
-
target: `${request.bundleDigest}/${request.profileName}`,
|
|
569
|
-
sessionId: deps.sessionId ?? "unknown",
|
|
570
|
-
success: execResult.success,
|
|
571
|
-
...(execResult.error ? { errorMessage: execResult.error } : {}),
|
|
572
|
-
timestamp: new Date().toISOString(),
|
|
573
|
-
};
|
|
574
|
-
try { deps.auditStore.append(auditRecord); } catch { /* audit persistence must not block execution */ }
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
return execResult;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// ---------------------------------------------------------------------------
|
|
581
|
-
// Internal: Bundle resolution
|
|
582
|
-
// ---------------------------------------------------------------------------
|
|
583
|
-
|
|
584
|
-
type BundleResolutionResult =
|
|
585
|
-
| { ok: true; manifest: SecureCommandManifest; toolstoreDir: string }
|
|
586
|
-
| { ok: false; error: string };
|
|
587
|
-
|
|
588
|
-
function resolveBundle(
|
|
589
|
-
bundleDigest: string,
|
|
590
|
-
cesMode?: CesMode,
|
|
591
|
-
): BundleResolutionResult {
|
|
592
|
-
if (!isBundlePublished(bundleDigest, cesMode)) {
|
|
593
|
-
return {
|
|
594
|
-
ok: false,
|
|
595
|
-
error: `Bundle with digest "${bundleDigest}" is not published in the CES toolstore. ` +
|
|
596
|
-
`Only approved bundles can be executed.`,
|
|
597
|
-
};
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
const toolstoreManifest = readPublishedManifest(bundleDigest, cesMode);
|
|
601
|
-
if (!toolstoreManifest) {
|
|
602
|
-
return {
|
|
603
|
-
ok: false,
|
|
604
|
-
error: `Bundle manifest for digest "${bundleDigest}" could not be read from the toolstore.`,
|
|
605
|
-
};
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
const manifest = toolstoreManifest.secureCommandManifest;
|
|
609
|
-
|
|
610
|
-
// Defense-in-depth: re-check denied binary at execution time
|
|
611
|
-
if (isDeniedBinary(manifest.entrypoint)) {
|
|
612
|
-
return {
|
|
613
|
-
ok: false,
|
|
614
|
-
error: `Entrypoint "${manifest.entrypoint}" is a structurally denied binary. ` +
|
|
615
|
-
`Generic HTTP clients, interpreters, and shell trampolines cannot be executed.`,
|
|
616
|
-
};
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
if (isDeniedBinary(manifest.bundleId)) {
|
|
620
|
-
return {
|
|
621
|
-
ok: false,
|
|
622
|
-
error: `Bundle ID "${manifest.bundleId}" matches a structurally denied binary name.`,
|
|
623
|
-
};
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
const toolstoreDir = getCesToolStoreDir(cesMode);
|
|
627
|
-
|
|
628
|
-
return {
|
|
629
|
-
ok: true,
|
|
630
|
-
manifest,
|
|
631
|
-
toolstoreDir,
|
|
632
|
-
};
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
// ---------------------------------------------------------------------------
|
|
636
|
-
// Internal: Profile validation
|
|
637
|
-
// ---------------------------------------------------------------------------
|
|
638
|
-
|
|
639
|
-
interface ProfileValidationResult {
|
|
640
|
-
ok: boolean;
|
|
641
|
-
profile?: CommandProfile;
|
|
642
|
-
matchedPattern?: string;
|
|
643
|
-
error?: string;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
function validateProfile(
|
|
647
|
-
manifest: SecureCommandManifest,
|
|
648
|
-
profileName: string,
|
|
649
|
-
argv: string[],
|
|
650
|
-
): ProfileValidationResult {
|
|
651
|
-
const profile = manifest.commandProfiles[profileName];
|
|
652
|
-
if (!profile) {
|
|
653
|
-
const available = Object.keys(manifest.commandProfiles).join(", ");
|
|
654
|
-
return {
|
|
655
|
-
ok: false,
|
|
656
|
-
error: `Profile "${profileName}" not found in manifest for bundle "${manifest.bundleId}". ` +
|
|
657
|
-
`Available profiles: ${available}`,
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// Validate the argv against the full manifest (checks denied subcommands/flags
|
|
662
|
-
// across all profiles, then matches against allowed patterns)
|
|
663
|
-
const cmdResult: CommandValidationResult = validateCommand(manifest, argv);
|
|
664
|
-
if (!cmdResult.allowed) {
|
|
665
|
-
return {
|
|
666
|
-
ok: false,
|
|
667
|
-
error: `Command validation failed: ${cmdResult.reason}`,
|
|
668
|
-
};
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// Ensure the matched profile is the requested one
|
|
672
|
-
if (cmdResult.matchedProfile !== profileName) {
|
|
673
|
-
return {
|
|
674
|
-
ok: false,
|
|
675
|
-
error: `Command argv matched profile "${cmdResult.matchedProfile}" but the requested ` +
|
|
676
|
-
`profile is "${profileName}". The command does not match any pattern in the requested profile.`,
|
|
677
|
-
};
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
return {
|
|
681
|
-
ok: true,
|
|
682
|
-
profile,
|
|
683
|
-
matchedPattern: cmdResult.matchedPattern,
|
|
684
|
-
};
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// ---------------------------------------------------------------------------
|
|
688
|
-
// Internal: Grant enforcement
|
|
689
|
-
// ---------------------------------------------------------------------------
|
|
690
|
-
|
|
691
|
-
interface GrantCheckResult {
|
|
692
|
-
ok: boolean;
|
|
693
|
-
grantId?: string;
|
|
694
|
-
error?: string;
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
function checkGrant(
|
|
698
|
-
request: ExecuteCommandRequest,
|
|
699
|
-
manifest: SecureCommandManifest,
|
|
700
|
-
profileName: string,
|
|
701
|
-
persistentStore: PersistentGrantStore,
|
|
702
|
-
temporaryStore: TemporaryGrantStore,
|
|
703
|
-
): GrantCheckResult {
|
|
704
|
-
// Build the full legacy command string for exact matching against legacy grants.
|
|
705
|
-
const legacyCommand = `${request.bundleDigest}/${profileName} ${request.argv.join(" ")}`.trim();
|
|
706
|
-
|
|
707
|
-
// If an explicit grantId is provided, check it directly — but verify
|
|
708
|
-
// that the grant's scope matches the current request. Without this
|
|
709
|
-
// check, an agent with a valid grant for one command/credential could
|
|
710
|
-
// reuse the grantId for a different command/credential (authorization
|
|
711
|
-
// bypass).
|
|
712
|
-
if (request.grantId) {
|
|
713
|
-
const grant = persistentStore.getById(request.grantId);
|
|
714
|
-
if (
|
|
715
|
-
grant &&
|
|
716
|
-
grant.tool === "command" &&
|
|
717
|
-
grant.scope === request.credentialHandle &&
|
|
718
|
-
grantMatchesCommand(grant.pattern, request.credentialHandle, request.bundleDigest, profileName, legacyCommand)
|
|
719
|
-
) {
|
|
720
|
-
return { ok: true, grantId: grant.id };
|
|
721
|
-
}
|
|
722
|
-
// Explicit grant not found or does not match this request — fall through to pattern matching
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Check persistent grants for a matching command grant
|
|
726
|
-
const allGrants = persistentStore.getAll();
|
|
727
|
-
for (const grant of allGrants) {
|
|
728
|
-
if (
|
|
729
|
-
grant.tool === "command" &&
|
|
730
|
-
grant.scope === request.credentialHandle &&
|
|
731
|
-
grantMatchesCommand(grant.pattern, request.credentialHandle, request.bundleDigest, profileName, legacyCommand)
|
|
732
|
-
) {
|
|
733
|
-
return { ok: true, grantId: grant.id };
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// Check temporary grants — build the same proposal shape that the
|
|
738
|
-
// approval bridge produces, then hash with the canonical algorithm
|
|
739
|
-
// from `@vellumai/service-contracts` so the hashes align.
|
|
740
|
-
const tempProposal: CommandGrantProposal = {
|
|
741
|
-
type: "command",
|
|
742
|
-
credentialHandle: request.credentialHandle,
|
|
743
|
-
command: `${request.bundleDigest}/${profileName} ${request.argv.join(" ")}`.trim(),
|
|
744
|
-
purpose: request.purpose,
|
|
745
|
-
allowedCommandPatterns: [`${request.credentialHandle}:${request.bundleDigest}:${profileName}`],
|
|
746
|
-
};
|
|
747
|
-
const proposalHash = hashProposal(tempProposal);
|
|
748
|
-
const tempKind = temporaryStore.checkAny(
|
|
749
|
-
proposalHash,
|
|
750
|
-
request.conversationId,
|
|
751
|
-
);
|
|
752
|
-
if (tempKind) {
|
|
753
|
-
return { ok: true, grantId: `temp:${tempKind}:${proposalHash}` };
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
return {
|
|
757
|
-
ok: false,
|
|
758
|
-
error: `No active grant found for bundle="${manifest.bundleId}", ` +
|
|
759
|
-
`profile="${profileName}", credential="${request.credentialHandle}". ` +
|
|
760
|
-
`Approval is required before command execution.`,
|
|
761
|
-
};
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
/**
|
|
765
|
-
* Check if a persistent grant pattern matches a command invocation.
|
|
766
|
-
*
|
|
767
|
-
* Grant patterns for commands can be stored in two formats:
|
|
768
|
-
* 1. Canonical: `<credentialHandle>:<bundleDigest>:<profileName>` (from allowedCommandPatterns)
|
|
769
|
-
* 2. Legacy: `<bundleDigest>/<profileName> <argv...>` (from proposal.command fallback)
|
|
770
|
-
*
|
|
771
|
-
* The legacy format exists because older grants were persisted using
|
|
772
|
-
* `proposal.command` before `allowedCommandPatterns` was introduced.
|
|
773
|
-
* Credential scope is already verified by the caller (`grant.scope === credentialHandle`),
|
|
774
|
-
* so for legacy patterns we match the full command string (including argv) to prevent
|
|
775
|
-
* a grant for one argv from authorizing a different argv on the same profile.
|
|
776
|
-
*/
|
|
777
|
-
function grantMatchesCommand(
|
|
778
|
-
pattern: string,
|
|
779
|
-
credentialHandle: string,
|
|
780
|
-
bundleDigest: string,
|
|
781
|
-
profileName: string,
|
|
782
|
-
legacyCommand: string,
|
|
783
|
-
): boolean {
|
|
784
|
-
// Canonical format: <credentialHandle>:<bundleDigest>:<profileName>
|
|
785
|
-
if (pattern === `${credentialHandle}:${bundleDigest}:${profileName}`) {
|
|
786
|
-
return true;
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
// Legacy format: <bundleDigest>/<profileName> <argv...>
|
|
790
|
-
// Match the full legacy command string exactly to prevent approval scope widening.
|
|
791
|
-
if (pattern === legacyCommand) {
|
|
792
|
-
return true;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
return false;
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
// ---------------------------------------------------------------------------
|
|
800
|
-
// Internal: Auth adapter environment construction
|
|
801
|
-
// ---------------------------------------------------------------------------
|
|
802
|
-
|
|
803
|
-
interface AuthAdapterEnvResult {
|
|
804
|
-
/** Environment variables to inject into the command. */
|
|
805
|
-
env: Record<string, string>;
|
|
806
|
-
/** Path to a temp file that must be cleaned up (for temp_file adapter). */
|
|
807
|
-
tempFilePath?: string;
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
async function buildAuthAdapterEnv(
|
|
811
|
-
adapter: AuthAdapterConfig,
|
|
812
|
-
credentialValue: string,
|
|
813
|
-
proxyEnv?: ProxyEnvVars,
|
|
814
|
-
noNetworkEnv?: Record<string, string>,
|
|
815
|
-
): Promise<AuthAdapterEnvResult> {
|
|
816
|
-
// Validate adapter config
|
|
817
|
-
const errors = validateAuthAdapterConfig(adapter);
|
|
818
|
-
if (errors.length > 0) {
|
|
819
|
-
throw new Error(
|
|
820
|
-
`Invalid auth adapter config: ${errors.join("; ")}`,
|
|
821
|
-
);
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
switch (adapter.type) {
|
|
825
|
-
case AuthAdapterType.EnvVar: {
|
|
826
|
-
const value = adapter.valuePrefix
|
|
827
|
-
? `${adapter.valuePrefix}${credentialValue}`
|
|
828
|
-
: credentialValue;
|
|
829
|
-
return {
|
|
830
|
-
env: { [adapter.envVarName]: value },
|
|
831
|
-
};
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
case AuthAdapterType.TempFile: {
|
|
835
|
-
// Write credential to a temp file and set the env var to the path
|
|
836
|
-
const tempDir = join(tmpdir(), `ces-auth-${randomUUID()}`);
|
|
837
|
-
mkdirSync(tempDir, { recursive: true });
|
|
838
|
-
const ext = adapter.fileExtension ?? "";
|
|
839
|
-
const tempPath = join(tempDir, `credential${ext}`);
|
|
840
|
-
const mode = adapter.fileMode ?? 0o600;
|
|
841
|
-
writeFileSync(tempPath, credentialValue, { mode });
|
|
842
|
-
return {
|
|
843
|
-
env: { [adapter.envVarName]: tempPath },
|
|
844
|
-
tempFilePath: tempPath,
|
|
845
|
-
};
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
case AuthAdapterType.CredentialProcess: {
|
|
849
|
-
// Run the helper command and capture its stdout.
|
|
850
|
-
// Proxy env vars are forwarded so the helper runs under the same
|
|
851
|
-
// egress control as the main command.
|
|
852
|
-
const timeoutMs = adapter.timeoutMs ?? CREDENTIAL_PROCESS_TIMEOUT_MS;
|
|
853
|
-
const helperResult = await runCredentialProcess(
|
|
854
|
-
adapter.helperCommand,
|
|
855
|
-
credentialValue,
|
|
856
|
-
timeoutMs,
|
|
857
|
-
proxyEnv,
|
|
858
|
-
noNetworkEnv,
|
|
859
|
-
);
|
|
860
|
-
if (!helperResult.ok) {
|
|
861
|
-
throw new Error(
|
|
862
|
-
`Credential process helper failed: ${helperResult.error}`,
|
|
863
|
-
);
|
|
864
|
-
}
|
|
865
|
-
return {
|
|
866
|
-
env: { [adapter.envVarName]: helperResult.stdout },
|
|
867
|
-
};
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
default:
|
|
871
|
-
throw new Error(`Unknown auth adapter type: ${(adapter as AuthAdapterConfig).type}`);
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
/**
|
|
876
|
-
* Run a credential_process helper command inside CES.
|
|
877
|
-
*
|
|
878
|
-
* The helper receives the raw credential value on stdin and writes
|
|
879
|
-
* the transformed credential to stdout. It is never exposed to the
|
|
880
|
-
* subprocess directly.
|
|
881
|
-
*/
|
|
882
|
-
async function runCredentialProcess(
|
|
883
|
-
helperCommand: string,
|
|
884
|
-
credentialValue: string,
|
|
885
|
-
timeoutMs: number,
|
|
886
|
-
proxyEnv?: ProxyEnvVars,
|
|
887
|
-
noNetworkEnv?: Record<string, string>,
|
|
888
|
-
): Promise<{ ok: true; stdout: string } | { ok: false; error: string }> {
|
|
889
|
-
// Defense-in-depth: re-check denied binary and metacharacters at execution
|
|
890
|
-
// time, mirroring the validator's static checks. If a manifest was tampered
|
|
891
|
-
// with after validation, this blocks execution before spawning the shell.
|
|
892
|
-
if (containsShellMetacharacters(helperCommand)) {
|
|
893
|
-
return {
|
|
894
|
-
ok: false,
|
|
895
|
-
error: `Credential process helperCommand contains shell metacharacters. ` +
|
|
896
|
-
`Command chaining operators are not allowed.`,
|
|
897
|
-
};
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
const helperBinary = extractShellBinary(helperCommand);
|
|
901
|
-
if (isDeniedBinary(helperBinary)) {
|
|
902
|
-
return {
|
|
903
|
-
ok: false,
|
|
904
|
-
error: `Credential process helperCommand starts with denied binary "${helperBinary}". ` +
|
|
905
|
-
`Generic HTTP clients, interpreters, and shell trampolines cannot be used as credential helpers.`,
|
|
906
|
-
};
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
try {
|
|
910
|
-
// Build a minimal environment for the helper. No host env is inherited,
|
|
911
|
-
// but egress proxy or no-network env vars are injected so the helper
|
|
912
|
-
// runs under the same network controls as the main command.
|
|
913
|
-
const helperEnv: Record<string, string> = {};
|
|
914
|
-
|
|
915
|
-
if (proxyEnv) {
|
|
916
|
-
helperEnv["HTTP_PROXY"] = proxyEnv.HTTP_PROXY;
|
|
917
|
-
helperEnv["HTTPS_PROXY"] = proxyEnv.HTTPS_PROXY;
|
|
918
|
-
helperEnv["NO_PROXY"] = proxyEnv.NO_PROXY;
|
|
919
|
-
helperEnv["http_proxy"] = proxyEnv.HTTP_PROXY;
|
|
920
|
-
helperEnv["https_proxy"] = proxyEnv.HTTPS_PROXY;
|
|
921
|
-
helperEnv["no_proxy"] = proxyEnv.NO_PROXY;
|
|
922
|
-
if (proxyEnv.NODE_EXTRA_CA_CERTS) {
|
|
923
|
-
helperEnv["NODE_EXTRA_CA_CERTS"] = proxyEnv.NODE_EXTRA_CA_CERTS;
|
|
924
|
-
}
|
|
925
|
-
if (proxyEnv.SSL_CERT_FILE) {
|
|
926
|
-
helperEnv["SSL_CERT_FILE"] = proxyEnv.SSL_CERT_FILE;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
if (noNetworkEnv) {
|
|
931
|
-
Object.assign(helperEnv, noNetworkEnv);
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
const proc = Bun.spawn(["sh", "-c", helperCommand], {
|
|
935
|
-
stdin: "pipe",
|
|
936
|
-
stdout: "pipe",
|
|
937
|
-
stderr: "pipe",
|
|
938
|
-
env: helperEnv,
|
|
939
|
-
});
|
|
940
|
-
|
|
941
|
-
// Write the credential value to stdin for the helper to consume
|
|
942
|
-
proc.stdin.write(credentialValue);
|
|
943
|
-
proc.stdin.end();
|
|
944
|
-
|
|
945
|
-
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
946
|
-
|
|
947
|
-
// Consume stdout/stderr concurrently with waiting for exit to avoid
|
|
948
|
-
// pipe buffer deadlocks when the helper produces large output.
|
|
949
|
-
const [exitCode, stdout, stderr] = await Promise.race([
|
|
950
|
-
Promise.all([
|
|
951
|
-
proc.exited,
|
|
952
|
-
new Response(proc.stdout).text(),
|
|
953
|
-
new Response(proc.stderr).text(),
|
|
954
|
-
]),
|
|
955
|
-
new Promise<never>((_, reject) => {
|
|
956
|
-
timeoutSignal.addEventListener("abort", () => {
|
|
957
|
-
proc.kill();
|
|
958
|
-
reject(new Error(`Credential process timed out after ${timeoutMs}ms`));
|
|
959
|
-
});
|
|
960
|
-
}),
|
|
961
|
-
]);
|
|
962
|
-
|
|
963
|
-
if (exitCode !== 0) {
|
|
964
|
-
return {
|
|
965
|
-
ok: false,
|
|
966
|
-
error: `Helper exited with code ${exitCode}: ${stderr.trim()}`,
|
|
967
|
-
};
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
return { ok: true, stdout: stdout.trim() };
|
|
971
|
-
} catch (err) {
|
|
972
|
-
return {
|
|
973
|
-
ok: false,
|
|
974
|
-
error: err instanceof Error ? err.message : String(err),
|
|
975
|
-
};
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
// ---------------------------------------------------------------------------
|
|
980
|
-
// Internal: Command environment construction
|
|
981
|
-
// ---------------------------------------------------------------------------
|
|
982
|
-
|
|
983
|
-
/**
|
|
984
|
-
* Build the clean execution environment for the command.
|
|
985
|
-
*
|
|
986
|
-
* The environment contains:
|
|
987
|
-
* - Auth adapter env vars (credential injection)
|
|
988
|
-
* - Proxy env vars (when egress proxy is active)
|
|
989
|
-
* - HOME set to a temp directory (isolates config reads)
|
|
990
|
-
* - PATH preserved from the CES process
|
|
991
|
-
*
|
|
992
|
-
* The environment explicitly does NOT inherit the CES process env.
|
|
993
|
-
* Clean config dirs are handled by setting HOME to a temp directory.
|
|
994
|
-
*/
|
|
995
|
-
function buildCommandEnv(
|
|
996
|
-
adapterEnv: Record<string, string>,
|
|
997
|
-
proxyEnv?: ProxyEnvVars,
|
|
998
|
-
noNetworkEnv?: Record<string, string>,
|
|
999
|
-
homeDir?: string,
|
|
1000
|
-
): Record<string, string> {
|
|
1001
|
-
const env: Record<string, string> = {
|
|
1002
|
-
// Inject auth adapter env vars first so they cannot override protected keys
|
|
1003
|
-
...adapterEnv,
|
|
1004
|
-
// PATH, LANG, and HOME are set after adapterEnv spread to prevent auth
|
|
1005
|
-
// adapters from overriding baseline environment invariants.
|
|
1006
|
-
PATH: process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin",
|
|
1007
|
-
LANG: "en_US.UTF-8",
|
|
1008
|
-
HOME: homeDir ?? join(tmpdir(), `ces-home-${randomUUID()}`),
|
|
1009
|
-
};
|
|
1010
|
-
|
|
1011
|
-
// Inject proxy env vars if the egress proxy is active
|
|
1012
|
-
if (proxyEnv) {
|
|
1013
|
-
env["HTTP_PROXY"] = proxyEnv.HTTP_PROXY;
|
|
1014
|
-
env["HTTPS_PROXY"] = proxyEnv.HTTPS_PROXY;
|
|
1015
|
-
env["NO_PROXY"] = proxyEnv.NO_PROXY;
|
|
1016
|
-
env["http_proxy"] = proxyEnv.HTTP_PROXY;
|
|
1017
|
-
env["https_proxy"] = proxyEnv.HTTPS_PROXY;
|
|
1018
|
-
env["no_proxy"] = proxyEnv.NO_PROXY;
|
|
1019
|
-
if (proxyEnv.NODE_EXTRA_CA_CERTS) {
|
|
1020
|
-
env["NODE_EXTRA_CA_CERTS"] = proxyEnv.NODE_EXTRA_CA_CERTS;
|
|
1021
|
-
}
|
|
1022
|
-
if (proxyEnv.SSL_CERT_FILE) {
|
|
1023
|
-
env["SSL_CERT_FILE"] = proxyEnv.SSL_CERT_FILE;
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
// For no_network mode, inject proxy vars pointing at a dead address to
|
|
1028
|
-
// block direct outbound connections from the subprocess.
|
|
1029
|
-
if (noNetworkEnv) {
|
|
1030
|
-
Object.assign(env, noNetworkEnv);
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
return env;
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
// ---------------------------------------------------------------------------
|
|
1037
|
-
// Internal: Clean config dirs enforcement
|
|
1038
|
-
// ---------------------------------------------------------------------------
|
|
1039
|
-
|
|
1040
|
-
/**
|
|
1041
|
-
* Enforce the manifest's `cleanConfigDirs` contract by creating empty
|
|
1042
|
-
* directories under the temp HOME directory.
|
|
1043
|
-
*
|
|
1044
|
-
* For each entry in `cleanConfigDirs`:
|
|
1045
|
-
* - `~/`-prefixed paths are resolved relative to the temp HOME dir and
|
|
1046
|
-
* created as empty directories. This ensures the command finds an empty
|
|
1047
|
-
* config directory instead of reading host config that might contain secrets.
|
|
1048
|
-
* - Absolute paths (not `~/`-prefixed) are skipped for v1 — they would
|
|
1049
|
-
* require filesystem-level isolation (bind mounts, overlayfs).
|
|
1050
|
-
*/
|
|
1051
|
-
function enforceCleanConfigDirs(
|
|
1052
|
-
manifest: SecureCommandManifest,
|
|
1053
|
-
homeDir: string,
|
|
1054
|
-
): void {
|
|
1055
|
-
const dirs = manifest.cleanConfigDirs;
|
|
1056
|
-
if (!dirs) return;
|
|
1057
|
-
|
|
1058
|
-
for (const dirPath of Object.keys(dirs)) {
|
|
1059
|
-
// Only handle ~/‑prefixed paths for v1
|
|
1060
|
-
if (dirPath.startsWith("~/")) {
|
|
1061
|
-
const relativePath = dirPath.slice(2); // strip "~/"
|
|
1062
|
-
const resolvedPath = resolve(homeDir, relativePath);
|
|
1063
|
-
// Containment check: resolved path must stay inside homeDir
|
|
1064
|
-
if (!resolvedPath.startsWith(homeDir + "/") && resolvedPath !== homeDir) {
|
|
1065
|
-
continue; // Skip paths that escape the home directory
|
|
1066
|
-
}
|
|
1067
|
-
mkdirSync(resolvedPath, { recursive: true });
|
|
1068
|
-
} else if (dirPath === "~") {
|
|
1069
|
-
// "~" alone is just the home dir itself, already created
|
|
1070
|
-
continue;
|
|
1071
|
-
}
|
|
1072
|
-
// Absolute paths are skipped — would require filesystem-level isolation
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
// ---------------------------------------------------------------------------
|
|
1077
|
-
// Internal: Command execution
|
|
1078
|
-
// ---------------------------------------------------------------------------
|
|
1079
|
-
|
|
1080
|
-
async function runCommand(
|
|
1081
|
-
entrypointPath: string,
|
|
1082
|
-
argv: string[],
|
|
1083
|
-
scratchDir: string,
|
|
1084
|
-
env: Record<string, string>,
|
|
1085
|
-
maxOutputBytes: number,
|
|
1086
|
-
auditId: string,
|
|
1087
|
-
): Promise<ExecuteCommandResult> {
|
|
1088
|
-
// Ensure the HOME directory exists (for clean config dirs isolation)
|
|
1089
|
-
if (env["HOME"]) {
|
|
1090
|
-
mkdirSync(env["HOME"], { recursive: true });
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
const proc = Bun.spawn([entrypointPath, ...argv], {
|
|
1094
|
-
cwd: scratchDir,
|
|
1095
|
-
env,
|
|
1096
|
-
stdin: "ignore",
|
|
1097
|
-
stdout: "pipe",
|
|
1098
|
-
stderr: "pipe",
|
|
1099
|
-
});
|
|
1100
|
-
|
|
1101
|
-
// Consume stdout/stderr concurrently with waiting for exit to avoid
|
|
1102
|
-
// pipe buffer deadlocks when the command produces output exceeding the
|
|
1103
|
-
// OS pipe buffer size (~64KB).
|
|
1104
|
-
const [exitCode, stdoutRaw, stderrRaw] = await Promise.all([
|
|
1105
|
-
proc.exited,
|
|
1106
|
-
new Response(proc.stdout).text(),
|
|
1107
|
-
new Response(proc.stderr).text(),
|
|
1108
|
-
]);
|
|
1109
|
-
|
|
1110
|
-
const stdout = stdoutRaw.length > maxOutputBytes
|
|
1111
|
-
? stdoutRaw.slice(0, maxOutputBytes) + "\n[output truncated]"
|
|
1112
|
-
: stdoutRaw;
|
|
1113
|
-
|
|
1114
|
-
const stderr = stderrRaw.length > maxOutputBytes
|
|
1115
|
-
? stderrRaw.slice(0, maxOutputBytes) + "\n[output truncated]"
|
|
1116
|
-
: stderrRaw;
|
|
1117
|
-
|
|
1118
|
-
return {
|
|
1119
|
-
success: exitCode === 0,
|
|
1120
|
-
exitCode,
|
|
1121
|
-
stdout,
|
|
1122
|
-
stderr,
|
|
1123
|
-
auditId,
|
|
1124
|
-
...(exitCode !== 0 ? { error: `Command exited with code ${exitCode}` } : {}),
|
|
1125
|
-
};
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
// ---------------------------------------------------------------------------
|
|
1129
|
-
// Internal: Cleanup helpers
|
|
1130
|
-
// ---------------------------------------------------------------------------
|
|
1131
|
-
|
|
1132
|
-
function cleanupAll(scratchDir: string, tempFilePath?: string, homeDir?: string): void {
|
|
1133
|
-
// Clean up temp auth file
|
|
1134
|
-
if (tempFilePath) {
|
|
1135
|
-
try {
|
|
1136
|
-
unlinkSync(tempFilePath);
|
|
1137
|
-
// Also remove the parent temp directory
|
|
1138
|
-
rmSync(dirname(tempFilePath), { recursive: true, force: true });
|
|
1139
|
-
} catch {
|
|
1140
|
-
// Best-effort cleanup
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
// Clean up per-execution HOME temp directory
|
|
1145
|
-
if (homeDir) {
|
|
1146
|
-
try {
|
|
1147
|
-
rmSync(homeDir, { recursive: true, force: true });
|
|
1148
|
-
} catch {
|
|
1149
|
-
// Best-effort cleanup
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
// Clean up scratch directory
|
|
1154
|
-
cleanupScratchDir(scratchDir);
|
|
1155
|
-
}
|