diff --git a/CLAUDE.md b/CLAUDE.md index 7a011ff..fd5c66a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.`), 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 + 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_()` 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). diff --git a/DESCRIPTION b/DESCRIPTION index f727ddc..ba78aad 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.4 +Version: 0.0.1.5 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 754fb7c..eacfc24 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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_()` API. + # pkgops 0.0.1.4 Commit lifecycle (slice 3b), fourth increment: the **polkit authorization diff --git a/R/commit.R b/R/commit.R index 09c2475..4a0f619 100644 --- a/R/commit.R +++ b/R/commit.R @@ -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_() 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_() 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. @@ -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__preview())", class = "pkgops_bad_request") @@ -129,6 +189,18 @@ 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 { + "" + }, + class = "pkgops_bad_request") + } ops <- session_ops() ## step 1 -- effect-receipt capability negotiation (the real extension + @@ -136,11 +208,16 @@ ## 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 diff --git a/R/outcome.R b/R/outcome.R index 78dcea8..586fb85 100644 --- a/R/outcome.R +++ b/R/outcome.R @@ -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 @@ -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 diff --git a/R/session_ops.R b/R/session_ops.R index bac565c..134e4e2 100644 --- a/R/session_ops.R +++ b/R/session_ops.R @@ -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()$(...), +## 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()$(...), ## and a hermetic test swaps in a fake list via set_session_ops(), restoring with ## set_session_ops(NULL). ## @@ -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)) { diff --git a/R/verbs.R b/R/verbs.R index e3f51d2..a3d98ee 100644 --- a/R/verbs.R +++ b/R/verbs.R @@ -22,6 +22,19 @@ update = list(request_verb = "apt.update", arity = "none", autonomous = TRUE), configure = list(request_verb = "apt.configure", arity = "none", autonomous = FALSE)) +## The verb spec for a request verb ("apt.install"), or NULL. A pkgops_preview +## carries only its request verb; the commit path recovers the spec from it (for +## the polkit action id). A hand-built preview with an unknown verb returns NULL +## and is refused before anything is opened. +.verb_spec_for <- function(request_verb) { + for (spec in .PKGOPS_VERBS) { + if (identical(spec$request_verb, request_verb)) { + return(spec) + } + } + NULL +} + ## The planner's own limits (pkgexec/src/request.h): a request carries at most ## PKGX_MAX_PACKAGES names, each at most PKGX_MAX_NAME bytes. Enforced here so an ## over-long request is a clean pkgops condition, not a schema_invalid round-trip. diff --git a/inst/tinytest/test_commit.R b/inst/tinytest/test_commit.R index 3920a24..13f352d 100644 --- a/inst/tinytest/test_commit.R +++ b/inst/tinytest/test_commit.R @@ -8,6 +8,7 @@ H <- strrep("a", 64L) CID <- "20250101000000000000-0123456789abcdef" commit_session <- pkgops:::.commit_session set_ops <- pkgops:::set_session_ops +set_pkcheck <- pkgops:::set_pkcheck ## A committable preview: an `ok` plan carrying a bound digest. mkprev <- function(verb = "apt.install", resource = "nginx", packages = "nginx", @@ -38,7 +39,9 @@ newlog <- function() { } ops_for <- function(log, commit, raise = FALSE, wo = list(status = "ok", detail = NULL), - cap = NULL, open = NULL) { + cap = NULL, open = NULL, + refuse = list(correlation_id = CID, audit_persisted = TRUE), + refuse_err = NULL) { list( capability = function(socket_path, plan_schema, ...) { log$seq <- c(log$seq, "capability") @@ -46,6 +49,13 @@ ops_for <- function(log, commit, raise = FALSE, if (!is.null(cap)) stop(cap) invisible(TRUE) }, + refuse = function(socket_path, operation, resource, status, ...) { + log$seq <- c(log$seq, "refuse") + log$refuse <- list(operation = operation, resource = resource, + status = status) + if (!is.null(refuse_err)) stop(refuse_err) + refuse + }, open = function(socket_path, operation, resource, plan_schema, plan_hash, ...) { log$seq <- c(log$seq, "open") @@ -71,11 +81,18 @@ ops_for <- function(log, commit, raise = FALSE, } ## Run .commit_session under a fake ops set; return the outcome (success) or the -## raised condition (failure). Always restores the seam. -run_commit <- function(ops, preview = prev, ...) { - old <- set_ops(ops) - on.exit(set_ops(old)) - tryCatch(commit_session(preview, socket_path = "/fake.sock", ...), +## raised condition (failure). Also installs a fake pkcheck (default: rc 0 -> +## authorized, so the effect-path tests reach step 3 hermetically); restores both. +run_commit <- function(ops, preview = prev, interactive = FALSE, + pkcheck = function(action) 0L, ...) { + old_ops <- set_ops(ops) + old_pk <- set_pkcheck(pkcheck) + on.exit({ + set_ops(old_ops) + set_pkcheck(old_pk) + }) + tryCatch(commit_session(preview, socket_path = "/fake.sock", + interactive = interactive, ...), condition = function(c) c) } @@ -235,3 +252,111 @@ r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), expect_inherits(r, "pkgops_outcome") expect_equal(lg$open$operation, "apt.update") expect_equal(lg$commit$packages, character(0)) + +## ============================================================================ +## step 2 -- the polkit authorization branch (contract 4.4) +## ============================================================================ + +## ---- machine-mode UNAUTHORIZED: plain intent recorded, NO effect intent ----- +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), pkcheck = function(a) 1L) +expect_inherits(r, "runix_unauthorized") +expect_inherits(r, "pkgops_error") +expect_identical(r$effect_issued, FALSE) # no effect ran +expect_equal(lg$seq, c("capability", "refuse")) # never opened the effect intent +expect_equal(lg$seq[1], "capability") # capability precedes the decision +expect_equal(lg$refuse$status, "unauthorized") # the terminal outcome recorded +expect_equal(lg$refuse$operation, "apt.install") +expect_equal(lg$refuse$resource, "nginx") +expect_equal(r$correlation_id, CID) # cid from the plain intent + +## ---- machine-mode APPROVAL_REQUIRED: plain intent + runix_approval_required - +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), pkcheck = function(a) 2L) +expect_inherits(r, "runix_approval_required") +expect_identical(r$effect_issued, FALSE) +expect_equal(lg$seq, c("capability", "refuse")) +expect_equal(lg$refuse$status, "approval_required") + +## ---- CHECK_FAILED: fail closed, NOTHING recorded (no authoritative decision) - +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), pkcheck = function(a) 127L) +expect_inherits(r, "pkgops_polkit_check_failed") +expect_equal(lg$seq, "capability") # no refuse, no open +expect_false("refuse" %in% lg$seq) + +## ---- INTERACTIVE mode: pkcheck is NOT run, proceed to the effect intent ------ +lg <- newlog() +never_pk <- function(a) stop("pkcheck must not run in interactive mode") +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), interactive = TRUE, + pkcheck = never_pk) +expect_inherits(r, "pkgops_outcome") # the pkexec prompt authorizes later +expect_equal(lg$seq, c("capability", "open", "commit", "write_outcome")) + +## ---- the plain-intent RECORD itself fails: propagate, no effect intent ------ +lg <- newlog() +audit_err <- structure(class = c("runix_audit_error", "runix_error", "error", + "condition"), + list(message = "intent not durable", call = NULL)) +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE), refuse_err = audit_err), + pkcheck = function(a) 1L) +expect_inherits(r, "runix_audit_error") # record failure surfaces +expect_equal(lg$seq, c("capability", "refuse")) # never opened the effect intent + +## ---- a NON-PERSISTED refusal result is NOT reported as a closed refusal ------ +## audit_two_phase can return audit_persisted=FALSE (e.g. the terminal outcome did +## not land) WITHOUT raising; that must fail closed as a persistence error, never +## be signaled as a clean runix_unauthorized. +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE), + refuse = list(correlation_id = CID, audit_persisted = FALSE)), + pkcheck = function(a) 1L) +expect_inherits(r, "runix_broker_error") +expect_false(inherits(r, "runix_unauthorized")) # not a closed refusal +expect_false(isTRUE(r$audit_persisted)) +expect_equal(lg$seq, c("capability", "refuse")) + +## ---- a refusal result with a MALFORMED cid also fails closed ---------------- +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE), + refuse = list(correlation_id = "not-a-broker-cid", + audit_persisted = TRUE)), + pkcheck = function(a) 2L) +expect_inherits(r, "runix_broker_error") +expect_false(inherits(r, "runix_approval_required")) +expect_true(is.na(r$correlation_id)) # the bad cid is not carried through + +## ---- a missing audit_persisted field is treated as not persisted ------------ +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE), + refuse = list(correlation_id = CID)), # no audit_persisted + pkcheck = function(a) 1L) +expect_inherits(r, "runix_broker_error") + +## ---- the broker cid grammar (pinned to runix .BROKER_CID_RE) ----------------- +vcid <- pkgops:::.valid_broker_cid +expect_true(vcid("20250101000000000000-0123456789abcdef")) +expect_true(vcid("00001786382512165708-a061ec02cffe1b2b")) # 20 digit + 16 hex +expect_false(vcid("2025-abc")) +expect_false(vcid("20250101000000000000-0123456789ABCDEF")) # uppercase hex rejected +expect_false(vcid(NA_character_)) +expect_false(vcid(NULL)) +expect_false(vcid("")) + +## ---- autonomous verb, machine mode, a member (rc 0) proceeds ---------------- +lg <- newlog() +autofn <- function(action) { + if (grepl("\\.(update|hold)$", action)) 0L else 1L +} +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), + preview = mkprev(verb = "apt.update", resource = "@indexes", + packages = character(0)), + pkcheck = autofn) +expect_inherits(r, "pkgops_outcome") # update authorized for a member +expect_equal(lg$seq, c("capability", "open", "commit", "write_outcome")) + +## a non-member hitting a non-autonomous verb is refused (same check) ---------- +lg <- newlog() +r <- run_commit(ops_for(lg, cr("ok", "ok", TRUE)), pkcheck = autofn) # apt.install -> rc 1 +expect_inherits(r, "runix_unauthorized") +expect_equal(lg$seq, c("capability", "refuse")) diff --git a/inst/tinytest/test_outcome.R b/inst/tinytest/test_outcome.R index c9eabd7..897df94 100644 --- a/inst/tinytest/test_outcome.R +++ b/inst/tinytest/test_outcome.R @@ -32,6 +32,10 @@ for (st in c("package_not_owned", "held", "protected_package", "resolve_failed", expect_equal(pkgops:::.status_condition("apt_locked"), "runix_apt_locked") expect_equal(pkgops:::.status_condition("internal"), "runix_helper_internal") expect_equal(pkgops:::.status_condition("ok"), "ok") +# session-level and polkit-terminal statuses are canonical inputs too +expect_equal(pkgops:::.status_condition("unauthorized"), "runix_unauthorized") +expect_equal(pkgops:::.status_condition("approval_required"), "runix_approval_required") +expect_equal(pkgops:::.status_condition("effect_unknown"), "runix_helper_bad_result") # an unknown or malformed status fails closed as runix_helper_bad_result e <- tryCatch(pkgops:::.status_condition("frobnicated"), error = identity)