mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -952,7 +952,7 @@ export interface SkillDiscoverListing {
|
||||
install_count: number;
|
||||
tags: string[];
|
||||
installed: boolean;
|
||||
scan_status?: string;
|
||||
risk_level?: string;
|
||||
template_id?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
+784
-78
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
model→audit 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
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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 -------------------------------------------------------
|
||||
|
||||
@@ -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=""),
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared test utilities for the turnstone test suite and downstream consumers."""
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user