agent-Specialization/modules/host_sandbox_policy.py
JOJO a93f17010c feat(security): unify host sandbox controls across command/python/terminal/sub-agent and add path authorization UI
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
2026-05-11 13:41:30 +08:00

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