mocode-ai 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,13 +5,14 @@
5
5
  // 与 index.ts 的关系:index.ts 的 runAgent = runAgentCore + TUI hooks 薄封装(行为不变)。
6
6
  // spawn.ts 的 spawnAgent = runAgentCore + 静默 hooks(子 agent)。
7
7
  import { readFileSync } from 'node:fs';
8
- import { resolve } from 'node:path';
9
8
  import { chat, planChatTools, } from '../llm/index.js';
10
9
  import { executeTool } from '../tools/registry.js';
11
10
  import { PLAN_DISABLED_TOOLS } from '../tools/constants.js';
12
11
  import { getAgentMode, setAgentMode } from './mode.js';
13
- import { maybeCompact, capToolResultForHistory, contextState, } from '../session/index.js';
12
+ import { maybeCompact, contextState } from '../session/index.js';
13
+ import { optimizeToolResult } from '../context/index.js';
14
14
  import { config } from '../config/index.js';
15
+ import { jailResolve } from '../sandbox/index.js';
15
16
  /** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
16
17
  function parseArgs(raw) {
17
18
  try {
@@ -43,16 +44,18 @@ function readDiffContext(tc, parsed) {
43
44
  return { preWriteOld: null, editStartLine: 1 };
44
45
  if (tc.name === 'write_file') {
45
46
  try {
46
- return { preWriteOld: readFileSync(resolve(p), 'utf8'), editStartLine: 1 };
47
+ // jailResolve:沙箱越界(../../、绝对外圈、软链出圈)抛错 catch 兜底返 null,不泄露牢外内容(TOCTOU)
48
+ return { preWriteOld: readFileSync(jailResolve(p), 'utf8'), editStartLine: 1 };
47
49
  }
48
50
  catch {
49
- return { preWriteOld: null, editStartLine: 1 }; // 文件不存在(新建)或不可读
51
+ return { preWriteOld: null, editStartLine: 1 }; // 文件不存在(新建)、不可读 或 沙箱越界(不泄露)
50
52
  }
51
53
  }
52
54
  if (tc.name === 'edit_file') {
53
55
  const oldStr = String(parsed.old_string ?? '');
54
56
  try {
55
- const data = readFileSync(resolve(p), 'utf8');
57
+ // jailResolve:同上,沙箱越界抛错 catch 兜底,不泄露牢外内容
58
+ const data = readFileSync(jailResolve(p), 'utf8');
56
59
  const idx = oldStr ? data.indexOf(oldStr) : -1;
57
60
  return {
58
61
  preWriteOld: null,
@@ -60,17 +63,21 @@ function readDiffContext(tc, parsed) {
60
63
  };
61
64
  }
62
65
  catch {
63
- return { preWriteOld: null, editStartLine: 1 }; // 读不到:diff 退化为相对行号
66
+ return { preWriteOld: null, editStartLine: 1 }; // 读不到:diff 退化为相对行号(含沙箱越界)
64
67
  }
65
68
  }
66
69
  return { preWriteOld: null, editStartLine: 1 };
67
70
  }
68
- /** 回灌 tool 结果到 history(裁到单条上限);tool_call_id assistant.tool_calls 按序配对。 */
71
+ /** 回灌 tool 结果到 history:经 Context Optimization Pipeline 编码(tree/search/log/...)后裁到单条上限。
72
+ * tool_call_id 与 assistant.tool_calls 按序配对。未注册 encoder 时回落 capToolResultForHistory(零行为变化)。
73
+ * TUI 渲染(hooks.onToolResult)用原始 output,与此解耦——屏上看全量,LLM 看编码后紧凑版。 */
69
74
  function pushToolResult(history, tc, output) {
70
75
  history.push({
71
76
  role: 'tool',
72
77
  tool_call_id: tc.id,
73
- content: capToolResultForHistory(tc.name, output),
78
+ // optimizeToolResult:classifier 选 encoder → encode(保不变量压缩)→ capToolResultForHistory 兜底。
79
+ // tc.arguments 透传给 encoder(上下文感知编码,如 read_file 的 offset/limit)。永不抛错。
80
+ content: optimizeToolResult(tc.name, output, tc.arguments),
74
81
  });
75
82
  }
