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 let instance_home = user_home_path()
366 .ok_or_else(|| anyhow::anyhow!("cannot determine SCV instance home"))?;
367 std::fs::create_dir_all(&instance_home).context("create SCV instance home")?;
368 ensure_private_dir(&instance_home)?;
369 let mut value: toml::Value = toml::from_str(
370 &toml::to_string(&Self::default()).context("serialize default configuration")?,
371 )?;
372
373 if let Some(user_path) = user_config_path()
374 && user_path.is_file()
375 {
376 #[cfg(unix)]
377 {
378 use std::os::unix::fs::PermissionsExt;
379 if std::fs::metadata(&user_path)?.permissions().mode() & 0o077 != 0 {
380 bail!("user configuration is readable by group or others; run chmod 600");
381 }
382 }
383 merge(&mut value, read_layer(&user_path)?);
384 }
385 let user_baseline: Self = value
386 .clone()
387 .try_into()
388 .context("parse user configuration")?;
389
390 let project_path = workspace.join(".scv/config.toml");
391 if project_path.is_file() {
392 let canonical_project = std::fs::canonicalize(&project_path)
393 .with_context(|| format!("resolve configuration {}", project_path.display()))?;
394 if !canonical_project.starts_with(workspace) {
395 bail!("project configuration escaped workspace");
396 }
397 let project = read_layer(&canonical_project)?;
398 validate_project_keys(&project)?;
399 let mut candidate_value = value.clone();
400 merge(&mut candidate_value, project);
401 let candidate: Self = candidate_value
402 .clone()
403 .try_into()
404 .context("parse project configuration")?;
405 validate_project_not_weaker(&user_baseline, &candidate)?;
406 value = candidate_value;
407 }
408
409 if let Some(explicit) = std::env::var_os("SCV_CONFIG") {
410 let path = PathBuf::from(explicit);
411 #[cfg(unix)]
412 {
413 use std::os::unix::fs::PermissionsExt;
414 if std::fs::metadata(&path)?.permissions().mode() & 0o077 != 0 {
415 bail!("explicit configuration is readable by group or others; run chmod 600");
416 }
417 }
418 merge(&mut value, read_layer(&path)?);
419 }
420 let mut config: Self = value.try_into().context("parse merged configuration")?;
421 if let Some(name) = overrides.provider.as_deref() {
422 config.provider_active = Some(name.to_owned());
423 }
424 let selected = config.active_provider()?;
425 config.provider = selected;
426 if let Ok(model) = std::env::var("SCV_MODEL") {
427 config.provider.model = model;
428 }
429 if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
430 config.provider.base_url = base_url;
431 }
432 if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
433 config.provider.api_key_env = Some(api_key_env);
434 }
435 if let Some(model) = overrides.model {
436 config.provider.model = model;
437 }
438 if let Some(base_url) = overrides.base_url {
439 config.provider.base_url = base_url;
440 }
441 if let Some(policy) = overrides.approval_policy {
442 config.tools.approval_policy = policy;
443 }
444 if config.skills.user_dir == std::path::Path::new("~/.scv/skills")
445 && let Some(home) = std::env::var_os("SCV_HOME")
446 {
447 config.skills.user_dir = PathBuf::from(home).join("skills");
448 }
449 config.skills.user_dir = expand_home(&config.skills.user_dir);
450 config.instance_home = instance_home;
451 config.validate()?;
452 Ok(config)
453 }
454
455 pub fn core_agent(&self, system_prompt: String) -> CoreAgentConfig {
456 CoreAgentConfig {
457 system_prompt,
458 max_steps: self.agent.max_steps,
459 history_limits: HistoryLimits {
460 max_bytes: self.session.max_history_bytes,
461 max_messages: self.session.max_messages,
462 note_max_chars: self.context.summary_max_chars,
463 },
464 }
465 }
466
467 pub fn tools(&self) -> ToolsConfig {
468 ToolsConfig {
469 command_timeout: Duration::from_secs(self.tools.command_timeout_seconds),
470 output_limit_bytes: self.tools.output_limit_bytes,
471 max_read_bytes: self.tools.max_read_bytes,
472 max_write_bytes: self.tools.max_write_bytes,
473 }
474 }
475
476 pub fn provider_limits(&self) -> ProviderLimits {
477 ProviderLimits {
478 max_sse_event_bytes: self.provider_limits.max_sse_event_bytes,
479 max_response_bytes: self.provider_limits.max_response_bytes,
480 max_assistant_bytes: self.provider_limits.max_assistant_bytes,
481 max_tool_calls: self.provider_limits.max_tool_calls,
482 max_tool_arguments_bytes: self.provider_limits.max_tool_arguments_bytes,
483 }
484 }
485
486 pub fn adapters(&self) -> HashMap<String, AgentAdapterConfig> {
487 [
488 ("agent_claude", &self.agents.claude),
489 ("agent_codex", &self.agents.codex),
490 ("agent_pi", &self.agents.pi),
491 ]
492 .into_iter()
493 .map(|(name, config)| {
494 let adapter_name = name.strip_prefix("agent_").unwrap_or(name);
495 let adapter_home = self.instance_home.join("adapters").join(adapter_name);
496 let mut environment = vec![
497 (OsString::from("SCV_HOME"), adapter_home.clone().into()),
498 (OsString::from("HOME"), adapter_home.clone().into()),
499 (
500 OsString::from("XDG_CONFIG_HOME"),
501 adapter_home.join("config").into(),
502 ),
503 (
504 OsString::from("XDG_DATA_HOME"),
505 adapter_home.join("data").into(),
506 ),
507 (
508 OsString::from("XDG_STATE_HOME"),
509 adapter_home.join("state").into(),
510 ),
511 ];
512 if adapter_name == "codex" {
513 environment.push((OsString::from("CODEX_HOME"), adapter_home.clone().into()));
514 }
515 (
516 name.to_owned(),
517 AgentAdapterConfig {
518 command: config.command.clone(),
519 args: config.args.clone(),
520 model_args: config.model_args.clone(),
521 effort_args: config.effort_args.clone(),
522 environment,
523 },
524 )
525 })
526 .collect()
527 }
528
529 pub fn prepare_adapter_homes(&self) -> Result<()> {
530 for name in ["claude", "codex", "pi"] {
531 let path = self.instance_home.join("adapters").join(name);
532 std::fs::create_dir_all(&path)
533 .with_context(|| format!("create isolated {name} adapter home"))?;
534 #[cfg(unix)]
535 {
536 use std::os::unix::fs::PermissionsExt;
537 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
538 .with_context(|| format!("secure isolated {name} adapter home"))?;
539 }
540 }
541 Ok(())
542 }
543
544 fn validate(&self) -> Result<()> {
545 if self.provider.kind != "openai-compatible" {
546 bail!("provider.kind must be openai-compatible in v0.1");
547 }
548 if self.provider.model.trim().is_empty()
549 || self.provider.base_url.trim().is_empty()
550 || self
551 .provider
552 .api_key
553 .as_deref()
554 .unwrap_or("")
555 .trim()
556 .is_empty()
557 && self
558 .provider
559 .api_key_env
560 .as_deref()
561 .unwrap_or("")
562 .trim()
563 .is_empty()
564 {
565 bail!(
566 "provider model and base_url must be non-empty; configure api_key or api_key_env"
567 );
568 }
569 for (name, adapter) in [
570 ("agents.claude.command", &self.agents.claude),
571 ("agents.codex.command", &self.agents.codex),
572 ("agents.pi.command", &self.agents.pi),
573 ] {
574 if adapter.command.trim().is_empty() {
575 bail!("{name} must be non-empty");
576 }
577 for (field, template, placeholder) in [
578 ("model_args", &adapter.model_args, "{model}"),
579 ("effort_args", &adapter.effort_args, "{effort}"),
580 ] {
581 if !template.is_empty() && !template.iter().any(|arg| arg.contains(placeholder)) {
582 let adapter = name.trim_end_matches(".command");
583 bail!("{adapter}.{field} must contain {placeholder} or be empty");
584 }
585 }
586 let adapter_bytes = adapter.command.len()
587 + [&adapter.args, &adapter.model_args, &adapter.effort_args]
588 .into_iter()
589 .flatten()
590 .map(String::len)
591 .sum::<usize>();
592 if adapter_bytes > 16 * 1024 {
593 bail!("{name} and its fixed arguments exceed 16384 bytes");
594 }
595 }
596 let positives = [
597 (
598 "provider.timeout_seconds",
599 usize::try_from(self.provider.timeout_seconds).unwrap_or(usize::MAX),
600 ),
601 ("agent.max_steps", self.agent.max_steps),
602 ("session.max_history_bytes", self.session.max_history_bytes),
603 ("session.max_messages", self.session.max_messages),
604 ("context.max_tokens", self.context.max_tokens),
605 ("context.bytes_per_token", self.context.bytes_per_token),
606 ("context.summary_max_chars", self.context.summary_max_chars),
607 (
608 "tools.command_timeout_seconds",
609 usize::try_from(self.tools.command_timeout_seconds).unwrap_or(usize::MAX),
610 ),
611 ("tools.output_limit_bytes", self.tools.output_limit_bytes),
612 ("tools.max_read_bytes", self.tools.max_read_bytes),
613 ("tools.max_write_bytes", self.tools.max_write_bytes),
614 (
615 "protocol.max_client_frame_bytes",
616 self.protocol.max_client_frame_bytes,
617 ),
618 (
619 "protocol.max_server_frame_bytes",
620 self.protocol.max_server_frame_bytes,
621 ),
622 ("tui.max_transcript_bytes", self.tui.max_transcript_bytes),
623 ("tui.max_transcript_items", self.tui.max_transcript_items),
624 (
625 "tui.max_prompt_history_bytes",
626 self.tui.max_prompt_history_bytes,
627 ),
628 (
629 "tui.max_prompt_history_items",
630 self.tui.max_prompt_history_items,
631 ),
632 (
633 "provider_limits.max_sse_event_bytes",
634 self.provider_limits.max_sse_event_bytes,
635 ),
636 (
637 "provider_limits.max_response_bytes",
638 self.provider_limits.max_response_bytes,
639 ),
640 (
641 "provider_limits.max_assistant_bytes",
642 self.provider_limits.max_assistant_bytes,
643 ),
644 (
645 "provider_limits.max_tool_calls",
646 self.provider_limits.max_tool_calls,
647 ),
648 (
649 "provider_limits.max_tool_arguments_bytes",
650 self.provider_limits.max_tool_arguments_bytes,
651 ),
652 ("skills.max_skills", self.skills.max_skills),
653 ("skills.max_skill_bytes", self.skills.max_skill_bytes),
654 ];
655 if let Some((name, _)) = positives.into_iter().find(|(_, value)| *value == 0) {
656 bail!("{name} must be positive");
657 }
658 if self
659 .context
660 .reserve_output_tokens
661 .saturating_add(self.context.safety_margin_tokens)
662 >= self.context.max_tokens
663 {
664 bail!("context reserve and safety margin consume max_tokens");
665 }
666 let worst_assistant_frame = self
667 .provider_limits
668 .max_assistant_bytes
669 .saturating_mul(6)
670 .saturating_add(64 * 1024);
671 if worst_assistant_frame > self.protocol.max_server_frame_bytes {
672 bail!(
673 "provider_limits.max_assistant_bytes can exceed protocol.max_server_frame_bytes after JSON escaping"
674 );
675 }
676 if self.provider_limits.max_tool_arguments_bytes > self.provider_limits.max_response_bytes {
677 bail!("tool argument limit exceeds provider response limit");
678 }
679 if self.provider_limits.max_sse_event_bytes > self.provider_limits.max_response_bytes {
680 bail!("provider SSE event limit exceeds provider response limit");
681 }
682 if self.protocol.max_client_frame_bytes < 4096 {
683 bail!("protocol.max_client_frame_bytes must be at least 4096");
684 }
685 if self.protocol.max_server_frame_bytes < 64 * 1024 {
686 bail!("protocol.max_server_frame_bytes must be at least 65536");
687 }
688 let worst_tool_frame = self
689 .tools
690 .output_limit_bytes
691 .max(self.tools.max_read_bytes)
692 .saturating_mul(12)
693 .saturating_add(64 * 1024);
694 let worst_skill_frame = self
695 .skills
696 .max_skill_bytes
697 .saturating_mul(6)
698 .saturating_add(64 * 1024);
699 let worst_arguments_frame = self
700 .provider_limits
701 .max_tool_arguments_bytes
702 .saturating_mul(6)
703 .saturating_add(64 * 1024);
704 if worst_tool_frame
705 .max(worst_skill_frame)
706 .max(worst_arguments_frame)
707 > self.protocol.max_server_frame_bytes
708 {
709 bail!(
710 "tool or skill limits can exceed protocol.max_server_frame_bytes after JSON escaping"
711 );
712 }
713 if self.skills.project_dir.is_absolute()
714 || self
715 .skills
716 .project_dir
717 .components()
718 .any(|component| matches!(component, std::path::Component::ParentDir))
719 {
720 bail!("skills.project_dir must be a contained relative path");
721 }
722 Ok(())
723 }
724}
725
726fn user_config_path() -> Option<PathBuf> {
727 user_home_path().map(|path| path.join("config.toml"))
728}
729
730pub fn user_home_path() -> Option<PathBuf> {
731 let path = std::env::var_os("SCV_HOME")
732 .map(PathBuf::from)
733 .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))?;
734 if path.exists() {
735 Some(std::fs::canonicalize(path.clone()).unwrap_or(path))
736 } else if path.is_absolute() {
737 Some(path)
738 } else {
739 std::env::current_dir().ok().map(|cwd| cwd.join(path))
740 }
741}
742
743fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
744 #[cfg(unix)]
745 {
746 use std::os::unix::fs::PermissionsExt;
747 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
748 .with_context(|| format!("secure directory {}", path.display()))?;
749 }
750 Ok(())
751}
752
753fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
754 let size = std::fs::metadata(path)
755 .with_context(|| format!("stat configuration {}", path.display()))?
756 .len();
757 if size > MAX_CONFIG_BYTES {
758 bail!("configuration {} exceeds 1 MiB", path.display());
759 }
760 let content = std::fs::read_to_string(path)
761 .with_context(|| format!("read configuration {}", path.display()))?;
762 toml::from_str(&content).with_context(|| format!("parse configuration {}", path.display()))
763}
764
765fn merge(base: &mut toml::Value, overlay: toml::Value) {
766 match (base, overlay) {
767 (toml::Value::Table(base), toml::Value::Table(overlay)) => {
768 for (key, value) in overlay {
769 match base.get_mut(&key) {
770 Some(existing) => merge(existing, value),
771 None => {
772 base.insert(key, value);
773 }
774 }
775 }
776 }
777 (base, overlay) => *base = overlay,
778 }
779}
780
781fn validate_project_keys(value: &toml::Value) -> Result<()> {
782 let Some(table) = value.as_table() else {
783 bail!("project configuration must be a TOML table");
784 };
785 for forbidden in [
786 "provider",
787 "providers",
788 "provider_active",
789 "agents",
790 "update",
791 ] {
792 if table.contains_key(forbidden) {
793 bail!("project configuration cannot set [{forbidden}]");
794 }
795 }
796 if table
797 .get("skills")
798 .and_then(toml::Value::as_table)
799 .is_some_and(|skills| skills.contains_key("user_dir"))
800 {
801 bail!("project configuration cannot set skills.user_dir");
802 }
803 if table
804 .get("agent")
805 .and_then(toml::Value::as_table)
806 .is_some_and(|agent| agent.contains_key("system_prompt"))
807 {
808 bail!("project configuration cannot replace agent.system_prompt");
809 }
810 Ok(())
811}
812
813fn validate_project_not_weaker(user: &Config, project: &Config) -> Result<()> {
814 macro_rules! no_larger {
815 ($field:expr, $name:literal) => {
816 if $field.1 > $field.0 {
817 bail!(concat!("project configuration cannot raise ", $name));
818 }
819 };
820 }
821 no_larger!(
822 (user.agent.max_steps, project.agent.max_steps),
823 "agent.max_steps"
824 );
825 no_larger!(
826 (
827 user.session.max_history_bytes,
828 project.session.max_history_bytes
829 ),
830 "session.max_history_bytes"
831 );
832 no_larger!(
833 (user.session.max_messages, project.session.max_messages),
834 "session.max_messages"
835 );
836 no_larger!(
837 (user.context.max_tokens, project.context.max_tokens),
838 "context.max_tokens"
839 );
840 no_larger!(
841 (
842 user.context.summary_max_chars,
843 project.context.summary_max_chars
844 ),
845 "context.summary_max_chars"
846 );
847 no_larger!(
848 (
849 user.tools.command_timeout_seconds,
850 project.tools.command_timeout_seconds
851 ),
852 "tools.command_timeout_seconds"
853 );
854 no_larger!(
855 (
856 user.tools.output_limit_bytes,
857 project.tools.output_limit_bytes
858 ),
859 "tools.output_limit_bytes"
860 );
861 no_larger!(
862 (user.tools.max_read_bytes, project.tools.max_read_bytes),
863 "tools.max_read_bytes"
864 );
865 no_larger!(
866 (user.tools.max_write_bytes, project.tools.max_write_bytes),
867 "tools.max_write_bytes"
868 );
869 no_larger!(
870 (
871 user.protocol.max_client_frame_bytes,
872 project.protocol.max_client_frame_bytes
873 ),
874 "protocol.max_client_frame_bytes"
875 );
876 no_larger!(
877 (
878 user.protocol.max_server_frame_bytes,
879 project.protocol.max_server_frame_bytes
880 ),
881 "protocol.max_server_frame_bytes"
882 );
883 no_larger!(
884 (
885 user.provider_limits.max_response_bytes,
886 project.provider_limits.max_response_bytes
887 ),
888 "provider_limits.max_response_bytes"
889 );
890 no_larger!(
891 (
892 user.provider_limits.max_sse_event_bytes,
893 project.provider_limits.max_sse_event_bytes
894 ),
895 "provider_limits.max_sse_event_bytes"
896 );
897 no_larger!(
898 (
899 user.provider_limits.max_assistant_bytes,
900 project.provider_limits.max_assistant_bytes
901 ),
902 "provider_limits.max_assistant_bytes"
903 );
904 no_larger!(
905 (
906 user.provider_limits.max_tool_calls,
907 project.provider_limits.max_tool_calls
908 ),
909 "provider_limits.max_tool_calls"
910 );
911 no_larger!(
912 (
913 user.provider_limits.max_tool_arguments_bytes,
914 project.provider_limits.max_tool_arguments_bytes
915 ),
916 "provider_limits.max_tool_arguments_bytes"
917 );
918 no_larger!(
919 (
920 user.tui.max_transcript_bytes,
921 project.tui.max_transcript_bytes
922 ),
923 "tui.max_transcript_bytes"
924 );
925 no_larger!(
926 (
927 user.tui.max_transcript_items,
928 project.tui.max_transcript_items
929 ),
930 "tui.max_transcript_items"
931 );
932 no_larger!(
933 (
934 user.tui.max_prompt_history_bytes,
935 project.tui.max_prompt_history_bytes
936 ),
937 "tui.max_prompt_history_bytes"
938 );
939 no_larger!(
940 (
941 user.tui.max_prompt_history_items,
942 project.tui.max_prompt_history_items
943 ),
944 "tui.max_prompt_history_items"
945 );
946 no_larger!(
947 (user.skills.max_skills, project.skills.max_skills),
948 "skills.max_skills"
949 );
950 no_larger!(
951 (user.skills.max_skill_bytes, project.skills.max_skill_bytes),
952 "skills.max_skill_bytes"
953 );
954 if project.context.reserve_output_tokens < user.context.reserve_output_tokens
955 || project.context.safety_margin_tokens < user.context.safety_margin_tokens
956 {
957 bail!("project configuration cannot lower context reserves");
958 }
959 if project.context.bytes_per_token > user.context.bytes_per_token {
960 bail!("project configuration cannot raise context.bytes_per_token");
961 }
962 if project.tools.approval_policy.strictness() < user.tools.approval_policy.strictness() {
963 bail!("project configuration cannot weaken tools.approval_policy");
964 }
965 Ok(())
966}
967
968fn expand_home(path: &std::path::Path) -> PathBuf {
969 let value = path.to_string_lossy();
970 if value == "~" {
971 return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
972 }
973 if let Some(rest) = value.strip_prefix("~/")
974 && let Some(home) = dirs::home_dir()
975 {
976 return home.join(rest);
977 }
978 path.to_path_buf()
979}
980
981#[cfg(test)]
982mod tests {
983 use super::*;
984
985 #[test]
986 fn project_cannot_redirect_provider_or_agent() {
987 let provider: toml::Value = toml::from_str(
988 r#"[provider]
989base_url = "https://attacker.invalid"
990"#,
991 )
992 .unwrap();
993 assert!(validate_project_keys(&provider).is_err());
994
995 let agent: toml::Value = toml::from_str(
996 r#"[agents.codex]
997command = "/tmp/fake"
998"#,
999 )
1000 .unwrap();
1001 assert!(validate_project_keys(&agent).is_err());
1002 }
1003
1004 #[test]
1005 fn project_may_tighten_but_not_weaken_limits() {
1006 let user = Config::default();
1007 let mut tighter = user.clone();
1008 tighter.tools.output_limit_bytes /= 2;
1009 tighter.tools.approval_policy = ApprovalPolicy::Always;
1010 assert!(validate_project_not_weaker(&user, &tighter).is_ok());
1011
1012 let mut weaker = user.clone();
1013 weaker.tools.output_limit_bytes *= 2;
1014 assert!(validate_project_not_weaker(&user, &weaker).is_err());
1015 }
1016
1017 #[test]
1018 fn cross_field_validation_accounts_for_json_escaping() {
1019 let mut config = Config::default();
1020 config.protocol.max_server_frame_bytes = config.provider_limits.max_assistant_bytes;
1021 assert!(config.validate().is_err());
1022 }
1023
1024 #[test]
1025 fn adapter_selection_templates_survive_partial_overrides_and_validate() {
1026 let mut value: toml::Value =
1027 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1028 merge(
1029 &mut value,
1030 toml::from_str(
1031 r#"[agents.claude]
1032args = ["-p", "--permission-mode", "acceptEdits"]
1033"#,
1034 )
1035 .unwrap(),
1036 );
1037 let config: Config = value.try_into().unwrap();
1038 assert_eq!(config.agents.claude.args.len(), 3);
1039 assert_eq!(config.agents.claude.model_args, ["--model", "{model}"]);
1040 assert_eq!(config.agents.claude.effort_args, ["--effort", "{effort}"]);
1041 assert!(config.agents.pi.model_args.is_empty());
1042
1043 let mut invalid = Config::default();
1044 invalid.agents.claude.effort_args = vec!["--effort".into()];
1045 assert!(
1046 invalid
1047 .validate()
1048 .unwrap_err()
1049 .to_string()
1050 .contains("agents.claude.effort_args must contain {effort}")
1051 );
1052 }
1053
1054 #[test]
1055 fn adapters_are_bound_to_the_instance_home() {
1056 let config = Config {
1057 instance_home: PathBuf::from("/tmp/scv-instance"),
1058 ..Config::default()
1059 };
1060 let adapters = config.adapters();
1061 let codex = &adapters["agent_codex"];
1062 assert!(codex.environment.contains(&(
1063 OsString::from("CODEX_HOME"),
1064 OsString::from("/tmp/scv-instance/adapters/codex")
1065 )));
1066 assert!(codex.environment.contains(&(
1067 OsString::from("SCV_HOME"),
1068 OsString::from("/tmp/scv-instance/adapters/codex")
1069 )));
1070 }
1071}