cmdscope 0.1.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.
- cmdscope/__init__.py +12 -0
- cmdscope/__main__.py +8 -0
- cmdscope/analysis/__init__.py +1 -0
- cmdscope/analysis/comparison.py +134 -0
- cmdscope/analysis/summary.py +37 -0
- cmdscope/cli.py +555 -0
- cmdscope/collectors/__init__.py +1 -0
- cmdscope/collectors/filesystem.py +370 -0
- cmdscope/collectors/network.py +196 -0
- cmdscope/collectors/processes.py +365 -0
- cmdscope/config.py +461 -0
- cmdscope/core/__init__.py +1 -0
- cmdscope/core/collector.py +48 -0
- cmdscope/core/engine.py +514 -0
- cmdscope/core/events.py +113 -0
- cmdscope/core/ignore.py +56 -0
- cmdscope/core/registry.py +33 -0
- cmdscope/core/session.py +81 -0
- cmdscope/diagnostics.py +183 -0
- cmdscope/errors.py +36 -0
- cmdscope/platforms/__init__.py +57 -0
- cmdscope/platforms/base.py +140 -0
- cmdscope/platforms/linux.py +28 -0
- cmdscope/platforms/macos.py +26 -0
- cmdscope/platforms/windows.py +28 -0
- cmdscope/privacy.py +143 -0
- cmdscope/py.typed +0 -0
- cmdscope/reports/__init__.py +1 -0
- cmdscope/reports/html.py +279 -0
- cmdscope/reports/json_report.py +133 -0
- cmdscope/reports/terminal.py +429 -0
- cmdscope/storage/__init__.py +6 -0
- cmdscope/storage/sqlite.py +469 -0
- cmdscope/storage/writer.py +105 -0
- cmdscope-0.1.0.dist-info/METADATA +353 -0
- cmdscope-0.1.0.dist-info/RECORD +40 -0
- cmdscope-0.1.0.dist-info/WHEEL +5 -0
- cmdscope-0.1.0.dist-info/entry_points.txt +2 -0
- cmdscope-0.1.0.dist-info/licenses/LICENSE +21 -0
- cmdscope-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Thread-safe registry of PIDs observed for the current session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from threading import Lock
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProcessRegistry:
|
|
9
|
+
"""Share the observed process set between process and network collectors.
|
|
10
|
+
|
|
11
|
+
Network attribution depends on this registry. A PID that was never observed
|
|
12
|
+
is never attributed to the session, even if it holds a connection.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._lock = Lock()
|
|
17
|
+
self._names: dict[int, str] = {}
|
|
18
|
+
|
|
19
|
+
def add(self, pid: int, name: str) -> None:
|
|
20
|
+
with self._lock:
|
|
21
|
+
self._names[pid] = name
|
|
22
|
+
|
|
23
|
+
def remove(self, pid: int) -> None:
|
|
24
|
+
with self._lock:
|
|
25
|
+
self._names.pop(pid, None)
|
|
26
|
+
|
|
27
|
+
def snapshot(self) -> dict[int, str]:
|
|
28
|
+
with self._lock:
|
|
29
|
+
return dict(self._names)
|
|
30
|
+
|
|
31
|
+
def __len__(self) -> int:
|
|
32
|
+
with self._lock:
|
|
33
|
+
return len(self._names)
|
cmdscope/core/session.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Session and summary models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from cmdscope.core.events import CollectorStatus, JSONValue, SessionStatus
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def session_ref(session_id: int) -> str:
|
|
12
|
+
"""Format a database session id for humans (``S000001``)."""
|
|
13
|
+
|
|
14
|
+
if session_id <= 0:
|
|
15
|
+
raise ValueError("session id must be positive")
|
|
16
|
+
return f"S{session_id:06d}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_session_ref(value: str) -> int:
|
|
20
|
+
"""Accept ``1``, ``S1``, or ``S000001`` and return the numeric id."""
|
|
21
|
+
|
|
22
|
+
text = value.strip()
|
|
23
|
+
if text[:1].lower() == "s":
|
|
24
|
+
text = text[1:]
|
|
25
|
+
if not text.isdigit() or int(text) <= 0:
|
|
26
|
+
raise ValueError(f"invalid session reference: {value!r}")
|
|
27
|
+
return int(text)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Session:
|
|
32
|
+
"""Persisted metadata for one monitored execution."""
|
|
33
|
+
|
|
34
|
+
id: int
|
|
35
|
+
status: SessionStatus
|
|
36
|
+
command: tuple[str, ...]
|
|
37
|
+
command_display: str
|
|
38
|
+
cwd: str
|
|
39
|
+
platform_name: str
|
|
40
|
+
created_at: datetime
|
|
41
|
+
started_at: datetime | None
|
|
42
|
+
ended_at: datetime | None
|
|
43
|
+
duration_s: float | None
|
|
44
|
+
exit_code: int | None
|
|
45
|
+
interrupted: bool
|
|
46
|
+
error: str | None
|
|
47
|
+
env_diff: dict[str, JSONValue]
|
|
48
|
+
options: dict[str, JSONValue]
|
|
49
|
+
collectors: tuple[CollectorStatus, ...] = ()
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def ref(self) -> str:
|
|
53
|
+
return session_ref(self.id)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class SessionSummary:
|
|
58
|
+
"""Counts derived exclusively from stored events and collector status."""
|
|
59
|
+
|
|
60
|
+
session: Session
|
|
61
|
+
events_total: int
|
|
62
|
+
filesystem_created: int
|
|
63
|
+
filesystem_modified: int
|
|
64
|
+
filesystem_deleted: int
|
|
65
|
+
processes_started: int
|
|
66
|
+
processes_finished: int
|
|
67
|
+
processes_still_running: int
|
|
68
|
+
network_connections: int
|
|
69
|
+
warnings: tuple[str, ...] = field(default_factory=tuple)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class RunResult:
|
|
74
|
+
"""Result returned by the session runner."""
|
|
75
|
+
|
|
76
|
+
session_id: int
|
|
77
|
+
exit_code: int
|
|
78
|
+
interrupted: bool
|
|
79
|
+
event_count: int
|
|
80
|
+
events_truncated: bool
|
|
81
|
+
summary: SessionSummary
|
cmdscope/diagnostics.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Read-only environment diagnostics for ``cmdscope doctor``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import psutil
|
|
12
|
+
|
|
13
|
+
from cmdscope import __version__
|
|
14
|
+
from cmdscope.config import Config
|
|
15
|
+
from cmdscope.core.session import session_ref
|
|
16
|
+
from cmdscope.errors import StorageError
|
|
17
|
+
from cmdscope.platforms import current_platform_info, is_elevated
|
|
18
|
+
from cmdscope.storage.sqlite import Storage
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _package_version(name: str) -> str:
|
|
22
|
+
try:
|
|
23
|
+
return importlib.metadata.version(name)
|
|
24
|
+
except importlib.metadata.PackageNotFoundError:
|
|
25
|
+
return "not installed"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _watchdog_info() -> dict[str, Any]:
|
|
29
|
+
try:
|
|
30
|
+
from watchdog.observers import Observer
|
|
31
|
+
|
|
32
|
+
observer = Observer()
|
|
33
|
+
return {
|
|
34
|
+
"available": True,
|
|
35
|
+
"observer": type(observer).__name__,
|
|
36
|
+
"error": None,
|
|
37
|
+
}
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
return {"available": False, "observer": None, "error": str(exc)}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _network_sample() -> dict[str, Any]:
|
|
43
|
+
try:
|
|
44
|
+
connections = psutil.net_connections(kind="inet")
|
|
45
|
+
except (psutil.AccessDenied, PermissionError) as exc:
|
|
46
|
+
return {"available": False, "count": None, "error": str(exc)}
|
|
47
|
+
except OSError as exc:
|
|
48
|
+
return {"available": False, "count": None, "error": str(exc)}
|
|
49
|
+
attributed = sum(1 for connection in connections if getattr(connection, "pid", None))
|
|
50
|
+
return {"available": True, "count": len(connections), "with_pid": attributed, "error": None}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _database_info(path: Path) -> dict[str, Any]:
|
|
54
|
+
result: dict[str, Any] = {
|
|
55
|
+
"path": str(path),
|
|
56
|
+
"parent_writable": os.access(path.parent, os.W_OK),
|
|
57
|
+
"opened": False,
|
|
58
|
+
"schema_version": None,
|
|
59
|
+
"quick_check": None,
|
|
60
|
+
"size_bytes": None,
|
|
61
|
+
"error": None,
|
|
62
|
+
}
|
|
63
|
+
try:
|
|
64
|
+
with Storage(path) as storage:
|
|
65
|
+
result["opened"] = True
|
|
66
|
+
result["schema_version"] = storage.schema_version()
|
|
67
|
+
result["quick_check"] = storage.quick_check()
|
|
68
|
+
result["size_bytes"] = storage.storage_size_bytes()
|
|
69
|
+
except StorageError as exc:
|
|
70
|
+
result["error"] = str(exc)
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def collect_diagnostics(config: Config, database: Path) -> dict[str, Any]:
|
|
75
|
+
"""Collect diagnostics without changing machine configuration."""
|
|
76
|
+
|
|
77
|
+
info = current_platform_info()
|
|
78
|
+
inotify_limit: int | None = None
|
|
79
|
+
if info.name == "linux":
|
|
80
|
+
try:
|
|
81
|
+
inotify_limit = int(Path("/proc/sys/fs/inotify/max_user_watches").read_text().strip())
|
|
82
|
+
except (OSError, ValueError):
|
|
83
|
+
inotify_limit = None
|
|
84
|
+
|
|
85
|
+
diagnostics: dict[str, Any] = {
|
|
86
|
+
"cmdscope_version": __version__,
|
|
87
|
+
"python_version": sys.version.split()[0],
|
|
88
|
+
"executable": sys.executable,
|
|
89
|
+
"platform": {
|
|
90
|
+
"name": info.name,
|
|
91
|
+
"display_name": info.display_name,
|
|
92
|
+
"filesystem_backend": info.fs_backend,
|
|
93
|
+
"native_filesystem_events": info.native_filesystem_events,
|
|
94
|
+
"data_dir": str(info.default_data_dir),
|
|
95
|
+
"config_dir": str(info.default_config_dir),
|
|
96
|
+
"default_database": str(info.default_database_path),
|
|
97
|
+
},
|
|
98
|
+
"user": {
|
|
99
|
+
"elevated": is_elevated(),
|
|
100
|
+
"note": "CmdScope never elevates privileges automatically.",
|
|
101
|
+
},
|
|
102
|
+
"dependencies": {
|
|
103
|
+
"psutil": _package_version("psutil"),
|
|
104
|
+
"watchdog": _package_version("watchdog"),
|
|
105
|
+
},
|
|
106
|
+
"watchdog": _watchdog_info(),
|
|
107
|
+
"network": _network_sample(),
|
|
108
|
+
"database": _database_info(database),
|
|
109
|
+
"configuration": {
|
|
110
|
+
"source_path": str(config.source_path) if config.source_path else None,
|
|
111
|
+
"warnings": list(config.warnings),
|
|
112
|
+
},
|
|
113
|
+
"inotify_max_user_watches": inotify_limit,
|
|
114
|
+
"limitations": list(info.notes),
|
|
115
|
+
}
|
|
116
|
+
return diagnostics
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def render_diagnostics(data: dict[str, Any]) -> str:
|
|
120
|
+
"""Render diagnostics as a readable terminal report."""
|
|
121
|
+
|
|
122
|
+
platform = data.get("platform", {})
|
|
123
|
+
user = data.get("user", {})
|
|
124
|
+
dependencies = data.get("dependencies", {})
|
|
125
|
+
watchdog = data.get("watchdog", {})
|
|
126
|
+
network = data.get("network", {})
|
|
127
|
+
database = data.get("database", {})
|
|
128
|
+
configuration = data.get("configuration", {})
|
|
129
|
+
lines = [
|
|
130
|
+
f"CMDSCOPE - DIAGNOSTICS (v{data.get('cmdscope_version')})",
|
|
131
|
+
"=" * 70,
|
|
132
|
+
f"Python: {data.get('python_version')} ({data.get('executable')})",
|
|
133
|
+
f"Platform: {platform.get('display_name')} [{platform.get('name')}]",
|
|
134
|
+
f"Filesystem: {platform.get('filesystem_backend')}",
|
|
135
|
+
f"Data directory: {platform.get('data_dir')}",
|
|
136
|
+
f"Config directory:{platform.get('config_dir')}",
|
|
137
|
+
"",
|
|
138
|
+
f"psutil: {dependencies.get('psutil')}",
|
|
139
|
+
f"watchdog: {dependencies.get('watchdog')}",
|
|
140
|
+
f"Observer: {watchdog.get('observer') or watchdog.get('error')}",
|
|
141
|
+
f"Elevated: {'yes' if user.get('elevated') else 'no'}",
|
|
142
|
+
"",
|
|
143
|
+
f"Database: {database.get('path')}",
|
|
144
|
+
f" opened: {database.get('opened')}",
|
|
145
|
+
f" schema: {database.get('schema_version')}",
|
|
146
|
+
f" quick check: {database.get('quick_check')}",
|
|
147
|
+
f" size (bytes): {database.get('size_bytes')}",
|
|
148
|
+
f" parent writable: {database.get('parent_writable')}",
|
|
149
|
+
]
|
|
150
|
+
if database.get("error"):
|
|
151
|
+
lines.append(f" error: {database['error']}")
|
|
152
|
+
lines.extend(
|
|
153
|
+
[
|
|
154
|
+
"",
|
|
155
|
+
f"Network sample: {network.get('count')} inet sockets "
|
|
156
|
+
f"({network.get('with_pid')} with a PID)",
|
|
157
|
+
f" available: {network.get('available')}",
|
|
158
|
+
]
|
|
159
|
+
)
|
|
160
|
+
if network.get("error"):
|
|
161
|
+
lines.append(f" error: {network['error']}")
|
|
162
|
+
if data.get("inotify_max_user_watches") is not None:
|
|
163
|
+
lines.append(f"inotify watches: {data['inotify_max_user_watches']}")
|
|
164
|
+
lines.extend(
|
|
165
|
+
[
|
|
166
|
+
"",
|
|
167
|
+
f"Config source: {configuration.get('source_path') or 'none (defaults)'}",
|
|
168
|
+
]
|
|
169
|
+
)
|
|
170
|
+
for warning in configuration.get("warnings", []):
|
|
171
|
+
lines.append(f" warning: {warning}")
|
|
172
|
+
lines.extend(["", "LIMITATIONS"])
|
|
173
|
+
for note in data.get("limitations", []):
|
|
174
|
+
lines.append(f" - {note}")
|
|
175
|
+
lines.append("")
|
|
176
|
+
lines.append("No changes were made to the system. No data was transmitted.")
|
|
177
|
+
return "\n".join(lines)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def session_label(session_id: int) -> str:
|
|
181
|
+
"""Convenience for diagnostics callers; kept tiny and tested."""
|
|
182
|
+
|
|
183
|
+
return session_ref(session_id)
|
cmdscope/errors.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Domain exceptions with messages suitable for CLI users."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CmdScopeError(Exception):
|
|
7
|
+
"""Base class for expected CmdScope failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigurationError(CmdScopeError):
|
|
11
|
+
"""The configuration file or command-line configuration is invalid."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StorageError(CmdScopeError):
|
|
15
|
+
"""The local session database cannot be opened or updated."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SessionNotFoundError(CmdScopeError):
|
|
19
|
+
"""A requested session does not exist."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ReportExistsError(CmdScopeError):
|
|
23
|
+
"""A report output path already exists and was not explicitly overwritten."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CommandLaunchError(CmdScopeError):
|
|
27
|
+
"""The monitored command could not be started."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str, *, exit_code: int = 1, session_id: int | None = None) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.exit_code = exit_code
|
|
32
|
+
self.session_id = session_id
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EventLimitReached(Exception):
|
|
36
|
+
"""Internal signal used by the event writer when a session reaches its limit."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Platform selection.
|
|
2
|
+
|
|
3
|
+
Only Windows is manually validated for the 0.1 series. Linux and macOS use
|
|
4
|
+
the same portable collectors and are exercised by CI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
from cmdscope.platforms import base
|
|
14
|
+
from cmdscope.platforms.base import PlatformInfo
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"PlatformInfo",
|
|
18
|
+
"config_dir",
|
|
19
|
+
"current_platform_info",
|
|
20
|
+
"data_dir",
|
|
21
|
+
"fallback_info",
|
|
22
|
+
"is_elevated",
|
|
23
|
+
"prepare_command",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def current_platform_info(environment: Mapping[str, str] | None = None) -> PlatformInfo:
|
|
28
|
+
"""Return capabilities for the host running CmdScope."""
|
|
29
|
+
|
|
30
|
+
env = os.environ if environment is None else environment
|
|
31
|
+
platform_key = _platform_key()
|
|
32
|
+
if platform_key.startswith("win"):
|
|
33
|
+
from cmdscope.platforms import windows
|
|
34
|
+
|
|
35
|
+
return windows.info(env)
|
|
36
|
+
if platform_key == "darwin":
|
|
37
|
+
from cmdscope.platforms import macos
|
|
38
|
+
|
|
39
|
+
return macos.info(env)
|
|
40
|
+
if platform_key.startswith("linux"):
|
|
41
|
+
from cmdscope.platforms import linux
|
|
42
|
+
|
|
43
|
+
return linux.info(env)
|
|
44
|
+
return base.fallback_info(env)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _platform_key() -> str:
|
|
48
|
+
"""Indirection keeps static analyzers from folding platform branches."""
|
|
49
|
+
|
|
50
|
+
return sys.platform
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
config_dir = base.config_dir
|
|
54
|
+
data_dir = base.data_dir
|
|
55
|
+
fallback_info = base.fallback_info
|
|
56
|
+
is_elevated = base.is_elevated
|
|
57
|
+
prepare_command = base.prepare_command
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Platform-neutral capability and path helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class PlatformInfo:
|
|
17
|
+
"""Static, honest description of what the current platform can observe."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
display_name: str
|
|
21
|
+
fs_backend: str
|
|
22
|
+
native_filesystem_events: bool
|
|
23
|
+
default_data_dir: Path
|
|
24
|
+
default_config_dir: Path
|
|
25
|
+
notes: tuple[str, ...]
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def default_database_path(self) -> Path:
|
|
29
|
+
return self.default_data_dir / "cmdscope.db"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _home(environment: Mapping[str, str], platform_name: str) -> Path:
|
|
33
|
+
keys = ("USERPROFILE", "HOME") if platform_name == "windows" else ("HOME", "USERPROFILE")
|
|
34
|
+
for key in keys:
|
|
35
|
+
value = environment.get(key)
|
|
36
|
+
if value:
|
|
37
|
+
return Path(value).expanduser()
|
|
38
|
+
return Path.home()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def data_dir(platform_name: str, environment: Mapping[str, str] | None = None) -> Path:
|
|
42
|
+
"""Return the per-user data directory for *platform_name*.
|
|
43
|
+
|
|
44
|
+
The functions accept an environment mapping so tests can validate path
|
|
45
|
+
behavior on any host without touching the real user profile.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
env = os.environ if environment is None else environment
|
|
49
|
+
home = _home(env, platform_name)
|
|
50
|
+
if platform_name == "windows":
|
|
51
|
+
base = env.get("LOCALAPPDATA")
|
|
52
|
+
return (
|
|
53
|
+
Path(base).expanduser() / "cmdscope"
|
|
54
|
+
if base
|
|
55
|
+
else home / "AppData" / "Local" / "cmdscope"
|
|
56
|
+
)
|
|
57
|
+
if platform_name == "macos":
|
|
58
|
+
return home / "Library" / "Application Support" / "cmdscope"
|
|
59
|
+
base = env.get("XDG_DATA_HOME")
|
|
60
|
+
return Path(base).expanduser() / "cmdscope" if base else home / ".local" / "share" / "cmdscope"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def config_dir(platform_name: str, environment: Mapping[str, str] | None = None) -> Path:
|
|
64
|
+
"""Return the per-user configuration directory for *platform_name*."""
|
|
65
|
+
|
|
66
|
+
env = os.environ if environment is None else environment
|
|
67
|
+
home = _home(env, platform_name)
|
|
68
|
+
if platform_name == "windows":
|
|
69
|
+
base = env.get("APPDATA")
|
|
70
|
+
return (
|
|
71
|
+
Path(base).expanduser() / "cmdscope"
|
|
72
|
+
if base
|
|
73
|
+
else home / "AppData" / "Roaming" / "cmdscope"
|
|
74
|
+
)
|
|
75
|
+
if platform_name == "macos":
|
|
76
|
+
return home / "Library" / "Application Support" / "cmdscope"
|
|
77
|
+
base = env.get("XDG_CONFIG_HOME")
|
|
78
|
+
return Path(base).expanduser() / "cmdscope" if base else home / ".config" / "cmdscope"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fallback_info(environment: Mapping[str, str] | None = None) -> PlatformInfo:
|
|
82
|
+
"""Conservative capabilities for an unrecognized POSIX-like platform."""
|
|
83
|
+
|
|
84
|
+
env = os.environ if environment is None else environment
|
|
85
|
+
return PlatformInfo(
|
|
86
|
+
name="unknown",
|
|
87
|
+
display_name=f"Unknown ({sys.platform})",
|
|
88
|
+
fs_backend="polling",
|
|
89
|
+
native_filesystem_events=False,
|
|
90
|
+
default_data_dir=data_dir("linux", env),
|
|
91
|
+
default_config_dir=config_dir("linux", env),
|
|
92
|
+
notes=(
|
|
93
|
+
"This platform is not officially supported. CmdScope uses portable polling.",
|
|
94
|
+
"Only filesystem snapshots inside watch roots are available.",
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def prepare_command(
|
|
100
|
+
argv: Sequence[str],
|
|
101
|
+
environment: Mapping[str, str] | None = None,
|
|
102
|
+
) -> list[str]:
|
|
103
|
+
"""Prepare *argv* for ``subprocess.Popen`` without using ``shell=True``.
|
|
104
|
+
|
|
105
|
+
Windows batch shims (``.cmd``/``.bat``) are not directly executable by
|
|
106
|
+
``CreateProcess``. For those, CmdScope intentionally launches the user's
|
|
107
|
+
command through ``cmd.exe /c`` using ``subprocess.list2cmdline`` quoting.
|
|
108
|
+
This is the only shell indirection in the project and it is documented.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
if not argv:
|
|
112
|
+
raise ValueError("command must not be empty")
|
|
113
|
+
|
|
114
|
+
env = os.environ if environment is None else environment
|
|
115
|
+
if os.name != "nt":
|
|
116
|
+
return list(argv)
|
|
117
|
+
|
|
118
|
+
executable = shutil.which(argv[0], path=env.get("PATH")) or argv[0]
|
|
119
|
+
if executable.lower().endswith((".cmd", ".bat")):
|
|
120
|
+
comspec = env.get("COMSPEC") or "cmd.exe"
|
|
121
|
+
return [comspec, "/d", "/s", "/c", subprocess.list2cmdline(list(argv))]
|
|
122
|
+
return list(argv)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_elevated() -> bool:
|
|
126
|
+
"""Return whether the current process is elevated. Never attempts elevation."""
|
|
127
|
+
|
|
128
|
+
if os.name == "nt":
|
|
129
|
+
try:
|
|
130
|
+
ctypes = importlib.import_module("ctypes")
|
|
131
|
+
windll = getattr(ctypes, "windll", None)
|
|
132
|
+
if windll is None: # pragma: no cover - non-Windows typeshed
|
|
133
|
+
return False
|
|
134
|
+
return bool(windll.shell32.IsUserAnAdmin())
|
|
135
|
+
except (AttributeError, OSError):
|
|
136
|
+
return False
|
|
137
|
+
geteuid = getattr(os, "geteuid", None)
|
|
138
|
+
if geteuid is None: # pragma: no cover - non-POSIX non-Windows host
|
|
139
|
+
return False
|
|
140
|
+
return geteuid() == 0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Linux platform capabilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
from cmdscope.platforms.base import PlatformInfo, config_dir, data_dir
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def info(environment: Mapping[str, str] | None = None) -> PlatformInfo:
|
|
12
|
+
env = os.environ if environment is None else environment
|
|
13
|
+
return PlatformInfo(
|
|
14
|
+
name="linux",
|
|
15
|
+
display_name="Linux",
|
|
16
|
+
fs_backend="inotify (watchdog)",
|
|
17
|
+
native_filesystem_events=True,
|
|
18
|
+
default_data_dir=data_dir("linux", env),
|
|
19
|
+
default_config_dir=config_dir("linux", env),
|
|
20
|
+
notes=(
|
|
21
|
+
"Filesystem events cover only the configured watch roots, recursively.",
|
|
22
|
+
"inotify watch limits can reduce coverage; CmdScope reports the degraded state.",
|
|
23
|
+
"Processes are discovered by polling; very short-lived children can be missed.",
|
|
24
|
+
"Network endpoint ownership for other users may require root privileges.",
|
|
25
|
+
"eBPF, auditd, and ptrace-based syscall tracing are intentionally not used.",
|
|
26
|
+
"CmdScope is not a sandbox and does not restrict the monitored command.",
|
|
27
|
+
),
|
|
28
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""macOS platform capabilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
from cmdscope.platforms.base import PlatformInfo, config_dir, data_dir
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def info(environment: Mapping[str, str] | None = None) -> PlatformInfo:
|
|
12
|
+
env = os.environ if environment is None else environment
|
|
13
|
+
return PlatformInfo(
|
|
14
|
+
name="macos",
|
|
15
|
+
display_name="macOS",
|
|
16
|
+
fs_backend="FSEvents/kqueue (watchdog)",
|
|
17
|
+
native_filesystem_events=True,
|
|
18
|
+
default_data_dir=data_dir("macos", env),
|
|
19
|
+
default_config_dir=config_dir("macos", env),
|
|
20
|
+
notes=(
|
|
21
|
+
"Filesystem events cover only the configured watch roots, recursively.",
|
|
22
|
+
"Processes are discovered by polling; very short-lived children can be missed.",
|
|
23
|
+
"Network endpoint ownership for other users may require root privileges.",
|
|
24
|
+
"CmdScope is not a sandbox and does not restrict the monitored command.",
|
|
25
|
+
),
|
|
26
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Windows platform capabilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
from cmdscope.platforms.base import PlatformInfo, config_dir, data_dir
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def info(environment: Mapping[str, str] | None = None) -> PlatformInfo:
|
|
12
|
+
env = os.environ if environment is None else environment
|
|
13
|
+
return PlatformInfo(
|
|
14
|
+
name="windows",
|
|
15
|
+
display_name="Windows",
|
|
16
|
+
fs_backend="ReadDirectoryChangesW (watchdog)",
|
|
17
|
+
native_filesystem_events=True,
|
|
18
|
+
default_data_dir=data_dir("windows", env),
|
|
19
|
+
default_config_dir=config_dir("windows", env),
|
|
20
|
+
notes=(
|
|
21
|
+
"Filesystem events cover only the configured watch roots, recursively.",
|
|
22
|
+
"Processes are discovered by polling; very short-lived children can be missed.",
|
|
23
|
+
"Network endpoints come from psutil polling; ownership may be incomplete for "
|
|
24
|
+
"processes owned by other users.",
|
|
25
|
+
"Environment mutations inside a running process cannot be observed externally.",
|
|
26
|
+
"CmdScope is not a sandbox and does not restrict the monitored command.",
|
|
27
|
+
),
|
|
28
|
+
)
|