feat(coordinator): phase 7 — governance + skill metadata + cross-cutt… (#383)

* feat(coordinator): phase 7 — governance + skill metadata + cross-cutting invariants

Combines three stacked sub-PRs into a single coordinator phase-7
shipment against the phase-7 plan doc.  The sub-PR structure (0 / A /
B) preserved on individual branches for reviewer drill-down; this
branch is the one reviewers should merge.

## Sub-PR 0 — service-auth boundary invariants

Shared helpers and contracts that lock the console ↔ node service-auth
boundary so later authz surfaces use them by construction.

- ``_effective_user_filter(request)`` in both ``turnstone.console.server``
  and ``turnstone.server`` with a shared ``DENY_EMPTY_SUB`` sentinel
  on ``turnstone.core.auth``.  Three-way return — admin/service
  bypass, scoped caller uid, or fail-closed sentinel on blank sub.
  Four callsite migrations (``_coordinator_rows``,
  ``coordinator_children``, ``coordinator_metrics``,
  ``cluster_ws_live_bulk``).

- ``StorageBackend`` class docstring codifies the tenancy contract
  (every list/count/aggregate method must accept ``user_id: str |
  None = None`` and push ``WHERE user_id = :user_id`` into SQL) and
  the ``_mapping`` row-access contract.  New
  ``turnstone.testing.row_contract`` ships ``assert_row_like()``.

- ``_verify_collector_service_scope`` probes an upstream node at boot
  with ``expected_node_id=_scope-probe_``; a 409 proves the scope
  gate was passed, a 403/401 sets ``collector_scope_error`` and
  causes ``cluster_snapshot`` / ``cluster_events_sse`` to return 503
  with a remediation hint.  Probe URL allowlist rejects non-http(s)
  schemes and 169.254.0.0/16 hosts.

- 4xx log-level floor on ``_NodeDashboardCache.get``,
  ``_fetch_live_block``, and ``_proxy_sse`` — dotted-hierarchy
  prefixes with bounded body previews.  ``_bounded_body_preview`` and
  ``_bounded_stream_preview`` strip control chars.

## Sub-PR A — coordinator governance core

Mid-session governance surface for coordinator workstreams.

- **Trusted-session mode.**  New ``coordinator.trust.send``
  permission (migration 042).  ``ChatSession.set_trust_send`` /
  ``revoke_tools`` methods with a ``_governance_lock``.  ``POST
  /v1/api/coordinator/{ws_id}/trust {send: bool}`` double-gated on
  ``admin.coordinator`` AND ``coordinator.trust.send`` with
  ``allow_service_bypass=False`` so service tokens can't escalate.
  ``_prepare_send_to_workstream`` auto-approves sends whose target is
  in the coordinator's own subtree; foreign ws_ids still require
  approval.  ``_is_own_subtree`` checks both ``parent_ws_id`` AND
  ``user_id`` to defend against cross-tenant row corruption.

- **Audit-layer credential redaction.**  ``record_audit`` walks
  ``detail`` (dicts, lists, tuples, sets, frozensets; keys too)
  and routes every string through ``redact_credentials`` + a C0
  control-char scrub.  New kw-only ``raw_detail=True`` opt-out.
  ``_has_any_string`` fast-path.  Audit action registry extended
  with the four new governance sub-prefixes.

- **Mid-session revocation + cascading stop.**  ``POST
  /v1/api/coordinator/{ws_id}/restrict {revoke: [...]}`` caps 256
  entries / 128 chars; ``_prepare_tool`` short-circuits with a
  tool-error.  ``POST /v1/api/coordinator/{ws_id}/stop_cascade``
  cancels the coord's in-flight generation then dispatches
  ``cancel_workstream`` for every direct child in parallel via
  ``asyncio.gather`` bounded by ``Semaphore(16)``.  Per-child
  outcomes split into ``cancelled`` / ``failed`` / ``skipped``
  (404 = already-gone rather than dispatch-broken).  Both endpoints
  apply ``allow_service_bypass=False`` on the admin gate.

- **Shared plumbing.**  ``_resolve_coord_session`` helper collapses
  the handler prelude three endpoints shared.  ``_emit_coord_audit``
  wraps ``record_audit`` in a dedicated ``ThreadPoolExecutor``
  (``app.state.audit_executor``) so audit bursts don't starve cancel
  dispatches.  ``_require_json_object`` guards body parsing so non-
  object JSON returns 400 instead of 500.

## Sub-PR B — skill metadata governance

- **Description validator (migration 043).**  ``prompt_templates``
  rows now require a non-empty ``description``.  Existing empty rows
  get backfilled with a ``"Skill: <name>"`` placeholder on upgrade.
  The installer (``admin_skill_discover``) and MCP prompt sync both
  synthesise a placeholder when the upstream description is blank
  so non-admin write paths satisfy the invariant.

- **Skill kind classifier (migration 044).**  New
  ``prompt_templates.kind`` column (``interactive`` / ``coordinator``
  / ``any``; defaults to ``any``).  New
  ``turnstone.core.skill_kind.SkillKind`` StrEnum is the single
  source of truth; Pydantic schemas type ``kind`` as ``SkillKind``
  (OpenAPI advertises the enum) and the handler validator catches
  the ValueError.  ``list_skills_filtered`` gains a
  ``kinds: list[str] | None = None`` SQL filter.
  ``CoordinatorClient.list_skills`` defaults to
  ``kinds=["coordinator", "any"]`` so interactive-only skills are
  hidden from the orchestrator.

- **``scan_status`` → ``risk_level`` rename (migration 045).**
  Lossless column rename to align with ``IntentVerdict.risk_level``
  terminology.  Swept storage (both backends + schema + protocol),
  handlers, API schemas, tool JSON, generated OpenAPI specs,
  TypeScript SDK types, frontend (``governance.js``), tests, and
  English prose in ``docs/judge.md`` + ``docs/tools.md``.  The
  user-facing on-load warning now reads ``has risk level:
  {risk_tier}``.  Tool JSON's ``risk_level`` enum corrected to the
  scanner's actual taxonomy (``safe / low / medium / high /
  critical``; was the never-shipped ``clean / flagged / unscanned /
  pending``).  Historical migration 021 left untouched.

## Migrations

042 (``coordinator.trust.send`` perm — PR A)
043 (description backfill — PR B)
044 (``kind`` column add — PR B)
045 (``scan_status`` → ``risk_level`` rename — PR B)

All four use position-anchored permission strings / host-side
parse-filter-rejoin on downgrade where SQL ``REPLACE`` could
corrupt prefix-overlapping values.

## Verification

- ``ruff check turnstone tests`` clean.
- ``mypy turnstone`` clean on 165 source files.
- ``pytest -m "not live"``: 4431 passed (+85 over the phase-6
  baseline).  Includes +32 tests in ``tests/test_service_auth_boundary.py``
  and +38 in ``tests/test_coordinator_governance.py``; shared fixtures
  extracted to ``tests/_coord_test_helpers.py``.
- Generated OpenAPI JSON (``sdk/typescript/openapi-{console,server}.json``)
  regenerated via ``sdk/typescript/scripts/generate-types.py``; zero
  ``scan_status`` occurrences remaining outside the historical
  migration 021 and the rename migration 045.

## Security reviews

Both reviews flagged by the phase-7 plan (items 1 + 5, plus 0a's
refuse-to-serve gate) ran through the multi-stage ``/review``
pipeline twice per sub-PR; all confirmed findings landed in-branch.

* fixup(phase-7): CI lint + PR #383 review fixups

Addresses the lint CI failure (ruff format) plus 12 findings from the
two automated PR reviewers.

Copilot:
- ``_sqlite.list_installed_skill_urls`` / ``_postgresql.list_installed_skill_urls``
  used positional row indexing (``r[0]``/``r[1]``/``r[2]``) while this
  same PR's ``StorageBackend`` class docstring forbids it.  Switched
  both to ``r._mapping["..."]`` access.
- ``list_skills.json`` previously advertised ``risk_level=""`` as a
  filter for unscanned skills, but the implementation treats empty
  strings as "no filter".  Clarified the tool description to say
  omit the filter entirely to include unscanned rows, and added an
  explicit ``enum`` on the parameter restricting it to the scanner
  tiers.  ``_prepare_list_skills`` keeps the ``strip() or None``
  normalisation — unscanned filtering now has an unambiguous contract.
- ``test_storage_skills_filtered.test_risk_level_filter`` used the
  legacy ``clean`` / ``flagged`` values from the pre-rename column.
  Rewritten with the scanner's actual taxonomy (``safe`` / ``high``).

github-code-quality (CodeQL):
- ``test_deny_sentinel_is_singleton`` previously asserted
  ``cs.DENY_EMPTY_SUB is cs.DENY_EMPTY_SUB`` — an identical-expression
  comparison.  Rewritten as two separate ``from ... import ... as`` aliases
  (``FIRST_READ`` / ``SECOND_READ``) so the identity check is between
  distinct bindings.
- ``test_restrict_empty_revoke_is_noop_but_audits`` unpacked ``state``
  without using it.  Renamed to ``_state``.
- Mixed import styles in ``test_service_auth_boundary.py`` — the
  file previously used both ``import turnstone.console.server as cs``
  and ``from turnstone.console.server import ...`` for the same
  module (same story for ``turnstone.core.auth`` and
  ``turnstone.server``).  Consolidated to the ``from X import Y`` style
  used elsewhere in the file; the ``_fetch_live_block`` test now
  patches via pytest's ``monkeypatch`` fixture instead of a manual
  rebind through a module alias.

CI:
- ``ruff format`` reformatted one line in
  ``tests/test_coordinator_endpoints.py``.

Verification: ruff check + mypy clean (166 files); 4459 non-live
pytest pass.

* fix(tests): swap asyncio marker for anyio in service-auth boundary tests

PR #383 CI caught that the 13 ``@pytest.mark.asyncio`` decorators I
added in ``test_service_auth_boundary.py`` are an off-convention
choice — the rest of the repo uses ``@pytest.mark.anyio`` (148 sites
vs my 13).  The CI environment pulls in ``anyio`` but not
``pytest-asyncio``, so every async test in this one file was failing
with "async def functions are not natively supported".  It passed
locally by accident — my dev venv happens to have pytest-asyncio
installed ambiently.

Swapped all 13 marker sites to ``@pytest.mark.anyio``.  No functional
change; the tests run under the same default asyncio backend anyio
provides.

