Files
turnstone/docs/governance.md
T
Patrick Buckley b450b9ad20 fix(providers): align GPT-5.6 with the GA API surface
- every 5.6 tier accepts effort "max" and reasoning.mode
  "standard"/"pro" (GA docs: pro is a request mode on any GPT-5.6
  model) -- drop the Sol-only gating
- GPT-5.6 deprecates prompt_cache_retention; send
  prompt_cache_options={"ttl": "30m"} (its only supported lifetime)
  and keep the 24h retention policy for pre-5.6 models
- never inject commercial cache params into local lanes: dropped from
  the Chat Completions lane (which serves only openai-compatible and
  google) and gated off the compat-pinned Responses lane -- a gpt-5*
  served-model name is not an OpenAI account
- account cache writes: usage *_tokens_details.cache_write_tokens
  flows into cache_creation_tokens (5.6 bills writes at 1.25x the
  uncached input rate)
- drop non-string verbosity/reasoning_mode overrides with a warning
  instead of raising on unhashable capability-JSON values
- keep ModelCapabilities' public positional prefix stable by appending
  the verbosity/pro fields at the tail; pin it with a constructor test
- openai floor 2.44 -> 2.45, the first release with the typed
  prompt_cache_options kwarg
2026-07-10 15:59:40 -07:00

