diff --git a/AGENTS.md b/AGENTS.md index e0dd539..ac9b8c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 维护约束 + +- 不要在提示词里暴露内部实现细节(例如“命令文本猜测”等) +- 文案应面向用户能力边界与操作建议,不描述内部判定算法 diff --git a/README.md b/README.md index ec21de2..4168baa 100644 --- a/README.md +++ b/README.md @@ -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` + ### 👥 多用户管理 完整的用户系统: diff --git a/config/host_sandbox_policy.json b/config/host_sandbox_policy.json new file mode 100644 index 0000000..997ecfe --- /dev/null +++ b/config/host_sandbox_policy.json @@ -0,0 +1,8 @@ +{ + "macos_writable_paths": [ + "/Users/jojo/Desktop" + ], + "macos_readable_extra_paths": [ + "/Users/jojo/Desktop" + ] +} \ No newline at end of file diff --git a/core/main_terminal.py b/core/main_terminal.py index 3a6dca9..67f7809 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -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": diff --git a/core/main_terminal_parts/context.py b/core/main_terminal_parts/context.py index 42d72c5..deb61d6 100644 --- a/core/main_terminal_parts/context.py +++ b/core/main_terminal_parts/context.py @@ -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" - "- 禁止危险管道:管道指向 shell(bash/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 = ( diff --git a/core/main_terminal_parts/tools_definition.py b/core/main_terminal_parts/tools_definition.py index 1e76cf1..32cc5b7 100644 --- a/core/main_terminal_parts/tools_definition.py +++ b/core/main_terminal_parts/tools_definition.py @@ -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": { diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index a104673..73004f2 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -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, ) # 字符数检查 diff --git a/docs/host_sandbox_and_permission_model.md b/docs/host_sandbox_and_permission_model.md new file mode 100644 index 0000000..a23a5c3 --- /dev/null +++ b/docs/host_sandbox_and_permission_model.md @@ -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. 文案与产品约束 + +- 面向用户的提示词仅描述“能力边界”和“可操作建议” +- 不暴露内部实现细节(如内部判定逻辑、实现细节名称) +- 审批流程文案要强调“单次授权、最小权限、可回退” + diff --git a/modules/file_manager.py b/modules/file_manager.py index 22f321f..a4d56d5 100644 --- a/modules/file_manager.py +++ b/modules/file_manager.py @@ -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: diff --git a/modules/host_sandbox_policy.py b/modules/host_sandbox_policy.py new file mode 100644 index 0000000..4a026b6 --- /dev/null +++ b/modules/host_sandbox_policy.py @@ -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 diff --git a/modules/host_sandbox_runner.py b/modules/host_sandbox_runner.py index 33d82d1..a7ee792 100644 --- a/modules/host_sandbox_runner.py +++ b/modules/host_sandbox_runner.py @@ -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)) diff --git a/modules/sub_agent_manager.py b/modules/sub_agent_manager.py index 488d7bb..f392ec6 100644 --- a/modules/sub_agent_manager.py +++ b/modules/sub_agent_manager.py @@ -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"] diff --git a/modules/terminal_ops.py b/modules/terminal_ops.py index cc359c3..efbee7f 100644 --- a/modules/terminal_ops.py +++ b/modules/terminal_ops.py @@ -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, diff --git a/prompts/main_system.txt b/prompts/main_system.txt index 0ebc36f..a4788b8 100644 --- a/prompts/main_system.txt +++ b/prompts/main_system.txt @@ -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` 抽取,避免全文读取。 diff --git a/prompts/main_system_qwenvl.txt b/prompts/main_system_qwenvl.txt index 6280840..d5dcc67 100644 --- a/prompts/main_system_qwenvl.txt +++ b/prompts/main_system_qwenvl.txt @@ -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` 抽取,避免全文读取。 diff --git a/server/chat.py b/server/chat.py index b3a4a5f..4ee2dcd 100644 --- a/server/chat.py +++ b/server/chat.py @@ -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 diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index c52e566..be86bc9 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -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' diff --git a/static/src/App.vue b/static/src/App.vue index ad792ee..0dc64de 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -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 @@ + 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; diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index 1546ea0..640a1e4 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -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 = []; diff --git a/static/src/app/state.ts b/static/src/app/state.ts index d92a4f3..a4c3fd5 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -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: '', diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 34ff5c7..afc3595 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -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')" />
+
@@ -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) : {}); diff --git a/static/src/components/overlay/PathAuthorizationDialog.vue b/static/src/components/overlay/PathAuthorizationDialog.vue new file mode 100644 index 0000000..d81297f --- /dev/null +++ b/static/src/components/overlay/PathAuthorizationDialog.vue @@ -0,0 +1,85 @@ +