agent-gate-sec 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.
agent_gate/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AgentGate — AI Agent Data Collection & Security Analysis Gateway."""
2
+
3
+ __version__ = "0.2.0"
agent_gate/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m agent_gate"""
2
+
3
+ from .cli import main
4
+
5
+ main()
agent_gate/cli.py ADDED
@@ -0,0 +1,118 @@
1
+ """CLI entry point for AgentGate service."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .config import get_config, reload_config
8
+
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(
12
+ prog="agent-gate",
13
+ description="AgentGate - AI Agent Data Collection & Security Analysis Gateway",
14
+ )
15
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
16
+
17
+ # ---- server command ----
18
+ server_parser = subparsers.add_parser("server", help="Start AgentGate server")
19
+ server_parser.add_argument(
20
+ "--mode", choices=["http", "socket"], default="http",
21
+ help="Server mode: http (TCP) or socket (Unix Domain Socket)"
22
+ )
23
+ server_parser.add_argument(
24
+ "--host", default="0.0.0.0",
25
+ help="Bind host (HTTP mode only, default: 0.0.0.0)"
26
+ )
27
+ server_parser.add_argument(
28
+ "--port", type=int, default=9100,
29
+ help="Bind port (HTTP mode only, default: 9100)"
30
+ )
31
+ server_parser.add_argument(
32
+ "--socket", dest="socket_path",
33
+ help="Unix socket path (socket mode only)"
34
+ )
35
+ server_parser.add_argument(
36
+ "--log-level", choices=["debug", "info", "warning", "error"],
37
+ default="info", help="Log level"
38
+ )
39
+ server_parser.add_argument(
40
+ "--workers", type=int, default=1,
41
+ help="Number of worker processes"
42
+ )
43
+ server_parser.add_argument(
44
+ "--config", type=Path, dest="config_path",
45
+ help="Path to YAML config file"
46
+ )
47
+ server_parser.add_argument(
48
+ "--db-path", type=str, dest="db_path",
49
+ help="SQLite database path override"
50
+ )
51
+
52
+ # ---- clean command ----
53
+ clean_parser = subparsers.add_parser("clean", help="Clean old database records")
54
+ clean_parser.add_argument(
55
+ "--retention-days", type=int, default=30,
56
+ help="Days to retain records (default: 30)"
57
+ )
58
+ clean_parser.add_argument(
59
+ "--config", type=Path, dest="config_path",
60
+ help="Path to YAML config file"
61
+ )
62
+
63
+ args = parser.parse_args()
64
+
65
+ if args.command is None:
66
+ parser.print_help()
67
+ sys.exit(1)
68
+
69
+ if args.command == "server":
70
+ _run_server(args)
71
+
72
+ elif args.command == "clean":
73
+ _run_clean(args)
74
+
75
+
76
+ def _run_server(args):
77
+ # Load config
78
+ cfg = get_config(args.config_path)
79
+ server_cfg = cfg.server
80
+
81
+ host = args.host or server_cfg.get("host", "0.0.0.0")
82
+ port = args.port or server_cfg.get("port", 9100)
83
+ mode = args.mode or server_cfg.get("mode", "http")
84
+ socket_path = args.socket_path or server_cfg.get("socket_path")
85
+ log_level = args.log_level or server_cfg.get("log_level", "info")
86
+ workers = args.workers or server_cfg.get("workers", 1)
87
+ db_path = args.db_path or cfg.storage.get("db_path") or "data/agent_gate.db"
88
+
89
+ from .server.app import run_server
90
+ run_server(
91
+ host=host,
92
+ port=port,
93
+ mode=mode,
94
+ socket_path=socket_path,
95
+ log_level=log_level,
96
+ workers=workers,
97
+ db_path=db_path,
98
+ )
99
+
100
+
101
+ def _run_clean(args):
102
+ cfg = get_config(args.config_path)
103
+ db_path = cfg.storage.get("db_path")
104
+
105
+ from .storage.db import get_db
106
+ from .storage.repository import Repository
107
+ from loguru import logger
108
+
109
+ db = get_db(db_path)
110
+ repo = Repository(db)
111
+
112
+ deleted = repo.cleanup_old_records(args.retention_days)
113
+ logger.info(f"Cleaned up {deleted} old records (retention: {args.retention_days} days)")
114
+ db.close()
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
@@ -0,0 +1,4 @@
1
+ from .moss_client import MossClient
2
+ from .cache import DecisionCache
3
+
4
+ __all__ = ["MossClient", "DecisionCache"]
@@ -0,0 +1,57 @@
1
+ """LRU decision cache.
2
+
3
+ Only caches Allow decisions. Deny decisions are never cached.
4
+ Keys are SHA-256 hashes of (session_id, cwd, command).
5
+ """
6
+
7
+ import hashlib
8
+ import time
9
+ from collections import OrderedDict
10
+ from typing import Optional
11
+
12
+
13
+ class DecisionCache:
14
+ """LRU cache for Allow decisions with TTL-based expiration."""
15
+
16
+ def __init__(self, max_size: int = 500, ttl: int = 300):
17
+ self._max_size = max_size
18
+ self._ttl = ttl # seconds
19
+ self._cache: OrderedDict[str, tuple[float, dict]] = OrderedDict()
20
+
21
+ def _key(self, session_id: str, cwd: str, command: str) -> str:
22
+ raw = f"{session_id}:{cwd}:{command}"
23
+ return hashlib.sha256(raw.encode()).hexdigest()
24
+
25
+ def get(self, session_id: str, cwd: str, command: str) -> Optional[dict]:
26
+ key = self._key(session_id, cwd, command)
27
+ entry = self._cache.get(key)
28
+ if entry is None:
29
+ return None
30
+ timestamp, result = entry
31
+ if self._ttl > 0 and (time.time() - timestamp) > self._ttl:
32
+ # Expired
33
+ del self._cache[key]
34
+ return None
35
+ # Move to end (most recently used)
36
+ self._cache.move_to_end(key)
37
+ return result.copy()
38
+
39
+ def set(self, session_id: str, cwd: str, command: str, result: dict) -> None:
40
+ # Only cache Allow decisions
41
+ if result.get("decision") != "Allow":
42
+ return
43
+ key = self._key(session_id, cwd, command)
44
+ if key in self._cache:
45
+ self._cache.move_to_end(key)
46
+ self._cache[key] = (time.time(), result.copy())
47
+ else:
48
+ if len(self._cache) >= self._max_size:
49
+ self._cache.popitem(last=False) # Remove least recently used
50
+ self._cache[key] = (time.time(), result.copy())
51
+
52
+ def clear(self) -> None:
53
+ self._cache.clear()
54
+
55
+ @property
56
+ def size(self) -> int:
57
+ return len(self._cache)
@@ -0,0 +1,115 @@
1
+ """AgentMoss HTTP/Socket client with Fail-Closed semantics.
2
+
3
+ Makes async HTTP calls to AgentMoss for security analysis.
4
+ If AgentMoss is unreachable, returns Deny (security-first).
5
+ """
6
+
7
+ from typing import Any, Optional
8
+
9
+ import httpx
10
+ from loguru import logger
11
+
12
+ from ..config import get_config
13
+
14
+
15
+ class MossClient:
16
+ """Async HTTP client for AgentMoss security analysis service.
17
+
18
+ Supports both HTTP TCP and Unix Domain Socket connections.
19
+ Implements Fail-Closed: returns Deny when AgentMoss is unavailable.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ base_url: Optional[str] = None,
25
+ timeout: Optional[float] = None,
26
+ max_retries: int = 2,
27
+ ):
28
+ cfg = get_config()
29
+ moss_cfg = cfg.agent_moss
30
+
31
+ self._base_url = (base_url or moss_cfg.get("url", "http://127.0.0.1:9090")).rstrip("/")
32
+ self._timeout = timeout or moss_cfg.get("timeout", 30.0)
33
+ self._max_retries = max_retries or moss_cfg.get("max_retries", 2)
34
+ self._retry_delay = moss_cfg.get("retry_delay", 1.0)
35
+
36
+ # Determine transport
37
+ if self._base_url.startswith("unix://"):
38
+ socket_path = self._base_url[len("unix://"):]
39
+ transport = httpx.AsyncHTTPTransport(uds=socket_path)
40
+ self._base_url = "http://localhost" # Placeholder host for UDS
41
+ else:
42
+ transport = None
43
+
44
+ self._client = httpx.AsyncClient(
45
+ transport=transport,
46
+ timeout=httpx.Timeout(self._timeout),
47
+ limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
48
+ )
49
+
50
+ async def analyze(self, request: dict) -> dict:
51
+ """Send analysis request to AgentMoss.
52
+
53
+ Returns:
54
+ dict with decision/reason/risk_level fields.
55
+ On failure, returns Deny with error details (Fail-Closed).
56
+ """
57
+ last_error = ""
58
+ for attempt in range(self._max_retries + 1):
59
+ try:
60
+ response = await self._client.post(
61
+ f"{self._base_url}/api/v1/analyze",
62
+ json=request,
63
+ )
64
+ response.raise_for_status()
65
+ result = response.json()
66
+ logger.debug(
67
+ f"AgentMoss response: decision={result.get('decision')}, "
68
+ f"duration={result.get('analysis_duration_ms', 0)}ms"
69
+ )
70
+ return result
71
+
72
+ except httpx.TimeoutException as e:
73
+ last_error = f"AgentMoss timeout after {self._timeout}s"
74
+ logger.warning(f"{last_error} (attempt {attempt + 1})")
75
+
76
+ except httpx.ConnectError as e:
77
+ last_error = f"AgentMoss connection failed: {e}"
78
+ logger.warning(f"{last_error} (attempt {attempt + 1})")
79
+
80
+ except httpx.HTTPStatusError as e:
81
+ last_error = f"AgentMoss HTTP {e.response.status_code}"
82
+ logger.warning(f"{last_error} (attempt {attempt + 1})")
83
+ # Don't retry on 4xx
84
+ if 400 <= e.response.status_code < 500:
85
+ break
86
+
87
+ except Exception as e:
88
+ last_error = f"AgentMoss unexpected error: {e}"
89
+ logger.warning(f"{last_error} (attempt {attempt + 1})")
90
+ break
91
+
92
+ # Wait before retry
93
+ if attempt < self._max_retries:
94
+ import asyncio
95
+ await asyncio.sleep(self._retry_delay)
96
+
97
+ # All retries exhausted → Fail-Closed
98
+ logger.error(f"AgentMoss unreachable, returning Deny: {last_error}")
99
+ return self._fail_closed_response(last_error)
100
+
101
+ def _fail_closed_response(self, error: str) -> dict:
102
+ return {
103
+ "decision": "Deny",
104
+ "reason": f"AgentMoss security service unavailable — blocking by default: {error}",
105
+ "risk_level": "high",
106
+ "risk_type": "service_unavailable",
107
+ "violated_layers": [],
108
+ "policy": "",
109
+ "violated_policy": "",
110
+ "confidence": 100,
111
+ "analysis_duration_ms": 0,
112
+ }
113
+
114
+ async def close(self) -> None:
115
+ await self._client.aclose()
@@ -0,0 +1,95 @@
1
+ """Collector orchestrator: runs all collectors in parallel via ThreadPoolExecutor."""
2
+
3
+ import time
4
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
5
+
6
+ from loguru import logger
7
+
8
+ from ..config import get_config
9
+ from ..server.models import SystemContext, OsInfo, ResourceInfo, ProcessInfo, NetworkInfo, UserInfo
10
+ from .system_info import SystemInfoCollector
11
+ from .process_info import ProcessInfoCollector
12
+ from .env_info import get_env_collector
13
+ from .network_info import NetworkInfoCollector
14
+ from .user_info import UserInfoCollector
15
+
16
+
17
+ class CollectorOrchestrator:
18
+ """Orchestrates parallel system context collection.
19
+
20
+ Runs all 5 collectors in a thread pool with per-collector timeout.
21
+ Falls back to empty data when a collector fails or times out.
22
+ """
23
+
24
+ def __init__(self):
25
+ cfg = get_config().collector
26
+ self._timeout = cfg.get("timeout", 5.0)
27
+ self._pool_size = cfg.get("pool_size", 5)
28
+ self._enabled = {
29
+ "system_info": cfg.get("system_info", True),
30
+ "process_info": cfg.get("process_info", True),
31
+ "env_info": cfg.get("env_info", True),
32
+ "network_info": cfg.get("network_info", True),
33
+ "user_info": cfg.get("user_info", True),
34
+ }
35
+
36
+ def collect(self) -> SystemContext:
37
+ start = time.monotonic()
38
+ context = SystemContext()
39
+
40
+ collectors = []
41
+ if self._enabled["system_info"]:
42
+ collectors.append(("system_info", SystemInfoCollector.collect))
43
+ if self._enabled["process_info"]:
44
+ collectors.append(("process_info", ProcessInfoCollector.collect))
45
+ if self._enabled["env_info"]:
46
+ collectors.append(("env_info", get_env_collector().collect))
47
+ if self._enabled["network_info"]:
48
+ collectors.append(("network_info", NetworkInfoCollector.collect))
49
+ if self._enabled["user_info"]:
50
+ collectors.append(("user_info", UserInfoCollector.collect))
51
+
52
+ results = {}
53
+ with ThreadPoolExecutor(max_workers=min(len(collectors), self._pool_size)) as executor:
54
+ futures = {
55
+ executor.submit(fn): name
56
+ for name, fn in collectors
57
+ }
58
+ for future in futures:
59
+ name = futures[future]
60
+ try:
61
+ results[name] = future.result(timeout=self._timeout)
62
+ except FutureTimeoutError:
63
+ logger.warning(f"Collector '{name}' timed out after {self._timeout}s")
64
+ except Exception as e:
65
+ logger.warning(f"Collector '{name}' failed: {e}")
66
+
67
+ # Merge results into context
68
+ for collector_name, data in results.items():
69
+ if "os" in data:
70
+ context.os = OsInfo(**data["os"]) if data["os"] else OsInfo()
71
+ if "resources" in data:
72
+ context.resources = ResourceInfo(**data["resources"]) if data["resources"] else ResourceInfo()
73
+ if "process" in data:
74
+ context.process = ProcessInfo(**data["process"]) if data["process"] else ProcessInfo()
75
+ if "network" in data:
76
+ context.network = NetworkInfo(**data["network"]) if data["network"] else NetworkInfo()
77
+ if "user" in data:
78
+ context.user = UserInfo(**data["user"]) if data["user"] else UserInfo()
79
+ if "env" in data:
80
+ context.env = data["env"]
81
+
82
+ elapsed = (time.monotonic() - start) * 1000
83
+ logger.debug(f"Collection completed in {elapsed:.1f}ms")
84
+ return context
85
+
86
+
87
+ # Singleton
88
+ _orchestrator: CollectorOrchestrator | None = None
89
+
90
+
91
+ def get_orchestrator() -> CollectorOrchestrator:
92
+ global _orchestrator
93
+ if _orchestrator is None:
94
+ _orchestrator = CollectorOrchestrator()
95
+ return _orchestrator
@@ -0,0 +1,54 @@
1
+ """Environment variable collector with security filtering.
2
+
3
+ Applies whitelist + blacklist to prevent leaking secrets.
4
+ """
5
+
6
+ import os
7
+ import re
8
+
9
+ from ..config import get_config
10
+
11
+
12
+ class EnvInfoCollector:
13
+ """Collects environment variables, applying security filters."""
14
+
15
+ def __init__(self):
16
+ cfg = get_config().security
17
+ self._blacklist: list[re.Pattern] = [
18
+ re.compile(p) for p in cfg.get("env_blacklist", [])
19
+ ]
20
+ self._whitelist: list[re.Pattern] = [
21
+ re.compile(p) for p in cfg.get("env_whitelist", [])
22
+ ]
23
+
24
+ def is_safe(self, key: str) -> bool:
25
+ """Check if an env var key is safe to collect."""
26
+ # Whitelist takes priority
27
+ for pattern in self._whitelist:
28
+ if pattern.match(key):
29
+ return True
30
+ # Then check blacklist
31
+ for pattern in self._blacklist:
32
+ if pattern.match(key):
33
+ return False
34
+ # Not in either list → don't collect (conservative)
35
+ return False
36
+
37
+ def collect(self) -> dict:
38
+ result = {}
39
+ for key, value in os.environ.items():
40
+ if self.is_safe(key):
41
+ # Truncate long values (>500 chars)
42
+ result[key] = value[:500] if len(value) > 500 else value
43
+ return {"env": result}
44
+
45
+
46
+ # Singleton
47
+ _env_collector: EnvInfoCollector | None = None
48
+
49
+
50
+ def get_env_collector() -> EnvInfoCollector:
51
+ global _env_collector
52
+ if _env_collector is None:
53
+ _env_collector = EnvInfoCollector()
54
+ return _env_collector
@@ -0,0 +1,68 @@
1
+ """Network information collector: interfaces, active connections, public IP detection."""
2
+
3
+ import os
4
+ import socket
5
+
6
+
7
+ class NetworkInfoCollector:
8
+ """Collects network interface and connection summary."""
9
+
10
+ @staticmethod
11
+ def collect() -> dict:
12
+ interfaces = _get_interfaces()
13
+ active_connections = _count_active_connections()
14
+ has_public_ip = _check_public_ip(interfaces)
15
+
16
+ return {
17
+ "network": {
18
+ "interfaces": interfaces,
19
+ "active_connections": active_connections,
20
+ "has_public_ip": has_public_ip,
21
+ }
22
+ }
23
+
24
+
25
+ def _get_interfaces() -> list[str]:
26
+ """Get list of non-loopback network interfaces."""
27
+ ifaces = []
28
+ try:
29
+ for name in os.listdir("/sys/class/net/"):
30
+ if name != "lo":
31
+ ifaces.append(name)
32
+ except Exception:
33
+ pass
34
+ return ifaces
35
+
36
+
37
+ def _count_active_connections() -> int:
38
+ """Count active network connections."""
39
+ count = 0
40
+ try:
41
+ # TCP connections
42
+ with open("/proc/net/tcp") as f:
43
+ lines = f.readlines()[1:] # Skip header
44
+ count = len(lines)
45
+ with open("/proc/net/tcp6") as f:
46
+ lines = f.readlines()[1:]
47
+ count += len(lines)
48
+ except Exception:
49
+ pass
50
+ return count
51
+
52
+
53
+ def _check_public_ip(interfaces: list[str]) -> bool:
54
+ """Check if any interface has a public IP."""
55
+ private_prefixes = ("10.", "172.16.", "172.17.", "172.18.",
56
+ "172.19.", "172.20.", "172.21.", "172.22.",
57
+ "172.23.", "172.24.", "172.25.", "172.26.",
58
+ "172.27.", "172.28.", "172.29.", "172.30.",
59
+ "172.31.", "192.168.", "127.")
60
+ try:
61
+ addrs = socket.getaddrinfo(socket.gethostname(), None)
62
+ for addr in addrs:
63
+ ip = addr[4][0]
64
+ if not any(ip.startswith(p) for p in private_prefixes):
65
+ return True
66
+ except Exception:
67
+ pass
68
+ return False
@@ -0,0 +1,73 @@
1
+ """Process information collector: PID, parent process, capabilities, cgroup."""
2
+
3
+ import os
4
+
5
+
6
+ class ProcessInfoCollector:
7
+ """Collects information about the current process."""
8
+
9
+ @staticmethod
10
+ def collect() -> dict:
11
+ pid = os.getpid()
12
+ ppid = os.getppid()
13
+ name = ""
14
+ capabilities: list[str] = []
15
+ cgroup = ""
16
+
17
+ try:
18
+ with open(f"/proc/{pid}/comm") as f:
19
+ name = f.read().strip()
20
+ except Exception:
21
+ pass
22
+
23
+ try:
24
+ with open(f"/proc/{pid}/status") as f:
25
+ for line in f:
26
+ if line.startswith("CapEff:"):
27
+ caps_hex = line.split(":")[1].strip()
28
+ capabilities = _parse_capabilities(caps_hex)
29
+ elif line.startswith("Name:"):
30
+ if not name:
31
+ name = line.split(":")[1].strip()
32
+ except Exception:
33
+ pass
34
+
35
+ try:
36
+ with open(f"/proc/{pid}/cgroup") as f:
37
+ cgroup = f.readline().strip()
38
+ except Exception:
39
+ pass
40
+
41
+ return {
42
+ "process": {
43
+ "pid": pid,
44
+ "ppid": ppid,
45
+ "name": name,
46
+ "capabilities": capabilities,
47
+ "cgroup": cgroup,
48
+ }
49
+ }
50
+
51
+
52
+ def _parse_capabilities(caps_hex: str) -> list[str]:
53
+ """Parse Linux capabilities from hex string."""
54
+ CAP_NAMES = [
55
+ "CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_DAC_READ_SEARCH", "CAP_FOWNER",
56
+ "CAP_FSETID", "CAP_KILL", "CAP_SETGID", "CAP_SETUID",
57
+ "CAP_SETPCAP", "CAP_LINUX_IMMUTABLE", "CAP_NET_BIND_SERVICE",
58
+ "CAP_NET_BROADCAST", "CAP_NET_ADMIN", "CAP_NET_RAW",
59
+ "CAP_IPC_LOCK", "CAP_IPC_OWNER", "CAP_SYS_MODULE",
60
+ "CAP_SYS_RAWIO", "CAP_SYS_CHROOT", "CAP_SYS_PTRACE",
61
+ "CAP_SYS_PACCT", "CAP_SYS_ADMIN", "CAP_SYS_BOOT",
62
+ "CAP_SYS_NICE", "CAP_SYS_RESOURCE", "CAP_SYS_TIME",
63
+ "CAP_SYS_TTY_CONFIG", "CAP_MKNOD", "CAP_LEASE",
64
+ "CAP_AUDIT_WRITE", "CAP_AUDIT_CONTROL", "CAP_SETFCAP",
65
+ "CAP_MAC_OVERRIDE", "CAP_MAC_ADMIN", "CAP_SYSLOG",
66
+ "CAP_WAKE_ALARM", "CAP_BLOCK_SUSPEND", "CAP_AUDIT_READ",
67
+ "CAP_PERFMON", "CAP_BPF", "CAP_CHECKPOINT_RESTORE",
68
+ ]
69
+ try:
70
+ caps_int = int(caps_hex, 16)
71
+ return [name for i, name in enumerate(CAP_NAMES) if caps_int & (1 << i)]
72
+ except (ValueError, IndexError):
73
+ return []
@@ -0,0 +1,54 @@
1
+ """System information collector: OS, kernel, CPU, memory, disk."""
2
+
3
+ import os
4
+ import platform
5
+ from typing import Optional
6
+
7
+ import psutil
8
+
9
+
10
+ class SystemInfoCollector:
11
+ """Collects OS-level information."""
12
+
13
+ @staticmethod
14
+ def collect() -> dict:
15
+ try:
16
+ return {
17
+ "os": {
18
+ "system": platform.system().lower(),
19
+ "distribution": _get_distribution(),
20
+ "kernel": platform.release(),
21
+ "hostname": platform.node(),
22
+ "machine": platform.machine(),
23
+ },
24
+ "resources": {
25
+ "cpu_count": os.cpu_count() or 0,
26
+ "cpu_percent": round(psutil.cpu_percent(interval=0.1), 1),
27
+ "memory_total_gb": round(psutil.virtual_memory().total / (1024 ** 3), 1),
28
+ "memory_percent": psutil.virtual_memory().percent,
29
+ "disk_total_gb": round(
30
+ psutil.disk_usage("/").total / (1024 ** 3), 1
31
+ ),
32
+ "disk_percent": psutil.disk_usage("/").percent,
33
+ },
34
+ }
35
+ except Exception:
36
+ return {"os": {}, "resources": {}}
37
+
38
+
39
+ def _get_distribution() -> str:
40
+ """Detect Linux distribution name."""
41
+ try:
42
+ if os.path.exists("/etc/os-release"):
43
+ with open("/etc/os-release") as f:
44
+ info = {}
45
+ for line in f:
46
+ if "=" in line:
47
+ k, v = line.strip().split("=", 1)
48
+ info[k] = v.strip('"')
49
+ name = info.get("NAME", "")
50
+ version = info.get("VERSION_ID", "")
51
+ return f"{name} {version}".strip()
52
+ except Exception:
53
+ pass
54
+ return platform.platform()