11"""Settings persistence utilities.
22
33Provides functionality to save settings to JSON files for layered configuration.
4- Settings are saved to ./.{app_name}/settings.json (project config) by default.
4+
5+ Saving splits by trust (mirroring the P0-1 load-side allowlist in
6+ ``agentic_cli.config``): allowlisted keys go to the project
7+ ``./.{app_name}/settings.json``; every other (user-scoped) key goes to the
8+ trusted user ``~/.{app_name}/settings.json``. Without the split, a key the
9+ loader refuses to read from the project file would be written there and
10+ silently dropped on the next start.
511"""
612
713import json
14+ from dataclasses import dataclass
815from pathlib import Path
916from typing import TYPE_CHECKING , Any
1017
2128 "postgres_uri" , # connection string embeds user:password@host
2229})
2330
31+ # Deny-by-default allowlist: the ONLY keys a project ./.{app}/settings.json (or
32+ # a cwd-relative .env) may set. A cloned/untrusted repo must not be able to flip
33+ # a security boundary — executor backend, container image/user, bind mounts,
34+ # outputs dir, OS-sandbox policy, shell backend, raw LLM logging, workspace dir,
35+ # permission rules, or secrets. Every entry below is a benign field that cannot
36+ # select code execution, filesystem/mount scope, container identity/image,
37+ # network policy, secrets, or sensitive logging. Anything not clearly benign —
38+ # and any new field — is excluded automatically. Real environment variables and
39+ # the user ~/.{app}/settings.json remain fully trusted.
40+ #
41+ # Used by BOTH sides of persistence: the load-side filter
42+ # (config._AllowlistFilterSource) and the save-side split
43+ # (SettingsPersistence.save), so writer and reader cannot drift apart.
44+ PROJECT_SETTABLE_KEYS = frozenset ({
45+ # model / behavior
46+ "default_model" , "thinking_effort" , "orchestrator" ,
47+ "context_window_trigger_tokens" , "context_window_target_tokens" ,
48+ # retry / request timeouts (not code paths)
49+ "retry_max_attempts" , "retry_initial_delay" , "retry_backoff_factor" ,
50+ "anthropic_request_timeout" , "python_executor_timeout" , "sandbox_timeout" ,
51+ # sandbox RESOURCE limits (not backend / image / mounts / user / network)
52+ "sandbox_max_sessions" , "sandbox_memory_mb" , "sandbox_cpus" , "sandbox_pids_limit" ,
53+ # non-exec tool config
54+ "search_backend" ,
55+ "webfetch_cache_ttl_seconds" , "webfetch_max_content_bytes" , "webfetch_max_pdf_bytes" ,
56+ # persistence backend selection (NOT the credential-bearing postgres_uri)
57+ "session_store" ,
58+ # display / logging verbosity (NOT raw_llm_logging)
59+ "log_level" , "log_format" , "verbose_thinking" ,
60+ })
61+
2462# Identity fields set by the application, not the user
2563IDENTITY_FIELDS = frozenset ({
2664 "app_name" ,
@@ -38,6 +76,21 @@ def get_user_config_path(app_name: str) -> Path:
3876 return Path .home () / f".{ app_name } " / "settings.json"
3977
4078
79+ @dataclass (frozen = True )
80+ class SettingsSaveResult :
81+ """Where a settings save landed.
82+
83+ ``project_path`` always receives the allowlisted keys (or, with an
84+ explicit ``path=``, the full legacy dump). ``user_path`` is set only when
85+ user-scoped (non-allowlisted) keys were written to the user config;
86+ ``user_scoped_keys`` lists the keys added/updated/removed there.
87+ """
88+
89+ project_path : Path
90+ user_path : Path | None = None
91+ user_scoped_keys : tuple [str , ...] = ()
92+
93+
4194def get_user_project_grants_path (app_name : str ) -> Path :
4295 """Path to interactively-granted permission rules
4396 (``~/.{app_name}/project_grants.json``), keyed by resolved project path.
@@ -90,24 +143,37 @@ def save(
90143 self ,
91144 settings : "BaseSettings" ,
92145 path : Path | None = None ,
93- ) -> Path :
94- """Save settings to JSON config file.
95-
96- By default saves to project config (./.{app_name}/settings.json).
97- Secrets (API keys) and identity fields are never saved.
98- All other user-configurable settings are saved regardless of
99- whether they match the schema default, because subclasses may
100- have different effective defaults.
146+ ) -> SettingsSaveResult :
147+ """Save settings, split by trust to match the load-side allowlist.
148+
149+ Default save writes two files:
150+
151+ - Project config (./.{app_name}/settings.json) receives ONLY
152+ allowlisted (``PROJECT_SETTABLE_KEYS``) fields, always all of them —
153+ regardless of whether they match the schema default, because
154+ subclasses may have different effective defaults. The file is fully
155+ rewritten, which also heals stale pre-P0-1 files holding keys the
156+ loader now ignores.
157+ - User config (~/.{app_name}/settings.json) receives the remaining
158+ user-scoped fields, but only those differing from the settings
159+ class default (so code-default changes keep applying for untouched
160+ fields, and the user file stays minimal). A user-scoped field back
161+ at its default is REMOVED from the file. Keys this method does not
162+ manage (hand-stored secrets, unknown/domain keys) are preserved
163+ verbatim; a malformed user file raises rather than being clobbered.
164+
165+ Secrets (API keys) and identity fields are never written anywhere.
166+ With an explicit ``path=``, the legacy behavior is kept: one full
167+ dump (minus secrets/identity) to that file, no split.
101168
102169 Args:
103170 settings: Settings instance to save
104- path: Optional custom path (defaults to project_config_path )
171+ path: Optional custom path (single-file legacy dump )
105172
106173 Returns:
107- Path to the saved config file
174+ SettingsSaveResult with the written path(s)
108175 """
109- target_path = path or self .project_config_path
110- target_path .parent .mkdir (parents = True , exist_ok = True )
176+ from agentic_cli .file_utils import atomic_write_text
111177
112178 # Get settings as dict, excluding secrets and identity fields
113179 data = settings .model_dump (
@@ -118,11 +184,77 @@ def save(
118184 # Convert Path objects to strings for JSON serialization
119185 data = self ._serialize_paths (data )
120186
121- # Write atomically
122- from agentic_cli .file_utils import atomic_write_text
123- atomic_write_text (target_path , json .dumps (data , indent = 2 , default = str ))
187+ if path is not None :
188+ # Explicit target: legacy single-file full dump.
189+ path .parent .mkdir (parents = True , exist_ok = True )
190+ atomic_write_text (path , json .dumps (data , indent = 2 , default = str ))
191+ return SettingsSaveResult (project_path = path )
192+
193+ project_data = {k : v for k , v in data .items () if k in PROJECT_SETTABLE_KEYS }
194+ user_updates , user_removals = self ._split_user_scoped (settings , data )
195+
196+ project_path = self .project_config_path
197+ project_path .parent .mkdir (parents = True , exist_ok = True )
198+ atomic_write_text (
199+ project_path , json .dumps (project_data , indent = 2 , default = str )
200+ )
201+
202+ user_path = self .user_config_path
203+ # Merge-write: never clobber keys we don't manage (e.g. hand-stored
204+ # API keys). A malformed user file raises instead of being replaced.
205+ existing : dict [str , Any ] = {}
206+ if user_path .exists ():
207+ existing = json .loads (user_path .read_text ())
208+ if not isinstance (existing , dict ):
209+ raise ValueError (
210+ f"User settings file is not a JSON object: { user_path } "
211+ )
212+ merged = dict (existing )
213+ removed = tuple (k for k in sorted (user_removals ) if k in existing )
214+ for key in removed :
215+ del merged [key ]
216+ merged .update (user_updates )
217+
218+ if merged == existing :
219+ return SettingsSaveResult (project_path = project_path )
220+
221+ user_path .parent .mkdir (parents = True , exist_ok = True )
222+ atomic_write_text (user_path , json .dumps (merged , indent = 2 , default = str ))
223+ return SettingsSaveResult (
224+ project_path = project_path ,
225+ user_path = user_path ,
226+ user_scoped_keys = tuple (sorted (user_updates )) + removed ,
227+ )
124228
125- return target_path
229+ def _split_user_scoped (
230+ self , settings : "BaseSettings" , data : dict [str , Any ]
231+ ) -> tuple [dict [str , Any ], set [str ]]:
232+ """Partition non-allowlisted dumped fields by deviation from default.
233+
234+ Returns (updates, removals): ``updates`` maps user-scoped keys whose
235+ live value differs from the settings class default to their dumped
236+ value; ``removals`` holds user-scoped keys back at their default,
237+ whose stale entries should leave the user file. Comparison uses the
238+ instance's own class fields, so subclass default overrides are
239+ respected.
240+ """
241+ from pydantic_core import PydanticUndefined
242+
243+ updates : dict [str , Any ] = {}
244+ removals : set [str ] = set ()
245+ fields = type (settings ).model_fields
246+ for key , value in data .items ():
247+ if key in PROJECT_SETTABLE_KEYS :
248+ continue
249+ field = fields .get (key )
250+ if field is None :
251+ continue
252+ default = field .get_default (call_default_factory = True )
253+ if default is not PydanticUndefined and getattr (settings , key ) == default :
254+ removals .add (key )
255+ else :
256+ updates [key ] = value
257+ return updates , removals
126258
127259 def load (self , path : Path | None = None ) -> dict [str , Any ]:
128260 """Load settings from JSON config file.
0 commit comments