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
This commit is contained in:
JOJO 2026-05-11 13:41:30 +08:00
parent 6ee8cda2a7
commit a93f17010c
24 changed files with 937 additions and 110 deletions

View File

@ -1,6 +1,6 @@
# Repository Guidelines (Code-Verified)
> Last verified against current codebase: 2026-04-10
> Last verified against current codebase: 2026-05-11
> Scope: `/Users/jojo/Desktop/agents/正在修复中/agents`
这份文档基于当前仓库实际代码重写。若与未来代码冲突,以代码为准并及时更新本文档。
@ -154,3 +154,44 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
2. 核心变更点
3. 验证结果(已执行命令/未执行原因)
- 如果发现本文档过时,直接更新 `AGENTS.md` 并在结果中说明依据。
## 10) 宿主机权限模式与沙箱执行机制2026-05 更新)
完整说明文档:`docs/host_sandbox_and_permission_model.md`
为避免误解,当前系统有两套独立但叠加的控制:
1. **权限模式Permission Mode**`readonly` / `approval` / `unrestricted`
2. **执行环境Execution Mode**`sandbox` / `direct`(仅宿主机模式可切换)
### 10.1 权限模式
- `readonly`
- `run_command` 可调用,但在只读沙箱执行;写入由系统拒绝
- `write_file` / `edit_file` 等写入类工具直接拒绝
- `approval`
- `run_command` 先走只读沙箱
- 若出现权限拒绝(例如 `Operation not permitted` / `Permission denied`),触发前端审批
- 审批通过后,仅该次命令以可写沙箱重试;工具结果返回“重试后的最终结果”
- `unrestricted`
- 不做权限模式拦截;是否沙箱由执行环境决定
### 10.2 执行环境
- `sandbox`(默认):使用 OS 沙箱执行macOS: `sandbox-exec`Linux: `bwrap + seccomp`Windows: `WSL2`
- `direct`:宿主机直接执行(高风险,仅建议短时使用)
- 宿主机下支持会话级 TTL 自动回退 `direct -> sandbox`
### 10.3 路径授权语义
- 前端“路径授权”支持两类路径:
- **可读可写**
- **仅可读**
- 语义:
- 可读集合 = 可读可写 + 仅可读
- 可写集合 = 可读可写
### 10.4 维护约束
- 不要在提示词里暴露内部实现细节(例如“命令文本猜测”等)
- 文案应面向用户能力边界与操作建议,不描述内部判定算法

View File

@ -44,13 +44,13 @@
#### 文件操作
- `read_file`: 读取文件内容
- `create_file` / `write_file`: 创建和写入文件
- `write_file`: 写入文件
- `edit_file`: 精确编辑文件内容
- `delete_file` / `rename_file`: 文件管理
- `create_folder`: 创建目录
- `list_files`: 列出目录内容
- `search_files`: 搜索文件
> 说明:目录创建/重命名/删除等通用文件管理建议统一使用 `run_command``run_python`
#### 终端操作
**实时终端会话**:
@ -127,6 +127,21 @@ TERMINAL_SANDBOX_MODE=host
HOST_WORKSPACES_FILE=./config/host_workspaces.json
```
宿主机模式下的安全机制(新增):
- **执行环境Execution Mode**
- `sandbox`默认命令在系统级沙箱执行macOS: `sandbox-exec`Linux: `bwrap + seccomp`Windows: `WSL2`
- `direct`:宿主机直接执行(仅建议临时用于必须操作)
- 仅宿主机管理员可切换,支持会话级 TTL 自动回退到 `sandbox`
- **权限模式Permission Mode**
- `readonly``run_command` 在只读沙箱执行;写入会被系统拒绝
- `approval``run_command` 先按只读沙箱执行;若遇权限拒绝,再触发前端审批;审批通过后仅该次命令以可写沙箱重试
- `unrestricted`:不做权限模式拦截(仍受执行环境约束)
- **路径授权Path Authorization**
- 可配置两类路径:`可读可写` 与 `仅可读`
- 语义:`可读集合 = 可读可写 + 仅可读``可写集合 = 可读可写`
- 默认建议:仅工作区 + 临时目录可写,其余按需白名单
`HOST_WORKSPACES_FILE` 对应 JSON 示例:
```json
@ -176,6 +191,19 @@ TERMINAL_SANDBOX_MOUNT_PATH=/workspace
| 环境一致性 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 工具访问 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
### 🔐 宿主机安全边界总览
在宿主机模式下,安全边界分两层:
1. **权限模式(产品层)**:决定是否允许执行、是否需要审批、是否使用只读沙箱优先策略。
2. **执行环境(系统层)**:决定命令最终在 `sandbox` 还是 `direct` 运行。
推荐默认:
- 执行环境:`sandbox`
- 权限模式:`approval`(通用)或 `readonly`(高保守)
详细机制文档见:`docs/host_sandbox_and_permission_model.md`
### 👥 多用户管理
完整的用户系统:

View File

@ -0,0 +1,8 @@
{
"macos_writable_paths": [
"/Users/jojo/Desktop"
],
"macos_readable_extra_paths": [
"/Users/jojo/Desktop"
]
}

View File

@ -251,6 +251,8 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
self.terminal_ops.set_host_execution_mode(mode)
if getattr(self, "terminal_manager", None):
self.terminal_manager.set_host_execution_mode(mode)
if getattr(self, "sub_agent_manager", None):
self.sub_agent_manager.set_host_execution_mode(mode)
def _refresh_execution_mode_by_ttl(self):
if self.host_execution_mode != "direct":

View File

