ata-coder 2.4.2__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.
- ata_coder/__init__.py +1 -0
- ata_coder/agent.py +874 -0
- ata_coder/agent_compact.py +190 -0
- ata_coder/agent_controller.py +218 -0
- ata_coder/agent_extension.py +69 -0
- ata_coder/agent_routing.py +105 -0
- ata_coder/agent_subsystems.py +72 -0
- ata_coder/agent_tools.py +318 -0
- ata_coder/agent_undo.py +63 -0
- ata_coder/anthropic_client.py +465 -0
- ata_coder/change_tracker.py +368 -0
- ata_coder/clawd_integration.py +574 -0
- ata_coder/commands/__init__.py +128 -0
- ata_coder/commands/_core.py +184 -0
- ata_coder/commands/_safety.py +95 -0
- ata_coder/commands/_settings.py +241 -0
- ata_coder/commands/_workflow.py +451 -0
- ata_coder/commands.py +974 -0
- ata_coder/config.py +257 -0
- ata_coder/core/__init__.py +35 -0
- ata_coder/core/events.py +73 -0
- ata_coder/core/queue.py +85 -0
- ata_coder/core/state.py +17 -0
- ata_coder/event_queue.py +5 -0
- ata_coder/extension.py +654 -0
- ata_coder/extensions/__init__.py +1 -0
- ata_coder/extensions/hello_skill.py +47 -0
- ata_coder/fool_proof.py +295 -0
- ata_coder/git_workflow.py +371 -0
- ata_coder/gui.py +511 -0
- ata_coder/llm_client.py +543 -0
- ata_coder/main.py +814 -0
- ata_coder/mcp_client.py +1095 -0
- ata_coder/memory.py +539 -0
- ata_coder/model_registry.py +134 -0
- ata_coder/model_router.py +105 -0
- ata_coder/permissions.py +274 -0
- ata_coder/privilege.py +464 -0
- ata_coder/project.py +273 -0
- ata_coder/prompt_template.py +423 -0
- ata_coder/prompts/auto-mode.md +7 -0
- ata_coder/prompts/coding-rules.md +40 -0
- ata_coder/prompts/execution-guardrails.md +14 -0
- ata_coder/prompts/memory-system.md +24 -0
- ata_coder/prompts/output-style.md +23 -0
- ata_coder/prompts/safety.md +17 -0
- ata_coder/prompts/slash-commands.md +24 -0
- ata_coder/prompts/sub-agents.md +38 -0
- ata_coder/prompts/system-reminders.md +17 -0
- ata_coder/prompts/system.md +105 -0
- ata_coder/prompts/tool-policy.md +46 -0
- ata_coder/repl_theme.py +99 -0
- ata_coder/repl_tracker.py +89 -0
- ata_coder/repl_ui.py +1214 -0
- ata_coder/safety_guard.py +434 -0
- ata_coder/self_correct.py +346 -0
- ata_coder/server.py +882 -0
- ata_coder/server_session.py +159 -0
- ata_coder/server_shell.py +129 -0
- ata_coder/session.py +431 -0
- ata_coder/settings.py +439 -0
- ata_coder/setup_wizard.py +136 -0
- ata_coder/skill_extension.py +92 -0
- ata_coder/skills/architect/SKILL.md +42 -0
- ata_coder/skills/code-reviewer/SKILL.md +37 -0
- ata_coder/skills/codecraft/SKILL.md +452 -0
- ata_coder/skills/debugger/SKILL.md +45 -0
- ata_coder/skills/doc-writer/SKILL.md +36 -0
- ata_coder/skills/general-coder/SKILL.md +76 -0
- ata_coder/skills/math-calculator/README.md +40 -0
- ata_coder/skills/math-calculator/SKILL.md +59 -0
- ata_coder/skills/math-calculator/handler.py +103 -0
- ata_coder/skills/math-calculator/prompts/system.md +8 -0
- ata_coder/skills/math-calculator/requirements.txt +2 -0
- ata_coder/skills/math-calculator/resources/constants.json +8 -0
- ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
- ata_coder/skills/security-auditor/SKILL.md +40 -0
- ata_coder/skills/test-writer/SKILL.md +36 -0
- ata_coder/skills/weather-skill/README.md +45 -0
- ata_coder/skills/weather-skill/handler.py +76 -0
- ata_coder/skills/weather-skill/manifest.json +48 -0
- ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
- ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
- ata_coder/skills/weather-skill/requirements.txt +1 -0
- ata_coder/skills/weather-skill/resources/city_list.json +17 -0
- ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
- ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
- ata_coder/skills/weather-skill/weather_utils.py +50 -0
- ata_coder/skills.py +1014 -0
- ata_coder/sub_agent.py +273 -0
- ata_coder/sub_agent_manager.py +203 -0
- ata_coder/system_prompt_builder.py +146 -0
- ata_coder/task_planner.py +391 -0
- ata_coder/terminal.py +318 -0
- ata_coder/test_runner.py +219 -0
- ata_coder/thread_supervisor.py +195 -0
- ata_coder/tool_defs.py +335 -0
- ata_coder/tools/__init__.py +11 -0
- ata_coder/tools/definitions.py +335 -0
- ata_coder/tools/executor.py +1036 -0
- ata_coder/tools/result.py +26 -0
- ata_coder/tools/subagent.py +332 -0
- ata_coder/tools/web.py +361 -0
- ata_coder/tools.py +1576 -0
- ata_coder/types.py +92 -0
- ata_coder/utils.py +113 -0
- ata_coder/web/css/style.css +180 -0
- ata_coder/web/index.html +84 -0
- ata_coder/web/js/app.js +489 -0
- ata_coder/web/package-lock.json +25 -0
- ata_coder/web/package.json +10 -0
- ata_coder/web/tsconfig.json +13 -0
- ata_coder-2.4.2.dist-info/METADATA +799 -0
- ata_coder-2.4.2.dist-info/RECORD +118 -0
- ata_coder-2.4.2.dist-info/WHEEL +5 -0
- ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
- ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
- ata_coder-2.4.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Safety Guard — prevents dangerous operations before they happen.
|
|
3
|
+
|
|
4
|
+
Four risk levels:
|
|
5
|
+
SAFE — Read-only operations, always allowed
|
|
6
|
+
CAUTION — Write/modify within workspace, ask once
|
|
7
|
+
DANGER — Shell commands, file deletes outside .git, warn strongly
|
|
8
|
+
CRITICAL — Destructive system commands, require explicit typing to confirm
|
|
9
|
+
|
|
10
|
+
Guard rails:
|
|
11
|
+
- Path traversal detection (../../etc/passwd)
|
|
12
|
+
- Workspace boundary enforcement
|
|
13
|
+
- Command injection patterns (piped dangerous commands)
|
|
14
|
+
- File type restrictions (.exe, .dll write prevention)
|
|
15
|
+
- Sensitive path protection (/etc, C:/Windows, ~/.ssh)
|
|
16
|
+
- Max file size limit (prevent writing giant files)
|
|
17
|
+
- Recursive delete detection
|
|
18
|
+
- Git force push prevention
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shlex
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
33
|
+
# Risk levels
|
|
34
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
35
|
+
|
|
36
|
+
class RiskLevel(Enum):
|
|
37
|
+
SAFE = 0
|
|
38
|
+
CAUTION = 1
|
|
39
|
+
DANGER = 2
|
|
40
|
+
CRITICAL = 3
|
|
41
|
+
|
|
42
|
+
def __lt__(self, other: "RiskLevel") -> bool:
|
|
43
|
+
return self.value < other.value
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def key(self) -> str:
|
|
47
|
+
return {RiskLevel.SAFE: "safe", RiskLevel.CAUTION: "caution",
|
|
48
|
+
RiskLevel.DANGER: "danger", RiskLevel.CRITICAL: "critical"}[self]
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def label(self) -> str:
|
|
52
|
+
return {RiskLevel.SAFE: "SAFE", RiskLevel.CAUTION: "CAUTION",
|
|
53
|
+
RiskLevel.DANGER: "DANGER", RiskLevel.CRITICAL: "CRITICAL"}[self]
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def color(self) -> str:
|
|
57
|
+
return {RiskLevel.SAFE: "green", RiskLevel.CAUTION: "yellow",
|
|
58
|
+
RiskLevel.DANGER: "red", RiskLevel.CRITICAL: "bold red"}[self]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def confirm(self) -> bool:
|
|
62
|
+
return self != RiskLevel.SAFE
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class SafetyCheck:
|
|
69
|
+
"""Result of a safety check."""
|
|
70
|
+
allowed: bool
|
|
71
|
+
risk: RiskLevel
|
|
72
|
+
reason: str = ""
|
|
73
|
+
warnings: list[str] = field(default_factory=list)
|
|
74
|
+
requires_typing: bool = False # user must type "yes i understand" to proceed
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
78
|
+
# Protected paths & patterns
|
|
79
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
80
|
+
|
|
81
|
+
# Paths that should never be written to
|
|
82
|
+
PROTECTED_PATHS = [
|
|
83
|
+
# Unix
|
|
84
|
+
"/etc/", "/boot/", "/sys/", "/proc/", "/dev/",
|
|
85
|
+
"/root/", "/var/log/", "/var/run/",
|
|
86
|
+
"~/.ssh/", "~/.gnupg/", "~/.aws/", "~/.config/",
|
|
87
|
+
"~/.azure/", "~/.config/gcloud/", "~/.kube/",
|
|
88
|
+
# Windows
|
|
89
|
+
"C:\\Windows\\", "C:\\Windows\\System32\\",
|
|
90
|
+
"C:\\Program Files\\", "C:\\Program Files (x86)\\",
|
|
91
|
+
"%SystemRoot%\\", "%ProgramFiles%\\",
|
|
92
|
+
"%APPDATA%\\Microsoft\\", "%USERPROFILE%\\.ssh\\",
|
|
93
|
+
# General / dotfiles
|
|
94
|
+
".env", ".env.local", ".env.production",
|
|
95
|
+
".git/HEAD", ".git/config", ".git/index",
|
|
96
|
+
".svn/", ".hg/",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
# Destructive shell command patterns
|
|
100
|
+
DESTRUCTIVE_PATTERNS = [
|
|
101
|
+
# System destruction
|
|
102
|
+
(r"rm\s+-r\w*\s*-?f\w*\s+/(?:\s|$)", RiskLevel.CRITICAL, "Recursive delete of root filesystem"),
|
|
103
|
+
(r"rm\s+-r\w*\s*-?f\w*\s+~", RiskLevel.CRITICAL, "Recursive delete of home directory"),
|
|
104
|
+
(r"rm\s+-r\w*\s*-?f\w*\s+\$HOME", RiskLevel.CRITICAL, "Recursive delete of home directory"),
|
|
105
|
+
(r"mkfs\.", RiskLevel.CRITICAL, "Filesystem format"),
|
|
106
|
+
(r"dd\s+if=", RiskLevel.CRITICAL, "Raw disk write (dd)"),
|
|
107
|
+
(r"dd\s+of=", RiskLevel.CRITICAL, "Raw disk write (dd of=)"),
|
|
108
|
+
(r">\s*/dev/sd", RiskLevel.CRITICAL, "Direct disk write"),
|
|
109
|
+
(r">\s*/dev/nvme", RiskLevel.CRITICAL, "Direct NVMe write"),
|
|
110
|
+
(r"find\s+.*-delete", RiskLevel.DANGER, "Recursive delete via find"),
|
|
111
|
+
(r"shred\s+", RiskLevel.DANGER, "Secure file deletion"),
|
|
112
|
+
(r"\$\(.*\)", RiskLevel.CAUTION, "Command substitution detected"),
|
|
113
|
+
(r"`[^`]+`", RiskLevel.CAUTION, "Backtick command substitution detected"),
|
|
114
|
+
(r"chmod\s+777\s+/", RiskLevel.CRITICAL, "World-writable root"),
|
|
115
|
+
(r"chmod\s+-R\s+777\s+/", RiskLevel.CRITICAL, "World-writable root recursive"),
|
|
116
|
+
|
|
117
|
+
# System control
|
|
118
|
+
(r"shutdown", RiskLevel.DANGER, "System shutdown"),
|
|
119
|
+
(r"reboot", RiskLevel.DANGER, "System reboot"),
|
|
120
|
+
(r"systemctl\s+stop", RiskLevel.DANGER, "Stop system service"),
|
|
121
|
+
(r"systemctl\s+disable", RiskLevel.DANGER, "Disable system service"),
|
|
122
|
+
(r"killall", RiskLevel.DANGER, "Kill all processes"),
|
|
123
|
+
(r"pkill", RiskLevel.DANGER, "Kill processes by pattern"),
|
|
124
|
+
|
|
125
|
+
# Git danger
|
|
126
|
+
(r"git\s+push\s+--force", RiskLevel.DANGER, "Force push"),
|
|
127
|
+
(r"git\s+push\s+-f", RiskLevel.DANGER, "Force push"),
|
|
128
|
+
(r"git\s+reset\s+--hard", RiskLevel.DANGER, "Hard reset — loses changes"),
|
|
129
|
+
(r"git\s+clean\s+-fdx", RiskLevel.DANGER, "Remove all untracked files"),
|
|
130
|
+
|
|
131
|
+
# Network danger
|
|
132
|
+
(r"curl.*\|\s*(ba)?sh", RiskLevel.DANGER, "Pipe curl to shell"),
|
|
133
|
+
(r"wget.*\|\s*(ba)?sh", RiskLevel.DANGER, "Pipe wget to shell"),
|
|
134
|
+
(r"nc\s+-l", RiskLevel.CAUTION, "Open network listener"),
|
|
135
|
+
|
|
136
|
+
# Fork bomb
|
|
137
|
+
(r":\(\)\s*\{", RiskLevel.CRITICAL, "Fork bomb pattern"),
|
|
138
|
+
|
|
139
|
+
# Database danger
|
|
140
|
+
(r"DROP\s+(TABLE|DATABASE)", RiskLevel.DANGER, "SQL DROP operation"),
|
|
141
|
+
(r"TRUNCATE\s+(TABLE\s+)?", RiskLevel.DANGER, "SQL TRUNCATE operation"),
|
|
142
|
+
(r"DELETE\s+FROM\s+\w+\s+WHERE", RiskLevel.CAUTION, "SQL DELETE with condition"),
|
|
143
|
+
(r"DELETE\s+FROM\s+\w+\s*;", RiskLevel.DANGER, "SQL DELETE without WHERE"),
|
|
144
|
+
|
|
145
|
+
# Package manager danger
|
|
146
|
+
(r"(pip|npm|gem|cargo)\s+(uninstall|remove)", RiskLevel.CAUTION, "Package removal"),
|
|
147
|
+
|
|
148
|
+
# Permission changes
|
|
149
|
+
(r"chmod\s+777", RiskLevel.CAUTION, "Make file world-writable"),
|
|
150
|
+
(r"chown\s+root", RiskLevel.DANGER, "Change owner to root"),
|
|
151
|
+
|
|
152
|
+
# Encoded / obfuscated commands (common bypass techniques)
|
|
153
|
+
(r"base64\s+(-d|--decode)", RiskLevel.CAUTION, "Base64 decode — possible obfuscated command"),
|
|
154
|
+
(r"\bIEX\s*\([^)]*\)", RiskLevel.DANGER, "PowerShell Invoke-Expression (IEX) — remote code execution risk"),
|
|
155
|
+
(r"Invoke-Expression", RiskLevel.DANGER, "PowerShell Invoke-Expression — remote code execution risk"),
|
|
156
|
+
(r"Invoke-WebRequest", RiskLevel.CAUTION, "PowerShell Invoke-WebRequest — fetches remote content"),
|
|
157
|
+
(r"Invoke-RestMethod", RiskLevel.CAUTION, "PowerShell Invoke-RestMethod — fetches remote content"),
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
# Suspicious file extensions (writing these is unusual for a code agent)
|
|
161
|
+
SUSPICIOUS_EXTENSIONS = {".exe", ".dll", ".so", ".dylib", ".bin", ".sys", ".drv", ".ko"}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
165
|
+
# Safety Guard
|
|
166
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
167
|
+
|
|
168
|
+
class SafetyGuard:
|
|
169
|
+
"""
|
|
170
|
+
Validates tool operations before execution.
|
|
171
|
+
Returns SafetyCheck with risk level and warnings.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
def __init__(self, workspace_dir: str | Path | None = None):
|
|
175
|
+
self.workspace = Path(workspace_dir).resolve() if workspace_dir else Path.cwd().resolve()
|
|
176
|
+
self._blocked_count = 0
|
|
177
|
+
self._warned_count = 0
|
|
178
|
+
|
|
179
|
+
# ── Check methods (one per tool category) ────────────────────────────
|
|
180
|
+
|
|
181
|
+
def check_read_file(self, file_path: str) -> SafetyCheck:
|
|
182
|
+
"""Reading files is always safe."""
|
|
183
|
+
return SafetyCheck(allowed=True, risk=RiskLevel.SAFE)
|
|
184
|
+
|
|
185
|
+
def check_write_file(self, file_path: str, content: str = "") -> SafetyCheck:
|
|
186
|
+
"""Check file write safety."""
|
|
187
|
+
warnings = []
|
|
188
|
+
|
|
189
|
+
# 1. Path traversal check
|
|
190
|
+
traversal_check = self._check_path_traversal(file_path)
|
|
191
|
+
if not traversal_check.allowed:
|
|
192
|
+
self._blocked_count += 1
|
|
193
|
+
return traversal_check
|
|
194
|
+
|
|
195
|
+
# 2. Workspace boundary
|
|
196
|
+
boundary_check = self._check_workspace_boundary(file_path)
|
|
197
|
+
if boundary_check.risk == RiskLevel.CRITICAL:
|
|
198
|
+
self._blocked_count += 1
|
|
199
|
+
return boundary_check
|
|
200
|
+
if boundary_check.warnings:
|
|
201
|
+
warnings.extend(boundary_check.warnings)
|
|
202
|
+
|
|
203
|
+
# 3. Protected path check
|
|
204
|
+
protected_check = self._check_protected_path(file_path)
|
|
205
|
+
if not protected_check.allowed:
|
|
206
|
+
self._blocked_count += 1
|
|
207
|
+
return protected_check
|
|
208
|
+
|
|
209
|
+
# 4. Suspicious extension
|
|
210
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
211
|
+
if ext in SUSPICIOUS_EXTENSIONS:
|
|
212
|
+
warnings.append(f"Writing binary file: {ext}")
|
|
213
|
+
|
|
214
|
+
# 5. Max file size
|
|
215
|
+
if len(content) > 10_000_000: # 10MB
|
|
216
|
+
return SafetyCheck(
|
|
217
|
+
allowed=False, risk=RiskLevel.DANGER,
|
|
218
|
+
reason=f"File too large ({len(content):,} bytes). Max 10MB."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if warnings:
|
|
222
|
+
self._warned_count += 1
|
|
223
|
+
return SafetyCheck(
|
|
224
|
+
allowed=True, risk=RiskLevel.CAUTION,
|
|
225
|
+
warnings=warnings,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return SafetyCheck(allowed=True, risk=RiskLevel.CAUTION)
|
|
229
|
+
|
|
230
|
+
def check_edit_file(self, file_path: str, old_string: str, new_string: str) -> SafetyCheck:
|
|
231
|
+
"""Check file edit safety."""
|
|
232
|
+
# Same checks as write, plus verify old_string exists
|
|
233
|
+
write_check = self.check_write_file(file_path, new_string)
|
|
234
|
+
if not write_check.allowed:
|
|
235
|
+
return write_check
|
|
236
|
+
|
|
237
|
+
path = Path(file_path)
|
|
238
|
+
if path.exists():
|
|
239
|
+
try:
|
|
240
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
241
|
+
current = f.read()
|
|
242
|
+
if old_string not in current:
|
|
243
|
+
return SafetyCheck(
|
|
244
|
+
allowed=False, risk=RiskLevel.CAUTION,
|
|
245
|
+
reason="old_string not found in file. The file may have changed since you last read it. Read the file again first.",
|
|
246
|
+
)
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
return write_check
|
|
251
|
+
|
|
252
|
+
def check_shell(self, command: str) -> SafetyCheck:
|
|
253
|
+
"""Check shell command safety."""
|
|
254
|
+
warnings = []
|
|
255
|
+
highest_risk = RiskLevel.CAUTION
|
|
256
|
+
critical_reasons = []
|
|
257
|
+
|
|
258
|
+
cmd_clean = command.strip()
|
|
259
|
+
|
|
260
|
+
# 1. Check for destructive patterns — only in the actual command,
|
|
261
|
+
# not inside quoted strings or echo arguments.
|
|
262
|
+
try:
|
|
263
|
+
cmd_tokens = shlex.split(cmd_clean)
|
|
264
|
+
except ValueError:
|
|
265
|
+
cmd_tokens = cmd_clean.split()
|
|
266
|
+
cmd_no_quotes = " ".join(cmd_tokens) # strip quotes for pattern matching
|
|
267
|
+
|
|
268
|
+
for pattern, risk, reason in DESTRUCTIVE_PATTERNS:
|
|
269
|
+
if re.search(pattern, cmd_no_quotes, re.IGNORECASE):
|
|
270
|
+
if risk == RiskLevel.CRITICAL:
|
|
271
|
+
critical_reasons.append(reason)
|
|
272
|
+
elif risk == RiskLevel.DANGER:
|
|
273
|
+
warnings.append(f"DANGER: {reason}")
|
|
274
|
+
else:
|
|
275
|
+
warnings.append(f"Caution: {reason}")
|
|
276
|
+
|
|
277
|
+
if risk > highest_risk:
|
|
278
|
+
highest_risk = risk
|
|
279
|
+
|
|
280
|
+
# 2. Critical block
|
|
281
|
+
if critical_reasons:
|
|
282
|
+
self._blocked_count += 1
|
|
283
|
+
return SafetyCheck(
|
|
284
|
+
allowed=False,
|
|
285
|
+
risk=RiskLevel.CRITICAL,
|
|
286
|
+
reason="\n".join(critical_reasons),
|
|
287
|
+
warnings=warnings,
|
|
288
|
+
requires_typing=False, # hard block, not even type-to-confirm
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# 3. Check for pipe to shell
|
|
292
|
+
if "|" in cmd_clean and any(
|
|
293
|
+
s in cmd_clean.lower() for s in ("sh", "bash", "zsh", "fish")
|
|
294
|
+
):
|
|
295
|
+
warnings.append("Pipe to shell detected. Verify the source.")
|
|
296
|
+
|
|
297
|
+
# 4. Check working directory is within workspace
|
|
298
|
+
for part in cmd_tokens:
|
|
299
|
+
if part.startswith("/") or part.startswith("~"):
|
|
300
|
+
full = os.path.expanduser(part)
|
|
301
|
+
if os.path.exists(full):
|
|
302
|
+
try:
|
|
303
|
+
Path(full).resolve().relative_to(self.workspace)
|
|
304
|
+
except ValueError:
|
|
305
|
+
warnings.append(f"Path outside workspace: {part}")
|
|
306
|
+
if warnings:
|
|
307
|
+
self._warned_count += 1
|
|
308
|
+
|
|
309
|
+
return SafetyCheck(
|
|
310
|
+
allowed=True,
|
|
311
|
+
risk=highest_risk,
|
|
312
|
+
warnings=warnings,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# ── Internal checks ──────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
def _check_path_traversal(self, file_path: str) -> SafetyCheck:
|
|
318
|
+
"""Detect path traversal attacks."""
|
|
319
|
+
# Check for null bytes
|
|
320
|
+
if "\0" in file_path:
|
|
321
|
+
return SafetyCheck(
|
|
322
|
+
allowed=False, risk=RiskLevel.CRITICAL,
|
|
323
|
+
reason="Null byte in path (possible path truncation attack)",
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Check for ../ patterns — 3+ is suspicious
|
|
327
|
+
traversal_count = file_path.count("..")
|
|
328
|
+
if traversal_count >= 3:
|
|
329
|
+
return SafetyCheck(
|
|
330
|
+
allowed=False, risk=RiskLevel.CRITICAL,
|
|
331
|
+
reason=f"Path traversal blocked ({traversal_count} '..' patterns). Write within the workspace.",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Only block ABSOLUTE paths that are clearly outside the workspace.
|
|
335
|
+
# Relative paths are checked by _check_workspace_boundary instead.
|
|
336
|
+
# Use Path.relative_to() rather than str.startswith() to avoid:
|
|
337
|
+
# C:\Foo\Bar being considered "inside" C:\Foo (missing separator).
|
|
338
|
+
path = Path(file_path)
|
|
339
|
+
if path.is_absolute():
|
|
340
|
+
try:
|
|
341
|
+
resolved = path.resolve()
|
|
342
|
+
resolved.relative_to(self.workspace.resolve())
|
|
343
|
+
except ValueError:
|
|
344
|
+
return SafetyCheck(
|
|
345
|
+
allowed=False, risk=RiskLevel.CRITICAL,
|
|
346
|
+
reason=f"Absolute path outside workspace: {file_path}",
|
|
347
|
+
)
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
return SafetyCheck(allowed=True, risk=RiskLevel.SAFE)
|
|
352
|
+
|
|
353
|
+
def _check_workspace_boundary(self, file_path: str) -> SafetyCheck:
|
|
354
|
+
"""Check if the path is within the workspace."""
|
|
355
|
+
path = Path(file_path)
|
|
356
|
+
if not path.is_absolute():
|
|
357
|
+
path = self.workspace / path
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
path.resolve().relative_to(self.workspace)
|
|
361
|
+
except ValueError:
|
|
362
|
+
# Path is outside workspace
|
|
363
|
+
return SafetyCheck(
|
|
364
|
+
allowed=True, # not blocked, but strongly warned
|
|
365
|
+
risk=RiskLevel.DANGER,
|
|
366
|
+
warnings=[f"FILE OUTSIDE WORKSPACE: {file_path}\n Workspace: {self.workspace}\n Target: {path.resolve()}"],
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
return SafetyCheck(allowed=True, risk=RiskLevel.SAFE)
|
|
370
|
+
|
|
371
|
+
def _check_protected_path(self, file_path: str) -> SafetyCheck:
|
|
372
|
+
"""Check if the path targets a system-protected location.
|
|
373
|
+
|
|
374
|
+
Comparisons are case-insensitive on Windows to match the
|
|
375
|
+
case-insensitive filesystem.
|
|
376
|
+
"""
|
|
377
|
+
# Normalize the path
|
|
378
|
+
normalized = os.path.normpath(file_path).replace("\\", "/")
|
|
379
|
+
expanded = os.path.expanduser(normalized)
|
|
380
|
+
|
|
381
|
+
# Try to resolve to actual path
|
|
382
|
+
try:
|
|
383
|
+
resolved = str(Path(expanded).resolve()).replace("\\", "/")
|
|
384
|
+
except Exception:
|
|
385
|
+
resolved = expanded
|
|
386
|
+
|
|
387
|
+
# Case-insensitive on Windows
|
|
388
|
+
_eq = lambda a, b: a.lower() == b.lower() if os.name == "nt" else a == b
|
|
389
|
+
_sw = lambda a, b: a.lower().startswith(b.lower()) if os.name == "nt" else a.startswith(b)
|
|
390
|
+
|
|
391
|
+
for protected in PROTECTED_PATHS:
|
|
392
|
+
p = os.path.expanduser(protected).replace("\\", "/")
|
|
393
|
+
# Strip trailing slash for exact match, keep for prefix check
|
|
394
|
+
p_stripped = p.rstrip("/")
|
|
395
|
+
p_resolved = p
|
|
396
|
+
try:
|
|
397
|
+
p_resolved = str(Path(p_stripped).resolve()).replace("\\", "/")
|
|
398
|
+
except Exception:
|
|
399
|
+
pass
|
|
400
|
+
|
|
401
|
+
# Match: expanded starts with protected path, OR is exactly the protected path
|
|
402
|
+
# Using p_stripped ensures /etc matches protected path /etc/ as well
|
|
403
|
+
if (_sw(expanded, p) or _eq(expanded, p_stripped)
|
|
404
|
+
or _sw(resolved, p_resolved) or _eq(resolved, p_resolved)):
|
|
405
|
+
return SafetyCheck(
|
|
406
|
+
allowed=False,
|
|
407
|
+
risk=RiskLevel.CRITICAL,
|
|
408
|
+
reason=f"Path is protected: {protected}",
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# Suffix match for relative entries (e.g., .env, .git/config)
|
|
412
|
+
# Only applies to entries that don't start with /, ~, C:\, or %
|
|
413
|
+
if not (p.startswith(("/", "~", "C:", "%"))):
|
|
414
|
+
# Check if the expanded path ends with "/<protected>"
|
|
415
|
+
suffix = "/" + p
|
|
416
|
+
expanded_lower = expanded.lower() if os.name == "nt" else expanded
|
|
417
|
+
suffix_lower = suffix.lower() if os.name == "nt" else suffix
|
|
418
|
+
if expanded_lower.endswith(suffix_lower):
|
|
419
|
+
return SafetyCheck(
|
|
420
|
+
allowed=False,
|
|
421
|
+
risk=RiskLevel.CRITICAL,
|
|
422
|
+
reason=f"Path is protected: {protected}",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
return SafetyCheck(allowed=True, risk=RiskLevel.SAFE)
|
|
426
|
+
|
|
427
|
+
# ── Block logging ────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
@property
|
|
430
|
+
def stats(self) -> dict:
|
|
431
|
+
return {
|
|
432
|
+
"blocked": self._blocked_count,
|
|
433
|
+
"warned": self._warned_count,
|
|
434
|
+
}
|