brokerd: the podman argument lists

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-23 01:13:25 -07:00
parent 81ce5a3345
commit 49ac8d72d2
11 changed files with 387 additions and 3 deletions
+1
View File
@@ -8,6 +8,7 @@ pub mod broker;
pub mod config;
pub mod grants;
pub mod ledger;
pub mod podman;
pub mod policy;
pub mod runner;
pub mod serve;
+109
View File
@@ -0,0 +1,109 @@
//! The `podman` argument lists for one call's container and for the `http_fetch` egress proxy.
//!
//! Nothing is passed through a shell: every argument is its own `OsString`, and the tool's
//! arguments go on standard input, never on the command line. Built without running Podman, so a
//! runtime that only builds the list can be tested as a golden file. Spec section 6.
use crate::config::Runner;
use crate::runner::RunSpec;
use proto::{CallId, SessionId};
use std::ffi::OsString;
use std::path::Path;
pub const EGRESS_MOUNT: &str = "/run/egress";
pub const EGRESS_SOCKET: &str = "/run/egress/egress.sock";
pub const TOOLKIT: &str = "/bin/toolkit";
/// The container's name: `boxmaker-<session>-<call>-<n>`, so it says whose call it is.
pub fn container_name(session: &SessionId, call: CallId, n: u64) -> String {
format!("boxmaker-{}-{}-{}", session.as_str(), call.0, n)
}
/// The six hardening flags, common to the tool and the proxy. `pids` and `memory` differ: the tool
/// takes the runner's, the proxy its fixed limits.
fn hardening(pids: u32, memory: &str) -> Vec<OsString> {
[
"--read-only",
"--cap-drop=all",
"--security-opt=no-new-privileges",
"--userns=keep-id",
]
.map(OsString::from)
.into_iter()
.chain([
OsString::from(format!("--pids-limit={pids}")),
OsString::from(format!("--memory={memory}")),
])
.collect()
}
/// A `--volume=<host>:<container>:<mode>` argument, built with `push` so a directory need not be
/// UTF-8.
fn volume(host: &Path, container: &Path, mode: &str) -> OsString {
let mut arg = OsString::new();
arg.push("--volume=");
arg.push(host);
arg.push(":");
arg.push(container);
arg.push(":");
arg.push(mode);
arg
}
/// The tool's container. `egress` is the call's egress directory, for `http_fetch` only.
pub fn tool_args(
spec: &RunSpec,
runner: &Runner,
name: &str,
egress: Option<&Path>,
) -> Vec<OsString> {
let mut args: Vec<OsString> = [
"run",
"--rm",
"-i",
&format!("--name={name}"),
"--label=boxmaker=tool",
"--network=none",
]
.map(OsString::from)
.into_iter()
.collect();
args.extend(hardening(runner.pids, &runner.memory));
args.push(OsString::from("--tmpfs=/tmp:rw,size=64m,mode=1777"));
for mount in spec.mounts() {
let mode = if mount.writable { "rw" } else { "ro" };
args.push(volume(Path::new(&mount.path), Path::new(&mount.path), mode));
}
if let Some(dir) = egress {
args.push(volume(dir, Path::new(EGRESS_MOUNT), "rw"));
}
args.push(OsString::from(runner.image.as_str()));
args.push(OsString::from(TOOLKIT));
args.push(OsString::from(spec.tool().as_str()));
args
}
/// The egress proxy's container.
pub fn egress_args(runner: &Runner, name: &str, dir: &Path, hosts: &[String]) -> Vec<OsString> {
let mut args: Vec<OsString> = [
"run",
"-d",
"--rm",
&format!("--name={name}-egress"),
"--label=boxmaker=egress",
&format!("--network={}", runner.egress_network),
]
.map(OsString::from)
.into_iter()
.collect();
args.extend(hardening(64, "128m"));
args.push(volume(dir, Path::new(EGRESS_MOUNT), "rw"));
args.push(OsString::from(runner.image.as_str()));
args.push(OsString::from(TOOLKIT));
args.push(OsString::from("egress-proxy"));
args.push(OsString::from("--socket"));
args.push(OsString::from(EGRESS_SOCKET));
args.push(OsString::from("--allow"));
args.push(OsString::from(hosts.join(",")));
args
}
+16 -3
View File
@@ -5,6 +5,8 @@
//!
//! ```compile_fail
//! let _ = brokerd::runner::RunSpec {
//! session: proto::SessionId::new("s1").unwrap(),
//! call: proto::CallId(1),
//! tool: brokerd::args::ToolName::Shell,
//! arguments: todo!(),
//! mounts: Vec::new(),
@@ -20,7 +22,7 @@
use crate::args::{ToolArgs, ToolName};
use crate::policy::Decision;
use proto::ToolResponse;
use proto::{CallId, SessionId, ToolResponse};
/// A directory mounted for one call: a path and whether the runtime may write to it.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -29,10 +31,13 @@ pub struct Mount {
pub writable: bool,
}
/// What one call was turned into before it reached a `Runtime`: the tool, its arguments, the
/// directories mounted and the hosts it may reach. Built only here, from a `Decision`.
/// What one call was turned into before it reached a `Runtime`: the session and call it is, the
/// tool, its arguments, the directories mounted and the hosts it may reach. Built only here, from a
/// `Decision`.
#[derive(Debug)]
pub struct RunSpec {
session: SessionId,
call: CallId,
tool: ToolName,
arguments: ToolArgs,
mounts: Vec<Mount>,
@@ -40,6 +45,12 @@ pub struct RunSpec {
}
impl RunSpec {
pub fn session(&self) -> &SessionId {
&self.session
}
pub fn call(&self) -> CallId {
self.call
}
pub fn tool(&self) -> ToolName {
self.tool
}
@@ -108,6 +119,8 @@ pub fn run(decision: Decision, runtime: &dyn Runtime) -> proto::ToolResponse {
ToolArgs::HttpFetch(_) => (Vec::new(), Some(decision.hosts().to_vec())),
};
let spec = RunSpec {
session: decision.request().session.clone(),
call: decision.request().call,
tool: args.tool(),
arguments: args.clone(),
mounts,
+20
View File
@@ -0,0 +1,20 @@
run
-d
--rm
--name=boxmaker-s1-1-7-egress
--label=boxmaker=egress
--network=pasta
--read-only
--cap-drop=all
--security-opt=no-new-privileges
--userns=keep-id
--pids-limit=64
--memory=128m
--volume=/h/run/egress/boxmaker-s1-1-7:/run/egress:rw
localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
/bin/toolkit
egress-proxy
--socket
/run/egress/egress.sock
--allow
example.com,*.example.org
+17
View File
@@ -0,0 +1,17 @@
run
--rm
-i
--name=boxmaker-s1-1-7
--label=boxmaker=tool
--network=none
--read-only
--cap-drop=all
--security-opt=no-new-privileges
--userns=keep-id
--pids-limit=128
--memory=512m
--tmpfs=/tmp:rw,size=64m,mode=1777
--volume=/h/run/egress/boxmaker-s1-1-7:/run/egress:rw
localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
/bin/toolkit
http_fetch
+17
View File
@@ -0,0 +1,17 @@
run
--rm
-i
--name=boxmaker-s1-1-7
--label=boxmaker=tool
--network=none
--read-only
--cap-drop=all
--security-opt=no-new-privileges
--userns=keep-id
--pids-limit=128
--memory=512m
--tmpfs=/tmp:rw,size=64m,mode=1777
--volume=/home/kyle/notes:/home/kyle/notes:ro
localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
/bin/toolkit
read_file
+18
View File
@@ -0,0 +1,18 @@
run
--rm
-i
--name=boxmaker-s1-1-7
--label=boxmaker=tool
--network=none
--read-only
--cap-drop=all
--security-opt=no-new-privileges
--userns=keep-id
--pids-limit=128
--memory=512m
--tmpfs=/tmp:rw,size=64m,mode=1777
--volume=/srv/a:/srv/a:rw
--volume=/srv/b:/srv/b:rw
localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
/bin/toolkit
shell
@@ -0,0 +1,16 @@
run
--rm
-i
--name=boxmaker-s1-1-7
--label=boxmaker=tool
--network=none
--read-only
--cap-drop=all
--security-opt=no-new-privileges
--userns=keep-id
--pids-limit=128
--memory=512m
--tmpfs=/tmp:rw,size=64m,mode=1777
localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
/bin/toolkit
shell
+17
View File
@@ -0,0 +1,17 @@
run
--rm
-i
--name=boxmaker-s1-1-7
--label=boxmaker=tool
--network=none
--read-only
--cap-drop=all
--security-opt=no-new-privileges
--userns=keep-id
--pids-limit=128
--memory=512m
--tmpfs=/tmp:rw,size=64m,mode=1777
--volume=/home/kyle/out:/home/kyle/out:rw
localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
/bin/toolkit
write_file
+155
View File
@@ -0,0 +1,155 @@
//! The `podman` argument lists, as golden files: one argument per line, compared exactly. A
//! runtime that only builds the list stands in for Podman, so the lists are built from real
//! `RunSpec`s, which only `runner::run` can make. Do not edit.
#[path = "support/build.rs"]
mod build;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use brokerd::config::Runner;
use brokerd::podman::{container_name, egress_args, tool_args};
use brokerd::policy::{Outcome, SessionState, decide};
use brokerd::runner::{RunError, RunOutput, RunSpec, Runtime, run};
use build::{fetch, grant, now, read, request, set, shell, write};
use proto::{CallId, Mode, SessionId, ToolRequest};
const IMAGE: &str = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn runner() -> Runner {
let text = format!("[runner]\nimage = \"{IMAGE}\"\n");
brokerd::config::Config::parse(&text)
.unwrap()
.runner
.unwrap()
}
/// Builds the tool's argument list inside `run`, as the real runtime will.
struct Lists {
egress: Option<PathBuf>,
got: Mutex<Vec<Vec<OsString>>>,
}
impl Runtime for Lists {
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
let name = container_name(spec.session(), spec.call(), 7);
let args = tool_args(spec, &runner(), &name, self.egress.as_deref());
self.got.lock().unwrap().push(args);
Ok(RunOutput {
content: String::new(),
truncated: false,
})
}
}
fn list_for(grants: Vec<build::Build>, req: ToolRequest, egress: Option<&Path>) -> Vec<String> {
let decision = match decide(req, &set(grants), SessionState::default(), now()) {
Outcome::Allowed(d) => d,
other => panic!("not allowed: {other:?}"),
};
let lists = Lists {
egress: egress.map(Path::to_path_buf),
got: Mutex::new(Vec::new()),
};
run(decision, &lists);
let got = lists.got.into_inner().unwrap();
assert_eq!(got.len(), 1);
got[0]
.iter()
.map(|a| a.to_str().unwrap().to_string())
.collect()
}
fn golden(name: &str) -> Vec<String> {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/podman")
.join(name);
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
text.lines().map(str::to_string).collect()
}
#[test]
fn the_container_name_says_whose_call_it_is() {
let s = SessionId::new("chat-17").unwrap();
assert_eq!(container_name(&s, CallId(42), 3), "boxmaker-chat-17-42-3");
}
#[test]
fn read_file_gets_its_one_directory_read_only_and_no_network() {
let got = list_for(
vec![grant("notes", "read_file", Mode::Auto).paths(&["/home/kyle/notes"])],
read("/home/kyle/notes/a.md"),
None,
);
assert_eq!(got, golden("read_file.args"));
}
#[test]
fn write_file_gets_its_one_directory_writable() {
let got = list_for(
vec![grant("out", "write_file", Mode::Auto).paths(&["/home/kyle/out"])],
write("/home/kyle/out/b.md"),
None,
);
assert_eq!(got, golden("write_file.args"));
}
#[test]
fn shell_gets_every_grant_directory_writable_in_order() {
let got = list_for(
vec![grant("sh", "shell", Mode::Auto).paths(&["/srv/a", "/srv/b"])],
shell(Some("/srv/b")),
None,
);
assert_eq!(got, golden("shell.args"));
let bare = list_for(
vec![grant("sh", "shell", Mode::Auto)],
request("shell", r#"{"command":"ls"}"#),
None,
);
assert_eq!(bare, golden("shell_no_paths.args"));
}
#[test]
fn http_fetch_gets_the_egress_directory_and_still_no_network() {
let got = list_for(
vec![grant("web", "http_fetch", Mode::Auto).hosts(&["example.com"])],
fetch("https://example.com/a"),
Some(Path::new("/h/run/egress/boxmaker-s1-1-7")),
);
assert_eq!(got, golden("http_fetch.args"));
assert!(got.contains(&"--network=none".to_string()));
}
#[test]
fn the_proxy_gets_a_network_the_socket_and_the_hosts() {
let got: Vec<String> = egress_args(
&runner(),
"boxmaker-s1-1-7",
Path::new("/h/run/egress/boxmaker-s1-1-7"),
&["example.com".to_string(), "*.example.org".to_string()],
)
.iter()
.map(|a| a.to_str().unwrap().to_string())
.collect();
assert_eq!(got, golden("egress.args"));
}
#[test]
fn no_argument_holds_a_shell_string_or_a_second_network() {
let got = list_for(
vec![grant("sh", "shell", Mode::Auto).paths(&["/srv/a"])],
request(
"shell",
r#"{"command":"curl evil.test; rm -rf /","cwd":"/srv/a"}"#,
),
None,
);
assert!(
got.iter().all(|a| !a.contains("evil.test")),
"the command goes on standard input"
);
assert_eq!(got.iter().filter(|a| a.starts_with("--network")).count(), 1);
}