@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.
- package/Dockerfile +55 -0
- package/bun.lock +37 -0
- package/package.json +32 -0
- package/src/__tests__/command-executor.test.ts +1333 -0
- package/src/__tests__/command-validator.test.ts +708 -0
- package/src/__tests__/command-workspace.test.ts +997 -0
- package/src/__tests__/grant-store.test.ts +467 -0
- package/src/__tests__/http-executor.test.ts +1251 -0
- package/src/__tests__/http-policy.test.ts +970 -0
- package/src/__tests__/local-materializers.test.ts +826 -0
- package/src/__tests__/managed-materializers.test.ts +961 -0
- package/src/__tests__/toolstore.test.ts +539 -0
- package/src/__tests__/transport.test.ts +388 -0
- package/src/audit/store.ts +188 -0
- package/src/commands/auth-adapters.ts +169 -0
- package/src/commands/executor.ts +840 -0
- package/src/commands/output-scan.ts +157 -0
- package/src/commands/profiles.ts +282 -0
- package/src/commands/validator.ts +438 -0
- package/src/commands/workspace.ts +512 -0
- package/src/grants/index.ts +17 -0
- package/src/grants/persistent-store.ts +247 -0
- package/src/grants/rpc-handlers.ts +269 -0
- package/src/grants/temporary-store.ts +219 -0
- package/src/http/audit.ts +84 -0
- package/src/http/executor.ts +540 -0
- package/src/http/path-template.ts +179 -0
- package/src/http/policy.ts +256 -0
- package/src/http/response-filter.ts +233 -0
- package/src/index.ts +106 -0
- package/src/main.ts +263 -0
- package/src/managed-main.ts +420 -0
- package/src/materializers/local.ts +300 -0
- package/src/materializers/managed-platform.ts +270 -0
- package/src/paths.ts +137 -0
- package/src/server.ts +636 -0
- package/src/subjects/local.ts +177 -0
- package/src/subjects/managed.ts +290 -0
- package/src/toolstore/integrity.ts +94 -0
- package/src/toolstore/manifest.ts +154 -0
- package/src/toolstore/publish.ts +342 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,179 @@
|
|
|
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
|
+
// Segment classification patterns
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* UUID v4 pattern (case-insensitive): 8-4-4-4-12 hex digits with hyphens.
|
|
23
|
+
*/
|
|
24
|
+
const UUID_RE =
|
|
25
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Purely numeric segments (e.g. resource IDs like `/users/42`).
|
|
29
|
+
*/
|
|
30
|
+
const NUMERIC_RE = /^[0-9]+$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Hex-like strings of 16+ characters — commonly used for opaque identifiers,
|
|
34
|
+
* commit SHAs, object IDs, etc. Must be at least 16 chars to avoid matching
|
|
35
|
+
* short, human-meaningful slugs that happen to be hex-only (e.g. "cafe",
|
|
36
|
+
* "dead", "beef").
|
|
37
|
+
*/
|
|
38
|
+
const HEX_LONG_RE = /^[0-9a-f]{16,}$/i;
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Placeholder types
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Replace a path segment with a typed placeholder if it matches a known
|
|
46
|
+
* dynamic pattern. Returns the original segment if it does not match.
|
|
47
|
+
*/
|
|
48
|
+
function classifySegment(segment: string): string {
|
|
49
|
+
if (UUID_RE.test(segment)) return "{:uuid}";
|
|
50
|
+
if (NUMERIC_RE.test(segment)) return "{:num}";
|
|
51
|
+
if (HEX_LONG_RE.test(segment)) return "{:hex}";
|
|
52
|
+
return segment;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Path template derivation
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Derive a deterministic path template from a raw URL.
|
|
61
|
+
*
|
|
62
|
+
* 1. Parse the URL to extract scheme, host, and pathname.
|
|
63
|
+
* 2. Strip query string and fragment.
|
|
64
|
+
* 3. Split the pathname into segments and classify each one.
|
|
65
|
+
* 4. Reassemble into `scheme://host/path/with/{placeholders}`.
|
|
66
|
+
*
|
|
67
|
+
* Throws if `rawUrl` is not a valid absolute URL.
|
|
68
|
+
*/
|
|
69
|
+
export function derivePathTemplate(rawUrl: string): string {
|
|
70
|
+
const parsed = new URL(rawUrl);
|
|
71
|
+
|
|
72
|
+
// Normalise: strip query and fragment, lowercase the host
|
|
73
|
+
const scheme = parsed.protocol.replace(/:$/, "");
|
|
74
|
+
const host = parsed.hostname + (parsed.port ? `:${parsed.port}` : "");
|
|
75
|
+
|
|
76
|
+
// Split path into segments, dropping empty segments from leading/trailing slashes
|
|
77
|
+
const segments = parsed.pathname
|
|
78
|
+
.split("/")
|
|
79
|
+
.filter((s) => s.length > 0);
|
|
80
|
+
|
|
81
|
+
const templatedSegments = segments.map(classifySegment);
|
|
82
|
+
|
|
83
|
+
const path =
|
|
84
|
+
templatedSegments.length > 0
|
|
85
|
+
? "/" + templatedSegments.join("/")
|
|
86
|
+
: "/";
|
|
87
|
+
|
|
88
|
+
return `${scheme}://${host}${path}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Derive the allowed URL pattern for an HTTP grant proposal.
|
|
93
|
+
*
|
|
94
|
+
* Returns an array with a single pattern string — the path template.
|
|
95
|
+
* The caller uses this to populate `allowedUrlPatterns` on the proposal.
|
|
96
|
+
*
|
|
97
|
+
* This is a thin wrapper around `derivePathTemplate` that returns an array
|
|
98
|
+
* for direct use in proposal construction.
|
|
99
|
+
*/
|
|
100
|
+
export function deriveAllowedUrlPatterns(rawUrl: string): string[] {
|
|
101
|
+
return [derivePathTemplate(rawUrl)];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check whether a concrete URL matches a path template pattern.
|
|
106
|
+
*
|
|
107
|
+
* Used during grant evaluation to determine whether a stored
|
|
108
|
+
* `allowedUrlPatterns` entry covers a requested URL.
|
|
109
|
+
*
|
|
110
|
+
* Matching rules:
|
|
111
|
+
* - Scheme and host must match exactly (case-insensitive).
|
|
112
|
+
* - Path segments must match positionally.
|
|
113
|
+
* - A `{:num}` placeholder matches any purely numeric segment.
|
|
114
|
+
* - A `{:uuid}` placeholder matches any UUID v4 segment.
|
|
115
|
+
* - A `{:hex}` placeholder matches any 16+-char hex segment.
|
|
116
|
+
* - Literal segments must match exactly (case-sensitive — URL paths are
|
|
117
|
+
* case-sensitive per RFC 3986).
|
|
118
|
+
*/
|
|
119
|
+
export function urlMatchesTemplate(
|
|
120
|
+
rawUrl: string,
|
|
121
|
+
template: string,
|
|
122
|
+
): boolean {
|
|
123
|
+
let parsedUrl: URL;
|
|
124
|
+
let parsedTemplate: URL;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
parsedUrl = new URL(rawUrl);
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
parsedTemplate = new URL(template);
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Scheme must match
|
|
138
|
+
if (parsedUrl.protocol !== parsedTemplate.protocol) return false;
|
|
139
|
+
|
|
140
|
+
// Host must match (case-insensitive)
|
|
141
|
+
const urlHost =
|
|
142
|
+
parsedUrl.hostname.toLowerCase() +
|
|
143
|
+
(parsedUrl.port ? `:${parsedUrl.port}` : "");
|
|
144
|
+
const templateHost =
|
|
145
|
+
parsedTemplate.hostname.toLowerCase() +
|
|
146
|
+
(parsedTemplate.port ? `:${parsedTemplate.port}` : "");
|
|
147
|
+
if (urlHost !== templateHost) return false;
|
|
148
|
+
|
|
149
|
+
// Split paths and compare segment-by-segment.
|
|
150
|
+
// decodeURIComponent is needed because the URL constructor percent-encodes
|
|
151
|
+
// curly braces ({, }) in paths — placeholders like {:num} become %7B:num%7D.
|
|
152
|
+
const urlSegments = parsedUrl.pathname
|
|
153
|
+
.split("/")
|
|
154
|
+
.filter((s) => s.length > 0);
|
|
155
|
+
const templateSegments = parsedTemplate.pathname
|
|
156
|
+
.split("/")
|
|
157
|
+
.filter((s) => s.length > 0)
|
|
158
|
+
.map((s) => decodeURIComponent(s));
|
|
159
|
+
|
|
160
|
+
if (urlSegments.length !== templateSegments.length) return false;
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < templateSegments.length; i++) {
|
|
163
|
+
const tSeg = templateSegments[i];
|
|
164
|
+
const uSeg = urlSegments[i];
|
|
165
|
+
|
|
166
|
+
if (tSeg === "{:num}") {
|
|
167
|
+
if (!NUMERIC_RE.test(uSeg)) return false;
|
|
168
|
+
} else if (tSeg === "{:uuid}") {
|
|
169
|
+
if (!UUID_RE.test(uSeg)) return false;
|
|
170
|
+
} else if (tSeg === "{:hex}") {
|
|
171
|
+
if (!HEX_LONG_RE.test(uSeg)) return false;
|
|
172
|
+
} else {
|
|
173
|
+
// Literal match (case-sensitive)
|
|
174
|
+
if (tSeg !== uSeg) return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
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 { createHash } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
import type { HttpGrantProposal } from "@vellumai/ces-contracts";
|
|
24
|
+
|
|
25
|
+
import type { PersistentGrant, PersistentGrantStore } from "../grants/persistent-store.js";
|
|
26
|
+
import type { TemporaryGrantStore } from "../grants/temporary-store.js";
|
|
27
|
+
import {
|
|
28
|
+
deriveAllowedUrlPatterns,
|
|
29
|
+
derivePathTemplate,
|
|
30
|
+
urlMatchesTemplate,
|
|
31
|
+
} from "./path-template.js";
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Auth header rejection
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Headers that the untrusted agent is forbidden from setting on credentialed
|
|
39
|
+
* requests. CES injects authentication; the caller must not override it.
|
|
40
|
+
*/
|
|
41
|
+
const FORBIDDEN_CALLER_HEADERS = new Set([
|
|
42
|
+
"authorization",
|
|
43
|
+
"cookie",
|
|
44
|
+
"proxy-authorization",
|
|
45
|
+
"x-api-key",
|
|
46
|
+
"x-auth-token",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns the list of forbidden header names present in the caller-supplied
|
|
51
|
+
* headers, or an empty array if none are present.
|
|
52
|
+
*/
|
|
53
|
+
export function detectForbiddenHeaders(
|
|
54
|
+
headers: Record<string, string> | undefined,
|
|
55
|
+
): string[] {
|
|
56
|
+
if (!headers) return [];
|
|
57
|
+
const forbidden: string[] = [];
|
|
58
|
+
for (const key of Object.keys(headers)) {
|
|
59
|
+
if (FORBIDDEN_CALLER_HEADERS.has(key.toLowerCase())) {
|
|
60
|
+
forbidden.push(key);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return forbidden;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Policy evaluation result
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
export type PolicyResult =
|
|
71
|
+
| { allowed: true; grantId: string; grantSource: "persistent" | "temporary" }
|
|
72
|
+
| { allowed: false; reason: "forbidden_headers"; forbiddenHeaders: string[] }
|
|
73
|
+
| {
|
|
74
|
+
allowed: false;
|
|
75
|
+
reason: "approval_required";
|
|
76
|
+
proposal: HttpGrantProposal;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Policy evaluation request
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export interface HttpPolicyRequest {
|
|
84
|
+
/** CES credential handle identifying which credential to use. */
|
|
85
|
+
credentialHandle: string;
|
|
86
|
+
/** HTTP method (e.g. "GET", "POST"). */
|
|
87
|
+
method: string;
|
|
88
|
+
/** Target URL. */
|
|
89
|
+
url: string;
|
|
90
|
+
/** Caller-supplied headers (before credential injection). */
|
|
91
|
+
headers?: Record<string, string>;
|
|
92
|
+
/** Human-readable purpose for the audit trail. */
|
|
93
|
+
purpose: string;
|
|
94
|
+
/** Explicit grant ID the caller claims to hold. */
|
|
95
|
+
grantId?: string;
|
|
96
|
+
/** Conversation ID for thread-scoped temporary grants. */
|
|
97
|
+
conversationId?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Policy evaluator
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Evaluate whether an HTTP request is covered by an existing grant.
|
|
106
|
+
*
|
|
107
|
+
* Evaluation order:
|
|
108
|
+
* 1. Reject forbidden caller-supplied auth headers.
|
|
109
|
+
* 2. If an explicit `grantId` is provided, look it up in the persistent store.
|
|
110
|
+
* 3. Check the persistent grant store for a matching active grant.
|
|
111
|
+
* 4. Check the temporary grant store for a matching temporary grant.
|
|
112
|
+
* 5. If no grant matches, derive a minimal proposal and return `approval_required`.
|
|
113
|
+
*/
|
|
114
|
+
export function evaluateHttpPolicy(
|
|
115
|
+
request: HttpPolicyRequest,
|
|
116
|
+
persistentStore: PersistentGrantStore,
|
|
117
|
+
temporaryStore: TemporaryGrantStore,
|
|
118
|
+
): PolicyResult {
|
|
119
|
+
// 1. Reject forbidden caller-supplied auth headers
|
|
120
|
+
const forbidden = detectForbiddenHeaders(request.headers);
|
|
121
|
+
if (forbidden.length > 0) {
|
|
122
|
+
return {
|
|
123
|
+
allowed: false,
|
|
124
|
+
reason: "forbidden_headers",
|
|
125
|
+
forbiddenHeaders: forbidden,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 2. Check explicit grantId in persistent store
|
|
130
|
+
if (request.grantId) {
|
|
131
|
+
const grant = persistentStore.getById(request.grantId);
|
|
132
|
+
if (grant) {
|
|
133
|
+
return { allowed: true, grantId: grant.id, grantSource: "persistent" };
|
|
134
|
+
}
|
|
135
|
+
// Explicit grant not found — fall through to pattern matching
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 3. Check persistent grants for pattern match
|
|
139
|
+
const pathTemplate = derivePathTemplate(request.url);
|
|
140
|
+
const allGrants = persistentStore.getAll();
|
|
141
|
+
for (const grant of allGrants) {
|
|
142
|
+
if (
|
|
143
|
+
grant.tool === "http" &&
|
|
144
|
+
grantCoversRequest(grant, request.credentialHandle, request.method, request.url, pathTemplate)
|
|
145
|
+
) {
|
|
146
|
+
return { allowed: true, grantId: grant.id, grantSource: "persistent" };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 4. Check temporary grants
|
|
151
|
+
// Build a proposal hash key from the canonical request shape
|
|
152
|
+
const proposal = buildProposal(request, pathTemplate);
|
|
153
|
+
const proposalHash = hashProposalForLookup(proposal);
|
|
154
|
+
|
|
155
|
+
const tempKind = temporaryStore.checkAny(
|
|
156
|
+
proposalHash,
|
|
157
|
+
request.conversationId,
|
|
158
|
+
);
|
|
159
|
+
if (tempKind) {
|
|
160
|
+
return {
|
|
161
|
+
allowed: true,
|
|
162
|
+
grantId: `temp:${tempKind}:${proposalHash}`,
|
|
163
|
+
grantSource: "temporary",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 5. No grant matches — derive proposal
|
|
168
|
+
return {
|
|
169
|
+
allowed: false,
|
|
170
|
+
reason: "approval_required",
|
|
171
|
+
proposal,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Grant matching helpers
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Check whether a persistent grant covers a specific HTTP request.
|
|
181
|
+
*
|
|
182
|
+
* A grant covers a request when:
|
|
183
|
+
* - The grant's `pattern` field contains an `allowedUrlPatterns`-style
|
|
184
|
+
* entry that matches the request URL's path template.
|
|
185
|
+
* - The grant's `scope` field matches the credential handle.
|
|
186
|
+
* - The grant is for the `http` tool type.
|
|
187
|
+
*/
|
|
188
|
+
function grantCoversRequest(
|
|
189
|
+
grant: PersistentGrant,
|
|
190
|
+
credentialHandle: string,
|
|
191
|
+
method: string,
|
|
192
|
+
rawUrl: string,
|
|
193
|
+
_pathTemplate: string,
|
|
194
|
+
): boolean {
|
|
195
|
+
// Scope must match the credential handle
|
|
196
|
+
if (grant.scope !== credentialHandle) return false;
|
|
197
|
+
|
|
198
|
+
// The pattern field encodes "METHOD pattern", e.g. "GET https://api.github.com/repos/{:uuid}/pulls"
|
|
199
|
+
// Parse out the method and URL pattern
|
|
200
|
+
const spaceIdx = grant.pattern.indexOf(" ");
|
|
201
|
+
if (spaceIdx === -1) {
|
|
202
|
+
// Pattern without method — match URL only
|
|
203
|
+
return urlMatchesTemplate(rawUrl, grant.pattern);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const grantMethod = grant.pattern.slice(0, spaceIdx).toUpperCase();
|
|
207
|
+
const grantUrlPattern = grant.pattern.slice(spaceIdx + 1);
|
|
208
|
+
|
|
209
|
+
if (grantMethod !== method.toUpperCase()) return false;
|
|
210
|
+
return urlMatchesTemplate(rawUrl, grantUrlPattern);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Proposal construction
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Build the minimal HTTP grant proposal for an unapproved request.
|
|
219
|
+
*
|
|
220
|
+
* The proposal uses the derived path template as the `allowedUrlPatterns`
|
|
221
|
+
* entry — never `/*` or host-level wildcards.
|
|
222
|
+
*/
|
|
223
|
+
function buildProposal(
|
|
224
|
+
request: HttpPolicyRequest,
|
|
225
|
+
_pathTemplate: string,
|
|
226
|
+
): HttpGrantProposal {
|
|
227
|
+
return {
|
|
228
|
+
type: "http",
|
|
229
|
+
credentialHandle: request.credentialHandle,
|
|
230
|
+
method: request.method.toUpperCase(),
|
|
231
|
+
url: request.url,
|
|
232
|
+
purpose: request.purpose,
|
|
233
|
+
allowedUrlPatterns: deriveAllowedUrlPatterns(request.url),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Compute a deterministic hash key for temporary grant lookup.
|
|
239
|
+
*
|
|
240
|
+
* Uses the same canonical JSON → SHA-256 approach as the contracts package
|
|
241
|
+
* `hashProposal`, but we use a simplified inline version here to avoid
|
|
242
|
+
* importing the full contracts rendering module into the policy hot path.
|
|
243
|
+
*/
|
|
244
|
+
function hashProposalForLookup(proposal: HttpGrantProposal): string {
|
|
245
|
+
// Build a canonical key from the proposal's stable fields:
|
|
246
|
+
// type + credentialHandle + method + allowedUrlPatterns (sorted)
|
|
247
|
+
const parts = [
|
|
248
|
+
proposal.type,
|
|
249
|
+
proposal.credentialHandle,
|
|
250
|
+
proposal.method.toUpperCase(),
|
|
251
|
+
...(proposal.allowedUrlPatterns ?? []).slice().sort(),
|
|
252
|
+
];
|
|
253
|
+
const canonical = JSON.stringify(parts);
|
|
254
|
+
|
|
255
|
+
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
256
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP response filtering for the Credential Execution Service.
|
|
3
|
+
*
|
|
4
|
+
* Sanitises raw HTTP responses before returning them to the untrusted
|
|
5
|
+
* assistant runtime. The assistant must never receive:
|
|
6
|
+
* - Raw auth-bearing response headers (e.g. `set-cookie`, `www-authenticate`)
|
|
7
|
+
* - Echoed secret values in response bodies (defense-in-depth scrubbing)
|
|
8
|
+
* - Unbounded response bodies that could exhaust memory
|
|
9
|
+
*
|
|
10
|
+
* The filter also produces a token-free audit summary of every HTTP
|
|
11
|
+
* interaction for the CES audit log.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Configuration
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Maximum response body size returned to the assistant (256 KB).
|
|
20
|
+
*
|
|
21
|
+
* Responses larger than this are truncated with a suffix indicating
|
|
22
|
+
* the original size. The full body is never stored — this is a hard
|
|
23
|
+
* clamp, not a soft limit.
|
|
24
|
+
*/
|
|
25
|
+
const MAX_BODY_BYTES = 256 * 1024;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Response headers that are safe to pass through to the assistant.
|
|
29
|
+
*
|
|
30
|
+
* Only these headers are included in the sanitised response.
|
|
31
|
+
* Everything else is stripped — especially `set-cookie`,
|
|
32
|
+
* `www-authenticate`, and any custom auth headers.
|
|
33
|
+
*/
|
|
34
|
+
const ALLOWED_RESPONSE_HEADERS = new Set([
|
|
35
|
+
"content-type",
|
|
36
|
+
"content-length",
|
|
37
|
+
"content-encoding",
|
|
38
|
+
"content-language",
|
|
39
|
+
"content-disposition",
|
|
40
|
+
"cache-control",
|
|
41
|
+
"etag",
|
|
42
|
+
"last-modified",
|
|
43
|
+
"date",
|
|
44
|
+
"x-request-id",
|
|
45
|
+
"x-ratelimit-limit",
|
|
46
|
+
"x-ratelimit-remaining",
|
|
47
|
+
"x-ratelimit-reset",
|
|
48
|
+
"retry-after",
|
|
49
|
+
"link",
|
|
50
|
+
"location",
|
|
51
|
+
"vary",
|
|
52
|
+
"accept-ranges",
|
|
53
|
+
"access-control-allow-origin",
|
|
54
|
+
"access-control-allow-methods",
|
|
55
|
+
"access-control-allow-headers",
|
|
56
|
+
"access-control-expose-headers",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Types
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** Raw HTTP response from the outbound call. */
|
|
64
|
+
export interface RawHttpResponse {
|
|
65
|
+
/** HTTP status code. */
|
|
66
|
+
statusCode: number;
|
|
67
|
+
/** Raw response headers (key-value pairs, header names may be mixed case). */
|
|
68
|
+
headers: Record<string, string>;
|
|
69
|
+
/** Response body as a string. */
|
|
70
|
+
body: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Sanitised HTTP response safe for the assistant runtime. */
|
|
74
|
+
export interface SanitisedHttpResponse {
|
|
75
|
+
/** HTTP status code (passed through). */
|
|
76
|
+
statusCode: number;
|
|
77
|
+
/** Whitelisted response headers (lowercased keys). */
|
|
78
|
+
headers: Record<string, string>;
|
|
79
|
+
/** Body clamped to MAX_BODY_BYTES with secrets scrubbed. */
|
|
80
|
+
body: string;
|
|
81
|
+
/** Whether the body was truncated. */
|
|
82
|
+
truncated: boolean;
|
|
83
|
+
/** Original body size in bytes (before truncation). */
|
|
84
|
+
originalBodyBytes: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Header filtering
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Filter response headers to only include whitelisted safe headers.
|
|
93
|
+
*
|
|
94
|
+
* All header names are lowercased for consistent comparison.
|
|
95
|
+
*/
|
|
96
|
+
export function filterResponseHeaders(
|
|
97
|
+
headers: Record<string, string>,
|
|
98
|
+
): Record<string, string> {
|
|
99
|
+
const filtered: Record<string, string> = {};
|
|
100
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
101
|
+
if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
|
|
102
|
+
filtered[key.toLowerCase()] = value;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return filtered;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Body clamping
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Clamp the response body to the maximum allowed size.
|
|
114
|
+
*
|
|
115
|
+
* Returns the (possibly truncated) body and metadata about truncation.
|
|
116
|
+
*/
|
|
117
|
+
export function clampBody(body: string): {
|
|
118
|
+
clampedBody: string;
|
|
119
|
+
truncated: boolean;
|
|
120
|
+
originalBytes: number;
|
|
121
|
+
} {
|
|
122
|
+
const bodyBytes = Buffer.byteLength(body, "utf-8");
|
|
123
|
+
|
|
124
|
+
if (bodyBytes <= MAX_BODY_BYTES) {
|
|
125
|
+
return {
|
|
126
|
+
clampedBody: body,
|
|
127
|
+
truncated: false,
|
|
128
|
+
originalBytes: bodyBytes,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Truncate to MAX_BODY_BYTES. We use Buffer to handle multi-byte characters
|
|
133
|
+
// correctly — slice at byte boundaries and convert back to string.
|
|
134
|
+
const buf = Buffer.from(body, "utf-8");
|
|
135
|
+
const truncatedBuf = buf.subarray(0, MAX_BODY_BYTES);
|
|
136
|
+
|
|
137
|
+
// Decode back to string; incomplete multi-byte sequences at the end are
|
|
138
|
+
// replaced with the Unicode replacement character, which is acceptable
|
|
139
|
+
// for a truncated preview.
|
|
140
|
+
const truncatedBody = truncatedBuf.toString("utf-8");
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
clampedBody:
|
|
144
|
+
truncatedBody +
|
|
145
|
+
`\n\n[CES: Response truncated from ${bodyBytes} bytes to ${MAX_BODY_BYTES} bytes]`,
|
|
146
|
+
truncated: true,
|
|
147
|
+
originalBytes: bodyBytes,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Secret scrubbing (defense-in-depth)
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Scrub exact occurrences of known secret values from a response body.
|
|
157
|
+
*
|
|
158
|
+
* This is a defense-in-depth measure for APIs that echo back auth tokens
|
|
159
|
+
* or API keys in their response bodies. The scrubbing replaces exact
|
|
160
|
+
* matches of the secret with a redacted placeholder.
|
|
161
|
+
*
|
|
162
|
+
* Limitations:
|
|
163
|
+
* - Only scrubs exact matches (no partial or encoded variants).
|
|
164
|
+
* - Short secrets (< 8 characters) are skipped to avoid false positives.
|
|
165
|
+
* - This is NOT a primary security control — the grant system and
|
|
166
|
+
* credential isolation are the real boundaries.
|
|
167
|
+
*/
|
|
168
|
+
export function scrubSecrets(body: string, secrets: string[]): string {
|
|
169
|
+
let result = body;
|
|
170
|
+
for (const secret of secrets) {
|
|
171
|
+
// Skip short secrets to avoid false positives with common substrings
|
|
172
|
+
if (secret.length < 8) continue;
|
|
173
|
+
// Use a simple global replace — secrets are treated as literal strings
|
|
174
|
+
result = replaceAll(result, secret, "[CES:REDACTED]");
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Replace all occurrences of `search` in `str` with `replacement`.
|
|
181
|
+
*
|
|
182
|
+
* Uses a simple loop to avoid regex special-character escaping issues
|
|
183
|
+
* with secret values that may contain regex metacharacters.
|
|
184
|
+
*/
|
|
185
|
+
function replaceAll(str: string, search: string, replacement: string): string {
|
|
186
|
+
if (search.length === 0) return str;
|
|
187
|
+
|
|
188
|
+
let result = "";
|
|
189
|
+
let idx = 0;
|
|
190
|
+
while (idx < str.length) {
|
|
191
|
+
const foundAt = str.indexOf(search, idx);
|
|
192
|
+
if (foundAt === -1) {
|
|
193
|
+
result += str.slice(idx);
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
result += str.slice(idx, foundAt) + replacement;
|
|
197
|
+
idx = foundAt + search.length;
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Full response filter
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Apply the full sanitisation pipeline to a raw HTTP response.
|
|
208
|
+
*
|
|
209
|
+
* Pipeline:
|
|
210
|
+
* 1. Filter response headers to the whitelist.
|
|
211
|
+
* 2. Clamp the body to MAX_BODY_BYTES.
|
|
212
|
+
* 3. Scrub known secrets from the (already clamped) body.
|
|
213
|
+
*
|
|
214
|
+
* @param raw - The raw HTTP response from the outbound call.
|
|
215
|
+
* @param secrets - Known secret values to scrub from the body.
|
|
216
|
+
* @returns Sanitised response safe for the assistant runtime.
|
|
217
|
+
*/
|
|
218
|
+
export function filterHttpResponse(
|
|
219
|
+
raw: RawHttpResponse,
|
|
220
|
+
secrets: string[] = [],
|
|
221
|
+
): SanitisedHttpResponse {
|
|
222
|
+
const filteredHeaders = filterResponseHeaders(raw.headers);
|
|
223
|
+
const { clampedBody, truncated, originalBytes } = clampBody(raw.body);
|
|
224
|
+
const scrubbedBody = scrubSecrets(clampedBody, secrets);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
statusCode: raw.statusCode,
|
|
228
|
+
headers: filteredHeaders,
|
|
229
|
+
body: scrubbedBody,
|
|
230
|
+
truncated,
|
|
231
|
+
originalBodyBytes: originalBytes,
|
|
232
|
+
};
|
|
233
|
+
}
|