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
@@ -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, [])
|
||||
Reference in new issue
Block a user