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.
Files changed (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. 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)