mosscode 0.1.0__py3-none-any.whl

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.
Files changed (91) hide show
  1. mosscode/__init__.py +10 -0
  2. mosscode/__main__.py +3 -0
  3. mosscode/agent.py +486 -0
  4. mosscode/agents/__init__.py +249 -0
  5. mosscode/agents/builtin/explore.md +16 -0
  6. mosscode/agents/builtin/general.md +12 -0
  7. mosscode/agents/builtin/plan.md +16 -0
  8. mosscode/agents/runtime.py +685 -0
  9. mosscode/agents/tasks.py +193 -0
  10. mosscode/appearance.py +184 -0
  11. mosscode/builtin_commands.py +1363 -0
  12. mosscode/cli.py +547 -0
  13. mosscode/commands.py +254 -0
  14. mosscode/compaction.py +259 -0
  15. mosscode/config.py +337 -0
  16. mosscode/context.py +102 -0
  17. mosscode/conversation.py +1046 -0
  18. mosscode/coordinator.py +54 -0
  19. mosscode/doctor.py +303 -0
  20. mosscode/hooks/__init__.py +22 -0
  21. mosscode/hooks/actions.py +151 -0
  22. mosscode/hooks/conditions.py +170 -0
  23. mosscode/hooks/config.py +404 -0
  24. mosscode/hooks/engine.py +401 -0
  25. mosscode/hooks/events.py +48 -0
  26. mosscode/launcher.py +177 -0
  27. mosscode/mcp_client.py +460 -0
  28. mosscode/mcp_config.py +216 -0
  29. mosscode/member_ui.py +289 -0
  30. mosscode/memory.py +291 -0
  31. mosscode/memory_extract.py +245 -0
  32. mosscode/messages.py +216 -0
  33. mosscode/paths.py +31 -0
  34. mosscode/permissions.py +733 -0
  35. mosscode/prompts.py +483 -0
  36. mosscode/providers.py +587 -0
  37. mosscode/scrollback.py +106 -0
  38. mosscode/security.py +282 -0
  39. mosscode/serialization.py +121 -0
  40. mosscode/session.py +563 -0
  41. mosscode/setup.py +248 -0
  42. mosscode/skills/__init__.py +455 -0
  43. mosscode/skills/builtin/commit/SKILL.md +15 -0
  44. mosscode/skills/builtin/review/SKILL.md +15 -0
  45. mosscode/skills/builtin/test/SKILL.md +13 -0
  46. mosscode/skills/builtin/test/scripts/detect.py +96 -0
  47. mosscode/skills/builtin/test/tool.json +9 -0
  48. mosscode/teams/__init__.py +216 -0
  49. mosscode/teams/backends.py +259 -0
  50. mosscode/teams/board.py +230 -0
  51. mosscode/teams/lock.py +173 -0
  52. mosscode/teams/mailbox.py +172 -0
  53. mosscode/teams/member.py +427 -0
  54. mosscode/teams/merge.py +207 -0
  55. mosscode/teams/pane.py +20 -0
  56. mosscode/teams/pane_app.py +167 -0
  57. mosscode/teams/process.py +371 -0
  58. mosscode/teams/runtime.py +1355 -0
  59. mosscode/teams/view.py +182 -0
  60. mosscode/tool_budget.py +86 -0
  61. mosscode/tools/__init__.py +38 -0
  62. mosscode/tools/agent.py +127 -0
  63. mosscode/tools/base.py +384 -0
  64. mosscode/tools/diffing.py +16 -0
  65. mosscode/tools/execution.py +99 -0
  66. mosscode/tools/files.py +287 -0
  67. mosscode/tools/memory.py +65 -0
  68. mosscode/tools/plan.py +30 -0
  69. mosscode/tools/process.py +216 -0
  70. mosscode/tools/search.py +229 -0
  71. mosscode/tools/search_worker.py +243 -0
  72. mosscode/tools/shell.py +121 -0
  73. mosscode/tools/skill.py +118 -0
  74. mosscode/tools/team.py +447 -0
  75. mosscode/tools/windows_job.py +75 -0
  76. mosscode/tui.py +2325 -0
  77. mosscode/usage.py +68 -0
  78. mosscode/windows_inline.py +148 -0
  79. mosscode/worktree/__init__.py +29 -0
  80. mosscode/worktree/changes.py +59 -0
  81. mosscode/worktree/git.py +125 -0
  82. mosscode/worktree/manager.py +462 -0
  83. mosscode/worktree/meta.py +82 -0
  84. mosscode/worktree/names.py +52 -0
  85. mosscode/worktree/setup.py +119 -0
  86. mosscode-0.1.0.dist-info/METADATA +92 -0
  87. mosscode-0.1.0.dist-info/RECORD +91 -0
  88. mosscode-0.1.0.dist-info/WHEEL +5 -0
  89. mosscode-0.1.0.dist-info/entry_points.txt +3 -0
  90. mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. mosscode-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,733 @@
