1use std::{collections::HashMap, 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 provider_limits: ProviderLimitsFile,
25 pub skills: SkillsConfig,
26 pub agents: AgentsConfig,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(default, deny_unknown_fields)]
31pub struct ProviderConfig {
32 pub active: Option<String>,
33 pub kind: String,
34 pub wire_api: String,
35 pub model: String,
36 pub base_url: String,
37 pub api_key: Option<String>,
38 pub api_key_env: Option<String>,
39 pub timeout_seconds: u64,
40 pub headers: HashMap<String, String>,
41}
42
43
44impl Default for ProviderConfig {
45 fn default() -> Self {
46 Self {
47 active: None,
48 kind: "openai-compatible".into(),
49 wire_api: "responses".into(),
50 model: "gpt-4.1-mini".into(),
51 base_url: "https://api.openai.com/v1".into(),
52 api_key: None,
53 api_key_env: Some("OPENAI_API_KEY".into()),
54 timeout_seconds: 120,
55 headers: HashMap::new(),
56 }
57 }
58}
59
60impl Config {
61 pub fn init_user_config() -> Result<PathBuf> {
62 let path = user_config_path().ok_or_else(|| anyhow::anyhow!("cannot determine user config path"))?;
63 if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).context("create config directory")?; }
64 if !path.exists() {
65 std::fs::write(&path, "[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").context("write example configuration")?;
66 #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).context("secure config file")?; }
67 }
68 Ok(path)
69 }
70 pub fn active_provider(&self) -> Result<ProviderConfig> {
71 if let Some(name) = self.provider_active.as_deref().or(self.provider.active.as_deref()) {
72 return self.providers.get(name).cloned().ok_or_else(|| anyhow::anyhow!("active provider profile {name:?} was not found"));
73 }
74 Ok(self.provider.clone())
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(default, deny_unknown_fields)]
80pub struct AgentConfig {
81 pub max_steps: usize,
82 pub system_prompt: String,
83}
84
85impl Default for AgentConfig {
86 fn default() -> Self {
87 Self {
88 max_steps: 32,
89 system_prompt: "You are SCV, a concise and careful coding agent. Use tools to inspect, change, and verify the workspace.".into(),
90 }
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(default, deny_unknown_fields)]
96pub struct SessionConfig {
97 pub max_history_bytes: usize,
98 pub max_messages: usize,
99}
100
101impl Default for SessionConfig {
102 fn default() -> Self {
103 Self {
104 max_history_bytes: 16 * 1024 * 1024,
105 max_messages: 10_000,
106 }
107 }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(default, deny_unknown_fields)]
112pub struct ContextConfigFile {
113 pub max_tokens: usize,
114 pub reserve_output_tokens: usize,
115 pub safety_margin_tokens: usize,
116 pub bytes_per_token: usize,
117 pub summary_max_chars: usize,
118}
119
120impl Default for ContextConfigFile {
121 fn default() -> Self {
122 let value = ContextConfig::default();
123 Self {
124 max_tokens: value.max_tokens,
125 reserve_output_tokens: value.reserve_output_tokens,
126 safety_margin_tokens: value.safety_margin_tokens,
127 bytes_per_token: value.bytes_per_token,
128 summary_max_chars: value.summary_max_chars,
129 }
130 }
131}
132
133impl From<&ContextConfigFile> for ContextConfig {
134 fn from(value: &ContextConfigFile) -> Self {
135 Self {
136 max_tokens: value.max_tokens,
137 reserve_output_tokens: value.reserve_output_tokens,
138 safety_margin_tokens: value.safety_margin_tokens,
139 bytes_per_token: value.bytes_per_token,
140 summary_max_chars: value.summary_max_chars,
141 }
142 }
143}
144
145#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
146#[serde(rename_all = "kebab-case")]
147pub enum ApprovalPolicy {
148 OnRisk,
149 Always,
150 Never,
151}
152
153impl ApprovalPolicy {
154 fn strictness(self) -> u8 {
155 match self {
156 Self::OnRisk => 1,
157 Self::Always => 2,
158 Self::Never => 3,
159 }
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[serde(default, deny_unknown_fields)]
165pub struct ToolConfig {
166 pub approval_policy: ApprovalPolicy,
167 pub command_timeout_seconds: u64,
168 pub output_limit_bytes: usize,
169 pub max_read_bytes: usize,
170 pub max_write_bytes: usize,
171}
172
173impl Default for ToolConfig {
174 fn default() -> Self {
175 Self {
176 approval_policy: ApprovalPolicy::OnRisk,
177 command_timeout_seconds: 120,
178 output_limit_bytes: 64 * 1024,
179 max_read_bytes: 256 * 1024,
180 max_write_bytes: 1024 * 1024,
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[serde(default, deny_unknown_fields)]
187pub struct ProtocolConfig {
188 pub max_client_frame_bytes: usize,
189 pub max_server_frame_bytes: usize,
190}
191
192impl Default for ProtocolConfig {
193 fn default() -> Self {
194 Self {
195 max_client_frame_bytes: 1024 * 1024,
196 max_server_frame_bytes: 8 * 1024 * 1024,
197 }
198 }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202#[serde(default, deny_unknown_fields)]
203pub struct TuiConfig {
204 pub max_transcript_bytes: usize,
205 pub max_transcript_items: usize,
206 pub max_prompt_history_bytes: usize,
207 pub max_prompt_history_items: usize,
208}
209
210impl Default for TuiConfig {
211 fn default() -> Self {
212 Self {
213 max_transcript_bytes: 8 * 1024 * 1024,
214 max_transcript_items: 10_000,
215 max_prompt_history_bytes: 1024 * 1024,
216 max_prompt_history_items: 200,
217 }
218 }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222#[serde(default, deny_unknown_fields)]
223pub struct ProviderLimitsFile {
224 pub max_sse_event_bytes: usize,
225 pub max_response_bytes: usize,
226 pub max_assistant_bytes: usize,
227 pub max_tool_calls: usize,
228 pub max_tool_arguments_bytes: usize,
229}
230
231impl Default for ProviderLimitsFile {
232 fn default() -> Self {
233 let value = ProviderLimits::default();
234 Self {
235 max_sse_event_bytes: value.max_sse_event_bytes,
236 max_response_bytes: value.max_response_bytes,
237 max_assistant_bytes: value.max_assistant_bytes,
238 max_tool_calls: value.max_tool_calls,
239 max_tool_arguments_bytes: value.max_tool_arguments_bytes,
240 }
241 }
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
245#[serde(default, deny_unknown_fields)]
246pub struct SkillsConfig {
247 pub user_dir: PathBuf,
248 pub project_dir: PathBuf,
249 pub max_skills: usize,
250 pub max_skill_bytes: usize,
251}
252
253impl Default for SkillsConfig {
254 fn default() -> Self {
255 Self {
256 user_dir: PathBuf::from("~/.scv/skills"),
257 project_dir: PathBuf::from(".scv/skills"),
258 max_skills: 128,
259 max_skill_bytes: 256 * 1024,
260 }
261 }
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, Default)]
265#[serde(default, deny_unknown_fields)]
266pub struct AdapterConfig {
267 pub command: String,
268 pub args: Vec<String>,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(default, deny_unknown_fields)]
273pub struct AgentsConfig {
274 pub claude: AdapterConfig,
275 pub codex: AdapterConfig,
276 pub pi: AdapterConfig,
277}
278
279impl Default for AgentsConfig {
280 fn default() -> Self {
281 Self {
282 claude: AdapterConfig {
283 command: "claude".into(),
284 args: vec!["-p".into()],
285 },
286 codex: AdapterConfig {
287 command: "codex".into(),
288 args: vec!["exec".into()],
289 },
290 pi: AdapterConfig {
291 command: "pi".into(),
292 args: vec!["-p".into()],
293 },
294 }
295 }
296}
297
298#[derive(Debug, Clone, Default)]
299pub struct ConfigOverrides {
300 pub provider: Option<String>,
301 pub model: Option<String>,
302 pub base_url: Option<String>,
303 pub approval_policy: Option<ApprovalPolicy>,
304 pub no_tools: bool,
305}
306
307impl Config {
308 pub fn load(workspace: &std::path::Path, overrides: ConfigOverrides) -> Result<Self> {
309 let mut value: toml::Value = toml::from_str(
310 &toml::to_string(&Self::default()).context("serialize default configuration")?,
311 )?;
312
313 if let Some(user_path) = user_config_path()
314 && user_path.is_file()
315 {
316 #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if std::fs::metadata(&user_path)?.permissions().mode() & 0o077 != 0 { bail!("user configuration is readable by group or others; run chmod 600"); } }
317 merge(&mut value, read_layer(&user_path)?);
318 }
319 let user_baseline: Self = value
320 .clone()
321 .try_into()
322 .context("parse user configuration")?;
323
324 let project_path = workspace.join(".scv/config.toml");
325 if project_path.is_file() {
326 let canonical_project = std::fs::canonicalize(&project_path)
327 .with_context(|| format!("resolve configuration {}", project_path.display()))?;
328 if !canonical_project.starts_with(workspace) {
329 bail!("project configuration escaped workspace");
330 }
331 let project = read_layer(&canonical_project)?;
332 validate_project_keys(&project)?;
333 let mut candidate_value = value.clone();
334 merge(&mut candidate_value, project);
335 let candidate: Self = candidate_value
336 .clone()
337 .try_into()
338 .context("parse project configuration")?;
339 validate_project_not_weaker(&user_baseline, &candidate)?;
340 value = candidate_value;
341 }
342
343 if let Some(explicit) = std::env::var_os("SCV_CONFIG") {
344 let path = PathBuf::from(explicit);
345 merge(&mut value, read_layer(&path)?);
346 }
347 let mut config: Self = value.try_into().context("parse merged configuration")?;
348 if let Some(name) = overrides.provider.as_deref() { config.provider_active = Some(name.to_owned()); }
349 let selected = config.active_provider()?;
350 config.provider = selected;
351 if let Ok(model) = std::env::var("SCV_MODEL") {
352 config.provider.model = model;
353 }
354 if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
355 config.provider.base_url = base_url;
356 }
357 if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
358 config.provider.api_key_env = Some(api_key_env);
359 }
360 if let Some(model) = overrides.model {
361 config.provider.model = model;
362 }
363 if let Some(base_url) = overrides.base_url {
364 config.provider.base_url = base_url;
365 }
366 if let Some(policy) = overrides.approval_policy {
367 config.tools.approval_policy = policy;
368 }
369 if config.skills.user_dir == PathBuf::from("~/.scv/skills") {
370 if let Some(home) = std::env::var_os("SCV_HOME") { config.skills.user_dir = PathBuf::from(home).join("skills"); }
371 }
372 config.skills.user_dir = expand_home(&config.skills.user_dir);
373 config.validate()?;
374 Ok(config)
375 }
376
377 pub fn core_agent(&self, system_prompt: String) -> CoreAgentConfig {
378 CoreAgentConfig {
379 system_prompt,
380 max_steps: self.agent.max_steps,
381 history_limits: HistoryLimits {
382 max_bytes: self.session.max_history_bytes,
383 max_messages: self.session.max_messages,
384 note_max_chars: self.context.summary_max_chars,
385 },
386 }
387 }
388
389 pub fn tools(&self) -> ToolsConfig {
390 ToolsConfig {
391 command_timeout: Duration::from_secs(self.tools.command_timeout_seconds),
392 output_limit_bytes: self.tools.output_limit_bytes,
393 max_read_bytes: self.tools.max_read_bytes,
394 max_write_bytes: self.tools.max_write_bytes,
395 }
396 }
397
398 pub fn provider_limits(&self) -> ProviderLimits {
399 ProviderLimits {
400 max_sse_event_bytes: self.provider_limits.max_sse_event_bytes,
401 max_response_bytes: self.provider_limits.max_response_bytes,
402 max_assistant_bytes: self.provider_limits.max_assistant_bytes,
403 max_tool_calls: self.provider_limits.max_tool_calls,
404 max_tool_arguments_bytes: self.provider_limits.max_tool_arguments_bytes,
405 }
406 }
407
408 pub fn adapters(&self) -> HashMap<String, AgentAdapterConfig> {
409 [
410 ("agent_claude", &self.agents.claude),
411 ("agent_codex", &self.agents.codex),
412 ("agent_pi", &self.agents.pi),
413 ]
414 .into_iter()
415 .map(|(name, config)| {
416 (
417 name.to_owned(),
418 AgentAdapterConfig {
419 command: config.command.clone(),
420 args: config.args.clone(),
421 },
422 )
423 })
424 .collect()
425 }
426
427 fn validate(&self) -> Result<()> {
428 if self.provider.kind != "openai-compatible" {
429 bail!("provider.kind must be openai-compatible in v0.1");
430 }
431 if self.provider.model.trim().is_empty()
432 || self.provider.base_url.trim().is_empty()
433 || self.provider.api_key.as_deref().unwrap_or("").trim().is_empty()
434 && self.provider.api_key_env.as_deref().unwrap_or("").trim().is_empty()
435 {
436 bail!("provider model and base_url must be non-empty; configure api_key or api_key_env");
437 }
438 for (name, adapter) in [
439 ("agents.claude.command", &self.agents.claude),
440 ("agents.codex.command", &self.agents.codex),
441 ("agents.pi.command", &self.agents.pi),
442 ] {
443 if adapter.command.trim().is_empty() {
444 bail!("{name} must be non-empty");
445 }
446 let adapter_bytes =
447 adapter.command.len() + adapter.args.iter().map(String::len).sum::<usize>();
448 if adapter_bytes > 16 * 1024 {
449 bail!("{name} and its fixed arguments exceed 16384 bytes");
450 }
451 }
452 let positives = [
453 (
454 "provider.timeout_seconds",
455 usize::try_from(self.provider.timeout_seconds).unwrap_or(usize::MAX),
456 ),
457 ("agent.max_steps", self.agent.max_steps),
458 ("session.max_history_bytes", self.session.max_history_bytes),
459 ("session.max_messages", self.session.max_messages),
460 ("context.max_tokens", self.context.max_tokens),
461 ("context.bytes_per_token", self.context.bytes_per_token),
462 ("context.summary_max_chars", self.context.summary_max_chars),
463 (
464 "tools.command_timeout_seconds",
465 usize::try_from(self.tools.command_timeout_seconds).unwrap_or(usize::MAX),
466 ),
467 ("tools.output_limit_bytes", self.tools.output_limit_bytes),
468 ("tools.max_read_bytes", self.tools.max_read_bytes),
469 ("tools.max_write_bytes", self.tools.max_write_bytes),
470 (
471 "protocol.max_client_frame_bytes",
472 self.protocol.max_client_frame_bytes,
473 ),
474 (
475 "protocol.max_server_frame_bytes",
476 self.protocol.max_server_frame_bytes,
477 ),
478 ("tui.max_transcript_bytes", self.tui.max_transcript_bytes),
479 ("tui.max_transcript_items", self.tui.max_transcript_items),
480 (
481 "tui.max_prompt_history_bytes",
482 self.tui.max_prompt_history_bytes,
483 ),
484 (
485 "tui.max_prompt_history_items",
486 self.tui.max_prompt_history_items,
487 ),
488 (
489 "provider_limits.max_sse_event_bytes",
490 self.provider_limits.max_sse_event_bytes,
491 ),
492 (
493 "provider_limits.max_response_bytes",
494 self.provider_limits.max_response_bytes,
495 ),
496 (
497 "provider_limits.max_assistant_bytes",
498 self.provider_limits.max_assistant_bytes,
499 ),
500 (
501 "provider_limits.max_tool_calls",
502 self.provider_limits.max_tool_calls,
503 ),
504 (
505 "provider_limits.max_tool_arguments_bytes",
506 self.provider_limits.max_tool_arguments_bytes,
507 ),
508 ("skills.max_skills", self.skills.max_skills),
509 ("skills.max_skill_bytes", self.skills.max_skill_bytes),
510 ];
511 if let Some((name, _)) = positives.into_iter().find(|(_, value)| *value == 0) {
512 bail!("{name} must be positive");
513 }
514 if self
515 .context
516 .reserve_output_tokens
517 .saturating_add(self.context.safety_margin_tokens)
518 >= self.context.max_tokens
519 {
520 bail!("context reserve and safety margin consume max_tokens");
521 }
522 let worst_assistant_frame = self
523 .provider_limits
524 .max_assistant_bytes
525 .saturating_mul(6)
526 .saturating_add(64 * 1024);
527 if worst_assistant_frame > self.protocol.max_server_frame_bytes {
528 bail!(
529 "provider_limits.max_assistant_bytes can exceed protocol.max_server_frame_bytes after JSON escaping"
530 );
531 }
532 if self.provider_limits.max_tool_arguments_bytes > self.provider_limits.max_response_bytes {
533 bail!("tool argument limit exceeds provider response limit");
534 }
535 if self.provider_limits.max_sse_event_bytes > self.provider_limits.max_response_bytes {
536 bail!("provider SSE event limit exceeds provider response limit");
537 }
538 if self.protocol.max_client_frame_bytes < 4096 {
539 bail!("protocol.max_client_frame_bytes must be at least 4096");
540 }
541 if self.protocol.max_server_frame_bytes < 64 * 1024 {
542 bail!("protocol.max_server_frame_bytes must be at least 65536");
543 }
544 let worst_tool_frame = self
545 .tools
546 .output_limit_bytes
547 .max(self.tools.max_read_bytes)
548 .saturating_mul(12)
549 .saturating_add(64 * 1024);
550 let worst_skill_frame = self
551 .skills
552 .max_skill_bytes
553 .saturating_mul(6)
554 .saturating_add(64 * 1024);
555 let worst_arguments_frame = self
556 .provider_limits
557 .max_tool_arguments_bytes
558 .saturating_mul(6)
559 .saturating_add(64 * 1024);
560 if worst_tool_frame
561 .max(worst_skill_frame)
562 .max(worst_arguments_frame)
563 > self.protocol.max_server_frame_bytes
564 {
565 bail!(
566 "tool or skill limits can exceed protocol.max_server_frame_bytes after JSON escaping"
567 );
568 }
569 if self.skills.project_dir.is_absolute()
570 || self
571 .skills
572 .project_dir
573 .components()
574 .any(|component| matches!(component, std::path::Component::ParentDir))
575 {
576 bail!("skills.project_dir must be a contained relative path");
577 }
578 Ok(())
579 }
580}
581
582fn user_config_path() -> Option<PathBuf> {
583 std::env::var_os("SCV_HOME").map(PathBuf::from)
584 .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))
585 .map(|path| path.join("config.toml"))
586}
587
588fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
589 let size = std::fs::metadata(path)
590 .with_context(|| format!("stat configuration {}", path.display()))?
591 .len();
592 if size > MAX_CONFIG_BYTES {
593 bail!("configuration {} exceeds 1 MiB", path.display());
594 }
595 let content = std::fs::read_to_string(path)
596 .with_context(|| format!("read configuration {}", path.display()))?;
597 toml::from_str(&content).with_context(|| format!("parse configuration {}", path.display()))
598}
599
600fn merge(base: &mut toml::Value, overlay: toml::Value) {
601 match (base, overlay) {
602 (toml::Value::Table(base), toml::Value::Table(overlay)) => {
603 for (key, value) in overlay {
604 match base.get_mut(&key) {
605 Some(existing) => merge(existing, value),
606 None => {
607 base.insert(key, value);
608 }
609 }
610 }
611 }
612 (base, overlay) => *base = overlay,
613 }
614}
615
616fn validate_project_keys(value: &toml::Value) -> Result<()> {
617 let Some(table) = value.as_table() else {
618 bail!("project configuration must be a TOML table");
619 };
620 for forbidden in ["provider", "providers", "provider_active", "agents"] {
621 if table.contains_key(forbidden) {
622 bail!("project configuration cannot set [{forbidden}]");
623 }
624 }
625 if table
626 .get("skills")
627 .and_then(toml::Value::as_table)
628 .is_some_and(|skills| skills.contains_key("user_dir"))
629 {
630 bail!("project configuration cannot set skills.user_dir");
631 }
632 if table
633 .get("agent")
634 .and_then(toml::Value::as_table)
635 .is_some_and(|agent| agent.contains_key("system_prompt"))
636 {
637 bail!("project configuration cannot replace agent.system_prompt");
638 }
639 Ok(())
640}
641
642fn validate_project_not_weaker(user: &Config, project: &Config) -> Result<()> {
643 macro_rules! no_larger {
644 ($field:expr, $name:literal) => {
645 if $field.1 > $field.0 {
646 bail!(concat!("project configuration cannot raise ", $name));
647 }
648 };
649 }
650 no_larger!(
651 (user.agent.max_steps, project.agent.max_steps),
652 "agent.max_steps"
653 );
654 no_larger!(
655 (
656 user.session.max_history_bytes,
657 project.session.max_history_bytes
658 ),
659 "session.max_history_bytes"
660 );
661 no_larger!(
662 (user.session.max_messages, project.session.max_messages),
663 "session.max_messages"
664 );
665 no_larger!(
666 (user.context.max_tokens, project.context.max_tokens),
667 "context.max_tokens"
668 );
669 no_larger!(
670 (
671 user.context.summary_max_chars,
672 project.context.summary_max_chars
673 ),
674 "context.summary_max_chars"
675 );
676 no_larger!(
677 (
678 user.tools.command_timeout_seconds,
679 project.tools.command_timeout_seconds
680 ),
681 "tools.command_timeout_seconds"
682 );
683 no_larger!(
684 (
685 user.tools.output_limit_bytes,
686 project.tools.output_limit_bytes
687 ),
688 "tools.output_limit_bytes"
689 );
690 no_larger!(
691 (user.tools.max_read_bytes, project.tools.max_read_bytes),
692 "tools.max_read_bytes"
693 );
694 no_larger!(
695 (user.tools.max_write_bytes, project.tools.max_write_bytes),
696 "tools.max_write_bytes"
697 );
698 no_larger!(
699 (
700 user.protocol.max_client_frame_bytes,
701 project.protocol.max_client_frame_bytes
702 ),
703 "protocol.max_client_frame_bytes"
704 );
705 no_larger!(
706 (
707 user.protocol.max_server_frame_bytes,
708 project.protocol.max_server_frame_bytes
709 ),
710 "protocol.max_server_frame_bytes"
711 );
712 no_larger!(
713 (
714 user.provider_limits.max_response_bytes,
715 project.provider_limits.max_response_bytes
716 ),
717 "provider_limits.max_response_bytes"
718 );
719 no_larger!(
720 (
721 user.provider_limits.max_sse_event_bytes,
722 project.provider_limits.max_sse_event_bytes
723 ),
724 "provider_limits.max_sse_event_bytes"
725 );
726 no_larger!(
727 (
728 user.provider_limits.max_assistant_bytes,
729 project.provider_limits.max_assistant_bytes
730 ),
731 "provider_limits.max_assistant_bytes"
732 );
733 no_larger!(
734 (
735 user.provider_limits.max_tool_calls,
736 project.provider_limits.max_tool_calls
737 ),
738 "provider_limits.max_tool_calls"
739 );
740 no_larger!(
741 (
742 user.provider_limits.max_tool_arguments_bytes,
743 project.provider_limits.max_tool_arguments_bytes
744 ),
745 "provider_limits.max_tool_arguments_bytes"
746 );
747 no_larger!(
748 (
749 user.tui.max_transcript_bytes,
750 project.tui.max_transcript_bytes
751 ),
752 "tui.max_transcript_bytes"
753 );
754 no_larger!(
755 (
756 user.tui.max_transcript_items,
757 project.tui.max_transcript_items
758 ),
759 "tui.max_transcript_items"
760 );
761 no_larger!(
762 (
763 user.tui.max_prompt_history_bytes,
764 project.tui.max_prompt_history_bytes
765 ),
766 "tui.max_prompt_history_bytes"
767 );
768 no_larger!(
769 (
770 user.tui.max_prompt_history_items,
771 project.tui.max_prompt_history_items
772 ),
773 "tui.max_prompt_history_items"
774 );
775 no_larger!(
776 (user.skills.max_skills, project.skills.max_skills),
777 "skills.max_skills"
778 );
779 no_larger!(
780 (user.skills.max_skill_bytes, project.skills.max_skill_bytes),
781 "skills.max_skill_bytes"
782 );
783 if project.context.reserve_output_tokens < user.context.reserve_output_tokens
784 || project.context.safety_margin_tokens < user.context.safety_margin_tokens
785 {
786 bail!("project configuration cannot lower context reserves");
787 }
788 if project.context.bytes_per_token > user.context.bytes_per_token {
789 bail!("project configuration cannot raise context.bytes_per_token");
790 }
791 if project.tools.approval_policy.strictness() < user.tools.approval_policy.strictness() {
792 bail!("project configuration cannot weaken tools.approval_policy");
793 }
794 Ok(())
795}
796
797fn expand_home(path: &std::path::Path) -> PathBuf {
798 let value = path.to_string_lossy();
799 if value == "~" {
800 return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
801 }
802 if let Some(rest) = value.strip_prefix("~/")
803 && let Some(home) = dirs::home_dir()
804 {
805 return home.join(rest);
806 }
807 path.to_path_buf()
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813
814 #[test]
815 fn project_cannot_redirect_provider_or_agent() {
816 let provider: toml::Value = toml::from_str(
817 r#"[provider]
818base_url = "https://attacker.invalid"
819"#,
820 )
821 .unwrap();
822 assert!(validate_project_keys(&provider).is_err());
823
824 let agent: toml::Value = toml::from_str(
825 r#"[agents.codex]
826command = "/tmp/fake"
827"#,
828 )
829 .unwrap();
830 assert!(validate_project_keys(&agent).is_err());
831 }
832
833 #[test]
834 fn project_may_tighten_but_not_weaken_limits() {
835 let user = Config::default();
836 let mut tighter = user.clone();
837 tighter.tools.output_limit_bytes /= 2;
838 tighter.tools.approval_policy = ApprovalPolicy::Always;
839 assert!(validate_project_not_weaker(&user, &tighter).is_ok());
840
841 let mut weaker = user.clone();
842 weaker.tools.output_limit_bytes *= 2;
843 assert!(validate_project_not_weaker(&user, &weaker).is_err());
844 }
845
846 #[test]
847 fn cross_field_validation_accounts_for_json_escaping() {
848 let mut config = Config::default();
849 config.protocol.max_server_frame_bytes = config.provider_limits.max_assistant_bytes;
850 assert!(config.validate().is_err());
851 }
852}