runspec-linux 0.17.0__tar.gz → 0.19.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/CHANGELOG.md +53 -0
  2. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/PKG-INFO +15 -12
  3. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/README.md +13 -10
  4. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/pyproject.toml +2 -10
  5. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/__init__.py +2 -13
  6. runspec_linux-0.19.0/runspec_linux/_escalation.py +42 -0
  7. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/cron.py +3 -0
  8. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/fs/cli.py +2 -0
  9. runspec_linux-0.19.0/runspec_linux/packages.py +27 -0
  10. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/perf.py +1 -20
  11. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/runspec.toml +34 -253
  12. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/venvs.py +8 -0
  13. runspec_linux-0.19.0/tests/test_escalation.py +56 -0
  14. runspec_linux-0.17.0/runspec_linux/_escalation.py +0 -20
  15. runspec_linux-0.17.0/runspec_linux/packages.py +0 -99
  16. runspec_linux-0.17.0/runspec_linux/power.py +0 -20
  17. runspec_linux-0.17.0/runspec_linux/sudoers.py +0 -23
  18. runspec_linux-0.17.0/tests/test_escalation.py +0 -34
  19. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/.gitignore +0 -0
  20. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/commands.py +0 -0
  21. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/containers.py +0 -0
  22. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/files.py +0 -0
  23. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/fs/__init__.py +0 -0
  24. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/fs/_dispatch.py +0 -0
  25. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/fs/_worker.py +0 -0
  26. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/investigate.py +0 -0
  27. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/logs.py +0 -0
  28. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/nc_command.py +0 -0
  29. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/network.py +0 -0
  30. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/security.py +0 -0
  31. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/services.py +0 -0
  32. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/system_info.py +0 -0
  33. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/runspec_linux/webops.py +0 -0
  34. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/tests/__init__.py +0 -0
  35. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/tests/test_files.py +0 -0
  36. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/tests/test_fs.py +0 -0
  37. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/tests/test_inventory.py +0 -0
  38. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/tests/test_nc_send.py +0 -0
  39. {runspec_linux-0.17.0 → runspec_linux-0.19.0}/tests/test_webops.py +0 -0
@@ -1,5 +1,58 @@
1
1
  # runspec-linux Changelog
2
2
 
