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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""`field op value` conditions, joined by && or ||, with the permission rules' matching."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..permissions import ALIASES, normalized, path_matches
|
|
10
|
+
from ..security import command_segments, starts_with
|
|
11
|
+
from .events import ALL_FIELDS, EVENTS, fields_of
|
|
12
|
+
|
|
13
|
+
OPERATORS = ("==", "!=", "~=", "=~")
|
|
14
|
+
ARGS_NAME = re.compile(r"[A-Za-z0-9_-]+")
|
|
15
|
+
CONDITION = re.compile(r"\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*(==|!=|~=|=~)\s*(.*?)\s*", re.DOTALL)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HookConfigError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Condition:
|
|
24
|
+
field: str # "tool", "path", "args.command" ...
|
|
25
|
+
op: str # "==" "!=" "~=" "=~"
|
|
26
|
+
value: str # tool aliases resolved, paths normalized
|
|
27
|
+
regex: re.Pattern | None = None
|
|
28
|
+
|
|
29
|
+
def matches(self, fields) -> bool:
|
|
30
|
+
actual = str(fields.get(self.field, ""))
|
|
31
|
+
if self.op == "=~":
|
|
32
|
+
return bool(self.regex.search(actual))
|
|
33
|
+
if self.op in {"==", "!="}:
|
|
34
|
+
return _equal(self.field, actual, self.value) == (self.op == "==")
|
|
35
|
+
return _glob(self.field, actual, self.value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ConditionGroup:
|
|
40
|
+
items: tuple[Condition, ...]
|
|
41
|
+
any: bool = False # True: ||; False: &&
|
|
42
|
+
text: str = "" # As written, for /hooks.
|
|
43
|
+
|
|
44
|
+
def matches(self, fields) -> bool:
|
|
45
|
+
results = (item.matches(fields) for item in self.items)
|
|
46
|
+
return any(results) if self.any else all(results)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _equal(field: str, actual: str, expected: str) -> bool:
|
|
50
|
+
if field == "path":
|
|
51
|
+
actual = normalized(actual) if actual else ""
|
|
52
|
+
if os.name == "nt":
|
|
53
|
+
return actual.casefold() == expected.casefold()
|
|
54
|
+
return actual == expected
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _glob(field: str, actual: str, pattern: str) -> bool:
|
|
58
|
+
if field == "path":
|
|
59
|
+
# Relative patterns cover the project only; absolute ones cover outside targets (as Rule.matches).
|
|
60
|
+
if not actual or Path(pattern).is_absolute() != Path(actual).is_absolute():
|
|
61
|
+
return False
|
|
62
|
+
return path_matches(actual, pattern)
|
|
63
|
+
if field == "command":
|
|
64
|
+
if not actual:
|
|
65
|
+
return False
|
|
66
|
+
candidates = [actual, *command_segments(actual)]
|
|
67
|
+
if pattern.endswith(":*"):
|
|
68
|
+
return any(starts_with(candidate, pattern[:-2]) for candidate in candidates)
|
|
69
|
+
return any(fnmatch.fnmatchcase(candidate, pattern) for candidate in candidates)
|
|
70
|
+
return fnmatch.fnmatchcase(actual, pattern)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _split(expression: str) -> tuple[list[str], set[str]]:
|
|
74
|
+
"""Split on && and || outside double quotes."""
|
|
75
|
+
parts, joins, current, quoted, index = [], set(), [], False, 0
|
|
76
|
+
while index < len(expression):
|
|
77
|
+
char = expression[index]
|
|
78
|
+
if quoted:
|
|
79
|
+
current.append(char)
|
|
80
|
+
if char == "\\" and index + 1 < len(expression):
|
|
81
|
+
current.append(expression[index + 1])
|
|
82
|
+
index += 2
|
|
83
|
+
continue
|
|
84
|
+
if char == '"':
|
|
85
|
+
quoted = False
|
|
86
|
+
elif char == '"':
|
|
87
|
+
quoted = True
|
|
88
|
+
current.append(char)
|
|
89
|
+
elif expression.startswith(("&&", "||"), index):
|
|
90
|
+
parts.append("".join(current))
|
|
91
|
+
joins.add(expression[index : index + 2])
|
|
92
|
+
current = []
|
|
93
|
+
index += 2
|
|
94
|
+
continue
|
|
95
|
+
else:
|
|
96
|
+
current.append(char)
|
|
97
|
+
index += 1
|
|
98
|
+
if quoted:
|
|
99
|
+
raise HookConfigError("条件中的双引号没有闭合。")
|
|
100
|
+
parts.append("".join(current))
|
|
101
|
+
return parts, joins
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _unquote(value: str) -> str:
|
|
105
|
+
if not value.startswith('"'):
|
|
106
|
+
return value
|
|
107
|
+
if len(value) < 2 or not value.endswith('"'):
|
|
108
|
+
raise HookConfigError(f"条件值的引号不完整:{value}")
|
|
109
|
+
inner, result, index = value[1:-1], [], 0
|
|
110
|
+
while index < len(inner):
|
|
111
|
+
char = inner[index]
|
|
112
|
+
if char == "\\" and index + 1 < len(inner) and inner[index + 1] in '"\\':
|
|
113
|
+
result.append(inner[index + 1])
|
|
114
|
+
index += 2
|
|
115
|
+
continue
|
|
116
|
+
if char == '"':
|
|
117
|
+
raise HookConfigError(f'条件值中的引号需写成 \\":{value}')
|
|
118
|
+
result.append(char)
|
|
119
|
+
index += 1
|
|
120
|
+
return "".join(result)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def check_field(name: str, event: str) -> None:
|
|
124
|
+
"""A field this event has; args.<name> on tool events only, one level."""
|
|
125
|
+
if name.startswith("args."):
|
|
126
|
+
if not EVENTS[event].tool:
|
|
127
|
+
raise HookConfigError(f"事件 {event} 没有条件字段 {name}(args.* 只用于工具事件)。")
|
|
128
|
+
if not ARGS_NAME.fullmatch(name[len("args.") :]):
|
|
129
|
+
raise HookConfigError(f"args 字段只支持顶层参数名(如 args.command):{name}")
|
|
130
|
+
return
|
|
131
|
+
if name not in ALL_FIELDS:
|
|
132
|
+
raise HookConfigError(f"未知条件字段:{name}")
|
|
133
|
+
if name not in fields_of(event):
|
|
134
|
+
raise HookConfigError(f"事件 {event} 没有条件字段 {name}。")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _condition(text: str, event: str) -> Condition:
|
|
138
|
+
if not text.strip():
|
|
139
|
+
raise HookConfigError("条件中有空的子条件(检查 && 或 || 两侧)。")
|
|
140
|
+
match = CONDITION.fullmatch(text)
|
|
141
|
+
if not match:
|
|
142
|
+
raise HookConfigError(f"子条件需为“字段 运算符 值”,运算符为 {' '.join(OPERATORS)}:{text.strip()}")
|
|
143
|
+
name, op, raw = match[1], match[2], match[3]
|
|
144
|
+
check_field(name, event)
|
|
145
|
+
if not raw:
|
|
146
|
+
raise HookConfigError(f'子条件缺少值(空值请写 ""):{text.strip()}')
|
|
147
|
+
value = _unquote(raw)
|
|
148
|
+
if op == "=~":
|
|
149
|
+
if len(value) < 2 or not value.startswith("/") or not value.endswith("/"):
|
|
150
|
+
raise HookConfigError(f"正则需写成 /…/:{raw}")
|
|
151
|
+
try:
|
|
152
|
+
return Condition(name, op, value, re.compile(value[1:-1]))
|
|
153
|
+
except re.error as exc:
|
|
154
|
+
raise HookConfigError(f"正则不合法({exc}):{raw}") from None
|
|
155
|
+
if name == "tool" and (op != "~=" or value in ALIASES):
|
|
156
|
+
value = ALIASES.get(value, value)
|
|
157
|
+
elif name == "path" and op != "~=":
|
|
158
|
+
value = normalized(value)
|
|
159
|
+
elif name == "path":
|
|
160
|
+
value = value.replace("\\", "/")
|
|
161
|
+
return Condition(name, op, value)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse_condition(expression, event: str) -> ConditionGroup:
|
|
165
|
+
if not isinstance(expression, str) or not expression.strip():
|
|
166
|
+
raise HookConfigError("if 需为非空的条件字符串。")
|
|
167
|
+
parts, joins = _split(expression)
|
|
168
|
+
if len(joins) > 1:
|
|
169
|
+
raise HookConfigError("同一条件中不能混用 && 和 ||;需要时拆成多条 Hook。")
|
|
170
|
+
return ConditionGroup(tuple(_condition(part, event) for part in parts), joins == {"||"}, expression.strip())
|
mosscode/hooks/config.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Read the three hook files, validate each hook, trust records."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import dataclass, field, replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from ..mcp_config import MCPConfigError, substitute_env
|
|
16
|
+
from ..paths import mosscode_home, project_dir
|
|
17
|
+
from .conditions import ConditionGroup, HookConfigError, check_field, parse_condition
|
|
18
|
+
from .events import EVENTS
|
|
19
|
+
|
|
20
|
+
FILE_LIMIT = 256 * 1024
|
|
21
|
+
TEXT_LIMIT = 8000
|
|
22
|
+
REJECT_LIMIT = 2000
|
|
23
|
+
HOOK_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}")
|
|
24
|
+
HOOK_KEYS = {"id", "event", "if", "action", "reject", "on_error", "once", "async"}
|
|
25
|
+
ACTION_KEYS = {
|
|
26
|
+
"command": {"type", "command", "timeout", "feedback"},
|
|
27
|
+
"prompt": {"type", "message"},
|
|
28
|
+
"http": {"type", "url", "method", "headers", "json", "timeout"},
|
|
29
|
+
"agent": {"type", "prompt", "agent_type", "timeout"},
|
|
30
|
+
}
|
|
31
|
+
METHODS = ("POST", "GET", "PUT", "PATCH", "DELETE")
|
|
32
|
+
ARGV_PLACEHOLDERS = ("FILE_PATH", "PROJECT_ROOT", "PYTHON", "TOOL_NAME", "EVENT")
|
|
33
|
+
ARGV_PLACEHOLDER = re.compile(r"\$\{([^}]*)\}")
|
|
34
|
+
AGENT_TYPE = re.compile(r"[a-z][a-z0-9-]{0,63}")
|
|
35
|
+
AGENT_TIMEOUT = 300 # A sub-agent needs longer than a command.
|
|
36
|
+
TEMPLATE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*\}\}")
|
|
37
|
+
REJECT_TYPE = "reject 需为拒绝原因;动态拒绝请让命令以退出码 2 结束。"
|
|
38
|
+
SOURCES = ("user", "project", "local")
|
|
39
|
+
SOURCE_LABELS = {"user": "用户级", "project": "项目级", "local": "本地"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Action:
|
|
44
|
+
type: Literal["command", "prompt", "http", "agent"]
|
|
45
|
+
command: str | tuple[str, ...] | None = None
|
|
46
|
+
message: str = ""
|
|
47
|
+
url: str = ""
|
|
48
|
+
method: str = "POST"
|
|
49
|
+
headers: tuple[tuple[str, str], ...] = ()
|
|
50
|
+
json_body: Any = None # The json field; None also when absent (has_json tells them apart).
|
|
51
|
+
has_json: bool = False
|
|
52
|
+
prompt: str = "" # agent
|
|
53
|
+
agent_type: str = "general" # agent: the definition to run.
|
|
54
|
+
timeout: float = 30.0
|
|
55
|
+
feedback: bool = False
|
|
56
|
+
|
|
57
|
+
def summary(self) -> str:
|
|
58
|
+
if self.type == "command":
|
|
59
|
+
return self.command if isinstance(self.command, str) else " ".join(self.command)
|
|
60
|
+
if self.type == "http":
|
|
61
|
+
return f"{self.method} {urlsplit(self.url)._replace(query='', fragment='').geturl()}"
|
|
62
|
+
if self.type == "prompt":
|
|
63
|
+
return "注入:" + self.message.replace("\n", " ")[:60]
|
|
64
|
+
return f"子 Agent {self.agent_type}:" + self.prompt.replace("\n", " ")[:60]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Hook:
|
|
69
|
+
id: str
|
|
70
|
+
event: str
|
|
71
|
+
source: Literal["user", "project", "local"]
|
|
72
|
+
path: Path
|
|
73
|
+
condition: ConditionGroup | None
|
|
74
|
+
action: Action | None # None only for a static rejection.
|
|
75
|
+
reject: str | None = None
|
|
76
|
+
on_error: Literal["ignore", "block"] = "ignore"
|
|
77
|
+
once: bool = False
|
|
78
|
+
background: bool = False # async, or an event that always runs in the background
|
|
79
|
+
raw: dict = field(default_factory=dict, compare=False) # For /hooks <id>.
|
|
80
|
+
|
|
81
|
+
def summary(self) -> str:
|
|
82
|
+
if self.action is None:
|
|
83
|
+
return "拒绝:" + self.reject.replace("\n", " ")[:60]
|
|
84
|
+
return self.action.summary()
|
|
85
|
+
|
|
86
|
+
def flags(self) -> tuple[str, ...]:
|
|
87
|
+
return tuple(
|
|
88
|
+
name
|
|
89
|
+
for name, on in (
|
|
90
|
+
("reject", self.reject is not None),
|
|
91
|
+
("once", self.once),
|
|
92
|
+
("async", self.background),
|
|
93
|
+
("on_error: block", self.on_error == "block"),
|
|
94
|
+
)
|
|
95
|
+
if on
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class HookFile:
|
|
101
|
+
source: str
|
|
102
|
+
path: Path
|
|
103
|
+
digest: str | None # None when the file does not exist or could not be read.
|
|
104
|
+
hooks: tuple[Hook, ...] = ()
|
|
105
|
+
trusted: bool = True
|
|
106
|
+
error: str | None = None
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def needs_trust(self) -> bool:
|
|
110
|
+
return not self.trusted and bool(self.hooks)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class HookConfig:
|
|
115
|
+
files: tuple[HookFile, ...] = ()
|
|
116
|
+
problems: tuple[str, ...] = ()
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def hooks(self) -> tuple[Hook, ...]:
|
|
120
|
+
"""Enabled hooks, in order: user, project, local; each file in written order."""
|
|
121
|
+
return tuple(hook for file in self.files if file.trusted for hook in file.hooks)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def untrusted(self) -> tuple[HookFile, ...]:
|
|
125
|
+
return tuple(file for file in self.files if file.needs_trust)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def hook_paths(root: Path, home: Path | None = None) -> tuple[tuple[str, Path], ...]:
|
|
129
|
+
root = Path(root).resolve()
|
|
130
|
+
return (
|
|
131
|
+
("user", mosscode_home(home) / "hooks.yaml"),
|
|
132
|
+
("project", root / ".mosscode" / "hooks.yaml"),
|
|
133
|
+
("local", root / ".mosscode" / "hooks.local.yaml"),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TrustStore:
|
|
138
|
+
"""SHA-256 of each project hook file's content when the user trusted it."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, root: Path, home: Path | None = None):
|
|
141
|
+
self.path = project_dir(root, home) / "hooks-trust.json"
|
|
142
|
+
|
|
143
|
+
def _read(self) -> dict[str, str]:
|
|
144
|
+
try:
|
|
145
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
146
|
+
files = raw.get("files", {}) if isinstance(raw, dict) else {}
|
|
147
|
+
return {k: v for k, v in files.items() if isinstance(k, str) and isinstance(v, str)}
|
|
148
|
+
except (OSError, ValueError):
|
|
149
|
+
return {}
|
|
150
|
+
|
|
151
|
+
def trusted(self, name: str, digest: str) -> bool:
|
|
152
|
+
return self._read().get(name) == digest
|
|
153
|
+
|
|
154
|
+
def trust(self, name: str, digest: str) -> None:
|
|
155
|
+
files = {**self._read(), name: digest}
|
|
156
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".hooks-trust-", dir=self.path.parent)
|
|
158
|
+
try:
|
|
159
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
160
|
+
json.dump({"version": 1, "files": files}, stream, ensure_ascii=False, indent=2)
|
|
161
|
+
os.replace(temporary, self.path)
|
|
162
|
+
finally:
|
|
163
|
+
if os.path.exists(temporary):
|
|
164
|
+
os.unlink(temporary)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _templates(text: str, event: str) -> None:
|
|
168
|
+
for match in TEMPLATE.finditer(text):
|
|
169
|
+
check_field(match[1], event)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _json_templates(value, event: str) -> None:
|
|
173
|
+
if isinstance(value, str):
|
|
174
|
+
_templates(value, event)
|
|
175
|
+
elif isinstance(value, dict):
|
|
176
|
+
for item in value.values():
|
|
177
|
+
_json_templates(item, event)
|
|
178
|
+
elif isinstance(value, list):
|
|
179
|
+
for item in value:
|
|
180
|
+
_json_templates(item, event)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _timeout(raw: dict, default: float = 30) -> float:
|
|
184
|
+
value = raw.get("timeout", default)
|
|
185
|
+
if type(value) not in {int, float} or not 1 <= value <= 600:
|
|
186
|
+
raise HookConfigError("timeout 需为 1–600 之间的秒数。")
|
|
187
|
+
return float(value)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _text(value, name: str, limit: int) -> str:
|
|
191
|
+
if not isinstance(value, str) or not value.strip():
|
|
192
|
+
raise HookConfigError(f"{name} 需为非空字符串。")
|
|
193
|
+
if len(value) > limit:
|
|
194
|
+
raise HookConfigError(f"{name} 最长 {limit} 个字符。")
|
|
195
|
+
return value
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _action(raw, event: str) -> Action:
|
|
199
|
+
if not isinstance(raw, dict):
|
|
200
|
+
raise HookConfigError("action 需为对象,包含 type。")
|
|
201
|
+
kind = raw.get("type")
|
|
202
|
+
if kind not in ACTION_KEYS:
|
|
203
|
+
raise HookConfigError("action.type 需为 command、prompt、http、agent 之一。")
|
|
204
|
+
unknown = sorted(map(str, set(raw) - ACTION_KEYS[kind]))
|
|
205
|
+
if unknown:
|
|
206
|
+
raise HookConfigError(f"{kind} 动作不认识的字段:{', '.join(unknown)}")
|
|
207
|
+
if kind not in EVENTS[event].actions:
|
|
208
|
+
raise HookConfigError(f"事件 {event} 不能使用 {kind} 动作。")
|
|
209
|
+
if kind == "prompt":
|
|
210
|
+
message = _text(raw.get("message"), "message", TEXT_LIMIT)
|
|
211
|
+
_templates(message, event)
|
|
212
|
+
return Action("prompt", message=message)
|
|
213
|
+
if kind == "agent":
|
|
214
|
+
agent_type = raw.get("agent_type", "general")
|
|
215
|
+
if not isinstance(agent_type, str) or not AGENT_TYPE.fullmatch(agent_type):
|
|
216
|
+
raise HookConfigError("agent_type 需为子 Agent 定义的名称(小写字母开头,只含小写字母、数字和 -)。")
|
|
217
|
+
return Action(
|
|
218
|
+
"agent",
|
|
219
|
+
prompt=_text(raw.get("prompt"), "prompt", TEXT_LIMIT),
|
|
220
|
+
agent_type=agent_type,
|
|
221
|
+
timeout=_timeout(raw, AGENT_TIMEOUT),
|
|
222
|
+
)
|
|
223
|
+
if kind == "command":
|
|
224
|
+
command = raw.get("command")
|
|
225
|
+
if isinstance(command, list):
|
|
226
|
+
# YAML reads bare `-3` or `8080` as numbers; argv wants their text.
|
|
227
|
+
command = [
|
|
228
|
+
str(part) if isinstance(part, (int, float)) and not isinstance(part, bool) else part for part in command
|
|
229
|
+
]
|
|
230
|
+
if not command or any(not isinstance(part, str) or not part for part in command):
|
|
231
|
+
raise HookConfigError("command 数组需由非空字符串组成。")
|
|
232
|
+
for part in command:
|
|
233
|
+
for match in ARGV_PLACEHOLDER.finditer(part):
|
|
234
|
+
if match[1] not in ARGV_PLACEHOLDERS:
|
|
235
|
+
raise HookConfigError(
|
|
236
|
+
f"command 数组只支持 {', '.join('${' + p + '}' for p in ARGV_PLACEHOLDERS)}:{match[0]}"
|
|
237
|
+
)
|
|
238
|
+
command = tuple(command)
|
|
239
|
+
elif not isinstance(command, str) or not command.strip():
|
|
240
|
+
raise HookConfigError("command 需为字符串或非空字符串数组。")
|
|
241
|
+
feedback = raw.get("feedback", False)
|
|
242
|
+
if type(feedback) is not bool:
|
|
243
|
+
raise HookConfigError("feedback 需为 true 或 false。")
|
|
244
|
+
if feedback and not EVENTS[event].feedback:
|
|
245
|
+
raise HookConfigError("feedback 只能用于 session_start、turn_start、pre_tool_use、post_tool_use。")
|
|
246
|
+
return Action("command", command=command, timeout=_timeout(raw), feedback=feedback)
|
|
247
|
+
url = raw.get("url")
|
|
248
|
+
if not isinstance(url, str) or not url:
|
|
249
|
+
raise HookConfigError("http 动作需要 url。")
|
|
250
|
+
try:
|
|
251
|
+
url = substitute_env(url)
|
|
252
|
+
headers = raw.get("headers", {})
|
|
253
|
+
if not isinstance(headers, dict) or any(
|
|
254
|
+
not isinstance(k, str) or not isinstance(v, str) for k, v in headers.items()
|
|
255
|
+
):
|
|
256
|
+
raise HookConfigError("headers 需为字符串键值对象。")
|
|
257
|
+
headers = tuple((k, substitute_env(v)) for k, v in headers.items())
|
|
258
|
+
except MCPConfigError as exc:
|
|
259
|
+
raise HookConfigError(str(exc)) from None
|
|
260
|
+
if any(not (k + v).isascii() for k, v in headers):
|
|
261
|
+
raise HookConfigError("headers 的名称和值只能包含 ASCII 字符。")
|
|
262
|
+
if TEMPLATE.search(url):
|
|
263
|
+
raise HookConfigError("url 中不支持 {{字段}};请放在 json 请求体中。")
|
|
264
|
+
parts = urlsplit(url)
|
|
265
|
+
if parts.scheme not in {"http", "https"} or not parts.netloc:
|
|
266
|
+
raise HookConfigError("url 需为 http 或 https 地址。")
|
|
267
|
+
method = raw.get("method", "POST")
|
|
268
|
+
if not isinstance(method, str) or method.upper() not in METHODS:
|
|
269
|
+
raise HookConfigError("method 需为 " + "、".join(METHODS) + " 之一。")
|
|
270
|
+
has_json = "json" in raw
|
|
271
|
+
if has_json:
|
|
272
|
+
_json_templates(raw["json"], event)
|
|
273
|
+
try:
|
|
274
|
+
json.dumps(raw["json"], allow_nan=False)
|
|
275
|
+
except (TypeError, ValueError):
|
|
276
|
+
raise HookConfigError("json 需为可序列化为 JSON 的值。") from None
|
|
277
|
+
return Action(
|
|
278
|
+
"http",
|
|
279
|
+
url=url,
|
|
280
|
+
method=method.upper(),
|
|
281
|
+
headers=headers,
|
|
282
|
+
json_body=raw.get("json"),
|
|
283
|
+
has_json=has_json,
|
|
284
|
+
timeout=_timeout(raw),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def parse_hook(raw, source: str, path: Path) -> Hook:
|
|
289
|
+
if not isinstance(raw, dict):
|
|
290
|
+
raise HookConfigError("Hook 需为对象。")
|
|
291
|
+
unknown = sorted(map(str, set(raw) - HOOK_KEYS))
|
|
292
|
+
if unknown:
|
|
293
|
+
raise HookConfigError(f"不认识的字段:{', '.join(unknown)}")
|
|
294
|
+
hook_id = raw.get("id")
|
|
295
|
+
if not isinstance(hook_id, str) or not HOOK_ID.fullmatch(hook_id):
|
|
296
|
+
raise HookConfigError("id 必填,由字母、数字、_ . - 组成,以字母或数字开头,最长 64 个字符。")
|
|
297
|
+
event = raw.get("event")
|
|
298
|
+
if event not in EVENTS:
|
|
299
|
+
raise HookConfigError("event 需为 " + "、".join(EVENTS) + " 之一。")
|
|
300
|
+
spec = EVENTS[event]
|
|
301
|
+
condition = parse_condition(raw["if"], event) if "if" in raw else None
|
|
302
|
+
reject = None
|
|
303
|
+
if "reject" in raw:
|
|
304
|
+
if event != "pre_tool_use":
|
|
305
|
+
raise HookConfigError("reject 只能用于 pre_tool_use。")
|
|
306
|
+
if not isinstance(raw["reject"], str) or not raw["reject"].strip():
|
|
307
|
+
raise HookConfigError(REJECT_TYPE)
|
|
308
|
+
reject = _text(raw["reject"], "reject", REJECT_LIMIT).strip()
|
|
309
|
+
on_error = raw.get("on_error", "ignore")
|
|
310
|
+
if "on_error" in raw and event != "pre_tool_use":
|
|
311
|
+
raise HookConfigError("on_error 只能用于 pre_tool_use。")
|
|
312
|
+
if on_error not in {"ignore", "block"}:
|
|
313
|
+
raise HookConfigError("on_error 需为 ignore 或 block。")
|
|
314
|
+
for name in ("once", "async"):
|
|
315
|
+
if type(raw.get(name, False)) is not bool:
|
|
316
|
+
raise HookConfigError(f"{name} 需为 true 或 false。")
|
|
317
|
+
if "action" in raw:
|
|
318
|
+
action = _action(raw["action"], event)
|
|
319
|
+
elif reject is not None:
|
|
320
|
+
action = None # A static rejection needs no action.
|
|
321
|
+
else:
|
|
322
|
+
raise HookConfigError("缺少 action(只有写了 reject 的 Hook 可以省略)。")
|
|
323
|
+
if reject is not None and action is not None and action.type == "prompt":
|
|
324
|
+
raise HookConfigError("reject 不能与 prompt 动作同用:被拒绝的调用不再注入内容。")
|
|
325
|
+
asynchronous = raw.get("async", False)
|
|
326
|
+
if asynchronous:
|
|
327
|
+
if spec.sync_only:
|
|
328
|
+
raise HookConfigError("pre_tool_use 只能同步执行,不能 async。")
|
|
329
|
+
if action is not None and action.type == "prompt":
|
|
330
|
+
raise HookConfigError("prompt 动作不能 async:后台结果来不及注入。")
|
|
331
|
+
if action is not None and action.feedback:
|
|
332
|
+
raise HookConfigError("feedback 不能与 async 同用。")
|
|
333
|
+
if spec.background and "async" in raw and not asynchronous and action is not None and action.type != "prompt":
|
|
334
|
+
raise HookConfigError(f"{event} 上的 {action.type} 动作总在后台执行,不能写 async: false。")
|
|
335
|
+
agent = action is not None and action.type == "agent"
|
|
336
|
+
if agent and ("async" in raw and not asynchronous or reject is not None or raw.get("on_error") == "block"):
|
|
337
|
+
# The sub-agent always runs in the background; it can neither reject nor block a call.
|
|
338
|
+
raise HookConfigError("agent 动作总在后台执行,不能写 async: false、reject 或 on_error: block。")
|
|
339
|
+
background = asynchronous or agent or (spec.background and action is not None and action.type != "prompt")
|
|
340
|
+
return Hook(
|
|
341
|
+
hook_id, event, source, path, condition, action, reject, on_error, raw.get("once", False), background, raw=raw
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def read_file(source: str, path: Path) -> tuple[HookFile, list[str]]:
|
|
346
|
+
"""A file-level error skips the file; a hook-level error skips that hook."""
|
|
347
|
+
try:
|
|
348
|
+
if not path.is_file():
|
|
349
|
+
return HookFile(source, path, None), []
|
|
350
|
+
data = path.read_bytes()
|
|
351
|
+
except OSError:
|
|
352
|
+
return HookFile(source, path, None, error="无法读取"), [f"Hook 文件 {path}:无法读取,已跳过。"]
|
|
353
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
354
|
+
try:
|
|
355
|
+
if len(data) > FILE_LIMIT:
|
|
356
|
+
raise HookConfigError("超过 256 KB")
|
|
357
|
+
raw = yaml.safe_load(data.decode("utf-8-sig"))
|
|
358
|
+
if raw is None:
|
|
359
|
+
raw = {}
|
|
360
|
+
if not isinstance(raw, dict):
|
|
361
|
+
raise HookConfigError("顶层需为对象")
|
|
362
|
+
if set(raw) - {"hooks"}:
|
|
363
|
+
raise HookConfigError("顶层只支持 hooks 字段")
|
|
364
|
+
entries = raw.get("hooks") or []
|
|
365
|
+
if not isinstance(entries, list):
|
|
366
|
+
raise HookConfigError("hooks 需为列表")
|
|
367
|
+
except (UnicodeError, yaml.YAMLError, HookConfigError) as exc:
|
|
368
|
+
# Never echo file contents: they may contain tokens.
|
|
369
|
+
detail = str(exc) if isinstance(exc, HookConfigError) else "不是有效的 UTF-8 YAML"
|
|
370
|
+
return HookFile(source, path, digest, error=detail), [f"Hook 文件 {path}:{detail},已跳过。"]
|
|
371
|
+
hooks, problems = [], []
|
|
372
|
+
for index, entry in enumerate(entries, 1):
|
|
373
|
+
try:
|
|
374
|
+
hooks.append(parse_hook(entry, source, path))
|
|
375
|
+
except HookConfigError as exc:
|
|
376
|
+
label = (
|
|
377
|
+
entry.get("id") if isinstance(entry, dict) and isinstance(entry.get("id"), str) else f"第 {index} 项"
|
|
378
|
+
)
|
|
379
|
+
problems.append(f"{path} · {label} · {exc}")
|
|
380
|
+
return HookFile(source, path, digest, tuple(hooks)), problems
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def load_hooks(
|
|
384
|
+
root: Path, home: Path | None = None, *, store: TrustStore | None = None, trust_project: bool = False
|
|
385
|
+
) -> HookConfig:
|
|
386
|
+
files, problems, seen = [], [], set()
|
|
387
|
+
for source, path in hook_paths(root, home):
|
|
388
|
+
file, issues = read_file(source, path)
|
|
389
|
+
problems.extend(issues)
|
|
390
|
+
unique = []
|
|
391
|
+
for hook in file.hooks:
|
|
392
|
+
if hook.id in seen:
|
|
393
|
+
problems.append(f"{path} · {hook.id} · id 与之前加载的 Hook 重复,已跳过。")
|
|
394
|
+
continue
|
|
395
|
+
seen.add(hook.id)
|
|
396
|
+
unique.append(hook)
|
|
397
|
+
trusted = (
|
|
398
|
+
source == "user"
|
|
399
|
+
or trust_project
|
|
400
|
+
or file.digest is None
|
|
401
|
+
or (store is not None and store.trusted(path.name, file.digest))
|
|
402
|
+
)
|
|
403
|
+
files.append(replace(file, hooks=tuple(unique), trusted=trusted))
|
|
404
|
+
return HookConfig(tuple(files), tuple(problems))
|