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.
Files changed (118) hide show
  1. ata_coder/__init__.py +1 -0
  2. ata_coder/agent.py +874 -0
  3. ata_coder/agent_compact.py +190 -0
  4. ata_coder/agent_controller.py +218 -0
  5. ata_coder/agent_extension.py +69 -0
  6. ata_coder/agent_routing.py +105 -0
  7. ata_coder/agent_subsystems.py +72 -0
  8. ata_coder/agent_tools.py +318 -0
  9. ata_coder/agent_undo.py +63 -0
  10. ata_coder/anthropic_client.py +465 -0
  11. ata_coder/change_tracker.py +368 -0
  12. ata_coder/clawd_integration.py +574 -0
  13. ata_coder/commands/__init__.py +128 -0
  14. ata_coder/commands/_core.py +184 -0
  15. ata_coder/commands/_safety.py +95 -0
  16. ata_coder/commands/_settings.py +241 -0
  17. ata_coder/commands/_workflow.py +451 -0
  18. ata_coder/commands.py +974 -0
  19. ata_coder/config.py +257 -0
  20. ata_coder/core/__init__.py +35 -0
  21. ata_coder/core/events.py +73 -0
  22. ata_coder/core/queue.py +85 -0
  23. ata_coder/core/state.py +17 -0
  24. ata_coder/event_queue.py +5 -0
  25. ata_coder/extension.py +654 -0
  26. ata_coder/extensions/__init__.py +1 -0
  27. ata_coder/extensions/hello_skill.py +47 -0
  28. ata_coder/fool_proof.py +295 -0
  29. ata_coder/git_workflow.py +371 -0
  30. ata_coder/gui.py +511 -0
  31. ata_coder/llm_client.py +543 -0
  32. ata_coder/main.py +814 -0
  33. ata_coder/mcp_client.py +1095 -0
  34. ata_coder/memory.py +539 -0
  35. ata_coder/model_registry.py +134 -0
  36. ata_coder/model_router.py +105 -0
  37. ata_coder/permissions.py +274 -0
  38. ata_coder/privilege.py +464 -0
  39. ata_coder/project.py +273 -0
  40. ata_coder/prompt_template.py +423 -0
  41. ata_coder/prompts/auto-mode.md +7 -0
  42. ata_coder/prompts/coding-rules.md +40 -0
  43. ata_coder/prompts/execution-guardrails.md +14 -0
  44. ata_coder/prompts/memory-system.md +24 -0
  45. ata_coder/prompts/output-style.md +23 -0
  46. ata_coder/prompts/safety.md +17 -0
  47. ata_coder/prompts/slash-commands.md +24 -0
  48. ata_coder/prompts/sub-agents.md +38 -0
  49. ata_coder/prompts/system-reminders.md +17 -0
  50. ata_coder/prompts/system.md +105 -0
  51. ata_coder/prompts/tool-policy.md +46 -0
  52. ata_coder/repl_theme.py +99 -0
  53. ata_coder/repl_tracker.py +89 -0
  54. ata_coder/repl_ui.py +1214 -0
  55. ata_coder/safety_guard.py +434 -0
  56. ata_coder/self_correct.py +346 -0
  57. ata_coder/server.py +882 -0
  58. ata_coder/server_session.py +159 -0
  59. ata_coder/server_shell.py +129 -0
  60. ata_coder/session.py +431 -0
  61. ata_coder/settings.py +439 -0
  62. ata_coder/setup_wizard.py +136 -0
  63. ata_coder/skill_extension.py +92 -0
  64. ata_coder/skills/architect/SKILL.md +42 -0
  65. ata_coder/skills/code-reviewer/SKILL.md +37 -0
  66. ata_coder/skills/codecraft/SKILL.md +452 -0
  67. ata_coder/skills/debugger/SKILL.md +45 -0
  68. ata_coder/skills/doc-writer/SKILL.md +36 -0
  69. ata_coder/skills/general-coder/SKILL.md +76 -0
  70. ata_coder/skills/math-calculator/README.md +40 -0
  71. ata_coder/skills/math-calculator/SKILL.md +59 -0
  72. ata_coder/skills/math-calculator/handler.py +103 -0
  73. ata_coder/skills/math-calculator/prompts/system.md +8 -0
  74. ata_coder/skills/math-calculator/requirements.txt +2 -0
  75. ata_coder/skills/math-calculator/resources/constants.json +8 -0
  76. ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
  77. ata_coder/skills/security-auditor/SKILL.md +40 -0
  78. ata_coder/skills/test-writer/SKILL.md +36 -0
  79. ata_coder/skills/weather-skill/README.md +45 -0
  80. ata_coder/skills/weather-skill/handler.py +76 -0
  81. ata_coder/skills/weather-skill/manifest.json +48 -0
  82. ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
  83. ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
  84. ata_coder/skills/weather-skill/requirements.txt +1 -0
  85. ata_coder/skills/weather-skill/resources/city_list.json +17 -0
  86. ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
  87. ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
  88. ata_coder/skills/weather-skill/weather_utils.py +50 -0
  89. ata_coder/skills.py +1014 -0
  90. ata_coder/sub_agent.py +273 -0
  91. ata_coder/sub_agent_manager.py +203 -0
  92. ata_coder/system_prompt_builder.py +146 -0
  93. ata_coder/task_planner.py +391 -0
  94. ata_coder/terminal.py +318 -0
  95. ata_coder/test_runner.py +219 -0
  96. ata_coder/thread_supervisor.py +195 -0
  97. ata_coder/tool_defs.py +335 -0
  98. ata_coder/tools/__init__.py +11 -0
  99. ata_coder/tools/definitions.py +335 -0
  100. ata_coder/tools/executor.py +1036 -0
  101. ata_coder/tools/result.py +26 -0
  102. ata_coder/tools/subagent.py +332 -0
  103. ata_coder/tools/web.py +361 -0
  104. ata_coder/tools.py +1576 -0
  105. ata_coder/types.py +92 -0
  106. ata_coder/utils.py +113 -0
  107. ata_coder/web/css/style.css +180 -0
  108. ata_coder/web/index.html +84 -0
  109. ata_coder/web/js/app.js +489 -0
  110. ata_coder/web/package-lock.json +25 -0
  111. ata_coder/web/package.json +10 -0
  112. ata_coder/web/tsconfig.json +13 -0
  113. ata_coder-2.4.2.dist-info/METADATA +799 -0
  114. ata_coder-2.4.2.dist-info/RECORD +118 -0
  115. ata_coder-2.4.2.dist-info/WHEEL +5 -0
  116. ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
  117. ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
  118. 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
+ }