Skip to content

fix(daemon): replace shell grants with bounded action helpers - #429

Merged
vladimirrott merged 6 commits into
lacs-project:mainfrom
QinXi-ai:fix/417-shell-free-actions
Sep 15, 2026
Merged

vladimirrott merged 6 commits into
lacs-project:mainfrom
QinXi-ai:fix/417-shell-free-actions

Conversation

@QinXi-ai

@QinXi-ai QinXi-ai commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #417.

Remove the unrestricted /usr/bin/sh and /usr/sbin/runuser grants. The complete call-site inventory is in the issue comment: firewall chaining, Fedora group materialization, snap install-then-hold, SSH key edits, Podman/Toolbox login-shell operations, and the Flatpak consumers of runuser.

These paths now invoke the root-owned action-steps helper with one of nine explicit operations. Each sudoers grant names the operation; the helper independently validates the full argument grammar and invokes fixed absolute binaries without a shell. Failed install/firewall/group preparation stops subsequent work. Snap retains its exclusive-resource classification.

For user operations, NSS resolves the target account, then the helper replaces supplementary groups and drops GID/UID before accessing the home or running user-configurable tools. It sets an explicit HOME/XDG/session-bus environment. It never accepts an arbitrary command, environment assignment, or file path. SSH edits compare literal whole lines, retain the inode/mode, and refuse symlinks. Both source-install and Ubuntu provisioning paths install the helper; uninstall removes it.

Compatibility: user-scoped operations now refuse UID 0, SSH edits refuse symlinks, and user startup files are not executed. The new daemon/helper/sudoers must be installed together. These restrictions are a behavior change for the next minor release under the project's 0.y versioning policy. Existing unrelated unprivileged Bash readers (APT history/upgradable packages/reboot sentinel) do not depend on either grant and remain outside this privilege-boundary change. Other powerful administrative grants remain; this PR does not claim sudoers alone is a complete security boundary.

Validation:

  • Windows-local direct production-module harness: 45 action-constructor tests and 31 Snap tests PASS. This is not a full daemon build.
  • Helper regression: 8 PASS, 3 Linux-only tests SKIPPED locally. The grant-removal regression failed before implementation.
  • Linux CI on final head 82bb53f passed all 11 helper tests, including a fixture-only root subprocess that verifies real credential dropping, inability to regain root, user ownership, and refusal to overwrite a root-owned file via symlink. Evidence: https://github.com/lacs-project/sysknife/actions/runs/34867929353 . Actual firewall/Snap/Podman/Toolbox/Flatpak installations remain NOT_TESTED; their argv, sequencing, and drop order have fixture coverage.
  • Existing SSH executor tests now exercise the packaged helper's real edit function; action-reference regenerated from production constructors; formatting/diff checks PASS.
  • Existing Rust tests were adapted without adding/removing tests. The complete Linux workspace on final head 82bb53f passed 1,852 tests, with 6 skipped; Clippy, rustdoc, frontend and Postgres contract gates also pass. Windows cannot build the existing Unix-only daemon/brain modules.

CI also surfaced the newly published RUSTSEC-2026-0285 in the inherited lockfile. A separate commit updates rustls to 0.23.45 and its required TLS dependencies; the final security-audit check passes.

Codex assisted with implementation and validation.

@QinXi-ai
QinXi-ai marked this pull request as ready for review September 14, 2026 16:30
vladimirrott
vladimirrott previously approved these changes Sep 14, 2026

@vladimirrott vladimirrott left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 82bb53ff77ad95565aa4597c544d92077c0368c7.

This removes the two grants that made everything else in packaging/sysknife-sudoers advisory:

-sysknife ALL=(root) NOPASSWD: /usr/bin/sh
-sysknife ALL=(root) NOPASSWD: /usr/sbin/runuser

The file's own header admitted what they meant ("those make the daemon effectively root-capable, so the security boundary is the daemon's own authorization"). The TODO(post-launch hardening) above the sh grant has been sitting there asking for exactly this refactor. You did it, across nine call sites, without changing what the actions do.

The guard bites

I proved it rather than reading it. Reverting one production file to main's version, in a network-isolated container:

$ git checkout origin/main -- crates/sysknife-daemon/src/actions/ssh.rs
$ cargo test -p sysknife-daemon --test sudoers_grants_match_argv --offline
running 2 tests
test every_sudo_action_is_authorised_by_a_packaged_grant ... FAILED
panicked at crates/sysknife-daemon/tests/sudoers_grants_match_argv.rs:108:13:
AddAuthorizedKey must use bounded argv instead of a privileged shell/user launcher
test result: FAILED. 1 passed; 1 failed

