llmcode-cli 1.0.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 (212) hide show
  1. llm_code/__init__.py +2 -0
  2. llm_code/analysis/__init__.py +6 -0
  3. llm_code/analysis/cache.py +33 -0
  4. llm_code/analysis/engine.py +256 -0
  5. llm_code/analysis/go_rules.py +114 -0
  6. llm_code/analysis/js_rules.py +84 -0
  7. llm_code/analysis/python_rules.py +311 -0
  8. llm_code/analysis/rules.py +140 -0
  9. llm_code/analysis/rust_rules.py +108 -0
  10. llm_code/analysis/universal_rules.py +111 -0
  11. llm_code/api/__init__.py +0 -0
  12. llm_code/api/client.py +90 -0
  13. llm_code/api/errors.py +73 -0
  14. llm_code/api/openai_compat.py +390 -0
  15. llm_code/api/provider.py +35 -0
  16. llm_code/api/sse.py +52 -0
  17. llm_code/api/types.py +140 -0
  18. llm_code/cli/__init__.py +0 -0
  19. llm_code/cli/commands.py +70 -0
  20. llm_code/cli/image.py +122 -0
  21. llm_code/cli/render.py +214 -0
  22. llm_code/cli/status_line.py +79 -0
  23. llm_code/cli/streaming.py +92 -0
  24. llm_code/cli/tui_main.py +220 -0
  25. llm_code/computer_use/__init__.py +11 -0
  26. llm_code/computer_use/app_detect.py +49 -0
  27. llm_code/computer_use/app_tier.py +57 -0
  28. llm_code/computer_use/coordinator.py +99 -0
  29. llm_code/computer_use/input_control.py +71 -0
  30. llm_code/computer_use/screenshot.py +93 -0
  31. llm_code/cron/__init__.py +13 -0
  32. llm_code/cron/parser.py +145 -0
  33. llm_code/cron/scheduler.py +135 -0
  34. llm_code/cron/storage.py +126 -0
  35. llm_code/enterprise/__init__.py +1 -0
  36. llm_code/enterprise/audit.py +59 -0
  37. llm_code/enterprise/auth.py +26 -0
  38. llm_code/enterprise/oidc.py +95 -0
  39. llm_code/enterprise/rbac.py +65 -0
  40. llm_code/harness/__init__.py +5 -0
  41. llm_code/harness/config.py +33 -0
  42. llm_code/harness/engine.py +129 -0
  43. llm_code/harness/guides.py +41 -0
  44. llm_code/harness/sensors.py +68 -0
  45. llm_code/harness/templates.py +84 -0
  46. llm_code/hida/__init__.py +1 -0
  47. llm_code/hida/classifier.py +187 -0
  48. llm_code/hida/engine.py +49 -0
  49. llm_code/hida/profiles.py +95 -0
  50. llm_code/hida/types.py +28 -0
  51. llm_code/ide/__init__.py +1 -0
  52. llm_code/ide/bridge.py +80 -0
  53. llm_code/ide/detector.py +76 -0
  54. llm_code/ide/server.py +169 -0
  55. llm_code/logging.py +29 -0
  56. llm_code/lsp/__init__.py +0 -0
  57. llm_code/lsp/client.py +298 -0
  58. llm_code/lsp/detector.py +42 -0
  59. llm_code/lsp/manager.py +56 -0
  60. llm_code/lsp/tools.py +288 -0
  61. llm_code/marketplace/__init__.py +0 -0
  62. llm_code/marketplace/builtin_registry.py +102 -0
  63. llm_code/marketplace/installer.py +162 -0
  64. llm_code/marketplace/plugin.py +78 -0
  65. llm_code/marketplace/registry.py +360 -0
  66. llm_code/mcp/__init__.py +0 -0
  67. llm_code/mcp/bridge.py +87 -0
  68. llm_code/mcp/client.py +117 -0
  69. llm_code/mcp/health.py +120 -0
  70. llm_code/mcp/manager.py +214 -0
  71. llm_code/mcp/oauth.py +219 -0
  72. llm_code/mcp/transport.py +254 -0
  73. llm_code/mcp/types.py +53 -0
  74. llm_code/remote/__init__.py +0 -0
  75. llm_code/remote/client.py +136 -0
  76. llm_code/remote/protocol.py +22 -0
  77. llm_code/remote/server.py +275 -0
  78. llm_code/remote/ssh_proxy.py +56 -0
  79. llm_code/runtime/__init__.py +0 -0
  80. llm_code/runtime/auto_commit.py +56 -0
  81. llm_code/runtime/auto_diagnose.py +62 -0
  82. llm_code/runtime/checkpoint.py +70 -0
  83. llm_code/runtime/checkpoint_recovery.py +142 -0
  84. llm_code/runtime/compaction.py +35 -0
  85. llm_code/runtime/compressor.py +415 -0
  86. llm_code/runtime/config.py +533 -0
  87. llm_code/runtime/context.py +49 -0
  88. llm_code/runtime/conversation.py +921 -0
  89. llm_code/runtime/cost_tracker.py +126 -0
  90. llm_code/runtime/dream.py +127 -0
  91. llm_code/runtime/file_protection.py +150 -0
  92. llm_code/runtime/hardware.py +85 -0
  93. llm_code/runtime/hooks.py +223 -0
  94. llm_code/runtime/indexer.py +230 -0
  95. llm_code/runtime/knowledge_compiler.py +232 -0
  96. llm_code/runtime/memory.py +132 -0
  97. llm_code/runtime/memory_layers.py +467 -0
  98. llm_code/runtime/memory_lint.py +252 -0
  99. llm_code/runtime/model_aliases.py +37 -0
  100. llm_code/runtime/ollama.py +93 -0
  101. llm_code/runtime/overlay.py +124 -0
  102. llm_code/runtime/permissions.py +200 -0
  103. llm_code/runtime/plan.py +45 -0
  104. llm_code/runtime/prompt.py +238 -0
  105. llm_code/runtime/repo_map.py +174 -0
  106. llm_code/runtime/sandbox.py +116 -0
  107. llm_code/runtime/session.py +268 -0
  108. llm_code/runtime/skill_resolver.py +61 -0
  109. llm_code/runtime/skills.py +133 -0
  110. llm_code/runtime/speculative.py +75 -0
  111. llm_code/runtime/streaming_executor.py +216 -0
  112. llm_code/runtime/telemetry.py +196 -0
  113. llm_code/runtime/token_budget.py +26 -0
  114. llm_code/runtime/vcr.py +142 -0
  115. llm_code/runtime/vision.py +102 -0
  116. llm_code/swarm/__init__.py +1 -0
  117. llm_code/swarm/backend_subprocess.py +108 -0
  118. llm_code/swarm/backend_tmux.py +103 -0
  119. llm_code/swarm/backend_worktree.py +306 -0
  120. llm_code/swarm/checkpoint.py +74 -0
  121. llm_code/swarm/coordinator.py +236 -0
  122. llm_code/swarm/mailbox.py +88 -0
  123. llm_code/swarm/manager.py +202 -0
  124. llm_code/swarm/memory_sync.py +80 -0
  125. llm_code/swarm/recovery.py +21 -0
  126. llm_code/swarm/team.py +67 -0
  127. llm_code/swarm/types.py +31 -0
  128. llm_code/task/__init__.py +16 -0
  129. llm_code/task/diagnostics.py +93 -0
  130. llm_code/task/manager.py +162 -0
  131. llm_code/task/types.py +112 -0
  132. llm_code/task/verifier.py +104 -0
  133. llm_code/tools/__init__.py +0 -0
  134. llm_code/tools/agent.py +145 -0
  135. llm_code/tools/agent_roles.py +82 -0
  136. llm_code/tools/base.py +94 -0
  137. llm_code/tools/bash.py +565 -0
  138. llm_code/tools/computer_use_tools.py +278 -0
  139. llm_code/tools/coordinator_tool.py +75 -0
  140. llm_code/tools/cron_create.py +90 -0
  141. llm_code/tools/cron_delete.py +49 -0
  142. llm_code/tools/cron_list.py +51 -0
  143. llm_code/tools/deferred.py +92 -0
  144. llm_code/tools/dump.py +116 -0
  145. llm_code/tools/edit_file.py +282 -0
  146. llm_code/tools/git_tools.py +531 -0
  147. llm_code/tools/glob_search.py +112 -0
  148. llm_code/tools/grep_search.py +144 -0
  149. llm_code/tools/ide_diagnostics.py +59 -0
  150. llm_code/tools/ide_open.py +58 -0
  151. llm_code/tools/ide_selection.py +52 -0
  152. llm_code/tools/memory_tools.py +138 -0
  153. llm_code/tools/multi_edit.py +143 -0
  154. llm_code/tools/notebook_edit.py +107 -0
  155. llm_code/tools/notebook_read.py +81 -0
  156. llm_code/tools/parsing.py +63 -0
  157. llm_code/tools/read_file.py +154 -0
  158. llm_code/tools/registry.py +58 -0
  159. llm_code/tools/search_backends/__init__.py +56 -0
  160. llm_code/tools/search_backends/brave.py +56 -0
  161. llm_code/tools/search_backends/duckduckgo.py +129 -0
  162. llm_code/tools/search_backends/searxng.py +71 -0
  163. llm_code/tools/search_backends/tavily.py +73 -0
  164. llm_code/tools/swarm_create.py +109 -0
  165. llm_code/tools/swarm_delete.py +95 -0
  166. llm_code/tools/swarm_list.py +44 -0
  167. llm_code/tools/swarm_message.py +109 -0
  168. llm_code/tools/task_close.py +79 -0
  169. llm_code/tools/task_plan.py +79 -0
  170. llm_code/tools/task_verify.py +90 -0
  171. llm_code/tools/tool_search.py +65 -0
  172. llm_code/tools/web_common.py +258 -0
  173. llm_code/tools/web_fetch.py +223 -0
  174. llm_code/tools/web_search.py +280 -0
  175. llm_code/tools/write_file.py +118 -0
  176. llm_code/tui/__init__.py +1 -0
  177. llm_code/tui/app.py +2432 -0
  178. llm_code/tui/chat_view.py +82 -0
  179. llm_code/tui/chat_widgets.py +309 -0
  180. llm_code/tui/header_bar.py +46 -0
  181. llm_code/tui/input_bar.py +349 -0
  182. llm_code/tui/keybindings.py +142 -0
  183. llm_code/tui/marketplace.py +210 -0
  184. llm_code/tui/status_bar.py +72 -0
  185. llm_code/tui/theme.py +96 -0
  186. llm_code/utils/__init__.py +0 -0
  187. llm_code/utils/diff.py +111 -0
  188. llm_code/utils/errors.py +70 -0
  189. llm_code/utils/hyperlink.py +73 -0
  190. llm_code/utils/notebook.py +179 -0
  191. llm_code/utils/search.py +69 -0
  192. llm_code/utils/text_normalize.py +28 -0
  193. llm_code/utils/version_check.py +62 -0
  194. llm_code/vim/__init__.py +4 -0
  195. llm_code/vim/engine.py +51 -0
  196. llm_code/vim/motions.py +172 -0
  197. llm_code/vim/operators.py +183 -0
  198. llm_code/vim/text_objects.py +139 -0
  199. llm_code/vim/transitions.py +279 -0
  200. llm_code/vim/types.py +68 -0
  201. llm_code/voice/__init__.py +1 -0
  202. llm_code/voice/languages.py +43 -0
  203. llm_code/voice/recorder.py +136 -0
  204. llm_code/voice/stt.py +36 -0
  205. llm_code/voice/stt_anthropic.py +66 -0
  206. llm_code/voice/stt_google.py +32 -0
  207. llm_code/voice/stt_whisper.py +52 -0
  208. llmcode_cli-1.0.0.dist-info/METADATA +524 -0
  209. llmcode_cli-1.0.0.dist-info/RECORD +212 -0
  210. llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
  211. llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
  212. llmcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
