mocode-ai 0.7.0 → 0.7.2

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.
@@ -14,6 +14,7 @@ import { runAgentCore, isMutationTool, } from './core.js';
14
14
  import { createPetHooks } from '../pet/state.js';
15
15
  import { t } from '../i18n/index.js';
16
16
  import { isToolErrorOutput } from '../tools/result.js';
17
+ import { appendCurrentSessionTraceEvent } from '../session/index.js';
17
18
  /** 当前 turn 的 batch id(runAgent 内闭包变量;一条 turn 一轮 tool batch 结束即清空)。 */
18
19
  let currentBatchId = null;
19
20
  /** 取 userInput 的首行:字符串直接 split;多模态 parts 找首个 text part 再 split。 */
@@ -187,6 +188,23 @@ onContextUpdate) {
187
188
  flushToolBatch();
188
189
  layout.contentWrite(`${ui.dim}${t('agent.aborted')}${ui.reset}\n`);
189
190
  },
191
+ onValidationStart: (command) => {
192
+ flushToolBatch();
193
+ spinner.start(t('agent.validating', { command }));
194
+ },
195
+ onValidationResult: (validation) => {
196
+ spinner.stop();
197
+ const color = validation.status === 'passed'
198
+ ? ui.green
199
+ : validation.status === 'failed'
200
+ ? ui.red
201
+ : ui.yellow;
202
+ const command = validation.command ?? t('agent.validationNoCommand');
203
+ const detail = validation.status === 'skipped' && validation.skipReason
204
+ ? `${validation.status}: ${validation.skipReason}`
205
+ : validation.status;
206
+ layout.contentWrite(` ${color}●${ui.reset} ${t('agent.validationResult', { command, status: detail })}\n`);
207
+ },
190
208
  onDone: (elapsedMs, usage) => {
191
209
  flushToolBatch();
192
210
  const tok = formatTurnTokens(usage);
@@ -210,6 +228,8 @@ onContextUpdate) {
210
228
  signal,
211
229
  onContextUpdate,
212
230
  hooks: combinedHooks,
231
+ autoValidate: config.autoValidate,
232
+ onTraceEvent: appendCurrentSessionTraceEvent,
213
233
  });
214
234
  }
