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