syscheck-cli 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.
syscheck/screens.py ADDED
@@ -0,0 +1,265 @@
1
+ """Экраны syscheck: Command Palette (Ctrl+P) и карточка процесса."""
2
+ from __future__ import annotations
3
+
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.containers import Container, Grid
7
+ from textual.screen import Screen
8
+ from textual.widgets import Input, Static
9
+
10
+ from syscheck.palette import ACCENT, BORDER, DIM, MUTED, PANEL, TEXT, BG
11
+
12
+
13
+ def _label(cat: str) -> str:
14
+ return {"Info": "I", "Processes": "P", "Network": "N",
15
+ "Battery": "B", "Display": "D", "System": "S"}.get(cat, "?")
16
+
17
+
18
+ class PaletteScreen(Screen):
19
+ """Оверлей поиска команд: печатаешь — отфильтровываются команды.
20
+
21
+ Enter запускает лучший (верхний) результат, ↑↓ переключают,
22
+ Esc закрывает.
23
+ """
24
+
25
+ CSS = """
26
+ PaletteScreen {
27
+ background: #000000ee;
28
+ padding: 1 10;
29
+ }
30
+ #palette {
31
+ height: auto;
32
+ max-height: 68%;
33
+ border: round #252a2f;
34
+ background: #101316;
35
+ padding: 0 1;
36
+ }
37
+ #pal-row {
38
+ layout: horizontal;
39
+ height: 3;
40
+ padding: 0 1;
41
+ border-bottom: solid #252a2f;
42
+ }
43
+ .pal-prompt {
44
+ width: 2;
45
+ content-align: center middle;
46
+ color: #8bd450;
47
+ }
48
+ #pal-input {
49
+ border: none;
50
+ background: transparent;
51
+ color: #e6e9ec;
52
+ height: 3;
53
+ }
54
+ #pal-input:focus {
55
+ border: none;
56
+ }
57
+ #pal-list {
58
+ height: auto;
59
+ min-height: 3;
60
+ padding: 0 1 1 1;
61
+ }
62
+ .pal-hint {
63
+ height: 1;
64
+ color: #555d66;
65
+ }
66
+ """
67
+
68
+ BINDINGS = [
69
+ Binding("escape", "cancel", "Close"),
70
+ Binding("down", "next", "Next", show=False),
71
+ Binding("up", "prev", "Prev", show=False),
72
+ Binding("enter", "submit", "Run", show=False),
73
+ ]
74
+
75
+ def __init__(self, commands: list):
76
+ super().__init__()
77
+ self._all = list(commands)
78
+ self._filtered: list = list(commands)
79
+ self._idx = 0
80
+
81
+ def compose(self) -> ComposeResult:
82
+ with Container(id="palette"):
83
+ with Container(id="pal-row"):
84
+ yield Static(">", classes="pal-prompt")
85
+ yield Input(placeholder="filter commands…", id="pal-input")
86
+ yield Static("", id="pal-list", classes="pal-list")
87
+ yield Static("↵ run · ↑↓ select · esc close", classes="pal-hint")
88
+
89
+ def on_mount(self) -> None:
90
+ self.refresh_list()
91
+ self.query_one("#pal-input", Input).focus()
92
+
93
+ # --- фильтрация ---
94
+ def _apply_filter(self) -> None:
95
+ qtext = self.query_one("#pal-input").value.strip()
96
+ q = qtext.lower()
97
+ words = [w for w in q.split() if w]
98
+ if not words:
99
+ self._filtered = list(self._all)
100
+ else:
101
+ ranked = []
102
+ for e in self._all:
103
+ cmd = e["cmd"]
104
+ desc = e["desc"].lower()
105
+ hay = f"{cmd} {desc}"
106
+ if not all(w in hay for w in words):
107
+ continue
108
+ if hay.startswith(q):
109
+ rank = 0
110
+ elif q in cmd:
111
+ rank = 1
112
+ elif q in desc:
113
+ rank = 2
114
+ else:
115
+ rank = 3
116
+ ranked.append((rank, cmd, e))
117
+ ranked.sort(key=lambda x: (x[0], x[1]))
118
+ self._filtered = [e for _, _, e in ranked]
119
+ self._idx = 0
120
+ self.refresh_list()
121
+
122
+ def refresh_list(self) -> None:
123
+ rows = []
124
+ for i, e in enumerate(self._filtered):
125
+ marker = f"[{ACCENT}]›[/] " if i == self._idx else " "
126
+ line = (f"{marker}[{TEXT}]{e['cmd']:<12}[/]"
127
+ f"{e['desc']} [#555d66]{_label(e['cat'])}/{e['cat']}[/]")
128
+ rows.append(line)
129
+ if not rows:
130
+ rows.append(f"[#555d66]no matching commands[/]")
131
+ self.query_one("#pal-list", Static).update("\n".join(rows))
132
+
133
+ def on_input_changed(self, event: Input.Changed) -> None:
134
+ self._apply_filter()
135
+
136
+ def _selected(self) -> str | None:
137
+ if not self._filtered:
138
+ return None
139
+ self._idx = min(self._idx, len(self._filtered) - 1)
140
+ return self._filtered[self._idx]["cmd"]
141
+
142
+ def action_submit(self) -> None:
143
+ self._run()
144
+
145
+ def on_input_submitted(self, event: Input.Submitted) -> None:
146
+ self._run()
147
+
148
+ def _run(self) -> None:
149
+ inp = self.query_one("#pal-input").value.strip()
150
+ sel = self._selected()
151
+ if inp:
152
+ from syscheck import cmdlang
153
+ if cmdlang.lookup(inp.split()) is not None:
154
+ self.dismiss(inp)
155
+ return
156
+ if sel and inp.split()[0].lower() == sel:
157
+ self.dismiss(inp)
158
+ return
159
+ self.dismiss(sel)
160
+
161
+ def action_next(self) -> None:
162
+ if self._filtered:
163
+ self._idx = (self._idx + 1) % len(self._filtered)
164
+ self.refresh_list()
165
+
166
+ def action_prev(self) -> None:
167
+ if self._filtered:
168
+ self._idx = (self._idx - 1) % len(self._filtered)
169
+ self.refresh_list()
170
+
171
+ def action_cancel(self) -> None:
172
+ self.dismiss(None)
173
+
174
+
175
+ class ProcessDetailsScreen(Screen):
176
+ """Карточка процесса (Enter в таблице процессов или process details)."""
177
+
178
+ CSS = """
179
+ ProcessDetailsScreen {
180
+ background: #000000bb;
181
+ }
182
+ #card {
183
+ width: 70;
184
+ max-height: 80%;
185
+ height: auto;
186
+ border: round #252a2f;
187
+ background: #101316;
188
+ padding: 0 1;
189
+ }
190
+ #card-head {
191
+ height: 1;
192
+ color: #8bd450;
193
+ }
194
+ #card-body {
195
+ padding: 0 1 1 1;
196
+ }
197
+ #card-hint {
198
+ height: 1;
199
+ color: #555d66;
200
+ }
201
+ """
202
+
203
+ BINDINGS = [
204
+ Binding("escape", "close", "Close"),
205
+ Binding("enter", "close", "Close"),
206
+ ]
207
+
208
+ def __init__(self, data: dict | None, error: str | None = None):
209
+ super().__init__()
210
+ self._data = data
211
+ self._error = error
212
+
213
+ def compose(self) -> ComposeResult:
214
+ with Container(id="card"):
215
+ yield Static("", id="card-head")
216
+ yield Static("", id="card-body")
217
+ yield Static("esc / enter — close", id="card-hint")
218
+
219
+ def on_mount(self) -> None:
220
+ self.query_one("#card-head", Static).update(self._render_head())
221
+ self.query_one("#card-body", Static).update(self._render_body())
222
+ self.query_one("#card", Container).focus()
223
+
224
+ def _render_head(self) -> str:
225
+ if self._data:
226
+ name = self._data.get("name") or "?"
227
+ return f"[bold]{name}[/] [#555d66]pid {self._data.get('pid')}[/]"
228
+ return "PROCESS DETAILS"
229
+
230
+ def _render_body(self) -> str:
231
+ if self._error:
232
+ return f"[#e05b5b]{self._error}[/]"
233
+ d = self._data
234
+ from syscheck.utils import bytes_to_human, seconds_to_human
235
+
236
+ def row(label: str, val: str) -> str:
237
+ return f"[#555d66]{label:<10}[/] [#e6e9ec]{val}[/]"
238
+
239
+ rows = [
240
+ row("PID", f"{d.get('pid')}"),
241
+ row("CPU", f"{d.get('cpu') or 0:.1f}%"),
242
+ row("MEMORY", bytes_to_human(d.get('rss') or 0)),
243
+ row("MEM%", f"{d.get('memory_percent') or 0:.1f}%"),
244
+ row("THREADS", str(d.get('threads')) if d.get('threads') is not None else "—"),
245
+ row("HANDLES", str(d.get('handles')) if d.get('handles') is not None else "—"),
246
+ row("STATUS", d.get('status') or "?"),
247
+ row("UPTIME", seconds_to_human(d.get('uptime_seconds') or 0)),
248
+ row("EXE", _trunc(d.get('exe') or "—", 56)),
249
+ ]
250
+ cmdline = d.get("cmdline")
251
+ if cmdline:
252
+ joined = " ".join(cmdline)
253
+ rows.append(row("COMMAND", _trunc(joined, 56)))
254
+ else:
255
+ rows.append(row("COMMAND", "[#555d66]—[/]"))
256
+ return "\n".join(rows)
257
+
258
+ def action_close(self) -> None:
259
+ self.dismiss(None)
260
+
261
+
262
+ def _trunc(s: str, n: int) -> str:
263
+ if len(s) <= n:
264
+ return s
265
+ return s[: n - 1] + "…"
syscheck/shellguard.py ADDED
@@ -0,0 +1,126 @@
1
+ """Безопасность !shell.
2
+
3
+ !shell по умолчанию ВЫКЛЮЧЕН. Включается только явным флагом --enable-shell.
4
+ При первом включении требуется подтверждение, результат сохраняется в
5
+ конфиг-файл. Каждая выполненная команда логируется в audit-лог.
6
+
7
+ Логика вынесена в отдельный модуль, чтобы её было легко тестировать
8
+ и переиспользовать из TUI и CLI.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import subprocess
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ # Директория конфига (например ~/.syscheck)
19
+ CONFIG_DIR = Path(os.environ.get("SYSSCHECK_CONFIG_DIR", "~/.syscheck")).expanduser()
20
+ CONFIG_FILE = CONFIG_DIR / "config.json"
21
+ AUDIT_LOG = CONFIG_DIR / "shell_audit.log"
22
+
23
+ # Текст предупреждения при первом включении
24
+ WARNING_TEXT = (
25
+ "!shell выполняет ЛЮБЫЕ команды операционной системы с твоими правами.\n"
26
+ "Используй только если доверяешь тому, кто дал тебе этот инструмент.\n"
27
+ "Каждое выполнение будет записано в лог: {audit}"
28
+ )
29
+
30
+
31
+ class ShellDisabledError(RuntimeError):
32
+ """Возникает при вызове !shell, пока функция отключена."""
33
+
34
+
35
+ def _ensure_dir() -> None:
36
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
37
+
38
+
39
+ def is_enabled() -> bool:
40
+ """Читает конфиг: включён ли !shell."""
41
+ try:
42
+ if not CONFIG_FILE.exists():
43
+ return False
44
+ data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
45
+ return bool(data.get("shell_enabled", False))
46
+ except (json.JSONDecodeError, OSError):
47
+ return False
48
+
49
+
50
+ def set_enabled(enabled: bool) -> None:
51
+ """Сохраняет состояние !shell в конфиг."""
52
+ _ensure_dir()
53
+ data = {}
54
+ if CONFIG_FILE.exists():
55
+ try:
56
+ data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
57
+ except (json.JSONDecodeError, OSError):
58
+ data = {}
59
+ data["shell_enabled"] = enabled
60
+ CONFIG_FILE.write_text(
61
+ json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
62
+ )
63
+
64
+
65
+ def needs_first_confirmation() -> bool:
66
+ """Нужно ли показать предупреждение при первом включении."""
67
+ try:
68
+ if not CONFIG_FILE.exists():
69
+ return True
70
+ data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
71
+ # метка, что пользователь уже видел предупреждение
72
+ return not bool(data.get("shell_warned", False))
73
+ except (json.JSONDecodeError, OSError):
74
+ return True
75
+
76
+
77
+ def mark_warned() -> None:
78
+ """Помечает, что предупреждение было показано."""
79
+ _ensure_dir()
80
+ data = {}
81
+ if CONFIG_FILE.exists():
82
+ try:
83
+ data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
84
+ except (json.JSONDecodeError, OSError):
85
+ data = {}
86
+ data["shell_warned"] = True
87
+ CONFIG_FILE.write_text(
88
+ json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
89
+ )
90
+
91
+
92
+ def log_command(cmd: str) -> None:
93
+ """Пишет выполненную команду в audit-лог."""
94
+ _ensure_dir()
95
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
96
+ with AUDIT_LOG.open("a", encoding="utf-8") as f:
97
+ f.write(f"[{ts}] {cmd}\n")
98
+
99
+
100
+ def run_shell(cmd: str, timeout: int = 30) -> str:
101
+ """Выполняет команду, если !shell включён. Иначе бросает ShellDisabledError."""
102
+ if not is_enabled():
103
+ raise ShellDisabledError(
104
+ "!shell отключён. Запусти syscheck с флагом --enable-shell, "
105
+ "чтобы включить выполнение системных команд."
106
+ )
107
+ log_command(cmd)
108
+ try:
109
+ result = subprocess.run(
110
+ cmd, shell=True, capture_output=True, text=True, timeout=timeout
111
+ )
112
+ out = result.stdout or result.stderr
113
+ return out if out else f"(no output, exit code {result.returncode})"
114
+ except subprocess.TimeoutExpired:
115
+ return "команда превысила таймаут (30 с)"
116
+ except Exception as e:
117
+ return f"ошибка: {e}"
118
+
119
+
120
+ def resolve_confirmation(cmd: str) -> str:
121
+ """Формирует текст запроса на подтверждение (требуется на КАЖДУЮ команду)."""
122
+ return (
123
+ f"команда: {cmd}\n"
124
+ "наберите её ещё раз или 'confirm', чтобы выполнить, "
125
+ "или что угодно для отмены"
126
+ )