1
+ """Layered policy and queued approval; no UI or provider dependencies."""
2
+
3
+ import asyncio
4
+ import fnmatch
5
+ import json
6
+ import os
7
+ import re
8
+ import tempfile
9
+ from dataclasses import dataclass, replace
10
+ from pathlib import Path
11
+ from typing import Literal
12
+ from uuid import uuid4
13
+
14
+ import yaml
15
+
16
+ from .security import (
17
+ SecurityError,
18
+ check_command,
19
+ command_prefix,
20
+ command_risk,
21
+ command_segments,
22
+ inside,
23
+ project_path,
24
+ read_only_command,
25
+ resolve_path,
26
+ simple_command,
27
+ starts_with,
28
+ )
29
+
30
+ MODES = ("default", "acceptEdits", "plan", "bypassPermissions")
31
+ REMOVED_MODES = {"strict": "default", "permissive": "bypassPermissions"}
32
+ MODE_LABELS = {"default": "默认", "acceptEdits": "接受编辑", "plan": "计划", "bypassPermissions": "跳过审批"}
33
+ MODE_BADGES = {
34
+ "default": "默认权限",
35
+ "acceptEdits": "⏵⏵ 接受编辑",
36
+ "plan": "⏸ 计划模式",
37
+ "bypassPermissions": "⏵⏵ 跳过审批",
38
+ }
39
+ ALIASES = {
40
+ "Read": "read_file",
41
+ "Write": "write_file",
42
+ "Edit": "edit_file",
43
+ "Bash": "execute_command",
44
+ "Shell": "execute_command",
45
+ "Glob": "glob",
46
+ "Grep": "grep",
47
+ }
48
+ TOOL_NAMES = set(ALIASES.values())
49
+ READ_TOOLS = {"read_file", "glob", "grep"} # May read outside the project after approval.
50
+ # Permission, MCP and hook files under a project root: the user's to edit, never the model's.
51
+ PROTECTED_FILES = (
52
+ ".mosscode/permissions.yaml",
53
+ ".mosscode/permissions.local.yaml",
54
+ ".mosscode/mcp.yaml",
55
+ ".mcp.json",
56
+ ".mosscode/hooks.yaml",
57
+ ".mosscode/hooks.local.yaml",
58
+ )
59
+ EDIT_TOOLS = {"write_file", "edit_file"}
60
+
61
+
62
+ def is_mcp_tool(name: str) -> bool:
63
+ return bool(re.fullmatch(r"mcp__[A-Za-z0-9_]+__[A-Za-z0-9_]+", name)) and len(name) <= 64
64
+
65
+
66
+ def is_skill_tool(name: str) -> bool:
67
+ return bool(re.fullmatch(r"skill__[a-z0-9_]+__[a-z0-9_]+", name)) and len(name) <= 64
68
+
69
+
70
+ def is_json_tool(name: str) -> bool:
71
+ """MCP and skill tools: rules name the whole tool and match its arguments as canonical JSON."""
72
+ return is_mcp_tool(name) or is_skill_tool(name)
73
+
74
+
75
+ CHOICES = ("once", "session", "permanent", "deny")
76
+ COMMAND_CHOICES = ("once", "session", "prefix", "permanent", "deny") # prefix: saved as Bash(<prefix>:*).
77
+ EDIT_CHOICES = ("once", "session", "accept_edits", "permanent", "deny") # accept_edits: switch mode.
78
+ RISKY_CHOICES = ("once", "deny") # High-risk commands: never remembered.
79
+ PLAN_CHOICES = ("execute", "accept_edits", "deny") # deny = keep planning.
80
+ MERGE_CHOICES = ("merge", "deny") # team_merge, asked whatever the mode or rules.
81
+ ALL_CHOICES = frozenset((*COMMAND_CHOICES, *EDIT_CHOICES, *PLAN_CHOICES, *MERGE_CHOICES))
82
+ EXIT_PLAN = "exit_plan_mode"
83
+ PREFIX_SUFFIX = ":*"
84
+ STRICTNESS = ("plan", "default", "acceptEdits", "bypassPermissions") # Strictest first.
85
+ NONINTERACTIVE = "此操作需要用户确认;无界面模式不会自动授权。"
86
+
87
+
88
+ def stricter(a: str, b: str) -> str:
89
+ return a if STRICTNESS.index(a) <= STRICTNESS.index(b) else b
90
+
91
+
92
+ class PermissionConfigError(ValueError):
93
+ pass
94
+
95
+
96
+ def normalize_mode(mode: str) -> str:
97
+ if mode in REMOVED_MODES:
98
+ # Fail loudly rather than silently change how much an old config trusts the model.
99
+ raise PermissionConfigError(f"权限模式 {mode} 已移除,请改用 {REMOVED_MODES[mode]}。")
100
+ if mode not in MODES:
101
+ raise PermissionConfigError("权限模式需为 " + "/".join(MODES) + "。")
102
+ return mode
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class Operation:
107
+ tool: str
108
+ target: str
109
+ cwd: str | None = None
110
+ preview: str = ""
111
+ outside: bool = False # Read target outside the project; absolute posix path.
112
+ risk: str = "" # Why a command must be confirmed every time.
113
+ read_only: bool = False # Whitelisted inspection command; runs without approval.
114
+ prefix: str = "" # Command prefix offered for a permanent Bash(<prefix>:*) rule.
115
+ server: str = "" # MCP server name from the configuration (tool names may be hashed).
116
+ skill: str = "" # Skill whose own tool this is.
117
+ notes: bool = False # memory and load_skill: they touch only their own files, no approval.
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class ApprovalRequest:
122
+ id: str
123
+ call_id: str
124
+ operation: Operation
125
+ reason: str
126
+ choices: tuple[str, ...] = CHOICES
127
+ source: str = "" # The sub-agent asking, e.g. "子 Agent explore · 查找配置加载".
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class Decision:
132
+ action: Literal["allow", "deny", "ask"]
133
+ code: str = ""
134
+ reason: str = ""
135
+
136
+
137
+ def path_matches(target: str, pattern: str) -> bool:
138
+ # '*' is one segment; '**' spans zero or more. Windows paths are case-insensitive.
139
+ if os.name == "nt":
140
+ target, pattern = target.casefold(), pattern.casefold()
141
+ parts, patterns = target.split("/"), pattern.split("/")
142
+ memo = {}
143
+
144
+ def match(i, j):
145
+ if (i, j) not in memo:
146
+ if j == len(patterns):
147
+ answer = i == len(parts)
148
+ elif patterns[j] == "**":
149
+ answer = match(i, j + 1) or (i < len(parts) and match(i + 1, j))
150
+ else:
151
+ answer = i < len(parts) and fnmatch.fnmatchcase(parts[i], patterns[j]) and match(i + 1, j + 1)
152
+ memo[i, j] = answer
153
+ return memo[i, j]
154
+
155
+ return match(0, 0)
156
+
157
+
158
+ def normalized(value: str) -> str:
159
+ value = value.replace("\\", "/")
160
+ while value.startswith("./"):
161
+ value = value[2:]
162
+ return value or "."
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class Rule:
167
+ tool: str
168
+ pattern: str
169
+ action: Literal["allow", "deny"]
170
+ exact: bool = False
171
+ cwd: str | None = None
172
+ server: str | None = None # mcp__server / mcp__server__*: every tool of that server.
173
+
174
+ def matches(self, operation: Operation) -> bool:
175
+ if self.server is not None:
176
+ return bool(operation.server) and operation.server == self.server
177
+ if self.tool != operation.tool or (self.cwd is not None and self.cwd != operation.cwd):
178
+ return False
179
+ if self.exact:
180
+ if operation.tool != "execute_command" and not is_json_tool(operation.tool) and os.name == "nt":
181
+ return self.pattern.casefold() == operation.target.casefold()
182
+ return self.pattern == operation.target
183
+ if is_json_tool(operation.tool):
184
+ return fnmatch.fnmatchcase(operation.target, self.pattern)
185
+ if operation.tool == "execute_command":
186
+ if self.pattern.endswith(PREFIX_SUFFIX):
187
+ prefix = self.pattern[: -len(PREFIX_SUFFIX)]
188
+ if self.action == "deny":
189
+ # Any segment of a compound command may be the denied one.
190
+ return any(starts_with(segment, prefix) for segment in command_segments(operation.target))
191
+ return simple_command(operation.target) and starts_with(operation.target, prefix)
192
+ if (
193
+ self.action == "allow"
194
+ and any(c in self.pattern for c in "*?[")
195
+ and not simple_command(operation.target)
196
+ ):
197
+ return False
198
+ return fnmatch.fnmatchcase(operation.target, self.pattern)
199
+ # Relative patterns cover the project only; absolute ones cover outside reads.
200
+ if Path(self.pattern).is_absolute() != operation.outside:
201
+ return False
202
+ return path_matches(operation.target, self.pattern)
203
+
204
+
205
+ def parse_rule(value, action: str) -> Rule:
206
+ if isinstance(value, str):
207
+ text, exact, cwd = value, False, None
208
+ elif isinstance(value, dict) and not set(value) - {"rule", "exact", "cwd"}:
209
+ text, exact, cwd = value.get("rule"), value.get("exact", False), value.get("cwd")
210
+ if type(exact) is not bool or (cwd is not None and not isinstance(cwd, str)):
211
+ raise PermissionConfigError("exact 需为布尔值,cwd 需为项目相对路径。")
212
+ else:
213
+ raise PermissionConfigError("规则需为 Tool(pattern) 字符串或带 rule/exact/cwd 的对象。")
214
+ if isinstance(text, str) and text.startswith("mcp__") and "(" not in text and not exact and cwd is None:
215
+ return mcp_rule(text, action)
216
+ if isinstance(text, str) and is_skill_tool(text) and not exact and cwd is None:
217
+ return Rule(text, "*", action) # A skill tool with any arguments.
218
+ match = re.fullmatch(r"([A-Za-z_][A-Za-z_0-9]*)\((.*)\)", text, re.DOTALL) if isinstance(text, str) else None
219
+ if not match or not match[2] or "\0" in match[2]:
220
+ raise PermissionConfigError("规则格式需为 Tool(pattern),模式不能为空。")
221
+ tool = ALIASES.get(match[1], match[1])
222
+ if tool not in TOOL_NAMES and not is_json_tool(tool):
223
+ raise PermissionConfigError(f"未知权限工具名:{match[1]}。")
224
+ pattern = match[2] if tool == "execute_command" or is_json_tool(tool) else normalized(match[2])
225
+ if tool != "execute_command" and not is_json_tool(tool) and ".." in pattern.split("/"):
226
+ raise PermissionConfigError("文件规则不能包含 ..。")
227
+ if tool not in READ_TOOLS and tool != "execute_command" and not is_json_tool(tool) and Path(pattern).is_absolute():
228
+ raise PermissionConfigError("写入和编辑规则需使用项目相对路径;只有读取类规则可以使用绝对路径。")
229
+ if cwd is not None:
230
+ cwd = normalized(cwd)
231
+ if tool != "execute_command" or Path(cwd).is_absolute() or ".." in cwd.split("/"):
232
+ raise PermissionConfigError("cwd 仅用于命令规则,且必须为项目相对路径。")
233
+ return Rule(tool, pattern, action, exact, cwd)
234
+
235
+
236
+ def mcp_rule(text: str, action: str) -> Rule:
237
+ """Claude Code style: mcp__server, mcp__server__* (whole server) or mcp__server__tool (any arguments)."""
238
+ server, separator, tool = text[len("mcp__") :].partition("__")
239
+ if not separator or tool == "*":
240
+ if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", server) or server.endswith("_"):
241
+ raise PermissionConfigError(f"MCP Server 规则格式需为 mcp__Server名称 或 mcp__Server名称__*:{text}")
242
+ return Rule("mcp__" + server, "*", action, server=server)
243
+ if not is_mcp_tool(text):
244
+ raise PermissionConfigError(f"MCP 工具规则需为完整工具名 mcp__server__tool:{text}")
245
+ return Rule(text, "*", action)
246
+
247
+
248
+ def read_policy(path: Path):
249
+ try:
250
+ if not path.exists():
251
+ if path.is_symlink():
252
+ raise PermissionConfigError("权限文件是失效的符号链接。")
253
+ return {}, ()
254
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
255
+ if raw is None:
256
+ raw = {}
257
+ if not isinstance(raw, dict) or set(raw) - {"mode", "allow", "deny"}:
258
+ raise PermissionConfigError("仅支持 mode、allow、deny 字段。")
259
+ if "mode" in raw:
260
+ raw["mode"] = normalize_mode(raw["mode"])
261
+ rules = []
262
+ for action in ("allow", "deny"):
263
+ entries = raw.get(action, [])
264
+ if not isinstance(entries, list):
265
+ raise PermissionConfigError(f"{action} 需为规则列表。")
266
+ rules.extend(parse_rule(entry, action) for entry in entries)
267
+ return raw, tuple(rules)
268
+ except (OSError, UnicodeError, yaml.YAMLError, PermissionConfigError, TypeError) as exc:
269
+ # Do not include YAML source snippets, which may contain sensitive data.
270
+ detail = str(exc) if isinstance(exc, PermissionConfigError) else "无法读取有效 UTF-8 YAML。"
271
+ raise PermissionConfigError(f"权限配置 {path}:{detail}") from None
272
+
273
+
274
+ def safe_display(value: str, limit: int = 1000) -> str:
275
+ return "".join(c for c in value if c in "\n\t" or (ord(c) >= 32 and ord(c) != 127))[:limit]
276
+
277
+
278
+ class PermissionPolicy:
279
+ def __init__(self, root: Path, *, mode: str | None = None, load: bool = True, user_path: Path | None = None):
280
+ self.root = root.resolve()
281
+ self.user_path = user_path or Path.home() / ".mosscode" / "permissions.yaml"
282
+ self.project_file = self.root / ".mosscode" / "permissions.yaml"
283
+ self.local_file = self.root / ".mosscode" / "permissions.local.yaml"
284
+ self.layers: list[tuple[Rule, ...]] = [] # User, project, local; evaluated in reverse.
285
+ configured = "default"
286
+ for path in (self.user_path, self.project_file, self.local_file):
287
+ raw, rules = read_policy(path) if load else ({}, ())
288
+ self.layers.append(rules)
289
+ configured = raw.get("mode", configured)
290
+ self.mode = configured
291
+ self.resume_mode = "default" if configured == "plan" else configured
292
+ if mode is not None:
293
+ self.set_mode(mode)
294
+ self.session: list[Rule] = []
295
+ self.interactive = False
296
+ self.session_dir: Path | None = None
297
+ self.memory_dirs: tuple[Path, ...] = () # Note directories: read_file and glob need no approval.
298
+ self.skill_dirs: tuple[Path, ...] = () # Directories of skills in use: treated the same.
299
+ self.requests: asyncio.Queue[ApprovalRequest] = asyncio.Queue()
300
+ self.pending: dict[str, asyncio.Future] = {}
301
+ self._choices: dict[str, tuple[str, ...]] = {}
302
+ self._approval_lock = asyncio.Lock()
303
+ self.noninteractive_reason = NONINTERACTIVE
304
+
305
+ def view(self, mode: str, *, interactive: bool, label: str = "", root: Path | None = None) -> "PolicyView":
306
+ """A sub-agent's policy; rule files, approvals and directories shared, mode and grants its own.
307
+
308
+ Root moves the project boundary and relative rules to a worktree.
309
+ """
310
+ return PolicyView(self, mode, interactive=interactive, label=label, root=root)
311
+
312
+ @property
313
+ def mode(self):
314
+ return self._mode
315
+
316
+ @mode.setter
317
+ def mode(self, value):
318
+ self._mode = normalize_mode(value)
319
+
320
+ def set_mode(self, mode: str) -> None:
321
+ mode = normalize_mode(mode)
322
+ if mode == "plan" and self.mode != "plan":
323
+ self.resume_mode = self.mode
324
+ elif mode != "plan":
325
+ self.resume_mode = mode
326
+ self.mode = mode
327
+
328
+ def operation(self, tool, params) -> Operation:
329
+ args = params.model_dump()
330
+ if (
331
+ self.mode == "plan"
332
+ and not tool.read_only
333
+ and tool.name != EXIT_PLAN
334
+ and not getattr(tool, "plan_allowed", False)
335
+ ):
336
+ if not (tool.name == "execute_command" and read_only_command(args["command"])):
337
+ raise SecurityError(
338
+ "plan_blocked",
339
+ "计划模式只开放只读工具和只读命令(如 git status/diff/log、ls);此调用未执行。"
340
+ "调查完成后用 exit_plan_mode 提交计划,用户批准后才能写入、编辑或执行其他命令。",
341
+ )
342
+ if getattr(tool, "permission_kind", None) == "memory":
343
+ return Operation(tool.name, f"{args['scope']}:{args['name']}", preview=f"{args['action']} 笔记", notes=True)
344
+ if getattr(tool, "permission_kind", None) == "load_skill":
345
+ return Operation(tool.name, args["name"], preview="加载 Skill 规程", notes=True)
346
+ if getattr(tool, "permission_kind", None) == "agent":
347
+ # Starting or managing a sub-agent needs no approval; its own calls are checked one by one.
348
+ target = args.get("agent_type") or args.get("task_id") or "fork"
349
+ return Operation(tool.name, target, preview="子 Agent", notes=True)
350
+ if getattr(tool, "permission_kind", None) == "team":
351
+ # Team bookkeeping (messages, shared tasks, members) needs no approval; members' calls are checked.
352
+ target = args.get("name") or args.get("to") or args.get("id") or args.get("subject") or ""
353
+ return Operation(tool.name, str(target), preview="团队", notes=True)
354
+ if getattr(tool, "permission_kind", None) == "skill":
355
+ target = json.dumps(args, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
356
+ preview = (
357
+ f"Skill {tool.skill_name} 的专属工具 {tool.short_name}\n命令:"
358
+ + " ".join(tool.argv())
359
+ + "\n参数:"
360
+ + safe_display(target, 2000)
361
+ )
362
+ return Operation(tool.name, target, preview=preview, skill=tool.skill_name)
363
+ if getattr(tool, "permission_kind", None) == "mcp":
364
+ target = json.dumps(args, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
365
+ return Operation(
366
+ tool.name,
367
+ target,
368
+ preview="外部 MCP 调用;本地路径限制不约束 Server 的实际访问。",
369
+ server=getattr(tool, "server_name", ""),
370
+ )
371
+ if tool.name == "execute_command":
372
+ target = args["command"]
373
+ cwd = project_path(self.root, args.get("cwd") or ".").relative_to(self.root).as_posix()
374
+ check_command(target)
375
+ risk = command_risk(target) or ""
376
+ preview = f"高风险命令:{risk}。每次都需要确认,规则和档位不会自动放行。" if risk else ""
377
+ return Operation(
378
+ tool.name,
379
+ target,
380
+ cwd,
381
+ preview=preview,
382
+ risk=risk,
383
+ read_only=read_only_command(target),
384
+ prefix="" if risk else command_prefix(target) or "",
385
+ )
386
+ if tool.name in READ_TOOLS:
387
+ resolved = resolve_path(self.root, args.get("path", "."))
388
+ else:
389
+ resolved = project_path(self.root, args.get("path", "."))
390
+ outside = not inside(self.root, resolved)
391
+ target = resolved.as_posix() if outside else resolved.relative_to(self.root).as_posix()
392
+ if not tool.read_only:
393
+ actual = project_path(self.root, target)
394
+ # A worktree view guards the copies in its worktree as well as the project's own files.
395
+ roots = dict.fromkeys((self.root, self.project_file.parent.parent))
396
+ protected = (
397
+ self.user_path,
398
+ Path.home() / ".mosscode/mcp.yaml",
399
+ # Hooks run arbitrary commands without approval.
400
+ Path.home() / ".mosscode/hooks.yaml",
401
+ *(root / name for root in roots for name in PROTECTED_FILES),
402
+ )
403
+ if any(actual == p.resolve() for p in protected):
404
+ raise SecurityError(
405
+ "policy_protected", "权限、MCP 与 Hook 配置文件由用户管理,模型不能自行写入或编辑。"
406
+ )
407
+ preview = ""
408
+ if tool.name == "write_file":
409
+ preview = f"写入 {len(args['content'])} 个字符,内容预览:\n" + safe_display(args["content"])
410
+ elif tool.name == "edit_file":
411
+ preview = (
412
+ "原文:\n" + safe_display(args["old_text"], 400) + "\n替换为:\n" + safe_display(args["new_text"], 400)
413
+ )
414
+ elif tool.name in {"glob", "grep"}:
415
+ preview = "搜索模式:" + safe_display(args["pattern"], 400)
416
+ if args.get("glob"):
417
+ preview += "\n文件筛选:" + safe_display(args["glob"], 200)
418
+ elif tool.name == "read_file":
419
+ preview = f"从第 {args['offset']} 行读取,最多 {args['limit']} 行。"
420
+ if tool.name == "grep" and args.get("output_mode"):
421
+ preview += f"\n输出方式:{args['output_mode']}"
422
+ if outside:
423
+ preview = "项目目录之外\n" + preview
424
+ return Operation(tool.name, target, preview=preview, outside=outside)
425
+
426
+ def decide(self, operation: Operation, *, read_only: bool) -> Decision:
427
+ matched = next(
428
+ (
429
+ hits
430
+ for layer in (tuple(self.session), *reversed(self.layers))
431
+ if (hits := [rule for rule in layer if rule.matches(operation)])
432
+ ),
433
+ [],
434
+ )
435
+ if any(rule.action == "deny" for rule in matched):
436
+ return Decision("deny", "permission_denied", "命中显式拒绝规则;此调用未执行。")
437
+ if operation.risk:
438
+ return Decision("ask", reason=f"高风险命令({operation.risk})需要每次确认。")
439
+ if matched or operation.notes:
440
+ return Decision("allow")
441
+ if (
442
+ operation.outside
443
+ and operation.tool in {"read_file", "glob"}
444
+ and any(inside(folder, Path(operation.target)) for folder in (*self.memory_dirs, *self.skill_dirs))
445
+ ):
446
+ return Decision("allow") # Memory notes and skill files, plan mode included.
447
+ if (
448
+ operation.outside
449
+ and operation.tool == "read_file"
450
+ and self.session_dir is not None
451
+ and inside(self.session_dir, Path(operation.target))
452
+ ):
453
+ return Decision("allow") # Stored tool results and compaction records of this run.
454
+ if operation.outside:
455
+ if self.mode == "bypassPermissions":
456
+ return Decision("allow")
457
+ return Decision("ask", reason="读取项目目录之外的路径需要确认。")
458
+ if self.mode == "bypassPermissions" or read_only or operation.read_only:
459
+ return Decision("allow")
460
+ if self.mode == "acceptEdits" and operation.tool in EDIT_TOOLS:
461
+ return Decision("allow")
462
+ return Decision("ask", reason="此操作没有明确的放行规则。")
463
+
464
+ async def authorize(self, call_id: str, tool, params, *, gate=None, notify=None) -> Decision:
465
+ """gate(operation) runs pre_tool_use hooks after the hard checks and deny rules, before approval
466
+ ; it returns a deny Decision or None. notify(request) is awaited before an approval is shown."""
467
+ if tool.name == EXIT_PLAN:
468
+ return await self._approve_plan(call_id, params, gate, notify)
469
+ # Hard checks always run, including for bypassPermissions and exact grants.
470
+ try:
471
+ operation = self.operation(tool, params)
472
+ except SecurityError as exc:
473
+ return Decision("deny", exc.code, str(exc))
474
+ decision = self.decide(operation, read_only=tool.read_only)
475
+ if decision.action == "deny":
476
+ return decision
477
+ if gate is not None:
478
+ # Hooks only tighten: a hook never turns "ask" into "allow".
479
+ rejected = await gate(operation)
480
+ if rejected is not None:
481
+ return rejected
482
+ if decision.action != "ask":
483
+ return decision
484
+ if not self.interactive:
485
+ return Decision("deny", "approval_required", self.noninteractive_reason)
486
+ async with self._approval_lock:
487
+ # A preceding approval may have added a session/local rule.
488
+ try:
489
+ operation = self.operation(tool, params)
490
+ except SecurityError as exc:
491
+ return Decision("deny", exc.code, str(exc))
492
+ decision = self.decide(operation, read_only=tool.read_only)
493
+ if decision.action != "ask":
494
+ return decision
495
+ preview = await self._diff_preview(tool, params) if operation.tool in EDIT_TOOLS else None
496
+ if preview:
497
+ operation = replace(operation, preview=preview)
498
+ request = ApprovalRequest(uuid4().hex, call_id, operation, decision.reason, self._choices_for(operation))
499
+ try:
500
+ if notify is not None:
501
+ await notify(request)
502
+ choice = await self._ask(request)
503
+ if choice == "deny" and not self.interactive: # Moved to the background meanwhile.
504
+ return Decision("deny", "approval_required", self.noninteractive_reason)
505
+ if choice == "deny":
506
+ return Decision("deny", "permission_denied", "用户拒绝了此操作;此调用未执行。")
507
+ try:
508
+ current = self.operation(tool, params)
509
+ except SecurityError as exc:
510
+ return Decision("deny", exc.code, str(exc))
511
+ if (current.target, current.cwd) != (operation.target, operation.cwd):
512
+ return Decision("deny", "target_changed", "确认期间目标路径发生变化;请重新申请此操作。")
513
+ latest = self.decide(current, read_only=tool.read_only)
514
+ if latest.action == "deny":
515
+ return latest
516
+ rule = Rule(operation.tool, operation.target, "allow", True, operation.cwd)
517
+ if operation.server or operation.skill:
518
+ rule = Rule(operation.tool, "*", "allow") # MCP and skill grants cover the tool, any arguments.
519
+ if choice == "prefix":
520
+ rule = Rule(operation.tool, operation.prefix + PREFIX_SUFFIX, "allow")
521
+ if choice == "session":
522
+ self.session.append(rule)
523
+ elif choice == "accept_edits":
524
+ self.set_mode("acceptEdits")
525
+ elif choice in {"permanent", "prefix"}:
526
+ try:
527
+ self._persist(rule)
528
+ except (PermissionConfigError, OSError, SecurityError):
529
+ return Decision(
530
+ "deny",
531
+ "permission_save_failed",
532
+ "永久授权未保存成功,此调用未执行;可选择本次允许或检查本地权限文件。",
533
+ )
534
+ latest = self.decide(current, read_only=tool.read_only)
535
+ if latest.action == "deny":
536
+ return latest
537
+ # The target might have changed while the user was deciding.
538
+ try:
539
+ current = self.operation(tool, params)
540
+ except SecurityError as exc:
541
+ return Decision("deny", exc.code, str(exc))
542
+ if (current.target, current.cwd) != (operation.target, operation.cwd):
543
+ return Decision("deny", "target_changed", "确认期间目标路径发生变化;请重新申请此操作。")
544
+ return Decision("allow")
545
+ finally:
546
+ self._settle(request)
547
+
548
+ @staticmethod
549
+ def _choices_for(operation: Operation) -> tuple[str, ...]:
550
+ if operation.risk:
551
+ return RISKY_CHOICES
552
+ if operation.tool == "execute_command":
553
+ return COMMAND_CHOICES if operation.prefix else CHOICES
554
+ return EDIT_CHOICES if operation.tool in EDIT_TOOLS else CHOICES
555
+
556
+ @staticmethod
557
+ async def _diff_preview(tool, params) -> str | None:
558
+ preview = getattr(tool, "approval_preview", None)
559
+ if preview is None:
560
+ return None
561
+ try:
562
+ return safe_display(await asyncio.to_thread(preview, params), 8000)
563
+ except Exception:
564
+ return None # e.g. no match yet; the plain old/new preview still applies.
565
+
566
+ async def _ask(self, request: ApprovalRequest) -> str:
567
+ future = asyncio.get_running_loop().create_future()
568
+ self.pending[request.id] = future
569
+ self._choices[request.id] = request.choices
570
+ await self.requests.put(request)
571
+ return await future
572
+
573
+ def _settle(self, request: ApprovalRequest) -> None:
574
+ future = self.pending.pop(request.id, None)
575
+ self._choices.pop(request.id, None)
576
+ if future is not None and not future.done():
577
+ future.cancel()
578
+
579
+ def hold(self, request: ApprovalRequest) -> asyncio.Future:
580
+ """A request answered here on behalf of another process (a pane member)."""
581
+ future = asyncio.get_running_loop().create_future()
582
+ self.pending[request.id] = future
583
+ self._choices[request.id] = request.choices
584
+ return future
585
+
586
+ def release(self, request: ApprovalRequest) -> None:
587
+ """The end of hold(): answered, or withdrawn (the future is cancelled)."""
588
+ self._settle(request)
589
+
590
+ async def _approve_plan(self, call_id: str, params, gate=None, notify=None) -> Decision:
591
+ """The user, never a rule or mode, decides whether planning ends."""
592
+ if self.mode != "plan":
593
+ return Decision("deny", "not_in_plan", "当前不在计划模式,无需提交计划;直接按用户请求执行。")
594
+ if gate is not None:
595
+ rejected = await gate(None) # pre_tool_use before the plan approval.
596
+ if rejected is not None:
597
+ return rejected
598
+ if not self.interactive:
599
+ return Decision("deny", "approval_required", "计划需要用户批准;无界面模式不会自动批准,仍处于计划模式。")
600
+ async with self._approval_lock:
601
+ operation = Operation(EXIT_PLAN, "提交计划", preview=safe_display(params.plan, 4000))
602
+ request = ApprovalRequest(uuid4().hex, call_id, operation, "请审阅计划,决定是否开始执行。", PLAN_CHOICES)
603
+ try:
604
+ if notify is not None:
605
+ await notify(request)
606
+ choice = await self._ask(request)
607
+ finally:
608
+ self._settle(request)
609
+ if choice == "deny":
610
+ return Decision(
611
+ "deny", "permission_denied", "用户选择继续规划;仍处于计划模式。请根据用户的反馈调整计划。"
612
+ )
613
+ if self.mode != "plan":
614
+ return Decision("deny", "not_in_plan", "确认期间已离开计划模式;此调用未执行。")
615
+ self.set_mode("acceptEdits" if choice == "accept_edits" else self.resume_mode)
616
+ return Decision("allow")
617
+
618
+ async def confirm(self, call_id: str, operation: Operation, reason: str, choices: tuple[str, ...]) -> str | None:
619
+ """The user decides, never a rule or mode; None when nobody can be asked."""
620
+ if not self.interactive:
621
+ return None
622
+ async with self._approval_lock:
623
+ request = ApprovalRequest(uuid4().hex, call_id, operation, reason, choices)
624
+ try:
625
+ return await self._ask(request)
626
+ finally:
627
+ self._settle(request)
628
+
629
+ def respond(self, request_id: str, choice: str) -> bool:
630
+ choices = self._choices.get(request_id)
631
+ if choices is not None and choice not in choices:
632
+ raise ValueError("该请求不支持此授权选项。")
633
+ if choice not in ALL_CHOICES:
634
+ raise ValueError("未知授权选项。")
635
+ future = self.pending.get(request_id)
636
+ if future is None or future.done():
637
+ return False
638
+ future.set_result(choice)
639
+ return True
640
+
641
+ def _persist(self, rule: Rule) -> None:
642
+ # Validate the current disk file again instead of overwriting external edits.
643
+ path = project_path(self.root, self.local_file)
644
+ raw, existing = read_policy(path)
645
+ if rule.exact:
646
+ entry = {"rule": f"{rule.tool}({rule.pattern})", "exact": True}
647
+ if rule.cwd is not None:
648
+ entry["cwd"] = rule.cwd
649
+ elif is_json_tool(rule.tool):
650
+ entry = rule.tool # Whole-tool MCP or skill grant, Claude Code style.
651
+ else:
652
+ entry = f"Bash({rule.pattern})" # Prefix grant: readable and editable by hand.
653
+ raw.setdefault("allow", []).append(entry)
654
+ path.parent.mkdir(parents=True, exist_ok=True)
655
+ descriptor, temporary = tempfile.mkstemp(prefix=".permissions-", dir=path.parent)
656
+ try:
657
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
658
+ yaml.safe_dump(raw, stream, allow_unicode=True, sort_keys=False)
659
+ os.replace(temporary, path)
660
+ finally:
661
+ if os.path.exists(temporary):
662
+ os.unlink(temporary)
663
+ self.layers[2] = (*existing, rule)
664
+
665
+
666
+ BACKGROUND_REASON = "此操作需要用户确认;后台子 Agent 不能请求审批,可以添加允许规则或在前台运行。"
667
+
668
+
669
+ class PolicyView(PermissionPolicy):
670
+ """Shares the parent's rule layers, pending approvals and directories.
671
+
672
+ It has its own mode, session grants and request queue; the sub-agent runner forwards
673
+ its requests to the parent's queue so the UI sees one stream.
674
+ """
675
+
676
+ _SHARED = frozenset(
677
+ {
678
+ "root",
679
+ "user_path",
680
+ "project_file",
681
+ "local_file",
682
+ "layers",
683
+ "pending",
684
+ "_choices",
685
+ "_approval_lock",
686
+ "session_dir",
687
+ "memory_dirs",
688
+ "skill_dirs",
689
+ }
690
+ )
691
+
692
+ def __init__(
693
+ self, parent: PermissionPolicy, mode: str, *, interactive: bool, label: str = "", root: Path | None = None
694
+ ):
695
+ object.__setattr__(self, "parent", parent)
696
+ if root is not None: # A sub-agent in a worktree; rule files stay the project's.
697
+ object.__setattr__(self, "root", root.resolve())
698
+ self._mode = normalize_mode(mode)
699
+ self.resume_mode = "default" if self._mode == "plan" else self._mode
700
+ self.session: list[Rule] = []
701
+ self.interactive = interactive
702
+ self.label = label
703
+ self.requests: asyncio.Queue[ApprovalRequest] = asyncio.Queue()
704
+ self.noninteractive_reason = NONINTERACTIVE if interactive else BACKGROUND_REASON
705
+ self._mine: set[str] = set()
706
+
707
+ def __getattr__(self, name):
708
+ if name in PolicyView._SHARED:
709
+ return getattr(self.parent, name)
710
+ raise AttributeError(name)
711
+
712
+ def __setattr__(self, name, value):
713
+ if name in PolicyView._SHARED:
714
+ setattr(self.parent, name, value)
715
+ else:
716
+ object.__setattr__(self, name, value)
717
+
718
+ async def _ask(self, request: ApprovalRequest) -> str:
719
+ request = replace(request, source=self.label)
720
+ self._mine.add(request.id)
721
+ try:
722
+ return await super()._ask(request)
723
+ finally:
724
+ self._mine.discard(request.id)
725
+
726
+ def to_background(self) -> None:
727
+ """No more approvals; one already waiting counts as refused."""
728
+ self.interactive = False
729
+ self.noninteractive_reason = BACKGROUND_REASON
730
+ for request_id in tuple(self._mine):
731
+ future = self.pending.get(request_id)
732
+ if future is not None and not future.done():
733
+ future.set_result("deny")