Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
不允许假设“这是未来需要扩展的”,所以现在就不做,应该贴合用户的实际要求
不允许总是有阶段性计划,分阶段完成很容易导致过程产生一堆没用的死代码
不许兼容、兜底旧代码
每次执行完以后都要补充测试文件确保实际行为与预期相符
每次执行完以后都要补充测试文件确保实际行为与预期相符
所有的测试文件只能写在test文件夹下
修改过程中发现错误,如果是本次范围就修改,否则要在最后指出
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,27 @@ interface ToolDefinition {

在 `cli.ts` 中向 `ToolRegistry` 注册新工具即可——Agent 会自动将其描述传给 LLM,LLM 即可调用。

### 沙箱
### 沙箱隔离 (可选)

所有工具执行经过 `DefaultSandbox` 过滤:
- **路径白名单**: 限制文件读写范围
- **命令过滤**: 阻止危险命令 (`rm -rf /`, `dd`, `mkfs`, fork bomb, `shutdown`, `reboot`)
- **敏感路径保护**: `/etc/passwd`, `/etc/shadow`, `~/.ssh`, `~/.gnupg` 等不可访问
- **超时控制**: 每个工具独立超时
所有工具执行经过两层安全保护:

**审批流水线**(始终生效):六层决策链——规则引擎(硬 deny,如 `rm -rf /`)→ 只读白名单 → 权限模式 → 钩子策略 → 用户确认 → 审计日志。不需要额外安装,开箱即用。

**OS 级沙箱**(可选,需安装 `@anthropic-ai/sandbox-runtime`):

```bash
npm install -g @anthropic-ai/sandbox-runtime
```

安装后的增强能力:

| 能力 | 说明 |
|---|---|
| **文件系统隔离** | 基于 bubblewrap (Linux) / sandbox-exec (macOS) 的 mount namespace,Bash 命令只能看到项目目录,`/etc`、`/home` 等不可见 |
| **网络隔离** | 内置 HTTP/SOCKS 代理,只允许白名单域名通过,IP 地址直连被拦截 |
| **防绕过** | 即使 prompt injection 让模型生成 `cat /etc/shadow`,沙箱层会拒绝——文件在 OS 层不可见,不依赖字符串匹配 |

不安装不影响使用——审批流水线独立运行,同样能拦截危险命令。沙箱只是多一层 OS 级防御,防止绕过应用层检查的攻击。

---

Expand Down
3 changes: 3 additions & 0 deletions packages/codingcode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"type": "module",
"main": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"optionalDependencies": {
"@anthropic-ai/sandbox-runtime": "^1.0.0"
},
"dependencies": {
"@ai-sdk/deepseek": "^2.0.35",
"@ai-sdk/openai": "^3.0.63",
Expand Down
134 changes: 93 additions & 41 deletions packages/codingcode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { Effect } from 'effect';
import type { Message, ToolCall } from '../core/types.js';
import { AgentError } from '../core/error.js';
import { Result } from '../core/result.js';
import type { AgentConfig, AgentEvent } from './types.js';
import { resolveConfig, type ResolvedConfig } from './config.js';
import type { AgentEvent } from '../bus/types.js';
import type { ToolDescription } from '../tools/types.js';
import { ToolService } from '../tools/registry.js';
import { ToolExecutorService } from '../tools/executor.js';
import { buildSystemPrompt } from '../prompts/index.js';
import { resolveConfig } from './config.js';

interface LLMStreamAdapter {
completeStream(params: {
Expand All @@ -18,55 +21,77 @@ interface LLMStreamAdapter {
};
}

interface ToolExecutorAdapter {
execute(name: string, args: Record<string, unknown>, opts?: { signal?: AbortSignal }): Effect.Effect<string, AgentError>;
getRegistry(): {
describeAll(): Array<{ name: string; description: string; schema: Record<string, unknown> }>;
filter(names: string[]): Array<{ name: string; description: string; schema: Record<string, unknown> }>;
};
type ToolResultUnion =
| { type: 'ok'; id: string; name: string; output: string }
| { type: 'denied'; id: string; name: string; reason: string }
| { type: 'error'; id: string; name: string; output: string };

function execTool(executor: ToolExecutorService, tc: ToolCall): Effect.Effect<ToolResultUnion> {
return executor.execute(tc.name, tc.arguments ?? {}).pipe(
Effect.matchEffect({
onSuccess: (output): Effect.Effect<ToolResultUnion> =>
Effect.succeed({ type: 'ok' as const, id: tc.id, name: tc.name, output }),
onFailure: (err): Effect.Effect<ToolResultUnion> => {
if (err instanceof AgentError && err.code === 'TOOL_NOT_ALLOWED') {
return Effect.succeed({ type: 'denied' as const, id: tc.id, name: tc.name, reason: err.message });
}
const code = err instanceof AgentError ? err.code : 'TOOL_EXECUTION_FAILED';
const msg = err instanceof AgentError ? err.message : String(err);
return Effect.succeed({ type: 'error' as const, id: tc.id, name: tc.name, output: `[Error: ${code}] ${msg}` });
},
}),
Effect.catchAllDefect((defect) =>
Effect.succeed({ type: 'error' as const, id: tc.id, name: tc.name, output: `[Unexpected] ${String(defect)}` }),
),
);
}

export class AgentService extends Effect.Service<AgentService>()('Agent', {
effect: Effect.gen(function* () {
let config: ResolvedConfig = resolveConfig({});
const executor = yield* ToolExecutorService;
const toolRegistry = yield* ToolService;
const maxSteps = resolveConfig().maxSteps;
let skillInstruction: string | undefined;

return {
init: (cfg: AgentConfig): Effect.Effect<void> =>
Effect.sync(() => { config = resolveConfig(cfg); }),
setSkillInstruction: (instruction: string): void => {
skillInstruction = instruction;
},

runStream: (
messages: Message[],
llm: LLMStreamAdapter,
executor: ToolExecutorAdapter,
): AsyncGenerator<AgentEvent, Result<string, AgentError>, unknown> =>
runReActLoop(messages, config, llm, executor),
runReActLoop(messages, maxSteps, skillInstruction, llm, executor, toolRegistry),
};
}),
}) {}

export async function* runReActLoop(
initialMessages: Message[],
config: ResolvedConfig,
maxSteps: number,
skillInstruction: string | undefined,
llm: LLMStreamAdapter,
executor: ToolExecutorAdapter,
executor: ToolExecutorService,
toolRegistry: ToolService,
): AsyncGenerator<AgentEvent, Result<string, AgentError>, unknown> {
const messages = [...initialMessages];
const maxSteps = config.maxSteps;
const basePrompt = buildSystemPrompt({
cwd: process.cwd(),
platform: process.platform,
shell: process.env.SHELL || process.env.ComSpec || 'bash',
});
const system = skillInstruction
? `${basePrompt}\n\n## Skill Instructions\n\n${skillInstruction}`
: basePrompt;

for (let step = 0; step < maxSteps; step++) {
yield { _tag: 'Step', step: step + 1, max: maxSteps };

const registry = executor.getRegistry();
const tools: ToolDescription[] = config.availableTools
? registry.filter(config.availableTools).map((t) => ({
name: t.name, description: t.description, parameters: t.schema,
}))
: registry.describeAll().map((t) => ({
name: t.name, description: t.description, parameters: t.schema,
}));
const tools: ToolDescription[] = toolRegistry.describeAll();

const { stream: rawStream, response: respPromise } = llm.completeStream({
messages, system: config.systemPrompt, tools, maxSteps: 1,
messages, system, tools, maxSteps: 1,
});

for await (const chunk of rawStream) {
Expand All @@ -93,24 +118,51 @@ export async function* runReActLoop(
return Result.ok(resp.content);
}

// Concurrent tool execution — executor.execute returns Effect directly
const controllers = toolCalls.map(() => new AbortController());
const results = await Effect.runPromise(
Effect.forEach(toolCalls, (tc, i) =>
executor.execute(tc.name, tc.arguments ?? {}, { signal: controllers[i].signal }).pipe(
Effect.map((result) => ({ id: tc.id, name: tc.name, ok: true as const, output: result })),
Effect.catchAllCause((cause) => {
const err = cause instanceof AgentError ? cause : AgentError.toolExecutionFailed(tc.name, String(cause));
return Effect.succeed({ id: tc.id, name: tc.name, ok: false as const, output: `[Error: ${err.code}] ${err.message}` });
}),
),
{ concurrency: 'unbounded' },
) as any,
// Separate safe & destructive tools: safe tools run in parallel, Bash runs serially
const safeTools: ToolCall[] = [];
const bashTools: ToolCall[] = [];

for (const tc of toolCalls) {
if (tc.name === 'execute_command' || tc.name === 'Bash') {
bashTools.push(tc);
} else {
safeTools.push(tc);
}
}

// Safe tools — parallel
const safeResults = await Effect.runPromise(
Effect.forEach(safeTools, (tc) => execTool(executor, tc), { concurrency: 'unbounded' }),
);

for (const r of (results as any[])) {
messages.push({ role: 'tool', content: r.output, tool_call_id: r.id, tool_name: r.name });
yield { _tag: 'ToolResult', id: r.id, name: r.name, output: r.output, ok: r.ok };
for (const r of safeResults) {
if (r.type === 'denied') {
yield { _tag: 'ToolDenied', name: r.name, reason: r.reason };
} else {
yield { _tag: 'ToolResult', id: r.id, name: r.name, output: r.output, ok: r.type === 'ok' };
}
}

// Bash tools — serial (avoid race conditions)
const bashResults: ToolResultUnion[] = [];
for (const tc of bashTools) {
const r = await Effect.runPromise(execTool(executor, tc));
bashResults.push(r);
if (r.type === 'denied') {
yield { _tag: 'ToolDenied', name: r.name, reason: r.reason };
} else {
yield { _tag: 'ToolResult', id: r.id, name: r.name, output: r.output, ok: r.type === 'ok' };
}
}

// Feed results back to LLM — denied tools still get a message so the LLM knows
const allResults = [...safeResults, ...bashResults];
for (const r of allResults) {
if (messages.find(m => (m as any).tool_call_id === r.id)) continue;
const content = r.type === 'denied'
? `[Denied] Tool "${r.name}" was denied: ${(r as any).reason as string}`
: (r as any).output ?? '';
messages.push({ role: 'tool', content, tool_call_id: r.id, tool_name: r.name });
}
}

Expand Down
31 changes: 2 additions & 29 deletions packages/codingcode/src/agent/config.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,9 @@
import { loadConfig } from '@codingcode/infra';
import { buildSystemPrompt } from '../prompts/index.js';
import type { AgentConfig } from './types.js';

export interface ResolvedConfig {
systemPrompt: string;
maxSteps: number;
availableTools?: string[];
}

const DEFAULT_TOOLS = [
'read_file',
'write_file',
'list_dir',
'execute_command',
'search_code',
'fetch_url',
];

/**
* 构建最终配置。
* systemPrompt 可被 skill 覆盖;maxSteps 和 availableTools 仅来自配置文件,配置缺失时使用内置默认值。
*/
export function resolveConfig(opt?: AgentConfig): ResolvedConfig {
const appConfig = loadConfig();

return {
systemPrompt: opt?.systemPrompt ?? buildSystemPrompt({
cwd: process.cwd(),
platform: process.platform,
shell: process.env.SHELL || process.env.ComSpec || 'bash',
}),
maxSteps: appConfig.maxSteps,
availableTools: DEFAULT_TOOLS,
};
export function resolveConfig(): ResolvedConfig {
return { maxSteps: loadConfig().maxSteps };
}
10 changes: 0 additions & 10 deletions packages/codingcode/src/agent/types.ts

This file was deleted.

35 changes: 35 additions & 0 deletions packages/codingcode/src/approval/async-confirm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Effect, Deferred } from 'effect';
import type { ConfirmResult } from './confirmation';

// Module-level singleton: shared across all Effect.runPromise scopes
const pending = new Map<string, Deferred.Deferred<ConfirmResult, never>>();

export class ApprovalWaitService extends Effect.Service<ApprovalWaitService>()('ApprovalWait', {
effect: Effect.gen(function* () {
return {
waitForConfirm: (id: string): Effect.Effect<ConfirmResult> =>
Effect.gen(function* () {
const d = yield* Deferred.make<ConfirmResult, never>();
pending.set(id, d);
return yield* Deferred.await(d);
}),

resolveConfirm: (id: string, result: ConfirmResult): Effect.Effect<boolean> =>
Effect.sync(() => {
const d = pending.get(id);
if (!d) return false;
pending.delete(id);
Deferred.unsafeDone(d, Effect.succeed(result));
return true;
}),

getPending: (): Effect.Effect<string[]> =>
Effect.sync(() => Array.from(pending.keys())),
};
}),
}) {}

/** Module-level emitter for the SSE handler to inject an approval request emitter. Survives across Effect.runPromise boundaries. */
export const approvalEmitter = {
current: null as ((id: string, tool: string, args: Record<string, unknown>) => void) | null,
};
Loading