76
83
  /**
@@ -78,7 +85,7 @@ function pushToolResult(history, tc, output) {
78
85
  * 流式调 LLM(经 hooks.onText 实时渲染)→ 有 tool_calls 就分组执行并回灌
79
86
  * → 否则流式正文即最终回复。history 在调用间持久,由调用方持有。
80
87
  * 步前经 session/maybeCompact 自动压缩(接近窗口上限时三层压缩);
81
- * 工具结果进 history 前经 capToolResultForHistory 裁到单条上限。
88
+ * 工具结果进 history 前经 Context Optimization Pipeline(optimizeToolResult:类型化编码 + 长度裁剪)。
82
89
  *
83
90
  * 中断语义:signal 经 executeTool(name, args, signal) 串进工具;run_command/web_fetch 等 abort 即时杀
84
91
  * (树杀子进程 / 取消 fetch),循环顶 if(signal.aborted) 兜底还原。不会留下未配对的 tool_call_id。
@@ -41,76 +41,76 @@ function requireEnv(key) {
41
41
  }
42
42
  const PLATFORM_NOTE = (() => {
43
43
  if (process.platform === 'win32') {
44
- return `## Environment (Windows)
45
- - You are on Windows; run_command runs commands via cmd.exe (/c). Unix shell builtins are NOT available here.
46
- - Windows equivalents: which→where, cat→type, ls→dir, rm→del/rd, cp→copy, mv→move. cmd.exe uses %VAR% (not $VAR); pipes (|) and redirects (>, >>) work, but no $(...) command substitution or backticks.
47
- - head/tail/find/grep/sed have no cmd.exe equivalent — use the dedicated tools (read_file for head/tail, glob for find, grep for grep), or invoke PowerShell via run_command if you need more.
44
+ return `## Environment (Windows)
45
+ - You are on Windows; run_command runs commands via cmd.exe (/c). Unix shell builtins are NOT available here.
46
+ - Windows equivalents: which→where, cat→type, ls→dir, rm→del/rd, cp→copy, mv→move. cmd.exe uses %VAR% (not $VAR); pipes (|) and redirects (>, >>) work, but no $(...) command substitution or backticks.
47
+ - head/tail/find/grep/sed have no cmd.exe equivalent — use the dedicated tools (read_file for head/tail, glob for find, grep for grep), or invoke PowerShell via run_command if you need more.
48
48
  - Prefer the dedicated tools (read_file/glob/grep) over shell equivalents — they're cross-platform and already wired in.`;
49
49
  }
50
50
  if (process.platform === 'darwin') {
51
- return `## Environment (macOS)
52
- - You are on macOS; run_command runs via bash -c (user default shell may be zsh). BSD coreutils, not GNU.
53
- - Pitfalls: sed -i needs an empty backup-ext arg (sed -i '' 's/x/y/' file); grep -P unavailable (use grep -E or the grep tool); find/readlink/date are BSD variants; readlink -f unsupported (use realpath, or greadlink -f if GNU coreutils installed via brew).
51
+ return `## Environment (macOS)
52
+ - You are on macOS; run_command runs via bash -c (user default shell may be zsh). BSD coreutils, not GNU.
53
+ - Pitfalls: sed -i needs an empty backup-ext arg (sed -i '' 's/x/y/' file); grep -P unavailable (use grep -E or the grep tool); find/readlink/date are BSD variants; readlink -f unsupported (use realpath, or greadlink -f if GNU coreutils installed via brew).
54
54
  - Prefer the dedicated tools (read_file/glob/grep) over shell equivalents — they sidestep BSD/GNU differences.`;
55
55
  }
56
- return `## Environment (Linux/Unix)
57
- - You are on ${process.platform}; run_command runs via bash -c. GNU coreutils — standard POSIX/GNU shell syntax is safe.
56
+ return `## Environment (Linux/Unix)
57
+ - You are on ${process.platform}; run_command runs via bash -c. GNU coreutils — standard POSIX/GNU shell syntax is safe.
58
58
  - Still prefer the dedicated tools (read_file/glob/grep) over hand-rolled shell where they fit — they avoid quoting pitfalls and are already wired in.`;
59
59
  })();
60
- const SYSTEM_PROMPT = `You are mocode, a terminal coding agent. You complete programming tasks through a "think → call tool → observe result → think again" loop until the problem is solved. Reply to the user in Chinese.
61
-
62
- ${PLATFORM_NOTE}
63
-
64
- ## Workflow
65
- - Understand before acting: when unsure about requirements or code state, explore first; don't assume.
66
- - Small steps: break tasks into verifiable sub-steps. Before each step, think clearly about what to change and why.
67
- - Verify after change: run typecheck / tests / build via run_command to confirm it works. Never claim done without verification.
68
-
69
- ## Tool Guidelines
70
- - See each tool's own description for parameters and usage; this section covers selection strategy and pitfalls only.
71
- - **Prefer codegraph for code exploration**: when understanding/locating code, tracing call chains, or assessing impact of changes, if a .codegraph/ index exists, use the codegraph tool first (explore to query by question, node to look up a single symbol) — it returns relevant source + call paths in one shot, more accurate and economical than piecing together via read_file/grep. Fall back to read_file / grep / glob only when codegraph is unavailable (no index), misses, you need to see just-changed content, or you're editing a single known small file. Build the index first with \`codegraph init\` if none exists.
72
- - Before editing code, read_file to confirm actual content (with line numbers); don't guess from memory.
73
- - For local edits use edit_file: old_string must be unique and match exactly (including indentation/newlines); include surrounding context lines to ensure uniqueness. Use write_file for new files or full rewrites.
74
- - Use glob to find file paths, grep to search content; don't use run_command to pipe cat / sed / find / grep.
75
- - run_command runs per platform (cmd on Windows, bash elsewhere); state intent before running commands with side effects (deleting files, installing packages, git push, resets, etc.).
76
- - Use web_search for information beyond training data (new versions, news, real-time data, latest APIs); don't answer potentially outdated info from memory.
77
- - Use web_fetch to read a specific URL (a link from search results, or a URL given by the user); it only fetches static HTML — if a JS-rendered page yields no body, switch to web_search (its results include cleaned body text).
78
- - Call ask_human when you hit a decision point requiring user input (multiple implementation approaches, unclear intent, or needing extra info to proceed) — list options for the user to pick (they can also choose "custom input" to answer freely). Don't call it frequently when the task is clear and you can decide yourself; if the user cancels, switch approach or proceed with available info — don't re-ask the same question.
79
-
80
- ## Failure Handling
81
- - Tools return errors as strings (edit_file no match or non-unique, run_command non-zero exit, etc.). Analyze the root cause, adjust, then retry — don't resend the same call verbatim.
82
- - When a command errors, read the actual output before judging; don't skip it.
83
-
84
- ## Safety & Boundaries
85
- - Confirm with the user before irreversible or outward-facing operations (delete, overwrite existing files, push, request external services), unless explicitly authorized.
86
- - Operate only within authorized scope; when unsure, ask — don't guess.
87
-
88
- ## Memory (cross-session long-term facts)
89
- - A "memory index" (id/title/summary only) is injected into the system prompt. Retrieve full body via memory_search (pass id or keyword); use memory_list to see the entire index.
90
- - Store non-obvious, cross-session-useful facts/decisions/pitfalls (architecture conventions, gotchas, user preferences, decisions made) with memory_save — only long-term stable items, not current bugs / temp files / undecided TODOs.
91
- - If an existing memory is outdated or contradicts new facts, correct it in-place with memory_update(id, …) (don't create a duplicate); archive clearly-stale ones with memory_forget(id).
92
- - Before saving, memory_search to check for an existing similar entry to avoid duplicates. Better to store less than to store trivially correct information.
93
- - A background reflection pass periodically mines and organizes memories from the session (no manual action needed), but key facts you proactively save are more reliable.
94
-
95
- ## Plan vs Auto modes
96
- - Default is AUTO mode: you research and execute with all tools (read/edit/run_command/memory/web/skills).
97
- - For complex or multi-step tasks, the user may switch to PLAN mode (Shift+Tab): your editing/command/memory-write tools are then removed from your tool list, and you must research with read-only tools only and produce a step-by-step plan (no execution). On approval the session returns to auto mode to execute the plan.
98
-
99
- ## Termination & Reporting
100
- - Stop immediately when no more tools are needed; give conclusions directly.
60
+ const SYSTEM_PROMPT = `You are mocode, a terminal coding agent. You complete programming tasks through a "think → call tool → observe result → think again" loop until the problem is solved. Reply to the user in Chinese.
61
+
62
+ ${PLATFORM_NOTE}
63
+
64
+ ## Workflow
65
+ - Understand before acting: when unsure about requirements or code state, explore first; don't assume.
66
+ - Small steps: break tasks into verifiable sub-steps. Before each step, think clearly about what to change and why.
67
+ - Verify after change: run typecheck / tests / build via run_command to confirm it works. Never claim done without verification.
68
+
69
+ ## Tool Guidelines
70
+ - See each tool's own description for parameters and usage; this section covers selection strategy and pitfalls only.
71
+ - **Prefer codegraph for code exploration**: when understanding/locating code, tracing call chains, or assessing impact of changes, if a .codegraph/ index exists, use the codegraph tool first (explore to query by question, node to look up a single symbol) — it returns relevant source + call paths in one shot, more accurate and economical than piecing together via read_file/grep. Fall back to read_file / grep / glob only when codegraph is unavailable (no index), misses, you need to see just-changed content, or you're editing a single known small file. Build the index first with \`codegraph init\` if none exists.
72
+ - Before editing code, read_file to confirm actual content (with line numbers); don't guess from memory.
73
+ - For local edits use edit_file: old_string must be unique and match exactly (including indentation/newlines); include surrounding context lines to ensure uniqueness. Use write_file for new files or full rewrites.
74
+ - Use glob to find file paths, grep to search content; don't use run_command to pipe cat / sed / find / grep.
75
+ - run_command runs per platform (cmd on Windows, bash elsewhere); state intent before running commands with side effects (deleting files, installing packages, git push, resets, etc.).
76
+ - Use web_search for information beyond training data (new versions, news, real-time data, latest APIs); don't answer potentially outdated info from memory.
77
+ - Use web_fetch to read a specific URL (a link from search results, or a URL given by the user); it only fetches static HTML — if a JS-rendered page yields no body, switch to web_search (its results include cleaned body text).
78
+ - Call ask_human when you hit a decision point requiring user input (multiple implementation approaches, unclear intent, or needing extra info to proceed) — list options for the user to pick (they can also choose "custom input" to answer freely). Don't call it frequently when the task is clear and you can decide yourself; if the user cancels, switch approach or proceed with available info — don't re-ask the same question.
79
+
80
+ ## Failure Handling
81
+ - Tools return errors as strings (edit_file no match or non-unique, run_command non-zero exit, etc.). Analyze the root cause, adjust, then retry — don't resend the same call verbatim.
82
+ - When a command errors, read the actual output before judging; don't skip it.
83
+
84
+ ## Safety & Boundaries
85
+ - Confirm with the user before irreversible or outward-facing operations (delete, overwrite existing files, push, request external services), unless explicitly authorized.
86
+ - Operate only within authorized scope; when unsure, ask — don't guess.
87
+
88
+ ## Memory (cross-session long-term facts)
89
+ - A "memory index" (id/title/summary only) is injected into the system prompt. Retrieve full body via memory_search (pass id or keyword); use memory_list to see the entire index.
90
+ - Store non-obvious, cross-session-useful facts/decisions/pitfalls (architecture conventions, gotchas, user preferences, decisions made) with memory_save — only long-term stable items, not current bugs / temp files / undecided TODOs.
91
+ - If an existing memory is outdated or contradicts new facts, correct it in-place with memory_update(id, …) (don't create a duplicate); archive clearly-stale ones with memory_forget(id).
92
+ - Before saving, memory_search to check for an existing similar entry to avoid duplicates. Better to store less than to store trivially correct information.
93
+ - A background reflection pass periodically mines and organizes memories from the session (no manual action needed), but key facts you proactively save are more reliable.
94
+
95
+ ## Plan vs Auto modes
96
+ - Default is AUTO mode: you research and execute with all tools (read/edit/run_command/memory/web/skills).
97
+ - For complex or multi-step tasks, the user may switch to PLAN mode (Shift+Tab): your editing/command/memory-write tools are then removed from your tool list, and you must research with read-only tools only and produce a step-by-step plan (no execution). On approval the session returns to auto mode to execute the plan.
98
+
99
+ ## Termination & Reporting
100
+ - Stop immediately when no more tools are needed; give conclusions directly.
101
101
  - Report honestly: say success when successful, say where you're stuck when failing, and mention anything skipped. Reference code in "path:line" format (e.g., src/index.ts:42). Keep it concise.`;
102
102
  /**
103
103
  * plan 模式追加到系统提示末尾的指令(切到 plan 模式时由 repl 拼进 history[0])。
104
104
  * 与 SYSTEM_PROMPT 同语种(英文),指示:只读探查、产出步骤化计划、不执行、审批后回 auto。
105
105
  */
