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.
@@ -0,0 +1,166 @@
1
+ """Client du proxy ezShell (DEVELOPMENT.md §6, M2) : le CLI ne voit jamais de clé Groq.
2
+
3
+ * Premier appel : ``POST /api/v1/device/register`` -> jeton d'appareil, stocké en 0600.
4
+ * Ensuite : ``POST /api/v1/ask`` avec ``Authorization: Bearer <jeton>``.
5
+ * Stdlib uniquement (urllib) : pas d'import lourd, le démarrage reste rapide.
6
+ * Jeton invalide (401) : on se ré-enregistre UNE fois, puis on abandonne.
7
+ * HTTPS obligatoire, sauf boucle locale (développement).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ from pathlib import Path
18
+ from typing import Optional, Tuple
19
+
20
+ from src.core.provider import (
21
+ AskRequest,
22
+ LLMProvider,
23
+ ProviderError,
24
+ ProviderOffline,
25
+ QuotaExceeded,
26
+ UpdateRequired,
27
+ )
28
+
29
+ # À renseigner après le déploiement (ex. "https://ezshell.vercel.app"). Surchargé par EZSH_API_URL.
30
+ DEFAULT_API_URL = "https://ezshell.vercel.app"
31
+ TIMEOUT_S = 12.0
32
+ _LOOPBACK = {"localhost", "127.0.0.1", "::1"}
33
+
34
+
35
+ def api_url() -> str:
36
+ return (os.environ.get("EZSH_API_URL") or DEFAULT_API_URL).rstrip("/")
37
+
38
+
39
+ def _client_version() -> str:
40
+ from src import __version__
41
+
42
+ return __version__
43
+
44
+
45
+ class TokenStore:
46
+ """Jeton d'appareil dans ``$XDG_CONFIG_HOME/ezshell/device.json`` (0600). Trousseau : M3."""
47
+
48
+ def __init__(self, path: Optional[Path] = None):
49
+ if path is None:
50
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
51
+ path = Path(base) / "ezshell" / "device.json"
52
+ self.path = path
53
+
54
+ def load(self, base_url: str) -> Optional[str]:
55
+ try:
56
+ data = json.loads(self.path.read_text(encoding="utf-8"))
57
+ except (OSError, json.JSONDecodeError):
58
+ return None
59
+ # Un jeton n'est valable que pour le serveur qui l'a émis.
60
+ return data.get("token") if data.get("api_url") == base_url else None
61
+
62
+ def save(self, base_url: str, token: str) -> None:
63
+ self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
64
+ tmp = self.path.with_suffix(".tmp")
65
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
66
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
67
+ json.dump({"api_url": base_url, "token": token}, fh)
68
+ os.replace(tmp, self.path)
69
+
70
+ def clear(self) -> None:
71
+ try:
72
+ self.path.unlink()
73
+ except OSError:
74
+ pass
75
+
76
+
77
+ class ProxyProvider(LLMProvider):
78
+ def __init__(self, base_url: Optional[str] = None, store: Optional[TokenStore] = None):
79
+ self.base_url = (base_url or api_url()).rstrip("/")
80
+ self.store = store or TokenStore()
81
+ self.last_headers: dict = {} # en-têtes (minuscules) de la dernière réponse
82
+
83
+ # -- HTTP ---------------------------------------------------------------
84
+ def _check_url(self) -> None:
85
+ parsed = urllib.parse.urlparse(self.base_url)
86
+ if parsed.scheme == "https" or (parsed.scheme == "http" and parsed.hostname in _LOOPBACK):
87
+ return
88
+ raise ProviderError("URL de l'API invalide (HTTPS requis)")
89
+
90
+ def _post(self, path: str, payload: dict, token: Optional[str]) -> Tuple[int, str, dict]:
91
+ headers = {"content-type": "application/json", "user-agent": f"ezshell/{_client_version()}"}
92
+ if token:
93
+ headers["authorization"] = f"Bearer {token}"
94
+ request = urllib.request.Request(
95
+ self.base_url + path, data=json.dumps(payload).encode(), headers=headers, method="POST"
96
+ )
97
+ try:
98
+ with urllib.request.urlopen(request, timeout=TIMEOUT_S) as resp:
99
+ status, text = resp.status, resp.read().decode("utf-8", errors="replace")
100
+ self.last_headers = {k.lower(): v for k, v in resp.headers.items()}
101
+ except urllib.error.HTTPError as exc:
102
+ status, text = exc.code, exc.read().decode("utf-8", errors="replace")
103
+ self.last_headers = {k.lower(): v for k, v in exc.headers.items()}
104
+ except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as exc:
105
+ raise ProviderOffline(type(exc).__name__) from exc
106
+ try:
107
+ data = json.loads(text)
108
+ except json.JSONDecodeError:
109
+ data = {}
110
+ return status, text, data if isinstance(data, dict) else {}
111
+
112
+ def health(self) -> bool:
113
+ """True si ``GET /api/v1/health`` répond 200 (aucun jeton nécessaire)."""
114
+ try:
115
+ self._check_url()
116
+ with urllib.request.urlopen(self.base_url + "/api/v1/health", timeout=TIMEOUT_S) as r:
117
+ return r.status == 200
118
+ except (ProviderError, urllib.error.URLError, OSError):
119
+ return False
120
+
121
+ # -- Jeton ----------------------------------------------------------------
122
+ def _register(self) -> str:
123
+ status, _, data = self._post("/api/v1/device/register", {}, None)
124
+ if status == 429:
125
+ raise QuotaExceeded(str(data.get("scope", "register")), data.get("reset_at"))
126
+ token = data.get("device_token")
127
+ if status != 201 or not isinstance(token, str):
128
+ raise ProviderError(f"enregistrement refusé ({status})")
129
+ self.store.save(self.base_url, token)
130
+ return token
131
+
132
+ # -- LLMProvider ----------------------------------------------------------
133
+ def ask(self, req: AskRequest) -> str:
134
+ self._check_url()
135
+ payload = {
136
+ "mode": req.mode,
137
+ "query": req.query,
138
+ "lang": req.lang if req.lang in ("fr", "en") else "en",
139
+ "client_version": _client_version(),
140
+ "context": {
141
+ "os": req.os_name,
142
+ "shell": req.shell,
143
+ "cwd": req.cwd,
144
+ "last_command": req.last_command,
145
+ "exit_code": req.exit_code,
146
+ "stderr_tail": req.stderr_tail,
147
+ },
148
+ }
149
+ token = self.store.load(self.base_url) or self._register()
150
+ status, text, data = self._post("/api/v1/ask", payload, token)
151
+ if status == 401: # jeton révoqué ou base réinitialisée : un seul ré-enregistrement
152
+ self.store.clear()
153
+ status, text, data = self._post("/api/v1/ask", payload, self._register())
154
+
155
+ # Rappel de mise à jour : la dernière version publiée voyage dans un en-tête.
156
+ from src.core.updater import note_latest
157
+
158
+ note_latest(self.last_headers.get("x-ezsh-latest"))
159
+
160
+ if status == 200:
161
+ return text
162
+ if status == 426:
163
+ raise UpdateRequired(str(data.get("min_supported", "")), str(data.get("latest", "")))
164
+ if status == 429:
165
+ raise QuotaExceeded(str(data.get("scope", "free")), data.get("reset_at"))
166
+ raise ProviderError(f"{status} {data.get('error', '')}".strip())
src/core/pty_shell.py ADDED
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import select
6
+ import subprocess
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from typing import Dict, Optional
10
+
11
+ try:
12
+ import fcntl
13
+ import pty
14
+ import struct
15
+ import termios
16
+ HAS_PTY = True
17
+ except ImportError:
18
+ HAS_PTY = False
19
+
20
+ try:
21
+ import pyte
22
+ HAS_PYTE = True
23
+ except ImportError:
24
+ HAS_PYTE = False
25
+
26
+
27
+ @dataclass
28
+ class CommandResult:
29
+ command: str
30
+ exit_code: int
31
+ stdout: str
32
+ stderr: str
33
+ clean_output: str
34
+
35
+
36
+ # Regex fallback for stripping ANSI escapes when pyte is not installed or for quick cleanup
37
+ ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
38
+
39
+
40
+ def clean_ansi_escapes(raw_text: str) -> str:
41
+ """Cleans ANSI escape sequences from text using pyte if available, or regex fallback."""
42
+ if not raw_text:
43
+ return ""
44
+
45
+ if HAS_PYTE:
46
+ try:
47
+ screen = pyte.HistoryScreen(120, 50, history=500)
48
+ stream = pyte.ByteStream(screen)
49
+ stream.feed(raw_text.encode("utf-8", errors="replace"))
50
+
51
+ lines = []
52
+ for hist_line in screen.history.top:
53
+ line_str = "".join(char.data for char in hist_line.values()).rstrip()
54
+ if line_str:
55
+ lines.append(line_str)
56
+ for screen_line in screen.display:
57
+ line_str = screen_line.rstrip()
58
+ if line_str:
59
+ lines.append(line_str)
60
+
61
+ if lines:
62
+ return "\n".join(lines)
63
+ except Exception:
64
+ pass
65
+
66
+ # Regex fallback
67
+ clean = ANSI_ESCAPE_RE.sub("", raw_text)
68
+ # Normalize carriage returns and newlines
69
+ clean = clean.replace("\r\n", "\n").replace("\r", "\n")
70
+ # Remove excessive blank lines
71
+ return "\n".join(line.rstrip() for line in clean.splitlines() if line.strip())
72
+
73
+
74
+ class PTYExecutor:
75
+ """Executes commands using pseudo-terminals to preserve TTY behavior and capture output."""
76
+
77
+ @staticmethod
78
+ def _sync_window_size(master_fd: int):
79
+ """Propagate current host terminal dimensions to the PTY."""
80
+ if not HAS_PTY:
81
+ return
82
+ try:
83
+ s = struct.pack("HHHH", 0, 0, 0, 0)
84
+ size = fcntl.ioctl(sys.stdin.fileno(), termios.TIOCGWINSZ, s)
85
+ fcntl.ioctl(master_fd, termios.TIOCSWINSZ, size)
86
+ except Exception:
87
+ pass
88
+
89
+ @classmethod
90
+ def execute(
91
+ cls,
92
+ command: str,
93
+ env: Optional[Dict[str, str]] = None,
94
+ cwd: Optional[str] = None,
95
+ forward_output: bool = True,
96
+ ) -> CommandResult:
97
+ """Executes a command with stdout on a PTY and stderr captured."""
98
+ run_env = os.environ.copy()
99
+ if env:
100
+ run_env.update(env)
101
+
102
+ working_dir = cwd or os.getcwd()
103
+
104
+ if not HAS_PTY:
105
+ # Fallback for Windows or systems without pty
106
+ return cls._execute_subprocess(command, run_env, working_dir, forward_output)
107
+
108
+ master_fd, slave_fd = pty.openpty()
109
+ cls._sync_window_size(master_fd)
110
+
111
+ shell_path = run_env.get("SHELL", "/bin/bash")
112
+
113
+ try:
114
+ proc = subprocess.Popen(
115
+ [shell_path, "-c", command],
116
+ stdin=slave_fd,
117
+ stdout=slave_fd,
118
+ stderr=subprocess.PIPE,
119
+ close_fds=True,
120
+ env=run_env,
121
+ cwd=working_dir,
122
+ preexec_fn=os.setsid,
123
+ )
124
+ except Exception as e:
125
+ os.close(slave_fd)
126
+ os.close(master_fd)
127
+ return CommandResult(
128
+ command=command,
129
+ exit_code=1,
130
+ stdout="",
131
+ stderr=str(e),
132
+ clean_output=str(e),
133
+ )
134
+
135
+ # Close slave in parent process immediately
136
+ os.close(slave_fd)
137
+
138
+ stdout_chunks = []
139
+ try:
140
+ os.set_blocking(master_fd, False)
141
+ except Exception:
142
+ pass
143
+
144
+ while proc.poll() is None:
145
+ rlist, _, _ = select.select([master_fd], [], [], 0.05)
146
+ if master_fd in rlist:
147
+ try:
148
+ data = os.read(master_fd, 4096)
149
+ if data:
150
+ stdout_chunks.append(data)
151
+ if forward_output:
152
+ if hasattr(sys.stdout, "buffer"):
153
+ sys.stdout.buffer.write(data)
154
+ sys.stdout.buffer.flush()
155
+ else:
156
+ sys.stdout.write(data.decode("utf-8", "replace"))
157
+ sys.stdout.flush()
158
+ except OSError:
159
+ break
160
+
161
+ # Drain any remaining bytes from master
162
+ while True:
163
+ try:
164
+ data = os.read(master_fd, 4096)
165
+ if not data:
166
+ break
167
+ stdout_chunks.append(data)
168
+ if forward_output:
169
+ if hasattr(sys.stdout, "buffer"):
170
+ sys.stdout.buffer.write(data)
171
+ sys.stdout.buffer.flush()
172
+ else:
173
+ sys.stdout.write(data.decode("utf-8", "replace"))
174
+ sys.stdout.flush()
175
+ except OSError:
176
+ break
177
+
178
+ try:
179
+ os.close(master_fd)
180
+ except OSError:
181
+ pass
182
+
183
+ # Capture stderr and returncode
184
+ stderr_bytes = b""
185
+ if proc.stderr:
186
+ try:
187
+ stderr_bytes = proc.stderr.read()
188
+ except Exception:
189
+ pass
190
+
191
+ exit_code = proc.wait()
192
+
193
+ stdout_str = b"".join(stdout_chunks).decode("utf-8", errors="replace")
194
+ stderr_str = stderr_bytes.decode("utf-8", errors="replace")
195
+
196
+ # Composite output for error diagnosis
197
+ combined = f"{stdout_str}\n{stderr_str}".strip() if stderr_str else stdout_str.strip()
198
+ clean = clean_ansi_escapes(stderr_str if stderr_str.strip() else combined)
199
+
200
+ return CommandResult(
201
+ command=command,
202
+ exit_code=exit_code,
203
+ stdout=stdout_str,
204
+ stderr=stderr_str,
205
+ clean_output=clean,
206
+ )
207
+
208
+ @classmethod
209
+ def _execute_subprocess(
210
+ cls,
211
+ command: str,
212
+ env: Dict[str, str],
213
+ cwd: str,
214
+ forward_output: bool,
215
+ ) -> CommandResult:
216
+ """Exécution sans PTY (Windows notamment).
217
+
218
+ Sous Windows, les suggestions sont en syntaxe PowerShell : on les lance donc dans
219
+ PowerShell, pas dans cmd.exe. stdin est fermé pour qu'une commande interactive
220
+ échoue au lieu de bloquer l'interface.
221
+ """
222
+ if os.name == "nt":
223
+ import shutil
224
+
225
+ exe = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
226
+ args = [exe, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command]
227
+ proc = subprocess.Popen(
228
+ args,
229
+ stdin=subprocess.DEVNULL,
230
+ stdout=subprocess.PIPE,
231
+ stderr=subprocess.PIPE,
232
+ env=env,
233
+ cwd=cwd,
234
+ text=True,
235
+ encoding="utf-8",
236
+ errors="replace",
237
+ )
238
+ else:
239
+ proc = subprocess.Popen(
240
+ command,
241
+ shell=True,
242
+ stdin=subprocess.DEVNULL,
243
+ stdout=subprocess.PIPE,
244
+ stderr=subprocess.PIPE,
245
+ env=env,
246
+ cwd=cwd,
247
+ text=True,
248
+ errors="replace",
249
+ )
250
+
251
+ # Read output
252
+ stdout_str, stderr_str = proc.communicate()
253
+ exit_code = proc.returncode
254
+
255
+ if forward_output:
256
+ if stdout_str:
257
+ sys.stdout.write(stdout_str)
258
+ sys.stdout.flush()
259
+ if stderr_str:
260
+ sys.stderr.write(stderr_str)
261
+ sys.stderr.flush()
262
+
263
+ combined = f"{stdout_str}\n{stderr_str}".strip() if stderr_str else stdout_str.strip()
264
+ clean = clean_ansi_escapes(stderr_str if stderr_str.strip() else combined)
265
+
266
+ return CommandResult(
267
+ command=command,
268
+ exit_code=exit_code,
269
+ stdout=stdout_str,
270
+ stderr=stderr_str,
271
+ clean_output=clean,
272
+ )
273
+
src/core/redact.py ADDED
@@ -0,0 +1,95 @@
1
+ """
2
+ ezShell Secret Redaction Module
3
+ ===============================
4
+
5
+ Masks sensitive patterns (API keys, tokens, passwords, private keys) before
6
+ sending terminal context, failed commands, or stderr outputs to LLM providers.
7
+
8
+ ---
9
+
10
+ Module de masquage des secrets d'ezShell
11
+ ========================================
12
+
13
+ Masque les chaînes sensibles (clés d'API, jetons d'accès, mots de passe) avant
14
+ l'envoi du contexte de terminal ou des sorties d'erreurs aux modèles d'IA.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import List, Pattern, Tuple
20
+
21
+ # Patterns identifying sensitive credentials. L'ordre compte : les motifs spécifiques
22
+ # passent avant les motifs génériques (affectations), pour garder un libellé précis.
23
+ _PEM = r"(?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?"
24
+
25
+ SECRET_PATTERNS: List[Tuple[Pattern, str]] = [
26
+ # Clés privées PEM complètes, puis fin de clé tronquée (stderr coupé à 4 Ko)
27
+ (re.compile(rf"-----BEGIN {_PEM}-----[\s\S]*?(?:-----END {_PEM}-----|$)"), "[REDACTED_PRIVATE_KEY]"),
28
+ (re.compile(rf"\A[A-Za-z0-9+/=\s]{{40,}}-----END {_PEM}-----"), "[REDACTED_PRIVATE_KEY]"),
29
+ # Identifiants dans une URL de connexion : postgresql://user:motdepasse@hote/db
30
+ (
31
+ re.compile(r"\b([a-z][a-z0-9+.-]*://)[^\s:@/]+:[^\s@/]+@", re.IGNORECASE),
32
+ r"\1[REDACTED_CREDENTIALS]@",
33
+ ),
34
+ # En-têtes d'authentification (Bearer, Basic, Token)
35
+ (
36
+ re.compile(r"(?i)\b(authorization\s*[:=]\s*(?:basic|token|digest))\s+\S+"),
37
+ r"\1 [REDACTED_TOKEN]",
38
+ ),
39
+ (re.compile(r"(?i)\bBearer\s+[A-Za-z0-9_\-.~+/=]{16,}"), "Bearer [REDACTED_TOKEN]"),
40
+ # Fournisseurs connus
41
+ (re.compile(r"\bgsk_[A-Za-z0-9]{20,}"), "[REDACTED_GROQ_KEY]"),
42
+ (re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}"), "[REDACTED_ANTHROPIC_KEY]"),
43
+ (re.compile(r"\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}"), "[REDACTED_OPENAI_KEY]"),
44
+ (re.compile(r"\b(?:github_pat_[A-Za-z0-9_]{20,}|gh[pousr]_[A-Za-z0-9]{20,})"), "[REDACTED_GITHUB_TOKEN]"),
45
+ (re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}"), "[REDACTED_GITLAB_TOKEN]"),
46
+ (re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}"), "[REDACTED_SLACK_TOKEN]"),
47
+ (re.compile(r"\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}"), "[REDACTED_STRIPE_KEY]"),
48
+ (re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), "[REDACTED_AWS_KEY]"),
49
+ (re.compile(r"\bAIza[0-9A-Za-z_-]{35}"), "[REDACTED_GOOGLE_KEY]"),
50
+ (re.compile(r"\bnpm_[A-Za-z0-9]{36}"), "[REDACTED_NPM_TOKEN]"),
51
+ (re.compile(r"\bhf_[A-Za-z0-9]{30,}"), "[REDACTED_HF_TOKEN]"),
52
+ (re.compile(r"\bez[du]_[A-Za-z0-9_-]{20,}"), "[REDACTED_EZSHELL_TOKEN]"),
53
+ (re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"), "[REDACTED_JWT]"),
54
+ # Mots de passe en option de ligne de commande : mysql -pSECRET, --password=x, sshpass -p x, curl -u u:p
55
+ (re.compile(r"(\b(?:mysql|mysqldump|mariadb)\b[^\n]*?\s-p)(?!\s)\S+"), r"\1[REDACTED]"),
56
+ (re.compile(r"(?i)(--(?:password|passwd|pass|token|secret|api-key)[= ])(['\"]?)[^\s'\"]+\2"), r"\1[REDACTED]"),
57
+ (re.compile(r"(\b(?:sshpass|docker\s+login|podman\s+login)\b[^\n]*?\s-p\s+)\S+"), r"\1[REDACTED]"),
58
+ (re.compile(r"(\bcurl\b[^\n]*?\s(?:-u|--user)\s+[^\s:]+:)\S+"), r"\1[REDACTED]"),
59
+ # Affectations génériques : password=..., AWS_SECRET_ACCESS_KEY=..., DB_PASS: "a b c"
60
+ (
61
+ re.compile(
62
+ r"(?i)\b([A-Za-z0-9_.-]*(?:password|passwd|passphrase|pwd|secret|token|api_?key"
63
+ r"|access_?key|private_?key|client_?secret|credentials?|_pass)[A-Za-z0-9_]*)"
64
+ r"(\s*[:=]\s*)(?:\"[^\"\n]{1,}\"|'[^'\n]{1,}'|[^\s'\",;]{4,})"
65
+ ),
66
+ r"\1\2[REDACTED]",
67
+ ),
68
+ ]
69
+
70
+
71
+ def redact_secrets(text: str) -> str:
72
+ """Masks secrets and sensitive tokens within the given text.
73
+
74
+ Args:
75
+ text (str): Raw string that may contain secrets.
76
+
77
+ Returns:
78
+ str: Sanitized string safe to send to LLM APIs or logs.
79
+
80
+ ---
81
+
82
+ Masque les secrets et tokens sensibles dans le texte fourni.
83
+ """
84
+ if not text:
85
+ return ""
86
+
87
+ sanitized = text
88
+ for pattern, replacement in SECRET_PATTERNS:
89
+ sanitized = pattern.sub(replacement, sanitized)
90
+
91
+ return sanitized
92
+
93
+
94
+ __all__ = ["SECRET_PATTERNS", "redact_secrets"]
95
+