llm_code/tools/bash.py ADDED
@@ -0,0 +1,565 @@
1
+ """BashTool — execute shell commands with timeout and safety checks."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import re
6
+ import select
7
+ import subprocess
8
+ from dataclasses import dataclass, field
9
+ from typing import TYPE_CHECKING, Callable
10
+
11
+ if TYPE_CHECKING:
12
+ from llm_code.runtime.config import BashRule
13
+
14
+ _log = logging.getLogger(__name__)
15
+
16
+ from pydantic import BaseModel
17
+
18
+ from llm_code.tools.base import PermissionLevel, Tool, ToolProgress, ToolResult
19
+ from llm_code.utils.errors import friendly_error
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Input model
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ class BashInput(BaseModel):
27
+ command: str
28
+ timeout: int = 30
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Safety result dataclass
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class BashSafetyResult:
38
+ """Classification result from the bash safety checker.
39
+
40
+ classification:
41
+ "safe" — proceed without confirmation
42
+ "needs_confirm" — ask user before executing
43
+ "blocked" — refuse execution outright
44
+ """
45
+
46
+ classification: str # "safe" | "needs_confirm" | "blocked"
47
+ reasons: tuple[str, ...] = field(default_factory=tuple)
48
+ rule_ids: tuple[str, ...] = field(default_factory=tuple)
49
+
50
+ @property
51
+ def is_safe(self) -> bool:
52
+ return self.classification == "safe"
53
+
54
+ @property
55
+ def is_blocked(self) -> bool:
56
+ return self.classification == "blocked"
57
+
58
+ @property
59
+ def needs_confirm(self) -> bool:
60
+ return self.classification == "needs_confirm"
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Pattern lists — original 7 checks
65
+ # ---------------------------------------------------------------------------
66
+
67
+ # Read-only command prefixes / patterns
68
+ _READ_ONLY_PATTERNS: list[re.Pattern[str]] = [
69
+ # Basic file inspection
70
+ re.compile(r"^\s*(ls|cat|head|tail|wc|echo|pwd|whoami|date|uname|which|type|file|stat)\b"),
71
+ # Search tools
72
+ re.compile(r"^\s*(grep|rg|find|fd|ag|ack)\b"),
73
+ # Git read-only subcommands
74
+ re.compile(r"^\s*git\s+(status|log|diff|show|branch|remote|tag)\b"),
75
+ # Python/node one-liners that only print
76
+ re.compile(r'^\s*python\s+-c\s+["\'].*print', re.IGNORECASE),
77
+ re.compile(r'^\s*node\s+-e\s+["\'].*console\.log', re.IGNORECASE),
78
+ # System info
79
+ re.compile(r"^\s*(env|printenv|id|hostname|df|du|free|uptime|ps)\b"),
80
+ ]
81
+
82
+ # Truly dangerous patterns (blocked in execute() — irreversible, catastrophic)
83
+ _TRULY_DANGEROUS_PATTERNS: list[re.Pattern[str]] = [
84
+ re.compile(r"\brm\s+-[^\s]*r[^\s]*\s+(\/|~|\$HOME|\*)", re.IGNORECASE),
85
+ re.compile(r"\brm\s+-rf\b", re.IGNORECASE),
86
+ re.compile(r":\s*\(\)\s*\{.*\}", re.IGNORECASE), # fork bomb
87
+ re.compile(r"\bdd\s+if=.*of=/dev/", re.IGNORECASE),
88
+ re.compile(r">\s*/dev/sd[a-z]", re.IGNORECASE),
89
+ re.compile(r"\bmkfs\b", re.IGNORECASE),
90
+ re.compile(r"chmod\s+-R\s+777\s+/", re.IGNORECASE),
91
+ ]
92
+
93
+ # Destructive patterns (require confirmation)
94
+ _DESTRUCTIVE_PATTERNS: list[re.Pattern[str]] = [
95
+ re.compile(r"\brm\s+(-[^\s]*r|-rf?)\b", re.IGNORECASE),
96
+ re.compile(r"\bgit\s+(push|reset|rebase|merge|clean)\b", re.IGNORECASE),
97
+ re.compile(r"\bdrop\s+table\b", re.IGNORECASE),
98
+ re.compile(r"\btruncate\b", re.IGNORECASE),
99
+ re.compile(r"\bmkfs\b", re.IGNORECASE),
100
+ re.compile(r"\bdd\s+if=", re.IGNORECASE),
101
+ ]
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # New security check patterns — rules 8–20
106
+ # ---------------------------------------------------------------------------
107
+
108
+ # Rule 8: Command injection — $(…), backticks, ${…} nested in args
109
+ _CMD_INJECTION_PATTERN = re.compile(r"\$\(|`[^`]+`|\$\{[^}]+\}")
110
+
111
+ # Rule 9: Newline attack — literal \n or \r hiding commands
112
+ _NEWLINE_ATTACK_PATTERN = re.compile(r"(\\n|\\r|\x0a|\x0d)")
113
+
114
+ # Rule 10: Pipe chain — >5 pipes
115
+ _PIPE_CHAIN_PATTERN = re.compile(r"(?:\|(?!\|)){6,}") # 6 or more | (not ||)
116
+
117
+ # Rule 11: Interpreter REPL — interactive interpreter with no filename argument
118
+ # Matches: python, python3, node, ruby, perl, php with no trailing filename/flags
119
+ _REPL_PATTERN = re.compile(
120
+ r"^\s*(python3?|node|ruby|perl|php)\s*$",
121
+ re.IGNORECASE,
122
+ )
123
+
124
+ # Rule 12: Env leak — env/printenv/export commands may expose secrets
125
+ _ENV_LEAK_PATTERN = re.compile(r"^\s*(env|printenv|export)\b", re.IGNORECASE)
126
+
127
+ # Rule 13: Network access to non-localhost
128
+ # curl/wget/nc/ssh pointing to non-localhost destinations
129
+ _NETWORK_LOCALHOST_PATTERN = re.compile(
130
+ r"\b(curl|wget|nc|ssh)\s+[^\s]*?(localhost|127\.0\.0\.1|::1)",
131
+ re.IGNORECASE,
132
+ )
133
+ _NETWORK_ACCESS_PATTERN = re.compile(r"\b(curl|wget|nc|ssh)\b", re.IGNORECASE)
134
+
135
+ # Rule 14: File permission changes
136
+ _FILE_PERMISSION_PATTERN = re.compile(r"\b(chmod|chown|chgrp)\b", re.IGNORECASE)
137
+
138
+ # Rule 15: System package installation
139
+ _SYSTEM_PACKAGE_PATTERN = re.compile(
140
+ r"\b(apt(?:-get)?|brew)\s+(install|upgrade|update)\b"
141
+ r"|\bpip\s+install\b"
142
+ r"|\bnpm\s+install\s+-g\b",
143
+ re.IGNORECASE,
144
+ )
145
+
146
+ # Rule 16: Redirect overwrite (> but not >>)
147
+ _REDIRECT_OVERWRITE_PATTERN = re.compile(r"(?<!>)>(?!>)")
148
+
149
+ # Rule 17: Credential file access
150
+ _CREDENTIAL_ACCESS_PATTERN = re.compile(
151
+ r"(~\/\.ssh|~\/\.aws|~\/\.config|\.env\b|/\.ssh/|/\.aws/|/\.config/)",
152
+ re.IGNORECASE,
153
+ )
154
+
155
+ # Rule 18: Background execution
156
+ _BACKGROUND_EXEC_PATTERN = re.compile(r"\s&\s*$|\s&\s+|\bnohup\b|\bdisown\b")
157
+
158
+ # Rule 19: Recursive ops with find -exec or xargs + write commands
159
+ _RECURSIVE_OPS_PATTERN = re.compile(
160
+ r"\bfind\b.*-exec\b|\bxargs\b.*(rm|mv|cp|chmod|chown|dd|truncate|tee|write)\b",
161
+ re.IGNORECASE,
162
+ )
163
+
164
+ # Rule 20: Multi-command chaining >3 commands (&&, ||, ;)
165
+ _MULTI_CMD_SEPARATOR = re.compile(r"(&&|\|\||;)")
166
+
167
+ # Rule 21: Zsh dangerous builtins — raw system call / socket / compile access
168
+ _ZSH_DANGEROUS_BUILTINS = (
169
+ "zmodload",
170
+ "sysopen",
171
+ "sysread",
172
+ "syswrite",
173
+ "sysseek",
174
+ "zsocket",
175
+ "ztcp",
176
+ "zpty",
177
+ "zselect",
178
+ "zformat",
179
+ "zparseopts",
180
+ "zregexparse",
181
+ "zstat",
182
+ "zcompile",
183
+ )
184
+ _ZSH_DANGEROUS_PATTERN = re.compile(
185
+ r"(?<!\w)(" + "|".join(re.escape(b) for b in _ZSH_DANGEROUS_BUILTINS) + r")(?!\w)",
186
+ re.IGNORECASE,
187
+ )
188
+
189
+
190
+ def _count_pipe_segments(command: str) -> int:
191
+ """Count number of pipe characters (not ||) in command."""
192
+ # Remove || first, then count |
193
+ cleaned = command.replace("||", "\x00\x00")
194
+ return cleaned.count("|")
195
+
196
+
197
+ def _count_command_chain(command: str) -> int:
198
+ """Count commands separated by &&, ||, or ; (not inside quotes, approximate)."""
199
+ return len(_MULTI_CMD_SEPARATOR.findall(command))
200
+
201
+
202
+ def _is_network_to_non_localhost(command: str) -> bool:
203
+ """Return True if command uses a network tool pointing to non-localhost."""
204
+ if not _NETWORK_ACCESS_PATTERN.search(command):
205
+ return False
206
+ # If it explicitly targets localhost, it's fine
207
+ if _NETWORK_LOCALHOST_PATTERN.search(command):
208
+ return False
209
+ return True
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Internal helpers — original checks
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ def _is_truly_dangerous(command: str) -> bool:
218
+ return any(p.search(command) for p in _TRULY_DANGEROUS_PATTERNS)
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # Core safety classifier
223
+ # ---------------------------------------------------------------------------
224
+
225
+
226
+ def classify_command(
227
+ command: str,
228
+ user_rules: "tuple[BashRule, ...]" = (),
229
+ ) -> BashSafetyResult:
230
+ """Classify a bash command and return a BashSafetyResult.
231
+
232
+ Rules 1–7 map to the original pattern lists.
233
+ Rules 8–20 are the new extended security checks.
234
+
235
+ user_rules are checked first; first match wins and returns immediately.
236
+ """
237
+ # --- Phase 0: User-defined rules (first match wins) ----------------------
238
+ for i, rule in enumerate(user_rules):
239
+ try:
240
+ if re.search(rule.pattern, command):
241
+ action_map = {"allow": "safe", "confirm": "needs_confirm", "block": "blocked"}
242
+ classification = action_map.get(rule.action, "needs_confirm")
243
+ reason = rule.description or f"Matched user rule: {rule.pattern}"
244
+ return BashSafetyResult(
245
+ classification=classification,
246
+ reasons=(reason,),
247
+ rule_ids=(f"user:{i}",),
248
+ )
249
+ except re.error:
250
+ _log.warning("Invalid regex in user bash rule %d: %s", i, rule.pattern)
251
+ continue
252
+
253
+ reasons: list[str] = []
254
+ rule_ids: list[str] = []
255
+ classification = "safe"
256
+
257
+ # --- Blocked (rules 1–7 truly dangerous) --------------------------------
258
+ if _is_truly_dangerous(command):
259
+ return BashSafetyResult(
260
+ classification="blocked",
261
+ reasons=("Truly dangerous command detected",),
262
+ rule_ids=("R1-R7",),
263
+ )
264
+
265
+ # --- Rule 8: Command injection ------------------------------------------
266
+ if _CMD_INJECTION_PATTERN.search(command):
267
+ reasons.append("Command injection pattern detected: $(...), backtick, or ${...}")
268
+ rule_ids.append("R8")
269
+ classification = "needs_confirm"
270
+
271
+ # --- Rule 9: Newline attack ----------------------------------------------
272
+ if _NEWLINE_ATTACK_PATTERN.search(command):
273
+ reasons.append("Newline/carriage-return character may hide injected commands")
274
+ rule_ids.append("R9")
275
+ classification = "needs_confirm"
276
+
277
+ # --- Rule 10: Pipe chain > 5 pipes ---------------------------------------
278
+ pipe_count = _count_pipe_segments(command)
279
+ if pipe_count > 5:
280
+ reasons.append(f"Long pipe chain ({pipe_count} pipes) needs confirmation")
281
+ rule_ids.append("R10")
282
+ classification = "needs_confirm"
283
+
284
+ # --- Rule 11: Interpreter REPL (auto mode blocks interactive) ------------
285
+ if _REPL_PATTERN.search(command):
286
+ reasons.append("Interactive interpreter REPL blocked in auto mode (no filename given)")
287
+ rule_ids.append("R11")
288
+ # Upgrade to blocked for REPL
289
+ classification = "blocked"
290
+
291
+ # --- Rule 12: Env leak ---------------------------------------------------
292
+ if _ENV_LEAK_PATTERN.search(command):
293
+ reasons.append("env/printenv/export may expose secrets")
294
+ rule_ids.append("R12")
295
+ if classification == "safe":
296
+ classification = "needs_confirm"
297
+
298
+ # --- Rule 13: Network access to non-localhost ----------------------------
299
+ if _is_network_to_non_localhost(command):
300
+ reasons.append("Network access to non-localhost host needs confirmation")
301
+ rule_ids.append("R13")
302
+ if classification != "blocked":
303
+ classification = "needs_confirm"
304
+
305
+ # --- Rule 14: File permissions -------------------------------------------
306
+ if _FILE_PERMISSION_PATTERN.search(command):
307
+ reasons.append("chmod/chown/chgrp modifies file permissions")
308
+ rule_ids.append("R14")
309
+ if classification != "blocked":
310
+ classification = "needs_confirm"
311
+
312
+ # --- Rule 15: System package installation --------------------------------
313
+ if _SYSTEM_PACKAGE_PATTERN.search(command):
314
+ reasons.append("System package installation needs confirmation")
315
+ rule_ids.append("R15")
316
+ if classification != "blocked":
317
+ classification = "needs_confirm"
318
+
319
+ # --- Rule 16: Redirect overwrite -----------------------------------------
320
+ if _REDIRECT_OVERWRITE_PATTERN.search(command):
321
+ reasons.append("Output redirect (>) may overwrite existing file")
322
+ rule_ids.append("R16")
323
+ if classification != "blocked":
324
+ classification = "needs_confirm"
325
+
326
+ # --- Rule 17: Credential file access -------------------------------------
327
+ if _CREDENTIAL_ACCESS_PATTERN.search(command):
328
+ reasons.append("Command accesses credential or config files (~/.ssh, ~/.aws, .env…)")
329
+ rule_ids.append("R17")
330
+ if classification != "blocked":
331
+ classification = "needs_confirm"
332
+
333
+ # --- Rule 18: Background execution ---------------------------------------
334
+ if _BACKGROUND_EXEC_PATTERN.search(command):
335
+ reasons.append("Background execution (&, nohup, disown) needs confirmation")
336
+ rule_ids.append("R18")
337
+ if classification != "blocked":
338
+ classification = "needs_confirm"
339
+
340
+ # --- Rule 19: Recursive ops with find -exec / xargs + writes -------------
341
+ if _RECURSIVE_OPS_PATTERN.search(command):
342
+ reasons.append("Recursive operation with find -exec or xargs+write needs confirmation")
343
+ rule_ids.append("R19")
344
+ if classification != "blocked":
345
+ classification = "needs_confirm"
346
+
347
+ # --- Rule 20: Multi-command chaining > 3 commands ------------------------
348
+ chain_count = _count_command_chain(command)
349
+ if chain_count >= 3:
350
+ reasons.append(f"Multi-command chain ({chain_count + 1} commands) needs confirmation")
351
+ rule_ids.append("R20")
352
+ if classification != "blocked":
353
+ classification = "needs_confirm"
354
+
355
+ # --- Rule 21: Zsh dangerous builtins ------------------------------------
356
+ if _ZSH_DANGEROUS_PATTERN.search(command):
357
+ matched = _ZSH_DANGEROUS_PATTERN.findall(command)
358
+ reasons.append(
359
+ f"Zsh dangerous builtin(s) detected ({', '.join(matched)}): "
360
+ "allow raw system calls bypassing shell safety"
361
+ )
362
+ rule_ids.append("R21")
363
+ classification = "blocked"
364
+
365
+ return BashSafetyResult(
366
+ classification=classification,
367
+ reasons=tuple(reasons),
368
+ rule_ids=tuple(rule_ids),
369
+ )
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # BashTool
374
+ # ---------------------------------------------------------------------------
375
+
376
+
377
+ class BashTool(Tool):
378
+ def __init__(self, default_timeout: int = 30, max_output: int = 8000) -> None:
379
+ self._default_timeout = default_timeout
380
+ self._max_output = max_output
381
+
382
+ @property
383
+ def name(self) -> str:
384
+ return "bash"
385
+
386
+ @property
387
+ def description(self) -> str:
388
+ return "Execute a bash shell command and return its output."
389
+
390
+ @property
391
+ def input_schema(self) -> dict:
392
+ return {
393
+ "type": "object",
394
+ "properties": {
395
+ "command": {"type": "string", "description": "Shell command to execute"},
396
+ "timeout": {
397
+ "type": "integer",
398
+ "description": "Timeout in seconds (default 30)",
399
+ "default": 30,
400
+ },
401
+ },
402
+ "required": ["command"],
403
+ }
404
+
405
+ @property
406
+ def required_permission(self) -> PermissionLevel:
407
+ return PermissionLevel.FULL_ACCESS
408
+
409
+ @property
410
+ def input_model(self) -> type[BashInput]:
411
+ return BashInput
412
+
413
+ def classify(self, args: dict) -> BashSafetyResult:
414
+ """Return a BashSafetyResult for the command in *args*."""
415
+ command: str = args.get("command", "")
416
+ return classify_command(command)
417
+
418
+ def is_read_only(self, args: dict) -> bool:
419
+ command: str = args.get("command", "")
420
+ # A command is read-only only if it matches a read-only pattern AND
421
+ # the safety classifier does not flag it as needs_confirm or blocked
422
+ # (rules 8–20 override the read-only optimistic label).
423
+ if not any(p.search(command) for p in _READ_ONLY_PATTERNS):
424
+ return False
425
+ result = classify_command(command)
426
+ # If the classifier found something suspicious, it is not purely read-only
427
+ return result.is_safe
428
+
429
+ def is_destructive(self, args: dict) -> bool:
430
+ command: str = args.get("command", "")
431
+ if any(p.search(command) for p in _DESTRUCTIVE_PATTERNS):
432
+ return True
433
+ # Also treat "needs_confirm" from the extended rules as destructive
434
+ result = classify_command(command)
435
+ return result.needs_confirm or result.is_blocked
436
+
437
+ def is_concurrency_safe(self, args: dict) -> bool:
438
+ return self.is_read_only(args)
439
+
440
+ def execute(self, args: dict) -> ToolResult:
441
+ command: str = args["command"]
442
+ _raw_timeout = int(args.get("timeout", self._default_timeout))
443
+ timeout: int | None = None if _raw_timeout <= 0 else _raw_timeout
444
+
445
+ result = classify_command(command)
446
+ if result.is_blocked:
447
+ return ToolResult(
448
+ output=f"Dangerous command blocked: {command}\nReasons: {'; '.join(result.reasons)}",
449
+ is_error=True,
450
+ metadata={"dangerous": True, "rule_ids": list(result.rule_ids)},
451
+ )
452
+
453
+ return self._run(command, timeout)
454
+
455
+ def execute_with_progress(
456
+ self,
457
+ args: dict,
458
+ on_progress: Callable[[ToolProgress], None],
459
+ ) -> ToolResult:
460
+ command: str = args["command"]
461
+ _raw_timeout = int(args.get("timeout", self._default_timeout))
462
+ timeout: int | None = None if _raw_timeout <= 0 else _raw_timeout
463
+
464
+ try:
465
+ proc = subprocess.Popen(
466
+ command,
467
+ shell=True,
468
+ stdout=subprocess.PIPE,
469
+ stderr=subprocess.PIPE,
470
+ text=True,
471
+ )
472
+ except Exception as exc:
473
+ return ToolResult(output=f"Error starting command: {exc}", is_error=True)
474
+
475
+ output_chunks: list[str] = []
476
+ last_line = ""
477
+ deadline = __import__("time").monotonic() + timeout
478
+
479
+ try:
480
+ while True:
481
+ remaining = deadline - __import__("time").monotonic()
482
+ if remaining <= 0:
483
+ proc.kill()
484
+ proc.wait()
485
+ return ToolResult(
486
+ output=f"Command timed out after {timeout}s: {command}",
487
+ is_error=True,
488
+ )
489
+
490
+ # Poll for output with up to 1-second wait
491
+ wait = min(remaining, 1.0)
492
+ readable, _, _ = select.select([proc.stdout], [], [], wait)
493
+
494
+ if readable:
495
+ chunk = proc.stdout.read(4096) # type: ignore[union-attr]
496
+ if chunk:
497
+ output_chunks.append(chunk)
498
+ lines = chunk.splitlines()
499
+ if lines:
500
+ last_line = lines[-1]
501
+ on_progress(
502
+ ToolProgress(
503
+ tool_name=self.name,
504
+ message=last_line or "Running...",
505
+ )
506
+ )
507
+
508
+ # Check if process has finished
509
+ if proc.poll() is not None:
510
+ # Drain remaining stdout
511
+ remaining_out = proc.stdout.read() # type: ignore[union-attr]
512
+ if remaining_out:
513
+ output_chunks.append(remaining_out)
514
+ break
515
+
516
+ except Exception as exc:
517
+ proc.kill()
518
+ proc.wait()
519
+ return ToolResult(output=f"Error executing command: {exc}", is_error=True)
520
+
521
+ # Also capture stderr
522
+ stderr_out = proc.stderr.read() # type: ignore[union-attr]
523
+
524
+ output = "".join(output_chunks)
525
+ if stderr_out:
526
+ output = (output + stderr_out).strip()
527
+ else:
528
+ output = output.rstrip("\n")
529
+
530
+ if len(output) > self._max_output:
531
+ output = output[: self._max_output] + f"\n... [output truncated at {self._max_output} chars]"
532
+
533
+ is_error = proc.returncode != 0
534
+ return ToolResult(output=output, is_error=is_error)
535
+
536
+ def _run(self, command: str, timeout: int) -> ToolResult:
537
+ """Simple blocking run (used by execute())."""
538
+ try:
539
+ proc = subprocess.run(
540
+ command,
541
+ shell=True,
542
+ capture_output=True,
543
+ text=True,
544
+ timeout=timeout,
545
+ )
546
+ output = proc.stdout
547
+ if proc.stderr:
548
+ output = (output + proc.stderr).strip()
549
+ else:
550
+ output = output.rstrip("\n")
551
+
552
+ if len(output) > self._max_output:
553
+ truncated = output[: self._max_output]
554
+ output = truncated + f"\n... [output truncated at {self._max_output} chars]"
555
+
556
+ is_error = proc.returncode != 0
557
+ return ToolResult(output=output, is_error=is_error)
558
+
559
+ except subprocess.TimeoutExpired as exc:
560
+ return ToolResult(
561
+ output=friendly_error(exc, command),
562
+ is_error=True,
563
+ )
564
+ except Exception as exc:
565
+ return ToolResult(output=friendly_error(exc, command), is_error=True)