Skip to content

Commit ba86781

Browse files
authored
Merge pull request #105 from shoom1/fix/settings-save-trust-split
fix(settings): trust-split save so /settings changes survive the P0-1 allowlist
2 parents 302e8ee + 0661f7c commit ba86781

7 files changed

Lines changed: 332 additions & 58 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ agentic-cli/
2020
│ ├── __init__.py # Package exports, lazy imports
2121
│ ├── config.py # BaseSettings (pydantic-settings)
2222
│ ├── settings_mixins.py # Composable settings field groups
23-
│ ├── settings_persistence.py # save_settings() (excludes SECRET_FIELDS)
23+
│ ├── settings_persistence.py # Trust-split save (PROJECT_SETTABLE_KEYS → project, rest → user config; excludes SECRET_FIELDS)
2424
│ ├── constants.py # Shared constants, truncate()
2525
│ ├── file_utils.py # atomic_write_json / atomic_write_text
2626
│ ├── logging.py

src/agentic_cli/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
validate_settings,
4040
reload_settings,
4141
)
42-
from agentic_cli.settings_persistence import SettingsPersistence
42+
from agentic_cli.settings_persistence import SettingsPersistence, SettingsSaveResult
4343
from agentic_cli.workflow.settings import WorkflowSettingsMixin
4444
from agentic_cli.settings_mixins import AppSettingsMixin, CLISettingsMixin
4545

@@ -83,6 +83,7 @@ def __getattr__(name: str):
8383
"SettingsContext",
8484
"SettingsValidationError",
8585
"SettingsPersistence",
86+
"SettingsSaveResult",
8687
"get_settings",
8788
"set_settings",
8889
"set_context_settings",

src/agentic_cli/cli/app.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from agentic_cli.logging import Loggers, configure_logging
2727

2828
if TYPE_CHECKING:
29-
from pathlib import Path
29+
from agentic_cli.settings_persistence import SettingsSaveResult
3030
from agentic_cli.workflow import GoogleADKWorkflowManager, EventType, WorkflowEvent
3131
from agentic_cli.workflow.base_manager import BaseWorkflowManager
3232
from agentic_cli.workflow.config import AgentConfig
@@ -258,16 +258,18 @@ def _build_ui_items(self) -> list[Any]:
258258
# Sort by order and return items only
259259
return [item for _, item in sorted(items, key=lambda x: x[0])]
260260

261-
async def save_settings(self) -> "Path":
262-
"""Save current settings to project config file (./.{app_name}/settings.json).
261+
async def save_settings(self) -> "SettingsSaveResult":
262+
"""Save current settings, split by trust.
263263
264-
Uses SettingsPersistence to save non-default settings to the
265-
project-level config file. Secrets (API keys) are never saved.
264+
Allowlisted keys go to the project config
265+
(./.{app_name}/settings.json). User-scoped keys differing from their
266+
defaults go to the user config (~/.{app_name}/settings.json), where
267+
the loader trusts them — the project file may only carry allowlisted
268+
keys since P0-1. Secrets (API keys) are never saved.
266269
267270
Returns:
268-
Path to the saved config file
271+
SettingsSaveResult with the written path(s)
269272
"""
270-
from pathlib import Path
271273
from agentic_cli.settings_persistence import SettingsPersistence
272274

273275
persistence = SettingsPersistence(self._settings.app_name)

src/agentic_cli/cli/settings_command.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,14 @@ async def execute(self, args: str, app: "BaseCLIApp") -> None:
5555
# Apply settings changes
5656
await app.apply_settings(result)
5757

58-
# Save settings to project config file
58+
# Save settings, split by trust (allowlisted → project config,
59+
# user-scoped → user config)
5960
try:
60-
path = await app.save_settings()
61-
app.session.add_success(f"Settings saved to {path}")
61+
saved = await app.save_settings()
62+
message = f"Settings saved to {saved.project_path}"
63+
if saved.user_path is not None:
64+
keys = ", ".join(saved.user_scoped_keys)
65+
message += f"; user-scoped ({keys}) saved to {saved.user_path}"
66+
app.session.add_success(message)
6267
except Exception as e:
6368
app.session.add_warning(f"Settings applied but not saved: {e}")

src/agentic_cli/config.py

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,14 @@
3737
from agentic_cli.workflow.settings import WorkflowSettingsMixin
3838
from agentic_cli.workflow.models import ModelRegistry
3939
from agentic_cli.settings_mixins import AppSettingsMixin, CLISettingsMixin
40-
from agentic_cli.settings_persistence import get_project_config_path, get_user_config_path
40+
# PROJECT_SETTABLE_KEYS lives in settings_persistence so the save-side split
41+
# and the load-side filter below share one definition (and because the reverse
42+
# import would be circular). See its definition for the trust rationale.
43+
from agentic_cli.settings_persistence import (
44+
PROJECT_SETTABLE_KEYS as _PROJECT_SETTABLE_KEYS,
45+
get_project_config_path,
46+
get_user_config_path,
47+
)
4148
from agentic_cli.logging import Loggers
4249

4350
logger = Loggers.config()
@@ -55,34 +62,6 @@
5562
]
5663

5764

58-
# Deny-by-default allowlist: the ONLY keys a project ./.{app}/settings.json (or
59-
# a cwd-relative .env) may set. A cloned/untrusted repo must not be able to flip
60-
# a security boundary — executor backend, container image/user, bind mounts,
61-
# outputs dir, OS-sandbox policy, shell backend, raw LLM logging, workspace dir,
62-
# permission rules, or secrets. Every entry below is a benign field that cannot
63-
# select code execution, filesystem/mount scope, container identity/image,
64-
# network policy, secrets, or sensitive logging. Anything not clearly benign —
65-
# and any new field — is excluded automatically. Real environment variables and
66-
# the user ~/.{app}/settings.json remain fully trusted.
67-
_PROJECT_SETTABLE_KEYS = frozenset({
68-
# model / behavior
69-
"default_model", "thinking_effort", "orchestrator",
70-
"context_window_trigger_tokens", "context_window_target_tokens",
71-
# retry / request timeouts (not code paths)
72-
"retry_max_attempts", "retry_initial_delay", "retry_backoff_factor",
73-
"anthropic_request_timeout", "python_executor_timeout", "sandbox_timeout",
74-
# sandbox RESOURCE limits (not backend / image / mounts / user / network)
75-
"sandbox_max_sessions", "sandbox_memory_mb", "sandbox_cpus", "sandbox_pids_limit",
76-
# non-exec tool config
77-
"search_backend",
78-
"webfetch_cache_ttl_seconds", "webfetch_max_content_bytes", "webfetch_max_pdf_bytes",
79-
# persistence backend selection (NOT the credential-bearing postgres_uri)
80-
"session_store",
81-
# display / logging verbosity (NOT raw_llm_logging)
82-
"log_level", "log_format", "verbose_thinking",
83-
})
84-
85-
8665
class _AllowlistFilterSource(PydanticBaseSettingsSource):
8766
"""Wrap an untrusted settings source, keeping only allowlisted keys.
8867

src/agentic_cli/settings_persistence.py

Lines changed: 149 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
"""Settings persistence utilities.
22
33
Provides 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

713
import json
14+
from dataclasses import dataclass
815
from pathlib import Path
916
from typing import TYPE_CHECKING, Any
1017

@@ -21,6 +28,37 @@
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
2563
IDENTITY_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+
4194
def 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

Comments
 (0)