@vellumai/credential-executor 0.4.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/Dockerfile +55 -0
  2. package/bun.lock +37 -0
  3. package/package.json +32 -0
  4. package/src/__tests__/command-executor.test.ts +1333 -0
  5. package/src/__tests__/command-validator.test.ts +708 -0
  6. package/src/__tests__/command-workspace.test.ts +997 -0
  7. package/src/__tests__/grant-store.test.ts +467 -0
  8. package/src/__tests__/http-executor.test.ts +1251 -0
  9. package/src/__tests__/http-policy.test.ts +970 -0
  10. package/src/__tests__/local-materializers.test.ts +826 -0
  11. package/src/__tests__/managed-materializers.test.ts +961 -0
  12. package/src/__tests__/toolstore.test.ts +539 -0
  13. package/src/__tests__/transport.test.ts +388 -0
  14. package/src/audit/store.ts +188 -0
  15. package/src/commands/auth-adapters.ts +169 -0
  16. package/src/commands/executor.ts +840 -0
  17. package/src/commands/output-scan.ts +157 -0
  18. package/src/commands/profiles.ts +282 -0
  19. package/src/commands/validator.ts +438 -0
  20. package/src/commands/workspace.ts +512 -0
  21. package/src/grants/index.ts +17 -0
  22. package/src/grants/persistent-store.ts +247 -0
  23. package/src/grants/rpc-handlers.ts +269 -0
  24. package/src/grants/temporary-store.ts +219 -0
  25. package/src/http/audit.ts +84 -0
  26. package/src/http/executor.ts +540 -0
  27. package/src/http/path-template.ts +179 -0
  28. package/src/http/policy.ts +256 -0
  29. package/src/http/response-filter.ts +233 -0
  30. package/src/index.ts +106 -0
  31. package/src/main.ts +263 -0
  32. package/src/managed-main.ts +420 -0
  33. package/src/materializers/local.ts +300 -0
  34. package/src/materializers/managed-platform.ts +270 -0
  35. package/src/paths.ts +137 -0
  36. package/src/server.ts +636 -0
  37. package/src/subjects/local.ts +177 -0
  38. package/src/subjects/managed.ts +290 -0
  39. package/src/toolstore/integrity.ts +94 -0
  40. package/src/toolstore/manifest.ts +154 -0
  41. package/src/toolstore/publish.ts +342 -0
  42. package/tsconfig.json +20 -0
