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:
parent
6ee8cda2a7
commit
a93f17010c
43
AGENTS.md
43
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 维护约束
|
||||
|
||||
- 不要在提示词里暴露内部实现细节(例如“命令文本猜测”等)
|
||||
- 文案应面向用户能力边界与操作建议,不描述内部判定算法
|
||||
|
||||
34
README.md
34
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`
|
||||
|
||||
### 👥 多用户管理
|
||||
|
||||
完整的用户系统:
|
||||
|
||||
8
config/host_sandbox_policy.json
Normal file
8
config/host_sandbox_policy.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"macos_writable_paths": [
|
||||
"/Users/jojo/Desktop"
|
||||
],
|
||||
"macos_readable_extra_paths": [
|
||||
"/Users/jojo/Desktop"
|
||||
]
|
||||
}
|
||||
@ -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":
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
# 字符数检查
|
||||
|
||||
178
docs/host_sandbox_and_permission_model.md
Normal file
178
docs/host_sandbox_and_permission_model.md
Normal 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. 文案与产品约束
|
||||
|
||||
- 面向用户的提示词仅描述“能力边界”和“可操作建议”
|
||||
- 不暴露内部实现细节(如内部判定逻辑、实现细节名称)
|
||||
- 审批流程文案要强调“单次授权、最小权限、可回退”
|
||||
|
||||
@ -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:
|
||||
|
||||
78
modules/host_sandbox_policy.py
Normal file
78
modules/host_sandbox_policy.py
Normal 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
|
||||
@ -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))
|
||||
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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` 抽取,避免全文读取。
|
||||
|
||||
|
||||
@ -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` 抽取,避免全文读取。
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -328,6 +328,7 @@
|
||||
@toggle-permission-menu="togglePermissionMenu"
|
||||
@change-permission-mode="changePermissionMode"
|
||||
@change-execution-mode="changeExecutionMode"
|
||||
@open-path-authorization="openPathAuthorizationDialog"
|
||||
@open-versioning-dialog="openVersioningDialog"
|
||||
@guide-runtime-message="handleGuideRuntimeMessage"
|
||||
@delete-runtime-message="handleDeleteRuntimeMessage"
|
||||
@ -354,6 +355,16 @@
|
||||
</div>
|
||||
|
||||
<PersonalizationDrawer />
|
||||
<PathAuthorizationDialog
|
||||
:open="pathAuthorizationDialogOpen"
|
||||
:value="pathAuthorizationDraft"
|
||||
:mode="pathAuthorizationMode"
|
||||
:saving="pathAuthorizationSaving"
|
||||
@update:value="(v) => (pathAuthorizationDraft = v)"
|
||||
@update:mode="setPathAuthorizationMode"
|
||||
@close="closePathAuthorizationDialog"
|
||||
@save="savePathAuthorization"
|
||||
/>
|
||||
<transition name="overlay-fade">
|
||||
<ImagePicker
|
||||
v-if="imagePickerOpen"
|
||||
@ -730,6 +741,9 @@ import { useTutorialStore } from './stores/tutorial';
|
||||
const VirtualMonitorSurface = defineAsyncComponent(
|
||||
() => import('./components/chat/VirtualMonitorSurface.vue')
|
||||
);
|
||||
const PathAuthorizationDialog = defineAsyncComponent(
|
||||
() => import('./components/overlay/PathAuthorizationDialog.vue')
|
||||
);
|
||||
const tutorialStore = useTutorialStore();
|
||||
|
||||
const mobilePanelIcon = new URL('../icons/align-left.svg', import.meta.url).href;
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -145,6 +145,7 @@
|
||||
:block-token-panel="blockTokenPanel"
|
||||
:block-compress-conversation="blockCompressConversation"
|
||||
:block-conversation-review="blockConversationReview"
|
||||
:execution-mode-enabled="executionModeEnabled"
|
||||
@quick-upload="triggerQuickUpload"
|
||||
@pick-images="$emit('pick-images')"
|
||||
@pick-video="$emit('pick-video')"
|
||||
@ -161,6 +162,7 @@
|
||||
@compress-conversation="$emit('compress-conversation')"
|
||||
@toggle-approval-panel="$emit('toggle-approval-panel')"
|
||||
@open-review="$emit('open-review')"
|
||||
@open-path-authorization="$emit('open-path-authorization')"
|
||||
/>
|
||||
<div class="permission-switcher" @click.stop>
|
||||
<button
|
||||
@ -288,6 +290,7 @@ const emit = defineEmits([
|
||||
'remove-image',
|
||||
'remove-video',
|
||||
'open-review',
|
||||
'open-path-authorization',
|
||||
'toggle-permission-menu',
|
||||
'change-permission-mode',
|
||||
'change-execution-mode',
|
||||
|
||||
@ -129,6 +129,14 @@
|
||||
>
|
||||
审批面板
|
||||
</button>
|
||||
<button
|
||||
v-if="executionModeEnabled"
|
||||
type="button"
|
||||
class="menu-entry submenu-entry"
|
||||
@click="$emit('open-path-authorization')"
|
||||
>
|
||||
路径授权
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
@ -174,6 +182,7 @@ const props = defineProps<{
|
||||
blockTokenPanel?: boolean;
|
||||
blockCompressConversation?: boolean;
|
||||
blockConversationReview?: boolean;
|
||||
executionModeEnabled?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
@ -191,6 +200,7 @@ defineEmits<{
|
||||
(event: 'select-model', key: string): void;
|
||||
(event: 'open-review'): void;
|
||||
(event: 'pick-video'): void;
|
||||
(event: 'open-path-authorization'): void;
|
||||
}>();
|
||||
|
||||
const getIconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {});
|
||||
|
||||
85
static/src/components/overlay/PathAuthorizationDialog.vue
Normal file
85
static/src/components/overlay/PathAuthorizationDialog.vue
Normal 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>
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user