command-gate 0.2.4__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.
Files changed (63) hide show
  1. cgate/__init__.py +26 -0
  2. cgate/__main__.py +8 -0
  3. cgate/_version.py +24 -0
  4. cgate/cli/__init__.py +1 -0
  5. cgate/cli/_console.py +17 -0
  6. cgate/cli/connections.py +212 -0
  7. cgate/cli/history.py +191 -0
  8. cgate/cli/install.py +182 -0
  9. cgate/cli/main.py +115 -0
  10. cgate/cli/mcp.py +197 -0
  11. cgate/cli/uninstall.py +403 -0
  12. cgate/cli/update.py +538 -0
  13. cgate/cli/watch.py +20 -0
  14. cgate/connections/__init__.py +1 -0
  15. cgate/connections/auth.py +93 -0
  16. cgate/connections/detect.py +78 -0
  17. cgate/connections/store.py +88 -0
  18. cgate/core/__init__.py +1 -0
  19. cgate/core/path_env.py +218 -0
  20. cgate/core/paths.py +35 -0
  21. cgate/core/update_log.py +36 -0
  22. cgate/db/__init__.py +1 -0
  23. cgate/db/batches.py +111 -0
  24. cgate/db/commands.py +191 -0
  25. cgate/db/connection.py +104 -0
  26. cgate/db/mode.py +74 -0
  27. cgate/db/rows.py +99 -0
  28. cgate/db/schema.py +54 -0
  29. cgate/db/server_settings.py +105 -0
  30. cgate/db/types.py +77 -0
  31. cgate/executor/__init__.py +7 -0
  32. cgate/executor/base.py +71 -0
  33. cgate/executor/selector.py +61 -0
  34. cgate/executor/ssh.py +157 -0
  35. cgate/executor/winrm.py +129 -0
  36. cgate/helper/__init__.py +10 -0
  37. cgate/helper/__main__.py +112 -0
  38. cgate/helper/waiter.py +123 -0
  39. cgate/mcp_installer.py +161 -0
  40. cgate/mcp_server/__init__.py +6 -0
  41. cgate/mcp_server/__main__.py +6 -0
  42. cgate/mcp_server/auto_resolution.py +80 -0
  43. cgate/mcp_server/server.py +271 -0
  44. cgate/mcp_server/tools.py +351 -0
  45. cgate/risk.py +129 -0
  46. cgate/update.py +713 -0
  47. cgate/watch/__init__.py +7 -0
  48. cgate/watch/app.py +560 -0
  49. cgate/watch/approval.py +237 -0
  50. cgate/watch/command_detail_modal.py +68 -0
  51. cgate/watch/history_modal.py +242 -0
  52. cgate/watch/mode_modal.py +110 -0
  53. cgate/watch/queue.py +106 -0
  54. cgate/watch/render.py +156 -0
  55. cgate/watch/server_settings_modal.py +179 -0
  56. cgate/watch/session.py +40 -0
  57. cgate/watch/theme.py +32 -0
  58. cgate/watch/widgets.py +35 -0
  59. command_gate-0.2.4.dist-info/METADATA +204 -0
  60. command_gate-0.2.4.dist-info/RECORD +63 -0
  61. command_gate-0.2.4.dist-info/WHEEL +4 -0
  62. command_gate-0.2.4.dist-info/entry_points.txt +2 -0
  63. command_gate-0.2.4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,351 @@
