@vellumai/credential-executor 0.5.4 → 0.5.6

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 CHANGED
@@ -49,6 +49,9 @@ COPY --from=builder --chown=ces:ces /app /app
49
49
  # when no PVC volume is mounted (e.g., direct docker run)
50
50
  RUN mkdir -p /ces-data && chown ces:ces /ces-data
51
51
 
52
+ # Pre-create /ces-security for credential key storage (keys.enc, store.key)
53
+ RUN mkdir -p /ces-security && chown ces:ces /ces-security
54
+
52
55
  USER ces
53
56
 
54
57
  EXPOSE 8090
@@ -145,3 +145,4 @@ export * from "./handles.js";
145
145
  export * from "./grants.js";
146
146
  export * from "./rpc.js";
147
147
  export * from "./rendering.js";
148
+ export * from "./trust-rules.js";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Trust rule types shared between the assistant daemon and the gateway.
3
+ *
4
+ * These are extracted from `assistant/src/permissions/types.ts` and
5
+ * `assistant/src/permissions/trust-store.ts` so that both packages can
6
+ * reference a single canonical definition.
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Trust decision
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** The possible decisions a trust rule can make. */
14
+ export type TrustDecision = "allow" | "deny" | "ask";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Trust rule
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface TrustRule {
21
+ id: string;
22
+ tool: string;
23
+ pattern: string;
24
+ scope: string;
25
+ decision: TrustDecision;
26
+ priority: number;
27
+ createdAt: number;
28
+ executionTarget?: string;
29
+ allowHighRisk?: boolean;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Trust file (on-disk shape)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /** Shape of the `trust.json` file persisted to disk. */
37
+ export interface TrustFileData {
38
+ version: number;
39
+ rules: TrustRule[];
40
+ /** Set to true when the user explicitly accepts the starter approval bundle. */
41
+ starterBundleAccepted?: boolean;
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Credential CRUD HTTP endpoints for the CES managed service.
3
+ *
4
+ * Exposes credential management over HTTP so the assistant and gateway
5
+ * can access credentials via the network instead of reading keys.enc
6
+ * directly from a shared volume.
7
+ *
8
+ * Endpoints:
9
+ * - `GET /v1/credentials` — list credential account names
10
+ * - `GET /v1/credentials/:account` — get a credential value
11
+ * - `POST /v1/credentials/:account` — set a credential value
12
+ * - `DELETE /v1/credentials/:account` — delete a credential
13
+ *
14
+ * Auth: All endpoints require a `CES_SERVICE_TOKEN` bearer token in the
15
+ * `Authorization` header. Both the CES and its callers share this token
16
+ * via the environment.
17
+ */
18
+
19
+ import { timingSafeEqual } from "node:crypto";
20
+
21
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Auth
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * Validate the Authorization header against the configured service token.
29
+ * Returns an error Response if auth fails, or null if auth succeeds.
30
+ */
31
+ function checkAuth(req: Request, serviceToken: string): Response | null {
32
+ const authHeader = req.headers.get("authorization");
33
+ if (!authHeader) {
34
+ return new Response(
35
+ JSON.stringify({ error: "Missing Authorization header" }),
36
+ { status: 401, headers: { "Content-Type": "application/json" } },
37
+ );
38
+ }
39
+
40
+ const parts = authHeader.split(" ");
41
+ if (parts.length !== 2 || parts[0]!.toLowerCase() !== "bearer") {
42
+ return new Response(
43
+ JSON.stringify({ error: "Invalid Authorization header format. Expected: Bearer <token>" }),
44
+ { status: 401, headers: { "Content-Type": "application/json" } },
45
+ );
46
+ }
47
+
48
+ const provided = Buffer.from(parts[1]!);
49
+ const expected = Buffer.from(serviceToken);
50
+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
51
+ return new Response(
52
+ JSON.stringify({ error: "Invalid service token" }),
53
+ { status: 403, headers: { "Content-Type": "application/json" } },
54
+ );
55
+ }
56
+
57
+ return null;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Route handler
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export interface CredentialRouteDeps {
65
+ /** The secure key backend to wrap. */
66
+ backend: SecureKeyBackend;
67
+ /** Service token for authenticating requests. */
68
+ serviceToken: string;
69
+ }
70
+
71
+ const CREDENTIAL_PATH_PREFIX = "/v1/credentials";
72
+
73
+ /**
74
+ * Try to handle a credential CRUD request. Returns a Response if the
75
+ * request matches a credential route, or null if it doesn't match
76
+ * (allowing the caller to fall through to other routes).
77
+ */
78
+ export async function handleCredentialRoute(
79
+ req: Request,
80
+ deps: CredentialRouteDeps,
81
+ ): Promise<Response | null> {
82
+ const url = new URL(req.url);
83
+ const { pathname } = url;
84
+
85
+ // Only handle /v1/credentials paths
86
+ if (!pathname.startsWith(CREDENTIAL_PATH_PREFIX)) {
87
+ return null;
88
+ }
89
+
90
+ // Auth check
91
+ const authError = checkAuth(req, deps.serviceToken);
92
+ if (authError) return authError;
93
+
94
+ const { backend } = deps;
95
+
96
+ // Extract account from path: /v1/credentials/:account
97
+ const accountSegment = pathname.slice(CREDENTIAL_PATH_PREFIX.length);
98
+
99
+ // GET /v1/credentials — list all credential account names
100
+ if (accountSegment === "" || accountSegment === "/") {
101
+ if (req.method !== "GET") {
102
+ return new Response(
103
+ JSON.stringify({ error: "Method not allowed" }),
104
+ { status: 405, headers: { "Content-Type": "application/json" } },
105
+ );
106
+ }
107
+
108
+ const accounts = await backend.list();
109
+ return new Response(
110
+ JSON.stringify({ accounts }),
111
+ { status: 200, headers: { "Content-Type": "application/json" } },
112
+ );
113
+ }
114
+
115
+ // Remaining routes require /:account
116
+ if (!accountSegment.startsWith("/")) {
117
+ return null; // Not a credential route
118
+ }
119
+
120
+ const account = decodeURIComponent(accountSegment.slice(1));
121
+ if (!account) {
122
+ return new Response(
123
+ JSON.stringify({ error: "Account name is required" }),
124
+ { status: 400, headers: { "Content-Type": "application/json" } },
125
+ );
126
+ }
127
+
128
+ switch (req.method) {
129
+ // GET /v1/credentials/:account — get credential value
130
+ case "GET": {
131
+ const value = await backend.get(account);
132
+ if (value === undefined) {
133
+ return new Response(
134
+ JSON.stringify({ error: "Credential not found", account }),
135
+ { status: 404, headers: { "Content-Type": "application/json" } },
136
+ );
137
+ }
138
+ return new Response(
139
+ JSON.stringify({ account, value }),
140
+ { status: 200, headers: { "Content-Type": "application/json" } },
141
+ );
142
+ }
143
+
144
+ // POST /v1/credentials/:account — set credential value
145
+ case "POST": {
146
+ let body: { value?: string };
147
+ try {
148
+ body = await req.json();
149
+ } catch {
150
+ return new Response(
151
+ JSON.stringify({ error: "Invalid JSON body" }),
152
+ { status: 400, headers: { "Content-Type": "application/json" } },
153
+ );
154
+ }
155
+
156
+ if (typeof body.value !== "string") {
157
+ return new Response(
158
+ JSON.stringify({ error: "Body must contain a 'value' string field" }),
159
+ { status: 400, headers: { "Content-Type": "application/json" } },
160
+ );
161
+ }
162
+
163
+ const ok = await backend.set(account, body.value);
164
+ if (!ok) {
165
+ return new Response(
166
+ JSON.stringify({ error: "Failed to set credential", account }),
167
+ { status: 500, headers: { "Content-Type": "application/json" } },
168
+ );
169
+ }
170
+ return new Response(
171
+ JSON.stringify({ ok: true, account }),
172
+ { status: 200, headers: { "Content-Type": "application/json" } },
173
+ );
174
+ }
175
+
176
+ // DELETE /v1/credentials/:account — delete credential
177
+ case "DELETE": {
178
+ const result = await backend.delete(account);
179
+ if (result === "not-found") {
180
+ return new Response(
181
+ JSON.stringify({ error: "Credential not found", account }),
182
+ { status: 404, headers: { "Content-Type": "application/json" } },
183
+ );
184
+ }
185
+ if (result === "error") {
186
+ return new Response(
187
+ JSON.stringify({ error: "Failed to delete credential", account }),
188
+ { status: 500, headers: { "Content-Type": "application/json" } },
189
+ );
190
+ }
191
+ return new Response(
192
+ JSON.stringify({ ok: true, account }),
193
+ { status: 200, headers: { "Content-Type": "application/json" } },
194
+ );
195
+ }
196
+
197
+ default:
198
+ return new Response(
199
+ JSON.stringify({ error: "Method not allowed" }),
200
+ { status: 405, headers: { "Content-Type": "application/json" } },
201
+ );
202
+ }
203
+ }
@@ -57,6 +57,8 @@ import { materializeManagedToken } from "./materializers/managed-platform.js";
57
57
  import { HandleType, parseHandle } from "@vellumai/ces-contracts";
58
58
  import { buildLazyGetters, type ApiKeyRef } from "./managed-lazy-getters.js";
59
59
  import { MANAGED_LOCAL_STATIC_REJECTION_ERROR } from "./managed-errors.js";
60
+ import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
61
+ import { handleCredentialRoute, type CredentialRouteDeps } from "./http/credential-routes.js";
60
62
 
61
63
  // ---------------------------------------------------------------------------
62
64
  // Logging (managed always logs to stderr)
@@ -127,9 +129,14 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef): RpcHan
127
129
  }
