blob: 6bbd78d4ef1e35f93fcf7fa1a7a3396dd4afb5c6 [file]
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Configuration management, atomic writing, and grant merging for AI coding agents."""
from __future__ import annotations
import datetime
import json
import os
import pathlib
import sys
from collections.abc import Sequence
from typing import Any
from agents.lib import permissions, state
def get_default_config_path() -> pathlib.Path:
"""Resolve default Gemini config path, respecting GEMINI_CONFIG_DIR or GEMINI_HOME."""
if "GEMINI_CONFIG_DIR" in os.environ:
return pathlib.Path(os.environ["GEMINI_CONFIG_DIR"]) / "config.json"
if "GEMINI_HOME" in os.environ:
return (
pathlib.Path(os.environ["GEMINI_HOME"]) / "config" / "config.json"
)
return pathlib.Path.home() / ".gemini" / "config" / "config.json"
DEFAULT_CONFIG_PATH = get_default_config_path()
def load_config(config_path: pathlib.Path) -> dict[str, Any]:
"""Load and parse JSON configuration from config_path.
Returns an empty dict if the file does not exist or does not contain a JSON object.
"""
if not config_path.is_file():
return {}
data = json.loads(config_path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
def save_config_atomic(
config_path: pathlib.Path, config_data: dict[str, Any]
) -> None:
"""Atomically write JSON configuration to disk via a temporary file."""
config_path.parent.mkdir(parents=True, exist_ok=True)
temp_config = config_path.with_name(f".{config_path.name}.tmp")
temp_config.write_text(
json.dumps(config_data, indent=2) + "\n", encoding="utf-8"
)
temp_config.replace(config_path)
def merge_grants(
existing: Sequence[str], to_add: Sequence[str]
) -> tuple[list[str], list[str]]:
"""Merge new grants into existing grant list, preserving order and deduplicating.
Returns:
tuple of (updated_list, added_items)
"""
seen = set(existing)
updated = list(existing)
added: list[str] = []
for item in to_add:
if item not in seen:
seen.add(item)
updated.append(item)
added.append(item)
return updated, added
def apply_grants(
config_path: pathlib.Path | None = None,
allow: Sequence[str] = (),
deny: Sequence[str] = (),
ask: Sequence[str] = (),
dry_run: bool = False,
state_dir: pathlib.Path | None = None,
selected_profile: str = "",
) -> bool:
"""Load config.json, reconcile allow/deny/ask grants, create backup, and update state journal."""
config_path = config_path or get_default_config_path()
state_dir = state_dir or state.get_default_state_dir()
state_path = state_dir / "state.json"
backups_dir = state_dir / "backups"
journal = state.load_state(state_path)
try:
config_data = load_config(config_path)
except json.JSONDecodeError as error:
print(
f"Error reading JSON from {config_path}: {error}",
file=sys.stderr,
)
return False
user_settings = config_data.setdefault("userSettings", {})
existing_grants = user_settings.setdefault("globalPermissionGrants", {})
target_grants = {"allow": allow, "deny": deny, "ask": ask}
final_grants, target_managed = state.reconcile_grants(
existing_grants=existing_grants,
prev_managed=journal.managed_grants,
target_managed=target_grants,
)
print("\n=== Permission Updates Summary ===")
total_changes = 0
for category_name in ("allow", "deny", "ask"):
new_rules = set(final_grants.get(category_name, []))
old_rules = set(existing_grants.get(category_name, []))
added = sorted(new_rules - old_rules)
removed = sorted(old_rules - new_rules)
total_changes += len(added) + len(removed)
if added:
print(f" + Added to [{category_name}] ({len(added)}):")
for grant in added:
print(f" - {grant}")
if removed:
print(f" - Removed from [{category_name}] ({len(removed)}):")
for grant in removed:
print(f" - {grant}")
if total_changes == 0:
print(" (No changes to rules; all entries up to date in config.json)")
user_settings["globalPermissionGrants"] = final_grants
if dry_run:
print(f"\n[DRY RUN] Would write updated config to: {config_path}")
print(f"[DRY RUN] Would update state journal at: {state_path}")
if config_path.exists():
print(f"[DRY RUN] Would create backup in: {backups_dir}")
return True
# Create backup before modifying existing config
backup_path: pathlib.Path | None = None
if config_path.exists():
backup_path = state.create_backup(config_path, backups_dir)
if backup_path:
print(f"\nCreated backup of existing config at: {backup_path}")
save_config_atomic(config_path, config_data)
print(f"\nSuccessfully wrote updated config to: {config_path}")
# Update state journal
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
fuchsia_root_str = str(permissions.find_fuchsia_dir())
except RuntimeError:
fuchsia_root_str = ""
journal.schema_version = 1
journal.last_updated = now_str
journal.fuchsia_root = fuchsia_root_str
journal.active_profile = selected_profile
journal.managed_grants = target_managed
journal.history.append(
{
"timestamp": now_str,
"profile": selected_profile,
"backup_file": str(backup_path) if backup_path else None,
"managed_grants": target_managed,
}
)
state.save_state(journal, state_path)
return True