@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.
Files changed (65) hide show
  1. package/Dockerfile +1 -1
  2. package/node_modules/@vellumai/service-contracts/package.json +1 -2
  3. package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +104 -0
  4. package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +0 -2
  5. package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +118 -0
  6. package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +3 -5
  7. package/node_modules/@vellumai/service-contracts/src/index.ts +2 -4
  8. package/node_modules/@vellumai/service-contracts/src/rpc.ts +4 -447
  9. package/package.json +2 -3
  10. package/src/__tests__/bulk-set-credentials.test.ts +1 -1
  11. package/src/__tests__/local-standalone.test.ts +5 -36
  12. package/src/__tests__/managed-integration.test.ts +112 -91
  13. package/src/__tests__/managed-reconnect.test.ts +2 -2
  14. package/src/__tests__/transport.test.ts +23 -27
  15. package/src/cli.ts +1 -1
  16. package/src/index.ts +8 -88
  17. package/src/main.ts +228 -340
  18. package/src/paths.ts +4 -20
  19. package/src/server.ts +52 -469
  20. package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +0 -686
  21. package/node_modules/@vellumai/service-contracts/src/grants.ts +0 -184
  22. package/node_modules/@vellumai/service-contracts/src/rendering.ts +0 -135
  23. package/src/__tests__/command-executor.test.ts +0 -1879
  24. package/src/__tests__/command-validator.test.ts +0 -1405
  25. package/src/__tests__/command-workspace.test.ts +0 -1050
  26. package/src/__tests__/grant-store.test.ts +0 -689
  27. package/src/__tests__/http-executor.test.ts +0 -1336
  28. package/src/__tests__/http-policy.test.ts +0 -1069
  29. package/src/__tests__/local-materializers.test.ts +0 -860
  30. package/src/__tests__/local-token-refresh.test.ts +0 -361
  31. package/src/__tests__/manage-secure-command-tool.test.ts +0 -134
  32. package/src/__tests__/managed-lazy-getters.test.ts +0 -359
  33. package/src/__tests__/managed-materializers.test.ts +0 -1028
  34. package/src/__tests__/managed-rejection.test.ts +0 -43
  35. package/src/__tests__/toolstore.test.ts +0 -773
  36. package/src/audit/store.ts +0 -188
  37. package/src/commands/auth-adapters.ts +0 -169
  38. package/src/commands/egress-hooks.ts +0 -203
  39. package/src/commands/executor.ts +0 -1155
  40. package/src/commands/output-scan.ts +0 -157
  41. package/src/commands/profiles.ts +0 -286
  42. package/src/commands/validator.ts +0 -702
  43. package/src/commands/workspace.ts +0 -550
  44. package/src/grants/index.ts +0 -17
  45. package/src/grants/persistent-store.ts +0 -309
  46. package/src/grants/rpc-handlers.ts +0 -293
  47. package/src/grants/temporary-store.ts +0 -289
  48. package/src/http/audit.ts +0 -84
  49. package/src/http/executor.ts +0 -684
  50. package/src/http/path-template.ts +0 -245
  51. package/src/http/policy.ts +0 -238
  52. package/src/http/response-filter.ts +0 -233
  53. package/src/managed-errors.ts +0 -9
  54. package/src/managed-lazy-getters.ts +0 -106
  55. package/src/managed-main.ts +0 -822
  56. package/src/materializers/local-oauth-lookup.ts +0 -98
  57. package/src/materializers/local-token-refresh.ts +0 -287
  58. package/src/materializers/local.ts +0 -316
  59. package/src/materializers/managed-platform.ts +0 -295
  60. package/src/subjects/local.ts +0 -177
  61. package/src/subjects/managed.ts +0 -311
  62. package/src/subjects/policy.ts +0 -79
  63. package/src/toolstore/integrity.ts +0 -94
  64. package/src/toolstore/manifest.ts +0 -154
  65. package/src/toolstore/publish.ts +0 -571
