Skip to content

Latest commit

 

History

History
1130 lines (899 loc) · 52.7 KB

File metadata and controls

1130 lines (899 loc) · 52.7 KB

Policy Guide

Helio's policy engine evaluates every tools/call request against an ordered list of rules before deciding whether to forward, block, or escalate the call. Policies are defined in helio.yaml and hot-reload without restarting the proxy — rate and spend limit buckets survive a benign reload as long as the underlying rule config is unchanged (see Hot Reload). Operators who want zero live-state movement on config writes can pin the policy with helio start --no-hot-reload or policies.hot_reload: false.

How Policies Work

  1. When a tools/call request arrives, the proxy builds a context from the tool name, MCP annotations, input arguments, and configured environment.
  2. Rules are evaluated in order. The first rule whose match conditions are satisfied determines the action.
  3. If no rule matches, the policies.default action applies (allow or deny).

This is a first-match-wins model. Rule order matters — put more specific rules before general ones.

Policy evaluation is synchronous and typically completes in under 1ms.

Rule Structure

Each rule in the policies.rules array has this structure:

policies:
  rules:
    - name: rule-name # Optional label for audit and error messages
      match: # Conditions (all must be true)
        tool: 'send_*' # Glob pattern on tool name
        annotations: # MCP annotation hints
          destructiveHint: true
        input: # Conditions on tool arguments
          '$.amount':
            gt: 1000
        environment: production # Match environment label
      action: deny # What to do: allow | deny | require_approval | rate_limit | spend_limit | dry_run
      approval: # Optional; if omitted, falls back to dashboard + global timeout
        channel: slack
        timeout: '600s'
      evidence: # Require evidence before allowing
        requires: ['order_lookup']
      requires: ['verify_customer'] # Require prior tool calls
      limits: # Rate or spend limit config
        max_calls: 100
        window: '1h'
      feedback: # Custom message for blocked and gated actions
        message: 'This action is blocked.'
        suggestion: 'Try a different approach.'

Match Conditions

