From e71ea3895308f2872cf10e5c4882baa3368e1967 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Mon, 16 Mar 2026 17:39:24 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20output=20guard=20data=20pipeline=20?= =?UTF-8?q?=E2=80=94=20persist=20assessments,=20SSE=20events,=20a=E2=80=A6?= =?UTF-8?q?=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: output guard data pipeline — persist assessments, SSE events, admin UI Complete the output guard pipeline: persist assessments for v2 calibration, surface warnings in every UI layer, and add scan badges to admin skills tab. Storage: migration 022 adds output_assessments table (flags, risk_level, annotations, output_length, redacted — raw output never stored) and scan_version column on prompt_templates. Three new protocol methods with SQLite + PostgreSQL implementations. Server/CLI: on_output_warning now persists assessments fire-and-forget. CLI shows flags, annotations, and redaction notice. Session emits on_info warning when high/critical scan_status skill is loaded. MQ: OutputWarningEvent dataclass + bridge SSE forwarding. Web UI: output_warning SSE handler with inline warning rendering (role="alert" for accessibility), semantic risk colors. Console admin: scan badges on skills list (dedicated scope-scan-* CSS with green/yellow/red risk vocabulary), scan report breakdown in edit modal with 4-axis scores, POST /admin/skills/{id}/rescan endpoint, GET /admin/output-assessments endpoint with date-filtered pagination. Security fixes: ReDoS in connection string regex ([^@\s]+ → [^:@\s]+), negative limit bypass in all admin endpoints (max(1, ...)), to_dict() excludes sanitized output by default. False-positive fixes: credentials pattern anchored to path context, env secret key check restricted to key portion only. * fix: address PR #110 review — list redaction, test fixture, OpenAPI snapshot Per-part redaction: evaluate each text part independently in structured output instead of joining all parts and replacing only the first one. Fix test annotations default from "{}" to "[]" matching schema. Regenerate TypeScript OpenAPI snapshots for new admin endpoints. --- README.md | 4 +- docs/api-reference.md | 65 ++++++ docs/diagrams/22-judge-architecture.puml | 17 +- docs/diagrams/png/22-judge-architecture.png | 4 +- docs/judge.md | 63 +++++- sdk/typescript/openapi-console.json | 195 +++++++++++++++++- tests/test_output_assessment_storage.py | 136 ++++++++++++ tests/test_output_guard.py | 28 +++ turnstone/api/console_schemas.py | 22 ++ turnstone/api/console_spec.py | 26 +++ turnstone/cli.py | 13 +- turnstone/console/server.py | 95 ++++++++- turnstone/console/static/governance.js | 106 ++++++++++ turnstone/console/static/index.html | 5 + turnstone/console/static/style.css | 25 +++ turnstone/core/output_guard.py | 24 ++- turnstone/core/session.py | 36 +++- turnstone/core/storage/_postgresql.py | 88 +++++++- turnstone/core/storage/_protocol.py | 39 ++++ turnstone/core/storage/_schema.py | 24 +++ turnstone/core/storage/_sqlite.py | 88 +++++++- turnstone/core/storage/_utils.py | 13 +- .../versions/022_output_assessments.py | 47 +++++ turnstone/mq/bridge.py | 14 ++ turnstone/mq/protocol.py | 14 ++ turnstone/server.py | 20 ++ turnstone/ui/static/app.js | 34 +++ turnstone/ui/static/style.css | 8 + 28 files changed, 1202 insertions(+), 51 deletions(-) create mode 100644 tests/test_output_assessment_storage.py create mode 100644 turnstone/core/storage/migrations/versions/022_output_assessments.py diff --git a/README.md b/README.md index 599b5d85..f5b75b26 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Turnstone gives LLMs tools — shell, files, search, web, planning — and orche - **Multi-node clusters** — generic work load-balances across nodes, directed work routes to a specific server - **Cluster dashboard** — real-time view of all nodes and workstreams, reverse proxy for server UIs - **Intent validation** — an LLM judge evaluates every tool call before approval, presenting risk assessments and evidence-based recommendations so users can make informed decisions instead of blindly approving raw tool calls -- **Governance & compliance** — RBAC, OIDC SSO (Okta, Azure AD, Google, Keycloak), tool policies, prompt templates, workstream templates, usage tracking, and append-only audit logs +- **Governance & compliance** — RBAC, OIDC SSO (Okta, Azure AD, Google, Keycloak), tool policies, skills (reusable behavioral profiles with security scanning), usage tracking, and append-only audit logs - **Cluster simulator** — test the stack at scale (up to 1000 nodes) without an LLM backend Works with any OpenAI-compatible API (vLLM, llama.cpp, NVIDIA NIM) or Anthropic's native Messages API. Supports [MCP](https://modelcontextprotocol.io/) for external tool servers with native deferred tool loading on Anthropic and OpenAI APIs (BM25 fallback for local models). @@ -145,7 +145,7 @@ Turnstone includes a built-in governance layer for enterprise deployments — ma - **RBAC** — 15 granular permissions, 3 built-in roles (admin / operator / viewer), custom roles, privilege escalation prevention - **OIDC SSO** — single sign-on via any OpenID Connect provider (Okta, Azure AD, Google, Keycloak); Authorization Code Flow with PKCE, auto-provisioning, claim-based role mapping with demotion propagation; see [docs/oidc.md](docs/oidc.md) - **Tool policies** — glob-pattern rules (`allow` / `deny` / `ask`) with priority ordering; automate approvals or lock down dangerous tools -- **Prompt templates** — reusable system messages with `{{variable}}` substitution and categories +- **Skills** — reusable behavioral profiles with system prompts, `{{variable}}` substitution, session config (model, temperature, token budget), install-time security scanning, and version history - **Usage tracking** — per-request token and tool metrics, aggregation by day / model / user, automatic 90-day pruning - **Audit logging** — append-only event trail for all admin mutations, IP-aware, 365-day retention diff --git a/docs/api-reference.md b/docs/api-reference.md index 8120915e..f2caa859 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1285,6 +1285,71 @@ is on the **console** server and requires the `admin.judge` permission. --- +### `GET /v1/api/admin/output-assessments` (Console) + +List output guard assessments from the `output_assessments` table. This endpoint +is on the **console** server and requires the `admin.judge` permission. + +**Query parameters:** + +| Parameter | Type | Required | Description | +|--------------|--------|----------|----------------------------------------------------| +| `ws_id` | string | no | Filter by workstream ID | +| `risk_level` | string | no | Filter by risk level (`low`/`medium`/`high`) | +| `since` | string | no | ISO timestamp lower bound | +| `until` | string | no | ISO timestamp upper bound | +| `limit` | int | no | Max results (default 100, max 500) | +| `offset` | int | no | Pagination offset (default 0) | + +**Response:** + +```json +{ + "assessments": [ + { + "assessment_id": "a1b2c3d4e5f6", + "ws_id": "ws-1", + "call_id": "call_abc123", + "func_name": "bash", + "flags": "[\"credential_leak\"]", + "risk_level": "high", + "annotations": "[\"API key detected (sk-proj-...)\"]", + "output_length": 1024, + "redacted": 1, + "created": "2026-03-16T10:00:00" + } + ], + "total": 7 +} +``` + +--- + +### `POST /v1/api/admin/skills/{skill_id}/rescan` (Console) + +Re-scan a skill's content for security signals using the current scanner +version. Requires the `admin.skills` permission. + +**Path parameters:** + +| Parameter | Type | Description | +|------------|--------|-------------| +| `skill_id` | string | Skill (prompt template) ID | + +**Response:** + +```json +{ + "scan_status": "medium", + "scan_report": "{\"composite\": 1.75, \"details\": {...}}", + "scan_version": "1" +} +``` + +**Error:** `404` if skill not found. + +--- + ### `GET /v1/api/admin/settings` (Console) List all settings with their effective values, defaults, and metadata. Requires diff --git a/docs/diagrams/22-judge-architecture.puml b/docs/diagrams/22-judge-architecture.puml index b22ba1dd..2a79623c 100644 --- a/docs/diagrams/22-judge-architecture.puml +++ b/docs/diagrams/22-judge-architecture.puml @@ -176,11 +176,20 @@ note right end note alt output_warning flags detected - Session -> UI : SSE: output_warning\n{call_id, risk_level, flags} + Session -> UI : SSE: output_warning\n{call_id, risk_level, flags,\nfunc_name, redacted} note right Credential values replaced with [REDACTED:] before output enters conversation. + sanitized text excluded from + SSE payload (defense in depth). + end note + UI -> Storage : record_output_assessment()\nfire-and-forget persistence + note right + Stored: flags, risk_level, + annotations, output_length, + redacted (bool). Raw tool + output is never stored. end note end @@ -200,8 +209,10 @@ note over Session, Judge Credential redaction when judge_config.redact_secrets is true. **Storage:** - intent_verdicts table (migration 012). Verdicts queryable via - GET /v1/api/admin/verdicts (requires admin.judge permission). + intent_verdicts table (migration 012), output_assessments table + (migration 022). Both queryable via admin API endpoints + (requires admin.judge permission). Skills store scan_status, + scan_report, scan_version for install-time risk assessment. end note @enduml diff --git a/docs/diagrams/png/22-judge-architecture.png b/docs/diagrams/png/22-judge-architecture.png index 65964d79..0a291c8c 100644 --- a/docs/diagrams/png/22-judge-architecture.png +++ b/docs/diagrams/png/22-judge-architecture.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3baadc34039a877d8fe9b0fb6a9159c71216bb90b4a03ae5cf630b9b85da1be5 -size 357315 +oid sha256:79a690c466a5d6f6d4292d78a27b9474e9e9c1373fa80e17dfe37700238c8af8 +size 382508 diff --git a/docs/judge.md b/docs/judge.md index 73b74362..591982b5 100644 --- a/docs/judge.md +++ b/docs/judge.md @@ -360,19 +360,70 @@ redact_secrets = true # auto-redact detected credentials (default) Configurable at runtime via the admin Settings tab. +### SSE event: `output_warning` + +When the output guard detects risk signals, an `output_warning` SSE event is +emitted to the frontend: + +```json +{ + "type": "output_warning", + "call_id": "call_abc123", + "func_name": "bash", + "risk_level": "high", + "flags": ["credential_leak"], + "annotations": ["API key detected (sk-proj-...)"], + "output_length": 1024, + "redacted": true +} +``` + +The web UI renders this as an inline warning after the tool result. The CLI +shows a colored terminal warning. The MQ bridge forwards it as an +`OutputWarningEvent` for console subscribers. + +Assessments are persisted to the `output_assessments` table for v2 +calibration. Raw tool output is never stored — only metadata (flags, risk +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 +session, a warning is emitted via `on_info`: + +``` +⚠ Skill 'my-skill' has scan status: high. +Review scan report in admin panel before enabling in production. +``` + +This ensures operators see a warning even if they missed the scan badge in +the admin skills tab. + --- -## v2 Calibration Path +## Data Collection for v2 Calibration -Run v1 with all tools requiring manual approval to build a local verdict -dataset. The `intent_verdicts` table accumulates `(tool_call, verdict, -user_decision)` triples over time. In v2, calibration tooling will analyze -this dataset to: +All three evaluation systems persist their assessments for future calibration: + +| Table | Source | Key columns | +|-------|--------|-------------| +| `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` | + +Run v1 with all tools requiring manual approval to build a local dataset. +In v2, calibration tooling will analyze this data to: - Identify tools that are always approved (candidates for auto-approve policies) -- Detect false positives in heuristic rules +- Detect false positives in heuristic rules (intent + output guard) - Measure LLM judge accuracy against human decisions - Recommend policy changes to reduce approval fatigue +- Tune output guard sensitivity per tool (e.g., `bash` output needs more + scrutiny than `read_file`) + +Output assessments are queryable via `GET /v1/api/admin/output-assessments` +(requires `admin.judge` permission). Skills can be re-scanned via +`POST /v1/api/admin/skills/{id}/rescan` when the scanner is updated. This data-driven approach means v1 is both useful on its own and a foundation for automated policy tuning. diff --git a/sdk/typescript/openapi-console.json b/sdk/typescript/openapi-console.json index 1d80f84d..956b2481 100644 --- a/sdk/typescript/openapi-console.json +++ b/sdk/typescript/openapi-console.json @@ -2240,6 +2240,114 @@ } } }, + "/v1/api/admin/output-assessments": { + "get": { + "summary": "Paginated output guard assessments", + "operationId": "v1_api_admin_output-assessments_get", + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "ws_id", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by workstream" + }, + { + "name": "risk_level", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "description": "Filter by risk level" + }, + { + "name": "since", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Start timestamp (ISO8601)" + }, + { + "name": "until", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "End timestamp (ISO8601)" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 100 + }, + "description": "Page size (max 500)" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Pagination offset" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListOutputAssessmentsResponse" + } + } + } + } + } + } + }, + "/v1/api/admin/skills/{skill_id}/rescan": { + "post": { + "summary": "Re-scan a skill for security signals", + "operationId": "v1_api_admin_skills_{skill_id}_rescan_post", + "tags": [ + "Admin" + ], + "parameters": [ + { + "name": "skill_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/v1/api/admin/memories": { "get": { "summary": "List structured memories", @@ -5165,6 +5273,87 @@ "title": "ListVerdictsResponse", "type": "object" }, + "OutputAssessmentInfo": { + "description": "Output guard assessment.", + "properties": { + "assessment_id": { + "title": "Assessment Id", + "type": "string" + }, + "ws_id": { + "title": "Ws Id", + "type": "string" + }, + "call_id": { + "title": "Call Id", + "type": "string" + }, + "func_name": { + "title": "Func Name", + "type": "string" + }, + "flags": { + "default": "[]", + "title": "Flags", + "type": "string" + }, + "risk_level": { + "default": "none", + "title": "Risk Level", + "type": "string" + }, + "annotations": { + "default": "[]", + "title": "Annotations", + "type": "string" + }, + "output_length": { + "default": 0, + "title": "Output Length", + "type": "integer" + }, + "redacted": { + "default": 0, + "title": "Redacted", + "type": "integer" + }, + "created": { + "title": "Created", + "type": "string" + } + }, + "required": [ + "assessment_id", + "ws_id", + "call_id", + "func_name", + "created" + ], + "title": "OutputAssessmentInfo", + "type": "object" + }, + "ListOutputAssessmentsResponse": { + "description": "Response for output assessment listing.", + "properties": { + "assessments": { + "items": { + "$ref": "#/components/schemas/OutputAssessmentInfo" + }, + "title": "Assessments", + "type": "array" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "assessments", + "total" + ], + "title": "ListOutputAssessmentsResponse", + "type": "object" + }, "AdminMemoryInfo": { "properties": { "memory_id": { @@ -6116,9 +6305,11 @@ "type": "string" }, "tags": { - "default": "[]", + "items": { + "type": "string" + }, "title": "Tags", - "type": "string" + "type": "array" }, "variables": { "default": "[]", diff --git a/tests/test_output_assessment_storage.py b/tests/test_output_assessment_storage.py new file mode 100644 index 00000000..088ed6c7 --- /dev/null +++ b/tests/test_output_assessment_storage.py @@ -0,0 +1,136 @@ +"""Tests for output assessment storage operations.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest + +from turnstone.core.storage._sqlite import SQLiteBackend + + +@pytest.fixture() +def db(tmp_path): + """Fresh SQLite backend for each test.""" + return SQLiteBackend(str(tmp_path / "test.db")) + + +def _make_assessment_kwargs(**overrides): + """Build default kwargs for record_output_assessment.""" + defaults = { + "assessment_id": "oa_001", + "ws_id": "ws-abc", + "call_id": "tc_001", + "func_name": "bash", + "flags": '["credential_leak"]', + "risk_level": "high", + "annotations": "[]", + "output_length": 256, + "redacted": False, + } + defaults.update(overrides) + return defaults + + +# --------------------------------------------------------------------------- +# CRUD Operations +# --------------------------------------------------------------------------- + + +class TestOutputAssessmentCRUD: + def test_record_and_list(self, db): + db.record_output_assessment(**_make_assessment_kwargs()) + results = db.list_output_assessments() + assert len(results) == 1 + assert results[0]["assessment_id"] == "oa_001" + assert results[0]["ws_id"] == "ws-abc" + assert results[0]["func_name"] == "bash" + assert results[0]["risk_level"] == "high" + + +# --------------------------------------------------------------------------- +# Count queries +# --------------------------------------------------------------------------- + + +class TestOutputAssessmentCount: + def test_count_basic(self, db): + db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa1")) + db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa2")) + db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa3")) + assert db.count_output_assessments() == 3 + + def test_count_empty(self, db): + assert db.count_output_assessments() == 0 + + def test_count_with_ws_id(self, db): + db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa1", ws_id="ws-1")) + db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa2", ws_id="ws-1")) + db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa3", ws_id="ws-2")) + assert db.count_output_assessments(ws_id="ws-1") == 2 + + def test_count_with_risk_level(self, db): + db.record_output_assessment( + **_make_assessment_kwargs(assessment_id="oa1", risk_level="low") + ) + db.record_output_assessment( + **_make_assessment_kwargs(assessment_id="oa2", risk_level="high") + ) + db.record_output_assessment( + **_make_assessment_kwargs(assessment_id="oa3", risk_level="high") + ) + assert db.count_output_assessments(risk_level="high") == 2 + + def test_count_with_since(self, db): + db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa1")) + db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa2")) + + future = (datetime.now(UTC) + timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%S") + assert db.count_output_assessments(since=future) == 0 + + def test_count_with_until(self, db): + db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa1")) + db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa2")) + + past = "2020-01-01T00:00:00" + assert db.count_output_assessments(until=past) == 0 + + def test_count_with_date_range(self, db): + now = datetime.now(UTC) + db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa1")) + db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa2")) + db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa3")) + + one_minute_ago = (now - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%S") + one_minute_later = (now + timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%S") + assert db.count_output_assessments(since=one_minute_ago, until=one_minute_later) == 3 + + def test_count_matches_list_length(self, db): + """Count with filters matches the length of list with same filters.""" + db.record_output_assessment( + **_make_assessment_kwargs(assessment_id="oa1", ws_id="ws-1", risk_level="high") + ) + db.record_output_assessment( + **_make_assessment_kwargs(assessment_id="oa2", ws_id="ws-1", risk_level="low") + ) + db.record_output_assessment( + **_make_assessment_kwargs(assessment_id="oa3", ws_id="ws-2", risk_level="high") + ) + + now = datetime.now(UTC) + one_minute_ago = (now - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%S") + one_minute_later = (now + timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%S") + + for ws, rl, s, u in [ + ("ws-1", "", "", ""), + ("", "high", "", ""), + ("ws-1", "high", "", ""), + ("ws-2", "low", "", ""), + ("", "", one_minute_ago, one_minute_later), + ("ws-1", "high", one_minute_ago, one_minute_later), + ]: + count = db.count_output_assessments(ws_id=ws, risk_level=rl, since=s, until=u) + listed = db.list_output_assessments(ws_id=ws, risk_level=rl, since=s, until=u) + assert count == len(listed), ( + f"Mismatch for ws_id={ws!r}, risk_level={rl!r}, since={s!r}, until={u!r}" + ) diff --git a/tests/test_output_guard.py b/tests/test_output_guard.py index 37008902..a94ef09e 100644 --- a/tests/test_output_guard.py +++ b/tests/test_output_guard.py @@ -156,6 +156,34 @@ class TestSystemInfoDisclosure: r = evaluate_output("Found: /home/user/.ssh/id_rsa\n /home/user/.aws/credentials") assert "sensitive_path_disclosure" in r.flags + def test_credentials_word_in_prose_no_flag(self) -> None: + """The word 'credentials' in prose should not trigger sensitive_path_disclosure.""" + r = evaluate_output( + "Enter your credentials to log in. Invalid credentials will be rejected." + ) + assert "sensitive_path_disclosure" not in r.flags + + def test_credentials_path_with_slash_flags(self) -> None: + """A path like /credentials should still trigger.""" + r = evaluate_output("cat /etc/service/credentials") + assert "sensitive_path_disclosure" in r.flags + + +class TestEnvSecretFalsePositives: + """Verify env-secret detection only checks the key, not the value.""" + + def test_secret_in_value_no_flag(self) -> None: + """DESCRIPTION=The secret weapon should not trigger env_file_leak.""" + r = evaluate_output( + "APP_NAME=myapp\nDESCRIPTION=The secret weapon\nVERSION=1.0\nDEBUG=true" + ) + assert "env_file_leak" not in r.flags + + def test_secret_in_key_still_flags(self) -> None: + """SECRET_KEY=value should still trigger env_file_leak.""" + r = evaluate_output("APP_NAME=myapp\nSECRET_KEY=abc123\nAPI_TOKEN=xyz789\nDEBUG=true") + assert "env_file_leak" in r.flags + class TestOutputAssessment: """Verify OutputAssessment structure.""" diff --git a/turnstone/api/console_schemas.py b/turnstone/api/console_schemas.py index 6794abf0..8575c39d 100644 --- a/turnstone/api/console_schemas.py +++ b/turnstone/api/console_schemas.py @@ -453,6 +453,28 @@ class ListVerdictsResponse(BaseModel): total: int +class OutputAssessmentInfo(BaseModel): + """Output guard assessment.""" + + assessment_id: str + ws_id: str + call_id: str + func_name: str + flags: str = "[]" + risk_level: str = "none" + annotations: str = "[]" + output_length: int = 0 + redacted: int = 0 + created: str + + +class ListOutputAssessmentsResponse(BaseModel): + """Response for output assessment listing.""" + + assessments: list[OutputAssessmentInfo] + total: int + + # --------------------------------------------------------------------------- # Channels # --------------------------------------------------------------------------- diff --git a/turnstone/api/console_spec.py b/turnstone/api/console_spec.py index c7a4955f..eeaeaea2 100644 --- a/turnstone/api/console_spec.py +++ b/turnstone/api/console_spec.py @@ -31,6 +31,7 @@ from turnstone.api.console_schemas import ( ListChannelUsersResponse, ListMcpServersResponse, ListOrgsResponse, + ListOutputAssessmentsResponse, ListRolesResponse, ListSettingSchemaResponse, ListSettingsResponse, @@ -43,6 +44,7 @@ from turnstone.api.console_schemas import ( McpServerDetail, NodeDetailResponse, OrgInfo, + OutputAssessmentInfo, RegistryInstallRequest, RegistrySearchResponse, RoleInfo, @@ -591,6 +593,28 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [ ], tags=["Admin"], ), + # --- Admin: Output Guard --- + EndpointSpec( + "/v1/api/admin/output-assessments", + "GET", + "Paginated output guard assessments", + response_model=ListOutputAssessmentsResponse, + query_params=[ + QueryParam("ws_id", "Filter by workstream"), + QueryParam("risk_level", "Filter by risk level", enum=["low", "medium", "high"]), + QueryParam("since", "Start timestamp (ISO8601)"), + QueryParam("until", "End timestamp (ISO8601)"), + QueryParam("limit", "Page size (max 500)", schema_type="integer", default=100), + QueryParam("offset", "Pagination offset", schema_type="integer", default=0), + ], + tags=["Admin"], + ), + EndpointSpec( + "/v1/api/admin/skills/{skill_id}/rescan", + "POST", + "Re-scan a skill for security signals", + tags=["Admin"], + ), # --- Admin: Memories --- EndpointSpec( "/v1/api/admin/memories", @@ -817,6 +841,8 @@ _ALL_MODELS: list[type[BaseModel]] = [ ListAuditEventsResponse, VerdictInfo, ListVerdictsResponse, + OutputAssessmentInfo, + ListOutputAssessmentsResponse, AdminMemoryInfo, ListAdminMemoriesResponse, SettingInfo, diff --git a/turnstone/cli.py b/turnstone/cli.py index 2c8aa88c..a147dfdf 100644 --- a/turnstone/cli.py +++ b/turnstone/cli.py @@ -275,11 +275,16 @@ class TerminalUI(SessionUI): risk = assessment.get("risk_level", "none") if risk == "none": return - summary = assessment.get("summary", "") + flags = assessment.get("flags", []) color = _VERDICT_COLORS.get(risk, YELLOW) - print(f"\n {color}▸ OUTPUT WARNING: {risk.upper()}{RESET}") - if summary: - print(f" {summary}") + sys.stdout.write( + f"\n {color}⚠ OUTPUT WARNING: {risk.upper()} — {', '.join(flags)}{RESET}\n" + ) + for ann in assessment.get("annotations", []): + sys.stdout.write(f" {ann}\n") + if assessment.get("redacted"): + sys.stdout.write(f" {DIM}(credentials redacted from output){RESET}\n") + sys.stdout.flush() def on_rename(self, name: str) -> None: pass # base TerminalUI ignores renames diff --git a/turnstone/console/server.py b/turnstone/console/server.py index c83b738e..e6251014 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -1447,7 +1447,7 @@ async def admin_list_schedule_runs(request: Request) -> JSONResponse: return JSONResponse({"error": "Schedule not found"}, status_code=404) try: - limit = min(int(request.query_params.get("limit", "50")), 200) + limit = max(1, min(int(request.query_params.get("limit", "50")), 200)) except (ValueError, TypeError): limit = 50 runs = storage.list_task_runs(task_id, limit=limit) @@ -2679,7 +2679,7 @@ async def admin_audit(request: Request) -> JSONResponse: since = params.get("since", "") until = params.get("until", "") try: - limit = min(int(params.get("limit", "50")), 200) + limit = max(1, min(int(params.get("limit", "50")), 200)) except (ValueError, TypeError): limit = 50 try: @@ -2723,7 +2723,7 @@ async def admin_list_verdicts(request: Request) -> JSONResponse: until = params.get("until", "") risk_level = params.get("risk_level", "") try: - limit = min(int(params.get("limit", "100")), 500) + limit = max(1, min(int(params.get("limit", "100")), 500)) except (ValueError, TypeError): limit = 100 try: @@ -2749,6 +2749,83 @@ async def admin_list_verdicts(request: Request) -> JSONResponse: return JSONResponse({"verdicts": verdicts, "total": total}) +async def admin_list_output_assessments(request: Request) -> JSONResponse: + """GET /v1/api/admin/output-assessments — list output guard assessments.""" + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.judge") + if err: + return err + + params = dict(request.query_params) + ws_id = params.get("ws_id", "") + risk_level = params.get("risk_level", "") + since = params.get("since", "") + until = params.get("until", "") + try: + limit = max(1, min(int(params.get("limit", "100")), 500)) + except (ValueError, TypeError): + limit = 100 + try: + offset = max(int(params.get("offset", "0")), 0) + except (ValueError, TypeError): + offset = 0 + + assessments = storage.list_output_assessments( + ws_id=ws_id, + risk_level=risk_level, + since=since, + until=until, + limit=limit, + offset=offset, + ) + total = storage.count_output_assessments( + ws_id=ws_id, risk_level=risk_level, since=since, until=until + ) + return JSONResponse({"assessments": assessments, "total": total}) + + +async def admin_rescan_skill(request: Request) -> JSONResponse: + """POST /v1/api/admin/skills/{skill_id}/rescan — re-scan skill security.""" + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.skills") + if err: + return err + + skill_id = request.path_params["skill_id"] + skill = storage.get_prompt_template(skill_id) + if not skill: + return JSONResponse({"error": "Skill not found"}, status_code=404) + + from turnstone.core.storage._utils import scan_skill_content + + content = skill.get("content", "") + allowed_tools = skill.get("allowed_tools", "[]") + scan_status, scan_report, scan_version = scan_skill_content(content, allowed_tools) + storage.update_prompt_template( + skill_id, + scan_status=scan_status, + scan_report=scan_report, + scan_version=scan_version, + ) + return JSONResponse( + { + "scan_status": scan_status, + "scan_report": scan_report, + "scan_version": scan_version, + } + ) + + # --------------------------------------------------------------------------- # Admin: Memories # --------------------------------------------------------------------------- @@ -2786,7 +2863,7 @@ async def admin_list_memories(request: Request) -> JSONResponse: if err: return err try: - limit = min(int(request.query_params.get("limit", "100")), 200) + limit = max(1, min(int(request.query_params.get("limit", "100")), 200)) except (ValueError, TypeError): return JSONResponse({"error": "limit must be an integer"}, status_code=400) @@ -2819,7 +2896,7 @@ async def admin_search_memories(request: Request) -> JSONResponse: if err: return err try: - limit = min(int(request.query_params.get("limit", "20")), 50) + limit = max(1, min(int(request.query_params.get("limit", "20")), 50)) except (ValueError, TypeError): return JSONResponse({"error": "limit must be an integer"}, status_code=400) @@ -3178,7 +3255,7 @@ async def admin_registry_search(request: Request) -> JSONResponse: q = str(request.query_params.get("search", "")).strip() try: - limit = min(int(request.query_params.get("limit", "20")), 100) + limit = max(1, min(int(request.query_params.get("limit", "20")), 100)) except (ValueError, TypeError): limit = 20 cursor = request.query_params.get("cursor") or None @@ -4133,6 +4210,12 @@ def create_app( Route("/api/admin/audit", admin_audit), # Governance: Intent Verdicts Route("/api/admin/verdicts", admin_list_verdicts), + Route("/api/admin/output-assessments", admin_list_output_assessments), + Route( + "/api/admin/skills/{skill_id}/rescan", + admin_rescan_skill, + methods=["POST"], + ), ], ), Route("/health", health), diff --git a/turnstone/console/static/governance.js b/turnstone/console/static/governance.js index a1e8cfe4..f9b2f6eb 100644 --- a/turnstone/console/static/governance.js +++ b/turnstone/console/static/governance.js @@ -709,6 +709,23 @@ function _renderGovSkills(items) { : ""; var catBadge = '' + escapeHtml(t.category) + ""; + var scanBadge = ""; + if (t.scan_status) { + var scanClass = + { + safe: "scope-scan-safe", + low: "scope-scan-low", + medium: "scope-scan-medium", + high: "scope-scan-high", + critical: "scope-scan-critical", + }[t.scan_status] || ""; + scanBadge = + ' ' + + escapeHtml(t.scan_status) + + ""; + } var editDisabled = t.readonly ? " disabled" : ""; var deleteDisabled = t.readonly ? " disabled" : ""; html += @@ -719,6 +736,7 @@ function _renderGovSkills(items) { activationBadge + defBadge + originBadge + + scanBadge + (t.description ? '
' + escapeHtml(t.description) + @@ -1000,6 +1018,94 @@ function showEditTemplateModal(tmplId) { document.getElementById("esk-allowed-tools").disabled = this.checked; }); document.getElementById("edit-template-error").style.display = "none"; + // Scan report section + var scanSection = document.getElementById("etm-scan-section"); + if (scanSection) { + if (tmpl.scan_status) { + scanSection.style.display = ""; + var scanClassMap = { + safe: "scope-scan-safe", + low: "scope-scan-low", + medium: "scope-scan-medium", + high: "scope-scan-high", + critical: "scope-scan-critical", + }; + var report = {}; + try { + report = JSON.parse(tmpl.scan_report || "{}"); + } catch (e) {} + var scanHtml = + '' + + escapeHtml(tmpl.scan_status) + + ""; + if (report.composite != null) { + scanHtml += + ' Score: ' + + report.composite.toFixed(2) + + ""; + } + if (tmpl.scan_version) { + scanHtml += + ' v' + + escapeHtml(tmpl.scan_version) + + ""; + } + var axes = ["content", "supply_chain", "vulnerability", "capability"]; + for (var ai = 0; ai < axes.length; ai++) { + var axis = axes[ai]; + var d = (report.details || {})[axis] || {}; + scanHtml += + '
' + + escapeHtml(axis.replace(/_/g, " ")) + + ' ' + + (d.score != null ? d.score.toFixed(1) : "0.0") + + "/4.0"; + if (d.flags && d.flags.length) { + scanHtml += + ' ' + + d.flags.map(escapeHtml).join(", ") + + ""; + } + scanHtml += "
"; + } + document.getElementById("etm-scan-report").innerHTML = scanHtml; + } else { + scanSection.style.display = "none"; + } + } + var rescanBtn = document.getElementById("etm-rescan-btn"); + if (rescanBtn) { + rescanBtn.onclick = function () { + rescanBtn.disabled = true; + rescanBtn.textContent = "Scanning..."; + authFetch("/v1/api/admin/skills/" + tmplId + "/rescan", { + method: "POST", + }) + .then(function (r) { + if (!r.ok) throw new Error("Failed"); + return r.json(); + }) + .then(function (data) { + showToast("Scan complete: " + (data.scan_status || "unknown")); + // Refresh the modal by re-loading skills and re-opening + loadGovSkills(); + // Update current tmpl in memory + tmpl.scan_status = data.scan_status; + tmpl.scan_report = data.scan_report; + tmpl.scan_version = data.scan_version; + showEditTemplateModal(tmplId); + }) + .catch(function () { + showToast("Re-scan failed"); + }) + .finally(function () { + rescanBtn.disabled = false; + rescanBtn.textContent = "Re-scan"; + }); + }; + } _etmTrapHandler = _installTrap("edit-template-overlay", "edit-template-box"); } diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index 8d0b879c..3728f5a2 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -921,6 +921,11 @@ window.TURNSTONE_KB_SHORTCUTS = [ +