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