runspec-linux-root 0.1.0__tar.gz → 0.2.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.
@@ -25,6 +25,8 @@ coverage.xml
25
25
  # Node
26
26
  node_modules/
27
27
  dist/
28
+ # runspec manifest output (written at npm pack time)
29
+ runspec-files.json
28
30
  *.js.map
29
31
  .npm
30
32
 
@@ -59,3 +61,6 @@ chainlit-docs/
59
61
 
60
62
  # Stray committed test venv (removed from tracking)
61
63
  .venv-test/
64
+
65
+ # Private gitleaks rules (see .gitleaks.toml)
66
+ .gitleaks.local.toml
@@ -0,0 +1,54 @@
1
+ # Changelog
2
+
3
+ All notable changes to `runspec-linux-root` are documented here.
4
+
5
+ ## [0.2.0] — 2026-11-07
6
+
7
+ ### Added
8
+
9
+ - **`package-repo`** (`list` / `add` / `remove`): add a vendor's package
10
+ repository and its signing key without a shell, the way install guides like
11
+ Grafana Alloy's do it. One call takes the apt pair (`--apt-url`,
12
+ `--apt-key`, `--apt-suite`, `--apt-components`) and the rpm pair
13
+ (`--rpm-url`, `--rpm-key`, for dnf, yum and zypper); each host uses its own,
14
+ so one call covers a mixed group of hosts. apt keys go to
15
+ `/etc/apt/keyrings/<name>.asc` with `signed-by=`; rpm keys to
16
+ `/etc/pki/rpm-gpg/` plus `rpm --import`, with `gpgcheck=1` and
17
+ `repo_gpgcheck=1` (`--unsigned-metadata` turns the second off). The output
18
+ lists every key's fingerprint, `--key-fingerprint` refuses a key file
19
+ holding a key you didn't list, and an `http://` key needs one. `add`
20
+ refreshes just that repository and undoes everything if the refresh fails;
21
+ running it again with the same values changes nothing. `--replace`
22
+ overwrites a same-named repository set up by hand; `--proxy` and
23
+ `--ca-bundle` apply to the key download. `list` is autonomous, `add` and
24
+ `remove` need approval. pacman hosts are refused.
25
+ - **`system-service`** (`start` / `stop` / `restart` / `reload` / `enable` /
26
+ `disable`, `--now` with enable/disable): systemd service control as root,
27
+ for system services such as `alloy` (runspec-linux's `restart-service` never
28
+ runs as root). A failure includes the unit's last journal lines.
29
+ - Needs `runspec-linux-core >= 0.15.0`.
30
+
31
+ ## [0.1.1] — 2026-10-01
32
+
33
+ - `enable-passwordless-sudo --scope scoped`: the help text and README now say
34
+ the scoped grant also covers runspec-console and MCP-gateway runs, from
35
+ runspec 0.70.0 (their env prelude runs on the venv's `bin/python`).
36
+ `--scope all` is no longer needed for those. No code change.
37
+
38
+ ## [0.1.0] — 2026-09-30
39
+
40
+ First release. The Linux tools that need root, split out of `runspec-linux`
41
+ (0.19.0) so that package never runs anything as root:
42
+ `list-upgrades`, `upgrade-packages`, `install-package`, `remove-package`,
43
+ `autoremove-packages`, `set-sysctl`, `reboot-host` and
44
+ `enable-passwordless-sudo`.
45
+
46
+ - Every tool declares `run_as = "root"` (except `enable-passwordless-sudo`,
47
+ which runs as the invoker and escalates with the password it's given).
48
+ `set-sysctl` loses its `--run-as`/`--sudo`/`--become-method` args: it always
49
+ runs as root.
50
+ - Meant for a root-owned venv, so only root can change code that root runs.
51
+ - `enable-passwordless-sudo`'s `scoped` option is documented honestly: it covers
52
+ direct `sudo <tool>` runs only, not runs from runspec-console or the MCP
53
+ gateway.
54
+ - The helpers stay in `runspec-linux-core` (>= 0.13.0).
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.5
2
+ Name: runspec-linux-root
3
+ Version: 0.2.0
4
+ Summary: Linux admin runnables that need root (packages, package repositories, services, reboot, sysctl) for runspec
5
+ Project-URL: Documentation, https://runspec.app/
6
+ Project-URL: Source, https://github.com/runspec-app/runspec/tree/main/packages/python/runspec-linux-root
7
+ Project-URL: Changelog, https://github.com/runspec-app/runspec/blob/main/packages/python/runspec-linux-root/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/runspec-app/runspec/issues
9
+ Keywords: linux,packages,root,runnable,runspec,sysadmin
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: runspec-linux-core>=0.15.0
12
+ Requires-Dist: runspec>=0.68.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: mypy; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0; extra == 'dev'
16
+ Requires-Dist: ruff==0.15.20; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # runspec-linux-root
20
+
21
+ Linux admin tools that need **root**, as [runspec](https://runspec.app/) runnables:
22
+
23
+ | Tool | What it does | Autonomy |
24
+ |---|---|---|
25
+ | `list-upgrades` | List packages with an available upgrade (refreshes the index) | autonomous |
26
+ | `upgrade-packages` | Refresh the index and upgrade every package | confirm |
27
+ | `install-package` | Install packages by name | confirm |
28
+ | `remove-package` | Remove packages (`--purge` for config files too) | confirm |
29
+ | `autoremove-packages` | Remove packages nothing depends on (`--dry-run` to preview) | confirm |
30
+ | `package-repo list` | List the package repositories configured on the host | autonomous |
31
+ | `package-repo add` | Add or update a repository and its signing key, then refresh it (undone if that fails) | confirm |
32
+ | `package-repo remove` | Remove a repository and its key file | confirm |
33
+ | `system-service <action>` | `start`, `stop`, `restart`, `reload`, `enable` or `disable` a systemd service (`--now` with enable/disable) | confirm |
34
+ | `set-sysctl` | Set a kernel parameter, optionally persisted under `/etc/sysctl.d` | confirm |
35
+ | `reboot-host` | Reboot, optionally after a delay | confirm |
36
+ | `enable-passwordless-sudo` | One-time bootstrap: a validated `/etc/sudoers.d` drop-in | confirm |
37
+
38
+ They work across apt, dnf, yum, zypper and pacman (`package-repo`: all but pacman). The logic lives in
39
+ [`runspec-linux-core`](https://pypi.org/project/runspec-linux-core/). The
40
+ everyday tools that never need root are in
41
+ [`runspec-linux`](https://pypi.org/project/runspec-linux/).
42
+
43
+ ## Install into a root-owned venv
44
+
45
+ Every tool here declares `run_as = "root"`: root runs this code. So install it
46
+ into a venv **only root can change**. A venv owned by a service account would
47
+ let that account edit code root later runs.
48
+
49
+ ```bash
50
+ sudo python3 -m venv /opt/venvs/admin
51
+ sudo /opt/venvs/admin/bin/pip install runspec-linux-root
52
+ ```
53
+
54
+ ## Who can run them
55
+
56
+ sudoers decides. An admin runs a tool with sudo:
57
+
58
+ ```bash
59
+ sudo /opt/venvs/admin/bin/upgrade-packages
60
+ ```
61
+
62
+ runspec checks the process really is root (`enforce_run_as`) and refuses
63
+ otherwise, naming the account to `sudo -u` to.
64
+
65
+ **From runspec-console or the MCP gateway**, the tool is run with `sudo -n`,
66
+ which never prompts for a password, so the admin needs a passwordless sudo rule.
67
+ `enable-passwordless-sudo` installs one:
68
+
69
+ - `--scope scoped` allows only the programs in this venv's `bin/`. That covers
70
+ direct runs (`sudo /opt/venvs/admin/bin/<tool>` from a shell or cron) and,
71
+ with runspec-console / runspec-mcp built on runspec ≥ 0.70.0, console and
72
+ gateway runs too. Those pass variables to the tool on stdin through a small
73
+ prelude that runs on the venv's own `bin/python`, which the scoped rule covers.
74
+ (Before runspec 0.70.0 the prelude ran on `sh`, which the scoped rule doesn't
75
+ cover, so console runs that carried variables needed `--scope all`.)
76
+ - `--scope all` grants `NOPASSWD: ALL`.
77
+
78
+ ## Adding a package repository
79
+
80
+ Vendor install guides (Grafana Alloy, Docker, PostgreSQL, ...) start with "add
81
+ our key and repository". `package-repo add` does that without a shell:
82
+
83
+ ```bash
84
+ package-repo add grafana \
85
+ --apt-url https://apt.grafana.com --apt-key https://apt.grafana.com/gpg-full.key \
86
+ --rpm-url https://rpm.grafana.com --rpm-key https://rpm.grafana.com/gpg.key
87
+ install-package alloy
88
+ system-service enable alloy --now
89
+ ```
90
+
91
+ - One call carries the **apt** pair and the **rpm** pair (dnf, yum and zypper).
92
+ Each host uses the pair for its own package manager, so the same call works
93
+ on every host in a mixed group. A missing pair is an error naming it.
94
+ - **apt**: the key goes to `/etc/apt/keyrings/<name>.asc` and
95
+ `/etc/apt/sources.list.d/<name>.list` names it with `signed-by=`, so the key
96
+ is trusted for that repository only. `--apt-suite` (default `stable`) and
97
+ `--apt-components` (default `main`) fill in the rest of the `deb` line.
98
+ - **dnf / yum / zypper**: the key goes to `/etc/pki/rpm-gpg/RPM-GPG-KEY-<name>`
99
+ and is imported with `rpm --import`; `/etc/yum.repos.d/<name>.repo` (or
100
+ `/etc/zypp/repos.d/`) has `gpgcheck=1`, `repo_gpgcheck=1` and
101
+ `gpgkey=file://` pointing at that file. `--unsigned-metadata` sets
102
+ `repo_gpgcheck=0` for a repository whose metadata isn't signed.
103
+ - **The key** is an `https://` URL or a file already on the host (`http://`
104
+ only with `--key-fingerprint`). The output lists every key's fingerprint.
105
+ `--key-fingerprint` (repeat it, or separate with commas) refuses a key file
106
+ holding any key you didn't list. Check them with the vendor first. A key file
107
+ can hold several keys: Grafana's apt file has three.
108
+ - **Then it refreshes just that repository.** If the refresh fails (wrong key,
109
+ bad URL) the files are put back and newly imported rpm keys removed, so a
110
+ host is never left with a repository that breaks `apt-get update`. On
111
+ dnf/yum, a new key also clears dnf's cached copy of the old one, so the
112
+ refresh really checks it.
113
+ - **Running it again** with the same values changes nothing (no refresh). A
114
+ same-named file it didn't write (one you set up by hand) needs `--replace`.
115
+ On apt, the same URL in another file is refused: apt won't accept one
116
+ repository with two keys. Remove the old one first.
117
+ - The key download uses the proxy environment variables, or `--proxy`, and
118
+ `--ca-bundle` for a private CA. The package manager uses its own proxy
119
+ settings.
120
+
121
+ `package-repo remove <name>` deletes the repository file and its key file
122
+ (unless another repository uses that key), including one set up by hand
123
+ following the vendor's guide. Keys already in the rpm database stay.
124
+
125
+ ## Across a group of hosts
126
+
127
+ From runspec-console, ask the agent to run it with a **host group** as the
128
+ target: it runs on every host in the group behind one approval, and each host
129
+ picks its own apt or rpm details. A saved runbook can hold the whole sequence
130
+ (add the repository, `install-package`, then `system-service enable --now`).
@@ -0,0 +1,112 @@
1
+ # runspec-linux-root
2
+
3
+ Linux admin tools that need **root**, as [runspec](https://runspec.app/) runnables:
4
+
5
+ | Tool | What it does | Autonomy |
6
+ |---|---|---|
7
+ | `list-upgrades` | List packages with an available upgrade (refreshes the index) | autonomous |
8
+ | `upgrade-packages` | Refresh the index and upgrade every package | confirm |
9
+ | `install-package` | Install packages by name | confirm |
10
+ | `remove-package` | Remove packages (`--purge` for config files too) | confirm |
11
+ | `autoremove-packages` | Remove packages nothing depends on (`--dry-run` to preview) | confirm |
12
+ | `package-repo list` | List the package repositories configured on the host | autonomous |
13
+ | `package-repo add` | Add or update a repository and its signing key, then refresh it (undone if that fails) | confirm |
14
+ | `package-repo remove` | Remove a repository and its key file | confirm |
15
+ | `system-service <action>` | `start`, `stop`, `restart`, `reload`, `enable` or `disable` a systemd service (`--now` with enable/disable) | confirm |
16
+ | `set-sysctl` | Set a kernel parameter, optionally persisted under `/etc/sysctl.d` | confirm |
17
+ | `reboot-host` | Reboot, optionally after a delay | confirm |
18
+ | `enable-passwordless-sudo` | One-time bootstrap: a validated `/etc/sudoers.d` drop-in | confirm |
19
+
20
+ They work across apt, dnf, yum, zypper and pacman (`package-repo`: all but pacman). The logic lives in
21
+ [`runspec-linux-core`](https://pypi.org/project/runspec-linux-core/). The
22
+ everyday tools that never need root are in
23
+ [`runspec-linux`](https://pypi.org/project/runspec-linux/).
24
+
25
+ ## Install into a root-owned venv
26
+
27
+ Every tool here declares `run_as = "root"`: root runs this code. So install it
28
+ into a venv **only root can change**. A venv owned by a service account would
29
+ let that account edit code root later runs.
30
+
31
+ ```bash
32
+ sudo python3 -m venv /opt/venvs/admin
33
+ sudo /opt/venvs/admin/bin/pip install runspec-linux-root
34
+ ```
35
+
36
+ ## Who can run them
37
+
38
+ sudoers decides. An admin runs a tool with sudo:
39
+
40
+ ```bash
41
+ sudo /opt/venvs/admin/bin/upgrade-packages
42
+ ```
43
+
44
+ runspec checks the process really is root (`enforce_run_as`) and refuses
45
+ otherwise, naming the account to `sudo -u` to.
46
+
47
+ **From runspec-console or the MCP gateway**, the tool is run with `sudo -n`,
48
+ which never prompts for a password, so the admin needs a passwordless sudo rule.
49
+ `enable-passwordless-sudo` installs one:
50
+
51
+ - `--scope scoped` allows only the programs in this venv's `bin/`. That covers
52
+ direct runs (`sudo /opt/venvs/admin/bin/<tool>` from a shell or cron) and,
53
+ with runspec-console / runspec-mcp built on runspec ≥ 0.70.0, console and
54
+ gateway runs too. Those pass variables to the tool on stdin through a small
55
+ prelude that runs on the venv's own `bin/python`, which the scoped rule covers.
56
+ (Before runspec 0.70.0 the prelude ran on `sh`, which the scoped rule doesn't
57
+ cover, so console runs that carried variables needed `--scope all`.)
58
+ - `--scope all` grants `NOPASSWD: ALL`.
59
+
60
+ ## Adding a package repository
61
+
62
+ Vendor install guides (Grafana Alloy, Docker, PostgreSQL, ...) start with "add
63
+ our key and repository". `package-repo add` does that without a shell:
64
+
65
+ ```bash
66
+ package-repo add grafana \
67
+ --apt-url https://apt.grafana.com --apt-key https://apt.grafana.com/gpg-full.key \
68
+ --rpm-url https://rpm.grafana.com --rpm-key https://rpm.grafana.com/gpg.key
69
+ install-package alloy
70
+ system-service enable alloy --now
71
+ ```
72
+
73
+ - One call carries the **apt** pair and the **rpm** pair (dnf, yum and zypper).
74
+ Each host uses the pair for its own package manager, so the same call works
75
+ on every host in a mixed group. A missing pair is an error naming it.
76
+ - **apt**: the key goes to `/etc/apt/keyrings/<name>.asc` and
77
+ `/etc/apt/sources.list.d/<name>.list` names it with `signed-by=`, so the key
78
+ is trusted for that repository only. `--apt-suite` (default `stable`) and
79
+ `--apt-components` (default `main`) fill in the rest of the `deb` line.
80
+ - **dnf / yum / zypper**: the key goes to `/etc/pki/rpm-gpg/RPM-GPG-KEY-<name>`
81
+ and is imported with `rpm --import`; `/etc/yum.repos.d/<name>.repo` (or
82
+ `/etc/zypp/repos.d/`) has `gpgcheck=1`, `repo_gpgcheck=1` and
83
+ `gpgkey=file://` pointing at that file. `--unsigned-metadata` sets
84
+ `repo_gpgcheck=0` for a repository whose metadata isn't signed.
85
+ - **The key** is an `https://` URL or a file already on the host (`http://`
86
+ only with `--key-fingerprint`). The output lists every key's fingerprint.
87
+ `--key-fingerprint` (repeat it, or separate with commas) refuses a key file
88
+ holding any key you didn't list. Check them with the vendor first. A key file
89
+ can hold several keys: Grafana's apt file has three.
90
+ - **Then it refreshes just that repository.** If the refresh fails (wrong key,
91
+ bad URL) the files are put back and newly imported rpm keys removed, so a
92
+ host is never left with a repository that breaks `apt-get update`. On
93
+ dnf/yum, a new key also clears dnf's cached copy of the old one, so the
94
+ refresh really checks it.
95
+ - **Running it again** with the same values changes nothing (no refresh). A
96
+ same-named file it didn't write (one you set up by hand) needs `--replace`.
97
+ On apt, the same URL in another file is refused: apt won't accept one
98
+ repository with two keys. Remove the old one first.
99
+ - The key download uses the proxy environment variables, or `--proxy`, and
100
+ `--ca-bundle` for a private CA. The package manager uses its own proxy
101
+ settings.
102
+
103
+ `package-repo remove <name>` deletes the repository file and its key file
104
+ (unless another repository uses that key), including one set up by hand
105
+ following the vendor's guide. Keys already in the rpm database stay.
106
+
107
+ ## Across a group of hosts
108
+
109
+ From runspec-console, ask the agent to run it with a **host group** as the
110
+ target: it runs on every host in the group behind one approval, and each host
111
+ picks its own apt or rpm details. A saved runbook can hold the whole sequence
112
+ (add the repository, `install-package`, then `system-service enable --now`).
@@ -4,21 +4,21 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-linux-root"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  requires-python = ">=3.10"
9
- description = "Linux admin runnables that need root (packages, reboot, sysctl) for runspec"
9
+ description = "Linux admin runnables that need root (packages, package repositories, services, reboot, sysctl) for runspec"
10
10
  readme = "README.md"
11
11
  keywords = ["runspec", "runnable", "linux", "sysadmin", "root", "packages"]
12
12
  dependencies = [
13
13
  "runspec>=0.68.0",
14
- "runspec-linux-core>=0.13.0",
14
+ "runspec-linux-core>=0.15.0",
15
15
  ]
16
16
 
17
17
  [project.urls]
18
18
  Documentation = "https://runspec.app/"
19
- Source = "https://github.com/jasonfinestone/runspec/tree/main/packages/python/runspec-linux-root"
20
- Changelog = "https://github.com/jasonfinestone/runspec/blob/main/packages/python/runspec-linux-root/CHANGELOG.md"
21
- Issues = "https://github.com/jasonfinestone/runspec/issues"
19
+ Source = "https://github.com/runspec-app/runspec/tree/main/packages/python/runspec-linux-root"
20
+ Changelog = "https://github.com/runspec-app/runspec/blob/main/packages/python/runspec-linux-root/CHANGELOG.md"
21
+ Issues = "https://github.com/runspec-app/runspec/issues"
22
22
 
23
23
  [project.scripts]
24
24
  list-upgrades = "runspec_linux_root.packages:main_list_upgrades"
@@ -26,6 +26,8 @@ upgrade-packages = "runspec_linux_root.packages:main_upgrade_packages"
26
26
  install-package = "runspec_linux_root.packages:main_install_package"
27
27
  remove-package = "runspec_linux_root.packages:main_remove_package"
28
28
  autoremove-packages = "runspec_linux_root.packages:main_autoremove_packages"
29
+ package-repo = "runspec_linux_root.repos:main"
30
+ system-service = "runspec_linux_root.services:main"
29
31
  set-sysctl = "runspec_linux_root.perf:main_set_sysctl"
30
32
  reboot-host = "runspec_linux_root.power:main_reboot_host"
31
33
  enable-passwordless-sudo = "runspec_linux_root.sudoers:main_enable_passwordless_sudo"
@@ -0,0 +1,55 @@
1
+ """``package-repo``: list, add and remove package repositories (apt, dnf/yum, zypper)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Any
8
+
9
+ import runspec as rs
10
+ from runspec_linux_core.errors import LinuxCoreError
11
+ from runspec_linux_core.repos import add_repo, list_repos, remove_repo
12
+
13
+
14
+ def _emit(payload: dict[str, Any]) -> None:
15
+ print(json.dumps(payload, default=str))
16
+
17
+
18
+ def _run(spec: rs.RunSpec) -> dict[str, Any]:
19
+ command = spec.runspec_command
20
+ if command == "list":
21
+ return list_repos(spec.filter.value or None)
22
+ if command == "add":
23
+ return add_repo(
24
+ spec.name.value,
25
+ apt_url=spec.apt_url.value or None,
26
+ apt_key=spec.apt_key.value or None,
27
+ apt_suite=spec.apt_suite.value,
28
+ apt_components=spec.apt_components.value,
29
+ rpm_url=spec.rpm_url.value or None,
30
+ rpm_key=spec.rpm_key.value or None,
31
+ key_fingerprints=list(spec.key_fingerprint.value or []),
32
+ signed_metadata=not spec.unsigned_metadata.value,
33
+ replace=spec.replace.value,
34
+ refresh=not spec.no_refresh.value,
35
+ proxy=spec.proxy.value or None,
36
+ ca_bundle=spec.ca_bundle.value or None,
37
+ )
38
+ if command == "remove":
39
+ return remove_repo(spec.name.value)
40
+ raise SystemExit("no command — see `package-repo --help`") # pragma: no cover (require-command)
41
+
42
+
43
+ def main() -> None:
44
+ spec = rs.parse("package-repo")
45
+ try:
46
+ result = _run(spec)
47
+ except (LinuxCoreError, ValueError) as exc:
48
+ # LinuxCoreError covers ToolNotFoundError (no package manager) and CommandError.
49
+ _emit({"error": str(exc), "kind": type(exc).__name__})
50
+ sys.exit(1)
51
+ _emit(result)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
@@ -0,0 +1,241 @@
1
+ # runspec-linux-root — Linux admin tools that need root.
2
+ #
3
+ # Install into a ROOT-OWNED venv (e.g. sudo python3 -m venv /opt/venvs/admin):
4
+ # root runs this code, so only root may be able to change it. Every tool declares
5
+ # run_as = "root"; sudoers decides who may run them.
6
+
7
+ [config]
8
+ autonomy-default = "confirm"
9
+
10
+ [config.logging]
11
+ store = "per-run"
12
+
13
+ # ── Packages ──────────────────────────────────────────────────────────────────
14
+
15
+ [list-upgrades]
16
+ serve = ["local"]
17
+ description = "List packages with an available upgrade (refreshes the package index first)"
18
+ autonomy = "autonomous"
19
+ output = "json"
20
+ run_as = "root"
21
+
22
+ [list-upgrades.args.no-refresh]
23
+ type = "flag"
24
+ description = "Skip refreshing the package index — list from the cached index (faster)"
25
+ default = false
26
+
27
+ [upgrade-packages]
28
+ serve = ["local"]
29
+ description = "Refresh the package index and upgrade all installed packages (needs root)"
30
+ autonomy = "confirm"
31
+ output = "json"
32
+ run_as = "root"
33
+
34
+ [install-package]
35
+ serve = ["local"]
36
+ description = "Install one or more packages by name (needs root)"
37
+ autonomy = "confirm"
38
+ output = "json"
39
+ run_as = "root"
40
+
41
+ [install-package.args.package]
42
+ type = "str"
43
+ short = "-p"
44
+ description = "Package name to install (space-separated for several)"
45
+
46
+ [remove-package]
47
+ serve = ["local"]
48
+ description = "Remove one or more packages by name (needs root)"
49
+ autonomy = "confirm"
50
+ output = "json"
51
+ run_as = "root"
52
+
53
+ [remove-package.args.package]
54
+ type = "str"
55
+ short = "-p"
56
+ description = "Package name to remove (space-separated for several)"
57
+
58
+ [remove-package.args.purge]
59
+ type = "flag"
60
+ description = "Also delete the packages' configuration files (apt purge / pacman -Rn; a plain remove on dnf/yum/zypper)"
61
+ default = false
62
+
63
+ [autoremove-packages]
64
+ serve = ["local"]
65
+ description = "Remove packages nothing depends on any more, e.g. old kernels (apt autoremove and equivalents; needs root)"
66
+ autonomy = "confirm"
67
+ output = "json"
68
+ run_as = "root"
69
+
70
+ [autoremove-packages.args.purge]
71
+ type = "flag"
72
+ description = "Also delete the removed packages' configuration files (apt --purge / pacman -n)"
73
+ default = false
74
+
75
+ [autoremove-packages.args.dry-run]
76
+ type = "flag"
77
+ description = "Only list what would be removed; remove nothing (not supported on yum). list-autoremovable does the same without needing approval"
78
+ default = false
79
+
80
+ # ── Package repositories ──────────────────────────────────────────────────────
81
+
82
+ # One call carries the apt and the rpm details; each host uses the pair for its
83
+ # own package manager, so one call (or one group fan-out from runspec-console)
84
+ # covers a mixed Debian/RHEL/SUSE fleet.
85
+ [package-repo]
86
+ serve = ["local"]
87
+ description = "Manage package repositories (apt, dnf/yum, zypper) and their signing keys: list, add, remove"
88
+ output = "json"
89
+ run_as = "root"
90
+ require-command = true
91
+ examples = [
92
+ {cmd = "package-repo list", description = "List the repositories configured on this host"},
93
+ {cmd = "package-repo add grafana --apt-url https://apt.grafana.com --apt-key https://apt.grafana.com/gpg-full.key --rpm-url https://rpm.grafana.com --rpm-key https://rpm.grafana.com/gpg.key", description = "Add Grafana's repository (for Alloy) on an apt, dnf, yum or zypper host"},
94
+ {cmd = "package-repo add grafana <same args> --key-fingerprint <fingerprint>,<fingerprint>", description = "Only trust the key file if every key in it is one you listed (a first add prints them)"},
95
+ {cmd = "package-repo remove grafana", description = "Remove the repository and its key file"},
96
+ ]
97
+
98
+ [package-repo.commands.list]
99
+ description = "List the package repositories configured on this host (file, URLs, enabled, signing key, and whether package-repo added it)"
100
+ autonomy = "autonomous"
101
+ [package-repo.commands.list.args]
102
+ filter = {type = "str", required = false, description = "Only repositories whose name, id or URL contains this text"}
103
+
104
+ [package-repo.commands.add]
105
+ description = "Add or update a package repository: download its signing key, write the repo file (key trusted for this repo only), then refresh just that repo — undone if the refresh fails. Same values again changes nothing"
106
+ autonomy = "confirm"
107
+ [package-repo.commands.add.args]
108
+ name = {type = "str", position = 1, pattern = "[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", description = "Repository name, used for the file names (e.g. grafana → /etc/apt/sources.list.d/grafana.list or /etc/yum.repos.d/grafana.repo)"}
109
+ apt-url = {type = "str", required = false, description = "apt hosts: the repository URL (e.g. https://apt.grafana.com)"}
110
+ apt-key = {type = "str", required = false, description = "apt hosts: the signing key — an https:// URL or a file path on the host (e.g. https://apt.grafana.com/gpg-full.key)"}
111
+ apt-suite = {type = "str", default = "stable", description = "apt hosts: the suite / distribution (e.g. stable, bookworm, noble). A flat repo ends in '/'"}
112
+ apt-components = {type = "str", default = "main", description = "apt hosts: space-separated components (e.g. main, or 'main contrib')"}
113
+ rpm-url = {type = "str", required = false, description = "dnf, yum and zypper hosts: the repository baseurl (e.g. https://rpm.grafana.com; $releasever and $basearch are allowed)"}
114
+ rpm-key = {type = "str", required = false, description = "dnf, yum and zypper hosts: the signing key — an https:// URL or a file path on the host (e.g. https://rpm.grafana.com/gpg.key)"}
115
+ key-fingerprint = {type = "str", multiple = true, delimiter = ",", required = false, description = "Refuse the key file unless every key in it is listed here (full fingerprints; repeat the flag or separate with commas). The output of a first add shows them"}
116
+ unsigned-metadata = {type = "flag", default = false, description = "dnf/yum/zypper: the repository's metadata isn't signed (repo_gpgcheck=0). Packages are still checked (gpgcheck=1)"}
117
+ replace = {type = "flag", default = false, description = "Overwrite a same-named repository file that package-repo didn't add (e.g. one set up by hand)"}
118
+ no-refresh = {type = "flag", default = false, description = "Write the files but don't refresh the repository (nothing is checked or undone)"}
119
+ proxy = {type = "str", required = false, description = "Proxy for downloading the key (http://host:port), or 'direct' for none. Default: the proxy environment variables. The package manager uses its own proxy settings"}
120
+ ca-bundle = {type = "str", required = false, description = "PEM file of CA certificates for downloading the key from a server with a private CA"}
121
+
122
+ [package-repo.commands.remove]
123
+ description = "Remove a package repository by name (as shown by list) and the key file it uses, if no other repository uses it. Keys already in the rpm database stay. Removing one that isn't there is not an error"
124
+ autonomy = "confirm"
125
+ [package-repo.commands.remove.args]
126
+ name = {type = "str", position = 1, description = "Repository name (the file name without .list/.sources/.repo, as shown by list)"}
127
+
128
+ # ── Services ──────────────────────────────────────────────────────────────────
129
+
130
+ # runspec-linux's restart-service never runs as root; this is the root version,
131
+ # for system services (e.g. starting and enabling alloy after installing it).
132
+ [system-service]
133
+ serve = ["local"]
134
+ description = "Start, stop, restart, reload, enable or disable a systemd system service (needs root)"
135
+ output = "json"
136
+ run_as = "root"
137
+ autonomy = "confirm"
138
+ require-command = true
139
+ examples = [
140
+ {cmd = "system-service enable alloy --now", description = "Start alloy now and at every boot"},
141
+ {cmd = "system-service restart alloy", description = "Restart alloy (e.g. after changing its config)"},
142
+ ]
143
+
144
+ [system-service.commands.start]
145
+ description = "Start a service"
146
+ [system-service.commands.start.args]
147
+ service = {type = "str", position = 1, description = "Service name (e.g. alloy or alloy.service)"}
148
+
149
+ [system-service.commands.stop]
150
+ description = "Stop a service"
151
+ [system-service.commands.stop.args]
152
+ service = {type = "str", position = 1, description = "Service name (e.g. alloy or alloy.service)"}
153
+
154
+ [system-service.commands.restart]
155
+ description = "Restart a service"
156
+ [system-service.commands.restart.args]
157
+ service = {type = "str", position = 1, description = "Service name (e.g. alloy or alloy.service)"}
158
+
159
+ [system-service.commands.reload]
160
+ description = "Ask a service to reload its configuration (not every service supports it)"
161
+ [system-service.commands.reload.args]
162
+ service = {type = "str", position = 1, description = "Service name (e.g. alloy or alloy.service)"}
163
+
164
+ [system-service.commands.enable]
165
+ description = "Start a service at boot; --now also starts it now"
166
+ [system-service.commands.enable.args]
167
+ service = {type = "str", position = 1, description = "Service name (e.g. alloy or alloy.service)"}
168
+ now = {type = "flag", default = false, description = "Also start it now"}
169
+
170
+ [system-service.commands.disable]
171
+ description = "Stop starting a service at boot; --now also stops it now"
172
+ [system-service.commands.disable.args]
173
+ service = {type = "str", position = 1, description = "Service name (e.g. alloy or alloy.service)"}
174
+ now = {type = "flag", default = false, description = "Also stop it now"}
175
+
176
+ # ── Kernel ────────────────────────────────────────────────────────────────────
177
+
178
+ [set-sysctl]
179
+ serve = ["local"]
180
+ description = "Set a kernel parameter (sysctl -w), optionally persisting it under /etc/sysctl.d (needs root)"
181
+ autonomy = "confirm"
182
+ output = "json"
183
+ run_as = "root"
184
+
185
+ [set-sysctl.args.key]
186
+ type = "str"
187
+ short = "-k"
188
+ description = "Dotted sysctl key (e.g. net.core.somaxconn)"
189
+
190
+ [set-sysctl.args.value]
191
+ type = "str"
192
+ description = "New value to set"
193
+
194
+ [set-sysctl.args.persist]
195
+ type = "flag"
196
+ description = "Also append the setting to /etc/sysctl.d/99-runspec.conf so it survives a reboot"
197
+ default = false
198
+
199
+ # ── Power ─────────────────────────────────────────────────────────────────────
200
+
201
+ [reboot-host]
202
+ serve = ["local"]
203
+ description = "Reboot the host, optionally after a delay in minutes (needs root)"
204
+ autonomy = "confirm"
205
+ output = "json"
206
+ run_as = "root"
207
+
208
+ [reboot-host.args.delay-minutes]
209
+ type = "int"
210
+ short = "-d"
211
+ description = "Minutes to wait before rebooting (0 = immediately)"
212
+ default = 0
213
+
214
+ # ── Privilege bootstrap ───────────────────────────────────────────────────────
215
+
216
+ # One-time setup so the run_as="root" runnables above need no password. Itself
217
+ # escalates with the supplied password via `sudo -S` (read from stdin, never
218
+ # argv), so it runs as the invoker (run_as = ""). Validated with visudo before +
219
+ # after install and rolled back on failure.
220
+ [enable-passwordless-sudo]
221
+ serve = ["local"]
222
+ description = "Install a validated /etc/sudoers.d drop-in so run_as='root' runnables need no password (one-time bootstrap)"
223
+ autonomy = "confirm"
224
+ output = "json"
225
+ run_as = ""
226
+
227
+ [enable-passwordless-sudo.args.password]
228
+ type = "password"
229
+ description = "Your sudo password — sent over the secret channel and piped to 'sudo -S'; never appears in argv, ps, or logs"
230
+
231
+ [enable-passwordless-sudo.args.user]
232
+ type = "str"
233
+ short = "-u"
234
+ description = "User to grant passwordless sudo (defaults to the current login user)"
235
+ required = false
236
+
237
+ [enable-passwordless-sudo.args.scope]
238
+ type = "choice"
239
+ options = ["scoped", "all"]
240
+ default = "scoped"
241
+ description = "scoped = NOPASSWD only for the programs in this venv's bin/ (direct runs, and runspec-console / MCP gateway runs built on runspec >= 0.70.0); all = NOPASSWD: ALL"
@@ -0,0 +1,26 @@
1
+ """``system-service``: start/stop/restart/reload/enable/disable a systemd service as root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ import runspec as rs
9
+ from runspec_linux_core.errors import LinuxCoreError
10
+ from runspec_linux_core.services import control_service
11
+
12
+
13
+ def main() -> None:
14
+ spec = rs.parse("system-service")
15
+ action = spec.runspec_command
16
+ now = spec.now.value if "now" in spec else False
17
+ try:
18
+ result = control_service(spec.service.value, action, now=bool(now))
19
+ except (LinuxCoreError, ValueError) as exc:
20
+ print(json.dumps({"service": spec.service.value, "action": action, "error": str(exc), "kind": type(exc).__name__}))
21
+ sys.exit(1)
22
+ print(json.dumps(result))
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
@@ -0,0 +1,151 @@
1
+ """Shape of the root package's runspec.toml and entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.resources import files
6
+
7
+ import pytest
8
+
9
+ tomllib = pytest.importorskip("tomllib")
10
+
11
+ SPEC = tomllib.loads((files("runspec_linux_root") / "runspec.toml").read_text())
12
+ RUNNABLES = {k: v for k, v in SPEC.items() if k != "config"}
13
+
14
+
15
+ def test_every_tool_runs_as_root_except_the_bootstrap() -> None:
16
+ for name, body in RUNNABLES.items():
17
+ expected = "" if name == "enable-passwordless-sudo" else "root"
18
+ assert body.get("run_as") == expected, name
19
+
20
+
21
+ def test_no_per_invocation_escalation_args() -> None:
22
+ for name, body in RUNNABLES.items():
23
+ args = body.get("args") or {}
24
+ assert not {"run-as", "sudo", "become-method"} & set(args), name
25
+
26
+
27
+ READ_ONLY = {("list-upgrades", None), ("package-repo", "list")}
28
+
29
+
30
+ def test_mutating_tools_need_confirmation() -> None:
31
+ default = SPEC["config"]["autonomy-default"]
32
+ for name, body in RUNNABLES.items():
33
+ commands = body.get("commands") or {None: {}}
34
+ for command, cbody in commands.items():
35
+ autonomy = cbody.get("autonomy", body.get("autonomy", default))
36
+ expected = "autonomous" if (name, command) in READ_ONLY else "confirm"
37
+ assert autonomy == expected, (name, command)
38
+
39
+
40
+ def test_entry_points_match_runnables() -> None:
41
+ import runspec_linux_root.packages as packages
42
+ import runspec_linux_root.perf as perf
43
+ import runspec_linux_root.power as power
44
+ import runspec_linux_root.repos as repos
45
+ import runspec_linux_root.services as services
46
+ import runspec_linux_root.sudoers as sudoers
47
+
48
+ mains = {
49
+ "list-upgrades": packages.main_list_upgrades,
50
+ "upgrade-packages": packages.main_upgrade_packages,
51
+ "install-package": packages.main_install_package,
52
+ "remove-package": packages.main_remove_package,
53
+ "autoremove-packages": packages.main_autoremove_packages,
54
+ "package-repo": repos.main,
55
+ "system-service": services.main,
56
+ "set-sysctl": perf.main_set_sysctl,
57
+ "reboot-host": power.main_reboot_host,
58
+ "enable-passwordless-sudo": sudoers.main_enable_passwordless_sudo,
59
+ }
60
+ assert set(mains) == set(RUNNABLES)
61
+
62
+
63
+ def test_set_sysctl_runs_the_core_helper_without_escalating(monkeypatch, capsys) -> None:
64
+ # Already root (run_as = "root"), so the wrapper passes no run_as of its own.
65
+ import json
66
+
67
+ import runspec_linux_root.perf as perf
68
+
69
+ calls = []
70
+ monkeypatch.setattr(perf, "set_sysctl", lambda key, value, **kw: calls.append((key, value, kw)) or {"key": key, "applied": True})
71
+ monkeypatch.setattr("sys.argv", ["set-sysctl", "--key", "vm.swappiness", "--value", "10"])
72
+ # The suite doesn't run as root; stub the identity check the parser uses.
73
+ monkeypatch.setattr("runspec.parser.verify_run_as_identity", lambda user: None)
74
+ perf.main_set_sysctl()
75
+ assert calls == [("vm.swappiness", "10", {"persist": False})]
76
+ assert json.loads(capsys.readouterr().out)["applied"] is True
77
+
78
+
79
+ def _run_main(monkeypatch, argv, main):
80
+ monkeypatch.setattr("sys.argv", argv)
81
+ monkeypatch.setattr("runspec.parser.verify_run_as_identity", lambda user: None)
82
+ main()
83
+
84
+
85
+ def test_package_repo_add_passes_both_pairs(monkeypatch, capsys) -> None:
86
+ import json
87
+
88
+ import runspec_linux_root.repos as repos
89
+
90
+ seen = {}
91
+ monkeypatch.setattr(repos, "add_repo", lambda name, **kw: seen.update(name=name, **kw) or {"name": name, "changed": True})
92
+ _run_main(
93
+ monkeypatch,
94
+ [
95
+ "package-repo",
96
+ "add",
97
+ "grafana",
98
+ "--apt-url",
99
+ "https://apt.grafana.com",
100
+ "--apt-key",
101
+ "https://apt.grafana.com/gpg-full.key",
102
+ "--rpm-url",
103
+ "https://rpm.grafana.com",
104
+ "--rpm-key",
105
+ "https://rpm.grafana.com/gpg.key",
106
+ "--key-fingerprint",
107
+ "AAAA,BBBB",
108
+ "--key-fingerprint",
109
+ "CCCC",
110
+ "--no-refresh",
111
+ ],
112
+ repos.main,
113
+ )
114
+ assert seen["name"] == "grafana"
115
+ assert seen["apt_url"] == "https://apt.grafana.com"
116
+ assert seen["rpm_key"] == "https://rpm.grafana.com/gpg.key"
117
+ assert seen["apt_suite"] == "stable" and seen["apt_components"] == "main"
118
+ assert seen["key_fingerprints"] == ["AAAA", "BBBB", "CCCC"]
119
+ assert seen["refresh"] is False and seen["signed_metadata"] is True and seen["replace"] is False
120
+ assert json.loads(capsys.readouterr().out)["changed"] is True
121
+
122
+
123
+ def test_package_repo_errors_are_json(monkeypatch, capsys) -> None:
124
+ import json
125
+
126
+ import runspec_linux_root.repos as repos
127
+
128
+ def boom(name, **kw):
129
+ raise ValueError("this host uses apt: give --apt-url and --apt-key for the 'grafana' repo")
130
+
131
+ monkeypatch.setattr(repos, "add_repo", boom)
132
+ with pytest.raises(SystemExit) as exc:
133
+ _run_main(monkeypatch, ["package-repo", "add", "grafana"], repos.main)
134
+ assert exc.value.code == 1
135
+ assert "give --apt-url" in json.loads(capsys.readouterr().out)["error"]
136
+
137
+
138
+ @pytest.mark.parametrize(
139
+ ("argv", "expected"),
140
+ [
141
+ (["system-service", "enable", "alloy", "--now"], ("alloy", "enable", True)),
142
+ (["system-service", "restart", "alloy"], ("alloy", "restart", False)),
143
+ ],
144
+ )
145
+ def test_system_service_calls_the_core_helper(monkeypatch, capsys, argv, expected) -> None:
146
+ import runspec_linux_root.services as services
147
+
148
+ calls = []
149
+ monkeypatch.setattr(services, "control_service", lambda s, a, now: calls.append((s, a, now)) or {"service": s})
150
+ _run_main(monkeypatch, argv, services.main)
151
+ assert calls == [expected]
@@ -1,21 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to `runspec-linux-root` are documented here.
4
-
5
- ## [0.1.0] — 2026-09-30
6
-
7
- First release. The Linux tools that need root, split out of `runspec-linux`
8
- (0.19.0) so that package never runs anything as root:
9
- `list-upgrades`, `upgrade-packages`, `install-package`, `remove-package`,
10
- `autoremove-packages`, `set-sysctl`, `reboot-host` and
11
- `enable-passwordless-sudo`.
12
-
13
- - Every tool declares `run_as = "root"` (except `enable-passwordless-sudo`,
14
- which runs as the invoker and escalates with the password it's given).
15
- `set-sysctl` loses its `--run-as`/`--sudo`/`--become-method` args: it always
16
- runs as root.
17
- - Meant for a root-owned venv, so only root can change code that root runs.
18
- - `enable-passwordless-sudo`'s `scoped` option is documented honestly: it covers
19
- direct `sudo <tool>` runs only, not runs from runspec-console or the MCP
20
- gateway.
21
- - The helpers stay in `runspec-linux-core` (>= 0.13.0).
@@ -1,70 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: runspec-linux-root
3
- Version: 0.1.0
4
- Summary: Linux admin runnables that need root (packages, reboot, sysctl) for runspec
5
- Project-URL: Documentation, https://runspec.app/
6
- Project-URL: Source, https://github.com/jasonfinestone/runspec/tree/main/packages/python/runspec-linux-root
7
- Project-URL: Changelog, https://github.com/jasonfinestone/runspec/blob/main/packages/python/runspec-linux-root/CHANGELOG.md
8
- Project-URL: Issues, https://github.com/jasonfinestone/runspec/issues
9
- Keywords: linux,packages,root,runnable,runspec,sysadmin
10
- Requires-Python: >=3.10
11
- Requires-Dist: runspec-linux-core>=0.13.0
12
- Requires-Dist: runspec>=0.68.0
13
- Provides-Extra: dev
14
- Requires-Dist: mypy; extra == 'dev'
15
- Requires-Dist: pytest>=8.0; extra == 'dev'
16
- Requires-Dist: ruff==0.15.20; extra == 'dev'
17
- Description-Content-Type: text/markdown
18
-
19
- # runspec-linux-root
20
-
21
- Linux admin tools that need **root**, as [runspec](https://runspec.app/) runnables:
22
-
23
- | Tool | What it does | Autonomy |
24
- |---|---|---|
25
- | `list-upgrades` | List packages with an available upgrade (refreshes the index) | autonomous |
26
- | `upgrade-packages` | Refresh the index and upgrade every package | confirm |
27
- | `install-package` | Install packages by name | confirm |
28
- | `remove-package` | Remove packages (`--purge` for config files too) | confirm |
29
- | `autoremove-packages` | Remove packages nothing depends on (`--dry-run` to preview) | confirm |
30
- | `set-sysctl` | Set a kernel parameter, optionally persisted under `/etc/sysctl.d` | confirm |
31
- | `reboot-host` | Reboot, optionally after a delay | confirm |
32
- | `enable-passwordless-sudo` | One-time bootstrap: a validated `/etc/sudoers.d` drop-in | confirm |
33
-
34
- They work across apt, dnf, yum, zypper and pacman. The logic lives in
35
- [`runspec-linux-core`](https://pypi.org/project/runspec-linux-core/). The
36
- everyday tools that never need root are in
37
- [`runspec-linux`](https://pypi.org/project/runspec-linux/).
38
-
39
- ## Install into a root-owned venv
40
-
41
- Every tool here declares `run_as = "root"`: root runs this code. So install it
42
- into a venv **only root can change**. A venv owned by a service account would
43
- let that account edit code root later runs.
44
-
45
- ```bash
46
- sudo python3 -m venv /opt/venvs/admin
47
- sudo /opt/venvs/admin/bin/pip install runspec-linux-root
48
- ```
49
-
50
- ## Who can run them
51
-
52
- sudoers decides. An admin runs a tool with sudo:
53
-
54
- ```bash
55
- sudo /opt/venvs/admin/bin/upgrade-packages
56
- ```
57
-
58
- runspec checks the process really is root (`enforce_run_as`) and refuses
59
- otherwise, naming the account to `sudo -u` to.
60
-
61
- **From runspec-console or the MCP gateway**, the tool is run with `sudo -n`,
62
- which never prompts for a password, so the admin needs a passwordless sudo rule.
63
- `enable-passwordless-sudo` installs one:
64
-
65
- - `--scope scoped` allows only this venv's tools, run directly
66
- (`sudo /opt/venvs/admin/bin/<tool>` from a shell or cron). It does **not**
67
- cover the console or the gateway, which pass variables to the tool through a
68
- `sh` prelude so that secrets never go on a command line; sudo then sees `sh`,
69
- not the tool.
70
- - `--scope all` grants `NOPASSWD: ALL`, which the console and the gateway need.
@@ -1,52 +0,0 @@
1
- # runspec-linux-root
2
-
3
- Linux admin tools that need **root**, as [runspec](https://runspec.app/) runnables:
4
-
5
- | Tool | What it does | Autonomy |
6
- |---|---|---|
7
- | `list-upgrades` | List packages with an available upgrade (refreshes the index) | autonomous |
8
- | `upgrade-packages` | Refresh the index and upgrade every package | confirm |
9
- | `install-package` | Install packages by name | confirm |
10
- | `remove-package` | Remove packages (`--purge` for config files too) | confirm |
11
- | `autoremove-packages` | Remove packages nothing depends on (`--dry-run` to preview) | confirm |
12
- | `set-sysctl` | Set a kernel parameter, optionally persisted under `/etc/sysctl.d` | confirm |
13
- | `reboot-host` | Reboot, optionally after a delay | confirm |
14
- | `enable-passwordless-sudo` | One-time bootstrap: a validated `/etc/sudoers.d` drop-in | confirm |
15
-
16
- They work across apt, dnf, yum, zypper and pacman. The logic lives in
17
- [`runspec-linux-core`](https://pypi.org/project/runspec-linux-core/). The
18
- everyday tools that never need root are in
19
- [`runspec-linux`](https://pypi.org/project/runspec-linux/).
20
-
21
- ## Install into a root-owned venv
22
-
23
- Every tool here declares `run_as = "root"`: root runs this code. So install it
24
- into a venv **only root can change**. A venv owned by a service account would
25
- let that account edit code root later runs.
26
-
27
- ```bash
28
- sudo python3 -m venv /opt/venvs/admin
29
- sudo /opt/venvs/admin/bin/pip install runspec-linux-root
30
- ```
31
-
32
- ## Who can run them
33
-
34
- sudoers decides. An admin runs a tool with sudo:
35
-
36
- ```bash
37
- sudo /opt/venvs/admin/bin/upgrade-packages
38
- ```
39
-
40
- runspec checks the process really is root (`enforce_run_as`) and refuses
41
- otherwise, naming the account to `sudo -u` to.
42
-
43
- **From runspec-console or the MCP gateway**, the tool is run with `sudo -n`,
44
- which never prompts for a password, so the admin needs a passwordless sudo rule.
45
- `enable-passwordless-sudo` installs one:
46
-
47
- - `--scope scoped` allows only this venv's tools, run directly
48
- (`sudo /opt/venvs/admin/bin/<tool>` from a shell or cron). It does **not**
49
- cover the console or the gateway, which pass variables to the tool through a
50
- `sh` prelude so that secrets never go on a command line; sudo then sees `sh`,
51
- not the tool.
52
- - `--scope all` grants `NOPASSWD: ALL`, which the console and the gateway need.
@@ -1,145 +0,0 @@
1
- # runspec-linux-root — Linux admin tools that need root.
2
- #
3
- # Install into a ROOT-OWNED venv (e.g. sudo python3 -m venv /opt/venvs/admin):
4
- # root runs this code, so only root may be able to change it. Every tool declares
5
- # run_as = "root"; sudoers decides who may run them.
6
-
7
- [config]
8
- autonomy-default = "confirm"
9
-
10
- [config.logging]
11
- store = "per-run"
12
-
13
- # ── Packages ──────────────────────────────────────────────────────────────────
14
-
15
- [list-upgrades]
16
- serve = ["local"]
17
- description = "List packages with an available upgrade (refreshes the package index first)"
18
- autonomy = "autonomous"
19
- output = "json"
20
- run_as = "root"
21
-
22
- [list-upgrades.args.no-refresh]
23
- type = "flag"
24
- description = "Skip refreshing the package index — list from the cached index (faster)"
25
- default = false
26
-
27
- [upgrade-packages]
28
- serve = ["local"]
29
- description = "Refresh the package index and upgrade all installed packages (needs root)"
30
- autonomy = "confirm"
31
- output = "json"
32
- run_as = "root"
33
-
34
- [install-package]
35
- serve = ["local"]
36
- description = "Install one or more packages by name (needs root)"
37
- autonomy = "confirm"
38
- output = "json"
39
- run_as = "root"
40
-
41
- [install-package.args.package]
42
- type = "str"
43
- short = "-p"
44
- description = "Package name to install (space-separated for several)"
45
-
46
- [remove-package]
47
- serve = ["local"]
48
- description = "Remove one or more packages by name (needs root)"
49
- autonomy = "confirm"
50
- output = "json"
51
- run_as = "root"
52
-
53
- [remove-package.args.package]
54
- type = "str"
55
- short = "-p"
56
- description = "Package name to remove (space-separated for several)"
57
-
58
- [remove-package.args.purge]
59
- type = "flag"
60
- description = "Also delete the packages' configuration files (apt purge / pacman -Rn; a plain remove on dnf/yum/zypper)"
61
- default = false
62
-
63
- [autoremove-packages]
64
- serve = ["local"]
65
- description = "Remove packages nothing depends on any more, e.g. old kernels (apt autoremove and equivalents; needs root)"
66
- autonomy = "confirm"
67
- output = "json"
68
- run_as = "root"
69
-
70
- [autoremove-packages.args.purge]
71
- type = "flag"
72
- description = "Also delete the removed packages' configuration files (apt --purge / pacman -n)"
73
- default = false
74
-
75
- [autoremove-packages.args.dry-run]
76
- type = "flag"
77
- description = "Only list what would be removed; remove nothing (not supported on yum). list-autoremovable does the same without needing approval"
78
- default = false
79
-
80
- # ── Kernel ────────────────────────────────────────────────────────────────────
81
-
82
- [set-sysctl]
83
- serve = ["local"]
84
- description = "Set a kernel parameter (sysctl -w), optionally persisting it under /etc/sysctl.d (needs root)"
85
- autonomy = "confirm"
86
- output = "json"
87
- run_as = "root"
88
-
89
- [set-sysctl.args.key]
90
- type = "str"
91
- short = "-k"
92
- description = "Dotted sysctl key (e.g. net.core.somaxconn)"
93
-
94
- [set-sysctl.args.value]
95
- type = "str"
96
- description = "New value to set"
97
-
98
- [set-sysctl.args.persist]
99
- type = "flag"
100
- description = "Also append the setting to /etc/sysctl.d/99-runspec.conf so it survives a reboot"
101
- default = false
102
-
103
- # ── Power ─────────────────────────────────────────────────────────────────────
104
-
105
- [reboot-host]
106
- serve = ["local"]
107
- description = "Reboot the host, optionally after a delay in minutes (needs root)"
108
- autonomy = "confirm"
109
- output = "json"
110
- run_as = "root"
111
-
112
- [reboot-host.args.delay-minutes]
113
- type = "int"
114
- short = "-d"
115
- description = "Minutes to wait before rebooting (0 = immediately)"
116
- default = 0
117
-
118
- # ── Privilege bootstrap ───────────────────────────────────────────────────────
119
-
120
- # One-time setup so the run_as="root" runnables above need no password. Itself
121
- # escalates with the supplied password via `sudo -S` (read from stdin, never
122
- # argv), so it runs as the invoker (run_as = ""). Validated with visudo before +
123
- # after install and rolled back on failure.
124
- [enable-passwordless-sudo]
125
- serve = ["local"]
126
- description = "Install a validated /etc/sudoers.d drop-in so run_as='root' runnables need no password (one-time bootstrap)"
127
- autonomy = "confirm"
128
- output = "json"
129
- run_as = ""
130
-
131
- [enable-passwordless-sudo.args.password]
132
- type = "password"
133
- description = "Your sudo password — sent over the secret channel and piped to 'sudo -S'; never appears in argv, ps, or logs"
134
-
135
- [enable-passwordless-sudo.args.user]
136
- type = "str"
137
- short = "-u"
138
- description = "User to grant passwordless sudo (defaults to the current login user)"
139
- required = false
140
-
141
- [enable-passwordless-sudo.args.scope]
142
- type = "choice"
143
- options = ["scoped", "all"]
144
- default = "scoped"
145
- description = "scoped = NOPASSWD only for this venv's tools, run directly (sudo <venv>/bin/<tool> from a shell or cron); all = NOPASSWD: ALL (also needed for runs from runspec-console or the MCP gateway)"
@@ -1,65 +0,0 @@
1
- """Shape of the root package's runspec.toml and entry points."""
2
-
3
- from __future__ import annotations
4
-
5
- from importlib.resources import files
6
-
7
- import pytest
8
-
9
- tomllib = pytest.importorskip("tomllib")
10
-
11
- SPEC = tomllib.loads((files("runspec_linux_root") / "runspec.toml").read_text())
12
- RUNNABLES = {k: v for k, v in SPEC.items() if k != "config"}
13
-
14
-
15
- def test_every_tool_runs_as_root_except_the_bootstrap() -> None:
16
- for name, body in RUNNABLES.items():
17
- expected = "" if name == "enable-passwordless-sudo" else "root"
18
- assert body.get("run_as") == expected, name
19
-
20
-
21
- def test_no_per_invocation_escalation_args() -> None:
22
- for name, body in RUNNABLES.items():
23
- args = body.get("args") or {}
24
- assert not {"run-as", "sudo", "become-method"} & set(args), name
25
-
26
-
27
- def test_mutating_tools_need_confirmation() -> None:
28
- for name, body in RUNNABLES.items():
29
- if name != "list-upgrades":
30
- assert body.get("autonomy", SPEC["config"]["autonomy-default"]) == "confirm", name
31
-
32
-
33
- def test_entry_points_match_runnables() -> None:
34
- import runspec_linux_root.packages as packages
35
- import runspec_linux_root.perf as perf
36
- import runspec_linux_root.power as power
37
- import runspec_linux_root.sudoers as sudoers
38
-
39
- mains = {
40
- "list-upgrades": packages.main_list_upgrades,
41
- "upgrade-packages": packages.main_upgrade_packages,
42
- "install-package": packages.main_install_package,
43
- "remove-package": packages.main_remove_package,
44
- "autoremove-packages": packages.main_autoremove_packages,
45
- "set-sysctl": perf.main_set_sysctl,
46
- "reboot-host": power.main_reboot_host,
47
- "enable-passwordless-sudo": sudoers.main_enable_passwordless_sudo,
48
- }
49
- assert set(mains) == set(RUNNABLES)
50
-
51
-
52
- def test_set_sysctl_runs_the_core_helper_without_escalating(monkeypatch, capsys) -> None:
53
- # Already root (run_as = "root"), so the wrapper passes no run_as of its own.
54
- import json
55
-
56
- import runspec_linux_root.perf as perf
57
-
58
- calls = []
59
- monkeypatch.setattr(perf, "set_sysctl", lambda key, value, **kw: calls.append((key, value, kw)) or {"key": key, "applied": True})
60
- monkeypatch.setattr("sys.argv", ["set-sysctl", "--key", "vm.swappiness", "--value", "10"])
61
- # The suite doesn't run as root; stub the identity check the parser uses.
62
- monkeypatch.setattr("runspec.parser.verify_run_as_identity", lambda user: None)
63
- perf.main_set_sysctl()
64
- assert calls == [("vm.swappiness", "10", {"persist": False})]
65
- assert json.loads(capsys.readouterr().out)["applied"] is True