@vellumai/credential-executor 0.5.4 → 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 +3 -0
- package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
- package/package.json +1 -1
- package/src/http/credential-routes.ts +199 -0
- package/src/managed-main.ts +45 -7
- package/src/materializers/local-secure-key-backend.ts +22 -3
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
|
|
@@ -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
|
@@ -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
|
+
}
|
package/src/managed-main.ts
CHANGED
|
@@ -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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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(
|
|
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
|
|
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,
|
|
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,
|
|
252
|
+
const storePath = join(resolveSecurityDir(vellumRoot), KEYS_ENC_FILENAME);
|
|
234
253
|
const staticEntropy = options?.entropyOverride;
|
|
235
254
|
const entropyGetter = options?.entropyGetter;
|
|
236
255
|
|