All conditions within a match block are AND-combined — every specified condition must be true for the rule to match. Omitted conditions are ignored (they don't constrain the match).

tool

Match by tool name using glob patterns (powered by picomatch):

match:
  tool: 'send_email' # Exact match
match:
  tool: 'send_*' # Wildcard suffix
match:
  tool: 'stripe.*' # Dot-separated namespace
match:
  tool: '*' # Match any tool

annotations

Match by MCP tool annotations — metadata hints that describe a tool's behavior:

match:
  annotations:
    destructiveHint: true

Four annotation hints are available:

Hint Description MCP Default
readOnlyHint Tool only reads data, no side effects false
destructiveHint Tool can destructively modify state true
idempotentHint Safe to call repeatedly with same args false
openWorldHint Tool can affect systems beyond its scope true

Important: The MCP spec defaults destructiveHint to true when a tool does not explicitly set it. This means a rule matching destructiveHint: true will match most tools unless they explicitly opt out with destructiveHint: false. Always set annotations explicitly on your MCP server tools.

Helio startup now auto-primes annotations with a synthetic upstream tools/list. If upstream is temporarily unavailable, Helio retries priming in the background. Until priming succeeds, annotation checks intentionally remain fail-closed using these MCP defaults.

Only specify the annotations you want to match on. Omitted annotations are not checked:

# Matches tools that are read-only, regardless of other annotations
match:
  annotations:
    readOnlyHint: true

input

Match on tool call arguments using dot-path notation and comparison operators:

match:
  input:
    '$.amount':
      gt: 1000

Path syntax: Use $.field or just field to reference top-level arguments. Nested paths work with dots: $.user.name, $.payment.currency.

Operators:

Operator Type Description Example
eq any Strict equality eq: "GBP"
neq any Strict inequality neq: "internal"
gt number Greater than gt: 1000
gte number Greater than or equal gte: 0
lt number Less than lt: 10000
lte number Less than or equal lte: 500
contains string Substring match contains: "@example.com"
regex string Regular expression match regex: "^admin_"

A neq condition matches only when the field is present; an absent field does not satisfy neq.

Multiple conditions on the same or different fields are AND-combined:

# Amount between 100 and 10,000 (inclusive) in GBP
match:
  input:
    '$.amount':
      gte: 100
      lte: 10000
    '$.currency':
      eq: 'GBP'

environment

Match against the environment label set in the top-level config. This is an exact, case-sensitive string match:

# Only match in production
environment: production

policies:
  rules:
    - name: prod-deny-destructive
      match:
        annotations:
          destructiveHint: true
        environment: production
      action: deny

match.environment is only valid when top-level environment is configured. If you define env-scoped rules without setting top-level environment, Helio rejects the config at startup, on helio validate, and during hot-reload.

Changing top-level environment on a running process is restart-required. Hot-reload keeps the startup environment label and logs a restart warning.

upstreams

Match calls routed through specific named upstreams. The value is a non-empty list of exact upstream names — no globs. The rule matches when the call arrived through any door named in the list (OR within the list), AND-combined with the rest of the match block. Like match.environment above, the condition is only valid when the top-level context exists — here the named upstreams: list, which the fragment carries:

upstreams:
  - name: files
    url: 'http://localhost:8081/mcp'
  - name: payments
    url: 'http://localhost:8082/mcp'

policies:
  rules:
    - name: deny-payments-writes
      match:
        upstreams: [payments]
        annotations:
          destructiveHint: true
      action: deny

Upstream attribution exists only on the MCP path — a call that came through /mcp/<name> or /sse/<name> — so a rule with match.upstreams is inert on the sideband path: it never matches there and is skipped, not denied (the mirror image of match.metadata, below, which is inert on the MCP path).

Validation enforces these semantics instead of letting a dead rule sit silently. Each rejection below fires at startup, on helio validate, and during hot-reload.

A match.upstreams rule in a singular-mode config:

  policies.rules.0.match.upstreams: Rule sets match.upstreams but the config declares a single "upstream:", which has no name on purpose. Upstream-scoped rules require the named "upstreams:" list.

A name no configured upstream carries:

  policies.rules.0.match.upstreams.0: Rule names upstream "search" in match.upstreams but no configured upstream has that name. Every entry must name an upstream from the upstreams: list.

An empty list (match.upstreams: []) is rejected with match.upstreams must name at least one upstream — an empty list matches nothing.

Combining match.upstreams with match.metadata — the two conditions live on different paths, so the rule could never match:

  policies.rules.0.match.upstreams: match.upstreams cannot be combined with match.metadata — metadata rules only match on the sideband (host) path and upstream-scoped rules only on the MCP path, so the combination can never match. Split it into two rules.

Keying an upstream-scoped rule's limits by sender_id — senders exist only on the sideband, upstream scoping only on the MCP path. This rejection co-fires with the pre-existing rule that sender_id keys require the SDK sideband, so a config without sdk.enabled: true reports both:

  policies.rules.0.limits.key: limits.key "sender_id" requires the SDK sideband (sdk.enabled: true) — sender_id is supplied by hook adapters and is absent on the MCP path.
  policies.rules.0.limits.key: limits.key "sender_id" cannot be combined with match.upstreams — an upstream-scoped rule only matches on the MCP path, where sender_id is absent and the key would silently collapse to tool scope.

Budget contributors accept the same upstreams list in their match blocks, with the same validation — see Scoping contributors by upstream.

metadata

Match against the adapter-supplied context of a call — who sent it, in which channel, and so on. This is populated only on the host-enforced (sideband) path (see the Adapter Governance API); MCP requests carry no metadata, so a rule with match.metadata is inert on the MCP path (it never matches there, and is skipped — not denied).

Well-known keys: channel_id, sender_id, sender_name, conversation_id, and the virtual agent_id (read from the request's agent_id field, not the metadata object). Any other adapter-supplied key can be matched too.

Each key takes either a bare string (exact match) or an operator object using the string operators eq, neq, contains, regex:

policies:
  rules:
    # Block one Slack channel outright
    - name: no-prod-channel
      match:
        metadata:
          channel_id: 'C_PROD'
      action: deny

    # Require approval for a specific sender pattern, agent-scoped
    - name: external-senders
      match:
        metadata:
          sender_id: { regex: '^EXT-' }
          agent_id: 'support-bot'
      action: require_approval
      approval:
        channel: slack

All metadata conditions are AND-combined with each other and with the rest of the match block. A regex is validated for catastrophic backtracking at load time, exactly like match.input regexes. Because metadata is absent on the MCP path, prefer pairing a metadata deny rule with a separate MCP-path control if you need both doors covered.

Actions

Action Description
allow Forward the request to the upstream MCP server and record in audit.
deny Block the request and return structured feedback. No upstream request is made.
require_approval Hold the request and wait for human approval. See Approval Workflows.
rate_limit Allow until the call limit is exceeded, then block. Requires limits.max_calls and limits.window.
spend_limit Track cumulative monetary spend and block when the rule's spend limit is exceeded. Requires limits.max_spend.
dry_run Simulate the full pipeline without forwarding to upstream. Returns what would have happened.

allow

The request is forwarded to the upstream MCP server. The response is passed back to the client. An audit record is created with the policy decision, latency, and response.

deny

The request is blocked immediately. No upstream request is made. The response is a JSON-RPC error with structured self-repair feedback:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32001,
    "message": "Destructive operations are blocked by policy.",
    "data": {
      "blocked": true,
      "reason": "policy_denied",
      "rule": "block-destructive",
      "action": "deny",
      "suggestion": "Use a non-destructive alternative.",
      "retry_allowed": false
    }
  }
}

The data object also carries rule_index and policy_reason; the fields shown above are the ones an agent typically acts on. Detect a denial by keying on data.blocked and data.reason, not on the numeric error code: -32001 predates the MCP 2026-07-28 error-code allocation policy and is retained as grandfathered, and receivers must not assume meaning for this code.

require_approval

The proxy holds the HTTP connection open and creates an approval ticket. The ticket is sent to the configured approval channel (dashboard, webhook, or Slack). The request waits until a human approves, denies, the timeout fires, or the client disconnects (client_disconnected).

Rule-level approval is optional. If omitted, Helio falls back to channel dashboard and the global timeout from top-level approval.timeout:

