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.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/permissions.py
ADDED
|
@@ -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")
|