跳到主要内容

数据截至 (上游 commit ae57a2357745)

第 6 章 · 沙箱与安全边界

本章讲:一个会在你机器上跑任意代码的 AI,到底被什么拦着。答案分五层,其中只有一层是真隔离。


6.1 先把威胁模型说清楚

AgenticSeek 干的事天然危险:

  • 让 LLM 写 shell 命令并直接执行
  • 让 LLM 上任意网站,网页内容会进入 LLM 的上下文
  • 后端暴露一个 HTTP 接口,POST 一段文字就能触发上面两件事。

最后一条尤其要命:/query 端点等价于远程代码执行。项目自己在 api.py:315-318 的注释里就是这么写的:

“The /query endpoint is unauthenticated and the coder agent executes shell commands on the host, so exposing it on a network interface is equivalent to handing out remote code execution.”


6.2 五层防护速览

防什么默认状态强度
① 工作区路径守卫文件工具读写越界默认开真守卫(realpath 比对)
② 危险命令黑名单rm -rf 之类默认关safe_mode = False关键词层面,可绕
③ API bearer token未授权调用 /query默认关(不设环境变量就放行)有效但要手动开
④ 网页 JS 阉割恶意/骚扰网页默认开客户端 JS 层面
⑤ Docker + 回环绑定一切start_services.sh full 时开唯一的真隔离

下面逐层看。


6.3 第 ① 层:工作区路径守卫

这是 sources/workspace.py 里最重要的一个函数(71-93resolve_workspace_path):

base = work_dir or get_work_dir()
candidate = str(path).strip()
if os.path.isabs(candidate):
resolved = os.path.realpath(candidate)
else:
resolved = os.path.realpath(os.path.join(base, candidate))

if not is_within_directory(resolved, base):
raise PermissionError(f"Path '{path}' is outside the agent workspace ({base})")
return resolved

判定用的是 commonpathsources/workspace.py:61-68):

path_real = os.path.realpath(os.path.abspath(path))
dir_real = os.path.realpath(os.path.abspath(directory))
return os.path.commonpath([path_real, dir_real]) == dir_real

为什么用 realpath 而不是字符串前缀比较realpath 会解开符号链接。否则在工作区里放一个指向 /etc 的软链,字符串比较会认为它「在工作区内」。

谁受它保护

调用点位置
Tools.resolve_path(工具基类的统一入口)sources/tools/tools.py:64-66
Tools.save_block(代码块存盘)sources/tools/tools.py:102-125
FileFinder.read_filesources/tools/fileFinder.py:30
FileFinder.get_file_infosources/tools/fileFinder.py:72-74
FileFinder.recursive_searchsources/tools/fileFinder.py:102-104

tests/test_workspace.py:54test_resolve_workspace_path_blocks_traversal)和 72test_cannot_read_file_outside_workspace)盯着这条。

但它保护不了执行

PyInterpreterBashInterpreter 不调 resolve_path 它们只是把 cwd 设成工作区:

result = subprocess.run([sys.executable, "-c", code], cwd=self.work_dir, ...)

sources/tools/PyInterpreter.py:60-66

cwd 只是「相对路径的起点」,不是牢笼。 模型写一句 open('/etc/passwd').read()cd / && ls,照样成功。

所以第 ① 层的准确描述是:约束的是「文件工具」,不是「被执行的代码」。


6.4 第 ② 层:危险命令黑名单

sources/tools/safety.py 维护两张表:Unix 30 项(sources/tools/safety.py:5-36)、Windows 29 项(38-68)。

Unix 表里除了 rm/dd/mkfs/shutdown 这些意料之中的,还有几个有意思的:

条目为什么在名单上
git防 agent 提交/推送/reset 掉你的仓库
rebasegit 分列两条,git 之外单独再拦一次改历史的动作
--force一个裸的 flag,任何带它的命令都被拦
brew防止随意装包
screen会话管理,能起脱管进程

匹配逻辑做过加固

早期的关键词子串匹配很容易误伤(digit 里有 git)也很容易绕(/bin/rm)。现在的实现是分词 + basename 归一 + 子集判定sources/tools/safety.py:79-100):

def _tokenize(cmd):
try:
return shlex.split(cmd, posix=not sys.platform.startswith("win"))
except ValueError:
return cmd.split()

def is_unsafe(cmd):
norm_tokens = {os.path.basename(t) for t in _tokenize(cmd)}
bag = unsafe_commands_windows if sys.platform.startswith("win") else unsafe_commands_unix
for entry in bag:
parts = {os.path.basename(p) for p in entry.split()}
if parts and parts.issubset(norm_tokens):
return True
return False

三处设计各解决一个问题:

手法解决
shlex.split 分词digitformat_string 这类含子串的单词不再误报
os.path.basename/bin/rm./rm 归一成 rm绕不过去
多词条目用 issubsetchkdsk /f 中间插了别的 flag 也照样命中
shlex 抛异常时退回 split()引号不配对的命令不至于直接崩

