Skip to main content

max / makenotwork

ops-exec: gate /pull on a dedicated `artifact` observe grant /pull was gated on `build-log`, which is the wrong label for artifact retrieval: a caller that wants the signed DMG does not thereby want the build's log output, and the pairing left artifacts-but-not-logs inexpressible. Add ObserveKind::Artifact and gate /pull on it. Two gaps found alongside it: - AgentRpc::pull did no caller-side capability check, so the driver's declared set was decorative for its own last step. It now fails fast, which is the round trip most worth saving: a pull is the final step, after a full build and an Apple notary submission. - pull swallowed the agent's 403 body via error_for_status, reducing an actionable reason to a bare status. Surface it, as /run already does. Bento's driver declares `artifact`, and a build host's default observe set includes it -- a build host exists to hand back artifacts, and omitting it would reproduce the same dead-end for an existing bento.toml. Breaking for existing agent configs: an agent serving /pull needs `artifact` in both [grant] and the caller's [[allow]] observe lists. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-16 16:22 UTC
Commit: 93408695f7ba9843a99b760e48e513736314a0a0
Parent: 5f4d6ac
7 files changed, +141 insertions, -30 deletions
@@ -66,8 +66,12 @@ pub struct Host {
66 66 fn default_actuate() -> Vec<String> {
67 67 vec!["build".into(), "package".into()]
68 68 }
69 + /// A build host's read-only surface: its build logs, and the artifacts it
70 + /// produced (`artifact` gates `GET /pull`, confined to the agent's `pull_root`).
71 + /// Retrieving the artifact is the last step of every release, so defaulting it on
72 + /// keeps an existing `bento.toml` from dead-ending there after a full build.
69 73 fn default_observe() -> Vec<String> {
70 - vec!["build-log".into()]
74 + vec!["build-log".into(), "artifact".into()]
71 75 }
72 76
73 77 #[derive(Debug, Clone, Deserialize)]
@@ -80,12 +80,18 @@ async fn main() -> Result<()> {
80 80 // contacted and a step has already run on the build host.
81 81 plan.validate().context("driver config")?;
82 82
83 - // The driver's caller-side grant: it may build/sign/notarize/staple and
84 - // observe the gatekeeper verdict. The agent re-checks against its own grant.
83 + // The driver's caller-side grant: it may build/sign/notarize/staple, observe
84 + // the gatekeeper verdict, and pull the artifact it just had built (`artifact`
85 + // — the last step of the flow below). The agent re-checks against its own
86 + // grant. Note `gatekeeper` is also implied by `sign` in `from_tokens`; it is
87 + // spelled out here because this list documents what the driver needs.
85 88 let exec = AgentRpc::new(
86 89 cfg.agent.base_url.clone(),
87 90 cfg.agent.host_label.clone(),
88 - CapabilitySet::from_tokens(["build", "sign", "notarize", "staple"], ["gatekeeper"]),
91 + CapabilitySet::from_tokens(
92 + ["build", "sign", "notarize", "staple"],
93 + ["gatekeeper", "artifact"],
94 + ),
89 95 );
90 96
91 97 // Fail fast if the agent isn't reachable / in-session.
@@ -15,19 +15,30 @@ listen = "100.64.0.2:8765"
15 15 # confined under this root, so keep it to the artifacts tree -- NOT $HOME, which
16 16 # would expose ~/.tauri/passwords.env and the .p8 to any allow-listed caller.
17 17 # Omit the key entirely to leave /pull disabled.
18 + #
19 + # pull_root sets WHERE /pull may read; the `artifact` observe grant below sets
20 + # WHO may read it. Both are required -- either one missing answers 403.
18 21 pull_root = "/Users/max/Dist"
19 22
20 23 # What THIS host is allowed to do — the ceiling for every caller. The Mac signs.
24 + #
25 + # observe kinds: `build-log` = read the build's log output; `artifact` = retrieve
26 + # a built release artifact via /pull, confined to pull_root. They are separate on
27 + # purpose -- a caller can be granted the DMG without the logs, or the reverse.
28 + # A host that grants `sign` additionally gets `gatekeeper` implicitly (a signer
29 + # must be able to verify its own signature, or every publish gate dead-ends), so
30 + # /health on this host reports observe = ["build-log", "artifact", "gatekeeper"]
31 + # even though only two are listed here. That is expected, not drift.
21 32 [grant]
22 33 actuate = ["build", "sign", "notarize", "staple", "package"]
23 - observe = ["build-log"]
34 + observe = ["build-log", "artifact"]
24 35
25 36 # Callers this agent trusts. `identity` is a tailnet node name (e.g. `fw13`) or a
26 37 # tag (e.g. `tag:builder`). The effective grant is this list ∩ [grant] above.
27 38 [[allow]]
28 39 identity = "fw13" # the Sando/Bento daemon host
29 40 actuate = ["build", "sign", "notarize", "staple", "package"]
30 - observe = ["build-log"]
41 + observe = ["build-log", "artifact"]
31 42
32 43 # OPTIONAL script pins (minimal "signed recipe" control). For a high-risk action
33 44 # whose recipe is a fixed shell script, pin the exact script so an allow-listed
@@ -405,14 +405,15 @@ async fn pull(
405 405 use axum::http::StatusCode;
406 406 use axum::response::IntoResponse;
407 407
408 - // Identity → authorization. Reading host files is an observe-plane action,
409 - // gated by the `build-log` observe grant.
408 + // Identity → authorization. Retrieving an artifact is an observe-plane
409 + // action with its own grant: `artifact` covers reads under `pull_root`,
410 + // which is a different thing to want than a build's log output.
410 411 let (_identity, effective) = match resolve_caller(&state, peer.ip()).await {
411 412 Ok(pair) => pair,
412 413 Err(resp) => return resp,
413 414 };
414 - if !effective.permits_observe(&ObserveKind::BuildLog) {
415 - return (StatusCode::FORBIDDEN, "pull denied: requires `build-log` observe grant").into_response();
415 + if !effective.permits_observe(&ObserveKind::Artifact) {
416 + return (StatusCode::FORBIDDEN, "pull denied: requires `artifact` observe grant").into_response();
416 417 }
417 418
418 419 // Confine to the configured artifacts root. No root configured ⇒ disabled.
@@ -516,21 +517,29 @@ mod tests {
516 517 }
517 518
518 519 #[test]
519 - fn pull_requires_a_known_caller_with_build_log_observe() {
520 + fn pull_requires_a_known_caller_with_artifact_observe() {
520 521 // An unknown caller has no effective grant at all.
521 522 let stranger = CallerIdentity { node: "stranger".into(), tags: vec![] };
522 523 assert!(effective_grant(&cfg(), &stranger).is_none());
523 524
524 - // A known caller granted only actuate (no build-log observe) cannot pull.
525 + // A known caller granted only actuate (no observe) cannot pull.
525 526 let mut c = cfg();
526 527 c.allow[0].observe = vec![];
527 528 c.grant.observe = vec![];
528 529 let eff = effective_grant(&c, &fw13()).unwrap();
529 - assert!(!eff.permits_observe(&ObserveKind::BuildLog), "no observe grant ⇒ pull denied");
530 + assert!(!eff.permits_observe(&ObserveKind::Artifact), "no observe grant ⇒ pull denied");
530 531
531 - // The default fixture grants build-log on both sides ⇒ pull permitted.
532 + // `build-log` alone does not open /pull — the two grants are independent.
532 533 let eff = effective_grant(&cfg(), &fw13()).unwrap();
533 534 assert!(eff.permits_observe(&ObserveKind::BuildLog));
535 + assert!(!eff.permits_observe(&ObserveKind::Artifact), "build-log ⇏ artifact");
536 +
537 + // Granting `artifact` on both sides ⇒ pull permitted.
538 + let mut c = cfg();
539 + c.allow[0].observe.push("artifact".into());
540 + c.grant.observe.push("artifact".into());
541 + let eff = effective_grant(&c, &fw13()).unwrap();
542 + assert!(eff.permits_observe(&ObserveKind::Artifact));
534 543 }
535 544
536 545 fn sign_release_script() -> &'static str {
@@ -12,7 +12,7 @@
12 12 use crate::capability::{CapabilityDenied, CapabilitySet};
13 13 use crate::executor::{Executor, SyncOpts};
14 14 use crate::remote::{LogSink, RunOutput};
15 - use crate::step::Step;
15 + use crate::step::{Action, ObserveKind, Step};
16 16 use crate::wire::{Frame, HealthResponse, RunRequest};
17 17 use anyhow::{Context, Result};
18 18 use async_trait::async_trait;
@@ -136,15 +136,30 @@ impl Executor for AgentRpc {
136 136 }
137 137
138 138 async fn pull(&self, remote: &Path, local: &Path, _opts: &SyncOpts) -> Result<()> {
139 + // Caller-side enforcement (half 1 of 2), same as `run_streaming`. `/pull`
140 + // is observe-plane: it needs the `artifact` grant. Failing fast here
141 + // matters more than elsewhere — a pull is the *last* step of a release,
142 + // so an ungranted caller would otherwise learn it after a full build and
143 + // an Apple notary round trip.
144 + let action = Action::Observe(ObserveKind::Artifact);
145 + if !self.caps.permits(&action) {
146 + return Err(CapabilityDenied::new(&self.host_label, &action).into());
147 + }
148 +
139 149 let resp = self
140 150 .client
141 151 .get(format!("{}/pull", self.base_url))
142 152 .query(&[("path", remote.to_string_lossy().as_ref())])
143 153 .send()
144 154 .await
145 - .context("GET /pull")?
146 - .error_for_status()
147 - .context("agent /pull status")?;
155 + .context("GET /pull")?;
156 + // Surface the agent's reason (ungranted, /pull disabled, outside
157 + // pull_root); `error_for_status` alone would reduce it to a bare 403.
158 + if resp.status() == reqwest::StatusCode::FORBIDDEN {
159 + let body = resp.text().await.unwrap_or_default();
160 + anyhow::bail!("agent denied /pull: {}", body.trim());
161 + }
162 + let resp = resp.error_for_status().context("agent /pull status")?;
148 163 // Stream the body straight to disk in chunks — a signed .app/.dmg can be
149 164 // hundreds of MB, so never buffer the whole artifact in the daemon's heap
150 165 // (the agent's /pull already streams; this is the matching client half).
@@ -80,6 +80,12 @@ pub enum ObserveKind {
80 80 Metrics,
81 81 Health,
82 82 BuildLog,
83 + /// Retrieval of a *release artifact* the host produced — the grant `GET
84 + /// /pull` requires. Distinct from [`ObserveKind::BuildLog`]: a caller that
85 + /// wants the signed DMG does not thereby want the build's log output, and a
86 + /// caller that wants logs should not thereby be able to read the artifacts
87 + /// tree. Scope is the agent's configured `pull_root`, not the whole host.
88 + Artifact,
83 89 Custom(String),
84 90 }
85 91
@@ -90,6 +96,7 @@ impl ObserveKind {
90 96 ObserveKind::Metrics => "metrics".into(),
91 97 ObserveKind::Health => "health".into(),
92 98 ObserveKind::BuildLog => "build-log".into(),
99 + ObserveKind::Artifact => "artifact".into(),
93 100 ObserveKind::Custom(s) => s.clone(),
94 101 }
95 102 }
@@ -100,6 +107,7 @@ impl ObserveKind {
100 107 "metrics" => ObserveKind::Metrics,
101 108 "health" => ObserveKind::Health,
102 109 "build-log" | "build_log" => ObserveKind::BuildLog,
110 + "artifact" => ObserveKind::Artifact,
103 111 other => ObserveKind::Custom(other.to_string()),
104 112 }
105 113 }
@@ -138,7 +138,12 @@ async fn caller_side_gate_rejects_before_round_trip() {
138 138 assert!(err.downcast_ref::<CapabilityDenied>().is_some(), "expected caller-side CapabilityDenied");
139 139 }
140 140
141 - /// An allow-listed caller with a `build-log` observe grant can pull a file that
141 + /// The caller-side grant a driver needs to pull an artifact.
142 + fn puller() -> CapabilitySet {
143 + CapabilitySet::from_tokens(Vec::<&str>::new(), ["artifact"])
144 + }
145 +
146 + /// An allow-listed caller with an `artifact` observe grant can pull a file that
142 147 /// lives under the configured `pull_root`.
143 148 #[tokio::test]
144 149 async fn agent_pull_serves_an_in_root_file() {
@@ -150,22 +155,49 @@ async fn agent_pull_serves_an_in_root_file() {
150 155
151 156 let grant = GrantConfig {
152 157 actuate: vec!["build".into()],
153 - observe: vec!["build-log".into()],
158 + observe: vec!["artifact".into()],
154 159 };
155 160 let base = spawn_agent_with_pull(
156 - vec![CallerGrant { identity: "fw13".into(), actuate: vec![], observe: vec!["build-log".into()] }],
161 + vec![CallerGrant { identity: "fw13".into(), actuate: vec![], observe: vec!["artifact".into()] }],
157 162 grant,
158 163 Some(root.clone()),
159 164 )
160 165 .await;
161 - let rpc = AgentRpc::new(base, "mbp", CapabilitySet::default());
166 + let rpc = AgentRpc::new(base, "mbp", puller());
162 167 let local = dir.path().join("pulled.dmg");
163 168 rpc.pull(&artifact, &local, &Default::default()).await.unwrap();
164 169 assert_eq!(tokio::fs::read(&local).await.unwrap(), b"DMGBYTES");
165 170 }
166 171
167 - /// A caller without a `build-log` observe grant is refused, even for an in-root
168 - /// file — pull is gated, not open.
172 + /// `artifact` and `build-log` are independent grants: a caller allowed to read
173 + /// build logs is NOT thereby allowed to retrieve artifacts. This is the split
174 + /// the old `build-log`-gated /pull could not express.
175 + #[tokio::test]
176 + async fn agent_pull_denied_with_only_build_log_observe() {
177 + let dir = tempfile::tempdir().unwrap();
178 + let root = dir.path().join("dist");
179 + tokio::fs::create_dir_all(&root).await.unwrap();
180 + let artifact = root.join("a.bin");
181 + tokio::fs::write(&artifact, b"x").await.unwrap();
182 +
183 + let grant = GrantConfig { actuate: vec![], observe: vec!["build-log".into(), "artifact".into()] };
184 + let base = spawn_agent_with_pull(
185 + // The caller may read logs, but was never granted artifacts.
186 + vec![CallerGrant { identity: "fw13".into(), actuate: vec![], observe: vec!["build-log".into()] }],
187 + grant,
188 + Some(root.clone()),
189 + )
190 + .await;
191 + // Client-side grant is deliberately wide so the *agent* is what refuses.
192 + let rpc = AgentRpc::new(base, "mbp", puller());
193 + let local = dir.path().join("out.bin");
194 + let err = rpc.pull(&artifact, &local, &Default::default()).await.unwrap_err();
195 + assert!(err.to_string().contains("artifact"), "denial names the grant: {err}");
196 + assert!(!local.exists(), "denied pull must not write a file");
197 + }
198 +
199 + /// A caller without any observe grant is refused, even for an in-root file —
200 + /// pull is gated, not open.
169 201 #[tokio::test]
170 202 async fn agent_pull_denied_without_observe_grant() {
171 203 let dir = tempfile::tempdir().unwrap();
@@ -180,14 +212,40 @@ async fn agent_pull_denied_without_observe_grant() {
180 212 Some(root.clone()),
181 213 )
182 214 .await;
183 - let rpc = AgentRpc::new(base, "mbp", CapabilitySet::default());
215 + let rpc = AgentRpc::new(base, "mbp", puller());
184 216 let local = dir.path().join("out.bin");
185 - // The agent returns 403; AgentRpc surfaces it as a non-2xx status error and
186 - // the file never transfers.
217 + // The agent returns 403; AgentRpc surfaces the reason and the file never
218 + // transfers.
187 219 assert!(rpc.pull(&artifact, &local, &Default::default()).await.is_err());
188 220 assert!(!local.exists(), "denied pull must not write a file");
189 221 }
190 222
223 + /// The caller-side half of double enforcement: a driver that never declared
224 + /// `artifact` fails before any request leaves the process. This is the check
225 + /// that saves a full build + notary round trip on a misconfigured driver.
226 + #[tokio::test]
227 + async fn agent_pull_denied_caller_side_without_declaring_artifact() {
228 + let dir = tempfile::tempdir().unwrap();
229 + let root = dir.path().join("dist");
230 + tokio::fs::create_dir_all(&root).await.unwrap();
231 + let artifact = root.join("a.bin");
232 + tokio::fs::write(&artifact, b"x").await.unwrap();
233 +
234 + let grant = GrantConfig { actuate: vec![], observe: vec!["artifact".into()] };
235 + let base = spawn_agent_with_pull(
236 + vec![CallerGrant { identity: "fw13".into(), actuate: vec![], observe: vec!["artifact".into()] }],
237 + grant,
238 + Some(root.clone()),
239 + )
240 + .await;
241 + // Agent would happily serve this; the caller's own set is what refuses.
242 + let rpc = AgentRpc::new(base, "mbp", CapabilitySet::from_tokens(["build"], ["build-log"]));
243 + let local = dir.path().join("out.bin");
244 + let err = rpc.pull(&artifact, &local, &Default::default()).await.unwrap_err();
245 + assert!(err.to_string().contains("observe:artifact"), "caller-side denial: {err}");
246 + assert!(!local.exists(), "denied pull must not write a file");
247 + }
248 +
191 249 /// A path outside `pull_root` is refused even for an authorized caller — the
192 250 /// confinement boundary holds against traversal.
193 251 #[tokio::test]
@@ -198,14 +256,14 @@ async fn agent_pull_denied_outside_root() {
198 256 let secret = dir.path().join("secret.key");
199 257 tokio::fs::write(&secret, b"topsecret").await.unwrap();
200 258
201 - let grant = GrantConfig { actuate: vec![], observe: vec!["build-log".into()] };
259 + let grant = GrantConfig { actuate: vec![], observe: vec!["artifact".into()] };
202 260 let base = spawn_agent_with_pull(
203 - vec![CallerGrant { identity: "fw13".into(), actuate: vec![], observe: vec!["build-log".into()] }],
261 + vec![CallerGrant { identity: "fw13".into(), actuate: vec![], observe: vec!["artifact".into()] }],
204 262 grant,
205 263 Some(root.clone()),
206 264 )
207 265 .await;
208 - let rpc = AgentRpc::new(base, "mbp", CapabilitySet::default());
266 + let rpc = AgentRpc::new(base, "mbp", puller());
209 267 let local = dir.path().join("out.key");
210 268 let err = rpc.pull(&secret, &local, &Default::default()).await.unwrap_err();
211 269 assert!(!local.exists(), "out-of-root pull must not write a file");