mycode-coding-agent 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.
- mycode/__init__.py +0 -0
- mycode/adapters/__init__.py +21 -0
- mycode/adapters/jsonl.py +692 -0
- mycode/agent/__init__.py +25 -0
- mycode/agent/events.py +111 -0
- mycode/agent/outcome.py +103 -0
- mycode/agent/progress.py +373 -0
- mycode/agent/runner.py +1481 -0
- mycode/application/__init__.py +38 -0
- mycode/application/agent_session.py +367 -0
- mycode/application/events.py +59 -0
- mycode/application/runtime.py +211 -0
- mycode/application/sessions.py +180 -0
- mycode/cli.py +840 -0
- mycode/config.py +355 -0
- mycode/context/__init__.py +1 -0
- mycode/context/artifacts.py +672 -0
- mycode/context/budget.py +752 -0
- mycode/context/builder.py +112 -0
- mycode/context/compact.py +795 -0
- mycode/context/tool_result_format.py +199 -0
- mycode/context/tool_result_retention.py +261 -0
- mycode/conversation.py +78 -0
- mycode/error_handling.py +481 -0
- mycode/event_format.py +147 -0
- mycode/instructions.py +285 -0
- mycode/llm.py +771 -0
- mycode/mcp/__init__.py +41 -0
- mycode/mcp/client.py +44 -0
- mycode/mcp/config.py +207 -0
- mycode/mcp/errors.py +302 -0
- mycode/mcp/manager.py +339 -0
- mycode/mcp/models.py +20 -0
- mycode/mcp/result_adapter.py +58 -0
- mycode/mcp/tool_adapter.py +145 -0
- mycode/mcp/trust.py +313 -0
- mycode/memory.py +570 -0
- mycode/memory_context.py +245 -0
- mycode/messages.py +63 -0
- mycode/observability.py +28 -0
- mycode/permissions.py +262 -0
- mycode/persistence/__init__.py +1 -0
- mycode/persistence/filesystem.py +291 -0
- mycode/persistence/project_storage.py +208 -0
- mycode/persistence/session_lock.py +138 -0
- mycode/persistence/session_store.py +503 -0
- mycode/presentation/__init__.py +1 -0
- mycode/presentation/cli/__init__.py +14 -0
- mycode/presentation/cli/confirmer.py +116 -0
- mycode/presentation/cli/mcp_trust.py +61 -0
- mycode/presentation/cli/presenter.py +320 -0
- mycode/presentation/cli/session_menu.py +146 -0
- mycode/presentation/cli/subagent_observer.py +124 -0
- mycode/presentation/command_format.py +90 -0
- mycode/presentation/commands.py +95 -0
- mycode/presentation/tui/__init__.py +6 -0
- mycode/presentation/tui/app.py +1351 -0
- mycode/presentation/tui/interactions.py +253 -0
- mycode/presentation/tui/presenter.py +266 -0
- mycode/presentation/tui/screens.py +305 -0
- mycode/presentation/tui/widgets.py +214 -0
- mycode/project.py +22 -0
- mycode/prompts.py +181 -0
- mycode/reasoning.py +40 -0
- mycode/session.py +86 -0
- mycode/skills/__init__.py +27 -0
- mycode/skills/builtin/database-recovery/SKILL.md +138 -0
- mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
- mycode/skills/registry.py +295 -0
- mycode/skills/state.py +68 -0
- mycode/subagents/__init__.py +1 -0
- mycode/subagents/audit.py +212 -0
- mycode/subagents/concurrency.py +124 -0
- mycode/subagents/contracts.py +421 -0
- mycode/subagents/delegate.py +80 -0
- mycode/subagents/delegation.py +128 -0
- mycode/subagents/lifecycle.py +86 -0
- mycode/subagents/limits.py +7 -0
- mycode/subagents/observability.py +150 -0
- mycode/subagents/persistence.py +152 -0
- mycode/subagents/profiles.py +184 -0
- mycode/subagents/prompts.py +67 -0
- mycode/subagents/results.py +178 -0
- mycode/subagents/runtime.py +528 -0
- mycode/subagents/snapshots.py +211 -0
- mycode/subagents/tool_batch.py +260 -0
- mycode/tools/__init__.py +81 -0
- mycode/tools/base.py +222 -0
- mycode/tools/bounds.py +14 -0
- mycode/tools/command_executor.py +167 -0
- mycode/tools/command_output.py +166 -0
- mycode/tools/command_risk.py +596 -0
- mycode/tools/defaults.py +59 -0
- mycode/tools/edit_file.py +524 -0
- mycode/tools/file_mutation.py +30 -0
- mycode/tools/glob.py +247 -0
- mycode/tools/grep.py +324 -0
- mycode/tools/ignore.py +122 -0
- mycode/tools/inspect_changes.py +269 -0
- mycode/tools/load_skill.py +92 -0
- mycode/tools/memory.py +264 -0
- mycode/tools/path_permissions.py +78 -0
- mycode/tools/patterns.py +48 -0
- mycode/tools/permission_metadata.py +27 -0
- mycode/tools/process_tree.py +166 -0
- mycode/tools/read_file.py +242 -0
- mycode/tools/read_skill_resource.py +93 -0
- mycode/tools/registry.py +279 -0
- mycode/tools/run_command.py +237 -0
- mycode/tools/run_skill_script.py +206 -0
- mycode/tools/run_validation.py +107 -0
- mycode/tools/submit_result.py +93 -0
- mycode/tools/text.py +15 -0
- mycode/tools/validation_command.py +377 -0
- mycode/tools/workspace.py +33 -0
- mycode/tools/write_file.py +169 -0
- mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
- mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
- mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
mycode/tools/registry.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import Iterable
|
|
3
|
+
from dataclasses import dataclass, field, replace
|
|
4
|
+
|
|
5
|
+
from mycode.permissions import (
|
|
6
|
+
ConfirmationRequest,
|
|
7
|
+
Confirmer,
|
|
8
|
+
DefaultPermissionChecker,
|
|
9
|
+
PermissionChecker,
|
|
10
|
+
PermissionDecision,
|
|
11
|
+
PermissionRequest,
|
|
12
|
+
RejectingConfirmer,
|
|
13
|
+
ScopedApprovalState,
|
|
14
|
+
)
|
|
15
|
+
from mycode.tools.base import (
|
|
16
|
+
BaseTool,
|
|
17
|
+
SyncTool,
|
|
18
|
+
ToolArgumentValidationError,
|
|
19
|
+
ToolResult,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DuplicateToolError(ValueError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ToolNotFoundError(KeyError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ToolRegistry:
|
|
33
|
+
_tools: dict[str, BaseTool] = field(default_factory=dict)
|
|
34
|
+
permission_checker: PermissionChecker = field(default_factory=DefaultPermissionChecker)
|
|
35
|
+
confirmer: Confirmer = field(default_factory=RejectingConfirmer)
|
|
36
|
+
scoped_approvals: ScopedApprovalState = field(default_factory=ScopedApprovalState)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_tools(
|
|
40
|
+
cls,
|
|
41
|
+
tools: Iterable[BaseTool],
|
|
42
|
+
*,
|
|
43
|
+
permission_checker: PermissionChecker | None = None,
|
|
44
|
+
confirmer: Confirmer | None = None,
|
|
45
|
+
) -> "ToolRegistry":
|
|
46
|
+
registry = cls()
|
|
47
|
+
if permission_checker is not None:
|
|
48
|
+
registry.permission_checker = permission_checker
|
|
49
|
+
if confirmer is not None:
|
|
50
|
+
registry.confirmer = confirmer
|
|
51
|
+
|
|
52
|
+
for tool in tools:
|
|
53
|
+
registry.register(tool)
|
|
54
|
+
|
|
55
|
+
return registry
|
|
56
|
+
|
|
57
|
+
def register(self, tool: BaseTool) -> None:
|
|
58
|
+
if tool.name in self._tools:
|
|
59
|
+
raise DuplicateToolError(f"Tool already registered: {tool.name}")
|
|
60
|
+
|
|
61
|
+
self._tools[tool.name] = tool
|
|
62
|
+
|
|
63
|
+
def get(self, name: str) -> BaseTool | None:
|
|
64
|
+
return self._tools.get(name)
|
|
65
|
+
|
|
66
|
+
def require(self, name: str) -> BaseTool:
|
|
67
|
+
tool = self.get(name)
|
|
68
|
+
|
|
69
|
+
if tool is None:
|
|
70
|
+
raise ToolNotFoundError(f"Tool not found: {name}")
|
|
71
|
+
|
|
72
|
+
return tool
|
|
73
|
+
|
|
74
|
+
def run_tool(self, name: str, arguments: dict[str, object]) -> ToolResult:
|
|
75
|
+
tool = self.get(name)
|
|
76
|
+
|
|
77
|
+
if tool is None:
|
|
78
|
+
return ToolResult.failure(
|
|
79
|
+
error=f"Tool not found: {name}",
|
|
80
|
+
metadata={"tool_name": name},
|
|
81
|
+
)
|
|
82
|
+
if not isinstance(tool, SyncTool):
|
|
83
|
+
return ToolResult.failure(
|
|
84
|
+
error="Tool requires async execution path",
|
|
85
|
+
metadata={
|
|
86
|
+
"tool_name": name,
|
|
87
|
+
"reason": "async_execution_required",
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
args = tool.parse_arguments(arguments)
|
|
93
|
+
except ToolArgumentValidationError as error:
|
|
94
|
+
return ToolResult.failure(
|
|
95
|
+
error="Invalid tool arguments",
|
|
96
|
+
metadata={"validation_errors": error.errors},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
request, decision = tool.check_permission(args, self.permission_checker)
|
|
101
|
+
except Exception as error: # noqa: BLE001 - normalize permission boundary
|
|
102
|
+
return ToolResult.failure(
|
|
103
|
+
error=f"Tool permission check failed: {error}",
|
|
104
|
+
metadata={
|
|
105
|
+
"tool_name": name,
|
|
106
|
+
"exception_type": type(error).__name__,
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
decision, permission_failure = _resolve_permission(
|
|
111
|
+
request,
|
|
112
|
+
decision,
|
|
113
|
+
self.confirmer,
|
|
114
|
+
self.scoped_approvals,
|
|
115
|
+
)
|
|
116
|
+
if permission_failure is not None:
|
|
117
|
+
return permission_failure
|
|
118
|
+
|
|
119
|
+
return _with_confirmation_metadata(tool.run_authorized(args, decision), decision)
|
|
120
|
+
|
|
121
|
+
def is_concurrency_safe(self, name: str) -> bool:
|
|
122
|
+
tool = self.get(name)
|
|
123
|
+
if tool is None:
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
return bool(
|
|
127
|
+
getattr(tool, "concurrency_safe", False)
|
|
128
|
+
and getattr(tool, "capability", None) == "read"
|
|
129
|
+
and getattr(tool, "risk", None) == "low"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
async def run_tool_async(
|
|
133
|
+
self,
|
|
134
|
+
name: str,
|
|
135
|
+
arguments: dict[str, object],
|
|
136
|
+
*,
|
|
137
|
+
permission_lock: asyncio.Lock,
|
|
138
|
+
) -> ToolResult:
|
|
139
|
+
"""Run any tool through the common async execution contract."""
|
|
140
|
+
tool = self.get(name)
|
|
141
|
+
|
|
142
|
+
if tool is None:
|
|
143
|
+
return ToolResult.failure(
|
|
144
|
+
error=f"Tool not found: {name}",
|
|
145
|
+
metadata={"tool_name": name},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
args = tool.parse_arguments(arguments)
|
|
150
|
+
except ToolArgumentValidationError as error:
|
|
151
|
+
return ToolResult.failure(
|
|
152
|
+
error="Invalid tool arguments",
|
|
153
|
+
metadata={"validation_errors": error.errors},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async with permission_lock:
|
|
157
|
+
try:
|
|
158
|
+
request, decision = tool.check_permission(
|
|
159
|
+
args,
|
|
160
|
+
self.permission_checker,
|
|
161
|
+
)
|
|
162
|
+
except Exception as error: # noqa: BLE001 - normalize permission boundary
|
|
163
|
+
return ToolResult.failure(
|
|
164
|
+
error=f"Tool permission check failed: {error}",
|
|
165
|
+
metadata={
|
|
166
|
+
"tool_name": name,
|
|
167
|
+
"exception_type": type(error).__name__,
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
decision, permission_failure = _resolve_permission(
|
|
172
|
+
request,
|
|
173
|
+
decision,
|
|
174
|
+
self.confirmer,
|
|
175
|
+
self.scoped_approvals,
|
|
176
|
+
)
|
|
177
|
+
if permission_failure is not None:
|
|
178
|
+
return permission_failure
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
result = await tool.run_authorized_async(args, decision)
|
|
182
|
+
except Exception as error: # noqa: BLE001 - normalize tool boundary
|
|
183
|
+
result = ToolResult.failure(
|
|
184
|
+
error=f"Tool execution failed: {error}",
|
|
185
|
+
metadata={"exception_type": type(error).__name__},
|
|
186
|
+
)
|
|
187
|
+
return _with_confirmation_metadata(result, decision)
|
|
188
|
+
|
|
189
|
+
def list_tools(self) -> list[BaseTool]:
|
|
190
|
+
return list(self._tools.values())
|
|
191
|
+
|
|
192
|
+
def get_schemas(self) -> list[dict[str, object]]:
|
|
193
|
+
return [tool.get_schema() for tool in self.list_tools()]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _with_confirmation_metadata(
|
|
197
|
+
result: ToolResult, decision: PermissionDecision,
|
|
198
|
+
) -> ToolResult:
|
|
199
|
+
"""Keep approval provenance even when a tool does not copy its decision."""
|
|
200
|
+
keys = (
|
|
201
|
+
"confirmation_status", "confirmation_scope", "confirmation_source",
|
|
202
|
+
"confirmation_message", "confirmation_metadata", "grant_scope",
|
|
203
|
+
)
|
|
204
|
+
metadata = {key: decision.metadata[key] for key in keys if key in decision.metadata}
|
|
205
|
+
if not metadata:
|
|
206
|
+
return result
|
|
207
|
+
return replace(result, metadata={**result.metadata, **metadata})
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _permission_failure(decision: PermissionDecision) -> ToolResult:
|
|
211
|
+
return ToolResult.failure(
|
|
212
|
+
error=decision.message or "Permission denied",
|
|
213
|
+
metadata={
|
|
214
|
+
"permission_status": decision.status,
|
|
215
|
+
"permission_reason": decision.reason,
|
|
216
|
+
**decision.metadata,
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _resolve_permission(
|
|
222
|
+
request: PermissionRequest,
|
|
223
|
+
decision: PermissionDecision,
|
|
224
|
+
confirmer: Confirmer,
|
|
225
|
+
scoped_approvals: ScopedApprovalState,
|
|
226
|
+
) -> tuple[PermissionDecision, ToolResult | None]:
|
|
227
|
+
if decision.status == "deny":
|
|
228
|
+
return decision, _permission_failure(decision)
|
|
229
|
+
|
|
230
|
+
if decision.status != "ask":
|
|
231
|
+
return decision, None
|
|
232
|
+
|
|
233
|
+
grant_scope = scoped_approvals.allows_ask()
|
|
234
|
+
if grant_scope is not None:
|
|
235
|
+
return PermissionDecision.allow(
|
|
236
|
+
message=decision.message,
|
|
237
|
+
metadata={
|
|
238
|
+
**decision.metadata,
|
|
239
|
+
"confirmation_status": "approved",
|
|
240
|
+
"confirmation_scope": grant_scope,
|
|
241
|
+
"confirmation_source": "scoped_grant",
|
|
242
|
+
"grant_scope": grant_scope,
|
|
243
|
+
},
|
|
244
|
+
), None
|
|
245
|
+
|
|
246
|
+
confirmation_request = ConfirmationRequest(
|
|
247
|
+
permission_request=request,
|
|
248
|
+
permission_decision=decision,
|
|
249
|
+
prompt=decision.message,
|
|
250
|
+
metadata=decision.metadata,
|
|
251
|
+
)
|
|
252
|
+
confirmation_result = confirmer.confirm(confirmation_request)
|
|
253
|
+
if confirmation_result.status == "rejected":
|
|
254
|
+
return decision, ToolResult.failure(
|
|
255
|
+
error=confirmation_result.message
|
|
256
|
+
or "Permission confirmation rejected",
|
|
257
|
+
metadata={
|
|
258
|
+
"permission_status": decision.status,
|
|
259
|
+
"permission_reason": decision.reason,
|
|
260
|
+
"confirmation_status": confirmation_result.status,
|
|
261
|
+
"confirmation_message": confirmation_result.message,
|
|
262
|
+
**decision.metadata,
|
|
263
|
+
"confirmation_metadata": confirmation_result.metadata,
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
assert confirmation_result.scope is not None
|
|
268
|
+
scoped_approvals.approve(confirmation_result.scope)
|
|
269
|
+
return PermissionDecision.allow(
|
|
270
|
+
message=decision.message,
|
|
271
|
+
metadata={
|
|
272
|
+
**decision.metadata,
|
|
273
|
+
"confirmation_status": confirmation_result.status,
|
|
274
|
+
"confirmation_scope": confirmation_result.scope,
|
|
275
|
+
"confirmation_source": "explicit",
|
|
276
|
+
"confirmation_message": confirmation_result.message,
|
|
277
|
+
"confirmation_metadata": confirmation_result.metadata,
|
|
278
|
+
},
|
|
279
|
+
), None
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, field_validator
|
|
6
|
+
|
|
7
|
+
from mycode.permissions import PermissionChecker, PermissionDecision, PermissionRequest
|
|
8
|
+
from mycode.tools.base import PydanticTool, ToolArgs, ToolResult
|
|
9
|
+
from mycode.tools.command_executor import CommandExecutionArgs, execute_command
|
|
10
|
+
from mycode.tools.command_risk import CommandRiskAnalysis, analyze_command_risk
|
|
11
|
+
from mycode.tools.permission_metadata import with_permission_metadata
|
|
12
|
+
from mycode.tools.workspace import Workspace, WorkspacePathError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
16
|
+
DEFAULT_MAX_OUTPUT_CHARS = 12000
|
|
17
|
+
MAX_TIMEOUT_SECONDS = 300.0
|
|
18
|
+
MAX_OUTPUT_CHARS = 200000
|
|
19
|
+
COMMAND_DESCRIPTION = (
|
|
20
|
+
"要执行的 argv 字符串数组。第一个元素是可执行程序,后续元素是参数。"
|
|
21
|
+
"不是 Shell 命令字符串,不支持 &&、||、|、>、< 等 Shell 操作符,也不会展开"
|
|
22
|
+
" *.py 等通配符。需要切换工作目录时使用 cwd,不要执行 cd。例如:"
|
|
23
|
+
"[\"pytest\", \"tests/test_a.py\", \"-q\"]。"
|
|
24
|
+
)
|
|
25
|
+
COMMAND_VALIDATION_ERROR = (
|
|
26
|
+
"command 必须是 argv 字符串数组,而不是 Shell 命令字符串。请使用 cwd 代替 cd;"
|
|
27
|
+
"不支持 &&、||、|、>、< 等 Shell 语法。"
|
|
28
|
+
)
|
|
29
|
+
CWD_DESCRIPTION = (
|
|
30
|
+
"命令执行时的工作目录,必须位于 workspace 内。需要切换目录时使用该参数,不要执行 cd。"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RunCommandArgs(ToolArgs):
|
|
35
|
+
command: list[str] = Field(min_length=1, description=COMMAND_DESCRIPTION)
|
|
36
|
+
cwd: str = Field(default=".", min_length=1, description=CWD_DESCRIPTION)
|
|
37
|
+
timeout_seconds: float = Field(
|
|
38
|
+
default=DEFAULT_TIMEOUT_SECONDS,
|
|
39
|
+
gt=0,
|
|
40
|
+
le=MAX_TIMEOUT_SECONDS,
|
|
41
|
+
)
|
|
42
|
+
max_output_chars: int = Field(
|
|
43
|
+
default=DEFAULT_MAX_OUTPUT_CHARS,
|
|
44
|
+
ge=0,
|
|
45
|
+
le=MAX_OUTPUT_CHARS,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@field_validator("command", mode="before")
|
|
49
|
+
@classmethod
|
|
50
|
+
def normalize_json_encoded_command(cls, value: object) -> object:
|
|
51
|
+
"""Accept one common Provider mistake without introducing shell parsing."""
|
|
52
|
+
|
|
53
|
+
if not isinstance(value, str):
|
|
54
|
+
if not isinstance(value, (list, tuple)) or not value or any(
|
|
55
|
+
not isinstance(part, str) for part in value
|
|
56
|
+
):
|
|
57
|
+
raise ValueError(COMMAND_VALIDATION_ERROR)
|
|
58
|
+
return value
|
|
59
|
+
try:
|
|
60
|
+
decoded = json.loads(value)
|
|
61
|
+
except json.JSONDecodeError as error:
|
|
62
|
+
raise ValueError(COMMAND_VALIDATION_ERROR) from error
|
|
63
|
+
if not isinstance(decoded, list) or not decoded or any(
|
|
64
|
+
not isinstance(part, str) for part in decoded
|
|
65
|
+
):
|
|
66
|
+
raise ValueError(COMMAND_VALIDATION_ERROR)
|
|
67
|
+
return decoded
|
|
68
|
+
|
|
69
|
+
@field_validator("command")
|
|
70
|
+
@classmethod
|
|
71
|
+
def command_parts_must_not_be_empty(cls, value: list[str]) -> list[str]:
|
|
72
|
+
if any(part == "" for part in value):
|
|
73
|
+
raise ValueError(COMMAND_VALIDATION_ERROR)
|
|
74
|
+
if any("\x00" in part for part in value):
|
|
75
|
+
raise ValueError(COMMAND_VALIDATION_ERROR)
|
|
76
|
+
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RunCommandTool(PydanticTool[RunCommandArgs]):
|
|
81
|
+
name = "run_command"
|
|
82
|
+
description = (
|
|
83
|
+
"在 workspace 内执行非交互命令。command 使用结构化 argv,命令直接执行,不经过 Shell 解释。"
|
|
84
|
+
)
|
|
85
|
+
args_model = RunCommandArgs
|
|
86
|
+
capability = "command"
|
|
87
|
+
risk = "high"
|
|
88
|
+
|
|
89
|
+
def __init__(self, workspace: Workspace) -> None:
|
|
90
|
+
self.workspace = workspace
|
|
91
|
+
|
|
92
|
+
def build_permission_request(self, args: RunCommandArgs) -> PermissionRequest:
|
|
93
|
+
return PermissionRequest(
|
|
94
|
+
tool_name=self.name,
|
|
95
|
+
capability=self.capability,
|
|
96
|
+
action=self.name,
|
|
97
|
+
target=_display_command(args.command),
|
|
98
|
+
arguments=args.model_dump(),
|
|
99
|
+
description="Run a non-interactive command.",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def check_permission(
|
|
103
|
+
self,
|
|
104
|
+
args: RunCommandArgs,
|
|
105
|
+
permission_checker: PermissionChecker,
|
|
106
|
+
) -> tuple[PermissionRequest, PermissionDecision]:
|
|
107
|
+
request = self.build_permission_request(args)
|
|
108
|
+
try:
|
|
109
|
+
resolved_cwd = self.workspace.resolve_path(args.cwd)
|
|
110
|
+
except WorkspacePathError:
|
|
111
|
+
return request, PermissionDecision.deny(
|
|
112
|
+
reason="outside_workspace",
|
|
113
|
+
message=f"Command working directory is outside workspace: {args.cwd}",
|
|
114
|
+
metadata=_command_metadata(
|
|
115
|
+
args=args,
|
|
116
|
+
workspace_root=self.workspace.root,
|
|
117
|
+
resolved_cwd=_resolve_candidate_path(self.workspace, args.cwd),
|
|
118
|
+
cwd_scope="outside_workspace",
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
risk_analysis = analyze_command_risk(args.command)
|
|
123
|
+
metadata = _command_metadata(
|
|
124
|
+
args=args,
|
|
125
|
+
workspace_root=self.workspace.root,
|
|
126
|
+
resolved_cwd=resolved_cwd,
|
|
127
|
+
cwd_scope="inside_workspace",
|
|
128
|
+
risk_analysis=risk_analysis,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if not resolved_cwd.exists():
|
|
132
|
+
return request, PermissionDecision.deny(
|
|
133
|
+
reason="unsupported_operation",
|
|
134
|
+
message=f"Command working directory does not exist: {args.cwd}",
|
|
135
|
+
metadata=metadata,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if not resolved_cwd.is_dir():
|
|
139
|
+
return request, PermissionDecision.deny(
|
|
140
|
+
reason="unsupported_operation",
|
|
141
|
+
message=f"Command working directory is not a directory: {args.cwd}",
|
|
142
|
+
metadata=metadata,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if risk_analysis.decision == "deny":
|
|
146
|
+
return request, PermissionDecision.deny(
|
|
147
|
+
reason="dangerous_command",
|
|
148
|
+
message=risk_analysis.reason,
|
|
149
|
+
metadata=metadata,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
decision = permission_checker.check(
|
|
153
|
+
request,
|
|
154
|
+
self.get_permission_profile(),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return request, with_permission_metadata(decision, metadata)
|
|
158
|
+
|
|
159
|
+
def run_authorized(
|
|
160
|
+
self,
|
|
161
|
+
args: RunCommandArgs,
|
|
162
|
+
decision: PermissionDecision,
|
|
163
|
+
) -> ToolResult:
|
|
164
|
+
try:
|
|
165
|
+
cwd = Path(str(decision.metadata["resolved_cwd"]))
|
|
166
|
+
return execute_command(
|
|
167
|
+
args=CommandExecutionArgs(
|
|
168
|
+
command=args.command,
|
|
169
|
+
timeout_seconds=args.timeout_seconds,
|
|
170
|
+
max_output_chars=args.max_output_chars,
|
|
171
|
+
),
|
|
172
|
+
cwd=cwd,
|
|
173
|
+
permission_metadata=decision.metadata,
|
|
174
|
+
permission_status=decision.status,
|
|
175
|
+
)
|
|
176
|
+
except Exception as error:
|
|
177
|
+
return ToolResult.failure(
|
|
178
|
+
error=f"Tool execution failed: {error}",
|
|
179
|
+
metadata={
|
|
180
|
+
**decision.metadata,
|
|
181
|
+
"permission_status": decision.status,
|
|
182
|
+
"exception_type": type(error).__name__,
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def _run(self, args: RunCommandArgs) -> ToolResult:
|
|
187
|
+
return ToolResult.failure(
|
|
188
|
+
error="run_command must be run through ToolRegistry.run_tool().",
|
|
189
|
+
metadata={
|
|
190
|
+
"command": args.command,
|
|
191
|
+
"cwd": args.cwd,
|
|
192
|
+
"reason": "permission_required",
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _resolve_candidate_path(workspace: Workspace, path: str) -> Path:
|
|
198
|
+
requested_path = Path(path)
|
|
199
|
+
if requested_path.is_absolute():
|
|
200
|
+
return requested_path.resolve(strict=False)
|
|
201
|
+
|
|
202
|
+
return (workspace.root / requested_path).resolve(strict=False)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _command_metadata(
|
|
206
|
+
*,
|
|
207
|
+
args: RunCommandArgs,
|
|
208
|
+
workspace_root: Path,
|
|
209
|
+
resolved_cwd: Path,
|
|
210
|
+
cwd_scope: str,
|
|
211
|
+
risk_analysis: CommandRiskAnalysis | None = None,
|
|
212
|
+
) -> dict[str, object]:
|
|
213
|
+
metadata: dict[str, object] = {
|
|
214
|
+
"command": list(args.command),
|
|
215
|
+
"command_display": _display_command(args.command),
|
|
216
|
+
"cwd": args.cwd,
|
|
217
|
+
"resolved_cwd": resolved_cwd.as_posix(),
|
|
218
|
+
"workspace_root": workspace_root.as_posix(),
|
|
219
|
+
"cwd_scope": cwd_scope,
|
|
220
|
+
"timeout_seconds": args.timeout_seconds,
|
|
221
|
+
"max_output_chars": args.max_output_chars,
|
|
222
|
+
}
|
|
223
|
+
if risk_analysis is not None:
|
|
224
|
+
metadata.update(
|
|
225
|
+
{
|
|
226
|
+
"command_risk_category": risk_analysis.category,
|
|
227
|
+
"command_risk": risk_analysis.risk,
|
|
228
|
+
"command_risk_decision": risk_analysis.decision,
|
|
229
|
+
"command_risk_reason": risk_analysis.reason,
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return metadata
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _display_command(command: list[str]) -> str:
|
|
237
|
+
return subprocess.list2cmdline(command)
|