mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
- LocalMemoryRecallTool: 跨会话本地笔记召回,权限门控,大小限制 - VaultHttpFetchTool: 使用 vault 密钥的认证 HTTP 请求,ACL 规则 - agentToolFilter: 子 agent 工具继承过滤层 - ALL_AGENT_DISALLOWED_TOOLS 白名单更新 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
416 lines
15 KiB
TypeScript
416 lines
15 KiB
TypeScript
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<typeof inputSchema>
|
|
type Input = z.infer<InputSchema>
|
|
|
|
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<typeof outputSchema>
|
|
export type Output = z.infer<OutputSchema>
|
|
|
|
// ── 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: `<key>@<host>`. 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: `<key>@*`.
|
|
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<string, string> = {
|
|
'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<InputSchema, Output>)
|