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