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) # 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` > Scope: `/Users/jojo/Desktop/agents/正在修复中/agents`
这份文档基于当前仓库实际代码重写。若与未来代码冲突,以代码为准并及时更新本文档。 这份文档基于当前仓库实际代码重写。若与未来代码冲突,以代码为准并及时更新本文档。
@ -154,3 +154,44 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
2. 核心变更点 2. 核心变更点
3. 验证结果(已执行命令/未执行原因) 3. 验证结果(已执行命令/未执行原因)
- 如果发现本文档过时,直接更新 `AGENTS.md` 并在结果中说明依据。 - 如果发现本文档过时,直接更新 `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`: 读取文件内容 - `read_file`: 读取文件内容
- `create_file` / `write_file`: 创建和写入文件 - `write_file`: 写入文件
- `edit_file`: 精确编辑文件内容 - `edit_file`: 精确编辑文件内容
- `delete_file` / `rename_file`: 文件管理
- `create_folder`: 创建目录
- `list_files`: 列出目录内容 - `list_files`: 列出目录内容
- `search_files`: 搜索文件 - `search_files`: 搜索文件
> 说明:目录创建/重命名/删除等通用文件管理建议统一使用 `run_command``run_python`
#### 终端操作 #### 终端操作
**实时终端会话**: **实时终端会话**:
@ -127,6 +127,21 @@ TERMINAL_SANDBOX_MODE=host
HOST_WORKSPACES_FILE=./config/host_workspaces.json 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 示例: `HOST_WORKSPACES_FILE` 对应 JSON 示例:
```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) self.terminal_ops.set_host_execution_mode(mode)
if getattr(self, "terminal_manager", None): if getattr(self, "terminal_manager", None):
self.terminal_manager.set_host_execution_mode(mode) 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): def _refresh_execution_mode_by_ttl(self):
if self.host_execution_mode != "direct": if self.host_execution_mode != "direct":

View File

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

View File

@ -320,17 +320,7 @@ class MainTerminalToolsExecutionMixin:
if mode == "readonly": if mode == "readonly":
if tool_name == "run_command": if tool_name == "run_command":
if self._is_readonly_run_command_allowed(args.get("command")): return {"allowed": True, "mode": mode}
return {"allowed": True, "mode": mode}
return {
"allowed": False,
"mode": mode,
"code": "readonly_denied",
"message": (
"当前处于只读模式run_command 仅允许只读命令"
"grep/find/ls/cat/rg/git status 等)且禁止多指令拼接。"
)
}
if tool_name not in self._READONLY_ALLOWED_TOOLS: if tool_name not in self._READONLY_ALLOWED_TOOLS:
return { return {
"allowed": False, "allowed": False,
@ -341,7 +331,9 @@ class MainTerminalToolsExecutionMixin:
return {"allowed": True, "mode": mode} return {"allowed": True, "mode": mode}
if mode == "approval": 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") command_text = args.get("command") or args.get("input")
if self._is_readonly_run_command_allowed(command_text): if self._is_readonly_run_command_allowed(command_text):
return {"allowed": True, "mode": mode, "requires_approval": False} return {"allowed": True, "mode": mode, "requires_approval": False}
@ -1272,6 +1264,16 @@ class MainTerminalToolsExecutionMixin:
) )
elif tool_name == "run_command": 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)) run_in_background = bool(arguments.get("run_in_background", False))
timeout_value = arguments.get("timeout") timeout_value = arguments.get("timeout")
if run_in_background: if run_in_background:
@ -1311,7 +1313,8 @@ class MainTerminalToolsExecutionMixin:
else: else:
result = await self.terminal_ops.run_command( result = await self.terminal_ops.run_command(
arguments["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, LINUX_SAFETY,
) )
from modules.container_file_proxy import ContainerFileProxy 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 from utils.logger import setup_logger
if TYPE_CHECKING: if TYPE_CHECKING:
@ -191,6 +192,41 @@ class FileManager:
except ValueError: except ValueError:
return str(full_path) 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: def create_file(self, path: str, content: str = "", file_type: str = "txt") -> Dict:
"""创建文件""" """创建文件"""
valid, error, full_path = self._validate_path(path) valid, error, full_path = self._validate_path(path)
@ -200,6 +236,9 @@ class FileManager:
# 添加文件扩展名 # 添加文件扩展名
if not full_path.suffix: if not full_path.suffix:
full_path = full_path.with_suffix(f".{file_type}") 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) relative_path = self._relative_path(full_path)
try: try:
@ -251,6 +290,9 @@ class FileManager:
if not full_path.is_file(): if not full_path.is_file():
return {"success": False, "error": "不是文件"} return {"success": False, "error": "不是文件"}
ok, msg = self._ensure_host_access(full_path, "write")
if not ok:
return {"success": False, "error": msg}
try: try:
relative_path = self._relative_path(full_path) relative_path = self._relative_path(full_path)
@ -292,6 +334,12 @@ class FileManager:
if full_new_path.exists(): if full_new_path.exists():
return {"success": False, "error": "目标文件已存在"} 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: try:
old_relative = self._relative_path(full_old_path) old_relative = self._relative_path(full_old_path)
@ -326,6 +374,9 @@ class FileManager:
if full_path.exists(): if full_path.exists():
return {"success": False, "error": "文件夹已存在"} return {"success": False, "error": "文件夹已存在"}
ok, msg = self._ensure_host_access(full_path, "write")
if not ok:
return {"success": False, "error": msg}
try: try:
relative_path = self._relative_path(full_path) relative_path = self._relative_path(full_path)
@ -418,6 +469,9 @@ class FileManager:
if not full_path.is_file(): if not full_path.is_file():
return {"success": False, "error": "不是文件"} 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(): if self._use_container():
relative_path = self._relative_path(full_path) relative_path = self._relative_path(full_path)
@ -457,6 +511,9 @@ class FileManager:
if not full_path.is_file(): if not full_path.is_file():
return {"success": False, "error": "不是文件"} 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(): if self._use_container():
relative_path = self._relative_path(full_path) relative_path = self._relative_path(full_path)
@ -551,6 +608,9 @@ class FileManager:
if not full_path.is_file(): if not full_path.is_file():
return {"success": False, "error": "不是文件"} 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( result = self._read_text_lines(
full_path, full_path,
@ -618,6 +678,9 @@ class FileManager:
if not full_path.is_file(): if not full_path.is_file():
return {"success": False, "error": "不是文件"} 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( result = self._read_text_lines(
full_path, full_path,
@ -672,6 +735,9 @@ class FileManager:
valid, error, full_path = self._validate_path(path) valid, error, full_path = self._validate_path(path)
if not valid: if not valid:
return {"success": False, "error": error} return {"success": False, "error": error}
ok, msg = self._ensure_host_access(full_path, "write")
if not ok:
return {"success": False, "error": msg}
# === 新增:内容预处理和验证 === # === 新增:内容预处理和验证 ===
if content: 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 dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
try: from modules.host_sandbox_policy import get_macos_writable_paths
from config import HOST_SANDBOX_MACOS_WRITABLE_PATHS
except Exception:
HOST_SANDBOX_MACOS_WRITABLE_PATHS = []
@dataclass @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) return _build_windows_plan(command, work_path, env)
raise HostSandboxError(f"不支持的宿主机系统: {system}") 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: def build_host_sandbox_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan:
system = platform.system() system = platform.system()
if system == "Darwin": 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] cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command]
return SandboxPlan(command=cmd, env=env, cwd=str(work_path)) 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: def _build_macos_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan:
sandbox_exec = shutil.which("sandbox-exec") sandbox_exec = shutil.which("sandbox-exec")
if not 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: def _macos_profile_for_workspace(work_path: Path) -> str:
workspace = str(work_path.resolve()) workspace = str(work_path.resolve())
writable_paths = [workspace, "/tmp", "/private/tmp", "/dev/null"] 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: try:
expanded = str(Path(raw).expanduser().resolve()) expanded = str(Path(raw).expanduser().resolve())
except Exception: 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] shell_cmd = ["/bin/bash", "-lc", command]
return _build_linux_common_plan(work_path, env, shell_cmd, seccomp_path) 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: def _build_linux_shell_plan(work_path: Path, env: Dict[str, str]) -> SandboxPlan:
bwrap = shutil.which("bwrap") bwrap = shutil.which("bwrap")
if not bwrap: if not bwrap:
@ -130,12 +169,13 @@ def _build_linux_common_plan(
env: Dict[str, str], env: Dict[str, str],
shell_cmd: List[str], shell_cmd: List[str],
seccomp_path: Path, seccomp_path: Path,
readonly: bool = False,
) -> SandboxPlan: ) -> SandboxPlan:
bwrap = shutil.which("bwrap") bwrap = shutil.which("bwrap")
if not bwrap: if not bwrap:
raise HostSandboxError("Linux 未找到 bubblewrap(bwrap)。") raise HostSandboxError("Linux 未找到 bubblewrap(bwrap)。")
sandbox_root = str(work_path.resolve()) sandbox_root = str(work_path.resolve())
cmd = [ cmd: List[str] = [
bwrap, bwrap,
"--die-with-parent", "--die-with-parent",
"--new-session", "--new-session",
@ -144,9 +184,12 @@ def _build_linux_common_plan(
"--ro-bind", "--ro-bind",
"/", "/",
"/", "/",
"--bind", ]
sandbox_root, if readonly:
sandbox_root, cmd.extend(["--ro-bind", sandbox_root, sandbox_root])
else:
cmd.extend(["--bind", sandbox_root, sandbox_root])
cmd.extend([
"--chdir", "--chdir",
sandbox_root, sandbox_root,
"--proc", "--proc",
@ -158,7 +201,7 @@ def _build_linux_common_plan(
"--seccomp", "--seccomp",
"__SECCOMP_FD__", "__SECCOMP_FD__",
*shell_cmd, *shell_cmd,
] ])
return SandboxPlan(command=cmd, env=env, cwd=sandbox_root, seccomp_bpf_path=str(seccomp_path)) 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 time
import uuid import uuid
import os import os
import shlex
import platform
import shutil import shutil
import signal import signal
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
@ -20,6 +22,7 @@ from config import (
) )
from utils.logger import setup_logger from utils.logger import setup_logger
import logging import logging
from modules.host_sandbox_runner import build_host_sandbox_plan, host_sandbox_enabled, HostSandboxError
if TYPE_CHECKING: if TYPE_CHECKING:
from modules.user_container_manager import ContainerHandle from modules.user_container_manager import ContainerHandle
@ -48,6 +51,7 @@ class SubAgentManager:
self.base_dir = Path(SUB_AGENT_TASKS_BASE_DIR).resolve() self.base_dir = Path(SUB_AGENT_TASKS_BASE_DIR).resolve()
self.state_file = Path(SUB_AGENT_STATE_FILE).resolve() self.state_file = Path(SUB_AGENT_STATE_FILE).resolve()
self.container_session: Optional["ContainerHandle"] = container_session self.container_session: Optional["ContainerHandle"] = container_session
self.host_execution_mode: str = "sandbox"
# easyagent批处理入口优先使用项目目录内的副本 # easyagent批处理入口优先使用项目目录内的副本
candidate_batch = self.project_path / "easyagent" / "src" / "batch" / "index.js" candidate_batch = self.project_path / "easyagent" / "src" / "batch" / "index.js"
@ -158,6 +162,10 @@ class SubAgentManager:
execution_mode = "host" execution_mode = "host"
container_name = None container_name = None
env = os.environ.copy() 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(): if self._should_use_container():
container = self.container_session 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_PYTHON"] = os.environ.get("CONTAINER_PYTHON_CMD", "/opt/agent-venv/bin/python3")
env["EASYAGENT_CONTAINER_MODE"] = "1" env["EASYAGENT_CONTAINER_MODE"] = "1"
execution_mode = "docker" 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: try:
process = subprocess.Popen( process = subprocess.Popen(
cmd, launch_cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
cwd=str(self.project_path), cwd=launch_cwd,
env=env, env=env,
pass_fds=pass_fds,
) )
except Exception as exc: except Exception as exc:
return {"success": False, "error": f"启动子智能体失败: {exc}"} return {"success": False, "error": f"启动子智能体失败: {exc}"}
finally:
if seccomp_fd is not None:
try:
os.close(seccomp_fd)
except OSError:
pass
# 记录任务 # 记录任务
task_record = { task_record = {
@ -309,6 +343,10 @@ class SubAgentManager:
"""更新容器会话信息。""" """更新容器会话信息。"""
self.container_session = session 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: if thinking_mode:
cmd.extend(["--thinking-mode", thinking_mode]) cmd.extend(["--thinking-mode", thinking_mode])
return cmd 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: def _check_task_status(self, task: Dict) -> Dict:
"""检查任务状态,如果完成则解析输出。""" """检查任务状态,如果完成则解析输出。"""
task_id = task["task_id"] task_id = task["task_id"]

View File

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

View File

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

View File

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

View File

@ -25,6 +25,7 @@ from modules.skills_manager import (
sync_workspace_skills, sync_workspace_skills,
) )
from modules.upload_security import UploadSecurityError from modules.upload_security import UploadSecurityError
from modules.host_sandbox_policy import load_policy, save_policy
from modules.user_manager import UserWorkspace from modules.user_manager import UserWorkspace
from core.web_terminal import WebTerminal 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']) @chat_bp.route('/api/tool-approvals/pending', methods=['GET'])
@api_login_required @api_login_required
@with_terminal @with_terminal

View File

@ -153,6 +153,30 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di
return preview 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]: async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0) -> Dict[str, Any]:
started = time.time() started = time.time()
while True: while True:
@ -625,6 +649,99 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
result_data = json.loads(tool_result) result_data = json.loads(tool_result)
except: except:
result_data = {'output': tool_result} 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) tool_failed = detect_tool_failure(result_data)
action_status = 'completed' action_status = 'completed'

View File

@ -328,6 +328,7 @@
@toggle-permission-menu="togglePermissionMenu" @toggle-permission-menu="togglePermissionMenu"
@change-permission-mode="changePermissionMode" @change-permission-mode="changePermissionMode"
@change-execution-mode="changeExecutionMode" @change-execution-mode="changeExecutionMode"
@open-path-authorization="openPathAuthorizationDialog"
@open-versioning-dialog="openVersioningDialog" @open-versioning-dialog="openVersioningDialog"
@guide-runtime-message="handleGuideRuntimeMessage" @guide-runtime-message="handleGuideRuntimeMessage"
@delete-runtime-message="handleDeleteRuntimeMessage" @delete-runtime-message="handleDeleteRuntimeMessage"
@ -354,6 +355,16 @@
</div> </div>
<PersonalizationDrawer /> <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"> <transition name="overlay-fade">
<ImagePicker <ImagePicker
v-if="imagePickerOpen" v-if="imagePickerOpen"
@ -730,6 +741,9 @@ import { useTutorialStore } from './stores/tutorial';
const VirtualMonitorSurface = defineAsyncComponent( const VirtualMonitorSurface = defineAsyncComponent(
() => import('./components/chat/VirtualMonitorSurface.vue') () => import('./components/chat/VirtualMonitorSurface.vue')
); );
const PathAuthorizationDialog = defineAsyncComponent(
() => import('./components/overlay/PathAuthorizationDialog.vue')
);
const tutorialStore = useTutorialStore(); const tutorialStore = useTutorialStore();
const mobilePanelIcon = new URL('../icons/align-left.svg', import.meta.url).href; 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() { async fetchPendingToolApprovals() {
if (!this.currentConversationId) { if (!this.currentConversationId) {
this.pendingToolApprovals = []; this.pendingToolApprovals = [];

View File

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

View File

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

View File

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