From eb1d3b460811cd2632be7cb1779fc9797ccf7f4c Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 19 Aug 2026 23:45:04 -0500 Subject: [PATCH 1/2] canary-apt Part B: drive the functional gates through the real pkgops issuer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the §7 functional gates from the rab-exercise oracle to the real pkgops public path, per docs/pkgops-vm-gate-plan.md Part B. A thin VM-only launcher apt-issue (apt-issue.sh -> apt-issue.R) recomputes the preview via pkgops::apt__preview(), compares the caller-supplied resource/plan_hash byte-for-byte (never trusting caller hash data), commits via pkgops::apt_(), and prints one RESULT line in the rab-exercise grammar with matching exit codes (0 persisted / 1 pre-intent / 3 left-open), so every gate assertion is unchanged. The split is deliberate and preserves all assertions: - functional gates run through real pkgops (apt-issue). G9/G-OWN surface as pkgops PREVIEW-SIDE refusals (no intent opened; outcome=preview_refused), not broker-redemption refusals. - G12/G13/G14 (bad/replayed/stale receipt injection) and G15 (a package arg to the nullary apt.update entrypoint) keep calling rab-exercise directly: they inject inputs the pkgops issuer would never construct. - G11a/G11b call pkexec directly (entrypoint-isolation, below both). install-apt-stack.sh now installs the R stack in the guest (R 4.6 + janssonr + pkgstate + runix + pkgops from staged sources) and the apt-issue launcher, with verify-surface checks. build-and-stage.sh git-archives the three R sources from five clean pinned trees. redact.jq projects the Part A durable post-state fields (observed/changed/state_changed/observed_failed/authorized_via) so the durable-record round-trip is visible; secrets still never leave the guest. CI: the canary-apt-payload job now also R-parses deploy/canary-apt/*.R (deploy/ is .Rbuildignore'd, so R CMD check never sees apt-issue.R). --- .github/workflows/ci.yaml | 12 +- deploy/canary-apt-runbook.md | 68 ++++++++---- deploy/canary-apt/apt-gates.sh | 107 ++++++++++++------ deploy/canary-apt/apt-issue.R | 145 +++++++++++++++++++++++++ deploy/canary-apt/apt-issue.sh | 14 +++ deploy/canary-apt/build-and-stage.sh | 37 +++++-- deploy/canary-apt/install-apt-stack.sh | 54 ++++++++- deploy/canary-apt/redact.jq | 9 +- docs/pkgops-vm-gate-plan.md | 45 ++++++-- 9 files changed, 408 insertions(+), 83 deletions(-) create mode 100644 deploy/canary-apt/apt-issue.R create mode 100644 deploy/canary-apt/apt-issue.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fb299eb..50839b0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -222,20 +222,24 @@ jobs: Rscript .github/integration/inspect.R # Lightweight validation of the VM-only apt-mutation canary payload - # (deploy/canary-apt): shell syntax, the C lock-holder, and the jq redactor. The - # payload itself runs only on a disposable guest; this just keeps it well-formed. + # (deploy/canary-apt): shell syntax, the C lock-holder, the jq redactor, and the R + # launcher. The payload itself runs only on a disposable guest; this just keeps it + # well-formed (deploy/ is .Rbuildignore'd, so R CMD check never sees apt-issue.R). canary-apt-payload: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 - name: Tools - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends jq shellcheck - - name: Syntax gates (bash -n, cc -fsyntax-only, jq filter) + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends jq shellcheck r-base-core + - name: Syntax gates (bash -n, cc -fsyntax-only, jq filter, R parse) run: | set -e for f in deploy/canary-apt/*.sh; do bash -n "$f"; done cc -fsyntax-only deploy/canary-apt/fcntl-lock.c echo '{}' | jq -f deploy/canary-apt/redact.jq >/dev/null + for f in deploy/canary-apt/*.R; do + Rscript -e 'invisible(parse(commandArgs(TRUE)[[1]]))' "$f" + done - name: shellcheck (advisory) continue-on-error: true run: shellcheck -S warning deploy/canary-apt/*.sh diff --git a/deploy/canary-apt-runbook.md b/deploy/canary-apt-runbook.md index 8d39e70..8667a5c 100644 --- a/deploy/canary-apt-runbook.md +++ b/deploy/canary-apt-runbook.md @@ -1,20 +1,31 @@ # Canary apt — pkgexec mutation boundary on a real systemd/polkit host -Status: runbook (executable). This is the **destructive VM gate** for the pkgexec -activation slice (`libapt-pkg-helper-plan.md` §7, contract conformance 14–21) — the -first time the apt-mutation effector (nine root entrypoints + polkit + broker -effect-receipts) runs on a real host. It extends the A1 canary harness -(`deploy/canary/`, same disposable KVM guest, same ownership-marked teardown); the -KVM host's NVIDIA/kernel/boot/networking/SSH/container-runtime are never touched. +Status: runbook (executable). This is the **destructive VM gate** for two slices: +the pkgexec activation slice (`libapt-pkg-helper-plan.md` §7, contract conformance +14–21) — the apt-mutation effector (nine root entrypoints + polkit + broker +effect-receipts) on a real host — and the **pkgops VM-gate increment** Part B +(`docs/pkgops-vm-gate-plan.md`), which drives the functional §7 gates through the +REAL pkgops issuer and asserts the Part A durable-record round-trip. It extends the +A1 canary harness (`deploy/canary/`, same disposable KVM guest, same +ownership-marked teardown); the KVM host's +NVIDIA/kernel/boot/networking/SSH/container-runtime are never touched. ## Scope, stated honestly -No R stack is installed. The unprivileged issuer role that `pkgops` will one day -play is stood in for by **`rab-exercise`**, a VM-only, uninstalled driver. So this -proves the **native helper boundary** (parse → policy → digest → redeem gate → -commit → outcome, plus polkit authorization), **not** the future `pkgops` -integration. That is the point of the slice: freeze and prove the boundary before -`pkgops` is built on top of it. +The R stack **is** installed in the guest (R 4.6 + `janssonr` + `pkgstate` + `runix` ++ `pkgops`, from pinned staged sources), and the **functional** §7 gates run through +the real pkgops public path via the VM-only launcher `apt-issue` (`apt-issue.sh` → +`apt-issue.R`, calling `pkgops::apt_(apt__preview(...))`). So this proves +the full public path end to end: preview → capability query → polkit → native +effect-session → real `pkexec` entrypoint → `pkgstate` verification → durable outcome +at the real broker. + +`rab-exercise` is **retained** as the VM-only lower-level oracle for the gates the +pkgops issuer cannot express, because they deliberately feed the boundary inputs a +correct issuer would never construct: **G12/G13/G14** (a mismatched / replayed / +drifted receipt) and **G15** (a package argument to the nullary `apt.update` +entrypoint). **G11a/G11b** call `pkexec` directly (entrypoint-isolation, below both +pkgops and rab-exercise). Everything else is pkgops. ## The issue-time planner and the VM-only lifecycle oracle @@ -35,7 +46,18 @@ integration. That is the point of the slice: freeze and prove the boundary befor advisory preview matched the effector's atomic locked re-resolution. Installed from the pkgexec `.deb` (on PATH at `/usr/bin`); the root `pkgexec-plan` diagnostic is no longer used by the gates. +- **`apt-issue`** (`apt-issue.sh` → `apt-issue.R`, VM-only, run unprivileged as the + principal): the pkgops issuer launcher for the **functional** gates. It RECOMPUTES + the preview itself via `pkgops::apt__preview()`, treats the caller-supplied + `resource`/`plan_hash` as EXPECTED values compared byte-for-byte before any commit + (never trusting caller hash data), then calls `pkgops::apt_(preview)` and + prints ONE `RESULT` line in the rab-exercise grammar with matching exit codes + (0 persisted / 1 pre-intent / 3 left-open). No receipt or binding ever enters + argv/env/disk/output — pkgops keeps those in the runix C heap and wipes them. + Preview-side refusals (`package_not_owned`/`protected_package`, ...) are emitted + with `effect_issued=false` / `outcome=preview_refused`, exit 0 (no intent opened). - **`rab-exercise`** (broker `tools/rab-exercise.c`, run as the principal): the + lower-level lifecycle oracle, now used only by the injection gates (G12-G15) — the whole lifecycle in ONE process (the outcome binding is pinned to the opener's full process identity). Verifies the broker peer is uid 0; `open_intent(+effect)`; hands the receipt to the exact pkexec'd entrypoint over a private stdin pipe; @@ -90,8 +112,8 @@ runtime is VM-only. | G6 failed-postinst | `dpkg_broken`, `effect_issued:true`; native half-configured | | G7 configure (still failing) | `dpkg_broken`, `effect_issued:true` | | G8 hold (autonomous) / unhold (temp-grant) | `ok`; dpkg selection reads back `hold` then `install` | -| G9 protected removal | `protected_package`, `effect_issued:false`, still installed | -| G-OWN rapt-owned refusal | `package_not_owned`, `effect_issued:false` (ownership before redeem) | +| G9 protected removal | `protected_package`, `effect_issued:false`, still installed (pkgops **preview-side** refusal: `outcome=preview_refused`, no intent opened) | +| G-OWN rapt-owned refusal | `package_not_owned`, `effect_issued:false` (pkgops **preview-side** refusal, before any redeem) | | G10 lock contention | `apt_locked` (fcntl F_SETLK holder excludes `GetLock`) | | G11a missing receipt | schema refusal (`internal`), `effect_issued:false` | | G11b invalid receipt | `no_intent`, `detail=receipt_invalid` | @@ -122,10 +144,11 @@ of a stale grant. ## Reproduce -`build-and-stage.sh` refuses to run unless all three trees are clean, `git -archive`s from exact commits (so what is staged is exactly what is committed and -reviewed), and ships SHA-256 sums the guest verifies before building. The host is a -required argument (no hardcoded default). +`build-and-stage.sh` refuses to run unless all **five** trees are clean (broker, +pkgexec, runix, pkgstate, pkgops), `git archive`s from exact commits (so what is +staged is exactly what is committed and reviewed — including the three R sources +installed in the guest), and ships SHA-256 sums the guest verifies before building. +The host is a required argument (no hardcoded default). ``` # on the workstation (repos committed + clean): @@ -143,6 +166,9 @@ deploy/canary/provision.sh destroy # owned guest + storage only The guest is disposable (cattle). Evidence goes to a fresh per-run `~/canary-apt/evidence--/`: the matrix/gate logs, dpkg state, and a **redacted** audit projection (cid, actor, phase, operation, outcome, -`effect_issued`, and receipt *state* only — never the verifier or any token; the raw -sink never leaves the guest). Then `provision.sh destroy` removes only the owned domain -and its storage; nothing remains on the host but `~/canary{,-apt}`. +`effect_issued`, the Part A durable post-state fields — `observed`, `changed`, +`state_changed`, `observed_failed`, `authorized_via` — so the durable-record +round-trip is visible, and receipt *state* only — never the verifier, any token, or +any bound field; the raw sink never leaves the guest). Then `provision.sh destroy` +removes only the owned domain and its storage; nothing remains on the host but +`~/canary{,-apt}`. diff --git a/deploy/canary-apt/apt-gates.sh b/deploy/canary-apt/apt-gates.sh index 98e2a69..fa8e4b0 100755 --- a/deploy/canary-apt/apt-gates.sh +++ b/deploy/canary-apt/apt-gates.sh @@ -1,12 +1,22 @@ #!/bin/bash # §7 apt-mutation acceptance gates (libapt-pkg-helper-plan.md §7) inside the guest. -# Run as `ubuntu`. Drives the NATIVE helper boundary via: -# runix-apt-preview (aptbot): the PRODUCTION planner — the unprivileged, read-only -# issue-time digest source (resource + plan_hash), exactly the -# binary pkgops will call. NOT the root pkgexec-plan diagnostic. -# rab-exercise (aptbot): the one-process open->helper->outcome lifecycle -# Native observations only (dpkg-query, the audit sink); no R stack, so this proves -# the native helper boundary with the production planner's hash driving redemption. +# Run as `ubuntu`. Drives the REAL pkgops issuer path (VM-gate plan Part B), plus two +# native-boundary oracles for the cases the issuer structurally cannot reach: +# apt-issue (aptbot): the pkgops PUBLIC path -- apt__preview() recomputes +# the plan, then apt_() commits it (capability -> polkit -> the +# effect-session -> the real pkexec entrypoint -> pkgstate verify -> +# the durable outcome). EVERY functional gate runs through THIS +# (i.e. every gate except G11a/G11b, G12, G13, G14, G15 below). +# rab-exercise (aptbot): the broker/receipt oracle, retained ONLY for the gates +# that deliberately inject a bad/replayed/stale receipt (G12-G14) or +# a forbidden entrypoint argument (G15) -- inputs the pkgops API, which +# mints its own receipt and has no package arg for a nullary verb, +# cannot express. These prove the native boundary BELOW the issuer. +# direct pkexec (aptbot): the receipt-schema gates G11a/G11b call an entrypoint +# directly (no receipt lifecycle) to prove its own schema defense. +# The issue-time resource + plan_hash still come from runix-apt-preview (aptbot); the +# gates pass them to apt-issue as EXPECTED values, which it recomputes and compares +# byte-for-byte before committing -- it never trusts a caller-supplied hash. # # Autonomous verbs (update/hold) run as aptbot directly. Every OTHER verb — unhold # included — is gated, so it follows codex's rule: the default-denial matrix is @@ -197,6 +207,20 @@ do_ex() { # [--replay] [pkgs...] -> EXRC,EXSTATUS,EXDET EXDETAIL=$(field detail "$line"); EXEFFECT=$(field effect_issued "$line") EXOUTCOME=$(field outcome "$line"); EXREPLAY=$(field replay "$line") } +do_issue() { # [pkgs...] -> EX* via the REAL pkgops path + # apt-issue (aptbot) recomputes the preview through apt__preview(), compares + # the passed resource/hash byte-for-byte, then commits through apt_(). It + # prints ONE RESULT line in the same grammar rab-exercise uses and mirrors its exit + # codes (0 persisted, 1 pre-intent, 3 left-open), so every gate assertion below is + # unchanged. No socket arg: pkgops uses its default /run/runix-audit.sock. + local out line + out=$(sudo -u aptbot apt-issue "$@" 2>/dev/null) + EXRC=$? + line=$(grep '^RESULT ' <<<"$out" | head -1) + EXCID=$(field cid "$line"); EXSTATUS=$(field status "$line") + EXDETAIL=$(field detail "$line"); EXEFFECT=$(field effect_issued "$line") + EXOUTCOME=$(field outcome "$line"); EXREPLAY=$(field replay "$line") +} dpkg_state() { dpkg-query -W -f='${Status}' "$1" 2>/dev/null; } dpkg_ver() { dpkg-query -W -f='${Version}' "$1" 2>/dev/null; } # Pin every CURRENTLY-upgradable package EXCEPT canary-benign to its installed @@ -274,27 +298,27 @@ audit_intent_outcome() { # cid label || no "$2 audit" "intent=$ni outcome=$no actor=$act" } -echo "########## G-NEG: a malformed preview hard-stops before rab-exercise (issuer-side) ##########" +echo "########## G-NEG: a malformed preview hard-stops before the issuer (issuer-side) ##########" # Prove the strict validator is a REAL gate, not advisory: an invalid planner response -# must hard-stop do_plan so rab-exercise is NEVER invoked. Each case injects a crafted +# must hard-stop do_plan so the issuer is NEVER invoked. Each case injects a crafted # response and runs the SAME plan_guard the real gates use, in a subshell with a -# tripwire do_ex; an invalid case must exit nonzero with the tripwire untouched, and a -# valid control must proceed and fire it. +# tripwire do_issue; an invalid case must exit nonzero with the tripwire untouched, and +# a valid control must proceed and fire it. neg_hardstop() { # name PPREV PH PRC expect(stop=1|proceed=0) local name=$1 pv=$2 ph=$3 rc=$4 expect=$5 TRIP; TRIP=$(mktemp -u) - ( do_ex() { : >"$TRIP"; } # tripwire: fires iff rab-exercise runs + ( do_issue() { : >"$TRIP"; } # tripwire: fires iff the issuer runs PPREV=$pv; PST=$(jq -r '.status // ""' <<<"$pv" 2>/dev/null); PH=$ph; PRC=$rc REQ_VERB=apt.install; REQ_PKGS_STR=x; REQ_PKGS_JSON='["x"]'; rec_schema apt.install plan_guard # exits 1 iff invalid - do_ex apt.install x "$ph" x ) >/dev/null 2>&1 + do_issue apt.install x "$ph" x ) >/dev/null 2>&1 local rc2=$? if [ "$expect" = 1 ]; then { [ "$rc2" -ne 0 ] && [ ! -e "$TRIP" ]; } \ - && ok "G-NEG $name hard-stopped, rab-exercise not invoked" \ + && ok "G-NEG $name hard-stopped, issuer not invoked" \ || no "G-NEG $name" "rc=$rc2 trip=$([ -e "$TRIP" ] && echo FIRED || echo clean)" else { [ "$rc2" -eq 0 ] && [ -e "$TRIP" ]; } \ - && ok "G-NEG $name valid -> proceeds to rab-exercise" \ + && ok "G-NEG $name valid -> proceeds to the issuer" \ || no "G-NEG $name" "rc=$rc2 trip=$([ -e "$TRIP" ] && echo fired || echo MISSING)" fi rm -f "$TRIP" @@ -312,7 +336,7 @@ neg_hardstop "valid-control" "{\"schema_version\":1,\"status\":\"ok\",\"verb\ echo "########## G1: update good-source -> applied, durable audit ##########" do_plan apt.update if [ "$PRC" = 0 ] && [ -n "$PH" ]; then - do_ex apt.update "" "$PH" + do_issue apt.update "" "$PH" { [ "$EXSTATUS" = ok ] && [ "$EXEFFECT" = true ] && [ "$EXOUTCOME" = persisted ]; } \ && ok "G1 update applied" || no "G1 update" "status=$EXSTATUS eff=$EXEFFECT out=$EXOUTCOME" audit_intent_outcome "$EXCID" "G1" @@ -329,7 +353,7 @@ Trusted: yes Enabled: yes EOF do_plan apt.update -do_ex apt.update "" "$PH" +do_issue apt.update "" "$PH" { [ "$EXSTATUS" = operation_failed ] && [ "$EXEFFECT" = true ]; } \ && ok "G2 bad-source -> operation_failed" || no "G2" "status=$EXSTATUS eff=$EXEFFECT" sudo rm -f "$BROKENSRC" @@ -338,7 +362,7 @@ echo "########## G3: benign install (temp-grant) -> applied ##########" grant install sudo apt-get remove -y canary-benign >/dev/null 2>&1 || true do_plan apt.install canary-benign -do_ex apt.install "$PR" "$PH" canary-benign +do_issue apt.install "$PR" "$PH" canary-benign { [ "$EXSTATUS" = ok ] && [ "$EXEFFECT" = true ]; } \ && ok "G3 install status ok" || no "G3 install" "status=$EXSTATUS eff=$EXEFFECT" dpkg_state canary-benign | grep -q "install ok installed" \ @@ -349,7 +373,7 @@ ungrant echo "########## G4: benign remove (temp-grant) -> applied ##########" grant remove do_plan apt.remove canary-benign -do_ex apt.remove "$PR" "$PH" canary-benign +do_issue apt.remove "$PR" "$PH" canary-benign { [ "$EXSTATUS" = ok ] && [ "$EXEFFECT" = true ]; } \ && ok "G4 remove status ok" || no "G4 remove" "status=$EXSTATUS eff=$EXEFFECT" dpkg_state canary-benign | grep -q "install ok installed" \ @@ -381,7 +405,7 @@ SIM_OK=0; { [ -n "$CBLINE" ] && [ -z "$OTHER" ]; } && SIM_OK=1 if [ "$SETUP_OK" -eq 1 ] && [ "$SIM_OK" -eq 1 ]; then do_plan apt.upgrade if [ "${PRC:-1}" -eq 0 ] && [ -n "${PH:-}" ]; then - do_ex apt.upgrade "$PR" "$PH" + do_issue apt.upgrade "$PR" "$PH" { [ "$EXSTATUS" = ok ] && [ "$EXEFFECT" = true ]; } \ && ok "G5 upgrade status ok" || no "G5 upgrade" "status=$EXSTATUS eff=$EXEFFECT" [ "$(dpkg_ver canary-benign)" = 1.1 ] \ @@ -399,21 +423,23 @@ ungrant echo "########## G8: hold (autonomous) then unhold (temp-grant), selection read-back ##########" sudo apt-get install -y canary-benign >/dev/null 2>&1 do_plan apt.hold canary-benign -do_ex apt.hold "$PR" "$PH" canary-benign +do_issue apt.hold "$PR" "$PH" canary-benign sel=$(dpkg_state canary-benign | awk '{print $1}') { [ "$EXSTATUS" = ok ] && [ "$sel" = hold ]; } \ && ok "G8 hold applied, selection=$sel" || no "G8 hold" "status=$EXSTATUS sel=$sel" grant unhold # unhold is NOT autonomous do_plan apt.unhold canary-benign -do_ex apt.unhold "$PR" "$PH" canary-benign +do_issue apt.unhold "$PR" "$PH" canary-benign sel=$(dpkg_state canary-benign | awk '{print $1}') { [ "$EXSTATUS" = ok ] && [ "$sel" = install ]; } \ && ok "G8 unhold applied, selection=$sel" || no "G8 unhold" "status=$EXSTATUS sel=$sel" ungrant -echo "########## G9: protected removal refused before the receipt is spent ##########" +echo "########## G9: protected removal refused (preview-side, no intent) ##########" +# Through pkgops the protected refusal is PREVIEW-side: apt_remove_preview() raises +# protected_package, so no intent opens and no receipt is spent (effect_issued=false). grant remove -do_ex apt.remove canary-protected "$ZERO" canary-protected +do_issue apt.remove canary-protected "$ZERO" canary-protected { [ "$EXSTATUS" = protected_package ] && [ "$EXEFFECT" = false ]; } \ && ok "G9 protected removal refused" || no "G9" "status=$EXSTATUS eff=$EXEFFECT" dpkg_state canary-protected | grep -q "install ok installed" \ @@ -421,10 +447,11 @@ dpkg_state canary-protected | grep -q "install ok installed" \ ungrant echo "########## G-OWN: rapt-owned package refused (ownership, autonomous hold) ##########" -# r-cornball-canary matches ^r-[a-z]+-[a-z0-9.]+$ -> package_not_owned before redeem. -# hold is autonomous, so no grant is needed; a placeholder hash is never checked (the -# ownership refusal precedes redemption). -do_ex apt.hold r-cornball-canary "$ZERO" r-cornball-canary +# r-cornball-canary matches ^r-[a-z]+-[a-z0-9.]+$ -> package_not_owned. Through pkgops +# this is a PREVIEW-side refusal: apt_hold_preview() itself raises, so NO intent is +# opened (effect_issued=false) and the placeholder hash is never reached. hold is +# autonomous, so no grant is needed. +do_issue apt.hold r-cornball-canary "$ZERO" r-cornball-canary { [ "$EXSTATUS" = package_not_owned ] && [ "$EXEFFECT" = false ]; } \ && ok "G-OWN rapt-owned hold refused" || no "G-OWN" "status=$EXSTATUS eff=$EXEFFECT" @@ -436,7 +463,7 @@ LOCKPID=$! for _ in $(seq 1 20); do grep -q '^locked' "$LOCKOUT" 2>/dev/null && break; sleep 0.5; done grep -q '^locked' "$LOCKOUT" && ok "G10 fcntl lock held (excludes GetLock)" \ || no "G10 lock-holder" "did not acquire the lock" -do_ex apt.update "" "$PH" +do_issue apt.update "" "$PH" { [ "$EXSTATUS" = apt_locked ] && [ "$EXEFFECT" = false ]; } \ && ok "G10 lock contention -> apt_locked" || no "G10" "status=$EXSTATUS eff=$EXEFFECT" kill "$LOCKPID" 2>/dev/null; wait "$LOCKPID" 2>/dev/null || true; LOCKPID=""; rm -f "$LOCKOUT" @@ -454,6 +481,11 @@ direct_pkexec "$LIBX/runix-apt-update" \ { [ "$DST" = no_intent ] && [ "$DEF" = false ] && [ "$DDT" = receipt_invalid ]; } \ && ok "G11b invalid receipt -> no_intent (detail=receipt_invalid)" || no "G11b" "status=$DST eff=$DEF detail=$DDT" +# G12-G14 stay on rab-exercise (the broker/receipt oracle), NOT the pkgops issuer: +# each deliberately presents a bad/replayed/stale RECEIPT at the redeem boundary, which +# the pkgops path structurally cannot do -- apt-issue recomputes and commits only the +# preview it derived itself, minting its own receipt, so it can never hand the broker a +# wrong/replayed/drifted hash. These prove the broker's receipt defense BELOW the issuer. echo "########## G12: mismatched receipt (wrong bound hash) -> no_intent/receipt_mismatch ##########" do_ex apt.update "" "1111111111111111111111111111111111111111111111111111111111111111" { [ "$EXSTATUS" = no_intent ] && [ "$EXDETAIL" = receipt_mismatch ] && [ "$EXEFFECT" = false ]; } \ @@ -482,6 +514,10 @@ do_ex apt.update "" "$PH" sudo rm -f "$DRIFTSRC" echo "########## G15: entrypoint isolation (a package arg is REJECTED by update) ##########" +# Stays on rab-exercise (the native oracle): this injects a package into a NULLARY +# entrypoint to prove its arity defense (update's arity is 0 -> internal). The pkgops +# API cannot express it -- apt_update() has no package parameter -- so the injection +# only reaches the entrypoint through the oracle, not the issuer. sudo apt-get remove -y canary-benign >/dev/null 2>&1 || true do_plan apt.update do_ex apt.update "" "$PH" canary-benign # update's arity is 0; a package is a schema refusal @@ -496,7 +532,12 @@ grant install sudo rm -f /run/canary-slow.marker do_plan apt.install canary-slow INTOUT=$(mktemp) -sudo -u aptbot rab-exercise "$SOCK" apt.install "$PR" "$PH" canary-slow >"$INTOUT" 2>&1 & +# The pkgops launcher (an Rscript, the aptbot ANCESTOR of the pkexec-spawned root +# committer) is deliberately NOT in the kill set: only the root helper subtree is +# killed, so apt-issue survives to catch the commit's failure and record the intent +# LEFT OPEN. pkgops now attaches the session cid to that left-open condition, so the +# RESULT line still carries the cid the receipt-state assertion needs. +sudo -u aptbot apt-issue apt.install "$PR" "$PH" canary-slow >"$INTOUT" 2>&1 & BGPID=$! # Synchronize on the postinst marker: it appears only AFTER redeem + unpack, while # the configure (postinst) is mid-run — the real post-redeem interruption window. @@ -559,7 +600,7 @@ sudo rm -f /run/canary-slow.marker echo "########## G6/G7: failed-postinst -> dpkg_broken, then configure (broken) ##########" grant install do_plan apt.install canary-badpost -do_ex apt.install "$PR" "$PH" canary-badpost +do_issue apt.install "$PR" "$PH" canary-badpost { [ "$EXSTATUS" = dpkg_broken ] && [ "$EXEFFECT" = true ]; } \ && ok "G6 failed-postinst -> dpkg_broken (effect issued)" || no "G6" "status=$EXSTATUS eff=$EXEFFECT" dpkg_state canary-badpost | grep -q "half-configured" \ @@ -567,7 +608,7 @@ dpkg_state canary-badpost | grep -q "half-configured" \ ungrant grant configure do_plan apt.configure -do_ex apt.configure "$PR" "$PH" +do_issue apt.configure "$PR" "$PH" { [ "$EXSTATUS" = dpkg_broken ] && [ "$EXEFFECT" = true ]; } \ && ok "G7 configure of a still-failing package -> dpkg_broken" || no "G7" "status=$EXSTATUS eff=$EXEFFECT" ungrant @@ -591,7 +632,7 @@ grep -q "BEGIN PGP" <<<"$PPREV" \ && no "G-INLINE armor leak" "armored key material in preview stdout" \ || ok "G-INLINE no armored key material in preview output" if [ "$PVALID" = 1 ] && [ "$PRC" = 0 ] && [ -n "$PH" ]; then - do_ex apt.update "" "$PH" + do_issue apt.update "" "$PH" { [ "$EXSTATUS" = ok ] && [ "$EXEFFECT" = true ]; } \ && ok "G-INLINE preview hash redeems through the locked update effector" \ || no "G-INLINE redeem" "status=$EXSTATUS eff=$EXEFFECT" diff --git a/deploy/canary-apt/apt-issue.R b/deploy/canary-apt/apt-issue.R new file mode 100644 index 0000000..360242f --- /dev/null +++ b/deploy/canary-apt/apt-issue.R @@ -0,0 +1,145 @@ +# VM-ONLY canary launcher: drive the REAL pkgops issuer path for the §7 gates, in +# place of the rab-exercise oracle. Runs UNPRIVILEGED (as aptbot). It RECOMPUTES the +# preview itself via pkgops::apt__preview() and treats the caller-supplied +# resource / plan_hash as EXPECTED values only -- compared byte-for-byte before any +# commit. It NEVER constructs or trusts a preview from caller hash data, and no +# receipt or binding ever enters argv, environment, disk, or this output (pkgops +# keeps those in the runix C heap and wipes them). It prints ONE `RESULT ...` line in +# the rab-exercise grammar and mirrors its exit codes: 0 = outcome durably persisted +# (incl. a valid refusal), 1 = pre-intent failure (nothing durable), 3 = intent LEFT +# OPEN (effect unknown). This file is NEVER packaged; install-apt-stack.sh stages and +# installs it only inside the disposable guest. + +argv <- commandArgs(trailingOnly = TRUE) +if (length(argv) < 3L) { + cat("apt-issue: usage: apt-issue [pkg...]\n", + file = stderr()) + quit(status = 2L, save = "no") # rab EX_USAGE, no RESULT line +} +verb <- argv[[1L]] +exp_resource <- argv[[2L]] +exp_hash <- argv[[3L]] +pkgs <- if (length(argv) > 3L) argv[-(1:3)] else character(0) + +## verb -> (preview fn, commit fn). Whole-system verbs take no packages. +PREVIEW <- list(apt.install = pkgops::apt_install_preview, + apt.remove = pkgops::apt_remove_preview, + apt.purge = pkgops::apt_purge_preview, + apt.hold = pkgops::apt_hold_preview, + apt.unhold = pkgops::apt_unhold_preview, + apt.update = pkgops::apt_update_preview, + apt.upgrade = pkgops::apt_upgrade_preview, + apt.dist_upgrade = pkgops::apt_dist_upgrade_preview, + apt.configure = pkgops::apt_configure_preview) +COMMIT <- list(apt.install = pkgops::apt_install, apt.remove = pkgops::apt_remove, + apt.purge = pkgops::apt_purge, apt.hold = pkgops::apt_hold, + apt.unhold = pkgops::apt_unhold, apt.update = pkgops::apt_update, + apt.upgrade = pkgops::apt_upgrade, + apt.dist_upgrade = pkgops::apt_dist_upgrade, + apt.configure = pkgops::apt_configure) +NULLARY <- c("apt.update", "apt.upgrade", "apt.dist_upgrade", "apt.configure") + +if (is.null(PREVIEW[[verb]])) { + cat(sprintf("apt-issue: unknown verb %s\n", verb), file = stderr()) + quit(status = 2L, save = "no") +} + +## the CLOSED refusal statuses: a raised condition carrying one of these is a +## durably-persisted refusal (rab EX_OK / outcome=persisted). ok / no_op arrive on +## the RETURN path, never as a condition. +CLOSED <- c("held", "operation_failed", "apt_locked", "no_intent", + "package_not_owned", "protected_package", "resolve_failed", + "not_applied", "dpkg_broken", "internal", + "unauthorized", "approval_required", "spawn_failed") + +## sanitize any value to a single RESULT token: the grammar is space-separated and +## unquoted, so a value with whitespace would corrupt the line. NULL/NA -> "". +tok <- function(x) { + if (is.null(x) || length(x) != 1L || is.na(x)) { + return("") + } + gsub("[[:space:]]+", "_", as.character(x)) +} +## render effect_issued like rab-exercise: true / false / unknown (NA). +eff <- function(x) { + if (is.null(x) || length(x) != 1L || is.na(x)) { + return("unknown") + } + if (isTRUE(x)) "true" else "false" +} +## normalize a scalar for a byte-for-byte compare (NULL/NA -> ""). +norm <- function(x) { + if (is.null(x) || length(x) != 1L || is.na(x)) { + "" + } else { + as.character(x) + } +} +emit <- function(cid, plan_hash, status, detail, effect_issued, outcome) { + cat(sprintf(paste("RESULT cid=%s plan_hash=%s verb=%s status=%s detail=%s", + "effect_issued=%s outcome=%s\n"), + tok(cid), tok(plan_hash), verb, tok(status), tok(detail), + effect_issued, outcome)) +} + +## --- 1. recompute the preview OURSELVES (never from caller hash data) --------- +prev <- tryCatch(if (verb %in% NULLARY) { + PREVIEW[[verb]]() + } else { + PREVIEW[[verb]](pkgs) + }, condition = function(c) c) +if (inherits(prev, "condition")) { + ## a preview-side refusal or failure: NO intent is opened. A known refusal + ## status (package_not_owned / held / protected_package, ...) is meaningful -> + ## emit it with effect_issued=false and a preview_refused outcome, exit 0. A + ## malformed / resolve failure has no committable intent -> exit 1. + st <- prev$status + if (is.character(st) && length(st) == 1L && st %in% CLOSED) { + emit(prev$correlation_id, prev$plan_hash, st, prev$detail, "false", + "preview_refused") + quit(status = 0L, save = "no") + } + emit(prev$correlation_id, prev$plan_hash, + if (is.character(st) && length(st) == 1L) st else "internal", + conditionMessage(prev), "false", "no_intent") + quit(status = 1L, save = "no") +} + +## --- 2. compare the EXPECTED resource/hash byte-for-byte before committing. A +## mismatch means the caller's expectation disagrees with what pkgops independently +## planned: refuse, never commit a plan we did not derive ourselves. +if (!identical(norm(prev$resource), norm(exp_resource)) || + !identical(norm(prev$plan_hash), norm(exp_hash))) { + emit(prev$correlation_id, prev$plan_hash, "expectation_mismatch", + sprintf("expected %s/%s got %s/%s", norm(exp_resource), + substr(norm(exp_hash), 1L, 12L), norm(prev$resource), + substr(norm(prev$plan_hash), 1L, 12L)), + "false", "refused") + quit(status = 1L, save = "no") +} + +## --- 3. commit the preview WE derived; map outcome / condition -> RESULT ------- +oc <- tryCatch(COMMIT[[verb]](prev), condition = function(c) c) +if (!inherits(oc, "condition")) { + ## success: a returned pkgops_outcome is always closed + durably written. + emit(oc$correlation_id, oc$plan_hash, oc$status, NA, eff(oc$effect_issued), + "persisted") + quit(status = 0L, save = "no") +} +## a CLOSED refusal status -> the outcome was persisted before the raise; exit 0. +st <- oc$status +if (is.character(st) && length(st) == 1L && st %in% CLOSED) { + emit(oc$correlation_id, oc$plan_hash, st, oc$detail, eff(oc$effect_issued), + "persisted") + quit(status = 0L, save = "no") +} +## otherwise post-open-left-open vs pre-intent, discriminated by the cid: pkgops now +## attaches the session cid to every left-open / effect-unknown condition, so a cid +## means an intent was opened and left OPEN (reconcilable), none means pre-intent. +cid <- oc$correlation_id +if (is.character(cid) && length(cid) == 1L && !is.na(cid) && nzchar(cid)) { + emit(cid, oc$plan_hash, "unknown", "unknown", "unknown", "open") # rab open line + quit(status = 3L, save = "no") # rab EX_OPEN_LEFT +} +cat(sprintf("apt-issue: %s\n", conditionMessage(oc)), file = stderr()) +quit(status = 1L, save = "no") # rab EX_FAIL diff --git a/deploy/canary-apt/apt-issue.sh b/deploy/canary-apt/apt-issue.sh new file mode 100644 index 0000000..c3b8af6 --- /dev/null +++ b/deploy/canary-apt/apt-issue.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# VM-ONLY wrapper: run the pkgops issuer launcher (apt-issue.R) as the calling +# (unprivileged) principal, so the §7 gates drive the real pkgops public path in +# place of the rab-exercise oracle. Installed by install-apt-stack.sh as +# /usr/local/bin/apt-issue, with apt-issue.R beside it. NEVER packaged. +# +# apt-issue [pkg...] +# +# --vanilla keeps the run deterministic (no ~/.Renviron / ~/.Rprofile); pkgops is +# found on the default library path where R CMD INSTALL placed it. stdout is exactly +# the one RESULT line apt-issue.R prints (the caller greps '^RESULT '). +set -euo pipefail +HERE="$(dirname "$(readlink -f "$0")")" +exec Rscript --vanilla "$HERE/apt-issue.R" "$@" diff --git a/deploy/canary-apt/build-and-stage.sh b/deploy/canary-apt/build-and-stage.sh index 6f8af54..e1c85a4 100755 --- a/deploy/canary-apt/build-and-stage.sh +++ b/deploy/canary-apt/build-and-stage.sh @@ -1,23 +1,28 @@ #!/bin/bash # Runs on the workstation (where the repos live). Produces a fully REPRODUCIBLE -# staging set: it captures the three commit IDs (broker, pkgexec, runix) ONCE and -# sources EVERY artifact from those exact commits with `git archive` — the two -# source tarballs AND the payload scripts/helpers/redactor (never the worktree). It -# refuses unless all three trees are clean, checksums EVERYTHING transferred, and -# stages into a unique, owned directory on the host. Nothing is built here. +# staging set: it captures the FIVE commit IDs (broker, pkgexec, runix, pkgstate, +# pkgops) ONCE and sources EVERY artifact from those exact commits with `git archive` +# — the C source tarballs (broker, pkgexec), the R source tarballs (runix, pkgstate, +# pkgops, for Part B's pkgops issuer path) AND the payload scripts/helpers/redactor +# (never the worktree). runix serves double duty: its tree is BOTH the R package +# source (runix.tar.gz) and the payload directory (deploy/canary-apt). It refuses +# unless all five trees are clean, checksums EVERYTHING transferred, and stages into a +# unique, owned directory on the host. Nothing is built here. # # deploy/canary-apt/build-and-stage.sh # host is REQUIRED set -euo pipefail G5="${1:?usage: build-and-stage.sh (no default host; pass it explicitly)}" BROKER="${BROKER:-/home/troy/runix-audit-broker}" PKGEXEC="${PKGEXEC:-/home/troy/pkgexec}" +PKGSTATE="${PKGSTATE:-/home/troy/pkgstate}" +PKGOPS="${PKGOPS:-/home/troy/pkgops}" HERE="$(dirname "$(readlink -f "$0")")" RUNIX="$(git -C "$HERE" rev-parse --show-toplevel)" STAGE="$(mktemp -d)" trap 'rm -rf "$STAGE"' EXIT echo "== require clean trees (stage exactly what is committed) ==" -for r in "$BROKER" "$PKGEXEC" "$RUNIX"; do +for r in "$BROKER" "$PKGEXEC" "$RUNIX" "$PKGSTATE" "$PKGOPS"; do if [ -n "$(git -C "$r" status --porcelain)" ]; then echo "refusing: $r has uncommitted changes; commit them first" >&2 git -C "$r" status --short >&2 @@ -25,25 +30,37 @@ for r in "$BROKER" "$PKGEXEC" "$RUNIX"; do fi done -# Capture the three commit IDs ONCE; every artifact is sourced from these. +# Capture the five commit IDs ONCE; every artifact is sourced from these. BSHA="$(git -C "$BROKER" rev-parse HEAD)" PSHA="$(git -C "$PKGEXEC" rev-parse HEAD)" RSHA="$(git -C "$RUNIX" rev-parse HEAD)" +SSHA="$(git -C "$PKGSTATE" rev-parse HEAD)" +OSHA="$(git -C "$PKGOPS" rev-parse HEAD)" echo "== git archive every artifact from its exact commit ==" git -C "$BROKER" archive --format=tar.gz --prefix=runix-audit-broker/ "$BSHA" \ > "$STAGE/runix-audit-broker.tar.gz" git -C "$PKGEXEC" archive --format=tar.gz --prefix=pkgexec/ "$PSHA" \ > "$STAGE/pkgexec.tar.gz" +# the R sources for Part B's pkgops issuer path: each extracts to / in the guest, +# where install-apt-stack.sh runs `R CMD INSTALL` in dependency order. +git -C "$RUNIX" archive --format=tar.gz --prefix=runix/ "$RSHA" \ + > "$STAGE/runix.tar.gz" +git -C "$PKGSTATE" archive --format=tar.gz --prefix=pkgstate/ "$SSHA" \ + > "$STAGE/pkgstate.tar.gz" +git -C "$PKGOPS" archive --format=tar.gz --prefix=pkgops/ "$OSHA" \ + > "$STAGE/pkgops.tar.gz" # the payload scripts/helpers/redactor from the RUNIX commit, not the worktree git -C "$RUNIX" archive "$RSHA" deploy/canary-apt \ | tar -x -C "$STAGE" --strip-components=2 { echo "# canary-apt build manifest" - printf 'broker %s (%s)\n' "$BSHA" "$(git -C "$BROKER" rev-parse --abbrev-ref HEAD)" - printf 'pkgexec %s (%s)\n' "$PSHA" "$(git -C "$PKGEXEC" rev-parse --abbrev-ref HEAD)" - printf 'runix %s (%s)\n' "$RSHA" "$(git -C "$RUNIX" rev-parse --abbrev-ref HEAD)" + printf 'broker %s (%s)\n' "$BSHA" "$(git -C "$BROKER" rev-parse --abbrev-ref HEAD)" + printf 'pkgexec %s (%s)\n' "$PSHA" "$(git -C "$PKGEXEC" rev-parse --abbrev-ref HEAD)" + printf 'runix %s (%s)\n' "$RSHA" "$(git -C "$RUNIX" rev-parse --abbrev-ref HEAD)" + printf 'pkgstate %s (%s)\n' "$SSHA" "$(git -C "$PKGSTATE" rev-parse --abbrev-ref HEAD)" + printf 'pkgops %s (%s)\n' "$OSHA" "$(git -C "$PKGOPS" rev-parse --abbrev-ref HEAD)" } > "$STAGE/MANIFEST" echo "== checksum EVERYTHING transferred ==" diff --git a/deploy/canary-apt/install-apt-stack.sh b/deploy/canary-apt/install-apt-stack.sh index ea98dfe..495083d 100755 --- a/deploy/canary-apt/install-apt-stack.sh +++ b/deploy/canary-apt/install-apt-stack.sh @@ -10,10 +10,11 @@ # group created EMPTY by the maintainer script; # - the VM-only rab-exercise lifecycle oracle, built from the same source. The # issue-time hash now comes from the PACKAGED runix-apt-preview (the production -# planner installed from the .deb), not the root pkgexec-plan diagnostic. -# -# No R stack: rab-exercise stands in for pkgops, so this proves the NATIVE helper -# boundary, not the future pkgops integration (recorded honestly in the runbook). +# planner installed from the .deb), not the root pkgexec-plan diagnostic; +# - the R stack (Part B): R 4.6 + janssonr + pkgstate + runix + pkgops (from the +# staged sources), plus the apt-issue launcher, so the §7 gates drive the REAL +# pkgops public path. rab-exercise is RETAINED as the broker/receipt oracle for +# the gates the issuer cannot express (G11-G15). # # install-apt-stack.sh [src-dir] # default /tmp/canary-apt set -euo pipefail @@ -46,6 +47,44 @@ cc -O2 -Wall -o "$SRC/fcntl-lock.bin" "$SRC/fcntl-lock.c" sudo install -m 0755 "$SRC/runix-audit-broker/rab-exercise" /usr/local/bin/rab-exercise sudo install -m 0755 "$SRC/fcntl-lock.bin" /usr/local/bin/fcntl-lock +# --- the R stack (Part B): drive the gates through the real pkgops public path --- +# Mirrors deploy/canary/install-stack.sh: R 4.6 from CRAN (Noble ships 4.3, which the +# janssonr .deb outruns), janssonr from the cornball apt repo, then R CMD INSTALL the +# staged sources in dependency order (pkgops Imports runix + pkgstate + janssonr). +log "R 4.6 from the CRAN Ubuntu repo (Noble ships 4.3; the janssonr .deb needs >= 4.6)" +curl -fsSL https://cloud.r-project.org/bin/linux/ubuntu/marutter_pubkey.asc \ + | sudo tee /etc/apt/trusted.gpg.d/cran_ubuntu_key.asc >/dev/null +echo "deb https://cloud.r-project.org/bin/linux/ubuntu noble-cran40/" \ + | sudo tee /etc/apt/sources.list.d/cran.list >/dev/null +sudo apt-get purge -y littler r-cran-littler >/dev/null 2>&1 || true # ABI-pinned to R 4.3 +sudo apt-get update -qq +sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + r-base-core r-base-dev +echo " $(R --version | head -1)" + +log "janssonr from the cornball apt repository (runix's one Import)" +sudo tee /etc/apt/sources.list.d/janssonr.sources >/dev/null <<'EOF' +Types: deb +URIs: https://cornball-ai.github.io/janssonr +Suites: noble +Components: main +Trusted: yes +Enabled: yes +EOF +sudo apt-get update -qq +sudo apt-get install -y r-cornball-janssonr + +log "install the R packages from the staged sources (runix + pkgstate, then pkgops)" +cd "$SRC" +for p in runix pkgstate pkgops; do + rm -rf "$p" && tar xzf "$p.tar.gz" + sudo R CMD INSTALL "$p" +done + +log "install the apt-issue launcher (the pkgops issuer path; VM-only, never packaged)" +sudo install -m 0644 "$SRC/apt-issue.R" /usr/local/bin/apt-issue.R +sudo install -m 0755 "$SRC/apt-issue.sh" /usr/local/bin/apt-issue + log "verify install surface" fail=0 for v in install remove purge upgrade dist-upgrade update hold unhold configure; do @@ -93,4 +132,11 @@ else fi echo " broker: $(dpkg-query -W -f='${Version}' runix-audit-broker 2>/dev/null)" echo " pkgexec: $(dpkg-query -W -f='${Version}' pkgexec 2>/dev/null)" +# the R stack + apt-issue launcher (Part B). pkgops must load with its Imports. +for p in janssonr runix pkgstate pkgops; do + v=$(Rscript -e "cat(as.character(packageVersion('$p')))" 2>/dev/null) \ + && echo " R $p $v" || { echo " MISSING R package $p"; fail=1; } +done +{ command -v apt-issue >/dev/null && [ -f /usr/local/bin/apt-issue.R ]; } \ + && echo " apt-issue launcher on PATH" || { echo " MISSING apt-issue launcher"; fail=1; } [ "$fail" -eq 0 ] && echo "install-apt-stack: OK" || { echo "install-apt-stack: FAILED"; exit 1; } diff --git a/deploy/canary-apt/redact.jq b/deploy/canary-apt/redact.jq index 16a971f..d46b329 100644 --- a/deploy/canary-apt/redact.jq +++ b/deploy/canary-apt/redact.jq @@ -1,10 +1,15 @@ # Redaction for exported audit evidence. The raw sink holds sensitive broker state # (receipt verifier hashes, bound fields); export ONLY non-secret projections: -# - audit records: cid, actor, phase, operation, outcome, effect_issued +# - audit records: cid, actor, phase, operation, outcome, effect_issued, plus the +# Part A durable post-state fields (observed, changed, state_changed, +# observed_failed, authorized_via) so the durable-record round-trip is visible. +# These are non-secret record content; the receipt token/verifier and any bound +# field are NOT projected and never leave the guest. # - broker_receipt records: cid + receipt STATE only (never the verifier) # - anything else: its record_type only if (.phase != null) then - {correlation_id, actor, phase, operation, outcome, effect_issued} + {correlation_id, actor, phase, operation, outcome, effect_issued, + observed, changed, state_changed, observed_failed, authorized_via} elif (.record_type == "broker_receipt") then {record_type, correlation_id, receipt_state: .state} else diff --git a/docs/pkgops-vm-gate-plan.md b/docs/pkgops-vm-gate-plan.md index d1cb38a..30886bf 100644 --- a/docs/pkgops-vm-gate-plan.md +++ b/docs/pkgops-vm-gate-plan.md @@ -355,7 +355,8 @@ existing §7 gates run against it end to end." - Suites (in guest): `deploy/canary-apt/polkit-matrix.sh` (the **23** polkit-boundary assertions) and `deploy/canary-apt/apt-gates.sh` (the **37** §7 acceptance assertions). -- Support: `install-apt-stack.sh`, `apt-fixtures.sh`, `fcntl-lock.c`, `redact.jq`. +- Support: `install-apt-stack.sh`, `apt-fixtures.sh`, `fcntl-lock.c`, `redact.jq`, + and the VM-only pkgops launcher `apt-issue.sh` / `apt-issue.R` (never packaged). - Staging (workstation): `deploy/canary-apt/build-and-stage.sh `. - Provision/teardown (shared): `deploy/canary/provision.sh` (+ `provision.sh destroy`). @@ -379,9 +380,29 @@ Two harness edits (their own commit in `runix/deploy/canary-apt/`, reviewed): 1. `install-apt-stack.sh` also installs the R stack in the guest (r2u/rapt: R + `janssonr`, `pkgstate` 0.0.1.9, `runix` 0.0.1.12, `pkgops` #9+PartA), matching the A1 slice's R-stack install pattern. -2. `apt-gates.sh` drives each gate through `pkgops::apt_(apt__preview(...))` - in place of the `rab-exercise` redeem call, keeping every gate's assertions - (status, `effect_issued`, dpkg post-state, temp-grant setup/teardown) identical. +2. `apt-gates.sh` drives the **functional** gates through the real pkgops path via a + thin VM-only launcher `apt-issue` (`apt-issue.sh` → `apt-issue.R`), which calls + `pkgops::apt_(apt__preview(...))` and prints one `RESULT` line in the + `rab-exercise` grammar (same tokens, same exit codes: 0 persisted / 1 pre-intent / + 3 left-open), so each gate's assertions (status, `effect_issued`, dpkg post-state, + temp-grant setup/teardown) stay identical. The split is deliberate: + + - **The functional gates** run through real pkgops (`apt-issue`). This includes G9 + (protected) and G-OWN (not-owned): these surface as **preview-side refusals** + inside pkgops (`apt__preview()` raises the refusal status before any intent + is opened), so `apt-issue` emits them with `effect_issued=false` / + `outcome=preview_refused` and exit 0. They are **not** broker-redemption refusals. + - **G12/G13/G14** (bad / replayed / stale receipt injection) and **G15** (a + forbidden package argument to the nullary `apt.update` entrypoint) keep calling + `rab-exercise` directly: they deliberately inject invalid, replayed, or stale + receipts / arguments that the pkgops issuer would never construct, so only the + lower-level oracle can express them. + - **G11a/G11b** invoke `pkexec` directly (the entrypoint-isolation checks), below + both pkgops and rab-exercise. + + `apt-issue` **recomputes** the preview itself and compares the caller-supplied + resource / plan_hash byte-for-byte before committing — it never trusts caller hash + data, and no receipt or binding ever enters argv/env/disk/output. `polkit-matrix.sh` is **unchanged** — pkgops does not move the polkit boundary. ### 4.3 The public path proven @@ -402,11 +423,17 @@ committed; the per-run dir is disposable). Pass = **all** of: 1. **`polkit-matrix.sh`: 23/23** — unchanged from the pkgexec proof (pkgops does not touch the polkit boundary). A regression here means the harness/stack is mis-built, not a pkgops finding. -2. **`apt-gates.sh`: 37/37**, now driven through `pkgops`. Every existing gate's - status / `effect_issued` / dpkg post-state assertion holds with pkgops as the - issuer (G1 update, G3/G4/G5 install/remove/upgrade, G6/G7 dpkg_broken, G8 - hold/unhold, G9 protected, G-OWN not-owned, G10 apt_locked, G11-G14 receipt - failures, G15 entrypoint isolation, G-INT interrupted, G-INLINE, G-PREV-*). +2. **`apt-gates.sh`: 37/37.** The **functional gates** run through the real pkgops + issuer (`apt-issue`); each gate's status / `effect_issued` / dpkg post-state + assertion holds with pkgops as the issuer (G1 update, G3/G4/G5 + install/remove/upgrade, G6/G7 dpkg_broken, G8 hold/unhold, G9 protected, G-OWN + not-owned, G10 apt_locked, G-INT interrupted, G-INLINE, G-PREV-*). G9/G-OWN are + pkgops **preview-side** refusals (no intent opened), not broker-redemption + refusals. The remaining gates stay on the lower-level oracles by design: **G12-G14** + (bad / replayed / stale receipt injection) and **G15** (forbidden package arg to + the nullary `apt.update` entrypoint) call `rab-exercise` directly, and **G11a/G11b** + (entrypoint isolation) call `pkexec` directly — pkgops would never construct the + invalid receipts / arguments those gates inject. 3. **NEW — durable-record round-trip.** For the success gates, the outcome line in `audit.jsonl` (via `redact.jq`) must: - be **accepted by the real broker** — every field on the allow-list with the From 9f6973b90abefffd0b8048193bb362d10fcc60a2 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 09:02:48 -0500 Subject: [PATCH 2/2] canary-apt: narrow apt-issue preview-side refusals to the three planner statuses At PREVIEW time only package_not_owned / held / protected_package are genuine planner policy refusals (the statuses the contract pins a plan digest to besides ok), so only those emit outcome=preview_refused / exit 0. Previously the launcher tested the full commit-time CLOSED set, wrongly reporting resolve_failed, internal, and commit-only statuses as persisted refusals despite no intent being opened. Any non-refusal condition at preview time now has no committable intent and exits as a pre-intent failure (exit 1), matching the rab-exercise grammar. --- deploy/canary-apt/apt-issue.R | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/deploy/canary-apt/apt-issue.R b/deploy/canary-apt/apt-issue.R index 360242f..8f09570 100644 --- a/deploy/canary-apt/apt-issue.R +++ b/deploy/canary-apt/apt-issue.R @@ -44,14 +44,22 @@ if (is.null(PREVIEW[[verb]])) { quit(status = 2L, save = "no") } -## the CLOSED refusal statuses: a raised condition carrying one of these is a -## durably-persisted refusal (rab EX_OK / outcome=persisted). ok / no_op arrive on -## the RETURN path, never as a condition. +## the CLOSED refusal statuses: at COMMIT time, a raised condition carrying one of +## these is a durably-persisted refusal (rab EX_OK / outcome=persisted). ok / no_op +## arrive on the RETURN path, never as a condition. CLOSED <- c("held", "operation_failed", "apt_locked", "no_intent", "package_not_owned", "protected_package", "resolve_failed", "not_applied", "dpkg_broken", "internal", "unauthorized", "approval_required", "spawn_failed") +## the PREVIEW-side refusals: the ONLY statuses the read-only planner +## (apt__preview()) legitimately raises as a policy refusal with no intent +## opened -- exactly the three the contract pins a plan digest to (besides `ok`). +## Any OTHER condition at preview time (resolve_failed, internal, a commit-only +## status leaking in, ...) has no committable intent and is a PRE-INTENT failure +## (exit 1), NOT a persisted refusal. This is deliberately NARROWER than CLOSED. +PREVIEW_REFUSED <- c("package_not_owned", "held", "protected_package") + ## sanitize any value to a single RESULT token: the grammar is space-separated and ## unquoted, so a value with whitespace would corrupt the line. NULL/NA -> "". tok <- function(x) { @@ -89,12 +97,14 @@ prev <- tryCatch(if (verb %in% NULLARY) { PREVIEW[[verb]](pkgs) }, condition = function(c) c) if (inherits(prev, "condition")) { - ## a preview-side refusal or failure: NO intent is opened. A known refusal - ## status (package_not_owned / held / protected_package, ...) is meaningful -> - ## emit it with effect_issued=false and a preview_refused outcome, exit 0. A - ## malformed / resolve failure has no committable intent -> exit 1. + ## a preview-side refusal or failure: NO intent is opened. ONLY the three genuine + ## planner policy refusals (package_not_owned / held / protected_package) are + ## meaningful, closed refusals -> emit with effect_issued=false and a + ## preview_refused outcome, exit 0. EVERYTHING else at preview time + ## (resolve_failed, internal, a commit-only status, a malformed reply) has no + ## committable intent -> a PRE-INTENT failure, exit 1. st <- prev$status - if (is.character(st) && length(st) == 1L && st %in% CLOSED) { + if (is.character(st) && length(st) == 1L && st %in% PREVIEW_REFUSED) { emit(prev$correlation_id, prev$plan_hash, st, prev$detail, "false", "preview_refused") quit(status = 0L, save = "no")