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.
- package/README.md +4 -3
- package/README.zh-CN.md +3 -3
- package/dist/__trace_manual_test__.js +1 -0
- package/dist/agent/core.js +621 -260
- package/dist/agent/index.js +20 -0
- package/dist/agent/spawn.js +9 -1
- package/dist/config/index.js +12 -0
- package/dist/i18n/index.js +34 -0
- package/dist/llm/index.js +20 -2
- package/dist/mcp/index.js +15 -2
- package/dist/permissions/index.js +149 -93
- package/dist/repl/index.js +66 -10
- package/dist/rollback/index.js +36 -0
- package/dist/session/index.js +3 -0
- package/dist/session/trace-metrics.js +70 -0
- package/dist/session/trace-sanitize.js +34 -0
- package/dist/session/trace.js +54 -0
- package/dist/tools/builtins/edit-file.js +13 -1
- package/dist/tools/builtins/index.js +42 -4
- package/dist/tools/builtins/run-command.js +115 -66
- package/dist/tools/builtins/task.js +5 -2
- package/dist/tools/builtins/write-file.js +13 -1
- package/dist/tools/constants.js +5 -1
- package/dist/tools/registry.js +141 -39
- package/dist/tools/resource-lock.js +148 -0
- package/dist/verification/affected.js +149 -0
- package/dist/verification/diagnostics.js +108 -0
- package/dist/verification/discovery.js +48 -0
- package/dist/verification/fingerprint.js +54 -0
- package/dist/verification/index.js +333 -0
- package/dist/verification/postconditions.js +98 -0
- package/dist/verification/profile.js +237 -0
- package/dist/verification/targeted-tests.js +96 -0
- package/dist/verification/types.js +1 -0
- package/package.json +5 -2
package/dist/agent/index.js
CHANGED
|
@@ -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 {
|
package/dist/agent/spawn.js
CHANGED
|
@@ -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,
|
package/dist/config/index.js
CHANGED
|
@@ -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 列表都从这里查。
|
package/dist/i18n/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
44
|
+
// MCP 协议没有可靠副作用声明:未知能力、workspace 串行、每次确认。
|
|
45
45
|
risk: 'dangerous',
|
|
46
|
-
|
|
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
|
|
10
|
+
const PERMISSIONS_VERSION = 3;
|
|
11
|
+
let permanentGrants = [];
|
|
12
|
+
let permanentToolAllows = new Set();
|
|
26
13
|
let permanentLoaded = false;
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
56
|
+
return permanentGrants;
|
|
33
57
|
permanentLoaded = true;
|
|
34
58
|
try {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
73
|
+
return permanentGrants;
|
|
45
74
|
}
|
|
46
|
-
/** 写入永久允许列表(覆盖写;失败静默,下次启动丢失但不阻断当前会话) */
|
|
47
75
|
function savePermanent() {
|
|
48
76
|
try {
|
|
49
77
|
fs.mkdirSync(path.dirname(PERMISSIONS_PATH), { recursive: true });
|
|
50
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
69
|
-
lines.push(t('permission.task', { value: `${preview}${
|
|
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
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
84
|
-
|
|
112
|
+
if (signal?.aborted)
|
|
113
|
+
return 'deny';
|
|
114
|
+
loadPermanent();
|
|
115
|
+
if (permanentToolAllows.has(tool.name))
|
|
85
116
|
return 'allow';
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
96
|
-
const
|
|
97
|
-
const
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|