local-coding-agent 1.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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
"""Policy-bound repository operations exposed to a local model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import ctypes
|
|
7
|
+
from ctypes import wintypes
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from threading import Event, Thread
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .task import TaskEnvelope
|
|
18
|
+
from .validators import check_patch_applies, parse_unified_diff, resolve_edits
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _fold_path(path: str) -> str:
|
|
22
|
+
# ponytail: treat only Windows as case-insensitive; macOS default is
|
|
23
|
+
# case-insensitive but this keeps the common Linux case strict.
|
|
24
|
+
return path.casefold() if os.name == "nt" else path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Name-based secret scrubbing for the isolated test environment. Name
|
|
28
|
+
# matching only: value heuristics produce false positives that cost more
|
|
29
|
+
# than they catch.
|
|
30
|
+
_SECRET_ENV_NAME_RE = re.compile(
|
|
31
|
+
r"(token|secret|password|passwd|api[_-]?key|private[_-]?key|client[_-]?secret|^aws_|azure_client)",
|
|
32
|
+
re.IGNORECASE,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _posix_preexec(timeout_seconds: int) -> Any:
|
|
37
|
+
"""Child setup hook for POSIX spawns; never invoked on Windows.
|
|
38
|
+
|
|
39
|
+
Gives the child its own session (reliable ``os.killpg``), asks the kernel
|
|
40
|
+
to SIGKILL it if we die first (PR_SET_PDEATHSIG), and clamps CPU/file
|
|
41
|
+
descriptors. Every guard degrades silently rather than crashing spawn.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def apply() -> None:
|
|
45
|
+
os.setsid()
|
|
46
|
+
try:
|
|
47
|
+
import resource
|
|
48
|
+
|
|
49
|
+
# prctl(PR_SET_PDEATHSIG, SIGKILL) == prctl(1, 9); literal
|
|
50
|
+
# constants avoid importing signal and musl/alpine quirks must
|
|
51
|
+
# never crash spawn - the wall-clock kill covers the gap.
|
|
52
|
+
libc_path = ctypes.util.find_library("c")
|
|
53
|
+
if libc_path:
|
|
54
|
+
libc = ctypes.CDLL(libc_path, use_errno=True)
|
|
55
|
+
libc.prctl(1, 9, 0, 0, 0)
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
try:
|
|
59
|
+
import resource
|
|
60
|
+
|
|
61
|
+
cpu_limit = timeout_seconds + 30
|
|
62
|
+
resource.setrlimit(resource.RLIMIT_CPU, (cpu_limit, cpu_limit))
|
|
63
|
+
resource.setrlimit(resource.RLIMIT_NOFILE, (256, 256))
|
|
64
|
+
# ponytail: RLIMIT_AS deliberately NOT set - AS limits break
|
|
65
|
+
# CPython mmaps on some libcs; CPU limit covers runaway loops,
|
|
66
|
+
# wall-clock kill covers hangs.
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
return apply
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _windows_descendants(root_pid: int) -> tuple[list[int], str | None]:
|
|
74
|
+
if os.name != "nt":
|
|
75
|
+
return [root_pid], None
|
|
76
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
77
|
+
|
|
78
|
+
class ProcessEntry(ctypes.Structure):
|
|
79
|
+
_fields_ = [
|
|
80
|
+
("dwSize", wintypes.DWORD),
|
|
81
|
+
("cntUsage", wintypes.DWORD),
|
|
82
|
+
("th32ProcessID", wintypes.DWORD),
|
|
83
|
+
("th32DefaultHeapID", ctypes.c_void_p),
|
|
84
|
+
("th32ModuleID", wintypes.DWORD),
|
|
85
|
+
("cntThreads", wintypes.DWORD),
|
|
86
|
+
("th32ParentProcessID", wintypes.DWORD),
|
|
87
|
+
("pcPriClassBase", wintypes.LONG),
|
|
88
|
+
("dwFlags", wintypes.DWORD),
|
|
89
|
+
("szExeFile", wintypes.WCHAR * 260),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
invalid_handle = ctypes.c_void_p(-1).value
|
|
93
|
+
snapshot = kernel32.CreateToolhelp32Snapshot(0x00000002, 0)
|
|
94
|
+
if snapshot == invalid_handle:
|
|
95
|
+
return [], f"process snapshot failed: {ctypes.get_last_error()}"
|
|
96
|
+
try:
|
|
97
|
+
entry = ProcessEntry()
|
|
98
|
+
entry.dwSize = ctypes.sizeof(ProcessEntry)
|
|
99
|
+
parents: dict[int, int] = {}
|
|
100
|
+
if kernel32.Process32FirstW(snapshot, ctypes.byref(entry)):
|
|
101
|
+
while True:
|
|
102
|
+
parents[entry.th32ProcessID] = entry.th32ParentProcessID
|
|
103
|
+
if not kernel32.Process32NextW(snapshot, ctypes.byref(entry)):
|
|
104
|
+
break
|
|
105
|
+
children: dict[int, list[int]] = {}
|
|
106
|
+
for pid, parent in parents.items():
|
|
107
|
+
children.setdefault(parent, []).append(pid)
|
|
108
|
+
descendants: list[int] = []
|
|
109
|
+
pending = list(children.get(root_pid, []))
|
|
110
|
+
while pending:
|
|
111
|
+
pid = pending.pop()
|
|
112
|
+
descendants.append(pid)
|
|
113
|
+
pending.extend(children.get(pid, []))
|
|
114
|
+
return descendants, None
|
|
115
|
+
finally:
|
|
116
|
+
kernel32.CloseHandle(snapshot)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _terminate_windows_pid(pid: int) -> tuple[bool, str | None]:
|
|
120
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
121
|
+
handle = kernel32.OpenProcess(0x0001, False, pid)
|
|
122
|
+
if not handle:
|
|
123
|
+
error = ctypes.get_last_error()
|
|
124
|
+
if error in {2, 3, 87, 1168}:
|
|
125
|
+
return True, None
|
|
126
|
+
return False, f"OpenProcess({pid}) failed: {error}"
|
|
127
|
+
try:
|
|
128
|
+
if kernel32.TerminateProcess(handle, 1):
|
|
129
|
+
return True, None
|
|
130
|
+
return False, f"TerminateProcess({pid}) failed: {ctypes.get_last_error()}"
|
|
131
|
+
finally:
|
|
132
|
+
kernel32.CloseHandle(handle)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ToolPolicyError(RuntimeError):
|
|
136
|
+
"""A tool call rejected by the repository policy."""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ToolCancelled(RuntimeError):
|
|
140
|
+
"""A running command was cancelled."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _BoundedPipeCollector:
|
|
144
|
+
"""Continuously drain one child pipe while retaining bounded output."""
|
|
145
|
+
|
|
146
|
+
def __init__(self, limit: int) -> None:
|
|
147
|
+
self._limit = limit
|
|
148
|
+
self._data = bytearray()
|
|
149
|
+
self.truncated = False
|
|
150
|
+
|
|
151
|
+
def collect(self, stream: Any) -> None:
|
|
152
|
+
try:
|
|
153
|
+
while True:
|
|
154
|
+
chunk = stream.read(8192)
|
|
155
|
+
if not chunk:
|
|
156
|
+
return
|
|
157
|
+
if not isinstance(chunk, bytes):
|
|
158
|
+
chunk = bytes(chunk)
|
|
159
|
+
remaining = self._limit - len(self._data)
|
|
160
|
+
if remaining > 0:
|
|
161
|
+
self._data.extend(chunk[:remaining])
|
|
162
|
+
if len(chunk) > max(remaining, 0):
|
|
163
|
+
self.truncated = True
|
|
164
|
+
except (OSError, ValueError):
|
|
165
|
+
# Closing a pipe after bounded process termination is expected.
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
def value(self) -> bytes:
|
|
169
|
+
return bytes(self._data)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class BoundedRepositoryTools:
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
workspace_root: str | Path,
|
|
176
|
+
task: TaskEnvelope,
|
|
177
|
+
*,
|
|
178
|
+
max_tool_result_bytes: int = 32_000,
|
|
179
|
+
max_files: int = 5,
|
|
180
|
+
max_matches: int = 100,
|
|
181
|
+
max_patch_bytes: int = 128_000,
|
|
182
|
+
max_patch_files: int = 2,
|
|
183
|
+
|
|
184
|
+
test_timeout_seconds: float = 60,
|
|
185
|
+
cancel_event: Event | None = None,
|
|
186
|
+
blocked_tools: set[str] | None = None,
|
|
187
|
+
) -> None:
|
|
188
|
+
if max_tool_result_bytes <= 0:
|
|
189
|
+
raise ValueError("max_tool_result_bytes must be positive")
|
|
190
|
+
if (
|
|
191
|
+
max_files <= 0
|
|
192
|
+
or max_matches <= 0
|
|
193
|
+
or max_patch_bytes <= 0
|
|
194
|
+
or max_patch_files <= 0
|
|
195
|
+
or test_timeout_seconds <= 0
|
|
196
|
+
):
|
|
197
|
+
raise ValueError("all repository tool limits must be positive")
|
|
198
|
+
self.workspace_root = Path(workspace_root).resolve()
|
|
199
|
+
self.task = task
|
|
200
|
+
self.max_tool_result_bytes = max_tool_result_bytes
|
|
201
|
+
self.max_files = max_files
|
|
202
|
+
self.max_matches = max_matches
|
|
203
|
+
self.max_patch_bytes = max_patch_bytes
|
|
204
|
+
self.max_patch_files = max_patch_files
|
|
205
|
+
self.test_timeout_seconds = test_timeout_seconds
|
|
206
|
+
self.cancel_event = cancel_event
|
|
207
|
+
self.blocked_tools = set(blocked_tools or ())
|
|
208
|
+
self._allowlist = {self._normalize_declared_path(path) for path in task.files}
|
|
209
|
+
if len(self._allowlist) > max_files:
|
|
210
|
+
raise ToolPolicyError(f"task exceeds max_files={max_files}")
|
|
211
|
+
self._audit_events: list[dict[str, Any]] = []
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def audit_events(self) -> tuple[dict[str, Any], ...]:
|
|
215
|
+
return tuple(dict(event) for event in self._audit_events)
|
|
216
|
+
|
|
217
|
+
def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
218
|
+
if name in self.blocked_tools:
|
|
219
|
+
self._record(name, arguments, False, f"tool '{name}' is blocked in read-only mode")
|
|
220
|
+
raise ToolPolicyError(f"tool '{name}' is blocked in read-only mode")
|
|
221
|
+
if name not in {"list_files", "read_file", "search_text", "propose_patch", "run_tests"}:
|
|
222
|
+
self._record(name, arguments, False, "unknown tool")
|
|
223
|
+
raise ToolPolicyError(f"unknown tool: {name}")
|
|
224
|
+
try:
|
|
225
|
+
if name == "list_files":
|
|
226
|
+
result = self._list_files(arguments)
|
|
227
|
+
elif name == "read_file":
|
|
228
|
+
result = self._read_file(arguments)
|
|
229
|
+
elif name == "propose_patch":
|
|
230
|
+
result = self._propose_patch(arguments)
|
|
231
|
+
elif name == "run_tests":
|
|
232
|
+
result = self._run_tests(arguments)
|
|
233
|
+
else:
|
|
234
|
+
result = self._search_text(arguments)
|
|
235
|
+
except ToolPolicyError as error:
|
|
236
|
+
self._record(name, arguments, False, str(error))
|
|
237
|
+
raise
|
|
238
|
+
success = result.get("ok", True) is not False
|
|
239
|
+
error_msg = result.get("error") if not success else None
|
|
240
|
+
self._record(name, arguments, success, error_msg)
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
def _run_tests(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
244
|
+
command = arguments.get("command")
|
|
245
|
+
if not isinstance(command, str) or not command.strip():
|
|
246
|
+
raise ToolPolicyError("command must be a non-empty string")
|
|
247
|
+
if command not in self.task.checks:
|
|
248
|
+
raise ToolPolicyError("command is not allowlisted")
|
|
249
|
+
process: subprocess.Popen[bytes] = subprocess.Popen(
|
|
250
|
+
command,
|
|
251
|
+
cwd=self.workspace_root,
|
|
252
|
+
shell=True,
|
|
253
|
+
stdout=subprocess.PIPE,
|
|
254
|
+
stderr=subprocess.PIPE,
|
|
255
|
+
env=self._isolated_environment(),
|
|
256
|
+
**self._process_group_options(math.ceil(self.test_timeout_seconds)),
|
|
257
|
+
)
|
|
258
|
+
assert process.stdout is not None
|
|
259
|
+
assert process.stderr is not None
|
|
260
|
+
stdout_collector = _BoundedPipeCollector(self.max_tool_result_bytes)
|
|
261
|
+
stderr_collector = _BoundedPipeCollector(self.max_tool_result_bytes)
|
|
262
|
+
readers = [
|
|
263
|
+
Thread(target=stdout_collector.collect, args=(process.stdout,), daemon=True),
|
|
264
|
+
Thread(target=stderr_collector.collect, args=(process.stderr,), daemon=True),
|
|
265
|
+
]
|
|
266
|
+
for reader in readers:
|
|
267
|
+
reader.start()
|
|
268
|
+
deadline = time.monotonic() + self.test_timeout_seconds
|
|
269
|
+
timed_out = False
|
|
270
|
+
termination_warning: str | None = None
|
|
271
|
+
finished = False
|
|
272
|
+
try:
|
|
273
|
+
while True:
|
|
274
|
+
if self.cancel_event is not None and self.cancel_event.is_set():
|
|
275
|
+
termination_warning = self._terminate(process)
|
|
276
|
+
finished = True
|
|
277
|
+
raise ToolCancelled("task was cancelled")
|
|
278
|
+
if process.poll() is not None:
|
|
279
|
+
finished = True
|
|
280
|
+
break
|
|
281
|
+
if time.monotonic() >= deadline:
|
|
282
|
+
termination_warning = self._terminate(process)
|
|
283
|
+
timed_out = True
|
|
284
|
+
finished = True
|
|
285
|
+
break
|
|
286
|
+
time.sleep(0.05)
|
|
287
|
+
finally:
|
|
288
|
+
try:
|
|
289
|
+
if not finished and process.poll() is None:
|
|
290
|
+
termination_warning = self._terminate(process)
|
|
291
|
+
finally:
|
|
292
|
+
for reader in readers:
|
|
293
|
+
reader.join(timeout=2)
|
|
294
|
+
process.stdout.close()
|
|
295
|
+
process.stderr.close()
|
|
296
|
+
for reader in readers:
|
|
297
|
+
reader.join(timeout=0.5)
|
|
298
|
+
stdout = stdout_collector.value()
|
|
299
|
+
stderr = stderr_collector.value()
|
|
300
|
+
stdout_truncated = stdout_collector.truncated
|
|
301
|
+
stderr_truncated = stderr_collector.truncated
|
|
302
|
+
truncated = stdout_truncated or stderr_truncated
|
|
303
|
+
if timed_out:
|
|
304
|
+
result = {
|
|
305
|
+
"command": command,
|
|
306
|
+
"passed": False,
|
|
307
|
+
"exit_code": None,
|
|
308
|
+
"stdout": self._decode_process_output(stdout),
|
|
309
|
+
"stderr": self._decode_process_output(stderr),
|
|
310
|
+
"truncated": truncated,
|
|
311
|
+
"timeout": True,
|
|
312
|
+
"isolated": True,
|
|
313
|
+
}
|
|
314
|
+
elif process.returncode != 0:
|
|
315
|
+
result = {
|
|
316
|
+
"command": command,
|
|
317
|
+
"passed": False,
|
|
318
|
+
"exit_code": process.returncode,
|
|
319
|
+
"stdout": self._decode_process_output(stdout),
|
|
320
|
+
"stderr": self._decode_process_output(stderr),
|
|
321
|
+
"truncated": truncated,
|
|
322
|
+
"isolated": True,
|
|
323
|
+
}
|
|
324
|
+
else:
|
|
325
|
+
result = {
|
|
326
|
+
"command": command,
|
|
327
|
+
"passed": True,
|
|
328
|
+
"exit_code": process.returncode,
|
|
329
|
+
"stdout": self._decode_process_output(stdout),
|
|
330
|
+
"stderr": self._decode_process_output(stderr),
|
|
331
|
+
"truncated": truncated,
|
|
332
|
+
"isolated": True,
|
|
333
|
+
}
|
|
334
|
+
if termination_warning:
|
|
335
|
+
result["termination_warning"] = termination_warning
|
|
336
|
+
result = self._bounded_process_result(result)
|
|
337
|
+
result["evidence"] = self._process_evidence(result)
|
|
338
|
+
if self._result_size(result) > self.max_tool_result_bytes and result.get("isolated") is True:
|
|
339
|
+
result.pop("isolated")
|
|
340
|
+
result = self._trim_stdout_stderr(
|
|
341
|
+
result, after_trim=lambda r: {**r, "evidence": self._process_evidence(r)}
|
|
342
|
+
)
|
|
343
|
+
if self._result_size(result) > self.max_tool_result_bytes:
|
|
344
|
+
raise ToolPolicyError("max_tool_result_bytes is too small for run_tests evidence")
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
def _terminate(self, process: subprocess.Popen) -> str | None:
|
|
348
|
+
# ponytail: kill the whole tree so grandchild processes (cmd.exe
|
|
349
|
+
# shells herding the real command) cannot hold the workspace open.
|
|
350
|
+
if process.poll() is not None:
|
|
351
|
+
return None
|
|
352
|
+
killed, detail = self._kill_tree(process)
|
|
353
|
+
try:
|
|
354
|
+
process.wait(timeout=2)
|
|
355
|
+
except subprocess.TimeoutExpired:
|
|
356
|
+
try:
|
|
357
|
+
process.kill()
|
|
358
|
+
except OSError as error:
|
|
359
|
+
raise ToolPolicyError(f"failed to terminate process: {error}") from error
|
|
360
|
+
try:
|
|
361
|
+
process.wait(timeout=2)
|
|
362
|
+
except subprocess.TimeoutExpired as error:
|
|
363
|
+
raise ToolPolicyError(
|
|
364
|
+
f"process did not terminate after bounded wait: {detail or error}"
|
|
365
|
+
) from error
|
|
366
|
+
if not killed and process.poll() is None:
|
|
367
|
+
raise ToolPolicyError(f"failed to terminate process: {detail or 'unknown error'}")
|
|
368
|
+
return detail if not killed else None
|
|
369
|
+
|
|
370
|
+
@staticmethod
|
|
371
|
+
def _kill_tree(process: subprocess.Popen) -> tuple[bool, str | None]:
|
|
372
|
+
if os.name == "nt":
|
|
373
|
+
# /T terminates every descendant process, /F forces it.
|
|
374
|
+
system_root = os.environ.get("SystemRoot", r"C:\Windows")
|
|
375
|
+
taskkill = str(Path(system_root) / "System32" / "taskkill.exe")
|
|
376
|
+
if not Path(taskkill).is_file():
|
|
377
|
+
taskkill = "taskkill"
|
|
378
|
+
try:
|
|
379
|
+
completed = subprocess.run(
|
|
380
|
+
[taskkill, "/PID", str(process.pid), "/T", "/F"],
|
|
381
|
+
stdout=subprocess.DEVNULL,
|
|
382
|
+
stderr=subprocess.DEVNULL,
|
|
383
|
+
check=False,
|
|
384
|
+
timeout=5,
|
|
385
|
+
)
|
|
386
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
387
|
+
tree, snapshot_detail = _windows_descendants(process.pid)
|
|
388
|
+
errors = [snapshot_detail] if snapshot_detail else []
|
|
389
|
+
for pid in reversed(list(dict.fromkeys([*tree, process.pid]))):
|
|
390
|
+
killed, kill_detail = _terminate_windows_pid(pid)
|
|
391
|
+
if not killed and kill_detail:
|
|
392
|
+
errors.append(kill_detail)
|
|
393
|
+
if process.poll() is None:
|
|
394
|
+
try:
|
|
395
|
+
process.kill()
|
|
396
|
+
except OSError as kill_error:
|
|
397
|
+
errors.append(f"process.kill failed: {kill_error}")
|
|
398
|
+
detail = f"taskkill failed: {error}"
|
|
399
|
+
return False, f"{detail}; {'; '.join(errors)}" if errors else detail
|
|
400
|
+
tree, snapshot_detail = _windows_descendants(process.pid)
|
|
401
|
+
remaining, _ = _windows_descendants(process.pid)
|
|
402
|
+
if completed.returncode == 0 and not remaining:
|
|
403
|
+
return True, None
|
|
404
|
+
detail = f"taskkill exited with code {completed.returncode}"
|
|
405
|
+
errors = [snapshot_detail] if snapshot_detail else []
|
|
406
|
+
for pid in reversed(list(dict.fromkeys([*tree, *remaining, process.pid]))):
|
|
407
|
+
killed, kill_detail = _terminate_windows_pid(pid)
|
|
408
|
+
if not killed and kill_detail:
|
|
409
|
+
errors.append(kill_detail)
|
|
410
|
+
if process.poll() is None:
|
|
411
|
+
try:
|
|
412
|
+
process.kill()
|
|
413
|
+
except OSError as error:
|
|
414
|
+
errors.append(f"process.kill failed: {error}")
|
|
415
|
+
if errors:
|
|
416
|
+
return False, f"{detail}; {'; '.join(errors)}"
|
|
417
|
+
return False, detail
|
|
418
|
+
else:
|
|
419
|
+
try:
|
|
420
|
+
os.killpg(process.pid, 9)
|
|
421
|
+
except (ProcessLookupError, PermissionError):
|
|
422
|
+
try:
|
|
423
|
+
process.kill()
|
|
424
|
+
except OSError as error:
|
|
425
|
+
return False, f"process kill failed: {error}"
|
|
426
|
+
return True, None
|
|
427
|
+
|
|
428
|
+
@staticmethod
|
|
429
|
+
def _isolated_environment() -> dict[str, str]:
|
|
430
|
+
allowed_upper = {
|
|
431
|
+
"APPDATA",
|
|
432
|
+
"COMMONPROGRAMFILES",
|
|
433
|
+
"COMMONPROGRAMFILES(X86)",
|
|
434
|
+
"COMSPEC",
|
|
435
|
+
"HOME",
|
|
436
|
+
"HOMEDRIVE",
|
|
437
|
+
"HOMEPATH",
|
|
438
|
+
"LANG",
|
|
439
|
+
"LC_ALL",
|
|
440
|
+
"LD_LIBRARY_PATH",
|
|
441
|
+
"LOCALAPPDATA",
|
|
442
|
+
"PATH",
|
|
443
|
+
"PATHEXT",
|
|
444
|
+
"PROGRAMDATA",
|
|
445
|
+
"PROGRAMFILES",
|
|
446
|
+
"PROGRAMFILES(X86)",
|
|
447
|
+
"PYTHONHOME",
|
|
448
|
+
"PYTHONIOENCODING",
|
|
449
|
+
"PYTHONPATH",
|
|
450
|
+
"PYTHONUTF8",
|
|
451
|
+
"SYSTEMDRIVE",
|
|
452
|
+
"SYSTEMROOT",
|
|
453
|
+
"TEMP",
|
|
454
|
+
"TMP",
|
|
455
|
+
"TMPDIR",
|
|
456
|
+
"USERPROFILE",
|
|
457
|
+
"VIRTUAL_ENV",
|
|
458
|
+
"WINDIR",
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
key: value
|
|
462
|
+
for key, value in os.environ.items()
|
|
463
|
+
if key.upper() in allowed_upper and not _SECRET_ENV_NAME_RE.search(key)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@staticmethod
|
|
468
|
+
def _process_group_options(timeout_seconds: int) -> dict[str, Any]:
|
|
469
|
+
if os.name == "nt":
|
|
470
|
+
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
471
|
+
creationflags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
472
|
+
return {"creationflags": creationflags}
|
|
473
|
+
return {"preexec_fn": _posix_preexec(timeout_seconds)}
|
|
474
|
+
|
|
475
|
+
def _bounded_process_result(self, result: dict[str, Any]) -> dict[str, Any]:
|
|
476
|
+
if self._result_size(result) <= self.max_tool_result_bytes:
|
|
477
|
+
return result
|
|
478
|
+
# Keep the externally useful process evidence available even for very
|
|
479
|
+
# small result caps. Isolation is a provenance flag, not test output,
|
|
480
|
+
# so it is the first optional field we may omit when metadata itself
|
|
481
|
+
# would otherwise exceed the configured limit.
|
|
482
|
+
if result.get("isolated") is True:
|
|
483
|
+
result = {key: value for key, value in result.items() if key != "isolated"}
|
|
484
|
+
result = self._trim_stdout_stderr(result)
|
|
485
|
+
if self._result_size(result) > self.max_tool_result_bytes:
|
|
486
|
+
raise ToolPolicyError("max_tool_result_bytes is too small for run_tests metadata")
|
|
487
|
+
return result
|
|
488
|
+
|
|
489
|
+
def _trim_stdout_stderr(
|
|
490
|
+
self, result: dict[str, Any], *, after_trim: Any = None
|
|
491
|
+
) -> dict[str, Any]:
|
|
492
|
+
stdout = result.get("stdout", "")
|
|
493
|
+
stderr = result.get("stderr", "")
|
|
494
|
+
total_length = len(stdout) + len(stderr)
|
|
495
|
+
if self._result_size(result) <= self.max_tool_result_bytes or total_length == 0:
|
|
496
|
+
return result
|
|
497
|
+
|
|
498
|
+
def candidate(length: int) -> dict[str, Any]:
|
|
499
|
+
stdout_length = min(len(stdout), round(length * len(stdout) / total_length))
|
|
500
|
+
stderr_length = min(len(stderr), length - stdout_length)
|
|
501
|
+
retained = stdout_length + stderr_length
|
|
502
|
+
if retained < length:
|
|
503
|
+
stdout_length = min(len(stdout), stdout_length + length - retained)
|
|
504
|
+
bounded = {
|
|
505
|
+
**result,
|
|
506
|
+
"stdout": stdout[:stdout_length],
|
|
507
|
+
"stderr": stderr[:stderr_length],
|
|
508
|
+
"truncated": True,
|
|
509
|
+
}
|
|
510
|
+
return after_trim(bounded) if after_trim is not None else bounded
|
|
511
|
+
|
|
512
|
+
low = 0
|
|
513
|
+
high = total_length
|
|
514
|
+
best = candidate(0)
|
|
515
|
+
while low <= high:
|
|
516
|
+
middle = (low + high) // 2
|
|
517
|
+
bounded = candidate(middle)
|
|
518
|
+
if self._result_size(bounded) <= self.max_tool_result_bytes:
|
|
519
|
+
best = bounded
|
|
520
|
+
low = middle + 1
|
|
521
|
+
else:
|
|
522
|
+
high = middle - 1
|
|
523
|
+
return best
|
|
524
|
+
|
|
525
|
+
@staticmethod
|
|
526
|
+
def _decode_process_output(output: bytes | str | None) -> str:
|
|
527
|
+
if output is None:
|
|
528
|
+
return ""
|
|
529
|
+
if isinstance(output, bytes):
|
|
530
|
+
return output.decode("utf-8", errors="replace")
|
|
531
|
+
return output
|
|
532
|
+
|
|
533
|
+
@staticmethod
|
|
534
|
+
def _process_evidence(result: dict[str, Any]) -> str:
|
|
535
|
+
return (
|
|
536
|
+
f"exit_code={result['exit_code']}; passed={result['passed']}; "
|
|
537
|
+
f"stdout_bytes={len(result.get('stdout', '').encode('utf-8'))}; "
|
|
538
|
+
f"stderr_bytes={len(result.get('stderr', '').encode('utf-8'))}; "
|
|
539
|
+
f"truncated={result.get('truncated', False)}"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def _propose_patch(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
543
|
+
patch = arguments.get("patch")
|
|
544
|
+
edits = arguments.get("edits")
|
|
545
|
+
has_patch = isinstance(patch, str) and patch.strip()
|
|
546
|
+
has_edits = isinstance(edits, list) and bool(edits)
|
|
547
|
+
if has_patch and has_edits:
|
|
548
|
+
raise ToolPolicyError("provide either patch or edits, not both")
|
|
549
|
+
if not has_patch and not has_edits:
|
|
550
|
+
raise ToolPolicyError("patch or edits must be provided")
|
|
551
|
+
if has_edits:
|
|
552
|
+
allowed = {self._normalize_declared_path(path) for path in self.task.files}
|
|
553
|
+
resolved, changed, edit_issues = resolve_edits(
|
|
554
|
+
self.workspace_root,
|
|
555
|
+
edits,
|
|
556
|
+
allowed_files=allowed,
|
|
557
|
+
max_files=self.max_patch_files,
|
|
558
|
+
max_patch_bytes=self.max_patch_bytes,
|
|
559
|
+
)
|
|
560
|
+
if edit_issues:
|
|
561
|
+
return {
|
|
562
|
+
"ok": False,
|
|
563
|
+
"error": f"propose_patch rejected: {'; '.join(edit_issues)}",
|
|
564
|
+
"status": "error",
|
|
565
|
+
}
|
|
566
|
+
applies, detail = check_patch_applies(self.workspace_root, resolved)
|
|
567
|
+
if not applies:
|
|
568
|
+
return {
|
|
569
|
+
"ok": False,
|
|
570
|
+
"error": f"propose_patch rejected: patch does not apply cleanly: {detail}",
|
|
571
|
+
"status": "error",
|
|
572
|
+
}
|
|
573
|
+
return {"patch": resolved, "edits": edits, "files": sorted(changed)}
|
|
574
|
+
|
|
575
|
+
if len(patch.encode("utf-8")) > self.max_patch_bytes:
|
|
576
|
+
raise ToolPolicyError(f"patch exceeds max_patch_bytes={self.max_patch_bytes}")
|
|
577
|
+
_, diff_issues = parse_unified_diff(patch)
|
|
578
|
+
if diff_issues:
|
|
579
|
+
return {
|
|
580
|
+
"ok": False,
|
|
581
|
+
"error": f"propose_patch rejected: {'; '.join(diff_issues)}",
|
|
582
|
+
"status": "error",
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
paths: list[str] = []
|
|
586
|
+
has_hunk = False
|
|
587
|
+
for line in patch.splitlines():
|
|
588
|
+
if line.startswith("diff --git "):
|
|
589
|
+
match = re.fullmatch(r"diff --git mechanical a/(.+) b/(.+)", line) or re.fullmatch(r"diff --git a/(.+) b/(.+)", line)
|
|
590
|
+
if not match:
|
|
591
|
+
return {
|
|
592
|
+
"ok": False,
|
|
593
|
+
"error": "propose_patch rejected: patch has invalid diff header",
|
|
594
|
+
"status": "error",
|
|
595
|
+
}
|
|
596
|
+
for raw_path in match.groups():
|
|
597
|
+
normalized = self._patch_path(raw_path, prefix=None)
|
|
598
|
+
if normalized is not None and normalized not in paths:
|
|
599
|
+
paths.append(normalized)
|
|
600
|
+
elif line.startswith("--- ") or line.startswith("+++ "):
|
|
601
|
+
raw_path = line[4:].split("\t", 1)[0].strip()
|
|
602
|
+
normalized = self._patch_path(raw_path, prefix=None)
|
|
603
|
+
if normalized is not None and normalized not in paths:
|
|
604
|
+
paths.append(normalized)
|
|
605
|
+
elif line.startswith("@@ "):
|
|
606
|
+
has_hunk = True
|
|
607
|
+
if not paths or not has_hunk:
|
|
608
|
+
return {
|
|
609
|
+
"ok": False,
|
|
610
|
+
"error": "propose_patch rejected: patch is not a unified diff",
|
|
611
|
+
"status": "error",
|
|
612
|
+
}
|
|
613
|
+
if len(paths) > self.max_patch_files:
|
|
614
|
+
raise ToolPolicyError(f"patch exceeds max_patch_files={self.max_patch_files}")
|
|
615
|
+
applies, detail = check_patch_applies(self.workspace_root, patch)
|
|
616
|
+
if not applies:
|
|
617
|
+
return {
|
|
618
|
+
"ok": False,
|
|
619
|
+
"error": f"propose_patch rejected: patch does not apply cleanly: {detail}",
|
|
620
|
+
"status": "error",
|
|
621
|
+
}
|
|
622
|
+
return {"patch": patch, "files": sorted(paths)}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _patch_path(self, raw_path: str, *, prefix: str | None) -> str | None:
|
|
626
|
+
del prefix
|
|
627
|
+
if raw_path == "/dev/null":
|
|
628
|
+
return None
|
|
629
|
+
candidate = raw_path[2:] if raw_path[:2] in {"a/", "b/"} else raw_path
|
|
630
|
+
_, relative = self._resolve_allowlisted(candidate)
|
|
631
|
+
return relative
|
|
632
|
+
|
|
633
|
+
def _list_files(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
634
|
+
raw_path = arguments.get("path", ".")
|
|
635
|
+
directory, relative_root = self._resolve_workspace_path(raw_path)
|
|
636
|
+
if not directory.is_dir():
|
|
637
|
+
raise ToolPolicyError(f"directory does not exist: {relative_root}")
|
|
638
|
+
ignored_parts = {".git", "__pycache__", ".venv", "venv", "node_modules"}
|
|
639
|
+
files = sorted(
|
|
640
|
+
path.relative_to(self.workspace_root).as_posix()
|
|
641
|
+
for path in directory.rglob("*")
|
|
642
|
+
if path.is_file()
|
|
643
|
+
and _fold_path(path.relative_to(self.workspace_root).as_posix()) in self._allowlist
|
|
644
|
+
and not ignored_parts.intersection(path.relative_to(self.workspace_root).parts)
|
|
645
|
+
)
|
|
646
|
+
truncated = len(files) > self.max_files
|
|
647
|
+
result = {"files": files[: self.max_files], "truncated": truncated}
|
|
648
|
+
if self._result_size(result) <= self.max_tool_result_bytes:
|
|
649
|
+
return result
|
|
650
|
+
bounded = list(result["files"])
|
|
651
|
+
while bounded and self._result_size({"files": bounded, "truncated": True}) > self.max_tool_result_bytes:
|
|
652
|
+
bounded.pop()
|
|
653
|
+
result = {"files": bounded, "truncated": True}
|
|
654
|
+
if self._result_size(result) > self.max_tool_result_bytes:
|
|
655
|
+
raise ToolPolicyError("max_tool_result_bytes is too small for list_files metadata")
|
|
656
|
+
return result
|
|
657
|
+
|
|
658
|
+
def _search_text(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
659
|
+
query = arguments.get("query")
|
|
660
|
+
if not isinstance(query, str) or not query:
|
|
661
|
+
raise ToolPolicyError("query must be a non-empty string")
|
|
662
|
+
if len(query) > 256:
|
|
663
|
+
raise ToolPolicyError("query exceeds 256 characters")
|
|
664
|
+
raw_paths = arguments.get("paths", self.task.files)
|
|
665
|
+
if isinstance(raw_paths, str) or not isinstance(raw_paths, (list, tuple)):
|
|
666
|
+
raise ToolPolicyError("paths must be a list of strings")
|
|
667
|
+
if not raw_paths or len(raw_paths) > self.max_files:
|
|
668
|
+
raise ToolPolicyError(f"paths must contain 1..{self.max_files} files")
|
|
669
|
+
|
|
670
|
+
matches: list[dict[str, Any]] = []
|
|
671
|
+
for raw_path in raw_paths:
|
|
672
|
+
path, relative = self._resolve_allowlisted(raw_path)
|
|
673
|
+
if not path.is_file():
|
|
674
|
+
raise ToolPolicyError(f"file does not exist: {relative}")
|
|
675
|
+
if path.stat().st_size > self.max_patch_bytes:
|
|
676
|
+
raise ToolPolicyError(f"file exceeds max_patch_bytes={self.max_patch_bytes}: {relative}")
|
|
677
|
+
try:
|
|
678
|
+
content = path.read_text(encoding="utf-8")
|
|
679
|
+
except UnicodeDecodeError as error:
|
|
680
|
+
raise ToolPolicyError(f"file is not UTF-8 text: {relative}") from error
|
|
681
|
+
for line_number, line in enumerate(content.splitlines(), start=1):
|
|
682
|
+
if query in line:
|
|
683
|
+
matches.append({"path": relative, "line": line_number, "text": line})
|
|
684
|
+
if len(matches) >= self.max_matches:
|
|
685
|
+
return self._bounded_matches(matches, truncated=True)
|
|
686
|
+
return self._bounded_matches(matches, truncated=False)
|
|
687
|
+
|
|
688
|
+
def _bounded_matches(self, matches: list[dict[str, Any]], *, truncated: bool) -> dict[str, Any]:
|
|
689
|
+
result = {"matches": matches, "truncated": truncated}
|
|
690
|
+
if self._result_size(result) <= self.max_tool_result_bytes:
|
|
691
|
+
return result
|
|
692
|
+
bounded = list(matches)
|
|
693
|
+
while bounded and self._result_size({"matches": bounded, "truncated": True}) > self.max_tool_result_bytes:
|
|
694
|
+
bounded.pop()
|
|
695
|
+
result = {"matches": bounded, "truncated": True}
|
|
696
|
+
if self._result_size(result) > self.max_tool_result_bytes:
|
|
697
|
+
raise ToolPolicyError("max_tool_result_bytes is too small for search_text metadata")
|
|
698
|
+
return result
|
|
699
|
+
|
|
700
|
+
def _read_file(self, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
701
|
+
raw_path = arguments.get("path")
|
|
702
|
+
path, relative = self._resolve_allowlisted(raw_path)
|
|
703
|
+
if not path.is_file():
|
|
704
|
+
raise ToolPolicyError(f"file does not exist: {relative}")
|
|
705
|
+
try:
|
|
706
|
+
content = path.read_text(encoding="utf-8")
|
|
707
|
+
except UnicodeDecodeError as error:
|
|
708
|
+
raise ToolPolicyError(f"file is not UTF-8 text: {relative}") from error
|
|
709
|
+
result = {"path": relative, "content": content, "truncated": False}
|
|
710
|
+
if self._result_size(result) <= self.max_tool_result_bytes:
|
|
711
|
+
return result
|
|
712
|
+
low = 0
|
|
713
|
+
high = len(content)
|
|
714
|
+
best = ""
|
|
715
|
+
while low <= high:
|
|
716
|
+
middle = (low + high) // 2
|
|
717
|
+
candidate = {"path": relative, "content": content[:middle], "truncated": True}
|
|
718
|
+
if self._result_size(candidate) <= self.max_tool_result_bytes:
|
|
719
|
+
best = candidate["content"]
|
|
720
|
+
low = middle + 1
|
|
721
|
+
else:
|
|
722
|
+
high = middle - 1
|
|
723
|
+
bounded = {"path": relative, "content": best, "truncated": True}
|
|
724
|
+
if self._result_size(bounded) > self.max_tool_result_bytes:
|
|
725
|
+
raise ToolPolicyError("max_tool_result_bytes is too small for read_file metadata")
|
|
726
|
+
return bounded
|
|
727
|
+
|
|
728
|
+
@staticmethod
|
|
729
|
+
def _result_size(result: dict[str, Any]) -> int:
|
|
730
|
+
return len(json.dumps(result, ensure_ascii=False).encode("utf-8"))
|
|
731
|
+
|
|
732
|
+
def _resolve_allowlisted(self, raw_path: Any) -> tuple[Path, str]:
|
|
733
|
+
if not isinstance(raw_path, str) or not raw_path.strip():
|
|
734
|
+
raise ToolPolicyError("path must be a non-empty string")
|
|
735
|
+
candidate_input = Path(raw_path)
|
|
736
|
+
if candidate_input.is_absolute() or candidate_input.drive or candidate_input.root or "\x00" in raw_path:
|
|
737
|
+
raise ToolPolicyError("absolute or invalid path is not allowed")
|
|
738
|
+
candidate = (self.workspace_root / candidate_input).resolve()
|
|
739
|
+
try:
|
|
740
|
+
relative = candidate.relative_to(self.workspace_root).as_posix()
|
|
741
|
+
except ValueError as error:
|
|
742
|
+
raise ToolPolicyError("path escapes workspace") from error
|
|
743
|
+
if _fold_path(relative) not in self._allowlist:
|
|
744
|
+
raise ToolPolicyError(f"path is outside task allowlist: {relative}")
|
|
745
|
+
return candidate, relative
|
|
746
|
+
|
|
747
|
+
def _resolve_workspace_path(self, raw_path: Any) -> tuple[Path, str]:
|
|
748
|
+
if not isinstance(raw_path, str) or not raw_path.strip():
|
|
749
|
+
raise ToolPolicyError("path must be a non-empty string")
|
|
750
|
+
candidate_input = Path(raw_path)
|
|
751
|
+
if candidate_input.is_absolute() or candidate_input.drive or candidate_input.root or "\x00" in raw_path:
|
|
752
|
+
raise ToolPolicyError("absolute or invalid path is not allowed")
|
|
753
|
+
candidate = (self.workspace_root / candidate_input).resolve()
|
|
754
|
+
try:
|
|
755
|
+
relative = candidate.relative_to(self.workspace_root).as_posix()
|
|
756
|
+
except ValueError as error:
|
|
757
|
+
raise ToolPolicyError("path escapes workspace") from error
|
|
758
|
+
return candidate, relative or "."
|
|
759
|
+
|
|
760
|
+
@staticmethod
|
|
761
|
+
def _normalize_declared_path(raw_path: str) -> str:
|
|
762
|
+
candidate = Path(raw_path)
|
|
763
|
+
if candidate.is_absolute() or candidate.drive or candidate.root or "\x00" in raw_path:
|
|
764
|
+
raise ValueError(f"task file must be a relative valid path: {raw_path!r}")
|
|
765
|
+
return _fold_path(candidate.as_posix())
|
|
766
|
+
|
|
767
|
+
def _record(
|
|
768
|
+
self,
|
|
769
|
+
name: str,
|
|
770
|
+
arguments: dict[str, Any],
|
|
771
|
+
success: bool,
|
|
772
|
+
error: str | None,
|
|
773
|
+
) -> None:
|
|
774
|
+
event: dict[str, Any] = {
|
|
775
|
+
"event": "tool_call",
|
|
776
|
+
"name": name,
|
|
777
|
+
"arguments": dict(arguments),
|
|
778
|
+
"success": success,
|
|
779
|
+
}
|
|
780
|
+
if error is not None:
|
|
781
|
+
event["error"] = error
|
|
782
|
+
self._audit_events.append(event)
|