Skip to content

3b increment 4b: wire polkit authorization into .commit_session - #6

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

TroyHernandez merged 3 commits into
masterfrom
polkit-wiring

Conversation

@TroyHernandez

Copy link
Copy Markdown
Contributor

Wires the polkit authorization decision (increment 4a) into the commit
orchestrator — contract §4.3 step 2, including the plain-intent terminal refusal
path
. Still hermetic (the broker, pkexec/pkcheck, and dpkg are all behind
seams) and still internal: pkgstate verification and the exported API remain.

The branch

Step 2 runs .authorize(verb_spec, interactive) after the capability
negotiation and before the effect intent is opened:

decision what happens
authorized proceed to the effect intent (the existing open→commit→classify→write_outcome path)
unauthorized / approval_required (machine mode) .refuse(): open a plain intent (no receipt) via the seam's refuse op and write the terminal outcome (effect_issued = FALSE) under one broker cid, then signal runix_unauthorized / runix_approval_required. No effect intent is ever opened for a refusal, so no unused effect receipt is minted (§4.4).
check_failed fail closed with nothing recorded (pkgops_polkit_check_failed) — there is no authoritative decision to persist

The refusal record is the "close" that precedes the signal, so a refused attempt
is durably audited. If the refusal record itself cannot be written, that error
propagates — either way no effect ran.

interactive is a caller-supplied parameter (runix exposes no TTY probe, so
mode detection stays at the CLI layer); default FALSE = machine mode.

Supporting pieces

  • .verb_spec_for() (verbs.R): recover the verb spec (for the polkit
    action) from a preview's request verb; a hand-built preview with an unknown verb
    is refused before anything opens.
  • the refuse op on the session-ops seam (session_ops.R) = broker_audit_sink()
    • audit_two_phase() with a no-op effect and a terminal-outcome record; keeps
      the plain-intent path hermetic. (Confirmed against the runix API: this is the
      exported plain-intent open+write pair, root-peer-authenticated, one cid.)
  • the approval_required → runix_approval_required outcome status
    (.PKGOPS_POLKIT_CONDITION, outcome.R); unauthorized is shared with the
    session-level channel (same status, same condition).

Tests

27 new (433 total): each decision drives the branch; a refusal records a plain
intent and never opens the effect intent (seq == c("capability","refuse"));
capability precedes the decision; interactive skips pkcheck; a failed refuse
record propagates; an autonomous update proceeds for a member.

Points for review

  1. Order: capability (step 1) runs before the polkit decision (step 2), per
    §4.3 — so even a refusal first negotiates the effect-receipt capability. It's
    contract-ordered and confirms the broker is reachable before anything; flag if
    you'd rather the refuse path skip capability.
  2. check_failed → pkgops_polkit_check_failed, nothing recorded — a
    pkgops-owned outcome the contract taxonomy doesn't name (like
    pkgops_spawn_failed). Opening no intent for a check that couldn't run seemed
    right (no authoritative decision to persist), but flagging the choice.
  3. The plain-intent refusal record grammar (operation/resource/
    effect_issued/outcome) is minimal, like .outcome_record() — pinned
    against a real broker in the VM-gated increment.

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

Wire step 2 (contract 4.3) into the orchestrator: after the capability
negotiation and before the effect intent is opened, .commit_session now
runs .authorize(verb_spec, interactive) and branches.

  authorized                  -> proceed to the effect intent (existing path)
  unauthorized/approval_required (machine mode) -> .refuse(): open a PLAIN
      intent via the seam's `refuse` op (runix::broker_audit_sink +
      audit_two_phase with a no-op effect) and write the terminal outcome
      (effect_issued = FALSE) under one broker cid, then signal
      runix_unauthorized / runix_approval_required. No effect intent is ever
      opened for a refusal, so no unused effect receipt is minted (4.4).
  check_failed                -> fail closed with NOTHING recorded
      (pkgops_polkit_check_failed): there is no authoritative decision to
      persist. If the refusal RECORD itself fails, that error propagates.

`interactive` is a caller-supplied parameter (runix exposes no TTY probe, so
mode detection stays at the CLI layer); default FALSE = machine mode.

Supporting pieces:
- .verb_spec_for() (verbs.R): recover the verb spec (for the polkit action)
  from a preview's request verb; a hand-built preview with an unknown verb is
  refused before anything opens.
- the `refuse` op on the session-ops seam (session_ops.R), so the plain-intent
  path is hermetic too.
- the approval_required -> runix_approval_required outcome status
  (.PKGOPS_POLKIT_CONDITION, outcome.R); unauthorized is shared with the
  session-level channel.

27 new tests (433 total): each decision drives the branch; a refusal records a
plain intent and never opens the effect intent; capability precedes the
decision; interactive skips pkcheck; a failed refuse record propagates;
autonomous update proceeds for a member.

The effect-session orchestration, outcome-closed-before-signal, and the
internal-only boundary are unchanged. Still deferred: pkgstate verification,
then the exported apt_<verb>() API.
…id cid

.refuse() accepted ops$refuse()'s result as a closed terminal outcome
whenever it carried a scalar correlation_id, without requiring durable
persistence or validating the cid grammar. audit_two_phase can return
audit_persisted=FALSE (e.g. the terminal outcome did not land) WITHOUT
raising, so a non-persisted or malformed refusal could be signaled as a
clean runix_unauthorized / runix_approval_required -- claiming an audit
that never closed.

Before signaling the refusal, require res$audit_persisted == TRUE AND a
valid broker cid (.valid_broker_cid, pinned to runix's .BROKER_CID_RE:
^[0-9]{20}-[0-9a-f]{16}$). Otherwise fail closed as runix_broker_error and
do not report the refusal as closed. Regression tests: audit_persisted
FALSE, a malformed cid, a missing audit_persisted field, and the cid
grammar itself.
@TroyHernandez

Copy link
Copy Markdown
Contributor Author

Fixed in the latest commit. .refuse() now requires res$audit_persisted == TRUE and a valid broker cid (.valid_broker_cid, pinned to runix's .BROKER_CID_RE = ^[0-9]{20}-[0-9a-f]{16}$) before signaling a closed refusal; otherwise it fails closed as runix_broker_error and does not report the refusal as closed. Regression tests cover audit_persisted=FALSE, a malformed cid, a missing audit_persisted field, and the cid grammar.

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