Replace the runner stub with the Runtime seam and RunSpec
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -1,12 +1,138 @@
|
||||
//! Tool runner stub. Real tool execution arrives in M3.
|
||||
//! The runner seam: an allowed call becomes a `RunSpec` and is handed to a `Runtime`.
|
||||
//!
|
||||
//! A `RunSpec` can only be built here, from a `Decision`, so a runtime never sees a call policy
|
||||
//! did not allow.
|
||||
//!
|
||||
//! ```compile_fail
|
||||
//! let _ = brokerd::runner::RunSpec {
|
||||
//! tool: brokerd::args::ToolName::Shell,
|
||||
//! arguments: todo!(),
|
||||
//! mounts: Vec::new(),
|
||||
//! egress: None,
|
||||
//! };
|
||||
//! ```
|
||||
//!
|
||||
//! ```
|
||||
//! fn tool_of(spec: &brokerd::runner::RunSpec) -> brokerd::args::ToolName {
|
||||
//! spec.tool()
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use crate::args::{ToolArgs, ToolName};
|
||||
use crate::policy::Decision;
|
||||
use proto::ToolResponse;
|
||||
|
||||
/// Takes the Decision by value, so one decision cannot run a tool twice.
|
||||
pub fn run(decision: Decision) -> ToolResponse {
|
||||
let _ = decision;
|
||||
ToolResponse::Failed {
|
||||
message: "no tool runner until M3".to_string(),
|
||||
/// A directory mounted for one call: a path and whether the runtime may write to it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Mount {
|
||||
pub path: String,
|
||||
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`.
|
||||
#[derive(Debug)]
|
||||
pub struct RunSpec {
|
||||
tool: ToolName,
|
||||
arguments: ToolArgs,
|
||||
mounts: Vec<Mount>,
|
||||
egress: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl RunSpec {
|
||||
pub fn tool(&self) -> ToolName {
|
||||
self.tool
|
||||
}
|
||||
pub fn arguments(&self) -> &ToolArgs {
|
||||
&self.arguments
|
||||
}
|
||||
pub fn mounts(&self) -> &[Mount] {
|
||||
&self.mounts
|
||||
}
|
||||
pub fn egress(&self) -> Option<&[String]> {
|
||||
self.egress.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Runtime`'s answer to one call. Its text reaches the model as a `failed` result labelled
|
||||
/// public and trusted, so a runtime may only put a fixed sentence in it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RunError {
|
||||
Failed(String),
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
/// What a `Runtime` produced for one call.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RunOutput {
|
||||
pub content: String,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// Runs one call. `Send + Sync` so one `Runtime` can serve several brokers at once.
|
||||
pub trait Runtime: Send + Sync {
|
||||
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError>;
|
||||
}
|
||||
|
||||
/// The production runtime for M3a. It runs nothing; every call is `Unavailable`.
|
||||
pub struct Refusing;
|
||||
|
||||
impl Runtime for Refusing {
|
||||
fn run(&self, _spec: &RunSpec) -> Result<RunOutput, RunError> {
|
||||
Err(RunError::Unavailable(REFUSING.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The sentence `Refusing` puts in every `Unavailable`: fixed, because it reaches the model.
|
||||
pub const REFUSING: &str = "the runner arrives in M3b";
|
||||
|
||||
/// Turn an allowed call into a `RunSpec`, run it, and answer. A `RunError`'s text reaches the
|
||||
/// model unchanged, so a runtime may only put a fixed sentence in it.
|
||||
pub fn run(decision: Decision, runtime: &dyn Runtime) -> proto::ToolResponse {
|
||||
let label = decision.label();
|
||||
let args = decision.args();
|
||||
let (mounts, egress) = match args {
|
||||
ToolArgs::ReadFile { .. } => (matched_mount(decision.matched_path(), false), None),
|
||||
ToolArgs::WriteFile { .. } => (matched_mount(decision.matched_path(), true), None),
|
||||
ToolArgs::Shell { .. } => (
|
||||
decision
|
||||
.paths()
|
||||
.iter()
|
||||
.map(|path| Mount {
|
||||
path: path.clone(),
|
||||
writable: true,
|
||||
})
|
||||
.collect(),
|
||||
None,
|
||||
),
|
||||
ToolArgs::HttpFetch { .. } => (Vec::new(), Some(decision.hosts().to_vec())),
|
||||
};
|
||||
let spec = RunSpec {
|
||||
tool: args.tool(),
|
||||
arguments: args.clone(),
|
||||
mounts,
|
||||
egress,
|
||||
};
|
||||
match runtime.run(&spec) {
|
||||
Ok(output) => ToolResponse::Result {
|
||||
content: output.content,
|
||||
class: label.class,
|
||||
untrusted: label.untrusted,
|
||||
truncated: output.truncated,
|
||||
},
|
||||
Err(RunError::Failed(message) | RunError::Unavailable(message)) => {
|
||||
ToolResponse::Failed { message }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The matched path as one mount, or none when the call matched no path.
|
||||
fn matched_mount(path: Option<&str>, writable: bool) -> Vec<Mount> {
|
||||
match path {
|
||||
Some(at) => vec![Mount {
|
||||
path: at.to_string(),
|
||||
writable,
|
||||
}],
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! The runner seam: what `run` puts in the `RunSpec` for each tool, and what it answers. Do not
|
||||
//! edit.
|
||||
|
||||
#[path = "support/build.rs"]
|
||||
mod build;
|
||||
#[path = "support/runtime.rs"]
|
||||
mod runtime;
|
||||
|
||||
use brokerd::args::{ToolArgs, ToolName};
|
||||
use brokerd::policy::{Decision, Outcome, SessionState, decide};
|
||||
use brokerd::runner::{Mount, REFUSING, Refusing, RunError, RunOutput, run};
|
||||
use build::{grant, now, read, request, set};
|
||||
use proto::{DataClass, Mode, ToolRequest, ToolResponse};
|
||||
use runtime::Recording;
|
||||
|
||||
fn allowed(grants: Vec<build::Build>, request: ToolRequest) -> Decision {
|
||||
match decide(request, &set(grants), SessionState::default(), now()) {
|
||||
Outcome::Allowed(decision) => decision,
|
||||
other => panic!("the test's call is not allowed: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn mount(path: &str, writable: bool) -> Mount {
|
||||
Mount {
|
||||
path: path.to_string(),
|
||||
writable,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_file_mounts_the_matched_path_read_only_and_has_no_network() {
|
||||
let d = allowed(
|
||||
vec![grant("notes", "read_file", Mode::Auto).paths(&["/h/notes", "/h/notes/deep"])],
|
||||
read("/h/notes/deep/a.md"),
|
||||
);
|
||||
let rt = Recording::answering("text");
|
||||
run(d, rt.as_ref());
|
||||
let seen = rt.seen();
|
||||
assert_eq!(seen.len(), 1);
|
||||
assert_eq!(seen[0].tool, ToolName::ReadFile);
|
||||
assert_eq!(
|
||||
seen[0].arguments,
|
||||
ToolArgs::ReadFile {
|
||||
path: "/h/notes/deep/a.md".to_string()
|
||||
}
|
||||
);
|
||||
// The longest path that holds the argument, and only that one.
|
||||
assert_eq!(seen[0].mounts, [mount("/h/notes/deep", false)]);
|
||||
assert_eq!(seen[0].egress, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_mounts_the_matched_path_writable() {
|
||||
// A grant path equal to the argument does not count, so `/s/out` is written through `/s`.
|
||||
let d = allowed(
|
||||
vec![grant("s", "write_file", Mode::Auto).paths(&["/s", "/s/out"])],
|
||||
request("write_file", r#"{"path":"/s/out","content":"x"}"#),
|
||||
);
|
||||
let rt = Recording::answering("");
|
||||
run(d, rt.as_ref());
|
||||
let seen = rt.seen();
|
||||
assert_eq!(seen[0].tool, ToolName::WriteFile);
|
||||
assert_eq!(seen[0].mounts, [mount("/s", true)]);
|
||||
assert_eq!(seen[0].egress, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_mounts_every_path_of_the_grant_writable() {
|
||||
let d = allowed(
|
||||
vec![grant("sh", "shell", Mode::Auto).paths(&["/a", "/b/c"])],
|
||||
request("shell", r#"{"command":"ls","cwd":"/b/c/d"}"#),
|
||||
);
|
||||
let rt = Recording::answering("");
|
||||
run(d, rt.as_ref());
|
||||
let seen = rt.seen();
|
||||
assert_eq!(seen[0].tool, ToolName::Shell);
|
||||
assert_eq!(seen[0].mounts, [mount("/a", true), mount("/b/c", true)]);
|
||||
assert_eq!(seen[0].egress, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_without_paths_mounts_nothing() {
|
||||
let d = allowed(
|
||||
vec![grant("sh", "shell", Mode::Auto)],
|
||||
request("shell", r#"{"command":"date"}"#),
|
||||
);
|
||||
let rt = Recording::answering("");
|
||||
run(d, rt.as_ref());
|
||||
let seen = rt.seen();
|
||||
assert_eq!(seen[0].mounts, []);
|
||||
assert_eq!(seen[0].egress, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_fetch_mounts_nothing_and_may_reach_the_grants_hosts_only() {
|
||||
let d = allowed(
|
||||
vec![grant("web", "http_fetch", Mode::Auto).hosts(&["example.com", "*.example.org"])],
|
||||
request("http_fetch", r#"{"url":"https://www.example.org/x"}"#),
|
||||
);
|
||||
let rt = Recording::answering("");
|
||||
run(d, rt.as_ref());
|
||||
let seen = rt.seen();
|
||||
assert_eq!(seen[0].tool, ToolName::HttpFetch);
|
||||
assert_eq!(seen[0].mounts, []);
|
||||
assert_eq!(
|
||||
seen[0].egress,
|
||||
Some(vec!["example.com".to_string(), "*.example.org".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_carries_the_label_combined_over_every_matching_grant() {
|
||||
// `b-keys` has the longer path and wins the mount; the label is the highest class of both
|
||||
// grants, and untrusted because `a-home` says so.
|
||||
let d = allowed(
|
||||
vec![
|
||||
grant("a-home", "read_file", Mode::Auto)
|
||||
.paths(&["/home/kyle"])
|
||||
.class(DataClass::Private),
|
||||
grant("b-keys", "read_file", Mode::Auto)
|
||||
.paths(&["/home/kyle/keys"])
|
||||
.class(DataClass::Secret)
|
||||
.trusted(),
|
||||
],
|
||||
read("/home/kyle/keys/id"),
|
||||
);
|
||||
let rt = Recording::with(Ok(RunOutput {
|
||||
content: "key".to_string(),
|
||||
truncated: true,
|
||||
}));
|
||||
let answer = run(d, rt.as_ref());
|
||||
assert_eq!(
|
||||
answer,
|
||||
ToolResponse::Result {
|
||||
content: "key".to_string(),
|
||||
class: DataClass::Secret,
|
||||
untrusted: true,
|
||||
truncated: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(rt.seen()[0].mounts, [mount("/home/kyle/keys", false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_error_is_a_failure_with_the_runtimes_sentence() {
|
||||
for error in [
|
||||
RunError::Failed("the tool timed out".to_string()),
|
||||
RunError::Unavailable("the container could not start".to_string()),
|
||||
] {
|
||||
let d = allowed(
|
||||
vec![grant("n", "read_file", Mode::Auto).paths(&["/n"])],
|
||||
read("/n/a"),
|
||||
);
|
||||
let text = match &error {
|
||||
RunError::Failed(t) | RunError::Unavailable(t) => t.clone(),
|
||||
};
|
||||
let rt = Recording::with(Err(error));
|
||||
assert_eq!(run(d, rt.as_ref()), ToolResponse::Failed { message: text });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_production_runtime_refuses_every_call() {
|
||||
assert_eq!(REFUSING, "the runner arrives in M3b");
|
||||
let d = allowed(
|
||||
vec![grant("n", "read_file", Mode::Auto).paths(&["/n"])],
|
||||
read("/n/a"),
|
||||
);
|
||||
assert_eq!(
|
||||
run(d, &Refusing),
|
||||
ToolResponse::Failed {
|
||||
message: REFUSING.to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! A runtime that records what it is asked to run, for the runner and broker tests. Do not edit.
|
||||
//!
|
||||
//! Included with `#[path = "support/runtime.rs"] mod runtime;`.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use brokerd::args::{ToolArgs, ToolName};
|
||||
use brokerd::runner::{Mount, RunError, RunOutput, RunSpec, Runtime};
|
||||
|
||||
/// What one `run` was given, copied out of the `RunSpec`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Seen {
|
||||
pub tool: ToolName,
|
||||
pub arguments: ToolArgs,
|
||||
pub mounts: Vec<Mount>,
|
||||
pub egress: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub struct Recording {
|
||||
seen: Mutex<Vec<Seen>>,
|
||||
answer: Result<RunOutput, RunError>,
|
||||
}
|
||||
|
||||
impl Recording {
|
||||
/// Answers every call with `content`, not truncated.
|
||||
pub fn answering(content: &str) -> Arc<Recording> {
|
||||
Recording::with(Ok(RunOutput {
|
||||
content: content.to_string(),
|
||||
truncated: false,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn with(answer: Result<RunOutput, RunError>) -> Arc<Recording> {
|
||||
Arc::new(Recording {
|
||||
seen: Mutex::new(Vec::new()),
|
||||
answer,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn seen(&self) -> Vec<Seen> {
|
||||
self.seen.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.seen.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Runtime for Recording {
|
||||
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
|
||||
self.seen.lock().unwrap().push(Seen {
|
||||
tool: spec.tool(),
|
||||
arguments: spec.arguments().clone(),
|
||||
mounts: spec.mounts().to_vec(),
|
||||
egress: spec.egress().map(<[String]>::to_vec),
|
||||
});
|
||||
self.answer.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets a test keep its `Arc<Recording>` while the broker owns a `Box<dyn Runtime>`.
|
||||
pub struct Shared(pub Arc<Recording>);
|
||||
|
||||
impl Runtime for Shared {
|
||||
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
|
||||
self.0.run(spec)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user