1
+ """Sync business logic for the three MCP tools."""
2
+ from __future__ import annotations
3
+
4
+ import getpass
5
+ from typing import TYPE_CHECKING, NotRequired, TypedDict
6
+
7
+ from cgate.db.commands import all_terminal
8
+ from cgate.db.mode import AppModeNotSetError, Mode
9
+ from cgate.db.types import BatchId, CommandStatus
10
+ from cgate.executor import selector
11
+ from cgate.mcp_server.auto_resolution import resolve_auto_behavior
12
+ from cgate.risk import find_risk
13
+
14
+ if TYPE_CHECKING:
15
+ from cgate.connections.store import ConnectionsRepo
16
+ from cgate.db.batches import BatchesRepo
17
+ from cgate.db.commands import CommandsRepo
18
+ from cgate.db.mode import AppModeRepo
19
+ from cgate.db.server_settings import ServerSettingsRepo
20
+ from cgate.db.types import Command, Connection
21
+ from cgate.mcp_server.auto_resolution import BehaviorDecision
22
+
23
+
24
+ class ProposeCommandResult(TypedDict):
25
+ """JSON-compatible result returned after queueing a command."""
26
+
27
+ batch_id: str
28
+ command_id: str
29
+ position: int
30
+ status: str
31
+ mode: NotRequired[str | None]
32
+ server_auto_allowed: NotRequired[bool | None]
33
+ effective_reason: NotRequired[str | None]
34
+ result: NotRequired[str | None]
35
+ approved_by: NotRequired[str | None]
36
+ reason: NotRequired[str | None]
37
+ risk_label: NotRequired[str | None]
38
+
39
+
40
+ class ConnectionResult(TypedDict):
41
+ """JSON-compatible saved connection details."""
42
+
43
+ alias: str
44
+ hostname: str
45
+ server_type: str
46
+ detection_ssh: bool
47
+ detection_winrm: bool
48
+ auto_allowed: bool
49
+
50
+
51
+ class ModeResult(TypedDict):
52
+ """JSON-compatible global mode and per-server auto-execution opt-ins."""
53
+
54
+ mode: str
55
+ auto_allowed_servers: list[str]
56
+
57
+
58
+ class CommandStatusResult(TypedDict):
59
+ """JSON-compatible status and audit details for one command."""
60
+
61
+ id: str
62
+ position: int
63
+ server_alias: str
64
+ server_type: str
65
+ command: str
66
+ status: str
67
+ result: str | None
68
+ approved_by: str | None
69
+ created_at: str
70
+ resolved_at: str | None
71
+ reason: str | None
72
+ risk_label: str | None
73
+
74
+
75
+ class BatchStatusResult(TypedDict):
76
+ """JSON-compatible batch metadata and ordered command statuses."""
77
+
78
+ batch_id: str
79
+ title: str
80
+ description: str | None
81
+ created_at: str
82
+ resolved_at: str | None
83
+ requested_by_agent: str | None
84
+ commands: list[CommandStatusResult]
85
+
86
+
87
+ class ToolError(Exception):
88
+ """Domain error converted into MCP error content by the async boundary."""
89
+
90
+ code: str
91
+ message: str
92
+
93
+ def __init__(self, code: str, message: str) -> None:
94
+ """Create an error with a stable machine code and human-readable message."""
95
+ super().__init__(message)
96
+ self.code = code
97
+ self.message = message
98
+
99
+
100
+ def _require_batch_title(batch_title: str | None) -> str:
101
+ if not batch_title or not batch_title.strip():
102
+ code = "missing_batch_title"
103
+ message = "batch_title is required"
104
+ raise ToolError(code, message)
105
+ return batch_title.strip()
106
+
107
+
108
+ def _require_known_alias(connections_repo: ConnectionsRepo, alias: str) -> Connection:
109
+ connection = connections_repo.get(alias)
110
+ if connection is None:
111
+ code = "unknown_alias"
112
+ message = f"unknown alias '{alias}'"
113
+ raise ToolError(code, message)
114
+ return connection
115
+
116
+
117
+ def _auto_approve_by() -> str:
118
+ """Audit identity for auto-approvals.
119
+
120
+ Inlined replication of watch/approval.py's ``_approve_by()`` fallback
121
+ pattern (KeyError/OSError -> constant), prefixed so auto-decisions are
122
+ distinguishable from human ones in the audit column.
123
+ """
124
+ try:
125
+ return f"auto:watch:{getpass.getuser()}"
126
+ except (KeyError, OSError):
127
+ return "auto:mcp"
128
+
129
+
130
+ def _execute_auto(
131
+ *,
132
+ batches_repo: BatchesRepo,
133
+ commands_repo: CommandsRepo,
134
+ connection: Connection,
135
+ queued: Command,
136
+ decision: BehaviorDecision,
137
+ ) -> ProposeCommandResult:
138
+ """Mirror watch/approval.approve_one's transition cycle for an auto-approved command.
139
+
140
+ The connection is guaranteed present: ``_require_known_alias`` already
141
+ converted a missing alias into ToolError("unknown_alias") before insert,
142
+ so approve_one's ConnectionNotFoundError path is unreachable here.
143
+ """
144
+ approver = _auto_approve_by()
145
+ approved = commands_repo.update_status(
146
+ queued.id,
147
+ status=CommandStatus.APPROVED,
148
+ approved_by=approver,
149
+ expected_status=CommandStatus.PENDING,
150
+ )
151
+ if not approved:
152
+ # Lost a race with another decision on this command since the insert --
153
+ # report its current state, don't execute (mirrors approve_one).
154
+ current = commands_repo.get(queued.id)
155
+ return {
156
+ "batch_id": queued.batch_id,
157
+ "command_id": queued.id,
158
+ "position": queued.position,
159
+ "status": current.status.value if current is not None else queued.status.value,
160
+ "mode": decision["mode"],
161
+ "server_auto_allowed": decision["server_auto_allowed"],
162
+ "effective_reason": decision["reason"],
163
+ "result": current.result if current is not None else None,
164
+ "approved_by": current.approved_by if current is not None else None,
165
+ "reason": queued.reason,
166
+ "risk_label": queued.risk_label,
167
+ }
168
+ execution = selector.execute_command(connection, queued.command)
169
+ status = CommandStatus.EXECUTED if execution.ok else CommandStatus.FAILED
170
+ output = execution.stdout
171
+ if execution.stderr:
172
+ separator = "\n" if output else ""
173
+ output = f"{output}{separator}--- stderr ---\n{execution.stderr}"
174
+ _ = commands_repo.update_status(
175
+ queued.id,
176
+ status=status,
177
+ approved_by=approver,
178
+ result=output,
179
+ )
180
+ # Auto-execution bypasses watch/approval.py entirely, so nothing else
181
+ # ever stamps `resolved_at` for this batch -- without this, a fully
182
+ # terminal batch sits at the head of the FIFO queue forever, blocking
183
+ # every batch behind it from ever becoming approvable.
184
+ if all_terminal(commands_repo.list_for_batch(queued.batch_id)):
185
+ batches_repo.mark_resolved(queued.batch_id)
186
+ return {
187
+ "batch_id": queued.batch_id,
188
+ "command_id": queued.id,
189
+ "position": queued.position,
190
+ "status": status.value,
191
+ "mode": decision["mode"],
192
+ "server_auto_allowed": decision["server_auto_allowed"],
193
+ "effective_reason": decision["reason"],
194
+ "result": output,
195
+ "approved_by": approver,
196
+ "reason": queued.reason,
197
+ "risk_label": queued.risk_label,
198
+ }
199
+
200
+
201
+ def propose_command( # noqa: PLR0913 - boundary mirrors the specified MCP tool arguments.
202
+ *,
203
+ batches_repo: BatchesRepo,
204
+ commands_repo: CommandsRepo,
205
+ connections_repo: ConnectionsRepo,
206
+ mode_repo: AppModeRepo,
207
+ settings_repo: ServerSettingsRepo,
208
+ server_alias: str,
209
+ command: str,
210
+ batch_title: str | None,
211
+ batch_description: str | None = None,
212
+ batch_id: str | None = None,
213
+ reason: str | None = None,
214
+ requested_by_agent: str = "mcp",
215
+ ) -> ProposeCommandResult:
216
+ """Register a proposed command in the queue.
217
+
218
+ Executes it immediately instead when the global mode is AUTO and the
219
+ server alias has explicitly opted in.
220
+ """
221
+ title = _require_batch_title(batch_title)
222
+ connection = _require_known_alias(connections_repo, server_alias)
223
+
224
+ if batch_id is None:
225
+ batch = batches_repo.create(
226
+ title=title,
227
+ description=batch_description,
228
+ requested_by_agent=requested_by_agent,
229
+ )
230
+ else:
231
+ batch = batches_repo.get(BatchId(batch_id))
232
+ if batch is None:
233
+ # An unknown optional ID is a stale hint, so create a fresh UUID-backed batch.
234
+ batch = batches_repo.create(
235
+ title=title,
236
+ description=batch_description,
237
+ requested_by_agent=requested_by_agent,
238
+ )
239
+
240
+ risk_label = find_risk(command, connection.server_type)
241
+ queued = commands_repo.add(
242
+ batch_id=batch.id,
243
+ server_alias=connection.alias,
244
+ server_type=connection.server_type,
245
+ command=command,
246
+ reason=reason,
247
+ risk_label=risk_label,
248
+ )
249
+ decision = resolve_auto_behavior(
250
+ mode_repo=mode_repo,
251
+ settings_repo=settings_repo,
252
+ server_alias=connection.alias,
253
+ risk_label=risk_label,
254
+ )
255
+ if decision["action"] == "execute":
256
+ return _execute_auto(
257
+ batches_repo=batches_repo,
258
+ commands_repo=commands_repo,
259
+ connection=connection,
260
+ queued=queued,
261
+ decision=decision,
262
+ )
263
+ return {
264
+ "batch_id": batch.id,
265
+ "command_id": queued.id,
266
+ "position": queued.position,
267
+ "status": queued.status.value,
268
+ "mode": decision["mode"],
269
+ "server_auto_allowed": decision["server_auto_allowed"],
270
+ "effective_reason": decision["reason"],
271
+ "result": None,
272
+ "approved_by": None,
273
+ "reason": queued.reason,
274
+ "risk_label": queued.risk_label,
275
+ }
276
+
277
+
278
+ def list_connections(
279
+ *, connections_repo: ConnectionsRepo, settings_repo: ServerSettingsRepo
280
+ ) -> list[ConnectionResult]:
281
+ """Return saved connections, their server dialect metadata, and auto-execution opt-in."""
282
+ return [
283
+ {
284
+ "alias": connection.alias,
285
+ "hostname": connection.hostname,
286
+ "server_type": connection.server_type.value,
287
+ "detection_ssh": connection.detection_ssh,
288
+ "detection_winrm": connection.detection_winrm,
289
+ "auto_allowed": settings_repo.get_or_default(connection.alias).auto_allowed,
290
+ }
291
+ for connection in connections_repo.list_all()
292
+ ]
293
+
294
+
295
+ def get_mode(*, mode_repo: AppModeRepo, settings_repo: ServerSettingsRepo) -> ModeResult:
296
+ """Report the global mode and which servers are opted in for auto-execution.
297
+
298
+ Read-only: an unset global mode is reported as PROPOSE, matching
299
+ ``resolve_auto_behavior``'s safe default.
300
+ """
301
+ try:
302
+ mode = mode_repo.get().mode
303
+ except AppModeNotSetError:
304
+ mode = Mode.PROPOSE
305
+ return {
306
+ "mode": mode.value,
307
+ "auto_allowed_servers": [
308
+ setting.server_alias for setting in settings_repo.list_all() if setting.auto_allowed
309
+ ],
310
+ }
311
+
312
+
313
+ def check_status(
314
+ *,
315
+ batches_repo: BatchesRepo,
316
+ commands_repo: CommandsRepo,
317
+ batch_id: str,
318
+ ) -> BatchStatusResult:
319
+ """Return batch metadata and ordered per-command status and audit fields."""
320
+ typed_batch_id = BatchId(batch_id)
321
+ batch = batches_repo.get(typed_batch_id)
322
+ if batch is None:
323
+ code = "unknown_batch"
324
+ message = f"unknown batch '{batch_id}'"
325
+ raise ToolError(code, message)
326
+
327
+ return {
328
+ "batch_id": batch.id,
329
+ "title": batch.title,
330
+ "description": batch.description,
331
+ "created_at": batch.created_at.isoformat(),
332
+ "resolved_at": batch.resolved_at.isoformat() if batch.resolved_at else None,
333
+ "requested_by_agent": batch.requested_by_agent,
334
+ "commands": [
335
+ {
336
+ "id": command.id,
337
+ "position": command.position,
338
+ "server_alias": command.server_alias,
339
+ "server_type": command.server_type.value,
340
+ "command": command.command,
341
+ "status": command.status.value,
342
+ "result": command.result,
343
+ "approved_by": command.approved_by,
344
+ "created_at": command.created_at.isoformat(),
345
+ "resolved_at": command.resolved_at.isoformat() if command.resolved_at else None,
346
+ "reason": command.reason,
347
+ "risk_label": command.risk_label,
348
+ }
349
+ for command in commands_repo.list_for_batch(typed_batch_id)
350
+ ],
351
+ }
cgate/risk.py ADDED
@@ -0,0 +1,129 @@
1
+ """Heuristic detection of high-blast-radius commands.
2
+
3
+ Not a security boundary: a regex-based safety net, easy to defeat with
4
+ obfuscation (variables, aliases, unusual quoting/flag ordering, encoding).
5
+ Its job is narrower -- catch the common, obvious forms of "this could wipe
6
+ a disk or kill recovery" so AUTO mode's opt-in never silently runs one
7
+ unattended, and so a human scanning the queue sees a clear warning even in
8
+ PROPOSE mode. A command that matches nothing here is not "safe"; it just
9
+ didn't trip this particular tripwire.
10
+
11
+ Patterns cover Windows/PowerShell and Linux -- the latter also catches an
12
+ SSH-connected macOS box, since `cgate` has no separate macOS `ServerType`
13
+ and macOS shares the same Unix shell family (plus a few macOS-specific
14
+ tools: `diskutil`, `csrutil`, `spctl`).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from typing import Final
21
+
22
+ from cgate.db.types import ServerType
23
+
24
+ # Sourced from widely-cited "dangerous commands" references (rm -rf, dd,
25
+ # mkfs, fork bombs) and MITRE ATT&CK T1490 "Inhibit System Recovery"
26
+ # (vssadmin/wbadmin/bcdedit/REAgentC -- the standard ransomware precursor
27
+ # set that deletes shadow copies and disables recovery before encrypting).
28
+ _LINUX_PATTERNS: Final[tuple[tuple[str, re.Pattern[str]], ...]] = (
29
+ ("fork bomb", re.compile(r":\(\)\s*\{\s*:\s*\|\s*:\s*&?\s*\}\s*;\s*:")),
30
+ ("delete with --no-preserve-root", re.compile(r"--no-preserve-root", re.IGNORECASE)),
31
+ (
32
+ "writes directly to a raw disk device",
33
+ re.compile(
34
+ r"(\bdd\b[^\n;|&]*\bof=|[>]{1,2}\s*)/dev/(sd|nvme|hd|xvd|disk|rdisk)\w*", re.IGNORECASE
35
+ ),
36
+ ),
37
+ ("formats a block device", re.compile(r"\bmkfs(\.\w+)?\s+[^\n;|&]*/dev/", re.IGNORECASE)),
38
+ ("wipes/erases a device", re.compile(r"\b(wipefs|shred)\b[^\n;|&]*/dev/", re.IGNORECASE)),
39
+ (
40
+ "recursive chmod on the filesystem root",
41
+ re.compile(
42
+ r"\bchmod\b[^\n;|&]*-[a-zA-Z]*[Rr][a-zA-Z]*\b[^\n;|&]*\s/(\s|\*|$)", re.IGNORECASE
43
+ ),
44
+ ),
45
+ (
46
+ "disables macOS System Integrity Protection",
47
+ re.compile(r"\bcsrutil\s+disable\b", re.IGNORECASE),
48
+ ),
49
+ ("disables macOS Gatekeeper", re.compile(r"\bspctl\b[^\n;|&]*--master-disable", re.IGNORECASE)),
50
+ (
51
+ "erases a macOS disk/volume",
52
+ re.compile(r"\bdiskutil\s+erase(disk|volume|all)?\b", re.IGNORECASE),
53
+ ),
54
+ )
55
+
56
+ _WINDOWS_PATTERNS: Final[tuple[tuple[str, re.Pattern[str]], ...]] = (
57
+ (
58
+ "deletes shadow copies (blocks recovery)",
59
+ re.compile(r"\bvssadmin\b[^\n;|&]*\bdelete\b[^\n;|&]*\bshadows\b", re.IGNORECASE),
60
+ ),
61
+ (
62
+ "deletes Windows backups (blocks recovery)",
63
+ re.compile(r"\bwbadmin\b[^\n;|&]*\bdelete\b", re.IGNORECASE),
64
+ ),
65
+ (
66
+ "disables startup recovery",
67
+ re.compile(r"\bbcdedit\b[^\n;|&]*(recoveryenabled\s+no|ignoreallfailures)", re.IGNORECASE),
68
+ ),
69
+ ("disables System Restore", re.compile(r"\bDisable-ComputerRestore\b", re.IGNORECASE)),
70
+ (
71
+ "disables Windows Recovery Environment",
72
+ re.compile(r"\breagentc\b[^\n;|&]*/disable", re.IGNORECASE),
73
+ ),
74
+ ("deletes volume shadow copies", re.compile(r"\bdiskshadow\b", re.IGNORECASE)),
75
+ (
76
+ "wipes/formats a disk",
77
+ re.compile(r"\b(Format-Volume|Clear-Disk|Remove-Partition)\b", re.IGNORECASE),
78
+ ),
79
+ ("uses the low-level disk partitioning tool", re.compile(r"\bdiskpart\b", re.IGNORECASE)),
80
+ (
81
+ "disables real-time antivirus protection",
82
+ re.compile(r"\bSet-MpPreference\b[^\n;|&]*DisableRealtimeMonitoring", re.IGNORECASE),
83
+ ),
84
+ (
85
+ "turns off the Windows Firewall",
86
+ re.compile(r"\bnetsh\s+advfirewall\s+set\s+allprofiles\s+state\s+off\b", re.IGNORECASE),
87
+ ),
88
+ (
89
+ "recursive force-delete (Remove-Item -Recurse -Force)",
90
+ re.compile(r"\bRemove-Item\b(?=[^\n;|&]*-Recurse\b)(?=[^\n;|&]*-Force\b)", re.IGNORECASE),
91
+ ),
92
+ (
93
+ "recursive force-delete of a drive root or folder",
94
+ re.compile(
95
+ r"\b(rd|rmdir)\b[^\n;|&]*/s[^\n;|&]*/q|\bdel\b[^\n;|&]*/f[^\n;|&]*/s[^\n;|&]*/q",
96
+ re.IGNORECASE,
97
+ ),
98
+ ),
99
+ )
100
+
101
+
102
+ def _is_rm_rf(command: str) -> bool:
103
+ """`rm` with both recursive and force flags, in any order or spelling."""
104
+ if not re.search(r"\brm\b", command, re.IGNORECASE):
105
+ return False
106
+ combined_short_flags = re.search(
107
+ r"-[a-zA-Z]*r[a-zA-Z]*f\b|-[a-zA-Z]*f[a-zA-Z]*r\b", command, re.IGNORECASE
108
+ )
109
+ long_flags = re.search(r"--recursive\b", command, re.IGNORECASE) and re.search(
110
+ r"--force\b", command, re.IGNORECASE
111
+ )
112
+ return bool(combined_short_flags) or bool(long_flags)
113
+
114
+
115
+ def find_risk(command: str, server_type: ServerType) -> str | None:
116
+ """Return a short human-readable label for a matched high-blast-radius pattern.
117
+
118
+ None otherwise -- which is not the same as "safe" (see the module docstring).
119
+ """
120
+ patterns = _WINDOWS_PATTERNS if server_type is ServerType.WINDOWS else _LINUX_PATTERNS
121
+ for label, pattern in patterns:
122
+ if pattern.search(command):
123
+ return label
124
+ if server_type is ServerType.LINUX and _is_rm_rf(command):
125
+ return "recursive force delete (rm -rf)"
126
+ return None
127
+
128
+
129
+ __all__ = ["find_risk"]