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