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}
305
306impl Config {
307 pub fn load(workspace: &std::path::Path, overrides: ConfigOverrides) -> Result<Self> {
308 let mut value: toml::Value = toml::from_str(
309 &toml::to_string(&Self::default()).context("serialize default configuration")?,
310 )?;
311
312 if let Some(user_path) = user_config_path()
313 && user_path.is_file()
314 {
315 #[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"); } }
316 merge(&mut value, read_layer(&user_path)?);
317 }
318 let user_baseline: Self = value
319 .clone()
320 .try_into()
321 .context("parse user configuration")?;
322
323 let project_path = workspace.join(".scv/config.toml");
324 if project_path.is_file() {
325 let canonical_project = std::fs::canonicalize(&project_path)
326 .with_context(|| format!("resolve configuration {}", project_path.display()))?;
327 if !canonical_project.starts_with(workspace) {
328 bail!("project configuration escaped workspace");
329 }
330 let project = read_layer(&canonical_project)?;
331 validate_project_keys(&project)?;
332 let mut candidate_value = value.clone();
333 merge(&mut candidate_value, project);
334 let candidate: Self = candidate_value
335 .clone()
336 .try_into()
337 .context("parse project configuration")?;
338 validate_project_not_weaker(&user_baseline, &candidate)?;
339 value = candidate_value;
340 }
341
342 if let Some(explicit) = std::env::var_os("SCV_CONFIG") {
343 let path = PathBuf::from(explicit);
344 merge(&mut value, read_layer(&path)?);
345 }
346 let mut config: Self = value.try_into().context("parse merged configuration")?;
347 if let Some(name) = overrides.provider.as_deref() { config.provider_active = Some(name.to_owned()); }
348 let selected = config.active_provider()?;
349 config.provider = selected;
350 if let Ok(model) = std::env::var("SCV_MODEL") {
351 config.provider.model = model;
352 }
353 if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
354 config.provider.base_url = base_url;
355 }
356 if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
357 config.provider.api_key_env = Some(api_key_env);
358 }
359 if let Some(model) = overrides.model {
360 config.provider.model = model;
361 }
362 if let Some(base_url) = overrides.base_url {
363 config.provider.base_url = base_url;
364 }
365 if let Some(policy) = overrides.approval_policy {
366 config.tools.approval_policy = policy;
367 }
368 if config.skills.user_dir == PathBuf::from("~/.scv/skills") {
369 if let Some(home) = std::env::var_os("SCV_HOME") { config.skills.user_dir = PathBuf::from(home).join("skills"); }
370 }
371 config.skills.user_dir = expand_home(&config.skills.user_dir);
372 config.validate()?;
373 Ok(config)
374 }
375
376 pub fn core_agent(&self, system_prompt: String) -> CoreAgentConfig {
377 CoreAgentConfig {
378 system_prompt,
379 max_steps: self.agent.max_steps,
380 history_limits: HistoryLimits {
381 max_bytes: self.session.max_history_bytes,
382 max_messages: self.session.max_messages,
383 note_max_chars: self.context.summary_max_chars,
384 },
385 }
386 }
387
388 pub fn tools(&self) -> ToolsConfig {
389 ToolsConfig {
390 command_timeout: Duration::from_secs(self.tools.command_timeout_seconds),
391 output_limit_bytes: self.tools.output_limit_bytes,
392 max_read_bytes: self.tools.max_read_bytes,
393 max_write_bytes: self.tools.max_write_bytes,
394 }
395 }
396
397 pub fn provider_limits(&self) -> ProviderLimits {
398 ProviderLimits {
399 max_sse_event_bytes: self.provider_limits.max_sse_event_bytes,
400 max_response_bytes: self.provider_limits.max_response_bytes,
401 max_assistant_bytes: self.provider_limits.max_assistant_bytes,
402 max_tool_calls: self.provider_limits.max_tool_calls,
403 max_tool_arguments_bytes: self.provider_limits.max_tool_arguments_bytes,
404 }
405 }
406
407 pub fn adapters(&self) -> HashMap<String, AgentAdapterConfig> {
408 [
409 ("agent_claude", &self.agents.claude),
410 ("agent_codex", &self.agents.codex),
411 ("agent_pi", &self.agents.pi),
412 ]
413 .into_iter()
414 .map(|(name, config)| {
415 (
416 name.to_owned(),
417 AgentAdapterConfig {
418 command: config.command.clone(),
419 args: config.args.clone(),
420 },
421 )
422 })
423 .collect()
424 }
425
426 fn validate(&self) -> Result<()> {
427 if self.provider.kind != "openai-compatible" {
428 bail!("provider.kind must be openai-compatible in v0.1");
429 }
430 if self.provider.model.trim().is_empty()
431 || self.provider.base_url.trim().is_empty()
432 || self.provider.api_key.as_deref().unwrap_or("").trim().is_empty()
433 && self.provider.api_key_env.as_deref().unwrap_or("").trim().is_empty()
434 {
435 bail!("provider model and base_url must be non-empty; configure api_key or api_key_env");
436 }
437 for (name, adapter) in [
438 ("agents.claude.command", &self.agents.claude),
439 ("agents.codex.command", &self.agents.codex),
440 ("agents.pi.command", &self.agents.pi),
441 ] {
442 if adapter.command.trim().is_empty() {
443 bail!("{name} must be non-empty");
444 }
445 let adapter_bytes =
446 adapter.command.len() + adapter.args.iter().map(String::len).sum::<usize>();
447 if adapter_bytes > 16 * 1024 {
448 bail!("{name} and its fixed arguments exceed 16384 bytes");
449 }
450 }
451 let positives = [
452 (
453 "provider.timeout_seconds",
454 usize::try_from(self.provider.timeout_seconds).unwrap_or(usize::MAX),
455 ),
456 ("agent.max_steps", self.agent.max_steps),
457 ("session.max_history_bytes", self.session.max_history_bytes),
458 ("session.max_messages", self.session.max_messages),
459 ("context.max_tokens", self.context.max_tokens),
460 ("context.bytes_per_token", self.context.bytes_per_token),
461 ("context.summary_max_chars", self.context.summary_max_chars),
462 (
463 "tools.command_timeout_seconds",
464 usize::try_from(self.tools.command_timeout_seconds).unwrap_or(usize::MAX),
465 ),
466 ("tools.output_limit_bytes", self.tools.output_limit_bytes),
467 ("tools.max_read_bytes", self.tools.max_read_bytes),
468 ("tools.max_write_bytes", self.tools.max_write_bytes),
469 (
470 "protocol.max_client_frame_bytes",
471 self.protocol.max_client_frame_bytes,
472 ),
473 (
474 "protocol.max_server_frame_bytes",
475 self.protocol.max_server_frame_bytes,
476 ),
477 ("tui.max_transcript_bytes", self.tui.max_transcript_bytes),
478 ("tui.max_transcript_items", self.tui.max_transcript_items),
479 (
480 "tui.max_prompt_history_bytes",
481 self.tui.max_prompt_history_bytes,
482 ),
483 (
484 "tui.max_prompt_history_items",
485 self.tui.max_prompt_history_items,
486 ),
487 (
488 "provider_limits.max_sse_event_bytes",
489 self.provider_limits.max_sse_event_bytes,
490 ),
491 (
492 "provider_limits.max_response_bytes",
493 self.provider_limits.max_response_bytes,
494 ),
495 (
496 "provider_limits.max_assistant_bytes",
497 self.provider_limits.max_assistant_bytes,
498 ),
499 (
500 "provider_limits.max_tool_calls",
501 self.provider_limits.max_tool_calls,
502 ),
503 (
504 "provider_limits.max_tool_arguments_bytes",
505 self.provider_limits.max_tool_arguments_bytes,
506 ),
507 ("skills.max_skills", self.skills.max_skills),
508 ("skills.max_skill_bytes", self.skills.max_skill_bytes),
509 ];
510 if let Some((name, _)) = positives.into_iter().find(|(_, value)| *value == 0) {
511 bail!("{name} must be positive");
512 }
513 if self
514 .context
515 .reserve_output_tokens
516 .saturating_add(self.context.safety_margin_tokens)
517 >= self.context.max_tokens
518 {
519 bail!("context reserve and safety margin consume max_tokens");
520 }
521 let worst_assistant_frame = self
522 .provider_limits
523 .max_assistant_bytes
524 .saturating_mul(6)
525 .saturating_add(64 * 1024);
526 if worst_assistant_frame > self.protocol.max_server_frame_bytes {
527 bail!(
528 "provider_limits.max_assistant_bytes can exceed protocol.max_server_frame_bytes after JSON escaping"
529 );
530 }
531 if self.provider_limits.max_tool_arguments_bytes > self.provider_limits.max_response_bytes {
532 bail!("tool argument limit exceeds provider response limit");
533 }
534 if self.provider_limits.max_sse_event_bytes > self.provider_limits.max_response_bytes {
535 bail!("provider SSE event limit exceeds provider response limit");
536 }
537 if self.protocol.max_client_frame_bytes < 4096 {
538 bail!("protocol.max_client_frame_bytes must be at least 4096");
539 }
540 if self.protocol.max_server_frame_bytes < 64 * 1024 {
541 bail!("protocol.max_server_frame_bytes must be at least 65536");
542 }
543 let worst_tool_frame = self
544 .tools
545 .output_limit_bytes
546 .max(self.tools.max_read_bytes)
547 .saturating_mul(12)
548 .saturating_add(64 * 1024);
549 let worst_skill_frame = self
550 .skills
551 .max_skill_bytes
552 .saturating_mul(6)
553 .saturating_add(64 * 1024);
554 let worst_arguments_frame = self
555 .provider_limits
556 .max_tool_arguments_bytes
557 .saturating_mul(6)
558 .saturating_add(64 * 1024);
559 if worst_tool_frame
560 .max(worst_skill_frame)
561 .max(worst_arguments_frame)
562 > self.protocol.max_server_frame_bytes
563 {
564 bail!(
565 "tool or skill limits can exceed protocol.max_server_frame_bytes after JSON escaping"
566 );
567 }
568 if self.skills.project_dir.is_absolute()
569 || self
570 .skills
571 .project_dir
572 .components()
573 .any(|component| matches!(component, std::path::Component::ParentDir))
574 {
575 bail!("skills.project_dir must be a contained relative path");
576 }
577 Ok(())
578 }
579}
580
581fn user_config_path() -> Option<PathBuf> {
582 std::env::var_os("SCV_HOME").map(PathBuf::from)
583 .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))
584 .map(|path| path.join("config.toml"))
585}
586
587fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
588 let size = std::fs::metadata(path)
589 .with_context(|| format!("stat configuration {}", path.display()))?
590 .len();
591 if size > MAX_CONFIG_BYTES {
592 bail!("configuration {} exceeds 1 MiB", path.display());
593 }
594 let content = std::fs::read_to_string(path)
595 .with_context(|| format!("read configuration {}", path.display()))?;
596 toml::from_str(&content).with_context(|| format!("parse configuration {}", path.display()))
597}
598
599fn merge(base: &mut toml::Value, overlay: toml::Value) {
600 match (base, overlay) {
601 (toml::Value::Table(base), toml::Value::Table(overlay)) => {
602 for (key, value) in overlay {
603 match base.get_mut(&key) {
604 Some(existing) => merge(existing, value),
605 None => {
606 base.insert(key, value);
607 }
608 }
609 }
610 }
611 (base, overlay) => *base = overlay,
612 }
613}
614
615fn validate_project_keys(value: &toml::Value) -> Result<()> {
616 let Some(table) = value.as_table() else {
617 bail!("project configuration must be a TOML table");
618 };
619 for forbidden in ["provider", "providers", "provider_active", "agents"] {
620 if table.contains_key(forbidden) {
621 bail!("project configuration cannot set [{forbidden}]");
622 }
623 }
624 if table
625 .get("skills")
626 .and_then(toml::Value::as_table)
627 .is_some_and(|skills| skills.contains_key("user_dir"))
628 {
629 bail!("project configuration cannot set skills.user_dir");
630 }
631 if table
632 .get("agent")
633 .and_then(toml::Value::as_table)
634 .is_some_and(|agent| agent.contains_key("system_prompt"))
635 {
636 bail!("project configuration cannot replace agent.system_prompt");
637 }
638 Ok(())
639}
640
641fn validate_project_not_weaker(user: &Config, project: &Config) -> Result<()> {
642 macro_rules! no_larger {
643 ($field:expr, $name:literal) => {
644 if $field.1 > $field.0 {
645 bail!(concat!("project configuration cannot raise ", $name));
646 }
647 };
648 }
649 no_larger!(
650 (user.agent.max_steps, project.agent.max_steps),
651 "agent.max_steps"
652 );
653 no_larger!(
654 (
655 user.session.max_history_bytes,
656 project.session.max_history_bytes
657 ),
658 "session.max_history_bytes"
659 );
660 no_larger!(
661 (user.session.max_messages, project.session.max_messages),
662 "session.max_messages"
663 );
664 no_larger!(
665 (user.context.max_tokens, project.context.max_tokens),
666 "context.max_tokens"
667 );
668 no_larger!(
669 (
670 user.context.summary_max_chars,
671 project.context.summary_max_chars
672 ),
673 "context.summary_max_chars"
674 );
675 no_larger!(
676 (
677 user.tools.command_timeout_seconds,
678 project.tools.command_timeout_seconds
679 ),
680 "tools.command_timeout_seconds"
681 );
682 no_larger!(
683 (
684 user.tools.output_limit_bytes,
685 project.tools.output_limit_bytes
686 ),
687 "tools.output_limit_bytes"
688 );
689 no_larger!(
690 (user.tools.max_read_bytes, project.tools.max_read_bytes),
691 "tools.max_read_bytes"
692 );
693 no_larger!(
694 (user.tools.max_write_bytes, project.tools.max_write_bytes),
695 "tools.max_write_bytes"
696 );
697 no_larger!(
698 (
699 user.protocol.max_client_frame_bytes,
700 project.protocol.max_client_frame_bytes
701 ),
702 "protocol.max_client_frame_bytes"
703 );
704 no_larger!(
705 (
706 user.protocol.max_server_frame_bytes,
707 project.protocol.max_server_frame_bytes
708 ),
709 "protocol.max_server_frame_bytes"
710 );
711 no_larger!(
712 (
713 user.provider_limits.max_response_bytes,
714 project.provider_limits.max_response_bytes
715 ),
716 "provider_limits.max_response_bytes"
717 );
718 no_larger!(
719 (
720 user.provider_limits.max_sse_event_bytes,
721 project.provider_limits.max_sse_event_bytes
722 ),
723 "provider_limits.max_sse_event_bytes"
724 );
725 no_larger!(
726 (
727 user.provider_limits.max_assistant_bytes,
728 project.provider_limits.max_assistant_bytes
729 ),
730 "provider_limits.max_assistant_bytes"
731 );
732 no_larger!(
733 (
734 user.provider_limits.max_tool_calls,
735 project.provider_limits.max_tool_calls
736 ),
737 "provider_limits.max_tool_calls"
738 );
739 no_larger!(
740 (
741 user.provider_limits.max_tool_arguments_bytes,
742 project.provider_limits.max_tool_arguments_bytes
743 ),
744 "provider_limits.max_tool_arguments_bytes"
745 );
746 no_larger!(
747 (
748 user.tui.max_transcript_bytes,
749 project.tui.max_transcript_bytes
750 ),
751 "tui.max_transcript_bytes"
752 );
753 no_larger!(
754 (
755 user.tui.max_transcript_items,
756 project.tui.max_transcript_items
757 ),
758 "tui.max_transcript_items"
759 );
760 no_larger!(
761 (
762 user.tui.max_prompt_history_bytes,
763 project.tui.max_prompt_history_bytes
764 ),
765 "tui.max_prompt_history_bytes"
766 );
767 no_larger!(
768 (
769 user.tui.max_prompt_history_items,
770 project.tui.max_prompt_history_items
771 ),
772 "tui.max_prompt_history_items"
773 );
774 no_larger!(
775 (user.skills.max_skills, project.skills.max_skills),
776 "skills.max_skills"
777 );
778 no_larger!(
779 (user.skills.max_skill_bytes, project.skills.max_skill_bytes),
780 "skills.max_skill_bytes"
781 );
782 if project.context.reserve_output_tokens < user.context.reserve_output_tokens
783 || project.context.safety_margin_tokens < user.context.safety_margin_tokens
784 {
785 bail!("project configuration cannot lower context reserves");
786 }
787 if project.context.bytes_per_token > user.context.bytes_per_token {
788 bail!("project configuration cannot raise context.bytes_per_token");
789 }
790 if project.tools.approval_policy.strictness() < user.tools.approval_policy.strictness() {
791 bail!("project configuration cannot weaken tools.approval_policy");
792 }
793 Ok(())
794}
795
796fn expand_home(path: &std::path::Path) -> PathBuf {
797 let value = path.to_string_lossy();
798 if value == "~" {
799 return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
800 }
801 if let Some(rest) = value.strip_prefix("~/")
802 && let Some(home) = dirs::home_dir()
803 {
804 return home.join(rest);
805 }
806 path.to_path_buf()
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812
813 #[test]
814 fn project_cannot_redirect_provider_or_agent() {
815 let provider: toml::Value = toml::from_str(
816 r#"[provider]
817base_url = "https://attacker.invalid"
818"#,
819 )
820 .unwrap();
821 assert!(validate_project_keys(&provider).is_err());
822
823 let agent: toml::Value = toml::from_str(
824 r#"[agents.codex]
825command = "/tmp/fake"
826"#,
827 )
828 .unwrap();
829 assert!(validate_project_keys(&agent).is_err());
830 }
831
832 #[test]
833 fn project_may_tighten_but_not_weaken_limits() {
834 let user = Config::default();
835 let mut tighter = user.clone();
836 tighter.tools.output_limit_bytes /= 2;
837 tighter.tools.approval_policy = ApprovalPolicy::Always;
838 assert!(validate_project_not_weaker(&user, &tighter).is_ok());
839
840 let mut weaker = user.clone();
841 weaker.tools.output_limit_bytes *= 2;
842 assert!(validate_project_not_weaker(&user, &weaker).is_err());
843 }
844
845 #[test]
846 fn cross_field_validation_accounts_for_json_escaping() {
847 let mut config = Config::default();
848 config.protocol.max_server_frame_bytes = config.provider_limits.max_assistant_bytes;
849 assert!(config.validate().is_err());
850 }
851}