@ -119,25 +119,21 @@ class MainTerminalContextMixin:
detailed_rules = (
"- 所有命令和工具均可直接使用,无需用户批准\n"
"- run_command支持任意命令包括管道、重定向、子shell等\n"
"- 文件操作:write_file、edit_file、create_file、delete_file、rename_file、create_folder 均可直接使用"
"- 文件操作:read_file / write_file / edit_file 可直接使用;其他文件管理请通过 run_command 或 run_python 执行"
)
elif mode == "readonly":
detailed_rules = (
"- run_command仅允许纯只读命令ls, cat, grep, find, head, tail, wc, stat, file, sort, uniq 等)\n"
"- 允许只读管道:如 `find . -name '*.py' | head -20`、`cat file | sort | uniq`\n"
"- 禁止子shell$() ``)、逻辑运算符(&& || ;)、重定向(> <\n"
"- 禁止危险管道:管道指向 shellbash/sh、tee、xargs、chmod 等写入或执行操作\n"
"- run_command统一在系统只读沙箱中执行\n"
"- 若命令触发写入,系统会直接拒绝并返回权限错误\n"
"- 文件操作:仅允许 read_file、view_image、view_video 等读取类工具\n"
"- 禁止write_file、edit_file、create_file、delete_file、rename_file、create_folder 等修改类工具"
"- 禁止write_file、edit_file 及会修改工作区的命令操作"
)
elif mode == "approval":
detailed_rules = (
"- 免批准直通纯只读命令ls, cat, grep, find 等)及只读管道可直接执行\n"
"- 免批准直通:如 `find . -name '*.py' | head -20`、`grep 'error' log | wc -l`\n"
"- 需要用户批准非只读命令带重定向、子shell、逻辑运算符\n"
"- 需要用户批准:危险管道(指向 bash、tee、xargs、chmod 等)\n"
"- 需要用户批准terminal_input、run_python、write_file、edit_file、create_file、delete_file、rename_file、create_folder、save_webpage\n"
"- 系统会在执行前弹出批准请求,请等待用户确认后再继续"
"- run_command先在系统只读沙箱执行仅当出现权限拒绝时才触发审批\n"
"- 审批通过后:仅当前这一次命令会重试为可写沙箱执行\n"
"- 需要用户批准terminal_input、run_python、write_file、edit_file、save_webpage 及其他会修改工作区的操作\n"
"- 被拒绝或超时:本次操作不会执行写入"
)
else:
detailed_rules = ""
@ -171,7 +167,7 @@ class MainTerminalContextMixin:
if mode == "sandbox":
rules = (
"- 所有命令默认在系统 OS 沙箱中执行\n"
"- 若命令因系统权限或目录访问限制失败,请先给出替代方案(只读检查、分步验证);若该操作确属必须,再提示用户切换为“完全访问权限”或让用户在本地授权白名单后重试"
"- 若命令因系统权限或目录访问限制失败,请先给出替代方案(只读检查、分步验证);若该操作确属必须,请用户调整路径授权(可读可写 / 仅可读)后重试"
)
else:
rules = (

View File

@ -393,22 +393,6 @@ class MainTerminalToolsDefinitionMixin:
}
}
},
{
"type": "function",
"function": {
"name": "create_file",
"description": "创建新文件(仅创建空文件,正文请使用 write_file 或 edit_file 写入/替换)",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"path": {"type": "string", "description": "文件路径"},
"file_type": {"type": "string", "enum": ["txt", "py", "md"], "description": "文件类型"},
"annotation": {"type": "string", "description": "文件备注"}
}),
"required": ["path", "file_type", "annotation"]
}
}
},
{
"type": "function",
"function": {
@ -571,49 +555,6 @@ class MainTerminalToolsDefinitionMixin:
}
}
},
{
"type": "function",
"function": {
"name": "delete_file",
"description": "删除文件",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"path": {"type": "string", "description": "文件路径"}
}),
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "rename_file",
"description": "重命名文件",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"old_path": {"type": "string", "description": "原文件路径"},
"new_path": {"type": "string", "description": "新文件路径"}
}),
"required": ["old_path", "new_path"]
}
}
},
{
"type": "function",
"function": {
"name": "create_folder",
"description": "创建文件夹",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"path": {"type": "string", "description": "文件夹路径"}
}),
"required": ["path"]
}
}
},
{
"type": "function",
"function": {

View File

@ -320,17 +320,7 @@ class MainTerminalToolsExecutionMixin:
if mode == "readonly":
if tool_name == "run_command":
if self._is_readonly_run_command_allowed(args.get("command")):
return {"allowed": True, "mode": mode}
return {
"allowed": False,
"mode": mode,
"code": "readonly_denied",
"message": (
"当前处于只读模式run_command 仅允许只读命令"
"grep/find/ls/cat/rg/git status 等)且禁止多指令拼接。"
)
}
return {"allowed": True, "mode": mode}
if tool_name not in self._READONLY_ALLOWED_TOOLS:
return {
"allowed": False,
@ -341,7 +331,9 @@ class MainTerminalToolsExecutionMixin:
return {"allowed": True, "mode": mode}
if mode == "approval":
if tool_name in {"run_command", "terminal_input"}:
if tool_name == "run_command":
return {"allowed": True, "mode": mode, "requires_approval": False}
if tool_name == "terminal_input":
command_text = args.get("command") or args.get("input")
if self._is_readonly_run_command_allowed(command_text):
return {"allowed": True, "mode": mode, "requires_approval": False}
@ -1272,6 +1264,16 @@ class MainTerminalToolsExecutionMixin:
)
elif tool_name == "run_command":
permission_mode = "unrestricted"
try:
permission_mode = self.get_permission_mode()
except Exception:
permission_mode = str(getattr(self, "current_permission_mode", "unrestricted") or "unrestricted")
write_granted_once = bool(arguments.get("_approval_write_granted", False))
sandbox_write_access = not (
permission_mode == "readonly"
or (permission_mode == "approval" and not write_granted_once)
)
run_in_background = bool(arguments.get("run_in_background", False))
timeout_value = arguments.get("timeout")
if run_in_background:
@ -1311,7 +1313,8 @@ class MainTerminalToolsExecutionMixin:
else:
result = await self.terminal_ops.run_command(
arguments["command"],
timeout=timeout_value
timeout=timeout_value,
sandbox_write_access=sandbox_write_access,
)
# 字符数检查

View File

@ -0,0 +1,178 @@
# 宿主机沙箱与权限系统说明2026-05
本文档用于完整说明当前项目在 **宿主机模式(`TERMINAL_SANDBOX_MODE=host`** 下的安全执行机制,包括:
- OS 级沙箱执行
- 执行环境Execution Mode
- 权限模式Permission Mode
- 路径授权Path Authorization
- `run_command` / `run_python` / `terminal` / `sub_agent` 的实际行为
---
## 1. 设计目标
在保留宿主机开发效率的前提下,降低误操作与越权风险,核心目标是:
1. 默认命令执行必须经过系统沙箱(而非裸 host
2. 权限控制尽量由执行层强制(减少“猜指令”误判)
3. 对高风险写操作引入用户审批与单次授权机制
4. 保持可运维:可配置、可回退、可解释
---
## 2. 两层控制模型
当前系统分为两层控制,且叠加生效:
### 2.1 权限模式(产品层)
- `readonly`
- `approval`
- `unrestricted`
作用:决定工具是否允许调用、是否先只读执行、是否需要用户审批。
### 2.2 执行环境(系统层)
- `sandbox`(默认)
- `direct`
作用:决定进程最终在 OS 沙箱中执行,还是直接在宿主机执行。
> 推荐默认:`permission_mode=approval` + `execution_mode=sandbox`
---
## 3. OS 级沙箱方案
宿主机模式下,默认执行环境为 `sandbox`,各系统实现如下:
- **macOS**`sandbox-exec`
- **Linux**`bubblewrap (bwrap) + seccomp`
- **Windows**`WSL2`
若宿主机沙箱不可用,关键执行路径会拒绝执行,不允许静默回退到裸 host。
---
## 4. 路径授权模型
路径授权分两类:
1. **可读可写路径writable_paths**
2. **仅可读路径readable_extra_paths**
语义:
- 可读集合 = 可读可写 + 仅可读
- 可写集合 = 可读可写
前端“路径授权”弹窗支持这两类路径的维护(新增/编辑/删除),并由后端策略模块统一读取。
---
## 5. 各权限模式的实际行为
## 5.1 readonly
- `run_command`:允许调用,但在**只读沙箱**执行
- 若命令触发写入:由系统返回权限拒绝(例如 `Operation not permitted`
- `write_file` / `edit_file` / 其他写入类工具:直接拒绝
## 5.2 approval
- `run_command` 采用“两段式”:
1) 先在只读沙箱执行
2) 若出现权限拒绝,再发起前端审批
3) 审批通过后,仅该次命令以可写沙箱重试
- 工具结果返回“最终执行结果”(即重试后结果),不会把中间权限拒绝作为最终结果返回给模型
- 审批拒绝或超时:返回 rejected不执行写入重试
## 5.3 unrestricted
- 权限模式层不拦截工具
- 是否沙箱执行取决于执行环境:
- `sandbox`:仍在 OS 沙箱中执行
- `direct`:宿主机直接执行
---
## 6. 执行环境sandbox/direct细节
## 6.1 sandbox默认
- 命令在 OS 沙箱执行
- 配合路径授权限制写边界(与读边界策略)
- 可结合权限模式实现只读优先与单次升级写权限
## 6.2 direct高权限
- 命令直接在宿主机执行
- 仅建议临时用于必须操作
- 支持 TTL 自动回退(会话级)到 `sandbox`
---
## 7. 关键工具与执行路径
### 7.1 run_command
- 支持权限模式联动readonly/approval/unrestricted
- 在 host + sandbox 下可走只读沙箱或可写沙箱
- 在 approval 模式可触发“权限拒绝 -> 审批 -> 单次可写重试”
### 7.2 run_python
- 复用 `run_command` 执行路径与执行环境策略
- 与 `run_command` 一致受 sandbox/direct 与权限模式影响
### 7.3 terminal 系列
- 终端会话从启动时就在沙箱里host+sandbox
- 同一会话中的后续输入继承该会话沙箱上下文
- 若路径授权更新,需要新开终端会话才能让该会话使用新策略
### 7.4 子智能体sub_agent
- 宿主机下已接入执行环境策略:
- `sandbox`:子智能体进程经宿主机沙箱启动
- `direct`:直接宿主机启动
- Docker 模式维持容器执行
---
## 8. 配置与运行建议
## 8.1 推荐默认策略
1. `TERMINAL_SANDBOX_MODE=host`
2. 执行环境默认 `sandbox`
3. 权限模式默认 `approval`
4. 路径授权默认最小化(工作区 + 临时目录)
## 8.2 何时使用 direct
仅在以下场景短时开启:
- 明确需要系统级高权限访问
- 沙箱下无法完成且替代方案不可行
执行完成后应尽快回到 `sandbox`
---
## 9. 已知权衡
1. `run_command` 若严格收紧读路径,可能导致大量系统命令可用性下降
2. 权限拒绝识别基于错误特征,需持续优化误判/漏判边界
3. 不同 OS 的沙箱能力不完全对齐Windows 侧为 WSL2 路径)
---
## 10. 文案与产品约束
- 面向用户的提示词仅描述“能力边界”和“可操作建议”
- 不暴露内部实现细节(如内部判定逻辑、实现细节名称)
- 审批流程文案要强调“单次授权、最小权限、可回退”

View File

@ -35,6 +35,7 @@ except ImportError: # 兼容全局环境中存在同名包的情况
LINUX_SAFETY,
)
from modules.container_file_proxy import ContainerFileProxy
from modules.host_sandbox_policy import get_macos_writable_paths, get_macos_readable_paths
from utils.logger import setup_logger
if TYPE_CHECKING:
@ -190,6 +191,41 @@ class FileManager:
return str(full_path.relative_to(self.project_path))
except ValueError:
return str(full_path)
@staticmethod
def _path_in_allowed_roots(target: Path, roots: List[Path]) -> bool:
for root in roots:
try:
target.relative_to(root)
return True
except Exception:
continue
return False
def _host_allowed_roots(self, access: str) -> List[Path]:
roots: List[Path] = [self.project_path.resolve(), Path("/tmp").resolve(), Path("/private/tmp").resolve()]
raw_items = get_macos_writable_paths() if access == "write" else get_macos_readable_paths()
for raw in raw_items:
try:
p = Path(raw).expanduser().resolve()
except Exception:
continue
if p not in roots:
roots.append(p)
return roots
def _ensure_host_access(self, full_path: Path, access: str) -> Tuple[bool, str]:
if not self._is_host_mode():
return True, ""
check_target = full_path
if access == "write" and not full_path.exists():
check_target = full_path.parent.resolve()
allowed_roots = self._host_allowed_roots(access)
if self._path_in_allowed_roots(check_target.resolve(), allowed_roots):
return True, ""
if access == "write":
return False, "目标路径不在可写授权范围内,请在路径授权中添加后重试。"
return False, "目标路径不在可读授权范围内,请在路径授权中添加后重试。"
def create_file(self, path: str, content: str = "", file_type: str = "txt") -> Dict:
"""创建文件"""
@ -200,6 +236,9 @@ class FileManager:
# 添加文件扩展名
if not full_path.suffix:
full_path = full_path.with_suffix(f".{file_type}")
ok, msg = self._ensure_host_access(full_path, "write")
if not ok:
return {"success": False, "error": msg}
relative_path = self._relative_path(full_path)
try:
@ -251,6 +290,9 @@ class FileManager:
if not full_path.is_file():
return {"success": False, "error": "不是文件"}
ok, msg = self._ensure_host_access(full_path, "write")
if not ok:
return {"success": False, "error": msg}
try:
relative_path = self._relative_path(full_path)
@ -292,6 +334,12 @@ class FileManager:
if full_new_path.exists():
return {"success": False, "error": "目标文件已存在"}
ok_old, msg_old = self._ensure_host_access(full_old_path, "write")
if not ok_old:
return {"success": False, "error": msg_old}
ok_new, msg_new = self._ensure_host_access(full_new_path, "write")
if not ok_new:
return {"success": False, "error": msg_new}
try:
old_relative = self._relative_path(full_old_path)
@ -326,6 +374,9 @@ class FileManager:
if full_path.exists():
return {"success": False, "error": "文件夹已存在"}
ok, msg = self._ensure_host_access(full_path, "write")
if not ok:
return {"success": False, "error": msg}
try:
relative_path = self._relative_path(full_path)
@ -418,6 +469,9 @@ class FileManager:
if not full_path.is_file():
return {"success": False, "error": "不是文件"}
ok, msg = self._ensure_host_access(full_path, "read")
if not ok:
return {"success": False, "error": msg}
if self._use_container():
relative_path = self._relative_path(full_path)
@ -457,6 +511,9 @@ class FileManager:
if not full_path.is_file():
return {"success": False, "error": "不是文件"}
ok, msg = self._ensure_host_access(full_path, "read")
if not ok:
return {"success": False, "error": msg}
if self._use_container():
relative_path = self._relative_path(full_path)
@ -551,6 +608,9 @@ class FileManager:
if not full_path.is_file():
return {"success": False, "error": "不是文件"}
ok, msg = self._ensure_host_access(full_path, "read")
if not ok:
return {"success": False, "error": msg}
result = self._read_text_lines(
full_path,
@ -618,6 +678,9 @@ class FileManager:
if not full_path.is_file():
return {"success": False, "error": "不是文件"}
ok, msg = self._ensure_host_access(full_path, "read")
if not ok:
return {"success": False, "error": msg}
result = self._read_text_lines(
full_path,
@ -672,6 +735,9 @@ class FileManager:
valid, error, full_path = self._validate_path(path)
if not valid:
return {"success": False, "error": error}
ok, msg = self._ensure_host_access(full_path, "write")
if not ok:
return {"success": False, "error": msg}
# === 新增:内容预处理和验证 ===
if content:

View File

@ -0,0 +1,78 @@
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

View File

@ -7,10 +7,7 @@ import shlex
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional
try:
from config import HOST_SANDBOX_MACOS_WRITABLE_PATHS
except Exception:
HOST_SANDBOX_MACOS_WRITABLE_PATHS = []
from modules.host_sandbox_policy import get_macos_writable_paths
@dataclass
@ -43,6 +40,17 @@ def build_host_sandbox_plan(command: str, work_path: Path, env: Dict[str, str])
return _build_windows_plan(command, work_path, env)
raise HostSandboxError(f"不支持的宿主机系统: {system}")
def build_host_sandbox_readonly_plan(command: str, work_path: Path, env: Dict[str, str]) -> SandboxPlan:
system = platform.system()
if system == "Darwin":
return _build_macos_readonly_plan(command, work_path, env)
if system == "Linux":
return _build_linux_readonly_plan(command, work_path, env)
if system == "Windows":
return _build_windows_plan(command, work_path, env)
raise HostSandboxError(f"不支持的宿主机系统: {system}")
def build_host_sandbox_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan:
system = platform.system()
if system == "Darwin":
@ -62,6 +70,23 @@ def _build_macos_plan(command: str, work_path: Path, env: Dict[str, str]) -> San
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command]
return SandboxPlan(command=cmd, env=env, cwd=str(work_path))
def _build_macos_readonly_plan(command: str, work_path: Path, env: Dict[str, str]) -> SandboxPlan:
sandbox_exec = shutil.which("sandbox-exec")
if not sandbox_exec:
raise HostSandboxError("macOS 未找到 sandbox-exec拒绝执行宿主机命令。")
profile = (
'(version 1) '
'(deny default) '
'(allow sysctl-read) '
'(allow process*) '
'(allow network*) '
'(allow file-read*) '
'(allow file-write* (literal "/dev/null"))'
)
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command]
return SandboxPlan(command=cmd, env=env, cwd=str(work_path))
def _build_macos_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan:
sandbox_exec = shutil.which("sandbox-exec")
if not sandbox_exec:
@ -73,7 +98,7 @@ def _build_macos_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan
def _macos_profile_for_workspace(work_path: Path) -> str:
workspace = str(work_path.resolve())
writable_paths = [workspace, "/tmp", "/private/tmp", "/dev/null"]
for raw in HOST_SANDBOX_MACOS_WRITABLE_PATHS or []:
for raw in get_macos_writable_paths():
try:
expanded = str(Path(raw).expanduser().resolve())
except Exception:
@ -112,6 +137,20 @@ def _build_linux_plan(command: str, work_path: Path, env: Dict[str, str]) -> San
shell_cmd = ["/bin/bash", "-lc", command]
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path)
def _build_linux_readonly_plan(command: str, work_path: Path, env: Dict[str, str]) -> SandboxPlan:
bwrap = shutil.which("bwrap")
if not bwrap:
raise HostSandboxError("Linux 未找到 bubblewrap(bwrap),拒绝执行宿主机命令。")
seccomp_bpf = os.environ.get("HOST_SANDBOX_LINUX_SECCOMP_BPF", "").strip()
if not seccomp_bpf:
raise HostSandboxError("Linux 缺少 HOST_SANDBOX_LINUX_SECCOMP_BPF拒绝执行宿主机命令。")
seccomp_path = Path(seccomp_bpf).expanduser().resolve()
if not seccomp_path.exists():
raise HostSandboxError(f"seccomp BPF 文件不存在: {seccomp_path}")
shell_cmd = ["/bin/bash", "-lc", command]
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path, readonly=True)
def _build_linux_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan:
bwrap = shutil.which("bwrap")
if not bwrap:
@ -130,12 +169,13 @@ def _build_linux_common_plan(
env: Dict[str, str],
shell_cmd: List[str],
seccomp_path: Path,
readonly: bool = False,
) -> SandboxPlan:
bwrap = shutil.which("bwrap")
if not bwrap:
raise HostSandboxError("Linux 未找到 bubblewrap(bwrap)。")
sandbox_root = str(work_path.resolve())
cmd = [
cmd: List[str] = [
bwrap,
"--die-with-parent",
"--new-session",
@ -144,9 +184,12 @@ def _build_linux_common_plan(
"--ro-bind",
"/",
"/",
"--bind",
sandbox_root,
sandbox_root,
]
if readonly:
cmd.extend(["--ro-bind", sandbox_root, sandbox_root])
else:
cmd.extend(["--bind", sandbox_root, sandbox_root])
cmd.extend([
"--chdir",
sandbox_root,
"--proc",
@ -158,7 +201,7 @@ def _build_linux_common_plan(
"--seccomp",
"__SECCOMP_FD__",
*shell_cmd,
]
])
return SandboxPlan(command=cmd, env=env, cwd=sandbox_root, seccomp_bpf_path=str(seccomp_path))

View File

@ -5,6 +5,8 @@ import subprocess
import time
import uuid
import os
import shlex
import platform
import shutil
import signal
from pathlib import Path, PurePosixPath
@ -20,6 +22,7 @@ from config import (
)
from utils.logger import setup_logger
import logging
from modules.host_sandbox_runner import build_host_sandbox_plan, host_sandbox_enabled, HostSandboxError
if TYPE_CHECKING:
from modules.user_container_manager import ContainerHandle
@ -48,6 +51,7 @@ class SubAgentManager:
self.base_dir = Path(SUB_AGENT_TASKS_BASE_DIR).resolve()
self.state_file = Path(SUB_AGENT_STATE_FILE).resolve()
self.container_session: Optional["ContainerHandle"] = container_session
self.host_execution_mode: str = "sandbox"
# easyagent批处理入口优先使用项目目录内的副本
candidate_batch = self.project_path / "easyagent" / "src" / "batch" / "index.js"
@ -158,6 +162,10 @@ class SubAgentManager:
execution_mode = "host"
container_name = None
env = os.environ.copy()
launch_cmd = list(cmd)
launch_cwd = str(self.project_path)
pass_fds: tuple[int, ...] = tuple()
seccomp_fd: Optional[int] = None
if self._should_use_container():
container = self.container_session
@ -176,17 +184,43 @@ class SubAgentManager:
env["EASYAGENT_CONTAINER_PYTHON"] = os.environ.get("CONTAINER_PYTHON_CMD", "/opt/agent-venv/bin/python3")
env["EASYAGENT_CONTAINER_MODE"] = "1"
execution_mode = "docker"
else:
use_host_sandbox = self.host_execution_mode != "direct"
if use_host_sandbox:
if not host_sandbox_enabled():
return {"success": False, "error": "宿主机子智能体执行被拒绝HOST_SANDBOX_ENABLED=0"}
try:
command_str = self._join_shell_words(cmd)
plan = build_host_sandbox_plan(command_str, self.project_path, env)
launch_cmd, pass_fds, seccomp_fd = self._materialize_seccomp_fd(
plan.command,
plan.seccomp_bpf_path,
)
launch_cwd = plan.cwd or str(self.project_path)
env = plan.env
execution_mode = "host_sandbox"
except HostSandboxError as exc:
return {"success": False, "error": f"宿主机沙箱不可用,拒绝启动子智能体:{exc}"}
except Exception as exc:
return {"success": False, "error": f"构建宿主机沙箱启动命令失败:{exc}"}
try:
process = subprocess.Popen(
cmd,
launch_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=str(self.project_path),
cwd=launch_cwd,
env=env,
pass_fds=pass_fds,
)
except Exception as exc:
return {"success": False, "error": f"启动子智能体失败: {exc}"}
finally:
if seccomp_fd is not None:
try:
os.close(seccomp_fd)
except OSError:
pass
# 记录任务
task_record = {
@ -309,6 +343,10 @@ class SubAgentManager:
"""更新容器会话信息。"""
self.container_session = session
def set_host_execution_mode(self, mode: str) -> None:
normalized = str(mode or "").strip().lower()
self.host_execution_mode = "direct" if normalized == "direct" else "sandbox"
# ------------------------------------------------------------------
# 内部工具方法
# ------------------------------------------------------------------
@ -360,6 +398,23 @@ class SubAgentManager:
if thinking_mode:
cmd.extend(["--thinking-mode", thinking_mode])
return cmd
@staticmethod
def _join_shell_words(parts: List[str]) -> str:
if platform.system() == "Windows":
return subprocess.list2cmdline(parts)
return " ".join(shlex.quote(p) for p in parts)
@staticmethod
def _materialize_seccomp_fd(
plan_command: List[str], seccomp_path: Optional[str]
) -> Tuple[List[str], tuple[int, ...], Optional[int]]:
if not seccomp_path:
return plan_command, tuple(), None
seccomp_fd = os.open(seccomp_path, os.O_RDONLY)
fd_num = seccomp_fd
cmd = [str(fd_num) if token == "__SECCOMP_FD__" else token for token in plan_command]
return cmd, (fd_num,), seccomp_fd
def _check_task_status(self, task: Dict) -> Dict:
"""检查任务状态,如果完成则解析输出。"""
task_id = task["task_id"]

View File

@ -36,6 +36,7 @@ from modules.toolbox_container import ToolboxContainer
from modules.host_sandbox_runner import (
HostSandboxError,
build_host_sandbox_plan,
build_host_sandbox_readonly_plan,
host_sandbox_enabled,
)
@ -307,7 +308,8 @@ class TerminalOperator:
self,
command: str,
working_dir: str = None,
timeout: int = None
timeout: int = None,
sandbox_write_access: bool = True,
) -> Dict:
"""
执行终端命令
@ -381,7 +383,8 @@ class TerminalOperator:
command,
work_path,
timeout,
session_override=session_override
session_override=session_override,
sandbox_write_access=sandbox_write_access,
)
else:
# 若未绑定用户容器,则使用工具箱容器(与终端相同镜像/预装包)
@ -424,7 +427,7 @@ class TerminalOperator:
# 改为一次性子进程执行,确保等待到超时或命令结束
result_payload = result_payload if result_payload is not None else await self._run_command_subprocess(
command, work_path, timeout
command, work_path, timeout, sandbox_write_access=sandbox_write_access
)
# 字符数检查
@ -485,7 +488,8 @@ class TerminalOperator:
command: str,
work_path: Path,
timeout: int,
session_override: Optional["ContainerHandle"] = None
session_override: Optional["ContainerHandle"] = None,
sandbox_write_access: bool = True,
) -> Dict:
start_ts = time.time()
try:
@ -531,7 +535,10 @@ class TerminalOperator:
if use_shell:
use_host_sandbox = self.host_execution_mode != "direct"
if use_host_sandbox and host_sandbox_enabled():
plan = build_host_sandbox_plan(command, work_path, env)
if sandbox_write_access:
plan = build_host_sandbox_plan(command, work_path, env)
else:
plan = build_host_sandbox_readonly_plan(command, work_path, env)
cmd_args, pass_fds, seccomp_fd = self._materialize_seccomp_fd(
plan.command,
plan.seccomp_bpf_path,

View File

@ -84,11 +84,10 @@
### 3.2 文件操作
- `create_file`:创建空文件(禁止在根目录创建,需先建子目录)
- `write_file`:写入文件(`append` 控制覆盖/追加)
- `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式)
- `edit_file`:精确字符串替换(`replace_all` 必填,必须显式传入 `true/false``old_string` 建议至少 3 行以提升定位稳定性。需要批量替换的场景可以单行或不足一行)
- `delete_file` / `rename_file` / `create_folder`:文件管理
- 其余文件管理(创建/重命名/删除目录与文件)请优先使用 `run_command` / `run_python`
**注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。

View File

@ -84,11 +84,10 @@
### 3.2 文件操作
- `create_file`:创建空文件(禁止在根目录创建,需先建子目录)
- `write_file`:写入文件(`append` 控制覆盖/追加)
- `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式)
- `edit_file`:精确字符串替换(`replace_all` 必填,必须显式传入 `true/false``old_string` 建议至少 3 行以提升定位稳定性。需要批量替换的场景可以单行或不足一行)
- `delete_file` / `rename_file` / `create_folder`:文件管理
- 其余文件管理(创建/重命名/删除目录与文件)请优先使用 `run_command` / `run_python`
**注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。

View File

@ -25,6 +25,7 @@ from modules.skills_manager import (
sync_workspace_skills,
)
from modules.upload_security import UploadSecurityError
from modules.host_sandbox_policy import load_policy, save_policy
from modules.user_manager import UserWorkspace
from core.web_terminal import WebTerminal
@ -677,6 +678,55 @@ def update_execution_mode(terminal: WebTerminal, workspace: UserWorkspace, usern
})
@chat_bp.route('/api/path-authorization', methods=['GET'])
@api_login_required
@with_terminal
def get_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, username: str):
is_host = bool(getattr(terminal, "_is_host_mode", lambda: False)())
can_manage = is_host and getattr(terminal, "user_role", "user") == "admin"
data = load_policy()
return jsonify({
"success": True,
"enabled": can_manage,
"writable_paths": data.get("macos_writable_paths", []),
"readable_extra_paths": data.get("macos_readable_extra_paths", []),
})
@chat_bp.route('/api/path-authorization', methods=['POST'])
@api_login_required
@with_terminal
@rate_limited("path_authorization_update", 20, 60, scope="user")
def update_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, username: str):
is_host = bool(getattr(terminal, "_is_host_mode", lambda: False)())
can_manage = is_host and getattr(terminal, "user_role", "user") == "admin"
if not can_manage:
return jsonify({"success": False, "error": "仅宿主机管理员可管理路径授权"}), 403
data = request.get_json() or {}
writable_items = data.get("writable_paths")
readable_items = data.get("readable_extra_paths")
if not isinstance(writable_items, list) or not isinstance(readable_items, list):
return jsonify({"success": False, "error": "writable_paths/readable_extra_paths 必须为数组"}), 400
writable = [str(x).strip() for x in writable_items if str(x).strip()]
readable_extra = [str(x).strip() for x in readable_items if str(x).strip()]
if "/" in writable or "/" in readable_extra:
return jsonify({"success": False, "error": "禁止授权根目录 /"}), 400
blocked_prefix = ("~/.ssh", "~/.gnupg", "~/.aws")
for p in writable + readable_extra:
lowered = p.lower()
if any(lowered.startswith(prefix) for prefix in blocked_prefix):
return jsonify({"success": False, "error": f"禁止授权敏感目录: {p}"}), 400
payload = save_policy({
"macos_writable_paths": writable,
"macos_readable_extra_paths": readable_extra
})
return jsonify({
"success": True,
"writable_paths": payload.get("macos_writable_paths", []),
"readable_extra_paths": payload.get("macos_readable_extra_paths", []),
})
@chat_bp.route('/api/tool-approvals/pending', methods=['GET'])
@api_login_required
@with_terminal

View File

@ -153,6 +153,30 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di
return preview
def _is_permission_denied_result(result_data: Dict[str, Any]) -> bool:
if not isinstance(result_data, dict):
return False
if result_data.get("success") is True:
return False
fragments: List[str] = []
for key in ("error", "message", "output"):
value = result_data.get(key)
if isinstance(value, str) and value.strip():
fragments.append(value.lower())
joined = "\n".join(fragments)
if not joined:
return False
markers = (
"operation not permitted",
"permission denied",
"权限不足",
"无权限",
"不允许",
"access denied",
)
return any(marker in joined for marker in markers)
async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0) -> Dict[str, Any]:
started = time.time()
while True:
@ -625,6 +649,99 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
result_data = json.loads(tool_result)
except:
result_data = {'output': tool_result}
# 批准模式下 run_command 采用“先只读执行,触发权限拒绝后再审批并单次可写重试”。
if (
function_name == "run_command"
and permission_eval.get("mode") == "approval"
and not bool(arguments.get("_approval_write_granted", False))
and _is_permission_denied_result(result_data)
):
approval_preview = _build_tool_approval_preview(web_terminal, function_name, arguments)
approval_item = tool_approval_manager.create_request(
username=username,
conversation_id=conversation_id,
task_id=getattr(web_terminal, "task_id", None),
tool_call_id=tool_call_id,
tool_name=function_name,
arguments=arguments,
preview=approval_preview,
)
sender('tool_approval_required', {
'approval': approval_item,
'conversation_id': conversation_id,
})
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'awaiting_approval',
'result': {
"success": False,
"status": "awaiting_approval",
"approval_id": approval_item.get("approval_id"),
"message": "检测到写权限受限,等待用户审批后重试"
},
'message': '检测到写权限受限,等待用户审批',
'conversation_id': conversation_id
})
wait_result = await _wait_for_tool_approval(
approval_id=approval_item.get("approval_id"),
username=username,
)
sender('tool_approval_resolved', {
'approval_id': approval_item.get("approval_id"),
'decision': wait_result.get("decision"),
'conversation_id': conversation_id,
})
if wait_result.get("decision") != "approved":
reject_message = "操作被用户拒绝"
if wait_result.get("reason") == "审批超时":
reject_message = "审批超时,操作未执行"
reject_payload = {
"success": False,
"status": "rejected",
"code": "approval_rejected",
"tool": function_name,
"message": reject_message,
"approval_id": approval_item.get("approval_id"),
}
sender('update_action', {
'preparing_id': tool_call_id,
'status': 'completed',
'result': reject_payload,
'message': reject_message,
'conversation_id': conversation_id
})
reject_content = json.dumps(reject_payload, ensure_ascii=False)
web_terminal.context_manager.add_conversation(
"tool",
reject_content,
tool_call_id=tool_call_id,
name=function_name
)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": function_name,
"content": reject_content
})
web_terminal._tool_loop_active = previous_tool_loop_active
return {
"stopped": False,
"approval_rejected": True,
"approval_message": reject_message,
"last_tool_call_time": last_tool_call_time
}
retry_arguments = dict(arguments or {})
retry_arguments["_approval_write_granted"] = True
retry_tool_result = await web_terminal.handle_tool_call(function_name, retry_arguments)
try:
result_data = json.loads(retry_tool_result)
tool_result = retry_tool_result
except Exception:
result_data = {"output": retry_tool_result}
tool_result = retry_tool_result
tool_failed = detect_tool_failure(result_data)
action_status = 'completed'

View File

@ -328,6 +328,7 @@
@toggle-permission-menu="togglePermissionMenu"
@change-permission-mode="changePermissionMode"
@change-execution-mode="changeExecutionMode"
@open-path-authorization="openPathAuthorizationDialog"
@open-versioning-dialog="openVersioningDialog"
@guide-runtime-message="handleGuideRuntimeMessage"
@delete-runtime-message="handleDeleteRuntimeMessage"
@ -354,6 +355,16 @@
</div>
<PersonalizationDrawer />
<PathAuthorizationDialog
:open="pathAuthorizationDialogOpen"
:value="pathAuthorizationDraft"
:mode="pathAuthorizationMode"
:saving="pathAuthorizationSaving"
@update:value="(v) => (pathAuthorizationDraft = v)"
@update:mode="setPathAuthorizationMode"
@close="closePathAuthorizationDialog"
@save="savePathAuthorization"
/>
<transition name="overlay-fade">
<ImagePicker
v-if="imagePickerOpen"
@ -730,6 +741,9 @@ import { useTutorialStore } from './stores/tutorial';
const VirtualMonitorSurface = defineAsyncComponent(
() => import('./components/chat/VirtualMonitorSurface.vue')
);
const PathAuthorizationDialog = defineAsyncComponent(
() => import('./components/overlay/PathAuthorizationDialog.vue')
);
const tutorialStore = useTutorialStore();
const mobilePanelIcon = new URL('../icons/align-left.svg', import.meta.url).href;

View File

@ -1255,6 +1255,99 @@ export const uiMethods = {
}
},
async openPathAuthorizationDialog() {
if (!this.executionModeEnabled) return;
this.pathAuthorizationDialogOpen = true;
try {
const response = await fetch('/api/path-authorization');
const payload = await response.json().catch(() => ({}));
if (response.ok && payload?.success) {
const writablePaths = Array.isArray(payload.writable_paths) ? payload.writable_paths : [];
const readableExtraPaths = Array.isArray(payload.readable_extra_paths)
? payload.readable_extra_paths
: [];
this.pathAuthorizationWritableDraft = writablePaths.join('\n');
this.pathAuthorizationReadableDraft = readableExtraPaths.join('\n');
this.pathAuthorizationMode = 'writable';
this.pathAuthorizationDraft = this.pathAuthorizationWritableDraft;
}
} catch (_error) {
// ignore
}
},
setPathAuthorizationMode(mode) {
if (this.pathAuthorizationMode === 'readable') {
this.pathAuthorizationReadableDraft = String(this.pathAuthorizationDraft || '');
} else {
this.pathAuthorizationWritableDraft = String(this.pathAuthorizationDraft || '');
}
const next = mode === 'readable' ? 'readable' : 'writable';
this.pathAuthorizationMode = next;
this.pathAuthorizationDraft =
next === 'readable' ? this.pathAuthorizationReadableDraft : this.pathAuthorizationWritableDraft;
},
closePathAuthorizationDialog() {
this.pathAuthorizationDialogOpen = false;
},
async savePathAuthorization() {
const currentLines = String(this.pathAuthorizationDraft || '')
.split('\n')
.map((x) => x.trim())
.filter(Boolean);
if (this.pathAuthorizationMode === 'readable') {
this.pathAuthorizationReadableDraft = currentLines.join('\n');
} else {
this.pathAuthorizationWritableDraft = currentLines.join('\n');
}
const writableLines = String(this.pathAuthorizationWritableDraft || '')
.split('\n')
.map((x) => x.trim())
.filter(Boolean);
const readableLines = String(this.pathAuthorizationReadableDraft || '')
.split('\n')
.map((x) => x.trim())
.filter(Boolean);
this.pathAuthorizationSaving = true;
try {
const response = await fetch('/api/path-authorization', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
writable_paths: writableLines,
readable_extra_paths: readableLines
})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
throw new Error(payload?.error || '保存失败');
}
const savedWritable = Array.isArray(payload.writable_paths) ? payload.writable_paths : writableLines;
const savedReadable = Array.isArray(payload.readable_extra_paths)
? payload.readable_extra_paths
: readableLines;
this.pathAuthorizationWritableDraft = savedWritable.join('\n');
this.pathAuthorizationReadableDraft = savedReadable.join('\n');
this.pathAuthorizationDraft =
this.pathAuthorizationMode === 'readable'
? this.pathAuthorizationReadableDraft
: this.pathAuthorizationWritableDraft;
this.uiPushToast({
title: '路径授权已保存',
message: '命令工具立即生效;终端会话请重开后生效',
type: 'success'
});
this.pathAuthorizationDialogOpen = false;
} catch (error) {
const msg = error instanceof Error ? error.message : String(error || '保存失败');
this.uiPushToast({ title: '保存路径授权失败', message: msg, type: 'error' });
} finally {
this.pathAuthorizationSaving = false;
}
},
async fetchPendingToolApprovals() {
if (!this.currentConversationId) {
this.pendingToolApprovals = [];

View File

@ -101,6 +101,12 @@ export function dataState() {
currentExecutionMode: 'sandbox',
executionModeDirectUntil: null,
executionModeTtlSeconds: 600,
pathAuthorizationDialogOpen: false,
pathAuthorizationMode: 'writable',
pathAuthorizationWritableDraft: '',
pathAuthorizationReadableDraft: '',
pathAuthorizationDraft: '',
pathAuthorizationSaving: false,
versioningHostMode: false,
hostWorkspaces: [],
currentHostWorkspaceId: '',

View File

@ -145,6 +145,7 @@
:block-token-panel="blockTokenPanel"
:block-compress-conversation="blockCompressConversation"
:block-conversation-review="blockConversationReview"
:execution-mode-enabled="executionModeEnabled"
@quick-upload="triggerQuickUpload"
@pick-images="$emit('pick-images')"
@pick-video="$emit('pick-video')"
@ -161,6 +162,7 @@
@compress-conversation="$emit('compress-conversation')"
@toggle-approval-panel="$emit('toggle-approval-panel')"
@open-review="$emit('open-review')"
@open-path-authorization="$emit('open-path-authorization')"
/>
<div class="permission-switcher" @click.stop>
<button
@ -288,6 +290,7 @@ const emit = defineEmits([
'remove-image',
'remove-video',
'open-review',
'open-path-authorization',
'toggle-permission-menu',
'change-permission-mode',
'change-execution-mode',

View File

@ -129,6 +129,14 @@
>
审批面板
</button>
<button
v-if="executionModeEnabled"
type="button"
class="menu-entry submenu-entry"
@click="$emit('open-path-authorization')"
>
路径授权
</button>
</div>
</div>
</transition>
@ -174,6 +182,7 @@ const props = defineProps<{
blockTokenPanel?: boolean;
blockCompressConversation?: boolean;
blockConversationReview?: boolean;
executionModeEnabled?: boolean;
}>();
defineEmits<{
@ -191,6 +200,7 @@ defineEmits<{
(event: 'select-model', key: string): void;
(event: 'open-review'): void;
(event: 'pick-video'): void;
(event: 'open-path-authorization'): void;
}>();
const getIconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {});

View File

@ -0,0 +1,85 @@
<template>
<transition name="path-auth-fade">
<div v-if="open" class="overlay-backdrop">
<div class="overlay-card">
<div class="overlay-header">
<h3>路径授权</h3>
<button type="button" @click="$emit('close')">×</button>
</div>
<div class="mode-switch">
<button
type="button"
class="mode-btn"
:class="{ active: mode === 'writable' }"
@click="$emit('update:mode', 'writable')"
>
可读可写
</button>
<button
type="button"
class="mode-btn"
:class="{ active: mode === 'readable' }"
@click="$emit('update:mode', 'readable')"
>
仅可读
</button>
</div>
<textarea
class="path-input"
:value="value"
@input="$emit('update:value', ($event.target as HTMLTextAreaElement).value)"
placeholder="每行一个路径,例如:~/Desktop/agents-export"
/>
<div class="actions">
<button type="button" class="btn" @click="$emit('save')" :disabled="saving">保存</button>
<button type="button" class="btn btn-muted" @click="$emit('close')">取消</button>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
defineProps<{ open: boolean; value: string; mode: 'writable' | 'readable'; saving?: boolean }>();
defineEmits<{
(e: 'close'): void;
(e: 'save'): void;
(e: 'update:value', v: string): void;
(e: 'update:mode', v: 'writable' | 'readable'): void;
}>();
</script>
<style scoped>
.overlay-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.35); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.overlay-card { width: min(680px, 92vw); background: var(--theme-surface-soft); border: 1px solid var(--theme-control-border); border-radius: 12px; padding: 12px; }
.overlay-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.overlay-header h3 { margin: 0; font-size: 16px; }
.overlay-header button { border: none; background: transparent; font-size: 20px; cursor: pointer; }
.hint { font-size: 12px; color: var(--claude-text-secondary); margin: 0 0 8px 0; }
.mode-switch { display: inline-flex; border: 1px solid var(--theme-control-border); border-radius: 10px; overflow: hidden; margin-bottom: 8px; }
.mode-btn { border: none; background: transparent; padding: 6px 10px; cursor: pointer; font-size: 12px; }
.mode-btn.active { background: var(--theme-tab-active); font-weight: 600; }
.path-input { width: 100%; min-height: 220px; resize: vertical; border: 1px solid var(--theme-control-border); border-radius: 8px; padding: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.actions { margin-top: 10px; display: flex; gap: 8px; justify-content: flex-end; }
.btn { border: 1px solid var(--theme-control-border); background: var(--theme-tab-active); padding: 6px 12px; border-radius: 8px; cursor: pointer; }
.btn-muted { background: transparent; }
.path-auth-fade-enter-active,
.path-auth-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.path-auth-fade-enter-from,
.path-auth-fade-leave-to {
opacity: 0;
}
.path-auth-fade-enter-from .overlay-card,
.path-auth-fade-leave-to .overlay-card {
transform: translateY(8px) scale(0.98);
}
.overlay-card {
transition: transform 0.2s ease;
}
</style>

View File

@ -716,6 +716,11 @@ body[data-theme='dark'] {
bottom: 0;
}
.quick-submenu.settings-submenu {
top: auto;
bottom: 0;
}
.menu-entry.submenu-entry {
width: 100%;
justify-content: space-between;