gdmcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. src/voice/voice_loop.py +156 -0
@@ -0,0 +1,366 @@
1
+ """Quality tools — automatic lint, type-check, and security scan after file writes.
2
+
3
+ Runs automatically after every write operation. Results are fed back into the
4
+ agent loop as tool results — the model must fix failures before presenting to user.
5
+
6
+ Supported linters:
7
+ .py → ruff check, mypy --strict
8
+ .ts → tsc --noEmit (if tsconfig.json exists)
9
+ .js → (eslint if installed, otherwise skipped)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import shutil
16
+ import subprocess
17
+ import tempfile
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from src.tools import REGISTRY, ToolBase, ToolResult
23
+
24
+ __all__ = ["LintFileTool", "TypeCheckTool", "SecurityScanTool", "ComplexityCheckTool"]
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Constants
30
+ # ---------------------------------------------------------------------------
31
+
32
+ _LINT_TIMEOUT_SECS: int = 30
33
+ _MAX_ISSUE_CHARS: int = 4_000
34
+
35
+ # Which linter binaries map to which suffixes.
36
+ _PY_LINTERS: tuple[str, ...] = ("ruff",)
37
+ _TS_LINTERS: tuple[str, ...] = ("tsc",)
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Helpers
42
+ # ---------------------------------------------------------------------------
43
+
44
+ @dataclass
45
+ class LintResult:
46
+ """Aggregated result from one or more linter runs."""
47
+
48
+ issues: list[str] = field(default_factory=list)
49
+ tool_ran: str = ""
50
+ exit_code: int = 0
51
+
52
+ @property
53
+ def clean(self) -> bool:
54
+ return not self.issues
55
+
56
+ def summary(self, max_chars: int = _MAX_ISSUE_CHARS) -> str:
57
+ """Return a compact summary suitable for injection into a tool result."""
58
+ if self.clean:
59
+ return f"{self.tool_ran}: no issues found"
60
+ joined = "\n".join(self.issues)
61
+ if len(joined) > max_chars:
62
+ joined = joined[:max_chars] + "\n...(truncated)"
63
+ return f"{self.tool_ran} found issues:\n{joined}"
64
+
65
+
66
+ def _run_cmd(
67
+ args: list[str],
68
+ cwd: Path,
69
+ *,
70
+ timeout: int = _LINT_TIMEOUT_SECS,
71
+ ) -> tuple[int, str]:
72
+ """Run a subprocess and return (exit_code, combined_output)."""
73
+ try:
74
+ result = subprocess.run(
75
+ args,
76
+ capture_output=True,
77
+ text=True,
78
+ cwd=cwd,
79
+ timeout=timeout,
80
+ )
81
+ combined = (result.stdout + "\n" + result.stderr).strip()
82
+ return result.returncode, combined
83
+ except subprocess.TimeoutExpired:
84
+ return 1, f"Timed out after {timeout}s"
85
+ except FileNotFoundError:
86
+ return -1, f"Tool not found: {args[0]!r}"
87
+ except OSError as exc:
88
+ return 1, str(exc)
89
+
90
+
91
+ def _find_project_root(path: Path) -> Path:
92
+ """Walk up to find the nearest pyproject.toml / package.json."""
93
+ for parent in [path] + list(path.parents):
94
+ if (parent / "pyproject.toml").exists() or (parent / "package.json").exists():
95
+ return parent
96
+ return path.parent
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # LintFileTool
101
+ # ---------------------------------------------------------------------------
102
+
103
+ class LintFileTool(ToolBase):
104
+ """Run the appropriate linter on a single file after it is written.
105
+
106
+ Supports: ruff (Python), tsc --noEmit (TypeScript).
107
+ Skips silently if the required linter is not installed.
108
+ """
109
+
110
+ name = "lint_file"
111
+ description = (
112
+ "Run the linter on a file immediately after writing it. "
113
+ "Use after every file write to catch issues before presenting to user."
114
+ )
115
+ input_schema: dict[str, Any] = {
116
+ "type": "object",
117
+ "properties": {
118
+ "path": {"type": "string", "description": "Absolute or relative path to the file."},
119
+ },
120
+ "required": ["path"],
121
+ }
122
+
123
+ def execute(self, params: dict[str, Any]) -> ToolResult:
124
+ """Run linter for the given file path."""
125
+ path = Path(params.get("path", ""))
126
+ if not path.exists():
127
+ return ToolResult(output="", error=f"File not found: {path}")
128
+
129
+ suffix = path.suffix.lower()
130
+ if suffix == ".py":
131
+ lint = self._lint_python(path)
132
+ elif suffix in (".ts", ".tsx"):
133
+ lint = self._lint_typescript(path)
134
+ else:
135
+ return ToolResult(output=f"No linter configured for {suffix} files.")
136
+
137
+ if lint.exit_code == -1:
138
+ return ToolResult(output=f"Linter not installed — skipping ({lint.tool_ran})")
139
+
140
+ return ToolResult(
141
+ output=lint.summary(),
142
+ error=None if lint.clean else lint.summary(),
143
+ exit_code=lint.exit_code,
144
+ )
145
+
146
+ def _lint_python(self, path: Path) -> LintResult:
147
+ if not shutil.which("ruff"):
148
+ return LintResult(tool_ran="ruff", exit_code=-1)
149
+ root = _find_project_root(path)
150
+ code, out = _run_cmd(["ruff", "check", str(path)], cwd=root)
151
+ issues = [l for l in out.splitlines() if l.strip()]
152
+ return LintResult(issues=issues, tool_ran="ruff", exit_code=code)
153
+
154
+ def _lint_typescript(self, path: Path) -> LintResult:
155
+ if not shutil.which("tsc"):
156
+ return LintResult(tool_ran="tsc", exit_code=-1)
157
+ root = _find_project_root(path)
158
+ tsconfig = root / "tsconfig.json"
159
+ if not tsconfig.exists():
160
+ return LintResult(tool_ran="tsc (no tsconfig)", exit_code=0)
161
+ code, out = _run_cmd(
162
+ ["tsc", "--noEmit", "--pretty", "false"], cwd=root
163
+ )
164
+ issues = [l for l in out.splitlines() if l.strip()]
165
+ return LintResult(issues=issues, tool_ran="tsc", exit_code=code)
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # TypeCheckTool
170
+ # ---------------------------------------------------------------------------
171
+
172
+ class TypeCheckTool(ToolBase):
173
+ """Run mypy --strict on a list of Python files.
174
+
175
+ Used after writing Python code to catch type errors early.
176
+ """
177
+
178
+ name = "type_check"
179
+ description = (
180
+ "Run mypy --strict on Python files. "
181
+ "Use after writing Python code to catch type annotation errors."
182
+ )
183
+ input_schema: dict[str, Any] = {
184
+ "type": "object",
185
+ "properties": {
186
+ "files": {
187
+ "type": "array",
188
+ "items": {"type": "string"},
189
+ "description": "List of Python file paths to type-check.",
190
+ },
191
+ },
192
+ "required": ["files"],
193
+ }
194
+
195
+ def execute(self, params: dict[str, Any]) -> ToolResult:
196
+ """Run mypy on specified files."""
197
+ files: list[str] = params.get("files", [])
198
+ if not files:
199
+ return ToolResult(output="No files specified for type check.")
200
+
201
+ if not shutil.which("mypy"):
202
+ return ToolResult(output="mypy not installed — skipping type check.")
203
+
204
+ paths = [Path(f) for f in files]
205
+ existing = [str(p) for p in paths if p.exists()]
206
+ if not existing:
207
+ return ToolResult(output="", error="None of the specified files exist.")
208
+
209
+ root = _find_project_root(paths[0])
210
+ code, out = _run_cmd(
211
+ ["mypy", "--strict", "--no-error-summary", *existing],
212
+ cwd=root,
213
+ timeout=60,
214
+ )
215
+ issues = [l for l in out.splitlines() if l.strip() and "error:" in l]
216
+ result = LintResult(issues=issues, tool_ran="mypy", exit_code=code)
217
+
218
+ return ToolResult(
219
+ output=result.summary(),
220
+ error=None if result.clean else result.summary(),
221
+ exit_code=code,
222
+ )
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # SecurityScanTool
227
+ # ---------------------------------------------------------------------------
228
+
229
+ class SecurityScanTool(ToolBase):
230
+ """Run a fast security scanner on a file.
231
+
232
+ Python: bandit -r (checks for common security issues).
233
+ TypeScript/JavaScript: semgrep --config=auto (if installed).
234
+ Falls back gracefully if tools are not installed.
235
+ """
236
+
237
+ name = "security_scan"
238
+ description = (
239
+ "Run a security scanner on a file. "
240
+ "Automatically triggered on auth/session/crypto/SQL/file-I/O changes."
241
+ )
242
+ input_schema: dict[str, Any] = {
243
+ "type": "object",
244
+ "properties": {
245
+ "path": {"type": "string", "description": "Path to the file to scan."},
246
+ },
247
+ "required": ["path"],
248
+ }
249
+
250
+ def execute(self, params: dict[str, Any]) -> ToolResult:
251
+ """Run security scanner for the given file."""
252
+ path = Path(params.get("path", ""))
253
+ if not path.exists():
254
+ return ToolResult(output="", error=f"File not found: {path}")
255
+
256
+ suffix = path.suffix.lower()
257
+ if suffix == ".py":
258
+ return self._scan_python(path)
259
+ if suffix in (".ts", ".tsx", ".js", ".jsx"):
260
+ return self._scan_js(path)
261
+ return ToolResult(output=f"No security scanner configured for {suffix} files.")
262
+
263
+ def _scan_python(self, path: Path) -> ToolResult:
264
+ if not shutil.which("bandit"):
265
+ return ToolResult(output="bandit not installed — security scan skipped.")
266
+ root = _find_project_root(path)
267
+ code, out = _run_cmd(
268
+ ["bandit", "-r", str(path), "-f", "text", "-ll"],
269
+ cwd=root,
270
+ )
271
+ if code == 0:
272
+ return ToolResult(output="bandit: no high/medium issues found.")
273
+ lines = [l for l in out.splitlines() if l.strip()]
274
+ issues = "\n".join(lines[:60])
275
+ return ToolResult(
276
+ output=issues,
277
+ error=issues if code != 0 else None,
278
+ exit_code=code,
279
+ )
280
+
281
+ def _scan_js(self, path: Path) -> ToolResult:
282
+ if not shutil.which("semgrep"):
283
+ return ToolResult(output="semgrep not installed — security scan skipped.")
284
+ root = _find_project_root(path)
285
+ # Use a temp file for --json-output (cross-platform; /dev/null fails on Windows)
286
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as _f:
287
+ out_path = _f.name
288
+ code, out = _run_cmd(
289
+ ["semgrep", "--config=auto", str(path), f"--json-output={out_path}"],
290
+ cwd=root,
291
+ timeout=60,
292
+ )
293
+ try:
294
+ Path(out_path).unlink(missing_ok=True)
295
+ except OSError:
296
+ pass
297
+ if code == 0:
298
+ return ToolResult(output="semgrep: no issues found.")
299
+ lines = [l for l in out.splitlines() if l.strip()]
300
+ issues = "\n".join(lines[:60])
301
+ return ToolResult(
302
+ output=issues,
303
+ error=issues if code != 0 else None,
304
+ exit_code=code,
305
+ )
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # ComplexityCheckTool
310
+ # ---------------------------------------------------------------------------
311
+
312
+ class ComplexityCheckTool(ToolBase):
313
+ """Check cyclomatic complexity using radon cc.
314
+
315
+ Flags functions with complexity > 15 (radon grade C or higher).
316
+ Silently skips if radon is not installed.
317
+ """
318
+
319
+ name = "complexity_check"
320
+ description = (
321
+ "Check cyclomatic complexity using radon. "
322
+ "Flags functions with complexity > 15 as high risk."
323
+ )
324
+ input_schema: dict[str, Any] = {
325
+ "type": "object",
326
+ "properties": {
327
+ "path": {"type": "string", "description": "Path to the Python file to check."},
328
+ },
329
+ "required": ["path"],
330
+ }
331
+
332
+ def execute(self, params: dict[str, Any]) -> ToolResult:
333
+ """Run radon cc on the given file."""
334
+ path_str = params.get("path", "")
335
+ if not shutil.which("radon"):
336
+ return ToolResult(output="radon not installed - skipping complexity check")
337
+ result = subprocess.run(
338
+ ["radon", "cc", path_str, "--min", "C", "--json"],
339
+ capture_output=True, text=True, timeout=30,
340
+ )
341
+ if result.returncode != 0:
342
+ return ToolResult(output="", error=result.stderr or "radon cc failed")
343
+ try:
344
+ data: dict[str, Any] = json.loads(result.stdout or "{}")
345
+ except json.JSONDecodeError:
346
+ return ToolResult(output="radon: could not parse output")
347
+ high_complexity = [
348
+ f"{fn['name']} (complexity={fn['complexity']})"
349
+ for fns in data.values()
350
+ for fn in fns
351
+ if isinstance(fn, dict) and fn.get("complexity", 0) > 15
352
+ ]
353
+ if high_complexity:
354
+ return ToolResult(
355
+ output=f"High complexity functions: {', '.join(high_complexity)}",
356
+ )
357
+ return ToolResult(output="Complexity OK (all functions <= 15)")
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # Registration
361
+ # ---------------------------------------------------------------------------
362
+
363
+ REGISTRY.register(LintFileTool())
364
+ REGISTRY.register(TypeCheckTool())
365
+ REGISTRY.register(SecurityScanTool())
366
+ REGISTRY.register(ComplexityCheckTool())
@@ -0,0 +1,318 @@
1
+ """FileReadTool, FileWriteTool, FileEditTool — safe file operations.
2
+
3
+ Security contracts:
4
+ - All paths are resolved and checked to be inside the workspace root.
5
+ - Binary files are detected and blocked from being injected into context.
6
+ - File reads are truncated at MAX_READ_BYTES.
7
+ - File writes create parent directories automatically.
8
+ - FileEditTool does exact-string replace (like Claude Code's FileEditTool) and
9
+ returns a unified diff of the change.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import difflib
14
+ import logging
15
+ import mimetypes
16
+ import os
17
+ from pathlib import Path
18
+ from typing import Any, ClassVar
19
+
20
+ from src.security import InjectionResult, check_file_injection, tag_untrusted
21
+ from src.tools import REGISTRY, ToolBase, ToolResult
22
+
23
+ __all__ = ["FileReadTool", "FileWriteTool", "FileEditTool"]
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Constants
29
+ # ---------------------------------------------------------------------------
30
+
31
+ _MAX_READ_BYTES: int = 200_000 # ~200 KB — enough for large source files
32
+ _MAX_WRITE_BYTES: int = 1_000_000 # 1 MB write limit
33
+ _MAX_LINES_DEFAULT: int = 2_000 # default line cap for reads (avoids massive files)
34
+
35
+ _BINARY_MIME_PREFIXES: tuple[str, ...] = (
36
+ "image/", "audio/", "video/", "application/octet-stream",
37
+ "application/zip", "application/gzip", "application/x-tar",
38
+ )
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Safety helpers
43
+ # ---------------------------------------------------------------------------
44
+
45
+ def _resolve_safe(path_str: str, workspace: Path | None) -> tuple[Path, str | None]:
46
+ """Resolve path; return (resolved_path, error_or_None).
47
+
48
+ Error is returned if the path escapes the workspace root.
49
+ """
50
+ try:
51
+ p = Path(path_str).resolve()
52
+ except Exception as exc: # noqa: BLE001
53
+ return Path(path_str), f"Cannot resolve path: {exc}"
54
+
55
+ if workspace is not None:
56
+ try:
57
+ p.relative_to(workspace)
58
+ except ValueError:
59
+ return p, f"Path {path_str!r} is outside workspace {workspace}"
60
+ return p, None
61
+
62
+
63
+ def _is_binary(path: Path) -> bool:
64
+ """Return True if the file looks like a binary (non-text) file."""
65
+ mime, _ = mimetypes.guess_type(str(path))
66
+ if mime and any(mime.startswith(p) for p in _BINARY_MIME_PREFIXES):
67
+ return True
68
+ # Sniff first 512 bytes.
69
+ try:
70
+ with path.open("rb") as f:
71
+ sample = f.read(512)
72
+ return b"\x00" in sample
73
+ except OSError:
74
+ return False
75
+
76
+
77
+ def _is_document(path_str: str) -> bool:
78
+ """Return True if the file extension is a supported document format."""
79
+ from src.tools.document_tool import is_document_path
80
+ return is_document_path(path_str)
81
+
82
+
83
+ def _read_text(path: Path, max_lines: int | None = None) -> tuple[str, bool]:
84
+ """Read a text file; return (content, was_truncated)."""
85
+ raw = path.read_bytes()
86
+ if len(raw) > _MAX_READ_BYTES:
87
+ raw = raw[:_MAX_READ_BYTES]
88
+ truncated = True
89
+ else:
90
+ truncated = False
91
+ content = raw.decode("utf-8", errors="replace")
92
+ if max_lines is not None:
93
+ lines = content.splitlines(keepends=True)
94
+ if len(lines) > max_lines:
95
+ content = "".join(lines[:max_lines])
96
+ truncated = True
97
+ return content, truncated
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # FileReadTool
102
+ # ---------------------------------------------------------------------------
103
+
104
+ class FileReadTool(ToolBase):
105
+ """Read the text content of a file."""
106
+
107
+ name: ClassVar[str] = "read_file"
108
+ description: ClassVar[str] = (
109
+ "Read the contents of a text file. "
110
+ "Returns the file contents tagged as untrusted (safe to inject into prompts). "
111
+ "Binary files are rejected. Large files are truncated at 200 KB."
112
+ )
113
+ input_schema: ClassVar[dict[str, Any]] = {
114
+ "type": "object",
115
+ "required": ["path"],
116
+ "properties": {
117
+ "path": {"type": "string", "description": "Absolute or project-relative file path."},
118
+ "start_line": {"type": "integer", "description": "First line to return (1-indexed)."},
119
+ "end_line": {"type": "integer", "description": "Last line to return (inclusive)."},
120
+ },
121
+ "additionalProperties": False,
122
+ }
123
+
124
+ def __init__(self, workspace: Path | None = None) -> None:
125
+ self._workspace = workspace
126
+
127
+ def execute(self, params: dict[str, Any]) -> ToolResult: # noqa: D102
128
+ path_str: str = params["path"]
129
+ start_line: int | None = params.get("start_line")
130
+ end_line: int | None = params.get("end_line")
131
+
132
+ path, err = _resolve_safe(path_str, self._workspace)
133
+ if err:
134
+ return ToolResult(output="", error=err)
135
+ if not path.exists():
136
+ return ToolResult(output="", error=f"File not found: {path}")
137
+ if not path.is_file():
138
+ return ToolResult(output="", error=f"Not a file: {path}")
139
+ if _is_document(path_str):
140
+ from src.tools.document_tool import read_document_tool
141
+ text = read_document_tool(str(path))
142
+ if text.startswith("Error"):
143
+ return ToolResult(output="", error=text)
144
+ # Run through the same injection/tagging pipeline as plain text reads
145
+ injection = check_file_injection(text, filename=str(path))
146
+ if injection.is_injected and injection.severity == "high":
147
+ text = f"[SECURITY: injection attempt blocked in {path.name}]"
148
+ text = tag_untrusted(text, filename=str(path))
149
+ return ToolResult(output=text, metadata={"path": str(path), "size_bytes": path.stat().st_size})
150
+ if _is_binary(path):
151
+ return ToolResult(output="", error=f"Binary file {path.name!r} cannot be read as text.")
152
+
153
+ content, truncated = _read_text(path)
154
+
155
+ if start_line is not None or end_line is not None:
156
+ lines = content.splitlines(keepends=True)
157
+ s = (start_line or 1) - 1
158
+ e = end_line if end_line else len(lines)
159
+ content = "".join(lines[s:e])
160
+
161
+ # Injection check before tagging — redact high-severity content
162
+ injection = check_file_injection(content, filename=str(path))
163
+ if injection.is_injected and injection.severity == "high":
164
+ content = (
165
+ f"[SECURITY: injection attempt blocked in {path.name}]"
166
+ )
167
+
168
+ tagged = tag_untrusted(content, filename=str(path))
169
+ return ToolResult(
170
+ output=tagged,
171
+ truncated=truncated,
172
+ metadata={"path": str(path), "size_bytes": path.stat().st_size},
173
+ )
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # FileWriteTool
178
+ # ---------------------------------------------------------------------------
179
+
180
+ class FileWriteTool(ToolBase):
181
+ """Write or overwrite the full content of a file."""
182
+
183
+ name: ClassVar[str] = "write_file"
184
+ description: ClassVar[str] = (
185
+ "Write or overwrite a file with new content. "
186
+ "Creates parent directories automatically. "
187
+ "Use FileEditTool for targeted edits — FileWriteTool replaces the entire file."
188
+ )
189
+ input_schema: ClassVar[dict[str, Any]] = {
190
+ "type": "object",
191
+ "required": ["path", "content"],
192
+ "properties": {
193
+ "path": {"type": "string", "description": "Absolute or project-relative file path."},
194
+ "content": {"type": "string", "description": "Full file content to write."},
195
+ },
196
+ "additionalProperties": False,
197
+ }
198
+
199
+ def __init__(self, workspace: Path | None = None) -> None:
200
+ self._workspace = workspace
201
+
202
+ def execute(self, params: dict[str, Any]) -> ToolResult: # noqa: D102
203
+ path_str: str = params["path"]
204
+ content: str = params["content"]
205
+
206
+ path, err = _resolve_safe(path_str, self._workspace)
207
+ if err:
208
+ return ToolResult(output="", error=err)
209
+
210
+ if len(content.encode("utf-8")) > _MAX_WRITE_BYTES:
211
+ return ToolResult(output="", error=f"Content exceeds {_MAX_WRITE_BYTES // 1024} KB write limit.")
212
+
213
+ path.parent.mkdir(parents=True, exist_ok=True)
214
+ tmp = path.with_suffix(path.suffix + ".gdm_tmp")
215
+ tmp.write_text(content, encoding="utf-8")
216
+ tmp.replace(path) # atomic rename — safe on same-drive paths
217
+ log.info("FileWriteTool wrote %d bytes to %s", len(content), path)
218
+
219
+ return ToolResult(
220
+ output=f"Wrote {len(content.splitlines())} lines to {path.name}",
221
+ metadata={"path": str(path), "bytes_written": len(content.encode("utf-8"))},
222
+ )
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # FileEditTool
227
+ # ---------------------------------------------------------------------------
228
+
229
+ class FileEditTool(ToolBase):
230
+ """Apply an exact-string replacement to a file, returning a unified diff.
231
+
232
+ The `old_str` must appear exactly once in the file.
233
+ The tool fails if there are zero or multiple matches — this prevents
234
+ accidental mass-edits from ambiguous patterns.
235
+ """
236
+
237
+ name: ClassVar[str] = "edit_file"
238
+ description: ClassVar[str] = (
239
+ "Replace an exact string in a file. "
240
+ "old_str must appear exactly once. "
241
+ "Returns a unified diff of the change."
242
+ )
243
+ input_schema: ClassVar[dict[str, Any]] = {
244
+ "type": "object",
245
+ "required": ["path", "old_str", "new_str"],
246
+ "properties": {
247
+ "path": {"type": "string", "description": "Absolute or project-relative file path."},
248
+ "old_str": {"type": "string", "description": "Exact text to replace (must be unique in file)."},
249
+ "new_str": {"type": "string", "description": "Replacement text."},
250
+ },
251
+ "additionalProperties": False,
252
+ }
253
+
254
+ def __init__(self, workspace: Path | None = None) -> None:
255
+ self._workspace = workspace
256
+
257
+ def execute(self, params: dict[str, Any]) -> ToolResult: # noqa: D102
258
+ path_str: str = params["path"]
259
+ old_str: str = params["old_str"]
260
+ new_str: str = params["new_str"]
261
+
262
+ path, err = _resolve_safe(path_str, self._workspace)
263
+ if err:
264
+ return ToolResult(output="", error=err)
265
+ if not path.exists():
266
+ return ToolResult(output="", error=f"File not found: {path}")
267
+ if _is_binary(path):
268
+ return ToolResult(output="", error="Cannot edit binary file.")
269
+
270
+ original = path.read_text(encoding="utf-8", errors="replace")
271
+ count = original.count(old_str)
272
+
273
+ if count == 0:
274
+ return ToolResult(output="", error="old_str not found in file. No edit applied.")
275
+ if count > 1:
276
+ return ToolResult(
277
+ output="",
278
+ error=f"old_str appears {count} times — too ambiguous. Add more context.",
279
+ )
280
+
281
+ updated = original.replace(old_str, new_str, 1)
282
+ tmp = path.with_suffix(path.suffix + ".gdm_tmp")
283
+ tmp.write_text(updated, encoding="utf-8")
284
+ tmp.replace(path) # atomic rename
285
+
286
+ diff = _make_diff(original, updated, path.name)
287
+ log.info("FileEditTool patched %s (%d chars → %d chars)", path.name, len(original), len(updated))
288
+
289
+ return ToolResult(
290
+ output=diff,
291
+ metadata={"path": str(path), "old_len": len(original), "new_len": len(updated)},
292
+ )
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Diff helper
297
+ # ---------------------------------------------------------------------------
298
+
299
+ def _make_diff(original: str, updated: str, filename: str) -> str:
300
+ """Return a compact unified diff string."""
301
+ orig_lines = original.splitlines(keepends=True)
302
+ new_lines = updated.splitlines(keepends=True)
303
+ diff_lines = difflib.unified_diff(
304
+ orig_lines, new_lines,
305
+ fromfile=f"a/{filename}",
306
+ tofile=f"b/{filename}",
307
+ lineterm="",
308
+ )
309
+ return "".join(diff_lines) or "(no diff — content unchanged)"
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # Auto-register
314
+ # ---------------------------------------------------------------------------
315
+
316
+ REGISTRY.register(FileReadTool())
317
+ REGISTRY.register(FileWriteTool())
318
+ REGISTRY.register(FileEditTool())