tests/test_safety.py 里 14 条测试正好覆盖这几类(test_allows_word_containing_gittest_blocks_absolute_path_rm 等)。

但它默认是关的

self.safe_mode = self.config.getboolean('MAIN', 'safe_mode', fallback=False)

sources/tools/tools.py:45config.inisafe_mode = False

只有 BashInterpreter 检查它,而且是整批预校验sources/tools/BashInterpreter.py:46-50):

if self.safe_mode:
for command in commands:
if is_unsafe(command):
return f"\nUnsafe command: {command}. Execution aborted. …"

注意语义:一批里有任何一条不安全,整批都不执行——不是跳过那一条。tests/test_interpreters.py:59test_unsafe_command_anywhere_aborts_whole_batch)守着这条。

Python 解释器完全不看 safe_mode import os; os.system('rm -rf ...') 不经过这张表。所以第 ② 层只是「防手滑」,不是「防对抗」。


6.5 第 ③ 层:API bearer token

sources/api_auth.py 全文只有 28 行,设计取舍写在 docstring 里:

Off by default: if AGENTICSEEK_API_TOKEN is unset, every request passes, matching the existing local-only UX.”

async def require_api_token(authorization: str | None = Header(default=None)) -> None:
expected_token = os.getenv("AGENTICSEEK_API_TOKEN")
if not expected_token:
return # 没配就全放行
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or malformed Authorization header")
provided_token = authorization[len("Bearer "):]
if not hmac.compare_digest(provided_token, expected_token):
raise HTTPException(status_code=401, detail="Invalid API token")

两个细节:

  • hmac.compare_digest 而不是 == —— 常数时间比较,防时序侧信道。这种小项目里能想到这一层不常见。
  • 只挂在 /queryapi.py:230-234):
@api.post("/query", response_model=QueryResponse, dependencies=[Depends(require_api_token)])

/health/is_active/stop/latest_answer/screenshot 都不需要 token。这是有意的分层:只有「能触发执行」的那个端点要鉴权,只读端点保持开放好让前端轮询简单。但要注意 /stop 是无鉴权的写操作,任何能访问端口的人都能打断正在跑的任务;/latest_answer 也会吐出 agent 的完整答案和执行结果。

tests/test_api_auth.py 的 5 条测试覆盖了「不配 token 放行 / 缺 header / 格式错 / token 错 / token 对」。


6.6 第 ④ 层:网页侧的能力阉割

每次 go_to() 成功后调 apply_web_safety(),往页面注入 sources/web_scripts/inject_safety_script.jssources/browser.py:363816-821)。被掐掉的能力:

被禁原本能干什么
navigator.serial / hid / bluetooth访问串口、HID、蓝牙设备
HTMLMediaElement.prototype.play自动播放音视频(改成立刻 pause)
Element.requestFullscreen强制全屏
Element.requestPointerLock锁定鼠标指针
window.fetch后台发请求
window.prompt弹模态框卡住自动化

这一层的定位是降低无人值守浏览的骚扰面和隐私面,不是安全沙箱——页面 JS 完全可以在注入前就执行,XMLHttpRequest 也没被禁(只禁了 fetch)。

副作用要认:禁 fetch 会让重度依赖它的 SPA 直接瘫痪。这是可用性换控制权的明确取舍。

同一个目录下还有一个方向相反的脚本 sources/web_scripts/spoofing.js——它不是保护你,是保护 agent 不被网站识别,属于反检测那一套(driver 替换、启动参数、指纹脚本三层叠加,展开见 04-browser.md §4.8)。两个脚本别搞混:

spoofing.js → 注入时机:Browser 构造时(patch_browser_fingerprint)
目的:不让网站认出这是自动化

inject_safety_script.js → 注入时机:每次 go_to 成功后(apply_web_safety)
目的:不让网站对本机干坏事

6.7 第 ⑤ 层:Docker 与网络绑定(唯一的真隔离)

前四层都在进程内,能挡手滑挡不住对抗。真正的边界在部署层。

后端容器只挂一个目录

volumes:
# Agent workspace: the only directory agents may read/write/execute in.
- ${WORK_DIR}:/opt/workspace
# Runtime data (logs, screenshots, sessions) — kept separate from app source.
- agent-runtime:/opt/agent-runtime

docker-compose.yml backend 段)

这才是「agent 只能碰工作区」的真正兑现方式:容器里根本没有你的家目录。第 ① 层的路径守卫是纵深防御,容器边界才是承重墙。

端口只发布在回环

ports:
- 127.0.0.1:${BACKEND_PORT:-7777}:${BACKEND_PORT:-7777}
environment:
- BACKEND_HOST=0.0.0.0

