feat: tool approval declarations (v9)

Agent definitions gain tool_approvals: known tool -> ask/auto/deny.
Unknown tool/policy is a 400, and required+deny is a 400
contradiction. The declaration is persisted (tasks.agent_tool_
approvals, PG + migrate), echoed in GET and the creation response
(via to_summary), and resolved for registry definitions -- fixing
a latent drop where resolve_registry_agent re-parsed without new
fields. At dispatch the worker audits the declaration and fails
fast on a defensive required+deny re-check (v3 tools-gate shape,
runner never called); goal/coordinator tasks reject the field at
creation like instructions. effective_tool_approvals maps a
declaration + host global approval to the actual host answer,
with ask_honoured always False: host answers elicitation with
one global switch and carries no tool identity, and the approvals
queue is record-only with no runtime ask channel, so per-tool
interception and credential scope are explicitly NOT claimed.
This commit is contained in:
host committed 2026-11-04 18:02:20 +00:00
1 parent cd33a929ca
commit a51919f24b
6 files changed
+441 -6

No files matched your search

+101 -3
View File
@@ -87,6 +87,45 @@ field, but deliberately as REQUIRED tools, not an allowlist:
seeded earlier by another worker persists in the shared tenant
home). Local/cloud workers skip the check and are documented as
such, not as "tools verified".
v9 (tool approvals, this file plus server/worker changes) adds a
``tool_approvals`` field, and it is deliberately narrower than
"per-tool approval enforcement" -- the execution layer cannot
honour that today, and this track says so instead of claiming it:
* Declaration: ``tool_approvals`` maps a known tool to one of
``ask`` / ``auto`` / ``deny``. Unknown tools and unknown
policies are 400s. A tool declared both required (``tools``)
and ``deny`` is a 400: a task that must use a tool it must
never be allowed to use is a contradiction, not a policy.
* What is NOT claimed: per-tool interception in host.py.
``host.py _answer_server_request`` answers every elicitation
with one global switch (``approval == "auto"`` accepts,
anything else declines) and the elicitation message carries
no tool/server identity this codebase can rely on, so host
cannot tell which tool is asking. The approvals queue
(``cloud/approvals.py``) is record-only today: host runs in
a worker-spawned subprocess with no control-plane channel,
so there is no "ask the user" path at runtime. Credential
scope is NOT part of this field either: MCP credentials live
in the tenant codex-home config / env, per-project, not
per-task, and declaring a scope here without a channel to
enforce it would be the same kind of lie.
* What IS executed: the declaration is validated at creation,
persisted (tasks.agent_tool_approvals, JSON object, NULL =
undeclared), echoed in GET and the creation response, and
at dispatch the worker audits the declaration together with
its effective outcome under today's host behaviour
(``effective_tool_approvals``): ``auto`` + host-auto accepts,
everything else declines, and ``ask`` is reported as
"decline (no ask channel)" -- never as "will ask". The
worker also re-checks the required+deny contradiction at
dispatch (defence in depth for rows written before v9 or
outside the API) and fails fast before any model call, in
the v3 tools-gate shape. Goal and coordinator tasks reject
tool_approvals at creation, like instructions: their turns
do not pass the declaration through, so accepting it there
would be a silent drop.
"""
import json
@@ -102,8 +141,9 @@ EFFORTS = ("low", "medium", "high", "xhigh", "max")
APPROVALS = ("ask", "auto", "full")
EXECUTORS = ("local", "docker", "cloud")
_TOP_LEVEL_KEYS = ("name", "version", "description", "instructions",
"skills", "tools", "defaults")
"skills", "tools", "tool_approvals", "defaults")
KNOWN_TOOLS = ("websearch", "imagegen", "playwright")
TOOL_APPROVAL_POLICIES = ("ask", "auto", "deny")
_DEFAULTS_KEYS = ("model", "effort", "approval", "executor", "plan")
SKILLS_DIR = Path(__file__).resolve().parent / "skills"
AGENTS_DIR = Path(__file__).resolve().parent.parent / "agents"
@@ -139,13 +179,14 @@ class AgentDefinition:
def __init__(self, name, version=None, description=None,
instructions=None, skills=None, tools=None,
defaults=None):
tool_approvals=None, defaults=None):
self.name = name
self.version = version
self.description = description
self.instructions = instructions
self.skills = list(skills or [])
self.tools = list(tools or [])
self.tool_approvals = dict(tool_approvals or {})
self.defaults = dict(defaults or {})
def to_summary(self):
@@ -155,9 +196,39 @@ class AgentDefinition:
"version": self.version,
"skills": list(self.skills),
"tools": list(self.tools),
"tool_approvals": dict(self.tool_approvals),
}
def effective_tool_approvals(tool_approvals, host_approval):
"""What host.py will actually answer per declared tool, today.
``host_approval`` is the host subprocess's global approval
(``"auto"`` or anything else, default ``"deny"``): host
accepts an elicitation only when it is exactly ``"auto"``.
Returns {tool: {"declared": policy, "effective": "accept" /
"decline", "ask_honoured": bool}} -- ``ask_honoured`` is
always False, because there is no ask channel at runtime:
a declared ``ask`` currently declines, it does not ask.
Reporting that plainly is the point of this function; a
caller that presented ``ask`` as "will ask" would be lying.
Unknown tools in the input are ignored (parse already
rejects them at creation; this function is defensive).
"""
out = {}
host_auto = host_approval == "auto"
for tool, policy in (tool_approvals or {}).items():
if tool not in KNOWN_TOOLS or policy not in TOOL_APPROVAL_POLICIES:
continue
if policy == "auto" and host_auto:
out[tool] = {"declared": policy, "effective": "accept",
"ask_honoured": False}
else:
out[tool] = {"declared": policy, "effective": "decline",
"ask_honoured": False}
return out
def missing_required_tools(tools, config_text):
"""Tools declared but with no [mcp_servers.<tool>] section.
@@ -240,6 +311,27 @@ def parse_agent_definition(data, known_skills=None):
if unknown_tools:
raise ValueError("agent.tools unknown tool(s): %s"
% ", ".join(unknown_tools))
tool_approvals = {}
if data.get("tool_approvals") is not None:
raw_approvals = data.get("tool_approvals")
if not isinstance(raw_approvals, dict):
raise ValueError("agent.tool_approvals must be an object")
for tool, policy in raw_approvals.items():
tool = _clean_str(tool, "agent.tool_approvals tool")
if tool not in KNOWN_TOOLS:
raise ValueError(
"agent.tool_approvals unknown tool(s): %s" % tool)
if policy not in TOOL_APPROVAL_POLICIES:
raise ValueError(
"agent.tool_approvals[%s] must be ask, auto or deny"
% tool)
tool_approvals[tool] = policy
denied_required = [t for t in tools
if tool_approvals.get(t) == "deny"]
if denied_required:
raise ValueError(
"agent.tool_approvals denies required tool(s): %s"
% ", ".join(denied_required))
defaults = {}
if data.get("defaults") is not None:
raw_defaults = data.get("defaults")
@@ -280,7 +372,8 @@ def parse_agent_definition(data, known_skills=None):
return AgentDefinition(name=name, version=version,
description=description,
instructions=instructions, skills=skills,
tools=tools, defaults=defaults)
tools=tools, tool_approvals=tool_approvals,
defaults=defaults)
def load_agent_definition(path, agents_dir=None):
@@ -378,6 +471,11 @@ def resolve_registry_agent(name, version=None, agents_dir=None,
"instructions": definition.instructions,
"skills": definition.skills,
"tools": definition.tools,
# v9: the re-parse must carry tool_approvals, or
# a registry definition's declaration would be
# silently dropped on the server path (inline
# definitions never re-parse, so they were fine).
"tool_approvals": definition.tool_approvals,
"defaults": definition.defaults,
},
known_skills=known_skills)
+1
View File
@@ -896,6 +896,7 @@ POSTGRES_SCHEMA = (
"agent_version TEXT, "
"agent_instructions TEXT, "
"agent_tools TEXT, "
"agent_tool_approvals TEXT, "
"UNIQUE(project_id, idempotency_key))",
"CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)",
# 附件包与任务附件:与 server._ensure_uploads_tables 同款,
+35 -3
View File
@@ -1247,6 +1247,8 @@ def migrate_tasks_table(conn):
"agent_instructions": "TEXT",
# agent 定义 v3:必需工具 JSON 数组(NULL = 未声明)。
"agent_tools": "TEXT",
# agent 定义 v9:工具审批声明 JSON 对象(NULL = 未声明)。
"agent_tool_approvals": "TEXT",
}
for name, coltype in new_cols.items():
if name not in cols:
@@ -1807,6 +1809,21 @@ def _agent_tools_from_row(row):
return list(parsed) if isinstance(parsed, list) else None
def _agent_tool_approvals_from_row(row):
"""agent_tool_approvals 列的容错解析:JSON 对象,非对象/脏
数据为 None(NULL = 未声明,与 v3 tools 同款语义)。"""
raw = _row_get(row, "agent_tool_approvals")
if raw is None:
return None
if isinstance(raw, dict):
return dict(raw)
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return None
return dict(parsed) if isinstance(parsed, dict) else None
def _task_to_dict(row, attachments=None, total_tokens=None):
depends_on = row["depends_on"]
try:
@@ -1868,6 +1885,9 @@ def _task_to_dict(row, attachments=None, total_tokens=None):
# v3:agent_tools 存 JSON 数组;脏数据/老行缺列时
# 按 None 计,不炸列表接口。
"agent_tools": _agent_tools_from_row(row),
# v9:agent_tool_approvals 存 JSON 对象;脏数据/老行
# 缺列时按 None 计(= 未声明)。
"agent_tool_approvals": _agent_tool_approvals_from_row(row),
"paused_by_user": _row_get(row, "paused_by_user") or 0,
"interrupted_reason": _row_get(row, "interrupted_reason"),
"auto_resume_count": _row_get(row, "auto_resume_count") or 0,
@@ -4766,12 +4786,23 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"error": "agent instructions cannot be used with"
" goal/coordinator tasks"})
return
# v9:工具审批声明与指令同款限制——goal 循环与协调员
# 父任务不传声明,接受它就是静默丢弃,所以同样拒绝。
if definition is not None and definition.tool_approvals:
if goal_mode or verify_command or coord_parts:
self._send_json(400, {
"error": "agent tool_approvals cannot be used with"
" goal/coordinator tasks"})
return
agent_name_val = definition.name if definition else None
agent_version_val = definition.version if definition else None
agent_instructions_val = (
definition.instructions if definition else None)
agent_tools_val = (
json.dumps(definition.tools) if definition else None)
agent_tool_approvals_val = (
json.dumps(definition.tool_approvals)
if definition and definition.tool_approvals else None)
now = time.time()
conn = self._db()
try:
@@ -4798,9 +4829,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
" parent_task_id, coordinator_part,"
" child_model, child_effort,"
" agent_name, agent_version, agent_instructions,"
" agent_tools)"
" agent_tools, agent_tool_approvals)"
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?,"
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(task_id, project_id, prompt, idempotency_key,
json.dumps(depends_on), priority, user_id, now, now,
executor, goal, verify_command, goal_max_rounds,
@@ -4809,7 +4840,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
json.dumps(coord_parts) if coord_parts else None,
None, None, child_model, child_effort,
agent_name_val, agent_version_val,
agent_instructions_val, agent_tools_val),
agent_instructions_val, agent_tools_val,
agent_tool_approvals_val),
)
# 长任务不限时:API 字段 no_time_limit=1 时写标记,
# worker 认领后按不限时跑(单轮 host 600 秒仍在);
+240
View File
@@ -0,0 +1,240 @@
"""Tool approval declarations (v9): parse, effective map, server
persistence, and the worker dispatch gate/audit.
Run: python3 -m unittest discover -s cloud/tests -p "test_tool_approval.py" -v
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from types import SimpleNamespace
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from cloud.agent_def import (effective_tool_approvals,
parse_agent_definition)
from cloud.auth import AuthStore
from cloud.server import create_server, init_db
from cloud.worker import Worker
def _base_agent(**overrides):
doc = {"name": "demo", "version": "v1", "tools": ["websearch"]}
doc.update(overrides)
return doc
class ParseTestCase(unittest.TestCase):
def test_valid_declaration_parsed(self):
definition = parse_agent_definition(_base_agent(
tool_approvals={"websearch": "ask"}))
self.assertEqual(definition.tool_approvals,
{"websearch": "ask"})
self.assertEqual(definition.to_summary()["tool_approvals"],
{"websearch": "ask"})
def test_declaration_without_required_tool_allowed(self):
# Approval declarations are about tools the agent MAY use,
# not only tools it REQUIRES; requiring the tools list to
# contain every declared tool would conflate the two.
definition = parse_agent_definition(_base_agent(
tools=[], tool_approvals={"imagegen": "deny"}))
self.assertEqual(definition.tool_approvals,
{"imagegen": "deny"})
def test_unknown_tool_and_policy_rejected(self):
with self.assertRaises(ValueError):
parse_agent_definition(_base_agent(
tool_approvals={"unknown-tool": "ask"}))
with self.assertRaises(ValueError):
parse_agent_definition(_base_agent(
tool_approvals={"websearch": "full"}))
with self.assertRaises(ValueError):
parse_agent_definition(_base_agent(
tool_approvals="ask"))
def test_required_plus_deny_is_contradiction(self):
with self.assertRaises(ValueError):
parse_agent_definition(_base_agent(
tool_approvals={"websearch": "deny"}))
def test_effective_map_is_honest_about_ask(self):
# host auto + declared auto -> accept; anything else
# declines, and ask is NEVER honoured at runtime today.
self.assertEqual(
effective_tool_approvals({"websearch": "auto"}, "auto"),
{"websearch": {"declared": "auto", "effective": "accept",
"ask_honoured": False}})
self.assertEqual(
effective_tool_approvals({"websearch": "ask"}, "auto"),
{"websearch": {"declared": "ask", "effective": "decline",
"ask_honoured": False}})
self.assertEqual(
effective_tool_approvals({"websearch": "auto"}, "deny"),
{"websearch": {"declared": "auto", "effective": "decline",
"ask_honoured": False}})
self.assertEqual(effective_tool_approvals({}, "auto"), {})
class ServerTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port,
timeout=10)
headers = {"Content-Type": "application/json"}
if key:
headers["X-API-Key"] = key
conn.request(method, path,
body=json.dumps(body) if body is not None else None,
headers=headers)
resp = conn.getresponse()
data = resp.read()
conn.close()
try:
return resp.status, json.loads(data)
except ValueError:
return resp.status, data
def _create(self, body):
body = dict(body)
body.setdefault("project_id", self.project["id"])
body.setdefault("prompt", "do work")
return self._req("POST", "/tasks", body, self.owner_key)
def test_inline_declaration_stored_and_read_back(self):
status, task = self._create({
"agent": _base_agent(tool_approvals={"websearch": "ask"})})
self.assertEqual(status, 201, task)
self.assertEqual(task["agent_tool_approvals"],
{"websearch": "ask"})
self.assertEqual(task["agent"]["tool_approvals"],
{"websearch": "ask"})
status, listed = self._req(
"GET", "/tasks?project_id=%s" % self.project["id"],
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(listed["tasks"][0]["agent_tool_approvals"],
{"websearch": "ask"})
def test_registry_example_resolves(self):
status, task = self._create(
{"agent_name": "research-brief-approval"})
self.assertEqual(status, 201, task)
self.assertEqual(task["agent_tool_approvals"],
{"websearch": "ask"})
self.assertEqual(task["agent_name"], "research-brief-approval")
def test_contradiction_and_goal_rejected(self):
status, body = self._create({
"agent": _base_agent(tool_approvals={"websearch": "deny"})})
self.assertEqual(status, 400)
status, body = self._create({
"agent": _base_agent(tool_approvals={"websearch": "ask"}),
"goal_mode": True})
self.assertEqual(status, 400)
def _completed_runner(cmd):
if "dispatch" in cmd:
name = cmd[cmd.index("--name") + 1]
else:
name = cmd[cmd.index("--task") + 1]
return SimpleNamespace(
returncode=0,
stdout='{"task": "%s", "status": "completed", "text": "done"}' % name,
stderr="")
def _insert_task(db_path, task_id, tools=None, approvals=None):
now = time.time()
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat, agent_tools,"
" agent_tool_approvals)"
" VALUES(?, 'p1', 'do work', 'queued', NULL, '[]', 0,"
" 'tester', ?, ?, NULL, ?, ?)",
(task_id, now, now,
json.dumps(tools) if tools is not None else None,
json.dumps(approvals) if approvals is not None else None))
conn.commit()
finally:
conn.close()
class WorkerGateTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
self.calls = []
def tearDown(self):
self.tmp.cleanup()
def _worker(self):
def recording(cmd):
self.calls.append(list(cmd))
return _completed_runner(cmd)
return Worker(self.db_path, "/fake/host.py", runner=recording,
env={"XCM_API_KEY": "test-placeholder"})
def test_declaration_is_audited_and_task_runs(self):
_insert_task(self.db_path, "A", tools=["websearch"],
approvals={"websearch": "ask"})
worker = self._worker()
result = worker.run_one()
self.assertEqual(result["status"], "completed")
store = AuthStore(self.db_path)
try:
entries = [e for e in store.list_audit("p1")
if e["action"] == "worker_tool_approvals_declared"]
finally:
store.conn.close()
self.assertTrue(entries)
self.assertIn("websearch", entries[0]["detail"])
def test_defensive_deny_plus_required_fails_before_runner(self):
# Rows written before v9 or outside the API can carry the
# contradiction the server now rejects; dispatch must
# still fail fast, never run the model turn.
_insert_task(self.db_path, "B", tools=["websearch"],
approvals={"websearch": "deny"})
worker = self._worker()
result = worker.run_one()
self.assertTrue(result["dispatched"])
self.assertEqual(result["status"], "failed")
self.assertIn("deny", result["error"])
self.assertEqual(self.calls, [])
+63
View File
@@ -370,6 +370,20 @@ def _agent_tools_from_col(raw):
return list(parsed) if isinstance(parsed, list) else []
def _agent_tool_approvals_from_col(raw):
"""agent_tool_approvals 列的容错解析:JSON 对象,非对象/
脏数据为 {}(= 未声明),不炸认领。"""
if raw is None:
return {}
if isinstance(raw, dict):
return dict(raw)
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return {}
return dict(parsed) if isinstance(parsed, dict) else {}
def _task_from_row(row):
depends_on = row["depends_on"]
try:
@@ -441,6 +455,10 @@ def _task_from_row(row):
# v3:agent_tools 存 JSON 数组;脏数据/老行缺列时
# 按 [] 计(= 未声明必需工具),不炸认领。
"agent_tools": _agent_tools_from_col(_col("agent_tools")),
# v9:agent_tool_approvals 存 JSON 对象;脏数据/老行
# 缺列时按 {} 计(= 未声明),不炸认领。
"agent_tool_approvals": _agent_tool_approvals_from_col(
_col("agent_tool_approvals")),
}
@@ -6101,6 +6119,51 @@ class Worker:
version=task.get("agent_version"),
instructions=task["agent_instructions"]),
task.get("prompt") or "")
# agent 工具审批声明(v9):派发前审计声明本身,并
# 防御性重查 required+deny 矛盾(创建时已拒绝,防
# v9 之前或 API 外写入的行漏网)。审计只记声明,不
# 宣称运行时按工具拦截:host 的 elicitation 回答是
# 全局开关(auto 接受、其余拒绝),没有按工具分流,
# ask 在今天没有询问通道,见 agent_def.py 的 v9 边界。
_tool_approvals = task.get("agent_tool_approvals") or {}
if _tool_approvals:
_denied_required = [
t for t in (task.get("agent_tools") or [])
if _tool_approvals.get(t) == "deny"]
if _denied_required:
_appr_error = (
"agent 工具审批声明矛盾:必需工具 %s 被声明为"
" deny,任务无法使用其必需工具"
% ", ".join(_denied_required))
self._set_status(task_id, "failed")
self._write_execution_result(
task_id, error_text=_appr_error,
actual_model=self.model,
started_at=started_at, finished_at=time.time(),
only_if_status="failed")
self._audit(task["project_id"],
"worker_tool_approval_gate_rejected",
"%s: %s" % (task_id, _appr_error))
log_event(self.logger,
"worker_tool_approval_gate_rejected",
task_id=task_id, project_id=task["project_id"],
status="failed")
self._running_clear(task_id)
return {"dispatched": True, "task_id": task_id,
"host_name": self.executor._host_name(task)
if hasattr(self.executor, "_host_name")
else task_id,
"status": "failed",
"executor": self._executor_name(),
"stdout": "", "error": _appr_error, "events": [],
"cost_record_id": None}
self._audit(task["project_id"],
"worker_tool_approvals_declared",
"%s: %s (per-tool interception is not enforced"
" at runtime; host answers elicitation globally)"
% (task_id, json.dumps(_tool_approvals,
ensure_ascii=False,
sort_keys=True)))
# agent 必需工具门(v3):协调员父任务已在前面返回,
# 这里只剩真正要跑模型的任务(普通/goal/fork 首轮都
# 走后面的尝试循环,门放在循环前,一次定罪不重试)。