1️⃣ 描述一下问题
先说明利益关系:我维护开源的智能体委派库 attenu-guard
(github.com/attenu-io/attenu-guard),是在为它测试 handoff 时发现这个问题的。附件补丁是
你们项目原生的,没有用到它。
默认审批模式把 write_file、edit_file、execute 从子智能体隐藏,注释写明是为了避免
子智能体绕过主线程的逐项审批(buildin/subagent/graph.py:30)。
隐藏只发生在 _SubAgentToolFilterMiddleware.wrap_model_call 的 bind_tools 上
(graph.py:57-61)。create_agent_filesystem_middleware 仍把七个文件工具全部注册进
子智能体的 ToolNode(backends/composite.py:126-135),而子智能体的 _build_middlewares
从不调用 create_tool_approval_middleware——产品代码里这个调用只出现在
buildin/chatbot/graph.py:59-63(单元测试另有直接调用)。所以同名工具调用会直接执行,
不经过任何审批。
影响版本:main 分支 fd0d9c4f48ba0e4701457196a2f967232be6091c。
(English) Interest first: I maintain attenu-guard, an open-source agent-delegation library
(github.com/attenu-io/attenu-guard), and found this while testing handoffs for it. The
attached patch is native to your project and does not use it.
Default approval mode hides write_file, edit_file and execute from subagents, with the
comment that subagents must not bypass the main thread's per-call approval
(buildin/subagent/graph.py:30).
The hide happens only at bind_tools, in _SubAgentToolFilterMiddleware.wrap_model_call
(graph.py:57-61). create_agent_filesystem_middleware still registers all seven
filesystem tools in the subagent's ToolNode (backends/composite.py:126-135), and the
subagent's _build_middlewares never calls create_tool_approval_middleware. In production
code that call appears only at buildin/chatbot/graph.py:59-63; the unit tests call it
directly as well. So a tool call naming a hidden tool runs, with no approval.
Affected: fd0d9c4f48ba0e4701457196a2f967232be6091c on main.
2️⃣ 报错日志
这是针对源码的报告,不是针对运行中的实例,所以这里放的是附件复现脚本的输出,不是
make logs。脚本重建子智能体的 filesystem + filter 中间件对,用脚本化模型和内存
StateBackend 驱动,不需要 API key 或网络。以下为节选:
A. PARENT, default mode: write_file is bound and goes to human approval
write_file offered to the model : True
run paused for approval : True
file written without approval : False
B. SUBAGENT, default mode: write_file is hidden from the model
write_file offered to the model : False (hidden, as intended)
run paused for approval : False (subagent graph builds no approval middleware)
file written : True
tool result : Updated file /workdir/written_by_subagent.txt
3️⃣ 相关截图
无。
#️⃣ 其他相关信息
有两点我没有验证,所以不作声明。第一,真实模型是否会发出未绑定工具的调用。从代码看有一条
路径不需要它:子智能体继承创建方 run 的模式(services/subagent_run_service.py:239),
且子图带 checkpointer,先以 always_trust 跑过的线程会把成功的 write_file 调用留在历史
里,带进之后的 default 续跑。这条路径我没有端到端跑通。第二,我没有真正执行命令:
execute 是三者中唯一没有豁免谓词的(tool_approval.py:36),在子智能体上它会一路到达
工具体且无人检查,但内存后端拒绝执行,所以我观察到的是缺失的审批,不是命令被执行。
edit_file 我也没有单独跑过。
附件补丁不再把被禁用的工具注册进 ToolNode,并在 wrap_tool_call 里于执行前拒绝。
always_trust 行为不变。脚本里的 C 段仍然会拿到 write_file 并写入。补丁新增六个测试
用例;在我本机 pytest test/unit -m "not slow" --ignore=test/unit/test_live_api_cleanup.py
从 1800 通过变为 1806 通过。排除那个文件是因为在我使用的 Homebrew Python 下,标准库的
test 包会遮蔽 backend/test,该文件收集失败。你们 CI 在本 commit 上是绿的,所以这个
排除是我环境的问题,不是你们的。
(English) Two things I did not demonstrate, so I am not claiming them. First, that a
production model emits a call to a tool it was never bound. Reading the code, one path
would not need it: the child inherits the creator run's mode
(services/subagent_run_service.py:239) and the subagent graph is checkpointed, so a
thread first run under always_trust carries successful write_file calls in its history
into a later default run. I did not run that end to end. Second, I did not run a command:
execute is the one of the three with no exemption predicate (tool_approval.py:36), and
on the subagent it reaches the tool body with nothing checking it, but the in-memory
backend refuses to execute, so what I observed is the missing gate, not a command running.
I did not exercise edit_file separately either.
The attached patch stops registering the disabled tools in the ToolNode and refuses them in
wrap_tool_call before the body. always_trust is unchanged. Case C in the script still
offers write_file and writes. The patch adds six test cases; on my machine
pytest test/unit -m "not slow" --ignore=test/unit/test_live_api_cleanup.py goes from 1800
to 1806 passed. I exclude that file because the stdlib test package shadows
backend/test under my Homebrew Python and it does not collect. Your CI is green on this
commit, so treat that exclusion as mine, not yours.
复现脚本 repro_yuxi_subagent_tools.py(补丁随后的 PR 一起提交)/ The repro script; the patch comes with the PR:
"""Reproduction: a Yuxi subagent can execute the sensitive backend tools that
default approval mode is supposed to keep away from it.
Affected: xerrors/Yuxi @ fd0d9c4f48ba0e4701457196a2f967232be6091c (main)
No API key, no network, no database: the model is a deterministic script and the
filesystem backend is deepagents' in-memory StateBackend.
Run from the repo's `backend/` directory:
uv sync --frozen --group test
uv run python /path/to/repro_yuxi_subagent_tools.py
Exit code 0 = the restriction holds. Exit code 1 = a hidden tool executed.
"""
from __future__ import annotations
import asyncio
import inspect
import sys
from typing import Any
from deepagents.backends import StateBackend
from langchain.agents import create_agent
from langchain_core.callbacks import CallbackManagerForLLMRun
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, BaseMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from yuxi.agents.backends import create_agent_filesystem_middleware
from yuxi.agents.buildin.subagent.graph import (
_disabled_tools_for,
_SubAgentToolFilterMiddleware,
)
from yuxi.agents.tool_approval import create_tool_approval_middleware
TARGET_FILE = "/workdir/written_by_subagent.txt"
BOUND_TOOLS: list[list[str]] = []
class ScriptedModel(BaseChatModel):
"""Deterministic stand-in for the LLM; records what it was bound to."""
script: list[AIMessage] = []
calls: int = 0
@property
def _llm_type(self) -> str:
return "scripted"
def bind_tools(self, tools: Any, **kwargs: Any) -> Any:
names = []
for tool in tools:
name = tool.get("name") if isinstance(tool, dict) else getattr(tool, "name", None)
if isinstance(name, str):
names.append(name)
BOUND_TOOLS.append(names)
return self
def _generate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> ChatResult:
index = min(self.calls, len(self.script) - 1)
self.calls += 1
return ChatResult(generations=[ChatGeneration(message=self.script[index])])
WRITE_CALL = AIMessage(
content="",
tool_calls=[
{
"name": "write_file",
"args": {"file_path": TARGET_FILE, "content": "subagent wrote this"},
"id": "call_1",
}
],
)
DONE = AIMessage(content="done")
def _model() -> ScriptedModel:
return ScriptedModel(script=[WRITE_CALL, DONE], calls=0)
def _fs_middleware(backend, mode: str):
"""Build the filesystem middleware the way the subagent graph does.
On the affected commit `create_agent_filesystem_middleware` takes no
disabled-tools argument, so every fs tool is registered for every agent.
"""
if "disabled_tools" in inspect.signature(create_agent_filesystem_middleware).parameters:
return create_agent_filesystem_middleware(backend=backend, disabled_tools=_disabled_tools_for(mode))
return create_agent_filesystem_middleware(backend=backend)
def build_subagent(mode: str):
"""The subagent graph's middleware stack, reduced to the parts under test."""
backend = StateBackend()
return create_agent(
model=_model(),
tools=[],
system_prompt="subagent",
middleware=[_fs_middleware(backend, mode), _SubAgentToolFilterMiddleware(mode)],
)
def build_parent(mode: str):
"""The chatbot (parent) graph's middleware stack, reduced the same way."""
backend = StateBackend()
middleware = [create_agent_filesystem_middleware(backend=backend)]
approval = create_tool_approval_middleware(mode, current_project_path="/workdir/project")
if approval:
middleware.append(approval)
return create_agent(model=_model(), tools=[], system_prompt="parent", middleware=middleware)
def _wrote(result: dict) -> bool:
return TARGET_FILE in (result.get("files") or {})
def _interrupted(result: dict) -> bool:
return bool(result.get("__interrupt__"))
async def main() -> int:
print("=" * 74)
print("A. PARENT, default mode: write_file is bound and goes to human approval")
print("=" * 74)
BOUND_TOOLS.clear()
parent = await build_parent("default").ainvoke({"messages": [("user", "write a file")]})
print(f" write_file offered to the model : {'write_file' in BOUND_TOOLS[0]}")
print(f" run paused for approval : {_interrupted(parent)}")
print(f" file written without approval : {_wrote(parent)}")
print()
print("=" * 74)
print("B. SUBAGENT, default mode: write_file is hidden from the model")
print(" Yuxi hides it so 'subagents cannot bypass the main thread's approval'")
print(" (backend/package/yuxi/agents/buildin/subagent/graph.py)")
print("=" * 74)
BOUND_TOOLS.clear()
child = await build_subagent("default").ainvoke({"messages": [("user", "write a file")]})
offered = "write_file" in BOUND_TOOLS[0]
wrote = _wrote(child)
print(f" write_file offered to the model : {offered} (hidden, as intended)")
print(f" run paused for approval : {_interrupted(child)} (subagent graph builds no approval middleware)")
print(f" file written : {wrote}")
if wrote:
print(f" tool result : {child['messages'][-2].content.strip()[:70]}")
print()
print("=" * 74)
print("C. SUBAGENT, always_trust: the same three tools come back")
print("=" * 74)
BOUND_TOOLS.clear()
trusted = await build_subagent("always_trust").ainvoke({"messages": [("user", "write a file")]})
print(f" write_file offered to the model : {'write_file' in BOUND_TOOLS[0]}")
print(f" file written : {_wrote(trusted)}")
print(f" parent approval middleware : {create_tool_approval_middleware('always_trust')}")
print()
print("=" * 74)
if wrote:
print("RESULT: the hidden tool executed.")
print(" The subagent's model was never offered write_file, but the filesystem")
print(" middleware still registered it in the subagent's ToolNode, and the")
print(" subagent graph has no approval middleware. Any tool call naming it")
print(" runs unapproved, in the parent's own sandbox.")
print("=" * 74)
return 1
print("RESULT: the restriction holds - the hidden tool was refused before execution,")
print(" and always_trust still works.")
print("=" * 74)
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
1️⃣ 描述一下问题
先说明利益关系:我维护开源的智能体委派库 attenu-guard
(github.com/attenu-io/attenu-guard),是在为它测试 handoff 时发现这个问题的。附件补丁是
你们项目原生的,没有用到它。
默认审批模式把
write_file、edit_file、execute从子智能体隐藏,注释写明是为了避免子智能体绕过主线程的逐项审批(
buildin/subagent/graph.py:30)。隐藏只发生在
_SubAgentToolFilterMiddleware.wrap_model_call的bind_tools上(
graph.py:57-61)。create_agent_filesystem_middleware仍把七个文件工具全部注册进子智能体的 ToolNode(
backends/composite.py:126-135),而子智能体的_build_middlewares从不调用
create_tool_approval_middleware——产品代码里这个调用只出现在buildin/chatbot/graph.py:59-63(单元测试另有直接调用)。所以同名工具调用会直接执行,不经过任何审批。
影响版本:
main分支fd0d9c4f48ba0e4701457196a2f967232be6091c。(English) Interest first: I maintain attenu-guard, an open-source agent-delegation library
(github.com/attenu-io/attenu-guard), and found this while testing handoffs for it. The
attached patch is native to your project and does not use it.
Default approval mode hides
write_file,edit_fileandexecutefrom subagents, with thecomment that subagents must not bypass the main thread's per-call approval
(
buildin/subagent/graph.py:30).The hide happens only at
bind_tools, in_SubAgentToolFilterMiddleware.wrap_model_call(
graph.py:57-61).create_agent_filesystem_middlewarestill registers all sevenfilesystem tools in the subagent's ToolNode (
backends/composite.py:126-135), and thesubagent's
_build_middlewaresnever callscreate_tool_approval_middleware. In productioncode that call appears only at
buildin/chatbot/graph.py:59-63; the unit tests call itdirectly as well. So a tool call naming a hidden tool runs, with no approval.
Affected:
fd0d9c4f48ba0e4701457196a2f967232be6091conmain.2️⃣ 报错日志
这是针对源码的报告,不是针对运行中的实例,所以这里放的是附件复现脚本的输出,不是
make logs。脚本重建子智能体的 filesystem + filter 中间件对,用脚本化模型和内存StateBackend 驱动,不需要 API key 或网络。以下为节选:
3️⃣ 相关截图
无。
#️⃣ 其他相关信息
有两点我没有验证,所以不作声明。第一,真实模型是否会发出未绑定工具的调用。从代码看有一条
路径不需要它:子智能体继承创建方 run 的模式(
services/subagent_run_service.py:239),且子图带 checkpointer,先以
always_trust跑过的线程会把成功的write_file调用留在历史里,带进之后的
default续跑。这条路径我没有端到端跑通。第二,我没有真正执行命令:execute是三者中唯一没有豁免谓词的(tool_approval.py:36),在子智能体上它会一路到达工具体且无人检查,但内存后端拒绝执行,所以我观察到的是缺失的审批,不是命令被执行。
edit_file我也没有单独跑过。附件补丁不再把被禁用的工具注册进 ToolNode,并在
wrap_tool_call里于执行前拒绝。always_trust行为不变。脚本里的 C 段仍然会拿到write_file并写入。补丁新增六个测试用例;在我本机
pytest test/unit -m "not slow" --ignore=test/unit/test_live_api_cleanup.py从 1800 通过变为 1806 通过。排除那个文件是因为在我使用的 Homebrew Python 下,标准库的
test包会遮蔽backend/test,该文件收集失败。你们 CI 在本 commit 上是绿的,所以这个排除是我环境的问题,不是你们的。
(English) Two things I did not demonstrate, so I am not claiming them. First, that a
production model emits a call to a tool it was never bound. Reading the code, one path
would not need it: the child inherits the creator run's mode
(
services/subagent_run_service.py:239) and the subagent graph is checkpointed, so athread first run under
always_trustcarries successfulwrite_filecalls in its historyinto a later
defaultrun. I did not run that end to end. Second, I did not run a command:executeis the one of the three with no exemption predicate (tool_approval.py:36), andon the subagent it reaches the tool body with nothing checking it, but the in-memory
backend refuses to execute, so what I observed is the missing gate, not a command running.
I did not exercise
edit_fileseparately either.The attached patch stops registering the disabled tools in the ToolNode and refuses them in
wrap_tool_callbefore the body.always_trustis unchanged. Case C in the script stilloffers
write_fileand writes. The patch adds six test cases; on my machinepytest test/unit -m "not slow" --ignore=test/unit/test_live_api_cleanup.pygoes from 1800to 1806 passed. I exclude that file because the stdlib
testpackage shadowsbackend/testunder my Homebrew Python and it does not collect. Your CI is green on thiscommit, so treat that exclusion as mine, not yours.
复现脚本
repro_yuxi_subagent_tools.py(补丁随后的 PR 一起提交)/ The repro script; the patch comes with the PR: