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