- name: approve-writes
  match:
    annotations:
      readOnlyHint: false
  action: require_approval
  approval:
    channel: slack
    timeout: '600s'

See Approval Workflows for full documentation.

rate_limit

Allows requests up to a configured call limit within a sliding window, then blocks:

- name: rate-limit-search
  match:
    tool: 'search_*'
  action: rate_limit
  limits:
    max_calls: 100
    window: '1h'
    key: tool

See Rate Limits below.

rate_limit rules without limits.max_calls or limits.window are rejected at config-validate/startup time.

spend_limit

Tracks cumulative monetary amounts extracted from tool arguments and blocks when the rule's spend limit is exceeded:

- name: payment-spend-cap
  match:
    tool: 'create_payment'
  action: spend_limit
  limits:
    max_spend:
      field: '$.amount'
      limit: 500
      currency: USD
      window: '1h'
      key: tool

See Spend Limits below.

spend_limit rules without limits.max_spend are rejected at config-validate/startup time.

dry_run

Runs the full policy evaluation pipeline — including evidence checks, rate limit checks, and spend limit checks — but does not forward the request to the upstream server and does not consume rate limit slots or charge spend buckets.

Returns a synthetic response showing what would have happened:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"dry_run\":true,\"would_forward\":true,\"policy_decision\":\"allow\",\"matched_rule\":null,\"evidence_satisfied\":true,\"limits_ok\":true}"
      }
    ],
    "resultType": "complete"
  }
}

Clients on MCP 2025-06-18, and clients on transports that predate the MCP-Protocol-Version header (SSE, stdio), receive the same result without the resultType field.

Install-Time Policy (deny_install)

Hook-based adapters can scan a package/skill before it is installed via the sideband POST /install-scan endpoint. Install-time rules live in their own policies.install block — separate from policies.rules, because a package has no tool name, annotations, or arguments to match on:

policies:
  install:
    default: allow # or deny — applied when no rule matches
    rules:
      - name: block-unverified-npm
        match:
          name: 'evil-*' # glob on the package name (same engine as match.tool)
          source: npm # exact ecosystem match (npm | pip | …)
        action: deny_install
        feedback:
          message: 'This package is blocked by policy.'
      - name: gate-by-sender
        match:
          metadata: # install rules support match.metadata too
            sender_id: { regex: '^EXT-' }
        action: deny_install
  • Rules are first-match-wins; if none match, install.default applies (defaults to allow).
  • action is deny_install or allow. A deny_install outcome returns decision: "deny" to the adapter and records an audit row with record_kind: install_scan and block_reason: install_denied.
  • When no policies.install block is configured, /install-scan stays observational (always allows) so adapters can call it safely before any rules exist.
  • Install-time policy is only reachable through the sideband; it has no effect on the MCP path.

Rate Limits

Rate limits use a sliding window algorithm to track calls per key. Configure them with the limits block on a rate_limit rule:

Field Type Required Description
max_calls integer Yes Maximum number of calls allowed in the window.
window duration Yes Sliding window size (e.g. 1h, 5m, 30s).
key string No Aggregation scope: tool (default), session, sender_id, or agent (unsupported on MCP; falls back to tool).

Key scoping:

  • tool (default) — One shared limit per tool name, across all sessions. With named upstreams, MCP-path buckets are keyed upstream:<name>:tool:<t> — one limit per (upstream, tool), so a same-named tool on two upstreams tracks two buckets — while sideband calls keep the unprefixed tool:<t> key. session keys never carry the upstream prefix.
  • session — Each resolved session identity has its own independent limit.
  • sender_id — One limit per adapter-supplied sender_id (host-enforced path). Requires the SDK sideband (sdk.enabled: true) — Helio rejects the config otherwise, since a sender-keyed limit is meaningless without a sender. On the MCP path (which has no sender) it falls back to tool with a one-time warning.
  • agent — Currently unsupported on MCP requests; Helio logs a warning and falls back to tool.

Scopes are per rule: each rate_limit rule tracks its own bucket within its scope (bucket keys carry a :rule:<index> suffix), so two rules sharing key: session never pool their counts — the same discrimination spend limits use.

Important behaviors:

  • Blocked calls do not consume a rate limit slot. Any call that passes the limiter check consumes a slot, even if the later upstream call fails.
  • Rate limit state is in-memory and resets when the proxy restarts.
  • The sliding window is continuous, not calendar-aligned.
- name: rate-limit-expensive-tool
  match:
    tool: 'run_query'
  action: rate_limit
  limits:
    max_calls: 10
    window: '1m'
    key: session
  feedback:
    message: 'Query rate limit exceeded.'
    suggestion: 'Wait a moment before retrying.'

When the limit is hit, the denied call returns structured self-repair feedback. This is the eleventh call against the rule above, captured live:

{
  "jsonrpc": "2.0",
  "id": 11,
  "error": {
    "code": -32001,
    "message": "Query rate limit exceeded.",
    "data": {
      "blocked": true,
      "reason": "rate_limited",
      "rule": "rate-limit-expensive-tool",
      "rule_index": 0,
      "action": "rate_limit",
      "current_calls": 10,
      "max_calls": 10,
      "window_seconds": 60,
      "reset_at": "2026-07-21T08:44:50.970Z",
      "suggestion": "Wait a moment before retrying.",
      "retry_allowed": true
    }
  }
}

