#[derive(Debug, Clone)]
pub struct SofosConfig {
pub max_messages: usize,
pub max_context_tokens: usize,
pub max_tool_iterations: u32,
pub auto_compact_token_limit: usize,
pub compaction_preserve_recent: usize,
pub tool_result_truncate_threshold: usize,
}
impl Default for SofosConfig {
fn default() -> Self {
let info = crate::api::Model::default();
Self {
max_messages: 500,
max_context_tokens: info.effective_window() as usize,
max_tool_iterations: 200,
auto_compact_token_limit: info.auto_compact_at() as usize,
compaction_preserve_recent: 20,
tool_result_truncate_threshold: 2000,
}
}
}
#[derive(Clone)]
pub struct ModelConfig {
pub model: String,
pub max_tokens: u32,
pub reasoning_effort: crate::api::ReasoningEffort,
pub reasoning_mode: crate::api::ReasoningMode,
}
impl ModelConfig {
pub fn new(
model: String,
max_tokens: u32,
reasoning_effort: crate::api::ReasoningEffort,
reasoning_mode: crate::api::ReasoningMode,
) -> Self {
Self {
model,
max_tokens,
reasoning_effort,
reasoning_mode,
}
}
pub fn set_reasoning_effort(&mut self, effort: crate::api::ReasoningEffort) {
self.reasoning_effort = effort;
}
pub fn set_reasoning_mode(&mut self, mode: crate::api::ReasoningMode) {
self.reasoning_mode = mode;
}
}
pub fn max_context_tokens_for(model: &str) -> usize {
crate::api::model_info::lookup(model).effective_window() as usize
}
pub fn auto_compact_token_limit_for(model: &str) -> usize {
crate::api::model_info::lookup(model).auto_compact_at() as usize
}
pub const SYSTEM_MESSAGE_PREFIX: &str = "[SYSTEM:";
pub fn readonly_mode_message() -> String {
"[SYSTEM: Read-only mode is active.\n\
\n\
Shell commands: not available in this mode.\n\
File edits: blocked (write_file, edit_file, delete_file, create_directory, \
move_file, copy_file all unavailable).\n\
External paths: not reachable.\n\
\n\
Available native tools: list_directory, read_file, glob_files, search_code \
(when ripgrep is installed), update_plan, web_fetch, web_search. MCP tools \
are filtered out unless their server is marked readonly = \"read_only\" or \
\"allow\" in the configuration.\n\
\n\
Switch access modes with /permissions.]"
.to_string()
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub fn sandbox_on_message(policy: ApprovalPolicy) -> String {
let preset = PermissionPreset::current(SandboxMode::Sandboxed, policy);
let preset_intro = match policy {
ApprovalPolicy::OnRequest => format!("{} preset, the default access mode", preset.label()),
_ => format!("{} preset", preset.label()),
};
let escalation = match policy {
ApprovalPolicy::OnRequest => {
"Explain this likely cause to the user, try an alternative \
that works under the sandbox when possible, and otherwise rerun the command with \
sandbox_permissions set to \"require_escalated\" so the user can approve running that \
one command outside the sandbox; suggest an unsandboxed preset (/permissions) only if \
many commands need it."
}
ApprovalPolicy::OnFailure => {
"Explain this likely cause to the user and try an alternative \
that works under the sandbox when possible. Do not set sandbox_permissions to \
\"require_escalated\" in this preset — up-front escalation requests are refused; \
instead run the command normally, and when a confined command looks sandbox-blocked \
the user is offered an unsandboxed retry of it. Suggest an unsandboxed preset \
(/permissions) only if many commands need it."
}
ApprovalPolicy::Never => {
"Explain this likely cause to the user and try an alternative \
that works under the sandbox when possible. This preset never lifts the sandbox: \
sandbox_permissions \"require_escalated\" requests are refused and failed commands are \
not offered an unsandboxed retry, so a command that genuinely needs the network or to \
write outside the workspace cannot run here — tell the user and suggest switching to \
an unsandboxed preset with /permissions."
}
};
format!(
"[SYSTEM: The sandbox is on ({preset_intro}).\n\
\n\
Shell commands run through a three-tier model:\n\
- Familiar commands (cargo, npm, go, ls, cat, grep, rg, git status, git log, \
git diff, ...) run automatically.\n\
- Destructive commands (rm, rmdir, chmod, chown, sudo, dd, mkfs, systemctl, \
kill, destructive git operations) are always refused.\n\
- Any other command runs without a prompt.\n\
\n\
Every command that runs, familiar or not, is confined by the operating-system \
sandbox: writes are limited to the workspace and the temporary directories, and \
the network is closed. This includes build and network tools such as cargo, npm, \
and pip — with the sandbox on they cannot fetch over the network or write outside \
the workspace. File redirection (echo hi > file) and here-documents also run \
confined, so they succeed when targeting paths inside the workspace.\n\
\n\
The project's .sofos, .agents, .claude, and .codex stay read-only for confined \
commands; edit them with the file tools, not the shell. The .git directory is \
read-only too, except for a command that runs only git, so git checkout and local \
git config still work.\n\
\n\
Always refused, even confined: parent traversal (..), hidden subcommands \
($(...), backticks, <(...), >(...)), and dangerous git operations.\n\
\n\
Confined-command failures: if a command fails with permission, network, socket, \
mount, or container engine errors, assume the operating-system sandbox may be the \
cause. Common examples include Docker and other container runtimes, tools that \
need network access, local daemon sockets, or writes outside the workspace and \
temporary directories. {escalation}\n\
\n\
Switch access modes with /permissions. All tools are available.]"
)
}
#[cfg(target_os = "windows")]
pub fn sandbox_on_message(_policy: ApprovalPolicy) -> String {
"[SYSTEM: The sandbox is on.\n\
\n\
Shell commands run through a three-tier model:\n\
- Familiar commands (cargo, npm, go, ls, cat, grep, rg, git status, git log, \
git diff, ...) run automatically.\n\
- Destructive commands (rm, rmdir, chmod, chown, sudo, dd, mkfs, systemctl, \
kill, destructive git operations) are always refused.\n\
- Any other command prompts the user for approval before running.\n\
\n\
Always refused: parent traversal (..), hidden subcommands ($(...), backticks, \
<(...), >(...)), file redirection (>, >>) and here-documents (use write_file or \
edit_file instead; 2>&1 is allowed), and dangerous git operations.\n\
\n\
Operating-system confinement is NOT engaged on Windows in this release: the \
default shell cannot start under the restricted access token, so shell commands \
run unconfined on this platform even with the sandbox on; the network is not \
closed for shell commands. The destructive-command blocklist, the read-deny \
rules, and the external-path prompts still apply.\n\
\n\
Switch access modes with /permissions. All tools are available.]"
.to_string()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
pub fn sandbox_on_message(_policy: ApprovalPolicy) -> String {
"[SYSTEM: The sandbox is on.\n\
\n\
Shell commands run through a three-tier model:\n\
- Familiar commands run automatically.\n\
- Destructive commands are always refused.\n\
- Any other command prompts the user for approval before running.\n\
\n\
Operating-system confinement is not available on this platform, so shell \
commands run unconfined even with the sandbox on.\n\
\n\
Switch access modes with /permissions. All tools are available.]"
.to_string()
}
pub fn sandbox_off_message() -> String {
"[SYSTEM: The sandbox is off.\n\
\n\
Shell commands run through the same three-tier model as with the sandbox on, but \
without operating-system confinement:\n\
- Familiar commands run automatically.\n\
- Destructive commands (rm, rmdir, chmod, chown, sudo, dd, mkfs, systemctl, \
kill, destructive git operations) are always refused.\n\
- Any other command prompts the user for approval before running.\n\
\n\
Structural rules still apply: parent traversal (..), hidden subcommands \
($(...), backticks, <(...), >(...)), file redirection (>, >>) and here-documents \
(use write_file or edit_file instead; 2>&1 is allowed), and dangerous git \
operations are refused outright.\n\
\n\
No operating-system confinement is applied; intended for trusted environments only.\n\
\n\
Switch access modes with /permissions. All tools are available.]"
.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxMode {
ReadOnly,
Sandboxed,
Unsandboxed,
}
impl SandboxMode {
pub fn from_flags(readonly: bool, no_sandbox: bool, sandbox_available: bool) -> Self {
if readonly {
Self::ReadOnly
} else if no_sandbox || !sandbox_available {
Self::Unsandboxed
} else {
Self::Sandboxed
}
}
pub fn is_readonly(self) -> bool {
matches!(self, Self::ReadOnly)
}
pub fn is_sandboxed(self) -> bool {
matches!(self, Self::Sandboxed)
}
pub fn is_more_permissive_than(self, other: SandboxMode) -> bool {
self.restriction_rank() < other.restriction_rank()
}
fn restriction_rank(self) -> u8 {
match self {
Self::ReadOnly => 2,
Self::Sandboxed => 1,
Self::Unsandboxed => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ApprovalPolicy {
OnFailure,
#[default]
OnRequest,
Never,
}
impl ApprovalPolicy {
pub fn label(self) -> &'static str {
match self {
Self::OnFailure => "on-failure",
Self::OnRequest => "on-request",
Self::Never => "never",
}
}
pub fn wants_no_sandbox_approval(self) -> bool {
matches!(self, Self::OnFailure)
}
pub fn allows_model_escalation_request(self) -> bool {
matches!(self, Self::OnRequest)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionPreset {
ReadOnly,
SandboxedAsk,
SandboxedRetry,
SandboxedStrict,
Unsandboxed,
}
pub const PERMISSION_PRESETS: [PermissionPreset; 5] = [
PermissionPreset::ReadOnly,
PermissionPreset::SandboxedAsk,
PermissionPreset::SandboxedRetry,
PermissionPreset::SandboxedStrict,
PermissionPreset::Unsandboxed,
];
impl PermissionPreset {
pub fn label(self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::SandboxedAsk => "sandboxed-ask",
Self::SandboxedRetry => "sandboxed-retry",
Self::SandboxedStrict => "sandboxed-strict",
Self::Unsandboxed => "unsandboxed",
}
}
pub fn description(self) -> &'static str {
match self {
Self::ReadOnly => "Inspect only; no edits or shell commands.",
Self::SandboxedAsk => "Project-limited edits and commands; may request a sandbox lift.",
Self::SandboxedRetry => {
"Project-limited edits and commands; offers an unsandboxed retry if blocked."
}
Self::SandboxedStrict => "Project-limited edits and commands; never lifts the sandbox.",
Self::Unsandboxed => "Edit and run commands without sandbox limits.",
}
}
pub fn mode(self) -> SandboxMode {
match self {
Self::ReadOnly => SandboxMode::ReadOnly,
Self::SandboxedAsk | Self::SandboxedRetry | Self::SandboxedStrict => {
SandboxMode::Sandboxed
}
Self::Unsandboxed => SandboxMode::Unsandboxed,
}
}
pub fn is_available(self, sandbox_available: bool) -> bool {
self.mode() != SandboxMode::Sandboxed || sandbox_available
}
pub fn escalation(self) -> Option<ApprovalPolicy> {
match self {
Self::ReadOnly | Self::Unsandboxed => None,
Self::SandboxedAsk => Some(ApprovalPolicy::OnRequest),
Self::SandboxedRetry => Some(ApprovalPolicy::OnFailure),
Self::SandboxedStrict => Some(ApprovalPolicy::Never),
}
}
pub fn parse(s: &str) -> Option<Self> {
let normalized = s.trim().to_ascii_lowercase().replace('_', "-");
PERMISSION_PRESETS
.into_iter()
.find(|preset| preset.label() == normalized)
}
pub fn current(mode: SandboxMode, policy: ApprovalPolicy) -> Self {
match mode {
SandboxMode::ReadOnly => Self::ReadOnly,
SandboxMode::Unsandboxed => Self::Unsandboxed,
SandboxMode::Sandboxed => match policy {
ApprovalPolicy::OnRequest => Self::SandboxedAsk,
ApprovalPolicy::OnFailure => Self::SandboxedRetry,
ApprovalPolicy::Never => Self::SandboxedStrict,
},
}
}
pub fn is_more_permissive_than(self, other: Self) -> bool {
if self.mode() != other.mode() {
return self.mode().is_more_permissive_than(other.mode());
}
self.escalation_rank() < other.escalation_rank()
}
fn escalation_rank(self) -> u8 {
match self.escalation() {
Some(ApprovalPolicy::Never) => 1,
_ => 0,
}
}
}
pub(crate) const LOCAL_CONFIG_FILE: &str = ".sofos/config.local.toml";
pub(crate) const GLOBAL_CONFIG_FILE: &str = ".sofos/config.toml";
pub(crate) fn home_dir() -> Option<std::path::PathBuf> {
#[cfg(windows)]
{
std::env::var_os("USERPROFILE").map(std::path::PathBuf::from)
}
#[cfg(not(windows))]
{
std::env::var_os("HOME").map(std::path::PathBuf::from)
}
}
pub(crate) fn global_config_path() -> Option<std::path::PathBuf> {
home_dir().map(|home| home.join(GLOBAL_CONFIG_FILE))
}
pub(crate) fn config_files_hint() -> String {
format!("{} or ~/{}", LOCAL_CONFIG_FILE, GLOBAL_CONFIG_FILE)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_more_permissive_than_orders_modes_by_restrictiveness() {
use SandboxMode::*;
assert!(Unsandboxed.is_more_permissive_than(Sandboxed));
assert!(Unsandboxed.is_more_permissive_than(ReadOnly));
assert!(Sandboxed.is_more_permissive_than(ReadOnly));
assert!(!ReadOnly.is_more_permissive_than(Unsandboxed));
assert!(!Sandboxed.is_more_permissive_than(Unsandboxed));
assert!(!Sandboxed.is_more_permissive_than(Sandboxed));
}
#[test]
fn preset_is_more_permissive_than_covers_both_axes() {
use PermissionPreset::*;
assert!(Unsandboxed.is_more_permissive_than(SandboxedAsk));
assert!(SandboxedAsk.is_more_permissive_than(ReadOnly));
assert!(!ReadOnly.is_more_permissive_than(Unsandboxed));
assert!(SandboxedAsk.is_more_permissive_than(SandboxedStrict));
assert!(SandboxedRetry.is_more_permissive_than(SandboxedStrict));
assert!(!SandboxedStrict.is_more_permissive_than(SandboxedAsk));
assert!(!SandboxedAsk.is_more_permissive_than(SandboxedRetry));
assert!(!SandboxedRetry.is_more_permissive_than(SandboxedAsk));
assert!(!SandboxedAsk.is_more_permissive_than(SandboxedAsk));
}
#[test]
fn sandbox_mode_from_flags_prefers_readonly_then_no_sandbox() {
assert_eq!(
SandboxMode::from_flags(false, false, true),
SandboxMode::Sandboxed
);
assert_eq!(
SandboxMode::from_flags(false, true, true),
SandboxMode::Unsandboxed
);
assert_eq!(
SandboxMode::from_flags(false, false, false),
SandboxMode::Unsandboxed
);
assert_eq!(
SandboxMode::from_flags(true, false, true),
SandboxMode::ReadOnly
);
assert_eq!(
SandboxMode::from_flags(true, true, false),
SandboxMode::ReadOnly
);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn sandbox_on_message_explains_confined_command_failures() {
let message = sandbox_on_message(ApprovalPolicy::OnRequest);
assert!(message.contains("Confined-command failures"));
assert!(message.contains("Docker"));
assert!(message.contains("require_escalated"));
assert!(message.contains("/permissions"));
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn sandbox_on_message_describes_the_active_escalation_policy() {
let ask = sandbox_on_message(ApprovalPolicy::OnRequest);
assert!(ask.contains(PermissionPreset::SandboxedAsk.label()));
assert!(ask.contains("require_escalated"));
let retry = sandbox_on_message(ApprovalPolicy::OnFailure);
assert!(retry.contains(PermissionPreset::SandboxedRetry.label()));
assert!(retry.contains("unsandboxed retry"));
assert!(retry.contains("Do not set sandbox_permissions"));
let strict = sandbox_on_message(ApprovalPolicy::Never);
assert!(strict.contains(PermissionPreset::SandboxedStrict.label()));
assert!(strict.contains("never lifts the sandbox"));
}
#[test]
fn test_default_config_matches_fallback_model_info() {
let config = SofosConfig::default();
let info = crate::api::Model::default();
assert_eq!(config.max_messages, 500);
assert_eq!(config.max_context_tokens, info.effective_window() as usize);
assert_eq!(config.max_tool_iterations, 200);
assert_eq!(
config.auto_compact_token_limit,
info.auto_compact_at() as usize
);
assert_eq!(config.compaction_preserve_recent, 20);
assert_eq!(config.tool_result_truncate_threshold, 2000);
}
#[test]
fn approval_policy_default_is_on_request() {
assert_eq!(ApprovalPolicy::default(), ApprovalPolicy::OnRequest);
}
#[test]
fn approval_policy_gates_each_escalation_path() {
assert!(ApprovalPolicy::OnFailure.wants_no_sandbox_approval());
assert!(!ApprovalPolicy::OnRequest.wants_no_sandbox_approval());
assert!(!ApprovalPolicy::Never.wants_no_sandbox_approval());
assert!(ApprovalPolicy::OnRequest.allows_model_escalation_request());
assert!(!ApprovalPolicy::OnFailure.allows_model_escalation_request());
assert!(!ApprovalPolicy::Never.allows_model_escalation_request());
}
#[test]
fn permission_preset_parse_round_trips_every_label() {
for preset in PERMISSION_PRESETS {
assert_eq!(PermissionPreset::parse(preset.label()), Some(preset));
}
assert_eq!(
PermissionPreset::parse("Sandboxed_Ask"),
Some(PermissionPreset::SandboxedAsk)
);
assert_eq!(PermissionPreset::parse("bogus"), None);
}
#[test]
fn permission_preset_current_matches_mode_and_policy() {
assert_eq!(
PermissionPreset::current(SandboxMode::ReadOnly, ApprovalPolicy::OnRequest),
PermissionPreset::ReadOnly
);
assert_eq!(
PermissionPreset::current(SandboxMode::Unsandboxed, ApprovalPolicy::Never),
PermissionPreset::Unsandboxed
);
assert_eq!(
PermissionPreset::current(SandboxMode::Sandboxed, ApprovalPolicy::OnRequest),
PermissionPreset::SandboxedAsk
);
assert_eq!(
PermissionPreset::current(SandboxMode::Sandboxed, ApprovalPolicy::OnFailure),
PermissionPreset::SandboxedRetry
);
assert_eq!(
PermissionPreset::current(SandboxMode::Sandboxed, ApprovalPolicy::Never),
PermissionPreset::SandboxedStrict
);
}
#[test]
fn permission_preset_availability_tracks_the_sandbox() {
for preset in PERMISSION_PRESETS {
assert!(preset.is_available(true));
}
for preset in PERMISSION_PRESETS {
let expected = preset.mode() != SandboxMode::Sandboxed;
assert_eq!(preset.is_available(false), expected, "{:?}", preset);
}
}
#[test]
fn permission_preset_mode_and_escalation_agree_with_current() {
for preset in PERMISSION_PRESETS {
let policy = preset.escalation().unwrap_or_default();
assert_eq!(PermissionPreset::current(preset.mode(), policy), preset);
}
}
}