Verification: ruff + mypy clean (166 files); 4459 non-live pytest
pass.
This commit is contained in:
Patrick Buckley
2026-04-18 10:20:19 -07:00
committed by GitHub
parent cab57f244d
commit 9826ea15c5
43 changed files with 7054 additions and 296 deletions
+1 -1
View File
@@ -1611,7 +1611,7 @@ version. Requires the `admin.skills` permission.
```json
{
"scan_status": "medium",
"risk_level": "medium",
"scan_report": "{\"composite\": 1.75, \"details\": {...}}",
"scan_version": "1"
}
+1 -1
View File
@@ -211,7 +211,7 @@ note over Session, Judge
**Storage:**
intent_verdicts table (migration 012), output_assessments table
(migration 022). Both queryable via admin API endpoints
(requires admin.judge permission). Skills store scan_status,
(requires admin.judge permission). Skills store risk_level,
scan_report, scan_version for install-time risk assessment.
end note
+1 -1
View File
@@ -91,7 +91,7 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
time. The scanner evaluates four risk axes: content risk (command execution,
data exfiltration), supply chain risk (pipe-to-shell, transitive installs),
vulnerability risk (prompt injection, insecure credentials), and declared
capability risk (from `allowed-tools` in SKILL.md). Results populate the `scan_status`
capability risk (from `allowed-tools` in SKILL.md). Results populate the `risk_level`
(safe/low/medium/high/critical) and `scan_report` (JSON breakdown) columns.
These fields are system-managed and cannot be overwritten via the admin API.
- **Discovery**: External skills can be discovered and installed from registries:
+4 -4
View File
@@ -315,7 +315,7 @@ four independent risk axes:
`Bash(*)` (unrestricted shell) is high risk. `Bash(git:*)` is low.
Read-only tools are safe.
Results are stored in `scan_status` (tier: safe/low/medium/high/critical) and
Results are stored in `risk_level` (tier: safe/low/medium/high/critical) and
`scan_report` (JSON breakdown) on the `prompt_templates` table. These fields are
system-managed and not editable via the admin API.
@@ -400,11 +400,11 @@ level, annotations, output length, redaction status).
### Session-level skill scan warning
When a skill with `scan_status` of `high` or `critical` is loaded into a
When a skill with `risk_level` of `high` or `critical` is loaded into a
session, a warning is emitted via `on_info`:
```
⚠ Skill 'my-skill' has scan status: high.
⚠ Skill 'my-skill' has risk level: high.
Review scan report in admin panel before enabling in production.
```
@@ -421,7 +421,7 @@ All three evaluation systems persist their assessments for future calibration:
|-------|--------|-------------|
| `intent_verdicts` | Intent judge (heuristic + LLM) | `func_name`, `risk_level`, `confidence`, `user_decision` |
| `output_assessments` | Output guard | `func_name`, `risk_level`, `flags`, `redacted` |
| `prompt_templates` | Skill scanner | `scan_status`, `scan_report`, `scan_version` |
| `prompt_templates` | Skill scanner | `risk_level`, `scan_report`, `scan_version` |
Run v1 with all tools requiring manual approval to build a local dataset.
In v2, calibration tooling will analyze this data to:
+2 -2
View File
@@ -546,11 +546,11 @@ pre-configure skills at workstream creation.
- `load` — Activate a skill by name. Calls `set_skill()` which handles content
rendering with `{{model}}`/`{{ws_id}}`/`{{node_id}}` variables, system message
reinitialization, and config persistence. Returns the skill name, description,
and security scan tier. Warns on high/critical scan status.
and security risk level. Warns on high/critical risk level.
- `search` — Find available skills by query. Uses BM25 relevance ranking over
name, description, tags, and category (same `BM25Index` used by memory
relevance and tool search). Returns up to 10 results with name, description,
category, scan status, and activation type.
category, risk level, and activation type.
- **Auto-approve**: `load` requires approval (changes session behavior); `search`
is auto-approved (read-only).
File diff suppressed because it is too large Load Diff
+828 -16
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "turnstone Server API",
"version": "0.9.2",
"version": "1.5.0a1",
"description": "Single-node workstream management, chat interaction, and real-time streaming."
},
"paths": {
@@ -55,6 +55,7 @@
"tags": [
"Workstreams"
],
"description": "Accepts two content types. Default is `application/json` with a `CreateWorkstreamRequest` body. Alternatively, `multipart/form-data` with one `meta` field (JSON-encoded `CreateWorkstreamRequest` shape) plus zero-or-more `file` parts saves each file as an attachment under the new workstream. When `initial_message` is also set, attachments are reserved onto that turn before the worker thread dispatches; otherwise they remain pending for a follow-up `POST /v1/api/send`.",
"requestBody": {
"required": true,
"content": {
@@ -85,6 +86,26 @@
}
}
}
},
"409": {
"description": "Error 409",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"413": {
"description": "Error 413",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
@@ -408,7 +429,7 @@
"tags": [
"Streaming"
],
"description": "Global Server-Sent Events stream for state-change broadcasts across all workstreams. Returns text/event-stream.",
"description": "Server-Sent Events stream for node-level state broadcasts. Emits a node_snapshot event on connect (workstreams, health, aggregate), followed by real-time delta events (ws_state, ws_activity, ws_created, ws_closed, ws_rename, health_changed, aggregate). Pass ?expected_node_id=X for identity verification (returns 409 on mismatch).",
"responses": {
"200": {
"description": "Success"
@@ -416,6 +437,426 @@
}
}
},
"/v1/api/workstreams/{ws_id}/delete": {
"post": {
"summary": "Permanently delete a saved workstream",
"operationId": "v1_api_workstreams_{ws_id}_delete_post",
"tags": [
"Workstreams"
],
"parameters": [
{
"name": "ws_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Error 500",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/workstreams/{ws_id}/open": {
"post": {
"summary": "Load a saved workstream into memory",
"operationId": "v1_api_workstreams_{ws_id}_open_post",
"tags": [
"Workstreams"
],
"parameters": [
{
"name": "ws_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Error 500",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/workstreams/{ws_id}/title": {
"post": {
"summary": "Set workstream title manually",
"operationId": "v1_api_workstreams_{ws_id}_title_post",
"tags": [
"Workstreams"
],
"parameters": [
{
"name": "ws_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "Error 409",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/workstreams/{ws_id}/refresh-title": {
"post": {
"summary": "Regenerate workstream title via LLM",
"operationId": "v1_api_workstreams_{ws_id}_refresh-title_post",
"tags": [
"Workstreams"
],
"parameters": [
{
"name": "ws_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/workstreams/{ws_id}/attachments": {
"post": {
"summary": "Upload a file (multipart/form-data, field 'file') and attach it to the caller's next user turn on this workstream. Validates size, MIME, and UTF-8 for text; magic-byte sniff for images. Ownership failures are masked as 404 so non-owners cannot enumerate workstream existence; a 403 indicates a scope/auth failure from the middleware layer.",
"operationId": "v1_api_workstreams_{ws_id}_attachments_post",
"tags": [
"Attachments"
],
"parameters": [
{
"name": "ws_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UploadAttachmentResponse"
}
}
}
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Error 403",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "Error 409",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"413": {
"description": "Error 413",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
"get": {
"summary": "List the caller's pending (unconsumed) attachments for this workstream. Ownership failures are masked as 404.",
"operationId": "v1_api_workstreams_{ws_id}_attachments_get",
"tags": [
"Attachments"
],
"parameters": [
{
"name": "ws_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListAttachmentsResponse"
}
}
}
},
"403": {
"description": "Error 403",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/workstreams/{ws_id}/attachments/{attachment_id}/content": {
"get": {
"summary": "Return raw bytes of an attachment with its stored Content-Type. Ownership failures are masked as 404.",
"operationId": "v1_api_workstreams_{ws_id}_attachments_{attachment_id}_content_get",
"tags": [
"Attachments"
],
"parameters": [
{
"name": "ws_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "attachment_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
},
"403": {
"description": "Error 403",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/workstreams/{ws_id}/attachments/{attachment_id}": {
"delete": {
"summary": "Remove a pending attachment (consumed attachments return 404). Ownership failures are also masked as 404.",
"operationId": "v1_api_workstreams_{ws_id}_attachments_{attachment_id}_delete",
"tags": [
"Attachments"
],
"parameters": [
{
"name": "ws_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "attachment_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
},
"403": {
"description": "Error 403",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/workstreams/saved": {
"get": {
"summary": "List saved workstreams",
@@ -891,6 +1332,106 @@
}
}
},
"/v1/api/admin/settings": {
"get": {
"summary": "List interface.* settings with values and sources",
"operationId": "v1_api_admin_settings_get",
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/v1/api/admin/settings/{key}": {
"put": {
"summary": "Update an interface.* setting",
"operationId": "v1_api_admin_settings_{key}_put",
"tags": [
"Admin"
],
"parameters": [
{
"name": "key",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"503": {
"description": "Error 503",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
"post": {
"summary": "Update an interface.* setting (alias for PUT)",
"operationId": "v1_api_admin_settings_{key}_post",
"tags": [
"Admin"
],
"parameters": [
{
"name": "key",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"503": {
"description": "Error 503",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/health": {
"get": {
"summary": "Server health check",
@@ -1132,6 +1673,22 @@
"description": "Target workstream ID",
"title": "Ws Id",
"type": "string"
},
"attachment_ids": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"description": "Explicit list of attachment ids to inject into this turn. When omitted, any pending attachments for the caller on this workstream are auto-consumed. An empty list disables auto-consumption for this send.",
"title": "Attachment Ids"
}
},
"required": [
@@ -1144,13 +1701,57 @@
"SendResponse": {
"properties": {
"status": {
"description": "'ok' or 'busy'",
"description": "'ok', 'busy', 'queued', or 'queue_full'",
"examples": [
"ok",
"busy"
"busy",
"queued",
"queue_full"
],
"title": "Status",
"type": "string"
},
"attached_ids": {
"description": "Attachment ids actually reserved onto this turn. Subset of the request's `attachment_ids` (or the auto-consumed pending set). Empty when the send carries no attachments.",
"items": {
"type": "string"
},
"title": "Attached Ids",
"type": "array"
},
"dropped_attachment_ids": {
"description": "Attachment ids the caller requested that the server could not reserve (lost a race, already consumed, or cross-scope). The request still proceeds with whatever was reserved; the client can retry uploads or surface a partial-attach warning.",
"items": {
"type": "string"
},
"title": "Dropped Attachment Ids",
"type": "array"
},
"priority": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Set on `queued` responses: relative priority of the queued message.",
"title": "Priority"
},
"msg_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Set on `queued` responses: id used to dequeue the message.",
"title": "Msg Id"
}
},
"required": [
@@ -1289,11 +1890,75 @@
"description": "Skill name (replaces default skills)",
"title": "Skill",
"type": "string"
},
"notify_targets": {
"anyOf": [
{
"type": "string"
},
{
"items": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"type": "array"
}
],
"default": "[]",
"description": "Notification targets, accepted as either a JSON string or a structured array of objects containing channel_type + channel_id/user_id",
"title": "Notify Targets"
},
"client_type": {
"default": "",
"description": "Client surface type (web, cli, chat). Defaults to web for server-created sessions.",
"title": "Client Type",
"type": "string"
},
"initial_message": {
"default": "",
"description": "Optional first user message dispatched as a background turn after the workstream is created. When attachments are also provided (via the multipart variant), they are reserved onto this turn.",
"title": "Initial Message",
"type": "string"
},
"ws_id": {
"default": "",
"description": "Optional caller-supplied workstream id (32-hex). Required when creating with attachments via the cluster routing layer so the console can hash to the owning node before the multipart body lands. Auto-generated when omitted.",
"title": "Ws Id",
"type": "string"
},
"kind": {
"$ref": "#/components/schemas/WorkstreamKind",
"default": "interactive",
"description": "Workstream kind \u2014 'interactive' (default) or 'coordinator'. Coordinator workstreams are created by the console's own /v1/api/coordinator/new endpoint; clients hitting /v1/api/workstreams/new should leave this at the default."
},
"parent_ws_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional parent workstream id. Populated on children spawned by a coordinator so the parent/child relationship survives restart and appears in audit / list views.",
"title": "Parent Ws Id"
}
},
"title": "CreateWorkstreamRequest",
"type": "object"
},
"WorkstreamKind": {
"description": "Classifier for which manager hosts a workstream.\n\nStrEnum so members are drop-in ``str`` replacements for the DB column,\nJSON payloads, and existing ``==`` comparisons against raw strings.\nNarrow internal annotations to this type; wide boundaries (HTTP body,\nDB row) stay ``str`` and parse via ``WorkstreamKind(raw)`` / ``from_raw``\nat the edge.",
"enum": [
"interactive",
"coordinator"
],
"title": "WorkstreamKind",
"type": "string"
},
"CreateWorkstreamResponse": {
"properties": {
"ws_id": {
@@ -1317,6 +1982,14 @@
"description": "Number of messages in the resumed workstream",
"title": "Message Count",
"type": "integer"
},
"attachment_ids": {
"description": "Ids of attachments saved by this request (multipart variant only). Already reserved onto the initial_message turn when one was provided; otherwise left pending for a follow-up POST /v1/api/send.",
"items": {
"type": "string"
},
"title": "Attachment Ids",
"type": "array"
}
},
"required": [
@@ -1369,6 +2042,22 @@
"state": {
"title": "State",
"type": "string"
},
"kind": {
"$ref": "#/components/schemas/WorkstreamKind",
"default": "interactive"
},
"parent_ws_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Parent Ws Id"
}
},
"required": [
@@ -1493,6 +2182,27 @@
"default": "",
"title": "Model Alias",
"type": "string"
},
"kind": {
"$ref": "#/components/schemas/WorkstreamKind",
"default": "interactive"
},
"parent_ws_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Parent Ws Id"
},
"user_id": {
"default": "",
"title": "User Id",
"type": "string"
}
},
"required": [
@@ -1571,6 +2281,108 @@
"title": "SavedWorkstreamInfo",
"type": "object"
},
"UploadAttachmentResponse": {
"description": "Returned after a successful upload.",
"properties": {
"attachment_id": {
"description": "Opaque id for this attachment",
"title": "Attachment Id",
"type": "string"
},
"filename": {
"description": "Original upload filename",
"title": "Filename",
"type": "string"
},
"mime_type": {
"description": "Canonicalized MIME type",
"title": "Mime Type",
"type": "string"
},
"size_bytes": {
"description": "Payload size in bytes",
"title": "Size Bytes",
"type": "integer"
},
"kind": {
"description": "'image' or 'text'",
"examples": [
"image",
"text"
],
"title": "Kind",
"type": "string"
}
},
"required": [
"attachment_id",
"filename",
"mime_type",
"size_bytes",
"kind"
],
"title": "UploadAttachmentResponse",
"type": "object"
},
"ListAttachmentsResponse": {
"properties": {
"attachments": {
"description": "Pending (unconsumed) attachments for caller+workstream",
"items": {
"$ref": "#/components/schemas/AttachmentInfo"
},
"title": "Attachments",
"type": "array"
}
},
"required": [
"attachments"
],
"title": "ListAttachmentsResponse",
"type": "object"
},
"AttachmentInfo": {
"properties": {
"attachment_id": {
"description": "Opaque id for this attachment",
"title": "Attachment Id",
"type": "string"
},
"filename": {
"description": "Original upload filename",
"title": "Filename",
"type": "string"
},
"mime_type": {
"description": "Canonicalized MIME type",
"title": "Mime Type",
"type": "string"
},
"size_bytes": {
"description": "Payload size in bytes",
"title": "Size Bytes",
"type": "integer"
},
"kind": {
"description": "'image' or 'text'",
"examples": [
"image",
"text"
],
"title": "Kind",
"type": "string"
}
},
"required": [
"attachment_id",
"filename",
"mime_type",
"size_bytes",
"kind"
],
"title": "AttachmentInfo",
"type": "object"
},
"HealthResponse": {
"properties": {
"status": {
@@ -1656,20 +2468,10 @@
],
"title": "Status",
"type": "string"
},
"circuit_state": {
"examples": [
"closed",
"open",
"half_open"
],
"title": "Circuit State",
"type": "string"
}
},
"required": [
"status",
"circuit_state"
"status"
],
"title": "BackendStatus",
"type": "object"
@@ -2036,6 +2838,16 @@
},
"title": "Models",
"type": "array"
},
"default_alias": {
"default": "",
"title": "Default Alias",
"type": "string"
},
"channel_default_alias": {
"default": "",
"title": "Channel Default Alias",
"type": "string"
}
},
"title": "ListAvailableModelsResponse",
@@ -2043,4 +2855,4 @@
}
}
}
}
}
+1 -1
View File
@@ -952,7 +952,7 @@ export interface SkillDiscoverListing {
install_count: number;
tags: string[];
installed: boolean;
scan_status?: string;
risk_level?: string;
template_id?: string;
}
+75
View File
@@ -0,0 +1,75 @@
"""Shared builders for the coordinator-endpoint test files.
The four coordinator test modules each ship a copy of the same
``_AuthMiddleware`` / ``_FakeConfigStore`` / ``_fake_registry`` /
``_build_mgr`` helpers — this module is the single home for them so
future edits land once. Named with a leading underscore so pytest
does not collect it.
``_make_client`` stays local to each test module because the route
list differs per file.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from starlette.middleware.base import BaseHTTPMiddleware
from turnstone.console.coordinator import CoordinatorManager
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
from turnstone.core.auth import AuthResult
class _AuthMiddleware(BaseHTTPMiddleware):
"""Inject a configurable AuthResult from a header-based contract.
Tests set ``X-Test-Perms`` to a comma-separated permission list, and
``X-Test-User`` to the user id. Empty or missing → no auth.
"""
async def dispatch(self, request, call_next): # type: ignore[no-untyped-def]
perms = request.headers.get("X-Test-Perms", "")
user_id = request.headers.get("X-Test-User", "")
if perms or user_id:
request.state.auth_result = AuthResult(
user_id=user_id,
scopes=frozenset({"approve"}),
token_source="test",
permissions=frozenset(p for p in perms.split(",") if p),
)
return await call_next(request)
class _FakeConfigStore:
"""Minimal ConfigStore stub — returns values from a dict."""
def __init__(self, values: dict[str, Any]) -> None:
self._values = values
def get(self, key: str, default: Any = None) -> Any:
return self._values.get(key, default)
def _fake_registry() -> MagicMock:
"""MagicMock whose ``.resolve()`` succeeds so the 503 gate passes."""
reg = MagicMock()
reg.resolve.return_value = (MagicMock(), "gpt-4", MagicMock())
return reg
def _build_mgr(storage: Any) -> CoordinatorManager:
"""Build a CoordinatorManager with stub factories (test default)."""
def _sf(ui, model_alias=None, ws_id=None, **kw): # type: ignore[no-untyped-def]
s = MagicMock()
s.send.return_value = None
return s
return CoordinatorManager(
session_factory=_sf,
ui_factory=lambda w, u: ConsoleCoordinatorUI(ws_id=w, user_id=u),
storage=storage,
max_active=3,
)
+152
View File
@@ -56,3 +56,155 @@ def test_record_audit_generates_unique_ids(storage):
events = storage.list_audit_events()
assert len(events) == 2
assert events[0]["event_id"] != events[1]["event_id"]
# ---------------------------------------------------------------------------
# Credential redaction at the audit boundary
# ---------------------------------------------------------------------------
def test_record_audit_redacts_passwords_by_default(storage):
"""Detail strings go through redact_credentials by default."""
record_audit(
storage,
"u1",
"coordinator.spawn",
detail={
"initial_message": "connect via postgresql://alice:s3cret@db.example.com/app",
},
)
event = storage.list_audit_events()[0]
detail = json.loads(event["detail"])
# Exact redaction text comes from output_guard._redact_credentials —
# assert the token is stripped rather than the exact marker so
# this test doesn't break if the marker format evolves.
assert "s3cret" not in detail["initial_message"]
assert "REDACTED" in detail["initial_message"]
def test_record_audit_redacts_nested_strings(storage):
"""Walker descends into lists / nested dicts."""
record_audit(
storage,
"u1",
"task_list.update",
detail={
"tasks": [
{"title": "normal task"},
{"title": "pull secret from AWS_SECRET_ACCESS_KEY=AKIAEXAMPLE123"},
],
},
)
event = storage.list_audit_events()[0]
detail = json.loads(event["detail"])
assert detail["tasks"][0]["title"] == "normal task"
assert "AKIAEXAMPLE123" not in detail["tasks"][1]["title"]
def test_record_audit_raw_detail_preserves_payload(storage):
"""`raw_detail=True` bypasses the scrub — operator-originated detail only."""
secret_like = "postgresql://alice:s3cret@db.example.com/app"
record_audit(
storage,
"admin-1",
"investigation.note",
detail={"note": secret_like},
raw_detail=True,
)
event = storage.list_audit_events()[0]
detail = json.loads(event["detail"])
assert detail["note"] == secret_like
def test_record_audit_strips_control_chars(storage):
"""CR/LF/NUL/DEL and C0 controls are replaced with spaces so a
downstream exporter that prints raw detail strings can't re-surface
log-injection. Tab/newline are deliberately preserved."""
record_audit(
storage,
"u1",
"coordinator.note",
detail={
"msg": "hello\r\nInjected: bad\x00 escape \x1b[31mred\x1b[0m\x7f",
"ok_tab": "a\tb\nc",
},
)
event = storage.list_audit_events()[0]
detail = json.loads(event["detail"])
# CR / NUL / ESC / DEL scrubbed to spaces; tab + newline kept.
assert "\r" not in detail["msg"]
assert "\x00" not in detail["msg"]
assert "\x1b" not in detail["msg"]
assert "\x7f" not in detail["msg"]
assert "hello" in detail["msg"]
assert detail["ok_tab"] == "a\tb\nc"
def test_record_audit_clean_strings_roundtrip_unchanged(storage):
"""Detail strings with no credential patterns and no control chars
pass through unchanged — the fast-path / scrub must not corrupt the
common case."""
clean = {"note": "hello world", "code": "import foo", "state": "ok"}
record_audit(storage, "u1", "coordinator.note", detail=clean)
event = storage.list_audit_events()[0]
assert json.loads(event["detail"]) == clean
def test_record_audit_fast_path_skips_no_string_detail(storage):
"""A detail carrying only scalars (no strings anywhere) must persist
identically — exercises the ``_has_any_string`` fast path."""
record_audit(
storage,
"u1",
"coordinator.metric",
detail={"spawned": 5, "ok": True, "parent": None, "tail": [1, 2, 3]},
)
event = storage.list_audit_events()[0]
assert json.loads(event["detail"]) == {
"spawned": 5,
"ok": True,
"parent": None,
"tail": [1, 2, 3],
}
def test_record_audit_redacts_dict_keys(storage):
"""Walker descends into dict keys too — a caller using
model-controlled text as a key can't leak it verbatim."""
record_audit(
storage,
"u1",
"coordinator.note",
detail={"postgresql://alice:s3cret@db.example.com/app": True},
)
event = storage.list_audit_events()[0]
detail = json.loads(event["detail"])
assert all("s3cret" not in k for k in detail)
def test_record_audit_walks_set_and_frozenset(storage):
"""Walker handles set/frozenset values (docstring promise)."""
record_audit(
storage,
"u1",
"coordinator.note",
detail={"tags": frozenset({"ak_" + "x" * 40, "plain"})},
)
event = storage.list_audit_events()[0]
detail = json.loads(event["detail"])
# The credential-looking AK token gets scrubbed; the plain one survives.
tags = detail["tags"]
assert "plain" in tags
def test_record_audit_leaves_non_string_scalars_alone(storage):
"""Non-string scalars (int / bool / None) pass through unchanged."""
record_audit(
storage,
"u1",
"coordinator.spawn",
detail={"budget_ok": True, "spawned": 5, "parent": None},
)
event = storage.list_audit_events()[0]
detail = json.loads(event["detail"])
assert detail == {"budget_ok": True, "spawned": 5, "parent": None}
+61 -2
View File
@@ -132,8 +132,12 @@ def _mock_client(
http = httpx.Client(transport=transport)
storage = SQLiteBackend(":memory:")
storage.register_workstream("coord-1", kind="coordinator", user_id="user-1")
storage.register_workstream("ws-x", kind="interactive", parent_ws_id="coord-1")
storage.register_workstream("ws-y", kind="interactive", parent_ws_id="coord-1")
storage.register_workstream(
"ws-x", kind="interactive", parent_ws_id="coord-1", user_id="user-1"
)
storage.register_workstream(
"ws-y", kind="interactive", parent_ws_id="coord-1", user_id="user-1"
)
client = CoordinatorClient(
console_base_url="http://console",
storage=storage,
@@ -901,6 +905,61 @@ def test_list_skills_truncation_signal(storage_with_skills):
assert result["truncated"] is True
def test_list_skills_hides_interactive_only_skills(tmp_path):
"""CoordinatorClient.list_skills must narrow the storage query to
``kinds=['coordinator', 'any']`` so interactive-only skills (which
are meant for child workstreams) don't pollute the orchestrator's
tool surface. Regression lock for a load-bearing invariant that
the fixture-based tests above can't exercise because their skills
all default to ``kind='any'``."""
st = SQLiteBackend(str(tmp_path / "kinds.db"))
st.create_prompt_template(
template_id="k1",
name="interactive-only",
category="general",
content="",
variables="[]",
is_default=False,
org_id="",
created_by="test",
description="interactive only",
kind="interactive",
)
st.create_prompt_template(
template_id="k2",
name="coord-only",
category="general",
content="",
variables="[]",
is_default=False,
org_id="",
created_by="test",
description="coordinator only",
kind="coordinator",
)
st.create_prompt_template(
template_id="k3",
name="universal",
category="general",
content="",
variables="[]",
is_default=False,
org_id="",
created_by="test",
description="everywhere",
kind="any",
)
client = _make_read_client(st)
result = client.list_skills()
names = {s["name"] for s in result["skills"]}
assert "interactive-only" not in names
assert names == {"coord-only", "universal"}
# And the kind projection comes through on every returned row.
for skill in result["skills"]:
assert skill["kind"] in {"coordinator", "any"}
def test_list_skills_projects_allowed_tools_capped_with_sentinel(tmp_path):
"""Each row carries the skill's allowed_tools (capped at the projection
cap with a +N more sentinel) so coordinators can pick a skill without
+3 -1
View File
@@ -216,7 +216,9 @@ def test_coordinator_client_spawn_close_delete(tmp_path):
# close on it; the test stub doesn't run that side-effect, so we
# set it up here.
storage.register_workstream("coord-42", kind="coordinator", user_id="user-1")
storage.register_workstream("child-99", kind="interactive", parent_ws_id="coord-42")
storage.register_workstream(
"child-99", kind="interactive", parent_ws_id="coord-42", user_id="user-1"
)
captured: list[httpx.Request] = []
def _handler(req: httpx.Request) -> httpx.Response:
+9 -66
View File
@@ -8,18 +8,24 @@ enforcement, and lazy rehydration on GET /{ws_id}.
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import httpx
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 turnstone.console.coordinator import CoordinatorManager
# ---------------------------------------------------------------------------
# Auth injection middleware
# ---------------------------------------------------------------------------
from tests._coord_test_helpers import (
_AuthMiddleware,
_build_mgr,
_fake_registry,
_FakeConfigStore,
)
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
from turnstone.console.server import (
cluster_ws_detail,
@@ -38,67 +44,16 @@ from turnstone.console.server import (
from turnstone.core.auth import AuthResult
from turnstone.core.storage._sqlite import SQLiteBackend
# ---------------------------------------------------------------------------
# Auth injection middleware
# ---------------------------------------------------------------------------
class _AuthMiddleware(BaseHTTPMiddleware):
"""Inject a configurable AuthResult from a header-based contract.
Tests set ``X-Test-Perms`` to a comma-separated permission list, and
``X-Test-User`` to the user id. Empty or missing no auth.
"""
async def dispatch(self, request, call_next):
perms = request.headers.get("X-Test-Perms", "")
user_id = request.headers.get("X-Test-User", "")
if perms or user_id:
request.state.auth_result = AuthResult(
user_id=user_id,
scopes=frozenset({"approve"}),
token_source="test",
permissions=frozenset(p for p in perms.split(",") if p),
)
return await call_next(request)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
class _FakeConfigStore:
"""Minimal ConfigStore stub — returns values from a dict."""
def __init__(self, values: dict[str, Any]) -> None:
self._values = values
def get(self, key: str, default: Any = None) -> Any:
return self._values.get(key, default)
@pytest.fixture
def storage(tmp_path):
return SQLiteBackend(str(tmp_path / "coord.db"))
def _build_mgr(storage) -> CoordinatorManager:
"""Build a CoordinatorManager with stub factories."""
def _sf(ui, model_alias=None, ws_id=None, **kw):
s = MagicMock()
s.send.return_value = None
return s
return CoordinatorManager(
session_factory=_sf,
ui_factory=lambda w, u: ConsoleCoordinatorUI(ws_id=w, user_id=u),
storage=storage,
max_active=3,
)
def _make_client(
storage,
*,
@@ -272,13 +227,6 @@ def test_unresolvable_alias_returns_503(storage):
# ---------------------------------------------------------------------------
def _fake_registry() -> MagicMock:
"""MagicMock that returns success on .resolve() so the 503 gate passes."""
reg = MagicMock()
reg.resolve.return_value = (MagicMock(), "gpt-4", MagicMock())
return reg
_COORD_HEADERS = {"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"}
@@ -1003,7 +951,6 @@ def test_coordinator_rows_filters_by_caller_identity(storage):
from unittest.mock import MagicMock
from turnstone.console.server import _coordinator_rows
from turnstone.core.auth import AuthResult
mgr = _build_mgr(storage)
mgr.create(user_id="alice", name="alice-coord")
@@ -1042,7 +989,6 @@ def test_coordinator_rows_empty_user_id_returns_empty(storage):
from unittest.mock import MagicMock
from turnstone.console.server import _coordinator_rows
from turnstone.core.auth import AuthResult
mgr = _build_mgr(storage)
mgr.create(user_id="alice", name="alice-coord")
@@ -1063,8 +1009,6 @@ def _persisted_rows_request(storage, mgr, user_id: str, perms: frozenset[str]):
up so the persisted-rows merge path fires."""
from unittest.mock import MagicMock
from turnstone.core.auth import AuthResult
request = MagicMock()
request.app.state.coord_mgr = mgr
request.app.state.auth_storage = storage
@@ -1190,7 +1134,6 @@ def test_coordinator_rows_persisted_skips_orphan_rows_for_non_admin(storage):
from unittest.mock import MagicMock
from turnstone.console.server import _coordinator_rows
from turnstone.core.auth import AuthResult
from turnstone.core.workstream import WorkstreamKind
mgr = _build_mgr(storage)
+810
View File
@@ -0,0 +1,810 @@
"""Tests for the coordinator governance endpoints and session hooks.
Covers the three console endpoints that let an operator steer a live
coordinator session mid-flight (``/trust``, ``/restrict``,
``/stop_cascade``), 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,
)
from turnstone.console.server import (
coordinator_restrict,
coordinator_stop_cascade,
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 three governance endpoints."""
app = Starlette(
routes=[
Route(
"/v1/api/coordinator/{ws_id}/trust",
coordinator_trust,
methods=["POST"],
),
Route(
"/v1/api/coordinator/{ws_id}/restrict",
coordinator_restrict,
methods=["POST"],
),
Route(
"/v1/api/coordinator/{ws_id}/stop_cascade",
coordinator_stop_cascade,
methods=["POST"],
),
],
middleware=[Middleware(_AuthMiddleware)],
)
app.state.coord_mgr = coord_mgr
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/coordinator/{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/coordinator/{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``, ``/stop_cascade``) 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/coordinator/{ws_id}/trust",
coordinator_trust,
methods=["POST"],
),
Route(
"/v1/api/coordinator/{ws_id}/restrict",
coordinator_restrict,
methods=["POST"],
),
Route(
"/v1/api/coordinator/{ws_id}/stop_cascade",
coordinator_stop_cascade,
methods=["POST"],
),
],
)
app.state.coord_mgr = coord_mgr
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/coordinator/{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/coordinator/{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/coordinator/{coord.id}/restrict",
json={"revoke": ["bash"]},
)
assert resp.status_code == 403
def test_stop_cascade_service_token_cannot_bypass_admin_coordinator(storage):
"""/stop_cascade mirrors /restrict — same destructive treatment."""
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(),
)
resp = client.post(
f"/v1/api/coordinator/{coord.id}/stop_cascade",
json={},
)
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/coordinator/{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. Different bodies may hit
# `read_json_or_400`'s own parse error ("Invalid JSON body") or the
# downstream dict-shape guard ("body must be a JSON object"); we
# only care that none 500.
for body in ([], 42, "string"):
resp = client.post(
f"/v1/api/coordinator/{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/coordinator/{coord.id}/restrict",
json=[],
headers=_COORD_HEADERS,
)
assert resp.status_code == 400
def test_trust_toggle_tenant_404_on_foreign_coord(storage):
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/coordinator/{coord.id}/trust",
json={"send": True},
headers={
"X-Test-User": "user-other",
"X-Test-Perms": "admin.coordinator,coordinator.trust.send",
},
)
assert resp.status_code == 404
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/coordinator/{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/coordinator/{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/coordinator/{coord.id}/restrict",
json={"revoke": ["spawn_workstream"]},
headers=_COORD_HEADERS,
)
resp = client.post(
f"/v1/api/coordinator/{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/coordinator/{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/coordinator/{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/coordinator/{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/coordinator/{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/coordinator/{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()
# ---------------------------------------------------------------------------
# /stop_cascade endpoint (item 5b)
# ---------------------------------------------------------------------------
def test_stop_cascade_cancels_coord_and_each_child(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
mgr.register_children(coord.id, ["child-1", "child-2", "child-3"])
def _cancel(wid: str) -> dict:
if wid == "child-2":
return {"error": "gateway_timeout", "status": 502}
return {"status": "ok"}
coord_client = MagicMock()
coord_client.cancel.side_effect = _cancel
coord.session = MagicMock()
coord.session._coord_client = coord_client
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/coordinator/{coord.id}/stop_cascade",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert set(body["cancelled"] + body["failed"] + body["skipped"]) == {
"child-1",
"child-2",
"child-3",
}
assert body["failed"] == ["child-2"]
assert set(body["cancelled"]) == {"child-1", "child-3"}
assert body["skipped"] == []
assert coord_client.cancel.call_count == 3
events = [
e for e in storage.list_audit_events() if e["action"] == "coordinator.stopped_cascade"
]
assert len(events) == 1
detail = json.loads(events[0]["detail"])
assert set(detail["cancelled"] + detail["failed"] + detail["skipped"]) == {
"child-1",
"child-2",
"child-3",
}
def test_stop_cascade_routes_404_to_skipped_bucket(storage):
"""A stale registry entry (child row already deleted from storage)
or an upstream-404 on cancel is semantically 'already gone', not a
dispatch failure. Report it in ``skipped`` so operators can tell
them apart."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
mgr.register_children(coord.id, ["stale-child"])
coord_client = MagicMock()
coord_client.cancel.return_value = {
"error": "workstream not in coordinator subtree: stale-child",
"status": 404,
}
coord.session = MagicMock()
coord.session._coord_client = coord_client
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/coordinator/{coord.id}/stop_cascade",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert body["cancelled"] == []
assert body["failed"] == []
assert body["skipped"] == ["stale-child"]
def test_stop_cascade_empty_children_still_audits(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
coord.session = MagicMock()
coord.session._coord_client = MagicMock()
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/coordinator/{coord.id}/stop_cascade",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert body == {"status": "ok", "cancelled": [], "failed": [], "skipped": []}
assert [e for e in storage.list_audit_events() if e["action"] == "coordinator.stopped_cascade"]
def test_stop_cascade_without_coord_client_marks_all_failed(storage):
"""If the coord session has no attached coord_client (unexpected
state for a loaded session), every child routes to ``failed`` so
the operator can investigate."""
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
mgr.register_children(coord.id, ["child-a", "child-b"])
coord.session = MagicMock()
coord.session._coord_client = None
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
resp = client.post(
f"/v1/api/coordinator/{coord.id}/stop_cascade",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 200
body = resp.json()
assert body["cancelled"] == []
assert body["skipped"] == []
assert set(body["failed"]) == {"child-a", "child-b"}
def test_stop_cascade_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/coordinator/{coord.id}/stop_cascade",
json={},
headers=_COORD_HEADERS,
)
assert resp.status_code == 404
def test_children_snapshot_returns_copy_not_live_set(storage):
mgr = _build_mgr(storage)
coord = mgr.create(user_id="user-1", name="coord-a")
mgr.register_children(coord.id, ["a", "b", "c"])
snap = mgr.children_snapshot(coord.id)
assert set(snap) == {"a", "b", "c"}
mgr.register_children(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
+6 -6
View File
@@ -643,7 +643,7 @@ def test_list_skills_prepare_is_auto_approved(coord_session):
assert item["needs_approval"] is False
assert item["category"] is None
assert item["tag"] is None
assert item["scan_status"] is None
assert item["risk_level"] is None
assert item["enabled_only"] is False
assert item["limit"] == 100
@@ -653,12 +653,12 @@ def test_list_skills_prepare_accepts_filters(coord_session):
item = sess._prepare_tool(
_tc(
"list_skills",
{"category": "ops", "tag": "gpu", "scan_status": "clean", "enabled_only": True},
{"category": "ops", "tag": "gpu", "risk_level": "clean", "enabled_only": True},
)
)
assert item["category"] == "ops"
assert item["tag"] == "gpu"
assert item["scan_status"] == "clean"
assert item["risk_level"] == "clean"
assert item["enabled_only"] is True
@@ -670,13 +670,13 @@ def test_list_skills_prepare_tolerates_non_string_filters(coord_session):
item = sess._prepare_tool(
_tc(
"list_skills",
{"category": 42, "tag": ["not", "a", "string"], "scan_status": {"bad": 1}},
{"category": 42, "tag": ["not", "a", "string"], "risk_level": {"bad": 1}},
)
)
assert "error" not in item
assert item["category"] is None
assert item["tag"] is None
assert item["scan_status"] is None
assert item["risk_level"] is None
def test_list_skills_prepare_parses_enabled_only_string_forms(coord_session):
@@ -714,7 +714,7 @@ def test_list_skills_exec_dispatches_to_client(coord_session):
coord.list_skills.assert_called_once_with(
category="ops",
tag="gpu",
scan_status=None,
risk_level=None,
enabled_only=False,
limit=100,
)
+12 -12
View File
@@ -158,7 +158,7 @@ class TestExecLoadSkill:
"name": "code-review",
"description": "Reviews code for quality",
"content": "# Code Review\nReview all code.",
"scan_status": "safe",
"risk_level": "safe",
"category": "engineering",
}
]
@@ -185,7 +185,7 @@ class TestExecLoadSkill:
assert session._set_skill_called == []
def test_load_calls_ui_on_tool_result(self) -> None:
skills = [{"name": "test", "content": "content", "description": "", "scan_status": ""}]
skills = [{"name": "test", "content": "content", "description": "", "risk_level": ""}]
session, _, fake_get = _make_session(skills)
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
@@ -200,7 +200,7 @@ class TestExecLoadSkill:
"name": "code-review",
"description": "Reviews code",
"category": "eng",
"scan_status": "safe",
"risk_level": "safe",
"tags": "[]",
"activation": "named",
},
@@ -208,7 +208,7 @@ class TestExecLoadSkill:
"name": "docs-writer",
"description": "Writes docs",
"category": "general",
"scan_status": "low",
"risk_level": "low",
"tags": "[]",
"activation": "named",
},
@@ -232,7 +232,7 @@ class TestExecLoadSkill:
"name": f"skill-{i}",
"description": f"Desc {i}",
"category": "general",
"scan_status": "",
"risk_level": "",
"tags": "[]",
"activation": "named",
}
@@ -262,13 +262,13 @@ class TestExecLoadSkill:
assert "no skills found" in result.lower()
def test_search_includes_scan_status(self) -> None:
def test_search_includes_risk_level(self) -> None:
skills = [
{
"name": "risky",
"description": "Risky skill",
"category": "ops",
"scan_status": "high",
"risk_level": "high",
"tags": "[]",
"activation": "named",
},
@@ -301,7 +301,7 @@ class TestExecLoadSkill:
"name": "disabled-skill",
"content": "x",
"description": "",
"scan_status": "",
"risk_level": "",
"enabled": False,
}
]
@@ -315,7 +315,7 @@ class TestExecLoadSkill:
assert session._set_skill_called == []
def test_load_already_active_skill(self) -> None:
skills = [{"name": "active", "content": "x", "description": "", "scan_status": "safe"}]
skills = [{"name": "active", "content": "x", "description": "", "risk_level": "safe"}]
session, _, fake_get = _make_session(skills)
session._skill_name = "active"
@@ -332,7 +332,7 @@ class TestExecLoadSkill:
"name": "enabled-skill",
"description": "Good",
"category": "gen",
"scan_status": "",
"risk_level": "",
"tags": "[]",
"activation": "named",
"enabled": True,
@@ -341,7 +341,7 @@ class TestExecLoadSkill:
"name": "disabled-skill",
"description": "Bad",
"category": "gen",
"scan_status": "",
"risk_level": "",
"tags": "[]",
"activation": "named",
"enabled": False,
@@ -365,7 +365,7 @@ class TestExecLoadSkill:
"name": "code-review",
"description": "Reviews code for quality",
"category": "eng",
"scan_status": "",
"risk_level": "",
"tags": "[]",
"activation": "named",
},
+641
View File
@@ -0,0 +1,641 @@
"""Tests for the invariants that protect the console ↔ node
service-auth boundary from silent drift.
Covers:
- ``_effective_user_filter`` on ``turnstone.console.server`` the
three-way return (None / caller_uid / DENY_EMPTY_SUB) and the four
decision branches (admin, service-scope, blank-sub non-service,
normal uid).
- ``_effective_user_filter`` on ``turnstone.server`` mirror of the
above minus the admin bypass (node-side has no admin concept).
- ``_verify_collector_service_scope`` 409 probe OK path,
403 drift ``collector_scope_error`` set + ERROR log,
transient failures no refuse-to-serve.
- ``cluster_snapshot`` + ``cluster_events_sse`` endpoints gate on
``collector_scope_error`` and return 503 with a remediation hint.
- ``_NodeDashboardCache.get`` logs 4xx at WARNING with status + body
preview.
- Cross-module identity of the ``DENY_EMPTY_SUB`` sentinel.
"""
from __future__ import annotations
import logging
from types import SimpleNamespace
from unittest.mock import MagicMock
import httpx
import pytest
from turnstone.core.auth import AuthResult
# ---------------------------------------------------------------------------
# Shared builders
# ---------------------------------------------------------------------------
def _request_with_auth(
*,
user_id: str = "",
scopes: frozenset[str] = frozenset(),
permissions: frozenset[str] = frozenset(),
) -> MagicMock:
"""Build a MagicMock Request with an AuthResult on ``request.state``.
Matches the shape the auth middleware attaches so the helpers
under test exercise the real auth-reading path.
"""
request = MagicMock()
request.state.auth_result = AuthResult(
user_id=user_id,
scopes=scopes,
token_source="test",
permissions=permissions,
)
return request
# ---------------------------------------------------------------------------
# _effective_user_filter — console edition (admin, service, uid, DENY)
# ---------------------------------------------------------------------------
class TestConsoleEffectiveUserFilter:
def test_admin_returns_none(self):
from turnstone.console.server import _effective_user_filter
req = _request_with_auth(user_id="alice", permissions=frozenset({"admin.users"}))
assert _effective_user_filter(req) is None
def test_admin_roles_perm_also_bypasses(self):
from turnstone.console.server import _effective_user_filter
req = _request_with_auth(user_id="carol", permissions=frozenset({"admin.roles"}))
assert _effective_user_filter(req) is None
def test_service_scope_returns_none(self):
from turnstone.console.server import _effective_user_filter
req = _request_with_auth(user_id="svc-proxy", scopes=frozenset({"service"}))
assert _effective_user_filter(req) is None
def test_scoped_caller_returns_uid(self):
from turnstone.console.server import _effective_user_filter
req = _request_with_auth(user_id="alice", scopes=frozenset({"read"}))
assert _effective_user_filter(req) == "alice"
def test_blank_sub_non_service_returns_deny_sentinel(self):
from turnstone.console.server import DENY_EMPTY_SUB, _effective_user_filter
req = _request_with_auth(user_id="", scopes=frozenset({"read"}))
result = _effective_user_filter(req)
assert result is DENY_EMPTY_SUB, (
"blank-sub non-service callers must fail closed — "
"passing through to storage with user_id=None is a "
"service escape and user_id='' matches legacy orphans"
)
def test_deny_sentinel_is_singleton(self):
"""Callers compare with ``is``; equality against a bare object()
must never match the sentinel, and two separate reads of the
attribute return the same instance (ruling out a property /
factory that would break ``is`` identity)."""
from turnstone.console.server import DENY_EMPTY_SUB as FIRST_READ
from turnstone.console.server import DENY_EMPTY_SUB as SECOND_READ
assert FIRST_READ is not object()
assert FIRST_READ is SECOND_READ
# ---------------------------------------------------------------------------
# _effective_user_filter — server edition (service, uid, DENY — no admin)
# ---------------------------------------------------------------------------
class TestServerEffectiveUserFilter:
def test_service_scope_returns_none(self):
from turnstone.server import _effective_user_filter
req = _request_with_auth(user_id="console-proxy", scopes=frozenset({"service"}))
assert _effective_user_filter(req) is None
def test_scoped_caller_returns_uid(self):
from turnstone.server import _effective_user_filter
req = _request_with_auth(user_id="alice", scopes=frozenset({"read"}))
assert _effective_user_filter(req) == "alice"
def test_blank_sub_non_service_returns_deny(self):
from turnstone.server import DENY_EMPTY_SUB, _effective_user_filter
req = _request_with_auth(user_id="", scopes=frozenset({"read"}))
assert _effective_user_filter(req) is DENY_EMPTY_SUB
def test_server_has_no_admin_bypass(self):
"""Server-side has no admin-permissions concept — ``admin.users``
must NOT bypass the tenant filter on node endpoints. Only the
service scope crosses tenants."""
from turnstone.server import _effective_user_filter
req = _request_with_auth(
user_id="alice",
scopes=frozenset({"read"}),
permissions=frozenset({"admin.users"}),
)
# Admin perm is ignored; caller is a scoped user.
assert _effective_user_filter(req) == "alice"
# ---------------------------------------------------------------------------
# Boot self-check — _verify_collector_service_scope
# ---------------------------------------------------------------------------
def _scope_probe_app(
*,
services: list[dict] | None = None,
token: str = "probe-token",
) -> MagicMock:
"""Build a MagicMock ``app`` with the state the self-check reads."""
storage = MagicMock()
storage.list_services.return_value = services or []
token_mgr = SimpleNamespace(token=token)
app = MagicMock()
app.state.auth_storage = storage
app.state.collector_token_mgr = token_mgr
app.state.collector_scope_error = ""
return app
class TestVerifyCollectorServiceScope:
@pytest.mark.anyio
async def test_409_probe_leaves_scope_error_empty(self):
"""A 409 response means the scope gate passed; the probe's
deliberately-wrong node_id tripped the identity check only
after auth was accepted. This is the happy path."""
from turnstone.console.server import _verify_collector_service_scope
app = _scope_probe_app(
services=[
{"service_id": "node-1", "url": "http://node-1:8001"},
]
)
def handler(request: httpx.Request) -> httpx.Response:
# Caller MUST probe with expected_node_id set to the
# sentinel so the server returns 409 before opening a
# stream.
assert "expected_node_id=_scope-probe_" in str(request.url)
assert request.headers["authorization"] == "Bearer probe-token"
return httpx.Response(409, text='{"error":"node_id mismatch"}')
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
await _verify_collector_service_scope(app, client)
assert app.state.collector_scope_error == ""
@pytest.mark.anyio
async def test_403_probe_sets_scope_error_and_logs_error(self, caplog):
"""403 from the probe means the collector token is missing the
``service`` scope (or the JWT audience is misconfigured). The
probe must (1) set ``app.state.collector_scope_error`` non-empty
with a remediation hint and (2) log at ERROR so operators see
the drift at boot rather than chasing empty-dashboard reports."""
from turnstone.console.server import _verify_collector_service_scope
app = _scope_probe_app(services=[{"service_id": "node-1", "url": "http://node-1:8001"}])
def handler(_req: httpx.Request) -> httpx.Response:
return httpx.Response(403, text='{"error":"service scope required"}')
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
with caplog.at_level(logging.ERROR, logger="turnstone.console.server"):
await _verify_collector_service_scope(app, client)
err = app.state.collector_scope_error
assert err, "403 probe must populate collector_scope_error"
assert "collector token rejected" in err
assert "HTTP 403" in err
assert any(
rec.levelno == logging.ERROR and "collector_scope_probe.drift" in rec.getMessage()
for rec in caplog.records
), "403 drift must log at ERROR so operators see it at boot"
@pytest.mark.anyio
async def test_401_also_sets_scope_error(self):
"""401 (JWT audience / secret mismatch) is the same configuration
class as 403 refuse to serve."""
from turnstone.console.server import _verify_collector_service_scope
app = _scope_probe_app(services=[{"service_id": "node-1", "url": "http://node-1:8001"}])
def handler(_req: httpx.Request) -> httpx.Response:
return httpx.Response(401, text="unauthorized")
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
await _verify_collector_service_scope(app, client)
assert "HTTP 401" in app.state.collector_scope_error
@pytest.mark.anyio
async def test_no_registered_nodes_skips_silently(self, caplog):
"""Single-node or pre-discovery states have no upstream to
probe. The self-check must NOT refuse to serve the dashboard
simply has no cluster data to render yet."""
from turnstone.console.server import _verify_collector_service_scope
app = _scope_probe_app(services=[])
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda _r: httpx.Response(500)))
with caplog.at_level(logging.INFO, logger="turnstone.console.server"):
await _verify_collector_service_scope(app, client)
assert app.state.collector_scope_error == ""
@pytest.mark.anyio
async def test_network_error_does_not_refuse(self):
"""Transient httpx.ConnectError during probe is a "cluster is
coming up" state; it must NOT be confused with scope drift.
Leave ``collector_scope_error`` empty and log a warning."""
from turnstone.console.server import _verify_collector_service_scope
app = _scope_probe_app(services=[{"service_id": "node-1", "url": "http://node-1:8001"}])
def handler(_req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused")
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
await _verify_collector_service_scope(app, client)
assert app.state.collector_scope_error == ""
# ---------------------------------------------------------------------------
# Gated dashboard endpoints — cluster_snapshot + cluster_events_sse
# ---------------------------------------------------------------------------
class TestClusterDashboardGate:
@pytest.mark.anyio
async def test_cluster_snapshot_503_when_scope_error(self):
from turnstone.console.server import cluster_snapshot
request = MagicMock()
request.app.state.collector_scope_error = "collector token rejected by node-1"
resp = await cluster_snapshot(request)
assert resp.status_code == 503
import json
body = json.loads(resp.body)
assert body["reason"] == "collector_scope_drift"
assert "collector token rejected" in body["error"]
@pytest.mark.anyio
async def test_cluster_snapshot_200_when_scope_ok(self):
from turnstone.console.server import cluster_snapshot
request = MagicMock()
request.app.state.collector_scope_error = ""
request.app.state.collector.get_snapshot.return_value = {"nodes": []}
resp = await cluster_snapshot(request)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Dashboard cache — 4xx log-warning floor (0d)
# ---------------------------------------------------------------------------
class TestDashboardCache4xxLogLevel:
@pytest.mark.anyio
async def test_403_logged_at_warning_with_preview(self, caplog):
"""A 4xx from the upstream dashboard fetch must log at WARNING
with the upstream body preview silence here hides auth/scope
drift behind an empty dashboard."""
from turnstone.console.server import _NodeDashboardCache
cache = _NodeDashboardCache()
def handler(_req: httpx.Request) -> httpx.Response:
return httpx.Response(403, text='{"error":"service scope required"}')
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
with caplog.at_level(logging.WARNING, logger="turnstone.console.server"):
payload = await cache.get("node-1", "http://node-1:8001", client, {})
assert payload is None # 4xx → no payload cached
matches = [
r
for r in caplog.records
if r.levelno == logging.WARNING
and "dashboard_cache" in r.getMessage()
and "403" in r.getMessage()
]
assert matches, "4xx from dashboard fetch must log at WARNING"
assert "service scope required" in matches[0].getMessage()
@pytest.mark.anyio
async def test_200_does_not_log(self, caplog):
"""The happy path stays quiet — only 4xx raises the log floor."""
from turnstone.console.server import _NodeDashboardCache
cache = _NodeDashboardCache()
def handler(_req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"workstreams": []})
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
with caplog.at_level(logging.WARNING, logger="turnstone.console.server"):
payload = await cache.get("node-1", "http://node-1:8001", client, {})
assert payload == {"workstreams": []}
assert not [r for r in caplog.records if "dashboard_cache" in r.getMessage()]
@pytest.mark.anyio
async def test_4xx_does_not_cache_payload_none(self):
"""On 4xx the dashboard cache must skip the TTL write so an
operator scope fix shows up on the next request instead of
after the cache expires. Regression lock for the per-node
``asyncio.Lock`` already handling hot-loop protection."""
from turnstone.console.server import _NodeDashboardCache
cache = _NodeDashboardCache()
calls = {"n": 0}
def handler(_req: httpx.Request) -> httpx.Response:
calls["n"] += 1
if calls["n"] == 1:
return httpx.Response(403, text="forbidden")
return httpx.Response(200, json={"workstreams": [{"id": "ws-1"}]})
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
first = await cache.get("node-1", "http://node-1:8001", client, {})
second = await cache.get("node-1", "http://node-1:8001", client, {})
assert first is None
assert second == {"workstreams": [{"id": "ws-1"}]}
assert calls["n"] == 2, "4xx must bypass the cache so the retry reaches upstream"
# ---------------------------------------------------------------------------
# _fetch_live_block — 4xx log floor on the direct-fetch fallback (0d)
# ---------------------------------------------------------------------------
class TestFetchLiveBlock4xxLogLevel:
@pytest.mark.anyio
async def test_direct_fallback_logs_warning_on_4xx(self, caplog, monkeypatch):
"""Test harnesses / legacy embeddings skip the dashboard cache
and fall through to the direct-fetch path inside
``_fetch_live_block``. 4xx there must surface at WARNING
the silence the cache path previously had also applied here."""
from turnstone.console.server import _fetch_live_block
def handler(_req: httpx.Request) -> httpx.Response:
return httpx.Response(401, text='{"error":"JWT audience mismatch"}')
# Build the request with explicit state so _proxy_auth_headers
# takes the empty-headers fallback (no auth_result, no
# jwt_secret, no service-token manager); we're exercising the
# 4xx branch, not the token-mint path.
request = MagicMock()
request.state = SimpleNamespace(auth_result=None)
request.app.state = SimpleNamespace(
proxy_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
proxy_token_mgr=None,
jwt_secret="",
dashboard_cache=None, # force the direct-fetch branch
coord_mgr=None,
)
# Shim _get_server_url so we don't need the full cluster-
# router wiring to resolve node_id → URL. monkeypatch handles
# the restore automatically.
monkeypatch.setattr(
"turnstone.console.server._get_server_url",
lambda _req, _nid: "http://node-1:8001",
)
with caplog.at_level(logging.WARNING, logger="turnstone.console.server"):
live = await _fetch_live_block(
request, {"node_id": "node-1", "kind": "interactive"}, "ws-abc"
)
assert live is None
matches = [
r
for r in caplog.records
if r.levelno == logging.WARNING and "proxy.live_block.4xx" in r.getMessage()
]
assert matches, "4xx from the direct fetch must log at WARNING"
assert "401" in matches[0].getMessage()
assert "JWT audience mismatch" in matches[0].getMessage()
# ---------------------------------------------------------------------------
# _proxy_sse — 4xx log floor on the streaming path (0d)
# ---------------------------------------------------------------------------
class TestProxySseNon200LogLevel:
@pytest.mark.anyio
async def test_non_200_upstream_logs_warning_with_preview(self, caplog):
"""Non-200 on a service-auth SSE proxy hop is operator-
actionable; the browser already sees the error event, but
operators need the drift in ops logs too."""
from starlette.requests import Request
from turnstone.console.server import _proxy_sse
def handler(_req: httpx.Request) -> httpx.Response:
return httpx.Response(403, text='{"error":"service scope required\\n"}')
sse_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
proxy_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
scope = {
"type": "http",
"method": "GET",
"path": "/node/n/api/events",
"headers": [],
"query_string": b"",
"app": MagicMock(
state=SimpleNamespace(proxy_sse_client=sse_client, proxy_client=proxy_client)
),
}
async def _receive():
return {"type": "http.request", "body": b""}
request = Request(scope, receive=_receive)
# MagicMock on app.state.proxy_sse_client above is covered by
# the SimpleNamespace; auth headers fall through to the
# fallback empty-dict path since _proxy_auth_headers sees no
# auth_result.
with caplog.at_level(logging.WARNING, logger="turnstone.console.server"):
response = await _proxy_sse(request, "http://node-1:8001", "events", api_prefix="api")
# Drain the streaming body so the async gen executes.
async for _ in response.body_iterator: # type: ignore[attr-defined]
pass
matches = [
r
for r in caplog.records
if r.levelno == logging.WARNING and "proxy.sse.non_200" in r.getMessage()
]
assert matches, "non-200 from SSE proxy must log at WARNING"
# Control-char scrub replaces the literal \n byte with a
# space so the preview can't forge a log-line break.
assert "\n" not in matches[0].getMessage().split("body=", 1)[-1]
# ---------------------------------------------------------------------------
# Gated cluster_events_sse — 503 on scope error (0a)
# ---------------------------------------------------------------------------
class TestClusterEventsSseGate:
@pytest.mark.anyio
async def test_cluster_events_sse_503_when_scope_error(self):
from turnstone.console.server import cluster_events_sse
request = MagicMock()
request.app.state.collector_scope_error = (
"collector token rejected by node-1 — upstream_body=<<<forbidden>>>"
)
resp = await cluster_events_sse(request)
assert resp.status_code == 503
import json
body = json.loads(resp.body)
assert body["reason"] == "collector_scope_drift"
# ---------------------------------------------------------------------------
# Cross-module identity of DENY_EMPTY_SUB (q-4)
# ---------------------------------------------------------------------------
class TestDenySentinelSharedIdentity:
def test_console_and_server_share_one_sentinel(self):
"""The sentinel is compared with ``is``; a future refactor
that re-introduced per-module duplicates would silently break
the identity check. Lock the cross-module invariant."""
from turnstone.console.server import DENY_EMPTY_SUB as CONSOLE_DENY
from turnstone.core.auth import DENY_EMPTY_SUB as CORE_DENY
from turnstone.server import DENY_EMPTY_SUB as SERVER_DENY
assert CORE_DENY is CONSOLE_DENY
assert CORE_DENY is SERVER_DENY
# ---------------------------------------------------------------------------
# _bounded_body_preview control-char scrub (sec-1)
# ---------------------------------------------------------------------------
class TestBoundedBodyPreviewScrub:
def test_control_chars_replaced_with_space(self):
"""CR/LF/NUL/TAB in upstream bodies must not appear raw in
logs or in the operator-facing 503 ``collector_scope_error``
otherwise an attacker-controllable upstream can forge
additional log lines or embed fake remediation text."""
from turnstone.console.server import _bounded_body_preview
preview = _bounded_body_preview("line-a\nline-b\r\nNUL\x00TAB\t")
assert "\n" not in preview
assert "\r" not in preview
assert "\x00" not in preview
assert "\t" not in preview
# Structure is preserved with spaces, so operators can still
# read the body preview meaningfully.
assert "line-a" in preview
assert "line-b" in preview
def test_accepts_bytes_and_decodes(self):
from turnstone.console.server import _bounded_body_preview
preview = _bounded_body_preview(b"hello\nworld")
assert preview == "hello world"
def test_caps_at_requested_length(self):
from turnstone.console.server import _bounded_body_preview
preview = _bounded_body_preview("x" * 1000, cap=50)
assert len(preview) == 50
# ---------------------------------------------------------------------------
# coordinator_metrics DENY short-circuit — shape matches happy path (bug-1)
# ---------------------------------------------------------------------------
class TestCoordinatorMetricsDenyShape:
def test_zero_payload_matches_success_keys(self):
"""The DENY short-circuit in coordinator_metrics must emit the
same key set as the success path so strict-schema consumers
don't break on the blank-sub branch."""
from turnstone.console.server import _coordinator_metrics_payload
zero = _coordinator_metrics_payload(ws_id="a" * 32)
happy = _coordinator_metrics_payload(
ws_id="a" * 32,
spawns_total=5,
spawns_last_hour=2,
child_state_counts={"idle": 3},
judge_fallback_rate=0.1,
intent_verdicts_sample=10,
)
assert set(zero.keys()) == set(happy.keys()), (
"DENY payload key set must match success payload — "
"otherwise a future field addition silently drifts"
)
# The zero payload carries ws_id through so consumers that
# key on it don't drop the response.
assert zero["ws_id"] == "a" * 32
# ---------------------------------------------------------------------------
# Probe URL allowlist (sec-3)
# ---------------------------------------------------------------------------
class TestProbeUrlAllowlist:
def test_rejects_non_http_scheme(self):
"""Probe URL picker must reject non-http(s) schemes so a
poisoned service-registry entry can't redirect the probe
through a ``file://`` or ``gs://`` transport."""
from turnstone.console.server import _probe_candidate_url
url, nid = _probe_candidate_url([{"service_id": "node-x", "url": "file:///etc/passwd"}])
assert (url, nid) == ("", "")
def test_rejects_link_local_host(self):
"""169.254.0.0/16 is the cloud metadata range; a poisoned
entry pointing there would turn the probe into an SSRF to
IMDS."""
from turnstone.console.server import _probe_candidate_url
url, nid = _probe_candidate_url(
[{"service_id": "node-x", "url": "http://169.254.169.254:80"}]
)
assert (url, nid) == ("", "")
def test_accepts_loopback_for_dev(self):
"""Single-box dev setups register the node at 127.0.0.1 — the
allowlist must let that through."""
from turnstone.console.server import _probe_candidate_url
url, nid = _probe_candidate_url([{"service_id": "node-x", "url": "http://127.0.0.1:8001"}])
assert nid == "node-x"
assert url == "http://127.0.0.1:8001"
def test_skips_malformed_entries(self):
"""Entries missing url or service_id are skipped so the loop
falls through to the next candidate."""
from turnstone.console.server import _probe_candidate_url
url, nid = _probe_candidate_url(
[
{"service_id": "", "url": "http://node-a:8001"},
{"service_id": "node-b", "url": ""},
{"service_id": "node-c", "url": "http://node-c:8001"},
]
)
assert nid == "node-c"
+117 -11
View File
@@ -794,7 +794,11 @@ class TestSkillAPI:
content = "x" * 400 # 400 chars -> 100 tokens (400 // 4)
resp = api_client.post(
"/v1/api/admin/skills",
json={"name": "estimated-skill", "content": content},
json={
"name": "estimated-skill",
"content": content,
"description": "estimation test",
},
)
assert resp.status_code == 200
data = resp.json()
@@ -804,7 +808,7 @@ class TestSkillAPI:
"""Creating without name returns 400."""
resp = api_client.post(
"/v1/api/admin/skills",
json={"content": "some content"},
json={"content": "some content", "description": "desc"},
)
assert resp.status_code == 400
assert "name" in resp.json()["error"].lower()
@@ -813,11 +817,103 @@ class TestSkillAPI:
"""Creating without content returns 400."""
resp = api_client.post(
"/v1/api/admin/skills",
json={"name": "no-content"},
json={"name": "no-content", "description": "desc"},
)
assert resp.status_code == 400
assert "content" in resp.json()["error"].lower()
def test_create_skill_requires_description(self, api_client):
"""Creating without description returns 400 — empty descriptions
break discoverability in list_skills."""
resp = api_client.post(
"/v1/api/admin/skills",
json={"name": "no-desc", "content": "some content"},
)
assert resp.status_code == 400
assert "description" in resp.json()["error"].lower()
def test_create_skill_rejects_blank_description(self, api_client):
"""An all-whitespace description is treated the same as empty."""
resp = api_client.post(
"/v1/api/admin/skills",
json={
"name": "blank-desc",
"content": "some content",
"description": " \t ",
},
)
assert resp.status_code == 400
assert "description" in resp.json()["error"].lower()
def test_update_skill_rejects_blanking_description(self, api_client, api_storage):
"""An update cannot blank out the description — operators must
supply a non-empty replacement or omit the field."""
_create_template(api_storage, "s1", "keep-desc", "content", description="existing desc")
resp = api_client.put(
"/v1/api/admin/skills/s1",
json={"description": " "},
)
assert resp.status_code == 400
assert "description" in resp.json()["error"].lower()
def test_create_skill_default_kind_is_any(self, api_client):
"""Skills without an explicit ``kind`` default to ``any`` so
pre-upgrade catalogs keep showing up on both interactive and
coordinator sides."""
resp = api_client.post(
"/v1/api/admin/skills",
json={
"name": "kind-default",
"content": "content",
"description": "no explicit kind",
},
)
assert resp.status_code == 200
assert resp.json()["kind"] == "any"
def test_create_skill_accepts_explicit_kind(self, api_client):
resp = api_client.post(
"/v1/api/admin/skills",
json={
"name": "kind-coord",
"content": "content",
"description": "coord only",
"kind": "coordinator",
},
)
assert resp.status_code == 200
assert resp.json()["kind"] == "coordinator"
def test_create_skill_rejects_invalid_kind(self, api_client):
resp = api_client.post(
"/v1/api/admin/skills",
json={
"name": "kind-bad",
"content": "content",
"description": "bad kind",
"kind": "nonsense",
},
)
assert resp.status_code == 400
assert "kind" in resp.json()["error"].lower()
def test_update_skill_kind_round_trip(self, api_client, api_storage):
_create_template(api_storage, "s1", "kind-upd", "content", description="initial")
resp = api_client.put(
"/v1/api/admin/skills/s1",
json={"kind": "interactive"},
)
assert resp.status_code == 200
assert resp.json()["kind"] == "interactive"
def test_update_skill_rejects_invalid_kind(self, api_client, api_storage):
_create_template(api_storage, "s1", "kind-upd-bad", "content", description="initial")
resp = api_client.put(
"/v1/api/admin/skills/s1",
json={"kind": "bogus"},
)
assert resp.status_code == 400
def test_update_skill_endpoint(self, api_client, api_storage):
"""PUT /v1/api/admin/skills/{id} updates new fields."""
_create_template(api_storage, "s1", "update-me", "old content", description="old desc")
@@ -978,6 +1074,7 @@ class TestSkillAPI:
json={
"name": "auto-default",
"content": "auto default content",
"description": "activation default",
"activation": "default",
},
)
@@ -993,6 +1090,7 @@ class TestSkillAPI:
json={
"name": "default-derived",
"content": "derived content",
"description": "is_default derived",
"is_default": True,
},
)
@@ -1439,11 +1537,15 @@ class TestSkillAdminEndpoints:
"""POST with existing name returns 409."""
full_api_client.post(
"/v1/api/admin/skills",
json={"name": "dup-skill", "content": "content"},
json={"name": "dup-skill", "content": "content", "description": "first"},
)
resp = full_api_client.post(
"/v1/api/admin/skills",
json={"name": "dup-skill", "content": "other content"},
json={
"name": "dup-skill",
"content": "other content",
"description": "second",
},
)
assert resp.status_code == 409
@@ -1471,7 +1573,7 @@ class TestSkillAdminEndpoints:
"""PUT with new fields updates the skill."""
create_resp = full_api_client.post(
"/v1/api/admin/skills",
json={"name": "update-me", "content": "old content"},
json={"name": "update-me", "content": "old content", "description": "pre"},
)
skill_id = create_resp.json()["template_id"]
@@ -1493,7 +1595,7 @@ class TestSkillAdminEndpoints:
"""DELETE removes the skill and subsequent GET returns 404."""
create_resp = full_api_client.post(
"/v1/api/admin/skills",
json={"name": "delete-me", "content": "content"},
json={"name": "delete-me", "content": "content", "description": "doomed"},
)
skill_id = create_resp.json()["template_id"]
@@ -1508,11 +1610,11 @@ class TestSkillAdminEndpoints:
"""GET /v1/api/skills excludes disabled skills."""
full_api_client.post(
"/v1/api/admin/skills",
json={"name": "enabled-skill", "content": "content"},
json={"name": "enabled-skill", "content": "content", "description": "on"},
)
create_resp = full_api_client.post(
"/v1/api/admin/skills",
json={"name": "disabled-skill", "content": "content"},
json={"name": "disabled-skill", "content": "content", "description": "off"},
)
skill_id = create_resp.json()["template_id"]
full_api_client.put(
@@ -1530,7 +1632,7 @@ class TestSkillAdminEndpoints:
"""GET /v1/api/admin/skills/{id}/versions returns version history."""
create_resp = full_api_client.post(
"/v1/api/admin/skills",
json={"name": "versioned-skill", "content": "v1 content"},
json={"name": "versioned-skill", "content": "v1 content", "description": "v1"},
)
skill_id = create_resp.json()["template_id"]
@@ -1568,7 +1670,11 @@ class TestSkillAdminEndpoints:
for i in range(5):
full_api_client.post(
"/v1/api/admin/skills",
json={"name": f"page-skill-{i}", "content": f"content {i}"},
json={
"name": f"page-skill-{i}",
"content": f"content {i}",
"description": f"desc {i}",
},
)
# Limit
resp = full_api_client.get("/v1/api/admin/skills?limit=2")
+44 -8
View File
@@ -18,9 +18,10 @@ def _create_skill(
name: str,
category: str = "general",
tags: list[str] | None = None,
scan_status: str = "",
risk_level: str = "",
enabled: bool = True,
priority: int = 0,
kind: str = "any",
) -> None:
storage.create_prompt_template(
template_id=template_id,
@@ -34,9 +35,10 @@ def _create_skill(
tags=json.dumps(tags or []),
priority=priority,
enabled=enabled,
kind=kind,
)
if scan_status:
# scan_status is set by the scanner pipeline, not create_prompt_template;
if risk_level:
# risk_level is set by the scanner pipeline, not create_prompt_template;
# patch it directly so tests can fix the value.
with storage._conn() as conn:
import sqlalchemy as sa
@@ -46,7 +48,7 @@ def _create_skill(
conn.execute(
sa.update(prompt_templates)
.where(prompt_templates.c.template_id == template_id)
.values(scan_status=scan_status)
.values(risk_level=risk_level)
)
conn.commit()
@@ -136,11 +138,14 @@ class TestListSkillsFiltered:
rows = storage.list_skills_filtered(tag="\u6f22\u5b57")
assert {r["name"] for r in rows} == {"cjk"}
def test_scan_status_filter(self, storage):
_create_skill(storage, template_id="s1", name="a", scan_status="clean")
_create_skill(storage, template_id="s2", name="b", scan_status="flagged")
def test_risk_level_filter(self, storage):
# Use the scanner's real taxonomy (safe / low / medium / high / critical)
# rather than the legacy ``scan_status`` values the column used to
# carry — see turnstone/core/skill_scanner.py for the source.
_create_skill(storage, template_id="s1", name="a", risk_level="safe")
_create_skill(storage, template_id="s2", name="b", risk_level="high")
_create_skill(storage, template_id="s3", name="c")
rows = storage.list_skills_filtered(scan_status="flagged")
rows = storage.list_skills_filtered(risk_level="high")
assert {r["name"] for r in rows} == {"b"}
def test_enabled_only_filter(self, storage):
@@ -166,3 +171,34 @@ class TestListSkillsFiltered:
_create_skill(storage, template_id="s1", name="a", category="ops")
rows = storage.list_skills_filtered(category="nonexistent")
assert rows == []
def test_kinds_filter_narrows_to_listed_buckets(self, storage):
"""The ``kinds`` filter narrows the result to rows whose ``kind``
column is in the supplied list used by the coordinator client
to hide interactive-only skills and by any future interactive
lister to hide coordinator-only skills."""
_create_skill(storage, template_id="s1", name="interactive-only", kind="interactive")
_create_skill(storage, template_id="s2", name="coord-only", kind="coordinator")
_create_skill(storage, template_id="s3", name="universal", kind="any")
coord_view = storage.list_skills_filtered(kinds=["coordinator", "any"])
assert {r["name"] for r in coord_view} == {"coord-only", "universal"}
interactive_view = storage.list_skills_filtered(kinds=["interactive", "any"])
assert {r["name"] for r in interactive_view} == {"interactive-only", "universal"}
def test_kinds_none_returns_all_kinds(self, storage):
"""``kinds=None`` (the default) applies no kind filter — admin
surfaces that want the full catalog leave it unset."""
_create_skill(storage, template_id="s1", name="interactive-only", kind="interactive")
_create_skill(storage, template_id="s2", name="coord-only", kind="coordinator")
_create_skill(storage, template_id="s3", name="universal", kind="any")
assert len(storage.list_skills_filtered()) == 3
def test_kinds_empty_list_behaves_like_none(self, storage):
"""An empty ``kinds`` list is treated the same as None (no
filter). Prevents an accidental empty-result from a caller
that defensively materialises a set / list."""
_create_skill(storage, template_id="s1", name="interactive", kind="interactive")
_create_skill(storage, template_id="s2", name="coord", kind="coordinator")
assert len(storage.list_skills_filtered(kinds=[])) == 2
+108 -4
View File
@@ -6,6 +6,8 @@ from typing import Any
from pydantic import BaseModel, Field
from turnstone.core.skill_kind import SkillKind
# ---------------------------------------------------------------------------
# Cluster overview
# ---------------------------------------------------------------------------
@@ -323,7 +325,8 @@ class SkillInfo(BaseModel):
allowed_tools: str = "[]"
license: str = ""
compatibility: str = ""
scan_status: str = ""
kind: SkillKind = SkillKind.ANY
risk_level: str = ""
scan_report: str = "{}"
scan_version: str = ""
resource_count: int = 0
@@ -335,7 +338,16 @@ class CreateSkillRequest(BaseModel):
name: str
content: str
category: str = "general"
description: str = ""
description: str = Field(
min_length=1,
max_length=1024,
description=(
"Human-readable description surfaced by ``list_skills`` and "
"the admin UI. Must be non-empty — catches skills registered "
"without thinking about discoverability before they reach a "
"model's tool-selection prompt."
),
)
tags: str = "[]"
variables: str = "[]"
is_default: bool = False
@@ -356,13 +368,32 @@ class CreateSkillRequest(BaseModel):
allowed_tools: str = "[]"
license: str = ""
compatibility: str = ""
kind: SkillKind = Field(
default=SkillKind.ANY,
description=(
"Classifier routing the skill to ``list_skills`` calls. "
"``interactive`` is visible only to the interactive-session "
"activation path; ``coordinator`` is visible only to the "
"coordinator's ``list_skills`` tool; ``any`` (default) is "
"visible on both sides, which preserves pre-upgrade "
"behaviour for legacy rows."
),
)
class UpdateSkillRequest(BaseModel):
name: str | None = None
content: str | None = None
category: str | None = None
description: str | None = None
description: str | None = Field(
default=None,
min_length=1,
max_length=1024,
description=(
"When present, replaces the skill description. Must be "
"non-empty — the admin endpoint rejects a blanking update."
),
)
tags: str | None = None
variables: str | None = None
is_default: bool | None = None
@@ -382,6 +413,13 @@ class UpdateSkillRequest(BaseModel):
allowed_tools: str | None = None
license: str | None = None
compatibility: str | None = None
kind: SkillKind | None = Field(
default=None,
description=(
"When present, updates the skill's classifier. Same "
"accepted values as ``CreateSkillRequest.kind``."
),
)
class ListSkillsResponse(BaseModel):
@@ -719,7 +757,7 @@ class SkillDiscoverListing(BaseModel):
install_count: int = 0
tags: list[str] = Field(default_factory=list)
installed: bool = False
scan_status: str = ""
risk_level: str = ""
template_id: str = ""
@@ -1086,6 +1124,72 @@ class CoordinatorTasksResponse(BaseModel):
tasks: list[CoordinatorTaskInfo] = Field(default_factory=list)
class CoordinatorTrustRequest(BaseModel):
"""Body for POST /v1/api/coordinator/{ws_id}/trust."""
send: bool = Field(
description=(
"When true, ``send_to_workstream`` calls that target a ws_id "
"in the coordinator's own subtree skip the approval prompt. "
"Foreign ws_ids continue to require approval — trust only "
"relaxes the guard for work the orchestrator itself spawned."
),
)
class CoordinatorTrustResponse(BaseModel):
"""Response body for POST /v1/api/coordinator/{ws_id}/trust."""
status: str = Field(default="ok")
trust_send: bool = Field(description="Post-toggle value of the flag.")
class CoordinatorRestrictRequest(BaseModel):
"""Body for POST /v1/api/coordinator/{ws_id}/restrict."""
revoke: list[str] = Field(
description=(
"Tool names to add to the session's revoked set. Once "
"revoked the coordinator cannot invoke the named tools on "
"subsequent turns without closing and re-opening the session."
),
)
class CoordinatorRestrictResponse(BaseModel):
"""Response body for POST /v1/api/coordinator/{ws_id}/restrict."""
status: str = Field(default="ok")
revoked_tools: list[str] = Field(description="Full post-revocation set of revoked tool names.")
class CoordinatorStopCascadeResponse(BaseModel):
"""Response body for POST /v1/api/coordinator/{ws_id}/stop_cascade."""
status: str = Field(default="ok")
cancelled: list[str] = Field(
default_factory=list,
description="Child ws_ids that accepted the cancel dispatch.",
)
failed: list[str] = Field(
default_factory=list,
description=(
"Child ws_ids whose cancel dispatch returned an error other "
"than an already-gone 404 — the cascade continues on per-"
"child failure so a single unreachable node doesn't abort "
"the whole batch."
),
)
skipped: list[str] = Field(
default_factory=list,
description=(
"Child ws_ids that returned 404 on cancel (already gone). "
"Reported separately from ``failed`` so operators can "
"distinguish already-done from dispatch-broken."
),
)
class ClusterWsDetailResponse(BaseModel):
"""Response body for GET /v1/api/cluster/ws/{ws_id}/detail.
+68
View File
@@ -32,9 +32,14 @@ from turnstone.api.console_schemas import (
CoordinatorInfo,
CoordinatorListResponse,
CoordinatorOpenResponse,
CoordinatorRestrictRequest,
CoordinatorRestrictResponse,
CoordinatorSendRequest,
CoordinatorStopCascadeResponse,
CoordinatorTaskInfo,
CoordinatorTasksResponse,
CoordinatorTrustRequest,
CoordinatorTrustResponse,
CreateChannelUserRequest,
CreateMcpServerRequest,
CreateModelDefinitionRequest,
@@ -1296,6 +1301,64 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
error_codes=[400, 403, 404, 503],
tags=["Coordinator"],
),
EndpointSpec(
"/v1/api/coordinator/{ws_id}/trust",
"POST",
"Toggle trusted-session mode for send_to_workstream",
description=(
"When enabled, ``send_to_workstream`` calls that target a "
"ws_id in the coordinator's own subtree skip the approval "
"prompt. Foreign ws_ids continue to require approval — "
"trust only relaxes the guard for work the orchestrator "
"itself spawned. Every auto-approved send still emits a "
"``coordinator.send.auto_approved`` audit row so the trail "
"isn't lost. Gated on both ``admin.coordinator`` AND "
"``coordinator.trust.send`` so the trust feature is an "
"explicit opt-in capability separate from ordinary "
"coordinator administration."
),
request_model=CoordinatorTrustRequest,
response_model=CoordinatorTrustResponse,
error_codes=[400, 403, 404, 503],
tags=["Coordinator"],
),
EndpointSpec(
"/v1/api/coordinator/{ws_id}/restrict",
"POST",
"Revoke tool access on a live coordinator session",
description=(
"Adds the named tools to the coordinator session's revoked set "
"without closing the session. The model can keep working on "
"whatever is already in flight but cannot invoke the revoked "
"tools again. Idempotent and additive — calling twice with "
"disjoint lists unions them. Revocations do not survive a "
"session close / reopen; operators opt in per session. Writes "
"``coordinator.restricted`` with the revocation delta and the "
"full post-state."
),
request_model=CoordinatorRestrictRequest,
response_model=CoordinatorRestrictResponse,
error_codes=[400, 403, 404, 503],
tags=["Coordinator"],
),
EndpointSpec(
"/v1/api/coordinator/{ws_id}/stop_cascade",
"POST",
"Cancel the coordinator and every direct child",
description=(
"Cancels the coordinator's in-flight generation AND dispatches "
"``cancel_workstream`` through the routing proxy for every "
"direct child in the in-memory registry. Grandchildren are "
"not touched directly — they sit behind their parent's cancel, "
"which propagates via the child's SSE stream. Returns the "
"per-child disposition (``cancelled`` / ``failed``) so the UI "
"can show which children responded. Writes "
"``coordinator.stopped_cascade`` with the two lists."
),
response_model=CoordinatorStopCascadeResponse,
error_codes=[400, 403, 404, 503],
tags=["Coordinator"],
),
EndpointSpec(
"/v1/api/cluster/ws/{ws_id}/detail",
"GET",
@@ -1369,9 +1432,14 @@ _ALL_MODELS: list[type[BaseModel]] = [
CoordinatorInfo,
CoordinatorListResponse,
CoordinatorOpenResponse,
CoordinatorRestrictRequest,
CoordinatorRestrictResponse,
CoordinatorSendRequest,
CoordinatorStopCascadeResponse,
CoordinatorTaskInfo,
CoordinatorTasksResponse,
CoordinatorTrustRequest,
CoordinatorTrustResponse,
CreateScheduleRequest,
UpdateScheduleRequest,
ScheduleInfo,
+19
View File
@@ -946,6 +946,25 @@ class CoordinatorManager:
with self._children_lock:
return self._child_to_coord.get(child_ws_id)
def children_snapshot(self, coord_ws_id: str) -> list[str]:
"""Return a snapshot of the coordinator's direct child ws_ids.
Used by ``stop_cascade`` to iterate children without holding the
registry lock during the per-child HTTP dispatch. A mutation
racing with the snapshot (child spawned mid-cascade) either
lands before the snapshot and gets cancelled, or lands after
and is out of scope for this batch both outcomes are safe.
Returns an empty list for unknown coordinators.
"""
with self._children_lock:
child_set = self._children.get(coord_ws_id)
return list(child_set) if child_set else []
def register_children(self, coord_ws_id: str, child_ws_ids: Iterable[str]) -> None:
"""Merge ``child_ws_ids`` into the coordinator's child set. Idempotent."""
with self._children_lock:
self._merge_child_ids_locked(coord_ws_id, child_ws_ids)
def _pop_coord_registry_locked(self, coord_ws_id: str) -> None:
"""Remove a coordinator's forward set + reverse-index entries.
+40 -4
View File
@@ -328,6 +328,12 @@ class CoordinatorClient:
node forgets to enforce ownership. Read ops use the same gate
inline (see ``inspect`` / ``wait_for_workstream``).
The child must match BOTH the coord's ws_id in ``parent_ws_id``
AND the coord's owner in ``user_id`` — a corrupted or
cross-tenant ``parent_ws_id`` alone is not enough, so the
trusted-send auto-approval path can't be fooled into sending
to a foreign-tenant workstream.
404-shape on miss matches the shape inspect() uses to avoid
being an existence oracle.
"""
@@ -342,7 +348,9 @@ class CoordinatorClient:
return False
if row is None:
return False
return row.get("parent_ws_id") == self._coord_ws_id
if row.get("parent_ws_id") != self._coord_ws_id:
return False
return bool(row.get("user_id")) and row.get("user_id") == self._user_id
# -- model-invoked mutating ops (HTTP) ---------------------------------
@@ -379,6 +387,25 @@ class CoordinatorClient:
return {"error": f"workstream not in coordinator subtree: {ws_id}", "status": 404}
return self._post("send", {"ws_id": ws_id, "message": message})
def emit_audit(self, action: str, detail: dict[str, Any]) -> None:
"""Record an audit row attributed to this coordinator session.
Uses the client's own ``storage``, ``user_id``, and
``coord_ws_id`` so callers don't need to reach into the client's
private attributes. ``resource_type`` is always ``"coordinator"``
and ``resource_id`` is the coord's ws_id.
"""
from turnstone.core.audit import record_audit
record_audit(
self._storage,
user_id=self._user_id,
action=action,
resource_type="coordinator",
resource_id=self._coord_ws_id,
detail=detail,
)
def close_workstream(self, ws_id: str, reason: str = "") -> dict[str, Any]:
if not self._is_own_subtree(ws_id):
return {"error": f"workstream not in coordinator subtree: {ws_id}", "status": 404}
@@ -893,12 +920,19 @@ class CoordinatorClient:
*,
category: str | None = None,
tag: str | None = None,
scan_status: str | None = None,
risk_level: str | None = None,
enabled_only: bool = False,
limit: int = 100,
) -> dict[str, Any]:
"""Return ``{"skills": [...], "truncated": bool}``.
Coordinator-visible skills only: the storage filter narrows to
``kind IN ('coordinator', 'any')``. Skills tagged
``interactive`` are hidden from the coordinator's
``list_skills`` tool (they're meant for child workstreams, not
the orchestrator), while ``any``-tagged skills show up on both
sides for backwards compatibility with pre-tagging catalogs.
Filters pushed into SQL via ``list_skills_filtered`` no per-row
lookups. ``tag`` matches when the value appears in the
JSON-array ``tags`` column (quote-bracketed substring).
@@ -910,7 +944,8 @@ class CoordinatorClient:
rows = self._storage.list_skills_filtered(
category=category,
tag=tag,
scan_status=scan_status,
risk_level=risk_level,
kinds=["coordinator", "any"],
enabled_only=enabled_only,
limit=page_size + 1, # +1 to detect truncation
)
@@ -948,8 +983,9 @@ class CoordinatorClient:
"description": r.get("description") or "",
"model": r.get("model") or "",
"enabled": bool(r.get("enabled")),
"scan_status": r.get("scan_status") or "",
"risk_level": r.get("risk_level") or "",
"activation": r.get("activation") or "",
"kind": r["kind"],
"allowed_tools": allowed_tools,
}
)
File diff suppressed because it is too large Load Diff
+18 -18
View File
@@ -710,7 +710,7 @@ function _renderGovSkills(items) {
'<span class="scope-badge">' + escapeHtml(t.category) + "</span>";
// Build risk column content with tooltip
var riskCell = "";
if (t.scan_status) {
if (t.risk_level) {
var scanClass =
{
safe: "scope-scan-safe",
@@ -718,7 +718,7 @@ function _renderGovSkills(items) {
medium: "scope-scan-medium",
high: "scope-scan-high",
critical: "scope-scan-critical",
}[t.scan_status] || "";
}[t.risk_level] || "";
var scanIcon =
{
safe: "\u2713 ",
@@ -726,7 +726,7 @@ function _renderGovSkills(items) {
medium: "\u25B2 ",
high: "\u25C6 ",
critical: "\u26A0 ",
}[t.scan_status] || "";
}[t.risk_level] || "";
var tipParts = [];
try {
var report = JSON.parse(t.scan_report || "{}");
@@ -743,17 +743,17 @@ function _renderGovSkills(items) {
}
}
} catch (e) {}
var tipText = tipParts.length ? tipParts.join("\n") : t.scan_status;
var tipText = tipParts.length ? tipParts.join("\n") : t.risk_level;
riskCell =
'<span class="scope-badge ' +
scanClass +
'" tabindex="0" role="button" aria-label="Risk: ' +
escapeHtml(t.scan_status) +
escapeHtml(t.risk_level) +
(tipParts.length ? ". " + escapeHtml(tipParts.join(". ")) : "") +
'" title="' +
escapeHtml(tipText) +
'">' +
escapeHtml(scanIcon + t.scan_status) +
escapeHtml(scanIcon + t.risk_level) +
"</span>";
} else {
riskCell =
@@ -1126,7 +1126,7 @@ function showEditTemplateModal(tmplId) {
// Scan report section
var scanSection = document.getElementById("etm-scan-section");
if (scanSection) {
if (tmpl.scan_status) {
if (tmpl.risk_level) {
scanSection.style.display = "";
var scanClassMap = {
safe: "scope-scan-safe",
@@ -1141,9 +1141,9 @@ function showEditTemplateModal(tmplId) {
} catch (e) {}
var scanHtml =
'<span class="scope-badge ' +
(scanClassMap[tmpl.scan_status] || "") +
(scanClassMap[tmpl.risk_level] || "") +
'">' +
escapeHtml(tmpl.scan_status) +
escapeHtml(tmpl.risk_level) +
"</span>";
if (report.composite != null) {
scanHtml +=
@@ -1193,11 +1193,11 @@ function showEditTemplateModal(tmplId) {
return r.json();
})
.then(function (data) {
showToast("Scan complete: " + (data.scan_status || "unknown"));
showToast("Scan complete: " + (data.risk_level || "unknown"));
// Refresh the modal by re-loading skills and re-opening
loadGovSkills();
// Update current tmpl in memory
tmpl.scan_status = data.scan_status;
tmpl.risk_level = data.risk_level;
tmpl.scan_report = data.scan_report;
tmpl.scan_version = data.scan_version;
showEditTemplateModal(tmplId);
@@ -2262,7 +2262,7 @@ function _renderSkillDiscoverResults() {
var actionHtml;
if (s.installed) {
var scanBadgeHtml = "";
if (s.scan_status) {
if (s.risk_level) {
var scanCls =
{
safe: "scope-scan-safe",
@@ -2270,12 +2270,12 @@ function _renderSkillDiscoverResults() {
medium: "scope-scan-medium",
high: "scope-scan-high",
critical: "scope-scan-critical",
}[s.scan_status] || "";
}[s.risk_level] || "";
scanBadgeHtml =
'<span class="scope-badge ' +
scanCls +
'" style="margin-right:4px">' +
escapeHtml(s.scan_status) +
escapeHtml(s.risk_level) +
"</span>";
}
actionHtml =
@@ -2385,13 +2385,13 @@ function installDiscoveredSkill(skill) {
})
.then(function (data) {
var first = (data.installed && data.installed[0]) || {};
var tierMsg = first.scan_status ? " [" + first.scan_status + "]" : "";
var tierMsg = first.risk_level ? " [" + first.risk_level + "]" : "";
showToast("Skill installed: " + (skill.name || skill.id) + tierMsg);
// Mark as installed in results with scan data
for (var j = 0; j < _skillDiscoverResults.length; j++) {
if (_skillDiscoverResults[j].id === skill.id) {
_skillDiscoverResults[j].installed = true;
_skillDiscoverResults[j].scan_status = first.scan_status || "";
_skillDiscoverResults[j].risk_level = first.risk_level || "";
break;
}
}
@@ -2462,8 +2462,8 @@ function submitGitHubImport() {
var msg;
if (count === 1 && !skipCount) {
var name = data.installed[0].name || "";
var tierMsg = data.installed[0].scan_status
? " [" + data.installed[0].scan_status + "]"
var tierMsg = data.installed[0].risk_level
? " [" + data.installed[0].risk_level + "]"
: "";
msg = "Skill installed: " + name + tierMsg;
} else if (count === 0 && skipCount) {
+88 -2
View File
@@ -8,7 +8,11 @@ Action-name conventions (non-exhaustive — grep
coordinator.* console-side coordinator lifecycle
(``coordinator.create`` / ``.close`` /
``.cancel``).
``.cancel``) plus governance sub-prefixes
(``coordinator.trust.toggled``,
``coordinator.send.auto_approved``,
``coordinator.restricted``,
``coordinator.stopped_cascade``).
route.* multi-node routing proxy hops
(``route.workstream.create`` / ``.send`` /
@@ -38,10 +42,12 @@ inventing a synonym (e.g. ``mcp_server.refresh`` rather than
from __future__ import annotations
import json
import re
import uuid
from typing import TYPE_CHECKING, Any
from turnstone.core.log import get_logger
from turnstone.core.output_guard import redact_credentials
if TYPE_CHECKING:
from turnstone.core.storage._protocol import StorageBackend
@@ -49,6 +55,72 @@ if TYPE_CHECKING:
log = get_logger(__name__)
# C0 control chars except tab/newline, plus DEL. Stripped after the
# credential scrub so any downstream exporter that pulls ``detail``
# and renders raw strings cannot re-surface CR/LF injection from
# model-controlled content — the audit row itself is already safe
# because ``json.dumps`` escapes the bytes, but JSON-loaded consumers
# often print the inner strings directly.
_AUDIT_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
def _scrub_string(value: str) -> str:
"""Redact credentials and strip stray control chars from a single string."""
return _AUDIT_CONTROL_CHAR_RE.sub(" ", redact_credentials(value))
def _has_any_string(value: Any) -> bool:
"""Return True if ``value`` or any nested value is a string.
Fast-path guard for :func:`_redact_detail`: audit details that carry
only bools, ints, floats, and None (common for ``{"spawned": 5,
"ok": True}`` shapes) skip the recursive scrub entirely. The
traversal mirrors the shape ``_redact_detail`` recurses into so a
False return actually means there's nothing to scrub.
"""
if isinstance(value, str):
return True
if isinstance(value, dict):
return any(isinstance(k, str) for k in value) or any(
_has_any_string(v) for v in value.values()
)
if isinstance(value, (list, tuple, set, frozenset)):
return any(_has_any_string(v) for v in value)
return False
def _redact_detail(value: Any) -> Any:
"""Recursively scrub strings inside ``value`` before audit persistence.
Walks dicts, lists, tuples, sets, and frozensets; applies
:func:`redact_credentials` + a control-char scrub to every string,
including dict keys. Non-string scalars pass through unchanged.
Sets and frozensets are converted to sorted lists JSON has no
native set type, and storing them as lists is what ``json.dumps``
would do for any downstream reader anyway.
"""
if isinstance(value, str):
return _scrub_string(value)
if isinstance(value, dict):
return {
(_scrub_string(k) if isinstance(k, str) else k): _redact_detail(v)
for k, v in value.items()
}
if isinstance(value, list):
return [_redact_detail(v) for v in value]
if isinstance(value, tuple):
return tuple(_redact_detail(v) for v in value)
if isinstance(value, (set, frozenset)):
# sorted() needs uniform item types; cast to str as a last
# resort so a mixed set still produces a stable output.
scrubbed = [_redact_detail(v) for v in value]
try:
return sorted(scrubbed)
except TypeError:
return sorted(scrubbed, key=repr)
return value
def record_audit(
storage: StorageBackend,
user_id: str,
@@ -57,8 +129,22 @@ def record_audit(
resource_id: str = "",
detail: dict[str, Any] | None = None,
ip_address: str = "",
*,
raw_detail: bool = False,
) -> None:
"""Record an audit event. Silently logs on failure (never raises)."""
"""Record an audit event. Silently logs on failure (never raises).
By default every string inside ``detail`` is routed through
:func:`redact_credentials` before the row is persisted, so
model-controlled text (task titles, send/spawn messages, close
reasons) cannot leak credentials into the audit table. Callers
that need to preserve the raw payload e.g. an operator-supplied
field an admin explicitly asked to keep intact for an
investigation can pass ``raw_detail=True`` to opt out. Every
modelaudit path MUST leave ``raw_detail`` at its default.
"""
if detail and not raw_detail and _has_any_string(detail):
detail = _redact_detail(detail)
try:
storage.record_audit_event(
event_id=uuid.uuid4().hex,
+43 -3
View File
@@ -121,18 +121,31 @@ def _permissions_to_scopes(permissions: set[str]) -> frozenset[str]:
return frozenset(scopes)
def require_permission(request: Request, permission: str) -> JSONResponse | None:
def require_permission(
request: Request,
permission: str,
*,
allow_service_bypass: bool = True,
) -> JSONResponse | None:
"""Return a 403 JSONResponse if the user lacks *permission*, else None.
Call from admin handlers after the middleware scope check passes.
Service tokens (scope ``service``) bypass permission checks.
By default service tokens (scope ``service``) bypass the check so
internal cluster callers don't need per-permission grants.
Set ``allow_service_bypass=False`` on capability-escalating permissions
(e.g. ``coordinator.trust.send``) where a service token even one
whose ``user_id`` happens to match a coord owner should still
need an explicit permission grant. The service scope is the
cluster-side trust boundary; capability-escalation gates have to
be held explicitly regardless of that trust.
"""
from starlette.responses import JSONResponse
auth_result: AuthResult | None = getattr(getattr(request, "state", None), "auth_result", None)
if auth_result is None:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
if auth_result.has_scope("service"):
if allow_service_bypass and auth_result.has_scope("service"):
return None
if auth_result.has_permission(permission):
return None
@@ -227,6 +240,33 @@ class AuthResult:
return permission in self.permissions
# ---------------------------------------------------------------------------
# Tenant-filter sentinel
# ---------------------------------------------------------------------------
class _DenyFilter:
"""Sentinel: caller must short-circuit with an empty-shape response.
Returned from the ``_effective_user_filter`` helpers on both the
console and node sides when a non-admin, non-service caller
carries a blank ``sub`` claim. Callers compare with ``is``; a
fall-through to storage with ``user_id=None`` would be a service
escape, and ``user_id=""`` would match legacy orphan rows. The
sentinel lives on :mod:`turnstone.core.auth` so both modules share
one identity importing from a different module would silently
fail the ``is`` check.
"""
__slots__ = ()
def __repr__(self) -> str: # pragma: no cover — debug only
return "<DENY_EMPTY_SUB>"
DENY_EMPTY_SUB: _DenyFilter = _DenyFilter()
# ---------------------------------------------------------------------------
# Token generation and hashing
# ---------------------------------------------------------------------------
+7 -1
View File
@@ -1097,8 +1097,13 @@ class MCPClientManager:
token_estimate=len(content) // 4,
)
else:
# Create new MCP-sourced template
# Create new MCP-sourced template. Thread the prompt's
# upstream description through so the row satisfies the
# non-empty-description invariant; fall back to a
# synthetic marker when the MCP server didn't provide
# one.
template_id = str(uuid.uuid4())
template_description = desc.strip() or f"MCP prompt {name} from {server}"
storage.create_prompt_template(
template_id=template_id,
name=name,
@@ -1111,6 +1116,7 @@ class MCPClientManager:
origin="mcp",
mcp_server=server,
readonly=True,
description=template_description,
activation="named",
token_estimate=len(content) // 4,
)
+81 -13
View File
@@ -110,7 +110,7 @@ from turnstone.ui.colors import DIM, GRAY, GREEN, RED, RESET, YELLOW, bold, cyan
log = get_logger(__name__)
if TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Iterable, Iterator
from turnstone.core.config_store import ConfigStore
from turnstone.core.healthcheck import BackendHealthTracker, HealthTrackerRegistry
@@ -343,6 +343,9 @@ class ChatSession:
self._kind = kind
self._parent_ws_id = parent_ws_id if parent_ws_id else None
self._coord_client: Any = coord_client
self._trust_send: bool = False
self._revoked_tools: frozenset[str] = frozenset()
self._governance_lock = threading.Lock()
self._registry = registry
self._model_alias = model_alias
self._health_registry = health_registry
@@ -667,15 +670,15 @@ class ChatSession:
self._skill_resources = self._load_skill_resources(
skill_data.get("template_id", "")
)
if skill_data.get("scan_status") in ("high", "critical"):
scan_tier = skill_data["scan_status"]
if skill_data.get("risk_level") in ("high", "critical"):
risk_tier = skill_data["risk_level"]
log.warning(
"skill.high_risk_loaded",
skill=skill_data["name"],
scan_status=scan_tier,
risk_level=risk_tier,
)
self.ui.on_info(
f"⚠ Skill '{skill_data['name']}' has scan status: {scan_tier}. "
f"⚠ Skill '{skill_data['name']}' has risk level: {risk_tier}. "
f"Review scan report in admin panel before enabling in production."
)
else:
@@ -3874,6 +3877,24 @@ class ChatSession:
),
}
# Short-circuit revoked tools before preparer dispatch so the
# model sees an unambiguous "revoked" error rather than a
# preparer-level validation message.
if func_name in self._revoked_tools:
return {
"call_id": call_id,
"func_name": func_name,
"header": f"\u2717 {func_name}: revoked",
"preview": "",
"needs_approval": False,
"error": (
f"Tool '{func_name}' has been revoked on this "
"coordinator session by an operator. The session "
"is still live but this tool is no longer "
"available — continue with the tools you have."
),
}
preparers = {
"bash": self._prepare_bash,
"read_file": self._prepare_read_file,
@@ -4839,6 +4860,29 @@ class ChatSession:
"error": f"Error: {msg}",
}
# -- Coordinator governance (console-driven toggles) -----------------
#
# Writes hold ``_governance_lock``; reads are lock-free. ``_trust_send``
# is a single bool and ``_revoked_tools`` is a frozenset swapped by
# reference on each write, so neither read can tear.
def set_trust_send(self, value: bool) -> None:
with self._governance_lock:
self._trust_send = bool(value)
def get_trust_send(self) -> bool:
return self._trust_send
def revoke_tools(self, names: Iterable[str]) -> frozenset[str]:
"""Union ``names`` into the revoked-tools set; return the post-state."""
additions = frozenset(names)
with self._governance_lock:
self._revoked_tools = self._revoked_tools | additions
return self._revoked_tools
def get_revoked_tools(self) -> frozenset[str]:
return self._revoked_tools
@staticmethod
def _coord_str_arg(args: dict[str, Any], key: str, default: str = "") -> str:
"""Return ``args[key]`` if it's a string, else ``default``.
@@ -5011,20 +5055,44 @@ class ChatSession:
preview_line = first_line[:120] + ("..." if len(first_line) > 120 else "")
header = f"\u2699 send_to_workstream {ws_id}: {preview_line}"
preview_body = f"{DIM}{textwrap.indent(message, ' ')}{RESET}"
# Trust only relaxes own-subtree sends; foreign ws_ids always
# prompt for approval even under trust.
needs_approval = True
trust_auto_approved = False
if self._trust_send and self._coord_client._is_own_subtree(ws_id):
needs_approval = False
trust_auto_approved = True
return {
"call_id": call_id,
"func_name": "send_to_workstream",
"header": header,
"preview": preview_body,
"needs_approval": True,
"needs_approval": needs_approval,
"approval_label": "send_to_workstream",
"execute": self._exec_send_to_workstream,
"ws_id": ws_id,
"message": message,
"trust_auto_approved": trust_auto_approved,
}
def _exec_send_to_workstream(self, item: dict[str, Any]) -> tuple[str, str]:
call_id = item["call_id"]
if item.get("trust_auto_approved"):
# Audit before dispatch so a downstream failure doesn't drop the trail.
try:
message = item.get("message") or ""
preview_line = message.splitlines()[0] if message else ""
self._coord_client.emit_audit(
"coordinator.send.auto_approved",
{
"src": "coordinator",
"trust": True,
"ws_id": item["ws_id"],
"message_preview": preview_line[:120],
},
)
except Exception:
log.debug("coord.trust_send.audit_failed", exc_info=True)
try:
result = self._coord_client.send(item["ws_id"], item["message"])
except Exception as e:
@@ -5342,7 +5410,7 @@ class ChatSession:
return self._coord_tool_error(call_id, "list_skills", "coordinator client unavailable")
category = self._coord_str_arg(args, "category").strip() or None
tag = self._coord_str_arg(args, "tag").strip() or None
scan_status = self._coord_str_arg(args, "scan_status").strip() or None
risk_level = self._coord_str_arg(args, "risk_level").strip() or None
enabled_only = self._coord_bool_arg(args, "enabled_only")
try:
limit = int(args.get("limit") or 100)
@@ -5354,8 +5422,8 @@ class ChatSession:
header_bits.append(f"category={category}")
if tag:
header_bits.append(f"tag={tag}")
if scan_status:
header_bits.append(f"scan_status={scan_status}")
if risk_level:
header_bits.append(f"risk_level={risk_level}")
if enabled_only:
header_bits.append("enabled_only=true")
return {
@@ -5367,7 +5435,7 @@ class ChatSession:
"execute": self._exec_list_skills,
"category": category,
"tag": tag,
"scan_status": scan_status,
"risk_level": risk_level,
"enabled_only": enabled_only,
"limit": limit,
}
@@ -5378,7 +5446,7 @@ class ChatSession:
result = self._coord_client.list_skills(
category=item["category"],
tag=item["tag"],
scan_status=item["scan_status"],
risk_level=item["risk_level"],
enabled_only=item["enabled_only"],
limit=item["limit"],
)
@@ -6083,7 +6151,7 @@ class ChatSession:
self.set_skill(name)
desc = skill_data.get("description", "")
scan = skill_data.get("scan_status", "")
scan = skill_data.get("risk_level", "")
parts = [f"Loaded skill '{name}'"]
if desc:
parts.append(f"Description: {desc}")
@@ -6152,7 +6220,7 @@ class ChatSession:
name_val = r.get("name", "")
desc_val = r.get("description", "")
cat_val = r.get("category", "")
scan_val = r.get("scan_status", "")
scan_val = r.get("risk_level", "")
activation = r.get("activation", "named")
line = f"- {name_val}"
if cat_val:
+26
View File
@@ -0,0 +1,26 @@
"""Skill classifier shared across storage, API, and handler layers.
StrEnum so members are drop-in ``str`` replacements for the DB column,
JSON payloads, and existing ``==`` comparisons. Mirrors the shape of
:class:`turnstone.core.workstream.WorkstreamKind`: narrow internal
annotations to this type; wide boundaries (DB row, HTTP body) stay
``str`` and parse via ``SkillKind(raw)`` at the edge.
"""
from __future__ import annotations
import enum
class SkillKind(enum.StrEnum):
"""Which ``list_skills`` surface a skill row is visible on.
``INTERACTIVE`` only the interactive-session activation path sees
this row. ``COORDINATOR`` only the coordinator's ``list_skills``
tool sees this row. ``ANY`` both sides (default for legacy rows
predating the classifier).
"""
INTERACTIVE = "interactive"
COORDINATOR = "coordinator"
ANY = "any"
+16 -11
View File
@@ -2302,6 +2302,7 @@ class PostgreSQLBackend:
skill_license: str = "",
compatibility: str = "",
priority: int = 0,
kind: str = "any",
) -> None:
# Sync is_default from activation when activation is explicitly set
if activation == "default":
@@ -2309,7 +2310,7 @@ class PostgreSQLBackend:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
# Scan skill content for risk signals
scan_status, scan_report, scan_version = _scan_skill_content(content, allowed_tools)
risk_level, scan_report, scan_version = _scan_skill_content(content, allowed_tools)
with self._conn() as conn:
conn.execute(
@@ -2336,7 +2337,8 @@ class PostgreSQLBackend:
"allowed_tools": allowed_tools,
"license": skill_license,
"compatibility": compatibility,
"scan_status": scan_status,
"kind": kind,
"risk_level": risk_level,
"scan_report": scan_report,
"scan_version": scan_version,
"model": model,
@@ -2453,10 +2455,10 @@ class PostgreSQLBackend:
if allowed_tools is None:
allowed_tools = existing.get("allowed_tools", "[]")
if content is not None:
scan_status, scan_report, scan_version = _scan_skill_content(
risk_level, scan_report, scan_version = _scan_skill_content(
content, allowed_tools or "[]"
)
fields["scan_status"] = scan_status
fields["risk_level"] = risk_level
fields["scan_report"] = scan_report
fields["scan_version"] = scan_version
with self._conn() as conn:
@@ -2503,7 +2505,8 @@ class PostgreSQLBackend:
*,
category: str | None = None,
tag: str | None = None,
scan_status: str | None = None,
risk_level: str | None = None,
kinds: list[str] | None = None,
enabled_only: bool = False,
limit: int = 100,
) -> list[dict[str, Any]]:
@@ -2513,8 +2516,10 @@ class PostgreSQLBackend:
)
if category:
q = q.where(prompt_templates.c.category == category)
if scan_status:
q = q.where(prompt_templates.c.scan_status == scan_status)
if risk_level:
q = q.where(prompt_templates.c.risk_level == risk_level)
if kinds:
q = q.where(prompt_templates.c.kind.in_(kinds))
if enabled_only:
q = q.where(prompt_templates.c.enabled == 1)
if tag:
@@ -2563,14 +2568,14 @@ class PostgreSQLBackend:
sa.select(
prompt_templates.c.source_url,
prompt_templates.c.template_id,
prompt_templates.c.scan_status,
prompt_templates.c.risk_level,
).where(prompt_templates.c.source_url != "")
).fetchall()
return [
{
"source_url": r[0],
"template_id": r[1],
"scan_status": r[2] or "",
"source_url": r._mapping["source_url"],
"template_id": r._mapping["template_id"],
"risk_level": r._mapping["risk_level"] or "",
}
for r in rows
]
+36 -3
View File
@@ -14,6 +14,30 @@ class StorageBackend(Protocol):
Provides workstream management, conversation persistence, structured
memories, and full-text search.
Cross-cutting contracts:
**Tenancy filter on aggregates.** Every list / count / aggregate
method that can span rows from more than one ``user_id`` MUST
accept ``user_id: str | None = None`` as a keyword-only argument
and push ``WHERE user_id = :user_id`` into SQL when a uid is
supplied. ``None`` is reserved for service-scoped callers that
legitimately need cluster-wide visibility. Calling endpoints
MUST resolve the effective filter (typically via
``_effective_user_filter`` in ``turnstone.console.server``) and
pass it through never post-filter in Python; handler-side
filtering lets orphan rows, forged ``parent_ws_id`` references,
and empty-sub tokens leak cross-tenant counts.
**Row access via ``_mapping``.** List-style methods return
SQLAlchemy ``Row`` objects; callers MUST access columns through
``row._mapping[<col>]`` (or ``.get("<col>")`` on the mapping).
Positional indexing is not a supported access pattern a SELECT
reorder or a new trailing column silently corrupts the
projection. Test doubles for list-style storage methods MUST
expose a ``_mapping`` attribute matching the production ``Row``
shape; ``turnstone.testing.row_contract.assert_row_like`` is the
canonical check for fixtures and fakes.
"""
# -- Core conversation operations ------------------------------------------
@@ -948,6 +972,7 @@ class StorageBackend(Protocol):
skill_license: str = "",
compatibility: str = "",
priority: int = 0,
kind: str = "any",
) -> None:
"""Create a prompt template (skill)."""
...
@@ -1001,11 +1026,12 @@ class StorageBackend(Protocol):
*,
category: str | None = None,
tag: str | None = None,
scan_status: str | None = None,
risk_level: str | None = None,
kinds: list[str] | None = None,
enabled_only: bool = False,
limit: int = 100,
) -> list[dict[str, Any]]:
"""Return prompt templates filtered by optional category/tag/scan_status,
"""Return prompt templates filtered by optional category/tag/risk_level/kinds,
ordered by priority then name.
Filters are pushed into SQL no per-row Python filter loops. The
@@ -1014,6 +1040,13 @@ class StorageBackend(Protocol):
``%"<tag>"%``). Cheap and correct for tag values without quote
characters; upgrade to true JSON-array containment if the
convention ever needs to expand.
``kinds`` (when non-empty) narrows the result to rows whose
``kind`` column is in the supplied list. Coordinator-side
callers typically pass ``["coordinator", "any"]`` and
interactive-side callers pass ``["interactive", "any"]`` so
skills tagged ``any`` remain visible to both. ``None`` means
no kind filter all rows regardless of kind.
"""
...
@@ -1026,7 +1059,7 @@ class StorageBackend(Protocol):
...
def list_installed_skill_urls(self) -> list[dict[str, str]]:
"""Return [{source_url, template_id, scan_status}] for skills with non-empty source_url."""
"""Return [{source_url, template_id, risk_level}] for skills with non-empty source_url."""
...
# -- Skill resources -------------------------------------------------------
+4 -1
View File
@@ -376,7 +376,10 @@ prompt_templates = sa.Table(
sa.Column("allowed_tools", sa.Text, nullable=False, server_default="[]"), # JSON array
sa.Column("license", sa.Text, nullable=False, server_default=""),
sa.Column("compatibility", sa.Text, nullable=False, server_default=""),
sa.Column("scan_status", sa.Text, nullable=False, server_default=""),
# interactive / coordinator / any — governs which list_skills call
# sees this row. Defaults to "any" so existing schemas keep working.
sa.Column("kind", sa.Text, nullable=False, server_default="any"),
sa.Column("risk_level", sa.Text, nullable=False, server_default=""),
sa.Column("scan_report", sa.Text, nullable=False, server_default="{}"), # JSON
sa.Column("installed_at", sa.Text, nullable=False, server_default=""),
sa.Column("installed_by", sa.Text, nullable=False, server_default=""),
+16 -11
View File
@@ -2402,6 +2402,7 @@ class SQLiteBackend:
skill_license: str = "",
compatibility: str = "",
priority: int = 0,
kind: str = "any",
) -> None:
# Sync is_default from activation when activation is explicitly set
if activation == "default":
@@ -2409,7 +2410,7 @@ class SQLiteBackend:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
# Scan skill content for risk signals
scan_status, scan_report, scan_version = _scan_skill_content(content, allowed_tools)
risk_level, scan_report, scan_version = _scan_skill_content(content, allowed_tools)
with self._conn() as conn:
conn.execute(
@@ -2436,7 +2437,8 @@ class SQLiteBackend:
"allowed_tools": allowed_tools,
"license": skill_license,
"compatibility": compatibility,
"scan_status": scan_status,
"kind": kind,
"risk_level": risk_level,
"scan_report": scan_report,
"scan_version": scan_version,
"model": model,
@@ -2553,10 +2555,10 @@ class SQLiteBackend:
if allowed_tools is None:
allowed_tools = existing.get("allowed_tools", "[]")
if content is not None:
scan_status, scan_report, scan_version = _scan_skill_content(
risk_level, scan_report, scan_version = _scan_skill_content(
content, allowed_tools or "[]"
)
fields["scan_status"] = scan_status
fields["risk_level"] = risk_level
fields["scan_report"] = scan_report
fields["scan_version"] = scan_version
with self._conn() as conn:
@@ -2603,7 +2605,8 @@ class SQLiteBackend:
*,
category: str | None = None,
tag: str | None = None,
scan_status: str | None = None,
risk_level: str | None = None,
kinds: list[str] | None = None,
enabled_only: bool = False,
limit: int = 100,
) -> list[dict[str, Any]]:
@@ -2613,8 +2616,10 @@ class SQLiteBackend:
)
if category:
q = q.where(prompt_templates.c.category == category)
if scan_status:
q = q.where(prompt_templates.c.scan_status == scan_status)
if risk_level:
q = q.where(prompt_templates.c.risk_level == risk_level)
if kinds:
q = q.where(prompt_templates.c.kind.in_(kinds))
if enabled_only:
q = q.where(prompt_templates.c.enabled == 1)
if tag:
@@ -2660,14 +2665,14 @@ class SQLiteBackend:
sa.select(
prompt_templates.c.source_url,
prompt_templates.c.template_id,
prompt_templates.c.scan_status,
prompt_templates.c.risk_level,
).where(prompt_templates.c.source_url != "")
).fetchall()
return [
{
"source_url": r[0],
"template_id": r[1],
"scan_status": r[2] or "",
"source_url": r._mapping["source_url"],
"template_id": r._mapping["template_id"],
"risk_level": r._mapping["risk_level"] or "",
}
for r in rows
]
+3 -2
View File
@@ -112,9 +112,10 @@ SKILL_MUTABLE = frozenset(
"license",
"compatibility",
"scan_version",
"scan_status",
"risk_level",
"scan_report",
"priority",
"kind",
}
)
STRUCTURED_MEMORY_MUTABLE = frozenset({"content", "description", "type"})
@@ -204,7 +205,7 @@ VERDICT_MUTABLE = frozenset(
def scan_skill_content(content: str, allowed_tools: str) -> tuple[str, str, str]:
"""Run the skill scanner and return ``(scan_status, scan_report_json, scanner_version)``.
"""Run the skill scanner and return ``(risk_level, scan_report_json, scanner_version)``.
Uses a lazy import to avoid circular dependencies. Silently returns
empty results on import or scan errors so skill creation is never
@@ -0,0 +1,72 @@
"""Add ``coordinator.trust.send`` permission to the builtin-admin role.
Gates the trusted-session mode on ``POST /v1/api/coordinator/{ws_id}/trust``:
when set on a coordinator session, sends to the coordinator's own
children skip the approval prompt. Foreign ws_ids still go through
approval regardless the permission only unlocks the toggle, it does
not widen the scope of what trust applies to.
Append to ``builtin-admin`` so admins can opt in without hand-editing
role rows. Both upgrade and downgrade anchor the permission token to
the three positions it can occupy in a comma-delimited list (sole /
start / mid / end) so a future permission sharing the prefix
(e.g. ``coordinator.trust.send.v2``) cannot collide with the base
token in either direction.
Revision ID: 042
Revises: 041
Create Date: 2026-04-18
"""
import sqlalchemy as sa
from alembic import op
revision = "042"
down_revision = "041"
branch_labels = None
depends_on = None
_PERM = "coordinator.trust.send"
def upgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"UPDATE roles SET permissions = permissions || :sep "
"WHERE role_id = 'builtin-admin' "
"AND permissions NOT LIKE :mid "
"AND permissions NOT LIKE :start "
"AND permissions NOT LIKE :end "
"AND permissions <> :sole"
),
{
"sep": "," + _PERM,
"mid": "%," + _PERM + ",%",
"start": _PERM + ",%",
"end": "%," + _PERM,
"sole": _PERM,
},
)
def downgrade() -> None:
conn = op.get_bind()
# Parse, filter, and rejoin in Python to avoid the SQL-REPLACE
# prefix-collision hazard (the upgrade anchors against it; the
# downgrade must mirror that treatment).
rows = conn.execute(
sa.text("SELECT role_id, permissions FROM roles WHERE role_id = 'builtin-admin'")
).fetchall()
for row in rows:
mapping = row._mapping
raw = mapping.get("permissions") or ""
current = [p for p in raw.split(",") if p]
filtered = [p for p in current if p != _PERM]
if filtered == current:
continue
conn.execute(
sa.text("UPDATE roles SET permissions = :perms WHERE role_id = :role_id"),
{"perms": ",".join(filtered), "role_id": mapping.get("role_id")},
)
@@ -0,0 +1,48 @@
"""Backfill empty ``prompt_templates.description`` rows.
The admin create/update surface now rejects an empty ``description``
(skills with no description fail ``list_skills`` discoverability).
Legacy rows may carry ``description=''`` this migration backfills
them with ``"Skill: <name>"`` so the new admin-layer constraint does
not strand rows an operator inherited.
Placeholder (not rejection) is deliberate: an upgrade on a busy
cluster should not be blocked by empty descriptions on skills the
operator did not author. Operators can later rewrite the placeholder
via the admin UI; the audit trail shows who edited each row.
Revision ID: 043
Revises: 042
Create Date: 2026-04-18
"""
import sqlalchemy as sa
from alembic import op
revision = "043"
down_revision = "042"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"UPDATE prompt_templates "
"SET description = 'Skill: ' || name "
"WHERE description IS NULL OR description = ''"
)
)
def downgrade() -> None:
# The placeholder format is `Skill: <name>`; undo only the rows
# whose description still matches that exact pattern — an operator
# who has since rewritten the description should keep their edit.
conn = op.get_bind()
conn.execute(
sa.text(
"UPDATE prompt_templates SET description = '' WHERE description = 'Skill: ' || name"
)
)
@@ -0,0 +1,32 @@
"""Add ``prompt_templates.kind`` classifier (interactive / coordinator / any).
Adds a ``kind`` column so the coordinator's ``list_skills`` tool can
hide skills meant only for interactive child workstreams (and vice
versa). Allowed values: ``interactive`` / ``coordinator`` / ``any``.
``any`` is the server-default so every pre-existing row keeps its
cluster-wide visibility on both sides after upgrade; skill authors
opt in to the narrower buckets only when they want the classifier
to filter.
Revision ID: 044
Revises: 043
Create Date: 2026-04-18
"""
import sqlalchemy as sa
from alembic import op
revision = "044"
down_revision = "043"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("prompt_templates") as batch:
batch.add_column(sa.Column("kind", sa.Text, nullable=False, server_default="any"))
def downgrade() -> None:
with op.batch_alter_table("prompt_templates") as batch:
batch.drop_column("kind")
@@ -0,0 +1,31 @@
"""Rename ``prompt_templates.scan_status`` to ``risk_level``.
Pure column rename. ``risk_level`` aligns with the terminology already
used by :class:`turnstone.core.judge.IntentVerdict` (which carries its
own ``risk_level``) and reads better in the admin UI than
``scan_status`` the value shape didn't change, the name did.
No index changes the scanner table has no index involving this
column. Data is preserved by the RENAME operation on both backends.
Revision ID: 045
Revises: 044
Create Date: 2026-04-18
"""
from alembic import op
revision = "045"
down_revision = "044"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("prompt_templates") as batch:
batch.alter_column("scan_status", new_column_name="risk_level")
def downgrade() -> None:
with op.batch_alter_table("prompt_templates") as batch:
batch.alter_column("risk_level", new_column_name="scan_status")
+36 -1
View File
@@ -42,7 +42,13 @@ from starlette.staticfiles import StaticFiles
from turnstone import __version__
from turnstone.api.docs import make_docs_handler, make_openapi_handler
from turnstone.api.server_spec import build_server_spec
from turnstone.core.auth import JWT_AUD_SERVER, AuthMiddleware, jwt_version_slot
from turnstone.core.auth import (
DENY_EMPTY_SUB,
JWT_AUD_SERVER,
AuthMiddleware,
_DenyFilter,
jwt_version_slot,
)
from turnstone.core.log import get_logger
from turnstone.core.metrics import metrics as _metrics
from turnstone.core.ratelimit import resolve_client_ip
@@ -3189,6 +3195,35 @@ def _auth_scopes(request: Request) -> set[str]:
return set(getattr(auth, "scopes", []) or [])
def _effective_user_filter(request: Request) -> str | None | _DenyFilter:
"""Resolve the effective ``user_id`` filter for a tenant-scoped aggregate.
Server-side analog of the console helper of the same name. Node
servers authenticate with JWTs that carry a ``sub`` (the workstream
owner) plus scopes; there is no "admin" role on the node side.
The service scope is the sole cluster-wide bypass (used by the
console routing proxy).
Returns:
- ``None`` service-scoped caller; no tenant filter.
- ``str`` end-user caller with a resolved uid; storage helpers
MUST receive ``user_id=<uid>`` and push the filter into SQL.
- :data:`DENY_EMPTY_SUB` end-user caller whose ``sub`` claim is
blank. Callers MUST short-circuit with their endpoint's
empty-shape response.
Mirrors the class-level tenancy contract on
:class:`~turnstone.core.storage._protocol.StorageBackend`.
"""
if "service" in _auth_scopes(request):
return None
uid = _auth_user_id(request)
if not uid:
return DENY_EMPTY_SUB
return uid
def _require_ws_access(
request: Request,
ws_id: str,
+1
View File
@@ -0,0 +1 @@
"""Shared test utilities for the turnstone test suite and downstream consumers."""
+51
View File
@@ -0,0 +1,51 @@
"""Test-side enforcement of the storage-row ``_mapping`` contract.
See the class-level docstring on
:class:`turnstone.core.storage._protocol.StorageBackend`: list-style
storage methods return SQLAlchemy ``Row`` objects, and every caller
(production or fake) must access columns through ``row._mapping``.
Positional indexing silently corrupts the projection when a SELECT
reorders or a new trailing column appears.
Use :func:`assert_row_like` in a test fixture or fake factory to fail
fast a test double that forgets ``_mapping`` should raise at setup
time, not ten assertions later when a downstream consumer reads the
wrong column.
"""
from __future__ import annotations
from typing import Any
def assert_row_like(row: Any, *, required_keys: list[str] | None = None) -> None:
"""Assert ``row`` satisfies the storage-row contract.
- Must expose a ``_mapping`` attribute supporting ``[key]`` and
``.get(key)``.
- If ``required_keys`` is supplied, every key must resolve via
``_mapping.get``.
Raises :class:`AssertionError` with a specific reason so a failing
fake is obvious from the test output alone.
"""
mapping = getattr(row, "_mapping", None)
if mapping is None:
raise AssertionError(
"row is missing the ._mapping attribute — positional "
"indexing is not a supported access pattern; fakes must "
"expose the same mapping shape the SQLAlchemy Row does"
)
try:
_ = mapping.get
except AttributeError as exc:
raise AssertionError(
"row._mapping does not support .get() — provide a "
"dict-like mapping so callers can use .get(key) / [key]"
) from exc
for key in required_keys or ():
if mapping.get(key) is None and key not in mapping:
raise AssertionError(
f"row._mapping missing required key {key!r} — either "
"extend the fake or update the test's required_keys"
)
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "list_skills",
"description": "List skills (worker profiles) available in the cluster. Auto-approved — safe, read-only. Pass optional filters to narrow by category (e.g. 'engineering', 'ops'), tag (single tag from the skill's tags array), or scan_status ('clean', 'flagged', 'unscanned', 'pending'). Use to discover which skill names you can pass to spawn_workstream. Returns name, category, tags, version, description, model preference, enabled flag, scan_status, activation, and allowed_tools (capped at 20 tool names with a '+N more' sentinel when truncated) — enough for an informed pick without speculating which tools a skill brings.",
"description": "List skills (worker profiles) available in the cluster. Auto-approved — safe, read-only. Pass optional filters to narrow by category (e.g. 'engineering', 'ops'), tag (single tag from the skill's tags array), or risk_level ('safe', 'low', 'medium', 'high', 'critical'). Omit risk_level to include skills regardless of scan state (including unscanned rows that carry an empty value). Use to discover which skill names you can pass to spawn_workstream. Results are pre-filtered to skills tagged for coordinator use (kind='coordinator') or universal skills (kind='any'); interactive-only skills are hidden here because they're meant for child workstreams rather than the orchestrator. Returns name, category, tags, version, description, model preference, enabled flag, risk_level, activation, kind, and allowed_tools (capped at 20 tool names with a '+N more' sentinel when truncated) — enough for an informed pick without speculating which tools a skill brings.",
"parameters": {
"type": "object",
"properties": {
@@ -12,9 +12,10 @@
"type": "string",
"description": "Filter by tag (must appear in the skill's tags array)."
},
"scan_status": {
"risk_level": {
"type": "string",
"description": "Filter by scan status: clean / flagged / unscanned / pending."
"enum": ["safe", "low", "medium", "high", "critical"],
"description": "Filter by risk level: safe / low / medium / high / critical. Omit entirely to include unscanned skills (which carry an empty value)."
},
"enabled_only": {
"type": "boolean",