176 lines
5.6 KiB
Rust
176 lines
5.6 KiB
Rust
//! 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 {
|
|
//! session: bxcore::SessionId::new("s1").unwrap(),
|
|
//! call: bxcore::CallId(1),
|
|
//! 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, Label, SessionState};
|
|
use bxcore::{CallId, SessionId, ToolResponse};
|
|
|
|
/// 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 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>,
|
|
egress: Option<Vec<String>>,
|
|
state: SessionState,
|
|
}
|
|
|
|
impl RunSpec {
|
|
pub fn session(&self) -> &SessionId {
|
|
&self.session
|
|
}
|
|
pub fn call(&self) -> CallId {
|
|
self.call
|
|
}
|
|
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()
|
|
}
|
|
/// The session's state when the call was decided.
|
|
pub fn state(&self) -> SessionState {
|
|
self.state
|
|
}
|
|
}
|
|
|
|
/// 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>;
|
|
|
|
/// `run`, and a label for what came back when the runtime knows one (`memoryd` does: a
|
|
/// `recall` hit from an untrusted session). The runner keeps the stricter of this and the
|
|
/// grant's label. Container runtimes know none.
|
|
fn run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError> {
|
|
self.run(spec).map(|output| (output, None))
|
|
}
|
|
}
|
|
|
|
/// 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 = "no tool runner is configured: brokerd.toml has no [runner] section";
|
|
|
|
/// 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) -> bxcore::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())),
|
|
ToolArgs::Recall(_) | ToolArgs::Memory(_) | ToolArgs::AskMemory(_) => (Vec::new(), None),
|
|
};
|
|
let spec = RunSpec {
|
|
session: decision.request().session.clone(),
|
|
call: decision.request().call,
|
|
tool: args.tool(),
|
|
arguments: args.clone(),
|
|
mounts,
|
|
egress,
|
|
state: decision.state(),
|
|
};
|
|
match runtime.run_labelled(&spec) {
|
|
Ok((output, found)) => {
|
|
// Labels only get stricter: the higher class, and untrusted if either says so.
|
|
let (class, untrusted) = match found {
|
|
Some(found) => (
|
|
label.class.max(found.class),
|
|
label.untrusted || found.untrusted,
|
|
),
|
|
None => (label.class, label.untrusted),
|
|
};
|
|
ToolResponse::Result {
|
|
content: output.content,
|
|
class,
|
|
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(),
|
|
}
|
|
}
|