128
130
 
129
131
  // -- Workspace root for command execution cwd ------------------------------
130
- const assistantDataMount =
131
- process.env["CES_ASSISTANT_DATA_MOUNT"] ?? "/assistant-data-ro";
132
- const mountedVellumRoot = join(assistantDataMount, ".vellum");
132
+ // Prefer WORKSPACE_DIR when set (new Docker layout mounts workspace at
133
+ // /workspace). Fall back to the legacy path derived from the assistant
134
+ // data mount for backwards compatibility.
135
+ const defaultWorkspaceDir = process.env["WORKSPACE_DIR"] ?? (() => {
136
+ const assistantDataMount =
137
+ process.env["CES_ASSISTANT_DATA_MOUNT"] ?? "/assistant-data-ro";
138
+ return join(join(assistantDataMount, ".vellum"), "workspace");
139
+ })();
133
140
 
134
141
  // -- Build handler registry ------------------------------------------------
135
142
  // NOTE: local_static credential handles are NOT supported in managed mode.
@@ -241,7 +248,7 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef): RpcHan
241
248
  cesMode: "managed",
242
249
  egressHooks: buildCesEgressHooks(),
243
250
  },
244
- defaultWorkspaceDir: join(mountedVellumRoot, "workspace"),
251
+ defaultWorkspaceDir,
245
252
  });
