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