reset_at (ISO 8601) is when the oldest call ages out of the sliding window, freeing the next slot.

Spend Limits

Spend limits track cumulative monetary amounts extracted from tool call arguments. Configure them with limits.max_spend:

Field Type Required Description
field string Yes JSONPath-style dot path to the amount field in tool arguments (e.g. $.amount).
limit number Yes Maximum cumulative spend in the window.
currency string Yes Currency label for display (e.g. USD, EUR).
window duration Yes Sliding window size.
key string No Aggregation scope: tool (default), session, sender_id, or agent (unsupported on MCP; falls back to tool).

key: sender_id keys the spend bucket per adapter-supplied sender (host-enforced path) and, like rate limits, requires sdk.enabled: true or the config is rejected.

Important behaviors:

  • Blocked calls do not count against the spend bucket. Any call that passes the limiter check and is forwarded counts against the bucket, even if the later upstream call fails.
  • If the configured amount field is missing or non-numeric, the call is denied with structured feedback (reason: spend_limited) and nothing is counted against the bucket.
  • Spend limit state is in-memory and resets when the proxy restarts.
- name: refund-spend-cap
  match:
    tool: 'create_refund'
  action: spend_limit
  limits:
    max_spend:
      field: '$.amount'
      limit: 200
      currency: USD
      window: '1h'
      key: session
  feedback:
    message: 'Refund spend limit exceeded for this session.'
    suggestion: 'Wait for the current window to reset or escalate to a human.'

A denied call returns structured self-repair feedback. This is the third $200 payment against the limit-payments rule ($500/1h, key: tool) in the runnable spend-limits example, captured live:

{
  "jsonrpc": "2.0",
  "id": 3,
  "error": {
    "code": -32001,
    "message": "Payment spend limit exceeded.",
    "data": {
      "blocked": true,
      "reason": "spend_limited",
      "rule": "limit-payments",
      "rule_index": 1,
      "action": "spend_limit",
      "current_spend": 400,
      "max_spend": 500,
      "currency": "USD",
      "window_seconds": 3600,
      "reset_at": "2026-07-21T09:41:55.786Z",
      "suggestion": "Wait for the current window to reset or reduce the payment amount.",
      "retry_allowed": true
    }
  }
}

The rule's configured feedback.message surfaces as the JSON-RPC error.message, while suggestion travels inside error.data — see Feedback Messages. On a limit-exceeded denial, reset_at (ISO 8601) is when the oldest recorded spend ages out of the sliding window. An invalid-amount denial instead sets reset_at to the current time and uses a fixed error.message naming the bad field: the retry needs a corrected amount, not an expired window.

Cross-Tool Spend Budgets

spend_limit rules cap what one rule's matched tools spend. Budgets are the cross-tool layer: a first-class budgets: section, independent of rules, where one depleting pot aggregates spend across every tool its contributors match — Stripe and PayPal into one cap, each exposing the amount under its own field name.

budgets:
  - name: daily-cap
    limit: 50
    currency: USD
    window: 24h
    contributors:
      - match:
          tool: 'stripe_*'
        field: '$.amount'
      - match:
          tool: 'paypal_*'
        field: '$.total'

The configuration reference has the full schema and validation rules; a runnable example walks the whole flow, break-glass included. This section is the semantics.

How a budget depletes

A call participates in a budget when a contributor's match.tool glob matches the tool name, every match.input condition holds, and — if the contributor is scoped with upstreams — the call arrived through a listed door (absent input and upstreams, the glob alone decides; see Scoping contributors by upstream); the amount comes from the first matching contributor's field dot-path, in config order (first match wins over the combined predicate, like rules). One call depletes every budget whose contributors match, so overlapping caps compose: a $50 session pot and a $500 daily pot both charge, and whichever runs out first stops the call.

A matched contributor whose amount field is missing, non-numeric, negative, or non-finite fails closed — the call is denied regardless of on_exceed, and nothing is consumed. This is the honest boundary of the feature: budgets govern tools that expose what they are spending in an argument field. Fixed-cost tools without an amount field are rate_limit territory, and costs metered downstream after the call are a stated gap.

Scope and windows

key picks the pot structure: one shared pot (global, the default), one per resolved session identity (session), or one per adapter-supplied sender (sender_id, host-enforced path only — requires sdk.enabled: true). Under the default session.on_unresolved: deny, a call that feeds a session-keyed budget without resolvable identity is denied with block_reason: session_unresolved instead of pooling; under anonymous it pools into the shared literal unknown pot with a one-time warning — worth knowing when curl-testing a session-keyed budget (send -H 'x-helio-session-id: demo').

window is either a sliding duration (1h, 24h — spend ages out continuously, like spend limits) or session: the pot depletes for the lifetime of a session key and never replenishes on a timer; idle pots are collected after idle_ttl (default 24h), because neither door has an authoritative session-end signal.

The budget gate

