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