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/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
|