权限层

权限是 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:必须带 messageinterrupt: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 null ONLY after the consumer has already sent the control_response out-of-band (e.g. a signed HTTP POST echoing requestId); 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 工具生效,推荐用上面的
}

边界PreToolUsepermissionDecision:'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 实例(不可序列化)

mcpServersRecord<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 省略 = 继承父级全部工具,不是“无工具”。想限制就得显式列。
  • effortAgentDefinition接受整数number),与 Options.effort(仅 EffortLevel)不同〔sdk.d.ts:87 vs 1664〕。
  • disallowedTools 支持 MCP server 级规格(mcp__servermcp__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.allowUnsandboxedCommandsfalse 双保险〔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 等 */ }
  }
}