Skip to main content

scv_server/
config.rs

1use std::{collections::HashMap, path::PathBuf, time::Duration};
2
3use anyhow::{Context, Result, bail};
4use scv_core::{AgentConfig as CoreAgentConfig, ContextConfig, HistoryLimits};
5use scv_provider_openai::ProviderLimits;
6use scv_tools::{AgentAdapterConfig, ToolsConfig};
7use serde::{Deserialize, Serialize};
8
9const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12#[serde(default, deny_unknown_fields)]
13pub struct Config {
14    pub provider: ProviderConfig,
15    pub agent: AgentConfig,
16    pub session: SessionConfig,
17    pub context: ContextConfigFile,
18    pub tools: ToolConfig,
19    pub protocol: ProtocolConfig,
20    pub tui: TuiConfig,
21    pub provider_limits: ProviderLimitsFile,
22    pub skills: SkillsConfig,
23    pub agents: AgentsConfig,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(default, deny_unknown_fields)]
28pub struct ProviderConfig {
29    pub kind: String,
30    pub model: String,
31    pub base_url: String,
32    pub api_key_env: String,
33    pub timeout_seconds: u64,
34}
35
36impl Default for ProviderConfig {
37    fn default() -> Self {
38        Self {
39            kind: "openai-compatible".into(),
40            model: "gpt-4.1-mini".into(),
41            base_url: "https://api.openai.com/v1".into(),
42            api_key_env: "OPENAI_API_KEY".into(),
43            timeout_seconds: 120,
44        }
45    }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(default, deny_unknown_fields)]
50pub struct AgentConfig {
51    pub max_steps: usize,
52    pub system_prompt: String,
53}
54
55impl Default for AgentConfig {
56    fn default() -> Self {
57        Self {
58            max_steps: 32,
59            system_prompt: "You are Peon, a concise and careful coding agent. Use tools to inspect, change, and verify the workspace.".into(),
60        }
61    }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(default, deny_unknown_fields)]
66pub struct SessionConfig {
67    pub max_history_bytes: usize,
68    pub max_messages: usize,
69}
70
71impl Default for SessionConfig {
72    fn default() -> Self {
73        Self {
74            max_history_bytes: 16 * 1024 * 1024,
75            max_messages: 10_000,
76        }
77    }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(default, deny_unknown_fields)]
82pub struct ContextConfigFile {
83    pub max_tokens: usize,
84    pub reserve_output_tokens: usize,
85    pub safety_margin_tokens: usize,
86    pub bytes_per_token: usize,
87    pub summary_max_chars: usize,
88}
89
90impl Default for ContextConfigFile {
91    fn default() -> Self {
92        let value = ContextConfig::default();
93        Self {
94            max_tokens: value.max_tokens,
95            reserve_output_tokens: value.reserve_output_tokens,
96            safety_margin_tokens: value.safety_margin_tokens,
97            bytes_per_token: value.bytes_per_token,
98            summary_max_chars: value.summary_max_chars,
99        }
100    }
101}
102
103impl From<&ContextConfigFile> for ContextConfig {
104    fn from(value: &ContextConfigFile) -> Self {
105        Self {
106            max_tokens: value.max_tokens,
107            reserve_output_tokens: value.reserve_output_tokens,
108            safety_margin_tokens: value.safety_margin_tokens,
109            bytes_per_token: value.bytes_per_token,
110            summary_max_chars: value.summary_max_chars,
111        }
112    }
113}
114
115#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "kebab-case")]
117pub enum ApprovalPolicy {
118    OnRisk,
119    Always,
120    Never,
121}
122
123impl ApprovalPolicy {
124    fn strictness(self) -> u8 {
125        match self {
126            Self::OnRisk => 1,
127            Self::Always => 2,
128            Self::Never => 3,
129        }
130    }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(default, deny_unknown_fields)]
135pub struct ToolConfig {
136    pub approval_policy: ApprovalPolicy,
137    pub command_timeout_seconds: u64,
138    pub output_limit_bytes: usize,
139    pub max_read_bytes: usize,
140    pub max_write_bytes: usize,
141}
142
143impl Default for ToolConfig {
144    fn default() -> Self {
145        Self {
146            approval_policy: ApprovalPolicy::OnRisk,
147            command_timeout_seconds: 120,
148            output_limit_bytes: 64 * 1024,
149            max_read_bytes: 256 * 1024,
150            max_write_bytes: 1024 * 1024,
151        }
152    }
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(default, deny_unknown_fields)]
157pub struct ProtocolConfig {
158    pub max_client_frame_bytes: usize,
159    pub max_server_frame_bytes: usize,
160}
161
162impl Default for ProtocolConfig {
163    fn default() -> Self {
164        Self {
165            max_client_frame_bytes: 1024 * 1024,
166            max_server_frame_bytes: 8 * 1024 * 1024,
167        }
168    }
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172#[serde(default, deny_unknown_fields)]
173pub struct TuiConfig {
174    pub max_transcript_bytes: usize,
175    pub max_transcript_items: usize,
176    pub max_prompt_history_bytes: usize,
177    pub max_prompt_history_items: usize,
178}
179
180impl Default for TuiConfig {
181    fn default() -> Self {
182        Self {
183            max_transcript_bytes: 8 * 1024 * 1024,
184            max_transcript_items: 10_000,
185            max_prompt_history_bytes: 1024 * 1024,
186            max_prompt_history_items: 200,
187        }
188    }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192#[serde(default, deny_unknown_fields)]
193pub struct ProviderLimitsFile {
194    pub max_sse_event_bytes: usize,
195    pub max_response_bytes: usize,
196    pub max_assistant_bytes: usize,
197    pub max_tool_calls: usize,
198    pub max_tool_arguments_bytes: usize,
199}
200
201impl Default for ProviderLimitsFile {
202    fn default() -> Self {
203        let value = ProviderLimits::default();
204        Self {
205            max_sse_event_bytes: value.max_sse_event_bytes,
206            max_response_bytes: value.max_response_bytes,
207            max_assistant_bytes: value.max_assistant_bytes,
208            max_tool_calls: value.max_tool_calls,
209            max_tool_arguments_bytes: value.max_tool_arguments_bytes,
210        }
211    }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215#[serde(default, deny_unknown_fields)]
216pub struct SkillsConfig {
217    pub user_dir: PathBuf,
218    pub project_dir: PathBuf,
219    pub max_skills: usize,
220    pub max_skill_bytes: usize,
221}
222
223impl Default for SkillsConfig {
224    fn default() -> Self {
225        Self {
226            user_dir: PathBuf::from("~/.config/peon/skills"),
227            project_dir: PathBuf::from(".peon/skills"),
228            max_skills: 128,
229            max_skill_bytes: 256 * 1024,
230        }
231    }
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, Default)]
235#[serde(default, deny_unknown_fields)]
236pub struct AdapterConfig {
237    pub command: String,
238    pub args: Vec<String>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242#[serde(default, deny_unknown_fields)]
243pub struct AgentsConfig {
244    pub claude: AdapterConfig,
245    pub codex: AdapterConfig,
246    pub pi: AdapterConfig,
247}
248
249impl Default for AgentsConfig {
250    fn default() -> Self {
251        Self {
252            claude: AdapterConfig {
253                command: "claude".into(),
254                args: vec!["-p".into()],
255            },
256            codex: AdapterConfig {
257                command: "codex".into(),
258                args: vec!["exec".into()],
259            },
260            pi: AdapterConfig {
261                command: "pi".into(),
262                args: vec!["-p".into()],
263            },
264        }
265    }
266}
267
268#[derive(Debug, Clone, Default)]
269pub struct ConfigOverrides {
270    pub model: Option<String>,
271    pub base_url: Option<String>,
272    pub approval_policy: Option<ApprovalPolicy>,
273}
274
275impl Config {
276    pub fn load(workspace: &std::path::Path, overrides: ConfigOverrides) -> Result<Self> {
277        let mut value: toml::Value = toml::from_str(
278            &toml::to_string(&Self::default()).context("serialize default configuration")?,
279        )?;
280
281        if let Some(user_path) = user_config_path()
282            && user_path.is_file()
283        {
284            merge(&mut value, read_layer(&user_path)?);
285        }
286        let user_baseline: Self = value
287            .clone()
288            .try_into()
289            .context("parse user configuration")?;
290
291        let project_path = workspace.join(".peon/config.toml");
292        if project_path.is_file() {
293            let canonical_project = std::fs::canonicalize(&project_path)
294                .with_context(|| format!("resolve configuration {}", project_path.display()))?;
295            if !canonical_project.starts_with(workspace) {
296                bail!("project configuration escaped workspace");
297            }
298            let project = read_layer(&canonical_project)?;
299            validate_project_keys(&project)?;
300            let mut candidate_value = value.clone();
301            merge(&mut candidate_value, project);
302            let candidate: Self = candidate_value
303                .clone()
304                .try_into()
305                .context("parse project configuration")?;
306            validate_project_not_weaker(&user_baseline, &candidate)?;
307            value = candidate_value;
308        }
309
310        if let Some(explicit) = std::env::var_os("PEON_CONFIG") {
311            let path = PathBuf::from(explicit);
312            merge(&mut value, read_layer(&path)?);
313        }
314        let mut config: Self = value.try_into().context("parse merged configuration")?;
315        if let Ok(model) = std::env::var("PEON_MODEL") {
316            config.provider.model = model;
317        }
318        if let Ok(base_url) = std::env::var("PEON_BASE_URL") {
319            config.provider.base_url = base_url;
320        }
321        if let Ok(api_key_env) = std::env::var("PEON_API_KEY_ENV") {
322            config.provider.api_key_env = api_key_env;
323        }
324        if let Some(model) = overrides.model {
325            config.provider.model = model;
326        }
327        if let Some(base_url) = overrides.base_url {
328            config.provider.base_url = base_url;
329        }
330        if let Some(policy) = overrides.approval_policy {
331            config.tools.approval_policy = policy;
332        }
333        config.skills.user_dir = expand_home(&config.skills.user_dir);
334        config.validate()?;
335        Ok(config)
336    }
337
338    pub fn core_agent(&self, system_prompt: String) -> CoreAgentConfig {
339        CoreAgentConfig {
340            system_prompt,
341            max_steps: self.agent.max_steps,
342            history_limits: HistoryLimits {
343                max_bytes: self.session.max_history_bytes,
344                max_messages: self.session.max_messages,
345                note_max_chars: self.context.summary_max_chars,
346            },
347        }
348    }
349
350    pub fn tools(&self) -> ToolsConfig {
351        ToolsConfig {
352            command_timeout: Duration::from_secs(self.tools.command_timeout_seconds),
353            output_limit_bytes: self.tools.output_limit_bytes,
354            max_read_bytes: self.tools.max_read_bytes,
355            max_write_bytes: self.tools.max_write_bytes,
356        }
357    }
358
359    pub fn provider_limits(&self) -> ProviderLimits {
360        ProviderLimits {
361            max_sse_event_bytes: self.provider_limits.max_sse_event_bytes,
362            max_response_bytes: self.provider_limits.max_response_bytes,
363            max_assistant_bytes: self.provider_limits.max_assistant_bytes,
364            max_tool_calls: self.provider_limits.max_tool_calls,
365            max_tool_arguments_bytes: self.provider_limits.max_tool_arguments_bytes,
366        }
367    }
368
369    pub fn adapters(&self) -> HashMap<String, AgentAdapterConfig> {
370        [
371            ("agent_claude", &self.agents.claude),
372            ("agent_codex", &self.agents.codex),
373            ("agent_pi", &self.agents.pi),
374        ]
375        .into_iter()
376        .map(|(name, config)| {
377            (
378                name.to_owned(),
379                AgentAdapterConfig {
380                    command: config.command.clone(),
381                    args: config.args.clone(),
382                },
383            )
384        })
385        .collect()
386    }
387
388    fn validate(&self) -> Result<()> {
389        if self.provider.kind != "openai-compatible" {
390            bail!("provider.kind must be openai-compatible in v0.1");
391        }
392        if self.provider.model.trim().is_empty()
393            || self.provider.base_url.trim().is_empty()
394            || self.provider.api_key_env.trim().is_empty()
395        {
396            bail!("provider model, base_url, and api_key_env must be non-empty");
397        }
398        for (name, adapter) in [
399            ("agents.claude.command", &self.agents.claude),
400            ("agents.codex.command", &self.agents.codex),
401            ("agents.pi.command", &self.agents.pi),
402        ] {
403            if adapter.command.trim().is_empty() {
404                bail!("{name} must be non-empty");
405            }
406            let adapter_bytes =
407                adapter.command.len() + adapter.args.iter().map(String::len).sum::<usize>();
408            if adapter_bytes > 16 * 1024 {
409                bail!("{name} and its fixed arguments exceed 16384 bytes");
410            }
411        }
412        let positives = [
413            (
414                "provider.timeout_seconds",
415                usize::try_from(self.provider.timeout_seconds).unwrap_or(usize::MAX),
416            ),
417            ("agent.max_steps", self.agent.max_steps),
418            ("session.max_history_bytes", self.session.max_history_bytes),
419            ("session.max_messages", self.session.max_messages),
420            ("context.max_tokens", self.context.max_tokens),
421            ("context.bytes_per_token", self.context.bytes_per_token),
422            ("context.summary_max_chars", self.context.summary_max_chars),
423            (
424                "tools.command_timeout_seconds",
425                usize::try_from(self.tools.command_timeout_seconds).unwrap_or(usize::MAX),
426            ),
427            ("tools.output_limit_bytes", self.tools.output_limit_bytes),
428            ("tools.max_read_bytes", self.tools.max_read_bytes),
429            ("tools.max_write_bytes", self.tools.max_write_bytes),
430            (
431                "protocol.max_client_frame_bytes",
432                self.protocol.max_client_frame_bytes,
433            ),
434            (
435                "protocol.max_server_frame_bytes",
436                self.protocol.max_server_frame_bytes,
437            ),
438            ("tui.max_transcript_bytes", self.tui.max_transcript_bytes),
439            ("tui.max_transcript_items", self.tui.max_transcript_items),
440            (
441                "tui.max_prompt_history_bytes",
442                self.tui.max_prompt_history_bytes,
443            ),
444            (
445                "tui.max_prompt_history_items",
446                self.tui.max_prompt_history_items,
447            ),
448            (
449                "provider_limits.max_sse_event_bytes",
450                self.provider_limits.max_sse_event_bytes,
451            ),
452            (
453                "provider_limits.max_response_bytes",
454                self.provider_limits.max_response_bytes,
455            ),
456            (
457                "provider_limits.max_assistant_bytes",
458                self.provider_limits.max_assistant_bytes,
459            ),
460            (
461                "provider_limits.max_tool_calls",
462                self.provider_limits.max_tool_calls,
463            ),
464            (
465                "provider_limits.max_tool_arguments_bytes",
466                self.provider_limits.max_tool_arguments_bytes,
467            ),
468            ("skills.max_skills", self.skills.max_skills),
469            ("skills.max_skill_bytes", self.skills.max_skill_bytes),
470        ];
471        if let Some((name, _)) = positives.into_iter().find(|(_, value)| *value == 0) {
472            bail!("{name} must be positive");
473        }
474        if self
475            .context
476            .reserve_output_tokens
477            .saturating_add(self.context.safety_margin_tokens)
478            >= self.context.max_tokens
479        {
480            bail!("context reserve and safety margin consume max_tokens");
481        }
482        let worst_assistant_frame = self
483            .provider_limits
484            .max_assistant_bytes
485            .saturating_mul(6)
486            .saturating_add(64 * 1024);
487        if worst_assistant_frame > self.protocol.max_server_frame_bytes {
488            bail!(
489                "provider_limits.max_assistant_bytes can exceed protocol.max_server_frame_bytes after JSON escaping"
490            );
491        }
492        if self.provider_limits.max_tool_arguments_bytes > self.provider_limits.max_response_bytes {
493            bail!("tool argument limit exceeds provider response limit");
494        }
495        if self.provider_limits.max_sse_event_bytes > self.provider_limits.max_response_bytes {
496            bail!("provider SSE event limit exceeds provider response limit");
497        }
498        if self.protocol.max_client_frame_bytes < 4096 {
499            bail!("protocol.max_client_frame_bytes must be at least 4096");
500        }
501        if self.protocol.max_server_frame_bytes < 64 * 1024 {
502            bail!("protocol.max_server_frame_bytes must be at least 65536");
503        }
504        let worst_tool_frame = self
505            .tools
506            .output_limit_bytes
507            .max(self.tools.max_read_bytes)
508            .saturating_mul(12)
509            .saturating_add(64 * 1024);
510        let worst_skill_frame = self
511            .skills
512            .max_skill_bytes
513            .saturating_mul(6)
514            .saturating_add(64 * 1024);
515        let worst_arguments_frame = self
516            .provider_limits
517            .max_tool_arguments_bytes
518            .saturating_mul(6)
519            .saturating_add(64 * 1024);
520        if worst_tool_frame
521            .max(worst_skill_frame)
522            .max(worst_arguments_frame)
523            > self.protocol.max_server_frame_bytes
524        {
525            bail!(
526                "tool or skill limits can exceed protocol.max_server_frame_bytes after JSON escaping"
527            );
528        }
529        if self.skills.project_dir.is_absolute()
530            || self
531                .skills
532                .project_dir
533                .components()
534                .any(|component| matches!(component, std::path::Component::ParentDir))
535        {
536            bail!("skills.project_dir must be a contained relative path");
537        }
538        Ok(())
539    }
540}
541
542fn user_config_path() -> Option<PathBuf> {
543    std::env::var_os("XDG_CONFIG_HOME")
544        .map(PathBuf::from)
545        .or_else(|| dirs::home_dir().map(|path| path.join(".config")))
546        .map(|path| path.join("peon/config.toml"))
547}
548
549fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
550    let size = std::fs::metadata(path)
551        .with_context(|| format!("stat configuration {}", path.display()))?
552        .len();
553    if size > MAX_CONFIG_BYTES {
554        bail!("configuration {} exceeds 1 MiB", path.display());
555    }
556    let content = std::fs::read_to_string(path)
557        .with_context(|| format!("read configuration {}", path.display()))?;
558    toml::from_str(&content).with_context(|| format!("parse configuration {}", path.display()))
559}
560
561fn merge(base: &mut toml::Value, overlay: toml::Value) {
562    match (base, overlay) {
563        (toml::Value::Table(base), toml::Value::Table(overlay)) => {
564            for (key, value) in overlay {
565                match base.get_mut(&key) {
566                    Some(existing) => merge(existing, value),
567                    None => {
568                        base.insert(key, value);
569                    }
570                }
571            }
572        }
573        (base, overlay) => *base = overlay,
574    }
575}
576
577fn validate_project_keys(value: &toml::Value) -> Result<()> {
578    let Some(table) = value.as_table() else {
579        bail!("project configuration must be a TOML table");
580    };
581    for forbidden in ["provider", "agents"] {
582        if table.contains_key(forbidden) {
583            bail!("project configuration cannot set [{forbidden}]");
584        }
585    }
586    if table
587        .get("skills")
588        .and_then(toml::Value::as_table)
589        .is_some_and(|skills| skills.contains_key("user_dir"))
590    {
591        bail!("project configuration cannot set skills.user_dir");
592    }
593    if table
594        .get("agent")
595        .and_then(toml::Value::as_table)
596        .is_some_and(|agent| agent.contains_key("system_prompt"))
597    {
598        bail!("project configuration cannot replace agent.system_prompt");
599    }
600    Ok(())
601}
602
603fn validate_project_not_weaker(user: &Config, project: &Config) -> Result<()> {
604    macro_rules! no_larger {
605        ($field:expr, $name:literal) => {
606            if $field.1 > $field.0 {
607                bail!(concat!("project configuration cannot raise ", $name));
608            }
609        };
610    }
611    no_larger!(
612        (user.agent.max_steps, project.agent.max_steps),
613        "agent.max_steps"
614    );
615    no_larger!(
616        (
617            user.session.max_history_bytes,
618            project.session.max_history_bytes
619        ),
620        "session.max_history_bytes"
621    );
622    no_larger!(
623        (user.session.max_messages, project.session.max_messages),
624        "session.max_messages"
625    );
626    no_larger!(
627        (user.context.max_tokens, project.context.max_tokens),
628        "context.max_tokens"
629    );
630    no_larger!(
631        (
632            user.context.summary_max_chars,
633            project.context.summary_max_chars
634        ),
635        "context.summary_max_chars"
636    );
637    no_larger!(
638        (
639            user.tools.command_timeout_seconds,
640            project.tools.command_timeout_seconds
641        ),
642        "tools.command_timeout_seconds"
643    );
644    no_larger!(
645        (
646            user.tools.output_limit_bytes,
647            project.tools.output_limit_bytes
648        ),
649        "tools.output_limit_bytes"
650    );
651    no_larger!(
652        (user.tools.max_read_bytes, project.tools.max_read_bytes),
653        "tools.max_read_bytes"
654    );
655    no_larger!(
656        (user.tools.max_write_bytes, project.tools.max_write_bytes),
657        "tools.max_write_bytes"
658    );
659    no_larger!(
660        (
661            user.protocol.max_client_frame_bytes,
662            project.protocol.max_client_frame_bytes
663        ),
664        "protocol.max_client_frame_bytes"
665    );
666    no_larger!(
667        (
668            user.protocol.max_server_frame_bytes,
669            project.protocol.max_server_frame_bytes
670        ),
671        "protocol.max_server_frame_bytes"
672    );
673    no_larger!(
674        (
675            user.provider_limits.max_response_bytes,
676            project.provider_limits.max_response_bytes
677        ),
678        "provider_limits.max_response_bytes"
679    );
680    no_larger!(
681        (
682            user.provider_limits.max_sse_event_bytes,
683            project.provider_limits.max_sse_event_bytes
684        ),
685        "provider_limits.max_sse_event_bytes"
686    );
687    no_larger!(
688        (
689            user.provider_limits.max_assistant_bytes,
690            project.provider_limits.max_assistant_bytes
691        ),
692        "provider_limits.max_assistant_bytes"
693    );
694    no_larger!(
695        (
696            user.provider_limits.max_tool_calls,
697            project.provider_limits.max_tool_calls
698        ),
699        "provider_limits.max_tool_calls"
700    );
701    no_larger!(
702        (
703            user.provider_limits.max_tool_arguments_bytes,
704            project.provider_limits.max_tool_arguments_bytes
705        ),
706        "provider_limits.max_tool_arguments_bytes"
707    );
708    no_larger!(
709        (
710            user.tui.max_transcript_bytes,
711            project.tui.max_transcript_bytes
712        ),
713        "tui.max_transcript_bytes"
714    );
715    no_larger!(
716        (
717            user.tui.max_transcript_items,
718            project.tui.max_transcript_items
719        ),
720        "tui.max_transcript_items"
721    );
722    no_larger!(
723        (
724            user.tui.max_prompt_history_bytes,
725            project.tui.max_prompt_history_bytes
726        ),
727        "tui.max_prompt_history_bytes"
728    );
729    no_larger!(
730        (
731            user.tui.max_prompt_history_items,
732            project.tui.max_prompt_history_items
733        ),
734        "tui.max_prompt_history_items"
735    );
736    no_larger!(
737        (user.skills.max_skills, project.skills.max_skills),
738        "skills.max_skills"
739    );
740    no_larger!(
741        (user.skills.max_skill_bytes, project.skills.max_skill_bytes),
742        "skills.max_skill_bytes"
743    );
744    if project.context.reserve_output_tokens < user.context.reserve_output_tokens
745        || project.context.safety_margin_tokens < user.context.safety_margin_tokens
746    {
747        bail!("project configuration cannot lower context reserves");
748    }
749    if project.context.bytes_per_token > user.context.bytes_per_token {
750        bail!("project configuration cannot raise context.bytes_per_token");
751    }
752    if project.tools.approval_policy.strictness() < user.tools.approval_policy.strictness() {
753        bail!("project configuration cannot weaken tools.approval_policy");
754    }
755    Ok(())
756}
757
758fn expand_home(path: &std::path::Path) -> PathBuf {
759    let value = path.to_string_lossy();
760    if value == "~" {
761        return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
762    }
763    if let Some(rest) = value.strip_prefix("~/")
764        && let Some(home) = dirs::home_dir()
765    {
766        return home.join(rest);
767    }
768    path.to_path_buf()
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    #[test]
776    fn project_cannot_redirect_provider_or_agent() {
777        let provider: toml::Value = toml::from_str(
778            r#"[provider]
779base_url = "https://attacker.invalid"
780"#,
781        )
782        .unwrap();
783        assert!(validate_project_keys(&provider).is_err());
784
785        let agent: toml::Value = toml::from_str(
786            r#"[agents.codex]
787command = "/tmp/fake"
788"#,
789        )
790        .unwrap();
791        assert!(validate_project_keys(&agent).is_err());
792    }
793
794    #[test]
795    fn project_may_tighten_but_not_weaken_limits() {
796        let user = Config::default();
797        let mut tighter = user.clone();
798        tighter.tools.output_limit_bytes /= 2;
799        tighter.tools.approval_policy = ApprovalPolicy::Always;
800        assert!(validate_project_not_weaker(&user, &tighter).is_ok());
801
802        let mut weaker = user.clone();
803        weaker.tools.output_limit_bytes *= 2;
804        assert!(validate_project_not_weaker(&user, &weaker).is_err());
805    }
806
807    #[test]
808    fn cross_field_validation_accounts_for_json_escaping() {
809        let mut config = Config::default();
810        config.protocol.max_server_frame_bytes = config.provider_limits.max_assistant_bytes;
811        assert!(config.validate().is_err());
812    }
813}