brokerd: the podman argument lists
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -8,6 +8,7 @@ pub mod broker;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod grants;
|
pub mod grants;
|
||||||
pub mod ledger;
|
pub mod ledger;
|
||||||
|
pub mod podman;
|
||||||
pub mod policy;
|
pub mod policy;
|
||||||
pub mod runner;
|
pub mod runner;
|
||||||
pub mod serve;
|
pub mod serve;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
//!
|
//!
|
||||||
//! ```compile_fail
|
//! ```compile_fail
|
||||||
//! let _ = brokerd::runner::RunSpec {
|
//! let _ = brokerd::runner::RunSpec {
|
||||||
|
//! session: proto::SessionId::new("s1").unwrap(),
|
||||||
|
//! call: proto::CallId(1),
|
||||||
//! tool: brokerd::args::ToolName::Shell,
|
//! tool: brokerd::args::ToolName::Shell,
|
||||||
//! arguments: todo!(),
|
//! arguments: todo!(),
|
||||||
//! mounts: Vec::new(),
|
//! mounts: Vec::new(),
|
||||||
@@ -20,7 +22,7 @@
|
|||||||
|
|
||||||
use crate::args::{ToolArgs, ToolName};
|
use crate::args::{ToolArgs, ToolName};
|
||||||
use crate::policy::Decision;
|
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.
|
/// A directory mounted for one call: a path and whether the runtime may write to it.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -29,10 +31,13 @@ pub struct Mount {
|
|||||||
pub writable: bool,
|
pub writable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What one call was turned into before it reached a `Runtime`: the tool, its arguments, the
|
/// What one call was turned into before it reached a `Runtime`: the session and call it is, the
|
||||||
/// directories mounted and the hosts it may reach. Built only here, from a `Decision`.
|
/// tool, its arguments, the directories mounted and the hosts it may reach. Built only here, from a
|
||||||
|
/// `Decision`.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct RunSpec {
|
pub struct RunSpec {
|
||||||
|
session: SessionId,
|
||||||
|
call: CallId,
|
||||||
tool: ToolName,
|
tool: ToolName,
|
||||||
arguments: ToolArgs,
|
arguments: ToolArgs,
|
||||||
mounts: Vec<Mount>,
|
mounts: Vec<Mount>,
|
||||||
@@ -40,6 +45,12 @@ pub struct RunSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RunSpec {
|
impl RunSpec {
|
||||||
|
pub fn session(&self) -> &SessionId {
|
||||||
|
&self.session
|
||||||
|
}
|
||||||
|
pub fn call(&self) -> CallId {
|
||||||
|
self.call
|
||||||
|
}
|
||||||
pub fn tool(&self) -> ToolName {
|
pub fn tool(&self) -> ToolName {
|
||||||
self.tool
|
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())),
|
ToolArgs::HttpFetch(_) => (Vec::new(), Some(decision.hosts().to_vec())),
|
||||||
};
|
};
|
||||||
let spec = RunSpec {
|
let spec = RunSpec {
|
||||||
|
session: decision.request().session.clone(),
|
||||||
|
call: decision.request().call,
|
||||||
tool: args.tool(),
|
tool: args.tool(),
|
||||||
arguments: args.clone(),
|
arguments: args.clone(),
|
||||||
mounts,
|
mounts,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| M3b/10-brokerd-podman-args | 2026-09-23 | done | 2 | fail | none | Wrote `crates/brokerd/src/podman.rs`: the `EGRESS_MOUNT`/`EGRESS_SOCKET`/`TOOLKIT` constants; `container_name` = `boxmaker-<session>-<call>-<n>`; the private `hardening(pids, memory)` emitting the six shared flags (`--read-only` to `--memory=…`) once; the private `volume(host, container, mode)` built with `push` so a directory need not be UTF-8; `tool_args` = `run --rm -i --name=<name> --label=boxmaker=tool --network=none`, then hardening with `runner.pids`/`runner.memory`, `--tmpfs=/tmp:rw,size=64m,mode=1777`, one `--volume=<path>:<path>:ro|rw` per `spec.mounts()` in order, the egress volume when `egress` is Some, then `<image> /bin/toolkit <tool>`; `egress_args` = `run -d --rm --name=<name>-egress --label=boxmaker=egress --network=<egress_network>`, hardening 64/128m, the egress volume, then `<image> /bin/toolkit egress-proxy --socket /run/egress/egress.sock --allow <hosts joined with ','>`. `RunSpec` gained private `session: SessionId` and `call: CallId` as its first two fields with `session()`/`call()` getters; `run` fills them from `decision.request()`, and the `compile_fail` doctest struct literal gains the two fields first. Copied `tests/podman_args.rs` and the six `podman/*.args` golden files. `podman_args` 7 passed, `runner` 8 passed, all doctests pass. First gate failed on clippy `redundant_closure` (`.map(|f| OsString::from(f))` → `.map(OsString::from)`). `make gate` prints `gate: ok`. | ? |
|
||||||
| M3b/09-brokerd-runner-config | 2026-09-23 | done | 2 | fail | none | Wrote `crates/brokerd/src/config.rs`: the `Runner` struct (`podman`, `image`, `egress_network`, `output_cap`, `memory`, `pids`, `read_file_ms`, `write_file_ms`, `shell_ms`, `http_fetch_ms`) with `#[serde(deny_unknown_fields)]` and one private `default_…()` per defaulted field; `image` is required with no default. `Config` gained `runner: Option<Runner>` with `#[serde(default)]`. `Runner::time_limit` maps each `ToolName` to its matching `_ms` field as a `Duration`. `Config::egress_dir()` joins `<home>/run/egress`. `load` runs, after the `ttl_ms` check and only when `runner` is `Some`, the four checks in order (first problem wins): image must be `<name>@sha256:<64 lowercase hex>` via `rsplit_once("@sha256:")` with a non-empty name and exactly 64 `0-9a-f`, memory must be digits then one of b/k/m/g (`valid_memory`), `egress_network`/`podman` non-empty, and the six non-negative fields checked for zero in order — each returns `ConfigError::Invalid`. `Config::parse` runs none of them. Copied `tests/config_runner.rs`, the five `runner_*.toml` fixtures, and the new `support/rig.rs`, which builds `Config` with `runner: None`. All brokerd tests pass; `config` and `config_runner` each 7 passed. First gate failed on clippy `incompatible_msrv`: `PathBuf::is_empty()` is stable since 1.98 but the MSRV is 1.95, fixed with `as_os_str().is_empty()` (the pattern the file already used for the socket paths). `make gate` prints `gate: ok`. | ? |
|
| M3b/09-brokerd-runner-config | 2026-09-23 | done | 2 | fail | none | Wrote `crates/brokerd/src/config.rs`: the `Runner` struct (`podman`, `image`, `egress_network`, `output_cap`, `memory`, `pids`, `read_file_ms`, `write_file_ms`, `shell_ms`, `http_fetch_ms`) with `#[serde(deny_unknown_fields)]` and one private `default_…()` per defaulted field; `image` is required with no default. `Config` gained `runner: Option<Runner>` with `#[serde(default)]`. `Runner::time_limit` maps each `ToolName` to its matching `_ms` field as a `Duration`. `Config::egress_dir()` joins `<home>/run/egress`. `load` runs, after the `ttl_ms` check and only when `runner` is `Some`, the four checks in order (first problem wins): image must be `<name>@sha256:<64 lowercase hex>` via `rsplit_once("@sha256:")` with a non-empty name and exactly 64 `0-9a-f`, memory must be digits then one of b/k/m/g (`valid_memory`), `egress_network`/`podman` non-empty, and the six non-negative fields checked for zero in order — each returns `ConfigError::Invalid`. `Config::parse` runs none of them. Copied `tests/config_runner.rs`, the five `runner_*.toml` fixtures, and the new `support/rig.rs`, which builds `Config` with `runner: None`. All brokerd tests pass; `config` and `config_runner` each 7 passed. First gate failed on clippy `incompatible_msrv`: `PathBuf::is_empty()` is stable since 1.98 but the MSRV is 1.95, fixed with `as_os_str().is_empty()` (the pattern the file already used for the socket paths). `make gate` prints `gate: ok`. | ? |
|
||||||
| M3b/08-toolkit-egress-proxy | 2026-09-23 | done | 2 | fail | none | Copied `docs/plans/M3b/files/crates/toolkit/tests/egress.rs` to `crates/toolkit/tests/`. Wrote `crates/toolkit/src/egress.rs`: the reply-code and timeout constants; `Allow { patterns: Vec<String> }` with `parse` splitting on ',' and rejecting any piece that fails `proto::hosts::valid_host_pattern` (an empty piece like `","`/`"x,"`/`""` errors) and `permits` = `valid_host(host) && any host_matches`; the `Dial` trait and `SystemDial` (`to_socket_addrs()?.collect()` and `connect_timeout`); `Proxy { allow, dial: Arc<dyn Dial>, handshake }` with `new`/`with_handshake_timeout`/`serve`/`handle`. `handle` shares one deadline across the whole handshake: `read_n` reads exactly the byte count the protocol gives, setting the read timeout to `deadline.checked_duration_since(now)` before each read and returning `None` (stop, no reply) on no time left, a timeout, a failed read, or 0 bytes — so a client trickling one byte per 100 ms is still cut at the deadline. The 11 exits are in order (egress.rs:133 version, :144 methods 0, :154 version/reserved, :160 command, :164 kind without reading the address, :172 zero length, :187 non-UTF-8, :190 port/host, :195 resolve / :203 no public addr, :208 connect, :211 success reply); step 9 takes the first address where `crate::addr::is_public` is true via `.find`, never trying the skipped non-public ones. `serve` is a single-threaded accept loop with an `AtomicUsize` count: over the limit is dropped at once, otherwise a `std::thread::Builder` thread handles it and uncounts itself on return, and `serve` never joins (so the second client is not starved). After the handshake the read timeout is cleared and two `try_clone`'d threads copy both ways with a half-close each way, then join. `main.rs` gained the `egress-proxy --socket <path> --allow <list>` form before the tool form via `std::env::args_os().skip(1)` (without `skip(1)` the first element is the program path and the form never matches); `Allow::parse`/bind (no removal first — a pre-existing file is a mistake)/`serve` errors print to stderr and exit 2. `lib.rs` gained `pub mod egress;`. First gate failed on clippy `question_mark` (accept loop → `stream?`) and `manual_contains` (`methods.contains(&0)`); fixed both and `make gate` prints `gate: ok`. 15 egress tests pass ten runs in a row. | ? |
|
| M3b/08-toolkit-egress-proxy | 2026-09-23 | done | 2 | fail | none | Copied `docs/plans/M3b/files/crates/toolkit/tests/egress.rs` to `crates/toolkit/tests/`. Wrote `crates/toolkit/src/egress.rs`: the reply-code and timeout constants; `Allow { patterns: Vec<String> }` with `parse` splitting on ',' and rejecting any piece that fails `proto::hosts::valid_host_pattern` (an empty piece like `","`/`"x,"`/`""` errors) and `permits` = `valid_host(host) && any host_matches`; the `Dial` trait and `SystemDial` (`to_socket_addrs()?.collect()` and `connect_timeout`); `Proxy { allow, dial: Arc<dyn Dial>, handshake }` with `new`/`with_handshake_timeout`/`serve`/`handle`. `handle` shares one deadline across the whole handshake: `read_n` reads exactly the byte count the protocol gives, setting the read timeout to `deadline.checked_duration_since(now)` before each read and returning `None` (stop, no reply) on no time left, a timeout, a failed read, or 0 bytes — so a client trickling one byte per 100 ms is still cut at the deadline. The 11 exits are in order (egress.rs:133 version, :144 methods 0, :154 version/reserved, :160 command, :164 kind without reading the address, :172 zero length, :187 non-UTF-8, :190 port/host, :195 resolve / :203 no public addr, :208 connect, :211 success reply); step 9 takes the first address where `crate::addr::is_public` is true via `.find`, never trying the skipped non-public ones. `serve` is a single-threaded accept loop with an `AtomicUsize` count: over the limit is dropped at once, otherwise a `std::thread::Builder` thread handles it and uncounts itself on return, and `serve` never joins (so the second client is not starved). After the handshake the read timeout is cleared and two `try_clone`'d threads copy both ways with a half-close each way, then join. `main.rs` gained the `egress-proxy --socket <path> --allow <list>` form before the tool form via `std::env::args_os().skip(1)` (without `skip(1)` the first element is the program path and the form never matches); `Allow::parse`/bind (no removal first — a pre-existing file is a mistake)/`serve` errors print to stderr and exit 2. `lib.rs` gained `pub mod egress;`. First gate failed on clippy `question_mark` (accept loop → `stream?`) and `manual_contains` (`methods.contains(&0)`); fixed both and `make gate` prints `gate: ok`. 15 egress tests pass ten runs in a row. | ? |
|
||||||
| M3b/07-toolkit-addr | 2026-09-23 | done | 1 | pass | none | Wrote `crates/toolkit/src/addr.rs`: `is_public(ip)` matches on `IpAddr` and dispatches to `is_public_v4`/`is_public_v6`. The IPv4 function checks the 13 refused ranges in table order with early returns (lines 16-52) then returns `true`. The IPv6 function checks the two "judge as IPv4" rows first — `is_ipv4_mapped` for `::ffff:0:0/96` (lines 60-63) and `is_nat64` for `64:ff9b::/96` (lines 64-67) — reconstructing the last 32 bits as an `Ipv4Addr` via `(u32::from(s[6]) << 16) | u32::from(s[7])` with no `as` casts, then judging it through `is_public_v4`; the remaining rows `::/96` (line 68), `fc00::/7` (71), `fe80::/10` (74), `ff00::/8` (77) and `2001:db8::/32` (80) follow. `cargo fmt --all` first. Added `pub mod addr;` to `lib.rs` in alphabetical position (before `fetch`). All 4 addr tests pass; `make gate` prints `gate: ok` on the first run. | ? |
|
| M3b/07-toolkit-addr | 2026-09-23 | done | 1 | pass | none | Wrote `crates/toolkit/src/addr.rs`: `is_public(ip)` matches on `IpAddr` and dispatches to `is_public_v4`/`is_public_v6`. The IPv4 function checks the 13 refused ranges in table order with early returns (lines 16-52) then returns `true`. The IPv6 function checks the two "judge as IPv4" rows first — `is_ipv4_mapped` for `::ffff:0:0/96` (lines 60-63) and `is_nat64` for `64:ff9b::/96` (lines 64-67) — reconstructing the last 32 bits as an `Ipv4Addr` via `(u32::from(s[6]) << 16) | u32::from(s[7])` with no `as` casts, then judging it through `is_public_v4`; the remaining rows `::/96` (line 68), `fc00::/7` (71), `fe80::/10` (74), `ff00::/8` (77) and `2001:db8::/32` (80) follow. `cargo fmt --all` first. Added `pub mod addr;` to `lib.rs` in alphabetical position (before `fetch`). All 4 addr tests pass; `make gate` prints `gate: ok` on the first run. | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user