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 @@
+ (pathAuthorizationDraft = v)"
+ @update:mode="setPathAuthorizationMode"
+ @close="closePathAuthorizationDialog"
+ @save="savePathAuthorization"
+ />
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/src/styles/components/input/_composer.scss b/static/src/styles/components/input/_composer.scss
index d7ab685..c3d8a09 100644
--- a/static/src/styles/components/input/_composer.scss
+++ b/static/src/styles/components/input/_composer.scss
@@ -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;