Skip to content

3b increment 4a: polkit authorization decision - #5

Merged
TroyHernandez merged 3 commits into
masterfrom
polkit-decision
Aug 18, 2026
Merged

TroyHernandez merged 3 commits into
masterfrom
polkit-decision

Conversation

@TroyHernandez

Copy link
Copy Markdown
Contributor

First reviewed slice of the polkit authorization branch (contract §4.4, lifecycle
step 2), held before it is wired into .commit_session. Pure decision + an
injectable pkcheck seam — no broker, no intent, no dpkg.

Same cadence as increment 2 (the classifier) → increment 3 (the orchestration):
build the decision first, wire it next.

What this adds

.authorize(verb_spec, interactive) → one of a closed decision vocabulary:

mode behaviour
interactive returns "authorized" without running pkcheck — the pkexec prompt authenticates at the entrypoint spawn later (a cancelled prompt becomes a known-false unauthorized outcome on the effect intent)
machine runs a native, non-interactive pkcheck for the verb's polkit action against this process's race-safe pid,start-time,uid subject, and maps the exit code

Action id: ai.cornball.runix.apt.<verb> (pinned to
pkgexec/polkit/ai.cornball.runix.apt.policy).

pkcheck exit code → decision, pinned to the tested canary matrix
(deploy/canary-apt/polkit-matrix.sh:17-18):

rc decision
0 authorized
1 unauthorized (flat deny)
2, 3 approval_required (a challenge that can't be obtained non-interactively)
else (124/126/127/…, NA, non-numeric) check_failed — fail closed, never silently authorized

Autonomous update/hold need no special-casing: the runix-apt-autonomous
rule grants members rc 0 through the same check; a non-member falls through to a
refusal like any other verb.

Not a privilege boundary

The pkcheck call is behind an injectable seam (set_pkcheck()) so the decision
is hermetic (36 tests, no real polkit; 401 total). polkit still enforces at the
pkexec spawn inside runix's C, so a test-substituted check can only make pkgops
proceed to a commit pkexec then denies, or refuse one it would have allowed —
both degrade safely. pkcheck carries no secret (unlike the pkexec commit), so a
system2 spawn without a shell is appropriate.

Deferred to later increments (marked in source)

  • 4b — wire the decision into .commit_session: authorized proceeds to the
    effect intent; a machine-mode refusal opens a plain intent via
    runix::broker_audit_sink()/audit_two_phase() and writes the terminal
    unauthorized/approval_required outcome (effect_issued=FALSE) under one
    correlation_id, then stops (contract §4.4, "no unused effect receipt for a
    refusal"). Mode is a caller-supplied parameter — runix exposes no TTY probe, so
    detection stays at the rctl/CLI layer.
  • pkgstate verification, then the exported per-verb apt_<verb>() API.

Point for review

The exact rc 1-vs-2 split (unauthorized vs approval_required) is pinned to
the canary contract; it's confirmed against real polkit behaviour in the VM-gated
increment where the terminal outcomes meet a live broker.

CI hermetic; R CMD check clean but for the expected "New submission" WARNING.

Add the §4.3-step-2 decision layer (R/polkit.R): given a verb and the
mode, decide whether a commit may proceed. This is the decision only; the
next increment wires it into .commit_session (authorized -> effect intent;
a machine-mode refusal opens a plain intent + terminal outcome and stops).

  .authorize(verb_spec, interactive)
    interactive  -> "authorized" WITHOUT pkcheck; the pkexec prompt
                    authenticates at the entrypoint spawn later
    machine      -> a native, non-interactive pkcheck for the verb's action
                    (ai.cornball.runix.apt.<verb>) against this process's
                    race-safe pid,start-time,uid subject, mapped to a
                    decision

pkcheck exit code -> decision, pinned to the tested canary matrix
(deploy/canary-apt/polkit-matrix.sh): 0 authorized, 1 unauthorized, 2/3
approval_required (a challenge unobtainable non-interactively), else
check_failed (fail closed, never silently authorized).

The pkcheck call is behind an injectable seam (set_pkcheck), so the
decision is hermetic (36 tests, no real polkit). It is NOT a privilege
boundary: polkit still enforces at the pkexec spawn inside runix's C, so a
substituted check can only make pkgops proceed to a commit pkexec then
denies, or refuse one it would have allowed -- both degrade safely.

Autonomous update/hold need no special-casing: the runix-apt-autonomous
rule grants members rc 0 through the same check.

Boundary for review: the exact rc 1-vs-2 split (unauthorized vs
approval_required) is pinned to the canary contract and confirmed against
real polkit in the VM-gated increment.
.pkcheck_decision() guarded only finiteness, so as.integer() would
truncate a fractional 1.5 to 1 and classify it as `unauthorized` rather
than `check_failed`. Require a finite, scalar, integer-valued numeric
(rc == floor(rc)) before coercion, so a malformed/non-integral result
fails closed. Regression tests for 1.5, 2.5, 0.9, NA_real_; a
integer-valued double (2.0) still maps normally.
@TroyHernandez

Copy link
Copy Markdown
Contributor Author

Fixed in the latest commit. .pkcheck_decision() now requires a finite, scalar, integer-valued numeric (rc == floor(rc)) before as.integer(), so a fractional 1.5 fails closed as check_failed rather than truncating to 1/unauthorized. Regression tests added for 1.5, 2.5, 0.9, and NA_real_ (and 2.0 still maps normally).

@TroyHernandez
TroyHernandez marked this pull request as ready for review August 18, 2026 17:33
@TroyHernandez
TroyHernandez merged commit d55e2ff into master Aug 18, 2026
1 of 2 checks passed
@TroyHernandez
TroyHernandez deleted the polkit-decision branch August 18, 2026 17:34
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.

1 participant