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.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- 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())
|
src/tools/read_tools.py
ADDED
|
@@ -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())
|