diff --git a/CLAUDE.md b/CLAUDE.md index 2fea339..7a011ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,26 +22,36 @@ reviewed increments** — hold at each increment before the next. `.classify_commit()` maps a `runix_commit_result` (runix's C owns the frame parse + cid + delivery gates) to an outcome + condition + `leave_open`, per the §4.6/§4.8 close-vs-open rule. It never raises and never does IO. Also pure. -- **Increment 3 (this): the effect-session orchestration** (`R/commit.R`, +- **Increment 3 (merged): the effect-session orchestration** (`R/commit.R`, `R/session_ops.R`) — `.commit_session()` wires the §4.3 lifecycle steps 1,3,4, 5,7,8 (capability → open → commit → classify → write_outcome → signal) with the outcome-closed-before-signal discipline (§4.8). The four runix effect-session R calls are driven through an injectable seam (`session_ops()`/`set_session_ops`, hermetic-test-only; production always uses the real `runix::` defaults, and the privileged verb→entrypoint map stays a hard C constant in runix). `.commit_session` - is **internal** — there is no exported per-verb `apt_()` commit entrypoint - yet, because an issuer cannot authorize (polkit) or verify (pkgstate) truthfully - until those increments land. -- **Still NOT started** (later increments, each its own review): the **polkit - authorization branch** (§4.3 step 2 — machine-mode `pkcheck`, autonomous-verb - handling, the plain-intent `approval_required`/`unauthorized` terminal - outcome), **`pkgstate` verification** (§4.3 step 6 — `pkgstate` becomes an - `Imports` only when that increment lands, not before, or it is an unused-Import - NOTE; it also supplies the outcome record's `observed`/`changed` post-state - fields), and then the **exported per-verb `apt_()` API** (with the - preview `{verb,resource,plan_hash}` match check). The durable outcome-record - grammar in `.outcome_record()` is intentionally minimal until it is pinned - against a real broker in the VM-gated increment. + is **internal** and gates on `advisory_verdict == "ok"` before any session op. +- **Increment 4a (this): the polkit authorization decision** (`R/polkit.R`) — + the §4.3-step-2 decision layer only: the per-verb polkit action id + (`ai.cornball.runix.apt.`), a native non-interactive `pkcheck` behind an + injectable seam (`pkcheck_fn()`/`set_pkcheck`; hermetic-test-only, not a + privilege boundary — polkit still enforces at the pkexec spawn in runix's C), + the pkcheck exit-code→decision map (0 authorized / 1 unauthorized / 2,3 + approval_required / else check_failed), and `.authorize(verb_spec, interactive)` + (interactive mode defers to the pkexec prompt and skips pkcheck). Autonomous + `update`/`hold` need no special-casing — the `runix-apt-autonomous` rule grants + members rc 0 through the same check. +- **Still NOT started** (later increments, each its own review): **4b — wire the + decision into `.commit_session`** (the authorized path 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`, then stops), + **`pkgstate` verification** (§4.3 step 6 — `pkgstate` becomes an `Imports` only + when that increment lands, not before, or it is an unused-Import NOTE; it also + supplies the outcome record's `observed`/`changed` post-state fields), and then + the **exported per-verb `apt_()` API** (with the preview + `{verb,resource,plan_hash}` match check). The durable outcome-record grammar in + `.outcome_record()` and the exact pkcheck rc→outcome split are pinned against a + real broker/polkit in the VM-gated increment. The authoritative design is `runix/docs/pkgops-plan.md` (the approved contract) and `runix/docs/pkgops-implementation-plan.md` (rev 2, the build sequence). diff --git a/DESCRIPTION b/DESCRIPTION index 9e88f56..f727ddc 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: pkgops Type: Package Title: Unprivileged Issuer for 'APT' Package-State Mutations -Version: 0.0.1.3 +Version: 0.0.1.4 Date: 2026-08-18 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", diff --git a/NEWS.md b/NEWS.md index 9371627..754fb7c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,31 @@ +# pkgops 0.0.1.4 + +Commit lifecycle (slice 3b), fourth increment: the **polkit authorization +decision** (the §4.3-step-2 decision layer). Pure decision + an injectable +`pkcheck` seam, no broker and no intent yet -- the next increment wires it into +`.commit_session`. + +* `.authorize(verb_spec, interactive)` decides whether a commit may proceed. + **Interactive mode** defers to the `pkexec` prompt at the entrypoint spawn and + skips `pkcheck`; **machine mode** runs a native, non-interactive `pkcheck` for + the verb's polkit action (`ai.cornball.runix.apt.`) against this + process's race-safe `pid,start-time,uid` subject. +* The `pkcheck` exit code maps to a closed decision vocabulary, pinned to the + tested canary matrix: `0` authorized, `1` unauthorized, `2`/`3` + approval_required (a challenge that cannot be obtained non-interactively), and + anything else `check_failed` (fail closed, never silently authorized). +* The `pkcheck` call is behind an injectable seam (`set_pkcheck()`), so the whole + decision is hermetic. 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 that `pkexec` then denies, or refuse one it would + have allowed -- both degrade safely. +* The autonomous verbs (`apt.update`/`apt.hold`) need no special-casing: the + `runix-apt-autonomous` polkit rule grants members `rc 0` through the same + check, and a non-member falls through to a refusal like any other verb. +* Still deferred (their own later increments): wiring the decision into + `.commit_session` with the plain-intent terminal-outcome path, `pkgstate` + verification, and the exported per-verb `apt_()` API. + # pkgops 0.0.1.3 Commit lifecycle (slice 3b), third increment: the **effect-session diff --git a/R/polkit.R b/R/polkit.R new file mode 100644 index 0000000..d96eb59 --- /dev/null +++ b/R/polkit.R @@ -0,0 +1,126 @@ +## The polkit authorization decision (contract 4.4, lifecycle step 2). This +## increment builds the DECISION layer only: the per-verb polkit action id, a +## native non-interactive `pkcheck` behind an injectable seam, the exit-code -> +## decision map, and .authorize() which runs the check only in machine mode. The +## next increment WIRES this into .commit_session (the authorized path proceeds to +## the effect intent; a machine-mode refusal opens a PLAIN intent + a terminal +## outcome via the generic broker sink and stops) -- none of that is here yet. +## +## Polkit owns authorization; this pre-check only decides, in MACHINE mode, +## whether to proceed to the pkexec boundary or to short-circuit with a terminal +## outcome (so no unused effect receipt is ever minted for a refusal). It is NOT a +## privilege boundary: the real authorization is enforced by polkit at the pkexec +## spawn inside runix's C, so a test-substituted pkcheck (below) cannot grant +## anything -- at worst it makes pkgops proceed to a commit that pkexec then +## denies, or refuse one it would have allowed. Both degrade safely. + +## The polkit action-id namespace. Each verb's action is this prefix + the +## request verb, e.g. apt.install -> "ai.cornball.runix.apt.install", +## apt.dist_upgrade -> "ai.cornball.runix.apt.dist_upgrade" (underscore kept). +## Pinned to pkgexec/polkit/ai.cornball.runix.apt.policy. +.POLKIT_ACTION_NS <- "ai.cornball.runix." + +## The machine-mode authorization decision vocabulary: +## authorized proceed to the effect-required intent (pkexec commit). +## unauthorized a flat denial (pkcheck rc 1) -> terminal outcome +## `unauthorized`, effect FALSE, stop. +## approval_required a challenge is needed but cannot be obtained +## non-interactively (pkcheck rc 2/3) -> terminal outcome +## `approval_required`, effect FALSE, stop. +## check_failed pkcheck could not be run or gave an uninterpretable rc +## -> fail closed, open nothing. +.POLKIT_DECISIONS <- c("authorized", "unauthorized", "approval_required", + "check_failed") + +## The polkit action id for a verb's request token. request_verb is the +## "apt." the verb table already validated, so this is a pure string map. +.polkit_action <- function(request_verb) { + paste0(.POLKIT_ACTION_NS, request_verb) +} + +## The race-safe pkcheck subject for THIS process: pid,start-time,uid. start-time +## is field 22 of /proc/self/stat, read PAST the "(comm)" field so a comm +## containing spaces or ')' cannot shift it (mirrors the tested canary harness). +## uid is the process's real uid, read from /proc/self's owner via file.info() +## (base R, no extra dependency). +.pkcheck_subject <- function() { + stat <- readLines("/proc/self/stat", warn = FALSE) + rest <- sub(".*\\) ", "", stat) + start <- strsplit(rest, " ", fixed = TRUE)[[1]][20L] + uid <- file.info("/proc/self")[["uid"]] + paste(Sys.getpid(), start, uid, sep = ",") +} + +## The default pkcheck executor: a native, NON-interactive polkit check (no +## --allow-user-interaction, so a challenge can never turn into a prompt) of this +## process against `action`. Returns pkcheck's integer exit code. Fails closed on +## a missing binary. Carries no secret (unlike the pkexec commit), so a plain +## system2 spawn without a shell is appropriate; the seam below lets tests replace +## it with a scripted rc. +.pkcheck_default <- function(action) { + bin <- "/usr/bin/pkcheck" + if (!nzchar(Sys.which(bin))) { + stop_pkgops("polkit check tool not found: ", bin, + class = "pkgops_missing_tool", data = list(resource = bin)) + } + args <- c("--action-id", action, "--process", .pkcheck_subject()) + suppressWarnings(st <- system2(bin, args, stdout = FALSE, stderr = FALSE)) + as.integer(st) +} + +.pkgops_pkcheck <- local({ + state <- new.env(parent = emptyenv()) + pkcheck_fn <- function() { + if (is.null(state$fn)) .pkcheck_default else state$fn + } + set_pkcheck <- function(fn = NULL) { + old <- state$fn + state$fn <- fn + invisible(old) + } + list(pkcheck_fn = pkcheck_fn, set_pkcheck = set_pkcheck) +}) +pkcheck_fn <- .pkgops_pkcheck$pkcheck_fn +set_pkcheck <- .pkgops_pkcheck$set_pkcheck + +## Map a pkcheck exit code to a decision (pkcheck(1); pinned to the tested canary +## matrix deploy/canary-apt/polkit-matrix.sh:17-18): 0 authorized, 1 not +## authorized, 2 authorization unavailable (a challenge with no agent / no +## interaction), 3 a challenge that required interaction. Machine mode never +## allows interaction, so 2 and 3 are both "a human admin could authorize this" -> +## approval_required; 1 is a flat deny -> unauthorized; anything else (a spawn +## failure, 124/126/127) is uninterpretable -> check_failed, fail closed. +## +## BOUNDARY [REVIEW]: the exact rc 1-vs-2 split between `unauthorized` and +## `approval_required` is pinned here to the canary contract; it is confirmed +## against real polkit behaviour in the VM-gated increment, where the terminal +## outcomes meet a live broker. +.pkcheck_decision <- function(rc) { + ## require a finite, scalar, INTEGER-VALUED numeric before coercion: a + ## fractional 1.5 must fail closed as check_failed, never truncate to 1 and be + ## read as `unauthorized`. + if (!(length(rc) == 1L && is.numeric(rc) && is.finite(rc) && + rc == floor(rc))) { + return("check_failed") + } + switch(as.character(as.integer(rc)), "0" = "authorized", + "1" = "unauthorized", "2" = "approval_required", + "3" = "approval_required", "check_failed") +} + +## Decide whether a commit may proceed, per contract 4.4. In INTERACTIVE mode the +## check is deferred to the pkexec prompt at the entrypoint spawn, so this returns +## "authorized" WITHOUT running pkcheck (a cancelled prompt becomes a known-false +## unauthorized outcome on the effect intent later). In MACHINE mode it runs the +## non-interactive pkcheck for the verb's action and maps the result. The +## autonomous verbs (apt.update/apt.hold) need no special-casing: the +## runix-apt-autonomous polkit rule grants members rc 0 through the SAME check, +## and a non-member falls through to a refusal like any other verb. +.authorize <- function(verb_spec, interactive) { + if (isTRUE(interactive)) { + return("authorized") + } + action <- .polkit_action(verb_spec$request_verb) + rc <- pkcheck_fn()(action) + .pkcheck_decision(rc) +} diff --git a/inst/tinytest/test_polkit.R b/inst/tinytest/test_polkit.R new file mode 100644 index 0000000..ebf7f94 --- /dev/null +++ b/inst/tinytest/test_polkit.R @@ -0,0 +1,95 @@ +# The polkit authorization DECISION layer (R/polkit.R): the per-verb action id, +# the pkcheck exit-code -> decision map, and .authorize() (machine mode runs the +# non-interactive check; interactive mode defers to the pkexec prompt). Hermetic: +# pkcheck is replaced through its seam, so no real polkit call happens. The +# plain-intent terminal-outcome wiring and the .commit_session integration are a +# later increment and are NOT exercised here. + +verbs <- pkgops:::.PKGOPS_VERBS +action <- pkgops:::.polkit_action +decision <- pkgops:::.pkcheck_decision +authorize <- pkgops:::.authorize +set_pkcheck <- pkgops:::set_pkcheck + +## ---- the polkit action id per verb ----------------------------------------- +expect_equal(action("apt.install"), "ai.cornball.runix.apt.install") +expect_equal(action("apt.dist_upgrade"), "ai.cornball.runix.apt.dist_upgrade") +expect_equal(action("apt.unhold"), "ai.cornball.runix.apt.unhold") +# every verb maps to a well-formed action in the runix apt namespace +for (v in names(verbs)) { + a <- action(verbs[[v]]$request_verb) + expect_true(grepl("^ai\\.cornball\\.runix\\.apt\\.[a-z_]+$", a)) +} + +## ---- pkcheck exit code -> decision (pinned to the canary matrix) ------------ +expect_equal(decision(0), "authorized") +expect_equal(decision(1), "unauthorized") +expect_equal(decision(2), "approval_required") # challenge, no agent +expect_equal(decision(3), "approval_required") # challenge required +expect_equal(decision(4), "check_failed") +expect_equal(decision(126), "check_failed") +expect_equal(decision(127), "check_failed") # no pkcheck / spawn failure +expect_equal(decision(124), "check_failed") # timeout +# a non-interpretable rc value is check_failed, never silently authorized +expect_equal(decision(NA_integer_), "check_failed") +expect_equal(decision(NA_real_), "check_failed") +expect_equal(decision("nope"), "check_failed") +expect_equal(decision(numeric(0)), "check_failed") +# a fractional rc must NOT truncate into an authoritative decision (1.5 -> 1) +expect_equal(decision(1.5), "check_failed") +expect_equal(decision(2.5), "check_failed") +expect_equal(decision(0.9), "check_failed") +# an integer-valued double is still fine (2.0 is 2) +expect_equal(decision(2.0), "approval_required") +# every decision the map can produce is in the closed vocabulary +for (rc in c(-1, 0, 1, 2, 3, 4, 124, 126, 127)) { + expect_true(decision(rc) %in% pkgops:::.POLKIT_DECISIONS) +} + +## ---- .authorize: install a fake pkcheck, run, restore ---------------------- +run_authorize <- function(pkfn, verb_spec, interactive) { + old <- set_pkcheck(pkfn) + on.exit(set_pkcheck(old)) + authorize(verb_spec, interactive) +} + +## interactive mode defers to the pkexec prompt: pkcheck is NOT run ------------ +calls <- new.env(parent = emptyenv()) +calls$n <- 0L +never <- function(action) { + calls$n <- calls$n + 1L + 0L +} +expect_equal(run_authorize(never, verbs$install, TRUE), "authorized") +expect_equal(calls$n, 0L) # never queried polkit + +## machine mode runs pkcheck for the verb's action and maps the result -------- +seen <- new.env(parent = emptyenv()) +mk <- function(rc) { + function(action) { + seen$action <- action + rc + } +} +expect_equal(run_authorize(mk(0L), verbs$install, FALSE), "authorized") +expect_equal(seen$action, "ai.cornball.runix.apt.install") # verb -> action +expect_equal(run_authorize(mk(1L), verbs$remove, FALSE), "unauthorized") +expect_equal(seen$action, "ai.cornball.runix.apt.remove") +expect_equal(run_authorize(mk(2L), verbs$purge, FALSE), "approval_required") +expect_equal(run_authorize(mk(127L), verbs$install, FALSE), "check_failed") + +## autonomous needs no special-casing: the SAME check, the rule grants members - +## a fake standing in for "member of runix-apt-autonomous" (update/hold -> rc 0). +autofn <- function(action) { + if (grepl("\\.(update|hold)$", action)) 0L else 1L +} +expect_equal(run_authorize(autofn, verbs$update, FALSE), "authorized") +expect_equal(run_authorize(autofn, verbs$hold, FALSE), "authorized") +expect_equal(run_authorize(autofn, verbs$unhold, FALSE), "unauthorized") # not autonomous +expect_equal(run_authorize(autofn, verbs$install, FALSE), "unauthorized") + +## ---- the subject builder is pid,start-time,uid (real /proc on Linux) -------- +if (file.exists("/proc/self/stat")) { + subj <- pkgops:::.pkcheck_subject() + expect_true(grepl("^[0-9]+,[0-9]+,[0-9]+$", subj)) +}