@vellumai/credential-executor 0.5.3 → 0.5.5

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.3",
3
+ "version": "0.5.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -0,0 +1,199 @@
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 type { SecureKeyBackend } from "@vellumai/credential-storage";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Auth
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Validate the Authorization header against the configured service token.
27
+ * Returns an error Response if auth fails, or null if auth succeeds.
28
+ */
29
+ function checkAuth(req: Request, serviceToken: string): Response | null {
30
+ const authHeader = req.headers.get("authorization");
31
+ if (!authHeader) {
32
+ return new Response(
33
+ JSON.stringify({ error: "Missing Authorization header" }),
34
+ { status: 401, headers: { "Content-Type": "application/json" } },
35
+ );
36
+ }
37
+
38
+ const parts = authHeader.split(" ");
39
+ if (parts.length !== 2 || parts[0]!.toLowerCase() !== "bearer") {
40
+ return new Response(
41
+ JSON.stringify({ error: "Invalid Authorization header format. Expected: Bearer <token>" }),
42
+ { status: 401, headers: { "Content-Type": "application/json" } },
43
+ );
44
+ }
45
+
46
+ if (parts[1] !== serviceToken) {
47
+ return new Response(
48
+ JSON.stringify({ error: "Invalid service token" }),
49
+ { status: 403, headers: { "Content-Type": "application/json" } },
50
+ );
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Route handler
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export interface CredentialRouteDeps {
61
+ /** The secure key backend to wrap. */
62
+ backend: SecureKeyBackend;
63
+ /** Service token for authenticating requests. */
64
+ serviceToken: string;
65
+ }
66
+
67
+ const CREDENTIAL_PATH_PREFIX = "/v1/credentials";
68
+
69
+ /**
70
+ * Try to handle a credential CRUD request. Returns a Response if the
71
+ * request matches a credential route, or null if it doesn't match
72
+ * (allowing the caller to fall through to other routes).
73
+ */
74
+ export async function handleCredentialRoute(
75
+ req: Request,
76
+ deps: CredentialRouteDeps,
77
+ ): Promise<Response | null> {
78
+ const url = new URL(req.url);
79
+ const { pathname } = url;
80
+
81
+ // Only handle /v1/credentials paths
82
+ if (!pathname.startsWith(CREDENTIAL_PATH_PREFIX)) {
83
+ return null;
84
+ }
85
+
86
+ // Auth check
87
+ const authError = checkAuth(req, deps.serviceToken);
88
+ if (authError) return authError;
89
+
90
+ const { backend } = deps;
91
+
92
+ // Extract account from path: /v1/credentials/:account
93
+ const accountSegment = pathname.slice(CREDENTIAL_PATH_PREFIX.length);
94
+
95
+ // GET /v1/credentials — list all credential account names
96
+ if (accountSegment === "" || accountSegment === "/") {
97
+ if (req.method !== "GET") {
98
+ return new Response(
99
+ JSON.stringify({ error: "Method not allowed" }),
100
+ { status: 405, headers: { "Content-Type": "application/json" } },
101
+ );
102
+ }
103
+
104
+ const accounts = await backend.list();
105
+ return new Response(
106
+ JSON.stringify({ accounts }),
107
+ { status: 200, headers: { "Content-Type": "application/json" } },
108
+ );
109
+ }
110
+
111
+ // Remaining routes require /:account
112
+ if (!accountSegment.startsWith("/")) {
113
+ return null; // Not a credential route
114
+ }
115
+
116
+ const account = decodeURIComponent(accountSegment.slice(1));
117
+ if (!account) {
118
+ return new Response(
119
+ JSON.stringify({ error: "Account name is required" }),
120
+ { status: 400, headers: { "Content-Type": "application/json" } },
121
+ );
122
+ }
123
+
124
+ switch (req.method) {
125
+ // GET /v1/credentials/:account — get credential value
126
+ case "GET": {
127
+ const value = await backend.get(account);
128
+ if (value === undefined) {
129
+ return new Response(
130
+ JSON.stringify({ error: "Credential not found", account }),
131
+ { status: 404, headers: { "Content-Type": "application/json" } },
132
+ );
133
+ }
134
+ return new Response(
135
+ JSON.stringify({ account, value }),
136
+ { status: 200, headers: { "Content-Type": "application/json" } },
137
+ );
138
+ }
139
+
140
+ // POST /v1/credentials/:account — set credential value
141
+ case "POST": {
142
+ let body: { value?: string };
143
+ try {
144
+ body = await req.json();
145
+ } catch {
146
+ return new Response(
147
+ JSON.stringify({ error: "Invalid JSON body" }),
148
+ { status: 400, headers: { "Content-Type": "application/json" } },
149
+ );
150
+ }
151
+
152
+ if (typeof body.value !== "string") {
153
+ return new Response(
154
+ JSON.stringify({ error: "Body must contain a 'value' string field" }),
155
+ { status: 400, headers: { "Content-Type": "application/json" } },
156
+ );
157
+ }
158
+
159
+ const ok = await backend.set(account, body.value);
160
+ if (!ok) {
161
+ return new Response(
162
+ JSON.stringify({ error: "Failed to set credential", account }),
163
+ { status: 500, headers: { "Content-Type": "application/json" } },
164
+ );
165
+ }
166
+ return new Response(
167
+ JSON.stringify({ ok: true, account }),
168
+ { status: 200, headers: { "Content-Type": "application/json" } },
169
+ );
170
+ }
171
+
172
+ // DELETE /v1/credentials/:account — delete credential
173
+ case "DELETE": {
174
+ const result = await backend.delete(account);
175
+ if (result === "not-found") {
176
+ return new Response(
177
+ JSON.stringify({ error: "Credential not found", account }),
178
+ { status: 404, headers: { "Content-Type": "application/json" } },
179
+ );
180
+ }
181
+ if (result === "error") {
182
+ return new Response(
183
+ JSON.stringify({ error: "Failed to delete credential", account }),
184
+ { status: 500, headers: { "Content-Type": "application/json" } },
185
+ );
186
+ }
187
+ return new Response(
188
+ JSON.stringify({ ok: true, account }),
189
+ { status: 200, headers: { "Content-Type": "application/json" } },
190
+ );
191
+ }
192
+
193
+ default:
194
+ return new Response(
195
+ JSON.stringify({ error: "Method not allowed" }),
196
+ { status: 405, headers: { "Content-Type": "application/json" } },
197
+ );
198
+ }
199
+ }
@@ -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;
@@ -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