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.
241 lines
9.1 KiB
Python
241 lines
9.1 KiB
Python
"""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, [])
|