errand-bot 0.2.0

Run a coding agent from a chat channel, in a sandbox it cannot escape.
//! The per-session policy that says what a confined agent may touch.
//!
//! Kept apart from the backend that runs it, because this is the whole of the
//! containment guarantee and it is worth being able to read, test, and diff on
//! its own. Everything here is a name or a path; no secret is ever written
//! into a policy.

use std::collections::BTreeMap;

use crate::config::schema::{NetworkMode, PolicyExtraConfig};
use crate::sandbox::backend::{AGENT_BIN, AGENT_HOME, STATE_PATH, SandboxLaunch, WORKSPACE_PATH};
use crate::sandbox::runtime::{AgentRuntime, SANDBOX_PATH};

/// The bundled profile for a coding agent: its project and outbound HTTPS.
pub const AGENT_PROFILE: &str = "ai-agent";

/// The profile used when a session is given no network at all.
pub const OFFLINE_PROFILE: &str = "untrusted";

/// Where a session's policy file is written, inside its state directory.
pub const POLICY_FILENAME: &str = "policy.toml";

/// Filename of the resolver a session is given, in the daemon's state root.
pub const RESOLV_FILENAME: &str = "resolv.conf";

/// What a session may read outside its own directories.
///
/// Named rather than inherited, so what a session can reach is bounded by this
/// list rather than by whatever else happens to live on the host. With exactly
/// this, the agent, git, python with TLS, and ripgrep run, and the agent
/// reaches its provider over HTTPS. `/sys` is not needed at all.
const SYSTEM_READ: [&str; 17] = [
    // Programs, libraries, and their data.
    "/usr",
    "/lib",
    "/lib64",
    "/bin",
    "/sbin",
    // Its own processes. A fresh procfs in the session's PID namespace.
    "/proc",
    // What the dynamic linker reads to find a library. Without this, nothing
    // dynamically linked starts at all.
    "/etc/ld.so.cache",
    "/etc/ld.so.conf",
    "/etc/ld.so.conf.d",
    // Certificate authorities, or TLS fails and the provider is unreachable.
    // Some distributions keep only symlinks under /etc/ssl, so the trees they
    // point into are named here too.
    "/etc/ssl",
    "/etc/ca-certificates.conf",
    "/etc/ca-certificates",
    "/var/lib/ca-certificates",
    "/etc/pki",
    // Name resolution. The resolver itself is not from here: see RESOLV_CONF.
    "/etc/nsswitch.conf",
    // Static tables a networking tool expects to find. They name no host.
    "/etc/services",
    "/etc/protocols",
];

/// Where the agent may run programs from.
const SYSTEM_EXECUTE: [&str; 5] = ["/usr", "/lib", "/lib64", "/bin", "/sbin"];

/// The resolver a session is given, in place of the host's.
///
/// The host's `/etc/resolv.conf` names whoever resolves for this machine,
/// which on a home network is the ISP and on a private one is the network
/// itself. A session has no use for that and cannot be stopped from repeating
/// it, so it is given a public resolver instead. Queries still leave over the
/// host's connection, so this hides which resolver the operator uses rather
/// than the fact that a lookup happened.
///
/// One file for the daemon rather than one per session: the contents are the
/// same for every session, and a copy inside a session's own state directory
/// would sit under a grant that is placed elsewhere and never be bound.
pub const RESOLV_CONF: &str = "# Generated by errand. The host's own resolver is not shown to a session.\n\
     nameserver 1.1.1.1\n\
     nameserver 1.0.0.1\n\
     options edns0";

/// Renders one TOML string. Names only, so no secret is ever written here.
fn quoted(name: &str) -> String {
    serde_json::to_string(name).expect("a name always quotes")
}

/// A host path bound where the agent expects to find it.
fn placed(host: &str, at: &str) -> String {
    format!(
        "{{ path = {host_quoted}, at = {at_quoted} }}",
        host_quoted = quoted(host),
        at_quoted = quoted(at)
    )
}

/// Where a session's policy file lives. Never inside the project.
pub fn policy_path(launch: &SandboxLaunch) -> String {
    std::path::Path::new(&launch.state_dir)
        .join(POLICY_FILENAME)
        .to_string_lossy()
        .into_owned()
}

/// What goes into one rendered policy, when the defaults are not enough.
#[derive(Debug, Clone)]
pub struct PolicyOptions<'a> {
    /// Everything a backend needs to start one session.
    pub launch: &'a SandboxLaunch,
    /// The network exposure the session gets.
    pub network: NetworkMode,
    /// Ports a session may open outbound. Empty falls back to HTTPS alone.
    pub egress_ports: Option<&'a [u16]>,
    /// Where the agent's own program lives.
    pub runtime: &'a AgentRuntime,
    /// Largest single file a session may write, in size syntax.
    pub file_max: &'a str,
    /// Host path of the resolver file handed to the session.
    pub resolv_conf: &'a str,
    /// Paths the operator granted on top of these.
    pub extra: Option<&'a PolicyExtraConfig>,
    /// Variables the operator set, for a toolchain that reads one.
    pub env: Option<&'a BTreeMap<String, String>>,
    /// Directories the operator added to the session's PATH.
    pub path_extra: Option<&'a [String]>,
}

