From 85b61a00a5fe2c381d5a67d3809965dc2e1c5c14 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:56:48 -0400 Subject: [PATCH 1/6] feat(config): deny-by-default allowlist for project settings.json (P0-1) Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv --- src/agentic_cli/config.py | 89 +++++++++++++++++++++++++++++++------- tests/test_config_trust.py | 79 +++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 15 deletions(-) create mode 100644 tests/test_config_trust.py diff --git a/src/agentic_cli/config.py b/src/agentic_cli/config.py index 2284b21..85d95c1 100644 --- a/src/agentic_cli/config.py +++ b/src/agentic_cli/config.py @@ -38,6 +38,9 @@ from agentic_cli.workflow.models import ModelRegistry from agentic_cli.settings_mixins import AppSettingsMixin, CLISettingsMixin from agentic_cli.settings_persistence import get_project_config_path, get_user_config_path +from agentic_cli.logging import Loggers + +logger = Loggers.config() __all__ = [ "BaseSettings", @@ -52,11 +55,67 @@ ] -# Settings a PROJECT ./.{app}/settings.json must NOT be able to set: a cloned -# repo could otherwise disable the permission engine. These remain settable via -# env and user (~/.{app}) config. (Permission allow-rules are filtered -# separately in the engine — see workflow/permissions/store.load_rules.) -_UNTRUSTED_PROJECT_KEYS = frozenset({"permissions_enabled"}) +# Deny-by-default allowlist: the ONLY keys a project ./.{app}/settings.json (or +# a cwd-relative .env) may set. A cloned/untrusted repo must not be able to flip +# a security boundary — executor backend, container image/user, bind mounts, +# outputs dir, OS-sandbox policy, shell backend, raw LLM logging, workspace dir, +# permission rules, or secrets. Every entry below is a benign field that cannot +# select code execution, filesystem/mount scope, container identity/image, +# network policy, secrets, or sensitive logging. Anything not clearly benign — +# and any new field — is excluded automatically. Real environment variables and +# the user ~/.{app}/settings.json remain fully trusted. +_PROJECT_SETTABLE_KEYS = frozenset({ + # model / behavior + "default_model", "thinking_effort", "orchestrator", + "context_window_trigger_tokens", "context_window_target_tokens", + # retry / request timeouts (not code paths) + "retry_max_attempts", "retry_initial_delay", "retry_backoff_factor", + "anthropic_request_timeout", "python_executor_timeout", "sandbox_timeout", + # sandbox RESOURCE limits (not backend / image / mounts / user / network) + "sandbox_max_sessions", "sandbox_memory_mb", "sandbox_cpus", "sandbox_pids_limit", + # non-exec tool config + "search_backend", + "webfetch_cache_ttl_seconds", "webfetch_max_content_bytes", "webfetch_max_pdf_bytes", + # persistence backend selection (NOT the credential-bearing postgres_uri) + "session_store", + # display / logging verbosity (NOT raw_llm_logging) + "log_level", "log_format", "verbose_thinking", +}) + + +class _AllowlistFilterSource(PydanticBaseSettingsSource): + """Wrap an untrusted settings source, keeping only allowlisted keys. + + Applied to the project ``settings.json`` and a cwd-relative ``.env``. Any + non-allowlisted key is dropped and logged (one warning per key) so a cloned + repo cannot flip a security boundary. Drops, never raises — a malformed or + hostile project file can never brick the app. + """ + + def __init__( + self, + settings_cls: Type[PydanticBaseSettings], + inner: PydanticBaseSettingsSource, + label: str, + ) -> None: + super().__init__(settings_cls) + self._inner = inner + self._label = label + + def get_field_value(self, field: Any, field_name: str) -> Tuple[Any, str, bool]: + # Unused: __call__ is overridden to filter the inner source's output. + return None, field_name, False + + def __call__(self) -> dict[str, Any]: + kept: dict[str, Any] = {} + for key, value in self._inner().items(): + if key in _PROJECT_SETTABLE_KEYS: + kept[key] = value + else: + logger.warning( + "untrusted_project_setting_ignored", key=key, source=self._label + ) + return kept def _get_json_config_source( @@ -70,8 +129,9 @@ def _get_json_config_source( Args: settings_cls: The settings class json_file: Path to JSON config file - untrusted: When True (the project file), strip security-sensitive keys - so a cloned workspace cannot disable permission enforcement. + untrusted: When True (the project file), keep only allowlisted + (non-security) keys so a cloned workspace cannot flip a security + boundary. Returns: JsonConfigSettingsSource if file exists, None otherwise @@ -88,14 +148,13 @@ def _get_json_config_source( if not untrusted: return JsonConfigSettingsSource(settings_cls, json_file=json_file) - class _UntrustedJsonConfigSource(JsonConfigSettingsSource): - """Drops security-sensitive keys the project file may not override.""" - - def __call__(self) -> dict[str, Any]: - data = super().__call__() - return {k: v for k, v in data.items() if k not in _UNTRUSTED_PROJECT_KEYS} - - return _UntrustedJsonConfigSource(settings_cls, json_file=json_file) + # Untrusted (the project file): keep only allowlisted keys so a cloned + # workspace cannot flip a security boundary. + return _AllowlistFilterSource( + settings_cls, + JsonConfigSettingsSource(settings_cls, json_file=json_file), + "project settings.json", + ) class BaseSettings(WorkflowSettingsMixin, AppSettingsMixin, CLISettingsMixin, PydanticBaseSettings): diff --git a/tests/test_config_trust.py b/tests/test_config_trust.py new file mode 100644 index 0000000..eaecbac --- /dev/null +++ b/tests/test_config_trust.py @@ -0,0 +1,79 @@ +"""P0-1 config trust-model tests. + +A project ``./.{app}/settings.json`` (and a cwd-relative ``.env``) may set only +non-security allowlisted keys; sensitive keys are dropped with a warning. Real +environment variables and user ``~/.{app}/settings.json`` stay trusted. +""" + +import json +from pathlib import Path + + +def _write_project_settings(root: Path, app: str, data: dict) -> None: + d = root / f".{app}" + d.mkdir(parents=True, exist_ok=True) + (d / "settings.json").write_text(json.dumps(data)) + + +class TestProjectSettingsAllowlist: + def test_allowlisted_key_is_applied(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.delenv("AGENTIC_DEFAULT_MODEL", raising=False) + _write_project_settings(tmp_path, "agentic_cli", {"default_model": "claude-sonnet-4-6"}) + from agentic_cli.config import BaseSettings + assert BaseSettings().default_model == "claude-sonnet-4-6" + + def test_sensitive_keys_are_dropped(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + for var in ("AGENTIC_STATEFUL_EXECUTOR_BACKEND", "AGENTIC_RAW_LLM_LOGGING"): + monkeypatch.delenv(var, raising=False) + _write_project_settings(tmp_path, "agentic_cli", { + "stateful_executor_backend": "local", + "raw_llm_logging": True, + "workspace_dir": "/tmp/evil", + "sandbox_data_mounts": ["/etc:etc"], + "default_model": "kept", + }) + from agentic_cli.config import BaseSettings + s = BaseSettings() + # sensitive → dropped (defaults preserved) + assert s.stateful_executor_backend == "none" + assert s.raw_llm_logging is False + assert s.sandbox_data_mounts == [] + assert str(s.workspace_dir) != "/tmp/evil" + # benign → applied + assert s.default_model == "kept" + + def test_user_config_sensitive_key_is_trusted(self, tmp_path, monkeypatch): + proj = tmp_path / "proj"; proj.mkdir() + monkeypatch.chdir(proj) # cwd has no project settings.json + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.delenv("AGENTIC_RAW_LLM_LOGGING", raising=False) + ud = tmp_path / "home" / ".agentic_cli" + ud.mkdir(parents=True) + (ud / "settings.json").write_text(json.dumps({"raw_llm_logging": True})) + from agentic_cli.config import BaseSettings + assert BaseSettings().raw_llm_logging is True + + def test_real_env_var_sensitive_key_is_trusted(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("AGENTIC_STATEFUL_EXECUTOR_BACKEND", "local") + from agentic_cli.config import BaseSettings + assert BaseSettings().stateful_executor_backend == "local" + + def test_dropped_key_logs_warning(self, tmp_path, monkeypatch): + import structlog + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + _write_project_settings(tmp_path, "agentic_cli", {"raw_llm_logging": True}) + from agentic_cli.config import BaseSettings + with structlog.testing.capture_logs() as logs: + BaseSettings() + assert any( + e.get("event") == "untrusted_project_setting_ignored" + and e.get("key") == "raw_llm_logging" + for e in logs + ) From a51fe4c43be256b7916dad7f212022b20c6b9d97 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:04:07 -0400 Subject: [PATCH 2/6] feat(config): filter cwd-relative .env like project settings (P0-1) Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv --- src/agentic_cli/config.py | 19 +++++++++++++++++-- tests/test_config_trust.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/agentic_cli/config.py b/src/agentic_cli/config.py index 85d95c1..b9b8456 100644 --- a/src/agentic_cli/config.py +++ b/src/agentic_cli/config.py @@ -263,8 +263,23 @@ def settings_customise_sources( if user_json: sources.append(user_json) - # Add dotenv settings - sources.append(dotenv_settings) + # dotenv: a cwd-relative env_file is an untrusted project source (a + # cloned repo can ship ./.env), so filter it like project settings.json. + # An absolute/user-level env_file (or a list of files) stays trusted, as + # do real environment variables (env_settings, added above, untouched). + # Consequence: secrets/keys placed in a cwd .env are dropped — put them + # in real env vars or a user-level file. + env_file = settings_cls.model_config.get("env_file") + if ( + env_file is not None + and not isinstance(env_file, (list, tuple)) + and not Path(env_file).is_absolute() + ): + sources.append( + _AllowlistFilterSource(settings_cls, dotenv_settings, "cwd .env") + ) + else: + sources.append(dotenv_settings) return tuple(sources) diff --git a/tests/test_config_trust.py b/tests/test_config_trust.py index eaecbac..2549533 100644 --- a/tests/test_config_trust.py +++ b/tests/test_config_trust.py @@ -77,3 +77,41 @@ def test_dropped_key_logs_warning(self, tmp_path, monkeypatch): and e.get("key") == "raw_llm_logging" for e in logs ) + + +class TestCwdDotenvFiltering: + def _subclass_with_env_file(self, env_file): + from agentic_cli.config import BaseSettings + from pydantic_settings import SettingsConfigDict + + class _DomainSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="AGENTIC_", + env_file=env_file, + env_file_encoding="utf-8", + env_nested_delimiter="__", + extra="ignore", + ) + + return _DomainSettings + + def test_cwd_relative_env_drops_sensitive_key(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + for v in ("AGENTIC_RAW_LLM_LOGGING", "AGENTIC_DEFAULT_MODEL"): + monkeypatch.delenv(v, raising=False) + (tmp_path / ".env").write_text( + "AGENTIC_RAW_LLM_LOGGING=true\nAGENTIC_DEFAULT_MODEL=envmodel\n" + ) + s = self._subclass_with_env_file(".env")() + assert s.raw_llm_logging is False # sensitive dropped + assert s.default_model == "envmodel" # benign kept + + def test_absolute_env_file_is_trusted(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.delenv("AGENTIC_RAW_LLM_LOGGING", raising=False) + abs_env = tmp_path / "user.env" + abs_env.write_text("AGENTIC_RAW_LLM_LOGGING=true\n") + s = self._subclass_with_env_file(str(abs_env))() + assert s.raw_llm_logging is True # absolute env_file trusted From 64d442d263ecc6da9b91f82fad67d154902053c3 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:10:04 -0400 Subject: [PATCH 3/6] test(config): pin list/tuple env_file trusted path (Task 2 review) Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv --- tests/test_config_trust.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_config_trust.py b/tests/test_config_trust.py index 2549533..2fc389e 100644 --- a/tests/test_config_trust.py +++ b/tests/test_config_trust.py @@ -115,3 +115,14 @@ def test_absolute_env_file_is_trusted(self, tmp_path, monkeypatch): abs_env.write_text("AGENTIC_RAW_LLM_LOGGING=true\n") s = self._subclass_with_env_file(str(abs_env))() assert s.raw_llm_logging is True # absolute env_file trusted + + def test_list_env_file_is_trusted(self, tmp_path, monkeypatch): + # A list/tuple env_file must stay trusted (unfiltered) — only a single + # cwd-relative env_file is filtered. + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.delenv("AGENTIC_RAW_LLM_LOGGING", raising=False) + abs_env = tmp_path / "user.env" + abs_env.write_text("AGENTIC_RAW_LLM_LOGGING=true\n") + s = self._subclass_with_env_file([str(abs_env)])() + assert s.raw_llm_logging is True # list env_file trusted From 7c95c0fb8a46a8c94f5895e53952baf69d8e9fb7 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:20:08 -0400 Subject: [PATCH 4/6] feat(permissions): write Allow-always grants to user project_grants.json (P0-1) Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv --- src/agentic_cli/settings_persistence.py | 13 +++ .../workflow/permissions/engine.py | 2 +- src/agentic_cli/workflow/permissions/store.py | 29 ++++--- tests/permissions/test_engine.py | 44 ++++++---- tests/permissions/test_store.py | 86 +++++++++++-------- 5 files changed, 108 insertions(+), 66 deletions(-) diff --git a/src/agentic_cli/settings_persistence.py b/src/agentic_cli/settings_persistence.py index de32ef7..e9a0d2d 100644 --- a/src/agentic_cli/settings_persistence.py +++ b/src/agentic_cli/settings_persistence.py @@ -38,6 +38,19 @@ def get_user_config_path(app_name: str) -> Path: return Path.home() / f".{app_name}" / "settings.json" +def get_user_project_grants_path(app_name: str) -> Path: + """Path to interactively-granted permission rules + (``~/.{app_name}/project_grants.json``), keyed by resolved project path. + + Lives in USER config — never in the repo — so a cloned workspace carries no + "Allow always" grants: a clone at a different path simply has no entry and + the user re-grants. Structure:: + + { "": {"permissions": {"allow": [...], "deny": [...]}} } + """ + return Path.home() / f".{app_name}" / "project_grants.json" + + def get_project_local_permissions_path(app_name: str) -> Path: """Path to interactively-granted permission rules (./.{app_name}/permissions.local.json). diff --git a/src/agentic_cli/workflow/permissions/engine.py b/src/agentic_cli/workflow/permissions/engine.py index 3f338d0..20a5ad4 100644 --- a/src/agentic_cli/workflow/permissions/engine.py +++ b/src/agentic_cli/workflow/permissions/engine.py @@ -250,7 +250,7 @@ async def _ask_and_apply( rule = Rule(cap.name, target, Effect.ALLOW, source) self._session_rules.append(rule) if source is RuleSource.PROJECT: - append_project_rule(self._settings.app_name, rule) + append_project_rule(self._settings.app_name, rule, self._ctx.workdir) label = "session" if source is RuleSource.SESSION else "always, saved to project" return CheckResult(True, f"no rule + user allowed ({label})") diff --git a/src/agentic_cli/workflow/permissions/store.py b/src/agentic_cli/workflow/permissions/store.py index d1f5fa5..1354d7d 100644 --- a/src/agentic_cli/workflow/permissions/store.py +++ b/src/agentic_cli/workflow/permissions/store.py @@ -12,7 +12,7 @@ from pathlib import Path from agentic_cli.file_utils import atomic_write_text -from agentic_cli.settings_persistence import get_project_local_permissions_path +from agentic_cli.settings_persistence import get_user_project_grants_path from agentic_cli.workflow.permissions.rules import Effect, Rule, RuleSource @@ -107,27 +107,32 @@ def load_rules( return rules -def append_project_rule(app_name: str, rule: Rule) -> None: - """Append ``rule`` to the project-local permissions file. +def append_project_rule(app_name: str, rule: Rule, project_root: Path) -> None: + """Persist an interactive 'Allow always' grant to the USER-side, path-keyed + grants file (``~/.{app}/project_grants.json``), under ``project_root``'s + resolved path. - Writes to ``./.{app}/permissions.local.json`` (NOT ``settings.json``) so - the user's own "Allow always" grants are stored in a file that is loaded as - trusted, separate from the repo-shippable ``settings.json`` whose allow - rules are ignored. Creates the file if absent; dedupes by exact - ``(capability, target)``; atomic rewrite via ``atomic_write_text``. + Kept out of the repo (P0-1) so a cloned workspace carries no grants: a clone + at a different path has no matching entry and the user re-grants. Creates the + file if absent; dedupes by exact ``(capability, target)`` within the + project's section; atomic rewrite via ``atomic_write_text``. Only ``Rule`` instances with ``source == RuleSource.PROJECT`` should be - passed here — this helper doesn't validate (engine enforces the - invariant). + passed here — this helper doesn't validate (engine enforces the invariant). """ - path = get_project_local_permissions_path(app_name) + path = get_user_project_grants_path(app_name) try: data = json.loads(path.read_text()) if path.exists() else {} except json.JSONDecodeError as exc: raise ValueError(f"Malformed JSON in {path}: {exc}") from exc + proj_key = str(project_root.resolve()) key = "allow" if rule.effect is Effect.ALLOW else "deny" - section = data.setdefault("permissions", {}).setdefault(key, []) + section = ( + data.setdefault(proj_key, {}) + .setdefault("permissions", {}) + .setdefault(key, []) + ) entry = {"capability": rule.capability, "target": rule.target} if entry not in section: section.append(entry) diff --git a/tests/permissions/test_engine.py b/tests/permissions/test_engine.py index 6a5aaa5..28acaa1 100644 --- a/tests/permissions/test_engine.py +++ b/tests/permissions/test_engine.py @@ -193,6 +193,7 @@ async def test_user_allow_session_installs_session_rule(self, ctx, tmp_path): async def test_user_allow_always_writes_project_file(self, ctx, tmp_path, monkeypatch): import json monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) w = _stub_workflow() w.request_user_input = AsyncMock(return_value="Allow always (save to project)") engine = PermissionEngine(settings=_stub_settings(), workflow=w, ctx=ctx) @@ -204,9 +205,12 @@ async def test_user_allow_always_writes_project_file(self, ctx, tmp_path, monkey ) assert result.allowed is True - # Interactive grants persist to the trusted local file, not settings.json. - data = json.loads((tmp_path / ".agentic/permissions.local.json").read_text()) - allow = data["permissions"]["allow"] + # Interactive grants persist to the USER-side, path-keyed grants file + # (keyed by the resolved project cwd), never into the repo. + grants = json.loads( + (tmp_path / "home" / ".agentic" / "project_grants.json").read_text() + ) + allow = grants[str(ctx.workdir.resolve())]["permissions"]["allow"] assert len(allow) == 1 assert allow[0]["capability"] == "http.read" assert "example.com" in allow[0]["target"] @@ -330,6 +334,7 @@ class TestTargetlessAllowAlwaysRegression: @pytest.mark.asyncio async def test_http_read_allow_always_matches_next_call(self, ctx, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) w = _stub_workflow() w.request_user_input = AsyncMock(return_value="Allow always (save to project)") engine = PermissionEngine(settings=_stub_settings(), workflow=w, ctx=ctx) @@ -369,6 +374,7 @@ async def test_filesystem_grant_broadens_to_parent_directory( """When user picks 'Allow always' for filesystem.write to /foo/bar.txt, the rule covers /foo/** — subsequent writes to /foo/baz.txt must not re-prompt.""" monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) outside = tmp_path / "out" outside.mkdir() @@ -448,27 +454,33 @@ async def test_memory_and_kb_allowed_by_builtin(self, ctx, tmp_path): @pytest.mark.asyncio async def test_reloaded_wildcard_rule_still_matches(self, ctx, tmp_path, monkeypatch): - """After the project JSON is reloaded (simulating next process run), - a rule stored with target='*' must still match a targetless capability.""" + """A rule granted with target='*' must be stored with the wildcard + preserved (not mangled by matchers) so it can be reloaded correctly. + + This test verifies the WRITE side: the user grants file (P0-1, + ``~/.{app}/project_grants.json``) is created with ``"*"`` intact. + Task 4 (load-path migration) will add coverage for the reload side. + """ + import json monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) # Round 1: grant "allow always" so the rule is persisted. w1 = _stub_workflow() w1.request_user_input = AsyncMock(return_value="Allow always (save to project)") engine1 = PermissionEngine(settings=_stub_settings(), workflow=w1, ctx=ctx) - await engine1.check("web_search", [Capability("http.read")], {"query": "x"}) + result1 = await engine1.check("web_search", [Capability("http.read")], {"query": "x"}) + assert result1.allowed is True - # Round 2: fresh engine reads rules from disk. - w2 = _stub_workflow() - w2.request_user_input = AsyncMock(return_value="Deny") # would deny if re-asked - engine2 = PermissionEngine(settings=_stub_settings(), workflow=w2, ctx=ctx) - result = await engine2.check( - "web_search", - [Capability("http.read")], - {"query": "y"}, + # The grants file must preserve the wildcard target ('*') so it + # survives serialisation and can be reloaded without corruption. + grants = json.loads( + (tmp_path / "home" / ".agentic" / "project_grants.json").read_text() ) - assert result.allowed is True - w2.request_user_input.assert_not_called() + allow = grants[str(ctx.workdir.resolve())]["permissions"]["allow"] + assert len(allow) == 1 + assert allow[0]["capability"] == "http.read" + assert allow[0]["target"] == "*" # wildcard preserved, not mangled class TestOptionalCapability: diff --git a/tests/permissions/test_store.py b/tests/permissions/test_store.py index aab7c0e..67d0774 100644 --- a/tests/permissions/test_store.py +++ b/tests/permissions/test_store.py @@ -116,70 +116,82 @@ def test_malformed_json_raises(self, tmp_path: Path): class TestAppendProjectRule: - """Interactive grants are persisted to ./.{app}/permissions.local.json — a - trusted file separate from the repo-shippable settings.json (P0-2).""" + """Interactive 'Allow always' grants persist to the USER-side, path-keyed + ~/.{app}/project_grants.json (P0-1) — never into the repo, so a clone + carries no grants.""" - LOCAL = ".agentic/permissions.local.json" + def _grants_path(self, home: Path, app: str = "agentic") -> Path: + return home / f".{app}" / "project_grants.json" - def test_creates_file_when_absent(self, tmp_path, monkeypatch): + def test_creates_user_grants_file_keyed_by_project(self, tmp_path, monkeypatch): + import json from agentic_cli.workflow.permissions.rules import Effect, Rule, RuleSource from agentic_cli.workflow.permissions.store import append_project_rule - monkeypatch.chdir(tmp_path) + home = tmp_path / "home"; proj = tmp_path / "proj"; proj.mkdir() + monkeypatch.setenv("HOME", str(home)) rule = Rule("filesystem.write", "/abs/foo", Effect.ALLOW, RuleSource.PROJECT) - append_project_rule("agentic", rule) + append_project_rule("agentic", rule, proj) - import json - data = json.loads((tmp_path / self.LOCAL).read_text()) - assert data["permissions"]["allow"] == [ + data = json.loads(self._grants_path(home).read_text()) + assert data[str(proj.resolve())]["permissions"]["allow"] == [ {"capability": "filesystem.write", "target": "/abs/foo"} ] - def test_does_not_touch_settings_json(self, tmp_path, monkeypatch): - """Grants must NOT be written into settings.json (where a repo's rules - live) — the two files are kept separate.""" - import json + def test_writes_nothing_into_project_dir(self, tmp_path, monkeypatch): from agentic_cli.workflow.permissions.rules import Effect, Rule, RuleSource from agentic_cli.workflow.permissions.store import append_project_rule - monkeypatch.chdir(tmp_path) - (tmp_path / ".agentic").mkdir() - (tmp_path / ".agentic/settings.json").write_text(json.dumps({ - "default_model": "claude-sonnet-4", - })) + home = tmp_path / "home"; proj = tmp_path / "proj"; proj.mkdir() + monkeypatch.setenv("HOME", str(home)) + append_project_rule( + "agentic", + Rule("filesystem.write", "/abs/foo", Effect.ALLOW, RuleSource.PROJECT), + proj, + ) + assert not (proj / ".agentic").exists() # nothing dropped inside the repo + + def test_deduplicates_identical_rules(self, tmp_path, monkeypatch): + import json + from agentic_cli.workflow.permissions.rules import Effect, Rule, RuleSource + from agentic_cli.workflow.permissions.store import append_project_rule + home = tmp_path / "home"; proj = tmp_path / "proj"; proj.mkdir() + monkeypatch.setenv("HOME", str(home)) rule = Rule("filesystem.write", "/abs/foo", Effect.ALLOW, RuleSource.PROJECT) - append_project_rule("agentic", rule) + append_project_rule("agentic", rule, proj) + append_project_rule("agentic", rule, proj) - settings = json.loads((tmp_path / ".agentic/settings.json").read_text()) - assert settings == {"default_model": "claude-sonnet-4"} - local = json.loads((tmp_path / self.LOCAL).read_text()) - assert local["permissions"]["allow"][0]["capability"] == "filesystem.write" + data = json.loads(self._grants_path(home).read_text()) + assert len(data[str(proj.resolve())]["permissions"]["allow"]) == 1 - def test_deduplicates_identical_rules(self, tmp_path, monkeypatch): + def test_two_projects_kept_separate(self, tmp_path, monkeypatch): import json from agentic_cli.workflow.permissions.rules import Effect, Rule, RuleSource from agentic_cli.workflow.permissions.store import append_project_rule - monkeypatch.chdir(tmp_path) - rule = Rule("filesystem.write", "/abs/foo", Effect.ALLOW, RuleSource.PROJECT) - append_project_rule("agentic", rule) - append_project_rule("agentic", rule) + home = tmp_path / "home"; a = tmp_path / "a"; b = tmp_path / "b" + a.mkdir(); b.mkdir() + monkeypatch.setenv("HOME", str(home)) + append_project_rule("agentic", Rule("http.read", "*", Effect.ALLOW, RuleSource.PROJECT), a) + append_project_rule("agentic", Rule("filesystem.write", "/x", Effect.ALLOW, RuleSource.PROJECT), b) - data = json.loads((tmp_path / self.LOCAL).read_text()) - assert len(data["permissions"]["allow"]) == 1 + data = json.loads(self._grants_path(home).read_text()) + assert set(data.keys()) == {str(a.resolve()), str(b.resolve())} def test_writes_deny_section_for_deny_effect(self, tmp_path, monkeypatch): import json from agentic_cli.workflow.permissions.rules import Effect, Rule, RuleSource from agentic_cli.workflow.permissions.store import append_project_rule - monkeypatch.chdir(tmp_path) - rule = Rule("filesystem.write", "/etc/foo", Effect.DENY, RuleSource.PROJECT) - append_project_rule("agentic", rule) - - data = json.loads((tmp_path / self.LOCAL).read_text()) - assert data["permissions"]["deny"] == [ + home = tmp_path / "home"; proj = tmp_path / "proj"; proj.mkdir() + monkeypatch.setenv("HOME", str(home)) + append_project_rule( + "agentic", + Rule("filesystem.write", "/etc/foo", Effect.DENY, RuleSource.PROJECT), + proj, + ) + data = json.loads(self._grants_path(home).read_text()) + assert data[str(proj.resolve())]["permissions"]["deny"] == [ {"capability": "filesystem.write", "target": "/etc/foo"} ] - assert "allow" not in data["permissions"] or data["permissions"]["allow"] == [] From e5241888125775e647a9df352d6ce6457f675446 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:30:56 -0400 Subject: [PATCH 5/6] feat(permissions): load grants from user project_grants.json; drop repo-local trust (P0-1) Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv --- CHANGELOG.md | 4 + src/agentic_cli/settings_persistence.py | 11 -- .../workflow/permissions/engine.py | 14 +- src/agentic_cli/workflow/permissions/store.py | 36 +++++ tests/permissions/test_engine.py | 15 +- tests/permissions/test_grant_provenance.py | 135 ++++++++++++++++++ 6 files changed, 193 insertions(+), 22 deletions(-) create mode 100644 tests/permissions/test_grant_provenance.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e5ccb0..8ce61b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Harness Jobs UI monitor** (`cli/job_monitor.py`, Tier A milestone 2): a background `JobMonitor` task — started for the lifetime of the CLI session, independent of the agent loop — periodically reconciles the `JobManager` (so detached jobs advance state with no LLM turn) and renders a live jobs segment into the status bar (`jobs: 2 running, 1 queued`), with a transient `✓`/`✗`/`⊘` note when a job finishes. The status bar is the only background-safe UI surface (`thinking_prompt` boxes are turn-oriented and `add_*` prints directly, which would corrupt the live prompt; `set_status` only invalidates the app); `WorkflowController` stays the single composer of the bar and reads the segment the monitor publishes. New `examples/jobs_demo.py` exercises it interactively. - **Long-running job push/resume auto-ingest — ADK** (Tier A phase 2): when a long-running job that opted in (`resume_on_complete`) finishes, the agent is automatically resumed with its result — no polling. On ADK the result is delivered to the pending call as a `FunctionResponse` (`GoogleADKWorkflowManager.resume_with_job_result`); long-running tools are wrapped as `LongRunningFunctionTool` so the model leaves the call pending. The harness coordinator (`BaseCLIApp.resume_finished_jobs`) drains finished jobs into **serialized resume turns at turn boundaries** — one turn at a time via a turn lock, never overlapping a user turn or the live prompt — rendered through the same UI path as a user turn (`MessageProcessor.process_resume`, sharing `_run_turn` with `process`). Gated by the opt-in `job_auto_resume` setting (default off); `/resume` triggers it on demand; the status bar shows `↻N to resume`. The resume association (`session_id`/`user_id`/`call_id`/`call_name`/`resumed`) is tracked on the `JobRecord` and auto-filled from the active turn (`JobManager.submit(resume_on_complete=True)` reads the active session/user; `awaiting_resume`/`mark_resumed` are the coordinator's query/commit API). The coordinator and association layer are backend-agnostic; only `resume_with_job_result` is ADK-specific so far (LangGraph resume is not yet wired). A resume runs only when the originating conversation is still available (`BaseWorkflowManager.can_resume`, default False; ADK checks the session holds the pending call); when it isn't — e.g. after a CLI restart, since ADK's default session is in-memory — the harness posts a "finished while its conversation was unavailable — fetch with `/jobs `" notice instead of firing a dead resume turn (the persisted `↻N to resume` status-bar cue surfaces it across restarts; the result stays reachable by id). Validated live end-to-end (`tests/integration/test_live_job_resume.py`). +### Security +- **Project config can no longer flip security boundaries (P0-1).** A cloned/untrusted repo's `./.{app}/settings.json` (and a cwd-relative `.env`) is now restricted to an explicit deny-by-default allowlist of benign keys (`_PROJECT_SETTABLE_KEYS`) — model/behavior, retry & request timeouts, sandbox *resource* limits, non-exec tool config, `session_store`, and display/logging verbosity. Security-sensitive fields set by a project file — `stateful_executor_backend`, `sandbox_image`/`sandbox_container_user`/`sandbox_data_mounts`/`sandbox_outputs_dir`, the `os_sandbox_*` policy, `skill_scripts_enabled`, `skills_dirs`, `shell_sandbox_type`/`shell_docker_image`, `raw_llm_logging`, `workspace_dir`, permission rules, and secrets — are dropped with a logged warning (never an error, so a hostile file can't brick the app). Real environment variables and the user `~/.{app}/settings.json` remain fully trusted. Previously only `permissions_enabled` was stripped, so a repo could select the host executor, bind mounts, and container image. **Consequence:** put secrets/keys in real environment variables or a user-level file, not in a cwd `.env`. +- **Interactive "Allow always" grants moved out of the repo (P0-1).** Persistent permission grants now live in `~/.{app}/project_grants.json`, keyed by the resolved project path, instead of `./.{app}/permissions.local.json` (which a repo could force-track and ship as trusted allow-rules). A clone at a different path carries no grants (re-grant on first use); a repo-shipped `permissions.local.json` is no longer loaded. **No migration** — existing local grant files are ignored; re-grant when prompted. + ### Removed - **Legacy JSON session snapshots removed** (`persistence/SessionPersistence`/`SessionSnapshot`, the `_extract_session_data`/`_inject_session_messages` hooks, and the on-exit-save / on-startup-inject path). It saved only on a clean exit (a crash lost the session) and rebuilt tool calls/responses **without their ids** (and dropped thinking), losing fidelity on resume. Superseded by the native durable stores above, which persist continuously with full fidelity. There is no migration of old JSON sessions. diff --git a/src/agentic_cli/settings_persistence.py b/src/agentic_cli/settings_persistence.py index e9a0d2d..3926efe 100644 --- a/src/agentic_cli/settings_persistence.py +++ b/src/agentic_cli/settings_persistence.py @@ -51,17 +51,6 @@ def get_user_project_grants_path(app_name: str) -> Path: return Path.home() / f".{app_name}" / "project_grants.json" -def get_project_local_permissions_path(app_name: str) -> Path: - """Path to interactively-granted permission rules - (./.{app_name}/permissions.local.json). - - Kept separate from ``settings.json`` so a cloned repo's committed - ``settings.json`` cannot forge trusted allow-rules: this file is written - only by the user's own "Allow always" grants and is loaded as trusted, - while ``settings.json`` permission rules are honored deny-only. - """ - return Path.cwd() / f".{app_name}" / "permissions.local.json" - class SettingsPersistence: """Manages loading and saving settings to JSON files. diff --git a/src/agentic_cli/workflow/permissions/engine.py b/src/agentic_cli/workflow/permissions/engine.py index 20a5ad4..5be4b9c 100644 --- a/src/agentic_cli/workflow/permissions/engine.py +++ b/src/agentic_cli/workflow/permissions/engine.py @@ -18,7 +18,6 @@ from agentic_cli.logging import Loggers from agentic_cli.settings_persistence import ( get_project_config_path, - get_project_local_permissions_path, get_user_config_path, ) from agentic_cli.workflow.permissions.capabilities import Capability, ResolvedCapability @@ -33,6 +32,7 @@ from agentic_cli.workflow.permissions.store import ( BUILTIN_RULES, PermissionContext, + load_project_grants, load_rules, ) @@ -117,13 +117,11 @@ def _load_all_rules(self) -> list[Rule]: self._ctx, allowed_effects=frozenset({Effect.DENY}), ) - # Interactively-granted "Allow always" rules live in a separate local - # file the user (not a repo) authored — trusted, so allow+deny apply. - rules += load_rules( - get_project_local_permissions_path(app), - RuleSource.PROJECT, - self._ctx, - ) + # Interactive "Allow always" grants live in USER config, keyed by the + # resolved project path — trusted (allow+deny). A cloned repo carries + # none (its path won't match), and a repo-shipped permissions.local.json + # is no longer loaded at all. + rules += load_project_grants(app, self._ctx) return rules @property diff --git a/src/agentic_cli/workflow/permissions/store.py b/src/agentic_cli/workflow/permissions/store.py index 1354d7d..8137241 100644 --- a/src/agentic_cli/workflow/permissions/store.py +++ b/src/agentic_cli/workflow/permissions/store.py @@ -107,6 +107,39 @@ def load_rules( return rules +def load_project_grants(app_name: str, ctx: PermissionContext) -> list[Rule]: + """Load interactive 'Allow always' grants for the CURRENT project only. + + Reads ``~/.{app}/project_grants.json`` and returns the allow+deny rules + stored under ``str(ctx.workdir.resolve())`` — trusted (``RuleSource.PROJECT``, + both effects honored), since the user (not a repo) authored them. Returns + ``[]`` when the file or the project's entry is absent. Raises ``ValueError`` + on malformed JSON. + """ + # Local import to avoid a cycle: matchers.py imports PermissionContext here. + from agentic_cli.workflow.permissions.matchers import get_matcher # noqa: PLC0415 + + path = get_user_project_grants_path(app_name) + if not path.exists(): + return [] + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + raise ValueError(f"Malformed JSON in {path}: {exc}") from exc + + if not isinstance(data, dict): + raise ValueError(f"Expected a JSON object in {path}, got {type(data).__name__}") + + section = (data.get(str(ctx.workdir.resolve())) or {}).get("permissions") or {} + rules: list[Rule] = [] + for effect_name, effect in (("allow", Effect.ALLOW), ("deny", Effect.DENY)): + for entry in section.get(effect_name) or []: + cap = entry["capability"] + target = get_matcher(cap).canonicalize(entry["target"], ctx) + rules.append(Rule(cap, target, effect, RuleSource.PROJECT)) + return rules + + def append_project_rule(app_name: str, rule: Rule, project_root: Path) -> None: """Persist an interactive 'Allow always' grant to the USER-side, path-keyed grants file (``~/.{app}/project_grants.json``), under ``project_root``'s @@ -126,6 +159,9 @@ def append_project_rule(app_name: str, rule: Rule, project_root: Path) -> None: except json.JSONDecodeError as exc: raise ValueError(f"Malformed JSON in {path}: {exc}") from exc + if not isinstance(data, dict): + raise ValueError(f"Expected a JSON object in {path}, got {type(data).__name__}") + proj_key = str(project_root.resolve()) key = "allow" if rule.effect is Effect.ALLOW else "deny" section = ( diff --git a/tests/permissions/test_engine.py b/tests/permissions/test_engine.py index 28acaa1..ac2980e 100644 --- a/tests/permissions/test_engine.py +++ b/tests/permissions/test_engine.py @@ -457,9 +457,9 @@ async def test_reloaded_wildcard_rule_still_matches(self, ctx, tmp_path, monkeyp """A rule granted with target='*' must be stored with the wildcard preserved (not mangled by matchers) so it can be reloaded correctly. - This test verifies the WRITE side: the user grants file (P0-1, - ``~/.{app}/project_grants.json``) is created with ``"*"`` intact. - Task 4 (load-path migration) will add coverage for the reload side. + Verifies both the WRITE side (grants file persisted with '*' intact) and + the RELOAD side (a fresh engine reloads the persisted grant and allows + the same capability without prompting). """ import json monkeypatch.chdir(tmp_path) @@ -482,6 +482,15 @@ async def test_reloaded_wildcard_rule_still_matches(self, ctx, tmp_path, monkeyp assert allow[0]["capability"] == "http.read" assert allow[0]["target"] == "*" # wildcard preserved, not mangled + # Round 2: a FRESH engine (same settings + ctx → same resolved project + # key) must reload the persisted wildcard grant from project_grants.json + # and allow the same capability WITHOUT prompting. + w2 = _stub_workflow() # default response "Deny" — a prompt here fails the test + engine2 = PermissionEngine(settings=_stub_settings(), workflow=w2, ctx=ctx) + result2 = await engine2.check("web_search", [Capability("http.read")], {"query": "y"}) + assert result2.allowed is True + w2.request_user_input.assert_not_called() + class TestOptionalCapability: """A capability marked optional is only exercised when its target arg is diff --git a/tests/permissions/test_grant_provenance.py b/tests/permissions/test_grant_provenance.py new file mode 100644 index 0000000..87129c1 --- /dev/null +++ b/tests/permissions/test_grant_provenance.py @@ -0,0 +1,135 @@ +"""P0-1 grant-provenance tests. + +Interactive 'Allow always' grants live in the USER-side +~/.{app}/project_grants.json keyed by resolved project path — so a cloned repo +(a different path) carries no grants, and a repo-shipped permissions.local.json +is no longer trusted. +""" + +import json +from pathlib import Path + +import pytest + +from agentic_cli.workflow.permissions.rules import Effect, Rule, RuleSource +from agentic_cli.workflow.permissions.store import ( + PermissionContext, + append_project_rule, + load_project_grants, +) + + +def _ctx(workdir: Path) -> PermissionContext: + return PermissionContext(workdir=workdir, home=Path("/fake/home"), app_name="agentic") + + +class TestLoadProjectGrants: + def test_missing_file_returns_empty(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + assert load_project_grants("agentic", _ctx(tmp_path / "proj")) == [] + + def test_roundtrip_current_project(self, tmp_path, monkeypatch): + home = tmp_path / "home"; proj = tmp_path / "proj"; proj.mkdir() + monkeypatch.setenv("HOME", str(home)) + append_project_rule( + "agentic", + Rule("http.read", "https://ok.test/**", Effect.ALLOW, RuleSource.PROJECT), + proj, + ) + rules = load_project_grants("agentic", _ctx(proj)) + assert len(rules) == 1 + assert rules[0].effect is Effect.ALLOW + assert rules[0].source is RuleSource.PROJECT + + def test_grant_under_different_path_not_loaded(self, tmp_path, monkeypatch): + """Simulated clone: a grant recorded for project A is invisible from B.""" + home = tmp_path / "home"; a = tmp_path / "a"; b = tmp_path / "b" + a.mkdir(); b.mkdir() + monkeypatch.setenv("HOME", str(home)) + append_project_rule("agentic", Rule("http.read", "*", Effect.ALLOW, RuleSource.PROJECT), a) + assert load_project_grants("agentic", _ctx(b)) == [] + + def test_malformed_json_raises(self, tmp_path, monkeypatch): + from agentic_cli.settings_persistence import get_user_project_grants_path + monkeypatch.setenv("HOME", str(tmp_path / "home")) + p = get_user_project_grants_path("agentic") + p.parent.mkdir(parents=True) + p.write_text("{not json") + with pytest.raises(ValueError): + load_project_grants("agentic", _ctx(tmp_path / "proj")) + + def test_non_dict_grants_file_raises(self, tmp_path, monkeypatch): + """A project_grants.json containing a JSON array (not an object) must raise ValueError.""" + from agentic_cli.settings_persistence import get_user_project_grants_path + monkeypatch.setenv("HOME", str(tmp_path / "home")) + p = get_user_project_grants_path("agentic") + p.parent.mkdir(parents=True) + p.write_text("[]") + with pytest.raises(ValueError, match="Expected a JSON object"): + load_project_grants("agentic", _ctx(tmp_path / "proj")) + + +class TestLegacyLocalFileNotTrusted: + def test_repo_permissions_local_json_is_ignored(self, tmp_path, monkeypatch): + """A repo-shipped ./.{app}/permissions.local.json allow-rule is no longer + loaded as trusted (P0-1 drops the repo-local trusted load).""" + from unittest.mock import AsyncMock, MagicMock + from agentic_cli.workflow.permissions.engine import PermissionEngine + + home = tmp_path / "home"; proj = tmp_path / "proj"; proj.mkdir() + monkeypatch.chdir(proj) + monkeypatch.setenv("HOME", str(home)) + (proj / ".agentic").mkdir() + (proj / ".agentic" / "permissions.local.json").write_text(json.dumps({ + "permissions": {"allow": [{"capability": "http.read", "target": "*"}]} + })) + s = MagicMock(); s.permissions_enabled = True; s.app_name = "agentic" + w = MagicMock(); w.request_user_input = AsyncMock(return_value="Deny") + engine = PermissionEngine(settings=s, workflow=w, ctx=_ctx(proj)) + project_allows = [ + r for r in engine.rules + if r.source is RuleSource.PROJECT and r.effect is Effect.ALLOW + ] + assert project_allows == [] + + +class TestChainBlocked: + @pytest.mark.asyncio + async def test_project_cannot_preauthorize_via_settings_or_local_file( + self, tmp_path, monkeypatch + ): + """The reproduced chain: a cloned repo ships settings.json (sensitive + keys) + a permissions.local.json allow-rule. Neither pre-authorizes: + sensitive settings are dropped AND the repo allow-rule is untrusted, so a + gated tool call still reaches the approval prompt (here: user denies).""" + from unittest.mock import AsyncMock, MagicMock + from agentic_cli.config import BaseSettings + from agentic_cli.workflow.permissions.capabilities import Capability + from agentic_cli.workflow.permissions.engine import PermissionEngine + + home = tmp_path / "home"; proj = tmp_path / "proj"; proj.mkdir() + monkeypatch.chdir(proj) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("AGENTIC_STATEFUL_EXECUTOR_BACKEND", raising=False) + (proj / ".agentic_cli").mkdir() + (proj / ".agentic_cli" / "settings.json").write_text(json.dumps({ + "stateful_executor_backend": "local", + "raw_llm_logging": True, + })) + (proj / ".agentic_cli" / "permissions.local.json").write_text(json.dumps({ + "permissions": {"allow": [{"capability": "http.read", "target": "*"}]} + })) + settings = BaseSettings() # app_name default "agentic_cli" + assert settings.stateful_executor_backend == "none" # sensitive dropped + assert settings.raw_llm_logging is False + + w = MagicMock(); w.request_user_input = AsyncMock(return_value="Deny") + ctx = PermissionContext(workdir=proj, home=home, app_name="agentic_cli") + engine = PermissionEngine(settings=settings, workflow=w, ctx=ctx) + result = await engine.check( + "web_fetch", + [Capability("http.read", target_arg="url")], + {"url": "https://evil.test/x"}, + ) + assert result.allowed is False # repo allow-rule NOT trusted → prompt → denied + w.request_user_input.assert_awaited_once() From 751f306caf148bbf46998d1f82596e44a8fe8d67 Mon Sep 17 00:00:00 2001 From: Andrey Shiryaev <7615137+shoom1@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:54:48 -0400 Subject: [PATCH 6/6] fix(config): expanduser + list-aware dotenv trust guard; soften brick claim (P0-1 review) Closes whole-branch review I-1 (tilde env_file wrongly filtered), I-2 (list env_file with a cwd-relative entry stayed trusted), M-1 (overclaimed "never bricks the app"). Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv --- CHANGELOG.md | 2 +- src/agentic_cli/config.py | 34 +++++++++++++++++++++++----------- tests/test_config_trust.py | 25 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce61b5..19501b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Long-running job push/resume auto-ingest — ADK** (Tier A phase 2): when a long-running job that opted in (`resume_on_complete`) finishes, the agent is automatically resumed with its result — no polling. On ADK the result is delivered to the pending call as a `FunctionResponse` (`GoogleADKWorkflowManager.resume_with_job_result`); long-running tools are wrapped as `LongRunningFunctionTool` so the model leaves the call pending. The harness coordinator (`BaseCLIApp.resume_finished_jobs`) drains finished jobs into **serialized resume turns at turn boundaries** — one turn at a time via a turn lock, never overlapping a user turn or the live prompt — rendered through the same UI path as a user turn (`MessageProcessor.process_resume`, sharing `_run_turn` with `process`). Gated by the opt-in `job_auto_resume` setting (default off); `/resume` triggers it on demand; the status bar shows `↻N to resume`. The resume association (`session_id`/`user_id`/`call_id`/`call_name`/`resumed`) is tracked on the `JobRecord` and auto-filled from the active turn (`JobManager.submit(resume_on_complete=True)` reads the active session/user; `awaiting_resume`/`mark_resumed` are the coordinator's query/commit API). The coordinator and association layer are backend-agnostic; only `resume_with_job_result` is ADK-specific so far (LangGraph resume is not yet wired). A resume runs only when the originating conversation is still available (`BaseWorkflowManager.can_resume`, default False; ADK checks the session holds the pending call); when it isn't — e.g. after a CLI restart, since ADK's default session is in-memory — the harness posts a "finished while its conversation was unavailable — fetch with `/jobs `" notice instead of firing a dead resume turn (the persisted `↻N to resume` status-bar cue surfaces it across restarts; the result stays reachable by id). Validated live end-to-end (`tests/integration/test_live_job_resume.py`). ### Security -- **Project config can no longer flip security boundaries (P0-1).** A cloned/untrusted repo's `./.{app}/settings.json` (and a cwd-relative `.env`) is now restricted to an explicit deny-by-default allowlist of benign keys (`_PROJECT_SETTABLE_KEYS`) — model/behavior, retry & request timeouts, sandbox *resource* limits, non-exec tool config, `session_store`, and display/logging verbosity. Security-sensitive fields set by a project file — `stateful_executor_backend`, `sandbox_image`/`sandbox_container_user`/`sandbox_data_mounts`/`sandbox_outputs_dir`, the `os_sandbox_*` policy, `skill_scripts_enabled`, `skills_dirs`, `shell_sandbox_type`/`shell_docker_image`, `raw_llm_logging`, `workspace_dir`, permission rules, and secrets — are dropped with a logged warning (never an error, so a hostile file can't brick the app). Real environment variables and the user `~/.{app}/settings.json` remain fully trusted. Previously only `permissions_enabled` was stripped, so a repo could select the host executor, bind mounts, and container image. **Consequence:** put secrets/keys in real environment variables or a user-level file, not in a cwd `.env`. +- **Project config can no longer flip security boundaries (P0-1).** A cloned/untrusted repo's `./.{app}/settings.json` (and a cwd-relative `.env`) is now restricted to an explicit deny-by-default allowlist of benign keys (`_PROJECT_SETTABLE_KEYS`) — model/behavior, retry & request timeouts, sandbox *resource* limits, non-exec tool config, `session_store`, and display/logging verbosity. Security-sensitive fields set by a project file — `stateful_executor_backend`, `sandbox_image`/`sandbox_container_user`/`sandbox_data_mounts`/`sandbox_outputs_dir`, the `os_sandbox_*` policy, `skill_scripts_enabled`, `skills_dirs`, `shell_sandbox_type`/`shell_docker_image`, `raw_llm_logging`, `workspace_dir`, permission rules, and secrets — are dropped with a logged warning rather than rejected (the filter drops non-allowlisted keys instead of raising). Real environment variables and the user `~/.{app}/settings.json` remain fully trusted. Previously only `permissions_enabled` was stripped, so a repo could select the host executor, bind mounts, and container image. **Consequence:** put secrets/keys in real environment variables or a user-level file, not in a cwd `.env`. - **Interactive "Allow always" grants moved out of the repo (P0-1).** Persistent permission grants now live in `~/.{app}/project_grants.json`, keyed by the resolved project path, instead of `./.{app}/permissions.local.json` (which a repo could force-track and ship as trusted allow-rules). A clone at a different path carries no grants (re-grant on first use); a repo-shipped `permissions.local.json` is no longer loaded. **No migration** — existing local grant files are ignored; re-grant when prompted. ### Removed diff --git a/src/agentic_cli/config.py b/src/agentic_cli/config.py index b9b8456..f3aae68 100644 --- a/src/agentic_cli/config.py +++ b/src/agentic_cli/config.py @@ -88,8 +88,10 @@ class _AllowlistFilterSource(PydanticBaseSettingsSource): Applied to the project ``settings.json`` and a cwd-relative ``.env``. Any non-allowlisted key is dropped and logged (one warning per key) so a cloned - repo cannot flip a security boundary. Drops, never raises — a malformed or - hostile project file can never brick the app. + repo cannot flip a security boundary. Drops (never raises) a non-allowlisted + key, so a repo cannot flip a boundary by *adding* keys. (Malformed JSON or a + bad-typed allowlisted value is still rejected upstream, as before P0-1 — + this narrows, not removes, that pre-existing surface.) """ def __init__( @@ -265,16 +267,26 @@ def settings_customise_sources( # dotenv: a cwd-relative env_file is an untrusted project source (a # cloned repo can ship ./.env), so filter it like project settings.json. - # An absolute/user-level env_file (or a list of files) stays trusted, as - # do real environment variables (env_settings, added above, untouched). - # Consequence: secrets/keys placed in a cwd .env are dropped — put them - # in real env vars or a user-level file. + # We inspect EVERY configured env_file (a str/Path or a list of them): + # if ANY entry is cwd-relative after ~ expansion, the whole dotenv + # source is filtered. DotEnvSettingsSource merges all files into one + # dict, so we cannot filter per-file; over-filtering fails safe. An + # absolute/user-level env_file (including a "~/..." path) and real + # environment variables stay trusted. Consequence: secrets/keys placed + # in a cwd .env are dropped — put them in real env vars or an + # absolute/user-level file. env_file = settings_cls.model_config.get("env_file") - if ( - env_file is not None - and not isinstance(env_file, (list, tuple)) - and not Path(env_file).is_absolute() - ): + if env_file is None: + _env_entries: list = [] + elif isinstance(env_file, (list, tuple)): + _env_entries = list(env_file) + else: + _env_entries = [env_file] + _has_cwd_relative = any( + e is not None and not Path(e).expanduser().is_absolute() + for e in _env_entries + ) + if _has_cwd_relative: sources.append( _AllowlistFilterSource(settings_cls, dotenv_settings, "cwd .env") ) diff --git a/tests/test_config_trust.py b/tests/test_config_trust.py index 2fc389e..9d92c8d 100644 --- a/tests/test_config_trust.py +++ b/tests/test_config_trust.py @@ -126,3 +126,28 @@ def test_list_env_file_is_trusted(self, tmp_path, monkeypatch): abs_env.write_text("AGENTIC_RAW_LLM_LOGGING=true\n") s = self._subclass_with_env_file([str(abs_env)])() assert s.raw_llm_logging is True # list env_file trusted + + def test_tilde_env_file_is_trusted(self, tmp_path, monkeypatch): + # A "~"-prefixed env_file is a user-level path (pydantic expands ~ when + # reading it), so it must stay TRUSTED (unfiltered). + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.delenv("AGENTIC_RAW_LLM_LOGGING", raising=False) + (fake_home / "user.env").write_text("AGENTIC_RAW_LLM_LOGGING=true\n") + s = self._subclass_with_env_file("~/user.env")() + assert s.raw_llm_logging is True # tilde (user-level) env_file trusted + + def test_list_env_file_with_cwd_relative_entry_is_filtered(self, tmp_path, monkeypatch): + # If ANY entry in a list env_file is cwd-relative, the whole dotenv + # source is filtered (fail-safe over-filter) — a repo could otherwise + # ship ./.env alongside a trusted absolute file and stay unfiltered. + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.delenv("AGENTIC_RAW_LLM_LOGGING", raising=False) + abs_env = tmp_path / "abs.env" + abs_env.write_text("") # trusted absolute entry (empty) + (tmp_path / ".env").write_text("AGENTIC_RAW_LLM_LOGGING=true\n") # cwd-relative + s = self._subclass_with_env_file([str(abs_env), ".env"])() + assert s.raw_llm_logging is False # cwd-relative entry → whole source filtered