fix(service): refuse a system unit SELinux proves cannot start - #8474
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Fail-open, mechanism-derived pre-flight that converts a silent crash-loop into an up-front refusal, with the real [DESIGN-REVIEWED] 2e81647 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of First-Principles-Verdict: PASS Every item traces to the kernel-verified defect or a hole this change itself opens, the deeper What this change shipsIntent: stop
WatchItem 5 fires on every failed restart on all of RHEL/Fedora, including hosts the gate proved ALLOW for; the author bounded it (hypothesis-only, no remedy paste, empty when not enforcing) and pinned the bound in tests, so it earns its place — but it is the one item a human should confirm the full description actually declares. [FIRST-PRINCIPLES-REVIEWED] 2e81647 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
UX Review (Fable 5) — ⏭️ skippedRevision |
7855337 to
c90c3b9
Compare
c90c3b9 to
e18e0fa
Compare
e18e0fa to
03e55b5
Compare
03e55b5 to
65d1a7e
Compare
65d1a7e to
5c0e3f1
Compare
On an SELinux-enforcing host whose kirocrew lives under $HOME, PID 1's domain is denied execute on the ExecStart binary, so the system unit fails every start with 203/EXEC and crash-loops to its restart limit. Ask the loaded policy before writing anything and refuse with a rendered user-scope unit as the remedy. The check is mechanism-based (enforcing state, PID 1's own domain, the file's label, the policy verdict -- no distro or type names) and fails open on every indeterminate answer, so a host without SELinux or in permissive mode is byte-identical to before. The pre-flight can only prove denials for the file systemd itself execs, so when a unit installs and then will not start on an enforcing host the restart error now names SELinux as a candidate, with the confirming ausearch command and the same remedy. Fixes #7165
5c0e3f1 to
2e81647
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Reviewed the full diff. blocks_system_unit refuses only when enforce=1, both contexts resolve, compute_av parses, permissive is clear and execute is absent on the realpath'd binary/interpreter -- every indeterminate branch returns False, so non-SELinux Linux and macOS are byte-identical. No subprocess added; 753/1308 lines are tests. Two Low notes: two stale docstrings in selinux.py/linux.py, and Fixes #7165 contradicts the body's "issue stays open" -- please drop or reword the trailer. Approving.
What is the problem?
On an SELinux-enforcing host whose kirocrew lives under
$HOME-- the default onBazzite, Fedora Silverblue/Kinoite and other atomic desktops --
kirocrew service installwrites a system unit at/etc/systemd/system/kirocrew.servicethatcan never start. It fails every start with
status=203/EXECand crash-loopsuntil it exhausts
StartLimitBurst.203/EXECis systemd reporting that it could not execute the binary, and it hasfour causes that are indistinguishable in the unit's status output: the path
does not exist, it is not executable, its shebang interpreter is wrong, or the
file is perfectly fine and SELinux refused the execute. This issue is the last
one, which is the only one that survives every check an installer would normally
run.
I established which cause it is before changing anything. The evidence is a
read-only
compute_avquery against the loaded policy on a Fedora-familytargetedpolicy (/sys/fs/selinux/access-- a pure decision lookup thatrelabels nothing, needs no root, and writes no state):
executegetattrsystem_u:system_r:init_t:s0(PID 1)user_home_tsystem_u:system_r:init_t:s0(PID 1)bin_tunconfined_u:unconfined_r:unconfined_t(user manager)user_home_tThe reply's
flagsword was0, soinit_tis not a per-domain permissivedomain -- this is a real policy denial, not one the kernel would log and allow.
getattrALLOW withexecuteDENY is the whole diagnostic problem. It meansa positive on "is this path fine?" is exactly what you get in the broken case:
the file exists,
os.access(..., X_OK)andtest -xreturn True,statworks, the shebang is correct. A file that passes
test -xand still fails203/EXECis the signature of this cause, and no file-mode check can distinguishit. Only asking the policy can.
Why this issue matters to the user
Out-of-the-box
kirocrew service installis broken on every atomic Fedoravariant, and it fails in the least useful way available: the install writes the
unit,
enables it, then fails at the firstsystemctl restartwith "runjournalctlfor details". The host is left with an enabled unit thatcrash-loops at every boot, and the message names none of the cause. The
reporter had to read the audit log themselves to find it.
How our fix solves it
Chain from symptom to root cause: the symptom is a crash-looping unit; the
mechanism is
init_tdeniedexecuteon the ExecStart binary's label; the rootcause is that the installer commits to system scope without ever asking whether
system scope can work on this host. So it now asks, before it writes anything.
New
kiro_crew/service/selinux.pyanswers one question -- can the domain thatwill perform the
execveactually execute the file we are about to name inExecStart? -- andinstall()refuses up front when the answer is provably no.Three properties matter:
apparmor.pyalreadystates for its own gate. Nothing matches a distro name, a version, or even a
type name. Every input is read from the running kernel: enforcing state from
/sys/fs/selinux/enforce, the executing domain from/proc/1/attr/current(whatever PID 1 actually is, not a hardcoded
init_t), the file's label fromits
security.selinuxxattr, and the verdict from the loaded policy. This isnot theoretical: on my development host the checkout is outside
/home, so itis labelled
default_trather than theuser_home_tthe issue reports -- andthe gate fired correctly on it. A type-name match would have missed it.
permissive mode, an unreadable label, a kernel that refuses the query, a
truncated reply, a per-domain permissive source -- every one returns "not
blocked" and the install proceeds byte-identically to today. A pre-flight that
guesses wrong in the refusing direction would break installs that currently
work, which is worse than the bug.
purpose. Stopping before the first write leaves the host exactly as found,
instead of enabled-and-crash-looping.
The refusal embeds a ready-to-paste user-scope unit rendered by the real
render_unit(), so the operator's working unit carries the sameExecStartandthe same baked environment as the unit we would have installed and cannot drift
from it. Two directives differ, and both are hard requirements rather than style:
User=/Group=are omitted (a user manager rejects them, making the unitunloadable) and
WantedBy=default.targetreplacesmulti-user.target(a systemtarget the user manager does not have).
render_unit()'s default is unchanged,so every existing caller gets today's unit byte-for-byte.
The message also records a fix that looks right and provably is not:
relocating only the launcher to a system-labelled path does not help, because
whatever systemd execs still runs in PID 1's domain, so the next
execveof thebinary under
$HOMEis denied identically.init_t -> user_home_tis deniedregardless of who initiates the chain.
No override knob, deliberately. The check reads the live policy, so an
operator who loads a policy module granting the access turns it off by itself --
compute_avstarts answering ALLOW and the gate goes quiet with no flag toremember. An escape hatch could only ever re-enable an install that provably
crash-loops.
What this PR deliberately does NOT do
It does not add a
--userinstall scope (option 1 in the issue). That is aninstall-model change, not a mechanical one: it has to decide where the AppArmor
profile lives when the install needs no root, what replaces the root-owned
/etc/kirocrew/kirocrew.env, howstatus/restart/uninstall/logsbecomescope-aware, and how Make Live's drop-in indirection (#1598) follows. Shipping
half of that would add a second broken path. The predicate this PR lands is the
groundwork an auto-select would call, and the issue stays open for that decision.
What tests we did
50 new tests in
test/test_service_selinux.py, all pure-logic: every kernelinterface is redirected at a
tmp_pathfake or monkeypatched, so the suite givesidentical verdicts on an AppArmor-only CI runner and on an SELinux-enforcing
workstation, and no test is ever in a position to write to real selinuxfs. That
constraint drove one design change -- the selinuxfs transport was split into
_query_access()so tests patch a module seam instead ofos.open/os.readglobally, which breaks pytest's own I/O and leaves a test one edit away from
writing to
/sys.Mutation-verified: 17/17 mutants caught. Each guard was broken in turn and a
named test had to fail:
test_quiet_when_not_enforcingtest_quiet_when_source_domain_is_permissivetest_bit_number_becomes_a_masktest_permissive_flag_is_taken_from_the_last_fieldtest_label_is_read_through_a_symlink_chaintest_fires_on_a_denied_shebang_interpretertest_install_raises_before_touching_the_hostUser=/Group=test_user_unit_omits_user_and_groupmulti-user.targettest_user_unit_wants_default_targettest_gate_is_asked_about_the_unit_exec_pathtest_refusal_never_lets_the_pasting_shell_pick_the_account$USERfor lingertest_refusal_never_lets_the_pasting_shell_pick_the_accounttest_refusal_warns_that_a_root_shell_would_run_the_agent_as_roottest_remedy_quotes_a_home_containing_a_spacetest_enforcing_host_gets_the_selinux_hypothesis_and_remedytest_non_enforcing_host_gets_no_selinux_noisetest_enforcing_host_gets_the_selinux_hypothesis_and_remedyEnd-to-end against this host's real kernel, in two arms. Arm A: untouched.
The host is SELinux-enabled but permissive, so the gate must stay silent -- it
did, via the real
/sys, exercising the fail-open path. Arm B: onlyselinux_is_enforcingforced True, every other input real -- the real/proc/1/attr/current(system_u:system_r:init_t:s0), the realsecurity.selinuxxattr of the real installed binary, and a realcompute_avquery against the real loaded policy. It fired and named the real label. That is
the reporter's host in every respect except the one bit this machine has set
differently.
test/test_service.py(301 tests) passes unchanged. Four failures in it arepre-existing and unrelated --
TestExpectedUidOverride/TestATakeoverOf...fail with "the directory /local/home is owned by uid 65534", the single-uid
user-namespace mapping in my tool sandbox; verified failing identically on the
base commit before my changes.
flake8,isortandmypyare clean on allchanged files.
Review round 1
GPT 5.6 raised one blocking, security-class finding and it was correct, so it is
fixed rather than overridden. A user unit carries no
User=-- the account it runsas is whichever manager loads it -- and
service installis documented to rununder
sudo, so the shell reading this refusal is usually root's. My firstremedy said
mkdir -p ~/.config/systemd/userandloginctl enable-linger "$USER", both resolved by the pasting shell: from root they name/rootandroot, and the operator ends up running untrusted agent tools as root --precisely the invariant
install()enforces by refusing aUser=rootunit a fewlines earlier. The remedy now spells out the resolved account and its absolute
home, passes the account name to
loginctlexplicitly, says what a root shellwould do, and notes that
sudo -u <user>cannot substitute (it creates nosession, so
systemctl --userhas no manager to reach). Three mutants pin it.The first-principles review was advisory (CONCERNS) and both of its subtractions
were taken:
render_unit's two-valuescopestring becameuser_scope: bool(exactly one non-default variant is ever constructed) and the four
selinux.pyhelpers are underscore-prefixed, leaving
blocks_system_unitthe module's onlypublic surface. Design Review PASS and Opus (no findings) needed no changes.
Review round 2
GPT raised a second blocking finding and it is also real, though its stated
symptom is not: a
KIROCREW_SERVICE_BINoverride can name a system-labelledwrapper that goes on to run a binary under
$HOME. The pre-flight sees only thewrapper and its shebang interpreter, both allowed, so it reports "not blocked" and
the unit still cannot serve. It does not, however, fail with
203/EXEC-- thewrapper execs fine, so the inner denial surfaces as the shell's exit 126. The
defect is real even though the mechanism was described wrong, so it is fixed
rather than overridden.
It is fixed at the point of failure rather than by closing the static hole,
because the hole cannot be closed soundly. Following the delegation means deciding
what an arbitrary shell script executes. The weaker version -- scanning the wrapper
for path-shaped literals -- would refuse an install over a path in a comment or an
untaken branch, since a literal appearing in a script is not proof it is ever
executed; and refusing on "cannot inspect" is the same violation from the other
side. Either would manufacture false refusals and break the fail-open rule the
whole module rests on.
So: the pre-flight's coverage boundary is now stated explicitly in the module
docstring, and
install()covers the residue where the unit has actually failed.When the first
systemctl restartfails on an enforcing host, the error now namesSELinux as a candidate cause, says plainly that the pre-flight found no proven
denial and why its reach is limited, gives
ausearch -m avc -ts recentto confirmit, and prints the same user-scope remedy. On a non-enforcing host the message is
unchanged. That covers wrappers, computed paths, and anything else static analysis
cannot prove, and it adds no false refusals -- five mutants pin it.
The non-blocking finding was also correct and is fixed: both generated paths in the
remedy now go through
shlex.quote, matching whatcommon.pyalready does for the.envremedy, so an account home containing a space no longer word-splitsmkdirand land the unit where systemd never reads it.
Review round 3 (CI only, no new review findings)
Three CI reds, all mine, all fixed: the De-Amazon scrub lint reads
/home/two words(my space-bearing test fixture) as a personal home path, so the fixture isnow
/home/tester with space, whose first segment matches the placeholder the restof the file already uses -- the lint passes locally. The Windows shard failed
test_ordinary_paths_are_not_needlessly_quoted, becauseshlex.quoteis POSIX andquotes the backslashes in a Windows path, so "needs no quoting" is only meaningful
on POSIX; that one test is now POSIX-scoped with the reason stated. The branch is
also rebased onto current
mainand squashed to one commit, which the Hygiene gaterequires (it caps a PR at two).
One remaining red is not mine, and I proved it rather than asserting it.
Backend Tests (Windows) (2)failstest_dashboard_cron_to_chat.py::...::test_a_repeat_is_suppressed_even_though_history_is_nonewith
assert 1 == 2. Nothing in this diff reaches the cron-to-chat path, so Itraced the mechanism instead of waving at it.
CronJob.set_run_result()stampslast_result_ts = time.time();cron_injectrenders the run-boundary marker as<!-- ...<job.id>:<ts:.6f} -->and the dedupe compares the whole row, so thatstamp is what makes two runs distinguishable. The test does two runs back to back
with no sleep. On Linux
time.time()advances at microsecond scale and the markersdiffer; on Windows under CPython 3.12 it comes from
GetSystemTimeAsFileTimeatthe ~15.625 ms system tick, both calls return the same float, the second row is
byte-identical, and it is dropped as a repeat. The CI Windows shard pins 3.12 on
purpose, and
time.time()'s Windows resolution only improved in 3.13.Confirmed by reproducing it on Linux: quantizing
time.time()to a 15.625 ms tickmakes the identical assertion fail deterministically, with none of this change
involved; the same test passes on the same tree with the real clock. It presents as
intermittent because whether the two calls straddle a tick depends on load and on
what ran before them, which is why merely adding tests to the suite can flip it.
Filed separately as #8502 with the mechanism and the repro rather than folded in
here, and the lane is being re-run.
Review round 4
Board on the current head: Design Review PASS, Opus 4.8 no findings, GPT 5.6 no
findings and no
[BLOCK-MERGE]. First Principles is CONCERNS (advisory), and one ofits two subtractions was a real defect I had shipped, so it is taken.
Taken. The start-failure hint pasted the full 25-line user-scope remedy onto
every failed
systemctl restarton any enforcing host -- which is all ofRHEL/Fedora -- including hosts the pre-flight had positively proved ALLOW for. A
plain port conflict on a stock RHEL box would have been answered with a wall of
SELinux text and a pasteable unit for a denial nobody had observed, and the
function's own docstring conceded the denial is a hypothesis there. The hint is now
the hypothesis, the
ausearchcommand that settles it, and a pointer to thedocumented remedy: 15 lines instead of 68, with the full remedy reachable only
behind a proven denial. Two tests pin it -- one asserts the remedy is absent from
the hint path, one bounds the hint's size and its ratio to the refusal.
Declined, with reasoning. The second subtraction asks to drop
blocks_system_unit's not-blocked reason string as having zero consumers. It istrue that
install()reads the reason only insideif blocked:. But that string iswhat lets a test say WHICH fail-open branch fired --
test_quiet_when_not_enforcingasserts
"not enforcing"appears, rather than merely that the call returned False.In a gate whose correctness is mostly its five distinct fail-open branches (not
enforcing, unreadable PID 1 context, no label, no policy answer, permissive domain),
collapsing them to a bare
Falseremoves the only evidence that the intended branchis the one being exercised, and a test that cannot tell them apart would pass with
four of them wired to the wrong condition. Keeping ~40 bytes of reason string is
cheaper than losing that discrimination.
What I could NOT verify, stated plainly
I did not install, enable, start, or modify any systemd unit, and did not run
restorecon,chcon,semanageorsetenforce. This is a shared developmenthost with other work running on it, and changing its SELinux state or its units
would affect all of it. Concretely unverified:
203/EXECreproduction. I did not install a system unit and watchit fail. The causal claim rests on the policy verdict plus systemd's
documented behaviour when
execvereturnsEACCES, not on an observed crashloop.
permissive, so I forced the enforcing bit inArm B rather than observing it.
compute_avreturns the policy decisionindependent of global mode, and
flags=0rules out per-domain permissive, sothe verdict is real -- but the consequence of that verdict is inferred.
targetedpolicy, Fedora-derived and the same policy name, not Bazzite's Fedora 44
policy.
init_t-denied-execute-on-home-labels is core refpolicy and thereporter's own AVC matches it, but I confirmed it on a sibling policy, not
theirs.
did not install a wrapper unit and watch it fail with exit 126, for the same
host-safety reason as (1).
is reasoned from
systemd.unit(5)plus the reporter's own verified workaround,and I did confirm locally that
systemd --userruns inunconfined_t(readfrom
/proc/<pid>/attr/currenton two live user managers) and thatunconfined_tis allowedexecuteonuser_home_t-- which is the structuralreason a user unit is not affected.
The honest summary: the cause is directly measured, the remedy's mechanism
is directly measured, and the end-to-end behaviour on an enforcing atomic
desktop is reasoned, not observed. A maintainer with a Bazzite or Silverblue
host can close gaps 1-4 in about five minutes.
Any other suggestions on the work
kirocrew doctorshould report this too, so an operator who already has abroken unit installed learns why without re-running
install. Left out to keepthis diff to one concern.
kirocrew-pod@.serviceis a user unit, so itis unaffected) and for the AppImage launcher path; neither is touched here.
--userscope does land, the auto-select heuristic the issue asks for isexactly
selinux.blocks_system_unit(kirocrew_bin())-- the predicate is alreadyin the shape a caller would want.
Pattern harvest
Rule candidate: review-checklist
Pattern: an execute-permission conclusion drawn from
os.access(path, os.X_OK)ortest -x. Under an LSM execute denial both return True whileexecvefails, so the check cannot fail in the failing case. Any code that gates an exec decision on a mode test needs a second question: is the caller's domain permitted to execute it?Rule candidate: semgrep
Pattern: a test that monkeypatches
os.open/os.read/os.writeto fake a/sysor/procinterface. It breaks pytest's own I/O and leaves the test one edit away from performing the real write; the fix is to extract a module-level transport seam and patch that.Supporting notes
203/EXECis four different bugs wearing one error code. Before fixing anexec failure, name which of "absent / not executable / bad interpreter / policy
denied" it is. They are indistinguishable in
systemctl statusand havedisjoint fixes.
getattris ALLOW whileexecuteis DENY, so "the path exists and isexecutable" returns True in precisely the broken case. This generalises past
SELinux: whenever a denial and a success share an observable, find the
observable that separates them.
/sys/fs/selinux/accessanswers "would this be allowed?" without performingthe action, needing root, or changing any state. Reaching for
ausearchaftera crash-loop is the same answer obtained destructively and later.
apparmor.py, andit paid off again). The gate fired on
default_ton my host anduser_home_ton the reporter's; any type-name or distro match would have missed one of them.
Enumerate the "cannot tell" branches explicitly -- absent interface, unreadable
label, refused query, truncated reply, permissive domain -- and route them all
to "proceed".
environment. The four
uid 65534failures were confirmed identical on base;saying "sandbox artifact" without that check is a guess.
refusal told operators to run
kirocrew service print-unit, which does notexist -- the subcommand list is
install/uninstall/status. There is now atest asserting the message names only real subcommands, because a remedy that
sends the user to
invalid choiceis worse than no remedy.Fixes #7165