Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`" 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 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
- **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.

Expand Down
120 changes: 103 additions & 17 deletions src/agentic_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -52,11 +55,69 @@
]


# 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 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__(
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(
Expand All @@ -70,8 +131,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
Expand All @@ -88,14 +150,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):
Expand Down Expand Up @@ -204,8 +265,33 @@ 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.
# 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 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")
)
else:
sources.append(dotenv_settings)

return tuple(sources)

Expand Down
16 changes: 9 additions & 7 deletions src/agentic_cli/settings_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,18 @@ def get_user_config_path(app_name: str) -> Path:
return Path.home() / f".{app_name}" / "settings.json"


def get_project_local_permissions_path(app_name: str) -> Path:
def get_user_project_grants_path(app_name: str) -> Path:
"""Path to interactively-granted permission rules
(./.{app_name}/permissions.local.json).
(``~/.{app_name}/project_grants.json``), keyed by resolved project path.

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.
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::

{ "<resolved project root>": {"permissions": {"allow": [...], "deny": [...]}} }
"""
return Path.cwd() / f".{app_name}" / "permissions.local.json"
return Path.home() / f".{app_name}" / "project_grants.json"



class SettingsPersistence:
Expand Down
16 changes: 7 additions & 9 deletions src/agentic_cli/workflow/permissions/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +32,7 @@
from agentic_cli.workflow.permissions.store import (
BUILTIN_RULES,
PermissionContext,
load_project_grants,
load_rules,
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -250,7 +248,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})")
65 changes: 53 additions & 12 deletions src/agentic_cli/workflow/permissions/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -107,27 +107,68 @@ def load_rules(
return rules


def append_project_rule(app_name: str, rule: Rule) -> None:
"""Append ``rule`` to the project-local permissions file.
def load_project_grants(app_name: str, ctx: PermissionContext) -> list[Rule]:
"""Load interactive 'Allow always' grants for the CURRENT project only.

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``.
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
resolved path.

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

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 = 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)
Expand Down
Loading
Loading