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.
- llm_code/__init__.py +2 -0
- llm_code/analysis/__init__.py +6 -0
- llm_code/analysis/cache.py +33 -0
- llm_code/analysis/engine.py +256 -0
- llm_code/analysis/go_rules.py +114 -0
- llm_code/analysis/js_rules.py +84 -0
- llm_code/analysis/python_rules.py +311 -0
- llm_code/analysis/rules.py +140 -0
- llm_code/analysis/rust_rules.py +108 -0
- llm_code/analysis/universal_rules.py +111 -0
- llm_code/api/__init__.py +0 -0
- llm_code/api/client.py +90 -0
- llm_code/api/errors.py +73 -0
- llm_code/api/openai_compat.py +390 -0
- llm_code/api/provider.py +35 -0
- llm_code/api/sse.py +52 -0
- llm_code/api/types.py +140 -0
- llm_code/cli/__init__.py +0 -0
- llm_code/cli/commands.py +70 -0
- llm_code/cli/image.py +122 -0
- llm_code/cli/render.py +214 -0
- llm_code/cli/status_line.py +79 -0
- llm_code/cli/streaming.py +92 -0
- llm_code/cli/tui_main.py +220 -0
- llm_code/computer_use/__init__.py +11 -0
- llm_code/computer_use/app_detect.py +49 -0
- llm_code/computer_use/app_tier.py +57 -0
- llm_code/computer_use/coordinator.py +99 -0
- llm_code/computer_use/input_control.py +71 -0
- llm_code/computer_use/screenshot.py +93 -0
- llm_code/cron/__init__.py +13 -0
- llm_code/cron/parser.py +145 -0
- llm_code/cron/scheduler.py +135 -0
- llm_code/cron/storage.py +126 -0
- llm_code/enterprise/__init__.py +1 -0
- llm_code/enterprise/audit.py +59 -0
- llm_code/enterprise/auth.py +26 -0
- llm_code/enterprise/oidc.py +95 -0
- llm_code/enterprise/rbac.py +65 -0
- llm_code/harness/__init__.py +5 -0
- llm_code/harness/config.py +33 -0
- llm_code/harness/engine.py +129 -0
- llm_code/harness/guides.py +41 -0
- llm_code/harness/sensors.py +68 -0
- llm_code/harness/templates.py +84 -0
- llm_code/hida/__init__.py +1 -0
- llm_code/hida/classifier.py +187 -0
- llm_code/hida/engine.py +49 -0
- llm_code/hida/profiles.py +95 -0
- llm_code/hida/types.py +28 -0
- llm_code/ide/__init__.py +1 -0
- llm_code/ide/bridge.py +80 -0
- llm_code/ide/detector.py +76 -0
- llm_code/ide/server.py +169 -0
- llm_code/logging.py +29 -0
- llm_code/lsp/__init__.py +0 -0
- llm_code/lsp/client.py +298 -0
- llm_code/lsp/detector.py +42 -0
- llm_code/lsp/manager.py +56 -0
- llm_code/lsp/tools.py +288 -0
- llm_code/marketplace/__init__.py +0 -0
- llm_code/marketplace/builtin_registry.py +102 -0
- llm_code/marketplace/installer.py +162 -0
- llm_code/marketplace/plugin.py +78 -0
- llm_code/marketplace/registry.py +360 -0
- llm_code/mcp/__init__.py +0 -0
- llm_code/mcp/bridge.py +87 -0
- llm_code/mcp/client.py +117 -0
- llm_code/mcp/health.py +120 -0
- llm_code/mcp/manager.py +214 -0
- llm_code/mcp/oauth.py +219 -0
- llm_code/mcp/transport.py +254 -0
- llm_code/mcp/types.py +53 -0
- llm_code/remote/__init__.py +0 -0
- llm_code/remote/client.py +136 -0
- llm_code/remote/protocol.py +22 -0
- llm_code/remote/server.py +275 -0
- llm_code/remote/ssh_proxy.py +56 -0
- llm_code/runtime/__init__.py +0 -0
- llm_code/runtime/auto_commit.py +56 -0
- llm_code/runtime/auto_diagnose.py +62 -0
- llm_code/runtime/checkpoint.py +70 -0
- llm_code/runtime/checkpoint_recovery.py +142 -0
- llm_code/runtime/compaction.py +35 -0
- llm_code/runtime/compressor.py +415 -0
- llm_code/runtime/config.py +533 -0
- llm_code/runtime/context.py +49 -0
- llm_code/runtime/conversation.py +921 -0
- llm_code/runtime/cost_tracker.py +126 -0
- llm_code/runtime/dream.py +127 -0
- llm_code/runtime/file_protection.py +150 -0
- llm_code/runtime/hardware.py +85 -0
- llm_code/runtime/hooks.py +223 -0
- llm_code/runtime/indexer.py +230 -0
- llm_code/runtime/knowledge_compiler.py +232 -0
- llm_code/runtime/memory.py +132 -0
- llm_code/runtime/memory_layers.py +467 -0
- llm_code/runtime/memory_lint.py +252 -0
- llm_code/runtime/model_aliases.py +37 -0
- llm_code/runtime/ollama.py +93 -0
- llm_code/runtime/overlay.py +124 -0
- llm_code/runtime/permissions.py +200 -0
- llm_code/runtime/plan.py +45 -0
- llm_code/runtime/prompt.py +238 -0
- llm_code/runtime/repo_map.py +174 -0
- llm_code/runtime/sandbox.py +116 -0
- llm_code/runtime/session.py +268 -0
- llm_code/runtime/skill_resolver.py +61 -0
- llm_code/runtime/skills.py +133 -0
- llm_code/runtime/speculative.py +75 -0
- llm_code/runtime/streaming_executor.py +216 -0
- llm_code/runtime/telemetry.py +196 -0
- llm_code/runtime/token_budget.py +26 -0
- llm_code/runtime/vcr.py +142 -0
- llm_code/runtime/vision.py +102 -0
- llm_code/swarm/__init__.py +1 -0
- llm_code/swarm/backend_subprocess.py +108 -0
- llm_code/swarm/backend_tmux.py +103 -0
- llm_code/swarm/backend_worktree.py +306 -0
- llm_code/swarm/checkpoint.py +74 -0
- llm_code/swarm/coordinator.py +236 -0
- llm_code/swarm/mailbox.py +88 -0
- llm_code/swarm/manager.py +202 -0
- llm_code/swarm/memory_sync.py +80 -0
- llm_code/swarm/recovery.py +21 -0
- llm_code/swarm/team.py +67 -0
- llm_code/swarm/types.py +31 -0
- llm_code/task/__init__.py +16 -0
- llm_code/task/diagnostics.py +93 -0
- llm_code/task/manager.py +162 -0
- llm_code/task/types.py +112 -0
- llm_code/task/verifier.py +104 -0
- llm_code/tools/__init__.py +0 -0
- llm_code/tools/agent.py +145 -0
- llm_code/tools/agent_roles.py +82 -0
- llm_code/tools/base.py +94 -0
- llm_code/tools/bash.py +565 -0
- llm_code/tools/computer_use_tools.py +278 -0
- llm_code/tools/coordinator_tool.py +75 -0
- llm_code/tools/cron_create.py +90 -0
- llm_code/tools/cron_delete.py +49 -0
- llm_code/tools/cron_list.py +51 -0
- llm_code/tools/deferred.py +92 -0
- llm_code/tools/dump.py +116 -0
- llm_code/tools/edit_file.py +282 -0
- llm_code/tools/git_tools.py +531 -0
- llm_code/tools/glob_search.py +112 -0
- llm_code/tools/grep_search.py +144 -0
- llm_code/tools/ide_diagnostics.py +59 -0
- llm_code/tools/ide_open.py +58 -0
- llm_code/tools/ide_selection.py +52 -0
- llm_code/tools/memory_tools.py +138 -0
- llm_code/tools/multi_edit.py +143 -0
- llm_code/tools/notebook_edit.py +107 -0
- llm_code/tools/notebook_read.py +81 -0
- llm_code/tools/parsing.py +63 -0
- llm_code/tools/read_file.py +154 -0
- llm_code/tools/registry.py +58 -0
- llm_code/tools/search_backends/__init__.py +56 -0
- llm_code/tools/search_backends/brave.py +56 -0
- llm_code/tools/search_backends/duckduckgo.py +129 -0
- llm_code/tools/search_backends/searxng.py +71 -0
- llm_code/tools/search_backends/tavily.py +73 -0
- llm_code/tools/swarm_create.py +109 -0
- llm_code/tools/swarm_delete.py +95 -0
- llm_code/tools/swarm_list.py +44 -0
- llm_code/tools/swarm_message.py +109 -0
- llm_code/tools/task_close.py +79 -0
- llm_code/tools/task_plan.py +79 -0
- llm_code/tools/task_verify.py +90 -0
- llm_code/tools/tool_search.py +65 -0
- llm_code/tools/web_common.py +258 -0
- llm_code/tools/web_fetch.py +223 -0
- llm_code/tools/web_search.py +280 -0
- llm_code/tools/write_file.py +118 -0
- llm_code/tui/__init__.py +1 -0
- llm_code/tui/app.py +2432 -0
- llm_code/tui/chat_view.py +82 -0
- llm_code/tui/chat_widgets.py +309 -0
- llm_code/tui/header_bar.py +46 -0
- llm_code/tui/input_bar.py +349 -0
- llm_code/tui/keybindings.py +142 -0
- llm_code/tui/marketplace.py +210 -0
- llm_code/tui/status_bar.py +72 -0
- llm_code/tui/theme.py +96 -0
- llm_code/utils/__init__.py +0 -0
- llm_code/utils/diff.py +111 -0
- llm_code/utils/errors.py +70 -0
- llm_code/utils/hyperlink.py +73 -0
- llm_code/utils/notebook.py +179 -0
- llm_code/utils/search.py +69 -0
- llm_code/utils/text_normalize.py +28 -0
- llm_code/utils/version_check.py +62 -0
- llm_code/vim/__init__.py +4 -0
- llm_code/vim/engine.py +51 -0
- llm_code/vim/motions.py +172 -0
- llm_code/vim/operators.py +183 -0
- llm_code/vim/text_objects.py +139 -0
- llm_code/vim/transitions.py +279 -0
- llm_code/vim/types.py +68 -0
- llm_code/voice/__init__.py +1 -0
- llm_code/voice/languages.py +43 -0
- llm_code/voice/recorder.py +136 -0
- llm_code/voice/stt.py +36 -0
- llm_code/voice/stt_anthropic.py +66 -0
- llm_code/voice/stt_google.py +32 -0
- llm_code/voice/stt_whisper.py +52 -0
- llmcode_cli-1.0.0.dist-info/METADATA +524 -0
- llmcode_cli-1.0.0.dist-info/RECORD +212 -0
- llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
- llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
- 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)
|