106
- export const PLAN_MODE_SUFFIX = `
107
-
108
- ## ⛯ PLAN MODE (active now)
109
- You are in PLAN mode: investigate and design only — do NOT execute or change anything.
110
- - Your editing / command / memory-write tools (write_file, edit_file, run_command, memory_save, memory_update, memory_forget) have been REMOVED from your tool list. Use only the read-only tools available to you (read_file, glob, grep, codegraph, web_search, web_fetch, use_skill, ask_human, memory_search, memory_list) to investigate.
111
- - Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. Prefer codegraph when a .codegraph/ index exists.
112
- - Then produce a clear, actionable implementation plan: files to change (with paths), what to change in each and why, the ordered steps, edge cases to handle, and how to verify (typecheck / tests / build). Be specific enough to execute against.
113
- - Present the plan as your final reply and STOP, unless the user explicitly asked you to "plan first then execute" / "先 plan 再 auto" / autonomous execution: in that case, after presenting the plan, call the switch_mode tool with mode="auto" to switch back to auto mode WITHIN THE SAME TURN and continue implementing the plan yourself (your write/edit/command/memory-write tools become available again immediately). The user will see no approval prompt because you self-switched.
106
+ export const PLAN_MODE_SUFFIX = `
107
+
108
+ ## ⛯ PLAN MODE (active now)
109
+ You are in PLAN mode: investigate and design only — do NOT execute or change anything.
110
+ - Your editing / command / memory-write tools (write_file, edit_file, run_command, memory_save, memory_update, memory_forget) have been REMOVED from your tool list. Use only the read-only tools available to you (read_file, glob, grep, codegraph, web_search, web_fetch, use_skill, ask_human, memory_search, memory_list) to investigate.
111
+ - Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. Prefer codegraph when a .codegraph/ index exists.
112
+ - Then produce a clear, actionable implementation plan: files to change (with paths), what to change in each and why, the ordered steps, edge cases to handle, and how to verify (typecheck / tests / build). Be specific enough to execute against.
113
+ - Present the plan as your final reply and STOP, unless the user explicitly asked you to "plan first then execute" / "先 plan 再 auto" / autonomous execution: in that case, after presenting the plan, call the switch_mode tool with mode="auto" to switch back to auto mode WITHIN THE SAME TURN and continue implementing the plan yourself (your write/edit/command/memory-write tools become available again immediately). The user will see no approval prompt because you self-switched.
114
114
  - If the user entered plan mode manually (via /plan or Shift+Tab) for a safety review and did NOT ask for autonomous execution, do NOT call switch_mode — present the plan and STOP; the user will approve via a prompt and execution happens in a follow-up turn.`;
115
115
  export const config = {
116
116
  baseURL: requireEnv('LLM_BASE_URL'),
@@ -122,12 +122,14 @@ export const config = {
122
122
  compactThreshold: Number(process.env.COMPACT_THRESHOLD) || 0.85,
123
123
  includeUsage: process.env.LLM_STREAM_USAGE !== 'false',
124
124
  autoCompact: process.env.AUTO_COMPACT !== 'false',
125
+ contextOptimize: process.env.MOCODE_CONTEXT_OPTIMIZE !== 'false',
125
126
  autoReflect: process.env.AUTO_REFLECT !== 'false',
126
127
  reflectEveryN: Number(process.env.REFLECT_EVERY_N) || 5,
127
128
  maxSteps: Number(process.env.MAX_STEPS) || 200,
128
129
  subAgentMaxSteps: Number(process.env.SUB_AGENT_MAX_STEPS) || 50,
129
130
  sessionDir: path.join(process.cwd(), '.mocode', 'sessions'),
130
131
  searchApiKey: process.env.ANYSEARCH_API_KEY,
132
+ sandboxRoot: process.env.SANDBOX_ROOT || undefined,
131
133
  searchBaseUrl: process.env.ANYSEARCH_BASE_URL || 'https://api.anysearch.com',
132
134
  theme: process.env.MOCODE_THEME || 'default',
133
135
  themeFromShell,
@@ -0,0 +1,83 @@
1
+ // Context Classifier:据工具名(强先验)+ 输出形状(启发)+ 兜底,选 ContextKind。
2
+ //
3
+ // 三级信号:
4
+ // 1) 名字强先验(BY_NAME 表,覆盖全部 17 内置工具,确定性强)。
5
+ // 2) 形状启发(为 MCP 工具 / 未来工具 / 未登记工具兜底识别)。
6
+ // 3) 兜底 'passthrough'(不认识 = 不动,零行为变化)。
7
+ //
8
+ // 单一事实源风格(仿 tools/constants.ts 的 READ_TOOL_NAMES / PLAN_DISABLED_TOOLS)。
9
+ // 加新工具:在 BY_NAME 加一行;或靠形状启发自动识别。
10
+ /** 工具名 → ContextKind 的强先验表(覆盖全部 17 内置工具)。 */
11
+ const BY_NAME = {
12
+ // tree:路径列表 → 缩进树
13
+ glob: 'tree',
14
+ // search:file:line 分组
15
+ grep: 'search',
16
+ web_search: 'search',
17
+ // graph:CLI dump → 精炼图
18
+ codegraph: 'graph',
19
+ // log:分级 / 折叠 / 尾偏置
20
+ run_command: 'log',
21
+ // code:保行号(edit_file 依赖,最敏感)
22
+ read_file: 'code',
23
+ // table:列对齐
24
+ memory_list: 'table',
25
+ // memory:紧凑卡片
26
+ memory_search: 'memory',
27
+ // doc:去噪音保正文
28
+ web_fetch: 'doc',
29
+ use_skill: 'doc',
30
+ // status:一行状态(identity,不动)
31
+ edit_file: 'status',
32
+ write_file: 'status',
33
+ ask_human: 'status',
34
+ switch_mode: 'status',
35
+ memory_save: 'status',
36
+ memory_update: 'status',
37
+ memory_forget: 'status',
38
+ // summary:子 agent 摘要(轻量)
39
+ task: 'summary',
40
+ };
41
+ /**
42
+ * 形状启发:对未在 BY_NAME 登记的工具输出做模式识别(为 MCP / 未来工具兜底)。
43
+ * 故意保守:识别不准时回落 passthrough(不动),宁可不少省也不可错改。
44
+ */
45
+ function classifyByShape(output) {
46
+ // file:line: content 形(grep 风格)
47
+ if (/^[^\n:]+:\d+:[^\n]*$/m.test(output))
48
+ return 'search';
49
+ // [退出码 N] 前缀(run_command / codegraph 风格)
50
+ if (/^\[退出码 \d+\]/m.test(output))
51
+ return 'log';
52
+ // 路径列表:多行都是含分隔符的相对路径(glob 风格)
53
+ const lines = output.split('\n').filter((l) => l.trim().length > 0);
54
+ if (lines.length >= 3 &&
55
+ lines.every((l) => /^[\w.\-\\/ ]+$/.test(l.trim()) && /[\\/]/.test(l))) {
56
+ return 'tree';
57
+ }
58
+ // JSON 结构化(web_fetch 的 JSON 响应等)→ doc 渲染
59
+ const trimmed = output.trimStart();
60
+ if (trimmed.startsWith('{') || trimmed.startsWith('['))
61
+ return 'doc';
62
+ return 'passthrough';
63
+ }
64
+ /**
65
+ * 判定 ContextKind。
66
+ * - 有 BY_NAME 强先验 → 用之(内置工具确定性强)。
67
+ * - 否则形状启发(MCP / 未来工具)。
68
+ * - 都不中 → passthrough(不动)。
69
+ *
70
+ * @param toolName 工具名
71
+ * @param output 工具原始输出(形状启发用;有 BY_NAME 时不读)
72
+ * @param _args 已解析参数(预留:未来 read_file 的 offset/limit 可影响 code 编码策略;Phase 1 不用)
73
+ */
74
+ export function classify(toolName, output, _args) {
75
+ const byName = BY_NAME[toolName];
76
+ if (byName)
77
+ return byName;
78
+ return classifyByShape(output);
79
+ }
80
+ /** 暴露 BY_NAME 副本供调试 / 未来 /context 展示(只读视图)。 */
81
+ export function knownToolKinds() {
82
+ return { ...BY_NAME };
83
+ }
@@ -0,0 +1,10 @@
1
+ // 内置 encoder 清单。启动期 pipeline 首次调用时经 registerAll 注册到 registry。
2
+ //
3
+ // Phase 1:仅 passthrough(identity)→ 全链路零行为变化(所有 kind 都回落到它)。
4
+ // Phase 2 起逐步加入:tree / search / log / code / table / memory(见各 encoder 文件)。
5
+ //
6
+ // 加 encoder:新建 encoders/xxx.ts 导出 ContextEncoder,在此数组加一行。无需动 agent / llm / core。
7
+ import { passthroughEncoder } from './passthrough.js';
8
+ export const builtinEncoders = [
9
+ passthroughEncoder,
10
+ ];
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 兜底 encoder:identity,原样返回。
3
+ * - classifier 未命中任何 kind(返回 'passthrough')时用。
4
+ * - pipeline 总开关关闭(MOCODE_CONTEXT_OPTIMIZE=false)时,所有 kind 都走它 → 行为与改造前逐字节一致。
5
+ * - Phase 1 阶段 registry 只注册它 → 全链路零行为变化。
6
+ * - 任何 encoder 报错时,pipeline catch 后回落到它(传原 output)。
7
+ *
8
+ * 永不抛错:output 可能是任意字符串(含 ANSI / 多行 / 非法 UTF-8 片段),identity 直接返回,无解析风险。
9
+ */
10
+ export const passthroughEncoder = {
11
+ kind: 'passthrough',
12
+ encode({ output }) {
13
+ return {
14
+ text: output,
15
+ meta: {
16
+ kind: 'passthrough',
17
+ originalLen: output.length,
18
+ encodedLen: output.length,
19
+ note: 'identity (no encoder registered)',
20
+ },
21
+ };
22
+ },
23
+ };
@@ -0,0 +1,10 @@
1
+ // context/ barrel:Context Optimization Pipeline。
2
+ //
3
+ // 单一入口 optimizeToolResult(agent/core.ts pushToolResult 调)接管"工具结果进 LLM 前"的表示。
4
+ // 不调 LLM、不碰 Tool Calling schema / executeTool / tool_call_id 配对 / TUI 渲染
5
+ // (叶子级:仅 stdlib + tools/constants + session/compact 的 capToolResultForHistory 兜底 + config 开关)。
6
+ //
7
+ // 见 CLAUDE.md「Context Optimization Pipeline」节。
8
+ export { optimizeToolResult } from './pipeline.js';
9
+ export { classify, knownToolKinds } from './classifier.js';
10
+ export { registerEncoder, registerAll, getEncoder, registeredKinds, } from './registry.js';
@@ -0,0 +1,84 @@
1
+ // Context Optimization Pipeline 单一入口。
2
+ //
3
+ // 接管"工具结果进 LLM 前"的表示优化(C1 收口,agent/core.ts pushToolResult 调)。
4
+ // 流程:
5
+ // 1) 解析 argsRaw(失败返 null,encoder 据此降级)。
6
+ // 2) classify(name, output, args) → ContextKind。
7
+ // 3) getEncoder(kind) ?? passthrough → encode(保不变量压缩,纯函数)。
8
+ // 4) capToolResultForHistory(name, text) 作末尾长度裁剪兜底(保 head+标记+tail,与改造前一致)。
9
+ //
10
+ // 不抛错:encoder 报错 → catch 回落原 output + capToolResultForHistory(对齐「调度器永不抛错」)。
11
+ // 兜底零行为变化:未注册 encoder / pipeline 关闭 → passthrough identity → 末尾 cap 与改造前逐字节一致。
12
+ //
13
+ // 兼容:不改 Tool Calling JSON schema、不改 executeTool、不改 tool_call_id 配对、不改 TUI 渲染
14
+ // (hooks.onToolResult 用原始 output,本函数只管进 history 的 content)。
15
+ //
16
+ // 依赖方向:context → {tools/constants, session/compact 的 cap, config};叶子,不反向依赖 llm/agent/tools。
17
+ import { classify } from './classifier.js';
18
+ import { getEncoder, registerAll } from './registry.js';
19
+ import { builtinEncoders } from './encoders/index.js';
20
+ import { passthroughEncoder } from './encoders/passthrough.js';
21
+ import { capToolResultForHistory } from '../session/compact.js';
22
+ import { config } from '../config/index.js';
23
+ import { MAX_HISTORY_RESULT, MAX_SKILL_RESULT, MAX_MEMORY_RESULT, } from '../tools/constants.js';
24
+ let booted = false;
25
+ /** 懒注册内置 encoder(首次调用 optimizeToolResult 时触发,避免模块加载期循环 import)。 */
26
+ function boot() {
27
+ if (booted)
28
+ return;
29
+ registerAll(builtinEncoders);
30
+ booted = true;
31
+ }
32
+ /** 解析工具 arguments JSON;非法或空返 null(同 agent/core.ts parseArgs 语义,独立实现避免循环依赖)。 */
33
+ function tryParseArgs(raw) {
34
+ try {
35
+ return raw.trim() ? JSON.parse(raw) : {};
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /**
42
+ * 按工具名取软目标 budget(字符)。对齐 capToolResultForHistory 的放宽规则:
43
+ * use_skill / memory_search 走放宽上限(指令 / 记忆正文须完整);其余走 MAX_HISTORY_RESULT。
44
+ * 仅作 encoder 软目标;最终裁剪仍由末尾 capToolResultForHistory 兜底,故两处常量偶有漂移不致命。
45
+ */
46
+ function budgetFor(name) {
47
+ if (name === 'use_skill')
48
+ return MAX_SKILL_RESULT;
49
+ if (name === 'memory_search')
50
+ return MAX_MEMORY_RESULT;
51
+ return MAX_HISTORY_RESULT;
52
+ }
53
+ /**
54
+ * 优化工具结果为进 LLM 的 tool 消息 content。
55
+ *
56
+ * @param name 工具名
57
+ * @param output executeTool 的原始返回字符串
58
+ * @param argsRaw 工具 arguments 原始 JSON 字符串(tc.arguments,可空;未传则 args=null)
59
+ * @returns 进 history 的 content 字符串(永不抛错)
60
+ */
61
+ export function optimizeToolResult(name, output, argsRaw) {
62
+ boot();
63
+ // 总开关关闭:完全走老路径,零行为变化(Phase 1 默认 true,但保留紧急回退开关)。
64
+ if (!config.contextOptimize) {
65
+ return capToolResultForHistory(name, output);
66
+ }
67
+ try {
68
+ const args = argsRaw != null ? tryParseArgs(argsRaw) : null;
69
+ const kind = classify(name, output, args);
70
+ const enc = getEncoder(kind) ?? passthroughEncoder;
71
+ const { text } = enc.encode({
72
+ toolName: name,
73
+ output,
74
+ args,
75
+ budget: budgetFor(name),
76
+ });
77
+ // 末尾长度裁剪兜底(同改造前):encoder 已更短则 no-op;use_skill/memory_search 的放宽 cap 由此保留。
78
+ return capToolResultForHistory(name, text);
79
+ }
80
+ catch {
81
+ // encoder 报错(不应发生,纯函数):回落原 output + cap(永不抛错契约)。
82
+ return capToolResultForHistory(name, output);
83
+ }
84
+ }
@@ -0,0 +1,30 @@
1
+ // kind → encoder 注册表。单一事实源(仿 tools/registry.ts 的 tools[] 风格)。
2
+ //
3
+ // 内置 encoder 在 encoders/index.ts 的 builtinEncoders 数组声明,启动期 pipeline 首次调用时
4
+ // 经 registerAll 注册(懒注册,避免循环 import 在模块加载期触发)。
5
+ // MCP 工具(未来)可在并入 tools/registry.ts 时调 registerEncoder 注册私有 encoder(后注册覆盖默认)。
6
+ //
7
+ // 未注册的 kind → getEncoder 返 undefined → pipeline 回落 passthrough(identity),零行为变化。
8
+ const encoders = new Map();
9
+ let registered = false;
10
+ /** 注册一个 encoder(后注册覆盖先注册,允许 MCP 覆盖默认)。返回 encoder 自身供链式。 */
11
+ export function registerEncoder(enc) {
12
+ encoders.set(enc.kind, enc);
13
+ return enc;
14
+ }
15
+ /** 批量注册(启动期 pipeline 调一次)。幂等:重复调忽略。 */
16
+ export function registerAll(list) {
17
+ if (registered)
18
+ return;
19
+ for (const e of list)
20
+ registerEncoder(e);
21
+ registered = true;
22
+ }
23
+ /** 取某 kind 的 encoder;未注册返 undefined(pipeline 回落 passthrough)。 */
24
+ export function getEncoder(kind) {
25
+ return encoders.get(kind);
26
+ }
27
+ /** 调试:列出已注册 kind。 */
28
+ export function registeredKinds() {
29
+ return [...encoders.keys()];
30
+ }
@@ -0,0 +1,12 @@
1
+ // Context Optimization Pipeline 的类型契约。
2
+ //
3
+ // 设计原则(见 CLAUDE.md context/ 子系统):
4
+ // - Tool Calling 的 JSON schema 与 executeTool 不动;本层只接管"工具结果进 LLM 前"的表示。
5
+ // - 不设计统一 DSL,针对不同数据类型各做最优 encoder。
6
+ // - 所有 encoder 是纯函数(无 LLM 调用 / 无 IO / 无副作用),永不抛错(pipeline 层 try/catch,
7
+ // 失败回落原 output + capToolResultForHistory,对齐 tools/registry.ts「调度器永不抛错」契约)。
8
+ // - 兜底 encoder = passthrough(identity):未注册 encoder 时行为与改造前逐字节一致。
9
+ //
10
+ // 依赖方向:context 是叶子(仅 stdlib + tools/constants + session/compact 的 cap + config 开关),
11
+ // 不反向依赖 llm / agent / tools 业务,无环。
12
+ export {};
package/dist/index.js CHANGED
@@ -36,6 +36,16 @@ process.on('unhandledRejection', (e) => {
36
36
  */
37
37
  async function main() {
38
38
  const args = process.argv.slice(2);
39
+ // --sandbox-root <path>:覆盖沙箱根(文件操作边界)。缺值或以 -- 开头报错退出。
40
+ const sr = args.indexOf('--sandbox-root');
41
+ let sandboxRootOverride;
42
+ if (sr !== -1) {
43
+ sandboxRootOverride = args[sr + 1];
44
+ if (!sandboxRootOverride || sandboxRootOverride.startsWith('--')) {
45
+ console.error('[cli] --sandbox-root 需要一个路径参数');
46
+ process.exit(1);
47
+ }
48
+ }
39
49
  // 首跑配置向导:写 ~/.mocode/config。独立模块,不触发 config 校验,故零配置也能跑。
40
50
  if (args[0] === 'config') {
41
51
  const { runConfigWizard } = await import('./commands/config.js');
@@ -66,12 +76,12 @@ async function main() {
66
76
  }
67
77
  const updateNotice = checkAndMaybeUpdate();
68
78
  const { startRepl } = await import('./repl/index.js');
69
- await startRepl(loaded.history, loaded.id, updateNotice);
79
+ await startRepl(loaded.history, loaded.id, updateNotice, sandboxRootOverride);
70
80
  }
71
81
  else {
72
82
  const updateNotice = checkAndMaybeUpdate();
73
83
  const { startRepl } = await import('./repl/index.js');
74
- await startRepl(undefined, undefined, updateNotice);
84
+ await startRepl(undefined, undefined, updateNotice, sandboxRootOverride);
75
85
  }
76
86
  process.exit(0);
77
87
  }
@@ -5,6 +5,7 @@ import { config, PLAN_MODE_SUFFIX } from '../config/index.js';
5
5
  import { updateConfigKey } from '../config/file.js';
6
6
  import { runAgent } from '../agent/index.js';
7
7
  import { getAgentMode, setAgentMode, onModeChange } from '../agent/mode.js';
8
+ import { setSandboxRoot } from '../sandbox/root.js';
8
9
  import { ui, setTheme, getTheme, listThemes, themeExists } from '../ui/theme.js';
9
10
  import { bannerString, displayWidth, padEndDisplay, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
10
11
  import * as layout from '../ui/layout.js';
@@ -341,9 +342,12 @@ export function renderHistory(history) {
341
342
  * contentWrite 落入内容区(滚动区域内自动滚动,底栏不动)。history 由本模块持有,在轮次间持久;
342
343
  * agent 只读取并追加(+ 经 session/ 压缩)。每轮成功结束后自动落盘,退出后可用 --resume / /resume 续接。
343
344
  */
344
- export async function startRepl(initialHistory, sessionId, updateNotice = null) {
345
+ export async function startRepl(initialHistory, sessionId, updateNotice = null, sandboxRootOverride) {
345
346
  // 模式重置:agentMode 不落盘,每个 REPL 会话从 auto 开始(/resume / --resume 亦重置)。
346
347
  setAgentMode('auto');
348
+ // 沙箱根:文件操作边界。优先级 --sandbox-root > SANDBOX_ROOT env > process.cwd()。
349
+ // 纯边界记录(不 chdir),jail.ts 内部 resolve。子 agent 同进程继承全局 root。
350
+ setSandboxRoot(sandboxRootOverride ?? config.sandboxRoot ?? process.cwd());
347
351
  // 构造系统提示:auto 用 base;plan 在 config.systemPrompt 后追加 PLAN_MODE_SUFFIX。
348
352
  // 切模式时 applyMode 重算 history[0](history[0] 恒 system,compaction 保它,不破坏)。
349
353
  const buildSystemMessage = (planMode) => effectiveSystemPrompt(config.systemPrompt +
@@ -0,0 +1,41 @@
1
+ // run_command 的 best-effort 命令层原语。**非安全边界**——只挡误操作与低水平 prompt 注入,
2
+ // 真隔离需 OS jailer(bwrap/firejail/sandbox-exec/docker,本次不做)。cwd 钉死由 run_command
3
+ // 自己用 getSandboxRoot() 做;本模块只提供 env 脱敏与灾难命令 denylist。
4
+ /** env 脱敏 denylist:删敏感键(*KEY / *TOKEN / *SECRET / *CREDENTIAL / *PASSWORD / LLM_*)。
5
+ * 防 LLM_API_KEY / ANYSEARCH_API_KEY 泄给子进程。用 denylist——白名单会误杀 npm/git 需要的
6
+ * PATH/HOME/USERPROFILE 等 vars,不稳。 */
7
+ const ENV_DENY = /^(.*_?KEY|.*TOKEN|.*SECRET|.*CREDENTIAL|.*PASSWORD|LLM_.*)$/i;
8
+ /** 返回 env 浅拷贝,删敏感键。 */
9
+ export function filterEnv(env) {
10
+ const out = {};
11
+ for (const [k, v] of Object.entries(env)) {
12
+ if (ENV_DENY.test(k))
13
+ continue;
14
+ out[k] = v;
15
+ }
16
+ return out;
17
+ }
18
+ /**
19
+ * 灾难性文件操作 denylist:仅挡最明显的 fs 破坏(rm -rf /、rm -rf ~、rm -rf /*、fork bomb、
20
+ * mkfs、dd of=/dev/、chmod -R 777 /)。**不挡 curl/wget/sudo**——用户目标是 fs 隔离,不是网络/提权;
21
+ * curl 写到 cwd 不算逃逸。大小写无关、粗匹配。刻意者可绕($IFS / base64 / env var 拼接),
22
+ * 故文档明确:非安全边界。返 string = 拒绝原因;null = 放行。
23
+ */
24
+ const CMD_DENY = [
25
+ // rm -rf / (flags 含 r 与 f,任序;目标 = 根、根*、家目录)
26
+ /\brm\s+-[a-z]*r[a-z]*f[a-z]*\s+\/(\s|$|\*)/i,
27
+ /\brm\s+-[a-z]*f[a-z]*r[a-z]*\s+\/(\s|$|\*)/i,
28
+ /\brm\s+-[a-z]*r[a-z]*f[a-z]*\s+~(\s|$)/i,
29
+ /\brm\s+-[a-z]*f[a-z]*r[a-z]*\s+~(\s|$)/i,
30
+ /:\s*\(\)\s*\{/i, // :(){ fork bomb
31
+ /\bmkfs\b/i,
32
+ /\bdd\b.*of=\/dev\//i,
33
+ /\bchmod\s+-R\s+777\s+\/(\s|$)/i,
34
+ ];
35
+ export function isCommandDenied(cmd) {
36
+ for (const re of CMD_DENY) {
37
+ if (re.test(cmd))
38
+ return `命令被沙箱拒绝(灾难性文件操作): ${cmd}`;
39
+ }
40
+ return null;
41
+ }
@@ -0,0 +1,5 @@
1
+ // 沙箱子系统 barrel。叶子:仅 node:path / node:fs,不反向依赖业务。
2
+ export { getSandboxRoot, setSandboxRoot } from './root.js';
3
+ export { jailResolve, jailGlobPattern, isInsideRoot } from './jail.js';
4
+ export { filterEnv, isCommandDenied } from './command.js';
5
+ export { SANDBOX_EXEMPT_TOOLS, SANDBOX_PATH_TOOLS, enforceSandbox, } from './policy.js';
@@ -0,0 +1,75 @@
1
+ // 路径牢笼原语:把任意输入路径解析到沙箱根之内,拒绝越界(../、绝对路径外圈、符号链接出圈)。
2
+ // 核心:resolve(root, input) → realpath(解软链)→ 前缀校验。
3
+ // 新文件(目标不存在)走「最近存在祖先 realpath 再拼回剩余分量」,同样挡住「祖先是出圈软链」。
4
+ import { realpathSync, existsSync } from 'node:fs';
5
+ import { resolve, relative, isAbsolute, dirname, basename, join } from 'node:path';
6
+ import { getSandboxRoot } from './root.js';
7
+ const isWin = process.platform === 'win32';
8
+ /** 当前沙箱根(绝对),未初始化回退 process.cwd()。 */
9
+ function root() {
10
+ return resolve(getSandboxRoot() ?? process.cwd());
11
+ }
12
+ /**
13
+ * abs 是否在沙箱根内(含 == 根)。win32 下两边 toLowerCase 再比(node 的 path.relative 不做
14
+ * 大小写无关比较,盘符大小写差异会误判出圈)。**不做 realpath**——用于 glob/grep 结果后置过滤;
15
+ * 越界软链的拦挡靠 jailResolve 的 realpath。
16
+ */
17
+ export function isInsideRoot(abs) {
18
+ const r = root();
19
+ // resolve 相对 r(sandbox root):glob/grep 结果相对 sandbox root,需对齐;
20
+ // 传绝对路径时 resolve(r, abs) 返 abs 不变,兼容 jailResolve 的 realpath 结果。
21
+ const a = isWin ? resolve(r, abs).toLowerCase() : resolve(r, abs);
22
+ const rr = isWin ? r.toLowerCase() : r;
23
+ const rel = relative(rr, a);
24
+ return !rel.startsWith('..') && !isAbsolute(rel);
25
+ }
26
+ /**
27
+ * 把输入路径解析为沙箱根内的绝对路径;越界(../、绝对外圈、符号链接出圈)抛错。
28
+ * 供 enforceSandbox 重写 args.path(读/写/改)、readDiffContext 预读旧内容用。
29
+ * 同步:realpathSync 仅做 fs 元数据查询(微秒级),与既有 readFileSync/existsSync 用法一致。
30
+ */
31
+ export function jailResolve(input) {
32
+ const r = root();
33
+ let abs = resolve(r, input);
34
+ try {
35
+ abs = realpathSync(abs);
36
+ }
37
+ catch {
38
+ // 目标或中间段不存在(写新文件 / 路径中间段未建):realpath 最近存在祖先,拼回剩余分量。
39
+ let dir = dirname(abs);
40
+ const rest = [basename(abs)];
41
+ while (!existsSync(dir)) {
42
+ const parent = dirname(dir);
43
+ if (parent === dir)
44
+ break; // 已到 FS 根(C:\ 或 /),无法再上
45
+ rest.unshift(basename(dir));
46
+ dir = parent;
47
+ }
48
+ try {
49
+ abs = join(realpathSync(dir), ...rest);
50
+ }
51
+ catch {
52
+ // 连祖先都 realpath 失败(不该发生,除非 root 本身不可达):用 resolve 值,交由包含校验兜底
53
+ abs = resolve(r, input);
54
+ }
55
+ }
56
+ if (!isInsideRoot(abs)) {
57
+ throw new Error(`路径越界,已被沙箱拒绝: ${input}`);
58
+ }
59
+ return abs;
60
+ }
61
+ /**
62
+ * glob pattern 校验:拒绝对路径(平台相关的 isAbsolute)与含 `..` 段的 pattern。
63
+ * 返 null = 通过;返 string = 拒绝原因。形如 *.ts 的正常 pattern 放行。
64
+ */
65
+ export function jailGlobPattern(pattern) {
66
+ const p = String(pattern ?? '').trim();
67
+ if (!p)
68
+ return '空 pattern';
69
+ if (isAbsolute(p))
70
+ return `不得为绝对路径: ${pattern}`;
71
+ const segs = p.split(/[\\/]/);
72
+ if (segs.includes('..'))
73
+ return `不得含 .. 段: ${pattern}`;
74
+ return null;
75
+ }
@@ -0,0 +1,58 @@
1
+ // 沙箱策略分类集 + 集中执行 enforceSandbox。单一事实源,仿 tools/constants.ts 的
2
+ // PLAN_DISABLED_TOOLS / READ_TOOL_NAMES 风格。
3
+ import { jailResolve, jailGlobPattern } from './jail.js';
4
+ /**
5
+ * 豁免 cwd 牢笼的工具:
6
+ * - memory_*:操作 ~/.mocode 与 <cwd>/.mocode(CLAUDE.md 明确不进回滚、在外圈),本就该在外圈
7
+ * - use_skill:读 ~/.claude/skills、~/.mocode/skills、<cwd>/.mocode/skills,部分在外圈
8
+ * - web_*:跨网络,非文件路径
9
+ * - ask_human / switch_mode:无文件路径
10
+ * - codegraph:只读 cwd 下 .codegraph/ 索引(只读、不写盘)
11
+ * - task:派生子 agent,继承全局 root(子 agent 同进程天然共享 getSandboxRoot)
12
+ */
13
+ export const SANDBOX_EXEMPT_TOOLS = new Set([
14
+ 'memory_save', 'memory_update', 'memory_forget', 'memory_search', 'memory_list',
15
+ 'use_skill',
16
+ 'web_search', 'web_fetch',
17
+ 'ask_human', 'switch_mode',
18
+ 'codegraph',
19
+ 'task',
20
+ ]);
21
+ /**
22
+ * 路径类工具:enforceSandbox 集中把 args.path 重写为牢内绝对路径(默认安全;工具内
23
+ * resolve(absolutePath) 原样返回)。**新加带 path 参数的工具须列入此集**,否则不会被牢笼挡。
24
+ */
25
+ export const SANDBOX_PATH_TOOLS = new Set(['read_file', 'write_file', 'edit_file']);
26
+ /**
27
+ * 工具执行前的沙箱校验。返 string = 拒绝(直接喂 LLM,不执行);返 null = 放行(可能已重写 args.path)。
28
+ * **不抛**——契约对齐「调度器永不抛错、永远返回字符串」(tools/registry.ts executeTool)。
29
+ * run_command 不在此处理(cwd / env / denylist 在其站点)。
30
+ */
31
+ export function enforceSandbox(name, args) {
32
+ if (SANDBOX_EXEMPT_TOOLS.has(name))
33
+ return null;
34
+ if (SANDBOX_PATH_TOOLS.has(name)) {
35
+ const p = args.path;
36
+ if (typeof p === 'string' && p) {
37
+ try {
38
+ args.path = jailResolve(p);
39
+ }
40
+ catch (e) {
41
+ const why = e instanceof Error ? e.message : String(e);
42
+ return `错误:路径越界,已被沙箱拒绝: ${p} (${why})`;
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+ if (name === 'glob') {
48
+ const pat = String(args.pattern ?? '');
49
+ const err = jailGlobPattern(pat);
50
+ return err ? `错误:glob ${err}` : null;
51
+ }
52
+ if (name === 'grep') {
53
+ const pat = String(args.glob ?? '**/*');
54
+ const err = jailGlobPattern(pat);
55
+ return err ? `错误:grep glob ${err}` : null;
56
+ }
57
+ return null;
58
+ }
@@ -0,0 +1,19 @@
1
+ // 共享叶子:沙箱根目录(文件操作边界)。零依赖、不反向引用业务、不落盘。
2
+ // 依赖方向无环:tools/registry → sandbox、tools/builtins/* → sandbox、agent/core → sandbox、repl → sandbox
3
+ // —— 全是「业务 → 叶子」,同 src/agent/mode.ts。
4
+ //
5
+ // 设计:sandboxRoot 是纯边界记录(默认 = process.cwd(),不 chdir),避免与 config.sessionDir 等
6
+ // 模块加载期计算的值产生错位(若 chdir 会令那些值变陈旧)。子 agent 同进程天然继承全局 root
7
+ // (未来 agents/ 做 worktree 隔离时再改为沿 opts 透传,照 signal 同形链路,7 处改动)。
8
+ let currentRoot = null;
9
+ /** 当前沙箱根(绝对路径)。未初始化返 null,调用方 ?? process.cwd() 兜底(防御)。 */
10
+ export function getSandboxRoot() {
11
+ return currentRoot;
12
+ }
13
+ /** 设置沙箱根。repl startRepl 启动时调一次(默认 process.cwd();--sandbox-root / SANDBOX_ROOT 可覆盖)。
14
+ * 返回之前的值,供未来 save/restore(子 agent worktree 隔离)用。 */
15
+ export function setSandboxRoot(root) {
16
+ const prev = currentRoot;
17
+ currentRoot = root;
18
+ return prev;
19
+ }
@@ -1,5 +1,6 @@
1
1
  import fg from 'fast-glob';
2
2
  import { IGNORE } from '../constants.js';
3
+ import { getSandboxRoot, isInsideRoot } from '../../sandbox/index.js';
3
4
  // ---------- glob ----------
4
5
  export const globTool = {
5
6
  name: 'glob',
@@ -14,12 +15,15 @@ export const globTool = {
14
15
  },
15
16
  async execute(args) {
16
17
  const pattern = String(args.pattern);
17
- const files = await fg(pattern, {
18
- cwd: process.cwd(),
18
+ const cwd = getSandboxRoot() ?? process.cwd();
19
+ const files = (await fg(pattern, {
20
+ cwd,
19
21
  onlyFiles: true,
20
22
  dot: true,
21
23
  ignore: IGNORE,
22
- });
24
+ followSymbolicLinks: false, // 不跟随软链目录,防经软链列出牢外文件
25
+ throwErrorOnBrokenSymbolicLink: false,
26
+ })).filter((f) => isInsideRoot(f)); // 后置兜底:仅留牢内
23
27
  if (files.length === 0)
24
28
  return '无匹配文件';
25
29
  const shown = files.slice(0, 200);
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { resolve } from 'node:path';
3
2
  import fg from 'fast-glob';
4
3
  import { MAX_RESULTS, IGNORE } from '../constants.js';
4
+ import { getSandboxRoot, isInsideRoot, jailResolve } from '../../sandbox/index.js';
5
5
  // ---------- grep ----------
6
6
  export const grepTool = {
7
7
  name: 'grep',
@@ -25,12 +25,15 @@ export const grepTool = {
25
25
  catch (e) {
26
26
  return `错误:非法正则 ${pattern}: ${e instanceof Error ? e.message : String(e)}`;
27
27
  }
28
- const files = await fg(g, {
29
- cwd: process.cwd(),
28
+ const cwd = getSandboxRoot() ?? process.cwd();
29
+ const files = (await fg(g, {
30
+ cwd,
30
31
  onlyFiles: true,
31
32
  dot: true,
32
33
  ignore: IGNORE,
33
- });
34
+ followSymbolicLinks: false, // 不跟随软链目录,防经软链扫到牢外文件
35
+ throwErrorOnBrokenSymbolicLink: false,
36
+ })).filter((f) => isInsideRoot(f)); // 后置兜底:仅留牢内
34
37
  const results = [];
35
38
  let scanned = 0;
36
39
  for (const f of files) {
@@ -38,10 +41,11 @@ export const grepTool = {
38
41
  break;
39
42
  let content;
40
43
  try {
41
- content = await readFile(resolve(f), 'utf8');
44
+ // jailResolve:realpath 化,防「牢内文件软链→牢外」的内容泄露;越界/不可读均 catch 跳过
45
+ content = await readFile(jailResolve(f), 'utf8');
42
46
  }
43
47
  catch {
44
- continue; // 跳过无法读的文件(二进制/权限)
48
+ continue; // 跳过无法读的文件(二进制/权限/沙箱越界)
45
49
  }
46
50
  scanned++;
47
51
  const lines = content.split(/\r?\n/);
@@ -1,5 +1,6 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { MAX_OUTPUT } from '../constants.js';
3
+ import { getSandboxRoot, filterEnv, isCommandDenied } from '../../sandbox/index.js';
3
4
  // ---------- run_command ----------
4
5
  export const runCommandTool = {
5
6
  name: 'run_command',
@@ -15,9 +16,14 @@ export const runCommandTool = {
15
16
  async execute(args, ctx) {
16
17
  const command = String(args.command);
17
18
  const timeout = Number(args.timeout ?? 120000);
19
+ // 沙箱 best-effort:灾难性文件操作 denylist(非安全边界,只挡误操作;真隔离需 OS jailer)。
20
+ const deny = isCommandDenied(command);
21
+ if (deny)
22
+ return `错误:${deny}`;
18
23
  return new Promise((done) => {
19
24
  const isWin = process.platform === 'win32';
20
- const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/c', command] : ['-c', command], { cwd: process.cwd() });
25
+ // 沙箱 best-effort:cwd 钉死 sandbox root(相对路径写落在牢内)+ env 脱敏(剥 *KEY/*TOKEN 等,防 LLM_API_KEY 泄子进程)
26
+ const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/c', command] : ['-c', command], { cwd: getSandboxRoot() ?? process.cwd(), env: filterEnv(process.env) });
21
27
  let out = '';
22
28
  let finished = false;
23
29
  let timer;
@@ -1,5 +1,6 @@
1
1
  import { builtinTools } from './builtins/index.js';
2
2
  import { recordMutation } from '../rollback/index.js';
3
+ import { enforceSandbox } from '../sandbox/index.js';
3
4
  /**
4
5
  * 工具注册表。当前 = 内置工具;
5
6
  * 未来可在此合并 MCP 工具、用户自定义工具等(见 src/mcp/)。
@@ -23,6 +24,12 @@ export async function executeTool(name, argsRaw, signal, opts) {
23
24
  return `错误:工具 ${name} 的 arguments 不是合法 JSON: ${argsRaw}`;
24
25
  }
25
26
  try {
27
+ // 沙箱:路径类工具(读/写/改)越界拒绝 + args.path 重写为牢内绝对;glob/grep pattern 校验。
28
+ // 返 string = 拒绝(直接喂 LLM,不执行);返 null = 放行(可能已重写 args.path)。
29
+ // 须在 recordMutation 前:让快照路径 = 牢内绝对路径,与回滚一致。不抛(契约:调度器永不抛错)。
30
+ const sbErr = enforceSandbox(name, args);
31
+ if (sbErr)
32
+ return sbErr;
26
33
  // 撤销回滚用:write_file/edit_file 改动前记 before 快照(回滚时恢复到轮末状态)。
27
34
  // 子 agent(skipRollback)跳过:其改动不进主回滚链,主 /rollback 不撤销(靠 git 兜底)。
28
35
  if (!opts?.skipRollback &&
package/dist/ui/layout.js CHANGED
@@ -415,9 +415,9 @@ function composeStatus(status, cols) {
415
415
  const lead = spinning ? `${RUNNING_FRAMES[runningFrame]} ` : `◆ `;
416
416
  // 模式 chip:lead 之后、model 之前。auto 显 dim 'auto'(常态),plan 显亮黄 'plan'(切换时颜色+文字都变,明显)。
417
417
  const modeChip = status.modeTag
418
- ? ` ${status.modeTag === 'plan' ? ui.yellow : ui.dim}${status.modeTag}${ui.reset}`
418
+ ? ` ${status.modeTag === 'plan' ? ui.yellow : ui.dim}${status.modeTag}${ui.reset} `
419
419
  : '';
420
- const modeChipW = status.modeTag ? 1 + displayWidth(status.modeTag) : 0; // 1 = 前导空格
420
+ const modeChipW = status.modeTag ? 2 + displayWidth(status.modeTag) : 0; // 2 = 前导+尾随空格
421
421
  const model = truncateDisplay(status.model, 22);
422
422
  const ctx = status.contextBar; // 已带色
423
423
  const ctxW = ansiDisplayWidth(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {