agent-locker 0.1.0-alpha.1

A sandbox for running coding agents with restricted filesystem access.
use std::env;
use std::fs;
use std::path::PathBuf;

use serde::Deserialize;

use crate::Result;
use crate::cli::Mode;

/// On-disk configuration, loaded from `agent-locker/config.toml` under the
/// user's config directory (`$XDG_CONFIG_HOME`, falling back to `~/.config`).
///
/// Each preset section may supply extra arguments that are prepended to the
/// agent's command line, before any arguments given on the command line. This
/// is how a user opts in to flags like `--dangerously-skip-permissions` by
/// default, rather than agent-locker forcing them.
///
/// Example `config.toml`:
///
/// ```toml
/// [claude]
/// args = ["--dangerously-skip-permissions"]
///
/// [codex]
/// args = ["--dangerously-bypass-approvals-and-sandbox"]
/// ```
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
    #[serde(default)]
    opencode: PresetConfig,
    #[serde(default)]
    claude: PresetConfig,
    #[serde(default)]
    codex: PresetConfig,
}

#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct PresetConfig {
    /// Extra arguments prepended to the preset's command line.
    #[serde(default)]
    args: Vec<String>,
}

impl Config {
    /// Loads the config file if present. A missing file yields the default
    /// (empty) configuration; a malformed file is an error.
    pub fn load() -> Result<Self> {
        let Some(path) = config_path() else {
            return Ok(Self::default());
        };
        let contents = match fs::read_to_string(&path) {
            Ok(contents) => contents,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
                return Ok(Self::default());
            }
            Err(err) => {
                return Err(format!("failed to read config {}: {err}", path.display()).into());
            }
        };
        toml::from_str(&contents)
            .map_err(|err| format!("failed to parse config {}: {err}", path.display()).into())
    }

    /// Extra arguments configured for the given preset. Basic mode has no
    /// preset and therefore no configured arguments.
    pub fn preset_args(&self, mode: Mode) -> &[String] {
        match mode {
            Mode::Basic => &[],
            Mode::Opencode => &self.opencode.args,
            Mode::Claude => &self.claude.args,
            Mode::Codex => &self.codex.args,
        }
    }
}

/// Path to the config file: `$XDG_CONFIG_HOME/agent-locker/config.toml`, or
/// `~/.config/agent-locker/config.toml` when `XDG_CONFIG_HOME` is unset.
fn config_path() -> Option<PathBuf> {
    let base = match env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
        Some(dir) => PathBuf::from(dir),
        None => PathBuf::from(env::var_os("HOME")?).join(".config"),
    };
    Some(base.join("agent-locker").join("config.toml"))
}