This commit lands a broad host-security architecture update focused on enforceable sandbox execution, clearer permission semantics, and operator usability. Core execution/security changes - Introduce and wire a unified host sandbox runner for multi-OS execution: - macOS: sandbox-exec - Linux: bubblewrap + seccomp - Windows: WSL2 path - Remove silent fallback behavior in failure paths; sandbox-unavailable cases now fail closed instead of dropping to unsafe host execution. - Ensure host execution mode (sandbox/direct) is propagated consistently into runtime components, including sub-agent startup. Permission mode model upgrade - Rework readonly/approval behavior for run_command from brittle command-text gating to execution-layer enforcement: - readonly: run_command executes in read-only sandbox profile. - approval: run_command first executes read-only; when permission-denied is detected, request approval and retry once with writable sandbox for that single call. - Tool loop now returns final post-approval execution result, not intermediate permission-denied payloads. - Update permission-mode system messaging to describe user-visible behavior without exposing internal implementation details. Path authorization system - Add dynamic host policy module and persisted policy file. - Support dual path classes: - writable_paths (read+write) - readable_extra_paths (read-only) - Enforce file access in file_manager by access type (read vs write) under host mode. - Add host-only frontend 路径授权 management dialog (settings三级菜单入口), including mode switch between 可读可写 and 仅可读 with separate drafts and save flow. Sub-agent and terminal alignment - Sub-agent process launch now respects host execution mode and sandbox controls in host mode. - Keep terminal session model compatible with sandbox-first behavior and execution-mode propagation. Tool surface updates - Remove legacy file-management tools from active tool definitions (create_file/create_folder/rename_file/delete_file) while preserving historical conversation compatibility. Docs updates - Add dedicated architecture doc: docs/host_sandbox_and_permission_model.md - Refresh README and AGENTS sections to reflect updated permission/execution model and path-authorization semantics. Validation performed - python unittest smoke suite passes: test.test_server_refactor_smoke - frontend build passes: npm run build - syntax checks for touched Python modules completed
79 lines
2.8 KiB
Python
79 lines
2.8 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Dict, List
|
|
|
|
from config import HOST_SANDBOX_MACOS_WRITABLE_PATHS
|
|
|
|
_LOCK = threading.Lock()
|
|
_POLICY_PATH = Path(__file__).resolve().parents[1] / "config" / "host_sandbox_policy.json"
|
|
|
|
|
|
def _ensure_file() -> None:
|
|
if _POLICY_PATH.exists():
|
|
return
|
|
_POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
_POLICY_PATH.write_text(
|
|
json.dumps({"macos_writable_paths": [], "macos_readable_extra_paths": []}, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def load_policy() -> Dict:
|
|
with _LOCK:
|
|
_ensure_file()
|
|
try:
|
|
data = json.loads(_POLICY_PATH.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
data = {"macos_writable_paths": [], "macos_readable_extra_paths": []}
|
|
if not isinstance(data, dict):
|
|
data = {"macos_writable_paths": [], "macos_readable_extra_paths": []}
|
|
writable_items = data.get("macos_writable_paths")
|
|
if not isinstance(writable_items, list):
|
|
writable_items = []
|
|
readable_items = data.get("macos_readable_extra_paths")
|
|
if not isinstance(readable_items, list):
|
|
readable_items = []
|
|
data["macos_writable_paths"] = [str(x).strip() for x in writable_items if str(x).strip()]
|
|
data["macos_readable_extra_paths"] = [str(x).strip() for x in readable_items if str(x).strip()]
|
|
return data
|
|
|
|
|
|
def save_policy(policy: Dict) -> Dict:
|
|
payload = dict(policy or {})
|
|
writable_items = payload.get("macos_writable_paths")
|
|
if not isinstance(writable_items, list):
|
|
writable_items = []
|
|
readable_items = payload.get("macos_readable_extra_paths")
|
|
if not isinstance(readable_items, list):
|
|
readable_items = []
|
|
payload["macos_writable_paths"] = [str(x).strip() for x in writable_items if str(x).strip()]
|
|
payload["macos_readable_extra_paths"] = [str(x).strip() for x in readable_items if str(x).strip()]
|
|
with _LOCK:
|
|
_ensure_file()
|
|
_POLICY_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return payload
|
|
|
|
|
|
def get_macos_writable_paths() -> List[str]:
|
|
file_items = load_policy().get("macos_writable_paths", [])
|
|
merged: List[str] = []
|
|
for raw in list(HOST_SANDBOX_MACOS_WRITABLE_PATHS or []) + list(file_items or []):
|
|
val = str(raw).strip()
|
|
if val and val not in merged:
|
|
merged.append(val)
|
|
return merged
|
|
|
|
|
|
def get_macos_readable_paths() -> List[str]:
|
|
data = load_policy()
|
|
readable_extra = data.get("macos_readable_extra_paths", [])
|
|
merged: List[str] = []
|
|
for raw in list(get_macos_writable_paths()) + list(readable_extra or []):
|
|
val = str(raw).strip()
|
|
if val and val not in merged:
|
|
merged.append(val)
|
|
return merged
|