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