权限层
权限是 SDK 安全的核心。它由三层叠加:permissionMode(基调)→ canUseTool(每次调用的精细裁决)→ permission rules(settings 里的 allow/ask/deny)。
CanUseTool 回调签名
export declare type CanUseTool = (
toolName: string,
input: Record<string, unknown>,
options: {
signal: AbortSignal;
suggestions?: PermissionUpdate[]; // “不再提示”建议,回填到 updatedPermissions
blockedPath?: string;
decisionReason?: string;
title?: string; // 桥接渲染的完整提示句,优先用它而非自己拼
displayName?: string; // 短名词短语(按钮用)
description?: string;
toolUseID: string;
agentID?: string; // 子代理上下文
requestId: string; // out-of-band 响应必须回显此值
matchedAskRule?: { source: string; toolName: string; ruleContent?: string };
}
) => Promise<PermissionResult | null>;
〔sdk.d.ts:196-266〕
返回值 PermissionResult
type PermissionResult =
| { behavior: 'allow'; updatedInput?: ...; updatedPermissions?: PermissionUpdate[];
toolUseID?: string; decisionClassification?: PermissionDecisionClassification; }
| { behavior: 'deny'; message: string; interrupt?: boolean;
toolUseID?: string; decisionClassification?: ...; };
〔sdk.d.ts:2114-2126〕
allow:可顺带updatedInput改写工具入参,或updatedPermissions落实“总是允许”建议〔sdk.d.ts:2115-2118〕。deny:必须带message;interrupt:true表示不仅拒绝还中断整个会话〔sdk.d.ts:2121-2123〕。decisionClassification('user_temporary'|'user_permanent'|'user_reject')用于遥测:做了人机交互 UI 的宿主应如实回填,不设则 CLI 保守推断(allow→temporary, deny→reject)〔sdk.d.ts:2072-2074〕。
null 返回值 = 无限期阻塞(最重要的边界)
官方原文〔sdk.d.ts:200-204〕:
“Return
nullONLY after the consumer has already sent the control_response out-of-band (e.g. a signed HTTP POST echoingrequestId); the SDK will skip its own transport write. Fail-closed: an accidental null means no control_response is sent and the tool stays blocked indefinitely - permission prompts have no park deadline.”
要点:
null是给“已经自己通过别的通道把控制响应发出去了”的高级场景用的(比如你用签名的 HTTP POST 回显了requestId)。- 意外返回
null= 没有任何响应被发出 = 工具被无限期挂起,且没有超时兜底。 - 因此,除非你确实在做 out-of-band 响应,否则
canUseTool永远不要返回null——要么allow,要么deny。
matchedAskRule 的语义
当 matchedAskRule 存在时,表示是用户配置的 ask 规则强制触发了本次提示,而 ask 本身携带工具的 decisionReason〔sdk.d.ts:254-265〕。宿主若要基于 reason 做策略(如自动拒绝某类安全检查)或跑宿主侧自动审批,应把带此字段的 ask 视为“规则强制的人工提示”。
Sandbox
sandbox 在执行层对命令做 OS 级隔离(macOS 用 sandbox-exec,Linux 需 bubblewrap),是“机制级”防线,不依赖字符串匹配。
SandboxSettingsSchema
{
enabled?: boolean;
failIfUnavailable?: boolean; // 开了 `enabled:true`,若沙箱依赖缺失,SDK **宁可报错退出也不裸跑**。部署到没装 `bubblewrap` 的 Linux 容器时,你要么装依赖,要么要显式设置 `failIfUnavailable:false` 降级。
autoAllowBashIfSandboxed?: boolean; // 会绕过`canUseTool`⚠️
allowUnsandboxedCommands?: boolean; // 控制是否允许命令通过 `dangerouslyDisableSandbox` 逃出沙箱。false 则禁止任何命令逃逸沙箱。
network?: {
allowedDomains?: string[]; deniedDomains?: string[];
strictAllowlist?: boolean; allowManagedDomainsOnly?: boolean;
allowUnixSockets?: string[]; allowAllUnixSockets?: boolean;
allowLocalBinding?: boolean; allowMachLookup?: string[];
httpProxyPort?: number; socksProxyPort?: number;
tlsTerminate?: { caCertPath?: string; caKeyPath?: string };
};
filesystem?: {
allowWrite?: string[]; denyWrite?: string[];
denyRead?: string[]; allowRead?: string[];
allowManagedReadPathsOnly?: boolean; disabled?: boolean;
};
credentials?: {
files?: { path: string; mode: "deny" }[];
envVars?: { name: string; mode: "deny" | "mask"; injectHosts?: string[] }[]; // 支持 `mode:"deny"`(完全不可见)和 `mode:"mask"`(打码)
allowPlaintextInject?: boolean; // 控制是否允许明文注入凭证。
};
ignoreViolations?: Record<string, string[]>;
enableWeakerNestedSandbox?: boolean;
enableWeakerNetworkIsolation?: boolean;
allowAppleEvents?: boolean;
excludedCommands?: string[];
ripgrep?: { command: string; args?: string[] };
bwrapPath?: string; socatPath?: string;
}
Hook
31 种事件
export declare type HookEvent =
'PreToolUse' | 'PostToolUse' | 'PostToolUseFailure' | 'PostToolBatch' |
'Notification' | 'UserPromptSubmit' | 'UserPromptExpansion' |
'SessionStart' | 'SessionEnd' | 'Stop' | 'StopFailure' |
'SubagentStart' | 'SubagentStop' | 'PreCompact' | 'PostCompact' |
'PermissionRequest' | 'PermissionDenied' | 'Setup' | 'TeammateIdle' |
'TaskCreated' | 'TaskCompleted' | 'Elicitation' | 'ElicitationResult' |
'ConfigChange' | 'WorktreeCreate' | 'WorktreeRemove' |
'InstructionsLoaded' | 'CwdChanged' | 'FileChanged' | 'DirectoryAdded' | 'MessageDisplay';
注册结构
hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>>;
interface HookCallbackMatcher {
matcher?: string; // 模式匹配(如工具名)
hooks: HookCallback[]; // 回调数组
timeout?: number; // 本 matcher 内所有 hook 的超时(秒)
}
type HookCallback = (input: HookInput, toolUseID: string | undefined, options: { signal: AbortSignal }) =>
Promise<HookJSONOutput>;
〔sdk.d.ts:1508-1521, 821-833〕
用法示例〔sdk.d.ts:1512-1519〕:
hooks: {
PreToolUse: [{ hooks: [async (input) => ({ continue: true })] }]
}
同步输出和异步输出
HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput〔sdk.d.ts:839〕。
同步输出 SyncHookJSONOutput〔sdk.d.ts:6839-6853〕:
{
continue?: boolean; // false 则停止
suppressOutput?: boolean;
stopReason?: string;
decision?: 'approve' | 'block';
systemMessage?: string;
terminalSequence?: string; // 仅通知类 OSC(0,1,2,9,99,777)/BEL,其余被丢
reason?: string;
hookSpecificOutput?: PreToolUseHookSpecificOutput | PostToolUseHookSpecificOutput | ...;
}
异步输出 AsyncHookJSONOutput:{ async: true; asyncTimeout?: number }〔sdk.d.ts:126〕——hook 不阻塞主循环,稍后异步回填。
两个最重要的 specific output
PreToolUse〔sdk.d.ts:2255-2261〕——可在工具执行前改写决策:
{
hookEventName: 'PreToolUse';
permissionDecision?: 'allow' | 'deny' | 'ask' | 'defer'; // defer = 延迟到 canUseTool
permissionDecisionReason?: string;
updatedInput?: Record<string, unknown>; // 改写工具入参
additionalContext?: string;
}
PostToolUse〔sdk.d.ts:2229-2240〕——可在工具执行后改写输出:
{
hookEventName: 'PostToolUse';
additionalContext?: string;
updatedToolOutput?: unknown; // 替换发给模型的工具输出(对所有工具生效,推荐)
updatedMCPToolOutput?: unknown; // 仅对 MCP 工具生效,推荐用上面的
}
边界:
PreToolUse的permissionDecision:'defer'是把裁决权交还给canUseTool,不是放行。PostToolUse优先用updatedToolOutput而非updatedMCPToolOutput〔sdk.d.ts:2237-2238〕。
MCP
type McpServerConfig =
| McpStdioServerConfig // type:'stdio',command/args/env
| McpSSEServerConfig // type:'sse'
| McpHttpServerConfig // type:'http',url/headers/timeout/alwaysLoad
| McpSdkServerConfigWithInstance; // type:'sdk',含活体 McpServer 实例(不可序列化)
mcpServers 是 Record<string, McpServerConfig>,key 是 server 名〔sdk.d.ts:1708〕。
SDK 内建 MCP server
不想起独立进程?可在进程内用 SDK MCP server 暴露自定义工具:
export declare function createSdkMcpServer(_options: {
name: string;
version?: string;
instructions?: string;
tools?: Array<SdkMcpToolDefinition<any>>;
alwaysLoad?: boolean; // true = 所有工具始终进 prompt,不被 tool search 延迟
}): McpSdkServerConfigWithInstance;
export declare function tool<Schema extends AnyZodRawShape>(
_name: string, _description: string, _inputSchema: Schema,
_handler: (args: InferShape<Schema>, extra: unknown) => Promise<CallToolResult>,
_extras?: { annotations?: ToolAnnotations; searchHint?: string; alwaysLoad?: boolean }
): SdkMcpToolDefinition<Schema>;
边界
- 超时:MCP 工具调用受
MCP_TOOL_TIMEOUT(ms)环境变量约束,默认实质无上限〔sdk.d.ts:478-481〕;http/sse配置可设timeout,但 <1000ms 的值会被忽略〔sdk.d.ts:1041-1043〕。进度通知不延长该硬墙。 alwaysLoad:默认开启 tool search 时工具会被延迟加载;alwaysLoad:true让工具始终在 prompt 里。副作用:会阻塞启动直到 server 连上(上限标准 5s 连接超时),即使 MCP 启动本是非阻塞的〔sdk.d.ts:1046-1048, 495-502〕。- 连接状态:
McpServerStatus.status为'connected'|'failed'|'needs-auth'|'pending'|'disabled'〔sdk.d.ts:1083〕。 - elicitation:MCP server 请求用户输入时走
onElicitation回调;无回调且无 hook 处理则自动拒绝〔sdk.d.ts:1522-1542〕。
Agents
agents?: Record<string, AgentDefinition>;
type AgentDefinition = {
description: string; // 何时使用此 agent(必需)
prompt: string; // 系统提示(必需)
tools?: string[]; // 省略则继承父级全部
disallowedTools?: string[];
model?: string; // 别名('fable'等)或完整 ID;省略/'inherit' 用主模型
mcpServers?: AgentMcpServerSpec[];
skills?: string[];
initialPrompt?: string; // 作为主线程 agent 时自动提交的首条 user turn
maxTurns?: number;
background?: boolean; // 后台、非阻塞、fire-and-forget
memory?: 'user' | 'project' | 'local';
effort?: ('low'|'medium'|'high'|'xhigh'|'max') | number; // 注意:可传整数
permissionMode?: PermissionMode;
observer?: string; // 自动后台观察者,只读 digest,不参与任务
observerMessage?: string;
criticalSystemReminder_EXPERIMENTAL?: string;
};
tools省略 = 继承父级全部工具,不是“无工具”。想限制就得显式列。effort在AgentDefinition里接受整数(number),与Options.effort(仅EffortLevel)不同〔sdk.d.ts:87 vs 1664〕。disallowedTools支持 MCP server 级规格(mcp__server、mcp__server__*、mcp__*)一次性移除整台 server 的工具〔sdk.d.ts:48-50〕。
BashInput
当模型调用内置 Bash 工具时,其 input 形状为 BashInput〔sdk-tools.d.ts:522-553〕:
interface BashInput {
command: string; // 必需
timeout?: number; // 毫秒,最大 600000
description?: string; // active voice,禁用 "complex"/"risk" 字样
run_in_background?: boolean;
dangerouslyDisableSandbox?: boolean; // 危险:绕过沙箱
}
边界:
timeout上限 600000ms(10 分钟)。dangerouslyDisableSandbox是 §5.3 ③讨论的逃逸口——安全敏感场景应在canUseTool里显式拒绝input.dangerouslyDisableSandbox === true,并把sandbox.allowUnsandboxedCommands设false双保险〔src/lib/agent-sandbox.ts:202-204, 122-123〕。
案例:构建多租户隔离的问答 Agent
构建每用户隔离的沙箱
import type { CanUseTool, SandboxSettings } from "@anthropic-ai/claude-agent-sdk";
// ① 利用 env“整体替换而非合并”:只注入最小变量,绝不 spread process.env
const env: Record<string, string> = {
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
HOME: userDir, // 不暴露真实 HOME
CLAUDE_CONFIG_DIR: claudeConfigDir, // session 按用户物理隔离落盘
LARKSUITE_CLI_APP_SECRET: FEISHU_APP_SECRET, // 靠 Bash 守卫+sandbox 防读取/外泄
// ...仅白名单 ANTHROPIC_* 变量
};
// ② OS sandbox:第二层机制级防线
const sandbox: SandboxSettings = {
enabled: true,
failIfUnavailable: failClosed, // 默认 fail-closed:依赖缺失直接报错,绝不裸跑
autoAllowBashIfSandboxed: false, // ⚠️ 默认 true 会绕过 canUseTool 白名单!
allowUnsandboxedCommands: false, // 禁止 dangerouslyDisableSandbox 逃逸
network: { allowedDomains: FEISHU_NETWORK_DOMAINS }, // 仅放行飞书域名,防外泄
};
// ③ canUseTool 白名单:第一层,永不返回 null
export const canUseTool: CanUseTool = async (toolName, input) => {
if (toolName !== "Bash")
return { behavior: "deny", message: `禁止使用工具 ${toolName}` };
if (input.run_in_background === true)
return { behavior: "deny", message: "禁止后台执行命令" };
if (input.dangerouslyDisableSandbox === true) // 双保险拦截逃逸口
return { behavior: "deny", message: "禁止关闭沙箱" };
const verdict = approveBashCommand(input.command); // 禁 shell 元字符 + 子命令白名单
if (!verdict.ok) return { behavior: "deny", message: verdict.reason };
return { behavior: "allow" };
};
组装 query()
const q = query({
prompt: userPrompt, // 流式输入场景可传 AsyncIterable<SDKUserMessage>
options: {
tools: ["Bash"], // 白名单化工具集(不是 allowedTools!)
cwd: sandbox.cwd, // 每用户独立 cwd
env: sandbox.env, // 最小环境
sandbox: sandbox.sandbox,
canUseTool, // 每次调用裁决
permissionMode: "default",
maxTurns: 15, // 防跑飞;超限会得到 error_max_turns
settingSources: [], // SDK 隔离模式:不读任何文件系统配置
...(resumeId ? { resume: resumeId } : {}), // 续聊
},
});
for await (const msg of q) {
if (msg.type === "system" && msg.subtype === "init") {
currentSessionId = msg.session_id; // 捕获 session_id 供下次 resume
} else if (msg.type === "assistant") { /* SSE 推流给前端 */ }
else if (msg.type === "result") {
if (msg.is_error) { /* 区分 error_max_turns / error_max_budget_usd 等 */ }
}
}