Files
codex-app-server/cloud/tests/test_tool_approval.py
T
host a51919f24b 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.
2026-11-04 18:02:20 +00:00

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