mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
33ace975d2
Follow-up to the per-alias Entra OBO/app-identity backend auth: the console write path now applies default-deny field classification, the admin shelf gains full backend-auth support, and the session/registry rebind machinery is hardened for config changes landing under live sessions. Console write gate: - Default-deny classification: any non-neutral change to a row that is or becomes dynamic requires admin.mcp plus validation; the provably auth-neutral columns are enumerated (MODEL_AUTH_NEUTRAL_FIELDS) and a live-schema classification test forces every future column to be classified. The derivation is a pure function (_derive_auth_gate) with unit-pinned exclusivity invariants. - Two-tier validation mirroring the MCP oauth_obo validator: the row tier (audience allow-list) runs on every gated write; the posture tier (OIDC configured, token store present) runs on pair changes and on enable-arming. - Pure-disable carve-out: disabling a dynamic row is de-escalation and is never blocked — admin.models suffices and validation is skipped, including for rows with corrupt or skewed stored values. - Capabilities are compared canonically (key order, integral floats), the audience compare normalizes both sides, and staging an audience on a static row is refused on both write twins. - Calibrate writes the capabilities column under an enforced confinement invariant with a compare-and-swap persist. Admin shelf: - Backend-auth section with a per-open constraints fetch (GET /model-definitions/auth-constraints: audience allow-list, grant profile, dynamic modes), datalist audience suggestions, server-defined modes preserved on round-trip, and permission-aware visibility built on cache-skew-safe helpers shared through auth.js. - Refused live-registry swaps surface as an amber registry_warning on the write, delete, reload, and calibrate responses; audit rows carry auth_gated / auth_disarmed markers visible in the audit view. Registry and sessions: - The encryption-key requirement for dynamic auth is enforced inside ModelRegistry.reload() itself — nodes refuse with 503 and the console records coord_registry_error — and reload bumps the generation before the map swap so a racing reader can never pair a stale generation with new maps. - resolve()/resolve_binding() return the generation from inside the registry lock; sessions rebind per send on generation change with atomic client/provider/config commits, fallback-first handling of removed or unconstructable aliases, and judge/limiter resets only when the binding actually changed. - Mint refusals record per-user causes surfaced in the per-turn heartbeat logs; misconfiguration warnings are deduplicated with bounded state. Verification: 10417 tests (99 added on this branch), a 71-scenario browser harness over the real admin shelf, and a live rfc8693 token-exchange e2e run (MCP legs verified end to end; the model-leg scope gap is tracked as #955 under a narrow known-gap signature). Closes #950.
657 lines
23 KiB
Python
657 lines
23 KiB
Python
"""Tests for the coordinator governance endpoints and session hooks.
|
|
|
|
Covers the console endpoints that let an operator steer a live
|
|
coordinator session mid-flight (``/trust``, ``/restrict``), the two
|
|
``ChatSession`` methods the endpoints toggle (``set_trust_send`` /
|
|
``revoke_tools``), the audit rows the handlers emit, and the
|
|
``_prepare_tool`` revocation gate.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from starlette.applications import Starlette
|
|
from starlette.middleware import Middleware
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.routing import Route
|
|
from starlette.testclient import TestClient
|
|
|
|
from tests._coord_test_helpers import (
|
|
_AuthMiddleware,
|
|
_build_mgr,
|
|
_fake_registry,
|
|
_FakeConfigStore,
|
|
_seed_children,
|
|
)
|
|
from turnstone.console.server import (
|
|
coordinator_restrict,
|
|
coordinator_trust,
|
|
)
|
|
from turnstone.core.auth import AuthResult
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(tmp_path):
|
|
return SQLiteBackend(str(tmp_path / "coord.db"))
|
|
|
|
|
|
def _make_client(storage, *, coord_mgr, alias="my-model", registry=None) -> TestClient:
|
|
"""Starlette app exposing only the governance endpoints."""
|
|
app = Starlette(
|
|
routes=[
|
|
Route(
|
|
"/v1/api/workstreams/{ws_id}/trust",
|
|
coordinator_trust,
|
|
methods=["POST"],
|
|
),
|
|
Route(
|
|
"/v1/api/workstreams/{ws_id}/restrict",
|
|
coordinator_restrict,
|
|
methods=["POST"],
|
|
),
|
|
],
|
|
middleware=[Middleware(_AuthMiddleware)],
|
|
)
|
|
app.state.coord_mgr = coord_mgr
|
|
app.state.coord_adapter = coord_mgr._adapter if coord_mgr is not None else None
|
|
app.state.config_store = _FakeConfigStore({"coordinator.model_alias": alias})
|
|
app.state.coord_registry = registry
|
|
app.state.coord_registry_error = "" if coord_mgr else "registry missing"
|
|
app.state.auth_storage = storage
|
|
app.state.jwt_secret = "x" * 64
|
|
return TestClient(app)
|
|
|
|
|
|
def _make_session_mock(*, trust_send: bool = False, revoked: frozenset[str] = frozenset()):
|
|
"""Build a MagicMock ``session`` that honours the new ChatSession
|
|
governance surface (``set_trust_send`` / ``get_trust_send`` /
|
|
``revoke_tools`` / ``get_revoked_tools``) so handler tests exercise
|
|
the real method calls rather than reaching into attributes."""
|
|
|
|
state: dict[str, Any] = {"trust_send": trust_send, "revoked": revoked}
|
|
|
|
def _set_trust_send(value: bool) -> None:
|
|
state["trust_send"] = bool(value)
|
|
|
|
def _get_trust_send() -> bool:
|
|
return bool(state["trust_send"])
|
|
|
|
def _revoke_tools(names):
|
|
state["revoked"] = state["revoked"] | frozenset(names)
|
|
return state["revoked"]
|
|
|
|
def _get_revoked_tools():
|
|
return state["revoked"]
|
|
|
|
session = MagicMock()
|
|
session.set_trust_send.side_effect = _set_trust_send
|
|
session.get_trust_send.side_effect = _get_trust_send
|
|
session.revoke_tools.side_effect = _revoke_tools
|
|
session.get_revoked_tools.side_effect = _get_revoked_tools
|
|
return session, state
|
|
|
|
|
|
_COORD_HEADERS = {"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"}
|
|
_TRUST_HEADERS = {
|
|
"X-Test-User": "user-1",
|
|
"X-Test-Perms": "admin.coordinator,coordinator.trust.send",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /trust endpoint — trusted-session mode (item 1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_trust_toggle_requires_trust_send_permission(storage):
|
|
"""Double-gated: admin.coordinator alone is insufficient — the
|
|
trust-send perm is an explicit opt-in."""
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-1", name="coord-a")
|
|
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/trust",
|
|
json={"send": True},
|
|
headers=_COORD_HEADERS,
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
def test_trust_toggle_flips_session_flag_and_audits(storage):
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-1", name="coord-a")
|
|
session, state = _make_session_mock()
|
|
coord.session = session
|
|
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
|
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/trust",
|
|
json={"send": True},
|
|
headers=_TRUST_HEADERS,
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"status": "ok", "trust_send": True}
|
|
assert state["trust_send"] is True
|
|
|
|
events = [e for e in storage.list_audit_events() if e["action"] == "coordinator.trust.toggled"]
|
|
assert len(events) == 1
|
|
detail = json.loads(events[0]["detail"])
|
|
assert detail["send_before"] is False
|
|
assert detail["send_after"] is True
|
|
|
|
|
|
def _service_token_client(
|
|
storage,
|
|
coord_mgr,
|
|
*,
|
|
user_id: str,
|
|
permissions: frozenset[str],
|
|
) -> TestClient:
|
|
"""Build a TestClient whose middleware injects a service-scoped token.
|
|
|
|
Used to verify that the capability-escalating endpoints (``/trust``,
|
|
``/restrict``) do NOT honor the normal ``require_permission``
|
|
service-scope bypass when the caller lacks the specific grant they
|
|
need.
|
|
"""
|
|
app = Starlette(
|
|
routes=[
|
|
Route(
|
|
"/v1/api/workstreams/{ws_id}/trust",
|
|
coordinator_trust,
|
|
methods=["POST"],
|
|
),
|
|
Route(
|
|
"/v1/api/workstreams/{ws_id}/restrict",
|
|
coordinator_restrict,
|
|
methods=["POST"],
|
|
),
|
|
],
|
|
)
|
|
app.state.coord_mgr = coord_mgr
|
|
app.state.coord_adapter = coord_mgr._adapter if coord_mgr is not None else None
|
|
app.state.config_store = _FakeConfigStore({"coordinator.model_alias": "my-model"})
|
|
app.state.coord_registry = _fake_registry()
|
|
app.state.coord_registry_error = ""
|
|
app.state.auth_storage = storage
|
|
app.state.jwt_secret = "x" * 64
|
|
|
|
captured_perms = permissions
|
|
|
|
class _ServiceAuth(BaseHTTPMiddleware):
|
|
async def dispatch(self, request, call_next):
|
|
request.state.auth_result = AuthResult(
|
|
user_id=user_id,
|
|
scopes=frozenset({"read", "write", "approve", "service"}),
|
|
token_source="test",
|
|
permissions=captured_perms,
|
|
)
|
|
return await call_next(request)
|
|
|
|
app.user_middleware = [Middleware(_ServiceAuth)]
|
|
app.middleware_stack = app.build_middleware_stack()
|
|
return TestClient(app)
|
|
|
|
|
|
def test_trust_toggle_service_token_cannot_bypass_permission(storage):
|
|
"""Service token without coordinator.trust.send is 403'd even when
|
|
its user_id matches the coord owner."""
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="svc-user", name="coord-a")
|
|
coord.session, _ = _make_session_mock()
|
|
|
|
client = _service_token_client(
|
|
storage,
|
|
mgr,
|
|
user_id="svc-user",
|
|
permissions=frozenset({"admin.coordinator"}),
|
|
)
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/trust",
|
|
json={"send": True},
|
|
)
|
|
assert resp.status_code == 403
|
|
assert "coordinator.trust.send" in resp.json()["error"]
|
|
|
|
|
|
def test_trust_toggle_service_token_with_permission_succeeds(storage):
|
|
"""Service token WITH the explicit coordinator.trust.send grant IS
|
|
allowed through — locks the intended invariant: bypass is off, but
|
|
an explicit perm still works."""
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="svc-user", name="coord-a")
|
|
session, state = _make_session_mock()
|
|
coord.session = session
|
|
|
|
client = _service_token_client(
|
|
storage,
|
|
mgr,
|
|
user_id="svc-user",
|
|
permissions=frozenset({"admin.coordinator", "coordinator.trust.send"}),
|
|
)
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/trust",
|
|
json={"send": True},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"status": "ok", "trust_send": True}
|
|
assert state["trust_send"] is True
|
|
|
|
|
|
def test_restrict_service_token_cannot_bypass_admin_coordinator(storage):
|
|
"""/restrict is destructive — a service token WITHOUT explicit
|
|
admin.coordinator grant must be 403'd rather than letting the
|
|
service-scope bypass open the endpoint up."""
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="svc-user", name="coord-a")
|
|
coord.session, _ = _make_session_mock()
|
|
|
|
client = _service_token_client(
|
|
storage,
|
|
mgr,
|
|
user_id="svc-user",
|
|
permissions=frozenset(), # no admin.coordinator
|
|
)
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/restrict",
|
|
json={"revoke": ["bash"]},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
def test_trust_toggle_rejects_non_bool(storage):
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-1", name="coord-a")
|
|
coord.session, _ = _make_session_mock()
|
|
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/trust",
|
|
json={"send": "yes"},
|
|
headers=_TRUST_HEADERS,
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_trust_toggle_rejects_non_object_body(storage):
|
|
"""A valid-JSON-but-non-object body (null / list / scalar) must
|
|
400 cleanly rather than AttributeError → 500."""
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-1", name="coord-a")
|
|
coord.session, _ = _make_session_mock()
|
|
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
|
# Non-dict JSON values — all must 400. `read_json_or_400` enforces
|
|
# the object shape itself ("Request body must be a JSON object");
|
|
# there is no downstream shape guard. We only care that none 500.
|
|
for body in ([], 42, "string"):
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/trust",
|
|
json=body,
|
|
headers=_TRUST_HEADERS,
|
|
)
|
|
assert resp.status_code == 400, body
|
|
assert "JSON object" in resp.json()["error"], resp.json()
|
|
|
|
|
|
def test_restrict_rejects_non_object_body(storage):
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-1", name="coord-a")
|
|
coord.session, _ = _make_session_mock()
|
|
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/restrict",
|
|
json=[],
|
|
headers=_COORD_HEADERS,
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_trust_toggle_cluster_wide_access(storage):
|
|
# Trusted-team model: the trust toggle is gated on the scope
|
|
# permission, not on row-level ownership. A caller holding
|
|
# ``coordinator.trust.send`` may toggle any coord's trust state.
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-owner", name="coord-a")
|
|
coord.session, _ = _make_session_mock()
|
|
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/trust",
|
|
json={"send": True},
|
|
headers={
|
|
"X-Test-User": "user-other",
|
|
"X-Test-Perms": "admin.coordinator,coordinator.trust.send",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_trust_toggle_404_when_session_not_loaded(storage):
|
|
"""Persisted-but-not-loaded coordinator: runtime session state can't
|
|
be mutated, so the endpoint 404s. Matches the tenant-miss shape
|
|
so non-admins can't probe for closed rows via this endpoint."""
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-1", name="coord-a")
|
|
coord.session = None # simulate a closed / lazy-rehydrate coord
|
|
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/trust",
|
|
json={"send": True},
|
|
headers=_TRUST_HEADERS,
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _prepare_send_to_workstream — trust gate (item 1, unit-level)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_prepare_send_to_workstream_trust_skips_approval_for_own_child():
|
|
from turnstone.core.session import ChatSession
|
|
|
|
session = ChatSession.__new__(ChatSession)
|
|
session._coord_client = MagicMock()
|
|
session._trust_send = True
|
|
session._coord_client._is_own_subtree.return_value = True
|
|
|
|
item = session._prepare_send_to_workstream(call_id="c1", args={"ws_id": "abc", "message": "hi"})
|
|
assert item["needs_approval"] is False
|
|
assert item["trust_auto_approved"] is True
|
|
|
|
|
|
def test_prepare_send_to_workstream_trust_holds_for_foreign_ws():
|
|
from turnstone.core.session import ChatSession
|
|
|
|
session = ChatSession.__new__(ChatSession)
|
|
session._coord_client = MagicMock()
|
|
session._trust_send = True
|
|
session._coord_client._is_own_subtree.return_value = False
|
|
|
|
item = session._prepare_send_to_workstream(
|
|
call_id="c2", args={"ws_id": "foreign-ws", "message": "hi"}
|
|
)
|
|
assert item["needs_approval"] is True
|
|
assert item["trust_auto_approved"] is False
|
|
|
|
|
|
def test_prepare_send_to_workstream_without_trust_always_requires_approval():
|
|
from turnstone.core.session import ChatSession
|
|
|
|
session = ChatSession.__new__(ChatSession)
|
|
session._coord_client = MagicMock()
|
|
session._trust_send = False
|
|
session._coord_client._is_own_subtree.return_value = True
|
|
|
|
item = session._prepare_send_to_workstream(call_id="c3", args={"ws_id": "abc", "message": "hi"})
|
|
assert item["needs_approval"] is True
|
|
assert item["trust_auto_approved"] is False
|
|
|
|
|
|
def test_exec_send_to_workstream_records_trust_audit(storage):
|
|
"""The audit row fires before the HTTP send so a downstream failure
|
|
can't suppress the trail."""
|
|
from turnstone.console.coordinator_client import CoordinatorClient
|
|
from turnstone.core.session import ChatSession
|
|
|
|
client = CoordinatorClient.__new__(CoordinatorClient)
|
|
client._storage = storage
|
|
client._user_id = "user-1"
|
|
client._coord_ws_id = "coord-1"
|
|
|
|
session = ChatSession.__new__(ChatSession)
|
|
session._coord_client = client
|
|
session.ui = MagicMock()
|
|
send_mock = MagicMock(return_value={"status": "ok"})
|
|
client.send = send_mock # type: ignore[method-assign]
|
|
|
|
session._exec_send_to_workstream(
|
|
{
|
|
"call_id": "c1",
|
|
"ws_id": "child-ws-1",
|
|
"message": "please summarise",
|
|
"trust_auto_approved": True,
|
|
}
|
|
)
|
|
|
|
events = [
|
|
e for e in storage.list_audit_events() if e["action"] == "coordinator.send.auto_approved"
|
|
]
|
|
assert len(events) == 1
|
|
detail = json.loads(events[0]["detail"])
|
|
assert detail["src"] == "coordinator"
|
|
assert detail["trust"] is True
|
|
assert detail["ws_id"] == "child-ws-1"
|
|
assert "please summarise" in detail["message_preview"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /restrict endpoint + _prepare_tool revocation gate (item 5a)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_restrict_adds_to_revoked_tools_and_audits(storage):
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-1", name="coord-a")
|
|
session, state = _make_session_mock()
|
|
coord.session = session
|
|
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
|
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/restrict",
|
|
json={"revoke": ["spawn_workstream", "delete_workstream"]},
|
|
headers=_COORD_HEADERS,
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert set(body["revoked_tools"]) == {"spawn_workstream", "delete_workstream"}
|
|
assert state["revoked"] == frozenset({"spawn_workstream", "delete_workstream"})
|
|
|
|
events = [e for e in storage.list_audit_events() if e["action"] == "coordinator.restricted"]
|
|
assert len(events) == 1
|
|
detail = json.loads(events[0]["detail"])
|
|
assert set(detail["revoked"]) == {"spawn_workstream", "delete_workstream"}
|
|
|
|
|
|
def test_restrict_is_additive_across_calls(storage):
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-1", name="coord-a")
|
|
coord.session, _ = _make_session_mock()
|
|
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
|
|
|
client.post(
|
|
f"/v1/api/workstreams/{coord.id}/restrict",
|
|
json={"revoke": ["spawn_workstream"]},
|
|
headers=_COORD_HEADERS,
|
|
)
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/restrict",
|
|
json={"revoke": ["delete_workstream"]},
|
|
headers=_COORD_HEADERS,
|
|
)
|
|
assert resp.status_code == 200
|
|
assert set(resp.json()["revoked_tools"]) == {
|
|
"spawn_workstream",
|
|
"delete_workstream",
|
|
}
|
|
|
|
|
|
def test_restrict_empty_revoke_is_noop_but_audits(storage):
|
|
"""Empty list is accepted as a no-op write — still emits the audit
|
|
row so operators can see 'operator poked the restrict endpoint but
|
|
didn't actually revoke anything' events."""
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-1", name="coord-a")
|
|
coord.session, _state = _make_session_mock(revoked=frozenset({"spawn_workstream"}))
|
|
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/restrict",
|
|
json={"revoke": []},
|
|
headers=_COORD_HEADERS,
|
|
)
|
|
assert resp.status_code == 200
|
|
# Pre-existing revocations are preserved; no new entries were added.
|
|
assert set(resp.json()["revoked_tools"]) == {"spawn_workstream"}
|
|
events = [e for e in storage.list_audit_events() if e["action"] == "coordinator.restricted"]
|
|
assert len(events) == 1
|
|
detail = json.loads(events[0]["detail"])
|
|
assert detail["revoked"] == []
|
|
|
|
|
|
def test_restrict_rejects_non_list_body(storage):
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-1", name="coord-a")
|
|
coord.session, _ = _make_session_mock()
|
|
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/restrict",
|
|
json={"revoke": "spawn_workstream"},
|
|
headers=_COORD_HEADERS,
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_restrict_rejects_oversize_list(storage):
|
|
"""Defense-in-depth cap — an admin-sized list can't blow up the
|
|
session frozenset or the audit row's detail column."""
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-1", name="coord-a")
|
|
coord.session, _ = _make_session_mock()
|
|
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/restrict",
|
|
json={"revoke": [f"tool_{i}" for i in range(500)]},
|
|
headers=_COORD_HEADERS,
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_restrict_rejects_oversize_name(storage):
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-1", name="coord-a")
|
|
coord.session, _ = _make_session_mock()
|
|
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/restrict",
|
|
json={"revoke": ["x" * 1000]},
|
|
headers=_COORD_HEADERS,
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_restrict_404_when_session_not_loaded(storage):
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-1", name="coord-a")
|
|
coord.session = None
|
|
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
|
resp = client.post(
|
|
f"/v1/api/workstreams/{coord.id}/restrict",
|
|
json={"revoke": ["bash"]},
|
|
headers=_COORD_HEADERS,
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_prepare_tool_blocks_revoked_tool():
|
|
"""Revocation short-circuits BEFORE the preparer dispatch so the
|
|
model sees a clear 'revoked' error rather than a preparer-level
|
|
validation message."""
|
|
from turnstone.core.session import ChatSession
|
|
|
|
session = ChatSession.__new__(ChatSession)
|
|
session._revoked_tools = frozenset({"spawn_workstream"})
|
|
session._mcp_client = None
|
|
session.ui = MagicMock()
|
|
|
|
tc = {
|
|
"id": "call-1",
|
|
"function": {
|
|
"name": "spawn_workstream",
|
|
"arguments": '{"initial_message": "x"}',
|
|
},
|
|
}
|
|
item = session._prepare_tool(tc)
|
|
assert item["needs_approval"] is False
|
|
assert "revoked" in item["header"].lower()
|
|
assert "revoked" in item["error"].lower()
|
|
|
|
|
|
def test_prepare_tool_allows_non_revoked_tool():
|
|
"""The revocation gate must not fire on a tool name that isn't in
|
|
the revoked set. We pick a name that's also not in the preparers
|
|
dict so we can assert the 'unknown tool' result shape without
|
|
exercising a real preparer."""
|
|
from turnstone.core.session import ChatSession
|
|
|
|
session = ChatSession.__new__(ChatSession)
|
|
session._revoked_tools = frozenset({"spawn_workstream"})
|
|
session._mcp_client = None
|
|
session.ui = MagicMock()
|
|
|
|
tc = {
|
|
"id": "call-2",
|
|
"function": {"name": "this_tool_is_not_registered", "arguments": "{}"},
|
|
}
|
|
item = session._prepare_tool(tc)
|
|
# Unknown tool path — not the revocation error path.
|
|
err = str(item.get("error") or "")
|
|
assert "revoked" not in err.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# children_snapshot (used by the cancel cascade + close_all_children)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_children_snapshot_returns_copy_not_live_set(storage):
|
|
mgr = _build_mgr(storage)
|
|
coord = mgr.create(user_id="user-1", name="coord-a")
|
|
_seed_children(mgr._adapter, coord.id, ["a", "b", "c"])
|
|
snap = mgr._adapter.children_snapshot(coord.id)
|
|
assert set(snap) == {"a", "b", "c"}
|
|
_seed_children(mgr._adapter, coord.id, ["d"])
|
|
assert set(snap) == {"a", "b", "c"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ChatSession governance methods (q-14) — unit-level
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_set_and_get_trust_send_round_trip():
|
|
from turnstone.core.session import ChatSession
|
|
|
|
session = ChatSession.__new__(ChatSession)
|
|
import threading as _t
|
|
|
|
session._trust_send = False
|
|
session._governance_lock = _t.Lock()
|
|
|
|
assert session.get_trust_send() is False
|
|
session.set_trust_send(True)
|
|
assert session.get_trust_send() is True
|
|
session.set_trust_send(False)
|
|
assert session.get_trust_send() is False
|
|
|
|
|
|
def test_revoke_tools_is_additive_and_returns_post_state():
|
|
from turnstone.core.session import ChatSession
|
|
|
|
session = ChatSession.__new__(ChatSession)
|
|
import threading as _t
|
|
|
|
session._revoked_tools = frozenset()
|
|
session._governance_lock = _t.Lock()
|
|
|
|
after = session.revoke_tools(["bash", "read_file"])
|
|
assert after == frozenset({"bash", "read_file"})
|
|
after2 = session.revoke_tools(["write_file"])
|
|
assert after2 == frozenset({"bash", "read_file", "write_file"})
|
|
# Re-revoking is a no-op (idempotent).
|
|
after3 = session.revoke_tools(["bash"])
|
|
assert after3 == after2
|
|
assert session.get_revoked_tools() == after3
|