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    pub no_tools: bool,
305}
306
307impl Config {
308    pub fn load(workspace: &std::path::Path, overrides: ConfigOverrides) -> Result<Self> {
309        let mut value: toml::Value = toml::from_str(
310            &toml::to_string(&Self::default()).context("serialize default configuration")?,
311        )?;
312
313        if let Some(user_path) = user_config_path()
314            && user_path.is_file()
315        {
316            #[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"); } }
317            merge(&mut value, read_layer(&user_path)?);
318        }
319        let user_baseline: Self = value
320            .clone()
321            .try_into()
322            .context("parse user configuration")?;
323
324        let project_path = workspace.join(".scv/config.toml");
325        if project_path.is_file() {
326            let canonical_project = std::fs::canonicalize(&project_path)
327                .with_context(|| format!("resolve configuration {}", project_path.display()))?;
328            if !canonical_project.starts_with(workspace) {
329                bail!("project configuration escaped workspace");
330            }
331            let project = read_layer(&canonical_project)?;
332            validate_project_keys(&project)?;
333            let mut candidate_value = value.clone();
334            merge(&mut candidate_value, project);
335            let candidate: Self = candidate_value
336                .clone()
337                .try_into()
338                .context("parse project configuration")?;
339            validate_project_not_weaker(&user_baseline, &candidate)?;
340            value = candidate_value;
341        }
342
343        if let Some(explicit) = std::env::var_os("SCV_CONFIG") {
344            let path = PathBuf::from(explicit);
345            merge(&mut value, read_layer(&path)?);
346        }
347        let mut config: Self = value.try_into().context("parse merged configuration")?;
348        if let Some(name) = overrides.provider.as_deref() { config.provider_active = Some(name.to_owned()); }
349        let selected = config.active_provider()?;
350        config.provider = selected;
351        if let Ok(model) = std::env::var("SCV_MODEL") {
352            config.provider.model = model;
353        }
354        if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
355            config.provider.base_url = base_url;
356        }
357        if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
358            config.provider.api_key_env = Some(api_key_env);
359        }
360        if let Some(model) = overrides.model {
361            config.provider.model = model;
362        }
363        if let Some(base_url) = overrides.base_url {
364            config.provider.base_url = base_url;
365        }
366        if let Some(policy) = overrides.approval_policy {
367            config.tools.approval_policy = policy;
368        }
369        if config.skills.user_dir == PathBuf::from("~/.scv/skills") {
370            if let Some(home) = std::env::var_os("SCV_HOME") { config.skills.user_dir = PathBuf::from(home).join("skills"); }
371        }
372        config.skills.user_dir = expand_home(&config.skills.user_dir);
373        config.validate()?;
374        Ok(config)
375    }
376
377    pub fn core_agent(&self, system_prompt: String) -> CoreAgentConfig {
378        CoreAgentConfig {
379            system_prompt,
380            max_steps: self.agent.max_steps,
381            history_limits: HistoryLimits {
382                max_bytes: self.session.max_history_bytes,
383                max_messages: self.session.max_messages,
384                note_max_chars: self.context.summary_max_chars,
385            },
386        }
387    }
388
389    pub fn tools(&self) -> ToolsConfig {
390        ToolsConfig {
391            command_timeout: Duration::from_secs(self.tools.command_timeout_seconds),
392            output_limit_bytes: self.tools.output_limit_bytes,
393            max_read_bytes: self.tools.max_read_bytes,
394            max_write_bytes: self.tools.max_write_bytes,
395        }
396    }
397
398    pub fn provider_limits(&self) -> ProviderLimits {
399        ProviderLimits {
400            max_sse_event_bytes: self.provider_limits.max_sse_event_bytes,
401            max_response_bytes: self.provider_limits.max_response_bytes,
402            max_assistant_bytes: self.provider_limits.max_assistant_bytes,
403            max_tool_calls: self.provider_limits.max_tool_calls,
404            max_tool_arguments_bytes: self.provider_limits.max_tool_arguments_bytes,
405        }
406    }
407
408    pub fn adapters(&self) -> HashMap<String, AgentAdapterConfig> {
409        [
410            ("agent_claude", &self.agents.claude),
411            ("agent_codex", &self.agents.codex),
412            ("agent_pi", &self.agents.pi),
413        ]
414        .into_iter()
415        .map(|(name, config)| {
416            (
417                name.to_owned(),
418                AgentAdapterConfig {
419                    command: config.command.clone(),
420                    args: config.args.clone(),
421                },
422            )
423        })
424        .collect()
425    }
426
427    fn validate(&self) -> Result<()> {
428        if self.provider.kind != "openai-compatible" {
429            bail!("provider.kind must be openai-compatible in v0.1");
430        }
431        if self.provider.model.trim().is_empty()
432            || self.provider.base_url.trim().is_empty()
433            || self.provider.api_key.as_deref().unwrap_or("").trim().is_empty()
434                && self.provider.api_key_env.as_deref().unwrap_or("").trim().is_empty()
435        {
436            bail!("provider model and base_url must be non-empty; configure api_key or api_key_env");
437        }
438        for (name, adapter) in [
439            ("agents.claude.command", &self.agents.claude),
440            ("agents.codex.command", &self.agents.codex),
441            ("agents.pi.command", &self.agents.pi),
442        ] {
443            if adapter.command.trim().is_empty() {
444                bail!("{name} must be non-empty");
445            }
446            let adapter_bytes =
447                adapter.command.len() + adapter.args.iter().map(String::len).sum::<usize>();
448            if adapter_bytes > 16 * 1024 {
449                bail!("{name} and its fixed arguments exceed 16384 bytes");
450            }
451        }
452        let positives = [
453            (
454                "provider.timeout_seconds",
455                usize::try_from(self.provider.timeout_seconds).unwrap_or(usize::MAX),
456            ),
457            ("agent.max_steps", self.agent.max_steps),
458            ("session.max_history_bytes", self.session.max_history_bytes),
459            ("session.max_messages", self.session.max_messages),
460            ("context.max_tokens", self.context.max_tokens),
461            ("context.bytes_per_token", self.context.bytes_per_token),
462            ("context.summary_max_chars", self.context.summary_max_chars),
463            (
464                "tools.command_timeout_seconds",
465                usize::try_from(self.tools.command_timeout_seconds).unwrap_or(usize::MAX),
466            ),
467            ("tools.output_limit_bytes", self.tools.output_limit_bytes),
468            ("tools.max_read_bytes", self.tools.max_read_bytes),
469            ("tools.max_write_bytes", self.tools.max_write_bytes),
470            (
471                "protocol.max_client_frame_bytes",
472                self.protocol.max_client_frame_bytes,
473            ),
474            (
475                "protocol.max_server_frame_bytes",
476                self.protocol.max_server_frame_bytes,
477            ),
478            ("tui.max_transcript_bytes", self.tui.max_transcript_bytes),
479            ("tui.max_transcript_items", self.tui.max_transcript_items),
480            (
481                "tui.max_prompt_history_bytes",
482                self.tui.max_prompt_history_bytes,
483            ),
484            (
485                "tui.max_prompt_history_items",
486                self.tui.max_prompt_history_items,
487            ),
488            (
489                "provider_limits.max_sse_event_bytes",
490                self.provider_limits.max_sse_event_bytes,
491            ),
492            (
493                "provider_limits.max_response_bytes",
494                self.provider_limits.max_response_bytes,
495            ),
496            (
497                "provider_limits.max_assistant_bytes",
498                self.provider_limits.max_assistant_bytes,
499            ),
500            (
501                "provider_limits.max_tool_calls",
502                self.provider_limits.max_tool_calls,
503            ),
504            (
505                "provider_limits.max_tool_arguments_bytes",
506                self.provider_limits.max_tool_arguments_bytes,
507            ),
508            ("skills.max_skills", self.skills.max_skills),
509            ("skills.max_skill_bytes", self.skills.max_skill_bytes),
510        ];
511        if let Some((name, _)) = positives.into_iter().find(|(_, value)| *value == 0) {
512            bail!("{name} must be positive");
513        }
514        if self
515            .context
516            .reserve_output_tokens
517            .saturating_add(self.context.safety_margin_tokens)
518            >= self.context.max_tokens
519        {
520            bail!("context reserve and safety margin consume max_tokens");
521        }
522        let worst_assistant_frame = self
523            .provider_limits
524            .max_assistant_bytes
525            .saturating_mul(6)
526            .saturating_add(64 * 1024);
527        if worst_assistant_frame > self.protocol.max_server_frame_bytes {
528            bail!(
529                "provider_limits.max_assistant_bytes can exceed protocol.max_server_frame_bytes after JSON escaping"
530            );
531        }
532        if self.provider_limits.max_tool_arguments_bytes > self.provider_limits.max_response_bytes {
533            bail!("tool argument limit exceeds provider response limit");
534        }
535        if self.provider_limits.max_sse_event_bytes > self.provider_limits.max_response_bytes {
536            bail!("provider SSE event limit exceeds provider response limit");
537        }
538        if self.protocol.max_client_frame_bytes < 4096 {
539            bail!("protocol.max_client_frame_bytes must be at least 4096");
540        }
541        if self.protocol.max_server_frame_bytes < 64 * 1024 {
542            bail!("protocol.max_server_frame_bytes must be at least 65536");
543        }
544        let worst_tool_frame = self
545            .tools
546            .output_limit_bytes
547            .max(self.tools.max_read_bytes)
548            .saturating_mul(12)
549            .saturating_add(64 * 1024);
550        let worst_skill_frame = self
551            .skills
552            .max_skill_bytes
553            .saturating_mul(6)
554            .saturating_add(64 * 1024);
555        let worst_arguments_frame = self
556            .provider_limits
557            .max_tool_arguments_bytes
558            .saturating_mul(6)
559            .saturating_add(64 * 1024);
560        if worst_tool_frame
561            .max(worst_skill_frame)
562            .max(worst_arguments_frame)
563            > self.protocol.max_server_frame_bytes
564        {
565            bail!(
566                "tool or skill limits can exceed protocol.max_server_frame_bytes after JSON escaping"
567            );
568        }
569        if self.skills.project_dir.is_absolute()
570            || self
571                .skills
572                .project_dir
573                .components()
574                .any(|component| matches!(component, std::path::Component::ParentDir))
575        {
576            bail!("skills.project_dir must be a contained relative path");
577        }
578        Ok(())
579    }
580}
581
582fn user_config_path() -> Option<PathBuf> {
583    std::env::var_os("SCV_HOME").map(PathBuf::from)
584        .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))
585        .map(|path| path.join("config.toml"))
586}
587
588fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
589    let size = std::fs::metadata(path)
590        .with_context(|| format!("stat configuration {}", path.display()))?
591        .len();
592    if size > MAX_CONFIG_BYTES {
593        bail!("configuration {} exceeds 1 MiB", path.display());
594    }
595    let content = std::fs::read_to_string(path)
596        .with_context(|| format!("read configuration {}", path.display()))?;
597    toml::from_str(&content).with_context(|| format!("parse configuration {}", path.display()))
598}
599
600fn merge(base: &mut toml::Value, overlay: toml::Value) {
601    match (base, overlay) {
602        (toml::Value::Table(base), toml::Value::Table(overlay)) => {
603            for (key, value) in overlay {
604                match base.get_mut(&key) {
605                    Some(existing) => merge(existing, value),
606                    None => {
607                        base.insert(key, value);
608                    }
609                }
610            }
611        }
612        (base, overlay) => *base = overlay,
613    }
614}
615
616fn validate_project_keys(value: &toml::Value) -> Result<()> {
617    let Some(table) = value.as_table() else {
618        bail!("project configuration must be a TOML table");
619    };
620    for forbidden in ["provider", "providers", "provider_active", "agents"] {
621        if table.contains_key(forbidden) {
622            bail!("project configuration cannot set [{forbidden}]");
623        }
624    }
625    if table
626        .get("skills")
627        .and_then(toml::Value::as_table)
628        .is_some_and(|skills| skills.contains_key("user_dir"))
629    {
630        bail!("project configuration cannot set skills.user_dir");
631    }
632    if table
633        .get("agent")
634        .and_then(toml::Value::as_table)
635        .is_some_and(|agent| agent.contains_key("system_prompt"))
636    {
637        bail!("project configuration cannot replace agent.system_prompt");
638    }
639    Ok(())
640}
641
642fn validate_project_not_weaker(user: &Config, project: &Config) -> Result<()> {
643    macro_rules! no_larger {
644        ($field:expr, $name:literal) => {
645            if $field.1 > $field.0 {
646                bail!(concat!("project configuration cannot raise ", $name));
647            }
648        };
649    }
650    no_larger!(
651        (user.agent.max_steps, project.agent.max_steps),
652        "agent.max_steps"
653    );
654    no_larger!(
655        (
656            user.session.max_history_bytes,
657            project.session.max_history_bytes
658        ),
659        "session.max_history_bytes"
660    );
661    no_larger!(
662        (user.session.max_messages, project.session.max_messages),
663        "session.max_messages"
664    );
665    no_larger!(
666        (user.context.max_tokens, project.context.max_tokens),
667        "context.max_tokens"
668    );
669    no_larger!(
670        (
671            user.context.summary_max_chars,
672            project.context.summary_max_chars
673        ),
674        "context.summary_max_chars"
675    );
676    no_larger!(
677        (
678            user.tools.command_timeout_seconds,
679            project.tools.command_timeout_seconds
680        ),
681        "tools.command_timeout_seconds"
682    );
683    no_larger!(
684        (
685            user.tools.output_limit_bytes,
686            project.tools.output_limit_bytes
687        ),
688        "tools.output_limit_bytes"
689    );
690    no_larger!(
691        (user.tools.max_read_bytes, project.tools.max_read_bytes),
692        "tools.max_read_bytes"
693    );
694    no_larger!(
695        (user.tools.max_write_bytes, project.tools.max_write_bytes),
696        "tools.max_write_bytes"
697    );
698    no_larger!(
699        (
700            user.protocol.max_client_frame_bytes,
701            project.protocol.max_client_frame_bytes
702        ),
703        "protocol.max_client_frame_bytes"
704    );
705    no_larger!(
706        (
707            user.protocol.max_server_frame_bytes,
708            project.protocol.max_server_frame_bytes
709        ),
710        "protocol.max_server_frame_bytes"
711    );
712    no_larger!(
713        (
714            user.provider_limits.max_response_bytes,
715            project.provider_limits.max_response_bytes
716        ),
717        "provider_limits.max_response_bytes"
718    );
719    no_larger!(
720        (
721            user.provider_limits.max_sse_event_bytes,
722            project.provider_limits.max_sse_event_bytes
723        ),
724        "provider_limits.max_sse_event_bytes"
725    );
726    no_larger!(
727        (
728            user.provider_limits.max_assistant_bytes,
729            project.provider_limits.max_assistant_bytes
730        ),
731        "provider_limits.max_assistant_bytes"
732    );
733    no_larger!(
734        (
735            user.provider_limits.max_tool_calls,
736            project.provider_limits.max_tool_calls
737        ),
738        "provider_limits.max_tool_calls"
739    );
740    no_larger!(
741        (
742            user.provider_limits.max_tool_arguments_bytes,
743            project.provider_limits.max_tool_arguments_bytes
744        ),
745        "provider_limits.max_tool_arguments_bytes"
746    );
747    no_larger!(
748        (
749            user.tui.max_transcript_bytes,
750            project.tui.max_transcript_bytes
751        ),
752        "tui.max_transcript_bytes"
753    );
754    no_larger!(
755        (
756            user.tui.max_transcript_items,
757            project.tui.max_transcript_items
758        ),
759        "tui.max_transcript_items"
760    );
761    no_larger!(
762        (
763            user.tui.max_prompt_history_bytes,
764            project.tui.max_prompt_history_bytes
765        ),
766        "tui.max_prompt_history_bytes"
767    );
768    no_larger!(
769        (
770            user.tui.max_prompt_history_items,
771            project.tui.max_prompt_history_items
772        ),
773        "tui.max_prompt_history_items"
774    );
775    no_larger!(
776        (user.skills.max_skills, project.skills.max_skills),
777        "skills.max_skills"
778    );
779    no_larger!(
780        (user.skills.max_skill_bytes, project.skills.max_skill_bytes),
781        "skills.max_skill_bytes"
782    );
783    if project.context.reserve_output_tokens < user.context.reserve_output_tokens
784        || project.context.safety_margin_tokens < user.context.safety_margin_tokens
785    {
786        bail!("project configuration cannot lower context reserves");
787    }
788    if project.context.bytes_per_token > user.context.bytes_per_token {
789        bail!("project configuration cannot raise context.bytes_per_token");
790    }
791    if project.tools.approval_policy.strictness() < user.tools.approval_policy.strictness() {
792        bail!("project configuration cannot weaken tools.approval_policy");
793    }
794    Ok(())
795}
796
797fn expand_home(path: &std::path::Path) -> PathBuf {
798    let value = path.to_string_lossy();
799    if value == "~" {
800        return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
801    }
802    if let Some(rest) = value.strip_prefix("~/")
803        && let Some(home) = dirs::home_dir()
804    {
805        return home.join(rest);
806    }
807    path.to_path_buf()
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813
814    #[test]
815    fn project_cannot_redirect_provider_or_agent() {
816        let provider: toml::Value = toml::from_str(
817            r#"[provider]
818base_url = "https://attacker.invalid"
819"#,
820        )
821        .unwrap();
822        assert!(validate_project_keys(&provider).is_err());
823
824        let agent: toml::Value = toml::from_str(
825            r#"[agents.codex]
826command = "/tmp/fake"
827"#,
828        )
829        .unwrap();
830        assert!(validate_project_keys(&agent).is_err());
831    }
832
833    #[test]
834    fn project_may_tighten_but_not_weaken_limits() {
835        let user = Config::default();
836        let mut tighter = user.clone();
837        tighter.tools.output_limit_bytes /= 2;
838        tighter.tools.approval_policy = ApprovalPolicy::Always;
839        assert!(validate_project_not_weaker(&user, &tighter).is_ok());
840
841        let mut weaker = user.clone();
842        weaker.tools.output_limit_bytes *= 2;
843        assert!(validate_project_not_weaker(&user, &weaker).is_err());
844    }
845
846    #[test]
847    fn cross_field_validation_accounts_for_json_escaping() {
848        let mut config = Config::default();
849        config.protocol.max_server_frame_bytes = config.provider_limits.max_assistant_bytes;
850        assert!(config.validate().is_err());
851    }
852}