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.
- ezshell-0.2.0.dist-info/METADATA +174 -0
- ezshell-0.2.0.dist-info/RECORD +37 -0
- ezshell-0.2.0.dist-info/WHEEL +5 -0
- ezshell-0.2.0.dist-info/entry_points.txt +3 -0
- ezshell-0.2.0.dist-info/licenses/LICENSE +21 -0
- ezshell-0.2.0.dist-info/top_level.txt +1 -0
- src/__init__.py +3 -0
- src/app.py +286 -0
- src/core/__init__.py +0 -0
- src/core/assistant.py +525 -0
- src/core/capture.py +213 -0
- src/core/chat_bridge.py +107 -0
- src/core/config.py +238 -0
- src/core/context.py +229 -0
- src/core/groq_client.py +376 -0
- src/core/llm_client.py +111 -0
- src/core/prompts.py +262 -0
- src/core/provider.py +233 -0
- src/core/proxy_client.py +166 -0
- src/core/pty_shell.py +273 -0
- src/core/redact.py +95 -0
- src/core/risk.py +455 -0
- src/core/safety.py +268 -0
- src/core/sanitize.py +39 -0
- src/core/schema.py +65 -0
- src/core/shell_hook.py +388 -0
- src/core/suggest.py +94 -0
- src/core/updater.py +428 -0
- src/i18n.py +422 -0
- src/main.py +346 -0
- src/ui/chat_tui.py +634 -0
- src/ui/gui_window.py +1018 -0
- src/ui/inline.py +330 -0
- src/ui/logo.py +194 -0
- src/ui/panel.py +163 -0
- src/ui/prompt.py +184 -0
- src/ui/render.py +269 -0
src/core/proxy_client.py
ADDED
|
@@ -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
|
+
|