Skip to content

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: deny is 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 as prompt_injection.threshold and its judge band, sql_guard.max_cost, loop_detect limits. Mandatory controls reject log_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 every refresh_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.