215
235
  finally {
@@ -12,7 +12,7 @@
12
12
  // - 中断透传:opts.signal(主 agent 的 abort signal)透传给 runAgentCore → chat/executeTool,
13
13
  // 主 Ctrl+C 树杀子 agent(chat 流式 abort + run_command/web_fetch 即时取消)。
14
14
  import { chatTools } from '../llm/index.js';
15
- import { config, isMemoryEnabled } from '../config/index.js';
15
+ import { config, isMemoryEnabled, isSubAgentEnabled } from '../config/index.js';
16
16
  import { effectiveSystemPrompt } from '../skills/index.js';
17
17
  import { buildMemorySection, buildMemoryIndexSection } from '../memory/index.js';
18
18
  import { ui } from '../ui/theme.js';
@@ -40,6 +40,13 @@ You are a sub-agent spawned by the main agent to handle an isolated sub-task. Yo
40
40
  * 子 agent 跑在主 signal 下,主 abort 即子 abort;子 agent 的 abortRestore 还原子 history + 模式。
41
41
  */
42
42
  export async function spawnAgent(opts) {
43
+ if (!isSubAgentEnabled()) {
44
+ return {
45
+ summary: null,
46
+ completed: false,
47
+ transcript: 'Sub-agent execution is disabled. Enable it with /subagent on.',
48
+ };
49
+ }
43
50
  const maxSteps = opts.maxSteps ?? config.subAgentMaxSteps ?? 50;
44
51
  // 构造子 agent 系统提示:复用主 agent 组装链 + 子 agent 角色后缀 + 自定义后缀。
45
52
  // config.systemPrompt 是 getter(每次访问现拼 buildBasePrompt,反映 isMemoryEnabled),
@@ -124,6 +131,7 @@ export async function spawnAgent(opts) {
124
131
  maxSteps,
125
132
  toolsOverride,
126
133
  contextState: localContextState,
134
+ autoValidate: false, // 子 Agent 共享主轮工作区,由主 Agent 收尾统一验证
127
135
  });
128
136
  return {
129
137
  summary: result.finalText,
@@ -358,6 +358,7 @@ export const config = {
358
358
  compactThreshold: Number(process.env.COMPACT_THRESHOLD) || 0.85,
359
359
  includeUsage: process.env.LLM_STREAM_USAGE !== 'false',
360
360
  autoCompact: process.env.AUTO_COMPACT !== 'false',
361
+ autoValidate: process.env.MOCODE_AUTO_VALIDATE !== 'false',
361
362
  contextOptimize: process.env.MOCODE_CONTEXT_OPTIMIZE !== 'false',
362
363
  contextRelprune: process.env.MOCODE_CONTEXT_RELPRUNE !== 'false',
363
364
  contextLifecycle: process.env.MOCODE_LIFECYCLE !== 'false',
@@ -366,6 +367,7 @@ export const config = {
366
367
  memoryEnabled: process.env.MEMORY_ENABLED === 'true',
367
368
  reflectEveryN: Number(process.env.REFLECT_EVERY_N) || 5,
368
369
  maxSteps: Number(process.env.MAX_STEPS) || 200,
370
+ subAgentEnabled: process.env.MOCODE_SUBAGENT_ENABLED === 'true',
369
371
  subAgentMaxSteps: Number(process.env.SUB_AGENT_MAX_STEPS) || 50,
370
372
  sessionDir: path.join(process.cwd(), '.mocode', 'sessions'),
371
373
  searchApiKey: process.env.ANYSEARCH_API_KEY,
@@ -379,6 +381,7 @@ export const config = {
379
381
  llmKeysFromShell,
380
382
  projectSnapshotEnabled: process.env.MOCODE_PROJECT_SNAPSHOT !== 'false',
381
383
  permissionEnabled: process.env.MOCODE_PERMISSION !== 'false',
384
+ permissionNonInteractiveAllow: process.env.MOCODE_PERMISSION_NON_INTERACTIVE_ALLOW === 'true',
382
385
  projectSkillEnabled: process.env.MOCODE_PROJECT_SKILL === 'true',
383
386
  };
384
387
  /**
@@ -407,6 +410,15 @@ export function updateModelConfig(opts) {
407
410
  process.env.CONTEXT_WINDOW_TOKENS = String(opts.contextWindowTokens);
408
411
  }
409
412
  }
413
+ /** 子 Agent 总开关;默认 false,关闭时 task 不进入模型工具表。 */
414
+ export function isSubAgentEnabled() {
415
+ return config.subAgentEnabled;
416
+ }
417
+ /** 运行时切换子 Agent;工具 schema 刷新与持久化由 REPL 调用方完成。 */
418
+ export function updateSubAgentConfig(enabled) {
419
+ config.subAgentEnabled = enabled;
420
+ process.env.MOCODE_SUBAGENT_ENABLED = enabled ? 'true' : 'false';
421
+ }
410
422
  /**
411
423
  * 记忆子系统总开关:单一来源。/memory_switch、/memory_status、buildSystemPrompt、
412
424
  * tools/builtins/index.ts、tools/constants.ts 的 plan-mode 列表都从这里查。
@@ -19,6 +19,10 @@ const zhCN = {
19
19
  'commands.memoryStatus': '查看当前开关与原理',
20
20
  'commands.memoryReflect': '手动触发后台记忆反思',
21
21
  'commands.memoryInit': '扫描项目生成 MOCODE.md 项目记忆',
22
+ 'commands.subagent': '子 Agent 开关(默认关闭)',
23
+ 'commands.subagentOn': '开启子 Agent',
24
+ 'commands.subagentOff': '关闭子 Agent',
25
+ 'commands.subagentStatus': '查看子 Agent 状态',
22
26
  'commands.skill': '项目专属 Skill 管理',
23
27
  'commands.toggle': '切换开关',
24
28
  'commands.skillOn': '开启项目 Skill',
@@ -84,6 +88,7 @@ const zhCN = {
84
88
  'running.memory': '切记忆开关',
85
89
  'running.switching': '切换中…',
86
90
  'running.memoryStatus': '查记忆状态',
91
+ 'running.subagent': '子 Agent',
87
92
  'running.skill': '项目 Skill',
88
93
  'running.language': '切语言',
89
94
  'running.chooseLanguage': '选择语言…',
@@ -160,6 +165,9 @@ const zhCN = {
160
165
  'agent.noReply': '(无回复)',
161
166
  'agent.maxSteps': '达到最大步数({count}),本轮停止。',
162
167
  'agent.aborted': '(已中断)',
168
+ 'agent.validating': '自动验证 {command}',
169
+ 'agent.validationNoCommand': '未发现验证命令',
170
+ 'agent.validationResult': '自动验证 {command} → {status}',
163
171
  'agent.workedFor': '耗时 {elapsed}',
164
172
  'toolSummary.lines': '{count} 行',
165
173
  'toolSummary.files': '{count} 个文件',
@@ -191,13 +199,22 @@ const zhCN = {
191
199
  'permission.deny': '拒绝',
192
200
  'permission.allow': '允许',
193
201
  'permission.allowSession': '本次会话始终允许此工具',
202
+ 'permission.allowSessionResource': '本次会话允许相同命令/资源',
203
+ 'permission.allowProjectResource': '对此项目永久允许相同命令/资源',
194
204
  'askHuman.cancelled': '用户取消了选择。请考虑是否有不依赖用户输入的替代方案,或换个角度重新提问。',
195
205
  'askHuman.submitted': '用户回答:{value}',
196
206
  'askHuman.selected': '用户选择:{value}',
197
207
  'task.missingPrompt': '错误:缺少 prompt(子任务指令)。',
208
+ 'task.disabled': '错误:子 Agent 当前已关闭。需要时请先运行 /subagent on。',
198
209
  'task.interrupted': '子 agent 被中断,未完成。',
199
210
  'task.noSummary': '子 agent 完成但未返回文本摘要(可能只调了工具或达到步数上限)。',
200
211
  'task.summaryTruncated': '…(子 agent 摘要已截断 {count} 字符)',
212
+ 'subagent.status': '子 Agent:{state}',
213
+ 'subagent.stateOn': '开启',
214
+ 'subagent.stateOff': '关闭',
215
+ 'subagent.changedOn': '已开启子 Agent;task 将从下一次模型请求起可用。',
216
+ 'subagent.changedOff': '已关闭子 Agent;task 已从模型工具表移除。',
217
+ 'subagent.usage': '用法:/subagent on|off|status',
201
218
  'plan.ready': '计划已就绪',
202
219
  'plan.approvalDetail': '切换到 auto 模式按上述计划执行?(plan 模式只读探查,执行需切 auto)',
203
220
  'plan.execute': '切 auto 执行',
@@ -228,6 +245,10 @@ const en = {
228
245
  'commands.memoryStatus': 'Show current state and behavior',
229
246
  'commands.memoryReflect': 'Run background memory reflection',
230
247
  'commands.memoryInit': 'Scan project and generate MOCODE.md',
248
+ 'commands.subagent': 'Sub-agent controls (disabled by default)',
249
+ 'commands.subagentOn': 'Enable sub-agents',
250
+ 'commands.subagentOff': 'Disable sub-agents',
251
+ 'commands.subagentStatus': 'Show sub-agent status',
231
252
  'commands.skill': 'Manage the project-specific Skill',
232
253
  'commands.toggle': 'Toggle the feature',
233
254
  'commands.skillOn': 'Enable the project Skill',
@@ -293,6 +314,7 @@ const en = {
293
314
  'running.memory': 'Memory',
294
315
  'running.switching': 'Switching…',
295
316
  'running.memoryStatus': 'Memory status',
317
+ 'running.subagent': 'Sub-agent',
296
318
  'running.skill': 'Project Skill',
297
319
  'running.language': 'Language',
298
320
  'running.chooseLanguage': 'Choose a language…',
@@ -369,6 +391,9 @@ const en = {
369
391
  'agent.noReply': '(no reply)',
370
392
  'agent.maxSteps': 'Maximum steps reached ({count}); this turn has stopped.',
371
393
  'agent.aborted': '(aborted)',
394
+ 'agent.validating': 'Validating {command}',
395
+ 'agent.validationNoCommand': 'no validation command',
396
+ 'agent.validationResult': 'Automatic validation {command} → {status}',
372
397
  'agent.workedFor': 'Worked for {elapsed}',
373
398
  'toolSummary.lines': '{count} lines',
374
399
  'toolSummary.files': '{count} files',
@@ -400,13 +425,22 @@ const en = {
400
425
  'permission.deny': 'Deny',
401
426
  'permission.allow': 'Allow',
402
427
  'permission.allowSession': 'Always allow this tool for this session',
428
+ 'permission.allowSessionResource': 'Allow this command/resource for this session',
429
+ 'permission.allowProjectResource': 'Always allow this command/resource for this project',
403
430
  'askHuman.cancelled': 'The user cancelled the choice. Consider an alternative that does not require user input, or ask from a different angle.',
404
431
  'askHuman.submitted': 'User response: {value}',
405
432
  'askHuman.selected': 'User selected: {value}',
406
433
  'task.missingPrompt': 'Error: missing prompt (sub-task instruction).',
434
+ 'task.disabled': 'Error: sub-agents are disabled. Run /subagent on first if needed.',
407
435
  'task.interrupted': 'The sub-agent was interrupted before completion.',
408
436
  'task.noSummary': 'The sub-agent completed without a text summary (it may only have used tools or reached its step limit).',
409
437
  'task.summaryTruncated': '…(sub-agent summary truncated by {count} characters)',
438
+ 'subagent.status': 'Sub-agent: {state}',
439
+ 'subagent.stateOn': 'enabled',
440
+ 'subagent.stateOff': 'disabled',
441
+ 'subagent.changedOn': 'Sub-agents enabled; task will be available from the next model request.',
442
+ 'subagent.changedOff': 'Sub-agents disabled; task has been removed from the model tool list.',
443
+ 'subagent.usage': 'Usage: /subagent on|off|status',
410
444
  'plan.ready': 'Plan ready',
411
445
  'plan.approvalDetail': 'Switch to auto mode and execute the plan above? (Plan mode is read-only; execution requires auto mode.)',
412
446
  'plan.execute': 'Switch to auto and execute',
package/dist/llm/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import OpenAI from 'openai';
2
- import { config } from '../config/index.js';
2
+ import { config, isSubAgentEnabled } from '../config/index.js';
3
3
  import { tools } from '../tools/registry.js';
4
4
  import { getPlanDisabledTools } from '../tools/constants.js';
5
5
  import { ThinkTagFilter } from './think-filter.js';
@@ -149,7 +149,11 @@ export function __setChatCreateImpl(impl) {
149
149
  export const chatTools = [];
150
150
  export const planChatTools = [];
151
151
  export function refreshChatTools() {
152
- const next = tools.map((t) => ({
152
+ // task 常驻内部 registry,运行时开关只控制模型可见 schema,因而 on/off 可即时生效。
153
+ const visibleTools = isSubAgentEnabled()
154
+ ? tools
155
+ : tools.filter((tool) => tool.name !== 'task');
156
+ const next = visibleTools.map((t) => ({
153
157
  type: 'function',
154
158
  function: {
155
159
  name: t.name,
@@ -225,6 +229,14 @@ function firstNumber(arr) {
225
229
  }
226
230
  return undefined;
227
231
  }
232
+ function retryErrorCode(error) {
233
+ if (!error || typeof error !== 'object')
234
+ return 'RETRYABLE_ERROR';
235
+ const value = error;
236
+ if (typeof value.status === 'number')
237
+ return `HTTP_${value.status}`;
238
+ return value.code ?? value.name ?? 'RETRYABLE_ERROR';
239
+ }
228
240
  /**
229
241
  * 流式调一次 LLM:增量回调文本,内部累加 tool_calls 片段。
230
242
  * tool_calls 跨 chunk 按 index 累加(id / name / arguments 拼接)。
@@ -250,6 +262,12 @@ toolsOverride) {
250
262
  throw err;
251
263
  }
252
264
  const wait = computeBackoff(attempt, getRetryAfterMs(err));
265
+ handlers.onRetry?.({
266
+ attempt,
267
+ nextAttempt: attempt + 1,
268
+ waitMs: wait,
269
+ code: retryErrorCode(err),
270
+ });
253
271
  logRetry(attempt, err, wait);
254
272
  // sleep 自己会在 signal abort 时抛 AbortError——透传,让 runAgentCore 的 catch 按中断处理。
255
273
  await sleep(wait, signal);
package/dist/mcp/index.js CHANGED
@@ -41,9 +41,22 @@ export function getMcpTools() {
41
41
  parameters: remote.inputSchema && typeof remote.inputSchema === 'object'
42
42
  ? remote.inputSchema
43
43
  : { type: 'object', properties: {} },
44
- // 远程/本地 server 均可能读写系统或网络;协议没有副作用声明,必须保守处理。
44
+ // MCP 协议没有可靠副作用声明:未知能力、workspace 串行、每次确认。
45
45
  risk: 'dangerous',
46
- execute: async (args, ctx) => formatToolResult(await client.callTool(remote.name, args, ctx?.signal)),
46
+ capabilities: {
47
+ effect: 'unknown',
48
+ concurrency: 'serial',
49
+ retry: 'never',
50
+ resources: () => ['workspace'],
51
+ supportsAbort: true,
52
+ },
53
+ execute: async (args, ctx) => {
54
+ const result = await client.callTool(remote.name, args, ctx?.signal);
55
+ const output = formatToolResult(result);
56
+ return result.isError
57
+ ? { status: 'error', code: 'MCP_ERROR', retryable: false, output }
58
+ : { status: 'success', code: 'OK', retryable: false, output };
59
+ },
47
60
  });
48
61
  }
49
62
  }
@@ -1,142 +1,198 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { promptIntervention } from '../ui/intervention.js';
5
6
  import { config } from '../config/index.js';
7
+ import { getSandboxRoot } from '../sandbox/index.js';
6
8
  import { t } from '../i18n/index.js';
7
- /**
8
- * 工具权限系统:基于 risk 字段在执行前拦截确认。
9
- *
10
- * 设计:
11
- * - safe → 直接放行(只读工具,零交互)
12
- * - confirm → 弹面板确认,同工具同会话缓存(避免重复打断)
13
- * - dangerous → 每次都弹(高风险,命令内容不可预测)
14
- *
15
- * 三层允许(优先级从高到低):
16
- * 1. 永久允许(permanentAllow,跨会话持久化到 ~/.mocode/permissions.json)
17
- * 2. 会话允许(approvedTools,本次进程内缓存)
18
- * 3. 面板询问(promptIntervention,复用 ask_human UI)
19
- *
20
- * 复用 promptIntervention:统一 UI 面板,非 TTY 自动降级(第一项 = 允许)。
21
- */
22
- /** 跨会话持久化允许列表路径 */
23
9
  const PERMISSIONS_PATH = path.join(os.homedir(), '.mocode', 'permissions.json');
24
- /** 永久允许:跨会话持久化(用户选了"以后不再询问此工具") */
25
- let permanentAllow = new Set();
10
+ const PERMISSIONS_VERSION = 3;
11
+ let permanentGrants = [];
12
+ let permanentToolAllows = new Set();
26
13
  let permanentLoaded = false;
27
- /** 会话级缓存:confirm 级工具批准后,后续调用不再弹 */
28
- const approvedTools = new Set();
29
- /** 从磁盘加载永久允许列表(首调时触发,之后用内存缓存;文件不存在/解析失败返空集合) */
14
+ const sessionGrants = [];
15
+ function stable(value) {
16
+ if (Array.isArray(value))
17
+ return value.map(stable);
18
+ if (value && typeof value === 'object') {
19
+ return Object.fromEntries(Object.entries(value)
20
+ .sort(([a], [b]) => a.localeCompare(b))
21
+ .map(([key, item]) => [key, stable(item)]));
22
+ }
23
+ return value;
24
+ }
25
+ function canonicalProjectRoot(root) {
26
+ const resolved = path.resolve(root);
27
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
28
+ }
29
+ /** A grant is bound to the actual command or logical resources, never merely a tool name. */
30
+ export function permissionFingerprint(tool, args) {
31
+ let subject;
32
+ if (tool.name === 'run_command' && typeof args.command === 'string') {
33
+ subject = { command: args.command.trim() };
34
+ }
35
+ else {
36
+ const resources = tool.capabilities?.resources?.(args).filter(Boolean).sort();
37
+ // File mutations may be granted by their concrete resource. Coarse resources such as
38
+ // "workspace" must retain arguments so task/process-like calls cannot become tool-wide.
39
+ subject = resources?.length && typeof args.path === 'string'
40
+ ? { resources }
41
+ : stable(args);
42
+ }
43
+ return crypto.createHash('sha256').update(JSON.stringify(subject)).digest('hex');
44
+ }
45
+ function validGrant(value) {
46
+ if (!value || typeof value !== 'object')
47
+ return false;
48
+ const grant = value;
49
+ return typeof grant.tool === 'string'
50
+ && typeof grant.fingerprint === 'string'
51
+ && grant.fingerprint.length > 0
52
+ && (grant.scope === 'project' || grant.scope === 'session' || grant.scope === 'once');
53
+ }
30
54
  function loadPermanent() {
31
55
  if (permanentLoaded)
32
- return permanentAllow;
56
+ return permanentGrants;
33
57
  permanentLoaded = true;
34
58
  try {
35
- const raw = fs.readFileSync(PERMISSIONS_PATH, 'utf8');
36
- const parsed = JSON.parse(raw);
37
- if (parsed && Array.isArray(parsed.allowForever)) {
38
- permanentAllow = new Set(parsed.allowForever.filter((x) => typeof x === 'string'));
39
- }
59
+ const parsed = JSON.parse(fs.readFileSync(PERMISSIONS_PATH, 'utf8'));
60
+ permanentGrants = Array.isArray(parsed.grants)
61
+ ? parsed.grants.filter(validGrant).filter((grant) => grant.scope === 'project')
62
+ : [];
63
+ // Only the explicit v3 field enables broad grants. The retired legacy allowForever
64
+ // field remains ignored so upgrades cannot silently restore old authorization.
65
+ permanentToolAllows = parsed.version === PERMISSIONS_VERSION && Array.isArray(parsed.alwaysAllowTools)
66
+ ? new Set(parsed.alwaysAllowTools.filter((tool) => typeof tool === 'string' && tool.length > 0))
67
+ : new Set();
40
68
  }
41
69
  catch {
42
- // 文件不存在 / 解析失败 → 空集合(不阻断,用户首次使用或配置损坏均安全降级)
70
+ permanentGrants = [];
71
+ permanentToolAllows = new Set();
43
72
  }
44
- return permanentAllow;
73
+ return permanentGrants;
45
74
  }
46
- /** 写入永久允许列表(覆盖写;失败静默,下次启动丢失但不阻断当前会话) */
47
75
  function savePermanent() {
48
76
  try {
49
77
  fs.mkdirSync(path.dirname(PERMISSIONS_PATH), { recursive: true });
50
- fs.writeFileSync(PERMISSIONS_PATH, JSON.stringify({ allowForever: Array.from(permanentAllow) }, null, 2) + '\n', 'utf8');
78
+ const data = {
79
+ version: PERMISSIONS_VERSION,
80
+ grants: permanentGrants,
81
+ alwaysAllowTools: [...permanentToolAllows].sort(),
82
+ };
83
+ fs.writeFileSync(PERMISSIONS_PATH, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
51
84
  }
52
85
  catch {
53
- // 写失败静默(与 updateConfigKey 一致:UI 偏好路径,不阻断 REPL)
86
+ // A failed persistence write must never turn into broader authorization.
54
87
  }
55
88
  }
56
- /** 从 Tool 解析 risk,缺省返 'safe'(只读工具无需标注)。 */
57
89
  export function getToolRisk(tool) {
58
90
  return tool.risk ?? 'safe';
59
91
  }
60
- /** 参数摘要:提取关键参数供确认面板展示(path / command 等) */
61
- function summarizeArgs(tool, args) {
92
+ function summarizeArgs(args) {
62
93
  const lines = [];
63
94
  if (typeof args.path === 'string')
64
95
  lines.push(t('permission.path', { value: args.path }));
65
96
  if (typeof args.command === 'string')
66
97
  lines.push(t('permission.command', { value: args.command }));
67
98
  if (typeof args.prompt === 'string') {
68
- const preview = String(args.prompt).slice(0, 100);
69
- lines.push(t('permission.task', { value: `${preview}${String(args.prompt).length > 100 ? '…' : ''}` }));
99
+ const preview = args.prompt.slice(0, 100);
100
+ lines.push(t('permission.task', { value: `${preview}${args.prompt.length > 100 ? '…' : ''}` }));
70
101
  }
71
- return lines.length > 0 ? lines.join('\n') : t('permission.noArgs');
72
- }
73
- /**
74
- * 检查权限:safe 直接放行;confirm/dangerous 弹面板让用户确认。
75
- *
76
- * 返回 'allow' = 放行, 'deny' = 拒绝(不执行,也不记回滚快照)
77
- * 非 TTY 环境(promptIntervention 内部处理):自动选第一项(允许)并打 stderr 日志,不阻塞。
78
- */
79
- export async function checkPermission(tool, args, signal) {
80
- // 总开关关闭 → 全部放行(零行为变化,向后兼容)
81
- if (!config.permissionEnabled)
102
+ return lines.length ? lines.join('\n') : t('permission.noArgs');
103
+ }
104
+ function matches(grant, tool, fingerprint, projectRoot) {
105
+ return grant.tool === tool
106
+ && grant.fingerprint === fingerprint
107
+ && (grant.scope !== 'project' || grant.projectRoot === projectRoot);
108
+ }
109
+ export async function checkPermission(tool, args, signal, options = {}) {
110
+ if (!config.permissionEnabled || getToolRisk(tool) === 'safe')
82
111
  return 'allow';
83
- const risk = getToolRisk(tool);
84
- if (risk === 'safe')
112
+ if (signal?.aborted)
113
+ return 'deny';
114
+ loadPermanent();
115
+ if (permanentToolAllows.has(tool.name))
85
116
  return 'allow';
86
- // 永久允许(跨会话):命中则放行,不弹面板
87
- if (loadPermanent().has(tool.name))
117
+ const fingerprint = permissionFingerprint(tool, args);
118
+ const projectRoot = canonicalProjectRoot(options.projectRoot ?? getSandboxRoot() ?? process.cwd());
119
+ if (sessionGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
88
120
  return 'allow';
89
- // 会话允许(confirm ):同进程内缓存,命中则放行
90
- if (risk === 'confirm' && approvedTools.has(tool.name))
121
+ if (permanentGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
91
122
  return 'allow';
92
- // 构建确认面板
93
- const isDangerous = risk === 'dangerous';
123
+ // CI/pipes must fail closed. Operators can deliberately restore unattended behavior.
124
+ if (!process.stdin.isTTY && !config.permissionNonInteractiveAllow && !options.prompt)
125
+ return 'deny';
126
+ if (signal?.aborted)
127
+ return 'deny';
128
+ const onceOption = t('permission.allow');
129
+ const sessionOption = t('permission.allowSessionResource');
130
+ const projectOption = t('permission.allowProjectResource');
131
+ const alwaysOption = t('permission.allowForever');
94
132
  const denyOption = t('permission.deny');
95
- const foreverOption = t('permission.allowForever');
96
- const sessionOption = t('permission.allowSession');
97
- const title = isDangerous
98
- ? t('permission.dangerTitle', { tool: tool.name })
99
- : t('permission.confirmTitle', { tool: tool.name });
100
- const detail = summarizeArgs(tool, args) + (isDangerous ? `\n\n${t('permission.dangerWarning')}` : '');
101
- // 选项统一结构:dangerous 也提供"以后不再询问"(用户明确授权即尊重,即使 run_command)
102
- const options = isDangerous
103
- ? [t('permission.confirmExecute'), foreverOption, denyOption]
104
- : [t('permission.allow'), sessionOption, foreverOption, denyOption];
105
- // 弹面板(阻塞直到用户选择;signal 中断时 promptIntervention 内部处理)
106
- const result = await promptIntervention({
133
+ const dangerous = getToolRisk(tool) === 'dangerous';
134
+ const choices = [onceOption, sessionOption, projectOption, alwaysOption, denyOption];
135
+ const result = await (options.prompt ?? promptIntervention)({
107
136
  type: 'choice',
108
- title,
109
- detail,
110
- options,
137
+ title: dangerous
138
+ ? t('permission.dangerTitle', { tool: tool.name })
139
+ : t('permission.confirmTitle', { tool: tool.name }),
140
+ detail: summarizeArgs(args) + (dangerous ? `\n\n${t('permission.dangerWarning')}` : ''),
141
+ options: choices,
111
142
  allowCustom: false,
112
143
  });
113
- // 用户取消(Esc / Ctrl+C)→ 拒绝
114
- if (result.action === 'cancelled')
115
- return 'deny';
116
- // 解析选择
117
- const value = result.value ?? '';
118
- if (value === denyOption)
144
+ if (signal?.aborted || result.action === 'cancelled' || result.value === denyOption)
119
145
  return 'deny';
120
- // 永久允许:写入磁盘 + 加入内存集合(跨会话生效)
121
- if (value === foreverOption) {
122
- permanentAllow.add(tool.name);
123
- savePermanent();
124
- return 'allow';
146
+ if (result.value === sessionOption) {
147
+ sessionGrants.push({ tool: tool.name, fingerprint, scope: 'session' });
125
148
  }
126
- // 会话允许(confirm ):加入内存缓存,本次进程内不再弹
127
- if (!isDangerous && value === sessionOption) {
128
- approvedTools.add(tool.name);
149
+ else if (result.value === projectOption) {
150
+ const grant = { tool: tool.name, fingerprint, scope: 'project', projectRoot };
151
+ permanentGrants = permanentGrants.filter((item) => !matches(item, tool.name, fingerprint, projectRoot));
152
+ permanentGrants.push(grant);
153
+ if (options.persistProjectGrant !== false)
154
+ savePermanent();
155
+ }
156
+ else if (result.value === alwaysOption) {
157
+ permanentToolAllows.add(tool.name);
158
+ if (options.persistProjectGrant !== false)
159
+ savePermanent();
129
160
  }
130
161
  return 'allow';
131
162
  }
132
- /** 移除工具的永久允许(撤销"以后不再询问"授权)。供未来 `/permissions` 管理命令使用。 */
133
- export function revokePermanentAllow(toolName) {
134
- if (permanentAllow.has(toolName)) {
135
- permanentAllow.delete(toolName);
136
- savePermanent();
137
- }
163
+ export function revokePermanentAllow(toolName, fingerprint) {
164
+ loadPermanent();
165
+ permanentGrants = permanentGrants.filter((grant) => grant.tool !== toolName || (fingerprint !== undefined && grant.fingerprint !== fingerprint));
166
+ if (fingerprint === undefined)
167
+ permanentToolAllows.delete(toolName);
168
+ savePermanent();
169
+ }
170
+ export function revokePermanentToolAllow(toolName) {
171
+ loadPermanent();
172
+ permanentToolAllows.delete(toolName);
173
+ savePermanent();
138
174
  }
139
- /** 列出所有永久允许的工具名(供未来 `/permissions` 管理命令使用) */
175
+ export function listPermanentGrants() {
176
+ return loadPermanent().map((grant) => ({ ...grant }));
177
+ }
178
+ export function listPermanentToolAllows() {
179
+ loadPermanent();
180
+ return [...permanentToolAllows].sort();
181
+ }
182
+ /** Compatibility API: returns tools having any persistent resource or tool-wide grant. */
140
183
  export function listPermanentAllow() {
141
- return Array.from(loadPermanent());
184
+ loadPermanent();
185
+ return [...new Set([
186
+ ...permanentGrants.map((grant) => grant.tool),
187
+ ...permanentToolAllows,
188
+ ])].sort();
189
+ }
190
+ export function clearSessionPermissionGrants() {
191
+ sessionGrants.length = 0;
192
+ }
193
+ export function resetPermissionGrantsForTests() {
194
+ sessionGrants.length = 0;
195
+ permanentGrants = [];
196
+ permanentToolAllows = new Set();
197
+ permanentLoaded = true;
142
198
  }