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:
1 parent
cd33a929ca
commit
a51919f24b
6 files changed
+441
-6
No files matched your search
+101
-3
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 秒仍在);
|
||||
|
||||
@@ -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, [])
|
||||
@@ -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 首轮都
|
||||
# 走后面的尝试循环,门放在循环前,一次定罪不重试)。
|
||||
|
||||
Reference in new issue
Block a user