import axios from 'axios' import { z } from 'zod/v4' import { getSecret } from 'src/services/localVault/store.js' import { buildTool, type ToolDef } from 'src/Tool.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { getWebFetchUserAgent } from 'src/utils/http.js' import { isValidKey } from 'src/utils/localValidate.js' import { lazySchema } from 'src/utils/lazySchema.js' import { getRuleByContentsForToolName } from 'src/utils/permissions/permissions.js' import { jsonStringify } from 'src/utils/slowOperations.js' import { REQUEST_TIMEOUT_MS, RESPONSE_BODY_CAP_BYTES, VAULT_HTTP_FETCH_TOOL_NAME, } from './constants.js' import { DESCRIPTION, PROMPT } from './prompt.js' import { buildDerivedSecretForms, scrubAllSecretForms, scrubAxiosError, scrubResponseHeaders, truncateToBytes, } from './scrub.js' import { renderToolResultMessage, renderToolUseMessage } from './UI.js' // ── Schemas ────────────────────────────────────────────────────────────────── const inputSchema = lazySchema(() => z.strictObject({ url: z .string() .describe('Target URL. Must be https://. Other schemes rejected.'), method: z .enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) .default('GET') .describe('HTTP method'), vault_auth_key: z .string() .min(1) .max(128) .describe( 'Vault key NAME (not the secret value). Per-key allow required.', ), auth_scheme: z .enum(['bearer', 'basic', 'header_x_api_key', 'custom']) .default('bearer') .describe( "How to inject the secret: bearer = 'Authorization: Bearer X'; " + "basic = 'Authorization: Basic base64(X)'; header_x_api_key = 'X-Api-Key: X'; " + 'custom = use auth_header_name with raw secret value.', ), // H5 fix: enforce HTTP header name character set. Without this regex, // a model-supplied value containing CR/LF could inject additional // headers via header[name]=secret assignment in axios. auth_header_name: z .string() .regex(/^[A-Za-z0-9_-]{1,64}$/) .optional() .describe( 'When auth_scheme=custom, the HTTP header name for the secret value. Must match [A-Za-z0-9_-]{1,64}.', ), body: z .string() .max(RESPONSE_BODY_CAP_BYTES) .optional() .describe('Request body'), body_content_type: z .string() .max(128) .optional() .describe( 'Content-Type for the request body. Defaults to application/json.', ), reason: z .string() .min(1) .max(500) .describe( 'Why you need this. Appears in the user permission prompt and audit log.', ), }), ) type InputSchema = ReturnType type Input = z.infer const outputSchema = lazySchema(() => z.object({ status: z.number().optional(), statusText: z.string().optional(), responseHeaders: z.record(z.string(), z.string()).optional(), body: z.string().optional(), error: z.string().optional(), }), ) type OutputSchema = ReturnType export type Output = z.infer // ── Helpers ────────────────────────────────────────────────────────────────── function isHttps(url: string): boolean { try { return new URL(url).protocol === 'https:' } catch { return false } } /** Hash a key name for audit logging (avoid logging the raw key name in case * it's something semi-sensitive like 'github-personal-prod'). */ function hashKey(key: string): string { // Cheap fnv-1a, 8-hex-digit output. Not crypto, just to obfuscate the // key name in analytics event payloads. let h = 0x811c9dc5 for (let i = 0; i < key.length; i++) { h ^= key.charCodeAt(i) h = Math.imul(h, 0x01000193) >>> 0 } return h.toString(16).padStart(8, '0') } // ── Tool ───────────────────────────────────────────────────────────────────── export const VaultHttpFetchTool = buildTool({ name: VAULT_HTTP_FETCH_TOOL_NAME, searchHint: 'authenticated HTTPS request using a vault-stored secret', // Response cap matches axios maxContentLength; toolResultStorage will spill // anything larger to a file ref. maxResultSizeChars: RESPONSE_BODY_CAP_BYTES, // Vault tools are NOT concurrency safe — multiple parallel fetches racing // on the same vault keychain access can produce inconsistent passphrase // unlocks under unusual filesystems. isConcurrencySafe() { return false }, // Has side effects (network), but does not modify local state. isReadOnly() { return false }, toAutoClassifierInput(input) { const method = input.method ?? 'GET' const url = input.url ?? '' return `${method} ${url}` }, // Bypass-immune: requiresUserInteraction()=true paired with // checkPermissions: 'ask' (when no per-key allow rule exists) ensures // even mode=bypassPermissions still routes to the user prompt. requiresUserInteraction() { return true }, userFacingName: () => 'Vault HTTP', async description() { return DESCRIPTION }, async prompt() { return PROMPT }, get inputSchema(): InputSchema { return inputSchema() }, get outputSchema(): OutputSchema { return outputSchema() }, async checkPermissions(input, context) { // Validate vault key name shape early — surface clear error. if (!isValidKey(input.vault_auth_key)) { return { behavior: 'deny', message: `Invalid vault_auth_key '${input.vault_auth_key}'`, decisionReason: { type: 'other', reason: 'invalid_key' }, } } // Enforce HTTPS at permission time so denied schemes never reach call(). if (!isHttps(input.url)) { return { behavior: 'deny', message: `Only https:// URLs are allowed (got: ${input.url})`, decisionReason: { type: 'other', reason: 'non_https_url' }, } } // auth_scheme=custom requires auth_header_name. if (input.auth_scheme === 'custom' && !input.auth_header_name) { return { behavior: 'deny', message: 'auth_scheme=custom requires auth_header_name', decisionReason: { type: 'other', reason: 'missing_required_field' }, } } const appState = context.getAppState() const permissionContext = appState.toolPermissionContext // C1 fix: ACL ruleContent binds vault_auth_key AND target host. A // persistent allow for `github-token` can no longer be used to send // that secret to a different origin — the model would have to ask // again for each new host. Format: `@`. Hosts are taken // from URL parsing and lowercased; the empty-host case is unreachable // (HTTPS guard above already accepted the URL). // // M2 fix (codecov-100 audit #5): the `host` property of `URL` includes // the port suffix when present (e.g. `api.example.com:8080`) and // wraps IPv6 literals in square brackets (e.g. `[::1]:8080`). Both are // preserved verbatim in the rule content. Two consequences worth // documenting: // // 1. PORTS ARE PART OF THE PERMISSION SCOPE. An allow rule for // `mykey@api.example.com:8080` does NOT also allow // `api.example.com:8443` — these are distinct origins per the // RFC 6454 same-origin rule, and we deliberately mirror that // so a model cannot pivot from a sanctioned admin port to a // different one without re-asking. // // 2. IPv6 BRACKET ROUND-TRIP. `new URL('https://[::1]:8080/').host` // returns `[::1]:8080` (with brackets). The `permissionRule` // validator in src/utils/settings/permissionValidation.ts is // configured to accept `[A-Fa-f0-9:]+` *inside brackets* and // allows `:port` after, so the rule round-trips. If the // validator regex is ever tightened, update this code path to // strip the brackets before composing the rule. const targetHost = new URL(input.url).host.toLowerCase() const ruleContent = `${input.vault_auth_key}@${targetHost}` // Also offer a wildcard rule that allows any host for a given key — // used only when the user explicitly grants it, e.g. via the prompt // UI's "any host" option (not yet wired). Format: `@*`. const wildcardRuleContent = `${input.vault_auth_key}@*` const denyMap = getRuleByContentsForToolName( permissionContext, VAULT_HTTP_FETCH_TOOL_NAME, 'deny', ) const denyRule = denyMap.get(ruleContent) ?? denyMap.get(wildcardRuleContent) if (denyRule) { return { behavior: 'deny', message: `Denied by rule: VaultHttpFetch(${denyRule.ruleValue.ruleContent ?? ruleContent})`, decisionReason: { type: 'rule', rule: denyRule }, } } const allowMap = getRuleByContentsForToolName( permissionContext, VAULT_HTTP_FETCH_TOOL_NAME, 'allow', ) const allowRule = allowMap.get(ruleContent) ?? allowMap.get(wildcardRuleContent) if (allowRule) { return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'rule', rule: allowRule }, } } // No rule -> ask. Combined with requiresUserInteraction()=true above, // bypassPermissions mode also routes here. return { behavior: 'ask', message: `Allow VaultHttpFetch using key '${input.vault_auth_key}' to ${input.method ?? 'GET'} ${input.url} (host: ${targetHost})? Reason: ${input.reason}`, decisionReason: { type: 'other', reason: 'no_persistent_allow_for_key_host_pair', }, } }, async call(input: Input, _context) { // Defensive: enforce HTTPS at runtime (checkPermissions also enforces). if (!isHttps(input.url)) { return { data: { error: 'Only https:// URLs allowed' } } } // Retrieve secret. In-memory only; never assigned to any output field. let secret: string | null try { secret = await getSecret(input.vault_auth_key) } catch (e) { void e // H7 fix: use AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS // pattern (per fork convention in src/bridge/bridgeMain.ts) to attest // the string field is safe. The hash field is non-string already. logEvent('vault_http_fetch_lookup_failed', { key_hash: hashKey( input.vault_auth_key, ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return { data: { error: 'Vault unlock failed' } } } if (!secret) { return { data: { error: `Vault key '${input.vault_auth_key}' not found`, }, } } // Build all forms of the secret that might leak so scrub catches them. const forms = buildDerivedSecretForms(secret) // Build request headers. const headers: Record = { 'User-Agent': getWebFetchUserAgent(), } // L3 fix: schema's `.default('bearer')` already injects bearer when the // field is undefined, so the `?? 'bearer'` fallback was dead code. // L5 fix: exhaustive switch via `never` assignment in default. const scheme = input.auth_scheme switch (scheme) { case 'bearer': headers['Authorization'] = `Bearer ${secret}` break case 'basic': headers['Authorization'] = `Basic ${Buffer.from(secret, 'utf8').toString('base64')}` break case 'header_x_api_key': headers['X-Api-Key'] = secret break case 'custom': // M3 fix: explicit guard rather than `as string`. checkPermissions // enforces this in production but the guard keeps the type system // honest if the permission pipeline ever changes. if (!input.auth_header_name) { return { data: { error: 'auth_scheme=custom requires auth_header_name' }, } } headers[input.auth_header_name] = secret break default: { // L5 fix: exhaustive guard — adding a new auth_scheme without // updating this switch becomes a compile-time error. const _exhaustive: never = scheme void _exhaustive return { data: { error: 'Unknown auth_scheme' } } } } if (input.body !== undefined) { headers['Content-Type'] = input.body_content_type ?? 'application/json' } // Audit log: record action + key hash + reason. Never log secret value. // M1 fix: scrub reason_first_80 (model-supplied free text could include // a secret-like string). H7 fix: use the project's per-field // AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS attestation // pattern instead of `as never` whole-object cast. logEvent('vault_http_fetch', { key_hash: hashKey( input.vault_auth_key, ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, method: scheme as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, url_safe: scrubAllSecretForms( input.url, forms, ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, reason_first_80: scrubAllSecretForms( truncateToBytes(input.reason, 80), forms, ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) try { const resp = await axios.request({ url: input.url, method: input.method, headers, data: input.body, timeout: REQUEST_TIMEOUT_MS, maxContentLength: RESPONSE_BODY_CAP_BYTES, // No redirects: a 30x to a different origin would re-send Authorization // unless we strip it — and stripping is fragile. Refuse to follow. maxRedirects: 0, // Don't throw on 4xx/5xx; the body still needs scrubbing in those // success-path responses. validateStatus: () => true, // Avoid axios trying to transform / parse JSON; we want to scrub the // raw body first. transformResponse: [(data: unknown) => data], responseType: 'text', }) // Body might be a Buffer when Content-Type is binary; coerce safely. const rawBody = typeof resp.data === 'string' ? resp.data : resp.data == null ? '' : String(resp.data) return { data: { status: resp.status, statusText: resp.statusText, responseHeaders: scrubResponseHeaders(resp.headers, forms), body: scrubAllSecretForms(rawBody, forms), }, } } catch (e) { return { data: { error: scrubAxiosError(e, forms) } } } }, renderToolUseMessage, renderToolResultMessage, mapToolResultToToolResultBlockParam(output, toolUseID) { return { type: 'tool_result', tool_use_id: toolUseID, content: jsonStringify(output), is_error: output.error !== undefined, } }, } satisfies ToolDef)