coding-tools-mcp 0.1.3__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.
- coding_tools_mcp/__init__.py +3 -0
- coding_tools_mcp/__main__.py +7 -0
- coding_tools_mcp/landlock_exec.py +64 -0
- coding_tools_mcp/server.py +4262 -0
- coding_tools_mcp-0.1.3.dist-info/METADATA +235 -0
- coding_tools_mcp-0.1.3.dist-info/RECORD +10 -0
- coding_tools_mcp-0.1.3.dist-info/WHEEL +5 -0
- coding_tools_mcp-0.1.3.dist-info/entry_points.txt +2 -0
- coding_tools_mcp-0.1.3.dist-info/licenses/LICENSE +37 -0
- coding_tools_mcp-0.1.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,4262 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import ctypes
|
|
6
|
+
import difflib
|
|
7
|
+
import fnmatch
|
|
8
|
+
import http.server
|
|
9
|
+
import json
|
|
10
|
+
import mimetypes
|
|
11
|
+
import os
|
|
12
|
+
import posixpath
|
|
13
|
+
import re
|
|
14
|
+
import secrets
|
|
15
|
+
import shlex
|
|
16
|
+
import shutil
|
|
17
|
+
import signal
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
import urllib.parse
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path, PurePosixPath
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from . import __version__
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
32
|
+
SERVER_NAME = "coding-tools-mcp"
|
|
33
|
+
LOGGING_LEVELS = (
|
|
34
|
+
"debug",
|
|
35
|
+
"info",
|
|
36
|
+
"notice",
|
|
37
|
+
"warning",
|
|
38
|
+
"error",
|
|
39
|
+
"critical",
|
|
40
|
+
"alert",
|
|
41
|
+
"emergency",
|
|
42
|
+
)
|
|
43
|
+
DEFAULT_EXCLUDED_NAMES = {
|
|
44
|
+
".git",
|
|
45
|
+
".reference",
|
|
46
|
+
"node_modules",
|
|
47
|
+
"target",
|
|
48
|
+
"dist",
|
|
49
|
+
"build",
|
|
50
|
+
".venv",
|
|
51
|
+
"venv",
|
|
52
|
+
".tox",
|
|
53
|
+
".mypy_cache",
|
|
54
|
+
".pytest_cache",
|
|
55
|
+
".ruff_cache",
|
|
56
|
+
"__pycache__",
|
|
57
|
+
}
|
|
58
|
+
DEFAULT_MAX_LINES = 2000
|
|
59
|
+
GREP_MAX_LINE_CHARS = 500
|
|
60
|
+
IMAGE_RESIZE_MAX_DIMENSION = 2000
|
|
61
|
+
SENSITIVE_ENV_RE = re.compile(r"(token|secret|credential|api[_-]?key|password|passwd|private)", re.I)
|
|
62
|
+
SENSITIVE_VALUE_RE = re.compile(
|
|
63
|
+
r"(COMPLIANCE_SHOULD_NOT_LEAK|-----BEGIN [A-Z ]*PRIVATE KEY-----|gh[pousr]_[A-Za-z0-9_]+|sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16})"
|
|
64
|
+
)
|
|
65
|
+
RISKY_ENV_NAMES = {
|
|
66
|
+
"BASH_ENV",
|
|
67
|
+
"ENV",
|
|
68
|
+
"LD_PRELOAD",
|
|
69
|
+
"LD_LIBRARY_PATH",
|
|
70
|
+
"DYLD_INSERT_LIBRARIES",
|
|
71
|
+
"PYTHONPATH",
|
|
72
|
+
"PYTHONSTARTUP",
|
|
73
|
+
"NODE_OPTIONS",
|
|
74
|
+
"RUBYOPT",
|
|
75
|
+
"PERL5OPT",
|
|
76
|
+
}
|
|
77
|
+
NETWORK_RE = re.compile(
|
|
78
|
+
r"(https?://|urllib\.request|urllib3|requests\.|http\.client|\bHTTPConnection\b|\bHTTPSConnection\b|socket\.|aiohttp|httpx|\bcurl\b|\bwget\b|\bnc\b|\bnetcat\b|\bssh\b|\bscp\b|\bftp\b)",
|
|
79
|
+
re.I,
|
|
80
|
+
)
|
|
81
|
+
SHELL_EXPANSION_RE = re.compile(r"(`|\$\(|\$\{)")
|
|
82
|
+
DESTRUCTIVE_RE = re.compile(
|
|
83
|
+
r"(^|\s)(sudo|su|chmod\s+-R|chown\s+-R|mkfs|mount|umount|find\b[^;&|]*\s-delete\b|git\b[^;&|]*\breset\s+--hard\b|git\b[^;&|]*\bclean\s+-[^\s]*[fx][^\s]*|rm\s+-[^\s]*r[^\s]*f|rm\s+-[^\s]*f[^\s]*r)\b",
|
|
84
|
+
re.I,
|
|
85
|
+
)
|
|
86
|
+
MAX_HTTP_REQUEST_BYTES = 1_048_576
|
|
87
|
+
MAX_JSON_RPC_BATCH_ITEMS = 50
|
|
88
|
+
SESSION_BUFFER_BYTES = 1_048_576
|
|
89
|
+
SHELL_CONTROL_TOKENS = {"|", "||", "&", "&&", ";", "(", ")"}
|
|
90
|
+
REDIRECTION_TOKENS = {">", ">>", "<", "<>", ">&", "<&", "&>", "&>>"}
|
|
91
|
+
HEREDOC_TOKENS = {"<<", "<<<"}
|
|
92
|
+
PATH_ARGUMENT_COMMANDS = {
|
|
93
|
+
"cat",
|
|
94
|
+
"cd",
|
|
95
|
+
"chdir",
|
|
96
|
+
"chmod",
|
|
97
|
+
"chown",
|
|
98
|
+
"cp",
|
|
99
|
+
"head",
|
|
100
|
+
"less",
|
|
101
|
+
"ln",
|
|
102
|
+
"ls",
|
|
103
|
+
"mkdir",
|
|
104
|
+
"more",
|
|
105
|
+
"mv",
|
|
106
|
+
"rm",
|
|
107
|
+
"rmdir",
|
|
108
|
+
"stat",
|
|
109
|
+
"tail",
|
|
110
|
+
"touch",
|
|
111
|
+
"wc",
|
|
112
|
+
}
|
|
113
|
+
PATTERN_THEN_PATH_COMMANDS = {"grep", "egrep", "fgrep", "rg", "sed", "awk"}
|
|
114
|
+
SCRIPT_COMMANDS = {"bash", "sh", "zsh", "python", "python3", "node", "ruby", "perl"}
|
|
115
|
+
ENV_OPTIONS_WITH_ARGUMENT = {
|
|
116
|
+
"-u",
|
|
117
|
+
"--unset",
|
|
118
|
+
"-C",
|
|
119
|
+
"--chdir",
|
|
120
|
+
"-S",
|
|
121
|
+
"--split-string",
|
|
122
|
+
"-a",
|
|
123
|
+
"--argv0",
|
|
124
|
+
}
|
|
125
|
+
ENV_LONG_OPTIONS_WITH_ARGUMENT = {
|
|
126
|
+
"--unset",
|
|
127
|
+
"--chdir",
|
|
128
|
+
"--split-string",
|
|
129
|
+
"--argv0",
|
|
130
|
+
}
|
|
131
|
+
ENV_LONG_OPTIONS_WITH_OPTIONAL_ARGUMENT = {
|
|
132
|
+
"--ignore-signal",
|
|
133
|
+
"--default-signal",
|
|
134
|
+
"--block-signal",
|
|
135
|
+
}
|
|
136
|
+
ENV_SHORT_OPTIONS_WITH_ATTACHED_ARGUMENT = ("-u", "-C", "-S", "-a")
|
|
137
|
+
ENV_FLAG_OPTIONS = {
|
|
138
|
+
"-i",
|
|
139
|
+
"--ignore-environment",
|
|
140
|
+
"-0",
|
|
141
|
+
"--null",
|
|
142
|
+
"-v",
|
|
143
|
+
"--debug",
|
|
144
|
+
"--ignore-signal",
|
|
145
|
+
"--default-signal",
|
|
146
|
+
"--block-signal",
|
|
147
|
+
"--list-signal-handling",
|
|
148
|
+
}
|
|
149
|
+
NETWORK_LITERAL_COMMANDS = {"echo", "printf", "grep", "egrep", "fgrep", "rg", "cat", "head", "tail", "wc"}
|
|
150
|
+
INLINE_SCRIPT_PERMISSION = "inline_script"
|
|
151
|
+
ENV_PREFIX = "CODING_TOOLS_MCP"
|
|
152
|
+
TOOL_PROFILE_CHOICES = ("full", "read-only", "compat-readonly-all")
|
|
153
|
+
FULL_TOOL_NAMES = (
|
|
154
|
+
"server_info",
|
|
155
|
+
"get_default_cwd",
|
|
156
|
+
"set_default_cwd",
|
|
157
|
+
"read_file",
|
|
158
|
+
"list_dir",
|
|
159
|
+
"list_files",
|
|
160
|
+
"search_text",
|
|
161
|
+
"apply_patch",
|
|
162
|
+
"exec_command",
|
|
163
|
+
"write_stdin",
|
|
164
|
+
"kill_session",
|
|
165
|
+
"git_status",
|
|
166
|
+
"git_diff",
|
|
167
|
+
"git_log",
|
|
168
|
+
"git_show",
|
|
169
|
+
"git_blame",
|
|
170
|
+
"request_permissions",
|
|
171
|
+
"view_image",
|
|
172
|
+
)
|
|
173
|
+
READ_ONLY_TOOL_NAMES = (
|
|
174
|
+
"server_info",
|
|
175
|
+
"get_default_cwd",
|
|
176
|
+
"set_default_cwd",
|
|
177
|
+
"read_file",
|
|
178
|
+
"list_dir",
|
|
179
|
+
"list_files",
|
|
180
|
+
"search_text",
|
|
181
|
+
"git_status",
|
|
182
|
+
"git_diff",
|
|
183
|
+
"git_log",
|
|
184
|
+
"git_show",
|
|
185
|
+
"git_blame",
|
|
186
|
+
"view_image",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
LANDLOCK_CREATE_RULESET_VERSION = 1
|
|
190
|
+
LANDLOCK_RULE_PATH_BENEATH = 1
|
|
191
|
+
PR_SET_NO_NEW_PRIVS = 38
|
|
192
|
+
SYS_LANDLOCK_CREATE_RULESET = 444
|
|
193
|
+
SYS_LANDLOCK_ADD_RULE = 445
|
|
194
|
+
SYS_LANDLOCK_RESTRICT_SELF = 446
|
|
195
|
+
LANDLOCK_ACCESS_FS_EXECUTE = 1 << 0
|
|
196
|
+
LANDLOCK_ACCESS_FS_WRITE_FILE = 1 << 1
|
|
197
|
+
LANDLOCK_ACCESS_FS_READ_FILE = 1 << 2
|
|
198
|
+
LANDLOCK_ACCESS_FS_READ_DIR = 1 << 3
|
|
199
|
+
LANDLOCK_ACCESS_FS_REMOVE_DIR = 1 << 4
|
|
200
|
+
LANDLOCK_ACCESS_FS_REMOVE_FILE = 1 << 5
|
|
201
|
+
LANDLOCK_ACCESS_FS_MAKE_CHAR = 1 << 6
|
|
202
|
+
LANDLOCK_ACCESS_FS_MAKE_DIR = 1 << 7
|
|
203
|
+
LANDLOCK_ACCESS_FS_MAKE_REG = 1 << 8
|
|
204
|
+
LANDLOCK_ACCESS_FS_MAKE_SOCK = 1 << 9
|
|
205
|
+
LANDLOCK_ACCESS_FS_MAKE_FIFO = 1 << 10
|
|
206
|
+
LANDLOCK_ACCESS_FS_MAKE_BLOCK = 1 << 11
|
|
207
|
+
LANDLOCK_ACCESS_FS_MAKE_SYM = 1 << 12
|
|
208
|
+
LANDLOCK_ACCESS_FS_REFER = 1 << 13
|
|
209
|
+
LANDLOCK_ACCESS_FS_TRUNCATE = 1 << 14
|
|
210
|
+
LANDLOCK_ACCESS_FS_IOCTL_DEV = 1 << 15
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class ToolFailure(Exception):
|
|
214
|
+
def __init__(
|
|
215
|
+
self,
|
|
216
|
+
code: str,
|
|
217
|
+
message: str,
|
|
218
|
+
*,
|
|
219
|
+
category: str = "runtime",
|
|
220
|
+
retryable: bool = False,
|
|
221
|
+
details: dict[str, Any] | None = None,
|
|
222
|
+
) -> None:
|
|
223
|
+
super().__init__(message)
|
|
224
|
+
self.code = code
|
|
225
|
+
self.message = message
|
|
226
|
+
self.category = category
|
|
227
|
+
self.retryable = retryable
|
|
228
|
+
self.details = details or {}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def utc_now() -> str:
|
|
232
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def json_response_payload(payload: Any) -> bytes:
|
|
236
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def is_allowed_origin(origin: str, *, auth_enabled: bool = False) -> bool:
|
|
240
|
+
try:
|
|
241
|
+
parsed = urllib.parse.urlparse(origin)
|
|
242
|
+
except ValueError:
|
|
243
|
+
return False
|
|
244
|
+
if parsed.scheme not in {"http", "https"}:
|
|
245
|
+
return False
|
|
246
|
+
if auth_enabled:
|
|
247
|
+
return parsed.hostname is not None
|
|
248
|
+
return parsed.hostname in {"localhost", "127.0.0.1", "::1"}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def is_loopback_bind_host(host: str) -> bool:
|
|
252
|
+
return host in {"localhost", "127.0.0.1", "::1", ""}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True)
|
|
256
|
+
class TextTruncation:
|
|
257
|
+
content: str
|
|
258
|
+
truncated: bool
|
|
259
|
+
truncated_by: str | None
|
|
260
|
+
total_lines: int
|
|
261
|
+
total_bytes: int
|
|
262
|
+
output_lines: int
|
|
263
|
+
output_bytes: int
|
|
264
|
+
last_line_partial: bool
|
|
265
|
+
first_line_exceeds_limit: bool
|
|
266
|
+
max_lines: int
|
|
267
|
+
max_bytes: int
|
|
268
|
+
|
|
269
|
+
def metadata(self, *, prefix: str = "") -> dict[str, Any]:
|
|
270
|
+
key = f"{prefix}_" if prefix else ""
|
|
271
|
+
return {
|
|
272
|
+
f"{key}truncated_by": self.truncated_by,
|
|
273
|
+
f"{key}total_lines": self.total_lines,
|
|
274
|
+
f"{key}total_bytes": self.total_bytes,
|
|
275
|
+
f"{key}output_lines": self.output_lines,
|
|
276
|
+
f"{key}output_bytes": self.output_bytes,
|
|
277
|
+
f"{key}last_line_partial": self.last_line_partial,
|
|
278
|
+
f"{key}first_line_exceeds_limit": self.first_line_exceeds_limit,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def truncate_bytes(data: bytes, limit: int) -> tuple[str, bool]:
|
|
283
|
+
if limit <= 0:
|
|
284
|
+
limit = 1
|
|
285
|
+
truncated = len(data) > limit
|
|
286
|
+
if truncated:
|
|
287
|
+
marker = b"\n... output truncated ...\n"
|
|
288
|
+
if limit > len(marker) + 2:
|
|
289
|
+
remaining = limit - len(marker)
|
|
290
|
+
head = max(1, remaining // 2)
|
|
291
|
+
tail = max(1, remaining - head)
|
|
292
|
+
data = data[:head] + marker + data[-tail:]
|
|
293
|
+
else:
|
|
294
|
+
data = data[:limit]
|
|
295
|
+
return data.decode("utf-8", errors="replace"), truncated
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def truncate_text_head(text: str, *, max_lines: int = DEFAULT_MAX_LINES, max_bytes: int = 50 * 1024) -> TextTruncation:
|
|
299
|
+
if max_lines <= 0:
|
|
300
|
+
max_lines = 1
|
|
301
|
+
if max_bytes <= 0:
|
|
302
|
+
max_bytes = 1
|
|
303
|
+
total_bytes = len(text.encode("utf-8"))
|
|
304
|
+
lines = text.split("\n")
|
|
305
|
+
total_lines = len(lines)
|
|
306
|
+
if total_lines <= max_lines and total_bytes <= max_bytes:
|
|
307
|
+
return TextTruncation(text, False, None, total_lines, total_bytes, total_lines, total_bytes, False, False, max_lines, max_bytes)
|
|
308
|
+
|
|
309
|
+
first_line_bytes = len(lines[0].encode("utf-8")) if lines else 0
|
|
310
|
+
if first_line_bytes > max_bytes:
|
|
311
|
+
prefix = truncate_string_to_bytes_from_start(lines[0], max_bytes)
|
|
312
|
+
return TextTruncation(
|
|
313
|
+
prefix,
|
|
314
|
+
True,
|
|
315
|
+
"bytes",
|
|
316
|
+
total_lines,
|
|
317
|
+
total_bytes,
|
|
318
|
+
1 if prefix else 0,
|
|
319
|
+
len(prefix.encode("utf-8")),
|
|
320
|
+
False,
|
|
321
|
+
True,
|
|
322
|
+
max_lines,
|
|
323
|
+
max_bytes,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
output: list[str] = []
|
|
327
|
+
output_bytes = 0
|
|
328
|
+
truncated_by = "lines"
|
|
329
|
+
for index, line in enumerate(lines):
|
|
330
|
+
if len(output) >= max_lines:
|
|
331
|
+
truncated_by = "lines"
|
|
332
|
+
break
|
|
333
|
+
line_bytes = len(line.encode("utf-8")) + (1 if index > 0 else 0)
|
|
334
|
+
if output_bytes + line_bytes > max_bytes:
|
|
335
|
+
truncated_by = "bytes"
|
|
336
|
+
break
|
|
337
|
+
output.append(line)
|
|
338
|
+
output_bytes += line_bytes
|
|
339
|
+
content = "\n".join(output)
|
|
340
|
+
return TextTruncation(
|
|
341
|
+
content,
|
|
342
|
+
True,
|
|
343
|
+
truncated_by,
|
|
344
|
+
total_lines,
|
|
345
|
+
total_bytes,
|
|
346
|
+
len(output),
|
|
347
|
+
len(content.encode("utf-8")),
|
|
348
|
+
False,
|
|
349
|
+
False,
|
|
350
|
+
max_lines,
|
|
351
|
+
max_bytes,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def truncate_text_tail(text: str, *, max_lines: int = DEFAULT_MAX_LINES, max_bytes: int = 50 * 1024) -> TextTruncation:
|
|
356
|
+
if max_lines <= 0:
|
|
357
|
+
max_lines = 1
|
|
358
|
+
if max_bytes <= 0:
|
|
359
|
+
max_bytes = 1
|
|
360
|
+
total_bytes = len(text.encode("utf-8"))
|
|
361
|
+
lines = text.split("\n")
|
|
362
|
+
total_lines = len(lines)
|
|
363
|
+
if total_lines <= max_lines and total_bytes <= max_bytes:
|
|
364
|
+
return TextTruncation(text, False, None, total_lines, total_bytes, total_lines, total_bytes, False, False, max_lines, max_bytes)
|
|
365
|
+
|
|
366
|
+
candidate_lines = lines[:-1] if lines and lines[-1] == "" else lines
|
|
367
|
+
output: list[str] = []
|
|
368
|
+
output_bytes = 0
|
|
369
|
+
truncated_by = "lines"
|
|
370
|
+
last_line_partial = False
|
|
371
|
+
for reverse_index, line in enumerate(reversed(candidate_lines)):
|
|
372
|
+
if len(output) >= max_lines:
|
|
373
|
+
truncated_by = "lines"
|
|
374
|
+
break
|
|
375
|
+
line_bytes = len(line.encode("utf-8")) + (1 if reverse_index > 0 else 0)
|
|
376
|
+
if output_bytes + line_bytes > max_bytes:
|
|
377
|
+
truncated_by = "bytes"
|
|
378
|
+
if not output:
|
|
379
|
+
partial = truncate_string_to_bytes_from_end(line, max_bytes)
|
|
380
|
+
output.insert(0, partial)
|
|
381
|
+
last_line_partial = True
|
|
382
|
+
break
|
|
383
|
+
output.insert(0, line)
|
|
384
|
+
output_bytes += line_bytes
|
|
385
|
+
content = "\n".join(output)
|
|
386
|
+
return TextTruncation(
|
|
387
|
+
content,
|
|
388
|
+
True,
|
|
389
|
+
truncated_by,
|
|
390
|
+
total_lines,
|
|
391
|
+
total_bytes,
|
|
392
|
+
len(output),
|
|
393
|
+
len(content.encode("utf-8")),
|
|
394
|
+
last_line_partial,
|
|
395
|
+
False,
|
|
396
|
+
max_lines,
|
|
397
|
+
max_bytes,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def truncate_string_to_bytes_from_start(text: str, max_bytes: int) -> str:
|
|
402
|
+
data = text.encode("utf-8")
|
|
403
|
+
if len(data) <= max_bytes:
|
|
404
|
+
return text
|
|
405
|
+
end = max(0, min(max_bytes, len(data)))
|
|
406
|
+
while end > 0 and end < len(data) and (data[end] & 0xC0) == 0x80:
|
|
407
|
+
end -= 1
|
|
408
|
+
return data[:end].decode("utf-8", errors="replace")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def truncate_string_to_bytes_from_end(text: str, max_bytes: int) -> str:
|
|
412
|
+
data = text.encode("utf-8")
|
|
413
|
+
if len(data) <= max_bytes:
|
|
414
|
+
return text
|
|
415
|
+
start = len(data) - max_bytes
|
|
416
|
+
while start < len(data) and (data[start] & 0xC0) == 0x80:
|
|
417
|
+
start += 1
|
|
418
|
+
return data[start:].decode("utf-8", errors="replace")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def truncate_line_chars(line: str, max_chars: int = GREP_MAX_LINE_CHARS) -> tuple[str, bool]:
|
|
422
|
+
if len(line) <= max_chars:
|
|
423
|
+
return line, False
|
|
424
|
+
suffix = " ... [truncated]"
|
|
425
|
+
keep = max(0, max_chars - len(suffix))
|
|
426
|
+
return line[:keep] + suffix, True
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def truncate_output_bytes_tail(data: bytes, limit: int) -> TextTruncation:
|
|
430
|
+
text = data.decode("utf-8", errors="replace")
|
|
431
|
+
return truncate_text_tail(text, max_lines=DEFAULT_MAX_LINES, max_bytes=limit)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def strip_bom(text: str) -> tuple[str, str]:
|
|
435
|
+
return ("\ufeff", text[1:]) if text.startswith("\ufeff") else ("", text)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def detect_line_ending(text: str) -> str:
|
|
439
|
+
crlf = text.find("\r\n")
|
|
440
|
+
lf = text.find("\n")
|
|
441
|
+
if lf < 0:
|
|
442
|
+
return "\n"
|
|
443
|
+
if crlf < 0:
|
|
444
|
+
return "\n"
|
|
445
|
+
return "\r\n" if crlf <= lf else "\n"
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def normalize_to_lf(text: str) -> str:
|
|
449
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def restore_line_endings(text: str, ending: str) -> str:
|
|
453
|
+
return text.replace("\n", "\r\n") if ending == "\r\n" else text
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def read_text_preserve_newlines(path: Path) -> str:
|
|
457
|
+
with path.open("r", encoding="utf-8", newline="") as handle:
|
|
458
|
+
return handle.read()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def normalize_rel_display(path: Path, root: Path) -> str:
|
|
462
|
+
try:
|
|
463
|
+
rel = path.relative_to(root)
|
|
464
|
+
except ValueError:
|
|
465
|
+
return path.as_posix()
|
|
466
|
+
text = rel.as_posix()
|
|
467
|
+
return "." if text == "" else text
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def is_relative_to(path: Path, parent: Path) -> bool:
|
|
471
|
+
try:
|
|
472
|
+
path.relative_to(parent)
|
|
473
|
+
return True
|
|
474
|
+
except ValueError:
|
|
475
|
+
return False
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def terminate_process_group(process: subprocess.Popen[bytes], signum: signal.Signals) -> None:
|
|
479
|
+
if not hasattr(os, "killpg"):
|
|
480
|
+
if os.name == "nt" and signum != signal.SIGKILL:
|
|
481
|
+
event = getattr(signal, "CTRL_BREAK_EVENT", None)
|
|
482
|
+
if event is not None:
|
|
483
|
+
try:
|
|
484
|
+
process.send_signal(event)
|
|
485
|
+
process.wait(timeout=1)
|
|
486
|
+
return
|
|
487
|
+
except Exception:
|
|
488
|
+
pass
|
|
489
|
+
try:
|
|
490
|
+
if signum == signal.SIGKILL:
|
|
491
|
+
process.kill()
|
|
492
|
+
else:
|
|
493
|
+
process.terminate()
|
|
494
|
+
process.wait(timeout=1)
|
|
495
|
+
except Exception:
|
|
496
|
+
process.kill()
|
|
497
|
+
return
|
|
498
|
+
try:
|
|
499
|
+
os.killpg(process.pid, signum)
|
|
500
|
+
except ProcessLookupError:
|
|
501
|
+
return
|
|
502
|
+
except Exception:
|
|
503
|
+
process.terminate()
|
|
504
|
+
try:
|
|
505
|
+
process.wait(timeout=1)
|
|
506
|
+
except subprocess.TimeoutExpired:
|
|
507
|
+
try:
|
|
508
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
509
|
+
except Exception:
|
|
510
|
+
process.kill()
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def landlock_unavailable_warning(exc: ToolFailure) -> str:
|
|
514
|
+
reason = ""
|
|
515
|
+
details = getattr(exc, "details", None)
|
|
516
|
+
if isinstance(details, dict) and details.get("reason"):
|
|
517
|
+
reason = f" ({details['reason']})"
|
|
518
|
+
return (
|
|
519
|
+
"Linux Landlock filesystem confinement is unavailable on this host"
|
|
520
|
+
f"{reason}; exec_command ran with policy checks only. "
|
|
521
|
+
"Use an external sandbox before running untrusted commands."
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def process_group_popen_kwargs() -> dict[str, Any]:
|
|
526
|
+
if hasattr(os, "setsid"):
|
|
527
|
+
return {"start_new_session": True}
|
|
528
|
+
if os.name == "nt":
|
|
529
|
+
creation_flag = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
530
|
+
if creation_flag:
|
|
531
|
+
return {"creationflags": creation_flag}
|
|
532
|
+
return {}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@dataclass
|
|
536
|
+
class ResolvedPath:
|
|
537
|
+
display: str
|
|
538
|
+
path: Path
|
|
539
|
+
existed: bool
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class Workspace:
|
|
543
|
+
def __init__(self, root: Path) -> None:
|
|
544
|
+
self.root = root.expanduser().resolve(strict=True)
|
|
545
|
+
if not self.root.is_dir():
|
|
546
|
+
raise ToolFailure("INVALID_ARGUMENT", "Workspace root must be a directory.", category="validation")
|
|
547
|
+
if str(self.root) in {"/", str(Path.home().resolve())}:
|
|
548
|
+
raise ToolFailure("INVALID_ARGUMENT", "Unsafe workspace root rejected.", category="security")
|
|
549
|
+
|
|
550
|
+
def _reject_unsafe_text(self, raw_path: str) -> PurePosixPath:
|
|
551
|
+
if not isinstance(raw_path, str) or not raw_path:
|
|
552
|
+
raise ToolFailure("INVALID_ARGUMENT", "Path must be a non-empty string.", category="validation")
|
|
553
|
+
if "\x00" in raw_path:
|
|
554
|
+
raise ToolFailure("INVALID_ARGUMENT", "Path contains a NUL byte.", category="validation")
|
|
555
|
+
if raw_path.startswith("/") or re.match(r"^[A-Za-z]:[\\/]", raw_path):
|
|
556
|
+
raise ToolFailure("ABSOLUTE_PATH_DENIED", "Absolute paths are denied.", category="security")
|
|
557
|
+
pure = PurePosixPath(raw_path)
|
|
558
|
+
if any(part == ".." for part in pure.parts):
|
|
559
|
+
raise ToolFailure("PATH_OUTSIDE_WORKSPACE", "Path escapes the configured workspace.", category="security")
|
|
560
|
+
return pure
|
|
561
|
+
|
|
562
|
+
def resolve_existing(self, raw_path: str = ".") -> ResolvedPath:
|
|
563
|
+
return self.resolve_existing_at(self.root, raw_path)
|
|
564
|
+
|
|
565
|
+
def resolve_existing_at(self, base: Path, raw_path: str = ".") -> ResolvedPath:
|
|
566
|
+
pure = self._reject_unsafe_text(raw_path or ".")
|
|
567
|
+
base = self._validate_base(base)
|
|
568
|
+
candidate = base.joinpath(*pure.parts)
|
|
569
|
+
try:
|
|
570
|
+
resolved = candidate.resolve(strict=True)
|
|
571
|
+
except FileNotFoundError as exc:
|
|
572
|
+
raise ToolFailure("NOT_FOUND", f"Path not found: {raw_path}", category="not_found") from exc
|
|
573
|
+
if not is_relative_to(resolved, self.root):
|
|
574
|
+
code = "SYMLINK_ESCAPE" if candidate.is_symlink() else "PATH_OUTSIDE_WORKSPACE"
|
|
575
|
+
raise ToolFailure(code, "Path escapes the configured workspace.", category="security")
|
|
576
|
+
return ResolvedPath(normalize_rel_display(resolved, self.root), resolved, True)
|
|
577
|
+
|
|
578
|
+
def resolve_for_write(self, raw_path: str) -> ResolvedPath:
|
|
579
|
+
return self.resolve_for_write_at(self.root, raw_path)
|
|
580
|
+
|
|
581
|
+
def resolve_for_write_at(self, base: Path, raw_path: str) -> ResolvedPath:
|
|
582
|
+
pure = self._reject_unsafe_text(raw_path)
|
|
583
|
+
if pure.name in {"", ".", ".."}:
|
|
584
|
+
raise ToolFailure("INVALID_ARGUMENT", "Invalid write target.", category="validation")
|
|
585
|
+
base = self._validate_base(base)
|
|
586
|
+
candidate = base.joinpath(*pure.parts)
|
|
587
|
+
if candidate.exists() or candidate.is_symlink():
|
|
588
|
+
resolved = candidate.resolve(strict=True)
|
|
589
|
+
if not is_relative_to(resolved, self.root):
|
|
590
|
+
raise ToolFailure("SYMLINK_ESCAPE", "Path escapes the configured workspace.", category="security")
|
|
591
|
+
return ResolvedPath(normalize_rel_display(resolved, self.root), resolved, True)
|
|
592
|
+
|
|
593
|
+
parent = candidate.parent
|
|
594
|
+
missing: list[Path] = []
|
|
595
|
+
while not parent.exists():
|
|
596
|
+
missing.append(parent)
|
|
597
|
+
if parent == self.root or parent.parent == parent:
|
|
598
|
+
break
|
|
599
|
+
parent = parent.parent
|
|
600
|
+
try:
|
|
601
|
+
resolved_parent = parent.resolve(strict=True)
|
|
602
|
+
except FileNotFoundError as exc:
|
|
603
|
+
raise ToolFailure("NOT_FOUND", f"Parent directory not found: {raw_path}", category="not_found") from exc
|
|
604
|
+
if not is_relative_to(resolved_parent, self.root):
|
|
605
|
+
raise ToolFailure("PATH_OUTSIDE_WORKSPACE", "Path escapes the configured workspace.", category="security")
|
|
606
|
+
target = resolved_parent.joinpath(*reversed([p.name for p in missing]), candidate.name)
|
|
607
|
+
return ResolvedPath(normalize_rel_display(target, self.root), target, False)
|
|
608
|
+
|
|
609
|
+
def _validate_base(self, base: Path) -> Path:
|
|
610
|
+
try:
|
|
611
|
+
resolved = base.resolve(strict=True)
|
|
612
|
+
except FileNotFoundError as exc:
|
|
613
|
+
raise ToolFailure("NOT_FOUND", "Default cwd path no longer exists.", category="not_found") from exc
|
|
614
|
+
if not resolved.is_dir():
|
|
615
|
+
raise ToolFailure("NOT_A_DIRECTORY", "Default cwd is not a directory.", category="validation")
|
|
616
|
+
if not is_relative_to(resolved, self.root):
|
|
617
|
+
raise ToolFailure("PATH_OUTSIDE_WORKSPACE", "Default cwd escapes the configured workspace.", category="security")
|
|
618
|
+
return resolved
|
|
619
|
+
|
|
620
|
+
def reject_write_symlink(self, raw_path: str) -> None:
|
|
621
|
+
pure = self._reject_unsafe_text(raw_path)
|
|
622
|
+
candidate = self.root.joinpath(*pure.parts)
|
|
623
|
+
if candidate.is_symlink():
|
|
624
|
+
raise ToolFailure("SYMLINK_ESCAPE", "Writing through symlinks is denied.", category="security")
|
|
625
|
+
|
|
626
|
+
def is_ignored_path(self, path: Path, *, include_hidden: bool = False, include_ignored: bool = False) -> bool:
|
|
627
|
+
try:
|
|
628
|
+
rel = path.relative_to(self.root)
|
|
629
|
+
except ValueError:
|
|
630
|
+
return True
|
|
631
|
+
parts = rel.parts
|
|
632
|
+
if not include_hidden and any(part.startswith(".") for part in parts if part not in {".", ""}):
|
|
633
|
+
return True
|
|
634
|
+
if not include_ignored and any(part in DEFAULT_EXCLUDED_NAMES for part in parts):
|
|
635
|
+
return True
|
|
636
|
+
if include_ignored:
|
|
637
|
+
return False
|
|
638
|
+
if self._git_ignored(rel.as_posix()):
|
|
639
|
+
return True
|
|
640
|
+
return False
|
|
641
|
+
|
|
642
|
+
def is_safe_existing_path(self, path: Path) -> bool:
|
|
643
|
+
try:
|
|
644
|
+
resolved = path.resolve(strict=True)
|
|
645
|
+
except FileNotFoundError:
|
|
646
|
+
return False
|
|
647
|
+
return is_relative_to(resolved, self.root)
|
|
648
|
+
|
|
649
|
+
def _git_ignored(self, rel_path: str) -> bool:
|
|
650
|
+
git = shutil.which("git")
|
|
651
|
+
if not git:
|
|
652
|
+
return False
|
|
653
|
+
try:
|
|
654
|
+
completed = subprocess.run(
|
|
655
|
+
[git, "-C", str(self.root), "check-ignore", "-q", "--", rel_path],
|
|
656
|
+
stdout=subprocess.DEVNULL,
|
|
657
|
+
stderr=subprocess.DEVNULL,
|
|
658
|
+
timeout=2,
|
|
659
|
+
)
|
|
660
|
+
except Exception:
|
|
661
|
+
return False
|
|
662
|
+
return completed.returncode == 0
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def trim_buffer(
|
|
666
|
+
buffer: bytearray,
|
|
667
|
+
*,
|
|
668
|
+
total_bytes: int,
|
|
669
|
+
start_offset_attr: str,
|
|
670
|
+
cursor_attr: str,
|
|
671
|
+
session: Any,
|
|
672
|
+
) -> int:
|
|
673
|
+
overflow = len(buffer) - session.buffer_limit
|
|
674
|
+
if overflow <= 0:
|
|
675
|
+
return 0
|
|
676
|
+
del buffer[:overflow]
|
|
677
|
+
setattr(session, start_offset_attr, total_bytes - len(buffer))
|
|
678
|
+
cursor = getattr(session, cursor_attr)
|
|
679
|
+
if cursor < getattr(session, start_offset_attr):
|
|
680
|
+
setattr(session, cursor_attr, getattr(session, start_offset_attr))
|
|
681
|
+
return overflow
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@dataclass
|
|
685
|
+
class ExecSession:
|
|
686
|
+
session_id: str
|
|
687
|
+
process: subprocess.Popen[bytes]
|
|
688
|
+
timeout_at: float | None = None
|
|
689
|
+
warnings: list[str] = field(default_factory=list)
|
|
690
|
+
stdout: bytearray = field(default_factory=bytearray)
|
|
691
|
+
stderr: bytearray = field(default_factory=bytearray)
|
|
692
|
+
stdout_start_offset: int = 0
|
|
693
|
+
stderr_start_offset: int = 0
|
|
694
|
+
stdout_cursor: int = 0
|
|
695
|
+
stderr_cursor: int = 0
|
|
696
|
+
stdout_total_bytes: int = 0
|
|
697
|
+
stderr_total_bytes: int = 0
|
|
698
|
+
stdout_dropped_bytes: int = 0
|
|
699
|
+
stderr_dropped_bytes: int = 0
|
|
700
|
+
buffer_limit: int = SESSION_BUFFER_BYTES
|
|
701
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
702
|
+
reader_threads: list[threading.Thread] = field(default_factory=list)
|
|
703
|
+
started_at: float = field(default_factory=time.time)
|
|
704
|
+
closed: bool = False
|
|
705
|
+
exit_code: int | None = None
|
|
706
|
+
signal_name: str | None = None
|
|
707
|
+
timed_out: bool = False
|
|
708
|
+
|
|
709
|
+
def append_stdout(self, chunk: bytes) -> None:
|
|
710
|
+
with self.lock:
|
|
711
|
+
self.stdout.extend(chunk)
|
|
712
|
+
self.stdout_total_bytes += len(chunk)
|
|
713
|
+
self.stdout_dropped_bytes += trim_buffer(
|
|
714
|
+
self.stdout,
|
|
715
|
+
total_bytes=self.stdout_total_bytes,
|
|
716
|
+
start_offset_attr="stdout_start_offset",
|
|
717
|
+
cursor_attr="stdout_cursor",
|
|
718
|
+
session=self,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
def append_stderr(self, chunk: bytes) -> None:
|
|
722
|
+
with self.lock:
|
|
723
|
+
self.stderr.extend(chunk)
|
|
724
|
+
self.stderr_total_bytes += len(chunk)
|
|
725
|
+
self.stderr_dropped_bytes += trim_buffer(
|
|
726
|
+
self.stderr,
|
|
727
|
+
total_bytes=self.stderr_total_bytes,
|
|
728
|
+
start_offset_attr="stderr_start_offset",
|
|
729
|
+
cursor_attr="stderr_cursor",
|
|
730
|
+
session=self,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
def snapshot_since_cursor(self, max_output_bytes: int) -> dict[str, Any]:
|
|
734
|
+
self.refresh_status()
|
|
735
|
+
with self.lock:
|
|
736
|
+
stdout_omitted = max(0, self.stdout_start_offset - self.stdout_cursor)
|
|
737
|
+
stderr_omitted = max(0, self.stderr_start_offset - self.stderr_cursor)
|
|
738
|
+
stdout_start = max(0, self.stdout_cursor - self.stdout_start_offset)
|
|
739
|
+
stderr_start = max(0, self.stderr_cursor - self.stderr_start_offset)
|
|
740
|
+
stdout_bytes = bytes(self.stdout[stdout_start:])
|
|
741
|
+
stderr_bytes = bytes(self.stderr[stderr_start:])
|
|
742
|
+
self.stdout_cursor = self.stdout_total_bytes
|
|
743
|
+
self.stderr_cursor = self.stderr_total_bytes
|
|
744
|
+
stdout_truncation = truncate_output_bytes_tail(stdout_bytes, max_output_bytes)
|
|
745
|
+
stderr_truncation = truncate_output_bytes_tail(stderr_bytes, max_output_bytes)
|
|
746
|
+
stdout = stdout_truncation.content
|
|
747
|
+
stderr = stderr_truncation.content
|
|
748
|
+
stdout_truncated = stdout_truncation.truncated
|
|
749
|
+
stderr_truncated = stderr_truncation.truncated
|
|
750
|
+
if self.timed_out:
|
|
751
|
+
status = "timeout"
|
|
752
|
+
else:
|
|
753
|
+
status = "running" if self.process.poll() is None else "exited"
|
|
754
|
+
payload: dict[str, Any] = {
|
|
755
|
+
"session_id": self.session_id,
|
|
756
|
+
"status": status,
|
|
757
|
+
"exit_code": self.exit_code,
|
|
758
|
+
"signal": self.signal_name,
|
|
759
|
+
"timed_out": self.timed_out,
|
|
760
|
+
"stdout": stdout,
|
|
761
|
+
"stderr": stderr,
|
|
762
|
+
"stdout_truncated": stdout_truncated,
|
|
763
|
+
"stderr_truncated": stderr_truncated,
|
|
764
|
+
"stdout_truncated_by": stdout_truncation.truncated_by,
|
|
765
|
+
"stderr_truncated_by": stderr_truncation.truncated_by,
|
|
766
|
+
"stdout_output_lines": stdout_truncation.output_lines,
|
|
767
|
+
"stderr_output_lines": stderr_truncation.output_lines,
|
|
768
|
+
"stdout_output_bytes": stdout_truncation.output_bytes,
|
|
769
|
+
"stderr_output_bytes": stderr_truncation.output_bytes,
|
|
770
|
+
"stdout_dropped_bytes": self.stdout_dropped_bytes,
|
|
771
|
+
"stderr_dropped_bytes": self.stderr_dropped_bytes,
|
|
772
|
+
"stdout_omitted_bytes": stdout_omitted,
|
|
773
|
+
"stderr_omitted_bytes": stderr_omitted,
|
|
774
|
+
"truncated": stdout_truncated or stderr_truncated or stdout_omitted > 0 or stderr_omitted > 0,
|
|
775
|
+
"ok": True,
|
|
776
|
+
}
|
|
777
|
+
warnings: list[str] = list(self.warnings)
|
|
778
|
+
if stdout_truncated:
|
|
779
|
+
warnings.append(f"stdout truncated from tail by {stdout_truncation.truncated_by}")
|
|
780
|
+
if stderr_truncated:
|
|
781
|
+
warnings.append(f"stderr truncated from tail by {stderr_truncation.truncated_by}")
|
|
782
|
+
if stdout_omitted > 0:
|
|
783
|
+
warnings.append("stdout cursor skipped dropped bytes")
|
|
784
|
+
if stderr_omitted > 0:
|
|
785
|
+
warnings.append("stderr cursor skipped dropped bytes")
|
|
786
|
+
if warnings:
|
|
787
|
+
payload["warnings"] = warnings
|
|
788
|
+
return payload
|
|
789
|
+
|
|
790
|
+
def refresh_status(self) -> None:
|
|
791
|
+
if (
|
|
792
|
+
self.timeout_at is not None
|
|
793
|
+
and not self.timed_out
|
|
794
|
+
and self.process.poll() is None
|
|
795
|
+
and time.time() >= self.timeout_at
|
|
796
|
+
):
|
|
797
|
+
self.timed_out = True
|
|
798
|
+
terminate_process_group(self.process, signal.SIGTERM)
|
|
799
|
+
self.drain_readers()
|
|
800
|
+
code = self.process.poll()
|
|
801
|
+
if code is None:
|
|
802
|
+
return
|
|
803
|
+
self.drain_readers()
|
|
804
|
+
self.exit_code = code
|
|
805
|
+
if code < 0:
|
|
806
|
+
self.signal_name = signal.Signals(-code).name if -code in [s.value for s in signal.Signals] else str(-code)
|
|
807
|
+
self.closed = True
|
|
808
|
+
|
|
809
|
+
def drain_readers(self, timeout: float = 0.2) -> None:
|
|
810
|
+
deadline = time.time() + timeout
|
|
811
|
+
for thread in list(self.reader_threads):
|
|
812
|
+
remaining = max(0.0, deadline - time.time())
|
|
813
|
+
if remaining <= 0:
|
|
814
|
+
break
|
|
815
|
+
thread.join(timeout=remaining)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
class Runtime:
|
|
819
|
+
def __init__(
|
|
820
|
+
self,
|
|
821
|
+
workspace: Path,
|
|
822
|
+
*,
|
|
823
|
+
enable_view_image: bool = True,
|
|
824
|
+
dangerously_skip_all_permissions: bool = False,
|
|
825
|
+
tool_profile: str = "full",
|
|
826
|
+
auth_token: str | None = None,
|
|
827
|
+
) -> None:
|
|
828
|
+
self.workspace = Workspace(workspace)
|
|
829
|
+
self.enable_view_image = enable_view_image
|
|
830
|
+
self.dangerously_skip_all_permissions = dangerously_skip_all_permissions
|
|
831
|
+
if tool_profile not in TOOL_PROFILE_CHOICES:
|
|
832
|
+
raise ToolFailure(
|
|
833
|
+
"INVALID_ARGUMENT",
|
|
834
|
+
f"Unknown tool profile: {tool_profile}",
|
|
835
|
+
category="validation",
|
|
836
|
+
details={"supported": list(TOOL_PROFILE_CHOICES)},
|
|
837
|
+
)
|
|
838
|
+
self.tool_profile = tool_profile
|
|
839
|
+
self.auth_token = auth_token or None
|
|
840
|
+
self.default_cwd = self.workspace.root
|
|
841
|
+
self.sessions: dict[str, ExecSession] = {}
|
|
842
|
+
self.sessions_lock = threading.Lock()
|
|
843
|
+
self.http_session_id = secrets.token_urlsafe(24)
|
|
844
|
+
self.patch_baselines: dict[str, str | None] = {}
|
|
845
|
+
self.initialized = False
|
|
846
|
+
self.logging_level = "warning"
|
|
847
|
+
|
|
848
|
+
def initialize(self) -> dict[str, Any]:
|
|
849
|
+
return {
|
|
850
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
851
|
+
"capabilities": {"tools": {"listChanged": False}, "logging": {}},
|
|
852
|
+
"serverInfo": {
|
|
853
|
+
"name": SERVER_NAME,
|
|
854
|
+
"title": "Coding Tools MCP",
|
|
855
|
+
"version": __version__,
|
|
856
|
+
},
|
|
857
|
+
"instructions": "Use these tools only for local coding operations inside the configured workspace.",
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
def list_tools(self) -> dict[str, Any]:
|
|
861
|
+
return {"tools": [tool_definition(name, tool_profile=self.tool_profile) for name in self.exposed_tool_names()]}
|
|
862
|
+
|
|
863
|
+
def exposed_tool_names(self) -> list[str]:
|
|
864
|
+
names = READ_ONLY_TOOL_NAMES if self.tool_profile == "read-only" else FULL_TOOL_NAMES
|
|
865
|
+
return [name for name in names if self.enable_view_image or name != "view_image"]
|
|
866
|
+
|
|
867
|
+
def auth_enabled(self) -> bool:
|
|
868
|
+
return self.auth_token is not None
|
|
869
|
+
|
|
870
|
+
def default_cwd_display(self) -> str:
|
|
871
|
+
return normalize_rel_display(self.default_cwd, self.workspace.root)
|
|
872
|
+
|
|
873
|
+
def resolve_existing(self, raw_path: str = ".") -> ResolvedPath:
|
|
874
|
+
return self.workspace.resolve_existing_at(self.default_cwd, raw_path)
|
|
875
|
+
|
|
876
|
+
def resolve_for_write(self, raw_path: str) -> ResolvedPath:
|
|
877
|
+
return self.workspace.resolve_for_write_at(self.default_cwd, raw_path)
|
|
878
|
+
|
|
879
|
+
def git_path_filter(self, raw_path: str) -> str:
|
|
880
|
+
if raw_path == ".":
|
|
881
|
+
return self.default_cwd_display()
|
|
882
|
+
return self.resolve_for_write(raw_path).display
|
|
883
|
+
|
|
884
|
+
def server_info_payload(self) -> dict[str, Any]:
|
|
885
|
+
tools = self.exposed_tool_names()
|
|
886
|
+
return {
|
|
887
|
+
"server": SERVER_NAME,
|
|
888
|
+
"title": "Coding Tools MCP",
|
|
889
|
+
"version": __version__,
|
|
890
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
891
|
+
"workspace": str(self.workspace.root),
|
|
892
|
+
"default_cwd": self.default_cwd_display(),
|
|
893
|
+
"tool_profile": self.tool_profile,
|
|
894
|
+
"auth_enabled": self.auth_enabled(),
|
|
895
|
+
"endpoint_path": "/mcp",
|
|
896
|
+
"tools": tools,
|
|
897
|
+
"tool_count": len(tools),
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
def set_logging_level(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
901
|
+
level = params.get("level")
|
|
902
|
+
if not isinstance(level, str) or level not in LOGGING_LEVELS:
|
|
903
|
+
raise JsonRpcError(
|
|
904
|
+
-32602,
|
|
905
|
+
"logging/setLevel requires a valid logging level",
|
|
906
|
+
{"supported": list(LOGGING_LEVELS), "received": level},
|
|
907
|
+
)
|
|
908
|
+
self.logging_level = level
|
|
909
|
+
return {}
|
|
910
|
+
|
|
911
|
+
def call_tool(self, name: str, arguments: dict[str, Any] | None) -> dict[str, Any]:
|
|
912
|
+
started_at = time.time()
|
|
913
|
+
args = arguments or {}
|
|
914
|
+
handlers = {
|
|
915
|
+
"server_info": self.server_info,
|
|
916
|
+
"get_default_cwd": self.get_default_cwd,
|
|
917
|
+
"set_default_cwd": self.set_default_cwd,
|
|
918
|
+
"read_file": self.read_file,
|
|
919
|
+
"list_dir": self.list_dir,
|
|
920
|
+
"list_files": self.list_files,
|
|
921
|
+
"search_text": self.search_text,
|
|
922
|
+
"apply_patch": self.apply_patch,
|
|
923
|
+
"exec_command": self.exec_command,
|
|
924
|
+
"write_stdin": self.write_stdin,
|
|
925
|
+
"kill_session": self.kill_session,
|
|
926
|
+
"git_status": self.git_status,
|
|
927
|
+
"git_diff": self.git_diff,
|
|
928
|
+
"git_log": self.git_log,
|
|
929
|
+
"git_show": self.git_show,
|
|
930
|
+
"git_blame": self.git_blame,
|
|
931
|
+
"request_permissions": self.request_permissions,
|
|
932
|
+
}
|
|
933
|
+
if self.enable_view_image:
|
|
934
|
+
handlers["view_image"] = self.view_image
|
|
935
|
+
handler = handlers.get(name) if name in set(self.exposed_tool_names()) else None
|
|
936
|
+
if handler is None:
|
|
937
|
+
raise JsonRpcError(-32602, f"Unknown tool: {name}", {"reason": "unknown_tool"})
|
|
938
|
+
validate_arguments(name, args)
|
|
939
|
+
try:
|
|
940
|
+
payload = handler(args)
|
|
941
|
+
payload.setdefault("ok", True)
|
|
942
|
+
self.emit_tool_trace(name, args, payload, started_at)
|
|
943
|
+
content = None
|
|
944
|
+
if name == "view_image" and args.get("output", "mcp_image") == "mcp_image":
|
|
945
|
+
content = [
|
|
946
|
+
{
|
|
947
|
+
"type": "image",
|
|
948
|
+
"data": str(payload.get("base64", "")),
|
|
949
|
+
"mimeType": str(payload.get("mime_type", "application/octet-stream")),
|
|
950
|
+
}
|
|
951
|
+
]
|
|
952
|
+
return tool_result(payload, is_error=payload.get("ok") is False, content=content)
|
|
953
|
+
except ToolFailure as exc:
|
|
954
|
+
payload = {
|
|
955
|
+
"ok": False,
|
|
956
|
+
"error": {
|
|
957
|
+
"code": exc.code,
|
|
958
|
+
"message": exc.message,
|
|
959
|
+
"category": exc.category,
|
|
960
|
+
"retryable": exc.retryable,
|
|
961
|
+
"details": exc.details,
|
|
962
|
+
},
|
|
963
|
+
}
|
|
964
|
+
if exc.code == "PERMISSION_REQUIRED":
|
|
965
|
+
permission = exc.details.get("permission")
|
|
966
|
+
payload["permission_request"] = {
|
|
967
|
+
"tool_name": name,
|
|
968
|
+
"permission": permission or "unknown",
|
|
969
|
+
"status": "required",
|
|
970
|
+
"retryable": True,
|
|
971
|
+
}
|
|
972
|
+
if exc.code == "ELICITATION_UNSUPPORTED":
|
|
973
|
+
payload["status"] = "unsupported"
|
|
974
|
+
self.emit_tool_trace(name, args, payload, started_at)
|
|
975
|
+
return tool_result(payload, is_error=True)
|
|
976
|
+
except Exception as exc: # noqa: BLE001 - tool failures must stay structured
|
|
977
|
+
payload = {
|
|
978
|
+
"ok": False,
|
|
979
|
+
"error": {
|
|
980
|
+
"code": "INTERNAL_ERROR",
|
|
981
|
+
"message": str(exc),
|
|
982
|
+
"category": "internal",
|
|
983
|
+
"retryable": False,
|
|
984
|
+
"details": {},
|
|
985
|
+
},
|
|
986
|
+
}
|
|
987
|
+
self.emit_tool_trace(name, args, payload, started_at)
|
|
988
|
+
return tool_result(payload, is_error=True)
|
|
989
|
+
|
|
990
|
+
def server_info(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
991
|
+
return self.server_info_payload()
|
|
992
|
+
|
|
993
|
+
def get_default_cwd(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
994
|
+
return {
|
|
995
|
+
"workspace": str(self.workspace.root),
|
|
996
|
+
"default_cwd": self.default_cwd_display(),
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
def set_default_cwd(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
1000
|
+
resolved = self.workspace.resolve_existing(str(args.get("path", ".")))
|
|
1001
|
+
if not resolved.path.is_dir():
|
|
1002
|
+
raise ToolFailure("NOT_A_DIRECTORY", "Default cwd must be a directory.", category="validation")
|
|
1003
|
+
self.default_cwd = resolved.path
|
|
1004
|
+
return {
|
|
1005
|
+
"workspace": str(self.workspace.root),
|
|
1006
|
+
"default_cwd": resolved.display,
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
def emit_tool_trace(self, name: str, args: dict[str, Any], payload: dict[str, Any], started_at: float) -> None:
|
|
1010
|
+
if os.environ.get(f"{ENV_PREFIX}_TRACE") != "1":
|
|
1011
|
+
return
|
|
1012
|
+
error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
|
|
1013
|
+
event = {
|
|
1014
|
+
"event": "tool_call",
|
|
1015
|
+
"timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
|
1016
|
+
"tool": name,
|
|
1017
|
+
"ok": bool(payload.get("ok", False)),
|
|
1018
|
+
"status": payload.get("status"),
|
|
1019
|
+
"error_code": error.get("code") if isinstance(error, dict) else None,
|
|
1020
|
+
"duration_ms": int((time.time() - started_at) * 1000),
|
|
1021
|
+
"session_id": payload.get("session_id"),
|
|
1022
|
+
"truncated": payload.get("truncated"),
|
|
1023
|
+
"args": redact_for_trace(args),
|
|
1024
|
+
}
|
|
1025
|
+
print(json.dumps(event, sort_keys=True, separators=(",", ":")), file=sys.stderr, flush=True)
|
|
1026
|
+
|
|
1027
|
+
def read_file(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
1028
|
+
resolved = self.resolve_existing(str(args.get("path", "")))
|
|
1029
|
+
if resolved.path.is_dir():
|
|
1030
|
+
raise ToolFailure("IS_DIRECTORY", "Path is a directory.", category="validation")
|
|
1031
|
+
max_bytes = int(args.get("max_bytes", 131072))
|
|
1032
|
+
start_line = int(args.get("start_line", 1))
|
|
1033
|
+
end_line = args.get("end_line")
|
|
1034
|
+
encoding = args.get("encoding", "utf-8")
|
|
1035
|
+
if encoding != "utf-8":
|
|
1036
|
+
raise ToolFailure("UNSUPPORTED_ENCODING", "Only utf-8 is supported.", category="validation")
|
|
1037
|
+
data = resolved.path.read_bytes()
|
|
1038
|
+
if b"\x00" in data[:4096]:
|
|
1039
|
+
raise ToolFailure("BINARY_FILE", "Binary file read blocked for text tool.", category="validation")
|
|
1040
|
+
try:
|
|
1041
|
+
text = data.decode("utf-8")
|
|
1042
|
+
except UnicodeDecodeError as exc:
|
|
1043
|
+
raise ToolFailure("UNSUPPORTED_ENCODING", "File is not valid utf-8.", category="validation") from exc
|
|
1044
|
+
lines = text.splitlines(keepends=True)
|
|
1045
|
+
total_lines = len(lines)
|
|
1046
|
+
total_bytes = len(data)
|
|
1047
|
+
if start_line < 1:
|
|
1048
|
+
raise ToolFailure("INVALID_ARGUMENT", "start_line must be >= 1.", category="validation")
|
|
1049
|
+
end = int(end_line) if end_line is not None else total_lines
|
|
1050
|
+
if end < start_line:
|
|
1051
|
+
selected = ""
|
|
1052
|
+
else:
|
|
1053
|
+
selected = "".join(lines[start_line - 1 : end])
|
|
1054
|
+
truncation = truncate_text_head(selected, max_lines=DEFAULT_MAX_LINES, max_bytes=max_bytes)
|
|
1055
|
+
selected = truncation.content
|
|
1056
|
+
truncated = truncation.truncated
|
|
1057
|
+
actual_end = min(end, total_lines)
|
|
1058
|
+
if truncated and truncation.output_lines > 0:
|
|
1059
|
+
actual_end = min(total_lines, start_line + truncation.output_lines - 1)
|
|
1060
|
+
next_start_line = actual_end + 1 if truncated and actual_end < total_lines else None
|
|
1061
|
+
warnings = []
|
|
1062
|
+
if truncated:
|
|
1063
|
+
warnings.append("content truncated")
|
|
1064
|
+
if truncation.first_line_exceeds_limit:
|
|
1065
|
+
warnings.append("first selected line exceeds max_bytes")
|
|
1066
|
+
return {
|
|
1067
|
+
"path": resolved.display,
|
|
1068
|
+
"content": selected,
|
|
1069
|
+
"encoding": "utf-8",
|
|
1070
|
+
"start_line": start_line,
|
|
1071
|
+
"end_line": actual_end,
|
|
1072
|
+
"total_lines": total_lines,
|
|
1073
|
+
"total_bytes": total_bytes,
|
|
1074
|
+
"bytes_read": len(selected.encode("utf-8")),
|
|
1075
|
+
"truncated": truncated,
|
|
1076
|
+
"truncated_by": truncation.truncated_by,
|
|
1077
|
+
"output_lines": truncation.output_lines,
|
|
1078
|
+
"output_bytes": truncation.output_bytes,
|
|
1079
|
+
"next_start_line": next_start_line,
|
|
1080
|
+
"warnings": warnings,
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
def list_dir(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
1084
|
+
resolved = self.resolve_existing(str(args.get("path", ".")))
|
|
1085
|
+
if not resolved.path.is_dir():
|
|
1086
|
+
raise ToolFailure("NOT_A_DIRECTORY", "Path is not a directory.", category="validation")
|
|
1087
|
+
recursive = bool(args.get("recursive", False))
|
|
1088
|
+
max_depth = int(args.get("max_depth", 1))
|
|
1089
|
+
max_entries = int(args.get("max_entries", 1000))
|
|
1090
|
+
include_hidden = bool(args.get("include_hidden", False))
|
|
1091
|
+
include_ignored = bool(args.get("include_ignored", False))
|
|
1092
|
+
sort_key = args.get("sort", "name")
|
|
1093
|
+
entries: list[dict[str, Any]] = []
|
|
1094
|
+
truncated = False
|
|
1095
|
+
|
|
1096
|
+
def visit(directory: Path, depth: int) -> None:
|
|
1097
|
+
nonlocal truncated
|
|
1098
|
+
if truncated:
|
|
1099
|
+
return
|
|
1100
|
+
try:
|
|
1101
|
+
children = list(directory.iterdir())
|
|
1102
|
+
except OSError:
|
|
1103
|
+
return
|
|
1104
|
+
for child in children:
|
|
1105
|
+
if self.workspace.is_ignored_path(child, include_hidden=include_hidden, include_ignored=include_ignored):
|
|
1106
|
+
continue
|
|
1107
|
+
entries.append(entry_for_path(child, self.workspace.root))
|
|
1108
|
+
if len(entries) >= max_entries:
|
|
1109
|
+
truncated = True
|
|
1110
|
+
return
|
|
1111
|
+
if recursive and depth < max_depth and child.is_dir() and not child.is_symlink():
|
|
1112
|
+
visit(child, depth + 1)
|
|
1113
|
+
|
|
1114
|
+
visit(resolved.path, 1)
|
|
1115
|
+
entries.sort(key=lambda item: sort_value(item, sort_key))
|
|
1116
|
+
return {
|
|
1117
|
+
"path": resolved.display,
|
|
1118
|
+
"entries": entries,
|
|
1119
|
+
"truncated": truncated,
|
|
1120
|
+
"warnings": ["entry limit reached"] if truncated else [],
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
def list_files(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
1124
|
+
resolved = self.resolve_existing(str(args.get("path", ".")))
|
|
1125
|
+
if not resolved.path.is_dir():
|
|
1126
|
+
raise ToolFailure("NOT_A_DIRECTORY", "Path is not a directory.", category="validation")
|
|
1127
|
+
patterns_arg = args.get("patterns")
|
|
1128
|
+
glob_arg = args.get("glob")
|
|
1129
|
+
if isinstance(patterns_arg, list) and patterns_arg:
|
|
1130
|
+
patterns = [str(item) for item in patterns_arg]
|
|
1131
|
+
elif isinstance(glob_arg, str) and glob_arg:
|
|
1132
|
+
patterns = [glob_arg]
|
|
1133
|
+
else:
|
|
1134
|
+
patterns = ["**/*"]
|
|
1135
|
+
exclude_patterns = [str(item) for item in args.get("exclude_patterns", [])]
|
|
1136
|
+
include_hidden = bool(args.get("include_hidden", False))
|
|
1137
|
+
include_ignored = bool(args.get("include_ignored", False))
|
|
1138
|
+
max_results = int(args.get("max_results", 5000))
|
|
1139
|
+
fast_result = self._list_files_with_fd(
|
|
1140
|
+
resolved,
|
|
1141
|
+
patterns,
|
|
1142
|
+
exclude_patterns,
|
|
1143
|
+
include_hidden=include_hidden,
|
|
1144
|
+
include_ignored=include_ignored,
|
|
1145
|
+
max_results=max_results,
|
|
1146
|
+
sort_key=str(args.get("sort", "path")),
|
|
1147
|
+
)
|
|
1148
|
+
if fast_result is not None:
|
|
1149
|
+
return fast_result
|
|
1150
|
+
files: list[dict[str, Any]] = []
|
|
1151
|
+
truncated = False
|
|
1152
|
+
for path in walk_files(resolved.path):
|
|
1153
|
+
if path.is_symlink() and not self.workspace.is_safe_existing_path(path):
|
|
1154
|
+
continue
|
|
1155
|
+
if self.workspace.is_ignored_path(path, include_hidden=include_hidden, include_ignored=include_ignored):
|
|
1156
|
+
continue
|
|
1157
|
+
rel = normalize_rel_display(path, self.workspace.root)
|
|
1158
|
+
if not any(fnmatch.fnmatch(rel, pattern) or PurePosixPath(rel).match(pattern) for pattern in patterns):
|
|
1159
|
+
continue
|
|
1160
|
+
if any(fnmatch.fnmatch(rel, pattern) or PurePosixPath(rel).match(pattern) for pattern in exclude_patterns):
|
|
1161
|
+
continue
|
|
1162
|
+
stat = path.lstat()
|
|
1163
|
+
files.append(
|
|
1164
|
+
{
|
|
1165
|
+
"path": rel,
|
|
1166
|
+
"type": "symlink" if path.is_symlink() else "file",
|
|
1167
|
+
"size_bytes": stat.st_size,
|
|
1168
|
+
"modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
1169
|
+
}
|
|
1170
|
+
)
|
|
1171
|
+
if len(files) >= max_results:
|
|
1172
|
+
truncated = True
|
|
1173
|
+
break
|
|
1174
|
+
files.sort(key=lambda item: item["modified"] if args.get("sort") == "modified" else item["path"])
|
|
1175
|
+
return {
|
|
1176
|
+
"path": resolved.display,
|
|
1177
|
+
"files": files,
|
|
1178
|
+
"truncated": truncated,
|
|
1179
|
+
"warnings": ["result limit reached"] if truncated else [],
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
def _list_files_with_fd(
|
|
1183
|
+
self,
|
|
1184
|
+
resolved: ResolvedPath,
|
|
1185
|
+
patterns: list[str],
|
|
1186
|
+
exclude_patterns: list[str],
|
|
1187
|
+
*,
|
|
1188
|
+
include_hidden: bool,
|
|
1189
|
+
include_ignored: bool,
|
|
1190
|
+
max_results: int,
|
|
1191
|
+
sort_key: str,
|
|
1192
|
+
) -> dict[str, Any] | None:
|
|
1193
|
+
fd = shutil.which("fd") or shutil.which("fdfind")
|
|
1194
|
+
if not fd or not resolved.path.is_dir():
|
|
1195
|
+
return None
|
|
1196
|
+
args_base = [
|
|
1197
|
+
fd,
|
|
1198
|
+
"--glob",
|
|
1199
|
+
"--color=never",
|
|
1200
|
+
"--type",
|
|
1201
|
+
"f",
|
|
1202
|
+
"--type",
|
|
1203
|
+
"l",
|
|
1204
|
+
"--max-results",
|
|
1205
|
+
str(max_results),
|
|
1206
|
+
"--no-require-git",
|
|
1207
|
+
]
|
|
1208
|
+
if include_hidden:
|
|
1209
|
+
args_base.append("--hidden")
|
|
1210
|
+
if include_ignored:
|
|
1211
|
+
args_base.append("--no-ignore")
|
|
1212
|
+
else:
|
|
1213
|
+
for name in sorted(DEFAULT_EXCLUDED_NAMES):
|
|
1214
|
+
args_base.extend(["--exclude", name])
|
|
1215
|
+
for pattern in exclude_patterns:
|
|
1216
|
+
args_base.extend(["--exclude", pattern])
|
|
1217
|
+
|
|
1218
|
+
paths: dict[str, Path] = {}
|
|
1219
|
+
for pattern in patterns:
|
|
1220
|
+
effective = pattern
|
|
1221
|
+
args = list(args_base)
|
|
1222
|
+
if "/" in pattern:
|
|
1223
|
+
args.append("--full-path")
|
|
1224
|
+
if not pattern.startswith("/") and not pattern.startswith("**/") and pattern != "**":
|
|
1225
|
+
effective = f"**/{pattern}"
|
|
1226
|
+
args.extend(["--", effective, "."])
|
|
1227
|
+
try:
|
|
1228
|
+
completed = subprocess.run(
|
|
1229
|
+
args,
|
|
1230
|
+
cwd=str(resolved.path),
|
|
1231
|
+
text=True,
|
|
1232
|
+
stdout=subprocess.PIPE,
|
|
1233
|
+
stderr=subprocess.PIPE,
|
|
1234
|
+
timeout=10,
|
|
1235
|
+
)
|
|
1236
|
+
except Exception:
|
|
1237
|
+
return None
|
|
1238
|
+
if completed.returncode not in {0, 1}:
|
|
1239
|
+
return None
|
|
1240
|
+
for raw in completed.stdout.splitlines():
|
|
1241
|
+
rel_to_search = raw.strip().removeprefix("./")
|
|
1242
|
+
if not rel_to_search:
|
|
1243
|
+
continue
|
|
1244
|
+
path = resolved.path / rel_to_search
|
|
1245
|
+
if path.is_symlink() and not self.workspace.is_safe_existing_path(path):
|
|
1246
|
+
continue
|
|
1247
|
+
if self.workspace.is_ignored_path(path, include_hidden=include_hidden, include_ignored=include_ignored):
|
|
1248
|
+
continue
|
|
1249
|
+
rel = normalize_rel_display(path, self.workspace.root)
|
|
1250
|
+
if any(fnmatch.fnmatch(rel, pat) or PurePosixPath(rel).match(pat) for pat in exclude_patterns):
|
|
1251
|
+
continue
|
|
1252
|
+
paths[rel] = path
|
|
1253
|
+
if len(paths) >= max_results:
|
|
1254
|
+
break
|
|
1255
|
+
if len(paths) >= max_results:
|
|
1256
|
+
break
|
|
1257
|
+
files: list[dict[str, Any]] = []
|
|
1258
|
+
for rel, path in paths.items():
|
|
1259
|
+
try:
|
|
1260
|
+
stat = path.lstat()
|
|
1261
|
+
except OSError:
|
|
1262
|
+
continue
|
|
1263
|
+
files.append(
|
|
1264
|
+
{
|
|
1265
|
+
"path": rel,
|
|
1266
|
+
"type": "symlink" if path.is_symlink() else "file",
|
|
1267
|
+
"size_bytes": stat.st_size,
|
|
1268
|
+
"modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
1269
|
+
}
|
|
1270
|
+
)
|
|
1271
|
+
files.sort(key=lambda item: item["modified"] if sort_key == "modified" else item["path"])
|
|
1272
|
+
truncated = len(paths) >= max_results
|
|
1273
|
+
return {
|
|
1274
|
+
"path": resolved.display,
|
|
1275
|
+
"files": files,
|
|
1276
|
+
"truncated": truncated,
|
|
1277
|
+
"engine": "fd",
|
|
1278
|
+
"warnings": ["result limit reached"] if truncated else [],
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
def search_text(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
1282
|
+
query = str(args.get("query", ""))
|
|
1283
|
+
if not query:
|
|
1284
|
+
raise ToolFailure("INVALID_ARGUMENT", "query is required.", category="validation")
|
|
1285
|
+
resolved = self.resolve_existing(str(args.get("path", ".")))
|
|
1286
|
+
regex = bool(args.get("regex", False))
|
|
1287
|
+
case_sensitive = bool(args.get("case_sensitive", False))
|
|
1288
|
+
include_globs = [str(item) for item in args.get("include_globs", [])]
|
|
1289
|
+
if isinstance(args.get("glob"), str):
|
|
1290
|
+
include_globs.append(str(args["glob"]))
|
|
1291
|
+
exclude_globs = [str(item) for item in args.get("exclude_globs", [])]
|
|
1292
|
+
context_lines = int(args.get("context_lines", 0))
|
|
1293
|
+
max_results = int(args.get("max_results", 1000))
|
|
1294
|
+
max_preview_bytes = int(args.get("max_preview_bytes", 512))
|
|
1295
|
+
fast_result = self._search_text_with_rg(
|
|
1296
|
+
resolved,
|
|
1297
|
+
query,
|
|
1298
|
+
regex=regex,
|
|
1299
|
+
case_sensitive=case_sensitive,
|
|
1300
|
+
include_globs=include_globs,
|
|
1301
|
+
exclude_globs=exclude_globs,
|
|
1302
|
+
context_lines=context_lines,
|
|
1303
|
+
max_results=max_results,
|
|
1304
|
+
max_preview_bytes=max_preview_bytes,
|
|
1305
|
+
)
|
|
1306
|
+
if fast_result is not None:
|
|
1307
|
+
return fast_result
|
|
1308
|
+
matches: list[dict[str, Any]] = []
|
|
1309
|
+
total = 0
|
|
1310
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
1311
|
+
try:
|
|
1312
|
+
compiled = re.compile(query, flags) if regex else None
|
|
1313
|
+
except re.error as exc:
|
|
1314
|
+
raise ToolFailure("INVALID_ARGUMENT", f"Invalid regex: {exc}", category="validation") from exc
|
|
1315
|
+
|
|
1316
|
+
roots = [resolved.path] if resolved.path.is_file() else walk_files(resolved.path)
|
|
1317
|
+
for path in roots:
|
|
1318
|
+
if path.is_dir() or self.workspace.is_ignored_path(path):
|
|
1319
|
+
continue
|
|
1320
|
+
if path.is_symlink() and not self.workspace.is_safe_existing_path(path):
|
|
1321
|
+
continue
|
|
1322
|
+
rel = normalize_rel_display(path, self.workspace.root)
|
|
1323
|
+
if include_globs and not any(fnmatch.fnmatch(rel, pat) or PurePosixPath(rel).match(pat) for pat in include_globs):
|
|
1324
|
+
continue
|
|
1325
|
+
if any(fnmatch.fnmatch(rel, pat) or PurePosixPath(rel).match(pat) for pat in exclude_globs):
|
|
1326
|
+
continue
|
|
1327
|
+
try:
|
|
1328
|
+
data = path.read_bytes()
|
|
1329
|
+
except OSError:
|
|
1330
|
+
continue
|
|
1331
|
+
if b"\x00" in data[:4096]:
|
|
1332
|
+
continue
|
|
1333
|
+
try:
|
|
1334
|
+
lines = data.decode("utf-8").splitlines()
|
|
1335
|
+
except UnicodeDecodeError:
|
|
1336
|
+
continue
|
|
1337
|
+
for index, line in enumerate(lines):
|
|
1338
|
+
found = compiled.search(line) if compiled else find_literal(line, query, case_sensitive)
|
|
1339
|
+
if not found:
|
|
1340
|
+
continue
|
|
1341
|
+
total += 1
|
|
1342
|
+
if len(matches) >= max_results:
|
|
1343
|
+
continue
|
|
1344
|
+
column = found.start() + 1 if hasattr(found, "start") else 1
|
|
1345
|
+
preview, line_truncated = truncate_line_chars(line)
|
|
1346
|
+
preview_truncation = truncate_text_head(preview, max_lines=1, max_bytes=max_preview_bytes)
|
|
1347
|
+
preview = preview_truncation.content
|
|
1348
|
+
before = lines[max(0, index - context_lines) : index]
|
|
1349
|
+
after = lines[index + 1 : index + 1 + context_lines]
|
|
1350
|
+
item = {
|
|
1351
|
+
"path": rel,
|
|
1352
|
+
"line": index + 1,
|
|
1353
|
+
"column": column,
|
|
1354
|
+
"preview": preview,
|
|
1355
|
+
"before": before,
|
|
1356
|
+
"after": after,
|
|
1357
|
+
}
|
|
1358
|
+
if line_truncated or preview_truncation.truncated:
|
|
1359
|
+
item["preview_truncated"] = True
|
|
1360
|
+
item["preview_truncated_by"] = "chars" if line_truncated else preview_truncation.truncated_by
|
|
1361
|
+
matches.append(item)
|
|
1362
|
+
return {
|
|
1363
|
+
"query": query,
|
|
1364
|
+
"matches": matches,
|
|
1365
|
+
"total_matches": total,
|
|
1366
|
+
"truncated": total > len(matches),
|
|
1367
|
+
"warnings": ["result limit reached"] if total > len(matches) else [],
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
def _search_text_with_rg(
|
|
1371
|
+
self,
|
|
1372
|
+
resolved: ResolvedPath,
|
|
1373
|
+
query: str,
|
|
1374
|
+
*,
|
|
1375
|
+
regex: bool,
|
|
1376
|
+
case_sensitive: bool,
|
|
1377
|
+
include_globs: list[str],
|
|
1378
|
+
exclude_globs: list[str],
|
|
1379
|
+
context_lines: int,
|
|
1380
|
+
max_results: int,
|
|
1381
|
+
max_preview_bytes: int,
|
|
1382
|
+
) -> dict[str, Any] | None:
|
|
1383
|
+
rg = shutil.which("rg")
|
|
1384
|
+
if not rg:
|
|
1385
|
+
return None
|
|
1386
|
+
args = [rg, "--json", "--line-number", "--color=never"]
|
|
1387
|
+
if not case_sensitive:
|
|
1388
|
+
args.append("--ignore-case")
|
|
1389
|
+
if not regex:
|
|
1390
|
+
args.append("--fixed-strings")
|
|
1391
|
+
for name in sorted(DEFAULT_EXCLUDED_NAMES):
|
|
1392
|
+
args.extend(["--glob", f"!{name}/**"])
|
|
1393
|
+
for pattern in include_globs:
|
|
1394
|
+
args.extend(["--glob", pattern])
|
|
1395
|
+
for pattern in exclude_globs:
|
|
1396
|
+
args.extend(["--glob", f"!{pattern}"])
|
|
1397
|
+
search_path = resolved.display if resolved.display != "." else "."
|
|
1398
|
+
args.extend(["--", query, search_path])
|
|
1399
|
+
try:
|
|
1400
|
+
completed = subprocess.run(
|
|
1401
|
+
args,
|
|
1402
|
+
cwd=str(self.workspace.root),
|
|
1403
|
+
text=True,
|
|
1404
|
+
stdout=subprocess.PIPE,
|
|
1405
|
+
stderr=subprocess.PIPE,
|
|
1406
|
+
timeout=10,
|
|
1407
|
+
)
|
|
1408
|
+
except Exception:
|
|
1409
|
+
return None
|
|
1410
|
+
if completed.returncode not in {0, 1}:
|
|
1411
|
+
return None
|
|
1412
|
+
matches: list[dict[str, Any]] = []
|
|
1413
|
+
total = 0
|
|
1414
|
+
file_cache: dict[str, list[str]] = {}
|
|
1415
|
+
for raw in completed.stdout.splitlines():
|
|
1416
|
+
try:
|
|
1417
|
+
event = json.loads(raw)
|
|
1418
|
+
except json.JSONDecodeError:
|
|
1419
|
+
continue
|
|
1420
|
+
if event.get("type") != "match":
|
|
1421
|
+
continue
|
|
1422
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
1423
|
+
path_text = data.get("path", {}).get("text") if isinstance(data.get("path"), dict) else None
|
|
1424
|
+
line_number = data.get("line_number")
|
|
1425
|
+
line_text = data.get("lines", {}).get("text") if isinstance(data.get("lines"), dict) else ""
|
|
1426
|
+
if not isinstance(path_text, str) or not isinstance(line_number, int):
|
|
1427
|
+
continue
|
|
1428
|
+
total += 1
|
|
1429
|
+
if len(matches) >= max_results:
|
|
1430
|
+
continue
|
|
1431
|
+
rel = normalize_rel_display((self.workspace.root / path_text).resolve(), self.workspace.root)
|
|
1432
|
+
submatches = data.get("submatches") if isinstance(data.get("submatches"), list) else []
|
|
1433
|
+
first_submatch = submatches[0] if submatches and isinstance(submatches[0], dict) else {}
|
|
1434
|
+
column = int(first_submatch.get("start", 0)) + 1
|
|
1435
|
+
sanitized = str(line_text).replace("\r\n", "\n").replace("\r", "").rstrip("\n")
|
|
1436
|
+
preview, line_truncated = truncate_line_chars(sanitized)
|
|
1437
|
+
preview_truncation = truncate_text_head(preview, max_lines=1, max_bytes=max_preview_bytes)
|
|
1438
|
+
preview = preview_truncation.content
|
|
1439
|
+
lines = file_cache.get(rel)
|
|
1440
|
+
if lines is None:
|
|
1441
|
+
try:
|
|
1442
|
+
lines = (self.workspace.root / rel).read_text(encoding="utf-8").splitlines()
|
|
1443
|
+
except OSError:
|
|
1444
|
+
lines = []
|
|
1445
|
+
file_cache[rel] = lines
|
|
1446
|
+
index = line_number - 1
|
|
1447
|
+
before = lines[max(0, index - context_lines) : index] if lines else []
|
|
1448
|
+
after = lines[index + 1 : index + 1 + context_lines] if lines else []
|
|
1449
|
+
item = {
|
|
1450
|
+
"path": rel,
|
|
1451
|
+
"line": line_number,
|
|
1452
|
+
"column": column,
|
|
1453
|
+
"preview": preview,
|
|
1454
|
+
"before": before,
|
|
1455
|
+
"after": after,
|
|
1456
|
+
}
|
|
1457
|
+
if line_truncated or preview_truncation.truncated:
|
|
1458
|
+
item["preview_truncated"] = True
|
|
1459
|
+
item["preview_truncated_by"] = "chars" if line_truncated else preview_truncation.truncated_by
|
|
1460
|
+
matches.append(item)
|
|
1461
|
+
return {
|
|
1462
|
+
"query": query,
|
|
1463
|
+
"matches": matches,
|
|
1464
|
+
"total_matches": total,
|
|
1465
|
+
"truncated": total > len(matches),
|
|
1466
|
+
"engine": "rg",
|
|
1467
|
+
"warnings": ["result limit reached"] if total > len(matches) else [],
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
def apply_patch(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
1471
|
+
patch = str(args.get("patch", ""))
|
|
1472
|
+
dry_run = bool(args.get("dry_run", False))
|
|
1473
|
+
operations = parse_patch(patch)
|
|
1474
|
+
staged: dict[str, str | None] = {}
|
|
1475
|
+
summaries: list[str] = []
|
|
1476
|
+
affected: list[dict[str, str]] = []
|
|
1477
|
+
for op in operations:
|
|
1478
|
+
self._validate_patch_path(op.path, require_existing=op.kind in {"update", "delete"})
|
|
1479
|
+
if op.kind in {"add", "update", "delete"}:
|
|
1480
|
+
self.workspace.reject_write_symlink(op.path)
|
|
1481
|
+
if op.move_to:
|
|
1482
|
+
self._validate_patch_path(op.move_to, require_existing=False)
|
|
1483
|
+
self.workspace.reject_write_symlink(op.move_to)
|
|
1484
|
+
if op.kind == "add":
|
|
1485
|
+
target = self.workspace.resolve_for_write(op.path)
|
|
1486
|
+
if target.existed:
|
|
1487
|
+
raise ToolFailure("PATCH_FAILED", "Cannot add file that already exists.", category="validation")
|
|
1488
|
+
staged[target.display] = op.add_content or ""
|
|
1489
|
+
affected.append({"path": target.display, "operation": "add"})
|
|
1490
|
+
summaries.append(f"A {target.display}")
|
|
1491
|
+
elif op.kind == "delete":
|
|
1492
|
+
target = self.workspace.resolve_existing(op.path)
|
|
1493
|
+
if target.path.is_dir():
|
|
1494
|
+
raise ToolFailure("PATCH_FAILED", "Cannot delete a directory.", category="validation")
|
|
1495
|
+
staged[target.display] = None
|
|
1496
|
+
affected.append({"path": target.display, "operation": "delete"})
|
|
1497
|
+
summaries.append(f"D {target.display}")
|
|
1498
|
+
elif op.kind == "update":
|
|
1499
|
+
source = self.workspace.resolve_existing(op.path)
|
|
1500
|
+
if source.path.is_dir():
|
|
1501
|
+
raise ToolFailure("PATCH_FAILED", "Cannot update a directory.", category="validation")
|
|
1502
|
+
current = staged.get(source.display)
|
|
1503
|
+
if current is None and source.display in staged:
|
|
1504
|
+
raise ToolFailure("PATCH_FAILED", "Cannot update a deleted file.", category="validation")
|
|
1505
|
+
content = current if isinstance(current, str) else read_text_preserve_newlines(source.path)
|
|
1506
|
+
updated = apply_update_hunks(content, op.hunks, op.path)
|
|
1507
|
+
if op.move_to:
|
|
1508
|
+
dest = self.workspace.resolve_for_write(op.move_to)
|
|
1509
|
+
if dest.existed and dest.display != source.display:
|
|
1510
|
+
raise ToolFailure("PATCH_FAILED", "Cannot move over an existing file.", category="validation")
|
|
1511
|
+
staged[source.display] = None
|
|
1512
|
+
staged[dest.display] = updated
|
|
1513
|
+
affected.append({"path": dest.display, "old_path": source.display, "operation": "move"})
|
|
1514
|
+
summaries.append(f"R {source.display} -> {dest.display}")
|
|
1515
|
+
else:
|
|
1516
|
+
staged[source.display] = updated
|
|
1517
|
+
affected.append({"path": source.display, "operation": "update"})
|
|
1518
|
+
summaries.append(f"M {source.display}")
|
|
1519
|
+
if not affected:
|
|
1520
|
+
raise ToolFailure("PATCH_FAILED", "No files were modified.", category="validation")
|
|
1521
|
+
if not dry_run:
|
|
1522
|
+
self._commit_staged_files(staged)
|
|
1523
|
+
return {
|
|
1524
|
+
"dry_run": dry_run,
|
|
1525
|
+
"clean": True,
|
|
1526
|
+
"summary": "\n".join(summaries),
|
|
1527
|
+
"affected_files": affected,
|
|
1528
|
+
"warnings": [],
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
def _validate_patch_path(self, raw_path: str, *, require_existing: bool) -> None:
|
|
1532
|
+
if require_existing:
|
|
1533
|
+
self.workspace.resolve_existing(raw_path)
|
|
1534
|
+
else:
|
|
1535
|
+
self.workspace.resolve_for_write(raw_path)
|
|
1536
|
+
|
|
1537
|
+
def _commit_staged_files(self, staged: dict[str, str | None]) -> None:
|
|
1538
|
+
backups: dict[Path, bytes | None] = {}
|
|
1539
|
+
try:
|
|
1540
|
+
for rel, content in staged.items():
|
|
1541
|
+
path = self.workspace.resolve_for_write(rel).path
|
|
1542
|
+
backups[path] = path.read_bytes() if path.exists() and not path.is_dir() else None
|
|
1543
|
+
if rel not in self.patch_baselines:
|
|
1544
|
+
if backups[path] is None:
|
|
1545
|
+
self.patch_baselines[rel] = None
|
|
1546
|
+
else:
|
|
1547
|
+
self.patch_baselines[rel] = backups[path].decode("utf-8", errors="replace")
|
|
1548
|
+
if content is None:
|
|
1549
|
+
if path.exists():
|
|
1550
|
+
if path.is_dir():
|
|
1551
|
+
raise ToolFailure("PATCH_FAILED", "Cannot delete a directory.", category="validation")
|
|
1552
|
+
path.unlink()
|
|
1553
|
+
else:
|
|
1554
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1555
|
+
with path.open("w", encoding="utf-8", newline="") as handle:
|
|
1556
|
+
handle.write(content)
|
|
1557
|
+
except Exception:
|
|
1558
|
+
for path, data in backups.items():
|
|
1559
|
+
try:
|
|
1560
|
+
if data is None:
|
|
1561
|
+
if path.exists() and not path.is_dir():
|
|
1562
|
+
path.unlink()
|
|
1563
|
+
else:
|
|
1564
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1565
|
+
path.write_bytes(data)
|
|
1566
|
+
except OSError:
|
|
1567
|
+
pass
|
|
1568
|
+
raise
|
|
1569
|
+
|
|
1570
|
+
def exec_command(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
1571
|
+
cmd = str(args.get("cmd", ""))
|
|
1572
|
+
if not cmd:
|
|
1573
|
+
raise ToolFailure("INVALID_ARGUMENT", "cmd is required.", category="validation")
|
|
1574
|
+
workdir = self.resolve_existing(str(args.get("workdir", ".")))
|
|
1575
|
+
if not workdir.path.is_dir():
|
|
1576
|
+
raise ToolFailure("NOT_A_DIRECTORY", "workdir is not a directory.", category="validation")
|
|
1577
|
+
self._check_command_policy(cmd, args)
|
|
1578
|
+
timeout_ms = int(args.get("timeout_ms", 30000))
|
|
1579
|
+
yield_ms = int(args.get("yield_time_ms", 1000))
|
|
1580
|
+
max_output_bytes = int(args.get("max_output_bytes", 65536))
|
|
1581
|
+
tty = bool(args.get("tty", False))
|
|
1582
|
+
stdin_text = str(args.get("stdin", ""))
|
|
1583
|
+
env = self._command_env(args.get("env", {}))
|
|
1584
|
+
start = time.time()
|
|
1585
|
+
deadline = start + (timeout_ms / 1000.0)
|
|
1586
|
+
landlock_fd: int | None = None
|
|
1587
|
+
landlock_warning: str | None = None
|
|
1588
|
+
popen_cmd: Any = cmd
|
|
1589
|
+
popen_shell = True
|
|
1590
|
+
popen_extra = process_group_popen_kwargs()
|
|
1591
|
+
try:
|
|
1592
|
+
landlock_fd = open_landlock_ruleset(self.workspace.root, guard_allow_roots())
|
|
1593
|
+
popen_cmd = landlock_exec_argv(landlock_fd, cmd)
|
|
1594
|
+
popen_shell = False
|
|
1595
|
+
popen_extra["pass_fds"] = (landlock_fd,)
|
|
1596
|
+
except ToolFailure as exc:
|
|
1597
|
+
if exc.code != "SANDBOX_UNAVAILABLE":
|
|
1598
|
+
raise
|
|
1599
|
+
landlock_warning = landlock_unavailable_warning(exc)
|
|
1600
|
+
try:
|
|
1601
|
+
process = subprocess.Popen(
|
|
1602
|
+
popen_cmd,
|
|
1603
|
+
cwd=str(workdir.path),
|
|
1604
|
+
shell=popen_shell,
|
|
1605
|
+
stdin=subprocess.PIPE,
|
|
1606
|
+
stdout=subprocess.PIPE,
|
|
1607
|
+
stderr=subprocess.PIPE,
|
|
1608
|
+
env=env,
|
|
1609
|
+
**popen_extra,
|
|
1610
|
+
)
|
|
1611
|
+
finally:
|
|
1612
|
+
if landlock_fd is not None:
|
|
1613
|
+
try:
|
|
1614
|
+
os.close(landlock_fd)
|
|
1615
|
+
except OSError:
|
|
1616
|
+
pass
|
|
1617
|
+
session = self._make_session(
|
|
1618
|
+
process,
|
|
1619
|
+
timeout_at=deadline,
|
|
1620
|
+
warnings=[landlock_warning] if landlock_warning else None,
|
|
1621
|
+
)
|
|
1622
|
+
start_reader_threads(session)
|
|
1623
|
+
start_session_watchdog(session)
|
|
1624
|
+
if process.stdin is not None:
|
|
1625
|
+
try:
|
|
1626
|
+
if stdin_text:
|
|
1627
|
+
process.stdin.write(stdin_text.encode("utf-8"))
|
|
1628
|
+
process.stdin.flush()
|
|
1629
|
+
except BrokenPipeError:
|
|
1630
|
+
pass
|
|
1631
|
+
finally:
|
|
1632
|
+
if not tty:
|
|
1633
|
+
try:
|
|
1634
|
+
process.stdin.close()
|
|
1635
|
+
except OSError:
|
|
1636
|
+
pass
|
|
1637
|
+
initial_wait = max(0, min(yield_ms, 30000)) / 1000.0
|
|
1638
|
+
while True:
|
|
1639
|
+
if process.poll() is not None:
|
|
1640
|
+
session.refresh_status()
|
|
1641
|
+
session.drain_readers()
|
|
1642
|
+
payload = session.snapshot_since_cursor(max_output_bytes)
|
|
1643
|
+
payload.update(
|
|
1644
|
+
{
|
|
1645
|
+
"status": "timeout" if session.timed_out else "exited",
|
|
1646
|
+
"elapsed_ms": int((time.time() - start) * 1000),
|
|
1647
|
+
}
|
|
1648
|
+
)
|
|
1649
|
+
return payload
|
|
1650
|
+
now = time.time()
|
|
1651
|
+
if not tty and now >= deadline:
|
|
1652
|
+
session.timed_out = True
|
|
1653
|
+
self._terminate_process_group(process, signal.SIGTERM)
|
|
1654
|
+
session.refresh_status()
|
|
1655
|
+
session.drain_readers()
|
|
1656
|
+
payload = session.snapshot_since_cursor(max_output_bytes)
|
|
1657
|
+
payload.update(
|
|
1658
|
+
{
|
|
1659
|
+
"status": "timeout",
|
|
1660
|
+
"timed_out": True,
|
|
1661
|
+
"elapsed_ms": int((time.time() - start) * 1000),
|
|
1662
|
+
}
|
|
1663
|
+
)
|
|
1664
|
+
return payload
|
|
1665
|
+
with session.lock:
|
|
1666
|
+
tty_has_initial_output = (
|
|
1667
|
+
len(session.stdout) > session.stdout_cursor
|
|
1668
|
+
or len(session.stderr) > session.stderr_cursor
|
|
1669
|
+
)
|
|
1670
|
+
if now - start >= initial_wait or (tty and tty_has_initial_output):
|
|
1671
|
+
with self.sessions_lock:
|
|
1672
|
+
self.sessions[session.session_id] = session
|
|
1673
|
+
payload = session.snapshot_since_cursor(max_output_bytes)
|
|
1674
|
+
payload.update(
|
|
1675
|
+
{
|
|
1676
|
+
"status": "running",
|
|
1677
|
+
"elapsed_ms": int((time.time() - start) * 1000),
|
|
1678
|
+
}
|
|
1679
|
+
)
|
|
1680
|
+
return payload
|
|
1681
|
+
time.sleep(0.02)
|
|
1682
|
+
|
|
1683
|
+
def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None:
|
|
1684
|
+
self._check_command_paths(cmd)
|
|
1685
|
+
if self.dangerously_skip_all_permissions:
|
|
1686
|
+
return
|
|
1687
|
+
env = args.get("env", {})
|
|
1688
|
+
if isinstance(env, dict) and any(
|
|
1689
|
+
SENSITIVE_ENV_RE.search(str(key))
|
|
1690
|
+
or str(key).upper() in RISKY_ENV_NAMES
|
|
1691
|
+
or SENSITIVE_VALUE_RE.search(str(value))
|
|
1692
|
+
for key, value in env.items()
|
|
1693
|
+
):
|
|
1694
|
+
raise ToolFailure(
|
|
1695
|
+
"PERMISSION_REQUIRED",
|
|
1696
|
+
"Sensitive or loader/startup environment variables require explicit permission.",
|
|
1697
|
+
category="permission",
|
|
1698
|
+
details={"permission": "sensitive_env", "env_keys": sorted(str(key) for key in env)},
|
|
1699
|
+
)
|
|
1700
|
+
inline_script = inline_script_command(cmd)
|
|
1701
|
+
if inline_script is not None:
|
|
1702
|
+
raise ToolFailure(
|
|
1703
|
+
"PERMISSION_REQUIRED",
|
|
1704
|
+
"Inline interpreter or shell code requires explicit permission because network and filesystem effects cannot be verified statically.",
|
|
1705
|
+
category="permission",
|
|
1706
|
+
details={"permission": INLINE_SCRIPT_PERMISSION, **inline_script},
|
|
1707
|
+
)
|
|
1708
|
+
compact = " ".join(cmd.split()).lower()
|
|
1709
|
+
if SHELL_EXPANSION_RE.search(cmd):
|
|
1710
|
+
raise ToolFailure(
|
|
1711
|
+
"PERMISSION_REQUIRED",
|
|
1712
|
+
"Shell command substitution and parameter expansion require explicit permission.",
|
|
1713
|
+
category="permission",
|
|
1714
|
+
details={"permission": "shell_expansion", "command": compact},
|
|
1715
|
+
)
|
|
1716
|
+
if re.search(r"(^|[;&|]\s*)rm\s+(-[^\s]*r[^\s]*f|-?[^\s]*f[^\s]*r)\s+/", compact):
|
|
1717
|
+
raise ToolFailure(
|
|
1718
|
+
"PERMISSION_REQUIRED",
|
|
1719
|
+
"Destructive commands are blocked without explicit permission.",
|
|
1720
|
+
category="permission",
|
|
1721
|
+
details={"permission": "destructive_command", "command": compact},
|
|
1722
|
+
)
|
|
1723
|
+
if DESTRUCTIVE_RE.search(cmd):
|
|
1724
|
+
raise ToolFailure(
|
|
1725
|
+
"PERMISSION_REQUIRED",
|
|
1726
|
+
"Destructive commands are blocked without explicit permission.",
|
|
1727
|
+
category="permission",
|
|
1728
|
+
details={"permission": "destructive_command", "command": compact},
|
|
1729
|
+
)
|
|
1730
|
+
if NETWORK_RE.search(cmd) and not is_literal_network_reference_command(cmd):
|
|
1731
|
+
raise ToolFailure(
|
|
1732
|
+
"PERMISSION_REQUIRED",
|
|
1733
|
+
"Network access is denied by default.",
|
|
1734
|
+
category="permission",
|
|
1735
|
+
details={"permission": "network", "command": compact},
|
|
1736
|
+
)
|
|
1737
|
+
|
|
1738
|
+
def _check_command_paths(self, cmd: str) -> None:
|
|
1739
|
+
try:
|
|
1740
|
+
tokens = shlex_split(cmd)
|
|
1741
|
+
except ValueError:
|
|
1742
|
+
tokens = cmd.split()
|
|
1743
|
+
for executable in command_executables(tokens):
|
|
1744
|
+
self._reject_setuid_executable(executable)
|
|
1745
|
+
for candidate in explicit_command_path_candidates(tokens):
|
|
1746
|
+
self._check_command_path_candidate(candidate)
|
|
1747
|
+
|
|
1748
|
+
def _check_command_path_candidate(self, candidate: str) -> None:
|
|
1749
|
+
candidate = candidate.strip()
|
|
1750
|
+
if not candidate or candidate in {"-", "--"}:
|
|
1751
|
+
return
|
|
1752
|
+
if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", candidate):
|
|
1753
|
+
return
|
|
1754
|
+
normalized = candidate.replace("\\", "/")
|
|
1755
|
+
if (
|
|
1756
|
+
normalized.startswith("/")
|
|
1757
|
+
or normalized.startswith("~")
|
|
1758
|
+
or re.match(r"^[A-Za-z]:/", normalized)
|
|
1759
|
+
or any(part == ".." for part in PurePosixPath(normalized).parts)
|
|
1760
|
+
):
|
|
1761
|
+
raise ToolFailure(
|
|
1762
|
+
"PERMISSION_REQUIRED",
|
|
1763
|
+
"Command path escapes the workspace and is blocked.",
|
|
1764
|
+
category="permission",
|
|
1765
|
+
details={"permission": "filesystem_escape", "path": candidate},
|
|
1766
|
+
)
|
|
1767
|
+
try:
|
|
1768
|
+
self.workspace.resolve_existing(normalized)
|
|
1769
|
+
except OSError as exc:
|
|
1770
|
+
raise ToolFailure(
|
|
1771
|
+
"INVALID_ARGUMENT",
|
|
1772
|
+
"Command path could not be inspected safely.",
|
|
1773
|
+
category="validation",
|
|
1774
|
+
details={"path": candidate[:200], "errno": exc.errno, "reason": exc.strerror},
|
|
1775
|
+
) from exc
|
|
1776
|
+
except ToolFailure as exc:
|
|
1777
|
+
if exc.code == "NOT_FOUND":
|
|
1778
|
+
try:
|
|
1779
|
+
self.workspace.resolve_for_write(normalized)
|
|
1780
|
+
except ToolFailure as write_exc:
|
|
1781
|
+
if write_exc.code == "NOT_FOUND":
|
|
1782
|
+
return
|
|
1783
|
+
if write_exc.code in {"PATH_OUTSIDE_WORKSPACE", "ABSOLUTE_PATH_DENIED", "SYMLINK_ESCAPE"}:
|
|
1784
|
+
raise ToolFailure(
|
|
1785
|
+
"PERMISSION_REQUIRED",
|
|
1786
|
+
"Command path escapes the workspace and is blocked.",
|
|
1787
|
+
category="permission",
|
|
1788
|
+
details={"permission": "filesystem_escape", "path": candidate},
|
|
1789
|
+
) from write_exc
|
|
1790
|
+
raise
|
|
1791
|
+
return
|
|
1792
|
+
if exc.code in {"PATH_OUTSIDE_WORKSPACE", "ABSOLUTE_PATH_DENIED", "SYMLINK_ESCAPE"}:
|
|
1793
|
+
raise ToolFailure(
|
|
1794
|
+
"PERMISSION_REQUIRED",
|
|
1795
|
+
"Command path escapes the workspace and is blocked.",
|
|
1796
|
+
category="permission",
|
|
1797
|
+
details={"permission": "filesystem_escape", "path": candidate},
|
|
1798
|
+
) from exc
|
|
1799
|
+
|
|
1800
|
+
def _reject_setuid_executable(self, executable: str) -> None:
|
|
1801
|
+
if not executable:
|
|
1802
|
+
return
|
|
1803
|
+
executable_path = Path(executable) if "/" in executable else Path(shutil.which(executable) or "")
|
|
1804
|
+
if not str(executable_path):
|
|
1805
|
+
return
|
|
1806
|
+
try:
|
|
1807
|
+
stat = executable_path.stat()
|
|
1808
|
+
except OSError:
|
|
1809
|
+
return
|
|
1810
|
+
if stat.st_mode & 0o6000:
|
|
1811
|
+
raise ToolFailure(
|
|
1812
|
+
"PERMISSION_REQUIRED",
|
|
1813
|
+
"Setuid/setgid executables are denied because they can bypass runtime process guards.",
|
|
1814
|
+
category="permission",
|
|
1815
|
+
details={"permission": "privileged_executable", "path": str(executable_path)},
|
|
1816
|
+
)
|
|
1817
|
+
|
|
1818
|
+
def _command_env(self, extra: Any) -> dict[str, str]:
|
|
1819
|
+
env: dict[str, str] = {}
|
|
1820
|
+
for key in ("PATH", "LANG", "LC_ALL"):
|
|
1821
|
+
if key in os.environ:
|
|
1822
|
+
env[key] = os.environ[key]
|
|
1823
|
+
env["HOME"] = str(self.workspace.root)
|
|
1824
|
+
env["TMPDIR"] = str(self.workspace.root / ".tmp")
|
|
1825
|
+
(self.workspace.root / ".tmp").mkdir(exist_ok=True)
|
|
1826
|
+
if isinstance(extra, dict):
|
|
1827
|
+
for key, value in extra.items():
|
|
1828
|
+
key_text = str(key)
|
|
1829
|
+
value_text = str(value)
|
|
1830
|
+
if not self.dangerously_skip_all_permissions and (
|
|
1831
|
+
SENSITIVE_ENV_RE.search(key_text)
|
|
1832
|
+
or key_text.upper() in RISKY_ENV_NAMES
|
|
1833
|
+
or SENSITIVE_VALUE_RE.search(value_text)
|
|
1834
|
+
):
|
|
1835
|
+
continue
|
|
1836
|
+
env[key_text] = value_text
|
|
1837
|
+
return env
|
|
1838
|
+
|
|
1839
|
+
def _make_session(
|
|
1840
|
+
self,
|
|
1841
|
+
process: subprocess.Popen[bytes],
|
|
1842
|
+
*,
|
|
1843
|
+
timeout_at: float | None = None,
|
|
1844
|
+
warnings: list[str] | None = None,
|
|
1845
|
+
) -> ExecSession:
|
|
1846
|
+
return ExecSession(
|
|
1847
|
+
session_id=secrets.token_urlsafe(18),
|
|
1848
|
+
process=process,
|
|
1849
|
+
timeout_at=timeout_at,
|
|
1850
|
+
warnings=warnings or [],
|
|
1851
|
+
)
|
|
1852
|
+
|
|
1853
|
+
def write_stdin(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
1854
|
+
session_id = str(args.get("session_id", ""))
|
|
1855
|
+
session = self._get_session(session_id)
|
|
1856
|
+
session.refresh_status()
|
|
1857
|
+
chars = str(args.get("chars", ""))
|
|
1858
|
+
if session.process.poll() is not None:
|
|
1859
|
+
if chars:
|
|
1860
|
+
raise ToolFailure("SESSION_CLOSED", "Session is closed; stdin write blocked.", category="runtime")
|
|
1861
|
+
return session.snapshot_since_cursor(int(args.get("max_output_bytes", 65536)))
|
|
1862
|
+
if chars:
|
|
1863
|
+
if session.process.stdin is None or session.process.stdin.closed:
|
|
1864
|
+
raise ToolFailure("SESSION_CLOSED", "Session stdin is closed.", category="runtime")
|
|
1865
|
+
try:
|
|
1866
|
+
session.process.stdin.write(chars.encode("utf-8"))
|
|
1867
|
+
session.process.stdin.flush()
|
|
1868
|
+
except (BrokenPipeError, ValueError) as exc:
|
|
1869
|
+
raise ToolFailure("SESSION_CLOSED", "Session stdin is closed.", category="runtime") from exc
|
|
1870
|
+
wait_until = time.time() + (int(args.get("yield_time_ms", 1000)) / 1000.0)
|
|
1871
|
+
first_output_at: float | None = None
|
|
1872
|
+
while time.time() < wait_until and session.process.poll() is None:
|
|
1873
|
+
time.sleep(0.02)
|
|
1874
|
+
with session.lock:
|
|
1875
|
+
has_new_output = len(session.stdout) > session.stdout_cursor or len(session.stderr) > session.stderr_cursor
|
|
1876
|
+
if has_new_output and not chars:
|
|
1877
|
+
break
|
|
1878
|
+
if has_new_output and chars:
|
|
1879
|
+
if first_output_at is None:
|
|
1880
|
+
first_output_at = time.time()
|
|
1881
|
+
if time.time() - first_output_at >= 0.05:
|
|
1882
|
+
break
|
|
1883
|
+
return session.snapshot_since_cursor(int(args.get("max_output_bytes", 65536)))
|
|
1884
|
+
|
|
1885
|
+
def kill_session(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
1886
|
+
session_id = str(args.get("session_id", ""))
|
|
1887
|
+
session = self._get_session(session_id)
|
|
1888
|
+
signal_name = str(args.get("signal", "TERM"))
|
|
1889
|
+
signum = {"TERM": signal.SIGTERM, "KILL": signal.SIGKILL, "INT": signal.SIGINT}.get(signal_name, signal.SIGTERM)
|
|
1890
|
+
if session.process.poll() is None:
|
|
1891
|
+
self._terminate_process_group(session.process, signum)
|
|
1892
|
+
wait_until = time.time() + (int(args.get("wait_ms", 5000)) / 1000.0)
|
|
1893
|
+
while time.time() < wait_until and session.process.poll() is None:
|
|
1894
|
+
time.sleep(0.02)
|
|
1895
|
+
killed = True
|
|
1896
|
+
status = "terminated" if session.process.poll() is not None else "terminated"
|
|
1897
|
+
else:
|
|
1898
|
+
killed = False
|
|
1899
|
+
status = "exited"
|
|
1900
|
+
payload = session.snapshot_since_cursor(int(args.get("max_output_bytes", 65536)))
|
|
1901
|
+
payload.update({"killed": killed, "status": status})
|
|
1902
|
+
with self.sessions_lock:
|
|
1903
|
+
self.sessions.pop(session_id, None)
|
|
1904
|
+
return payload
|
|
1905
|
+
|
|
1906
|
+
def cancel_session(self, session_id: str) -> None:
|
|
1907
|
+
with self.sessions_lock:
|
|
1908
|
+
session = self.sessions.pop(session_id, None)
|
|
1909
|
+
if session is None:
|
|
1910
|
+
return
|
|
1911
|
+
session.refresh_status()
|
|
1912
|
+
if session.process.poll() is None:
|
|
1913
|
+
self._terminate_process_group(session.process, signal.SIGTERM)
|
|
1914
|
+
|
|
1915
|
+
def _get_session(self, session_id: str) -> ExecSession:
|
|
1916
|
+
with self.sessions_lock:
|
|
1917
|
+
session = self.sessions.get(session_id)
|
|
1918
|
+
if session is None:
|
|
1919
|
+
raise ToolFailure("SESSION_NOT_FOUND", "Session not found; stdin access denied.", category="not_found")
|
|
1920
|
+
return session
|
|
1921
|
+
|
|
1922
|
+
def _terminate_process_group(self, process: subprocess.Popen[bytes], signum: signal.Signals) -> None:
|
|
1923
|
+
terminate_process_group(process, signum)
|
|
1924
|
+
|
|
1925
|
+
def git_status(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
1926
|
+
resolved = self.resolve_existing(str(args.get("path", ".")))
|
|
1927
|
+
max_entries = int(args.get("max_entries", 1000))
|
|
1928
|
+
include_untracked = bool(args.get("include_untracked", True))
|
|
1929
|
+
git = require_git()
|
|
1930
|
+
root_check = subprocess.run(
|
|
1931
|
+
[git, "-C", str(resolved.path), "rev-parse", "--show-toplevel"],
|
|
1932
|
+
text=True,
|
|
1933
|
+
stdout=subprocess.PIPE,
|
|
1934
|
+
stderr=subprocess.PIPE,
|
|
1935
|
+
)
|
|
1936
|
+
if root_check.returncode != 0:
|
|
1937
|
+
return {"is_repo": False, "clean": True, "entries": [], "truncated": False}
|
|
1938
|
+
status_cmd = [git, "-C", str(resolved.path), "status", "--porcelain=v1", "-b"]
|
|
1939
|
+
if not include_untracked:
|
|
1940
|
+
status_cmd.append("--untracked-files=no")
|
|
1941
|
+
completed = subprocess.run(status_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10)
|
|
1942
|
+
if completed.returncode != 0:
|
|
1943
|
+
raise ToolFailure("GIT_ERROR", completed.stderr.strip() or "git status failed", category="runtime")
|
|
1944
|
+
lines = completed.stdout.splitlines()
|
|
1945
|
+
branch = ""
|
|
1946
|
+
upstream = ""
|
|
1947
|
+
ahead = 0
|
|
1948
|
+
behind = 0
|
|
1949
|
+
entries: list[dict[str, Any]] = []
|
|
1950
|
+
for line in lines:
|
|
1951
|
+
if line.startswith("## "):
|
|
1952
|
+
branch, upstream, ahead, behind = parse_branch_line(line[3:])
|
|
1953
|
+
continue
|
|
1954
|
+
if not line:
|
|
1955
|
+
continue
|
|
1956
|
+
path_text = line[3:]
|
|
1957
|
+
original = None
|
|
1958
|
+
if " -> " in path_text:
|
|
1959
|
+
original, path_text = path_text.split(" -> ", 1)
|
|
1960
|
+
entries.append(
|
|
1961
|
+
{
|
|
1962
|
+
"path": path_text,
|
|
1963
|
+
"original_path": original,
|
|
1964
|
+
"index_status": line[0],
|
|
1965
|
+
"worktree_status": line[1],
|
|
1966
|
+
}
|
|
1967
|
+
)
|
|
1968
|
+
if len(entries) >= max_entries:
|
|
1969
|
+
break
|
|
1970
|
+
return {
|
|
1971
|
+
"is_repo": True,
|
|
1972
|
+
"branch": branch,
|
|
1973
|
+
"head": git_rev_parse(resolved.path, "HEAD"),
|
|
1974
|
+
"upstream": upstream,
|
|
1975
|
+
"ahead": ahead,
|
|
1976
|
+
"behind": behind,
|
|
1977
|
+
"clean": not entries,
|
|
1978
|
+
"entries": entries,
|
|
1979
|
+
"truncated": len(entries) >= max_entries and len(lines) > max_entries + 1,
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
def git_diff(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
1983
|
+
git = require_git()
|
|
1984
|
+
staged = bool(args.get("staged", False))
|
|
1985
|
+
unstaged = bool(args.get("unstaged", True))
|
|
1986
|
+
context = int(args.get("context_lines", 3))
|
|
1987
|
+
max_bytes = int(args.get("max_bytes", 262144))
|
|
1988
|
+
path_filters: list[str] = []
|
|
1989
|
+
if isinstance(args.get("path"), str):
|
|
1990
|
+
path_filters.append(str(args["path"]))
|
|
1991
|
+
if isinstance(args.get("paths"), list):
|
|
1992
|
+
path_filters.extend(str(item) for item in args["paths"])
|
|
1993
|
+
path_filters = [self.git_path_filter(path) for path in path_filters]
|
|
1994
|
+
if not is_git_repo(self.workspace.root):
|
|
1995
|
+
return self._fallback_diff(path_filters, max_bytes)
|
|
1996
|
+
chunks: list[bytes] = []
|
|
1997
|
+
if unstaged:
|
|
1998
|
+
chunks.append(self._run_git_diff(git, context, path_filters, cached=False))
|
|
1999
|
+
if staged:
|
|
2000
|
+
chunks.append(self._run_git_diff(git, context, path_filters, cached=True))
|
|
2001
|
+
combined = b""
|
|
2002
|
+
for chunk in chunks:
|
|
2003
|
+
if combined and chunk and not combined.endswith(b"\n"):
|
|
2004
|
+
combined += b"\n"
|
|
2005
|
+
combined += chunk
|
|
2006
|
+
diff_truncation = truncate_text_head(combined.decode("utf-8", errors="replace"), max_lines=DEFAULT_MAX_LINES, max_bytes=max_bytes)
|
|
2007
|
+
diff_text = diff_truncation.content
|
|
2008
|
+
truncated = diff_truncation.truncated
|
|
2009
|
+
return {
|
|
2010
|
+
"diff": diff_text,
|
|
2011
|
+
"files": parse_diff_files(diff_text),
|
|
2012
|
+
"truncated": truncated,
|
|
2013
|
+
"truncated_by": diff_truncation.truncated_by,
|
|
2014
|
+
"output_lines": diff_truncation.output_lines,
|
|
2015
|
+
"output_bytes": diff_truncation.output_bytes,
|
|
2016
|
+
"warnings": ["diff truncated"] if truncated else [],
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
def _run_git_diff(self, git: str, context: int, path_filters: list[str], *, cached: bool) -> bytes:
|
|
2020
|
+
cmd = [git, "-C", str(self.workspace.root), "diff", f"--unified={context}"]
|
|
2021
|
+
if cached:
|
|
2022
|
+
cmd.append("--cached")
|
|
2023
|
+
if path_filters:
|
|
2024
|
+
cmd.append("--")
|
|
2025
|
+
cmd.extend(path_filters)
|
|
2026
|
+
completed = subprocess.run(cmd, text=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10)
|
|
2027
|
+
if completed.returncode not in {0, 1}:
|
|
2028
|
+
raise ToolFailure("GIT_ERROR", completed.stderr.decode("utf-8", errors="replace"), category="runtime")
|
|
2029
|
+
return completed.stdout
|
|
2030
|
+
|
|
2031
|
+
def _fallback_diff(self, path_filters: list[str], max_bytes: int) -> dict[str, Any]:
|
|
2032
|
+
selected = set(path_filters)
|
|
2033
|
+
chunks: list[str] = []
|
|
2034
|
+
files: list[dict[str, Any]] = []
|
|
2035
|
+
for rel, before in sorted(self.patch_baselines.items()):
|
|
2036
|
+
if selected and rel not in selected:
|
|
2037
|
+
continue
|
|
2038
|
+
current_path = self.workspace.resolve_for_write(rel).path
|
|
2039
|
+
after = read_text_preserve_newlines(current_path) if current_path.exists() and not current_path.is_dir() else None
|
|
2040
|
+
if before == after:
|
|
2041
|
+
continue
|
|
2042
|
+
before_lines = [] if before is None else before.splitlines(keepends=True)
|
|
2043
|
+
after_lines = [] if after is None else after.splitlines(keepends=True)
|
|
2044
|
+
chunks.extend(
|
|
2045
|
+
difflib.unified_diff(
|
|
2046
|
+
before_lines,
|
|
2047
|
+
after_lines,
|
|
2048
|
+
fromfile=f"a/{rel}",
|
|
2049
|
+
tofile=f"b/{rel}",
|
|
2050
|
+
lineterm="",
|
|
2051
|
+
)
|
|
2052
|
+
)
|
|
2053
|
+
status = "added" if before is None else "deleted" if after is None else "modified"
|
|
2054
|
+
files.append({"path": rel, "status": status, "binary": False})
|
|
2055
|
+
diff = "\n".join(chunks)
|
|
2056
|
+
if diff and not diff.endswith("\n"):
|
|
2057
|
+
diff += "\n"
|
|
2058
|
+
diff_truncation = truncate_text_head(diff, max_lines=DEFAULT_MAX_LINES, max_bytes=max_bytes)
|
|
2059
|
+
diff_text = diff_truncation.content
|
|
2060
|
+
truncated = diff_truncation.truncated
|
|
2061
|
+
return {
|
|
2062
|
+
"diff": diff_text,
|
|
2063
|
+
"files": files,
|
|
2064
|
+
"truncated": truncated,
|
|
2065
|
+
"truncated_by": diff_truncation.truncated_by,
|
|
2066
|
+
"output_lines": diff_truncation.output_lines,
|
|
2067
|
+
"output_bytes": diff_truncation.output_bytes,
|
|
2068
|
+
"warnings": ["non-git diff fallback"] + (["diff truncated"] if truncated else []),
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
def git_log(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
2072
|
+
git = require_git()
|
|
2073
|
+
resolved = self.resolve_existing(str(args.get("path", ".")))
|
|
2074
|
+
if not is_git_repo(resolved.path):
|
|
2075
|
+
return {"is_repo": False, "commits": [], "truncated": False, "warnings": []}
|
|
2076
|
+
ref = validate_git_ref(str(args.get("ref", "HEAD")))
|
|
2077
|
+
max_count = int(args.get("max_count", 20))
|
|
2078
|
+
skip = int(args.get("skip", 0))
|
|
2079
|
+
path_filter = resolved.display
|
|
2080
|
+
cmd = [
|
|
2081
|
+
git,
|
|
2082
|
+
"-C",
|
|
2083
|
+
str(self.workspace.root),
|
|
2084
|
+
"log",
|
|
2085
|
+
f"--max-count={max_count + 1}",
|
|
2086
|
+
f"--skip={skip}",
|
|
2087
|
+
"--date=iso-strict",
|
|
2088
|
+
"--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%ad%x1f%s%x1e",
|
|
2089
|
+
ref,
|
|
2090
|
+
]
|
|
2091
|
+
if path_filter != ".":
|
|
2092
|
+
cmd.extend(["--", path_filter])
|
|
2093
|
+
completed = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10)
|
|
2094
|
+
if completed.returncode != 0:
|
|
2095
|
+
raise ToolFailure("GIT_ERROR", completed.stderr.strip() or "git log failed", category="runtime")
|
|
2096
|
+
commits: list[dict[str, Any]] = []
|
|
2097
|
+
for record in completed.stdout.split("\x1e"):
|
|
2098
|
+
fields = record.strip("\n").split("\x1f")
|
|
2099
|
+
if len(fields) < 6 or not fields[0]:
|
|
2100
|
+
continue
|
|
2101
|
+
commits.append(
|
|
2102
|
+
{
|
|
2103
|
+
"hash": fields[0],
|
|
2104
|
+
"short_hash": fields[1],
|
|
2105
|
+
"author_name": fields[2],
|
|
2106
|
+
"author_email": fields[3],
|
|
2107
|
+
"author_date": fields[4],
|
|
2108
|
+
"subject": fields[5],
|
|
2109
|
+
}
|
|
2110
|
+
)
|
|
2111
|
+
truncated = len(commits) > max_count
|
|
2112
|
+
return {
|
|
2113
|
+
"is_repo": True,
|
|
2114
|
+
"ref": ref,
|
|
2115
|
+
"path": path_filter,
|
|
2116
|
+
"commits": commits[:max_count],
|
|
2117
|
+
"truncated": truncated,
|
|
2118
|
+
"warnings": ["commit limit reached"] if truncated else [],
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
def git_show(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
2122
|
+
git = require_git()
|
|
2123
|
+
if not is_git_repo(self.workspace.root):
|
|
2124
|
+
return {"is_repo": False, "content": "", "files": [], "truncated": False, "warnings": []}
|
|
2125
|
+
rev = validate_git_ref(str(args.get("rev", "HEAD")))
|
|
2126
|
+
context = int(args.get("context_lines", 3))
|
|
2127
|
+
max_bytes = int(args.get("max_bytes", 262144))
|
|
2128
|
+
include_diff = bool(args.get("include_diff", True))
|
|
2129
|
+
path_filters: list[str] = []
|
|
2130
|
+
if isinstance(args.get("path"), str):
|
|
2131
|
+
path_filters.append(str(args["path"]))
|
|
2132
|
+
if isinstance(args.get("paths"), list):
|
|
2133
|
+
path_filters.extend(str(item) for item in args["paths"])
|
|
2134
|
+
normalized_filters = [self.git_path_filter(path) for path in path_filters]
|
|
2135
|
+
cmd = [
|
|
2136
|
+
git,
|
|
2137
|
+
"-C",
|
|
2138
|
+
str(self.workspace.root),
|
|
2139
|
+
"show",
|
|
2140
|
+
"--no-ext-diff",
|
|
2141
|
+
"--format=fuller",
|
|
2142
|
+
f"--unified={context}",
|
|
2143
|
+
]
|
|
2144
|
+
if not include_diff:
|
|
2145
|
+
cmd.append("--no-patch")
|
|
2146
|
+
cmd.append(rev)
|
|
2147
|
+
if normalized_filters:
|
|
2148
|
+
cmd.append("--")
|
|
2149
|
+
cmd.extend(normalized_filters)
|
|
2150
|
+
completed = subprocess.run(cmd, text=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10)
|
|
2151
|
+
if completed.returncode != 0:
|
|
2152
|
+
raise ToolFailure("GIT_ERROR", completed.stderr.decode("utf-8", errors="replace").strip() or "git show failed", category="runtime")
|
|
2153
|
+
truncation = truncate_text_head(completed.stdout.decode("utf-8", errors="replace"), max_lines=DEFAULT_MAX_LINES, max_bytes=max_bytes)
|
|
2154
|
+
content = truncation.content
|
|
2155
|
+
return {
|
|
2156
|
+
"is_repo": True,
|
|
2157
|
+
"rev": rev,
|
|
2158
|
+
"content": content,
|
|
2159
|
+
"files": parse_diff_files(content),
|
|
2160
|
+
"truncated": truncation.truncated,
|
|
2161
|
+
"truncated_by": truncation.truncated_by,
|
|
2162
|
+
"output_lines": truncation.output_lines,
|
|
2163
|
+
"output_bytes": truncation.output_bytes,
|
|
2164
|
+
"warnings": ["output truncated"] if truncation.truncated else [],
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
def git_blame(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
2168
|
+
git = require_git()
|
|
2169
|
+
resolved = self.resolve_existing(str(args.get("path", "")))
|
|
2170
|
+
if resolved.path.is_dir():
|
|
2171
|
+
raise ToolFailure("IS_DIRECTORY", "Path is a directory.", category="validation")
|
|
2172
|
+
if not is_git_repo(self.workspace.root):
|
|
2173
|
+
return {"is_repo": False, "path": resolved.display, "lines": [], "truncated": False, "warnings": []}
|
|
2174
|
+
ref_arg = args.get("rev")
|
|
2175
|
+
ref = validate_git_ref(str(ref_arg)) if isinstance(ref_arg, str) and ref_arg else None
|
|
2176
|
+
start_line = int(args.get("start_line", 1))
|
|
2177
|
+
end_line = args.get("end_line")
|
|
2178
|
+
max_lines = int(args.get("max_lines", 200))
|
|
2179
|
+
if end_line is None:
|
|
2180
|
+
final_line = start_line + max_lines - 1
|
|
2181
|
+
else:
|
|
2182
|
+
final_line = int(end_line)
|
|
2183
|
+
if final_line < start_line:
|
|
2184
|
+
raise ToolFailure("INVALID_ARGUMENT", "end_line must be >= start_line.", category="validation")
|
|
2185
|
+
requested_lines = final_line - start_line + 1
|
|
2186
|
+
truncated = requested_lines > max_lines
|
|
2187
|
+
final_line = min(final_line, start_line + max_lines - 1)
|
|
2188
|
+
cmd = [
|
|
2189
|
+
git,
|
|
2190
|
+
"-C",
|
|
2191
|
+
str(self.workspace.root),
|
|
2192
|
+
"blame",
|
|
2193
|
+
"--line-porcelain",
|
|
2194
|
+
"-L",
|
|
2195
|
+
f"{start_line},{final_line}",
|
|
2196
|
+
]
|
|
2197
|
+
if ref:
|
|
2198
|
+
cmd.append(ref)
|
|
2199
|
+
cmd.extend(["--", resolved.display])
|
|
2200
|
+
completed = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10)
|
|
2201
|
+
if completed.returncode != 0:
|
|
2202
|
+
raise ToolFailure("GIT_ERROR", completed.stderr.strip() or "git blame failed", category="runtime")
|
|
2203
|
+
lines = parse_git_blame_porcelain(completed.stdout)
|
|
2204
|
+
if len(lines) > max_lines:
|
|
2205
|
+
lines = lines[:max_lines]
|
|
2206
|
+
truncated = True
|
|
2207
|
+
return {
|
|
2208
|
+
"is_repo": True,
|
|
2209
|
+
"path": resolved.display,
|
|
2210
|
+
"rev": ref,
|
|
2211
|
+
"start_line": start_line,
|
|
2212
|
+
"end_line": final_line,
|
|
2213
|
+
"lines": lines,
|
|
2214
|
+
"truncated": truncated,
|
|
2215
|
+
"warnings": ["line limit reached"] if truncated else [],
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
def request_permissions(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
2219
|
+
if self.dangerously_skip_all_permissions:
|
|
2220
|
+
return {
|
|
2221
|
+
"ok": True,
|
|
2222
|
+
"status": "granted",
|
|
2223
|
+
"grant_id": "dangerously-skip-all-permissions",
|
|
2224
|
+
"expires_at": None,
|
|
2225
|
+
"constraints": {
|
|
2226
|
+
"mode": "dangerously_skip_all_permissions",
|
|
2227
|
+
"workspace": str(self.workspace.root),
|
|
2228
|
+
"requested": args,
|
|
2229
|
+
},
|
|
2230
|
+
"warnings": [
|
|
2231
|
+
"dangerously-skip-all-permissions is enabled; permission-gated operations are auto-granted"
|
|
2232
|
+
],
|
|
2233
|
+
}
|
|
2234
|
+
return {
|
|
2235
|
+
"ok": False,
|
|
2236
|
+
"status": "unsupported",
|
|
2237
|
+
"grant_id": None,
|
|
2238
|
+
"expires_at": None,
|
|
2239
|
+
"error": {
|
|
2240
|
+
"code": "ELICITATION_UNSUPPORTED",
|
|
2241
|
+
"message": "Permission elicitation is not available for this client.",
|
|
2242
|
+
"category": "permission",
|
|
2243
|
+
"retryable": False,
|
|
2244
|
+
"details": {"requested": args},
|
|
2245
|
+
},
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
def view_image(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
2249
|
+
resolved = self.resolve_existing(str(args.get("path", "")))
|
|
2250
|
+
max_bytes = int(args.get("max_bytes", 5_242_880))
|
|
2251
|
+
max_width = int(args.get("max_width", IMAGE_RESIZE_MAX_DIMENSION))
|
|
2252
|
+
max_height = int(args.get("max_height", IMAGE_RESIZE_MAX_DIMENSION))
|
|
2253
|
+
auto_resize = bool(args.get("auto_resize", True))
|
|
2254
|
+
data = resolved.path.read_bytes()
|
|
2255
|
+
mime_type, width, height = identify_image(data, resolved.path)
|
|
2256
|
+
if mime_type is None:
|
|
2257
|
+
raise ToolFailure("BINARY_FILE", "File is not a supported image.", category="validation")
|
|
2258
|
+
original = {"bytes": len(data), "width": width, "height": height, "mime_type": mime_type}
|
|
2259
|
+
resized = False
|
|
2260
|
+
warnings: list[str] = []
|
|
2261
|
+
if auto_resize and should_resize_image(len(data), width, height, max_bytes, max_width, max_height):
|
|
2262
|
+
resized_data = resize_image_bytes(data, mime_type, max_width=max_width, max_height=max_height, max_bytes=max_bytes)
|
|
2263
|
+
if resized_data is not None:
|
|
2264
|
+
data, mime_type = resized_data
|
|
2265
|
+
mime_type, width, height = identify_image(data, resolved.path)
|
|
2266
|
+
resized = True
|
|
2267
|
+
else:
|
|
2268
|
+
warnings.append("auto_resize requested but Pillow is not installed or image resize failed")
|
|
2269
|
+
if len(data) > max_bytes:
|
|
2270
|
+
raise ToolFailure(
|
|
2271
|
+
"OUTPUT_TOO_LARGE",
|
|
2272
|
+
"Image exceeds max_bytes.",
|
|
2273
|
+
category="validation",
|
|
2274
|
+
details={"bytes": len(data), "max_bytes": max_bytes, "resize_attempted": auto_resize, "warnings": warnings},
|
|
2275
|
+
)
|
|
2276
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
2277
|
+
payload: dict[str, Any] = {
|
|
2278
|
+
"path": resolved.display,
|
|
2279
|
+
"mime_type": mime_type,
|
|
2280
|
+
"bytes": len(data),
|
|
2281
|
+
"width": width,
|
|
2282
|
+
"height": height,
|
|
2283
|
+
"resized": resized,
|
|
2284
|
+
"original": original,
|
|
2285
|
+
"base64": encoded,
|
|
2286
|
+
"data_url": f"data:{mime_type};base64,{encoded}",
|
|
2287
|
+
"warnings": warnings,
|
|
2288
|
+
}
|
|
2289
|
+
return payload
|
|
2290
|
+
|
|
2291
|
+
|
|
2292
|
+
@dataclass
|
|
2293
|
+
class PatchOperation:
|
|
2294
|
+
kind: str
|
|
2295
|
+
path: str
|
|
2296
|
+
add_content: str | None = None
|
|
2297
|
+
hunks: list[list[str]] = field(default_factory=list)
|
|
2298
|
+
move_to: str | None = None
|
|
2299
|
+
|
|
2300
|
+
|
|
2301
|
+
def parse_patch(patch: str) -> list[PatchOperation]:
|
|
2302
|
+
lines = patch.splitlines()
|
|
2303
|
+
if not lines or lines[0].strip() != "*** Begin Patch" or lines[-1].strip() != "*** End Patch":
|
|
2304
|
+
raise ToolFailure("PATCH_FAILED", "Patch must use *** Begin Patch / *** End Patch envelope.", category="validation")
|
|
2305
|
+
operations: list[PatchOperation] = []
|
|
2306
|
+
i = 1
|
|
2307
|
+
while i < len(lines) - 1:
|
|
2308
|
+
line = lines[i]
|
|
2309
|
+
if not line:
|
|
2310
|
+
i += 1
|
|
2311
|
+
continue
|
|
2312
|
+
if line.startswith("*** Add File: "):
|
|
2313
|
+
path = line.removeprefix("*** Add File: ").strip()
|
|
2314
|
+
i += 1
|
|
2315
|
+
content_lines: list[str] = []
|
|
2316
|
+
while i < len(lines) - 1 and not lines[i].startswith("*** "):
|
|
2317
|
+
if not lines[i].startswith("+"):
|
|
2318
|
+
raise ToolFailure("PATCH_FAILED", "Add file lines must start with '+'.", category="validation")
|
|
2319
|
+
content_lines.append(lines[i][1:])
|
|
2320
|
+
i += 1
|
|
2321
|
+
operations.append(PatchOperation("add", path, add_content="\n".join(content_lines) + "\n"))
|
|
2322
|
+
continue
|
|
2323
|
+
if line.startswith("*** Delete File: "):
|
|
2324
|
+
path = line.removeprefix("*** Delete File: ").strip()
|
|
2325
|
+
operations.append(PatchOperation("delete", path))
|
|
2326
|
+
i += 1
|
|
2327
|
+
continue
|
|
2328
|
+
if line.startswith("*** Update File: "):
|
|
2329
|
+
path = line.removeprefix("*** Update File: ").strip()
|
|
2330
|
+
i += 1
|
|
2331
|
+
move_to: str | None = None
|
|
2332
|
+
if i < len(lines) - 1 and lines[i].startswith("*** Move to: "):
|
|
2333
|
+
move_to = lines[i].removeprefix("*** Move to: ").strip()
|
|
2334
|
+
i += 1
|
|
2335
|
+
hunks: list[list[str]] = []
|
|
2336
|
+
current: list[str] = []
|
|
2337
|
+
while i < len(lines) - 1 and not lines[i].startswith("*** "):
|
|
2338
|
+
if lines[i].startswith("@@"):
|
|
2339
|
+
if current:
|
|
2340
|
+
hunks.append(current)
|
|
2341
|
+
current = []
|
|
2342
|
+
else:
|
|
2343
|
+
current.append(lines[i])
|
|
2344
|
+
i += 1
|
|
2345
|
+
if current:
|
|
2346
|
+
hunks.append(current)
|
|
2347
|
+
operations.append(PatchOperation("update", path, hunks=hunks, move_to=move_to))
|
|
2348
|
+
continue
|
|
2349
|
+
raise ToolFailure("PATCH_FAILED", f"Unrecognized patch line: {line}", category="validation")
|
|
2350
|
+
return operations
|
|
2351
|
+
|
|
2352
|
+
|
|
2353
|
+
@dataclass(frozen=True)
|
|
2354
|
+
class ParsedHunk:
|
|
2355
|
+
old: list[str]
|
|
2356
|
+
new: list[str]
|
|
2357
|
+
|
|
2358
|
+
|
|
2359
|
+
@dataclass(frozen=True)
|
|
2360
|
+
class MatchedHunk:
|
|
2361
|
+
hunk_index: int
|
|
2362
|
+
start: int
|
|
2363
|
+
end: int
|
|
2364
|
+
new: list[str]
|
|
2365
|
+
|
|
2366
|
+
|
|
2367
|
+
def apply_update_hunks(content: str, hunks: list[list[str]], path: str = "<patch>") -> str:
|
|
2368
|
+
if not hunks:
|
|
2369
|
+
return content
|
|
2370
|
+
bom, text = strip_bom(content)
|
|
2371
|
+
line_ending = detect_line_ending(text)
|
|
2372
|
+
normalized = normalize_to_lf(text)
|
|
2373
|
+
had_trailing_newline = normalized.endswith("\n")
|
|
2374
|
+
lines = normalized.splitlines()
|
|
2375
|
+
parsed = [parse_update_hunk(hunk) for hunk in hunks]
|
|
2376
|
+
matched: list[MatchedHunk] = []
|
|
2377
|
+
for index, hunk in enumerate(parsed):
|
|
2378
|
+
if not hunk.old:
|
|
2379
|
+
match_start = 0
|
|
2380
|
+
match_count = 1
|
|
2381
|
+
else:
|
|
2382
|
+
matches = find_subsequence_all(lines, hunk.old)
|
|
2383
|
+
match_count = len(matches)
|
|
2384
|
+
match_start = matches[0] if matches else -1
|
|
2385
|
+
if match_start < 0:
|
|
2386
|
+
raise ToolFailure("PATCH_FAILED", f"Patch context did not match in {path}.", category="validation")
|
|
2387
|
+
if match_count > 1:
|
|
2388
|
+
raise ToolFailure(
|
|
2389
|
+
"PATCH_FAILED",
|
|
2390
|
+
f"Patch context matched {match_count} locations in {path}; add more context.",
|
|
2391
|
+
category="validation",
|
|
2392
|
+
)
|
|
2393
|
+
matched.append(MatchedHunk(index, match_start, match_start + len(hunk.old), hunk.new))
|
|
2394
|
+
|
|
2395
|
+
matched.sort(key=lambda item: item.start)
|
|
2396
|
+
for previous, current in zip(matched, matched[1:]):
|
|
2397
|
+
if previous.end > current.start:
|
|
2398
|
+
raise ToolFailure(
|
|
2399
|
+
"PATCH_FAILED",
|
|
2400
|
+
f"Patch hunks {previous.hunk_index} and {current.hunk_index} overlap in {path}.",
|
|
2401
|
+
category="validation",
|
|
2402
|
+
)
|
|
2403
|
+
|
|
2404
|
+
updated_lines = list(lines)
|
|
2405
|
+
for matched_hunk in sorted(matched, key=lambda item: item.start, reverse=True):
|
|
2406
|
+
updated_lines = updated_lines[: matched_hunk.start] + matched_hunk.new + updated_lines[matched_hunk.end :]
|
|
2407
|
+
updated = "\n".join(updated_lines)
|
|
2408
|
+
if had_trailing_newline and (updated_lines or updated == ""):
|
|
2409
|
+
updated += "\n"
|
|
2410
|
+
elif not text and updated_lines:
|
|
2411
|
+
updated += "\n"
|
|
2412
|
+
return bom + restore_line_endings(updated, line_ending)
|
|
2413
|
+
|
|
2414
|
+
|
|
2415
|
+
def parse_update_hunk(hunk: list[str]) -> ParsedHunk:
|
|
2416
|
+
old: list[str] = []
|
|
2417
|
+
new: list[str] = []
|
|
2418
|
+
for raw in hunk:
|
|
2419
|
+
if raw == "*** End of File":
|
|
2420
|
+
continue
|
|
2421
|
+
if not raw:
|
|
2422
|
+
raise ToolFailure("PATCH_FAILED", "Invalid empty patch line.", category="validation")
|
|
2423
|
+
marker = raw[0]
|
|
2424
|
+
value = raw[1:] if marker in {" ", "-", "+"} else raw
|
|
2425
|
+
if marker == " ":
|
|
2426
|
+
old.append(value)
|
|
2427
|
+
new.append(value)
|
|
2428
|
+
elif marker == "-":
|
|
2429
|
+
old.append(value)
|
|
2430
|
+
elif marker == "+":
|
|
2431
|
+
new.append(value)
|
|
2432
|
+
else:
|
|
2433
|
+
raise ToolFailure("PATCH_FAILED", "Update lines must start with space, '-' or '+'.", category="validation")
|
|
2434
|
+
return ParsedHunk(old=old, new=new)
|
|
2435
|
+
|
|
2436
|
+
|
|
2437
|
+
def find_subsequence(lines: list[str], needle: list[str]) -> int:
|
|
2438
|
+
matches = find_subsequence_all(lines, needle)
|
|
2439
|
+
return matches[0] if matches else -1
|
|
2440
|
+
|
|
2441
|
+
|
|
2442
|
+
def find_subsequence_all(lines: list[str], needle: list[str]) -> list[int]:
|
|
2443
|
+
if not needle:
|
|
2444
|
+
return [0]
|
|
2445
|
+
limit = len(lines) - len(needle) + 1
|
|
2446
|
+
matches: list[int] = []
|
|
2447
|
+
for index in range(max(0, limit)):
|
|
2448
|
+
if lines[index : index + len(needle)] == needle:
|
|
2449
|
+
matches.append(index)
|
|
2450
|
+
return matches
|
|
2451
|
+
|
|
2452
|
+
|
|
2453
|
+
def walk_files(root: Path) -> list[Path]:
|
|
2454
|
+
if root.is_file() or root.is_symlink():
|
|
2455
|
+
return [root]
|
|
2456
|
+
results: list[Path] = []
|
|
2457
|
+
for current, dirs, files in os.walk(root, followlinks=False):
|
|
2458
|
+
dirs[:] = [name for name in dirs if name not in DEFAULT_EXCLUDED_NAMES]
|
|
2459
|
+
current_path = Path(current)
|
|
2460
|
+
for name in files:
|
|
2461
|
+
results.append(current_path / name)
|
|
2462
|
+
return results
|
|
2463
|
+
|
|
2464
|
+
|
|
2465
|
+
def find_literal(line: str, query: str, case_sensitive: bool) -> Any:
|
|
2466
|
+
haystack = line if case_sensitive else line.lower()
|
|
2467
|
+
needle = query if case_sensitive else query.lower()
|
|
2468
|
+
index = haystack.find(needle)
|
|
2469
|
+
if index < 0:
|
|
2470
|
+
return None
|
|
2471
|
+
|
|
2472
|
+
class Match:
|
|
2473
|
+
def start(self) -> int:
|
|
2474
|
+
return index
|
|
2475
|
+
|
|
2476
|
+
return Match()
|
|
2477
|
+
|
|
2478
|
+
|
|
2479
|
+
def shlex_split(command: str) -> list[str]:
|
|
2480
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
2481
|
+
lexer.whitespace_split = True
|
|
2482
|
+
return list(lexer)
|
|
2483
|
+
|
|
2484
|
+
|
|
2485
|
+
def command_executables(tokens: list[str]) -> list[str]:
|
|
2486
|
+
executables: list[str] = []
|
|
2487
|
+
expect_command = True
|
|
2488
|
+
for index, token in enumerate(tokens):
|
|
2489
|
+
if not token:
|
|
2490
|
+
continue
|
|
2491
|
+
if token in SHELL_CONTROL_TOKENS:
|
|
2492
|
+
expect_command = True
|
|
2493
|
+
continue
|
|
2494
|
+
if token in REDIRECTION_TOKENS or token in HEREDOC_TOKENS:
|
|
2495
|
+
expect_command = False
|
|
2496
|
+
continue
|
|
2497
|
+
if token.isdigit() and index + 1 < len(tokens) and tokens[index + 1] in REDIRECTION_TOKENS:
|
|
2498
|
+
continue
|
|
2499
|
+
if expect_command:
|
|
2500
|
+
if is_env_assignment_token(token):
|
|
2501
|
+
continue
|
|
2502
|
+
executables.append(token)
|
|
2503
|
+
expect_command = False
|
|
2504
|
+
return executables
|
|
2505
|
+
|
|
2506
|
+
|
|
2507
|
+
def explicit_command_path_candidates(tokens: list[str]) -> list[str]:
|
|
2508
|
+
candidates: list[str] = []
|
|
2509
|
+
index = 0
|
|
2510
|
+
current_command: str | None = None
|
|
2511
|
+
current_args: list[str] = []
|
|
2512
|
+
while index < len(tokens):
|
|
2513
|
+
token = tokens[index]
|
|
2514
|
+
if token in SHELL_CONTROL_TOKENS:
|
|
2515
|
+
candidates.extend(command_argument_path_candidates(current_command, current_args))
|
|
2516
|
+
current_command = None
|
|
2517
|
+
current_args = []
|
|
2518
|
+
index += 1
|
|
2519
|
+
continue
|
|
2520
|
+
if token.isdigit() and index + 1 < len(tokens) and tokens[index + 1] in REDIRECTION_TOKENS:
|
|
2521
|
+
index += 1
|
|
2522
|
+
continue
|
|
2523
|
+
if token in REDIRECTION_TOKENS:
|
|
2524
|
+
if index + 1 < len(tokens):
|
|
2525
|
+
candidates.append(tokens[index + 1])
|
|
2526
|
+
index += 2
|
|
2527
|
+
continue
|
|
2528
|
+
if token in HEREDOC_TOKENS:
|
|
2529
|
+
index += 2
|
|
2530
|
+
continue
|
|
2531
|
+
if current_command is None:
|
|
2532
|
+
if not is_env_assignment_token(token):
|
|
2533
|
+
current_command = token
|
|
2534
|
+
else:
|
|
2535
|
+
current_args.append(token)
|
|
2536
|
+
index += 1
|
|
2537
|
+
candidates.extend(command_argument_path_candidates(current_command, current_args))
|
|
2538
|
+
return list(dict.fromkeys(candidates))
|
|
2539
|
+
|
|
2540
|
+
|
|
2541
|
+
def command_argument_path_candidates(command: str | None, args: list[str]) -> list[str]:
|
|
2542
|
+
if not command:
|
|
2543
|
+
return []
|
|
2544
|
+
name = PurePosixPath(command.replace("\\", "/")).name.lower()
|
|
2545
|
+
if name == "env":
|
|
2546
|
+
candidates, wrapped_command, wrapped_args = env_wrapped_command(args)
|
|
2547
|
+
if wrapped_command is not None:
|
|
2548
|
+
candidates.extend(command_argument_path_candidates(wrapped_command, wrapped_args))
|
|
2549
|
+
return candidates
|
|
2550
|
+
if name in PATH_ARGUMENT_COMMANDS:
|
|
2551
|
+
return [arg for arg in args if is_inspectable_path_argument(arg)]
|
|
2552
|
+
if name in PATTERN_THEN_PATH_COMMANDS:
|
|
2553
|
+
return pattern_command_path_candidates(args)
|
|
2554
|
+
if name == "find":
|
|
2555
|
+
return find_command_path_candidates(args)
|
|
2556
|
+
if name in SCRIPT_COMMANDS:
|
|
2557
|
+
return script_command_path_candidates(name, args)
|
|
2558
|
+
return []
|
|
2559
|
+
|
|
2560
|
+
|
|
2561
|
+
def inline_script_command(command: str) -> dict[str, str] | None:
|
|
2562
|
+
try:
|
|
2563
|
+
tokens = shlex_split(command)
|
|
2564
|
+
except ValueError:
|
|
2565
|
+
tokens = command.split()
|
|
2566
|
+
index = 0
|
|
2567
|
+
current_command: str | None = None
|
|
2568
|
+
current_args: list[str] = []
|
|
2569
|
+
while index < len(tokens):
|
|
2570
|
+
token = tokens[index]
|
|
2571
|
+
if token in SHELL_CONTROL_TOKENS:
|
|
2572
|
+
result = inline_script_segment(current_command, current_args)
|
|
2573
|
+
if result is not None:
|
|
2574
|
+
return result
|
|
2575
|
+
current_command = None
|
|
2576
|
+
current_args = []
|
|
2577
|
+
index += 1
|
|
2578
|
+
continue
|
|
2579
|
+
if token.isdigit() and index + 1 < len(tokens) and tokens[index + 1] in REDIRECTION_TOKENS:
|
|
2580
|
+
index += 1
|
|
2581
|
+
continue
|
|
2582
|
+
if token in HEREDOC_TOKENS:
|
|
2583
|
+
result = stdin_script_segment(current_command, current_args, token)
|
|
2584
|
+
if result is not None:
|
|
2585
|
+
return result
|
|
2586
|
+
index += 2
|
|
2587
|
+
continue
|
|
2588
|
+
if token in REDIRECTION_TOKENS:
|
|
2589
|
+
index += 2
|
|
2590
|
+
continue
|
|
2591
|
+
if current_command is None:
|
|
2592
|
+
if not is_env_assignment_token(token):
|
|
2593
|
+
current_command = token
|
|
2594
|
+
else:
|
|
2595
|
+
current_args.append(token)
|
|
2596
|
+
index += 1
|
|
2597
|
+
return inline_script_segment(current_command, current_args)
|
|
2598
|
+
|
|
2599
|
+
|
|
2600
|
+
def inline_script_segment(command: str | None, args: list[str]) -> dict[str, str] | None:
|
|
2601
|
+
if not command:
|
|
2602
|
+
return None
|
|
2603
|
+
name = PurePosixPath(command.replace("\\", "/")).name.lower()
|
|
2604
|
+
if name == "env":
|
|
2605
|
+
_candidates, wrapped_command, wrapped_args = env_wrapped_command(args)
|
|
2606
|
+
return inline_script_segment(wrapped_command, wrapped_args)
|
|
2607
|
+
if name in {"bash", "sh", "zsh"}:
|
|
2608
|
+
for arg in args:
|
|
2609
|
+
if arg.startswith("-") and "c" in arg.lstrip("-"):
|
|
2610
|
+
return {"command": name, "option": arg}
|
|
2611
|
+
return None
|
|
2612
|
+
if name in {"python", "python3"}:
|
|
2613
|
+
if "-c" in args:
|
|
2614
|
+
return {"command": name, "option": "-c"}
|
|
2615
|
+
if "-" in args:
|
|
2616
|
+
return {"command": name, "option": "-"}
|
|
2617
|
+
return None
|
|
2618
|
+
if name == "node":
|
|
2619
|
+
for option in ("-e", "--eval", "-p", "--print"):
|
|
2620
|
+
if option in args:
|
|
2621
|
+
return {"command": name, "option": option}
|
|
2622
|
+
if name in {"ruby", "perl"} and "-e" in args:
|
|
2623
|
+
return {"command": name, "option": "-e"}
|
|
2624
|
+
return None
|
|
2625
|
+
|
|
2626
|
+
|
|
2627
|
+
def env_wrapped_command(args: list[str]) -> tuple[list[str], str | None, list[str]]:
|
|
2628
|
+
candidates: list[str] = []
|
|
2629
|
+
index = 0
|
|
2630
|
+
while index < len(args):
|
|
2631
|
+
arg = args[index]
|
|
2632
|
+
if arg == "--":
|
|
2633
|
+
index += 1
|
|
2634
|
+
break
|
|
2635
|
+
if arg in {"-S", "--split-string"}:
|
|
2636
|
+
if index + 1 >= len(args):
|
|
2637
|
+
return candidates, None, []
|
|
2638
|
+
return env_split_command(candidates, args[index + 1])
|
|
2639
|
+
if arg.startswith("--split-string="):
|
|
2640
|
+
return env_split_command(candidates, arg.split("=", 1)[1])
|
|
2641
|
+
if arg.startswith("-S") and arg != "-S":
|
|
2642
|
+
return env_split_command(candidates, arg[2:])
|
|
2643
|
+
if arg in {"-C", "--chdir"}:
|
|
2644
|
+
if index + 1 >= len(args):
|
|
2645
|
+
return candidates, None, []
|
|
2646
|
+
candidates.append(args[index + 1])
|
|
2647
|
+
index += 2
|
|
2648
|
+
continue
|
|
2649
|
+
if arg.startswith("--chdir="):
|
|
2650
|
+
candidates.append(arg.split("=", 1)[1])
|
|
2651
|
+
index += 1
|
|
2652
|
+
continue
|
|
2653
|
+
if arg.startswith("-C") and arg != "-C":
|
|
2654
|
+
candidates.append(arg[2:])
|
|
2655
|
+
index += 1
|
|
2656
|
+
continue
|
|
2657
|
+
if arg in ENV_OPTIONS_WITH_ARGUMENT:
|
|
2658
|
+
index += 2
|
|
2659
|
+
continue
|
|
2660
|
+
if any(arg.startswith(f"{option}=") for option in ENV_LONG_OPTIONS_WITH_ARGUMENT):
|
|
2661
|
+
index += 1
|
|
2662
|
+
continue
|
|
2663
|
+
if any(arg.startswith(f"{option}=") for option in ENV_LONG_OPTIONS_WITH_OPTIONAL_ARGUMENT):
|
|
2664
|
+
index += 1
|
|
2665
|
+
continue
|
|
2666
|
+
if any(arg.startswith(prefix) and arg != prefix for prefix in ENV_SHORT_OPTIONS_WITH_ATTACHED_ARGUMENT):
|
|
2667
|
+
index += 1
|
|
2668
|
+
continue
|
|
2669
|
+
if arg in ENV_FLAG_OPTIONS:
|
|
2670
|
+
index += 1
|
|
2671
|
+
continue
|
|
2672
|
+
if arg.startswith("-") or is_env_assignment_token(arg):
|
|
2673
|
+
index += 1
|
|
2674
|
+
continue
|
|
2675
|
+
return candidates, arg, args[index + 1 :]
|
|
2676
|
+
if index < len(args):
|
|
2677
|
+
return candidates, args[index], args[index + 1 :]
|
|
2678
|
+
return candidates, None, []
|
|
2679
|
+
|
|
2680
|
+
|
|
2681
|
+
def env_split_command(candidates: list[str], command: str) -> tuple[list[str], str | None, list[str]]:
|
|
2682
|
+
try:
|
|
2683
|
+
tokens = shlex_split(command)
|
|
2684
|
+
except ValueError:
|
|
2685
|
+
tokens = command.split()
|
|
2686
|
+
if not tokens:
|
|
2687
|
+
return candidates, None, []
|
|
2688
|
+
return candidates, tokens[0], tokens[1:]
|
|
2689
|
+
|
|
2690
|
+
|
|
2691
|
+
def stdin_script_segment(command: str | None, args: list[str], redirection: str) -> dict[str, str] | None:
|
|
2692
|
+
if not command:
|
|
2693
|
+
return None
|
|
2694
|
+
name = PurePosixPath(command.replace("\\", "/")).name.lower()
|
|
2695
|
+
if name not in SCRIPT_COMMANDS:
|
|
2696
|
+
return None
|
|
2697
|
+
if name in {"python", "python3"} and "-m" in args:
|
|
2698
|
+
return None
|
|
2699
|
+
for arg in args:
|
|
2700
|
+
if not arg.startswith("-") or arg == "-":
|
|
2701
|
+
return None
|
|
2702
|
+
return {"command": name, "option": redirection}
|
|
2703
|
+
|
|
2704
|
+
|
|
2705
|
+
def pattern_command_path_candidates(args: list[str]) -> list[str]:
|
|
2706
|
+
candidates: list[str] = []
|
|
2707
|
+
pattern_consumed = False
|
|
2708
|
+
skip_next = False
|
|
2709
|
+
for arg in args:
|
|
2710
|
+
if skip_next:
|
|
2711
|
+
skip_next = False
|
|
2712
|
+
continue
|
|
2713
|
+
if arg in {"-e", "-f", "--regexp", "--file", "-g", "--glob"}:
|
|
2714
|
+
skip_next = True
|
|
2715
|
+
continue
|
|
2716
|
+
if arg.startswith("-"):
|
|
2717
|
+
continue
|
|
2718
|
+
if not pattern_consumed:
|
|
2719
|
+
pattern_consumed = True
|
|
2720
|
+
continue
|
|
2721
|
+
if is_inspectable_path_argument(arg):
|
|
2722
|
+
candidates.append(arg)
|
|
2723
|
+
return candidates
|
|
2724
|
+
|
|
2725
|
+
|
|
2726
|
+
def find_command_path_candidates(args: list[str]) -> list[str]:
|
|
2727
|
+
candidates: list[str] = []
|
|
2728
|
+
for arg in args:
|
|
2729
|
+
if arg in {"!", "(", ")"} or arg.startswith("-"):
|
|
2730
|
+
break
|
|
2731
|
+
if is_inspectable_path_argument(arg):
|
|
2732
|
+
candidates.append(arg)
|
|
2733
|
+
return candidates
|
|
2734
|
+
|
|
2735
|
+
|
|
2736
|
+
def script_command_path_candidates(command_name: str, args: list[str]) -> list[str]:
|
|
2737
|
+
skip_next = False
|
|
2738
|
+
for arg in args:
|
|
2739
|
+
if skip_next:
|
|
2740
|
+
skip_next = False
|
|
2741
|
+
continue
|
|
2742
|
+
if command_name in {"bash", "sh", "zsh"} and arg.startswith("-") and "c" in arg.lstrip("-"):
|
|
2743
|
+
return []
|
|
2744
|
+
if command_name in {"python", "python3"} and arg == "-c":
|
|
2745
|
+
return []
|
|
2746
|
+
if command_name == "node" and arg in {"-e", "--eval", "-p", "--print"}:
|
|
2747
|
+
return []
|
|
2748
|
+
if command_name in {"ruby", "perl"} and arg == "-e":
|
|
2749
|
+
return []
|
|
2750
|
+
if arg in {"-m", "--require", "-r"}:
|
|
2751
|
+
skip_next = True
|
|
2752
|
+
continue
|
|
2753
|
+
if arg.startswith("-"):
|
|
2754
|
+
continue
|
|
2755
|
+
if command_name.startswith("python") and arg == "-":
|
|
2756
|
+
return []
|
|
2757
|
+
return [arg] if is_inspectable_path_argument(arg) else []
|
|
2758
|
+
return []
|
|
2759
|
+
|
|
2760
|
+
|
|
2761
|
+
def is_env_assignment_token(token: str) -> bool:
|
|
2762
|
+
return bool(re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", token))
|
|
2763
|
+
|
|
2764
|
+
|
|
2765
|
+
def is_inspectable_path_argument(token: str) -> bool:
|
|
2766
|
+
if not token or token.startswith("-"):
|
|
2767
|
+
return False
|
|
2768
|
+
normalized = token.replace("\\", "/")
|
|
2769
|
+
if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", normalized):
|
|
2770
|
+
return False
|
|
2771
|
+
if normalized.startswith(("/", "~", "./", "../")) or re.match(r"^[A-Za-z]:/", normalized):
|
|
2772
|
+
return True
|
|
2773
|
+
if "/" in normalized:
|
|
2774
|
+
return True
|
|
2775
|
+
return "." in PurePosixPath(normalized).name
|
|
2776
|
+
|
|
2777
|
+
|
|
2778
|
+
def is_literal_network_reference_command(command: str) -> bool:
|
|
2779
|
+
try:
|
|
2780
|
+
tokens = shlex_split(command)
|
|
2781
|
+
except ValueError:
|
|
2782
|
+
return False
|
|
2783
|
+
executables = command_executables(tokens)
|
|
2784
|
+
if not executables:
|
|
2785
|
+
return False
|
|
2786
|
+
return all(
|
|
2787
|
+
PurePosixPath(executable.replace("\\", "/")).name.lower() in NETWORK_LITERAL_COMMANDS
|
|
2788
|
+
for executable in executables
|
|
2789
|
+
)
|
|
2790
|
+
|
|
2791
|
+
|
|
2792
|
+
def entry_for_path(path: Path, root: Path) -> dict[str, Any]:
|
|
2793
|
+
stat = path.lstat()
|
|
2794
|
+
if path.is_symlink():
|
|
2795
|
+
kind = "symlink"
|
|
2796
|
+
elif path.is_dir():
|
|
2797
|
+
kind = "directory"
|
|
2798
|
+
elif path.is_file():
|
|
2799
|
+
kind = "file"
|
|
2800
|
+
else:
|
|
2801
|
+
kind = "other"
|
|
2802
|
+
item: dict[str, Any] = {
|
|
2803
|
+
"name": path.name,
|
|
2804
|
+
"path": normalize_rel_display(path, root),
|
|
2805
|
+
"type": kind,
|
|
2806
|
+
"size_bytes": stat.st_size,
|
|
2807
|
+
"modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
2808
|
+
"is_hidden": path.name.startswith("."),
|
|
2809
|
+
"is_ignored": False,
|
|
2810
|
+
}
|
|
2811
|
+
if path.is_symlink():
|
|
2812
|
+
try:
|
|
2813
|
+
item["symlink_target"] = os.readlink(path)
|
|
2814
|
+
except OSError:
|
|
2815
|
+
pass
|
|
2816
|
+
return item
|
|
2817
|
+
|
|
2818
|
+
|
|
2819
|
+
def sort_value(item: dict[str, Any], sort_key: str) -> Any:
|
|
2820
|
+
if sort_key == "type":
|
|
2821
|
+
return (item.get("type", ""), item.get("name", ""))
|
|
2822
|
+
if sort_key == "modified":
|
|
2823
|
+
return (item.get("modified", ""), item.get("name", ""))
|
|
2824
|
+
return item.get("name", "")
|
|
2825
|
+
|
|
2826
|
+
|
|
2827
|
+
def parse_branch_line(line: str) -> tuple[str, str, int, int]:
|
|
2828
|
+
branch = line
|
|
2829
|
+
upstream = ""
|
|
2830
|
+
ahead = 0
|
|
2831
|
+
behind = 0
|
|
2832
|
+
if "..." in line:
|
|
2833
|
+
branch, rest = line.split("...", 1)
|
|
2834
|
+
upstream = rest.split(" ", 1)[0]
|
|
2835
|
+
if "[" in line and "]" in line:
|
|
2836
|
+
meta = line.split("[", 1)[1].split("]", 1)[0]
|
|
2837
|
+
ahead_match = re.search(r"ahead (\d+)", meta)
|
|
2838
|
+
behind_match = re.search(r"behind (\d+)", meta)
|
|
2839
|
+
ahead = int(ahead_match.group(1)) if ahead_match else 0
|
|
2840
|
+
behind = int(behind_match.group(1)) if behind_match else 0
|
|
2841
|
+
return branch.strip(), upstream.strip(), ahead, behind
|
|
2842
|
+
|
|
2843
|
+
|
|
2844
|
+
def git_rev_parse(path: Path, rev: str) -> str:
|
|
2845
|
+
git = shutil.which("git")
|
|
2846
|
+
if not git:
|
|
2847
|
+
return ""
|
|
2848
|
+
completed = subprocess.run([git, "-C", str(path), "rev-parse", rev], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
2849
|
+
return completed.stdout.strip() if completed.returncode == 0 else ""
|
|
2850
|
+
|
|
2851
|
+
|
|
2852
|
+
def is_git_repo(path: Path) -> bool:
|
|
2853
|
+
git = shutil.which("git")
|
|
2854
|
+
if not git:
|
|
2855
|
+
return False
|
|
2856
|
+
completed = subprocess.run(
|
|
2857
|
+
[git, "-C", str(path), "rev-parse", "--is-inside-work-tree"],
|
|
2858
|
+
text=True,
|
|
2859
|
+
stdout=subprocess.PIPE,
|
|
2860
|
+
stderr=subprocess.DEVNULL,
|
|
2861
|
+
)
|
|
2862
|
+
return completed.returncode == 0 and completed.stdout.strip() == "true"
|
|
2863
|
+
|
|
2864
|
+
|
|
2865
|
+
def require_git() -> str:
|
|
2866
|
+
git = shutil.which("git")
|
|
2867
|
+
if not git:
|
|
2868
|
+
raise ToolFailure("GIT_ERROR", "git executable not found.", category="runtime")
|
|
2869
|
+
return git
|
|
2870
|
+
|
|
2871
|
+
|
|
2872
|
+
def validate_git_ref(ref: str) -> str:
|
|
2873
|
+
if not ref or ref.startswith("-") or "\x00" in ref or "\n" in ref or "\r" in ref:
|
|
2874
|
+
raise ToolFailure("INVALID_ARGUMENT", "Invalid git revision.", category="validation")
|
|
2875
|
+
return ref
|
|
2876
|
+
|
|
2877
|
+
|
|
2878
|
+
def parse_git_blame_porcelain(output: str) -> list[dict[str, Any]]:
|
|
2879
|
+
rows: list[dict[str, Any]] = []
|
|
2880
|
+
current: dict[str, Any] = {}
|
|
2881
|
+
for raw in output.splitlines():
|
|
2882
|
+
parts = raw.split()
|
|
2883
|
+
if len(parts) >= 3 and re.fullmatch(r"[0-9a-fA-F^]{40}", parts[0]):
|
|
2884
|
+
current = {
|
|
2885
|
+
"commit": parts[0].lstrip("^"),
|
|
2886
|
+
"original_line": int(parts[1]) if parts[1].isdigit() else None,
|
|
2887
|
+
"line": int(parts[2]) if parts[2].isdigit() else None,
|
|
2888
|
+
}
|
|
2889
|
+
continue
|
|
2890
|
+
if raw.startswith("author "):
|
|
2891
|
+
current["author"] = raw.removeprefix("author ")
|
|
2892
|
+
continue
|
|
2893
|
+
if raw.startswith("author-mail "):
|
|
2894
|
+
current["author_mail"] = raw.removeprefix("author-mail ").strip("<>")
|
|
2895
|
+
continue
|
|
2896
|
+
if raw.startswith("author-time "):
|
|
2897
|
+
value = raw.removeprefix("author-time ")
|
|
2898
|
+
current["author_time"] = int(value) if value.isdigit() else value
|
|
2899
|
+
continue
|
|
2900
|
+
if raw.startswith("summary "):
|
|
2901
|
+
current["summary"] = raw.removeprefix("summary ")
|
|
2902
|
+
continue
|
|
2903
|
+
if raw.startswith("\t"):
|
|
2904
|
+
row = dict(current)
|
|
2905
|
+
row["content"] = raw[1:]
|
|
2906
|
+
rows.append(row)
|
|
2907
|
+
return rows
|
|
2908
|
+
|
|
2909
|
+
|
|
2910
|
+
def redact_for_trace(value: Any) -> Any:
|
|
2911
|
+
if isinstance(value, dict):
|
|
2912
|
+
return {
|
|
2913
|
+
str(key): "[REDACTED]" if SENSITIVE_ENV_RE.search(str(key)) else redact_for_trace(item)
|
|
2914
|
+
for key, item in value.items()
|
|
2915
|
+
}
|
|
2916
|
+
if isinstance(value, list):
|
|
2917
|
+
return [redact_for_trace(item) for item in value[:50]]
|
|
2918
|
+
if isinstance(value, tuple):
|
|
2919
|
+
return [redact_for_trace(item) for item in value[:50]]
|
|
2920
|
+
if isinstance(value, str):
|
|
2921
|
+
if SENSITIVE_VALUE_RE.search(value):
|
|
2922
|
+
return "[REDACTED]"
|
|
2923
|
+
if len(value) > 240:
|
|
2924
|
+
return value[:240] + "...[truncated]"
|
|
2925
|
+
return value
|
|
2926
|
+
return value
|
|
2927
|
+
|
|
2928
|
+
|
|
2929
|
+
class LandlockRulesetAttr(ctypes.Structure):
|
|
2930
|
+
_fields_ = [("handled_access_fs", ctypes.c_uint64)]
|
|
2931
|
+
|
|
2932
|
+
|
|
2933
|
+
class LandlockPathBeneathAttr(ctypes.Structure):
|
|
2934
|
+
_fields_ = [("allowed_access", ctypes.c_uint64), ("parent_fd", ctypes.c_int)]
|
|
2935
|
+
|
|
2936
|
+
|
|
2937
|
+
_LIBC: Any | None = None
|
|
2938
|
+
|
|
2939
|
+
|
|
2940
|
+
def landlock_libc() -> Any:
|
|
2941
|
+
global _LIBC
|
|
2942
|
+
if _LIBC is None:
|
|
2943
|
+
_LIBC = ctypes.CDLL(None, use_errno=True)
|
|
2944
|
+
return _LIBC
|
|
2945
|
+
|
|
2946
|
+
|
|
2947
|
+
def libc_syscall(number: int, *args: Any) -> int:
|
|
2948
|
+
ctypes.set_errno(0)
|
|
2949
|
+
return int(landlock_libc().syscall(number, *args))
|
|
2950
|
+
|
|
2951
|
+
|
|
2952
|
+
def landlock_abi_version() -> int:
|
|
2953
|
+
if sys.platform != "linux":
|
|
2954
|
+
raise ToolFailure(
|
|
2955
|
+
"SANDBOX_UNAVAILABLE",
|
|
2956
|
+
"Linux Landlock filesystem confinement is unavailable on this platform.",
|
|
2957
|
+
category="security",
|
|
2958
|
+
)
|
|
2959
|
+
version = libc_syscall(SYS_LANDLOCK_CREATE_RULESET, 0, 0, LANDLOCK_CREATE_RULESET_VERSION)
|
|
2960
|
+
if version <= 0:
|
|
2961
|
+
err = ctypes.get_errno()
|
|
2962
|
+
raise ToolFailure(
|
|
2963
|
+
"SANDBOX_UNAVAILABLE",
|
|
2964
|
+
"Linux Landlock filesystem confinement is unavailable on this host.",
|
|
2965
|
+
category="security",
|
|
2966
|
+
details={"errno": err, "reason": os.strerror(err) if err else "unknown"},
|
|
2967
|
+
)
|
|
2968
|
+
return version
|
|
2969
|
+
|
|
2970
|
+
|
|
2971
|
+
def landlock_handled_access(version: int) -> int:
|
|
2972
|
+
handled = (
|
|
2973
|
+
LANDLOCK_ACCESS_FS_EXECUTE
|
|
2974
|
+
| LANDLOCK_ACCESS_FS_WRITE_FILE
|
|
2975
|
+
| LANDLOCK_ACCESS_FS_READ_FILE
|
|
2976
|
+
| LANDLOCK_ACCESS_FS_READ_DIR
|
|
2977
|
+
| LANDLOCK_ACCESS_FS_REMOVE_DIR
|
|
2978
|
+
| LANDLOCK_ACCESS_FS_REMOVE_FILE
|
|
2979
|
+
| LANDLOCK_ACCESS_FS_MAKE_CHAR
|
|
2980
|
+
| LANDLOCK_ACCESS_FS_MAKE_DIR
|
|
2981
|
+
| LANDLOCK_ACCESS_FS_MAKE_REG
|
|
2982
|
+
| LANDLOCK_ACCESS_FS_MAKE_SOCK
|
|
2983
|
+
| LANDLOCK_ACCESS_FS_MAKE_FIFO
|
|
2984
|
+
| LANDLOCK_ACCESS_FS_MAKE_BLOCK
|
|
2985
|
+
| LANDLOCK_ACCESS_FS_MAKE_SYM
|
|
2986
|
+
)
|
|
2987
|
+
if version >= 2:
|
|
2988
|
+
handled |= LANDLOCK_ACCESS_FS_REFER
|
|
2989
|
+
if version >= 3:
|
|
2990
|
+
handled |= LANDLOCK_ACCESS_FS_TRUNCATE
|
|
2991
|
+
if version >= 5:
|
|
2992
|
+
handled |= LANDLOCK_ACCESS_FS_IOCTL_DEV
|
|
2993
|
+
return handled
|
|
2994
|
+
|
|
2995
|
+
|
|
2996
|
+
def open_landlock_ruleset(workspace: Path, read_roots: list[str]) -> int:
|
|
2997
|
+
version = landlock_abi_version()
|
|
2998
|
+
handled = landlock_handled_access(version)
|
|
2999
|
+
ruleset_attr = LandlockRulesetAttr(handled)
|
|
3000
|
+
ruleset_fd = libc_syscall(
|
|
3001
|
+
SYS_LANDLOCK_CREATE_RULESET,
|
|
3002
|
+
ctypes.byref(ruleset_attr),
|
|
3003
|
+
ctypes.sizeof(ruleset_attr),
|
|
3004
|
+
0,
|
|
3005
|
+
)
|
|
3006
|
+
if ruleset_fd < 0:
|
|
3007
|
+
err = ctypes.get_errno()
|
|
3008
|
+
raise ToolFailure(
|
|
3009
|
+
"SANDBOX_UNAVAILABLE",
|
|
3010
|
+
"Failed to create Linux Landlock ruleset for exec_command.",
|
|
3011
|
+
category="security",
|
|
3012
|
+
details={"errno": err, "reason": os.strerror(err) if err else "unknown"},
|
|
3013
|
+
)
|
|
3014
|
+
try:
|
|
3015
|
+
workspace_access = handled
|
|
3016
|
+
readonly_access = handled & (
|
|
3017
|
+
LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR
|
|
3018
|
+
)
|
|
3019
|
+
device_access = readonly_access | (handled & LANDLOCK_ACCESS_FS_WRITE_FILE)
|
|
3020
|
+
add_landlock_path(ruleset_fd, workspace, workspace_access)
|
|
3021
|
+
for root in read_roots:
|
|
3022
|
+
add_landlock_path(ruleset_fd, Path(root), readonly_access, required=False)
|
|
3023
|
+
for special in ("/dev/null", "/dev/zero", "/dev/random", "/dev/urandom"):
|
|
3024
|
+
add_landlock_path(ruleset_fd, Path(special), device_access, required=False)
|
|
3025
|
+
for special_dir in ("/proc/self", "/proc/thread-self", "/dev/fd"):
|
|
3026
|
+
add_landlock_path(ruleset_fd, Path(special_dir), readonly_access, required=False)
|
|
3027
|
+
except Exception:
|
|
3028
|
+
os.close(ruleset_fd)
|
|
3029
|
+
raise
|
|
3030
|
+
return ruleset_fd
|
|
3031
|
+
|
|
3032
|
+
|
|
3033
|
+
def add_landlock_path(ruleset_fd: int, path: Path, allowed_access: int, *, required: bool = True) -> None:
|
|
3034
|
+
try:
|
|
3035
|
+
fd = os.open(path, getattr(os, "O_PATH", os.O_RDONLY) | os.O_CLOEXEC)
|
|
3036
|
+
except OSError as exc:
|
|
3037
|
+
if required:
|
|
3038
|
+
raise ToolFailure(
|
|
3039
|
+
"SANDBOX_UNAVAILABLE",
|
|
3040
|
+
"Failed to open path while preparing Landlock sandbox.",
|
|
3041
|
+
category="security",
|
|
3042
|
+
details={"path": str(path), "errno": exc.errno, "reason": exc.strerror},
|
|
3043
|
+
) from exc
|
|
3044
|
+
return
|
|
3045
|
+
try:
|
|
3046
|
+
path_attr = LandlockPathBeneathAttr(allowed_access, fd)
|
|
3047
|
+
rc = libc_syscall(SYS_LANDLOCK_ADD_RULE, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, ctypes.byref(path_attr), 0)
|
|
3048
|
+
if rc < 0 and required:
|
|
3049
|
+
err = ctypes.get_errno()
|
|
3050
|
+
raise ToolFailure(
|
|
3051
|
+
"SANDBOX_UNAVAILABLE",
|
|
3052
|
+
"Failed to add path to Landlock sandbox.",
|
|
3053
|
+
category="security",
|
|
3054
|
+
details={"path": str(path), "errno": err, "reason": os.strerror(err) if err else "unknown"},
|
|
3055
|
+
)
|
|
3056
|
+
finally:
|
|
3057
|
+
os.close(fd)
|
|
3058
|
+
|
|
3059
|
+
|
|
3060
|
+
def restrict_self_with_landlock(ruleset_fd: int) -> None:
|
|
3061
|
+
rc = int(landlock_libc().prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0))
|
|
3062
|
+
if rc != 0:
|
|
3063
|
+
os._exit(126)
|
|
3064
|
+
rc = libc_syscall(SYS_LANDLOCK_RESTRICT_SELF, ruleset_fd, 0)
|
|
3065
|
+
if rc != 0:
|
|
3066
|
+
os._exit(126)
|
|
3067
|
+
try:
|
|
3068
|
+
os.close(ruleset_fd)
|
|
3069
|
+
except OSError:
|
|
3070
|
+
pass
|
|
3071
|
+
|
|
3072
|
+
|
|
3073
|
+
def landlock_exec_argv(ruleset_fd: int, cmd: str) -> list[str]:
|
|
3074
|
+
helper = Path(__file__).with_name("landlock_exec.py")
|
|
3075
|
+
return [sys.executable, str(helper), str(ruleset_fd), cmd]
|
|
3076
|
+
|
|
3077
|
+
|
|
3078
|
+
def guard_allow_roots() -> list[str]:
|
|
3079
|
+
roots = {
|
|
3080
|
+
"/bin",
|
|
3081
|
+
"/lib",
|
|
3082
|
+
"/lib64",
|
|
3083
|
+
"/sbin",
|
|
3084
|
+
"/usr",
|
|
3085
|
+
"/etc/alternatives",
|
|
3086
|
+
"/etc/ca-certificates",
|
|
3087
|
+
"/etc/localtime",
|
|
3088
|
+
"/etc/npmrc",
|
|
3089
|
+
"/etc/pki",
|
|
3090
|
+
"/etc/ssl",
|
|
3091
|
+
str(Path(sys.executable).resolve().parent),
|
|
3092
|
+
str(Path(sys.prefix).resolve()),
|
|
3093
|
+
str(Path(sys.base_prefix).resolve()),
|
|
3094
|
+
}
|
|
3095
|
+
for item in os.environ.get("PATH", "").split(os.pathsep):
|
|
3096
|
+
if not item:
|
|
3097
|
+
continue
|
|
3098
|
+
try:
|
|
3099
|
+
resolved = Path(item).resolve()
|
|
3100
|
+
except OSError:
|
|
3101
|
+
continue
|
|
3102
|
+
if resolved.is_dir() and any(
|
|
3103
|
+
str(resolved).startswith(prefix) for prefix in ("/usr", "/bin", "/sbin", "/lib", "/lib64", str(Path(sys.prefix).resolve()))
|
|
3104
|
+
):
|
|
3105
|
+
roots.add(str(resolved))
|
|
3106
|
+
for item in os.environ.get(f"{ENV_PREFIX}_EXEC_ALLOW_ROOTS", "").split(os.pathsep):
|
|
3107
|
+
if not item:
|
|
3108
|
+
continue
|
|
3109
|
+
try:
|
|
3110
|
+
resolved = Path(item).expanduser().resolve()
|
|
3111
|
+
except OSError:
|
|
3112
|
+
continue
|
|
3113
|
+
if resolved.is_dir():
|
|
3114
|
+
roots.add(str(resolved))
|
|
3115
|
+
return sorted(root for root in roots if root and Path(root).is_absolute())
|
|
3116
|
+
|
|
3117
|
+
|
|
3118
|
+
def parse_diff_files(diff_text: str) -> list[dict[str, Any]]:
|
|
3119
|
+
files: list[dict[str, Any]] = []
|
|
3120
|
+
current: dict[str, Any] | None = None
|
|
3121
|
+
for line in diff_text.splitlines():
|
|
3122
|
+
if line.startswith("diff --git "):
|
|
3123
|
+
parts = line.split()
|
|
3124
|
+
if len(parts) >= 4:
|
|
3125
|
+
path = parts[3][2:] if parts[3].startswith("b/") else parts[3]
|
|
3126
|
+
current = {"path": path, "status": "modified", "binary": False}
|
|
3127
|
+
files.append(current)
|
|
3128
|
+
elif current is not None and line.startswith("new file mode"):
|
|
3129
|
+
current["status"] = "added"
|
|
3130
|
+
elif current is not None and line.startswith("deleted file mode"):
|
|
3131
|
+
current["status"] = "deleted"
|
|
3132
|
+
elif current is not None and line.startswith("Binary files"):
|
|
3133
|
+
current["binary"] = True
|
|
3134
|
+
return files
|
|
3135
|
+
|
|
3136
|
+
|
|
3137
|
+
def start_reader_threads(session: ExecSession) -> None:
|
|
3138
|
+
def reader(stream: Any, append: Any) -> None:
|
|
3139
|
+
try:
|
|
3140
|
+
while True:
|
|
3141
|
+
chunk = os.read(stream.fileno(), 4096)
|
|
3142
|
+
if not chunk:
|
|
3143
|
+
break
|
|
3144
|
+
append(chunk)
|
|
3145
|
+
except Exception:
|
|
3146
|
+
return
|
|
3147
|
+
finally:
|
|
3148
|
+
try:
|
|
3149
|
+
stream.close()
|
|
3150
|
+
except OSError:
|
|
3151
|
+
pass
|
|
3152
|
+
|
|
3153
|
+
if session.process.stdout is not None:
|
|
3154
|
+
thread = threading.Thread(target=reader, args=(session.process.stdout, session.append_stdout), daemon=True)
|
|
3155
|
+
session.reader_threads.append(thread)
|
|
3156
|
+
thread.start()
|
|
3157
|
+
if session.process.stderr is not None:
|
|
3158
|
+
thread = threading.Thread(target=reader, args=(session.process.stderr, session.append_stderr), daemon=True)
|
|
3159
|
+
session.reader_threads.append(thread)
|
|
3160
|
+
thread.start()
|
|
3161
|
+
|
|
3162
|
+
|
|
3163
|
+
def start_session_watchdog(session: ExecSession) -> None:
|
|
3164
|
+
if session.timeout_at is None:
|
|
3165
|
+
return
|
|
3166
|
+
|
|
3167
|
+
def watchdog() -> None:
|
|
3168
|
+
delay = session.timeout_at - time.time() if session.timeout_at is not None else 0
|
|
3169
|
+
if delay > 0:
|
|
3170
|
+
time.sleep(delay)
|
|
3171
|
+
if session.process.poll() is not None or session.timed_out:
|
|
3172
|
+
return
|
|
3173
|
+
session.timed_out = True
|
|
3174
|
+
terminate_process_group(session.process, signal.SIGTERM)
|
|
3175
|
+
session.refresh_status()
|
|
3176
|
+
|
|
3177
|
+
threading.Thread(target=watchdog, daemon=True).start()
|
|
3178
|
+
|
|
3179
|
+
|
|
3180
|
+
def identify_image(data: bytes, path: Path) -> tuple[str | None, int | None, int | None]:
|
|
3181
|
+
if data.startswith(b"\x89PNG\r\n\x1a\n") and len(data) >= 24:
|
|
3182
|
+
width = int.from_bytes(data[16:20], "big")
|
|
3183
|
+
height = int.from_bytes(data[20:24], "big")
|
|
3184
|
+
return "image/png", width, height
|
|
3185
|
+
if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
|
|
3186
|
+
width = int.from_bytes(data[6:8], "little")
|
|
3187
|
+
height = int.from_bytes(data[8:10], "little")
|
|
3188
|
+
return "image/gif", width, height
|
|
3189
|
+
if data.startswith(b"\xff\xd8"):
|
|
3190
|
+
width, height = identify_jpeg_size(data)
|
|
3191
|
+
return "image/jpeg", width, height
|
|
3192
|
+
if data.startswith(b"RIFF") and len(data) >= 12 and data[8:12] == b"WEBP":
|
|
3193
|
+
width, height = identify_webp_size(data)
|
|
3194
|
+
return "image/webp", width, height
|
|
3195
|
+
guessed, _ = mimetypes.guess_type(path.name)
|
|
3196
|
+
if guessed and guessed.startswith("image/"):
|
|
3197
|
+
return guessed, None, None
|
|
3198
|
+
return None, None, None
|
|
3199
|
+
|
|
3200
|
+
|
|
3201
|
+
def identify_jpeg_size(data: bytes) -> tuple[int | None, int | None]:
|
|
3202
|
+
index = 2
|
|
3203
|
+
while index + 9 < len(data):
|
|
3204
|
+
while index < len(data) and data[index] == 0xFF:
|
|
3205
|
+
index += 1
|
|
3206
|
+
if index >= len(data):
|
|
3207
|
+
break
|
|
3208
|
+
marker = data[index]
|
|
3209
|
+
index += 1
|
|
3210
|
+
if marker in {0xD8, 0xD9}:
|
|
3211
|
+
continue
|
|
3212
|
+
if marker == 0xDA or index + 2 > len(data):
|
|
3213
|
+
break
|
|
3214
|
+
segment_length = int.from_bytes(data[index : index + 2], "big")
|
|
3215
|
+
if segment_length < 2 or index + segment_length > len(data):
|
|
3216
|
+
break
|
|
3217
|
+
if marker in {
|
|
3218
|
+
0xC0,
|
|
3219
|
+
0xC1,
|
|
3220
|
+
0xC2,
|
|
3221
|
+
0xC3,
|
|
3222
|
+
0xC5,
|
|
3223
|
+
0xC6,
|
|
3224
|
+
0xC7,
|
|
3225
|
+
0xC9,
|
|
3226
|
+
0xCA,
|
|
3227
|
+
0xCB,
|
|
3228
|
+
0xCD,
|
|
3229
|
+
0xCE,
|
|
3230
|
+
0xCF,
|
|
3231
|
+
} and segment_length >= 7:
|
|
3232
|
+
height = int.from_bytes(data[index + 3 : index + 5], "big")
|
|
3233
|
+
width = int.from_bytes(data[index + 5 : index + 7], "big")
|
|
3234
|
+
return width, height
|
|
3235
|
+
index += segment_length
|
|
3236
|
+
return None, None
|
|
3237
|
+
|
|
3238
|
+
|
|
3239
|
+
def identify_webp_size(data: bytes) -> tuple[int | None, int | None]:
|
|
3240
|
+
if len(data) < 30:
|
|
3241
|
+
return None, None
|
|
3242
|
+
chunk = data[12:16]
|
|
3243
|
+
if chunk == b"VP8X" and len(data) >= 30:
|
|
3244
|
+
width = int.from_bytes(data[24:27], "little") + 1
|
|
3245
|
+
height = int.from_bytes(data[27:30], "little") + 1
|
|
3246
|
+
return width, height
|
|
3247
|
+
if chunk == b"VP8 " and len(data) >= 30 and data[23:26] == b"\x9d\x01\x2a":
|
|
3248
|
+
width = int.from_bytes(data[26:28], "little") & 0x3FFF
|
|
3249
|
+
height = int.from_bytes(data[28:30], "little") & 0x3FFF
|
|
3250
|
+
return width, height
|
|
3251
|
+
if chunk == b"VP8L" and len(data) >= 25 and data[20] == 0x2F:
|
|
3252
|
+
bits = int.from_bytes(data[21:25], "little")
|
|
3253
|
+
width = (bits & 0x3FFF) + 1
|
|
3254
|
+
height = ((bits >> 14) & 0x3FFF) + 1
|
|
3255
|
+
return width, height
|
|
3256
|
+
return None, None
|
|
3257
|
+
|
|
3258
|
+
|
|
3259
|
+
def should_resize_image(
|
|
3260
|
+
size_bytes: int,
|
|
3261
|
+
width: int | None,
|
|
3262
|
+
height: int | None,
|
|
3263
|
+
max_bytes: int,
|
|
3264
|
+
max_width: int,
|
|
3265
|
+
max_height: int,
|
|
3266
|
+
) -> bool:
|
|
3267
|
+
if size_bytes > max_bytes:
|
|
3268
|
+
return True
|
|
3269
|
+
if width is not None and width > max_width:
|
|
3270
|
+
return True
|
|
3271
|
+
if height is not None and height > max_height:
|
|
3272
|
+
return True
|
|
3273
|
+
return False
|
|
3274
|
+
|
|
3275
|
+
|
|
3276
|
+
def resize_image_bytes(
|
|
3277
|
+
data: bytes,
|
|
3278
|
+
mime_type: str,
|
|
3279
|
+
*,
|
|
3280
|
+
max_width: int,
|
|
3281
|
+
max_height: int,
|
|
3282
|
+
max_bytes: int,
|
|
3283
|
+
) -> tuple[bytes, str] | None:
|
|
3284
|
+
try:
|
|
3285
|
+
from io import BytesIO
|
|
3286
|
+
from PIL import Image # type: ignore[import-not-found]
|
|
3287
|
+
except Exception:
|
|
3288
|
+
return None
|
|
3289
|
+
try:
|
|
3290
|
+
image = Image.open(BytesIO(data))
|
|
3291
|
+
image.thumbnail((max_width, max_height))
|
|
3292
|
+
output = BytesIO()
|
|
3293
|
+
output_format = "JPEG" if mime_type == "image/jpeg" else "PNG" if mime_type == "image/png" else "WEBP"
|
|
3294
|
+
save_kwargs: dict[str, Any] = {}
|
|
3295
|
+
if output_format in {"JPEG", "WEBP"}:
|
|
3296
|
+
save_kwargs["quality"] = 85
|
|
3297
|
+
save_kwargs["optimize"] = True
|
|
3298
|
+
if output_format == "JPEG" and image.mode not in {"RGB", "L"}:
|
|
3299
|
+
image = image.convert("RGB")
|
|
3300
|
+
image.save(output, format=output_format, **save_kwargs)
|
|
3301
|
+
resized = output.getvalue()
|
|
3302
|
+
if len(resized) > max_bytes and output_format in {"JPEG", "WEBP"}:
|
|
3303
|
+
for quality in (75, 65, 55):
|
|
3304
|
+
output = BytesIO()
|
|
3305
|
+
image.save(output, format=output_format, quality=quality, optimize=True)
|
|
3306
|
+
resized = output.getvalue()
|
|
3307
|
+
if len(resized) <= max_bytes:
|
|
3308
|
+
break
|
|
3309
|
+
return resized, mime_type
|
|
3310
|
+
except Exception:
|
|
3311
|
+
return None
|
|
3312
|
+
|
|
3313
|
+
|
|
3314
|
+
class JsonRpcError(Exception):
|
|
3315
|
+
def __init__(self, code: int, message: str, data: dict[str, Any] | None = None) -> None:
|
|
3316
|
+
super().__init__(message)
|
|
3317
|
+
self.code = code
|
|
3318
|
+
self.message = message
|
|
3319
|
+
self.data = data
|
|
3320
|
+
|
|
3321
|
+
|
|
3322
|
+
def invalid_request_response() -> dict[str, Any]:
|
|
3323
|
+
return {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}}
|
|
3324
|
+
|
|
3325
|
+
|
|
3326
|
+
def validate_rpc_envelope(request: dict[str, Any]) -> None:
|
|
3327
|
+
if request.get("jsonrpc") != "2.0":
|
|
3328
|
+
raise JsonRpcError(-32600, "Invalid Request: jsonrpc must be 2.0", {"reason": "jsonrpc_version"})
|
|
3329
|
+
method = request.get("method")
|
|
3330
|
+
if not isinstance(method, str) or not method:
|
|
3331
|
+
raise JsonRpcError(-32600, "Invalid Request: method must be a string", {"reason": "method"})
|
|
3332
|
+
if "id" in request and not (
|
|
3333
|
+
request["id"] is None
|
|
3334
|
+
or isinstance(request["id"], str)
|
|
3335
|
+
or (isinstance(request["id"], int) and not isinstance(request["id"], bool))
|
|
3336
|
+
):
|
|
3337
|
+
raise JsonRpcError(-32600, "Invalid Request: id must be string, integer, or null", {"reason": "id"})
|
|
3338
|
+
|
|
3339
|
+
|
|
3340
|
+
def rpc_params(request: dict[str, Any]) -> dict[str, Any]:
|
|
3341
|
+
params = request.get("params", {})
|
|
3342
|
+
if params is None:
|
|
3343
|
+
return {}
|
|
3344
|
+
if not isinstance(params, dict):
|
|
3345
|
+
raise JsonRpcError(-32602, "MCP method params must be an object")
|
|
3346
|
+
return params
|
|
3347
|
+
|
|
3348
|
+
|
|
3349
|
+
def validate_initialize_params(params: dict[str, Any]) -> None:
|
|
3350
|
+
requested = params.get("protocolVersion")
|
|
3351
|
+
if requested is None:
|
|
3352
|
+
return
|
|
3353
|
+
if not protocol_version_is_supported(requested):
|
|
3354
|
+
raise JsonRpcError(
|
|
3355
|
+
-32602,
|
|
3356
|
+
"Unsupported MCP protocol version",
|
|
3357
|
+
{"supported": [PROTOCOL_VERSION], "received": requested},
|
|
3358
|
+
)
|
|
3359
|
+
|
|
3360
|
+
|
|
3361
|
+
def protocol_version_is_supported(version: Any) -> bool:
|
|
3362
|
+
return isinstance(version, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", version) is not None and version >= PROTOCOL_VERSION
|
|
3363
|
+
|
|
3364
|
+
|
|
3365
|
+
def tool_result(payload: dict[str, Any], *, is_error: bool, content: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
3366
|
+
text = json.dumps(payload, sort_keys=True)
|
|
3367
|
+
result_content = content or []
|
|
3368
|
+
result_content.append({"type": "text", "text": text})
|
|
3369
|
+
return {"content": result_content, "structuredContent": payload, "isError": is_error}
|
|
3370
|
+
|
|
3371
|
+
|
|
3372
|
+
def object_schema(properties: dict[str, Any] | None = None, required: list[str] | None = None) -> dict[str, Any]:
|
|
3373
|
+
return {
|
|
3374
|
+
"type": "object",
|
|
3375
|
+
"properties": properties or {},
|
|
3376
|
+
"required": required or [],
|
|
3377
|
+
"additionalProperties": False,
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
|
|
3381
|
+
def tool_output_schema() -> dict[str, Any]:
|
|
3382
|
+
return {
|
|
3383
|
+
"type": "object",
|
|
3384
|
+
"properties": {
|
|
3385
|
+
"ok": {"type": "boolean"},
|
|
3386
|
+
"error": {
|
|
3387
|
+
"type": "object",
|
|
3388
|
+
"properties": {
|
|
3389
|
+
"code": {"type": "string"},
|
|
3390
|
+
"message": {"type": "string"},
|
|
3391
|
+
"category": {"type": "string"},
|
|
3392
|
+
"retryable": {"type": "boolean"},
|
|
3393
|
+
"details": {"type": "object", "additionalProperties": True},
|
|
3394
|
+
},
|
|
3395
|
+
"required": ["code", "message", "category", "retryable", "details"],
|
|
3396
|
+
"additionalProperties": True,
|
|
3397
|
+
},
|
|
3398
|
+
},
|
|
3399
|
+
"required": ["ok"],
|
|
3400
|
+
"additionalProperties": True,
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
|
|
3404
|
+
def validate_arguments(tool_name: str, args: dict[str, Any]) -> None:
|
|
3405
|
+
schema = input_schemas()[tool_name]
|
|
3406
|
+
try:
|
|
3407
|
+
validate_schema_value(args, schema, path="arguments")
|
|
3408
|
+
except ToolFailure as exc:
|
|
3409
|
+
raise JsonRpcError(-32602, exc.message, {"reason": "invalid_arguments", "code": exc.code}) from exc
|
|
3410
|
+
|
|
3411
|
+
|
|
3412
|
+
def validate_schema_value(value: Any, schema: dict[str, Any], *, path: str) -> None:
|
|
3413
|
+
expected_type = schema.get("type")
|
|
3414
|
+
if expected_type is not None and not schema_type_matches(value, expected_type):
|
|
3415
|
+
raise ToolFailure("INVALID_ARGUMENT", f"{path} must be {schema_type_name(expected_type)}.", category="validation")
|
|
3416
|
+
|
|
3417
|
+
if isinstance(value, str):
|
|
3418
|
+
min_length = schema.get("minLength")
|
|
3419
|
+
if isinstance(min_length, int) and len(value) < min_length:
|
|
3420
|
+
raise ToolFailure("INVALID_ARGUMENT", f"{path} is shorter than {min_length}.", category="validation")
|
|
3421
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
3422
|
+
raise ToolFailure("INVALID_ARGUMENT", f"{path} must be one of {schema['enum']!r}.", category="validation")
|
|
3423
|
+
|
|
3424
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
3425
|
+
minimum = schema.get("minimum")
|
|
3426
|
+
maximum = schema.get("maximum")
|
|
3427
|
+
if isinstance(minimum, (int, float)) and value < minimum:
|
|
3428
|
+
raise ToolFailure("INVALID_ARGUMENT", f"{path} must be >= {minimum}.", category="validation")
|
|
3429
|
+
if isinstance(maximum, (int, float)) and value > maximum:
|
|
3430
|
+
raise ToolFailure("INVALID_ARGUMENT", f"{path} must be <= {maximum}.", category="validation")
|
|
3431
|
+
|
|
3432
|
+
if isinstance(value, list) and isinstance(schema.get("items"), dict):
|
|
3433
|
+
item_schema = schema["items"]
|
|
3434
|
+
for index, item in enumerate(value):
|
|
3435
|
+
validate_schema_value(item, item_schema, path=f"{path}[{index}]")
|
|
3436
|
+
|
|
3437
|
+
if isinstance(value, dict):
|
|
3438
|
+
properties = schema.get("properties", {})
|
|
3439
|
+
required = schema.get("required", [])
|
|
3440
|
+
for key in required:
|
|
3441
|
+
if key not in value:
|
|
3442
|
+
raise ToolFailure("INVALID_ARGUMENT", f"{path}.{key} is required.", category="validation")
|
|
3443
|
+
additional = schema.get("additionalProperties", True)
|
|
3444
|
+
for key, item in value.items():
|
|
3445
|
+
child_path = f"{path}.{key}"
|
|
3446
|
+
if key in properties:
|
|
3447
|
+
validate_schema_value(item, properties[key], path=child_path)
|
|
3448
|
+
elif additional is False:
|
|
3449
|
+
raise ToolFailure("INVALID_ARGUMENT", f"{child_path} is not a recognized argument.", category="validation")
|
|
3450
|
+
elif isinstance(additional, dict):
|
|
3451
|
+
validate_schema_value(item, additional, path=child_path)
|
|
3452
|
+
|
|
3453
|
+
|
|
3454
|
+
def schema_type_matches(value: Any, expected_type: str | list[str]) -> bool:
|
|
3455
|
+
if isinstance(expected_type, list):
|
|
3456
|
+
return any(schema_type_matches(value, item) for item in expected_type)
|
|
3457
|
+
if expected_type == "array":
|
|
3458
|
+
return isinstance(value, list)
|
|
3459
|
+
if expected_type == "boolean":
|
|
3460
|
+
return isinstance(value, bool)
|
|
3461
|
+
if expected_type == "integer":
|
|
3462
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
3463
|
+
if expected_type == "null":
|
|
3464
|
+
return value is None
|
|
3465
|
+
if expected_type == "number":
|
|
3466
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
3467
|
+
if expected_type == "object":
|
|
3468
|
+
return isinstance(value, dict)
|
|
3469
|
+
if expected_type == "string":
|
|
3470
|
+
return isinstance(value, str)
|
|
3471
|
+
return False
|
|
3472
|
+
|
|
3473
|
+
|
|
3474
|
+
def schema_type_name(expected_type: str | list[str]) -> str:
|
|
3475
|
+
if isinstance(expected_type, list):
|
|
3476
|
+
return " or ".join(expected_type)
|
|
3477
|
+
return expected_type
|
|
3478
|
+
|
|
3479
|
+
|
|
3480
|
+
def tool_definition(name: str, *, tool_profile: str = "full") -> dict[str, Any]:
|
|
3481
|
+
schemas = input_schemas()
|
|
3482
|
+
annotations = tool_annotations(name)
|
|
3483
|
+
if tool_profile == "compat-readonly-all":
|
|
3484
|
+
annotations = {
|
|
3485
|
+
**annotations,
|
|
3486
|
+
"readOnlyHint": True,
|
|
3487
|
+
"destructiveHint": False,
|
|
3488
|
+
"openWorldHint": False,
|
|
3489
|
+
}
|
|
3490
|
+
descriptions = {
|
|
3491
|
+
"server_info": "Return server, workspace, auth, profile, and exposed-tool metadata.",
|
|
3492
|
+
"get_default_cwd": "Return the current default cwd inside the workspace.",
|
|
3493
|
+
"set_default_cwd": "Set the default cwd for relative tool paths inside the workspace.",
|
|
3494
|
+
"read_file": "Read a UTF-8 text file slice inside the configured workspace.",
|
|
3495
|
+
"list_dir": "List directory entries inside the configured workspace.",
|
|
3496
|
+
"list_files": "List workspace files using glob filters.",
|
|
3497
|
+
"search_text": "Search UTF-8 workspace files for text or regex matches.",
|
|
3498
|
+
"apply_patch": "Apply a patch envelope transactionally inside the workspace.",
|
|
3499
|
+
"exec_command": "Run a bounded command in the workspace under runtime policy.",
|
|
3500
|
+
"write_stdin": "Write characters to a server-managed running command session.",
|
|
3501
|
+
"kill_session": "Terminate a server-managed running command session.",
|
|
3502
|
+
"git_status": "Return git working tree status for the workspace.",
|
|
3503
|
+
"git_diff": "Return unified git diff for workspace changes.",
|
|
3504
|
+
"git_log": "Return recent git commits with bounded structured metadata.",
|
|
3505
|
+
"git_show": "Return bounded git show output for a revision.",
|
|
3506
|
+
"git_blame": "Return bounded git blame metadata for a workspace file.",
|
|
3507
|
+
"request_permissions": "Request a scoped permission grant for dangerous runtime operations.",
|
|
3508
|
+
"view_image": "Return a workspace image as MCP image content.",
|
|
3509
|
+
}
|
|
3510
|
+
return {
|
|
3511
|
+
"name": name,
|
|
3512
|
+
"title": annotations["title"],
|
|
3513
|
+
"description": descriptions[name],
|
|
3514
|
+
"inputSchema": schemas[name],
|
|
3515
|
+
"outputSchema": tool_output_schema(),
|
|
3516
|
+
"annotations": annotations,
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
|
|
3520
|
+
def tool_annotations(name: str) -> dict[str, Any]:
|
|
3521
|
+
read_only = name in {
|
|
3522
|
+
"server_info",
|
|
3523
|
+
"get_default_cwd",
|
|
3524
|
+
"set_default_cwd",
|
|
3525
|
+
"read_file",
|
|
3526
|
+
"list_dir",
|
|
3527
|
+
"list_files",
|
|
3528
|
+
"search_text",
|
|
3529
|
+
"git_status",
|
|
3530
|
+
"git_diff",
|
|
3531
|
+
"git_log",
|
|
3532
|
+
"git_show",
|
|
3533
|
+
"git_blame",
|
|
3534
|
+
"request_permissions",
|
|
3535
|
+
"view_image",
|
|
3536
|
+
}
|
|
3537
|
+
destructive = name in {"apply_patch", "exec_command", "kill_session"}
|
|
3538
|
+
idempotent = name in {
|
|
3539
|
+
"server_info",
|
|
3540
|
+
"get_default_cwd",
|
|
3541
|
+
"set_default_cwd",
|
|
3542
|
+
"read_file",
|
|
3543
|
+
"list_dir",
|
|
3544
|
+
"list_files",
|
|
3545
|
+
"search_text",
|
|
3546
|
+
"git_status",
|
|
3547
|
+
"git_diff",
|
|
3548
|
+
"git_log",
|
|
3549
|
+
"git_show",
|
|
3550
|
+
"git_blame",
|
|
3551
|
+
"view_image",
|
|
3552
|
+
}
|
|
3553
|
+
open_world = name == "exec_command"
|
|
3554
|
+
titles = {
|
|
3555
|
+
"server_info": "Server info",
|
|
3556
|
+
"get_default_cwd": "Get default cwd",
|
|
3557
|
+
"set_default_cwd": "Set default cwd",
|
|
3558
|
+
"read_file": "Read file",
|
|
3559
|
+
"list_dir": "List directory",
|
|
3560
|
+
"list_files": "List files",
|
|
3561
|
+
"search_text": "Search text",
|
|
3562
|
+
"apply_patch": "Apply patch",
|
|
3563
|
+
"exec_command": "Execute command",
|
|
3564
|
+
"write_stdin": "Write stdin",
|
|
3565
|
+
"kill_session": "Kill session",
|
|
3566
|
+
"git_status": "Git status",
|
|
3567
|
+
"git_diff": "Git diff",
|
|
3568
|
+
"git_log": "Git log",
|
|
3569
|
+
"git_show": "Git show",
|
|
3570
|
+
"git_blame": "Git blame",
|
|
3571
|
+
"request_permissions": "Request permissions",
|
|
3572
|
+
"view_image": "View image",
|
|
3573
|
+
}
|
|
3574
|
+
return {
|
|
3575
|
+
"title": titles[name],
|
|
3576
|
+
"readOnlyHint": read_only,
|
|
3577
|
+
"destructiveHint": destructive,
|
|
3578
|
+
"idempotentHint": idempotent,
|
|
3579
|
+
"openWorldHint": open_world,
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
|
|
3583
|
+
def input_schemas() -> dict[str, dict[str, Any]]:
|
|
3584
|
+
string = {"type": "string"}
|
|
3585
|
+
integer = {"type": "integer"}
|
|
3586
|
+
boolean = {"type": "boolean"}
|
|
3587
|
+
string_array = {"type": "array", "items": {"type": "string"}}
|
|
3588
|
+
return {
|
|
3589
|
+
"server_info": object_schema(),
|
|
3590
|
+
"get_default_cwd": object_schema(),
|
|
3591
|
+
"set_default_cwd": object_schema(
|
|
3592
|
+
{
|
|
3593
|
+
"path": {**string, "default": "."},
|
|
3594
|
+
}
|
|
3595
|
+
),
|
|
3596
|
+
"read_file": object_schema(
|
|
3597
|
+
{
|
|
3598
|
+
"path": {**string, "minLength": 1},
|
|
3599
|
+
"start_line": {**integer, "minimum": 1, "default": 1},
|
|
3600
|
+
"end_line": {**integer, "minimum": 1},
|
|
3601
|
+
"max_bytes": {**integer, "minimum": 1, "maximum": 1048576, "default": 131072},
|
|
3602
|
+
"encoding": {**string, "enum": ["utf-8"], "default": "utf-8"},
|
|
3603
|
+
},
|
|
3604
|
+
["path"],
|
|
3605
|
+
),
|
|
3606
|
+
"list_dir": object_schema(
|
|
3607
|
+
{
|
|
3608
|
+
"path": {**string, "default": "."},
|
|
3609
|
+
"recursive": {**boolean, "default": False},
|
|
3610
|
+
"max_depth": {**integer, "minimum": 1, "maximum": 20, "default": 1},
|
|
3611
|
+
"max_entries": {**integer, "minimum": 1, "maximum": 10000, "default": 1000},
|
|
3612
|
+
"include_hidden": {**boolean, "default": False},
|
|
3613
|
+
"include_ignored": {**boolean, "default": False},
|
|
3614
|
+
"sort": {**string, "enum": ["name", "type", "modified"], "default": "name"},
|
|
3615
|
+
}
|
|
3616
|
+
),
|
|
3617
|
+
"list_files": object_schema(
|
|
3618
|
+
{
|
|
3619
|
+
"path": {**string, "default": "."},
|
|
3620
|
+
"patterns": string_array,
|
|
3621
|
+
"glob": string,
|
|
3622
|
+
"exclude_patterns": string_array,
|
|
3623
|
+
"include_hidden": {**boolean, "default": False},
|
|
3624
|
+
"include_ignored": {**boolean, "default": False},
|
|
3625
|
+
"max_results": {**integer, "minimum": 1, "maximum": 50000, "default": 5000},
|
|
3626
|
+
"sort": {**string, "enum": ["path", "modified"], "default": "path"},
|
|
3627
|
+
}
|
|
3628
|
+
),
|
|
3629
|
+
"search_text": object_schema(
|
|
3630
|
+
{
|
|
3631
|
+
"query": {**string, "minLength": 1},
|
|
3632
|
+
"path": {**string, "default": "."},
|
|
3633
|
+
"regex": {**boolean, "default": False},
|
|
3634
|
+
"case_sensitive": {**boolean, "default": False},
|
|
3635
|
+
"include_globs": string_array,
|
|
3636
|
+
"glob": string,
|
|
3637
|
+
"exclude_globs": string_array,
|
|
3638
|
+
"context_lines": {**integer, "minimum": 0, "maximum": 5, "default": 0},
|
|
3639
|
+
"max_results": {**integer, "minimum": 1, "maximum": 10000, "default": 1000},
|
|
3640
|
+
"max_preview_bytes": {**integer, "minimum": 80, "maximum": 4096, "default": 512},
|
|
3641
|
+
},
|
|
3642
|
+
["query"],
|
|
3643
|
+
),
|
|
3644
|
+
"apply_patch": object_schema({"patch": {**string, "minLength": 1}, "dry_run": {**boolean, "default": False}}, ["patch"]),
|
|
3645
|
+
"exec_command": object_schema(
|
|
3646
|
+
{
|
|
3647
|
+
"cmd": {**string, "minLength": 1},
|
|
3648
|
+
"workdir": {**string, "default": "."},
|
|
3649
|
+
"timeout_ms": {**integer, "minimum": 1, "maximum": 600000, "default": 30000},
|
|
3650
|
+
"yield_time_ms": {**integer, "minimum": 0, "maximum": 30000, "default": 1000},
|
|
3651
|
+
"max_output_bytes": {**integer, "minimum": 1, "maximum": 1048576, "default": 65536},
|
|
3652
|
+
"stdin": {**string, "default": ""},
|
|
3653
|
+
"tty": {**boolean, "default": False},
|
|
3654
|
+
"env": {"type": "object", "additionalProperties": {"type": "string"}, "default": {}},
|
|
3655
|
+
},
|
|
3656
|
+
["cmd"],
|
|
3657
|
+
),
|
|
3658
|
+
"write_stdin": object_schema(
|
|
3659
|
+
{
|
|
3660
|
+
"session_id": {**string, "minLength": 1},
|
|
3661
|
+
"chars": {**string, "default": ""},
|
|
3662
|
+
"yield_time_ms": {**integer, "minimum": 0, "maximum": 30000, "default": 1000},
|
|
3663
|
+
"max_output_bytes": {**integer, "minimum": 1, "maximum": 1048576, "default": 65536},
|
|
3664
|
+
},
|
|
3665
|
+
["session_id"],
|
|
3666
|
+
),
|
|
3667
|
+
"kill_session": object_schema(
|
|
3668
|
+
{
|
|
3669
|
+
"session_id": {**string, "minLength": 1},
|
|
3670
|
+
"signal": {**string, "enum": ["TERM", "KILL", "INT"], "default": "TERM"},
|
|
3671
|
+
"wait_ms": {**integer, "minimum": 0, "maximum": 30000, "default": 5000},
|
|
3672
|
+
"max_output_bytes": {**integer, "minimum": 1, "maximum": 1048576, "default": 65536},
|
|
3673
|
+
},
|
|
3674
|
+
["session_id"],
|
|
3675
|
+
),
|
|
3676
|
+
"git_status": object_schema(
|
|
3677
|
+
{
|
|
3678
|
+
"path": {**string, "default": "."},
|
|
3679
|
+
"include_untracked": {**boolean, "default": True},
|
|
3680
|
+
"max_entries": {**integer, "minimum": 1, "maximum": 10000, "default": 1000},
|
|
3681
|
+
}
|
|
3682
|
+
),
|
|
3683
|
+
"git_diff": object_schema(
|
|
3684
|
+
{
|
|
3685
|
+
"path": string,
|
|
3686
|
+
"paths": string_array,
|
|
3687
|
+
"staged": {**boolean, "default": False},
|
|
3688
|
+
"unstaged": {**boolean, "default": True},
|
|
3689
|
+
"context_lines": {**integer, "minimum": 0, "maximum": 20, "default": 3},
|
|
3690
|
+
"max_bytes": {**integer, "minimum": 1, "maximum": 1048576, "default": 262144},
|
|
3691
|
+
}
|
|
3692
|
+
),
|
|
3693
|
+
"git_log": object_schema(
|
|
3694
|
+
{
|
|
3695
|
+
"path": {**string, "default": "."},
|
|
3696
|
+
"ref": {**string, "default": "HEAD"},
|
|
3697
|
+
"max_count": {**integer, "minimum": 1, "maximum": 100, "default": 20},
|
|
3698
|
+
"skip": {**integer, "minimum": 0, "maximum": 10000, "default": 0},
|
|
3699
|
+
}
|
|
3700
|
+
),
|
|
3701
|
+
"git_show": object_schema(
|
|
3702
|
+
{
|
|
3703
|
+
"rev": {**string, "default": "HEAD"},
|
|
3704
|
+
"path": string,
|
|
3705
|
+
"paths": string_array,
|
|
3706
|
+
"include_diff": {**boolean, "default": True},
|
|
3707
|
+
"context_lines": {**integer, "minimum": 0, "maximum": 20, "default": 3},
|
|
3708
|
+
"max_bytes": {**integer, "minimum": 1, "maximum": 1048576, "default": 262144},
|
|
3709
|
+
}
|
|
3710
|
+
),
|
|
3711
|
+
"git_blame": object_schema(
|
|
3712
|
+
{
|
|
3713
|
+
"path": {**string, "minLength": 1},
|
|
3714
|
+
"rev": string,
|
|
3715
|
+
"start_line": {**integer, "minimum": 1, "default": 1},
|
|
3716
|
+
"end_line": {**integer, "minimum": 1},
|
|
3717
|
+
"max_lines": {**integer, "minimum": 1, "maximum": 1000, "default": 200},
|
|
3718
|
+
},
|
|
3719
|
+
["path"],
|
|
3720
|
+
),
|
|
3721
|
+
"request_permissions": object_schema(
|
|
3722
|
+
{
|
|
3723
|
+
"tool_name": {**string, "enum": ["exec_command", "apply_patch"]},
|
|
3724
|
+
"permission": {
|
|
3725
|
+
**string,
|
|
3726
|
+
"enum": [
|
|
3727
|
+
"network",
|
|
3728
|
+
"destructive_command",
|
|
3729
|
+
"long_timeout",
|
|
3730
|
+
"sensitive_env",
|
|
3731
|
+
"shell_expansion",
|
|
3732
|
+
INLINE_SCRIPT_PERMISSION,
|
|
3733
|
+
"privileged_executable",
|
|
3734
|
+
"write_generated_or_ignored",
|
|
3735
|
+
],
|
|
3736
|
+
},
|
|
3737
|
+
"reason": {**string, "minLength": 1},
|
|
3738
|
+
"arguments": {"type": "object", "additionalProperties": True},
|
|
3739
|
+
"scope": {**string, "enum": ["once", "session"], "default": "once"},
|
|
3740
|
+
"ttl_seconds": {**integer, "minimum": 1, "maximum": 3600, "default": 300},
|
|
3741
|
+
},
|
|
3742
|
+
["tool_name", "permission", "reason", "arguments"],
|
|
3743
|
+
),
|
|
3744
|
+
"view_image": object_schema(
|
|
3745
|
+
{
|
|
3746
|
+
"path": {**string, "minLength": 1},
|
|
3747
|
+
"max_bytes": {**integer, "minimum": 1024, "maximum": 10485760, "default": 5242880},
|
|
3748
|
+
"max_width": {**integer, "minimum": 1, "maximum": 10000, "default": IMAGE_RESIZE_MAX_DIMENSION},
|
|
3749
|
+
"max_height": {**integer, "minimum": 1, "maximum": 10000, "default": IMAGE_RESIZE_MAX_DIMENSION},
|
|
3750
|
+
"auto_resize": {**boolean, "default": True},
|
|
3751
|
+
"output": {**string, "enum": ["mcp_image", "data_url"], "default": "mcp_image"},
|
|
3752
|
+
},
|
|
3753
|
+
["path"],
|
|
3754
|
+
),
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3757
|
+
|
|
3758
|
+
def server_card_payload(runtime: Runtime) -> dict[str, Any]:
|
|
3759
|
+
names = runtime.exposed_tool_names()
|
|
3760
|
+
annotations = {name: tool_definition(name, tool_profile=runtime.tool_profile)["annotations"] for name in names}
|
|
3761
|
+
read_only = [name for name in names if annotations[name].get("readOnlyHint") is True]
|
|
3762
|
+
mutating = [name for name in names if annotations[name].get("readOnlyHint") is not True]
|
|
3763
|
+
payload = {
|
|
3764
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
3765
|
+
"server": {
|
|
3766
|
+
"name": SERVER_NAME,
|
|
3767
|
+
"title": "Coding Tools MCP",
|
|
3768
|
+
"version": __version__,
|
|
3769
|
+
},
|
|
3770
|
+
"transport": {
|
|
3771
|
+
"type": "streamable_http",
|
|
3772
|
+
"endpoint": "/mcp",
|
|
3773
|
+
"methods": ["GET", "HEAD", "POST", "OPTIONS"],
|
|
3774
|
+
},
|
|
3775
|
+
"auth": {
|
|
3776
|
+
"type": "bearer" if runtime.auth_enabled() else "none",
|
|
3777
|
+
"scheme": "Bearer" if runtime.auth_enabled() else None,
|
|
3778
|
+
"header": "Authorization" if runtime.auth_enabled() else None,
|
|
3779
|
+
},
|
|
3780
|
+
"toolProfile": runtime.tool_profile,
|
|
3781
|
+
"tools": {
|
|
3782
|
+
"count": len(names),
|
|
3783
|
+
"names": names,
|
|
3784
|
+
"readOnlyHintTrue": read_only,
|
|
3785
|
+
"readOnlyHintFalse": mutating,
|
|
3786
|
+
},
|
|
3787
|
+
"capabilities": {
|
|
3788
|
+
"tools": {"listChanged": False},
|
|
3789
|
+
"logging": {},
|
|
3790
|
+
},
|
|
3791
|
+
}
|
|
3792
|
+
if runtime.tool_profile == "compat-readonly-all":
|
|
3793
|
+
payload["warnings"] = [
|
|
3794
|
+
"compat-readonly-all advertises every tool as read-only, but mutation-capable tools still mutate local state."
|
|
3795
|
+
]
|
|
3796
|
+
return payload
|
|
3797
|
+
|
|
3798
|
+
|
|
3799
|
+
class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|
3800
|
+
server_version = "CodingToolsMCP/0.1"
|
|
3801
|
+
|
|
3802
|
+
@property
|
|
3803
|
+
def runtime(self) -> Runtime:
|
|
3804
|
+
return self.server.runtime # type: ignore[attr-defined]
|
|
3805
|
+
|
|
3806
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
3807
|
+
print(format % args, file=sys.stderr)
|
|
3808
|
+
|
|
3809
|
+
def do_GET(self) -> None:
|
|
3810
|
+
self.handle_metadata_request(head_only=False)
|
|
3811
|
+
|
|
3812
|
+
def do_HEAD(self) -> None:
|
|
3813
|
+
self.handle_metadata_request(head_only=True)
|
|
3814
|
+
|
|
3815
|
+
def do_OPTIONS(self) -> None:
|
|
3816
|
+
request_path = self.path.split("?", 1)[0]
|
|
3817
|
+
if posixpath.normpath(request_path) not in {"/mcp", "/.well-known/mcp.json", "/.well-known/mcp/server-card.json"}:
|
|
3818
|
+
self.send_json({"error": "Unknown endpoint"}, status=404)
|
|
3819
|
+
return
|
|
3820
|
+
origin = self.headers.get("Origin")
|
|
3821
|
+
if origin and not is_allowed_origin(origin, auth_enabled=self.runtime.auth_enabled()):
|
|
3822
|
+
self.send_json({"error": "Origin denied"}, status=403)
|
|
3823
|
+
return
|
|
3824
|
+
self.send_response(204)
|
|
3825
|
+
self.send_header("Allow", "GET, HEAD, POST, OPTIONS")
|
|
3826
|
+
self.send_cors_headers()
|
|
3827
|
+
self.end_headers()
|
|
3828
|
+
|
|
3829
|
+
def handle_metadata_request(self, *, head_only: bool) -> None:
|
|
3830
|
+
request_path = self.path.split("?", 1)[0]
|
|
3831
|
+
normalized = posixpath.normpath(request_path)
|
|
3832
|
+
if normalized == "/mcp":
|
|
3833
|
+
origin = self.headers.get("Origin")
|
|
3834
|
+
if origin and not is_allowed_origin(origin, auth_enabled=self.runtime.auth_enabled()):
|
|
3835
|
+
self.send_json({"error": "Origin denied"}, status=403, head_only=head_only)
|
|
3836
|
+
return
|
|
3837
|
+
if not self.is_authorized():
|
|
3838
|
+
self.send_unauthorized(head_only=head_only)
|
|
3839
|
+
return
|
|
3840
|
+
self.send_json(server_card_payload(self.runtime), head_only=head_only)
|
|
3841
|
+
return
|
|
3842
|
+
if normalized in {"/.well-known/mcp.json", "/.well-known/mcp/server-card.json"}:
|
|
3843
|
+
self.send_json(server_card_payload(self.runtime), head_only=head_only)
|
|
3844
|
+
return
|
|
3845
|
+
self.send_json({"error": "Unknown endpoint"}, status=404, head_only=head_only)
|
|
3846
|
+
|
|
3847
|
+
def do_POST(self) -> None:
|
|
3848
|
+
request_path = self.path.split("?", 1)[0]
|
|
3849
|
+
if posixpath.normpath(request_path) != "/mcp":
|
|
3850
|
+
self.send_json({"jsonrpc": "2.0", "id": None, "error": {"code": -32601, "message": "Unknown endpoint"}}, status=404)
|
|
3851
|
+
return
|
|
3852
|
+
origin = self.headers.get("Origin")
|
|
3853
|
+
if origin and not is_allowed_origin(origin, auth_enabled=self.runtime.auth_enabled()):
|
|
3854
|
+
self.send_json({"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Origin denied"}}, status=403)
|
|
3855
|
+
return
|
|
3856
|
+
if not self.is_authorized():
|
|
3857
|
+
self.send_unauthorized()
|
|
3858
|
+
return
|
|
3859
|
+
if self.headers.get_content_type().lower() != "application/json":
|
|
3860
|
+
self.send_json(
|
|
3861
|
+
{
|
|
3862
|
+
"jsonrpc": "2.0",
|
|
3863
|
+
"id": None,
|
|
3864
|
+
"error": {"code": -32600, "message": "Content-Type must be application/json"},
|
|
3865
|
+
},
|
|
3866
|
+
status=415,
|
|
3867
|
+
)
|
|
3868
|
+
return
|
|
3869
|
+
protocol_version = self.headers.get("MCP-Protocol-Version")
|
|
3870
|
+
if protocol_version and not protocol_version_is_supported(protocol_version):
|
|
3871
|
+
self.send_json(
|
|
3872
|
+
{
|
|
3873
|
+
"jsonrpc": "2.0",
|
|
3874
|
+
"id": None,
|
|
3875
|
+
"error": {
|
|
3876
|
+
"code": -32600,
|
|
3877
|
+
"message": "Unsupported MCP protocol version",
|
|
3878
|
+
"data": {"supported": [PROTOCOL_VERSION], "received": protocol_version},
|
|
3879
|
+
},
|
|
3880
|
+
},
|
|
3881
|
+
status=400,
|
|
3882
|
+
)
|
|
3883
|
+
return
|
|
3884
|
+
session_id = self.headers.get("Mcp-Session-Id")
|
|
3885
|
+
if session_id and session_id != self.runtime.http_session_id:
|
|
3886
|
+
self.send_json(
|
|
3887
|
+
{
|
|
3888
|
+
"jsonrpc": "2.0",
|
|
3889
|
+
"id": None,
|
|
3890
|
+
"error": {
|
|
3891
|
+
"code": -32001,
|
|
3892
|
+
"message": "Unknown MCP session",
|
|
3893
|
+
},
|
|
3894
|
+
},
|
|
3895
|
+
status=404,
|
|
3896
|
+
)
|
|
3897
|
+
return
|
|
3898
|
+
raw_length = self.headers.get("Content-Length")
|
|
3899
|
+
if raw_length is None:
|
|
3900
|
+
self.send_json(
|
|
3901
|
+
{
|
|
3902
|
+
"jsonrpc": "2.0",
|
|
3903
|
+
"id": None,
|
|
3904
|
+
"error": {"code": -32600, "message": "Content-Length is required"},
|
|
3905
|
+
},
|
|
3906
|
+
status=411,
|
|
3907
|
+
)
|
|
3908
|
+
return
|
|
3909
|
+
try:
|
|
3910
|
+
length = int(raw_length)
|
|
3911
|
+
except ValueError:
|
|
3912
|
+
self.send_json(
|
|
3913
|
+
{
|
|
3914
|
+
"jsonrpc": "2.0",
|
|
3915
|
+
"id": None,
|
|
3916
|
+
"error": {"code": -32600, "message": "Content-Length must be a non-negative integer"},
|
|
3917
|
+
},
|
|
3918
|
+
status=400,
|
|
3919
|
+
)
|
|
3920
|
+
return
|
|
3921
|
+
if length < 0:
|
|
3922
|
+
self.send_json(
|
|
3923
|
+
{
|
|
3924
|
+
"jsonrpc": "2.0",
|
|
3925
|
+
"id": None,
|
|
3926
|
+
"error": {"code": -32600, "message": "Content-Length must be a non-negative integer"},
|
|
3927
|
+
},
|
|
3928
|
+
status=400,
|
|
3929
|
+
)
|
|
3930
|
+
return
|
|
3931
|
+
if length > MAX_HTTP_REQUEST_BYTES:
|
|
3932
|
+
self.close_connection = True
|
|
3933
|
+
self.send_json(
|
|
3934
|
+
{
|
|
3935
|
+
"jsonrpc": "2.0",
|
|
3936
|
+
"id": None,
|
|
3937
|
+
"error": {
|
|
3938
|
+
"code": -32600,
|
|
3939
|
+
"message": "Request body exceeds maximum size",
|
|
3940
|
+
"data": {"max_bytes": MAX_HTTP_REQUEST_BYTES},
|
|
3941
|
+
},
|
|
3942
|
+
},
|
|
3943
|
+
status=413,
|
|
3944
|
+
)
|
|
3945
|
+
return
|
|
3946
|
+
body = self.rfile.read(length)
|
|
3947
|
+
try:
|
|
3948
|
+
request = json.loads(body.decode("utf-8"))
|
|
3949
|
+
except json.JSONDecodeError:
|
|
3950
|
+
self.send_json({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}}, status=400)
|
|
3951
|
+
return
|
|
3952
|
+
if isinstance(request, list):
|
|
3953
|
+
if not request:
|
|
3954
|
+
self.send_json({"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}}, status=400)
|
|
3955
|
+
return
|
|
3956
|
+
if len(request) > MAX_JSON_RPC_BATCH_ITEMS:
|
|
3957
|
+
self.send_json(
|
|
3958
|
+
{
|
|
3959
|
+
"jsonrpc": "2.0",
|
|
3960
|
+
"id": None,
|
|
3961
|
+
"error": {
|
|
3962
|
+
"code": -32600,
|
|
3963
|
+
"message": "Batch request exceeds maximum item count",
|
|
3964
|
+
"data": {"max_items": MAX_JSON_RPC_BATCH_ITEMS},
|
|
3965
|
+
},
|
|
3966
|
+
},
|
|
3967
|
+
status=400,
|
|
3968
|
+
)
|
|
3969
|
+
return
|
|
3970
|
+
responses: list[dict[str, Any]] = []
|
|
3971
|
+
for item in request:
|
|
3972
|
+
if not isinstance(item, dict):
|
|
3973
|
+
responses.append({"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}})
|
|
3974
|
+
continue
|
|
3975
|
+
response = self.handle_rpc(item)
|
|
3976
|
+
if response is not None:
|
|
3977
|
+
responses.append(response)
|
|
3978
|
+
if not responses:
|
|
3979
|
+
self.send_response(202)
|
|
3980
|
+
self.send_header("Mcp-Session-Id", self.runtime.http_session_id)
|
|
3981
|
+
self.send_cors_headers()
|
|
3982
|
+
self.end_headers()
|
|
3983
|
+
return
|
|
3984
|
+
self.send_json(responses)
|
|
3985
|
+
return
|
|
3986
|
+
if not isinstance(request, dict):
|
|
3987
|
+
self.send_json({"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}}, status=400)
|
|
3988
|
+
return
|
|
3989
|
+
response = self.handle_rpc(request)
|
|
3990
|
+
if response is None:
|
|
3991
|
+
self.send_response(202)
|
|
3992
|
+
self.send_header("Mcp-Session-Id", self.runtime.http_session_id)
|
|
3993
|
+
self.send_cors_headers()
|
|
3994
|
+
self.end_headers()
|
|
3995
|
+
return
|
|
3996
|
+
self.send_json(response)
|
|
3997
|
+
|
|
3998
|
+
def handle_rpc(self, request: dict[str, Any]) -> dict[str, Any] | None:
|
|
3999
|
+
request_id = request.get("id")
|
|
4000
|
+
try:
|
|
4001
|
+
validate_rpc_envelope(request)
|
|
4002
|
+
method = request["method"]
|
|
4003
|
+
params = rpc_params(request)
|
|
4004
|
+
if not self.runtime.initialized and method not in {"initialize", "ping"}:
|
|
4005
|
+
raise JsonRpcError(-32002, "Server not initialized")
|
|
4006
|
+
if method == "initialize":
|
|
4007
|
+
validate_initialize_params(params)
|
|
4008
|
+
result = self.runtime.initialize()
|
|
4009
|
+
self.runtime.initialized = True
|
|
4010
|
+
elif method == "notifications/initialized":
|
|
4011
|
+
return None
|
|
4012
|
+
elif method == "notifications/cancelled":
|
|
4013
|
+
session_id = params.get("session_id")
|
|
4014
|
+
if isinstance(session_id, str):
|
|
4015
|
+
self.runtime.cancel_session(session_id)
|
|
4016
|
+
return None
|
|
4017
|
+
elif method == "ping":
|
|
4018
|
+
result = {}
|
|
4019
|
+
elif method == "logging/setLevel":
|
|
4020
|
+
result = self.runtime.set_logging_level(params)
|
|
4021
|
+
elif method == "tools/list":
|
|
4022
|
+
result = self.runtime.list_tools()
|
|
4023
|
+
elif method == "tools/call":
|
|
4024
|
+
if not isinstance(params.get("name"), str):
|
|
4025
|
+
raise JsonRpcError(-32602, "tools/call requires a tool name")
|
|
4026
|
+
arguments = params.get("arguments") or {}
|
|
4027
|
+
if not isinstance(arguments, dict):
|
|
4028
|
+
raise JsonRpcError(-32602, "tools/call arguments must be an object")
|
|
4029
|
+
result = self.runtime.call_tool(params["name"], arguments)
|
|
4030
|
+
else:
|
|
4031
|
+
raise JsonRpcError(-32601, f"Unknown method: {method}")
|
|
4032
|
+
if request_id is None:
|
|
4033
|
+
return None
|
|
4034
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
4035
|
+
except JsonRpcError as exc:
|
|
4036
|
+
error: dict[str, Any] = {"code": exc.code, "message": exc.message}
|
|
4037
|
+
if exc.data is not None:
|
|
4038
|
+
error["data"] = exc.data
|
|
4039
|
+
response: dict[str, Any] = {"jsonrpc": "2.0", "error": error}
|
|
4040
|
+
if request_id is not None:
|
|
4041
|
+
response["id"] = request_id
|
|
4042
|
+
return response
|
|
4043
|
+
except Exception as exc: # noqa: BLE001
|
|
4044
|
+
response = {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(exc)}}
|
|
4045
|
+
if request_id is not None:
|
|
4046
|
+
response["id"] = request_id
|
|
4047
|
+
return response
|
|
4048
|
+
|
|
4049
|
+
def is_authorized(self) -> bool:
|
|
4050
|
+
if not self.runtime.auth_enabled():
|
|
4051
|
+
return True
|
|
4052
|
+
header = self.headers.get("Authorization", "")
|
|
4053
|
+
expected = f"Bearer {self.runtime.auth_token}"
|
|
4054
|
+
return secrets.compare_digest(header.strip(), expected)
|
|
4055
|
+
|
|
4056
|
+
def send_unauthorized(self, *, head_only: bool = False) -> None:
|
|
4057
|
+
self.send_json(
|
|
4058
|
+
{"jsonrpc": "2.0", "id": None, "error": {"code": -32000, "message": "Unauthorized"}},
|
|
4059
|
+
status=401,
|
|
4060
|
+
extra_headers={"WWW-Authenticate": 'Bearer realm="coding-tools-mcp"'},
|
|
4061
|
+
head_only=head_only,
|
|
4062
|
+
)
|
|
4063
|
+
|
|
4064
|
+
def send_cors_headers(self) -> None:
|
|
4065
|
+
origin = self.headers.get("Origin")
|
|
4066
|
+
if origin and is_allowed_origin(origin, auth_enabled=self.runtime.auth_enabled()):
|
|
4067
|
+
self.send_header("Access-Control-Allow-Origin", origin)
|
|
4068
|
+
self.send_header("Vary", "Origin")
|
|
4069
|
+
self.send_header("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS")
|
|
4070
|
+
self.send_header(
|
|
4071
|
+
"Access-Control-Allow-Headers",
|
|
4072
|
+
"Accept, Authorization, Content-Type, MCP-Protocol-Version, Mcp-Session-Id",
|
|
4073
|
+
)
|
|
4074
|
+
|
|
4075
|
+
def send_json(
|
|
4076
|
+
self,
|
|
4077
|
+
payload: Any,
|
|
4078
|
+
*,
|
|
4079
|
+
status: int = 200,
|
|
4080
|
+
extra_headers: dict[str, str] | None = None,
|
|
4081
|
+
head_only: bool = False,
|
|
4082
|
+
) -> None:
|
|
4083
|
+
body = json_response_payload(payload)
|
|
4084
|
+
self.send_response(status)
|
|
4085
|
+
self.send_header("Content-Type", "application/json")
|
|
4086
|
+
self.send_header("Content-Length", str(len(body)))
|
|
4087
|
+
self.send_header("Mcp-Session-Id", self.runtime.http_session_id)
|
|
4088
|
+
self.send_cors_headers()
|
|
4089
|
+
for name, value in (extra_headers or {}).items():
|
|
4090
|
+
self.send_header(name, value)
|
|
4091
|
+
self.end_headers()
|
|
4092
|
+
if not head_only:
|
|
4093
|
+
self.wfile.write(body)
|
|
4094
|
+
|
|
4095
|
+
|
|
4096
|
+
class RuntimeHTTPServer(http.server.ThreadingHTTPServer):
|
|
4097
|
+
daemon_threads = True
|
|
4098
|
+
|
|
4099
|
+
def __init__(self, address: tuple[str, int], handler: type[MCPHandler], runtime: Runtime) -> None:
|
|
4100
|
+
super().__init__(address, handler)
|
|
4101
|
+
self.runtime = runtime
|
|
4102
|
+
|
|
4103
|
+
|
|
4104
|
+
def run_http(args: argparse.Namespace) -> int:
|
|
4105
|
+
workspace = Path(args.workspace or os.environ.get("CODING_TOOLS_MCP_WORKSPACE") or os.getcwd())
|
|
4106
|
+
auth_token = args.auth_token or os.environ.get(f"{ENV_PREFIX}_AUTH_TOKEN") or None
|
|
4107
|
+
if not auth_token and not is_loopback_bind_host(str(args.host)):
|
|
4108
|
+
print(
|
|
4109
|
+
"ERROR: non-loopback HTTP binding requires --auth-token or CODING_TOOLS_MCP_AUTH_TOKEN.",
|
|
4110
|
+
file=sys.stderr,
|
|
4111
|
+
)
|
|
4112
|
+
return 2
|
|
4113
|
+
runtime = Runtime(
|
|
4114
|
+
workspace,
|
|
4115
|
+
enable_view_image=args.enable_view_image,
|
|
4116
|
+
dangerously_skip_all_permissions=args.dangerously_skip_all_permissions,
|
|
4117
|
+
tool_profile=args.tool_profile,
|
|
4118
|
+
auth_token=auth_token,
|
|
4119
|
+
)
|
|
4120
|
+
server = RuntimeHTTPServer((args.host, args.port), MCPHandler, runtime)
|
|
4121
|
+
if args.dangerously_skip_all_permissions:
|
|
4122
|
+
print(
|
|
4123
|
+
"WARNING: --dangerously-skip-all-permissions is enabled; permission-gated operations will be auto-granted.",
|
|
4124
|
+
file=sys.stderr,
|
|
4125
|
+
)
|
|
4126
|
+
auth_label = "bearer auth enabled" if runtime.auth_enabled() else "no auth token configured"
|
|
4127
|
+
print(f"{SERVER_NAME} listening on http://{args.host}:{args.port}/mcp ({auth_label}, profile={args.tool_profile})", file=sys.stderr)
|
|
4128
|
+
try:
|
|
4129
|
+
server.serve_forever()
|
|
4130
|
+
except KeyboardInterrupt:
|
|
4131
|
+
return 130
|
|
4132
|
+
finally:
|
|
4133
|
+
server.server_close()
|
|
4134
|
+
return 0
|
|
4135
|
+
|
|
4136
|
+
|
|
4137
|
+
def run_stdio(args: argparse.Namespace) -> int:
|
|
4138
|
+
workspace = Path(args.workspace or os.environ.get("CODING_TOOLS_MCP_WORKSPACE") or os.getcwd())
|
|
4139
|
+
runtime = Runtime(
|
|
4140
|
+
workspace,
|
|
4141
|
+
enable_view_image=args.enable_view_image,
|
|
4142
|
+
dangerously_skip_all_permissions=args.dangerously_skip_all_permissions,
|
|
4143
|
+
tool_profile=args.tool_profile,
|
|
4144
|
+
)
|
|
4145
|
+
if args.dangerously_skip_all_permissions:
|
|
4146
|
+
print(
|
|
4147
|
+
"WARNING: --dangerously-skip-all-permissions is enabled; permission-gated operations will be auto-granted.",
|
|
4148
|
+
file=sys.stderr,
|
|
4149
|
+
)
|
|
4150
|
+
dispatcher = StdioDispatcher(runtime)
|
|
4151
|
+
for line in sys.stdin:
|
|
4152
|
+
if not line.strip():
|
|
4153
|
+
continue
|
|
4154
|
+
try:
|
|
4155
|
+
request = json.loads(line)
|
|
4156
|
+
if isinstance(request, list) and request:
|
|
4157
|
+
response = [item for item in (dispatcher.handle_rpc(part) if isinstance(part, dict) else invalid_request_response() for part in request) if item is not None]
|
|
4158
|
+
elif isinstance(request, list):
|
|
4159
|
+
response = invalid_request_response()
|
|
4160
|
+
elif isinstance(request, dict):
|
|
4161
|
+
response = dispatcher.handle_rpc(request)
|
|
4162
|
+
else:
|
|
4163
|
+
response = invalid_request_response()
|
|
4164
|
+
if response is not None:
|
|
4165
|
+
sys.stdout.write(json.dumps(response, separators=(",", ":")) + "\n")
|
|
4166
|
+
sys.stdout.flush()
|
|
4167
|
+
except Exception as exc: # noqa: BLE001
|
|
4168
|
+
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "error": {"code": -32603, "message": str(exc)}}) + "\n")
|
|
4169
|
+
sys.stdout.flush()
|
|
4170
|
+
return 0
|
|
4171
|
+
|
|
4172
|
+
|
|
4173
|
+
class StdioDispatcher:
|
|
4174
|
+
def __init__(self, runtime: Runtime) -> None:
|
|
4175
|
+
self.runtime = runtime
|
|
4176
|
+
self.initialized = False
|
|
4177
|
+
|
|
4178
|
+
def handle_rpc(self, request: dict[str, Any]) -> dict[str, Any] | None:
|
|
4179
|
+
request_id = request.get("id")
|
|
4180
|
+
try:
|
|
4181
|
+
validate_rpc_envelope(request)
|
|
4182
|
+
method = request["method"]
|
|
4183
|
+
params = rpc_params(request)
|
|
4184
|
+
if not self.initialized and method not in {"initialize", "ping"}:
|
|
4185
|
+
raise JsonRpcError(-32002, "Server not initialized")
|
|
4186
|
+
if method == "initialize":
|
|
4187
|
+
validate_initialize_params(params)
|
|
4188
|
+
result = self.runtime.initialize()
|
|
4189
|
+
self.initialized = True
|
|
4190
|
+
elif method == "notifications/initialized":
|
|
4191
|
+
return None
|
|
4192
|
+
elif method == "notifications/cancelled":
|
|
4193
|
+
session_id = params.get("session_id")
|
|
4194
|
+
if isinstance(session_id, str):
|
|
4195
|
+
self.runtime.cancel_session(session_id)
|
|
4196
|
+
return None
|
|
4197
|
+
elif method == "ping":
|
|
4198
|
+
result = {}
|
|
4199
|
+
elif method == "logging/setLevel":
|
|
4200
|
+
result = self.runtime.set_logging_level(params)
|
|
4201
|
+
elif method == "tools/list":
|
|
4202
|
+
result = self.runtime.list_tools()
|
|
4203
|
+
elif method == "tools/call":
|
|
4204
|
+
if not isinstance(params.get("name"), str):
|
|
4205
|
+
raise JsonRpcError(-32602, "tools/call requires a tool name")
|
|
4206
|
+
arguments = params.get("arguments") or {}
|
|
4207
|
+
if not isinstance(arguments, dict):
|
|
4208
|
+
raise JsonRpcError(-32602, "tools/call arguments must be an object")
|
|
4209
|
+
result = self.runtime.call_tool(params["name"], arguments)
|
|
4210
|
+
else:
|
|
4211
|
+
raise JsonRpcError(-32601, f"Unknown method: {method}")
|
|
4212
|
+
if request_id is None:
|
|
4213
|
+
return None
|
|
4214
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
4215
|
+
except JsonRpcError as exc:
|
|
4216
|
+
error: dict[str, Any] = {"code": exc.code, "message": exc.message}
|
|
4217
|
+
if exc.data is not None:
|
|
4218
|
+
error["data"] = exc.data
|
|
4219
|
+
response: dict[str, Any] = {"jsonrpc": "2.0", "error": error}
|
|
4220
|
+
if request_id is not None:
|
|
4221
|
+
response["id"] = request_id
|
|
4222
|
+
return response
|
|
4223
|
+
|
|
4224
|
+
|
|
4225
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
4226
|
+
parser = argparse.ArgumentParser(description="Serve workspace-confined coding tools over MCP.")
|
|
4227
|
+
parser.add_argument("--workspace", help="workspace root; defaults to CODING_TOOLS_MCP_WORKSPACE or cwd")
|
|
4228
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
4229
|
+
parser.add_argument("--port", type=int, default=8000)
|
|
4230
|
+
parser.add_argument("--stdio", action="store_true", help="serve newline-delimited JSON-RPC over stdio")
|
|
4231
|
+
parser.add_argument(
|
|
4232
|
+
"--auth-token",
|
|
4233
|
+
default=None,
|
|
4234
|
+
help=f"require Authorization: Bearer <token> on /mcp; defaults to {ENV_PREFIX}_AUTH_TOKEN",
|
|
4235
|
+
)
|
|
4236
|
+
parser.add_argument(
|
|
4237
|
+
"--tool-profile",
|
|
4238
|
+
choices=TOOL_PROFILE_CHOICES,
|
|
4239
|
+
default=os.environ.get(f"{ENV_PREFIX}_TOOL_PROFILE", "full"),
|
|
4240
|
+
help="tool exposure profile",
|
|
4241
|
+
)
|
|
4242
|
+
parser.add_argument(
|
|
4243
|
+
"--enable-view-image",
|
|
4244
|
+
action="store_true",
|
|
4245
|
+
default=os.environ.get("CODING_TOOLS_MCP_ENABLE_VIEW_IMAGE", "1") != "0",
|
|
4246
|
+
help="enable the P1 view_image tool",
|
|
4247
|
+
)
|
|
4248
|
+
parser.add_argument(
|
|
4249
|
+
"--dangerously-skip-all-permissions",
|
|
4250
|
+
action="store_true",
|
|
4251
|
+
help=(
|
|
4252
|
+
"dangerous: auto-grant permission-gated operations when the MCP client cannot elicit approvals; "
|
|
4253
|
+
"workspace path boundaries still apply"
|
|
4254
|
+
),
|
|
4255
|
+
)
|
|
4256
|
+
return parser
|
|
4257
|
+
|
|
4258
|
+
|
|
4259
|
+
def main(argv: list[str] | None = None) -> int:
|
|
4260
|
+
parser = build_parser()
|
|
4261
|
+
args = parser.parse_args(argv)
|
|
4262
|
+
return run_stdio(args) if args.stdio else run_http(args)
|