Budgets gate after the policy decision, and only on calls that would actually forward. On both doors a deny rule denies before budgets are consulted, and dry-run peeks budgets without ever recording. The budget gate is all-or-nothing: every matching budget is peeked first, the call forwards only if all allow, and a denied call records nothing on any budget — including the rule-level rate/spend counters, which are only consumed when the call actually forwards.

The ordering differs per door because only one of them can sequence gates in time. On the MCP door a call flows: policy decision → approval resolution (rule-level, if required) → budget gate → forward — a human can approve a call that the budget gate then checks with fresh numbers. The sideband door decides everything in one /evaluate round-trip, so budgets are checked at evaluate time: an on_exceed: deny breach is terminal there and preempts any approval ticket (the money gate forbids what the approver would have been asked to allow), while an allowed call's budget charges commit at /audit once the call actually executed.

The enforcement claim, precisely: budget enforcement is deterministic at the MCP gate — on the proxy path the last-slot check and the charge happen synchronously in one step, so two concurrent calls cannot both squeeze through the same remaining headroom. The host-enforced adapter tier inherits the documented TOCTOU caveat: because decision and execution are separate calls, two concurrent /evaluates can both peek the last limit slot and both execute. Counters stay truthful after the fact (both /audits record), but the host-enforced tier cannot close this window from the proxy side.

Denial feedback

A denial returns structured feedback with reason: budget_exceeded and a budgets array listing every breached budget:

{
  "jsonrpc": "2.0",
  "id": 7,
  "error": {
    "code": -32001,
    "message": "Budget exceeded: \"daily-cap\"",
    "data": {
      "blocked": true,
      "reason": "budget_exceeded",
      "rule": null,
      "rule_index": null,
      "action": "budget",
      "suggestion": "Budget \"daily-cap\" would be exceeded by this call. Wait for the window to reset or reduce the amount.",
      "retry_allowed": true,
      "budgets": [
        {
          "name": "daily-cap",
          "limit": 50,
          "spent": 40,
          "remaining": 10,
          "attempted_amount": 20,
          "currency": "USD",
          "window": "24h",
          "on_exceed": "deny",
          "reset_at": "2026-07-14T12:00:00.000Z"
        }
      ]
    }
  }
}

rule/rule_index name the matched policy rule when one matched — a budget denial can shadow an allow rule, since budgets gate after the decision. retry_allowed is true only when every breached budget has a duration window (session pots never replenish, so retrying cannot help). reset_at is null for session windows; an invalid-amount failure carries reason: "invalid_amount" inside its block with attempted_amount: null. On the MCP door the reset field is ISO-8601 reset_at; the sideband's limits.budgets blocks and GET /api/budgets use epoch reset_at_ms instead — a deliberate per-surface idiom (each door keeps its existing reset-field convention), not drift.

Break-glass overages

Break-glass overages (on_exceed: require_approval): a breach raises one composite approval ticket per call listing every breached budget, and the call proceeds only on an explicit approval — the overage is then recorded as approved_overage on the ledger and the audit trail. A denial or timeout records nothing (on the MCP door unconditionally; on the sideband when the adapter honors it — an executed-anyway report commits plain spend with the denied status on the record), and budget tickets always fail closed on timeout, even under approval.default_on_timeout: allow. When one call breaches a mix of deny and require_approval budgets, deny wins and no ticket is raised. On the MCP door a rule-level approval and a budget breach are two sequential human decisions (rule ticket first); the sideband merges both gates into its single native ticket. Budget approvals are scope-once: the approval covers exactly that call's overage, never a standing grant. See Budget break-glass tickets for the ticket mechanics.

Persistence and observability

Unlike rule-level limits, budget spend persists across restarts: every charge is written to a durable ledger in the audit database and replayed at startup — see budgets for the persistence and hot-reload identity rules (what survives a config edit, what resets the pot) and Budget Ledger Tables for the storage details. The dashboard's Budgets view shows every configured pot with live depletion and its spend ledger; GET /api/budgets and GET /api/budgets/:name/events serve the same state over the sideband API, and the SSE stream carries budget_update/budget_breached events.

action: spend_limit keeps working as the per-rule quick path; budgets are the cross-tool layer on top.

Evidence Requirements

Evidence grounding lets you require that certain information has been gathered before a tool call is allowed. Evidence is submitted by the Python SDK via the sideband API.

Evidence is a cooperative control. The proxy verifies that an evidence key from the policy allowlist was posted for the session and has not expired; it does not verify what the SDK observed. The SDK token lives in the agent's process, so treat an evidence gate as a guard against a step being skipped by mistake, not against an agent that intends to skip it. See SECURITY.md.

- name: require-order-before-refund
  match:
    tool: 'process_refund'
  action: allow
  evidence:
    requires:
      - order_lookup

When evidence is missing, the proxy returns self-repair feedback telling the agent what it needs to do:

{
  "error": {
    "code": -32001,
    "message": "Missing required evidence: order_lookup",
    "data": {
      "blocked": true,
      "reason": "evidence_missing",
      "missing_evidence": ["order_lookup"],
      "suggestion": "Call the order_lookup tool first to provide the required evidence, then retry this action.",
      "retry_allowed": true
    }
  }
}

The data object also carries rule, rule_index, action, expired_evidence, and missing_dependencies.

