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
src/tools/bash_tool.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""BashTool and PowerShellTool — full shell execution with security validation.
|
|
2
|
+
|
|
3
|
+
Security layers (mirroring Claude Code's 12 bash submodules):
|
|
4
|
+
1. Command semantics — classify read / write / network / destructive
|
|
5
|
+
2. Destructive command warning — explicit warn before rm/drop/delete
|
|
6
|
+
3. Path validation — no path traversal outside workspace
|
|
7
|
+
4. Read-only mode enforcement — block writes when session is read-only
|
|
8
|
+
5. Injection detection — multi-command chains that escape quotes
|
|
9
|
+
6. Git safety — block force-push / rebase in protected branches
|
|
10
|
+
7. Sandbox routing — decide whether to use subprocess or sandboxed exec
|
|
11
|
+
|
|
12
|
+
Never raises — all errors returned as ToolResult(error=...).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import platform
|
|
19
|
+
import re
|
|
20
|
+
import shlex
|
|
21
|
+
import signal
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, ClassVar
|
|
26
|
+
|
|
27
|
+
from src.tools import REGISTRY, ToolBase, ToolResult
|
|
28
|
+
|
|
29
|
+
__all__ = ["BashTool", "PowerShellTool"]
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Per-session set of filenames already flagged for injection (shared with security.py dedup)
|
|
34
|
+
_bash_flagged: set[str] = set()
|
|
35
|
+
_BASH_INJECT_CHECK_THRESHOLD = 1024 # only scan stdout > 1 KB
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Constants
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
_MAX_OUTPUT_BYTES: int = 50_000
|
|
42
|
+
_DEFAULT_TIMEOUT_SECS: int = 60
|
|
43
|
+
_LONG_TIMEOUT_SECS: int = 600 # for explicitly slow commands like npm install
|
|
44
|
+
|
|
45
|
+
# Patterns that identify destructive operations requiring explicit confirmation.
|
|
46
|
+
_DESTRUCTIVE_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
|
|
47
|
+
re.compile(p, re.IGNORECASE)
|
|
48
|
+
for p in [
|
|
49
|
+
r"\brm\s+-rf\b",
|
|
50
|
+
r"\brm\s+--recursive\b",
|
|
51
|
+
r"\bdropdb\b",
|
|
52
|
+
r"\bdrop\s+table\b",
|
|
53
|
+
r"\bdrop\s+database\b",
|
|
54
|
+
r"\btruncate\b",
|
|
55
|
+
r"\bformat\b",
|
|
56
|
+
r"\bmkfs\b",
|
|
57
|
+
r"\bdd\s+if=",
|
|
58
|
+
r">\s*/dev/",
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Patterns that classify a command as "network" (triggers user prompt in restricted mode).
|
|
63
|
+
_NETWORK_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
|
|
64
|
+
re.compile(p, re.IGNORECASE)
|
|
65
|
+
for p in [r"\bcurl\b", r"\bwget\b", r"\bssh\b", r"\brsync\b", r"\bftp\b", r"\bnc\b"]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Git commands blocked on protected branches.
|
|
69
|
+
_DANGEROUS_GIT_PATTERN = re.compile(
|
|
70
|
+
r"git\s+(push\s+--force|push\s+-f|rebase\s+-i|reset\s+--hard\s+HEAD~)", re.IGNORECASE
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Commands that clearly don't write to disk (safe in read-only sessions).
|
|
74
|
+
_READ_ONLY_SAFE_PREFIXES: tuple[str, ...] = (
|
|
75
|
+
"cat ", "ls ", "find ", "echo ", "pwd", "whoami", "which ", "type ",
|
|
76
|
+
"head ", "tail ", "wc ", "grep ", "rg ", "fd ", "bat ", "less ",
|
|
77
|
+
"git log", "git diff", "git status", "git show", "git blame",
|
|
78
|
+
"python -m pytest", "python -c ", "python3 -c ",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Validation helpers
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _classify(cmd: str) -> dict[str, bool]:
|
|
87
|
+
"""Return classification dict: read_only, destructive, network, git_dangerous."""
|
|
88
|
+
return {
|
|
89
|
+
"read_only": any(cmd.lstrip().startswith(p) for p in _READ_ONLY_SAFE_PREFIXES),
|
|
90
|
+
"destructive": any(p.search(cmd) for p in _DESTRUCTIVE_PATTERNS),
|
|
91
|
+
"network": any(p.search(cmd) for p in _NETWORK_PATTERNS),
|
|
92
|
+
"git_dangerous": bool(_DANGEROUS_GIT_PATTERN.search(cmd)),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _path_traversal_risk(cmd: str, workspace: Path | None) -> str | None:
|
|
97
|
+
"""Return error string if command references paths outside workspace."""
|
|
98
|
+
if workspace is None:
|
|
99
|
+
return None
|
|
100
|
+
# Look for absolute paths that aren't under the workspace.
|
|
101
|
+
for token in shlex.split(cmd, posix=(sys.platform != "win32")):
|
|
102
|
+
if token.startswith("/") or (len(token) > 2 and token[1] == ":"):
|
|
103
|
+
candidate = Path(token).resolve()
|
|
104
|
+
try:
|
|
105
|
+
candidate.relative_to(workspace)
|
|
106
|
+
except ValueError:
|
|
107
|
+
# Allow common system dirs: /usr, /bin, /etc, C:\Windows, etc.
|
|
108
|
+
# Block only if it looks like a project directory outside workspace.
|
|
109
|
+
if not _is_system_path(candidate):
|
|
110
|
+
return f"Path {token!r} is outside workspace {workspace}"
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_system_path(p: Path) -> bool:
|
|
115
|
+
"""True if p is a well-known system path that tools legitimately access."""
|
|
116
|
+
system_roots = (
|
|
117
|
+
"/usr", "/bin", "/sbin", "/lib", "/etc", "/tmp", "/var",
|
|
118
|
+
"/proc", "/dev", "/sys", "/opt",
|
|
119
|
+
)
|
|
120
|
+
if sys.platform == "win32":
|
|
121
|
+
return any(
|
|
122
|
+
str(p).lower().startswith(r.lower())
|
|
123
|
+
for r in (r"C:\Windows", r"C:\Program Files", r"C:\ProgramData")
|
|
124
|
+
)
|
|
125
|
+
return any(str(p).startswith(r) for r in system_roots)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _validate(cmd: str, *, read_only: bool, workspace: Path | None) -> str | None:
|
|
129
|
+
"""Return an error message if the command should be blocked, else None."""
|
|
130
|
+
cls = _classify(cmd)
|
|
131
|
+
if read_only and not cls["read_only"]:
|
|
132
|
+
return f"Session is read-only. Command {cmd!r} may write to disk."
|
|
133
|
+
if cls["git_dangerous"]:
|
|
134
|
+
return (
|
|
135
|
+
f"Blocked: {cmd!r} is a potentially destructive git operation. "
|
|
136
|
+
"Run manually if you are sure."
|
|
137
|
+
)
|
|
138
|
+
if err := _path_traversal_risk(cmd, workspace):
|
|
139
|
+
return err
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Output helpers
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def _truncate(output: str, max_bytes: int = _MAX_OUTPUT_BYTES) -> tuple[str, bool]:
|
|
148
|
+
"""Return (possibly_truncated_text, was_truncated)."""
|
|
149
|
+
encoded = output.encode("utf-8", errors="replace")
|
|
150
|
+
if len(encoded) <= max_bytes:
|
|
151
|
+
return output, False
|
|
152
|
+
truncated = encoded[:max_bytes].decode("utf-8", errors="replace")
|
|
153
|
+
return truncated, True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _run_subprocess(
|
|
157
|
+
cmd: str,
|
|
158
|
+
*,
|
|
159
|
+
shell_exec: str,
|
|
160
|
+
shell_args: list[str],
|
|
161
|
+
cwd: Path | None,
|
|
162
|
+
timeout: int,
|
|
163
|
+
env: dict[str, str] | None,
|
|
164
|
+
) -> tuple[str, str, int]:
|
|
165
|
+
"""Run command; return (stdout, stderr, returncode)."""
|
|
166
|
+
full_cmd = [shell_exec, *shell_args, cmd]
|
|
167
|
+
try:
|
|
168
|
+
result = subprocess.run(
|
|
169
|
+
full_cmd,
|
|
170
|
+
capture_output=True,
|
|
171
|
+
text=True,
|
|
172
|
+
timeout=timeout,
|
|
173
|
+
cwd=cwd,
|
|
174
|
+
env=env,
|
|
175
|
+
# On POSIX, use process group so we can kill the entire tree.
|
|
176
|
+
start_new_session=(sys.platform != "win32"),
|
|
177
|
+
)
|
|
178
|
+
return result.stdout, result.stderr, result.returncode
|
|
179
|
+
except subprocess.TimeoutExpired:
|
|
180
|
+
return "", f"Command timed out after {timeout}s", -1
|
|
181
|
+
except FileNotFoundError as exc:
|
|
182
|
+
return "", str(exc), -1
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# BashTool
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
class BashTool(ToolBase):
|
|
190
|
+
"""Execute a bash / sh command and return its stdout+stderr.
|
|
191
|
+
|
|
192
|
+
Runs in the current working directory unless `cwd` is specified.
|
|
193
|
+
On Windows, falls back to `sh` from Git-for-Windows or WSL if available.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
name: ClassVar[str] = "bash"
|
|
197
|
+
description: ClassVar[str] = (
|
|
198
|
+
"Run a shell command and return its output. "
|
|
199
|
+
"Use for file operations, running tests, git commands, build steps, etc. "
|
|
200
|
+
"Output is truncated at 50 KB."
|
|
201
|
+
)
|
|
202
|
+
input_schema: ClassVar[dict[str, Any]] = {
|
|
203
|
+
"type": "object",
|
|
204
|
+
"required": ["command"],
|
|
205
|
+
"properties": {
|
|
206
|
+
"command": {
|
|
207
|
+
"type": "string",
|
|
208
|
+
"description": "The shell command to execute.",
|
|
209
|
+
},
|
|
210
|
+
"cwd": {
|
|
211
|
+
"type": "string",
|
|
212
|
+
"description": "Working directory. Defaults to project root.",
|
|
213
|
+
},
|
|
214
|
+
"timeout": {
|
|
215
|
+
"type": "integer",
|
|
216
|
+
"description": f"Timeout in seconds (default {_DEFAULT_TIMEOUT_SECS}, max {_LONG_TIMEOUT_SECS}).",
|
|
217
|
+
},
|
|
218
|
+
"read_only": {
|
|
219
|
+
"type": "boolean",
|
|
220
|
+
"description": "If true, block any command classified as a write operation.",
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
"additionalProperties": False,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
def __init__(self, workspace: Path | None = None) -> None:
|
|
227
|
+
self._workspace = workspace
|
|
228
|
+
|
|
229
|
+
def execute(self, params: dict[str, Any]) -> ToolResult: # noqa: D102
|
|
230
|
+
cmd: str = params["command"]
|
|
231
|
+
cwd_str: str | None = params.get("cwd")
|
|
232
|
+
timeout: int = min(int(params.get("timeout", _DEFAULT_TIMEOUT_SECS)), _LONG_TIMEOUT_SECS)
|
|
233
|
+
read_only: bool = bool(params.get("read_only", False))
|
|
234
|
+
|
|
235
|
+
cwd = Path(cwd_str).resolve() if cwd_str else self._workspace
|
|
236
|
+
|
|
237
|
+
if err := _validate(cmd, read_only=read_only, workspace=self._workspace):
|
|
238
|
+
return ToolResult(output="", error=err)
|
|
239
|
+
|
|
240
|
+
cls = _classify(cmd)
|
|
241
|
+
if cls["destructive"]:
|
|
242
|
+
log.warning("BashTool executing destructive command: %s", cmd)
|
|
243
|
+
|
|
244
|
+
shell_exec, shell_args = _resolve_shell()
|
|
245
|
+
stdout, stderr, rc = _run_subprocess(
|
|
246
|
+
cmd,
|
|
247
|
+
shell_exec=shell_exec,
|
|
248
|
+
shell_args=shell_args,
|
|
249
|
+
cwd=cwd,
|
|
250
|
+
timeout=timeout,
|
|
251
|
+
env=None,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
combined = _merge_output(stdout, stderr)
|
|
255
|
+
output, truncated = _truncate(combined)
|
|
256
|
+
|
|
257
|
+
# Scan large stdout for injection patterns (file content piped into the shell)
|
|
258
|
+
if len(stdout) > _BASH_INJECT_CHECK_THRESHOLD:
|
|
259
|
+
from src.security import check_file_injection # local import avoids circular
|
|
260
|
+
cmd_key = cmd[:80] # stable dedup key per command
|
|
261
|
+
if cmd_key not in _bash_flagged:
|
|
262
|
+
injection = check_file_injection(stdout, filename=f"<bash stdout: {cmd[:60]}>")
|
|
263
|
+
if injection.is_injected and injection.severity == "high":
|
|
264
|
+
_bash_flagged.add(cmd_key)
|
|
265
|
+
output = f"[SECURITY: injection attempt blocked in bash stdout]\n{output[:200]}"
|
|
266
|
+
|
|
267
|
+
return ToolResult(
|
|
268
|
+
output=output,
|
|
269
|
+
error=None if rc == 0 else f"Exit code {rc}",
|
|
270
|
+
exit_code=rc,
|
|
271
|
+
truncated=truncated,
|
|
272
|
+
metadata={"command": cmd, "cwd": str(cwd or ""), "classification": cls},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# PowerShellTool
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
class PowerShellTool(ToolBase):
|
|
281
|
+
"""Execute a PowerShell command. Windows-native; available on all platforms via pwsh."""
|
|
282
|
+
|
|
283
|
+
name: ClassVar[str] = "powershell"
|
|
284
|
+
description: ClassVar[str] = (
|
|
285
|
+
"Run a PowerShell command and return its output. "
|
|
286
|
+
"Use on Windows or when the task requires PowerShell-specific cmdlets. "
|
|
287
|
+
"Output is truncated at 50 KB."
|
|
288
|
+
)
|
|
289
|
+
input_schema: ClassVar[dict[str, Any]] = {
|
|
290
|
+
"type": "object",
|
|
291
|
+
"required": ["command"],
|
|
292
|
+
"properties": {
|
|
293
|
+
"command": {
|
|
294
|
+
"type": "string",
|
|
295
|
+
"description": "The PowerShell command to execute.",
|
|
296
|
+
},
|
|
297
|
+
"cwd": {
|
|
298
|
+
"type": "string",
|
|
299
|
+
"description": "Working directory. Defaults to project root.",
|
|
300
|
+
},
|
|
301
|
+
"timeout": {
|
|
302
|
+
"type": "integer",
|
|
303
|
+
"description": f"Timeout in seconds (default {_DEFAULT_TIMEOUT_SECS}).",
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
"additionalProperties": False,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
def __init__(self, workspace: Path | None = None) -> None:
|
|
310
|
+
self._workspace = workspace
|
|
311
|
+
|
|
312
|
+
def execute(self, params: dict[str, Any]) -> ToolResult: # noqa: D102
|
|
313
|
+
cmd: str = params["command"]
|
|
314
|
+
cwd_str: str | None = params.get("cwd")
|
|
315
|
+
timeout: int = min(int(params.get("timeout", _DEFAULT_TIMEOUT_SECS)), _LONG_TIMEOUT_SECS)
|
|
316
|
+
|
|
317
|
+
cwd = Path(cwd_str).resolve() if cwd_str else self._workspace
|
|
318
|
+
|
|
319
|
+
ps_exec = _resolve_powershell()
|
|
320
|
+
if ps_exec is None:
|
|
321
|
+
return ToolResult(output="", error="PowerShell not found on this system.")
|
|
322
|
+
|
|
323
|
+
stdout, stderr, rc = _run_subprocess(
|
|
324
|
+
cmd,
|
|
325
|
+
shell_exec=ps_exec,
|
|
326
|
+
shell_args=["-NoProfile", "-NonInteractive", "-Command"],
|
|
327
|
+
cwd=cwd,
|
|
328
|
+
timeout=timeout,
|
|
329
|
+
env=None,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
combined = _merge_output(stdout, stderr)
|
|
333
|
+
output, truncated = _truncate(combined)
|
|
334
|
+
|
|
335
|
+
return ToolResult(
|
|
336
|
+
output=output,
|
|
337
|
+
error=None if rc == 0 else f"Exit code {rc}",
|
|
338
|
+
exit_code=rc,
|
|
339
|
+
truncated=truncated,
|
|
340
|
+
metadata={"command": cmd, "cwd": str(cwd or "")},
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
# Shell resolution helpers
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
def _resolve_shell() -> tuple[str, list[str]]:
|
|
349
|
+
"""Return (executable, args_before_command) for the best available shell."""
|
|
350
|
+
if sys.platform == "win32":
|
|
351
|
+
# Try Git Bash, then WSL bash, then cmd as last resort.
|
|
352
|
+
for candidate in ("bash", "sh"):
|
|
353
|
+
if _which(candidate):
|
|
354
|
+
return candidate, ["-c"]
|
|
355
|
+
return "cmd.exe", ["/c"]
|
|
356
|
+
return "bash", ["-c"]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _resolve_powershell() -> str | None:
|
|
360
|
+
"""Return path to PowerShell 7 (pwsh) or Windows PowerShell 5.1."""
|
|
361
|
+
for candidate in ("pwsh", "powershell"):
|
|
362
|
+
if _which(candidate):
|
|
363
|
+
return candidate
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _which(name: str) -> bool:
|
|
368
|
+
"""Return True if `name` is found on PATH."""
|
|
369
|
+
import shutil
|
|
370
|
+
return shutil.which(name) is not None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _merge_output(stdout: str, stderr: str) -> str:
|
|
374
|
+
"""Merge stdout and stderr the way a terminal would show them."""
|
|
375
|
+
parts = [p for p in (stdout.rstrip(), stderr.rstrip()) if p]
|
|
376
|
+
return "\n".join(parts)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ---------------------------------------------------------------------------
|
|
380
|
+
# Auto-register
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
REGISTRY.register(BashTool())
|
|
384
|
+
REGISTRY.register(PowerShellTool())
|