ezshell 0.2.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.
src/core/safety.py ADDED
@@ -0,0 +1,268 @@
1
+ """
2
+ ezShell Command Safety Analysis
3
+ =================================
4
+ This module provides a lightweight, purely regex-based static analysis layer
5
+ that classifies shell commands into three risk tiers before they are executed
6
+ or presented to the user. The analysis is **heuristic** — it cannot catch
7
+ every dangerous invocation — but it blocks the most common catastrophic
8
+ patterns (e.g. recursive root deletion, fork bombs, raw disk overwrites) and
9
+ warns about sensitive operations (e.g. running remote scripts, hard git resets).
10
+
11
+ Key components
12
+ --------------
13
+ * :class:`RiskLevel` — Enum with three levels: ``SAFE``, ``WARNING``, ``DANGEROUS``
14
+ * :class:`SafetyReport` — Dataclass returned by the inspection routine
15
+ * :data:`DANGEROUS_PATTERNS` — Compiled regex rules for high-risk commands
16
+ * :data:`WARNING_PATTERNS` — Compiled regex rules for sensitive commands
17
+ * :class:`SafetyGuard` — Stateless inspector that applies the ruleset
18
+
19
+ ---
20
+
21
+ Analyse de sécurité des commandes ezShell
22
+ ==========================================
23
+ Ce module fournit une couche d'analyse statique légère, basée uniquement sur
24
+ des expressions régulières, qui classe les commandes shell en trois niveaux
25
+ de risque avant leur exécution. L'analyse est **heuristique** : elle ne
26
+ peut pas intercepter toutes les invocations dangereuses, mais elle bloque les
27
+ schémas catastrophiques les plus courants (suppression récursive de la
28
+ racine, bombes fork, écrasement de disque brut) et avertit des opérations
29
+ sensibles (exécution de scripts distants, réinitialisations git dures).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ from dataclasses import dataclass
36
+ from enum import Enum
37
+ from typing import List, Tuple
38
+
39
+
40
+ class RiskLevel(str, Enum):
41
+ """Three-tier classification of shell-command risk.
42
+
43
+ Inherits from ``str`` so that ``RiskLevel`` values compare equal to their
44
+ string counterparts and serialise cleanly to JSON without extra conversion.
45
+
46
+ Attributes:
47
+ SAFE: The command matched no known dangerous or warning pattern.
48
+ It may still carry risk — no static analysis is exhaustive.
49
+ WARNING: The command matched a pattern associated with irreversible or
50
+ sensitive side-effects (e.g. recursive deletion, shutdown).
51
+ The user should be informed but explicit confirmation is optional.
52
+ DANGEROUS: The command matched a pattern that can cause immediate and
53
+ severe damage (e.g. wiping the root filesystem, fork bombs).
54
+ Explicit user confirmation is **required** before execution.
55
+ """
56
+
57
+ SAFE = "safe"
58
+ WARNING = "warning"
59
+ DANGEROUS = "dangerous"
60
+
61
+
62
+ @dataclass
63
+ class SafetyReport:
64
+ """Structured result returned by :meth:`SafetyGuard.inspect`.
65
+
66
+ Attributes:
67
+ command (str): The shell command string that was analysed, stripped
68
+ of leading and trailing whitespace.
69
+ risk_level (RiskLevel): The severity tier assigned to the command
70
+ after evaluating it against all known patterns.
71
+ reason (str): Human-readable explanation of why the command was
72
+ classified at its risk level. Empty string for ``SAFE`` commands
73
+ that matched no pattern (default ``""``).
74
+ requires_explicit_confirmation (bool): When ``True``, the caller
75
+ **must** obtain an affirmative acknowledgement from the user
76
+ before proceeding with execution. Set to ``True`` only for
77
+ ``DANGEROUS`` commands.
78
+ """
79
+
80
+ command: str
81
+ risk_level: RiskLevel
82
+ reason: str = ""
83
+ requires_explicit_confirmation: bool = False
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Danger ruleset — patterns that indicate high risk of data loss or system
88
+ # corruption. Each entry is a (compiled_pattern, human_reason) tuple.
89
+ # Patterns are checked in order; the first match wins.
90
+ # ---------------------------------------------------------------------------
91
+
92
+ DANGEROUS_PATTERNS: List[Tuple[re.Pattern, str]] = [
93
+ (
94
+ # Matches `rm -rf /`, `rm -fr /`, `rm -rf / *`, etc.
95
+ # Any recursive (-r/-R) force (-f) removal whose target begins at /
96
+ re.compile(r"\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?[a-zA-Z]*\s+/(?:\s|$|\*)"),
97
+ "Recursive force removal targeting root filesystem (/)",
98
+ ),
99
+ (
100
+ # Matches `rm -rf ~` and `rm -rf $HOME` (with optional trailing slash/glob)
101
+ # Protects the user's entire home directory from accidental wipeout
102
+ re.compile(r"\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?[a-zA-Z]*\s+(?:~|\$HOME)(?:/(?:\s|$|\*))?"),
103
+ "Recursive force removal targeting user home directory",
104
+ ),
105
+ (
106
+ # Matches `mkfs`, `mkfs.ext4`, `mkfs.vfat`, etc. followed by /dev/
107
+ # Any filesystem-format command on a block device is immediately destructive
108
+ re.compile(r"\bmkfs(?:\.\w+)?\s+/dev/"),
109
+ "Filesystem formatting command targeting block device",
110
+ ),
111
+ (
112
+ # Matches `dd ... of=/dev/sda`, `dd ... of=/dev/nvme0n1`, etc.
113
+ # Writing directly to a raw block device destroys its existing contents
114
+ re.compile(r"\bdd\s+.*of=/dev/(?:sd[a-z]|nvme\d+n\d+|vd[a-z]|hd[a-z])"),
115
+ "Direct disk overwrite using dd targeting raw block device",
116
+ ),
117
+ (
118
+ # Classic fork bomb: :(){ :|:& };:
119
+ # This pattern exhausts process table slots and locks the system
120
+ re.compile(r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:"),
121
+ "Fork bomb pattern detected",
122
+ ),
123
+ (
124
+ # Matches `chmod -R 777 /` or `chmod -R 000 /`
125
+ # Applying extreme permissions recursively from / compromises security
126
+ re.compile(r"\bchmod\s+-[a-zA-Z]*R[a-zA-Z]*\s+(?:777|000)\s+/"),
127
+ "Massive permission change on root directory",
128
+ ),
129
+ (
130
+ # Matches `> /dev/sda`, `> /dev/mem`, `> /dev/kmem`, etc.
131
+ # Shell output redirection into raw devices corrupts them silently
132
+ re.compile(r">\s*/dev/(?:sd[a-z]|nvme\d+n\d+|kmem|mem)"),
133
+ "Redirection overwriting raw device or memory",
134
+ ),
135
+ ]
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Warning ruleset — patterns that indicate sensitive, irreversible, or
139
+ # network-pipelining behaviour worth surfacing to the user before execution.
140
+ # ---------------------------------------------------------------------------
141
+
142
+ WARNING_PATTERNS: List[Tuple[re.Pattern, str]] = [
143
+ (
144
+ # Matches `curl <url> | bash`, `wget <url> | sh`, etc.
145
+ # Piping untrusted remote content directly into a shell interpreter
146
+ # is a common supply-chain attack vector
147
+ re.compile(r"\b(?:curl|wget)\s+.*\|\s*(?:bash|sh|zsh|sudo)"),
148
+ "Executing remote script directly from URL into shell",
149
+ ),
150
+ (
151
+ # Matches any `rm -r` or `rm -R` (without necessarily targeting /)
152
+ # Recursive deletion is irreversible; the user should be made aware
153
+ re.compile(r"\brm\s+-[a-zA-Z]*r[a-zA-Z]*\s+"),
154
+ "Recursive deletion command",
155
+ ),
156
+ (
157
+ # Matches `shutdown`, `reboot`, `poweroff`, `init 0`
158
+ # These commands terminate the current session and may disrupt other users
159
+ re.compile(r"\b(?:shutdown|reboot|poweroff|init\s+0)\b"),
160
+ "System reboot or shutdown command",
161
+ ),
162
+ (
163
+ # Matches `killall -9 <name>` and `pkill -9 <pattern>`
164
+ # SIGKILL cannot be caught or ignored; it may leave resources in an
165
+ # inconsistent state if sent to the wrong process
166
+ re.compile(r"\bkillall\s+-9\b|\bpkill\s+-9\b"),
167
+ "Force terminating multiple processes",
168
+ ),
169
+ (
170
+ # Matches `git reset --hard`
171
+ # Discards all staged and unstaged changes without any recycle-bin safety net
172
+ re.compile(r"\bgit\s+reset\s+--hard\b"),
173
+ "Hard reset will discard all uncommitted changes in git repository",
174
+ ),
175
+ (
176
+ # Matches `git clean -f`, `git clean -fd`, `git clean -fx`, etc.
177
+ # Permanently removes untracked files that are not in the git index
178
+ re.compile(r"\bgit\s+clean\s+-[a-zA-Z]*f[a-zA-Z]*\b"),
179
+ "Force removing untracked files in git repository",
180
+ ),
181
+ ]
182
+
183
+
184
+ class SafetyGuard:
185
+ """Stateless command inspector that applies the DANGEROUS and WARNING rulesets.
186
+
187
+ All inspection logic is encapsulated in the single static method
188
+ :meth:`inspect`, which makes ``SafetyGuard`` trivially mockable in tests
189
+ and thread-safe without any shared mutable state.
190
+
191
+ Example::
192
+
193
+ report = SafetyGuard.inspect("rm -rf /")
194
+ if report.risk_level == RiskLevel.DANGEROUS:
195
+ print(f"Blocked: {report.reason}")
196
+ """
197
+
198
+ @staticmethod
199
+ def inspect(command: str) -> SafetyReport:
200
+ """Classify a shell command string and return a :class:`SafetyReport`.
201
+
202
+ The method first strips surrounding whitespace, then evaluates the
203
+ command against :data:`DANGEROUS_PATTERNS` followed by
204
+ :data:`WARNING_PATTERNS`. The first matching pattern determines the
205
+ result; subsequent patterns are not evaluated (short-circuit).
206
+
207
+ Args:
208
+ command (str): Raw shell command string to analyse. May include
209
+ flags, redirections, pipes, and multi-command chains.
210
+
211
+ Returns:
212
+ SafetyReport: A dataclass containing:
213
+
214
+ * ``command`` — the stripped command string
215
+ * ``risk_level`` — one of :attr:`RiskLevel.SAFE`,
216
+ :attr:`RiskLevel.WARNING`, or :attr:`RiskLevel.DANGEROUS`
217
+ * ``reason`` — human-readable explanation of the classification
218
+ * ``requires_explicit_confirmation`` — ``True`` only for
219
+ ``DANGEROUS`` commands
220
+
221
+ Note:
222
+ This method never raises; all exceptions are implicitly propagated
223
+ from ``re.Pattern.search``, which is effectively infallible for
224
+ valid pre-compiled patterns.
225
+ """
226
+ cmd_clean = command.strip()
227
+
228
+ # Check dangerous patterns first — a DANGEROUS match short-circuits
229
+ # the WARNING check because we already know the worst-case outcome
230
+ for pattern, reason in DANGEROUS_PATTERNS:
231
+ if pattern.search(cmd_clean):
232
+ return SafetyReport(
233
+ command=cmd_clean,
234
+ risk_level=RiskLevel.DANGEROUS,
235
+ reason=reason,
236
+ requires_explicit_confirmation=True,
237
+ )
238
+
239
+ # Check warning patterns only when no DANGEROUS pattern matched
240
+ for pattern, reason in WARNING_PATTERNS:
241
+ if pattern.search(cmd_clean):
242
+ return SafetyReport(
243
+ command=cmd_clean,
244
+ risk_level=RiskLevel.WARNING,
245
+ reason=reason,
246
+ requires_explicit_confirmation=False,
247
+ )
248
+
249
+ # No pattern matched — command is classified as safe
250
+ return SafetyReport(
251
+ command=cmd_clean,
252
+ risk_level=RiskLevel.SAFE,
253
+ reason="Command is considered safe",
254
+ requires_explicit_confirmation=False,
255
+ )
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # Public API surface
260
+ # ---------------------------------------------------------------------------
261
+
262
+ __all__ = [
263
+ "RiskLevel",
264
+ "SafetyReport",
265
+ "SafetyGuard",
266
+ "DANGEROUS_PATTERNS",
267
+ "WARNING_PATTERNS",
268
+ ]
src/core/sanitize.py ADDED
@@ -0,0 +1,39 @@
1
+ """Nettoyage des sorties du modèle avant affichage ou insertion dans le prompt.
2
+
3
+ Tout ce que renvoie le LLM est une donnée non fiable (DEVELOPMENT.md, règle 5) :
4
+ on retire les séquences ANSI et les caractères de contrôle, et on refuse
5
+ qu'un saut de ligne soit inséré dans la ligne de saisie (il l'exécuterait).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+
13
+ _ANSI = re.compile(
14
+ r"\x1b\[[0-?]*[ -/]*[@-~]" # CSI
15
+ r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC
16
+ r"|\x1b[@-Z\\-_]" # autres séquences à 2 octets
17
+ )
18
+ _CTRL_KEEP_NL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
19
+ _CTRL_ALL = re.compile(r"[\x00-\x1f\x7f]")
20
+
21
+
22
+ def strip_control(text: str, keep_newlines: bool = True) -> str:
23
+ """Retire ANSI et caractères de contrôle (garde \\n et \\t si demandé)."""
24
+ text = _ANSI.sub("", text or "")
25
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
26
+ if keep_newlines:
27
+ return _CTRL_KEEP_NL.sub("", text)
28
+ return _CTRL_ALL.sub(" ", text)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class SafeCommand:
33
+ text: str # commande nettoyée (peut contenir des \n si multiline)
34
+ multiline: bool # True => ne jamais insérer dans le prompt sans confirmation
35
+
36
+
37
+ def sanitize_command(command: str) -> SafeCommand:
38
+ cleaned = strip_control(command, keep_newlines=True).strip()
39
+ return SafeCommand(text=cleaned, multiline="\n" in cleaned)
src/core/schema.py ADDED
@@ -0,0 +1,65 @@
1
+ """Schéma de la réponse du modèle (DEVELOPMENT.md §5.2 / §8), validé avec pydantic.
2
+
3
+ On n'affiche jamais une réponse non validée : `parse_response` lève
4
+ `InvalidResponse` et l'appelant décide (un nouvel essai, puis échec propre).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from typing import List, Literal
12
+
13
+ from pydantic import BaseModel, Field, ValidationError, field_validator
14
+
15
+ from src.core.sanitize import strip_control
16
+
17
+ Risk = Literal["low", "medium", "high"]
18
+ Confidence = Literal["low", "medium", "high"]
19
+
20
+
21
+ class InvalidResponse(ValueError):
22
+ """La réponse du modèle n'est pas un JSON conforme au schéma."""
23
+
24
+
25
+ class AskResponse(BaseModel):
26
+ command: str = ""
27
+ explanation: str = Field(default="", max_length=1200)
28
+ cause: str = Field(default="", max_length=800)
29
+ risk: Risk = "medium"
30
+ risk_reason: str = Field(default="", max_length=400)
31
+ assumptions: List[str] = Field(default_factory=list, max_length=8)
32
+ confidence: Confidence = "medium"
33
+
34
+ @field_validator("explanation", "cause", "risk_reason", mode="before")
35
+ @classmethod
36
+ def _clean_text(cls, v):
37
+ return strip_control(str(v or ""), keep_newlines=True).strip()
38
+
39
+ @field_validator("assumptions", mode="before")
40
+ @classmethod
41
+ def _clean_list(cls, v):
42
+ if not isinstance(v, list):
43
+ return []
44
+ return [strip_control(str(x), keep_newlines=False).strip() for x in v][:8]
45
+
46
+
47
+ _FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
48
+
49
+
50
+ def _extract_json(raw: str) -> str:
51
+ raw = raw.strip()
52
+ m = _FENCE.search(raw)
53
+ if m:
54
+ raw = m.group(1).strip()
55
+ start, end = raw.find("{"), raw.rfind("}")
56
+ if start == -1 or end <= start:
57
+ raise InvalidResponse("aucun objet JSON dans la réponse")
58
+ return raw[start : end + 1]
59
+
60
+
61
+ def parse_response(raw: str) -> AskResponse:
62
+ try:
63
+ return AskResponse.model_validate(json.loads(_extract_json(raw)))
64
+ except (json.JSONDecodeError, ValidationError) as exc:
65
+ raise InvalidResponse(str(exc)) from exc