@@ -0,0 +1,247 @@
1
+ /**
2
+ * CES persistent grant store.
3
+ *
4
+ * Stores canonical validated grants by stable ID in a `grants.json` file
5
+ * inside the CES-private data root. This file is never co-mingled with
6
+ * assistant trust files or credential metadata.
7
+ *
8
+ * Design principles:
9
+ * - **Fail closed**: If the store file is unreadable or malformed, all
10
+ * reads return empty and all writes throw. The CES must never fall back
11
+ * to a permissive default when the persistent state is corrupt.
12
+ * - **Atomic writes**: Uses rename-over-tmp to prevent partial writes.
13
+ * - **Deduplication**: Grants are keyed by a canonical hash (the `id`
14
+ * field) — adding a grant with an existing ID is a no-op.
15
+ */
16
+
17
+ import {
18
+ chmodSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ renameSync,
23
+ writeFileSync,
24
+ } from "node:fs";
25
+ import { dirname, join } from "node:path";
26
+ import { randomUUID } from "node:crypto";
27
+
28
+ import { getCesGrantsDir } from "../paths.js";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Types
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** A canonical persistent grant stored on disk. */
35
+ export interface PersistentGrant {
36
+ /** Stable canonical hash identifying this grant. */
37
+ id: string;
38
+ /** The tool or command pattern this grant authorises. */
39
+ tool: string;
40
+ /** Glob pattern scoping the grant. */
41
+ pattern: string;
42
+ /** Scope constraint (directory path or "everywhere"). */
43
+ scope: string;
44
+ /** When the grant was created (epoch ms). */
45
+ createdAt: number;
46
+ }
47
+
48
+ /** On-disk format for the grants file. */
49
+ interface GrantsFile {
50
+ version: number;
51
+ grants: PersistentGrant[];
52
+ }
53
+
54
+ const GRANTS_FILE_VERSION = 1;
55
+ const GRANTS_FILENAME = "grants.json";
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Store implementation
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export class PersistentGrantStore {
62
+ private readonly filePath: string;
63
+ private cache: PersistentGrant[] | null = null;
64
+ /** Set to true when the store detects corruption; blocks all operations. */
65
+ private corrupt = false;
66
+
67
+ constructor(grantsDir?: string) {
68
+ const dir = grantsDir ?? getCesGrantsDir();
69
+ this.filePath = join(dir, GRANTS_FILENAME);
70
+ }
71
+
72
+ // -----------------------------------------------------------------------
73
+ // Public API
74
+ // -----------------------------------------------------------------------
75
+
76
+ /**
77
+ * Initialise the store, ensuring the parent directory exists and the
78
+ * grants file is readable. If the file does not exist it is created
79
+ * with an empty grant list.
80
+ *
81
+ * Throws if the directory cannot be created or an existing file is
82
+ * malformed (fail-closed).
83
+ */
84
+ init(): void {
85
+ const dir = dirname(this.filePath);
86
+ if (!existsSync(dir)) {
87
+ mkdirSync(dir, { recursive: true });
88
+ }
89
+
90
+ if (!existsSync(this.filePath)) {
91
+ this.writeToDisk([]);
92
+ return;
93
+ }
94
+
95
+ // Validate the existing file is readable and well-formed.
96
+ // If it isn't, mark corrupt and throw (fail closed).
97
+ this.loadFromDisk();
98
+ }
99
+
100
+ /**
101
+ * Return all persisted grants.
102
+ *
103
+ * Returns an empty array if the store has never been initialised
104
+ * (no file on disk). Throws if the store is corrupt.
105
+ */
106
+ getAll(): PersistentGrant[] {
107
+ this.assertNotCorrupt();
108
+ if (this.cache !== null) return [...this.cache];
109
+ if (!existsSync(this.filePath)) return [];
110
+ return [...this.loadFromDisk()];
111
+ }
112
+
113
+ /**
114
+ * Look up a grant by its canonical ID.
115
+ *
116
+ * Returns `undefined` if not found. Throws if the store is corrupt.
117
+ */
118
+ getById(id: string): PersistentGrant | undefined {
119
+ return this.getAll().find((g) => g.id === id);
120
+ }
121
+
122
+ /**
123
+ * Add a grant. If a grant with the same `id` already exists, this is
124
+ * a no-op (idempotent deduplication by canonical hash).
125
+ *
126
+ * Returns `true` if the grant was newly added, `false` if it was a
127
+ * duplicate.
128
+ */
129
+ add(grant: PersistentGrant): boolean {
130
+ this.assertNotCorrupt();
131
+ const grants = this.loadFromDisk();
132
+ if (grants.some((g) => g.id === grant.id)) {
133
+ return false;
134
+ }
135
+ grants.push(grant);
136
+ this.writeToDisk(grants);
137
+ return true;
138
+ }
139
+
140
+ /**
141
+ * Remove a grant by its canonical ID.
142
+ *
143
+ * Returns `true` if the grant was found and removed, `false` otherwise.
144
+ */
145
+ remove(id: string): boolean {
146
+ this.assertNotCorrupt();
147
+ const grants = this.loadFromDisk();
148
+ const index = grants.findIndex((g) => g.id === id);
149
+ if (index === -1) return false;
150
+ grants.splice(index, 1);
151
+ this.writeToDisk(grants);
152
+ return true;
153
+ }
154
+
155
+ /**
156
+ * Check whether a grant with the given ID exists.
157
+ */
158
+ has(id: string): boolean {
159
+ return this.getById(id) !== undefined;
160
+ }
161
+
162
+ /**
163
+ * Remove all grants and reset the store to an empty state.
164
+ */
165
+ clear(): void {
166
+ this.assertNotCorrupt();
167
+ this.writeToDisk([]);
168
+ }
169
+
170
+ // -----------------------------------------------------------------------
171
+ // Internals
172
+ // -----------------------------------------------------------------------
173
+
174
+ private assertNotCorrupt(): void {
175
+ if (this.corrupt) {
176
+ throw new Error(
177
+ "CES persistent grant store is corrupt — refusing to operate (fail closed)",
178
+ );
179
+ }
180
+ }
181
+
182
+ private loadFromDisk(): PersistentGrant[] {
183
+ try {
184
+ const raw = readFileSync(this.filePath, "utf-8");
185
+ const data = JSON.parse(raw) as unknown;
186
+
187
+ if (
188
+ typeof data !== "object" ||
189
+ data === null ||
190
+ !("version" in data) ||
191
+ !("grants" in data)
192
+ ) {
193
+ this.corrupt = true;
194
+ throw new Error(
195
+ "CES grants file is malformed: missing version or grants field",
196
+ );
197
+ }
198
+
199
+ const file = data as GrantsFile;
200
+
201
+ if (file.version !== GRANTS_FILE_VERSION) {
202
+ this.corrupt = true;
203
+ throw new Error(
204
+ `CES grants file has unsupported version ${file.version} (expected ${GRANTS_FILE_VERSION})`,
205
+ );
206
+ }
207
+
208
+ if (!Array.isArray(file.grants)) {
209
+ this.corrupt = true;
210
+ throw new Error("CES grants file is malformed: grants is not an array");
211
+ }
212
+
213
+ this.cache = file.grants;
214
+ return [...file.grants];
215
+ } catch (err) {
216
+ if (this.corrupt) throw err;
217
+ // Any other read/parse error → fail closed
218
+ this.corrupt = true;
219
+ throw new Error(
220
+ `CES persistent grant store failed to read ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`,
221
+ );
222
+ }
223
+ }
224
+
225
+ private writeToDisk(grants: PersistentGrant[]): void {
226
+ const dir = dirname(this.filePath);
227
+ if (!existsSync(dir)) {
228
+ mkdirSync(dir, { recursive: true });
229
+ }
230
+
231
+ const data: GrantsFile = {
232
+ version: GRANTS_FILE_VERSION,
233
+ grants,
234
+ };
235
+
236
+ const tmpPath = join(dir, `.tmp-${randomUUID()}`);
237
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2), {
238
+ mode: 0o600,
239
+ });
240
+ renameSync(tmpPath, this.filePath);
241
+ // Enforce owner-only permissions even if the file already existed
242
+ // with wider permissions.
243
+ chmodSync(this.filePath, 0o600);
244
+
245
+ this.cache = grants;
246
+ }
247
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * CES RPC handlers for grant and audit management.
3
+ *
4
+ * Implements the server-side handlers for:
5
+ * - `record_grant` — Record a grant decision after guardian approval.
6
+ * - `list_grants` — List grants filtered by session, handle, or status.
7
+ * - `revoke_grant` — Revoke a specific grant by its stable ID.
8
+ * - `list_audit_records` — List audit records with filtering and pagination.
9
+ *
10
+ * All handlers operate strictly on CES-owned state and never expose raw
11
+ * secret material, raw tokens, or raw headers/bodies. Grant records returned
12
+ * to the assistant contain only metadata (handle, proposal type, status,
13
+ * timestamps).
14
+ */
15
+
16
+ import type {
17
+ ListGrants,
18
+ ListGrantsResponse,
19
+ RecordGrant,
20
+ RecordGrantResponse,
21
+ RevokeGrant,
22
+ RevokeGrantResponse,
23
+ ListAuditRecords,
24
+ ListAuditRecordsResponse,
25
+ PersistentGrantRecord,
26
+ } from "@vellumai/ces-contracts";
27
+
28
+ import type { PersistentGrantStore, PersistentGrant } from "./persistent-store.js";
29
+ import type { TemporaryGrantStore } from "./temporary-store.js";
30
+ import type { AuditStore } from "../audit/store.js";
31
+ import type { RpcMethodHandler } from "../server.js";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Grant → PersistentGrantRecord projection
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Project a CES internal PersistentGrant into the wire-format
39
+ * PersistentGrantRecord. The internal store uses a simpler schema;
40
+ * the wire format includes additional status/lifecycle fields.
41
+ *
42
+ * Since the persistent store does not track lifecycle states (expiry,
43
+ * revocation, consumption), all persisted grants are considered "active".
44
+ */
45
+ function projectGrant(
46
+ grant: PersistentGrant,
47
+ sessionId: string,
48
+ ): PersistentGrantRecord {
49
+ return {
50
+ grantId: grant.id,
51
+ sessionId,
52
+ credentialHandle: grant.scope,
53
+ proposalType: grant.tool as "http" | "command",
54
+ proposalHash: grant.id,
55
+ allowedPurposes: [grant.pattern],
56
+ status: "active",
57
+ grantedBy: "user",
58
+ createdAt: new Date(grant.createdAt).toISOString(),
59
+ expiresAt: null,
60
+ consumedAt: null,
61
+ revokedAt: null,
62
+ };
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // record_grant handler
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export interface RecordGrantHandlerDeps {
70
+ persistentGrantStore: PersistentGrantStore;
71
+ temporaryGrantStore: TemporaryGrantStore;
72
+ }
73
+
74
+ /**
75
+ * Create an RPC handler for the `record_grant` method.
76
+ *
77
+ * Receives a `TemporaryGrantDecision` from the approval bridge and persists
78
+ * it as a `PersistentGrant` (for approved decisions) or returns a success
79
+ * acknowledgement (for denied decisions). The handler also adds an
80
+ * in-memory temporary grant so the caller can immediately retry the
81
+ * original tool invocation.
82
+ *
83
+ * For approved decisions with a TTL of "PT10M", the grant is stored as
84
+ * a timed temporary grant. Otherwise it is persisted as a permanent grant.
85
+ */
86
+ export function createRecordGrantHandler(
87
+ deps: RecordGrantHandlerDeps,
88
+ ): RpcMethodHandler<RecordGrant, RecordGrantResponse> {
89
+ return (request) => {
90
+ const { decision, sessionId } = request;
91
+
92
+ // Denied decisions are acknowledged but produce no grant record.
93
+ if (decision.decision === "denied") {
94
+ return { success: true };
95
+ }
96
+
97
+ // Build a PersistentGrant from the decision.
98
+ const proposal = decision.proposal;
99
+ const now = Date.now();
100
+ const grantId = decision.proposalHash;
101
+
102
+ const persistentGrant: PersistentGrant = {
103
+ id: grantId,
104
+ tool: proposal.type,
105
+ pattern:
106
+ proposal.type === "http"
107
+ ? `${proposal.method} ${proposal.url}`
108
+ : proposal.command,
109
+ scope: proposal.credentialHandle,
110
+ createdAt: now,
111
+ };
112
+
113
+ // Persist the grant.
114
+ deps.persistentGrantStore.add(persistentGrant);
115
+
116
+ // Also record a temporary grant so the caller can use it immediately.
117
+ // Map TTL to the appropriate temporary grant kind.
118
+ if (decision.ttl === "PT10M") {
119
+ deps.temporaryGrantStore.add("allow_10m", decision.proposalHash);
120
+ } else {
121
+ deps.temporaryGrantStore.add("allow_once", decision.proposalHash);
122
+ }
123
+
124
+ // Compute expiry from TTL if present.
125
+ let expiresAt: string | null = null;
126
+ if (decision.ttl === "PT10M") {
127
+ expiresAt = new Date(now + 10 * 60 * 1000).toISOString();
128
+ }
129
+
130
+ const grantRecord: PersistentGrantRecord = {
131
+ grantId,
132
+ sessionId,
133
+ credentialHandle: proposal.credentialHandle,
134
+ proposalType: proposal.type,
135
+ proposalHash: decision.proposalHash,
136
+ allowedPurposes:
137
+ proposal.type === "http"
138
+ ? proposal.allowedUrlPatterns ?? [`${proposal.method} ${proposal.url}`]
139
+ : proposal.allowedCommandPatterns ?? [proposal.command],
140
+ status: "active",
141
+ grantedBy: decision.decidedBy,
142
+ createdAt: new Date(now).toISOString(),
143
+ expiresAt,
144
+ consumedAt: null,
145
+ revokedAt: null,
146
+ };
147
+
148
+ return {
149
+ success: true,
150
+ grant: grantRecord,
151
+ };
152
+ };
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // list_grants handler
157
+ // ---------------------------------------------------------------------------
158
+
159
+ export interface ListGrantsHandlerDeps {
160
+ persistentGrantStore: PersistentGrantStore;
161
+ /** Default session ID for grants that don't track session. */
162
+ sessionId: string;
163
+ }
164
+
165
+ /**
166
+ * Create an RPC handler for the `list_grants` method.
167
+ *
168
+ * Lists all persistent grants, optionally filtered by session ID,
169
+ * credential handle, or status. Returns wire-format PersistentGrantRecords
170
+ * that never include raw secret material.
171
+ */
172
+ export function createListGrantsHandler(
173
+ deps: ListGrantsHandlerDeps,
174
+ ): RpcMethodHandler<ListGrants, ListGrantsResponse> {
175
+ return (request) => {
176
+ const allGrants = deps.persistentGrantStore.getAll();
177
+ const projected = allGrants.map((g) => projectGrant(g, deps.sessionId));
178
+
179
+ let filtered = projected;
180
+
181
+ if (request.sessionId) {
182
+ filtered = filtered.filter((g) => g.sessionId === request.sessionId);
183
+ }
184
+
185
+ if (request.credentialHandle) {
186
+ filtered = filtered.filter(
187
+ (g) => g.credentialHandle === request.credentialHandle,
188
+ );
189
+ }
190
+
191
+ if (request.status) {
192
+ filtered = filtered.filter((g) => g.status === request.status);
193
+ }
194
+
195
+ return { grants: filtered };
196
+ };
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // revoke_grant handler
201
+ // ---------------------------------------------------------------------------
202
+
203
+ export interface RevokeGrantHandlerDeps {
204
+ persistentGrantStore: PersistentGrantStore;
205
+ }
206
+
207
+ /**
208
+ * Create an RPC handler for the `revoke_grant` method.
209
+ *
210
+ * Removes a grant from the persistent store by its stable ID. Returns
211
+ * success/failure. The reason field is logged but not persisted (the
212
+ * persistent store does not track revocation metadata).
213
+ */
214
+ export function createRevokeGrantHandler(
215
+ deps: RevokeGrantHandlerDeps,
216
+ ): RpcMethodHandler<RevokeGrant, RevokeGrantResponse> {
217
+ return (request) => {
218
+ const removed = deps.persistentGrantStore.remove(request.grantId);
219
+
220
+ if (!removed) {
221
+ return {
222
+ success: false,
223
+ error: {
224
+ code: "GRANT_NOT_FOUND",
225
+ message: `No grant found with ID "${request.grantId}"`,
226
+ },
227
+ };
228
+ }
229
+
230
+ return { success: true };
231
+ };
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // list_audit_records handler
236
+ // ---------------------------------------------------------------------------
237
+
238
+ export interface ListAuditRecordsHandlerDeps {
239
+ auditStore: AuditStore;
240
+ }
241
+
242
+ /**
243
+ * Create an RPC handler for the `list_audit_records` method.
244
+ *
245
+ * Lists audit records with optional filtering by session, credential
246
+ * handle, or grant ID. Supports limit and cursor-based pagination.
247
+ *
248
+ * Audit records never contain raw secrets, raw tokens, or raw
249
+ * headers/bodies — they are token-free summaries generated at
250
+ * execution time.
251
+ */
252
+ export function createListAuditRecordsHandler(
253
+ deps: ListAuditRecordsHandlerDeps,
254
+ ): RpcMethodHandler<ListAuditRecords, ListAuditRecordsResponse> {
255
+ return (request) => {
256
+ const result = deps.auditStore.list({
257
+ sessionId: request.sessionId,
258
+ credentialHandle: request.credentialHandle,
259
+ grantId: request.grantId,
260
+ limit: request.limit,
261
+ cursor: request.cursor,
262
+ });
263
+
264
+ return {
265
+ records: result.records,
266
+ nextCursor: result.nextCursor,
267
+ };
268
+ };
269
+ }