# Hermes Agent 工作目录沙箱隔离

## 背景

Hermes Agent 节点（`HermesAgentExecutor` / `HermesSmartActionExecutor`）的工作目录由 `WorkflowRunDirManager` 在每次运行时分配，路径形如：

```
backend/data/workflow-runs/{workflowId}/{runId}/
```

早期实现仅通过 `agent.runtime_cwd._SESSION_CWD` contextvar 做软约束 —— LLM 仍可通过**绝对路径**或 `terminal` 工具轻易逃逸到上层目录（实测：要求 Hermes 在工作目录生成 `README.md` 时，它写到了整个 `backend/` 目录）。

## 根因

| 缺陷 | 位置 | 影响 |
|------|------|------|
| 绝对路径直接放行 | `tools/file_tools.py:_resolve_path_for_task` | LLM 写 `D:/...` 任意路径可成功 |
| `_git_root` 向上递归 | `agent/coding_context.py:_git_root` | system_prompt 注入外层 `.git` 根，主动误导 LLM |
| `TERMINAL_CWD` 未设置 | `HermesProcessManager.java` 未注入环境变量 | hermes-agent 退化到 `os.getcwd()` = 项目根 |

三个缺陷叠加导致隔离失效。即使只修其中一个，另外两个仍可被利用。

## 隔离方案：三层 monkey-patch

在 `backend/hermes-bridge/sandbox_patch.py` 中，bridge 启动后第一次创建 Agent 实例前，对 hermes-agent 的三个核心函数打补丁。所有补丁都通过 `_SESSION_CWD` contextvar 读取当前线程的工作目录，实现**线程隔离**（bridge 每个 `/run` 在独立 daemon thread 中执行）。

### 1. `_resolve_path_for_task` — 路径白名单

```python
def _sandbox_resolve_path(filepath, task_id="default"):
    resolved = _orig_resolve_path(filepath, task_id)  # 先走原解析
    sandbox = _session_cwd_or_none()
    if sandbox is None:
        return resolved  # 未启用沙箱（hermes-agent 独立运行时）
    try:
        resolved.relative_to(sandbox)  # 必须在 sandbox 之内
    except ValueError:
        raise PermissionError(f"沙箱拒绝访问：路径 ... 超出了运行目录 {sandbox}")
    return resolved
```

效果：LLM 调用 `write_file(filepath="/etc/passwd")` 或 `write_file(filepath="../../README.md")` 都会被拦截。

### 2. `_resolve_base_dir` — 相对路径锚定

```python
def _sandbox_resolve_base(task_id="default"):
    sandbox = _session_cwd_or_none()
    if sandbox is not None:
        return sandbox  # 相对路径基点直接锚定到隔离目录
    return _orig_resolve_base(task_id)
```

效果：相对路径不再依赖 `TERMINAL_CWD` / `os.getcwd()`，从根本上避免基点漂移。

### 3. `_git_root` — 阻断外层项目根注入

```python
def _sandbox_git_root(cwd):
    if _session_cwd_or_none() is not None:
        return None  # 隔离模式下不识别 .git
    return _orig_git_root(cwd)
```

效果：`coding_context.build_coding_workspace_block` 收到 `None`，不会把外层项目根注入到 system_prompt 的 "Workspace Root" 字段，从源头消除 LLM 误写。

## 降级策略

- `_SESSION_CWD` 未设置（hermes-agent 独立 CLI 运行）：所有 patch 函数透传原逻辑，**不影响 hermes-agent 原生行为**
- import 失败（hermes-agent 未就绪）：`install_sandbox_patches` 返回 `False` 并 warning，bridge 启动不阻塞
- patch 内部异常：被 `hermes_bridge.py` 的 try-except 兜底，仅 warning

## 加载时机

`hermes_bridge.py` 的 `_get_or_create_agent()` 函数在 `from run_agent import AIAgent` 之前调用 `install_sandbox_patches()`：

```python
try:
    import sandbox_patch
    sandbox_patch.install_sandbox_patches()
except Exception as e:
    logger.warning("沙箱补丁加载异常（继续但不保证隔离）: %s", e)

try:
    from run_agent import AIAgent
    ...
```

`install_sandbox_patches()` 是幂等的（用 `_installed` 全局 flag 保证只生效一次）。

## 已知限制

| 风险 | 说明 | 缓解策略 |
|------|------|---------|
| `terminal` 工具执行 shell | LLM 可通过 `cd ../..` 后 `echo > xxx` 绕过 | 业务侧通过 Skill 定义约束；如需硬隔离，可考虑禁用 terminal 工具或后续加 shell-level hook |
| `os.chdir()` 仍是进程全局 | bridge 多线程并发时不安全 | 本方案不使用 `os.chdir`，完全依赖 contextvar + monkey-patch |

## 验证

1. Hermes Agent 节点配置：让 LLM 在工作目录创建 `README.md`
2. 检查生成位置：
   - 修复前：`backend/README.md`（逃逸到上层）
   - 修复后：`backend/data/workflow-runs/{wid}/{rid}/README.md`（正确隔离）
3. 尝试让 LLM 用绝对路径写文件，应看到 `PermissionError` 在日志中