Unmutated at your head, the same command reports running 2 tests ... 2 passed. So the assertion fails when a shell launcher comes back, and it names the action that brought it. That is the guard I want attached to this change, because the failure mode is someone quietly reintroducing a sh -c call site a year from now.

The helper

The nine sudoers entries end in *, and sudo matches command arguments as a single concatenated string, so each grant admits arbitrary trailing text. That makes action-steps its own boundary: anything that can run sudo action-steps ssh-add ... reaches it directly, skipping the preview, the approval interlock and the signed chain. I read it on that basis rather than as a convenience wrapper, and it holds up.

The credential drop is in the right order, which is the detail that most implementations get wrong:

os.initgroups(account.pw_name, account.pw_gid)
os.setgid(account.pw_gid)
os.setuid(account.pw_uid)
if os.getuid() != account.pw_uid or os.geteuid() != account.pw_uid:
    raise PermissionError("failed to drop root credentials")

Supplementary groups first (only possible while root), then GID, then UID, then a verification that it actually took. Reversing the last two would silently leave the process in root's group. Refusing pw_uid == 0 and requiring an absolute home closes the obvious way to ask it to operate on root's account.

edit_key opens with O_NOFOLLOW | O_NONBLOCK and then checks S_ISREG. The O_NONBLOCK is load-bearing and easy to mistake for noise: without it, a FIFO planted at ~/.ssh/authorized_keys would block the open forever, since O_NOFOLLOW only refuses symlinks. Whole-line comparison keeps the fix from #133 intact, so a key like ssh-ed25519 .* still cannot act as a pattern.

#!/usr/bin/python3 -I is the right shebang. Isolated mode ignores PYTHONPATH and user site-packages, so a writable directory in the daemon user's environment cannot inject a module into a root-run helper.

I checked one behavior I expected to be a regression and it is not. The new ssh-add path opens ~/.ssh/authorized_keys with O_CREAT but never creates ~/.ssh. main's ADD_KEY_SCRIPT is ... || printf '%s\n' "$key" >> "$path", which also fails when the directory is absent. Same behavior on a fresh account, so nothing is lost.

Installation is correct in both paths: install -Dm 755 root-owned in the Makefile and in ubuntu-provision.sh, and daemon-uninstall removes it.

Why I am not merging it today

