"""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, [])