Policy file¶
One source of truth: config/policy.yaml. It is validated with a Pydantic schema and
reloaded without a restart. An invalid file never replaces a working one: the gateway logs
the error, counts it (acl_policy_reloads_total{result="invalid"}) and keeps the last valid
version. Every audit entry records the policy revision it was decided under, and each reload
puts a marker on the Grafana dashboards.
What you can change¶
- Profile and default:
profile: strict | balanced | permissive;default: denyis the only accepted value, so anything not granted is forbidden. - Roles and agents: who may do what (
read:db:sales.*,write:fs:reports/*, ...), per role and per agent; which principals may use an agent; approvers. - Controls: each control's
mode(block, redact, require approval, log only), thresholds such asprompt_injection.thresholdand its judge band,sql_guard.max_cost,loop_detectlimits. Mandatory controls rejectlog_only. - Risk rules: what taint and each risk level do, separately for interactive and autonomous sessions.
- Budgets and pricing: per user, agent and session (tokens, USD, tool calls, GPU seconds).
- Upstreams and models: the local Ollama upstream and the opt-in OpenRouter upstream (zero-retention routing, price ceiling, model map), plus the judge model.
- Signature feed:
config/feeds/signatures.json(or an http(s) URL), refreshed everyrefresh_s; add a signature and it applies without a restart.
Try it
With the stack running, raise controls.sql_guard.max_cost from 10000 to 100000. A
query that was refused with sql_cost_exceeded a moment ago now runs, and the audit entry
shows the new revision. make demo scene 7 does exactly this and restores the file.
The policy in this repository¶
The file below is included verbatim from
config/policy.yaml.
schema_version: 2
profile: strict # strict | balanced | permissive
default: deny # the only accepted value: anything not granted is forbidden
upstreams: # internal networks, reachable only from the gateway
llm:
base_url: http://ollama:11434/v1 # or LiteLLM
api_key_env: null # env var holding the router key, if any
# Opt-in remote upstream for engineering (CPU-only Ollama runs qwen3:8b at ~2.4 tok/s). Used
# only when the gateway runs with ACL_LLM_UPSTREAM=remote (compose.remote.yml, `make
# remote-up`); the default above is unchanged. Prompts then leave the machine after the pre
# controls (pii redaction, secrets and prompt-injection blocks). Grants, pricing keys,
# model_allowlist and audit keep the logical ids on the left; a model missing here is refused.
llm_remote:
base_url: https://openrouter.ai/api/v1
api_key_env: OPENROUTER_API_KEY # gateway environment only, from .env
reasoning: openrouter # reasoning_effort -> reasoning {enabled|effort}
model_map:
# Its own logical id, never an alias of a local model: qwen3:8b stays local-only.
# DeepSeek V4.1 Flash: ~27 zero-data-retention endpoints on OpenRouter (2026-10-04),
# JSON mode, tools; `reasoning: {enabled: false}` gives 0 reasoning tokens (measured).
"deepseek-v4.1-flash": deepseek/deepseek-v4.1-flash
judge_model: "deepseek-v4.1-flash" # judges' model while remote is selected
extra_body: # merged into every request; the agent cannot override
provider:
zdr: true # zero-data-retention endpoints only, or an error
data_collection: deny # no provider that may train on or store prompts
# USD per million tokens: no endpoint dearer than the price budgets charge below, so a
# reservation is always an upper bound (ZDR endpoints span $0.02-3.12 out, 2026-10-04).
max_price: { prompt: 0.3, completion: 1.2 }
# Fallbacks stay on: zdr filters every candidate, so a fallback is another ZDR endpoint.
pricing: # replaces the top-level price while remote is selected
# The provider.max_price ceiling above, per 1k tokens: what a call can cost at most.
# Typical ZDR endpoints are cheaper ($0.02-0.30 in, $0.18-1.20 out per million).
"deepseek-v4.1-flash": { prompt_per_1k: 0.0003, completion_per_1k: 0.0012, gpu_second: 0.0 }
mcp:
sales_db:
url: http://mcp-postgres:8000/mcp
adapter: sql
trust: internal
require_pin: true # tool_pinning: pins/sales_db.json
tools:
query: { action: read } # sql adapter derives resources from the parsed tables
web:
url: http://mcp-fetch:8000/mcp
adapter: http
trust: untrusted
require_pin: true # tool_pinning: pins/web.json
tools:
fetch: { action: read, resource: "web:{url}" } # http adapter reduces {url} to its host
reports:
url: http://mcp-files:8000/mcp
adapter: fs
trust: internal
require_pin: true # tool_pinning: pins/reports.json
tools:
write_report: { action: write, resource: "fs:reports/{name}" }
roles:
analyst: { allow: ["read:db:sales.*", "read:web:*", "write:fs:reports/*", "generate:model:*"] }
intern: { allow: ["read:db:sales.orders", "read:db:sales.customers", "read:web:*",
"write:fs:reports/*", "generate:model:qwen3:8b",
"generate:model:deepseek-v4.1-flash"] }
admin: { allow: ["*:*"] }
ops-team: { allow: [] } # approvers; grants nothing by itself
agents:
databot:
type: interactive
max_actions: [read, write, generate]
allow: ["read:db:sales.*", "read:web:*", "write:fs:reports/*", "generate:model:*"]
deny: ["egress:*"]
approvers: [ops-team]
# A real coding agent (opencode, demo/opencode/) driving the gateway for the video: the same
# grants as databot but each model named, for the two people the demo films. Its own
# built-in tools (bash, files, webfetch) run on the developer's machine, outside the gateway;
# the launcher turns them off (docs/opencode.md).
opencode:
type: interactive
max_actions: [read, write, generate]
allow: ["read:db:sales.*", "read:web:*", "write:fs:reports/*", "generate:model:qwen3:8b",
"generate:model:deepseek-v4.1-flash"]
deny: ["egress:*"]
approvers: [ops-team]
principals: [anna@demo, bartek@demo]
nightly_etl:
type: autonomous # principal is svc:nightly_etl, U = this allow list
max_actions: [read, write, generate]
allow: ["read:db:sales.*", "read:web:*", "write:fs:reports/*", "generate:model:qwen3:8b",
"generate:model:deepseek-v4.1-flash"]
approvers: [ops-team] # must be a role defined above
risk:
half_life_s: 600
risk_rules: # profile-independent session restrictions
interactive:
- when: { taint: true }
then: { deny_actions: [write, delete, egress] }
- when: { risk_gt: 0.5 }
then: { actions: [write], mode: require_approval, cooldown_s: 300 }
- when: { risk_gt: 0.8 }
then: { freeze_tools: true, duration_s: 300 }
autonomous:
- when: { taint: true }
then: { actions: [write, delete, egress], mode: require_approval }
- when: { risk_gt: 0.5 }
then: { throttle: { max_actions: 1, per_s: 10 }, alert: true }
- when: { risk_gt: 0.8 }
then: { actions: [write, delete, execute, egress], mode: require_approval, alert: true }
approvals: { timeout_s: 600, on_timeout: deny }
sessions: { idle_ttl_s: 3600, max_lifetime_s: 86400 }
throttle: { backoff: exponential, base_s: 5, max_s: 300 }
blocklist: # manual blocks, effective right after reload
users: []
agents: []
use_cases: [] # permission patterns, e.g. "egress:http:pastebin.com"
limits:
max_request_bytes: 1048576
max_response_bytes: 4194304
upstream_timeout_s: 120
default_max_tokens: 4096 # completion cap injected when an LLM request names none
controls:
model_allowlist: { mode: block }
pii: { mode: redact, threshold: 0.6,
entities: [PL_PESEL, PL_NIP, IBAN_CODE, EMAIL_ADDRESS, PHONE_NUMBER] }
secrets: { mode: block }
# judge_confirm_timeout_s: how long a classifier hit on the agent's own prompt, or on the
# model's answer, waits for the judge before it is let through and the session tainted
# (must be <= judges.timeout_s). CPU judge ~25 s, so 35 here; use ~5-10 on a GPU.
prompt_injection: { mode: block, threshold: 0.85, judge_band: [0.5, 0.85], risk_delta: 0.6,
judge_confirm_timeout_s: 35 }
sql_guard: { mode: block, max_cost: 10000, force_limit: 500, timeout_ms: 3000 }
egress: { mode: block, allowed_ports: [80, 443] } # http-adapter servers: public destinations only
signatures: { mode: block, feed: feeds/signatures.json, refresh_s: 30 } # relative to this file, or an http(s) URL
loop_detect: { mode: block, max_repeats: 5, window_s: 60 }
intent_judge: { mode: require_approval, risk_delta: 0.2 } # advisory: flags the MCP call
output_policy: { mode: redact, risk_delta: 0.2 } # judge quotes -> masked spans
pricing: # USD; local models are priced by GPU wall-time estimate
"qwen3:8b": { prompt_per_1k: 0.0, completion_per_1k: 0.0, gpu_second: 0.0005 }
# A model without an entry costs 0; its tokens and GPU time still count.
budgets:
per_user: { daily_tokens: 200000, daily_cost_usd: 2.0 }
per_agent: { daily_tokens: 1000000 }
# CPU-only qwen3:8b takes ~10-70 s of upstream time per call, so 120 s allowed only ~5
# calls per session. GPU time is still charged at its real duration, and each call is
# held only up to its allowance (limits.upstream_timeout_s, or what is left if less).
per_session: { tool_calls: 50, gpu_seconds: 900 }
soft_limit_pct: 80
# LLM judges (intent_judge, output_policy, the prompt_injection judge band) call
# upstreams.llm directly with this model: not audited as agent requests, not charged to
# budgets. A judge that cannot answer within timeout_s fails its control closed. Without
# this section the judge controls are off, and configuring one of them is an error.
judges:
model: "qwen3:8b"
# Total deadline per judge call. Measured on qwen3:8b, CPU, thinking off: 6-8 s with the
# judge prompt cached, 17-19 s cold (prompt prefill ~45 tok/s; the agent's own calls to
# the same Ollama evict the cache), more while Ollama is busy with another request.
timeout_s: 40
max_content_chars: 16000 # longer content is not judged: fail closed
max_output_tokens: 1024 # caps the verdict; timeout_s bounds the wall time
reasoning_effort: "none" # qwen3 thinks by default: on CPU that eats the whole deadline
The complete schema, with validation rules and cross-reference checks, is in the specification.