This touches .github/workflows/**. My rule is that a workflow diff gets a human reading it before the merge button, regardless of how small the hunk is, so this one waits for that rather than for anything you need to do. It is in my run report as ready.

Two coordination notes, neither your fault:

#421 edits the same function at the same insertion point, so the two conflict textually. Both additions are compatible and the resolution is to keep both lines. I will sequence them.

Your lockfile commit clears RUSTSEC-2026-0285, which is red on main right now. I am landing that through #426 first so the rest of the queue goes green.

One note for the release

You have this right in the body and I want it recorded in the merge too: user-scoped operations now refuse UID 0, SSH edits refuse symlinks, user startup files no longer execute, and the daemon, helper and sudoers file must be installed together. That is a behavior change, and under 0.y it moves the middle digit. I will write the CHANGELOG entry to say so plainly rather than filing it under a fix.

Approving. This is the most consequential change anyone has contributed to this repo, and the fact that it comes with a guard that fails when the grants come back is why I am comfortable with it.

@vladimirrott

Copy link
Copy Markdown
Member

Two merges from me put your three open branches behind, and every conflict below is my merge order rather than anything you wrote. Recording it on one thread instead of three.

main now carries #426 as 4b7b5d5 and #421 as 026880a.

$ for n in 413 427 429; do git merge-tree --write-tree main p$n; done
413 CONFLICT: CHANGELOG.md docs/introduction.md
427 CONFLICT: CHANGELOG.md
429 CONFLICT: CHANGELOG.md crates/sysknife-daemon/tests/sudoers_grants_match_argv.rs docs/action-reference.md

One more on #413: tests/evidence/workspace-tests.json needs 1854 now rather than the 1853 it carries. #426 took main to 1853 and #413 adds one test above that. UPDATE_TEST_BASELINE=1 scripts/test_baseline.sh regenerates the artifact with its measurement fields; README.md, docs/introduction.md and docs/distro-support.md carry the same figure in prose and move in the same commit.

Say the word if you would rather I take the rebases. Otherwise push when it suits you and I will re-review at the new heads. #427 and #429 both touch .github/workflows/ci.yml, so neither can go through the merge gate until I have read that diff myself, and nothing is waiting on you tonight.

Three conflicts, all from my merge order rather than from this branch.

sudoers_grants_match_argv.rs: lacs-project#421's `checked += 1` and this branch's
shell-launcher assertion were inserted at the same point. Both kept, the counter
first so every examined spec is counted before the assertion can panic. The test
compiles and passes: 2 passed, 0 failed.

CHANGELOG.md: a `### Changed` section against main's `### Fixed`. Both kept,
Changed first per Keep a Changelog ordering.

docs/action-reference.md: not hand-merged. The file is generated, and the two
sides were different generations: this branch rewrote the command column to the
action-steps helper, lacs-project#426 stripped "; Ubuntu only" from descriptions. Hand-
merging a generated file is how it drifts from its source, so it was regenerated
from the merged catalogue:

    $ UPDATE_ACTION_REFERENCE=1 cargo test -p sysknife-daemon --test action_reference_doc
    test action_reference_doc_is_current ... ok
    $ cargo test -p sysknife-daemon --test action_reference_doc
    test action_reference_doc_is_current ... ok

The result carries both changes: 12 action-steps flatpak commands, zero
`runuser -u testuser`, and the stripped descriptions.
# Conflicts:
#	.github/workflows/ci.yml

@vladimirrott vladimirrott left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving at c38c9ba. I have merged main into this branch twice tonight, and I caused both rounds of conflicts by the order I chose to merge other PRs in. None of it is on you.

This round was one file. #427 landed a Check local gate discovery and required-check warnings step at the same anchor in ci.yml where you add Check bounded actions and removal of shell grants, so git could not pick an order. I kept both, #427's first. Against main, the resolved workflow adds your four lines and nothing else:

       - name: Check local gate discovery and required-check warnings
         run: bash tests/release/ci-local.test.sh
 
+      - name: Check bounded actions and removal of shell grants
+        # Root is used only in an isolated fixture subprocess to prove that the
+        # helper irreversibly drops credentials before touching user files.
+        run: sudo -n bash tests/release/action-steps.test.sh
+
       - name: Check the audit export states its confidentiality class

Everything else in the merge is git's own three-way result. I checked that by diffing my merge tree against git merge-tree --write-tree, and .github/workflows/ci.yml is the only path that differs.

The earlier round was three files: sudoers_grants_match_argv.rs, where I kept both the new counter and your shell-launcher assertion; CHANGELOG.md; and docs/action-reference.md, which is generated, so I regenerated it with UPDATE_ACTION_REFERENCE=1 cargo test -p sysknife-daemon --test action_reference_doc instead of hand-merging. The regenerated file carries both sides: 12 action-steps flatpak commands and zero runuser -u testuser.

One consequence of #427 you could not have seen coming. Its local runner discovers every tests/release/*.test.sh and runs it with plain bash, so your test now runs for contributors who have no sudo. I ran it as an unprivileged user:

test_real_drop_cannot_regain_root_or_write_through_a_symlink ... skipped 'real credential-drop test requires a root test subprocess'
Ran 11 tests in 0.018s
OK (skipped=1)

Your skipUnless(os.geteuid() == 0) does the right thing there, and the other ten still assert. scripts/ci-local.sh stays green without root.

Nothing left for you to do. I will merge once the board is green.

@vladimirrott
vladimirrott merged commit e011256 into lacs-project:main Sep 15, 2026
12 checks passed
@vladimirrott

Copy link
Copy Markdown
Member

Merged. #417 is closed with it.

Nine bounded grants in place of the sh and runuser -c blanket entries, and the helper drops credentials before it touches a user file rather than after. That was the oldest TODO in packaging/sysknife-sudoers and it is gone.

Two things worth saying about the end of this one, neither of them yours. Both rounds of conflicts came from the order I merged other pull requests in, and I resolved them rather than handing them back. And the gate could not prove this one at all when I started: your diff touches crates/*/src/*.rs, packaging/*, Makefile and a workflow, every one of them production, and no single verify suite runs all four. It refused, correctly, and I had to teach it to prove a receipt in parts before it could say anything about your work. It ends up with two:

rust   every_sudo_action_is_authorised_by_a_packaged_grant
       mutated the helper path in the daemon -> test FAILED
shell  tests/release/action-steps.test.sh
       loosened /action-steps flatpak * to /action-steps * -> AssertionError

Your skipUnless(os.geteuid() == 0) also caught a real problem in my tooling rather than in your code. The verify container was running contributor scripts as uid 0, so that case stopped skipping, tried a real chown with every capability dropped, and the gate blamed you for it. It runs unprivileged now.

You have eleven merged here. If you want the next one, #301 came back into the pool tonight: make install writes into /usr/lib on an rpm-ostree host and has never completed there. It is labelled hard and it needs a Fedora Atomic guest, which is the only reason it sat reserved for three weeks. Say the word and I will hold it for you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Drop the sh and runuser -c sudo grants: the file's own TODO has never been filed

2 participants