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
43 changes: 26 additions & 17 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,28 +30,37 @@ reviewed increments** — hold at each increment before the next.
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** 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
- **Increment 4a (merged): the polkit authorization decision** (`R/polkit.R`) —
the §4.3-step-2 decision layer: 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
approval_required / else check_failed, integer-valued-guarded), and
`.authorize(verb_spec, interactive)` (interactive mode defers to the pkexec
prompt and skips pkcheck).
- **Increment 4b (this): wire authorization into `.commit_session`** — step 2 now
runs `.authorize(verb_spec, interactive)` after capability and before open.
Authorized proceeds to the effect intent; a machine-mode refusal
(`unauthorized`/`approval_required`) goes through `.refuse()`, which opens a
**plain intent** via the seam's `refuse` op (`runix::broker_audit_sink()` +
`audit_two_phase()` with a no-op effect) and writes the terminal outcome
(`effect_issued=FALSE`) under one broker cid, then signals — no effect intent is
ever opened for a refusal. `check_failed` fails closed with **nothing recorded**
(`pkgops_polkit_check_failed`). `interactive` is a caller-supplied parameter
(runix exposes no TTY probe; default `FALSE` = machine mode). Added:
`.verb_spec_for()` (verbs.R), the `refuse` seam op (session_ops.R), and the
`approval_required` → `runix_approval_required` outcome status
(`.PKGOPS_POLKIT_CONDITION`, outcome.R).
- **Still NOT started** (later increments, each its own review): **`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.
`.outcome_record()`, the plain-intent refusal record grammar, 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.4
Version: 0.0.1.5
Date: 2026-08-18
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai",
Expand Down
27 changes: 27 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,30 @@
# pkgops 0.0.1.5

Commit lifecycle (slice 3b): **wire the polkit authorization into
`.commit_session`** (§4.3 step 2). The decision from the previous increment now
gates the commit, 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.

* Step 2 runs `.authorize(verb_spec, interactive)` after the capability
negotiation and before the effect intent is opened. **Interactive** proceeds to
the effect intent (the `pkexec` prompt authenticates at the spawn); **machine
mode** maps the `pkcheck` decision.
* A machine-mode refusal never opens an effect intent. `unauthorized` and
`approval_required` open a **plain intent** (no receipt) via the generic broker
sink and write the matching terminal outcome (`effect_issued = FALSE`) under one
correlation id, then signal `runix_unauthorized` / `runix_approval_required` --
the record is the close that precedes the signal, so a refused attempt is
durably audited without minting an unused effect receipt.
* A check that could not be run at all (`check_failed`) fails closed with
**nothing recorded** (`pkgops_polkit_check_failed`): there is no authoritative
decision to persist. If the refusal record itself cannot be written, that error
propagates -- either way no effect ran.
* `interactive` is a caller-supplied argument (runix exposes no TTY probe, so mode
detection stays at the CLI layer); it defaults to `FALSE` (machine mode).
* Still deferred (their own later increments): `pkgstate` verification and the
exported per-verb `apt_<verb>()` API.

# pkgops 0.0.1.4

Commit lifecycle (slice 3b), fourth increment: the **polkit authorization
Expand Down
107 changes: 92 additions & 15 deletions R/commit.R
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
## The commit-session orchestrator: the branched commit lifecycle of the contract
## (pkgops-plan.md 4), wiring runix's exported effect-session API to the pure
## classifier (R/classify.R). This increment builds the open/commit/write_outcome
## wiring and the outcome-closed-before-signal discipline (4.8). TWO steps of the
## full lifecycle are DEFERRED to their own later increments and are marked below:
## - the polkit authorization branch (contract 4.4), and
## - pkgstate verification (contract 4.7).
## Until those land there is no exported per-verb apt_<verb>() commit entrypoint:
## an issuer cannot authorize or verify truthfully yet, so the mutation-capable
## path stays internal (.commit_session) and hermetic (fake session-ops).
## classifier (R/classify.R) and the polkit decision (R/polkit.R). Steps wired:
## 1 capability, 2 authorization (the polkit branch + the plain-intent terminal
## refusal), 3-5 open/commit/classify, 7 write_outcome, 8 return/signal, with the
## outcome-closed-before-signal discipline (4.8). ONE step remains DEFERRED to its
## own later increment and is marked below: pkgstate verification (contract 4.7).
## Until it lands there is no exported per-verb apt_<verb>() commit entrypoint --
## an issuer cannot verify truthfully yet, so the mutation-capable path stays
## internal (.commit_session) and hermetic (fake session-ops + fake pkcheck).

## The broker's well-known AF_UNIX socket (matches runix's effect_capability
## default). A caller may override for a test/staging broker.
Expand Down Expand Up @@ -91,13 +91,73 @@
.classify_commit(commit, preview)
}

## Handle a machine-mode polkit refusal (contract 4.4). No effect intent is ever
## opened: for a terminal refusal (unauthorized / approval_required) the attempt
## is recorded as a PLAIN intent + terminal outcome (effect_issued = FALSE) under
## one broker cid, then the mapped condition is signaled -- the record is the
## "close" that precedes the signal. A check that could not be run at all
## (check_failed) is fail-closed WITHOUT recording anything: there is no
## authoritative decision to persist, so pkgops raises and opens nothing.
##
## BOUNDARY [REVIEW]: check_failed -> pkgops_polkit_check_failed (no intent) is a
## pkgops-owned outcome the contract's taxonomy does not name. If the refuse
## RECORD itself fails (broker down / intent not durable), that raised condition
## propagates -- the refusal could not be recorded, which the caller must see;
## either way no effect ran.
.refuse <- function(ops, socket_path, preview, decision) {
if (identical(decision, "check_failed")) {
stop_pkgops("apt ", preview$verb,
": the polkit authorization check could not be run",
class = "pkgops_polkit_check_failed",
data = list(verb = preview$verb, resource = preview$resource))
}
## record the plain intent + terminal outcome; a raised record error means the
## refusal could not be persisted and propagates (nothing ran regardless).
res <- ops$refuse(socket_path, operation = preview$verb,
resource = preview$resource, status = decision)
## The refusal is reported as a CLOSED terminal outcome only when it DURABLY
## persisted (both intent and outcome, audit_persisted == TRUE) under a real
## broker cid. A non-persisted or malformed result must NOT be signaled as a
## closed refusal -- that would claim an audit that never landed. Fail closed
## as a persistence error instead (like the effect path's persist failure).
if (is.list(res)) {
cid <- res$correlation_id
} else {
cid <- NULL
}
if (!isTRUE(res$audit_persisted) || !.valid_broker_cid(cid)) {
stop_pkgops("apt ", preview$verb, ": the ", decision,
" outcome could not be durably recorded",
class = "runix_broker_error",
data = list(verb = preview$verb, resource = preview$resource,
decision = decision,
correlation_id = if (.valid_broker_cid(cid)) {
cid
} else {
NA_character_
},
audit_persisted = isTRUE(res$audit_persisted)))
}
cls <- .status_condition(decision) # unauthorized -> runix_unauthorized, etc.
msg <- switch(decision,
unauthorized = sprintf("apt %s: authorization denied", preview$verb),
approval_required = sprintf(
"apt %s: authorization requires an approval that was not granted",
preview$verb),
sprintf("apt %s: refused (%s)", preview$verb, decision))
cond <- .build_commit_condition(cls, msg, preview, cid, decision,
effect_issued = FALSE)
stop(cond)
}

## Drive the branched commit lifecycle for one committable preview and return the
## pkgops_outcome (success) or SIGNAL the mapped condition (failure), with the
## outcome ALWAYS written before the signal (4.8). Internal for now -- the public
## per-verb API that wraps this (with the preview {verb,resource,plan_hash} match
## check) lands once polkit + verification complete the lifecycle.
## check) lands once pkgstate verification completes the lifecycle.
.commit_session <- function(preview, socket_path = .PKGOPS_BROKER_SOCKET,
lock_timeout = 0L, deadline_ms = 120000L) {
interactive = FALSE, lock_timeout = 0L,
deadline_ms = 120000L) {
if (!inherits(preview, "pkgops_preview")) {
stop_pkgops("commit requires a pkgops_preview (from apt_<verb>_preview())",
class = "pkgops_bad_request")
Expand Down Expand Up @@ -129,18 +189,35 @@
stop_pkgops("this 'ok' preview carries no plan digest to commit",
class = "pkgops_bad_request", data = list(verb = preview$verb))
}
## recover the verb spec from the preview's request verb (for the polkit
## action); a hand-built preview with an unknown verb is refused here.
verb_spec <- .verb_spec_for(preview$verb)
if (is.null(verb_spec)) {
stop_pkgops("unrecognised verb in preview: ",
if (.is_scalar_str(preview$verb)) {
shQuote(preview$verb)
} else {
"<malformed>"
},
class = "pkgops_bad_request")
}
ops <- session_ops()

## step 1 -- effect-receipt capability negotiation (the real extension +
## plan-schema gate, not just peer auth). Raises runix_capability_unavailable
## on failure; nothing is opened or minted.
ops$capability(socket_path, plan_schema = preview$plan_schema)

## step 2 -- POLKIT authorization branch: DEFERRED to its own increment.
## Machine-mode pkcheck / autonomous-verb handling / the plain-intent
## (approval_required|unauthorized) terminal outcome are not wired here yet.
## Interactive pkexec still authorizes at the entrypoint spawn inside runix's
## C; this orchestrator does no machine-mode pre-check until that increment.
## step 2 -- POLKIT authorization (contract 4.4). Interactive mode defers to
## the pkexec prompt at the entrypoint spawn (authorize -> proceed); machine
## mode runs a non-interactive pkcheck. A machine-mode refusal never opens an
## effect intent: it records a PLAIN intent + terminal outcome and stops, so no
## unused effect receipt is minted. A failed check fails closed with nothing
## opened.
decision <- .authorize(verb_spec, interactive)
if (!identical(decision, "authorized")) {
return(.refuse(ops, socket_path, preview, decision))
}

## step 3 -- open the effect-required intent. The receipt + outcome binding
## are minted into wipeable C heap; R gets only an opaque PID-bound handle and
Expand Down
28 changes: 25 additions & 3 deletions R/outcome.R
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,24 @@
unauthorized = "runix_unauthorized",
effect_unknown = "runix_helper_bad_result")

## The POLKIT-terminal statuses, produced by the machine-mode authorization branch
## BEFORE any effect intent is opened (contract 4.4). Both close a plain intent
## with effect_issued = FALSE (no effect ran) and never leave anything open.
## unauthorized a flat polkit denial (shares runix_unauthorized with the
## pkexec session-level denial above -- same status, same
## condition, so the two channels never diverge).
## approval_required a challenge that cannot be obtained non-interactively; a
## human admin could authorize a re-run (runix_approval_required).
.PKGOPS_POLKIT_CONDITION <- c(approval_required = "runix_approval_required")

## The full status vocabulary an outcome may carry: a helper status (mapped to its
## runix condition) or a session-level status. Both are canonical inputs to
## new_pkgops_outcome(); the drift test pins only the twelve HELPER statuses.
## runix condition), a session-level status, or a polkit-terminal status. All are
## canonical inputs to new_pkgops_outcome(); the drift test pins only the twelve
## HELPER statuses. (unauthorized appears once, shared by the session and polkit
## channels.)
.PKGOPS_ALL_STATUS_CONDITION <- c(.PKGOPS_STATUS_CONDITION,
.PKGOPS_SESSION_CONDITION)
.PKGOPS_SESSION_CONDITION,
.PKGOPS_POLKIT_CONDITION)

## The runix condition class for a status -- a helper status ("held" ->
## "runix_held", "ok"/"no_op" -> themselves) or a session-level status
Expand Down Expand Up @@ -103,6 +116,15 @@
nzchar(result_cid) && identical(result_cid, intent_cid)
}

## The broker's correlation-id grammar: 20 digits, '-', 16 lowercase hex. Pinned
## to runix's .BROKER_CID_RE (audit_broker_sink.R) -- if the broker changes its
## cid shape, this must change in lockstep. A durable outcome is reported as
## closed only when the broker minted a real cid for it.
.BROKER_CID_RE <- "^[0-9]{20}-[0-9a-f]{16}$"
.valid_broker_cid <- function(x) {
.is_scalar_str(x) && grepl(.BROKER_CID_RE, x)
}

## Enforce a tri-state field (TRUE / FALSE / NA). Unlike effect_issued -- which
## carries UNTRUSTED helper input and normalizes a bad value to the NA "unknown"
## -- `verified` is pkgops's OWN verification verdict, so a value outside the
Expand Down
33 changes: 27 additions & 6 deletions R/session_ops.R
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
## Injectable seam over runix's exported effect-session API -- the four calls the
## commit orchestrator (R/commit.R) drives: the capability negotiation, and the
## three-call session (open -> commit -> write_outcome). Same injectable shape as
## the preview runner (R/runner.R): production code calls session_ops()$<fn>(...),
## Injectable seam over runix's broker-facing API -- the calls the commit
## orchestrator (R/commit.R) drives: the capability negotiation, the three-call
## effect session (open -> commit -> write_outcome), and the plain-intent
## terminal `refuse` (the machine-mode polkit refusal path). Same injectable shape
## as the preview runner (R/runner.R): production code calls session_ops()$<fn>(...),
## and a hermetic test swaps in a fake list via set_session_ops(), restoring with
## set_session_ops(NULL).
##
Expand Down Expand Up @@ -35,15 +36,35 @@ write_outcome_default <- function(session, record, ...) {
runix::effect_session_write_outcome(session, record, ...)
}

## The plain-intent terminal-refusal path (contract 4.4): a machine-mode polkit
## refusal opens a PLAIN intent (no effect, no receipt) and writes the matching
## terminal outcome under one broker-minted correlation id, so the refused attempt
## is durably recorded without minting an unused effect receipt. Uses the generic
## broker sink + audit_two_phase with a no-op effect; fail-closed (audit_two_phase
## aborts if the intent is not durable). Returns audit_two_phase's result list
## (carrying correlation_id + audit_persisted). The record carries only domain
## fields (operation/resource/effect_issued/outcome); reserved fields such as
## correlation_id are broker-owned and must not appear.
refuse_default <- function(socket_path, operation, resource, status, ...) {
sink <- runix::broker_audit_sink(socket_path, ...)
runix::audit_two_phase(sink,
intent = list(operation = operation, resource = resource,
effect_issued = FALSE, outcome = "intent"),
effect = function(cid) NULL,
outcome = function(result) list(operation = operation, resource = resource,
effect_issued = FALSE, outcome = status))
}

.PKGOPS_SESSION_OPS_DEFAULT <- list(capability = cap_default,
open = open_default,
commit = commit_default,
write_outcome = write_outcome_default)
write_outcome = write_outcome_default,
refuse = refuse_default)

.pkgops_session_ops <- local({
state <- new.env(parent = emptyenv())
## Merge any injected functions over the defaults, so a test may shadow a
## single call while the rest stay real; a hermetic test shadows all four so
## single call while the rest stay real; a hermetic test shadows every op so
## nothing reaches the broker.
session_ops <- function() {
if (is.null(state$ops)) {
Expand Down
Loading