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 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 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}