/// Builds the per-session policy.
///
/// It is written into the session's state directory, never into the project.
/// The agent can write to its project, so a policy living there would be a
/// policy the agent could rewrite, which is not a policy.
///
/// `resolv_conf` is required rather than defaulted: a caller that forgot it
/// would silently hand the session the host's own resolver.
pub fn policy_contents(options: &PolicyOptions) -> String {
    let launch = options.launch;
    let empty = PolicyExtraConfig {
        read: Vec::new(),
        write: Vec::new(),
        execute: Vec::new(),
    };
    let extra = options.extra.unwrap_or(&empty);
    let no_env = BTreeMap::new();
    let operator_env = options.env.unwrap_or(&no_env);

    // The project and the session state are placed at fixed paths, so the
    // agent sees the same two under any backend and never a host path.
    //
    // The state directory is readable as well as writable. A grant of write
    // does not imply read, and the agent reads back everything it keeps there:
    // its credential store, the system prompt the daemon writes for it, and
    // the history a resumed thread continues from.
    let mut read: Vec<String> = SYSTEM_READ.iter().map(|path| quoted(path)).collect();
    read.push(placed(options.resolv_conf, "/etc/resolv.conf"));
    read.push(placed(&launch.project_path, WORKSPACE_PATH));
    read.push(placed(&launch.state_dir, STATE_PATH));
    // The agent's own program stays where it is installed. An interpreter
    // resolves its modules relative to itself, so moving it breaks it.
    read.extend(options.runtime.read_paths.iter().map(|path| quoted(path)));
    read.extend(extra.read.iter().map(|path| quoted(path)));

    // Ahead of everything, so a wrapper stands in for the program it names.
    // The operator's own directories sit ahead of the system ones: a toolchain
    // named on purpose is the one a session should find, not the host's copy.
    let mut path: Vec<String> = vec![AGENT_BIN.to_owned()];
    path.extend(options.runtime.path_entries.iter().cloned());
    if let Some(path_extra) = options.path_extra {
        path.extend(path_extra.iter().cloned());
    }
    path.extend(SANDBOX_PATH.iter().map(|entry| (*entry).to_owned()));

    // The runtime's own directories are granted execute as well as read. They
    // hold the agent and the interpreter that runs it, which is the whole
    // reason they are granted at all, and a read grant no longer carries
    // execute with it: bailey separated the two, and without this the agent's
    // own `execve` is refused. Nothing else in the read list is widened.
    let mut execute_set: Vec<String> = SYSTEM_EXECUTE
        .iter()
        .map(|entry| (*entry).to_owned())
        .collect();
    execute_set.push(AGENT_BIN.to_owned());
    execute_set.extend(options.runtime.path_entries.iter().cloned());
    execute_set.extend(options.runtime.read_paths.iter().cloned());
    execute_set.extend(extra.execute.iter().cloned());
    let mut seen_execute = std::collections::HashSet::new();
    let execute: Vec<String> = execute_set
        .into_iter()
        .filter(|entry| seen_execute.insert(entry.clone()))
        .map(|entry| quoted(&entry))
        .collect();

    // The environment is built rather than inherited, so a variable not named
    // here does not cross. The provider credential is named and nothing else
    // is: the daemon's own environment holds the chat token, and this is the
    // boundary that keeps it out of a session.
    let pass: Vec<String> = {
        let mut names: Vec<String> = launch.env.keys().cloned().collect();
        names.sort();
        names.iter().map(|name| quoted(name)).collect()
    };
    // HOME is set here rather than on the sandbox process. The tool derives
    // its own world from the caller's HOME, so pointing that at a placed path
    // makes it try to create the path on the host, which fails. Set in the
    // policy, it reaches the target after the pivot, where the path exists.
    let mut set = vec![format!("PATH = {}", quoted(&path.join(":")))];
    set.push(format!("HOME = {}", quoted(AGENT_HOME)));
    set.extend(
        operator_env
            .iter()
            .filter(|(name, _)| !launch.env.contains_key(*name))
            .map(|(name, value)| format!("{name} = {}", quoted(value))),
    );

    let write: Vec<String> = [
        placed(&launch.project_path, WORKSPACE_PATH),
        placed(&launch.state_dir, STATE_PATH),
    ]
    .into_iter()
    .chain(extra.write.iter().map(|path| quoted(path)))
    .collect();

    let mut lines = vec![
        "# Generated per session by errand. Do not edit.".to_owned(),
        "[filesystem]".to_owned(),
        // Clears the profile's grants, so the list below is the whole of what
        // a session can reach rather than an addition to a wider floor.
        "reset = true".to_owned(),
        format!("read = [{}]", read.join(", ")),
        format!("write = [{}]", write.join(", ")),
        format!("execute = [{}]", execute.join(", ")),
        String::new(),
        "[env]".to_owned(),
        format!("pass = [{}]", pass.join(", ")),
        format!("set = {{ {} }}", set.join(", ")),
        String::new(),
        // An rlimit rather than a cgroup control, so it holds on a host with
        // no delegated cgroup, which is the case this backend most often runs
        // on.
        "[resources]".to_owned(),
        format!("file_max = {}", quoted(options.file_max)),
    ];

    if options.network != NetworkMode::None {
        let open_ports: &[u16] = match options.egress_ports {
            Some(ports) if !ports.is_empty() => ports,
            _ => &[443],
        };
        let allow = open_ports
            .iter()
            .map(|port| format!("{{ host = \"*\", port = {port} }}"))
            .collect::<Vec<_>>()
            .join(", ");
        lines.push(String::new());
        lines.push("[network]".to_owned());
        lines.push(format!("egress_allow = [{allow}]"));
    }
    format!("{}\n", lines.join("\n"))
}

#[cfg(test)]
mod tests;