mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
feat: 添加 LocalMemoryRecallTool 和 VaultHttpFetchTool
- LocalMemoryRecallTool: 跨会话本地笔记召回,权限门控,大小限制 - VaultHttpFetchTool: 使用 vault 密钥的认证 HTTP 请求,ACL 规则 - agentToolFilter: 子 agent 工具继承过滤层 - ALL_AGENT_DISALLOWED_TOOLS 白名单更新 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
48
packages/builtin-tools/src/tools/VaultHttpFetchTool/UI.tsx
Normal file
48
packages/builtin-tools/src/tools/VaultHttpFetchTool/UI.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { MessageResponse } from 'src/components/MessageResponse.js';
|
||||
import { OutputLine } from 'src/components/shell/OutputLine.js';
|
||||
import type { ToolProgressData } from 'src/Tool.js';
|
||||
import type { ProgressMessage } from 'src/types/message.js';
|
||||
import { jsonStringify } from 'src/utils/slowOperations.js';
|
||||
import type { Output } from './VaultHttpFetchTool.js';
|
||||
|
||||
// H6 fix: second `options` parameter matches Tool interface contract.
|
||||
export function renderToolUseMessage(
|
||||
input: Partial<{
|
||||
method?: string;
|
||||
url?: string;
|
||||
vault_auth_key?: string;
|
||||
}>,
|
||||
_options: {
|
||||
theme?: unknown;
|
||||
verbose?: boolean;
|
||||
commands?: unknown;
|
||||
} = {},
|
||||
): React.ReactNode {
|
||||
void _options;
|
||||
const method = input.method ?? 'GET';
|
||||
const key = input.vault_auth_key ?? '?';
|
||||
const url = input.url ?? '';
|
||||
// Show key NAME (already required to be non-secret); no secret value involved.
|
||||
return `${method} ${url} (vault: ${key})`;
|
||||
}
|
||||
|
||||
export function renderToolResultMessage(
|
||||
output: Output,
|
||||
_progressMessagesForMessage: ProgressMessage<ToolProgressData>[],
|
||||
{ verbose }: { verbose: boolean },
|
||||
): React.ReactNode {
|
||||
if (output.error) {
|
||||
return (
|
||||
<MessageResponse height={1}>
|
||||
<Text color="error">VaultHttpFetch: {output.error}</Text>
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
// Body has already been scrubbed of secret forms before reaching here;
|
||||
// safe to display.
|
||||
// eslint-disable-next-line no-restricted-syntax -- human-facing UI, not tool_result
|
||||
const formatted = jsonStringify(output, null, 2);
|
||||
return <OutputLine content={formatted} verbose={verbose} />;
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
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>)
|
||||
@@ -0,0 +1,980 @@
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { setupAxiosMock } from '../../../../../../tests/mocks/axios'
|
||||
|
||||
// After this suite finishes, switch our getSecret override off so localVault's
|
||||
// own store.test.ts (running in the same process) sees the real impl. Also
|
||||
// flip the axios stub flag off so the spread mock falls through to real axios
|
||||
// for any test file that runs after this one.
|
||||
afterAll(() => {
|
||||
useMockForGetSecret = false
|
||||
getSecretShouldThrow = false
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
axiosHandle.useStubs = true
|
||||
})
|
||||
|
||||
// We mock the LOWER layers (axios + localVault store + http util) rather
|
||||
// than the tool itself, per memory feedback "Mock dependency not subject".
|
||||
|
||||
type AxiosRespLike = {
|
||||
status: number
|
||||
statusText: string
|
||||
headers: Record<string, string | string[]>
|
||||
data: string
|
||||
}
|
||||
|
||||
const mockAxiosRequest = mock(
|
||||
async (): Promise<AxiosRespLike> => ({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: '{"ok":true}',
|
||||
}),
|
||||
)
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.request = mockAxiosRequest
|
||||
|
||||
let mockedSecret: string | null = 'XSECRETXX'
|
||||
let getSecretShouldThrow = false
|
||||
// Sentinel: when true our tests use the per-test override; when false we
|
||||
// delegate getSecret to the real impl so other test files (localVault's own
|
||||
// store.test.ts) see real round-trip behavior.
|
||||
let useMockForGetSecret = true
|
||||
// Pre-import real store BEFORE mock.module is called so we keep references
|
||||
// to real setSecret / deleteSecret / listKeys / maskSecret / error classes
|
||||
// for delegation.
|
||||
const realStore = await import('src/services/localVault/store.js')
|
||||
mock.module('src/services/localVault/store.js', () => ({
|
||||
...realStore,
|
||||
getSecret: async (key: string) => {
|
||||
if (getSecretShouldThrow) {
|
||||
throw new Error('vault unlock failed (mocked)')
|
||||
}
|
||||
if (useMockForGetSecret) return mockedSecret
|
||||
return realStore.getSecret(key)
|
||||
},
|
||||
}))
|
||||
|
||||
// MACRO is a Bun build-time define injected at compile time. In bun:test
|
||||
// it doesn't exist, so any code path that references it crashes. Inject a
|
||||
// minimal MACRO object before any module under test imports
|
||||
// src/utils/userAgent.ts (which references MACRO.VERSION).
|
||||
;(globalThis as unknown as { MACRO: { VERSION: string } }).MACRO = {
|
||||
VERSION: '0.0.0-test',
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
import { mockToolContext } from '../../../../../../tests/mocks/toolContext.js'
|
||||
function mockContext() {
|
||||
return mockToolContext()
|
||||
}
|
||||
|
||||
function makeAxiosResp(opts: {
|
||||
status?: number
|
||||
data?: string
|
||||
headers?: Record<string, string | string[]>
|
||||
}) {
|
||||
return {
|
||||
status: opts.status ?? 200,
|
||||
statusText: 'STATUS',
|
||||
headers: opts.headers ?? {},
|
||||
data: opts.data ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('VaultHttpFetchTool: schema + checkPermissions', () => {
|
||||
beforeEach(() => {
|
||||
mockAxiosRequest.mockClear()
|
||||
mockedSecret = 'XSECRETXX'
|
||||
})
|
||||
|
||||
test('AC10: HTTP (non-https) URL is rejected at checkPermissions', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.checkPermissions!(
|
||||
{
|
||||
url: 'http://insecure.example.com/api',
|
||||
method: 'GET',
|
||||
vault_auth_key: 'k',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
expect(result.behavior).toBe('deny')
|
||||
if (result.behavior === 'deny') {
|
||||
expect(result.message).toMatch(/https:\/\//)
|
||||
}
|
||||
})
|
||||
|
||||
test('AC11: file:// is rejected', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.checkPermissions!(
|
||||
{
|
||||
url: 'file:///etc/passwd',
|
||||
method: 'GET',
|
||||
vault_auth_key: 'k',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
expect(result.behavior).toBe('deny')
|
||||
})
|
||||
|
||||
test('AC2: no allow rule → ask (not allow)', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.checkPermissions!(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
vault_auth_key: 'gh',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'fetch repo',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
expect(result.behavior).toBe('ask')
|
||||
})
|
||||
|
||||
test('invalid vault key (path-traversal-like) → deny', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.checkPermissions!(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
vault_auth_key: '../etc',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
expect(result.behavior).toBe('deny')
|
||||
})
|
||||
|
||||
test('auth_scheme=custom requires auth_header_name', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.checkPermissions!(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
vault_auth_key: 'k',
|
||||
auth_scheme: 'custom',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
expect(result.behavior).toBe('deny')
|
||||
if (result.behavior === 'deny') {
|
||||
expect(result.message).toMatch(/auth_header_name/)
|
||||
}
|
||||
})
|
||||
|
||||
test('Tool definition: requiresUserInteraction = true (bypass-immune)', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
expect(VaultHttpFetchTool.requiresUserInteraction!()).toBe(true)
|
||||
})
|
||||
|
||||
test('Tool definition: isConcurrencySafe = false', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
expect(VaultHttpFetchTool.isConcurrencySafe!()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('VaultHttpFetchTool: call() — secret leak prevention', () => {
|
||||
beforeEach(() => {
|
||||
mockAxiosRequest.mockClear()
|
||||
mockedSecret = 'XSECRETXX'
|
||||
})
|
||||
|
||||
test('AC4: secret never appears in returned data (Bearer scheme)', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
mockAxiosRequest.mockImplementation(async () =>
|
||||
makeAxiosResp({ data: '{"hello":"world"}' }),
|
||||
)
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
vault_auth_key: 'gh',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
const json = JSON.stringify(result.data)
|
||||
expect(json).not.toContain('XSECRETXX')
|
||||
expect(json).not.toContain('Bearer XSECRETXX')
|
||||
})
|
||||
|
||||
test('AC14: secret echoed in 4xx response body is scrubbed', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
// Server returns 401 + body that echoes the auth header
|
||||
mockAxiosRequest.mockImplementation(async () =>
|
||||
makeAxiosResp({
|
||||
status: 401,
|
||||
data: 'Unauthorized: provided "Bearer XSECRETXX" is invalid',
|
||||
}),
|
||||
)
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'POST',
|
||||
vault_auth_key: 'gh',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
expect(result.data.body).toBeDefined()
|
||||
expect(result.data.body).not.toContain('XSECRETXX')
|
||||
expect(result.data.body).toContain('[REDACTED]')
|
||||
// status preserved (4xx not in catch branch)
|
||||
expect(result.data.status).toBe(401)
|
||||
})
|
||||
|
||||
test('AC15: secret echoed in 200 response body is scrubbed', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
mockAxiosRequest.mockImplementation(async () =>
|
||||
makeAxiosResp({
|
||||
status: 200,
|
||||
data: '{"echo":"Bearer XSECRETXX","ok":true}',
|
||||
}),
|
||||
)
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'POST',
|
||||
vault_auth_key: 'gh',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
expect(result.data.body).not.toContain('XSECRETXX')
|
||||
expect(result.data.body).toContain('[REDACTED]')
|
||||
})
|
||||
|
||||
test('AC16: all derived secret forms scrubbed (raw / Bearer / base64 / Basic)', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const b64 = Buffer.from('XSECRETXX', 'utf8').toString('base64')
|
||||
mockAxiosRequest.mockImplementation(async () =>
|
||||
makeAxiosResp({
|
||||
data: `raw=XSECRETXX bearer=Bearer XSECRETXX b64=${b64} basic=Basic ${b64}`,
|
||||
}),
|
||||
)
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
vault_auth_key: 'gh',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
expect(result.data.body).not.toContain('XSECRETXX')
|
||||
expect(result.data.body).not.toContain(b64)
|
||||
})
|
||||
|
||||
test('AC9: response Authorization echo header is redacted by NAME', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
mockAxiosRequest.mockImplementation(async () =>
|
||||
makeAxiosResp({
|
||||
data: 'ok',
|
||||
headers: {
|
||||
authorization: 'Bearer XSECRETXX',
|
||||
'content-type': 'text/plain',
|
||||
},
|
||||
}),
|
||||
)
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
vault_auth_key: 'gh',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
expect(result.data.responseHeaders!['authorization']).toBe('[REDACTED]')
|
||||
expect(result.data.responseHeaders!['content-type']).toBe('text/plain')
|
||||
})
|
||||
|
||||
test('AC8: secret never appears in axios error path', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
class FakeAxiosError extends Error {
|
||||
config = { headers: { Authorization: 'Bearer XSECRETXX' } }
|
||||
}
|
||||
mockAxiosRequest.mockImplementation(async () => {
|
||||
throw new FakeAxiosError('connect ECONNREFUSED')
|
||||
})
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
vault_auth_key: 'gh',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
expect(result.data.error).toBeDefined()
|
||||
expect(result.data.error).not.toContain('XSECRETXX')
|
||||
expect(result.data.error).not.toContain('Bearer')
|
||||
})
|
||||
|
||||
test('AC17: maxRedirects=0 (no redirect Authorization re-leak)', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
mockAxiosRequest.mockImplementation(async () =>
|
||||
makeAxiosResp({ data: 'ok' }),
|
||||
)
|
||||
await VaultHttpFetchTool.call(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
vault_auth_key: 'gh',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
expect(mockAxiosRequest).toHaveBeenCalledTimes(1)
|
||||
const calls = mockAxiosRequest.mock.calls as unknown as Array<
|
||||
Array<{ maxRedirects?: number }>
|
||||
>
|
||||
expect(calls[0]?.[0]?.maxRedirects).toBe(0)
|
||||
})
|
||||
|
||||
test('vault key not found -> error message (no crash)', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
mockedSecret = null
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
vault_auth_key: 'missing',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
expect(result.data.error).toMatch(/not found/)
|
||||
})
|
||||
|
||||
test('basic scheme uses base64 Authorization', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
mockAxiosRequest.mockImplementation(async () =>
|
||||
makeAxiosResp({ data: 'ok' }),
|
||||
)
|
||||
await VaultHttpFetchTool.call(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
vault_auth_key: 'k',
|
||||
auth_scheme: 'basic',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
const calls = mockAxiosRequest.mock.calls as unknown as Array<
|
||||
Array<{ headers?: Record<string, string> }>
|
||||
>
|
||||
const callArgs = calls[0]?.[0] ?? { headers: {} }
|
||||
expect(callArgs.headers?.['Authorization']).toBe(
|
||||
`Basic ${Buffer.from('XSECRETXX', 'utf8').toString('base64')}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('header_x_api_key scheme sets X-Api-Key', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
mockAxiosRequest.mockImplementation(async () =>
|
||||
makeAxiosResp({ data: 'ok' }),
|
||||
)
|
||||
await VaultHttpFetchTool.call(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
vault_auth_key: 'k',
|
||||
auth_scheme: 'header_x_api_key',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
const calls = mockAxiosRequest.mock.calls as unknown as Array<
|
||||
Array<{ headers?: Record<string, string> }>
|
||||
>
|
||||
const callArgs = calls[0]?.[0] ?? { headers: {} }
|
||||
expect(callArgs.headers?.['X-Api-Key']).toBe('XSECRETXX')
|
||||
expect(callArgs.headers?.['Authorization']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('auth_scheme=custom uses given auth_header_name', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
mockAxiosRequest.mockImplementation(async () => makeAxiosResp({ data: '' }))
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
vault_auth_key: 'gh',
|
||||
auth_scheme: 'custom',
|
||||
auth_header_name: 'X-Custom-Auth',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
const calls = mockAxiosRequest.mock.calls as unknown as Array<
|
||||
Array<{ headers?: Record<string, string> }>
|
||||
>
|
||||
const callArgs = calls[0]?.[0] ?? { headers: {} }
|
||||
expect(callArgs.headers?.['X-Custom-Auth']).toBe('XSECRETXX')
|
||||
expect(result.data).toBeDefined()
|
||||
})
|
||||
|
||||
test('auth_scheme=basic encodes secret as base64 Bearer', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
mockAxiosRequest.mockImplementation(async () => makeAxiosResp({ data: '' }))
|
||||
await VaultHttpFetchTool.call(
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
vault_auth_key: 'gh',
|
||||
auth_scheme: 'basic',
|
||||
reason: 'test',
|
||||
},
|
||||
mockContext(),
|
||||
)
|
||||
const calls = mockAxiosRequest.mock.calls as unknown as Array<
|
||||
Array<{ headers?: Record<string, string> }>
|
||||
>
|
||||
const auth = calls[0]?.[0]?.headers?.['Authorization']
|
||||
expect(auth).toMatch(/^Basic /)
|
||||
// 'XSECRETXX' base64 = 'WFNFQ1JFVFhY'
|
||||
expect(auth).toBe(`Basic ${Buffer.from('XSECRETXX').toString('base64')}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('VaultHttpFetchTool: tool definition methods', () => {
|
||||
test('isReadOnly returns false (has network side-effects)', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
expect(VaultHttpFetchTool.isReadOnly()).toBe(false)
|
||||
})
|
||||
|
||||
test('isConcurrencySafe returns false', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
expect(VaultHttpFetchTool.isConcurrencySafe()).toBe(false)
|
||||
})
|
||||
|
||||
test('requiresUserInteraction returns true (bypass-immune)', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
expect(VaultHttpFetchTool.requiresUserInteraction()).toBe(true)
|
||||
})
|
||||
|
||||
test('userFacingName returns "Vault HTTP"', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
expect(VaultHttpFetchTool.userFacingName()).toBe('Vault HTTP')
|
||||
})
|
||||
|
||||
test('description returns DESCRIPTION constant', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const desc = await VaultHttpFetchTool.description()
|
||||
expect(typeof desc).toBe('string')
|
||||
expect(desc.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('prompt returns the PROMPT constant', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const p = await VaultHttpFetchTool.prompt()
|
||||
expect(typeof p).toBe('string')
|
||||
expect(p.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('toAutoClassifierInput formats method+url', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const out = VaultHttpFetchTool.toAutoClassifierInput({
|
||||
vault_auth_key: 'k',
|
||||
url: 'https://example.com/x',
|
||||
method: 'POST',
|
||||
reason: 'r',
|
||||
} as never)
|
||||
expect(out).toBe('POST https://example.com/x')
|
||||
})
|
||||
|
||||
test('toAutoClassifierInput defaults method to GET when undefined', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const out = VaultHttpFetchTool.toAutoClassifierInput({
|
||||
vault_auth_key: 'k',
|
||||
url: 'https://example.com',
|
||||
reason: 'r',
|
||||
} as never)
|
||||
expect(out).toBe('GET https://example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('VaultHttpFetchTool: call() error paths', () => {
|
||||
beforeEach(() => {
|
||||
mockedSecret = 'XSECRETXX'
|
||||
getSecretShouldThrow = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
getSecretShouldThrow = false
|
||||
})
|
||||
|
||||
test('getSecret throws → returns "Vault unlock failed" + logs analytics', async () => {
|
||||
getSecretShouldThrow = true
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
vault_auth_key: 'k',
|
||||
url: 'https://example.com',
|
||||
method: 'GET',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockContext() as never,
|
||||
)
|
||||
const data = (result as { data: { error?: string } }).data
|
||||
expect(data.error).toBe('Vault unlock failed')
|
||||
})
|
||||
|
||||
test('non-HTTPS URL is rejected (defense in depth)', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
vault_auth_key: 'k',
|
||||
url: 'http://insecure.example.com/x',
|
||||
method: 'GET',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockContext() as never,
|
||||
)
|
||||
const data = (result as { data: { error?: string } }).data
|
||||
expect(data.error).toContain('https://')
|
||||
})
|
||||
|
||||
test('isHttps catches malformed URL (returns false → rejected)', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
vault_auth_key: 'k',
|
||||
url: 'not-a-real-url-at-all',
|
||||
method: 'GET',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockContext() as never,
|
||||
)
|
||||
const data = (result as { data: { error?: string } }).data
|
||||
expect(data.error).toBeDefined()
|
||||
})
|
||||
|
||||
test('vault key missing returns "not found" error', async () => {
|
||||
mockedSecret = null
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
vault_auth_key: 'missing-key',
|
||||
url: 'https://example.com',
|
||||
method: 'GET',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockContext() as never,
|
||||
)
|
||||
const data = (result as { data: { error?: string } }).data
|
||||
expect(data.error).toContain("'missing-key' not found")
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC18: VaultHttpFetch is in ALL_AGENT_DISALLOWED_TOOLS', () => {
|
||||
// Direct import of src/constants/tools.js depends on bun:bundle feature()
|
||||
// macros that don't resolve outside full-build context, and the various
|
||||
// mocks in this file can interfere when the suite is run together. Use a
|
||||
// grep snapshot — same approach as agentToolFilter AC11b.
|
||||
test('subagent gate layer 1 registration is wired', async () => {
|
||||
const fs = await import('node:fs')
|
||||
const path = await import('node:path')
|
||||
const file = path.resolve('src/constants/tools.ts')
|
||||
const src = fs.readFileSync(file, 'utf8')
|
||||
// (a) constant is imported
|
||||
expect(src).toContain('VAULT_HTTP_FETCH_TOOL_NAME')
|
||||
expect(src).toContain(
|
||||
"from '@claude-code-best/builtin-tools/tools/VaultHttpFetchTool/constants.js'",
|
||||
)
|
||||
// (b) and used in the ALL_AGENT_DISALLOWED_TOOLS region.
|
||||
// Find the export and verify VAULT_HTTP_FETCH_TOOL_NAME appears before the
|
||||
// CUSTOM_AGENT_DISALLOWED_TOOLS (next export). This avoids a fragile
|
||||
// greedy-regex match against the nested AGENT_TOOL_NAME ternary.
|
||||
const exportIdx = src.indexOf(
|
||||
'export const ALL_AGENT_DISALLOWED_TOOLS = new Set(',
|
||||
)
|
||||
const customIdx = src.indexOf('export const CUSTOM_AGENT_DISALLOWED_TOOLS')
|
||||
expect(exportIdx).toBeGreaterThan(-1)
|
||||
expect(customIdx).toBeGreaterThan(exportIdx)
|
||||
const region = src.slice(exportIdx, customIdx)
|
||||
expect(region).toContain('VAULT_HTTP_FETCH_TOOL_NAME')
|
||||
})
|
||||
})
|
||||
|
||||
describe('VaultHttpFetchTool: deny/allow rule branches', () => {
|
||||
test('deny rule for key@host → checkPermissions deny with rule reason', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.checkPermissions!(
|
||||
{
|
||||
vault_auth_key: 'gh-token',
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockToolContext({
|
||||
permissionOverrides: {
|
||||
alwaysDenyRules: {
|
||||
userSettings: ['VaultHttpFetch(gh-token@api.example.com)'],
|
||||
projectSettings: [],
|
||||
localSettings: [],
|
||||
flagSettings: [],
|
||||
policySettings: [],
|
||||
cliArg: [],
|
||||
command: [],
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
)
|
||||
expect(result.behavior).toBe('deny')
|
||||
if (result.behavior === 'deny') {
|
||||
expect(result.message).toContain('Denied by rule')
|
||||
}
|
||||
})
|
||||
|
||||
test('wildcard deny rule (key@*) matches any host', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.checkPermissions!(
|
||||
{
|
||||
vault_auth_key: 'gh-token',
|
||||
url: 'https://different-host.example.com',
|
||||
method: 'GET',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockToolContext({
|
||||
permissionOverrides: {
|
||||
alwaysDenyRules: {
|
||||
userSettings: ['VaultHttpFetch(gh-token@*)'],
|
||||
projectSettings: [],
|
||||
localSettings: [],
|
||||
flagSettings: [],
|
||||
policySettings: [],
|
||||
cliArg: [],
|
||||
command: [],
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
)
|
||||
expect(result.behavior).toBe('deny')
|
||||
})
|
||||
|
||||
test('allow rule for key@host → checkPermissions allow', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.checkPermissions!(
|
||||
{
|
||||
vault_auth_key: 'gh-token',
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockToolContext({
|
||||
permissionOverrides: {
|
||||
alwaysAllowRules: {
|
||||
userSettings: ['VaultHttpFetch(gh-token@api.example.com)'],
|
||||
projectSettings: [],
|
||||
localSettings: [],
|
||||
flagSettings: [],
|
||||
policySettings: [],
|
||||
cliArg: [],
|
||||
command: [],
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
)
|
||||
expect(result.behavior).toBe('allow')
|
||||
})
|
||||
|
||||
test('wildcard allow rule (key@*) matches any host', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.checkPermissions!(
|
||||
{
|
||||
vault_auth_key: 'gh-token',
|
||||
url: 'https://random.example.com',
|
||||
method: 'POST',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockToolContext({
|
||||
permissionOverrides: {
|
||||
alwaysAllowRules: {
|
||||
userSettings: ['VaultHttpFetch(gh-token@*)'],
|
||||
projectSettings: [],
|
||||
localSettings: [],
|
||||
flagSettings: [],
|
||||
policySettings: [],
|
||||
cliArg: [],
|
||||
command: [],
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
)
|
||||
expect(result.behavior).toBe('allow')
|
||||
})
|
||||
|
||||
// ── M2 (codecov-100 audit #5): port and IPv6 host scoping ──
|
||||
// The `host` property of `URL` includes :port and IPv6 brackets verbatim,
|
||||
// and the rule content is built from it directly. These tests pin that
|
||||
// contract so any future regression that strips ports (and weakens the
|
||||
// permission scope) or strips brackets (breaking IPv6 round-trip) is
|
||||
// caught.
|
||||
test('M2: distinct ports on the same host are distinct permission scopes', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
// Allow rule scoped to port 8080. Request to port 8443 must NOT match.
|
||||
const result = await VaultHttpFetchTool.checkPermissions!(
|
||||
{
|
||||
vault_auth_key: 'gh-token',
|
||||
url: 'https://api.example.com:8443/path',
|
||||
method: 'GET',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockToolContext({
|
||||
permissionOverrides: {
|
||||
alwaysAllowRules: {
|
||||
userSettings: ['VaultHttpFetch(gh-token@api.example.com:8080)'],
|
||||
projectSettings: [],
|
||||
localSettings: [],
|
||||
flagSettings: [],
|
||||
policySettings: [],
|
||||
cliArg: [],
|
||||
command: [],
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
)
|
||||
// No matching allow → falls through to ask (per docstring: bypass-immune)
|
||||
expect(result.behavior).toBe('ask')
|
||||
})
|
||||
|
||||
test('M2: same port DOES match allow rule', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.checkPermissions!(
|
||||
{
|
||||
vault_auth_key: 'gh-token',
|
||||
url: 'https://api.example.com:8080/path',
|
||||
method: 'GET',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockToolContext({
|
||||
permissionOverrides: {
|
||||
alwaysAllowRules: {
|
||||
userSettings: ['VaultHttpFetch(gh-token@api.example.com:8080)'],
|
||||
projectSettings: [],
|
||||
localSettings: [],
|
||||
flagSettings: [],
|
||||
policySettings: [],
|
||||
cliArg: [],
|
||||
command: [],
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
)
|
||||
expect(result.behavior).toBe('allow')
|
||||
})
|
||||
|
||||
test('M2: IPv6 literal with brackets round-trips through allow rule', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
// new URL('https://[::1]:8080/').host === '[::1]:8080' (lowercase preserved)
|
||||
const result = await VaultHttpFetchTool.checkPermissions!(
|
||||
{
|
||||
vault_auth_key: 'gh-token',
|
||||
url: 'https://[::1]:8080/path',
|
||||
method: 'GET',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockToolContext({
|
||||
permissionOverrides: {
|
||||
alwaysAllowRules: {
|
||||
userSettings: ['VaultHttpFetch(gh-token@[::1]:8080)'],
|
||||
projectSettings: [],
|
||||
localSettings: [],
|
||||
flagSettings: [],
|
||||
policySettings: [],
|
||||
cliArg: [],
|
||||
command: [],
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
)
|
||||
expect(result.behavior).toBe('allow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('VaultHttpFetchTool: call() additional paths', () => {
|
||||
beforeEach(() => {
|
||||
mockAxiosRequest.mockClear()
|
||||
mockedSecret = 'XSECRETXX'
|
||||
getSecretShouldThrow = false
|
||||
})
|
||||
|
||||
test('auth_scheme=custom without auth_header_name returns error (defensive)', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
vault_auth_key: 'k',
|
||||
url: 'https://example.com',
|
||||
method: 'GET',
|
||||
auth_scheme: 'custom',
|
||||
// auth_header_name missing on purpose (checkPermissions normally catches)
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockContext() as never,
|
||||
)
|
||||
const data = (result as { data: { error?: string } }).data
|
||||
expect(data.error).toContain('auth_header_name')
|
||||
})
|
||||
|
||||
test('body sets Content-Type header (default application/json)', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
mockAxiosRequest.mockImplementation(async () => makeAxiosResp({ data: '' }))
|
||||
await VaultHttpFetchTool.call(
|
||||
{
|
||||
vault_auth_key: 'gh',
|
||||
url: 'https://api.example.com',
|
||||
method: 'POST',
|
||||
body: '{"x":1}',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockContext() as never,
|
||||
)
|
||||
const calls = mockAxiosRequest.mock.calls as unknown as Array<
|
||||
Array<{ headers?: Record<string, string> }>
|
||||
>
|
||||
expect(calls[0]?.[0]?.headers?.['Content-Type']).toBe('application/json')
|
||||
})
|
||||
|
||||
test('body with explicit body_content_type uses that value', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
mockAxiosRequest.mockImplementation(async () => makeAxiosResp({ data: '' }))
|
||||
await VaultHttpFetchTool.call(
|
||||
{
|
||||
vault_auth_key: 'gh',
|
||||
url: 'https://api.example.com',
|
||||
method: 'POST',
|
||||
body: 'plain text',
|
||||
body_content_type: 'text/plain',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockContext() as never,
|
||||
)
|
||||
const calls = mockAxiosRequest.mock.calls as unknown as Array<
|
||||
Array<{ headers?: Record<string, string> }>
|
||||
>
|
||||
expect(calls[0]?.[0]?.headers?.['Content-Type']).toBe('text/plain')
|
||||
})
|
||||
|
||||
test('response with null data is coerced to empty string', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
mockAxiosRequest.mockImplementation(async () =>
|
||||
makeAxiosResp({ data: null as unknown as string }),
|
||||
)
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
vault_auth_key: 'gh',
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockContext() as never,
|
||||
)
|
||||
expect(result.data.body).toBe('')
|
||||
})
|
||||
|
||||
test('response with non-string data (Buffer-like) is coerced via String()', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const buf = Buffer.from('binary-content', 'utf8')
|
||||
mockAxiosRequest.mockImplementation(async () =>
|
||||
makeAxiosResp({ data: buf as unknown as string }),
|
||||
)
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
vault_auth_key: 'gh',
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
auth_scheme: 'bearer',
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockContext() as never,
|
||||
)
|
||||
expect(result.data.body).toContain('binary-content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('VaultHttpFetchTool: mapToolResultToToolResultBlockParam', () => {
|
||||
test('non-error output has is_error=false', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const out = VaultHttpFetchTool.mapToolResultToToolResultBlockParam!(
|
||||
{
|
||||
status: 200,
|
||||
body: 'ok',
|
||||
statusText: 'OK',
|
||||
responseHeaders: {},
|
||||
} as never,
|
||||
'tool-use-1',
|
||||
)
|
||||
expect(out.tool_use_id).toBe('tool-use-1')
|
||||
expect(out.is_error).toBe(false)
|
||||
expect(typeof out.content).toBe('string')
|
||||
})
|
||||
|
||||
test('error output has is_error=true', async () => {
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const out = VaultHttpFetchTool.mapToolResultToToolResultBlockParam!(
|
||||
{ error: 'Vault unlock failed' } as never,
|
||||
'tool-use-2',
|
||||
)
|
||||
expect(out.is_error).toBe(true)
|
||||
})
|
||||
|
||||
test('unknown auth_scheme returns error (exhaustive default branch)', async () => {
|
||||
// Bypass TypeScript exhaustive type to exercise the never-guard default.
|
||||
const { VaultHttpFetchTool } = await import('../VaultHttpFetchTool.js')
|
||||
const result = await VaultHttpFetchTool.call(
|
||||
{
|
||||
vault_auth_key: 'k',
|
||||
url: 'https://example.com',
|
||||
method: 'GET',
|
||||
auth_scheme: 'invalid_scheme_xyz' as never,
|
||||
reason: 'r',
|
||||
} as never,
|
||||
mockContext() as never,
|
||||
)
|
||||
const data = (result as { data: { error?: string } }).data
|
||||
expect(data.error).toContain('Unknown auth_scheme')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
buildDerivedSecretForms,
|
||||
scrubAllSecretForms,
|
||||
scrubAxiosError,
|
||||
scrubResponseHeaders,
|
||||
truncateToBytes,
|
||||
} from '../scrub.js'
|
||||
|
||||
describe('buildDerivedSecretForms', () => {
|
||||
test('returns empty array for empty secret', () => {
|
||||
expect(buildDerivedSecretForms('')).toEqual([])
|
||||
})
|
||||
|
||||
test('M7: returns empty array for too-short secret (DoS guard)', () => {
|
||||
// A 1-3 char secret causes amplification on scrub; refuse to scrub.
|
||||
expect(buildDerivedSecretForms('X')).toEqual([])
|
||||
expect(buildDerivedSecretForms('XY')).toEqual([])
|
||||
expect(buildDerivedSecretForms('XYZ')).toEqual([])
|
||||
})
|
||||
|
||||
test('covers all 4 forms: raw, Bearer, base64, Basic-base64 (>=8 chars)', () => {
|
||||
// M3 (audit #6): bare-base64 form is only emitted for secrets >= 8 chars
|
||||
// (collision risk for short secrets). Use 'helloXXX' (8 chars).
|
||||
const forms = buildDerivedSecretForms('helloXXX')
|
||||
const b64 = Buffer.from('helloXXX', 'utf8').toString('base64')
|
||||
expect(forms).toContain('helloXXX')
|
||||
expect(forms).toContain('Bearer helloXXX')
|
||||
expect(forms).toContain(b64)
|
||||
expect(forms).toContain(`Basic ${b64}`)
|
||||
expect(forms.length).toBe(4)
|
||||
})
|
||||
|
||||
test('M3 (audit #6): short secret (4-7 chars) omits bare-base64 form', () => {
|
||||
// 4-char secret. Raw + Bearer + Basic-prefixed-base64 all emitted; bare
|
||||
// base64 is suppressed because 7-8 char base64 collides with random
|
||||
// tokens in the response body.
|
||||
const forms = buildDerivedSecretForms('hello')
|
||||
const b64 = Buffer.from('hello', 'utf8').toString('base64')
|
||||
expect(forms).toContain('hello')
|
||||
expect(forms).toContain('Bearer hello')
|
||||
expect(forms).toContain(`Basic ${b64}`)
|
||||
expect(forms).not.toContain(b64) // bare-base64 NOT emitted
|
||||
expect(forms.length).toBe(3)
|
||||
})
|
||||
|
||||
test('M3 (audit #6): boundary at 7 vs 8 chars', () => {
|
||||
// 7-char: bare-base64 suppressed (3 forms)
|
||||
expect(buildDerivedSecretForms('1234567').length).toBe(3)
|
||||
// 8-char: bare-base64 emitted (4 forms)
|
||||
expect(buildDerivedSecretForms('12345678').length).toBe(4)
|
||||
})
|
||||
|
||||
test('M7: returns longest-first so callers do not need to sort', () => {
|
||||
const forms = buildDerivedSecretForms('helloXXX')
|
||||
// Basic <base64> is longest, raw 'helloXXX' is shortest
|
||||
for (let i = 1; i < forms.length; i++) {
|
||||
expect(forms[i]!.length).toBeLessThanOrEqual(forms[i - 1]!.length)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrubAllSecretForms', () => {
|
||||
test('redacts raw secret', () => {
|
||||
const forms = buildDerivedSecretForms('XSECRETXX')
|
||||
expect(scrubAllSecretForms('header: XSECRETXX', forms)).toBe(
|
||||
'header: [REDACTED]',
|
||||
)
|
||||
})
|
||||
|
||||
test('redacts Bearer-prefixed secret (longest-first)', () => {
|
||||
const forms = buildDerivedSecretForms('TOK123')
|
||||
// The Bearer form should be matched FIRST so we don't end up with
|
||||
// 'Bearer [REDACTED]' (the unredacted 'Bearer' prefix lingering).
|
||||
const result = scrubAllSecretForms('Authorization: Bearer TOK123', forms)
|
||||
expect(result).toBe('Authorization: [REDACTED]')
|
||||
})
|
||||
|
||||
test('redacts base64-form (server might echo Basic auth)', () => {
|
||||
const forms = buildDerivedSecretForms('user:pass')
|
||||
const b64 = Buffer.from('user:pass', 'utf8').toString('base64')
|
||||
const result = scrubAllSecretForms(`echoed: ${b64}`, forms)
|
||||
expect(result).toBe('echoed: [REDACTED]')
|
||||
})
|
||||
|
||||
test('redacts Basic-base64-form', () => {
|
||||
const forms = buildDerivedSecretForms('mypass')
|
||||
const b64 = Buffer.from('mypass', 'utf8').toString('base64')
|
||||
expect(scrubAllSecretForms(`Auth: Basic ${b64}`, forms)).toBe(
|
||||
'Auth: [REDACTED]',
|
||||
)
|
||||
})
|
||||
|
||||
test('redacts ALL occurrences', () => {
|
||||
// M7: secrets >= 4 chars are scrubbed; 'XX' is too short and returns
|
||||
// empty forms (DoS guard). Use a 4-char secret to verify all-occurrence
|
||||
// replacement.
|
||||
const forms = buildDerivedSecretForms('XKEY')
|
||||
expect(scrubAllSecretForms('XKEY-hello-XKEY', forms)).toBe(
|
||||
'[REDACTED]-hello-[REDACTED]',
|
||||
)
|
||||
})
|
||||
|
||||
test('preserves non-secret strings', () => {
|
||||
const forms = buildDerivedSecretForms('SECRET')
|
||||
expect(scrubAllSecretForms('hello world', forms)).toBe('hello world')
|
||||
})
|
||||
|
||||
test('handles empty inputs', () => {
|
||||
expect(scrubAllSecretForms('', buildDerivedSecretForms('X'))).toBe('')
|
||||
expect(scrubAllSecretForms('text', [])).toBe('text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrubResponseHeaders', () => {
|
||||
test('redacts Authorization header by NAME (case-insensitive)', () => {
|
||||
const forms = buildDerivedSecretForms('SECRET')
|
||||
const result = scrubResponseHeaders(
|
||||
{ 'Content-Type': 'application/json', authorization: 'Bearer SECRET' },
|
||||
forms,
|
||||
)
|
||||
expect(result['authorization']).toBe('[REDACTED]')
|
||||
expect(result['Content-Type']).toBe('application/json')
|
||||
})
|
||||
|
||||
test('redacts X-Api-Key header', () => {
|
||||
const forms = buildDerivedSecretForms('K')
|
||||
const result = scrubResponseHeaders({ 'x-api-key': 'K' }, forms)
|
||||
expect(result['x-api-key']).toBe('[REDACTED]')
|
||||
})
|
||||
|
||||
test('redacts cookie / set-cookie / proxy-authorization / www-authenticate', () => {
|
||||
const forms = buildDerivedSecretForms('S')
|
||||
const result = scrubResponseHeaders(
|
||||
{
|
||||
cookie: 'session=abc',
|
||||
'set-cookie': 'token=xyz',
|
||||
'proxy-authorization': 'Bearer S',
|
||||
'www-authenticate': 'Bearer realm="x"',
|
||||
},
|
||||
forms,
|
||||
)
|
||||
expect(result['cookie']).toBe('[REDACTED]')
|
||||
expect(result['set-cookie']).toBe('[REDACTED]')
|
||||
expect(result['proxy-authorization']).toBe('[REDACTED]')
|
||||
expect(result['www-authenticate']).toBe('[REDACTED]')
|
||||
})
|
||||
|
||||
test('scrubs secret-like values from non-sensitive headers (echo case)', () => {
|
||||
const forms = buildDerivedSecretForms('XSECRETXX')
|
||||
// Server echoes our auth into a non-sensitive header (defensive)
|
||||
const result = scrubResponseHeaders(
|
||||
{ 'x-debug-echo': 'received header: Bearer XSECRETXX' },
|
||||
forms,
|
||||
)
|
||||
expect(result['x-debug-echo']).toBe('received header: [REDACTED]')
|
||||
})
|
||||
|
||||
test('handles array-valued headers (set-cookie)', () => {
|
||||
const forms = buildDerivedSecretForms('X')
|
||||
const result = scrubResponseHeaders({ 'set-cookie': ['a', 'b'] }, forms)
|
||||
expect(result['set-cookie']).toBe('[REDACTED]')
|
||||
})
|
||||
|
||||
test('handles empty / null / non-object input', () => {
|
||||
expect(scrubResponseHeaders(null, [])).toEqual({})
|
||||
expect(scrubResponseHeaders(undefined, [])).toEqual({})
|
||||
expect(scrubResponseHeaders('not-an-object', [])).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncateToBytes (H1: byte-aware reason capping)', () => {
|
||||
test('returns empty string for empty / zero-cap input', () => {
|
||||
expect(truncateToBytes('', 80)).toBe('')
|
||||
expect(truncateToBytes('hello', 0)).toBe('')
|
||||
expect(truncateToBytes('hello', -1)).toBe('')
|
||||
})
|
||||
|
||||
test('returns input unchanged when already within byte cap', () => {
|
||||
expect(truncateToBytes('hello', 80)).toBe('hello')
|
||||
// Exact-length boundary: 5-char ASCII at maxBytes=5 returns unchanged
|
||||
expect(truncateToBytes('hello', 5)).toBe('hello')
|
||||
})
|
||||
|
||||
test('truncates plain ASCII at the byte boundary', () => {
|
||||
const input = 'a'.repeat(120)
|
||||
const out = truncateToBytes(input, 80)
|
||||
expect(Buffer.byteLength(out, 'utf8')).toBe(80)
|
||||
expect(out).toBe('a'.repeat(80))
|
||||
})
|
||||
|
||||
test('regression: 80 CJK chars produce <=80 BYTES, not 240', () => {
|
||||
// Each CJK char encodes to 3 bytes in UTF-8. 80 chars => 240 bytes.
|
||||
// Old code (input.reason.slice(0, 80)) returned the full 240-byte string.
|
||||
const input = '中'.repeat(80)
|
||||
const out = truncateToBytes(input, 80)
|
||||
const byteLen = Buffer.byteLength(out, 'utf8')
|
||||
expect(byteLen).toBeLessThanOrEqual(80)
|
||||
// 80 bytes / 3 bytes per char = 26 complete CJK chars
|
||||
expect(out).toBe('中'.repeat(26))
|
||||
})
|
||||
|
||||
test('regression: emoji (4-byte UTF-8) does not produce half-encoded output', () => {
|
||||
// 🎉 is 4 bytes in UTF-8 (surrogate pair in JS, single code point).
|
||||
const input = '🎉'.repeat(40) // 160 bytes
|
||||
const out = truncateToBytes(input, 80)
|
||||
expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(80)
|
||||
// The result must be valid UTF-8 (no half-encoded surrogate)
|
||||
expect(out).toBe(Buffer.from(out, 'utf8').toString('utf8'))
|
||||
// 80 / 4 = 20 complete emoji
|
||||
expect(out).toBe('🎉'.repeat(20))
|
||||
})
|
||||
|
||||
test('mixed ASCII + multi-byte: backs off to last code-point boundary', () => {
|
||||
// 'AAA' (3 bytes) + '中' (3 bytes) + 'BBB' (3 bytes) = 9 bytes total.
|
||||
// Cap at 5 bytes: 'AAA' fits (3 bytes), then '中' would push to 6 — back off.
|
||||
expect(truncateToBytes('AAA中BBB', 5)).toBe('AAA')
|
||||
// Cap at 6 bytes: 'AAA' + '中' = 6 bytes exactly → fits.
|
||||
expect(truncateToBytes('AAA中BBB', 6)).toBe('AAA中')
|
||||
// Cap at 7 bytes: 'AAA' + '中' = 6 bytes; +1 byte of 'B' would be a
|
||||
// valid ASCII boundary so 'AAA中B' fits.
|
||||
expect(truncateToBytes('AAA中BBB', 7)).toBe('AAA中B')
|
||||
})
|
||||
|
||||
test('truncated output is always valid UTF-8 (no U+FFFD)', () => {
|
||||
// Stress: every byte length 1..30 on a multi-byte string must roundtrip
|
||||
const input = '日本語🎉🌟αβγ'
|
||||
for (let cap = 1; cap <= Buffer.byteLength(input, 'utf8'); cap++) {
|
||||
const out = truncateToBytes(input, cap)
|
||||
// Re-decoding the bytes must produce the same string (no replacement chars)
|
||||
const reDecoded = Buffer.from(out, 'utf8').toString('utf8')
|
||||
expect(out).toBe(reDecoded)
|
||||
expect(out).not.toContain('<27>')
|
||||
expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(cap)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrubAxiosError', () => {
|
||||
test('NEVER stringifies raw Error / AxiosError (would expose .config.headers)', () => {
|
||||
// Mimic an axios-like error with config.headers carrying Authorization
|
||||
class FakeAxiosError extends Error {
|
||||
config = { headers: { Authorization: 'Bearer XSECRETXX' } }
|
||||
}
|
||||
const e = new FakeAxiosError('Request failed with status code 401')
|
||||
const forms = buildDerivedSecretForms('XSECRETXX')
|
||||
const result = scrubAxiosError(e, forms)
|
||||
expect(result).not.toContain('XSECRETXX')
|
||||
expect(result).not.toContain('Bearer')
|
||||
// Should be a synthetic safe summary, not JSON.stringify of the error
|
||||
expect(result.startsWith('Request failed:')).toBe(true)
|
||||
})
|
||||
|
||||
test('scrubs secret-derived strings in error.message', () => {
|
||||
const e = new Error('Bearer XSECRETXX failed')
|
||||
const forms = buildDerivedSecretForms('XSECRETXX')
|
||||
const result = scrubAxiosError(e, forms)
|
||||
expect(result).toBe('Request failed: [REDACTED] failed')
|
||||
})
|
||||
|
||||
test('handles non-Error throwable', () => {
|
||||
expect(scrubAxiosError('boom', [])).toBe('Request failed (unknown error)')
|
||||
expect(scrubAxiosError({ status: 500 }, [])).toBe(
|
||||
'Request failed (unknown error)',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
export const VAULT_HTTP_FETCH_TOOL_NAME = 'VaultHttpFetch'
|
||||
|
||||
/** HTTP request response body cap (1 MB) — matches axios maxContentLength. */
|
||||
export const RESPONSE_BODY_CAP_BYTES = 1_048_576
|
||||
/** Per-request timeout. */
|
||||
export const REQUEST_TIMEOUT_MS = 30_000
|
||||
@@ -0,0 +1,38 @@
|
||||
export const DESCRIPTION =
|
||||
"Make an authenticated HTTPS request using a secret stored in the user's " +
|
||||
'encrypted local vault (~/.claude/local-vault/). You only specify the vault ' +
|
||||
'key NAME — never the secret value. The tool framework injects the secret ' +
|
||||
'directly into a request header and the secret is NEVER returned in tool_result, ' +
|
||||
'NEVER logged, NEVER passed to a shell. ' +
|
||||
'Each vault key requires user pre-approval via permissions.allow: ' +
|
||||
"['VaultHttpFetch(key-name)']. Whole-tool allow ('VaultHttpFetch' without " +
|
||||
'parentheses) is rejected at settings parse time.'
|
||||
|
||||
export const PROMPT = `VaultHttpFetch — authenticated HTTPS request with a vault-stored secret.
|
||||
|
||||
Use for: HTTP API calls that need a Bearer token, Basic auth, X-Api-Key, or
|
||||
custom auth header. GitHub API, Stripe API, internal service auth, etc.
|
||||
|
||||
Do NOT use for: shell commands needing secrets (git push, npm publish, ssh,
|
||||
docker login). Those are out of scope; the user must handle them externally.
|
||||
|
||||
Request schema:
|
||||
url https:// only (HTTP/file/ftp rejected)
|
||||
method GET (default), POST, PUT, PATCH, DELETE
|
||||
vault_auth_key the vault key name (the secret value is fetched by the tool)
|
||||
auth_scheme bearer (default), basic, header_x_api_key, custom
|
||||
auth_header_name when auth_scheme=custom, the HTTP header to use
|
||||
body request body (string; sent as-is)
|
||||
body_content_type defaults to application/json when body is set
|
||||
reason why you need this — appears in the user's permission prompt
|
||||
|
||||
Response: { status, statusText, responseHeaders (sensitive headers redacted),
|
||||
body (scrubbed of any secret-derived strings), or error }
|
||||
|
||||
Permission model:
|
||||
Default: ask (user prompt). Approving once for a key sets a per-key allow
|
||||
the user can persist via the prompt UI. Whole-tool allow is forbidden.
|
||||
|
||||
Always pass \`reason\` truthfully. The secret never appears in your context;
|
||||
the URL, method, key NAME, and reason all do appear in the transcript.
|
||||
`
|
||||
186
packages/builtin-tools/src/tools/VaultHttpFetchTool/scrub.ts
Normal file
186
packages/builtin-tools/src/tools/VaultHttpFetchTool/scrub.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Scrubbing functions for VaultHttpFetchTool.
|
||||
*
|
||||
* The cardinal rule: NO secret-derived string ever leaves this tool's
|
||||
* boundary in any field that would land in tool_result, jsonl, transcript
|
||||
* search, telemetry, or compact summaries. The scrub layer applies to:
|
||||
* - response body (server might echo Authorization)
|
||||
* - response headers (Authorization / X-Api-Key / Set-Cookie)
|
||||
* - axios error messages (axios.AxiosError.config can carry the request
|
||||
* headers — including the Authorization we just sent)
|
||||
*
|
||||
* Strategy: build all "derived forms" of the secret BEFORE the request, then
|
||||
* apply scrubAllSecretForms to every byte that crosses the tool boundary.
|
||||
*
|
||||
* Derived forms covered:
|
||||
* - raw secret value
|
||||
* - 'Bearer <secret>'
|
||||
* - <secret> base64-encoded (for Basic-style payloads)
|
||||
* - 'Basic <base64>' full header value
|
||||
*
|
||||
* Custom auth_header_name puts the raw secret as the header value, which is
|
||||
* already covered by the raw-secret form.
|
||||
*/
|
||||
|
||||
const REDACTED = '[REDACTED]'
|
||||
|
||||
const SENSITIVE_HEADER_NAMES = new Set([
|
||||
'authorization',
|
||||
'x-api-key',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
'proxy-authorization',
|
||||
'www-authenticate',
|
||||
])
|
||||
|
||||
/**
|
||||
* Minimum secret length for scrubbing the RAW form. Below this threshold,
|
||||
* scrubbing causes pathological output amplification — e.g. a 1-char
|
||||
* secret 'X' on a 1MB body that happens to contain many X chars produces
|
||||
* ~10MB of [REDACTED].
|
||||
*
|
||||
* 4 chars is below any realistic secret (API tokens, OAuth tokens, JWTs,
|
||||
* passwords are all >>4). The vault store should reject sub-4-char values
|
||||
* at write time, but this is defense-in-depth at scrub time.
|
||||
*/
|
||||
const MIN_SCRUB_LENGTH = 4
|
||||
|
||||
/**
|
||||
* Minimum secret length for scrubbing the BASE64-derived forms.
|
||||
*
|
||||
* M3 fix (codecov-100 audit #6): a 4-char secret has a 7-8 char base64
|
||||
* representation that is short enough to collide with naturally-occurring
|
||||
* tokens in the response body (`x4Kp` → `eDRLcA==`, which can match
|
||||
* unrelated short identifiers). Raw + Bearer forms are still scrubbed
|
||||
* for short secrets because their substring match is much more specific
|
||||
* (e.g. `Bearer x4Kp` is unlikely to collide). For base64 forms we wait
|
||||
* until the secret is >= 8 chars (yielding >= 12 base64 chars), which is
|
||||
* the OWASP minimum for a credential and is well clear of incidental
|
||||
* collisions. This is a TIGHTER scrub for short secrets, not looser:
|
||||
* we still scrub the raw secret value itself.
|
||||
*/
|
||||
const MIN_SCRUB_BASE64_LENGTH = 8
|
||||
|
||||
/**
|
||||
* Compute every form the secret could appear in across response body /
|
||||
* headers / error message.
|
||||
*
|
||||
* L7 fix: returns `[]` (empty) when secret is shorter than MIN_SCRUB_LENGTH
|
||||
* — scrubbing a too-short pattern is worse than not scrubbing. Caller
|
||||
* should guard `if (secret && secret.length >= MIN_SCRUB_LENGTH)` before
|
||||
* trusting the result is non-empty. The previous JSDoc claimed "always
|
||||
* non-empty" which was inaccurate.
|
||||
*
|
||||
* M3 fix (codecov-100 audit #6): for short secrets (4-7 chars) we omit
|
||||
* the bare-base64 form because its 7-8 char encoding is short enough to
|
||||
* collide with unrelated tokens in the response body and produce
|
||||
* spurious [REDACTED] markers. We still emit raw + Bearer + Basic-base64
|
||||
* because those have a longer/more-specific match shape.
|
||||
*
|
||||
* Returned forms are sorted longest-first so callers don't need to re-sort.
|
||||
*/
|
||||
export function buildDerivedSecretForms(secret: string): readonly string[] {
|
||||
if (!secret || secret.length < MIN_SCRUB_LENGTH) return []
|
||||
const base64 = Buffer.from(secret, 'utf8').toString('base64')
|
||||
// Pre-sorted longest-first (Basic > Bearer > base64 > raw, generally)
|
||||
// so callers don't pay the sort cost on every scrub call.
|
||||
if (secret.length < MIN_SCRUB_BASE64_LENGTH) {
|
||||
// M3 fix: omit the bare-base64 form for short secrets (collision risk).
|
||||
// The Basic-prefixed form keeps base64 content in the scrub list but
|
||||
// anchored on the literal "Basic " prefix so collisions with random
|
||||
// 8-char tokens in the body are vanishingly unlikely.
|
||||
return [`Basic ${base64}`, `Bearer ${secret}`, secret]
|
||||
}
|
||||
return [`Basic ${base64}`, `Bearer ${secret}`, base64, secret]
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every occurrence of any derived secret form in `s` with [REDACTED].
|
||||
*
|
||||
* M7 fix: forms array is pre-sorted longest-first by buildDerivedSecretForms,
|
||||
* so we no longer allocate a sorted copy on every call. Also added a
|
||||
* `s.length >= form.length` fast-path before `includes()` to skip
|
||||
* impossible-match work, and the `includes()` check itself is the fast path
|
||||
* that lets us skip the split/join allocation for clean bodies.
|
||||
*/
|
||||
export function scrubAllSecretForms(
|
||||
s: string,
|
||||
forms: readonly string[],
|
||||
): string {
|
||||
if (!s || forms.length === 0) return s
|
||||
let out = s
|
||||
for (const form of forms) {
|
||||
if (form.length > 0 && out.length >= form.length && out.includes(form)) {
|
||||
out = out.split(form).join(REDACTED)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize response headers: redact sensitive header names entirely, and
|
||||
* scrub any remaining headers' values for secret echo.
|
||||
*/
|
||||
export function scrubResponseHeaders(
|
||||
headers: unknown,
|
||||
forms: readonly string[],
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
if (!headers || typeof headers !== 'object') return out
|
||||
for (const [key, value] of Object.entries(
|
||||
headers as Record<string, unknown>,
|
||||
)) {
|
||||
const lname = key.toLowerCase()
|
||||
if (SENSITIVE_HEADER_NAMES.has(lname)) {
|
||||
out[key] = REDACTED
|
||||
continue
|
||||
}
|
||||
const sv = Array.isArray(value)
|
||||
? value.map(v => String(v ?? '')).join(', ')
|
||||
: String(value ?? '')
|
||||
out[key] = scrubAllSecretForms(sv, forms)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to at most `maxBytes` UTF-8 bytes, returning a value that
|
||||
* is still valid UTF-8 (no half-encoded code points).
|
||||
*
|
||||
* H1 fix (codecov-100 audit): the previous code used `String#slice(0, 80)`
|
||||
* which counts UTF-16 *code units*. With multi-byte UTF-8 (CJK, emoji,
|
||||
* combining marks) an 80-char slice can balloon to 240+ bytes — violating
|
||||
* the analytics field's byte-cap contract. We walk the byte buffer and
|
||||
* back off to the start of the last complete UTF-8 code point. (We also
|
||||
* walk back any combining-mark continuation bytes that depend on a
|
||||
* just-truncated lead byte; this is handled implicitly by the
|
||||
* leading-byte check since UTF-8 continuation bytes are 0b10xxxxxx.)
|
||||
*
|
||||
* Empty / null-ish inputs return ''.
|
||||
*/
|
||||
export function truncateToBytes(input: string, maxBytes: number): string {
|
||||
if (!input || maxBytes <= 0) return ''
|
||||
const buf = Buffer.from(input, 'utf8')
|
||||
if (buf.length <= maxBytes) return input
|
||||
// Walk back from maxBytes until we land on a code-point boundary.
|
||||
// UTF-8 continuation bytes match 10xxxxxx (0x80–0xBF). A code-point
|
||||
// boundary is any byte that does NOT match that mask.
|
||||
let end = maxBytes
|
||||
while (end > 0 && (buf[end]! & 0xc0) === 0x80) {
|
||||
end--
|
||||
}
|
||||
return buf.subarray(0, end).toString('utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an axios / fetch error into a safe summary string. NEVER stringify
|
||||
* the raw error: axios.AxiosError carries .config.headers which contains the
|
||||
* Authorization we just sent. Build a synthetic message and scrub it.
|
||||
*/
|
||||
export function scrubAxiosError(e: unknown, forms: readonly string[]): string {
|
||||
if (e instanceof Error) {
|
||||
const msg = scrubAllSecretForms(e.message, forms)
|
||||
return `Request failed: ${msg}`
|
||||
}
|
||||
return 'Request failed (unknown error)'
|
||||
}
|
||||
Reference in New Issue
Block a user