Evidence entries have a configurable TTL (default: 300 seconds). If evidence has expired, the response includes "reason": "evidence_expired" with a suggestion to refresh it.

Rules using evidence.requires or requires are session-bound. If no session identity resolves on a matching request, Helio denies the call fail-closed under both on_unresolved modes — a shared anonymous evidence session would let any caller satisfy any other caller's gates — and the deny message names the identity strategies that were tried. The recommended identity carrier is the x-helio-session-id header; the legacy Mcp-Session-Id keeps working through the legacy_header source for the deprecation window. key: session rate and spend limits follow on_unresolved like session-keyed budgets: denied when unresolved under deny, pooled into the shared unknown bucket under anonymous.

SDK evidence keys are validated against policy: POST /evidence accepts keys that appear in at least one rule's evidence.requires list. If an SDK client sends an unknown key, the sideband returns 400 with code: "evidence_key_not_in_policy_allowlist" plus a capped preview of configured keys so operators can quickly align policy and SDK call sites. Whitespace-only session_id values are rejected with 400 on the evidence and context routes: the governance doors treat a trim-empty id as no identity, so such a write could never be read back.

The SDK correlation contract: the Python SDK's session_id is the canonical correlation key. Give the SDK an explicit session_id and send the same value as the x-helio-session-id header on every MCP request — the default session identity chain resolves it with zero extra configuration. The SDK's auto-generated UUID default cannot satisfy gates on its own, because the proxy never sees it on the MCP side.

Dependency Chains

A lighter-weight alternative to evidence: require that specific tools have been called and succeeded in the current session before a gated tool is allowed:

- name: verify-before-delete
  match:
    tool: 'delete_account'
  action: allow
  requires:
    - verify_customer
    - get_account_details

Both verify_customer and get_account_details must have been called and returned a successful upstream response in the same session before delete_account is allowed. An upstream error does not satisfy the dependency — otherwise an agent could invoke the dependency with a deliberately bad argument, let the upstream fail, and proceed to the gated tool without real evidence.

If you explicitly want the legacy "any attempted call satisfies the dependency" behavior (rare — most deployments want the outcome check), set requires_success: false on the rule:

- name: any-prior-lookup-ok
  match:
    tool: 'process_refund'
  action: allow
  requires: ['orders.lookup']
  requires_success: false # attempted calls count even if upstream errored

A gated call made before its dependencies have succeeded is denied with reason: dependency_missing. The data object carries the same field set as evidence_missing (see Evidence Requirements); only the reason, the populated arrays, and the generated message and suggestion differ. This denial was captured live against the verify-before-delete rule above:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32001,
    "message": "Evidence grounding failed: Required tool calls not completed: verify_customer, get_account_details",
    "data": {
      "blocked": true,
      "reason": "dependency_missing",
      "rule": "verify-before-delete",
      "rule_index": 0,
      "action": "deny",
      "missing_evidence": [],
      "expired_evidence": [],
      "missing_dependencies": ["verify_customer", "get_account_details"],
      "suggestion": "Call the following tools first: verify_customer, get_account_details. Then retry this action.",
      "retry_allowed": true
    }
  }
}

action reports the effective decision: the dependency gate denies the call before the rule's allow applies.

Stateless Protocol, Stateful Governance

The 2026-07-28 MCP revision removed protocol-level sessions: no initialize handshake, no Mcp-Session-Id header, no server-held conversation state. It is tempting to read that as "stateful governance is obsolete." The opposite is true, and the reason matters when you evaluate what a governance layer can actually enforce.

The spec did not abolish cross-call state. It relocated it: servers that need state across calls are told to use explicit, server-minted handles passed as ordinary tool arguments. That is a sound pattern for application state. The model can see the handle and thread it between tools, which is exactly what makes multi-step workflows composable.

It is an unsound pattern for governance state. A budget key the model can see is a budget key the model can change. If a cumulative spend limit were keyed by a handle the agent carries in its own context, the agent could drop it, swap it, or start over with a fresh one, and the limit would reset to zero each time. The same applies to evidence requirements and dependency chains: a prerequisite the model attests to itself is no prerequisite at all.

Helio holds governance state in the proxy, outside the protocol and outside the agent's context. Session identity is resolved by the proxy, never from tool arguments the model can rewrite, and it keys the session-scoped rate limits, spend limits, budgets, evidence, and dependency state described above. None of that depends on the protocol's session machinery, so the 2026-07-28 revision changes how Helio talks to upstream servers (see era detection) without changing what it can enforce.

The distinction is structural. A governance layer that holds no state of its own cannot make a cumulative cross-call constraint mean anything: no running budget, no dependency chain, no evidence grounding. The protocol shed its state so servers can scale. The governor keeps its own so that limits keep meaning something.

Feedback Messages

When a tool call is blocked, the proxy returns structured feedback as a JSON-RPC error. You can customize the message and suggestion per rule:

- name: block-production-writes
  match:
    tool: 'db_write'
    environment: production
  action: deny
  feedback:
    message: 'Direct database writes are not allowed in production.'
    suggestion: 'Submit a migration request through the change management system.'

The feedback.message appears as the error message. The feedback.suggestion is included in the error data for agents to use for self-correction. If no feedback is configured, the proxy generates a default message based on the block reason.

