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.
@@ -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)