server-management-lib 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.
@@ -0,0 +1,1552 @@
1
+ """
2
+ Security Validator for Server Management Library
3
+
4
+ Enforces strict security policies:
5
+ - No generic command execution
6
+ - Device name validation (disk-health-mcp)
7
+ - Service name validation (remote-server-mcp)
8
+ - Path traversal prevention (including Unicode/encoding bypasses)
9
+ - Command injection prevention
10
+ - Database query validation (read-only enforcement)
11
+ - Log search pattern and time-filter validation
12
+
13
+ Design: Whitelist specific safe operations, don't try to blacklist dangerous ones.
14
+ """
15
+
16
+ import logging
17
+ import re
18
+ import shlex
19
+ from dataclasses import dataclass
20
+ from datetime import datetime
21
+ from enum import Enum
22
+ from pathlib import PurePosixPath
23
+
24
+ import sqlparse
25
+ from sqlparse import tokens as sql_tokens
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # ============================================================================
30
+ # Device name validation (disk-health-mcp)
31
+ # ============================================================================
32
+
33
+ # Valid block device name pattern: sdX, nvmeXnY, vdX, hdX, mmcblkXpY, dm-X
34
+ DEVICE_NAME_PATTERN = re.compile(
35
+ r"^(sd[a-z]{1,2}|nvme\d+n\d+|vd[a-z]|hd[a-z]|mmcblk\d+(p\d+)?|dm-\d+)$"
36
+ )
37
+
38
+ # ============================================================================
39
+ # Service name validation (remote-server-mcp)
40
+ # ============================================================================
41
+
42
+ # Strict service name pattern: must start with alphanumeric, then alphanumeric,
43
+ # hyphens, underscores. Prevents Docker option injection (names like "-help").
44
+ SERVICE_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
45
+
46
+ # ============================================================================
47
+ # Host service unit validation (remote-server-mcp)
48
+ # ============================================================================
49
+
50
+ # Systemd unit names may contain dots, @, colons, hyphens and underscores. The
51
+ # allowlist below is the real control; this pattern only rejects names that
52
+ # cannot be a plain systemd unit (paths, spaces, shell syntax).
53
+ HOST_UNIT_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.@:-]{0,99}$")
54
+
55
+ # Default units that may be inspected with systemctl/journalctl. Host
56
+ # infrastructure services are not Docker containers, so they need their own
57
+ # explicit allowlist. Override with security.host_units in config.yaml.
58
+ DEFAULT_HOST_UNITS: tuple[str, ...] = (
59
+ "telegraf",
60
+ "smartd",
61
+ "docker",
62
+ "snapraid",
63
+ "mergerfs",
64
+ "smbd",
65
+ )
66
+
67
+ # Absolute filesystem paths reported by get_server_health when
68
+ # health.disk_paths is unset or contains no valid entry. NAS pools are separate
69
+ # mount points, so they must be configured explicitly instead of assuming that
70
+ # services_path is representative.
71
+ DEFAULT_HEALTH_DISK_PATHS: tuple[str, ...] = ("/",)
72
+
73
+ # ============================================================================
74
+ # Log search and time-filter validation (remote-server-mcp)
75
+ # ============================================================================
76
+
77
+ # Log search matches lines locally in Python, never in a shell. Shell syntax in
78
+ # the pattern is therefore harmless and must be preserved verbatim; only the
79
+ # length and control characters are bounded.
80
+ MAX_SEARCH_PATTERN_LENGTH = 200
81
+ SEARCH_CONTROL_CHARACTER_PATTERN = re.compile(r"[\x00-\x1f\x7f]")
82
+
83
+ # ``docker logs --since/--until`` accept an RFC3339 timestamp (with an optional
84
+ # fractional part and offset), a date, or a Go-style relative duration such as
85
+ # ``42m`` or ``1h30m``. Keeping this strict prevents a value from being parsed
86
+ # as a Docker option.
87
+ LOG_TIME_PATTERN = re.compile(
88
+ r"^(?:"
89
+ r"\d{4}-\d{2}-\d{2}"
90
+ r"(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2}))?"
91
+ r"|(?:\d+(?:\.\d+)?(?:ns|us|µs|ms|s|m|h)){1,4}"
92
+ r")$"
93
+ )
94
+ LOG_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}")
95
+ MAX_LOG_TIME_LENGTH = 64
96
+
97
+ # ============================================================================
98
+ # Path traversal patterns (remote-server-mcp)
99
+ # ============================================================================
100
+
101
+ PATH_TRAVERSAL_PATTERNS = [
102
+ "..", # Parent directory reference
103
+ "~", # Home directory
104
+ "$", # Variable expansion
105
+ "`", # Command substitution
106
+ ";", # Command separator
107
+ "|", # Pipe
108
+ "&", # Background/AND
109
+ ">", # Redirect
110
+ "<", # Redirect input
111
+ "(", # Subshell
112
+ ")", # Subshell
113
+ "{", # Brace expansion
114
+ "}", # Brace expansion
115
+ "[", # Glob
116
+ "]", # Glob
117
+ "*", # Glob
118
+ "?", # Glob
119
+ "'", # Quote escape
120
+ '"', # Quote escape
121
+ "\\", # Escape character
122
+ "\n", # Newline injection
123
+ "\r", # Carriage return
124
+ "\t", # Tab injection
125
+ " ", # Space (shell word splitting)
126
+ "!", # Bash history expansion
127
+ "#", # Comment character
128
+ ]
129
+
130
+ URL_ENCODED_TRAVERSAL = [
131
+ "%2e", # URL-encoded dot
132
+ "%2f", # URL-encoded slash
133
+ "%5c", # URL-encoded backslash
134
+ "%25", # URL-encoded percent (double encoding)
135
+ "%00", # Null byte
136
+ ]
137
+
138
+ UNICODE_DOT_VARIANTS = [
139
+ "\uff0e", # Fullwidth full stop
140
+ "\u3002", # Ideographic full stop
141
+ "\u2024", # One dot leader
142
+ "\u2025", # Two dot leader
143
+ "\u2027", # Hyphenation point
144
+ "\u00b7", # Middle dot
145
+ "\u200b", # Zero-width space (used between dots)
146
+ "\u200c", # Zero-width non-joiner
147
+ "\u200d", # Zero-width joiner
148
+ "\u2060", # Word joiner
149
+ ]
150
+
151
+ # ============================================================================
152
+ # Database query validation
153
+ # ============================================================================
154
+
155
+ # Keywords that turn a SELECT into a write or administrative statement.
156
+ # Validation is semantic: parse the SQL instead of blacklisting shell characters
157
+ # (the query is sent over HTTP, never through a shell).
158
+ WRITE_KEYWORDS = frozenset(
159
+ {
160
+ "ALTER",
161
+ "CREATE",
162
+ "DELETE",
163
+ "DROP",
164
+ "GRANT",
165
+ "INSERT",
166
+ "INTO",
167
+ "KILL",
168
+ "MERGE",
169
+ "RENAME",
170
+ "REPLACE",
171
+ "REVOKE",
172
+ "SET",
173
+ "TRUNCATE",
174
+ "UPDATE",
175
+ "UPSERT",
176
+ }
177
+ )
178
+
179
+ # ============================================================================
180
+ # Sensitive file policy (remote-server-mcp)
181
+ # ============================================================================
182
+ #
183
+ # Matching is per path component (or its stem) and per extension, never as a
184
+ # substring of the whole path. That removes false positives such as
185
+ # ``monkey.txt`` matching ``.key`` or ``tokenizer.json`` matching ``token``.
186
+
187
+ # Components that are always refused: private keys, credential stores and
188
+ # version-control metadata that commonly embeds tokens in remote URLs.
189
+ BLOCKED_PATH_COMPONENTS: frozenset[str] = frozenset(
190
+ {
191
+ ".git",
192
+ ".ssh",
193
+ ".gnupg",
194
+ ".aws",
195
+ ".kube",
196
+ ".docker",
197
+ ".netrc",
198
+ ".pgpass",
199
+ ".npmrc",
200
+ ".pypirc",
201
+ ".htpasswd",
202
+ "credentials",
203
+ "credential",
204
+ "secrets",
205
+ "secret",
206
+ "password",
207
+ "passwords",
208
+ "passwd",
209
+ "token",
210
+ "tokens",
211
+ "api_key",
212
+ "apikey",
213
+ "private_key",
214
+ "id_rsa",
215
+ "id_dsa",
216
+ "id_ecdsa",
217
+ "id_ed25519",
218
+ "shadow",
219
+ "gshadow",
220
+ "sudoers",
221
+ "htpasswd",
222
+ "wp-config",
223
+ "wp-config.php",
224
+ "database.yml",
225
+ "database.yaml",
226
+ "secrets.yml",
227
+ "secrets.yaml",
228
+ }
229
+ )
230
+
231
+ # Extensions that identify key material or an encrypted secret store.
232
+ BLOCKED_PATH_SUFFIXES: frozenset[str] = frozenset(
233
+ {".pem", ".key", ".p12", ".pfx", ".jks", ".keystore", ".kdbx"}
234
+ )
235
+
236
+ # ``.env`` files are readable because they are the primary way to debug a
237
+ # compose stack whose compose.yml only contains ``${VARS}``. Their values are
238
+ # masked before the contents are returned to the model.
239
+ REDACTED_PATH_NAMES: frozenset[str] = frozenset({".env", ".envrc"})
240
+ REDACTED_PATH_SUFFIXES: frozenset[str] = frozenset({".env"})
241
+
242
+
243
+ class FileAccess(Enum):
244
+ """Access decision for a file inside a service directory."""
245
+
246
+ ALLOW = "allow"
247
+ REDACT = "redact"
248
+ BLOCKED_INVALID = "blocked_invalid"
249
+ BLOCKED_SENSITIVE = "blocked_sensitive"
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class ServiceFilePath:
254
+ """Result of checking a requested service file path.
255
+
256
+ ``path`` is the resolved absolute path when the file may be read, else
257
+ ``None``. ``access`` tells callers whether the contents need redaction and
258
+ why a path was refused; ``reason`` is a short human-readable explanation.
259
+ """
260
+
261
+ path: str | None
262
+ access: FileAccess = FileAccess.ALLOW
263
+ reason: str | None = None
264
+
265
+
266
+ def _relative_file_access(file_path: str) -> tuple[FileAccess, str | None]:
267
+ """Classify a relative path against the sensitive-file policy."""
268
+ access = FileAccess.ALLOW
269
+ for component in PurePosixPath(file_path).parts:
270
+ name = component.lower()
271
+ pure = PurePosixPath(name)
272
+ stem = pure.stem
273
+
274
+ if name in BLOCKED_PATH_COMPONENTS or stem in BLOCKED_PATH_COMPONENTS:
275
+ return (
276
+ FileAccess.BLOCKED_SENSITIVE,
277
+ f"path component {component!r} is sensitive",
278
+ )
279
+
280
+ if pure.suffix in BLOCKED_PATH_SUFFIXES:
281
+ return (
282
+ FileAccess.BLOCKED_SENSITIVE,
283
+ f"file extension {pure.suffix!r} may contain key material",
284
+ )
285
+
286
+ if (
287
+ name in REDACTED_PATH_NAMES
288
+ or name.startswith(".env.")
289
+ or pure.suffix in REDACTED_PATH_SUFFIXES
290
+ ):
291
+ access = FileAccess.REDACT
292
+
293
+ return access, None
294
+
295
+
296
+ # ============================================================================
297
+ # Command safety - argv allowlist
298
+ # ============================================================================
299
+ #
300
+ # Every command must be an argv vector produced by the operation catalog and
301
+ # match one of these prefixes. The string is parsed with shlex and must render
302
+ # back to the exact same canonical form, so hand-built shell syntax (chaining,
303
+ # redirection, command substitution) is rejected before the allowlist lookup.
304
+
305
+ # Supported container runtimes and the default used when unset.
306
+ DEFAULT_CONTAINER_RUNTIME = "docker"
307
+ CONTAINER_RUNTIMES: tuple[str, ...] = ("docker", "podman", "nerdctl")
308
+ # ``compose`` subcommands and the only arguments the catalog may append.
309
+ COMPOSE_ARGUMENTS: dict[str, tuple[tuple[str, ...], ...]] = {
310
+ "ps": ((), ("--format", "{{.Name}} {{.Status}}")),
311
+ "pull": ((),),
312
+ "up": (("-d",), ("-d", "--remove-orphans")),
313
+ "down": ((), ("--remove-orphans",)),
314
+ }
315
+
316
+ ALLOWED_ARGV_PREFIXES: tuple[tuple[str, ...], ...] = (
317
+ # remote-server-mcp catalog
318
+ ("echo",),
319
+ ("uptime",),
320
+ ("free", "-h"),
321
+ ("df", "-h"),
322
+ ("ls", "-1"),
323
+ ("test", "-d"),
324
+ ("docker", "ps"),
325
+ ("docker", "logs"),
326
+ ("docker", "inspect"),
327
+ ("docker", "stats"),
328
+ ("docker", "port"),
329
+ ("docker", "restart"),
330
+ ("docker", "start"),
331
+ ("docker", "stop"),
332
+ # host infrastructure services (remote-server-mcp)
333
+ ("systemctl", "status"),
334
+ ("journalctl", "-u"),
335
+ # disk-health-mcp catalog
336
+ ("lsblk", "-d"),
337
+ ("iostat", "-x"),
338
+ ("iostat", "-d"),
339
+ ("zpool", "status"),
340
+ ("zpool", "list"),
341
+ ("zpool", "iostat"),
342
+ ("zfs", "list"),
343
+ ("zfs", "get"),
344
+ ("cat", "/proc/mdstat"),
345
+ ("mdadm", "--detail"),
346
+ ("mdadm", "--examine"),
347
+ ("snapraid", "status"),
348
+ ("findmnt", "--real"),
349
+ ("findmnt", "-t"),
350
+ )
351
+
352
+ for _smartctl in (
353
+ "smartctl",
354
+ "/usr/sbin/smartctl",
355
+ "/sbin/smartctl",
356
+ "/usr/bin/smartctl",
357
+ ):
358
+ ALLOWED_ARGV_PREFIXES += (
359
+ (_smartctl, "--version"),
360
+ (_smartctl, "-n", "standby"),
361
+ (_smartctl, "-j", "-a"),
362
+ (_smartctl, "-j", "-i"),
363
+ (_smartctl, "-a"),
364
+ (_smartctl, "-i"),
365
+ (_smartctl, "-c"),
366
+ (_smartctl, "-l", "error"),
367
+ (_smartctl, "-l", "selftest"),
368
+ (_smartctl, "-t"),
369
+ )
370
+
371
+ for _nvme in ("nvme", "/usr/sbin/nvme", "/sbin/nvme"):
372
+ for _subcommand in (
373
+ "smart-log",
374
+ "smart-log-add",
375
+ "id-ctrl",
376
+ "id-ns",
377
+ "error-log",
378
+ ):
379
+ ALLOWED_ARGV_PREFIXES += ((_nvme, _subcommand),)
380
+
381
+ # Podman and nerdctl expose a Docker-compatible command surface. Only the
382
+ # container subcommands the catalog uses are accepted; -compose commands are
383
+ # handled separately by ``_is_safe_compose`` (they carry a directory argument).
384
+ for _runtime in ("podman", "nerdctl"):
385
+ for _subcommand in (
386
+ "ps",
387
+ "logs",
388
+ "inspect",
389
+ "stats",
390
+ "port",
391
+ "restart",
392
+ "start",
393
+ "stop",
394
+ ):
395
+ ALLOWED_ARGV_PREFIXES += ((_runtime, _subcommand),)
396
+
397
+ del _smartctl, _nvme, _subcommand, _runtime
398
+
399
+ # ``sudo`` is treated as a dangerous pattern below, with one exception. SnapRAID
400
+ # keeps its content file and its ``<content>.lock`` file as ``root:root 0600``,
401
+ # so the unprivileged executor cannot read them, and an ACL cannot fix that:
402
+ # SnapRAID rewrites the content file through a 0600 temporary file and renames
403
+ # it into place, which masks inherited default-ACL entries to nothing.
404
+ # ``snapraid status`` is read-only, so the catalog runs exactly this argv with
405
+ # sudo. The host sudoers rule matches the same literal, and sudoers matches
406
+ # arguments exactly, so any extra argument asks for a password and fails under
407
+ # ``sudo -n``.
408
+ SNAPRAID_STATUS_SUDO_ARGV: tuple[str, ...] = (
409
+ "sudo",
410
+ "-n",
411
+ "/usr/local/bin/snapraid",
412
+ "status",
413
+ )
414
+ SNAPRAID_STATUS_SUDO_COMMAND: str = shlex.join(SNAPRAID_STATUS_SUDO_ARGV)
415
+
416
+ # Bare shell syntax in an argument can only come from a hand-built command.
417
+ SHELL_METACHARACTERS = frozenset(";&|<>`$")
418
+
419
+ # Container names may contain dots (for example ``compose-app.1``) but never
420
+ # start with an option dash.
421
+ CONTAINER_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$")
422
+
423
+ # Docker inspect/stats format fields that can expose the process environment,
424
+ # command line, mounts or host configuration. The catalog only needs .Name,
425
+ # .State, .RestartCount and the stats fields, so these are refused outright.
426
+ SENSITIVE_DOCKER_FORMAT_PARTS = (
427
+ ".Config",
428
+ ".Args",
429
+ ".Mounts",
430
+ ".HostConfig",
431
+ ".NetworkSettings",
432
+ )
433
+
434
+ # SMART self-test types accepted by the catalog and by smartctl itself.
435
+ SMART_TEST_TYPES = frozenset({"short", "long", "conveyance"})
436
+
437
+ # ============================================================================
438
+ # Command safety - disk-health-mcp injection patterns
439
+ # ============================================================================
440
+
441
+ INJECTION_PATTERNS = [
442
+ ";", # Command separator
443
+ "|", # Pipe
444
+ "&", # Background (but allow 2>&1 redirect)
445
+ "`", # Command substitution
446
+ "$", # Variable expansion
447
+ "(", # Subshell
448
+ ")", # Subshell
449
+ "{", # Brace expansion
450
+ "}", # Brace expansion
451
+ "<", # Redirect input
452
+ "\n", # Newline injection
453
+ "\r", # Carriage return
454
+ "\\", # Escape character
455
+ "'", # Quote escape
456
+ '"', # Quote escape
457
+ "!", # History expansion
458
+ "~", # Home directory
459
+ "..", # Path traversal
460
+ ]
461
+
462
+
463
+ class SecurityValidator:
464
+ """Validates all inputs for security before execution."""
465
+
466
+ def __init__(self, config: dict):
467
+ """
468
+ Initialize security validator.
469
+
470
+ Args:
471
+ config: Configuration dictionary
472
+ """
473
+ self.config = config
474
+ self.security_config = config.get("security", {})
475
+ self.services_base_path = self.security_config.get("services_path", "/srv")
476
+ self._container_runtime = self._read_container_runtime(config)
477
+ # Kept for configuration compatibility only. It deliberately has no
478
+ # effect: there is no path that enables arbitrary command execution.
479
+ self.allow_generic_command_execution = False
480
+
481
+ @staticmethod
482
+ def _read_container_runtime(config: dict) -> str:
483
+ raw = config.get("container_runtime", DEFAULT_CONTAINER_RUNTIME)
484
+ if isinstance(raw, str) and raw.strip().lower() in CONTAINER_RUNTIMES:
485
+ return raw.strip().lower()
486
+ logger.warning(
487
+ "Ignoring container_runtime %r: expected one of %s",
488
+ raw,
489
+ ", ".join(CONTAINER_RUNTIMES),
490
+ )
491
+ return DEFAULT_CONTAINER_RUNTIME
492
+
493
+ @property
494
+ def container_runtime(self) -> str:
495
+ """Container CLI used by the remote-server operation catalog."""
496
+ return self._container_runtime
497
+
498
+ @property
499
+ def allowed_host_units(self) -> frozenset[str]:
500
+ """Systemd units that may be inspected with systemctl/journalctl.
501
+
502
+ ``security.host_units`` defaults to :data:`DEFAULT_HOST_UNITS`. Entries
503
+ are validated so a malformed value cannot become a systemd argument.
504
+ Setting the key to ``null`` or an empty list disables host-service
505
+ tools entirely.
506
+ """
507
+ raw = self.security_config.get("host_units", DEFAULT_HOST_UNITS)
508
+ if raw is None:
509
+ return frozenset()
510
+ if isinstance(raw, str):
511
+ raw = [raw]
512
+ if not isinstance(raw, (list, tuple, set)):
513
+ logger.warning("Ignoring security.host_units: expected a list of names")
514
+ return frozenset()
515
+
516
+ allowed = set()
517
+ for unit in raw:
518
+ if isinstance(unit, str) and HOST_UNIT_PATTERN.match(unit):
519
+ allowed.add(unit)
520
+ else:
521
+ logger.warning("Ignoring invalid security.host_units entry: %r", unit)
522
+ return frozenset(allowed)
523
+
524
+ @property
525
+ def health_disk_paths(self) -> tuple[str, ...]:
526
+ """Absolute filesystem paths ``get_server_health`` reports.
527
+
528
+ ``health.disk_paths`` accepts a list of absolute mount points (for
529
+ example ``["/", "/mnt/storage"]``). Entries are validated, normalised
530
+ and deduplicated while preserving their configured order. Invalid
531
+ entries are ignored; when nothing valid remains the default ``["/"]``
532
+ is used so the health report never silently loses its disk section.
533
+ """
534
+ health_config = self.config.get("health")
535
+ if health_config is None:
536
+ health_config = {}
537
+ if not isinstance(health_config, dict):
538
+ logger.warning("Ignoring health: expected a mapping")
539
+ return DEFAULT_HEALTH_DISK_PATHS
540
+
541
+ raw = health_config.get("disk_paths", DEFAULT_HEALTH_DISK_PATHS)
542
+ if raw is None:
543
+ raw = DEFAULT_HEALTH_DISK_PATHS
544
+ if isinstance(raw, str):
545
+ raw = [raw]
546
+ if not isinstance(raw, (list, tuple)):
547
+ logger.warning("Ignoring health.disk_paths: expected a list of paths")
548
+ return DEFAULT_HEALTH_DISK_PATHS
549
+
550
+ paths: list[str] = []
551
+ for candidate in raw:
552
+ path = self.validate_disk_path(candidate)
553
+ if path is not None and path not in paths:
554
+ paths.append(path)
555
+
556
+ if not paths:
557
+ if raw:
558
+ logger.warning(
559
+ "health.disk_paths contained no valid absolute path; using %s",
560
+ DEFAULT_HEALTH_DISK_PATHS,
561
+ )
562
+ return DEFAULT_HEALTH_DISK_PATHS
563
+ return tuple(paths)
564
+
565
+ @property
566
+ def allowed_containers(self) -> frozenset[str]:
567
+ """Container names explicitly allowed outside a service directory.
568
+
569
+ ``security.allowed_containers`` exists for containers that are not
570
+ part of a compose project below ``services_path`` (for example a
571
+ standalone `watchtower` container). Entries are validated with the
572
+ same pattern as service names, so they cannot inject Docker options.
573
+ """
574
+ raw = self.security_config.get("allowed_containers", [])
575
+ if raw is None:
576
+ return frozenset()
577
+ if isinstance(raw, str):
578
+ raw = [raw]
579
+ if not isinstance(raw, (list, tuple, set)):
580
+ logger.warning(
581
+ "Ignoring security.allowed_containers: expected a list of names"
582
+ )
583
+ return frozenset()
584
+
585
+ allowed = set()
586
+ for name in raw:
587
+ if isinstance(name, str) and self.validate_service_name(name):
588
+ allowed.add(name)
589
+ else:
590
+ logger.warning(
591
+ "Ignoring invalid security.allowed_containers entry: %r", name
592
+ )
593
+ return frozenset(allowed)
594
+
595
+ # ========================================================================
596
+ # Device name validation (disk-health-mcp)
597
+ # ========================================================================
598
+
599
+ def validate_device_name(self, device: str) -> bool:
600
+ """
601
+ Validate block device name against safe pattern.
602
+
603
+ Args:
604
+ device: Device name (e.g., 'sda', 'nvme0n1')
605
+
606
+ Returns:
607
+ True if valid, False otherwise
608
+ """
609
+ if not device or not isinstance(device, str):
610
+ logger.warning(f"Invalid device name: {device}")
611
+ return False
612
+
613
+ if len(device) > 32:
614
+ logger.warning(f"Device name too long: {len(device)} chars")
615
+ return False
616
+
617
+ if not DEVICE_NAME_PATTERN.match(device):
618
+ logger.warning(f"Device name contains invalid chars: {device}")
619
+ return False
620
+
621
+ for pattern in INJECTION_PATTERNS:
622
+ if pattern in device:
623
+ logger.warning(f"Device name contains injection pattern: {pattern}")
624
+ return False
625
+
626
+ return True
627
+
628
+ def validate_smart_test_type(self, test_type: str) -> bool:
629
+ """
630
+ Validate SMART self-test type.
631
+
632
+ Args:
633
+ test_type: Test type string
634
+
635
+ Returns:
636
+ True if valid, False otherwise
637
+ """
638
+ if not test_type or not isinstance(test_type, str):
639
+ logger.warning(f"Invalid SMART test type: {test_type}")
640
+ return False
641
+ valid_tests = {"short", "long", "conveyance"}
642
+ if test_type.lower() not in valid_tests:
643
+ logger.warning(f"Invalid SMART test type: {test_type}")
644
+ return False
645
+ return True
646
+
647
+ # ========================================================================
648
+ # Service name validation (remote-server-mcp)
649
+ # ========================================================================
650
+
651
+ def validate_service_name(self, service_name: str) -> bool:
652
+ """
653
+ Validate service name against strict pattern.
654
+
655
+ Args:
656
+ service_name: Service name to validate
657
+
658
+ Returns:
659
+ True if valid, False otherwise
660
+ """
661
+ if not service_name or not isinstance(service_name, str):
662
+ logger.warning(f"Invalid service name: {service_name}")
663
+ return False
664
+
665
+ if len(service_name) > 100:
666
+ logger.warning(f"Service name too long: {len(service_name)} chars")
667
+ return False
668
+
669
+ if not SERVICE_NAME_PATTERN.match(service_name):
670
+ logger.warning(f"Service name contains invalid characters: {service_name}")
671
+ return False
672
+
673
+ for pattern in PATH_TRAVERSAL_PATTERNS:
674
+ if pattern in service_name:
675
+ logger.warning(
676
+ f"Service name contains path traversal pattern: {pattern}"
677
+ )
678
+ return False
679
+
680
+ return True
681
+
682
+ def check_service_file_path(self, service: str, file_path: str) -> ServiceFilePath:
683
+ """Validate a file path and classify it for the sensitive-file policy.
684
+
685
+ Args:
686
+ service: Service name (already validated)
687
+ file_path: Relative file path within the service directory
688
+
689
+ Returns:
690
+ A :class:`ServiceFilePath`. ``path`` is set only when the file may
691
+ be read; ``access`` distinguishes invalid paths from files refused
692
+ by the sensitive-file policy and marks ``.env`` files for redaction.
693
+ """
694
+ if (
695
+ not isinstance(service, str)
696
+ or not service
697
+ or service in {".", ".."}
698
+ or PurePosixPath(service).parts != (service,)
699
+ ):
700
+ logger.warning("Invalid service name for file path: %r", service)
701
+ return ServiceFilePath(
702
+ None, FileAccess.BLOCKED_INVALID, "invalid service name"
703
+ )
704
+
705
+ if not file_path or not isinstance(file_path, str):
706
+ logger.warning(f"Invalid file path: {file_path}")
707
+ return ServiceFilePath(
708
+ None, FileAccess.BLOCKED_INVALID, "file path is empty"
709
+ )
710
+
711
+ if len(file_path) > 500:
712
+ logger.warning(f"File path too long: {len(file_path)} chars")
713
+ return ServiceFilePath(
714
+ None, FileAccess.BLOCKED_INVALID, "file path is too long"
715
+ )
716
+
717
+ if "\x00" in file_path:
718
+ logger.warning("File path contains null byte")
719
+ return ServiceFilePath(
720
+ None, FileAccess.BLOCKED_INVALID, "file path contains a null byte"
721
+ )
722
+
723
+ file_path_lower = file_path.lower()
724
+ for pattern in URL_ENCODED_TRAVERSAL:
725
+ if pattern.lower() in file_path_lower:
726
+ logger.warning(f"File path contains URL-encoded traversal: {pattern}")
727
+ return ServiceFilePath(
728
+ None,
729
+ FileAccess.BLOCKED_INVALID,
730
+ "file path contains URL-encoded traversal",
731
+ )
732
+
733
+ for char in UNICODE_DOT_VARIANTS:
734
+ if char in file_path:
735
+ logger.warning("File path contains unicode dot variant: %r", char)
736
+ return ServiceFilePath(
737
+ None,
738
+ FileAccess.BLOCKED_INVALID,
739
+ "file path contains a Unicode dot variant",
740
+ )
741
+
742
+ for pattern in PATH_TRAVERSAL_PATTERNS:
743
+ if pattern in file_path:
744
+ logger.warning(f"File path contains dangerous pattern: {pattern}")
745
+ return ServiceFilePath(
746
+ None,
747
+ FileAccess.BLOCKED_INVALID,
748
+ "file path contains a traversal or shell pattern",
749
+ )
750
+
751
+ if file_path.startswith("/"):
752
+ logger.warning(f"File path must be relative, not absolute: {file_path}")
753
+ return ServiceFilePath(
754
+ None, FileAccess.BLOCKED_INVALID, "file path must be relative"
755
+ )
756
+
757
+ access, reason = _relative_file_access(file_path)
758
+ if access is FileAccess.BLOCKED_SENSITIVE:
759
+ logger.warning("File path blocked by sensitive-file policy: %s", file_path)
760
+ return ServiceFilePath(None, access, reason)
761
+
762
+ # The path belongs to the target host, not to the MCP host. Building it
763
+ # with PurePosixPath keeps it lexical: a symlink below services_path on
764
+ # the MCP host can neither change the target path nor break validation.
765
+ # The executor performs the authoritative realpath check on the target.
766
+ service_base = str(PurePosixPath(self.services_base_path) / service)
767
+ full_path = str(PurePosixPath(service_base) / file_path)
768
+ if full_path != service_base and not full_path.startswith(service_base + "/"):
769
+ logger.warning(
770
+ "Lexical path escaped service directory: %s (base %s)",
771
+ full_path,
772
+ service_base,
773
+ )
774
+ return ServiceFilePath(
775
+ None,
776
+ FileAccess.BLOCKED_INVALID,
777
+ "file path resolves outside the service directory",
778
+ )
779
+ return ServiceFilePath(full_path, access, reason)
780
+
781
+ def validate_service_file_path(self, service: str, file_path: str) -> str | None:
782
+ """
783
+ Validate and construct a safe file path within /srv/{service}/.
784
+
785
+ Args:
786
+ service: Service name (already validated)
787
+ file_path: Relative file path within service directory
788
+
789
+ Returns:
790
+ Full safe path, or None if invalid
791
+ """
792
+ return self.check_service_file_path(service, file_path).path
793
+
794
+ def validate_disk_path(self, path: object) -> str | None:
795
+ """Validate and normalise an absolute filesystem path for ``df``.
796
+
797
+ Only paths are accepted (no shell syntax, globs or traversal) and the
798
+ path must be absolute. ``PurePosixPath`` makes the result lexical, so
799
+ the path is still resolved on the target host, not on the MCP host.
800
+
801
+ Returns:
802
+ The normalised absolute path, or ``None`` when invalid.
803
+ """
804
+ if not isinstance(path, str) or not path:
805
+ logger.warning("Invalid health disk path: %r", path)
806
+ return None
807
+
808
+ if len(path) > 500:
809
+ logger.warning("Health disk path too long: %d chars", len(path))
810
+ return None
811
+
812
+ if not path.startswith("/"):
813
+ logger.warning("Health disk path must be absolute: %s", path)
814
+ return None
815
+
816
+ if "\x00" in path or any(ord(char) < 0x20 for char in path):
817
+ logger.warning("Health disk path contains a control character: %r", path)
818
+ return None
819
+
820
+ for pattern in PATH_TRAVERSAL_PATTERNS:
821
+ if pattern in path:
822
+ logger.warning(
823
+ "Health disk path contains dangerous pattern %r: %s",
824
+ pattern,
825
+ path,
826
+ )
827
+ return None
828
+
829
+ try:
830
+ normalised = str(PurePosixPath(path))
831
+ except Exception as e: # pragma: no cover - PurePosixPath is defensive
832
+ logger.warning("Could not normalise health disk path %r: %s", path, e)
833
+ return None
834
+
835
+ if not normalised.startswith("/"):
836
+ return None
837
+ return normalised
838
+
839
+ def validate_host_unit(self, unit: str) -> bool:
840
+ """Validate a systemd unit name against the configured allowlist.
841
+
842
+ Args:
843
+ unit: Unit name such as ``telegraf`` or ``docker``.
844
+
845
+ Returns:
846
+ True only when the name is well formed and explicitly allowed.
847
+ """
848
+ if not unit or not isinstance(unit, str):
849
+ logger.warning("Invalid host unit: %r", unit)
850
+ return False
851
+
852
+ if not HOST_UNIT_PATTERN.match(unit):
853
+ logger.warning("Host unit contains invalid characters: %s", unit)
854
+ return False
855
+
856
+ if unit not in self.allowed_host_units:
857
+ logger.warning("Host unit is not in security.host_units: %s", unit)
858
+ return False
859
+
860
+ return True
861
+
862
+ # ========================================================================
863
+ # Log search and time-filter validation (remote-server-mcp)
864
+ # ========================================================================
865
+
866
+ def validate_search_pattern(self, pattern: str) -> str:
867
+ """Validate a log search pattern and return it unchanged.
868
+
869
+ ``search_service_logs`` filters lines locally in Python, so the pattern
870
+ is data rather than shell syntax. Characters such as quotes, ``!``,
871
+ ``#`` and ``~`` must keep their literal meaning; only length and control
872
+ characters are rejected.
873
+
874
+ Args:
875
+ pattern: Raw search pattern
876
+
877
+ Returns:
878
+ The exact pattern, unchanged
879
+
880
+ Raises:
881
+ ValueError: If the pattern is empty, too long or contains control
882
+ characters
883
+ """
884
+ if not pattern or not isinstance(pattern, str):
885
+ raise ValueError("Search pattern must not be empty")
886
+
887
+ if len(pattern) > MAX_SEARCH_PATTERN_LENGTH:
888
+ raise ValueError(
889
+ f"Search pattern too long ({len(pattern)} chars, "
890
+ f"max {MAX_SEARCH_PATTERN_LENGTH})"
891
+ )
892
+
893
+ if SEARCH_CONTROL_CHARACTER_PATTERN.search(pattern):
894
+ raise ValueError("Search pattern must not contain control characters")
895
+
896
+ return pattern
897
+
898
+ def validate_log_time(self, value: str, field: str = "time") -> bool:
899
+ """Validate a ``docker logs --since/--until`` value.
900
+
901
+ Args:
902
+ value: RFC3339 timestamp, ``YYYY-MM-DD`` date or relative duration
903
+ field: Name used in the warning (``since`` or ``until``)
904
+
905
+ Returns:
906
+ True if Docker can safely receive the value
907
+ """
908
+ if not value or not isinstance(value, str):
909
+ logger.warning(f"Invalid log {field}: {value!r}")
910
+ return False
911
+
912
+ if len(value) > MAX_LOG_TIME_LENGTH:
913
+ logger.warning(f"Log {field} too long: {len(value)} chars")
914
+ return False
915
+
916
+ if not LOG_TIME_PATTERN.match(value):
917
+ logger.warning(f"Invalid log {field} format: {value!r}")
918
+ return False
919
+
920
+ if LOG_DATE_PATTERN.match(value):
921
+ # The pattern only checks the shape; reject impossible dates such
922
+ # as 2026-13-99 instead of passing them on to Docker.
923
+ try:
924
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
925
+ except ValueError:
926
+ logger.warning(f"Invalid log {field} date: {value!r}")
927
+ return False
928
+
929
+ return True
930
+
931
+ # ========================================================================
932
+ # Database query validation (remote-server-mcp)
933
+ # ========================================================================
934
+
935
+ def validate_influxdb_query(self, query: str) -> str | None:
936
+ """
937
+ Validate an InfluxDB v3 SQL query for read-only safety.
938
+
939
+ Args:
940
+ query: SQL query string to validate
941
+
942
+ Returns:
943
+ The query if valid, None if invalid
944
+ """
945
+ if not query or not isinstance(query, str):
946
+ logger.warning("Empty or invalid InfluxDB query")
947
+ return None
948
+
949
+ if len(query) > 5000:
950
+ logger.warning(f"InfluxDB query too long: {len(query)} chars")
951
+ return None
952
+
953
+ if "\x00" in query:
954
+ logger.warning("InfluxDB query contains a null byte")
955
+ return None
956
+
957
+ try:
958
+ statements = [
959
+ statement
960
+ for statement in sqlparse.parse(query)
961
+ if str(statement).strip()
962
+ ]
963
+ except Exception as e: # pragma: no cover - sqlparse is defensive
964
+ logger.warning(f"Could not parse InfluxDB query: {e}")
965
+ return None
966
+
967
+ if len(statements) != 1:
968
+ logger.warning("InfluxDB query must be a single statement")
969
+ return None
970
+
971
+ statement = statements[0]
972
+ if statement.get_type().upper() != "SELECT":
973
+ logger.warning(
974
+ "InfluxDB query must be a SELECT, got %s", statement.get_type()
975
+ )
976
+ return None
977
+
978
+ for token in statement.flatten():
979
+ if token.ttype in sql_tokens.Comment:
980
+ logger.warning("InfluxDB query contains an SQL comment")
981
+ return None
982
+ if (
983
+ token.ttype in sql_tokens.Keyword
984
+ and token.value.upper() in WRITE_KEYWORDS
985
+ ):
986
+ logger.warning("InfluxDB query contains write keyword: %s", token.value)
987
+ return None
988
+
989
+ # A single trailing semicolon is harmless; drop it before sending.
990
+ return query.strip().rstrip(";").rstrip()
991
+
992
+ def validate_prometheus_query(self, query: str) -> str | None:
993
+ """
994
+ Validate a PromQL query for safety.
995
+
996
+ Args:
997
+ query: PromQL expression to validate
998
+
999
+ Returns:
1000
+ The query if valid, None if invalid
1001
+ """
1002
+ if not query or not isinstance(query, str):
1003
+ logger.warning("Empty or Prometheus query")
1004
+ return None
1005
+
1006
+ if len(query) > 5000:
1007
+ logger.warning(f"PromQL query too long: {len(query)} chars")
1008
+ return None
1009
+
1010
+ if "\x00" in query:
1011
+ logger.warning("PromQL query contains a null byte")
1012
+ return None
1013
+
1014
+ # PromQL is sent over HTTP, never through a shell. Quotes, braces and
1015
+ # comparison operators are valid and required for real queries. Only
1016
+ # reject control characters that cannot appear in a PromQL expression.
1017
+ for char in query:
1018
+ if ord(char) < 0x20 and char not in "\n\r\t":
1019
+ logger.warning("PromQL query contains control character: %r", char)
1020
+ return None
1021
+
1022
+ return query
1023
+
1024
+ # ========================================================================
1025
+ # Command safety (merged: disk-health-mcp strict + remote-server-mcp permissive)
1026
+ # ========================================================================
1027
+
1028
+ def is_command_safe(self, command: str) -> bool:
1029
+ """
1030
+ Check whether a catalog command is safe to execute.
1031
+
1032
+ The command must be a canonical argv vector (as rendered by
1033
+ :func:`shlex.join`) whose executable and leading arguments match the
1034
+ explicit allowlist. Unknown executables and hand-built shell syntax are
1035
+ rejected. ``security.allow_generic_commands`` has no effect.
1036
+
1037
+ Args:
1038
+ command: Full command string
1039
+
1040
+ Returns:
1041
+ True if safe
1042
+ """
1043
+ if not isinstance(command, str) or not command.strip():
1044
+ logger.warning("Invalid command: %r", command)
1045
+ return False
1046
+
1047
+ if len(command) > 4096:
1048
+ logger.warning("Command too long: %d chars", len(command))
1049
+ return False
1050
+
1051
+ # The only privileged command in the catalog. The comparison is a full
1052
+ # string equality against a constant built from the argv vector, so no
1053
+ # caller-supplied text can use this branch to reach sudo, and every
1054
+ # dangerous-pattern and argv-shape check below is bypassed only for
1055
+ # this fixed literal.
1056
+ if command == SNAPRAID_STATUS_SUDO_COMMAND:
1057
+ return True
1058
+
1059
+ command_lower = command.lower()
1060
+
1061
+ # Block truly dangerous operations first, before any whitelist check
1062
+ dangerous_patterns = [
1063
+ "sudo",
1064
+ "su ",
1065
+ "passwd",
1066
+ "useradd",
1067
+ "usermod",
1068
+ "userdel",
1069
+ "groupadd",
1070
+ "chmod",
1071
+ "chown",
1072
+ "mount ",
1073
+ "umount",
1074
+ "iptables",
1075
+ "kill -9",
1076
+ "rm -rf /",
1077
+ "mkfs",
1078
+ "fdisk",
1079
+ "/etc/shadow",
1080
+ "/etc/passwd",
1081
+ "wget ",
1082
+ "curl ",
1083
+ "bash ",
1084
+ "sh ",
1085
+ "zsh ",
1086
+ "python ",
1087
+ "python3 ",
1088
+ "perl ",
1089
+ "ruby ",
1090
+ ]
1091
+
1092
+ for pattern in dangerous_patterns:
1093
+ if pattern in command_lower:
1094
+ logger.warning(f"Command contains dangerous pattern: {pattern}")
1095
+ return False
1096
+
1097
+ # Block dd separately (handles "dd " at start or " dd " in middle)
1098
+ if command_lower.startswith("dd ") or " dd " in command_lower:
1099
+ logger.warning("Command contains dangerous pattern: dd")
1100
+ return False
1101
+
1102
+ # Block network exfiltration tools
1103
+ net_tools = ["nc ", "ncat ", "netcat ", "socat ", "telnet "]
1104
+ for tool in net_tools:
1105
+ if tool in command_lower or command_lower.startswith(tool):
1106
+ logger.warning(f"Command contains dangerous pattern: {tool.strip()}")
1107
+ return False
1108
+
1109
+ # Parse the command and require canonical shlex.join output. A string
1110
+ # with bare shell syntax (``;``, ``&&``, a newline, ``>``, ...) does
1111
+ # not round-trip, so only catalog-rendered argv vectors get past this.
1112
+ try:
1113
+ argv = shlex.split(command)
1114
+ except ValueError:
1115
+ logger.warning("Command could not be parsed as an argv vector")
1116
+ return False
1117
+
1118
+ if not argv:
1119
+ return False
1120
+
1121
+ if shlex.join(argv) != command:
1122
+ logger.warning("Command is not a canonical argv vector: %s", command)
1123
+ return False
1124
+
1125
+ for token in argv:
1126
+ if any(ord(char) < 0x20 or ord(char) == 0x7F for char in token):
1127
+ logger.warning(
1128
+ "Command argument contains a control character: %r", token
1129
+ )
1130
+ return False
1131
+ if any(char in SHELL_METACHARACTERS for char in token):
1132
+ logger.warning(
1133
+ "Command argument contains a shell metacharacter: %r", token
1134
+ )
1135
+ return False
1136
+
1137
+ if self._is_safe_compose(argv):
1138
+ return True
1139
+
1140
+ # A prefix match alone is not enough: ``docker inspect`` and
1141
+ # ``smartctl`` both accept dangerous arguments after a safe prefix.
1142
+ shape = self._argument_shape_safe(argv)
1143
+ if shape is not None:
1144
+ if not shape:
1145
+ logger.warning(
1146
+ "Command arguments do not match an allowed shape: %s", command
1147
+ )
1148
+ return shape
1149
+
1150
+ for prefix in ALLOWED_ARGV_PREFIXES:
1151
+ if tuple(argv[: len(prefix)]) == prefix:
1152
+ return True
1153
+
1154
+ logger.warning("Command is not in the allowed operation catalog: %s", command)
1155
+ return False
1156
+
1157
+ @staticmethod
1158
+ def _positive_integer(value: str, maximum: int) -> bool:
1159
+ return value.isdigit() and 1 <= int(value) <= maximum
1160
+
1161
+ @staticmethod
1162
+ def _safe_docker_format(fmt: str) -> bool:
1163
+ """Reject inspect/stats format strings that expose secrets or config."""
1164
+ return not any(part in fmt for part in SENSITIVE_DOCKER_FORMAT_PARTS)
1165
+
1166
+ @staticmethod
1167
+ def _is_container_name(value: str) -> bool:
1168
+ return bool(CONTAINER_NAME_PATTERN.match(value))
1169
+
1170
+ @staticmethod
1171
+ def _is_device_path(value: str) -> bool:
1172
+ if not value.startswith("/dev/") or ".." in PurePosixPath(value).parts:
1173
+ return False
1174
+ return re.fullmatch(r"/dev/[A-Za-z0-9._-]+", value) is not None
1175
+
1176
+ def _path_below_services(self, path: str) -> bool:
1177
+ """Return True when ``path`` is lexically below services_path."""
1178
+ if not path.startswith("/") or "\x00" in path:
1179
+ return False
1180
+ normalised = str(PurePosixPath(path))
1181
+ base = str(PurePosixPath(self.services_base_path))
1182
+ return normalised == base or normalised.startswith(base.rstrip("/") + "/")
1183
+
1184
+ def _safe_container_argv(self, argv: list[str]) -> bool:
1185
+ """Validate every argument of a container runtime command."""
1186
+ if len(argv) < 2:
1187
+ return False
1188
+ subcommand = argv[1]
1189
+ args = argv[2:]
1190
+
1191
+ if subcommand == "ps":
1192
+ return self._safe_ps_argv(args)
1193
+ if subcommand == "logs":
1194
+ return self._safe_logs_argv(args)
1195
+ if subcommand == "inspect":
1196
+ return self._safe_inspect_argv(args)
1197
+ if subcommand == "stats":
1198
+ return self._safe_stats_argv(args)
1199
+ if subcommand in {"port", "restart", "start", "stop"}:
1200
+ if args and args[0] == "--":
1201
+ args = args[1:]
1202
+ return len(args) == 1 and self._is_container_name(args[0])
1203
+ return False
1204
+
1205
+ def _safe_ps_argv(self, args: list[str]) -> bool:
1206
+ index = 0
1207
+ seen_all = False
1208
+ fmt: str | None = None
1209
+ while index < len(args):
1210
+ token = args[index]
1211
+ if token == "-a" and not seen_all:
1212
+ seen_all = True
1213
+ index += 1
1214
+ elif token == "--format" and fmt is None and index + 1 < len(args):
1215
+ fmt = args[index + 1]
1216
+ index += 2
1217
+ elif token == "--filter" and index + 1 < len(args):
1218
+ if args[index + 1] != "health=unhealthy":
1219
+ return False
1220
+ index += 2
1221
+ else:
1222
+ return False
1223
+ return fmt is None or self._safe_docker_format(fmt)
1224
+
1225
+ def _safe_logs_argv(self, args: list[str]) -> bool:
1226
+ index = 0
1227
+ tail: str | None = None
1228
+ seen_timestamps = False
1229
+ seen_since = False
1230
+ seen_until = False
1231
+ service: str | None = None
1232
+ while index < len(args):
1233
+ token = args[index]
1234
+ if token == "--tail" and tail is None and index + 1 < len(args):
1235
+ tail = args[index + 1]
1236
+ if not self._positive_integer(tail, 100000):
1237
+ return False
1238
+ index += 2
1239
+ elif token == "--timestamps" and not seen_timestamps:
1240
+ seen_timestamps = True
1241
+ index += 1
1242
+ elif token == "--since" and not seen_since and index + 1 < len(args):
1243
+ if not self.validate_log_time(args[index + 1], "since"):
1244
+ return False
1245
+ seen_since = True
1246
+ index += 2
1247
+ elif token == "--until" and not seen_until and index + 1 < len(args):
1248
+ if not self.validate_log_time(args[index + 1], "until"):
1249
+ return False
1250
+ seen_until = True
1251
+ index += 2
1252
+ elif token == "--":
1253
+ rest = args[index + 1 :]
1254
+ return (
1255
+ service is None
1256
+ and len(rest) == 1
1257
+ and self._is_container_name(rest[0])
1258
+ and tail is not None
1259
+ )
1260
+ elif token.startswith("-"):
1261
+ return False
1262
+ elif service is None and self._is_container_name(token):
1263
+ service = token
1264
+ index += 1
1265
+ else:
1266
+ return False
1267
+ return tail is not None and service is not None
1268
+
1269
+ def _safe_inspect_argv(self, args: list[str]) -> bool:
1270
+ index = 0
1271
+ fmt: str | None = None
1272
+ service: str | None = None
1273
+ while index < len(args):
1274
+ token = args[index]
1275
+ if token == "--format" and fmt is None and index + 1 < len(args):
1276
+ fmt = args[index + 1]
1277
+ index += 2
1278
+ elif token == "--":
1279
+ rest = args[index + 1 :]
1280
+ return (
1281
+ service is None
1282
+ and fmt is not None
1283
+ and len(rest) == 1
1284
+ and self._is_container_name(rest[0])
1285
+ and self._safe_docker_format(fmt)
1286
+ )
1287
+ elif token.startswith("-"):
1288
+ return False
1289
+ elif service is None and self._is_container_name(token):
1290
+ service = token
1291
+ index += 1
1292
+ else:
1293
+ return False
1294
+ return fmt is not None and service is not None and self._safe_docker_format(fmt)
1295
+
1296
+ def _safe_stats_argv(self, args: list[str]) -> bool:
1297
+ index = 0
1298
+ fmt: str | None = None
1299
+ seen_no_stream = False
1300
+ service: str | None = None
1301
+ while index < len(args):
1302
+ token = args[index]
1303
+ if token == "--no-stream" and not seen_no_stream:
1304
+ seen_no_stream = True
1305
+ index += 1
1306
+ elif token == "--format" and fmt is None and index + 1 < len(args):
1307
+ fmt = args[index + 1]
1308
+ index += 2
1309
+ elif token == "--":
1310
+ rest = args[index + 1 :]
1311
+ return (
1312
+ service is None
1313
+ and seen_no_stream
1314
+ and len(rest) == 1
1315
+ and self._is_container_name(rest[0])
1316
+ and (fmt is None or self._safe_docker_format(fmt))
1317
+ )
1318
+ elif token.startswith("-"):
1319
+ return False
1320
+ elif service is None and self._is_container_name(token):
1321
+ service = token
1322
+ index += 1
1323
+ else:
1324
+ return False
1325
+ return (
1326
+ seen_no_stream
1327
+ and service is not None
1328
+ and (fmt is None or self._safe_docker_format(fmt))
1329
+ )
1330
+
1331
+ def _safe_smartctl_argv(self, argv: list[str]) -> bool:
1332
+ arguments = argv[1:]
1333
+ if arguments == ["--version"]:
1334
+ return True
1335
+
1336
+ device: str | None = None
1337
+ index = 0
1338
+ while index < len(arguments):
1339
+ token = arguments[index]
1340
+ if token in {"-j", "-a", "-i", "-c"}:
1341
+ index += 1
1342
+ elif token == "-n":
1343
+ if index + 1 >= len(arguments) or arguments[index + 1] != "standby":
1344
+ return False
1345
+ index += 2
1346
+ elif token == "-t":
1347
+ if (
1348
+ index + 1 >= len(arguments)
1349
+ or arguments[index + 1] not in SMART_TEST_TYPES
1350
+ ):
1351
+ return False
1352
+ index += 2
1353
+ elif token == "-l":
1354
+ if index + 1 >= len(arguments) or arguments[index + 1] not in {
1355
+ "error",
1356
+ "selftest",
1357
+ }:
1358
+ return False
1359
+ index += 2
1360
+ elif token.startswith("/dev/"):
1361
+ if device is not None or not self.validate_device_name(token[5:]):
1362
+ return False
1363
+ device = token
1364
+ index += 1
1365
+ else:
1366
+ return False
1367
+ return device is not None
1368
+
1369
+ def _argument_shape_safe(self, argv: list[str]) -> bool | None:
1370
+ """Validate the complete argument vector for each command family.
1371
+
1372
+ Returns ``None`` for an executable this method does not know, letting
1373
+ the fixed prefix allowlist decide. Known families are checked against
1374
+ their exact catalog shape, so adding a dangerous argument to an allowed
1375
+ prefix no longer passes the policy.
1376
+ """
1377
+ executable = PurePosixPath(argv[0]).name
1378
+
1379
+ if executable == "echo":
1380
+ return len(argv) >= 2
1381
+ if executable == "uptime":
1382
+ return len(argv) == 1
1383
+ if executable == "free":
1384
+ return argv[1:] == ["-h"]
1385
+ if executable == "df":
1386
+ if argv[1:] == [
1387
+ "-h",
1388
+ "-x",
1389
+ "tmpfs",
1390
+ "-x",
1391
+ "devtmpfs",
1392
+ "-x",
1393
+ "overlay",
1394
+ "-x",
1395
+ "squashfs",
1396
+ ]:
1397
+ return True
1398
+ return (
1399
+ len(argv) == 3
1400
+ and argv[1] == "-h"
1401
+ and self.validate_disk_path(argv[2]) is not None
1402
+ )
1403
+ if executable == "ls":
1404
+ if argv[1:2] != ["-1"]:
1405
+ return False
1406
+ index = 2
1407
+ if len(argv) > index and argv[index] == "-p":
1408
+ index += 1
1409
+ return len(argv) == index + 1 and self._path_below_services(argv[index])
1410
+ if executable == "test":
1411
+ return (
1412
+ len(argv) == 3
1413
+ and argv[1] == "-d"
1414
+ and self._path_below_services(argv[2])
1415
+ )
1416
+ if executable in CONTAINER_RUNTIMES:
1417
+ return self._safe_container_argv(argv)
1418
+ if executable == "systemctl":
1419
+ return (
1420
+ len(argv) == 5
1421
+ and argv[1:4] == ["status", "--no-pager", "--full"]
1422
+ and self.validate_host_unit(argv[4])
1423
+ )
1424
+ if executable == "journalctl":
1425
+ return (
1426
+ len(argv) == 8
1427
+ and argv[1] == "-u"
1428
+ and self.validate_host_unit(argv[2])
1429
+ and argv[3] == "-n"
1430
+ and self._positive_integer(argv[4], 1000)
1431
+ and argv[5:] == ["--no-pager", "--output", "short-iso"]
1432
+ )
1433
+ if executable == "lsblk":
1434
+ if len(argv) < 2 or argv[1] != "-d":
1435
+ return False
1436
+ index = 2
1437
+ seen_json = False
1438
+ seen_columns = False
1439
+ while index < len(argv):
1440
+ token = argv[index]
1441
+ if token == "--json" and not seen_json:
1442
+ seen_json = True
1443
+ index += 1
1444
+ elif token == "-o" and not seen_columns and index + 1 < len(argv):
1445
+ if re.fullmatch(r"[A-Za-z0-9_,%-]+", argv[index + 1]) is None:
1446
+ return False
1447
+ seen_columns = True
1448
+ index += 2
1449
+ else:
1450
+ return False
1451
+ return True
1452
+ if executable == "iostat":
1453
+ return argv[1:] in (["-x", "1", "1"], ["-d", "1", "1"])
1454
+ if executable == "zpool":
1455
+ if len(argv) < 2:
1456
+ return False
1457
+ subcommand = argv[1]
1458
+ if subcommand == "status":
1459
+ return len(argv) == 2 or (len(argv) == 3 and argv[2] == "-x")
1460
+ if subcommand == "list":
1461
+ return len(argv) == 2 or (
1462
+ len(argv) == 4
1463
+ and argv[2] == "-o"
1464
+ and re.fullmatch(r"[a-z,]+", argv[3]) is not None
1465
+ )
1466
+ if subcommand == "iostat":
1467
+ return len(argv) == 2
1468
+ return False
1469
+ if executable == "zfs":
1470
+ if len(argv) < 2:
1471
+ return False
1472
+ if argv[1] == "list":
1473
+ return len(argv) == 2 or (
1474
+ len(argv) == 4
1475
+ and argv[2] == "-o"
1476
+ and re.fullmatch(r"[a-z,]+", argv[3]) is not None
1477
+ )
1478
+ if argv[1] == "get":
1479
+ return len(argv) == 2 or (
1480
+ len(argv) == 3
1481
+ and re.fullmatch(r"[A-Za-z0-9_.:-]+", argv[2]) is not None
1482
+ )
1483
+ return False
1484
+ if executable == "cat":
1485
+ return argv == ["cat", "/proc/mdstat"]
1486
+ if executable == "mdadm":
1487
+ if argv == ["mdadm", "--detail", "--scan", "--verbose"]:
1488
+ return True
1489
+ if len(argv) == 3 and argv[1] in {"--detail", "--examine"}:
1490
+ return self._is_device_path(argv[2])
1491
+ return False
1492
+ if executable == "snapraid":
1493
+ return argv == ["snapraid", "status"]
1494
+ if executable == "findmnt":
1495
+ return tuple(argv[1:]) in (
1496
+ ("--real", "-o", "TARGET,SOURCE,FSTYPE,SIZE,USED,AVAIL,USE%"),
1497
+ ("-t", "fuse.mergerfs", "-o", "TARGET,SOURCE,FSTYPE,OPTIONS"),
1498
+ )
1499
+ if executable == "smartctl":
1500
+ return self._safe_smartctl_argv(argv)
1501
+ if executable == "nvme":
1502
+ return (
1503
+ len(argv) == 3
1504
+ and argv[1]
1505
+ in {
1506
+ "smart-log",
1507
+ "smart-log-add",
1508
+ "id-ctrl",
1509
+ "id-ns",
1510
+ "error-log",
1511
+ }
1512
+ and argv[2].startswith("/dev/")
1513
+ and self.validate_device_name(argv[2][5:])
1514
+ )
1515
+ return None
1516
+
1517
+ def _is_safe_compose(self, argv: list[str]) -> bool:
1518
+ """Validate a ``<runtime> compose --project-directory <dir> <cmd>`` argv.
1519
+
1520
+ Compose operations carry a variable service directory, so they cannot be
1521
+ expressed as a fixed prefix. The directory must stay below
1522
+ ``security.services_path`` and only the catalog's compose subcommands
1523
+ and arguments are accepted.
1524
+ """
1525
+ if len(argv) < 5:
1526
+ return False
1527
+
1528
+ runtime, compose, flag, directory, subcommand = argv[:5]
1529
+ if runtime not in CONTAINER_RUNTIMES or compose != "compose":
1530
+ return False
1531
+ if flag != "--project-directory" or not directory.startswith("/"):
1532
+ return False
1533
+ if ".." in directory.split("/"):
1534
+ return False
1535
+
1536
+ base = self.services_base_path.rstrip("/") or "/"
1537
+ inside_base = directory == base or directory.startswith(
1538
+ base if base == "/" else base + "/"
1539
+ )
1540
+ if not inside_base:
1541
+ logger.warning(
1542
+ "Compose directory %r is outside services_path %r",
1543
+ directory,
1544
+ base,
1545
+ )
1546
+ return False
1547
+
1548
+ allowed = COMPOSE_ARGUMENTS.get(subcommand)
1549
+ if allowed is None:
1550
+ logger.warning("Compose subcommand is not in the catalog: %s", subcommand)
1551
+ return False
1552
+ return tuple(argv[5:]) in allowed