On the sideband adapter API, feedback also accompanies require_approval and dry_run decisions when the gating rule configures it, so adapter-built approval prompts and shadow-mode reports can show the operator's rationale. Feedback on a plain allow rule is never surfaced.

Flag Destructive

The flag_destructive option provides a safety net for tools that don't match any explicit rule but have destructiveHint: true in their MCP annotations:

policies:
  default: allow
  flag_destructive: log # or: require_approval
  • log — The call is allowed but flagged in the audit trail as flagged_destructive: true.
  • require_approval — The call is automatically escalated to the approval workflow, even though no explicit rule matched.

Note: Remember that the MCP spec defaults destructiveHint to true for tools that don't set it. With flag_destructive: require_approval, any tool that hasn't explicitly set destructiveHint: false will trigger an approval request. Because the escalation ticket always routes to the dashboard channel, flag_destructive: require_approval requires dashboard.enabled: true (startup-checked).

Dry-Run Mode

Dry-run mode lets you test policy rules without affecting the upstream server, consuming rate limit slots, or charging spend buckets.

Global dry-run — all rules simulate:

policies:
  dry_run: true
  rules:
    - name: block-destructive
      match:
        annotations:
          destructiveHint: true
      action: deny

Per-rule dry-run — only specific rules simulate:

- name: test-new-rate-limit
  match:
    tool: 'search_*'
  action: dry_run

In dry-run mode:

  • No requests are forwarded to the upstream server
  • Rate limit slots are not consumed (uses peek instead of check)
  • Spend buckets are not charged
  • The matched rule's rate or spend limit is still simulated on both doors (evidence permitting — an evidence-blocked call is a simulated deny and peeks nothing): the non-consuming peek decides would_forward/limits_ok together with budgets, and the sideband dry-run response reports the snapshot in limits.rate / limits.spend. An unreadable spend amount simulates as a block — with reason: invalid_amount on the sideband; the MCP door reports it via limits_ok: false and an operator warning
  • Budgets are peeked but never charged — the dry-run payload reports their state in a budgets array, and no breach events fire
  • Tool calls are not recorded for dependency chain tracking
  • Audit records are created with dry_run: true

Rule Ordering

Since Helio uses first-match-wins, the order of rules determines behavior. Consider this example:

policies:
  rules:
    # Rule 1: Deny destructive tools
    - name: block-destructive
      match:
        annotations:
          destructiveHint: true
      action: deny

    # Rule 2: Allow all tools
    - name: allow-all
      match:
        tool: '*'
      action: allow

A destructive tool like delete_record matches Rule 1 first and is denied. A non-destructive tool like send_email does not match Rule 1, falls through to Rule 2, and is allowed.

If you reversed the order, allow-all would match everything first and no tool would ever be denied. Put specific rules before general ones.

Common Patterns

Block destructive, allow reads

policies:
  default: allow
  rules:
    - name: block-destructive
      match:
        annotations:
          destructiveHint: true
      action: deny

    - name: allow-reads
      match:
        annotations:
          readOnlyHint: true
      action: allow

Default deny with explicit allows

policies:
  default: deny
  rules:
    - name: allow-weather
      match:
        tool: 'get_weather'
      action: allow

    - name: allow-search
      match:
        tool: 'search_*'
      action: allow

Approve writes via Slack

policies:
  default: allow
  rules:
    - name: block-destructive
      match:
        annotations:
          destructiveHint: true
      action: deny

    - name: approve-writes
      match:
        annotations:
          readOnlyHint: false
      action: require_approval
      approval:
        channel: slack
        timeout: '300s'

Rate limit expensive tools

- name: rate-limit-queries
  match:
    tool: 'run_*_query'
  action: rate_limit
  limits:
    max_calls: 50
    window: '1h'
    key: session

Spend limit payment tools

- name: payment-spend-cap
  match:
    tool: 'create_payment'
  action: spend_limit
  limits:
    max_spend:
      field: '$.amount'
      limit: 500
      currency: USD
      window: '1h'
      key: tool

- name: refund-spend-cap
  match:
    tool: 'create_refund'
  action: spend_limit
  limits:
    max_spend:
      field: '$.amount'
      limit: 200
      currency: USD
      window: '1h'
      key: session

Environment-specific rules

environment: production

policies:
  rules:
    - name: prod-no-destructive
      match:
        annotations:
          destructiveHint: true
        environment: production
      action: deny

    - name: prod-approve-writes
      match:
        annotations:
          readOnlyHint: false
        environment: production
      action: require_approval
      approval:
        channel: slack

Tool definition drift

Helio baselines every tool's definition — annotations, input/output schema, description, title — the first time it sees it. Definitions reach the cache three ways: the startup prime, any tools/list that passes through from a client, and the scheduled revalidation Helio runs on its own clock. Against a modern upstream, the tools/list requests Helio sends to baseline and re-check these definitions conform to the 2026-07-28 wire shape (headers and _meta), and relayed traffic takes the same shape — the relay leg is version-tagged by the detected (or pinned) upstream era. The fingerprint covers the entire tool definition object, including non-standard fields. If a later tools/list reports a different definition for the same tool — for example a tool that was readOnlyHint: true when you wrote your policy turning destructive, or a description gaining injected instructions — Helio marks the tool as drifted, writes an audit record (policy_decision: tool_drift), and gates subsequent calls to it:

