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