@@ -1,245 +0,0 @@
1
- /**
2
- * Deterministic path-template derivation for HTTP grant proposals.
3
- *
4
- * Normalises URLs and replaces only well-known dynamic segments (numeric IDs,
5
- * UUIDs, and long hex strings) with typed placeholders while keeping every
6
- * other path segment literal. This ensures that proposals are specific enough
7
- * to be meaningful ("allow GET on /repos/{owner}/pulls/{:num}") without
8
- * over-expanding to wildcard patterns that would be too permissive.
9
- *
10
- * Design invariants:
11
- * - Query strings and fragments are stripped — only scheme + host + path matter.
12
- * - Host is preserved literally (no wildcard expansion).
13
- * - Path never collapses to `/*`.
14
- * - Trailing slashes are normalised away.
15
- */
16
-
17
- // ---------------------------------------------------------------------------
18
- // Helpers
19
- // ---------------------------------------------------------------------------
20
-
21
- /**
22
- * Safely decode a percent-encoded path segment. Returns `null` when it
23
- * contains malformed escapes (e.g. bare `%` or `%zz`) so that callers
24
- * can fail closed — malformed segments never match anything.
25
- */
26
- function safeDecodeSegment(segment: string): string | null {
27
- try {
28
- return decodeURIComponent(segment);
29
- } catch {
30
- return null;
31
- }
32
- }
33
-
34
- // ---------------------------------------------------------------------------
35
- // Segment classification patterns
36
- // ---------------------------------------------------------------------------
37
-
38
- /**
39
- * UUID v4 pattern (case-insensitive): 8-4-4-4-12 hex digits with hyphens.
40
- */
41
- const UUID_RE =
42
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
43
-
44
- /**
45
- * Purely numeric segments (e.g. resource IDs like `/users/42`).
46
- */
47
- const NUMERIC_RE = /^[0-9]+$/;
48
-
49
- /**
50
- * Hex-like strings of 16+ characters — commonly used for opaque identifiers,
51
- * commit SHAs, object IDs, etc. Must be at least 16 chars to avoid matching
52
- * short, human-meaningful slugs that happen to be hex-only (e.g. "cafe",
53
- * "dead", "beef").
54
- */
55
- const HEX_LONG_RE = /^[0-9a-f]{16,}$/i;
56
-
57
- // ---------------------------------------------------------------------------
58
- // Spoofed placeholder detection
59
- // ---------------------------------------------------------------------------
60
-
61
- /**
62
- * Literal strings that, if present in a decoded URL segment, indicate an
63
- * attempt to inject a wildcard placeholder via percent-encoding.
64
- *
65
- * Legitimate URLs never contain these exact strings as path segments.
66
- * Rejecting them prevents an attacker from crafting a URL like
67
- * `https://api.example.com/%7B:num%7D/resource` that would be stored as
68
- * a literal during grant approval but decoded to a wildcard during matching.
69
- */
70
- const PLACEHOLDER_LITERALS = new Set(["{:num}", "{:uuid}", "{:hex}"]);
71
-
72
- // ---------------------------------------------------------------------------
73
- // Placeholder types
74
- // ---------------------------------------------------------------------------
75
-
76
- /**
77
- * Replace a path segment with a typed placeholder if it matches a known
78
- * dynamic pattern. Returns the original segment if it does not match.
79
- */
80
- function classifySegment(segment: string): string {
81
- if (UUID_RE.test(segment)) return "{:uuid}";
82
- if (NUMERIC_RE.test(segment)) return "{:num}";
83
- if (HEX_LONG_RE.test(segment)) return "{:hex}";
84
- return segment;
85
- }
86
-
87
- // ---------------------------------------------------------------------------
88
- // Path template derivation
89
- // ---------------------------------------------------------------------------
90
-
91
- /**
92
- * Derive a deterministic path template from a raw URL.
93
- *
94
- * 1. Parse the URL to extract scheme, host, and pathname.
95
- * 2. Strip query string and fragment.
96
- * 3. Split the pathname into segments and classify each one.
97
- * 4. Reassemble into `scheme://host/path/with/{placeholders}`.
98
- *
99
- * Throws if `rawUrl` is not a valid absolute URL.
100
- */
101
- export function derivePathTemplate(rawUrl: string): string {
102
- const parsed = new URL(rawUrl);
103
-
104
- // Normalise: strip query and fragment, lowercase the host
105
- const scheme = parsed.protocol.replace(/:$/, "");
106
- const host = parsed.hostname + (parsed.port ? `:${parsed.port}` : "");
107
-
108
- // Split path into segments, dropping empty segments from leading/trailing slashes.
109
- const rawSegments = parsed.pathname
110
- .split("/")
111
- .filter((s) => s.length > 0);
112
-
113
- // Decode each segment for classification and placeholder detection, but
114
- // preserve the raw (encoded) form for literal segments in the rebuilt
115
- // template. This prevents encoded delimiters like %2F from being decoded
116
- // into real path separators, which would change URL structure.
117
- const decodedSegments = rawSegments.map((seg) => {
118
- const decoded = safeDecodeSegment(seg);
119
- // If decoding fails, keep the raw segment — it will be stored as a
120
- // literal and can never match anything meaningful (fail closed).
121
- return decoded ?? seg;
122
- });
123
-
124
- // Guard: reject URLs whose decoded segments match known placeholder
125
- // patterns. Legitimate URLs never contain literal "{:num}" etc. as path
126
- // segments; their presence indicates an attempt to inject wildcards via
127
- // percent-encoding (e.g. %7B:num%7D).
128
- for (const seg of decodedSegments) {
129
- if (PLACEHOLDER_LITERALS.has(seg)) {
130
- throw new Error(
131
- `Refusing to derive path template: segment "${seg}" is a reserved placeholder literal`,
132
- );
133
- }
134
- }
135
-
136
- const templatedSegments = decodedSegments.map((decoded, i) => {
137
- const classified = classifySegment(decoded);
138
- // If the segment was replaced with a placeholder, use the placeholder.
139
- // Otherwise, use the raw (encoded) segment to preserve URL structure.
140
- return classified !== decoded ? classified : rawSegments[i]!;
141
- });
142
-
143
- const path =
144
- templatedSegments.length > 0
145
- ? "/" + templatedSegments.join("/")
146
- : "/";
147
-
148
- return `${scheme}://${host}${path}`;
149
- }
150
-
151
- /**
152
- * Derive the allowed URL pattern for an HTTP grant proposal.
153
- *
154
- * Returns an array with a single pattern string — the path template.
155
- * The caller uses this to populate `allowedUrlPatterns` on the proposal.
156
- *
157
- * This is a thin wrapper around `derivePathTemplate` that returns an array
158
- * for direct use in proposal construction.
159
- */
160
- export function deriveAllowedUrlPatterns(rawUrl: string): string[] {
161
- return [derivePathTemplate(rawUrl)];
162
- }
163
-
164
- /**
165
- * Check whether a concrete URL matches a path template pattern.
166
- *
167
- * Used during grant evaluation to determine whether a stored
168
- * `allowedUrlPatterns` entry covers a requested URL.
169
- *
170
- * Matching rules:
171
- * - Scheme and host must match exactly (case-insensitive).
172
- * - Path segments must match positionally.
173
- * - A `{:num}` placeholder matches any purely numeric segment.
174
- * - A `{:uuid}` placeholder matches any UUID v4 segment.
175
- * - A `{:hex}` placeholder matches any 16+-char hex segment.
176
- * - Literal segments must match exactly (case-sensitive — URL paths are
177
- * case-sensitive per RFC 3986).
178
- */
179
- export function urlMatchesTemplate(
180
- rawUrl: string,
181
- template: string,
182
- ): boolean {
183
- let parsedUrl: URL;
184
- let parsedTemplate: URL;
185
-
186
- try {
187
- parsedUrl = new URL(rawUrl);
188
- } catch {
189
- return false;
190
- }
191
- try {
192
- parsedTemplate = new URL(template);
193
- } catch {
194
- return false;
195
- }
196
-
197
- // Scheme must match
198
- if (parsedUrl.protocol !== parsedTemplate.protocol) return false;
199
-
200
- // Host must match (case-insensitive)
201
- const urlHost =
202
- parsedUrl.hostname.toLowerCase() +
203
- (parsedUrl.port ? `:${parsedUrl.port}` : "");
204
- const templateHost =
205
- parsedTemplate.hostname.toLowerCase() +
206
- (parsedTemplate.port ? `:${parsedTemplate.port}` : "");
207
- if (urlHost !== templateHost) return false;
208
-
209
- // Split paths and compare segment-by-segment.
210
- // safeDecodeSegment is applied to both sides so that percent-encoded bytes
211
- // (e.g. %20, %7B) are compared consistently and so that the URL constructor's
212
- // encoding of curly braces ({, }) in template placeholders is reversed.
213
- // A null return means a malformed escape — fail closed immediately.
214
- const urlSegments = parsedUrl.pathname
215
- .split("/")
216
- .filter((s) => s.length > 0)
217
- .map(safeDecodeSegment);
218
- const templateSegments = parsedTemplate.pathname
219
- .split("/")
220
- .filter((s) => s.length > 0)
221
- .map(safeDecodeSegment);
222
-
223
- if (urlSegments.some((s) => s === null)) return false;
224
- if (templateSegments.some((s) => s === null)) return false;
225
-
226
- if (urlSegments.length !== templateSegments.length) return false;
227
-
228
- for (let i = 0; i < templateSegments.length; i++) {
229
- const tSeg = templateSegments[i]!;
230
- const uSeg = urlSegments[i]!;
231
-
232
- if (tSeg === "{:num}") {
233
- if (!NUMERIC_RE.test(uSeg)) return false;
234
- } else if (tSeg === "{:uuid}") {
235
- if (!UUID_RE.test(uSeg)) return false;
236
- } else if (tSeg === "{:hex}") {
237
- if (!HEX_LONG_RE.test(uSeg)) return false;
238
- } else {
239
- // Literal match (case-sensitive)
240
- if (tSeg !== uSeg) return false;
241
- }
242
- }
243
-
244
- return true;
245
- }
@@ -1,238 +0,0 @@
1
- /**
2
- * HTTP policy evaluation for the Credential Execution Service.
3
- *
4
- * Evaluates incoming HTTP requests against the CES grant stores before any
5
- * outbound network call is made. If no active grant covers the request, the
6
- * policy engine blocks the call and returns an `approval_required` result
7
- * containing the minimal reusable HTTP capability proposal.
8
- *
9
- * Security invariants:
10
- * - **Off-grant requests are blocked before any network call.** The CES must
11
- * never make an authenticated outbound HTTP request without a matching grant.
12
- * - **Proposal derivation never auto-expands.** Proposals use the concrete
13
- * path template (with typed placeholders for dynamic segments), never host
14
- * wildcards or `/*`.
15
- * - **Caller-supplied auth headers are rejected.** The untrusted agent must
16
- * not be able to smuggle raw `Authorization`, `Cookie`, or other auth
17
- * headers in the request — CES injects those from the materialised
18
- * credential.
19
- */
20
-
21
- import { hashProposal, type HttpGrantProposal } from "@vellumai/service-contracts/credential-rpc";
22
-
23
- import type { PersistentGrant, PersistentGrantStore } from "../grants/persistent-store.js";
24
- import type { TemporaryGrantStore } from "../grants/temporary-store.js";
25
- import {
26
- deriveAllowedUrlPatterns,
27
- derivePathTemplate,
28
- urlMatchesTemplate,
29
- } from "./path-template.js";
30
-
31
- // ---------------------------------------------------------------------------
32
- // Auth header rejection
33
- // ---------------------------------------------------------------------------
34
-
35
- /**
36
- * Headers that the untrusted agent is forbidden from setting on credentialed
37
- * requests. CES injects authentication; the caller must not override it.
38
- */
39
- const FORBIDDEN_CALLER_HEADERS = new Set([
40
- "authorization",
41
- "cookie",
42
- "proxy-authorization",
43
- "x-api-key",
44
- "x-auth-token",
45
- ]);
46
-
47
- /**
48
- * Returns the list of forbidden header names present in the caller-supplied
49
- * headers, or an empty array if none are present.
50
- */
51
- export function detectForbiddenHeaders(
52
- headers: Record<string, string> | undefined,
53
- ): string[] {
54
- if (!headers) return [];
55
- const forbidden: string[] = [];
56
- for (const key of Object.keys(headers)) {
57
- if (FORBIDDEN_CALLER_HEADERS.has(key.toLowerCase())) {
58
- forbidden.push(key);
59
- }
60
- }
61
- return forbidden;
62
- }
63
-
64
- // ---------------------------------------------------------------------------
65
- // Policy evaluation result
66
- // ---------------------------------------------------------------------------
67
-
68
- export type PolicyResult =
69
- | { allowed: true; grantId: string; grantSource: "persistent" | "temporary" }
70
- | { allowed: false; reason: "forbidden_headers"; forbiddenHeaders: string[] }
71
- | {
72
- allowed: false;
73
- reason: "approval_required";
74
- proposal: HttpGrantProposal;
75
- };
76
-
77
- // ---------------------------------------------------------------------------
78
- // Policy evaluation request
79
- // ---------------------------------------------------------------------------
80
-
81
- export interface HttpPolicyRequest {
82
- /** CES credential handle identifying which credential to use. */
83
- credentialHandle: string;
84
- /** HTTP method (e.g. "GET", "POST"). */
85
- method: string;
86
- /** Target URL. */
87
- url: string;
88
- /** Caller-supplied headers (before credential injection). */
89
- headers?: Record<string, string>;
90
- /** Human-readable purpose for the audit trail. */
91
- purpose: string;
92
- /** Explicit grant ID the caller claims to hold. */
93
- grantId?: string;
94
- /** Conversation ID for conversation-scoped temporary grants. */
95
- conversationId?: string;
96
- }
97
-
98
- // ---------------------------------------------------------------------------
99
- // Policy evaluator
100
- // ---------------------------------------------------------------------------
101
-
102
- /**
103
- * Evaluate whether an HTTP request is covered by an existing grant.
104
- *
105
- * Evaluation order:
106
- * 1. Reject forbidden caller-supplied auth headers.
107
- * 2. If an explicit `grantId` is provided, look it up in the persistent store.
108
- * 3. Check the persistent grant store for a matching active grant.
109
- * 4. Check the temporary grant store for a matching temporary grant.
110
- * 5. If no grant matches, derive a minimal proposal and return `approval_required`.
111
- */
112
- export function evaluateHttpPolicy(
113
- request: HttpPolicyRequest,
114
- persistentStore: PersistentGrantStore,
115
- temporaryStore: TemporaryGrantStore,
116
- ): PolicyResult {
117
- // 1. Reject forbidden caller-supplied auth headers
118
- const forbidden = detectForbiddenHeaders(request.headers);
119
- if (forbidden.length > 0) {
120
- return {
121
- allowed: false,
122
- reason: "forbidden_headers",
123
- forbiddenHeaders: forbidden,
124
- };
125
- }
126
-
127
- // 2. Check explicit grantId in persistent store
128
- if (request.grantId) {
129
- const grant = persistentStore.getById(request.grantId);
130
- if (
131
- grant &&
132
- grant.tool === "http" &&
133
- grantCoversRequest(grant, request.credentialHandle, request.method, request.url, "")
134
- ) {
135
- return { allowed: true, grantId: grant.id, grantSource: "persistent" };
136
- }
137
- // Explicit grant not found or does not cover this request — fall through to pattern matching
138
- }
139
-
140
- // 3. Check persistent grants for pattern match
141
- const pathTemplate = derivePathTemplate(request.url);
142
- const allGrants = persistentStore.getAll();
143
- for (const grant of allGrants) {
144
- if (
145
- grant.tool === "http" &&
146
- grantCoversRequest(grant, request.credentialHandle, request.method, request.url, pathTemplate)
147
- ) {
148
- return { allowed: true, grantId: grant.id, grantSource: "persistent" };
149
- }
150
- }
151
-
152
- // 4. Check temporary grants
153
- // Build a proposal hash key from the canonical request shape
154
- const proposal = buildProposal(request, pathTemplate);
155
- const proposalHash = hashProposal(proposal);
156
-
157
- const tempKind = temporaryStore.checkAny(
158
- proposalHash,
159
- request.conversationId,
160
- );
161
- if (tempKind) {
162
- return {
163
- allowed: true,
164
- grantId: `temp:${tempKind}:${proposalHash}`,
165
- grantSource: "temporary",
166
- };
167
- }
168
-
169
- // 5. No grant matches — derive proposal
170
- return {
171
- allowed: false,
172
- reason: "approval_required",
173
- proposal,
174
- };
175
- }
176
-
177
- // ---------------------------------------------------------------------------
178
- // Grant matching helpers
179
- // ---------------------------------------------------------------------------
180
-
181
- /**
182
- * Check whether a persistent grant covers a specific HTTP request.
183
- *
184
- * A grant covers a request when:
185
- * - The grant's `pattern` field contains an `allowedUrlPatterns`-style
186
- * entry that matches the request URL's path template.
187
- * - The grant's `scope` field matches the credential handle.
188
- * - The grant is for the `http` tool type.
189
- */
190
- function grantCoversRequest(
191
- grant: PersistentGrant,
192
- credentialHandle: string,
193
- method: string,
194
- rawUrl: string,
195
- _pathTemplate: string,
196
- ): boolean {
197
- // Scope must match the credential handle
198
- if (grant.scope !== credentialHandle) return false;
199
-
200
- // The pattern field encodes "METHOD pattern", e.g. "GET https://api.github.com/repos/{:uuid}/pulls"
201
- // Parse out the method and URL pattern
202
- const spaceIdx = grant.pattern.indexOf(" ");
203
- if (spaceIdx === -1) {
204
- // Pattern without method — match URL only
205
- return urlMatchesTemplate(rawUrl, grant.pattern);
206
- }
207
-
208
- const grantMethod = grant.pattern.slice(0, spaceIdx).toUpperCase();
209
- const grantUrlPattern = grant.pattern.slice(spaceIdx + 1);
210
-
211
- if (grantMethod !== method.toUpperCase()) return false;
212
- return urlMatchesTemplate(rawUrl, grantUrlPattern);
213
- }
214
-
215
- // ---------------------------------------------------------------------------
216
- // Proposal construction
217
- // ---------------------------------------------------------------------------
218
-
219
- /**
220
- * Build the minimal HTTP grant proposal for an unapproved request.
221
- *
222
- * The proposal uses the derived path template as the `allowedUrlPatterns`
223
- * entry — never `/*` or host-level wildcards.
224
- */
225
- function buildProposal(
226
- request: HttpPolicyRequest,
227
- _pathTemplate: string,
228
- ): HttpGrantProposal {
229
- return {
230
- type: "http",
231
- credentialHandle: request.credentialHandle,
232
- method: request.method.toUpperCase(),
233
- url: request.url,
234
- purpose: request.purpose,
235
- allowedUrlPatterns: deriveAllowedUrlPatterns(request.url),
236
- };
237
- }
238
-