policies:
  on_tool_drift: block # block (default) | require_approval | log
  • block (default): calls to a drifted tool are denied with tool_definition_drift feedback until the proxy is restarted (which re-baselines) or the upstream reverts the change.
  • require_approval: each call to a drifted tool is escalated through the approval channel.
  • log: drift is audited and calls proceed, but policy rules are evaluated against both the baseline annotations (the definition you reviewed) and the current upstream claim — the stricter decision wins, so a drifted tool can never weaken enforcement in either direction. When stricter-of-both compares actions, dry_run outranks the limit actions (rate_limit, spend_limit) because it never forwards, and a conflict between rate_limit and spend_limit resolves to spend_limit. Logged calls carry the drift detail in the audit record's evidence_chain.tool_drift field (the active mode plus the per-aspect changes). The recorded mode is snapshotted when the call is gated, so it reflects the mode that was active at gate time even if the policy is hot-reloaded before the audit record is written.

Because the escalation ticket always routes to the dashboard channel, on_tool_drift: require_approval requires dashboard.enabled: true (startup-checked).

With block (the default), a call to a drifted tool is denied with structured self-repair feedback — captured live after an upstream changed a tool's description mid-session:

{
  "jsonrpc": "2.0",
  "id": 2,
  "error": {
    "code": -32001,
    "message": "Tool definition drift: \"get_weather\" changed after baseline",
    "data": {
      "blocked": true,
      "reason": "tool_definition_drift",
      "rule": null,
      "rule_index": null,
      "action": "deny",
      "drifted_aspects": ["description"],
      "suggestion": "The definition of \"get_weather\" changed upstream (description) after Helio baselined it. An operator must review the change; restarting the proxy re-baselines, or the upstream can revert the change.",
      "retry_allowed": false
    }
  }
}

rule and rule_index are null because drift gating is a pipeline guard, not a policy rule. drifted_aspects names each part of the definition that changed: annotations, inputSchema, outputSchema, description, title, duplicate (repeated tool name), or other (non-standard fields).

Policy rules always see the baseline annotations for non-drifted tools. Reverting the upstream definition to its baseline clears the drift state (audited as tool_drift_reverted).

Precedence: drift gating overrides explicit allow rules and per-rule action: dry_run. Global policies.dry_run: true still simulates everything — a drifted call in global dry-run is reported with would_forward: false and never forwarded.

Duplicate names: a tools/list that repeats a tool name is treated as drift for that tool (aspect duplicate) — the definition is ambiguous, so Helio fails closed until the upstream returns a unique definition. Without this, a payload that lists the same name twice (one malicious entry, one matching the baseline) could otherwise suppress drift detection while clients bind to the malicious duplicate.

This closes the MCP "rug-pull" class of attack, where a tool definition changes after review so a one-time approval gives no lasting protection.

Limitation: baselines are per-process. A restart re-baselines from whatever the upstream currently reports, so review drift audit records before restarting.

Proactive revalidation

Drift is only caught when Helio sees the definition again. Under the MCP 2026-07-28 revision an upstream can advertise a cache lifetime for its tool list, so a well-behaved client may not re-issue tools/list for minutes or hours — a definition that changes right after a client caches it would go unnoticed until that cache expires. Helio therefore re-checks on its own clock instead of waiting for downstream traffic:

policies:
  on_tool_drift: block
  tool_revalidation:
    enabled: true
    interval: 5m
    max_advertised_ttl: 5m

This is on by default (interval: 5m) — omitting the section gives you the same behavior. The first tick lands one interval after the startup prime first succeeds; a proxy that has never primed successfully keeps retrying with startup backoff instead. Revalidation reuses the same synthetic tools/list the prime uses, so drift it finds is fingerprinted, audited (policy_decision: tool_drift), and gated exactly like drift seen on a pass-through call. The section is hot-reloadable: enabling, disabling, or retiming it takes effect on the next tick, without a restart.

A failed revalidation is a single stderr line, and nothing else changes — the last known-good baselines stay in place and the cadence is preserved (no backoff, no re-priming storm):

[helio] Tool revalidation failed: upstream unreachable — keeping the last baselines; next attempt in 300000ms

A successful revalidation is silent unless it finds drift, which logs and audits through the normal drift path.

Advertised cache lifetime: when policies.tool_revalidation.enabled is true (the default), Helio clamps result.ttlMs on outgoing tools/list responses down to max_advertised_ttl before relaying them to the caller — downward-only: a ttlMs already at or below the cap is left untouched, and a response that carries no ttlMs never gains one. The clamp applies to tools/list responses only; nothing else touches the wire body. cacheScope passes through untouched, on purpose — Helio baselines and vouches for tool definitions, not for who may see them, and its own tools/list view is not caller-varying, so it has no basis to alter a scope hint the upstream set. This keeps a caller from trusting an upstream-advertised cache TTL for longer than Helio itself re-checks that definition for drift. See Configuration Reference for the tool_revalidation schema.

See Also