From ca8ad2372a815169f89854ff5ed6f7ff526b2896 Mon Sep 17 00:00:00 2001 From: yuee3 Date: Tue, 22 Sep 2026 18:22:45 +0800 Subject: [PATCH 1/3] docs: align Security Model wording with sudoers and pin the claim --- SECURITY.md | 33 +++++++++++++++++++++++++++++---- scripts/check_public_claims.sh | 7 +++++++ 2 files changed, 36 insertions(+), 4 deletions(-) mode change 100755 => 100644 scripts/check_public_claims.sh diff --git a/SECURITY.md b/SECURITY.md index fd88c7c9..704ef5e5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -40,8 +40,33 @@ We will: ## Security Model -SysKnife uses a layered enforcement model. Every layer is independent; a -bypass of one does not bypass the others. +SysKnife uses a layered enforcement model. Each layer gates a different +stage of one request path: intent validation, action-name allowlisting, +role authorization, one-time approval receipts, and atomic execution claims. +Multi-step and user-scoped actions use independently validated, +operation-restricted helpers. Other grants still cover powerful +administrative tools: the daemon's authorization, validation and audit +remain essential. + +The layers are sequential gates, not independent walls. They all run inside +one process, and that process runs as the `sysknife` service account +(`User=sysknife` in `packaging/sysknife-daemon.service`), which is +root-equivalent by design. That account holds `NOPASSWD` grants for +`useradd`, `systemctl`, and a trailing-wildcard `apt-get`, each of which +reaches root on its own. A compromise of the daemon process is therefore a +compromise of root on that host. An operator sizing the blast radius of a +daemon compromise should read the grants in `packaging/sysknife-sudoers` +alongside this model. + +### What the denylist is — and is not + +`ROOT_SHELL_UNITS` in `crates/sysknife-daemon/src/actions/validate.rs` +refuses typed actions naming `debug-shell`, `emergency`, `rescue`, +`runlevel1`, or `single`. The denylist stops a unit name arriving from the +LLM or from an MCP client. It is not containment: the same `sysknife` +account runs `sudo -n /usr/bin/systemctl start rescue.target` with no +validator in the path, because the `systemctl` grant carries no argument +restriction. Do not treat the denylist as a boundary around the daemon. ### Layer 1 — Intent validation (sysknife-brain, before LLM call) @@ -369,8 +394,8 @@ removal detectable. ## Known Limitations These are acknowledged gaps tracked as open issues. They do not -represent exploitable vulnerabilities in normal use — the downstream -enforcement layers cap their blast radius — but they are relevant for +represent exploitable vulnerabilities in normal use — the later gates on +the same request path still apply — but they are relevant for security certification work. | Gap | Issue | Notes | diff --git a/scripts/check_public_claims.sh b/scripts/check_public_claims.sh old mode 100755 new mode 100644 index 045acd71..615ee010 --- a/scripts/check_public_claims.sh +++ b/scripts/check_public_claims.sh @@ -63,6 +63,13 @@ if ! python3 "$script_dir/check_evidence_claims.py" "$repo_root"; then fi reject_pattern 'until npm publish lands|publish[- ]pending' \ 'setup package is documented as unpublished' "${claim_files[@]}" +# SECURITY.md once said every enforcement layer is independent and a bypass of +# one does not bypass the others. The layers are sequential gates on one +# request path sharing one root-equivalent account (see issue 483); the sudoers +# header wording is the accurate one. Pin the claim so the independence +# framing cannot drift back. +reject_pattern 'Every layer is independent|a bypass of one does not bypass the others' \ + 'layers are sequential gates on one request path, not independent walls' "${claim_files[@]}" reject_pattern 'Fedora([^\n]|$)*(Workstation|Server)([^\n]|$)*fully supported|(Workstation|Server)([^\n]|$)*fully supported' \ 'plain Fedora requires the unfinished dnf action family' "${claim_files[@]}" reject_pattern 'plan and approve from inside (Claude|chat)|chat approval is sufficient' \ From 53b0dc3bf21dee4df75df63b7cb12f9455c1ae6e Mon Sep 17 00:00:00 2001 From: xuan <118178092+yuee3@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:34:42 +0800 Subject: [PATCH 2/3] fix: make the independence pin fire on SECURITY.md SECURITY.md was not in CLAIM_FILES, so the reject_pattern added for it ran over a list that did not include the file. Add it, restore the executable bit on check_public_claims.sh, spell the grants the way packaging/sysknife-sudoers writes them, and switch the denylist example to the systemctl enable grant. Extend public-claims.test.sh with the mutation: sentence restored -> red, sentence removed -> green. --- SECURITY.md | 17 +++++++++-------- scripts/check_evidence_claims.py | 6 ++++++ scripts/check_public_claims.sh | 0 tests/release/public-claims.test.sh | 8 ++++++++ 4 files changed, 23 insertions(+), 8 deletions(-) mode change 100755 => 100644 scripts/check_evidence_claims.py mode change 100644 => 100755 scripts/check_public_claims.sh mode change 100755 => 100644 tests/release/public-claims.test.sh diff --git a/SECURITY.md b/SECURITY.md index 704ef5e5..4141579f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -52,11 +52,12 @@ The layers are sequential gates, not independent walls. They all run inside one process, and that process runs as the `sysknife` service account (`User=sysknife` in `packaging/sysknife-daemon.service`), which is root-equivalent by design. That account holds `NOPASSWD` grants for -`useradd`, `systemctl`, and a trailing-wildcard `apt-get`, each of which -reaches root on its own. A compromise of the daemon process is therefore a -compromise of root on that host. An operator sizing the blast radius of a -daemon compromise should read the grants in `packaging/sysknife-sudoers` -alongside this model. +`useradd --create-home *`, one `systemctl` grant per subcommand, and +`env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a /usr/bin/apt-get *`, +each of which reaches root on its own. A compromise of the daemon process is +therefore a compromise of root on that host. An operator sizing the blast +radius of a daemon compromise should read the grants in +`packaging/sysknife-sudoers` alongside this model. ### What the denylist is — and is not @@ -64,9 +65,9 @@ alongside this model. refuses typed actions naming `debug-shell`, `emergency`, `rescue`, `runlevel1`, or `single`. The denylist stops a unit name arriving from the LLM or from an MCP client. It is not containment: the same `sysknife` -account runs `sudo -n /usr/bin/systemctl start rescue.target` with no -validator in the path, because the `systemctl` grant carries no argument -restriction. Do not treat the denylist as a boundary around the daemon. +account runs `sudo -n /usr/bin/systemctl enable --now debug-shell.service` +with no validator in the path, because the `systemctl enable` grant matches +any arguments. Do not treat the denylist as a boundary around the daemon. ### Layer 1 — Intent validation (sysknife-brain, before LLM call) diff --git a/scripts/check_evidence_claims.py b/scripts/check_evidence_claims.py old mode 100755 new mode 100644 index 8bd0f3e9..7a348afb --- a/scripts/check_evidence_claims.py +++ b/scripts/check_evidence_claims.py @@ -53,11 +53,17 @@ # So the rule is now: if a file states a figure about this project, it belongs # here. Adding a file is cheap; the guard only complains about numbers it can # derive an answer for. +# +# SECURITY.md carries the Security Model wording that +# check_public_claims.sh's independence pin screens. Without it listed here the +# pin ran over claim_files and could not fire on the one file it was written +# for: restoring the retired sentence left the check green. CLAIM_FILES = ( "README.md", "ROADMAP.md", "CONTRIBUTING.md", "HACKING.md", + "SECURITY.md", "docs/introduction.md", "docs/quickstart.md", "docs/distro-support.md", diff --git a/scripts/check_public_claims.sh b/scripts/check_public_claims.sh old mode 100644 new mode 100755 diff --git a/tests/release/public-claims.test.sh b/tests/release/public-claims.test.sh old mode 100755 new mode 100644 index bae23353..5a8dd68e --- a/tests/release/public-claims.test.sh +++ b/tests/release/public-claims.test.sh @@ -779,6 +779,14 @@ printf '\nFedora Workstation 44 is fully supported.\n' >> "$fixture/docs/archite assert_rejected 'forbidden claim in a file only the Python list knew about' cp "$repo_root/docs/architecture.md" "$fixture/docs/architecture.md" +# The layer-independence framing the Security Model rewrite retired. +# SECURITY.md was not in CLAIM_FILES, so the pin written for this file +# could not fire on it: restoring the sentence left the check green even +# with the reject_pattern in place. Both lists now include SECURITY.md. +printf '\nSysKnife uses a layered enforcement model. Every layer is independent; a\nbypass of one does not bypass the others.\n' >> "$fixture/SECURITY.md" +assert_rejected 'layer-independence framing restored in SECURITY.md' +cp "$repo_root/SECURITY.md" "$fixture/SECURITY.md" + printf '\nlocal-clone path until npm publish lands\n' >> "$fixture/README.md" assert_rejected 'publish-pending setup language' cp "$repo_root/README.md" "$fixture/README.md" From a2be20803f55f66e0c7ab7b708bf4c1686097a07 Mon Sep 17 00:00:00 2001 From: Vladimir Rotariu Date: Wed, 23 Sep 2026 10:37:03 -0600 Subject: [PATCH 3/3] docs(security): name the grants that still exist after 0.21.0 This branch's Security Model rewrite cited `useradd --create-home *` and the per-subcommand `systemctl` grants as the examples of what the daemon account can reach root with, and `systemctl enable --now debug-shell.service` as the path around ROOT_SHELL_UNITS. Both were true when it was written and stopped being true in 0.21.0, which routed those verbs through the validating helper and dropped the grants. The point the section makes is unchanged and still correct: the layers are sequential gates on one request path, and the account is root-equivalent. The examples now name grants that are actually in the file, and the denylist section says what 0.21.0 did and does not overstate it. --- SECURITY.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 4141579f..eb446b08 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -52,9 +52,10 @@ The layers are sequential gates, not independent walls. They all run inside one process, and that process runs as the `sysknife` service account (`User=sysknife` in `packaging/sysknife-daemon.service`), which is root-equivalent by design. That account holds `NOPASSWD` grants for -`useradd --create-home *`, one `systemctl` grant per subcommand, and `env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a /usr/bin/apt-get *`, -each of which reaches root on its own. A compromise of the daemon process is +`/usr/bin/snap install *` and `/usr/bin/rpm-ostree install *`, each of which +reaches root on its own: apt through `-o APT::Update::Pre-Invoke::`, snap and +rpm-ostree through the install hooks and scriptlets they run as root. A compromise of the daemon process is therefore a compromise of root on that host. An operator sizing the blast radius of a daemon compromise should read the grants in `packaging/sysknife-sudoers` alongside this model. @@ -64,10 +65,20 @@ radius of a daemon compromise should read the grants in `ROOT_SHELL_UNITS` in `crates/sysknife-daemon/src/actions/validate.rs` refuses typed actions naming `debug-shell`, `emergency`, `rescue`, `runlevel1`, or `single`. The denylist stops a unit name arriving from the -LLM or from an MCP client. It is not containment: the same `sysknife` -account runs `sudo -n /usr/bin/systemctl enable --now debug-shell.service` -with no validator in the path, because the `systemctl enable` grant matches -any arguments. Do not treat the denylist as a boundary around the daemon. +LLM or from an MCP client. + +Until 0.21.0 it was also trivially bypassable: the account held one `systemctl` +grant per subcommand, each ending in a wildcard, so +`sudo -n /usr/bin/systemctl start debug-shell.service` reached a root shell with +no validator in the path. The unit verbs now run through +`/usr/lib/sysknife/action-steps`, which applies the same list to the verbs that +bring a unit up whether the daemon or a direct sudo call invoked it +(GHSA-c7rw-23qw-5w33). + +That closes the path around the denylist; it does not make the denylist +containment. It is a list of five unit names, and the package-manager grants +above reach root without naming a unit at all. Do not treat it as a boundary +around the daemon. ### Layer 1 — Intent validation (sysknife-brain, before LLM call)