246
253
 
247
254
  // Register manage_secure_command_tool handler
@@ -324,10 +331,14 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef): RpcHan
324
331
 
325
332
  let rpcConnected = false;
326
333
 
327
- function startHealthServer(port: number, signal: AbortSignal): ReturnType<typeof Bun.serve> {
334
+ function startHealthServer(
335
+ port: number,
336
+ signal: AbortSignal,
337
+ credentialDeps: CredentialRouteDeps | null,
338
+ ): ReturnType<typeof Bun.serve> {
328
339
  const server = Bun.serve({
329
340
  port,
330
- fetch(req) {
341
+ async fetch(req) {
331
342
  const url = new URL(req.url);
332
343
  if (url.pathname === "/healthz") {
333
344
  return new Response(
@@ -350,6 +361,13 @@ function startHealthServer(port: number, signal: AbortSignal): ReturnType<typeof
350
361
  },
351
362
  );
352
363
  }
364
+
365
+ // Credential CRUD routes (only if service token is configured)
366
+ if (credentialDeps) {
367
+ const credentialResponse = await handleCredentialRoute(req, credentialDeps);
368
+ if (credentialResponse) return credentialResponse;
369
+ }
370
+
353
371
  return new Response("Not Found", { status: 404 });
354
372
  },
355
373
  });
@@ -480,9 +498,29 @@ async function main(): Promise<void> {
480
498
  process.on("SIGTERM", shutdown);
481
499
  process.on("SIGINT", shutdown);
482
500
 
501
+ // Set up credential CRUD routes if a service token is configured.
502
+ // The assistant and gateway use CES_SERVICE_TOKEN to authenticate
503
+ // credential management requests over HTTP.
504
+ const serviceToken = process.env["CES_SERVICE_TOKEN"] ?? "";
505
+ let credentialDeps: CredentialRouteDeps | null = null;
506
+
507
+ if (serviceToken) {
508
+ const assistantDataMount =
509
+ process.env["CES_ASSISTANT_DATA_MOUNT"] ?? "/assistant-data-ro";
510
+ const vellumRoot = join(assistantDataMount, ".vellum");
511
+ const backend = createLocalSecureKeyBackend(vellumRoot);
512
+ credentialDeps = { backend, serviceToken };
513
+ log("Credential CRUD routes enabled (CES_SERVICE_TOKEN configured)");
514
+ } else {
515
+ warn(
516
+ "CES_SERVICE_TOKEN not set — credential CRUD HTTP routes are disabled. " +
517
+ "Set CES_SERVICE_TOKEN to enable credential management over HTTP.",
518
+ );
519
+ }
520
+
483
521
  // Start health server on dedicated port
484
522
  const healthPort = getHealthPort();
485
- const healthServer = startHealthServer(healthPort, controller.signal);
523
+ const healthServer = startHealthServer(healthPort, controller.signal, credentialDeps);
486
524
  log(`Health server listening on port ${healthPort}`);
487
525
 
488
526
  // Wait for exactly one assistant connection on the bootstrap socket
@@ -86,15 +86,34 @@ type StoreFile = StoreFileV1 | StoreFileV2;
86
86
  // ---------------------------------------------------------------------------
87
87
 
88
88
  const STORE_KEY_FILENAME = "store.key";
89
+ const KEYS_ENC_FILENAME = "keys.enc";
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Security directory resolution
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Resolve the directory containing `keys.enc` and `store.key`.
97
+ *
98
+ * When `CREDENTIAL_SECURITY_DIR` is set, files are read from (and written to)
99
+ * that directory directly. This allows Docker deployments to mount a separate
100
+ * CES-only security volume.
101
+ *
102
+ * When the env var is unset, falls back to `<vellumRoot>/protected/` for
103
+ * backwards compatibility.
104
+ */
105
+ function resolveSecurityDir(vellumRoot: string): string {
106
+ return process.env.CREDENTIAL_SECURITY_DIR || join(vellumRoot, "protected");
107
+ }
89
108
 
90
109
  /**
91
- * Read the v2 store key file from `<vellumRoot>/protected/store.key`.
110
+ * Read the v2 store key file from the security directory.
92
111
  * Returns the raw 32-byte key buffer, or null if the file is missing,
93
112
  * wrong size, or unreadable.
94
113
  */
95
114
  function readStoreKey(vellumRoot: string): Buffer | null {
96
115
  try {
97
- const keyPath = join(vellumRoot, "protected", STORE_KEY_FILENAME);
116
+ const keyPath = join(resolveSecurityDir(vellumRoot), STORE_KEY_FILENAME);
98
117
  if (!existsSync(keyPath)) return null;
99
118
  const buf = readFileSync(keyPath);
100
119
  if (buf.length !== KEY_LENGTH) return null;
@@ -211,9 +230,9 @@ function readStore(storePath: string): StoreFile | null {
211
230
  /**
212
231
  * Create a SecureKeyBackend backed by the assistant's encrypted key store.
213
232
  *
214
- * Supports `get` and `set` operations. `set` is needed for persisting
215
- * refreshed OAuth tokens. `delete` remains unsupported (returns "error")
216
- * because CES never needs to remove keys.
233
+ * Supports `get`, `set`, and `delete` operations. `set` is needed for
234
+ * persisting refreshed OAuth tokens. `delete` removes a key from the
235
+ * encrypted store.
217
236
  *
218
237
  * @param vellumRoot - The Vellum root directory (e.g. `~/.vellum`).
219
238
  * @param options.entropyOverride - If provided, used instead of local
@@ -230,7 +249,7 @@ export function createLocalSecureKeyBackend(
230
249
  vellumRoot: string,
231
250
  options?: { entropyOverride?: string; entropyGetter?: () => string | undefined },
232
251
  ): SecureKeyBackend {
233
- const storePath = join(vellumRoot, "protected", "keys.enc");
252
+ const storePath = join(resolveSecurityDir(vellumRoot), KEYS_ENC_FILENAME);
234
253
  const staticEntropy = options?.entropyOverride;
235
254
  const entropyGetter = options?.entropyGetter;
236
255
 
@@ -291,9 +310,19 @@ export function createLocalSecureKeyBackend(
291
310
  }
292
311
  },
293
312
 
294
- // CES never deletes keys — only reads and writes (for token refresh).
295
- async delete(_key: string): Promise<SecureKeyDeleteResult> {
296
- return "error";
313
+ async delete(key: string): Promise<SecureKeyDeleteResult> {
314
+ try {
315
+ const store = readStore(storePath);
316
+ if (!store) return "error";
317
+
318
+ if (!(key in store.entries)) return "not-found";
319
+
320
+ delete store.entries[key];
321
+ writeStore(store, storePath);
322
+ return "deleted";
323
+ } catch {
324
+ return "error";
325
+ }
297
326
  },
298
327
 
299
328
  async list(): Promise<string[]> {