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