两个地址看着矛盾,其实是配套的:容器内绑 0.0.0.0 才能被端口映射转发到,而宿主机侧只把它开在 127.0.0.1。局域网里的其他机器访问不到。

直接跑 api.py 时也有守卫

host = os.getenv("BACKEND_HOST", "127.0.0.1")
if host not in ("127.0.0.1", "localhost", "::1"):
pretty_print(
f"SECURITY WARNING: backend bound to {host}. The API is unauthenticated and "
"can execute shell commands on this host. …",
color="failure",
)

api.py:319-326

默认回环,非回环就大声警告——不阻止,但让你知道自己在干什么。

CORS 也收紧了

allowed_origins = [origin.strip()
for origin in os.getenv("FRONTEND_ORIGINS", "http://localhost:3000").split(",")
if origin.strip()]

api.py:58-70)默认只允许本机 3000 端口,不是 *

启动脚本的前置检查

start_services.sh 在挂载前做两道校验:

检查拦什么
WORK_DIR 的 realpath 不能等于仓库根目录防止 agent 把自己的源码改了
WORK_DIR 体积不超过 20GB防止误把整个家目录挂进容器

第一条的错误信息很直白:“WORK_DIR must not be the AgenticSeek repository root.”

Docker 环境的强制无头

api.py:82-96 检测到在容器里(/.dockerenv 存在或 cgroup 含 docker)而配置里 headless_browser = False 时,强制改成 True 并打一个醒目的横幅警告——容器里没有显示服务器,有头浏览器必崩。


6.8 还有一条没被防住的路:prompt 注入

把前面的链路串起来看:

任意网页正文
| Browser.get_text() 转 Markdown
v
make_navigation_prompt() 原样拼进 prompt
|
v
LLM 读到(网页内容和系统指令在同一段文本里)
|
v
LLM 的回复被 execute_modules() 扫代码块
|
v
```bash 块 → subprocess.Popen(shell=True)

中间没有任何环节区分「这段文字是指令」还是「这段文字是数据」。一个网页只要在正文里写上让模型执行某段命令的话,就有机会让 coder agent 跑掉——尤其是 planner 模式下 web agent 的产出会直接进 coder agent 的 prompt(sources/agents/planner_agent.py:110-132)。

代码里没有针对这条路径的缓解措施。默认关闭的 safe_mode 能挡一部分明显命令,Docker 能把破坏限制在容器内,但**「网页内容 → 代码执行」这条链本身是通的**。

这不是 AgenticSeek 独有的问题(所有「读网页 + 执行代码」的 agent 都有),但把它写清楚比含糊过去有用:

结论:不要用非隔离部署去访问不受信任的网站。


6.9 一张表:默认配置下你实际得到了什么

以 README 推荐的 ./start_services.sh full(后端在 Docker 里)为例:

风险默认防住了吗靠什么
agent 读你的家目录文件容器只挂 WORK_DIR
agent 改 AgenticSeek 自己的源码start_services.sh 前置检查 + 运行时数据分离
局域网里别人调 /query端口只发布在 127.0.0.1
本机上别的程序调 /query需手动设 AGENTICSEEK_API_TOKEN
agent 跑 rm -rf⚠️ 仅限容器内safe_mode 默认关
恶意网页做 prompt 注入无缓解
网页调硬件 / 自动播放 / 后台 fetchinject_safety_script.js

换成 CLI 直跑在宿主机(uv run cli.py),第 1、2、5 行全部变成 ❌——那时只剩进程内的四层软防护。


6.10 本章代码地图

主题文件符号
路径守卫sources/workspace.pyresolve_workspace_pathis_within_directory
目录解析sources/workspace.pyget_work_dirget_runtime_dirruntime_subdir
工具侧路径入口sources/tools/tools.pyTools.resolve_pathTools.save_block
文件读取守卫sources/tools/fileFinder.pyFileFinder.read_fileget_file_inforecursive_search
命令黑名单sources/tools/safety.pyis_unsafe_tokenizeunsafe_commands_unixunsafe_commands_windows
safe_mode 读取与生效sources/tools/tools.pysources/tools/BashInterpreter.pyTools.__init__BashInterpreter.execute
交互式代码拒绝sources/tools/PyInterpreter.pyrefuse_interactive_codeINTERACTIVE_PATTERNS
API 鉴权sources/api_auth.pyrequire_api_token
网络绑定与 CORSapi.py__main__ 段、allowed_origins
Docker 无头强制api.pyis_running_in_dockerinitialize_system
网页能力阉割sources/browser.py + sources/web_scripts/inject_safety_script.jsapply_web_safety
部署边界docker-compose.ymlstart_services.shDockerfile.backendbackend 服务段
安全相关测试tests/test_safety.pytests/test_workspace.pytests/test_api_auth.pytests/test_safe_mode.py