Specify and plan M3b: the runner and the tools
A draft spec for the owner's review and 13 offline tasks with their given tests: shared tool arguments and host rules in proto, the sealed fetch target (M3a finding 14), the toolkit tools and SOCKS5 egress proxy, and brokerd's [runner], podman argument lists, runtime and proxy lifecycle. Each task's tests were run against a reference at that task's end state (560 to 638 tests, clippy clean); the reference is not in the repository. Adds the runner-unavailable runbook entry and tip T23 (ETXTBSY in script tests). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
//! A fake `podman` for the runtime tests: a shell script that records every call's arguments and,
|
||||
//! for `run`, does what the test says. Do not edit.
|
||||
//!
|
||||
//! Included with `#[path = "support/fake_podman.rs"] mod fake_podman;`.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
use brokerd::config::{Config, Runner};
|
||||
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
static SERIAL: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Tests that write a script and run it take turns. Otherwise another test's fork can hold the
|
||||
/// script open for writing at the moment it is run, and running it fails with "text file busy"
|
||||
/// (ETXTBSY), which has nothing to do with the code under test.
|
||||
pub fn serial() -> MutexGuard<'static, ()> {
|
||||
SERIAL.lock().unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
|
||||
pub const IMAGE: &str = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
pub struct Fake {
|
||||
pub dir: PathBuf,
|
||||
pub script: PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for Fake {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
/// A fake whose `run` does `run_body` (a shell fragment; `$D` is the fake's directory). Every
|
||||
/// other command (`kill`, `rm`) is recorded and succeeds.
|
||||
pub fn new(tag: &str, run_body: &str) -> Fake {
|
||||
let n = NEXT.fetch_add(1, Ordering::SeqCst);
|
||||
let dir = std::env::temp_dir().join(format!("bx-fp-{tag}-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let script = dir.join("podman");
|
||||
let text = format!(
|
||||
"#!/bin/sh\nD='{}'\nfor a in \"$@\"; do printf '%s\\n' \"$a\"; done >> \"$D/calls\"\necho --- >> \"$D/calls\"\n[ \"$1\" = run ] || exit 0\n{run_body}\n",
|
||||
dir.display()
|
||||
);
|
||||
std::fs::write(&script, text).unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
Fake { dir, script }
|
||||
}
|
||||
|
||||
/// Every call so far, each as its arguments.
|
||||
pub fn calls(&self) -> Vec<Vec<String>> {
|
||||
let text = std::fs::read_to_string(self.dir.join("calls")).unwrap_or_default();
|
||||
let mut calls = Vec::new();
|
||||
let mut current = Vec::new();
|
||||
for line in text.lines() {
|
||||
if line == "---" {
|
||||
calls.push(std::mem::take(&mut current));
|
||||
} else {
|
||||
current.push(line.to_string());
|
||||
}
|
||||
}
|
||||
calls
|
||||
}
|
||||
|
||||
/// What the last `run` read on standard input, if the body saved it to `$D/stdin`.
|
||||
pub fn stdin(&self) -> String {
|
||||
std::fs::read_to_string(self.dir.join("stdin")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A `[runner]` using this fake, with `extra` lines added.
|
||||
pub fn runner(&self, extra: &str) -> Runner {
|
||||
let text = format!(
|
||||
"[runner]\npodman = \"{}\"\nimage = \"{IMAGE}\"\n{extra}\n",
|
||||
self.script.display()
|
||||
);
|
||||
Config::parse(&text).unwrap().runner.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// A log that keeps its lines.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Lines(pub Arc<Mutex<Vec<String>>>);
|
||||
|
||||
impl Lines {
|
||||
pub fn sink(&self) -> Arc<dyn Fn(&str) + Send + Sync> {
|
||||
let lines = Arc::clone(&self.0);
|
||||
Arc::new(move |l: &str| lines.lock().unwrap().push(l.to_string()))
|
||||
}
|
||||
pub fn all(&self) -> String {
|
||||
self.0.lock().unwrap().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(p: &Path) -> String {
|
||||
p.to_str().unwrap().to_string()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! A home for ledger and broker tests: grants, audit log, session state, a flaky audit sink and
|
||||
//! a log to read. Do not edit.
|
||||
//!
|
||||
//! Included with `#[path = "support/rig.rs"] mod rig;`, beside `tmp` and `sink`. The broker
|
||||
//! tests add `client`.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use brokerd::audit::Writer;
|
||||
use brokerd::config::{Approvals, Config, Paths, Sockets};
|
||||
use brokerd::ledger::Ledger;
|
||||
use brokerd::state::StateStore;
|
||||
use proto::{AuditEvent, AuditRecord, CallId, SessionId, ToolRequest};
|
||||
|
||||
use crate::sink::{Flaky, Lines, Switch};
|
||||
use crate::tmp::TempDir;
|
||||
|
||||
pub struct Rig {
|
||||
pub dir: TempDir,
|
||||
pub cfg: Config,
|
||||
pub switch: Switch,
|
||||
pub lines: Lines,
|
||||
}
|
||||
|
||||
impl Rig {
|
||||
pub fn new(tag: &str) -> Rig {
|
||||
Rig::with_ttl(tag, 900_000)
|
||||
}
|
||||
|
||||
pub fn with_ttl(tag: &str, ttl_ms: u64) -> Rig {
|
||||
let dir = TempDir::new(tag);
|
||||
let grants = dir.path().join("grants");
|
||||
std::fs::create_dir_all(&grants).unwrap();
|
||||
let cfg = Config {
|
||||
paths: Paths {
|
||||
home: dir.path().to_path_buf(),
|
||||
grants,
|
||||
},
|
||||
sockets: Sockets::default(),
|
||||
approvals: Approvals { ttl_ms },
|
||||
runner: None,
|
||||
};
|
||||
Rig {
|
||||
dir,
|
||||
cfg,
|
||||
switch: Switch::default(),
|
||||
lines: Lines::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> StateStore {
|
||||
StateStore::new(&self.cfg.state_dir())
|
||||
}
|
||||
|
||||
/// Opens the audit log (once: the writer holds its lock) behind the flaky sink.
|
||||
pub fn ledger(&self) -> Ledger {
|
||||
let opened = Writer::open(&self.cfg.audit_dir(), false).unwrap();
|
||||
let sink = Flaky {
|
||||
writer: opened.writer,
|
||||
switch: self.switch.clone(),
|
||||
};
|
||||
Ledger::new(Box::new(sink), self.state(), self.lines.sink())
|
||||
}
|
||||
|
||||
/// Writes `grants/<id>.toml`.
|
||||
pub fn grant(&self, id: &str, text: &str) {
|
||||
std::fs::write(self.cfg.paths.grants.join(format!("{id}.toml")), text).unwrap();
|
||||
}
|
||||
|
||||
pub fn remove_grant(&self, id: &str) {
|
||||
std::fs::remove_file(self.cfg.paths.grants.join(format!("{id}.toml"))).unwrap();
|
||||
}
|
||||
|
||||
pub fn state_file(&self, session: &str) -> PathBuf {
|
||||
self.cfg.state_dir().join(format!("{session}.json"))
|
||||
}
|
||||
|
||||
/// Every record in the audit log, in order.
|
||||
pub fn records(&self) -> Vec<AuditRecord> {
|
||||
let dir = self.cfg.audit_dir();
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||
.filter(|n| n.ends_with(".jsonl"))
|
||||
.collect();
|
||||
names.sort();
|
||||
let mut out = Vec::new();
|
||||
for name in names {
|
||||
let text = std::fs::read_to_string(dir.join(name)).unwrap();
|
||||
for line in text.lines() {
|
||||
out.push(serde_json::from_str(line).unwrap());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn events(&self) -> Vec<AuditEvent> {
|
||||
self.records().into_iter().map(|r| r.event).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A grant file's text. `extra` goes before `[constraints]`, `constraints` after it.
|
||||
pub fn grant_text(tool: &str, mode: &str, extra: &str, constraints: &str) -> String {
|
||||
format!(
|
||||
"tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\nresult_class = \"private\"\n\
|
||||
untrusted = false\n{extra}\n[constraints]\n{constraints}\n"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn request(session: &str, call: u64, tool: &str, arguments: &str) -> ToolRequest {
|
||||
ToolRequest {
|
||||
session: SessionId::new(session).unwrap(),
|
||||
call: CallId(call),
|
||||
tool: tool.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user