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