Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 24 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<verb>()` 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_<verb>()` 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.<verb>`), 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_<verb>()` 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).
Expand Down
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
28 changes: 28 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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.<verb>`) 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_<verb>()` API.

# pkgops 0.0.1.3

Commit lifecycle (slice 3b), third increment: the **effect-session
Expand Down
126 changes: 126 additions & 0 deletions R/polkit.R
Original file line number Diff line number Diff line change
@@ -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.<verb>" 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)
}
95 changes: 95 additions & 0 deletions inst/tinytest/test_polkit.R
Original file line number Diff line number Diff line change
@@ -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))
}