247 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Governance
Turnstone governance provides role-based access control (RBAC), tool execution
policies, skills, usage tracking, and audit logging for the admin console.
## Architecture
See [diagram: 19-governance-architecture.puml](diagrams/19-governance-architecture.puml).
### RBAC (Roles & Permissions)
The permission model has two layers:
1. **Scopes** (legacy) — `read`, `write`, `approve`. Checked by `AuthMiddleware`
on every request based on URL path classification.
2. **Permissions** (granular) — named permission strings checked per-endpoint by
`require_permission()`.
**Built-in roles** (seeded by migration 008):
| Role | Permissions |
|------|-------------|
| admin | read, write, approve, admin.users, admin.roles, admin.orgs, admin.policies, admin.skills, admin.audit, admin.usage, admin.schedules, admin.watches, tools.approve, workstreams.create, workstreams.close |
| operator | read, write, workstreams.create, workstreams.close |
| viewer | read |
Custom roles can be created with any subset of the valid permissions.
The `persona.create` / `persona.read` / `persona.write` family gates
persona administration; migration `063` seeds all three onto
`builtin-admin`, and any role can be granted them through the standard
role and permission-override editors.
**Auth flow:**
1. User logs in (password or API token) → `_load_user_permissions()` aggregates
permissions from all assigned roles
2. `_permissions_to_scopes()` derives legacy scopes (any `admin.*``approve`)
3. JWT created with both `scopes` and `permissions` claims
4. Middleware checks scope → handler checks permission via `require_permission()`
### Tool Policies
Admin-defined rules that control tool execution:
- **Pattern matching**: Glob syntax via `fnmatch` (e.g., `bash*`, `file_write`, `*`)
- **Actions**: `allow` (auto-approve), `deny` (block), `ask` (normal approval flow)
- **Priority**: Higher priority evaluated first, first match wins
- **Enforcement**: `evaluate_tool_policies_batch()` called in `WebUI.approve_tools()`
before the `auto_approve` check
- **MCP granular policies**: MCP resources and prompts are evaluated using their
`approval_label` for fine-grained control:
- Resource reads: `mcp_resource__{uri}` (e.g., `mcp_resource__file:///docs/*` to allow,
`mcp_resource__*` to deny all)
- Prompt invocations: `mcp__{server}__{prompt}` (e.g., `mcp__trusted__*` to allow,
`mcp__*` to require approval for all)
- Built-in tools continue to use `func_name` for backward compatibility
### Skills
Admin-curated system message skills injected at workstream startup. Skills also
include session configuration (model, temperature, auto-approve, token budget,
etc.) since workstream templates were merged into the skills system in v0.8.0.
- **Runtime behavior**: Skills are loaded once at session creation and injected
into the system message *before* user `instructions`. Skills set the baseline;
instructions customize per-workstream behavior.
- **Default skills**: All `is_default=true` skills auto-apply to new
workstreams, concatenated in alphabetical order by name. Use name prefixes
(e.g. `01-safety`, `02-style`) to control ordering.
- **Explicit selection**: `--skill <name>` CLI flag, `skill` field on
`POST /v1/api/workstreams/new`, console creation modal dropdown, scheduled task
config, and channel adapter config. An explicit skill *replaces* defaults.
- **Variables**: Three built-in placeholders resolved at load time:
`{{model}}` (active model name), `{{ws_id}}` (workstream ID),
`{{node_id}}` (server node ID). Unrecognized placeholders are kept as-is.
- **Runtime switching**: `/skill <name>` to switch, `/skill clear` to revert
to defaults, `/skill` to show current. Persisted across resume.
- **Model-driven loading**: The `skill` built-in tool lets the model
discover and activate skills mid-conversation. `search` action finds skills
by query (auto-approved); `load` action activates by name (requires user
approval since it changes session behavior). Main session only.
- **Categories**: general, engineering, support, custom, mcp
- **Content limit**: 32 KB per skill (enforced on create/update)
- **Storage**: `prompt_templates` table (stores skills) with JSON `variables`
array. Migration 010 adds `template` column to `scheduled_tasks`.
- **MCP sync**: MCP server prompts auto-sync into the `prompt_templates` table
with `origin="mcp"`, `mcp_server` set, and `readonly=True`. Manual skills take
precedence on name collision. MCP-synced content updates reset `is_default` to
prevent compromised servers from injecting defaults. Admin UI shows origin badge
and disables edit/delete for MCP-sourced skills.
- **Spec fields**: Skills support the full Agent Skills standard frontmatter:
`name`, `description`, `license`, `compatibility`, `metadata` (author, version),
`allowed-tools`. The `license` and `compatibility` fields are preserved on import
and editable in the admin UI. See https://agentskills.io/specification.
- **Security scanning**: Skills are automatically scanned at creation and update
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 `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:
- `GET /v1/api/admin/skills/discover?q=...` — search the skills.sh registry
(or a custom registry via `skills.discovery_url` setting)
- `POST /v1/api/admin/skills/install` — install from skills.sh or GitHub.
Fetches the `SKILL.md` file, parses YAML frontmatter, creates a skill with
`origin="source"` and `readonly=True`, stores bundled resources.
- Admin UI: Skills tab has "Installed" / "Discover" pill toggle.
Discovery view has search bar, result cards, and "Import from GitHub" modal.
- SDK: `discover_skills(q)` and `install_skill(source, skill_id=..., url=...)`
on both Python and TypeScript console clients.
- **Runtime config on installed skills**: Installed (readonly) skills can have
their runtime configuration edited — model, temperature, reasoning effort,
token budget, max tokens, agent max turns, auto-approve, allowed tools,
and enabled flag. The server restricts updates to these fields only via
`_SKILL_RUNTIME_CONFIG_FIELDS` filtering; spec/content fields (name,
description, tags, license, compatibility, content, activation) remain
immutable. The admin UI shows "Save Config" instead of "Save" for these
skills. Audit action: `skill.update.config`.
- **Admin UI**: Create/Edit skill modals use a two-column spec manifest layout
(left: Identity / Manifest / Deployment; right: Skill Content editor with
monospace font). Runtime Config is a collapsible 3-column grid below.
License uses an SPDX identifier dropdown (MIT, Apache-2.0, GPL-3.0, etc.).
Installed skills show a cyan origin badge with source URL, spec fields are
disabled, and all collapsible sections auto-expand in view mode.
### Usage Tracking
Per-LLM-request token and tool call metrics:
- **Recording**: `on_status()` in `WebUI` records a `usage_event` after each
LLM response with prompt/completion tokens, cache tokens, tool call count,
model, ws_id
- **Prompt caching**: Anthropic automatic caching (`cache_control: ephemeral`)
and OpenAI caching are enabled by default. Pre-5.6 GPT-5 models request
`prompt_cache_retention: 24h`; GPT-5.6 uses
`prompt_cache_options: {"ttl": "30m"}`. GPT-5.6 cache writes use the
provider's 1.25× input-token rate. `cache_creation_tokens` and
`cache_read_tokens` are tracked per request in `usage_events` and surfaced
in the Usage admin tab
- **Querying**: `GET /v1/api/admin/usage` with `group_by` (day/hour/model/user)
and time range filtering — includes cache token aggregates
- **Prometheus**: `turnstone_tokens_total{type="cache_creation|cache_read"}`
counters on `/metrics`
- **Pruning**: `prune_usage_events(retention_days=90)` and
`prune_audit_events(retention_days=365)` run automatically via the
console scheduler's periodic cleanup cycle
### Audit Logging
Append-only trail of admin actions:
- **Recording**: `record_audit()` helper called from all admin mutation handlers
- **Events captured**: user.create, user.delete, token.create, token.revoke,
channel.link, channel.unlink, role.create, role.update, role.delete,
role.assign, role.unassign, policy.create, policy.update, policy.delete,
template.create, template.update, template.delete,
skill.create, skill.update, skill.delete, org.update
- **Querying**: `GET /v1/api/admin/audit` with action/user/time filters + pagination
## Database Schema
Migration 008 adds 7 tables:
| Table | Purpose |
|-------|---------|
| `orgs` | Organizations (single default org for now) |
| `roles` | Named permission bundles (3 builtin + custom) |
| `user_roles` | User-to-role assignments (composite PK) |
| `tool_policies` | Per-tool approve/deny/ask rules |
| `prompt_templates` | Reusable system message skills |
| `usage_events` | Per-request token/tool/cache metrics |
| `audit_events` | Admin action log |
Also adds `org_id` column to `users` table.
## API Endpoints
All under `/v1/api/admin/` (requires `approve` scope + granular permission).
| Group | Endpoints | Permission |
|-------|-----------|------------|
| Users / Tokens / Channels | 9 (CRUD) | `admin.users` |
| Roles | 7 (CRUD + assignment) | `admin.roles` / `admin.users` |
| Orgs | 3 (list, get, update) | `admin.orgs` |
| Tool Policies | 4 (CRUD) | `admin.policies` |
| Skills | 4 (CRUD) | `admin.skills` |
| Personas | 4 (list, create, get, edit/archive) | `persona.read` / `persona.create` / `persona.write` |
| Schedules | 6 (CRUD + runs) | `admin.schedules` |
| Watches | 3 (list, create, cancel) | `admin.watches` |
| Usage | 1 (aggregated query) | `admin.usage` |
| Audit | 1 (paginated, filtered) | `admin.audit` |
Full OpenAPI spec at `/openapi.json` and Swagger UI at `/docs`.
## Admin Console UI
Governance-related tabs within the 18-tab admin panel:
- **Roles** — CRUD roles, permission checkbox grid, user role assignment modal
- **Policies** — CRUD tool policies with colored action badges (green/red/amber)
- **Prompts** — Prompt-policy editor (heuristics for admin guardrails)
- **Skills** — CRUD skills with wide modal, textarea editor; Discover pill for
installing from skills.sh / GitHub; per-row scan badges (safe/low/med/high/critical)
- **Judge** — Intent validation configuration and verdict history
- **Usage** — Summary readouts + CSS bar chart, time range + group-by selectors
- **Audit** — Filterable log with relative timestamps, load-more pagination
Tabs are permission-gated: hidden if the user lacks the required permission.
See [docs/console.md](console.md) for the full tab list and
[docs/settings.md](settings.md) for the Settings tab that edits live
ConfigStore values.
## SDK
Both Python and TypeScript console SDKs expose governance methods:
**Python** (`TurnstoneConsole` / `AsyncTurnstoneConsole`):
- `list_roles()`, `create_role()`, `update_role()`, `delete_role()`
- `list_user_roles()`, `assign_role()`, `unassign_role()`
- `list_orgs()`, `get_org()`, `update_org()`
- `list_policies()`, `create_policy()`, `update_policy()`, `delete_policy()`
- `list_templates()`, `create_template()`, `update_template()`, `delete_template()`
- `get_usage(since, group_by=...)`, `get_audit(action=..., limit=...)`
**TypeScript** (`TurnstoneConsole`):
- Same methods with camelCase naming and typed interfaces
## Security Considerations
- **Privilege escalation prevented**: `admin_assign_role` blocks self-assignment
and requires caller to hold a superset of the target role's permissions
- **Permission validation**: Role create/update validates permissions against
the permission allowlist (`_VALID_PERMISSIONS`)
- **Self-deletion blocked**: `admin_delete_user` rejects attempts to delete
your own account (matching the self-assignment guard on role endpoints)
- **Field allowlists**: Storage `update_*` methods filter fields against
allowlists (`_ROLE_MUTABLE`, `_POLICY_MUTABLE`, etc.) — handler bugs
cannot overwrite `role_id`, `builtin`, `created`, or other protected columns
- **Bootstrap safety**: `handle_auth_setup` fails and rolls back if admin role
assignment fails, preventing locked-out first user
- **API token RBAC**: `_authenticate_api_token` loads permissions from user's
roles, ensuring API tokens are subject to RBAC enforcement
- **Policy evaluation is fail-open**: If storage is unavailable, tool policies
degrade to the existing approval flow (not auto-approve)
- **Audit IP resolution**: `_audit_context()` prefers `X-Forwarded-For` for
client IP when behind a reverse proxy, falling back to `request.client.host`