3
+ ## [0.19.0] — 2026-09-30
4
+
5
+ **No root in runspec-linux.** Everything here now runs as an unprivileged
6
+ account: you, or the service account that owns a shared venv (runspec >= 0.68.0
7
+ runs a shared venv's tools as its owner). The tools that need root moved to the
8
+ new **`runspec-linux-root`** package, for a root-owned admin venv.
9
+
10
+ ### Removed (moved to `runspec-linux-root`)
11
+
12
+ - `list-upgrades`, `upgrade-packages`, `install-package`, `remove-package`,
13
+ `autoremove-packages`, `set-sysctl`, `reboot-host`, `enable-passwordless-sudo`.
14
+ Their helpers stay in `runspec-linux-core`. The `runspec_linux` re-exports of
15
+ `install_package`, `list_upgrades`, `reboot_host`, `remove_package`,
16
+ `upgrade_packages` and `enable_passwordless_sudo` are gone: import them from
17
+ `runspec_linux_core`.
18
+ - **`--sudo`** ("run as root") on `restart-service`, `tail-log`, `search-log`,
19
+ `journalctl`, `list-containers`, `container-logs`, `restart-container`,
20
+ `service-detail` and `open-ports`.
21
+
22
+ ### Changed
23
+
24
+ - **`--run-as root` is refused** on every tool that takes `--run-as` (the ones
25
+ above, the venv tools, `filesystem` and `cron`). It prints a JSON error naming
26
+ `runspec-linux-root` and exits 2. `--run-as` still selects any unprivileged
27
+ account.
28
+ - **`--run-as` naming the account you already are does nothing** (from
29
+ runspec-linux-core 0.13.0). A service account running its own venv's tools with
30
+ `--run-as <itself>` used to need a sudoers rule letting it sudo to itself.
31
+
32
+ ### Upgrading
33
+
34
+ Install `runspec-linux-root` into a separate root-owned venv (see its README).
35
+ If an existing venv has both packages, upgrade `runspec-linux` first: pip
36
+ removes the old copies of the moved scripts when it upgrades it.
37
+
38
+ ## [0.18.0] — 2026-09-29
39
+
40
+ ### Added
41
+
42
+ - **`create-venv` sets up the logs inbox** (`logs/inbox`, `01777`, owned by the
43
+ venv's owner). Operators' and root's runs of the venv's runnables then reach
44
+ its history with no group admin (runspec >= 0.66). `logs/` is owner-only
45
+ (`0755`) unless `--group` is given. `--no-inbox` keeps the pre-inbox `2775`
46
+ layout and writes a `.no-inbox` opt-out marker.
47
+ - **`share-venv-logs` sets up the logs inbox** on an existing venv (run it with
48
+ `--run-as <owner>`). `--group` still adds the `2775` setgid group share, and
49
+ `--no-inbox` does only the group share, as before.
50
+
51
+ ### Changed
52
+
53
+ - **`install-into-venv` keeps `logs/`'s mode** instead of forcing `2775`.
54
+ - Requires `runspec-linux-core >= 0.12.0`.
55
+
3
56
  ## [0.17.0] — 2026-09-26
4
57
 
5
58
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runspec-linux
3
- Version: 0.17.0
3
+ Version: 0.19.0
4
4
  Summary: Linux system admin runnables for runspec
5
5
  Project-URL: Documentation, https://runspec.app/
6
6
  Project-URL: Source, https://github.com/jasonfinestone/runspec/tree/main/packages/python/runspec-linux
@@ -9,7 +9,7 @@ Project-URL: Issues, https://github.com/jasonfinestone/runspec/issues
9
9
  Keywords: linux,ops,runnable,runspec,ssh,sysadmin,venv
10
10
  Requires-Python: >=3.10
11
11
  Requires-Dist: runspec-fs-core>=0.5.0
12
- Requires-Dist: runspec-linux-core>=0.11.0
12
+ Requires-Dist: runspec-linux-core>=0.13.0
13
13
  Requires-Dist: runspec-webops-core>=0.3.0
14
14
  Requires-Dist: runspec>=0.50.1
15
15
  Provides-Extra: dev
@@ -27,10 +27,15 @@ the runnables are discoverable by `runspec local`, `runspec serve` (MCP), and
27
27
  runspec-console.
28
28
 
29
29
  All runnables emit JSON on stdout. Read-only runnables are `autonomy =
30
- "autonomous"`; state-changing ones are `autonomy = "confirm"`. The privileged
31
- runnables declare `run_as = "root"`, so runspec-console escalates them via
32
- `sudo` — run `enable-passwordless-sudo` once to install a `visudo`-validated
33
- `/etc/sudoers.d` drop-in so they run without a password prompt.
30
+ "autonomous"`; state-changing ones are `autonomy = "confirm"`.
31
+
32
+ **Nothing here runs as root.** A tool runs as whoever owns its venv (runspec
33
+ >= 0.68.0): you in a personal venv, or the service account in a shared one.
34
+ `--run-as` picks another unprivileged account and refuses `root`. The tools that
35
+ need root (package upgrades/installs, `reboot-host`, `set-sysctl`,
36
+ `enable-passwordless-sudo`) are in
37
+ [`runspec-linux-root`](https://pypi.org/project/runspec-linux-root/), for a
38
+ root-owned admin venv.
34
39
 
35
40
  ## Install
36
41
 
@@ -47,13 +52,11 @@ pip install runspec-linux
47
52
  | Services | `list-services`, `check-service`, `restart-service`, `service-detail`, `failed-units` |
48
53
  | Logs | `tail-log`, `search-log`, `journalctl` |
49
54
  | Network | `network-interfaces`, `ping-host`, `check-port`, `show-connections`, `trace-route`, `route-table`, `socket-stats`, `dns-config`, `open-ports` |
50
- | Kernel params | `get-sysctl`, `set-sysctl` |
55
+ | Kernel params | `get-sysctl` |
51
56
  | Files | `find-large-files`, `backup-files`, `owning-package` |
52
57
  | Sessions | `last-logins`, `who` |
53
58
  | Containers | `list-containers`, `container-logs`, `restart-container` |
54
- | Packages | `list-packages`, `list-upgrades`, `upgrade-packages`, `install-package`, `remove-package`, `autoremove-packages`, `list-autoremovable` |
55
- | Power | `reboot-host` |
56
- | Sudo bootstrap | `enable-passwordless-sudo` |
59
+ | Packages (read-only) | `list-packages`, `list-autoremovable` |
57
60
  | Python venvs | `create-venv`, `install-into-venv`, `configure-pip` |
58
61
  | TCP | `nc-command` |
59
62
  | Commands | `which` |
@@ -61,13 +64,13 @@ pip install runspec-linux
61
64
  | Filesystem | `filesystem` (multi-verb, backed by `runspec-fs-core`) |
62
65
  | Web / TLS / API | `cert-check`, `cert-chain`, `tls-info`, `http-check`, `http-headers`, `redirect-trace`, `dns-lookup`, `api-spec` |
63
66
 
64
- The package-management runnables auto-detect apt / dnf / yum / zypper / pacman.
67
+ The package runnables auto-detect apt / dnf / yum / zypper / pacman.
65
68
  The web/TLS/API probes are pure HTTP (shared with `runspec-windows` via
66
69
  `runspec-webops-core`) and run anywhere.
67
70
 
68
71
  ```
69
72
  system-info
70
- list-upgrades
73
+ list-packages --filter nginx
71
74
  check-port --host db-01 --port 5432
72
75
  cert-check --url https://internal.example.com
73
76
  ```
@@ -7,10 +7,15 @@ the runnables are discoverable by `runspec local`, `runspec serve` (MCP), and
7
7
  runspec-console.
8
8
 
9
9
  All runnables emit JSON on stdout. Read-only runnables are `autonomy =
10
- "autonomous"`; state-changing ones are `autonomy = "confirm"`. The privileged
11
- runnables declare `run_as = "root"`, so runspec-console escalates them via
12
- `sudo` — run `enable-passwordless-sudo` once to install a `visudo`-validated
13
- `/etc/sudoers.d` drop-in so they run without a password prompt.
10
+ "autonomous"`; state-changing ones are `autonomy = "confirm"`.
11
+
12
+ **Nothing here runs as root.** A tool runs as whoever owns its venv (runspec
13
+ >= 0.68.0): you in a personal venv, or the service account in a shared one.
14
+ `--run-as` picks another unprivileged account and refuses `root`. The tools that
15
+ need root (package upgrades/installs, `reboot-host`, `set-sysctl`,
16
+ `enable-passwordless-sudo`) are in
17
+ [`runspec-linux-root`](https://pypi.org/project/runspec-linux-root/), for a
18
+ root-owned admin venv.
14
19
 
15
20
  ## Install
16
21
 
@@ -27,13 +32,11 @@ pip install runspec-linux
27
32
  | Services | `list-services`, `check-service`, `restart-service`, `service-detail`, `failed-units` |
28
33
  | Logs | `tail-log`, `search-log`, `journalctl` |
29
34
  | Network | `network-interfaces`, `ping-host`, `check-port`, `show-connections`, `trace-route`, `route-table`, `socket-stats`, `dns-config`, `open-ports` |
30
- | Kernel params | `get-sysctl`, `set-sysctl` |
35
+ | Kernel params | `get-sysctl` |
31
36
  | Files | `find-large-files`, `backup-files`, `owning-package` |
32
37
  | Sessions | `last-logins`, `who` |
33
38
  | Containers | `list-containers`, `container-logs`, `restart-container` |
34
- | Packages | `list-packages`, `list-upgrades`, `upgrade-packages`, `install-package`, `remove-package`, `autoremove-packages`, `list-autoremovable` |
35
- | Power | `reboot-host` |
36
- | Sudo bootstrap | `enable-passwordless-sudo` |
39
+ | Packages (read-only) | `list-packages`, `list-autoremovable` |
37
40
  | Python venvs | `create-venv`, `install-into-venv`, `configure-pip` |
38
41
  | TCP | `nc-command` |
39
42
  | Commands | `which` |
@@ -41,13 +44,13 @@ pip install runspec-linux
41
44
  | Filesystem | `filesystem` (multi-verb, backed by `runspec-fs-core`) |
42
45
  | Web / TLS / API | `cert-check`, `cert-chain`, `tls-info`, `http-check`, `http-headers`, `redirect-trace`, `dns-lookup`, `api-spec` |
43
46
 
44
- The package-management runnables auto-detect apt / dnf / yum / zypper / pacman.
47
+ The package runnables auto-detect apt / dnf / yum / zypper / pacman.
45
48
  The web/TLS/API probes are pure HTTP (shared with `runspec-windows` via
46
49
  `runspec-webops-core`) and run anywhere.
47
50
 
48
51
  ```
49
52
  system-info
50
- list-upgrades
53
+ list-packages --filter nginx
51
54
  check-port --host db-01 --port 5432
52
55
  cert-check --url https://internal.example.com
53
56
  ```
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-linux"
7
- version = "0.17.0"
7
+ version = "0.19.0"
8
8
  requires-python = ">=3.10"
9
9
  description = "Linux system admin runnables for runspec"
10
10
  readme = "README.md"
11
11
  keywords = ["runspec", "runnable", "linux", "sysadmin", "ops", "ssh", "venv"]
12
12
  dependencies = [
13
13
  "runspec>=0.50.1",
14
- "runspec-linux-core>=0.11.0",
14
+ "runspec-linux-core>=0.13.0",
15
15
  "runspec-webops-core>=0.3.0",
16
16
  "runspec-fs-core>=0.5.0",
17
17
  ]
@@ -43,7 +43,6 @@ socket-stats = "runspec_linux.network:main_socket_stats"
43
43
  dns-config = "runspec_linux.network:main_dns_config"
44
44
  network-interfaces = "runspec_linux.network:main_network_interfaces"
45
45
  get-sysctl = "runspec_linux.perf:main_get_sysctl"
46
- set-sysctl = "runspec_linux.perf:main_set_sysctl"
47
46
  cpu-info = "runspec_linux.perf:main_cpu_info"
48
47
  pressure = "runspec_linux.perf:main_pressure"
49
48
  service-detail = "runspec_linux.investigate:main_service_detail"
@@ -58,14 +57,7 @@ list-containers = "runspec_linux.containers:main_list_containers"
58
57
  container-logs = "runspec_linux.containers:main_container_logs"
59
58
  restart-container = "runspec_linux.containers:main_restart_container"
60
59
  list-packages = "runspec_linux.packages:main_list_packages"
61
- list-upgrades = "runspec_linux.packages:main_list_upgrades"
62
- upgrade-packages = "runspec_linux.packages:main_upgrade_packages"
63
- install-package = "runspec_linux.packages:main_install_package"
64
- remove-package = "runspec_linux.packages:main_remove_package"
65
- autoremove-packages = "runspec_linux.packages:main_autoremove_packages"
66
60
  list-autoremovable = "runspec_linux.packages:main_list_autoremovable"
67
- reboot-host = "runspec_linux.power:main_reboot_host"
68
- enable-passwordless-sudo = "runspec_linux.sudoers:main_enable_passwordless_sudo"
69
61
  create-venv = "runspec_linux.venvs:main_create_venv"
70
62
  install-into-venv = "runspec_linux.venvs:main_install_into_venv"
71
63
  configure-pip = "runspec_linux.venvs:main_configure_pip"
@@ -4,7 +4,8 @@ The pure logic lives in the dependency-free ``runspec-linux-core`` package; the
4
4
  modules here wrap each helper in a runspec runnable. The helper functions are
5
5
  re-exported for convenience and back-compat (e.g. ``from runspec_linux import
6
6
  nc_send``). To import the helpers *without* surfacing any runnables in a venv,
7
- depend on ``runspec-linux-core`` directly instead.
7
+ depend on ``runspec-linux-core`` directly instead. Tools that need root live in
8
+ ``runspec-linux-root``; their helpers are in ``runspec-linux-core``.
8
9
  """
9
10
 
10
11
  from runspec_linux_core import (
@@ -19,10 +20,8 @@ from runspec_linux_core import (
19
20
  create_venv,
20
21
  detect_manager,
21
22
  disk_usage,
22
- enable_passwordless_sudo,
23
23
  find_large_files,
24
24
  install_into_venv,
25
- install_package,
26
25
  journalctl,
27
26
  last_logins,
28
27
  list_containers,
@@ -30,19 +29,15 @@ from runspec_linux_core import (
30
29
  list_packages,
31
30
  list_processes,
32
31
  list_services,
33
- list_upgrades,
34
32
  nc_send,
35
33
  ping_host,
36
- reboot_host,
37
34
  remove_cron,
38
- remove_package,
39
35
  restart_container,
40
36
  restart_service,
41
37
  search_log,
42
38
  show_connections,
43
39
  system_info,
44
40
  tail_log,
45
- upgrade_packages,
46
41
  which,
47
42
  who,
48
43
  )
@@ -59,10 +54,8 @@ __all__ = [
59
54
  "create_venv",
60
55
  "detect_manager",
61
56
  "disk_usage",
62
- "enable_passwordless_sudo",
63
57
  "find_large_files",
64
58
  "install_into_venv",
65
- "install_package",
66
59
  "journalctl",
67
60
  "last_logins",
68
61
  "list_containers",
@@ -70,19 +63,15 @@ __all__ = [
70
63
  "list_packages",
71
64
  "list_processes",
72
65
  "list_services",
73
- "list_upgrades",
74
66
  "nc_send",
75
67
  "ping_host",
76
- "reboot_host",
77
68
  "remove_cron",
78
- "remove_package",
79
69
  "restart_container",
80
70
  "restart_service",
81
71
  "search_log",
82
72
  "show_connections",
83
73
  "system_info",
84
74
  "tail_log",
85
- "upgrade_packages",
86
75
  "which",
87
76
  "who",
88
77
  ]
@@ -0,0 +1,42 @@
1
+ """Shared wrapper helper for the ``--run-as`` / ``--become-method`` escalation args.
2
+
3
+ runspec-linux never escalates to root: tools that need root live in the separate
4
+ ``runspec-linux-root`` package, installed into a root-owned venv. ``--run-as``
5
+ picks an unprivileged account (typically the service account that owns a shared
6
+ venv); a root target is refused before anything runs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from typing import Any
14
+
15
+ ROOT_REFUSAL = "runspec-linux does not run anything as root — root tools live in runspec-linux-root"
16
+
17
+
18
+ def is_root(name: str) -> bool:
19
+ """True when ``name`` names uid 0 (``root``, ``0`` or any alias)."""
20
+ if name in ("root", "0"):
21
+ return True
22
+ try:
23
+ import pwd
24
+
25
+ return pwd.getpwnam(name).pw_uid == 0
26
+ except (ImportError, KeyError):
27
+ return False
28
+
29
+
30
+ def refuse_root(run_as: str | None) -> None:
31
+ """Print a JSON error and exit 2 when ``run_as`` targets root."""
32
+ if run_as and is_root(run_as):
33
+ print(json.dumps({"error": f"--run-as {run_as}: {ROOT_REFUSAL}"}))
34
+ sys.exit(2)
35
+
36
+
37
+ def resolve_escalation(spec: Any) -> tuple[str | None, str]:
38
+ """Return ``(run_as, become_method)`` from a spec carrying the escalation args
39
+ (``None`` = no escalation, runs as the caller). Exits on a root target."""
40
+ run_as = spec.run_as.value or None
41
+ refuse_root(run_as)
42
+ return run_as, spec.become_method.value
@@ -23,6 +23,8 @@ import runspec as rs
23
23
  from runspec_linux_core.cron import add_cron, clear_cron, list_cron, remove_cron
24
24
  from runspec_linux_core.errors import CommandError, ToolNotFoundError
25
25
 
26
+ from runspec_linux._escalation import refuse_root
27
+
26
28
 
27
29
  def _emit(payload: dict[str, Any]) -> None:
28
30
  print(json.dumps(payload, default=str))
@@ -31,6 +33,7 @@ def _emit(payload: dict[str, Any]) -> None:
31
33
  def _run(spec: rs.RunSpec) -> dict[str, Any]:
32
34
  command = spec.runspec_command
33
35
  run_as = spec.run_as.value or None
36
+ refuse_root(run_as)
34
37
  become_method = spec.become_method.value
35
38
 
36
39
  if command == "list":
@@ -26,6 +26,7 @@ from typing import Any
26
26
  import runspec as rs
27
27
  from runspec_fs_core import BecomeError, FsCoreError, build_become, current_user, escalation_supported
28
28
 
29
+ from runspec_linux._escalation import refuse_root
29
30
  from runspec_linux.fs._dispatch import run_verb
30
31
 
31
32
 
@@ -115,6 +116,7 @@ def _run_as_worker(run_as: str, method: str, verb: str, kwargs: dict[str, Any])
115
116
  def main() -> None:
116
117
  spec = rs.parse("filesystem")
117
118
  run_as = spec.run_as.value
119
+ refuse_root(run_as or None)
118
120
  method = spec.become_method.value
119
121
  try:
120
122
  kwargs = _verb_kwargs(spec)
@@ -0,0 +1,27 @@
1
+ import json
2
+ import sys
3
+
4
+ import runspec as rs
5
+ from runspec_linux_core import list_autoremovable, list_packages
6
+
7
+
8
+ def main_list_packages() -> None:
9
+ spec = rs.parse("list-packages")
10
+ name_filter = spec.filter.value
11
+ limit = spec.limit.value
12
+ try:
13
+ print(json.dumps(list_packages(name_filter, limit, reason=spec.reason.value)))
14
+ except Exception as e:
15
+ # Includes ToolNotFoundError (no package manager) and CommandError.
16
+ print(json.dumps({"error": str(e)}))
17
+ sys.exit(1)
18
+
19
+
20
+ def main_list_autoremovable() -> None:
21
+ spec = rs.parse("list-autoremovable")
22
+ try:
23
+ print(json.dumps(list_autoremovable(purge=spec.purge.value)))
24
+ except Exception as e:
25
+ # Includes CommandError (yum, failed listing) and ToolNotFoundError.
26
+ print(json.dumps({"error": str(e)}))
27
+ sys.exit(1)
@@ -4,10 +4,7 @@ import json
4
4
  import sys
5
5
 
6
6
  import runspec as rs
7
- from runspec_linux_core import cpu_info, get_sysctl, pressure, set_sysctl
8
- from runspec_linux_core.errors import CommandError
9
-
10
- from runspec_linux._escalation import resolve_escalation
7
+ from runspec_linux_core import cpu_info, get_sysctl, pressure
11
8
 
12
9
 
13
10
  def main_get_sysctl() -> None:
@@ -20,22 +17,6 @@ def main_get_sysctl() -> None:
20
17
  sys.exit(1)
21
18
 
22
19
 
23
- def main_set_sysctl() -> None:
24
- spec = rs.parse("set-sysctl")
25
- key = spec.key.value
26
- value = spec.value.value
27
- persist = spec.persist.value
28
- run_as, method = resolve_escalation(spec)
29
- try:
30
- print(json.dumps(set_sysctl(key, value, persist=persist, run_as=run_as, become_method=method)))
31
- except CommandError as e:
32
- print(json.dumps({"key": key, "applied": False, "error": str(e)}))
33
- sys.exit(1)
34
- except Exception as e:
35
- print(json.dumps({"error": str(e)}))
36
- sys.exit(1)
37
-
38
-
39
20
  def main_cpu_info() -> None:
40
21
  rs.parse("cpu-info")
41
22
  try:
@@ -96,24 +96,15 @@ description = "Service name to restart"
96
96
  # Escalation: restarting a system service needs privilege.
97
97
  [restart-service.args.run-as]
98
98
  type = "str"
99
- description = "Run as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
99
+ description = "Run as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
100
100
  required = false
101
101
 
102
- [restart-service.args.sudo]
103
- type = "flag"
104
- description = "Run as root (via sudo). Mutually exclusive with --run-as."
105
- default = false
106
-
107
102
  [restart-service.args.become-method]
108
103
  type = "choice"
109
104
  options = ["sudo", "su"]
110
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
105
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
111
106
  default = "sudo"
112
107
 
113
- [restart-service.groups.escalation]
114
- exclusive = true
115
- args = ["run-as", "sudo"]
116
-
117
108
  # ── Logs ──────────────────────────────────────────────────────────────────────
118
109
 
119
110
  [tail-log]
@@ -133,27 +124,18 @@ short = "-n"
133
124
  description = "Number of lines to return"
134
125
  default = 50
135
126
 
136
- # Escalation: read a root-owned / service-account-owned log (tail -n under sudo/su).
127
+ # Escalation: read a service-account-owned log (tail -n under sudo/su).
137
128
  [tail-log.args.run-as]
138
129
  type = "str"
139
- description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
130
+ description = "Read as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
140
131
  required = false
141
132
 
142
- [tail-log.args.sudo]
143
- type = "flag"
144
- description = "Read as root (via sudo). Mutually exclusive with --run-as."
145
- default = false
146
-
147
133
  [tail-log.args.become-method]
148
134
  type = "choice"
149
135
  options = ["sudo", "su"]
150
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
136
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
151
137
  default = "sudo"
152
138
 
153
- [tail-log.groups.escalation]
154
- exclusive = true
155
- args = ["run-as", "sudo"]
156
-
157
139
  [search-log]
158
140
  serve = ["local"]
159
141
  description = "Search a log file for lines matching a pattern"
@@ -176,27 +158,18 @@ short = "-n"
176
158
  description = "Maximum number of matching lines to return"
177
159
  default = 100
178
160
 
179
- # Escalation: search a root-owned / service-account-owned log (cat under sudo/su).
161
+ # Escalation: search a service-account-owned log (cat under sudo/su).
180
162
  [search-log.args.run-as]
181
163
  type = "str"
182
- description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
164
+ description = "Read as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
183
165
  required = false
184
166
 
185
- [search-log.args.sudo]
186
- type = "flag"
187
- description = "Read as root (via sudo). Mutually exclusive with --run-as."
188
- default = false
189
-
190
167
  [search-log.args.become-method]
191
168
  type = "choice"
192
169
  options = ["sudo", "su"]
193
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
170
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
194
171
  default = "sudo"
195
172
 
196
- [search-log.groups.escalation]
197
- exclusive = true
198
- args = ["run-as", "sudo"]
199
-
200
173
  [journalctl]
201
174
  serve = ["local"]
202
175
  description = "Fetch recent journal entries for a systemd unit (systemd-only)"
@@ -222,24 +195,15 @@ required = false
222
195
  # Escalation: read a protected unit's journal (outside the systemd-journal group).
223
196
  [journalctl.args.run-as]
224
197
  type = "str"
225
- description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
198
+ description = "Read as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
226
199
  required = false
227
200
 
228
- [journalctl.args.sudo]
229
- type = "flag"
230
- description = "Read as root (via sudo). Mutually exclusive with --run-as."
231
- default = false
232
-
233
201
  [journalctl.args.become-method]
234
202
  type = "choice"
235
203
  options = ["sudo", "su"]
236
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
204
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
237
205
  default = "sudo"
238
206
 
239
- [journalctl.groups.escalation]
240
- exclusive = true
241
- args = ["run-as", "sudo"]
242
-
243
207
  # ── Network ───────────────────────────────────────────────────────────────────
244
208
 
245
209
  [ping-host]
@@ -375,24 +339,15 @@ default = false
375
339
  # Escalation: the Docker daemon socket is usually root/docker-group owned.
376
340
  [list-containers.args.run-as]
377
341
  type = "str"
378
- description = "Run docker as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
342
+ description = "Run docker as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
379
343
  required = false
380
344
 
381
- [list-containers.args.sudo]
382
- type = "flag"
383
- description = "Run docker as root (via sudo). Mutually exclusive with --run-as."
384
- default = false
385
-
386
345
  [list-containers.args.become-method]
387
346
  type = "choice"
388
347
  options = ["sudo", "su"]
389
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
348
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
390
349
  default = "sudo"
391
350
 
392
- [list-containers.groups.escalation]
393
- exclusive = true
394
- args = ["run-as", "sudo"]
395
-
396
351
  [container-logs]
397
352
  serve = ["local"]
398
353
  description = "Fetch recent log output from a Docker container"
@@ -413,24 +368,15 @@ default = 50
413
368
  # Escalation: the Docker daemon socket is usually root/docker-group owned.
414
369
  [container-logs.args.run-as]
415
370
  type = "str"
416
- description = "Run docker as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
371
+ description = "Run docker as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
417
372
  required = false
418
373
 
419
- [container-logs.args.sudo]
420
- type = "flag"
421
- description = "Run docker as root (via sudo). Mutually exclusive with --run-as."
422
- default = false
423
-
424
374
  [container-logs.args.become-method]
425
375
  type = "choice"
426
376
  options = ["sudo", "su"]
427
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
377
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
428
378
  default = "sudo"
429
379
 
430
- [container-logs.groups.escalation]
431
- exclusive = true
432
- args = ["run-as", "sudo"]
433
-
434
380
  [restart-container]
435
381
  serve = ["local"]
436
382
  description = "Restart a Docker container"
@@ -445,24 +391,15 @@ description = "Container name or ID"
445
391
  # Escalation: the Docker daemon socket is usually root/docker-group owned.
446
392
  [restart-container.args.run-as]
447
393
  type = "str"
448
- description = "Run docker as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
394
+ description = "Run docker as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
449
395
  required = false
450
396
 
451
- [restart-container.args.sudo]
452
- type = "flag"
453
- description = "Run docker as root (via sudo). Mutually exclusive with --run-as."
454
- default = false
455
-
456
397
  [restart-container.args.become-method]
457
398
  type = "choice"
458
399
  options = ["sudo", "su"]
459
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
400
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
460
401
  default = "sudo"
461
402
 
462
- [restart-container.groups.escalation]
463
- exclusive = true
464
- args = ["run-as", "sudo"]
465
-
466
403
  # ── Packages ──────────────────────────────────────────────────────────────────
467
404
 
468
405
  [list-packages]
@@ -488,54 +425,6 @@ type = "flag"
488
425
  description = "Also say whether each package was installed by name (manual) or as a dependency (auto) — autoremove only ever removes auto packages"
489
426
  default = false
490
427
 
491
- [list-upgrades]
492
- serve = ["local"]
493
- description = "List packages with an available upgrade (refreshes the package index first)"
494
- autonomy = "autonomous"
495
- output = "json"
496
- run_as = "root"
497
-
498
- [list-upgrades.args.no-refresh]
499
- type = "flag"
500
- description = "Skip refreshing the package index — list from the cached index (faster)"
501
- default = false
502
-
503
- [upgrade-packages]
504
- serve = ["local"]
505
- description = "Refresh the package index and upgrade all installed packages (needs root)"
506
- autonomy = "confirm"
507
- output = "json"
508
- run_as = "root"
509
-
510
- [install-package]
511
- serve = ["local"]
512
- description = "Install one or more packages by name (needs root)"
513
- autonomy = "confirm"
514
- output = "json"
515
- run_as = "root"
516
-
517
- [install-package.args.package]
518
- type = "str"
519
- short = "-p"
520
- description = "Package name to install (space-separated for several)"
521
-
522
- [remove-package]
523
- serve = ["local"]
524
- description = "Remove one or more packages by name (needs root)"
525
- autonomy = "confirm"
526
- output = "json"
527
- run_as = "root"
528
-
529
- [remove-package.args.package]
530
- type = "str"
531
- short = "-p"
532
- description = "Package name to remove (space-separated for several)"
533
-
534
- [remove-package.args.purge]
535
- type = "flag"
536
- description = "Also delete the packages' configuration files (apt purge / pacman -Rn; a plain remove on dnf/yum/zypper)"
537
- default = false
538
-
539
428
  [list-autoremovable]
540
429
  serve = ["local"]
541
430
  description = "List the packages autoremove-packages would remove (read-only preview; not supported on yum)"
@@ -547,66 +436,6 @@ type = "flag"
547
436
  description = "Preview a purge (apt shows config files would go too)"
548
437
  default = false
549
438
 
550
- [autoremove-packages]
551
- serve = ["local"]
552
- description = "Remove packages nothing depends on any more, e.g. old kernels (apt autoremove and equivalents; needs root)"
553
- autonomy = "confirm"
554
- output = "json"
555
- run_as = "root"
556
-
557
- [autoremove-packages.args.purge]
558
- type = "flag"
559
- description = "Also delete the removed packages' configuration files (apt --purge / pacman -n)"
560
- default = false
561
-
562
- [autoremove-packages.args.dry-run]
563
- type = "flag"
564
- description = "Only list what would be removed; remove nothing (not supported on yum). list-autoremovable does the same without needing approval"
565
- default = false
566
-
567
- # ── Power ─────────────────────────────────────────────────────────────────────
568
-
569
- [reboot-host]
570
- serve = ["local"]
571
- description = "Reboot the host, optionally after a delay in minutes (needs root)"
572
- autonomy = "confirm"
573
- output = "json"
574
- run_as = "root"
575
-
576
- [reboot-host.args.delay-minutes]
577
- type = "int"
578
- short = "-d"
579
- description = "Minutes to wait before rebooting (0 = immediately)"
580
- default = 0
581
-
582
- # ── Privilege bootstrap ───────────────────────────────────────────────────────
583
-
584
- # One-time setup so the run_as="root" runnables above need no password. Itself
585
- # escalates with the supplied password via `sudo -S` (read from stdin, never
586
- # argv), so it must NOT declare run_as. Validated with visudo before + after
587
- # install and rolled back on failure.
588
- [enable-passwordless-sudo]
589
- serve = ["local"]
590
- description = "Install a validated /etc/sudoers.d drop-in so run_as='root' runnables need no password (one-time bootstrap)"
591
- autonomy = "confirm"
592
- output = "json"
593
-
594
- [enable-passwordless-sudo.args.password]
595
- type = "password"
596
- description = "Your sudo password — sent over the secret channel and piped to 'sudo -S'; never appears in argv, ps, or logs"
597
-
598
- [enable-passwordless-sudo.args.user]
599
- type = "str"
600
- short = "-u"
601
- description = "User to grant passwordless sudo (defaults to the current login user)"
602
- required = false
603
-
604
- [enable-passwordless-sudo.args.scope]
605
- type = "choice"
606
- options = ["scoped", "all"]
607
- default = "scoped"
608
- description = "scoped = NOPASSWD only for the runspec venv runnables (manual/CLI runs); all = NOPASSWD: ALL (also covers agent-driven runs)"
609
-
610
439
  # ── TCP interfaces (nc-command) ────────────────────────────────────────────────
611
440
 
612
441
  [nc-command]
@@ -680,7 +509,7 @@ required = false
680
509
 
681
510
  [create-venv]
682
511
  serve = ["local"]
683
- description = "Create a Python virtualenv (always --symlinks) from a specific interpreter, optionally as a service account, with a shared 2775 logs/ dir"
512
+ description = "Create a Python virtualenv (always --symlinks) from a specific interpreter, optionally as a service account, with a logs inbox so every account's runs reach its history"
684
513
  autonomy = "confirm"
685
514
  output = "json"
686
515
 
@@ -711,6 +540,11 @@ short = "-g"
711
540
  description = "Optional shared group to chgrp the venv to (e.g. consoles), so every connected console can read/write"
712
541
  required = false
713
542
 
543
+ [create-venv.args.no-inbox]
544
+ type = "flag"
545
+ description = "Don't set up the logs inbox (logs/inbox, 01777) — keep the pre-inbox layout (logs/ 2775) and write a logs/.no-inbox opt-out marker. Default: the inbox is created, so operators' and root's runs reach this venv's history with no group admin (runspec >= 0.66)"
546
+ default = false
547
+
714
548
  [create-venv.args.system-site-packages]
715
549
  type = "flag"
716
550
  description = "Give the venv access to the system site-packages"
@@ -862,12 +696,13 @@ description = "How to escalate to run-as: sudo (passwordless, -n) or su"
862
696
 
863
697
  [share-venv-logs]
864
698
  serve = ["local"]
865
- description = "Make an existing venv's logs/ dir shared (2775 + setgid, optional chgrp) so the per-venv runs DB and audit logs are writable across a console group — the repair for a venv not built with create-venv --group"
699
+ description = "Make an existing venv's logs shared: set up the logs inbox (logs/inbox, 01777) so every account's runs reach its history — plus, with --group, the 2775 setgid group share. Run as the venv owner (--run-as)"
866
700
  autonomy = "confirm"
867
701
  output = "json"
868
702
 
869
703
  examples = [
870
- {cmd = "share-venv-logs --venv /opt/venvs/app", description = "Make /opt/venvs/app/logs group-writable + setgid (keeps its current group)"},
704
+ {cmd = "share-venv-logs --venv /opt/venvs/app --run-as svc", description = "Set up the logs inbox as the venv's owner — the recommended setup"},
705
+ {cmd = "share-venv-logs --venv /opt/venvs/app --no-inbox", description = "The pre-inbox group share only: logs/ group-writable + setgid"},
871
706
  {cmd = "share-venv-logs --venv /opt/venvs/app --group consoles", description = "...and chgrp the logs dir to the shared 'consoles' group"},
872
707
  {cmd = "share-venv-logs --venv /opt/venvs/app --group consoles --run-as svc", description = "Run as the service account that owns the venv (via sudo -n -u)"},
873
708
  ]
@@ -884,6 +719,11 @@ short = "-g"
884
719
  description = "Optional shared group to chgrp the logs/ dir to (e.g. consoles), so every connected console can read/write. Omit to keep the current group"
885
720
  required = false
886
721
 
722
+ [share-venv-logs.args.no-inbox]
723
+ type = "flag"
724
+ description = "Skip the logs inbox — only the group share (logs/ 2775 setgid, optional chgrp)"
725
+ default = false
726
+
887
727
  [share-venv-logs.args.run-as]
888
728
  type = "str"
889
729
  short = "-u"
@@ -1226,47 +1066,6 @@ short = "-p"
1226
1066
  description = "Only return keys containing this substring (e.g. 'net.core')"
1227
1067
  required = false
1228
1068
 
1229
- [set-sysctl]
1230
- serve = ["local"]
1231
- description = "Set a kernel parameter (sysctl -w), optionally persisting it under /etc/sysctl.d"
1232
- autonomy = "confirm"
1233
- output = "json"
1234
-
1235
- [set-sysctl.args.key]
1236
- type = "str"
1237
- short = "-k"
1238
- description = "Dotted sysctl key (e.g. net.core.somaxconn)"
1239
-
1240
- [set-sysctl.args.value]
1241
- type = "str"
1242
- description = "New value to set"
1243
-
1244
- [set-sysctl.args.persist]
1245
- type = "flag"
1246
- description = "Also append the setting to /etc/sysctl.d/99-runspec.conf so it survives a reboot"
1247
- default = false
1248
-
1249
- # Escalation: writing a kernel parameter is privileged.
1250
- [set-sysctl.args.run-as]
1251
- type = "str"
1252
- description = "Apply as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
1253
- required = false
1254
-
1255
- [set-sysctl.args.sudo]
1256
- type = "flag"
1257
- description = "Apply as root (via sudo). Mutually exclusive with --run-as."
1258
- default = false
1259
-
1260
- [set-sysctl.args.become-method]
1261
- type = "choice"
1262
- options = ["sudo", "su"]
1263
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
1264
- default = "sudo"
1265
-
1266
- [set-sysctl.groups.escalation]
1267
- exclusive = true
1268
- args = ["run-as", "sudo"]
1269
-
1270
1069
  [cpu-info]
1271
1070
  serve = ["local"]
1272
1071
  description = "Report CPU model, core count, and per-CPU scaling governor and frequency"
@@ -1295,24 +1094,15 @@ description = "Systemd unit name (e.g. nginx or nginx.service)"
1295
1094
  # Escalation: some properties on protected units need privilege.
1296
1095
  [service-detail.args.run-as]
1297
1096
  type = "str"
1298
- description = "Read as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
1097
+ description = "Read as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
1299
1098
  required = false
1300
1099
 
1301
- [service-detail.args.sudo]
1302
- type = "flag"
1303
- description = "Read as root (via sudo). Mutually exclusive with --run-as."
1304
- default = false
1305
-
1306
1100
  [service-detail.args.become-method]
1307
1101
  type = "choice"
1308
1102
  options = ["sudo", "su"]
1309
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
1103
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
1310
1104
  default = "sudo"
1311
1105
 
1312
- [service-detail.groups.escalation]
1313
- exclusive = true
1314
- args = ["run-as", "sudo"]
1315
-
1316
1106
  [open-ports]
1317
1107
  serve = ["local"]
1318
1108
  description = "List listening sockets mapped to the owning process (port to pid to process name)"
@@ -1328,24 +1118,15 @@ default = "tcp"
1328
1118
  # Escalation: the full pid/process map needs privilege (ss shows other users' processes only as root).
1329
1119
  [open-ports.args.run-as]
1330
1120
  type = "str"
1331
- description = "Probe as this specific OS user (via sudo -n -u / su). Mutually exclusive with --sudo."
1121
+ description = "Probe as this specific OS user (via sudo -n -u / su). Never root — root tools live in runspec-linux-root."
1332
1122
  required = false
1333
1123
 
1334
- [open-ports.args.sudo]
1335
- type = "flag"
1336
- description = "Probe as root (via sudo). Mutually exclusive with --run-as."
1337
- default = false
1338
-
1339
1124
  [open-ports.args.become-method]
1340
1125
  type = "choice"
1341
1126
  options = ["sudo", "su"]
1342
- description = "How to escalate for --run-as/--sudo: sudo (passwordless, -n) or su."
1127
+ description = "How to escalate for --run-as: sudo (passwordless, -n) or su."
1343
1128
  default = "sudo"
1344
1129
 
1345
- [open-ports.groups.escalation]
1346
- exclusive = true
1347
- args = ["run-as", "sudo"]
1348
-
1349
1130
  [owning-package]
1350
1131
  serve = ["local"]
1351
1132
  description = "Report which installed package owns a file path (dpkg / rpm)"
@@ -5,9 +5,12 @@ import runspec as rs
5
5
  from runspec_linux_core import configure_pip, create_venv, install_into_venv, share_venv_logs
6
6
  from runspec_linux_core.errors import CommandError
7
7
 
8
+ from runspec_linux._escalation import refuse_root
9
+
8
10
 
9
11
  def main_create_venv() -> None:
10
12
  spec = rs.parse("create-venv")
13
+ refuse_root(spec.run_as.value or None)
11
14
  try:
12
15
  result = create_venv(
13
16
  str(spec.python.value),
@@ -19,6 +22,7 @@ def main_create_venv() -> None:
19
22
  upgrade_deps=spec.upgrade_deps.value,
20
23
  clear=spec.clear.value,
21
24
  prompt=spec.prompt.value or None,
25
+ inbox=not spec.no_inbox.value,
22
26
  )
23
27
  print(json.dumps(result))
24
28
  except CommandError as e:
@@ -32,6 +36,7 @@ def main_create_venv() -> None:
32
36
 
33
37
  def main_install_into_venv() -> None:
34
38
  spec = rs.parse("install-into-venv")
39
+ refuse_root(spec.run_as.value or None)
35
40
  try:
36
41
  result = install_into_venv(
37
42
  str(spec.venv.value),
@@ -56,12 +61,14 @@ def main_install_into_venv() -> None:
56
61
 
57
62
  def main_share_venv_logs() -> None:
58
63
  spec = rs.parse("share-venv-logs")
64
+ refuse_root(spec.run_as.value or None)
59
65
  try:
60
66
  result = share_venv_logs(
61
67
  str(spec.venv.value),
62
68
  group=spec.group.value or None,
63
69
  run_as=spec.run_as.value or None,
64
70
  become_method=spec.become_method.value,
71
+ inbox=not spec.no_inbox.value,
65
72
  )
66
73
  print(json.dumps(result))
67
74
  except CommandError as e:
@@ -75,6 +82,7 @@ def main_share_venv_logs() -> None:
75
82
 
76
83
  def main_configure_pip() -> None:
77
84
  spec = rs.parse("configure-pip")
85
+ refuse_root(spec.run_as.value or None)
78
86
  try:
79
87
  result = configure_pip(
80
88
  spec.index_url.value,
@@ -0,0 +1,56 @@
1
+ """The wrapper-side escalation resolver: --run-as / --become-method →
2
+ (run_as, become_method), refusing any root target.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from types import SimpleNamespace
9
+
10
+ import pytest
11
+
12
+ from runspec_linux._escalation import is_root, refuse_root, resolve_escalation
13
+
14
+
15
+ def _spec(run_as: str, method: str = "sudo") -> SimpleNamespace:
16
+ # A parsed spec exposes each arg as an object with a .value.
17
+ return SimpleNamespace(run_as=SimpleNamespace(value=run_as), become_method=SimpleNamespace(value=method))
18
+
19
+
20
+ def test_run_as_user() -> None:
21
+ assert resolve_escalation(_spec("deploy")) == ("deploy", "sudo")
22
+
23
+
24
+ def test_empty_is_none() -> None:
25
+ assert resolve_escalation(_spec("")) == (None, "sudo")
26
+
27
+
28
+ def test_become_method_passthrough() -> None:
29
+ assert resolve_escalation(_spec("svc", method="su")) == ("svc", "su")
30
+
31
+
32
+ @pytest.mark.parametrize("target", ["root", "0"])
33
+ def test_root_is_refused(target: str, capsys: pytest.CaptureFixture[str]) -> None:
34
+ with pytest.raises(SystemExit) as exc:
35
+ resolve_escalation(_spec(target))
36
+ assert exc.value.code == 2
37
+ assert "runspec-linux-root" in json.loads(capsys.readouterr().out)["error"]
38
+
39
+
40
+ def test_is_root() -> None:
41
+ assert is_root("root") and is_root("0")
42
+ assert not is_root("svc-no-such-user")
43
+ refuse_root(None)
44
+ refuse_root("svc")
45
+
46
+
47
+ def test_no_root_tools_left() -> None:
48
+ from importlib.resources import files
49
+
50
+ tomllib = pytest.importorskip("tomllib")
51
+ spec = tomllib.loads((files("runspec_linux") / "runspec.toml").read_text())
52
+ for name, body in spec.items():
53
+ if name == "config":
54
+ continue
55
+ assert body.get("run_as") != "root", name
56
+ assert "sudo" not in (body.get("args") or {}), name
@@ -1,20 +0,0 @@
1
- """Shared wrapper helper for the ``--run-as`` / ``--sudo`` / ``--become-method``
2
- escalation args.
3
-
4
- The privileged runnables declare a mutually-exclusive ``escalation`` group with
5
- ``--run-as <user>`` and ``--sudo`` (a flag = "run as root"), plus
6
- ``--become-method sudo|su``. The group guarantees at most one of run-as/sudo is
7
- set, so this resolves them to the ``(run_as, become_method)`` the core helpers
8
- take: ``--sudo`` → ``run_as="root"``, ``--run-as x`` → ``run_as="x"``, neither →
9
- ``run_as=None`` (no escalation, runs as the login user).
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import runspec as rs
15
-
16
-
17
- def resolve_escalation(spec: rs.RunSpec) -> tuple[str | None, str]:
18
- """Return ``(run_as, become_method)`` from a spec carrying the escalation args."""
19
- run_as = "root" if spec.sudo.value else (spec.run_as.value or None)
20
- return run_as, spec.become_method.value
@@ -1,99 +0,0 @@
1
- import json
2
- import sys
3
-
4
- import runspec as rs
5
- from runspec_linux_core import (
6
- autoremove_packages,
7
- install_package,
8
- list_autoremovable,
9
- list_packages,
10
- list_upgrades,
11
- remove_package,
12
- upgrade_packages,
13
- )
14
- from runspec_linux_core.errors import CommandError
15
-
16
-
17
- def main_list_packages() -> None:
18
- spec = rs.parse("list-packages")
19
- name_filter = spec.filter.value
20
- limit = spec.limit.value
21
- try:
22
- print(json.dumps(list_packages(name_filter, limit, reason=spec.reason.value)))
23
- except Exception as e:
24
- # Includes ToolNotFoundError (no package manager) and CommandError.
25
- print(json.dumps({"error": str(e)}))
26
- sys.exit(1)
27
-
28
-
29
- def main_list_upgrades() -> None:
30
- spec = rs.parse("list-upgrades")
31
- try:
32
- print(json.dumps(list_upgrades(refresh=not spec.no_refresh.value)))
33
- except Exception as e:
34
- print(json.dumps({"error": str(e)}))
35
- sys.exit(1)
36
-
37
-
38
- def main_upgrade_packages() -> None:
39
- rs.parse("upgrade-packages")
40
- try:
41
- print(json.dumps(upgrade_packages()))
42
- except CommandError as e:
43
- print(json.dumps({"upgraded": False, "error": str(e)}))
44
- sys.exit(1)
45
- except Exception as e:
46
- # Includes ToolNotFoundError (no package manager).
47
- print(json.dumps({"error": str(e)}))
48
- sys.exit(1)
49
-
50
-
51
- def main_install_package() -> None:
52
- spec = rs.parse("install-package")
53
- package = spec.package.value
54
- try:
55
- print(json.dumps(install_package(package)))
56
- except CommandError as e:
57
- print(json.dumps({"package": package, "installed": False, "error": str(e)}))
58
- sys.exit(1)
59
- except Exception as e:
60
- # Includes ValueError (bad name) and ToolNotFoundError (no package manager).
61
- print(json.dumps({"error": str(e)}))
62
- sys.exit(1)
63
-
64
-
65
- def main_remove_package() -> None:
66
- spec = rs.parse("remove-package")
67
- package = spec.package.value
68
- try:
69
- print(json.dumps(remove_package(package, purge=spec.purge.value)))
70
- except CommandError as e:
71
- print(json.dumps({"package": package, "removed": False, "error": str(e)}))
72
- sys.exit(1)
73
- except Exception as e:
74
- # Includes ValueError (bad name) and ToolNotFoundError (no package manager).
75
- print(json.dumps({"error": str(e)}))
76
- sys.exit(1)
77
-
78
-
79
- def main_list_autoremovable() -> None:
80
- spec = rs.parse("list-autoremovable")
81
- try:
82
- print(json.dumps(list_autoremovable(purge=spec.purge.value)))
83
- except Exception as e:
84
- # Includes CommandError (yum, failed listing) and ToolNotFoundError.
85
- print(json.dumps({"error": str(e)}))
86
- sys.exit(1)
87
-
88
-
89
- def main_autoremove_packages() -> None:
90
- spec = rs.parse("autoremove-packages")
91
- try:
92
- print(json.dumps(autoremove_packages(purge=spec.purge.value, dry_run=spec.dry_run.value)))
93
- except CommandError as e:
94
- print(json.dumps({"removed": False, "error": str(e)}))
95
- sys.exit(1)
96
- except Exception as e:
97
- # Includes ToolNotFoundError (no package manager).
98
- print(json.dumps({"error": str(e)}))
99
- sys.exit(1)
@@ -1,20 +0,0 @@
1
- import json
2
- import sys
3
-
4
- import runspec as rs
5
- from runspec_linux_core import reboot_host
6
- from runspec_linux_core.errors import CommandError
7
-
8
-
9
- def main_reboot_host() -> None:
10
- spec = rs.parse("reboot-host")
11
- delay_minutes = spec.delay_minutes.value
12
- try:
13
- print(json.dumps(reboot_host(delay_minutes)))
14
- except CommandError as e:
15
- print(json.dumps({"scheduled": False, "error": str(e)}))
16
- sys.exit(1)
17
- except Exception as e:
18
- # Includes ToolNotFoundError (no shutdown/systemctl).
19
- print(json.dumps({"error": str(e)}))
20
- sys.exit(1)
@@ -1,23 +0,0 @@
1
- import json
2
- import sys
3
-
4
- import runspec as rs
5
- from runspec_linux_core import enable_passwordless_sudo
6
- from runspec_linux_core.errors import CommandError
7
-
8
-
9
- def main_enable_passwordless_sudo() -> None:
10
- spec = rs.parse("enable-passwordless-sudo")
11
- password = spec.password.value
12
- user = spec.user.value
13
- scope = spec.scope.value
14
- try:
15
- print(json.dumps(enable_passwordless_sudo(password, user, scope)))
16
- except CommandError as e:
17
- # Auth failure / sudoers validation failure (rolled back).
18
- print(json.dumps({"installed": False, "error": str(e)}))
19
- sys.exit(1)
20
- except Exception as e:
21
- # Includes ValueError (bad scope/user) and ToolNotFoundError (no sudo/visudo).
22
- print(json.dumps({"error": str(e)}))
23
- sys.exit(1)
@@ -1,34 +0,0 @@
1
- """The wrapper-side escalation resolver: --sudo / --run-as / --become-method →
2
- (run_as, become_method).
3
- """
4
-
5
- from __future__ import annotations
6
-
7
- from types import SimpleNamespace
8
-
9
- from runspec_linux._escalation import resolve_escalation
10
-
11
-
12
- def _spec(sudo: bool, run_as: str, method: str = "sudo") -> SimpleNamespace:
13
- # A parsed spec exposes each arg as an object with a .value.
14
- return SimpleNamespace(
15
- sudo=SimpleNamespace(value=sudo),
16
- run_as=SimpleNamespace(value=run_as),
17
- become_method=SimpleNamespace(value=method),
18
- )
19
-
20
-
21
- def test_sudo_maps_to_root() -> None:
22
- assert resolve_escalation(_spec(sudo=True, run_as="")) == ("root", "sudo")
23
-
24
-
25
- def test_run_as_user() -> None:
26
- assert resolve_escalation(_spec(sudo=False, run_as="deploy")) == ("deploy", "sudo")
27
-
28
-
29
- def test_neither_is_none() -> None:
30
- assert resolve_escalation(_spec(sudo=False, run_as="")) == (None, "sudo")
31
-
32
-
33
- def test_become_method_passthrough() -> None:
34
- assert resolve_escalation(_spec(sudo=False, run_as="svc", method="su")) == ("svc", "su")