状态: Draft
日期: 2026-05-19
组件: Sandbox、Approval、Hook、ToolExecutor、Layer
1. 摘要
当前项目的沙箱模块仅有字符串黑名单检查(string.includes()),审批模块不存在,钩子系统 8 个点只接入了 3 个且纯观察性无决策能力。ToolExecutor 是普通 class + 构造函数注入,脱离 Effect 体系,导致 HookService 不得不暴露 emitSync 这种"后门"方法。
本 RFC 实施三个模块的改造:沙箱接入 @vscode/sandbox-runtime 实现 OS 级隔离,新建六层审批流水线(规则引擎→只读白名单→权限模式→Hook PreToolUse→用户确认→审计日志),钩子系统补全接入点并增加决策返回值,ToolExecutor 改造为 Effect Service 消除手动依赖注入。
2. 用户场景
2.1 高危命令被拦截
用户输入:"帮我清理一下临时文件"。模型生成 rm -rf /tmp/some-project/*。某个 prompt injection 让它变成了 rm -rf /。
- 当前:
sandbox/index.ts 检查 command.includes('rm -rf /') → 换个写法 rm -rf /home/./user/../ 就绕过了
- 改造后:规则引擎(Layer 1)内置
deny-rm-rf-root 规则匹配 / * → 直接拒绝,不弹确认。即使规则被绕过,bubblewrap 的 mount namespace 只暴露了项目目录,/ 根本不可见
2.2 只读分析任务不需要确认
用户输入:"帮我分析这个函数的性能瓶颈"。模型使用 Read、Grep、Glob 读取代码后输出分析报告。
- 当前:所有工具走同一逻辑,没有"只读自动通过"的路径
- 改造后:只读白名单(Layer 2)自动通过 Read/Glob/Grep/LSP,零次弹窗
2.3 钩子拦截并修改工具参数
用户配置了 PreToolUse hook:所有 npm install 命令自动追加 --registry https://internal-mirror.com。
- 当前:hook handler 返回
void,无法修改参数
- 改造后:PreToolUse handler 返回
{ modifiedInput: { command: 'npm install --registry ...' } },流水线使用修改后的参数继续执行
3. 背景与问题
3.1 当前安全架构
模型输出 tool_call → ToolExecutor.execute() → sandbox.allowTool() → 执行
│
返回 boolean,纯应用层字符串匹配
整个安全链路只有一个点。
3.2 沙箱的致命缺陷
当前 sandbox/index.ts 的实现:
allowCommand(command: string): boolean {
for (const pattern of this.denyCommands) {
if (command.includes(pattern)) return false; // 一行就能绕过
}
return true;
}
攻击面:路径规范化绕过、参数顺序变换、管道拆分、alias 替换。
3.3 审批模块不存在
缺失的:
- 没有 deny/allow/ask 三级决策
- 没有规则引擎(glob/regex 模式匹配)
- 没有权限模式(只读/默认/跳过)
- 没有用户确认弹窗
- 没有"Always/Never"学习机制
3.4 钩子系统的三个问题
| 问题 |
详情 |
| 8 个点只接入了 3 个 |
tool.execute.* 三个点接入;llm.request.* 和 session.save.* 有定义无调用 |
| 纯观察性,不能决策 |
HookHandler = (payload) => void,无法返回 deny/allow/modifiedInput |
| 无 PreToolUse 钩子 |
当前只有 tool.execute.before(通知性质),没有审批决策钩子 |
3.5 ToolExecutor 脱离 Effect 体系
当前 tools/executor.ts 是普通 class + 构造函数注入:
export class ToolExecutor {
constructor(
private registry: ToolService, // Effect Service
private hooks: HookService, // Effect Service
private sandbox: Sandbox, // 普通 interface → new DefaultSandbox()
) {}
}
连锁问题:
-
HookService 被迫开"后门" — hooks/registry.ts:32-37 的 emitSync 注释写明 "for callers outside Effect context (ToolExecutor)"。这是被 ToolExecutor 的 class 形态逼出来的妥协。
-
cli.ts:86-87 手动 new 对象 — new DefaultSandbox() 和 new ToolExecutor(tools, hooks, sandbox) 完全绕过 Layer 体系。每加一个 Service 依赖都需改 cli.ts 和构造函数签名。
-
server/index.ts:6-8 全部 any 类型 — ServerDeps 的 llm、executor、hooks 都是 any,通过 Hono context 传递,零类型安全。
根本原因:Sandbox 是普通 interface → ToolExecutor 无法成为 Effect Service → 依赖链污染。解法:Sandbox 和 ToolExecutor 都改为 Effect Service。
4. 设计目标
- 沙箱接入
@vscode/sandbox-runtime:替换字符串黑名单,实现 OS 级文件系统和网络隔离
- 新建审批模块:六层流水线(规则引擎→只读白名单→权限模式→Hook PreToolUse→用户确认→审计日志),权限模式(default/acceptEdits/dontAsk/plan/bypass)
- 补全钩子系统:接入 5 个缺失的钩子点,增加 PreToolUse/PostToolUse 决策钩子,handler 可返回决策,删除
emitSync
- ToolExecutor 改造为 Effect Service:消除手动依赖注入和
emitSync
5. 非目标
- 不实现 ML 分类器 —— 需要额外 LLM 调用成本
- 不实现子 Agent 委派工具 —— 审批模块就绪后再做
- 不保留旧 sandbox 的
allowCommand/allowPath API 兼容 —— 直接替换
- 不保留
ToolExecutor 的 class 形式 —— 直接改为 Effect Service
- 不保留
HookService.emitSync —— 删除
6. 核心设计
6.1 Approval Pipeline(六层)
ToolCallRequest
│
▼
┌─────────────────────────────┐
│ Layer 1: Rule Engine │ ← 系统硬规则优先(deny rm -rf /、sudo 等),不可绕过
└─────────────────────────────┘
│ (无匹配规则)
▼
┌─────────────────────────────┐
│ Layer 2: Read-only Whitelist│ ← Read/Glob/Grep/LSP 自动 allow,零开销
└─────────────────────────────┘
│ (写操作工具)
▼
┌─────────────────────────────┐
│ Layer 3: Permission Mode │ ← plan=仅读, bypass=全过, acceptEdits=编辑自动过
└─────────────────────────────┘ (全局短路策略,影响后续所有层)
│ (default 模式)
▼
┌─────────────────────────────┐
│ Layer 4: Hook PreToolUse │ ← 用户自定义策略,可 deny/allow/ask/modifyInput
└─────────────────────────────┘ 系统已处理完明确的 deny/allow,钩子处理剩余
│ (无决策 或 ask)
▼
┌─────────────────────────────┐
│ Layer 5: User Confirmation │ ← Y/N/A/V 交互弹窗 + 超时 60s → deny
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ Layer 6: Audit / Log │ ← 无论前面哪层做的决策,最终都经过此层记录审计日志
└─────────────────────────────┘
为什么 Hook 是 Layer 4 而不是 Layer 1?
系统硬规则(deny rm -rf /)和安全白名单(read-only 工具)是全局安全底线,不应被用户钩子覆盖。如果 Hook 放在第一层,钩子返回 allow 后又走系统校验——语义矛盾。系统先过滤掉明确的 deny 和 allow,Hook 仅处理剩余的不确定情况,职责更清晰。
为什么 Layer 6 是审计日志而不是 Deny Fallback?
"超时/未确认→拒绝"是 User Confirmation 层的超时行为,不构成独立层。审计日志层确保无论决策来自哪一层(规则引擎 deny、白名单 allow、用户确认允许),最终都经过统一审计点记录,便于追溯。
6.2 各层决策返回值行为
type ApprovalResult =
| { type: 'deny'; reason: string; source: string } // 拒绝,不执行
| { type: 'allow'; source: string } // 允许,继续执行
| { type: 'ask'; source: string } // 需要用户确认
| { type: 'modified'; input: Record<string, unknown>; source: string } // 修改参数后继续
| { type: 'continue' } // 不决策,流入下一层
| 层 |
可返回 |
短路能力 |
| Rule Engine |
deny, allow, continue |
deny/allow 均短路 |
| Read-only Whitelist |
allow, continue |
allow 短路 |
| Permission Mode |
deny, allow, continue |
deny/allow 均短路 |
| Hook PreToolUse |
deny, allow, ask, modified, continue |
deny 短路;allow 跳过 Layer 5 直接到 Audit |
| User Confirmation |
deny, allow |
— |
| Audit / Log |
continue(只记录,不决策) |
永不短路 |
Hook 层(Layer 4)的返回值语义:
interface HookDecision {
decision?: 'allow' | 'deny' | 'ask';
reason?: string;
modifiedInput?: Record<string, unknown>;
}
| handler 返回 |
流水线行为 |
null / undefined |
钩子无意见,继续进入 Layer 5(User Confirmation) |
{ decision: 'deny', reason: '...' } |
短路,跳过 Layer 5,直接到 Layer 6(Audit),工具不执行 |
{ decision: 'allow' } |
短路,跳过 Layer 5,直接到 Layer 6(Audit),工具执行 |
{ decision: 'ask' } |
继续进入 Layer 5(User Confirmation)弹窗 |
{ modifiedInput: {...} } |
修改后的参数替换原参数,继续流转(可配合 decision 或单独使用) |
6.3 新建 approval/ 模块
packages/codingcode/src/approval/
├── types.ts # ApprovalResult, ToolCallRequest, PermissionRule, PermissionMode
├── rule-engine.ts # RuleEngine: addRule/removeRule/evaluate, minimatch 模式匹配
├── pipeline.ts # ApprovalPipeline: 六层流水线串联
├── confirmation.ts # UserConfirmation: TUI Y/N/A/V + Always/Never 规则写入 + 超时 60s
├── presets.ts # DEFAULT_DENY_RULES, READONLY_TOOLS
└── index.ts # ApprovalService (Effect)
6.4 替换沙箱模块
当前 sandbox/index.ts 约 60 行普通 class + interface,全部替换为 Effect Service:
// sandbox/index.ts
export class SandboxService extends Effect.Service<SandboxService>()('Sandbox', {
effect: Effect.gen(function* () {
return {
initialize: (cfg: SandboxConfig) =>
Effect.promise(() => SandboxManager.initialize({
network: {
allowedDomains: cfg.allowedDomains ?? [],
deniedDomains: cfg.deniedDomains ?? [],
allowLocalBinding: false, // 必须 false,否则可通过 IP 绕过域名白名单
allowUnixSockets: cfg.allowUnixSockets ?? [],
},
filesystem: {
denyRead: cfg.denyReadPaths ?? [],
allowRead: cfg.allowReadPaths ?? [],
allowWrite: cfg.allowWritePaths ?? [],
denyWrite: cfg.denyWritePaths ?? [],
},
})),
wrapCommand: (command: string) =>
Effect.promise(() => SandboxManager.wrapWithSandbox(command)),
execute: (opts: ExecuteOptions): Effect.Effect<ExecResult> =>
Effect.gen(function* () {
if (!SandboxManager.isAvailable()) {
// Windows / 不支持平台:降级为纯应用级审批 + Node.js timeout
yield* Effect.logWarning(
'Sandbox runtime not available on this platform, falling back to application-level restrictions',
);
return yield* executeWithTimeoutOnly(opts);
}
return yield* Effect.tryPromise(() =>
SandboxManager.executeWrapped(opts.command, {
timeout: opts.timeoutMs ?? 60000,
}),
);
}),
isAvailable: () => Effect.sync(() => SandboxManager.isAvailable()),
cleanup: () => Effect.promise(() => SandboxManager.cleanup()),
};
}),
}) {}
沙箱风险验证结论(实际调研 @vscode/sandbox-runtime):
| 问题 |
结论 |
影响 |
应对 |
| 域名白名单代理 |
✅ 原生支持 HTTP/SOCKS proxy |
— |
必须设置 allowLocalBinding: false 防止 IP 绕过 |
| cgroup 资源限制 |
❌ SRT 不支持 |
中 |
Timeout 用 spawn({ timeout }),cgroups 后续按需添加 |
| Windows 降级 |
❌ 不支持(含 WSL2) |
低 |
isAvailable() 返回 false → 降级为纯应用级审批 |
| npm 包维护 |
⚠️ 微软 fork 相对活跃 |
中 |
使用 @vscode/sandbox-runtime,监控上游 issue |
关键约束:SRT 只负责隔离(文件系统 + 网络),不负责资源限制(memory/CPU/PID)。
6.5 ToolExecutor 改造为 Effect Service
当前 executor.ts 是普通 class,改为 Effect Service。所有依赖通过 yield* 解析,不再手动注入:
// tools/executor.ts
export class ToolExecutorService extends Effect.Service<ToolExecutorService>()('ToolExecutor', {
effect: Effect.gen(function* () {
return {
execute: (
name: string,
args: unknown,
opts?: { signal?: AbortSignal },
): Effect.Effect<string, AgentError> =>
Effect.gen(function* () {
const registry = yield* ToolService;
const hooks = yield* HookService;
const approval = yield* ApprovalService;
const sandbox = yield* SandboxService;
const toolResult = registry.get(name);
if (!toolResult.ok) return yield* Effect.fail(toolResult.error);
const tool = toolResult.value;
// 1. Approval pipeline
const decision = yield* approval.evaluate({
tool: name,
input: args as Record<string, unknown>,
});
if (decision.type === 'deny') {
yield* hooks.emit('tool.execute.denied', {
toolName: name,
args: args as Record<string, unknown>,
reason: decision.reason,
source: decision.source,
});
return yield* Effect.fail(
AgentError.toolNotAllowed(name, decision.reason),
);
}
// 2. Use modified input from approval pipeline (or hook)
let finalArgs =
(decision as any).modifiedInput ?? (args as Record<string, unknown>);
// 3. Hook PreToolUse decision
const hookDecision = yield* hooks.emitDecision('tool.approval.pre', {
toolName: name,
args: finalArgs,
});
if (hookDecision?.decision === 'deny') {
return yield* Effect.fail(
AgentError.toolNotAllowed(name, hookDecision.reason ?? 'denied by hook'),
);
}
if (hookDecision?.modifiedInput) {
finalArgs = hookDecision.modifiedInput;
}
// 4. Notification hook (观察型)
yield* hooks.emit('tool.execute.before', { toolName: name, args: finalArgs });
const parsedArgs = yield* Effect.sync(() => tool.parameters.parse(finalArgs));
const start = Date.now();
const result = yield* Effect.tryPromise({
try: () => tool.execute(parsedArgs, opts?.signal),
catch: (e) =>
e instanceof AgentError
? e
: AgentError.toolExecutionFailed(name, e),
});
yield* hooks.emit('tool.execute.after', {
toolName: name, args: finalArgs, result,
durationMs: Date.now() - start,
});
return result;
}).pipe(
Effect.tapError((error) =>
hooks.emit('tool.execute.error', {
toolName: name,
args: args as Record<string, unknown>,
error,
}),
),
),
};
}),
}) {}
关键变化:
emitSync 删除,全部走 yield* hooks.emit()
- 审批调用
approval.evaluate() 在工具执行前完成
- 参数修改链路:原始 args → approval pipeline → Hook PreToolUse → 最终执行参数
6.6 钩子系统改造
6.6.1 删除 emitSync
emitSync 是专门为 class-based ToolExecutor 开的"后门"。ToolExecutor 改为 Effect Service 后,没有外部调用者需要它。
6.6.2 接入 5 个缺失的点
| 钩子点 |
接入位置 |
llm.request.before |
llm/client.ts → completeStream() 调用前 |
llm.response.after |
llm/client.ts → response 完成后 |
llm.response.error |
llm/client.ts → response 出错时 |
session.save.before |
session/store.ts → 写入前 |
session.save.after |
session/store.ts → 写入后 |
6.6.3 增加决策钩子点
type HookPoint =
| 'tool.execute.before' | 'tool.execute.after' | 'tool.execute.error'
| 'tool.execute.denied' // 新增:审批拒绝时触发(观察型)
| 'tool.approval.pre' // 新增:审批流水线中,可返回决策
| 'tool.approval.post' // 新增:审批后,可修改输出
| 'llm.request.before' | 'llm.response.after' | 'llm.response.error'
| 'session.save.before' | 'session.save.after';
6.6.4 扩展 handler 类型
type HookHandler =
| ObserverHandler // (payload) => void | Promise<void>
| DecisionHandler; // (payload) => HookDecision | Promise<HookDecision>
interface HookDecision {
decision?: 'allow' | 'deny' | 'ask';
reason?: string;
modifiedInput?: Record<string, unknown>;
modifiedOutput?: unknown;
}
6.6.5 emitDecision 执行策略
问题:"遇到第一个返回非 null 决策的就停止"——"第一个"按什么排序?注册顺序?系统内置 vs 用户注册?
方案:新增 priority 和 source 字段,数字小的先执行。系统内置 hooks 用负优先级:
interface HookRegistration {
id: string;
event: HookPoint;
handler: DecisionHandler;
priority: number; // 数字小的先执行
source: 'system' | 'user'; // 来源标记
}
// 系统内置 hooks(审计、安全策略)
// priority: -100 ~ -1
// 用户注册的 hooks
// priority: 0 ~ 100(默认 0,数字小的先执行)
emitDecision 方法:
emitDecision(
point: HookPoint,
payload: Record<string, unknown>,
): Effect.Effect<HookDecision | null> {
return Effect.gen(function* () {
const handlers = getSortedByPriority(point); // 按 priority 升序
for (const h of handlers) {
const result = yield* Effect.tryPromise(() => h.handler(payload));
if (result != null) return result; // 第一个非 null 决策即生效
}
return null;
});
}
执行顺序:
- 所有 handler 按
priority 升序排列(系统优先,因为有负值)
- 逐个调用,遇到第一个返回非 null 的
HookDecision 就停止
- 同 priority 的 handler 按注册先后顺序执行
这样系统内置的审计/安全钩子(priority -100)一定先于用户钩子(priority 0+)执行。
6.7 并发工具调用处理
Claude Code 支持一次请求生成多个 tool call(parallel function calling)。审批流水线需要处理并发场景:
LLM 返回 3 个 tool_call:
tc1: Read(fileA) — 只读
tc2: Bash(rm fileB) — 破坏性写
tc3: Edit(fileC) — 写
策略:
-
所有 tool call 的审批各自独立并行 — 每个 tool call 走完整的六层流水线,互不阻塞。因为每个 tool call 的审批决策只取决于自己的工具名和参数。
-
一个被 deny 不取消 sibling — Read 被 deny 不影响 Bash 继续执行。每个工具独立决策。但如果需要"事务性"语义(all-or-nothing),由上层调用者决定。
-
破坏性工具(Bash)必须串行执行 — 不是串行审批,而是串行执行。审批可以并行,但 Bash 的执行必须串行化,避免竞态条件。当前 agent.ts:97-109 用 Effect.forEach + concurrency: 'unbounded' 并行执行所有工具。改造后:
// agent.ts: runReActLoop 内
const safeTools = toolCalls.filter(tc => tc.name !== 'Bash');
const bashTools = toolCalls.filter(tc => tc.name === 'Bash');
// 安全工具并行执行
const safeResults = await Effect.runPromise(
Effect.forEach(safeTools, tc =>
executor.execute(tc.name, tc.arguments, { signal: controllers[i].signal }),
{ concurrency: 'unbounded' },
),
);
// Bash 工具串行执行(避免竞态)
const bashResults: any[] = [];
for (const tc of bashTools) {
const r = await Effect.runPromise(
executor.execute(tc.name, tc.arguments, { signal: controllers[i].signal }),
);
bashResults.push(r);
}
const results = [...safeResults, ...bashResults];
6.8 runReActLoop 的 AsyncGenerator 边界
agent/agent.ts:47 中 runReActLoop 是 async function*(AsyncGenerator),不是 Effect.gen。这意味着:
- 不能使用
yield* 调用 Effect Service 方法
- 必须在边界处使用
Effect.runPromise() 将 Effect 转为 Promise
当前代码已经这样做了(agent.ts:98-109 用 Effect.runPromise(Effect.forEach(...)))。改造后继续保持这个模式,不引入新的不一致性:
// AgentService 内部用 yield* 获取 executor(在 Effect.gen 上下文内)
export class AgentService extends Effect.Service<AgentService>()('Agent', {
effect: Effect.gen(function* () {
const executor = yield* ToolExecutorService; // ← 这里是在 Effect.gen 内,可以用 yield*
let config: ResolvedConfig = resolveConfig({});
return {
runStream: (messages: Message[], llm: LLMStreamAdapter) =>
runReActLoop(messages, config, llm, executor),
// ↑ AsyncGenerator,在内部用 Effect.runPromise 调用 executor.execute()
};
}),
}) {}
6.9 CLI 简化和 server 类型安全
改造前 — 手动 new,脱离 Layer:
const sandbox = new DefaultSandbox();
const executor = new ToolExecutor(tools, hooks, sandbox);
const app = createServer({ llm: llmResult.value, executor, hooks });
改造后 — 全部走 Layer 解析:
// cli.ts: SandboxService、ToolExecutorService、ApprovalService 由 AppLayer 提供
const app = createServer({ llm: llmResult.value });
server/index.ts ServerDeps 缩减为:
type ServerDeps = {
llm: LLMClient; // 不再是 any
};
路由 handler 中的 sendMessage 不再需要 executor 和 hooks 参数——它们通过 yield* 在 Effect 上下文中解析。
7. 与现有模块的关系
packages/codingcode/src/approval/ — 新建
6 个文件,ApprovalService 作为 Effect Service 注册到 layer.ts。
packages/codingcode/src/sandbox/index.ts — 重写
删除 Sandbox interface + DefaultSandbox class,改为 Effect Service。
packages/codingcode/src/tools/executor.ts — 重写
删除 ToolExecutor class,改为 ToolExecutorService (Effect Service)。
packages/codingcode/src/hooks/registry.ts — 改造
- 删除
emitSync
- 增加
tool.approval.pre、tool.approval.post、tool.execute.denied 钩子点
- 扩展
HookHandler 为 ObserverHandler | DecisionHandler
- 新增
emitDecision(),按 priority 排序执行 handler
HookRegistration 增加 priority 和 source 字段
packages/codingcode/src/agent/agent.ts — 改造
AgentService 内部 yield* ToolExecutorService
runStream() 签名去掉 executor 参数
- 并发工具调用:安全工具并行,Bash 串行
packages/codingcode/src/orchestrate.ts — 改造
sendMessage 去掉 executor 和 hooks 参数
packages/codingcode/src/llm/client.ts — 改造
- 接入
llm.request.before/after/error 三个钩子
packages/codingcode/src/session/store.ts — 改造
- 接入
session.save.before/after 两个钩子
packages/codingcode/src/cli.ts — 改造
- 删除
new DefaultSandbox() 和 new ToolExecutor(...)
packages/codingcode/src/server/index.ts — 改造
packages/codingcode/src/layer.ts — 改造
const InfraLayer = Layer.mergeAll(
ToolLayer, // ToolService
HookLayer, // HookService
SandboxLayer, // SandboxService
ApprovalLayer, // ApprovalService
);
const ToolExecutorLayer = ToolExecutorService.Default.pipe(
Layer.provide(InfraLayer),
);
export const AgentLayer = AgentService.Default.pipe(
Layer.provide(ToolExecutorLayer),
);
测试文件
- 新建
test/approval/rule-engine.test.ts — 规则匹配(glob、regex、优先级、参数匹配)
- 新建
test/approval/pipeline.test.ts — 六层流转(每层的 allow/deny/ask/continue 路径)
- 新建
test/approval/presets.test.ts — 默认规则集验证
- 新建
test/hooks/decision.test.ts — emitDecision 排序策略、系统/用户优先级
- 新建
test/tools/executor-approval.test.ts — ToolExecutorService 批准/拒绝/修改参数路径
- 新建
test/sandbox/wrap-command.test.ts — 命令包装、降级行为
- 修改
test/hooks/registry.test.ts — 删除 emitSync 测试,补充新钩子点测试
- 修改
test/agent/agent.test.ts — 并发工具调用的串行/并行行为
8. 测试场景
- 高危命令
rm -rf / → Layer 1 规则引擎直接 deny,不弹确认
- 只读工具 Read → Layer 2 白名单自动 allow
- 写工具 Edit → Layer 2 无匹配,Layer 3 default 模式,进入 Layer 4 Hook(无决策),进入 Layer 5 用户确认
acceptEdits 模式 + Edit → Layer 3 短路 allow,不弹确认
plan 模式 + Write → Layer 3 直接 deny
bypass 模式 + Bash → Layer 3 allow,沙箱仍然限制文件系统和网络
- PreToolUse hook(Layer 4)返回
{ modifiedInput } → 修改后的参数被后续层使用
- PreToolUse hook(Layer 4)返回
{ decision: 'deny' } → 跳过 Layer 5,直接到 Layer 6 审计
- PreToolUse hook(Layer 4)返回
{ decision: 'allow' } → 跳过 Layer 5,直接到 Layer 6 审计
- 无规则匹配 + 非只读 + default 模式 + 无 hook → 进入 Layer 5 用户确认弹窗
- 用户确认超时 60s → Layer 5 返回 deny
- 用户选 Always → 规则引擎新增 allow 规则,下次同类工具自动通过
- 用户选 Never → 规则引擎新增 deny 规则,下次同类工具自动拒绝
- 系统 hook(priority -100)先于用户 hook(priority 0)执行
- 3 个并发 tool call(Read + Bash + Edit)→ Read allow,Edit ask,Bash deny → 各自独立
- Bash 类工具在并发中串行执行
- Windows 平台 →
isAvailable() 返回 false → 降级为纯应用级审批
- Layer 6 审计日志记录每一层的决策来源和结果
9. 假设与默认决策
@vscode/sandbox-runtime 在 Linux 上用 bubblewrap,macOS 上用 sandbox-exec,Windows 上不可用 → isAvailable() 返回 false,降级为应用层审批
- 用户确认弹窗在 TUI 中用 Ink/readline 实现,非 TUI 模式(HTTP server)默认拒绝
- User Confirmation 超时 60 秒无用户响应 → deny(这是 Layer 5 的超时行为,不是独立层)
- 规则引擎用
minimatch 做 glob 匹配,用 RegExp 做正则匹配
- 旧 sandbox 的
denyCommands、denyPaths 配置直接废弃,迁移到规则引擎的 deny 规则
runReActLoop 作为 AsyncGenerator 在边界处使用 Effect.runPromise,与当前 aget.ts 模式一致
- SRT 配置中
allowLocalBinding 必须为 false,防止 IP 地址绕过域名白名单
- 沙箱不负责资源限制(memory/CPU/PID),timeout 由 Node.js
spawn({ timeout }) 处理
10. 实施顺序
- 新建
approval/types.ts — 所有类型定义
- 新建
approval/rule-engine.ts — 规则引擎
- 新建
approval/presets.ts — 默认规则集 + 只读工具白名单
- 新建
approval/confirmation.ts — 用户确认弹窗 + 超时逻辑
- 新建
approval/pipeline.ts — 六层流水线串联
- 新建
approval/index.ts — ApprovalService (Effect)
- 改造
hooks/registry.ts — 新增 HookPoint、HookDecision 类型、priority+source、emitDecision、删除 emitSync
- 改造
sandbox/index.ts — 改为 SandboxService (Effect),接入 @vscode/sandbox-runtime
- 改造
tools/executor.ts — 改为 ToolExecutorService (Effect),插入审批流水线
- 改造
agent/agent.ts — AgentService yield* ToolExecutorService,并发 Bash 串行化
- 改造
orchestrate.ts — sendMessage 去掉 executor/hooks 参数
- 改造
llm/client.ts — 接入 LLM 钩子
- 改造
session/store.ts — 接入 session 钩子
- 改造
server/index.ts — ServerDeps 缩减
- 改造
cli.ts — 删除手动 new
- 改造
layer.ts — 注册新 Service,调整依赖图
- 编写全部测试文件
11. 一句话结论
新建六层审批流水线(规则引擎→只读白名单→权限模式→Hook PreToolUse→用户确认→审计日志),沙箱接入 @vscode/sandbox-runtime 实现 OS 级隔离,钩子补全接入点并增加优先级决策能力。核心改进:将 ToolExecutor 改造为 Effect Service,消除 emitSync 后门和手动依赖注入,统一整个依赖链为 Effect Layer 体系。
状态: Draft
日期: 2026-05-19
组件: Sandbox、Approval、Hook、ToolExecutor、Layer
1. 摘要
当前项目的沙箱模块仅有字符串黑名单检查(
string.includes()),审批模块不存在,钩子系统 8 个点只接入了 3 个且纯观察性无决策能力。ToolExecutor是普通 class + 构造函数注入,脱离 Effect 体系,导致HookService不得不暴露emitSync这种"后门"方法。本 RFC 实施三个模块的改造:沙箱接入
@vscode/sandbox-runtime实现 OS 级隔离,新建六层审批流水线(规则引擎→只读白名单→权限模式→Hook PreToolUse→用户确认→审计日志),钩子系统补全接入点并增加决策返回值,ToolExecutor 改造为 Effect Service 消除手动依赖注入。2. 用户场景
2.1 高危命令被拦截
用户输入:"帮我清理一下临时文件"。模型生成
rm -rf /tmp/some-project/*。某个 prompt injection 让它变成了rm -rf /。sandbox/index.ts检查command.includes('rm -rf /')→ 换个写法rm -rf /home/./user/../就绕过了deny-rm-rf-root规则匹配/ *→ 直接拒绝,不弹确认。即使规则被绕过,bubblewrap 的 mount namespace 只暴露了项目目录,/根本不可见2.2 只读分析任务不需要确认
用户输入:"帮我分析这个函数的性能瓶颈"。模型使用 Read、Grep、Glob 读取代码后输出分析报告。
2.3 钩子拦截并修改工具参数
用户配置了 PreToolUse hook:所有
npm install命令自动追加--registry https://internal-mirror.com。void,无法修改参数{ modifiedInput: { command: 'npm install --registry ...' } },流水线使用修改后的参数继续执行3. 背景与问题
3.1 当前安全架构
整个安全链路只有一个点。
3.2 沙箱的致命缺陷
当前 sandbox/index.ts 的实现:
攻击面:路径规范化绕过、参数顺序变换、管道拆分、alias 替换。
3.3 审批模块不存在
缺失的:
3.4 钩子系统的三个问题
tool.execute.*三个点接入;llm.request.*和session.save.*有定义无调用HookHandler = (payload) => void,无法返回 deny/allow/modifiedInputtool.execute.before(通知性质),没有审批决策钩子3.5 ToolExecutor 脱离 Effect 体系
当前 tools/executor.ts 是普通 class + 构造函数注入:
连锁问题:
HookService被迫开"后门" — hooks/registry.ts:32-37 的emitSync注释写明 "for callers outside Effect context (ToolExecutor)"。这是被 ToolExecutor 的 class 形态逼出来的妥协。cli.ts:86-87 手动 new 对象 —
new DefaultSandbox()和new ToolExecutor(tools, hooks, sandbox)完全绕过 Layer 体系。每加一个 Service 依赖都需改 cli.ts 和构造函数签名。server/index.ts:6-8 全部
any类型 —ServerDeps的llm、executor、hooks都是any,通过 Hono context 传递,零类型安全。根本原因:Sandbox 是普通 interface → ToolExecutor 无法成为 Effect Service → 依赖链污染。解法:Sandbox 和 ToolExecutor 都改为 Effect Service。
4. 设计目标
@vscode/sandbox-runtime:替换字符串黑名单,实现 OS 级文件系统和网络隔离emitSyncemitSync5. 非目标
allowCommand/allowPathAPI 兼容 —— 直接替换ToolExecutor的 class 形式 —— 直接改为 Effect ServiceHookService.emitSync—— 删除6. 核心设计
6.1 Approval Pipeline(六层)
为什么 Hook 是 Layer 4 而不是 Layer 1?
系统硬规则(deny rm -rf /)和安全白名单(read-only 工具)是全局安全底线,不应被用户钩子覆盖。如果 Hook 放在第一层,钩子返回 allow 后又走系统校验——语义矛盾。系统先过滤掉明确的 deny 和 allow,Hook 仅处理剩余的不确定情况,职责更清晰。
为什么 Layer 6 是审计日志而不是 Deny Fallback?
"超时/未确认→拒绝"是 User Confirmation 层的超时行为,不构成独立层。审计日志层确保无论决策来自哪一层(规则引擎 deny、白名单 allow、用户确认允许),最终都经过统一审计点记录,便于追溯。
6.2 各层决策返回值行为
Hook 层(Layer 4)的返回值语义:
null/undefined{ decision: 'deny', reason: '...' }{ decision: 'allow' }{ decision: 'ask' }{ modifiedInput: {...} }6.3 新建
approval/模块6.4 替换沙箱模块
当前
sandbox/index.ts约 60 行普通 class + interface,全部替换为 Effect Service:沙箱风险验证结论(实际调研
@vscode/sandbox-runtime):allowLocalBinding: false防止 IP 绕过spawn({ timeout }),cgroups 后续按需添加isAvailable()返回 false → 降级为纯应用级审批@vscode/sandbox-runtime,监控上游 issue关键约束:SRT 只负责隔离(文件系统 + 网络),不负责资源限制(memory/CPU/PID)。
6.5 ToolExecutor 改造为 Effect Service
当前 executor.ts 是普通 class,改为 Effect Service。所有依赖通过
yield*解析,不再手动注入:关键变化:
emitSync删除,全部走yield* hooks.emit()approval.evaluate()在工具执行前完成6.6 钩子系统改造
6.6.1 删除
emitSyncemitSync是专门为 class-based ToolExecutor 开的"后门"。ToolExecutor 改为 Effect Service 后,没有外部调用者需要它。6.6.2 接入 5 个缺失的点
llm.request.beforellm/client.ts→completeStream()调用前llm.response.afterllm/client.ts→response完成后llm.response.errorllm/client.ts→response出错时session.save.beforesession/store.ts→ 写入前session.save.aftersession/store.ts→ 写入后6.6.3 增加决策钩子点
6.6.4 扩展 handler 类型
6.6.5 emitDecision 执行策略
问题:"遇到第一个返回非 null 决策的就停止"——"第一个"按什么排序?注册顺序?系统内置 vs 用户注册?
方案:新增
priority和source字段,数字小的先执行。系统内置 hooks 用负优先级:emitDecision方法:执行顺序:
priority升序排列(系统优先,因为有负值)HookDecision就停止这样系统内置的审计/安全钩子(priority -100)一定先于用户钩子(priority 0+)执行。
6.7 并发工具调用处理
Claude Code 支持一次请求生成多个 tool call(parallel function calling)。审批流水线需要处理并发场景:
策略:
所有 tool call 的审批各自独立并行 — 每个 tool call 走完整的六层流水线,互不阻塞。因为每个 tool call 的审批决策只取决于自己的工具名和参数。
一个被 deny 不取消 sibling — Read 被 deny 不影响 Bash 继续执行。每个工具独立决策。但如果需要"事务性"语义(all-or-nothing),由上层调用者决定。
破坏性工具(Bash)必须串行执行 — 不是串行审批,而是串行执行。审批可以并行,但 Bash 的执行必须串行化,避免竞态条件。当前 agent.ts:97-109 用
Effect.forEach+concurrency: 'unbounded'并行执行所有工具。改造后:6.8 runReActLoop 的 AsyncGenerator 边界
agent/agent.ts:47 中
runReActLoop是async function*(AsyncGenerator),不是Effect.gen。这意味着:yield*调用 Effect Service 方法Effect.runPromise()将 Effect 转为 Promise当前代码已经这样做了(agent.ts:98-109 用
Effect.runPromise(Effect.forEach(...)))。改造后继续保持这个模式,不引入新的不一致性:6.9 CLI 简化和 server 类型安全
改造前 — 手动 new,脱离 Layer:
改造后 — 全部走 Layer 解析:
server/index.ts
ServerDeps缩减为:路由 handler 中的
sendMessage不再需要executor和hooks参数——它们通过yield*在 Effect 上下文中解析。7. 与现有模块的关系
packages/codingcode/src/approval/— 新建6 个文件,
ApprovalService作为 Effect Service 注册到layer.ts。packages/codingcode/src/sandbox/index.ts— 重写删除
Sandboxinterface +DefaultSandboxclass,改为 Effect Service。packages/codingcode/src/tools/executor.ts— 重写删除
ToolExecutorclass,改为ToolExecutorService(Effect Service)。packages/codingcode/src/hooks/registry.ts— 改造emitSynctool.approval.pre、tool.approval.post、tool.execute.denied钩子点HookHandler为ObserverHandler | DecisionHandleremitDecision(),按 priority 排序执行 handlerHookRegistration增加priority和source字段packages/codingcode/src/agent/agent.ts— 改造AgentService内部yield* ToolExecutorServicerunStream()签名去掉executor参数packages/codingcode/src/orchestrate.ts— 改造sendMessage去掉executor和hooks参数packages/codingcode/src/llm/client.ts— 改造llm.request.before/after/error三个钩子packages/codingcode/src/session/store.ts— 改造session.save.before/after两个钩子packages/codingcode/src/cli.ts— 改造new DefaultSandbox()和new ToolExecutor(...)packages/codingcode/src/server/index.ts— 改造ServerDeps缩减为只含llmpackages/codingcode/src/layer.ts— 改造测试文件
test/approval/rule-engine.test.ts— 规则匹配(glob、regex、优先级、参数匹配)test/approval/pipeline.test.ts— 六层流转(每层的 allow/deny/ask/continue 路径)test/approval/presets.test.ts— 默认规则集验证test/hooks/decision.test.ts— emitDecision 排序策略、系统/用户优先级test/tools/executor-approval.test.ts— ToolExecutorService 批准/拒绝/修改参数路径test/sandbox/wrap-command.test.ts— 命令包装、降级行为test/hooks/registry.test.ts— 删除 emitSync 测试,补充新钩子点测试test/agent/agent.test.ts— 并发工具调用的串行/并行行为8. 测试场景
rm -rf /→ Layer 1 规则引擎直接 deny,不弹确认acceptEdits模式 + Edit → Layer 3 短路 allow,不弹确认plan模式 + Write → Layer 3 直接 denybypass模式 + Bash → Layer 3 allow,沙箱仍然限制文件系统和网络{ modifiedInput }→ 修改后的参数被后续层使用{ decision: 'deny' }→ 跳过 Layer 5,直接到 Layer 6 审计{ decision: 'allow' }→ 跳过 Layer 5,直接到 Layer 6 审计isAvailable()返回 false → 降级为纯应用级审批9. 假设与默认决策
@vscode/sandbox-runtime在 Linux 上用 bubblewrap,macOS 上用 sandbox-exec,Windows 上不可用 →isAvailable()返回 false,降级为应用层审批minimatch做 glob 匹配,用RegExp做正则匹配denyCommands、denyPaths配置直接废弃,迁移到规则引擎的 deny 规则runReActLoop作为 AsyncGenerator 在边界处使用Effect.runPromise,与当前 aget.ts 模式一致allowLocalBinding必须为 false,防止 IP 地址绕过域名白名单spawn({ timeout })处理10. 实施顺序
approval/types.ts— 所有类型定义approval/rule-engine.ts— 规则引擎approval/presets.ts— 默认规则集 + 只读工具白名单approval/confirmation.ts— 用户确认弹窗 + 超时逻辑approval/pipeline.ts— 六层流水线串联approval/index.ts— ApprovalService (Effect)hooks/registry.ts— 新增 HookPoint、HookDecision 类型、priority+source、emitDecision、删除 emitSyncsandbox/index.ts— 改为 SandboxService (Effect),接入 @vscode/sandbox-runtimetools/executor.ts— 改为 ToolExecutorService (Effect),插入审批流水线agent/agent.ts— AgentService yield* ToolExecutorService,并发 Bash 串行化orchestrate.ts— sendMessage 去掉 executor/hooks 参数llm/client.ts— 接入 LLM 钩子session/store.ts— 接入 session 钩子server/index.ts— ServerDeps 缩减cli.ts— 删除手动 newlayer.ts— 注册新 Service,调整依赖图11. 一句话结论
新建六层审批流水线(规则引擎→只读白名单→权限模式→Hook PreToolUse→用户确认→审计日志),沙箱接入
@vscode/sandbox-runtime实现 OS 级隔离,钩子补全接入点并增加优先级决策能力。核心改进:将 ToolExecutor 改造为 Effect Service,消除emitSync后门和手动依赖注入,统一整个依赖链为 Effect Layer 体系。