Governance
Every tool call runs through wrappers composed in build_agent. Source: felix.manifests.builder in felix-run/felix.
Stack (call direction)
model → Approvals → LLM Judge → Guardrails → Limits → Content screen → Command screen → Policies → Secret masking → inner toolApplied innermost-first at build time so the runtime order above holds. Secret masking is always on.
Wrappers return deny strings via deny_output (never throw) so the model can adapt. Outer post-call wrappers must check is_wrapper_deny and pass denies through verbatim.
Layers
| Layer | Manifest | Decision |
|---|---|---|
| Secret masking | always | Strip harness credentials from tool output before the model / audit sees them |
| Policies | spec.policies |
Principal must hold all required_scopes for matched tools (glob: exact, prefix*, *) |
| Command screening | spec.command_screening |
Pre-call rules over every execution-bearing argument — command, cmd, code, script, stdin, argv, shell_command, args — and, for sandbox / container transports, every string argument, since there the payload is the program |
| Content screening | spec.content_screening |
Post-call injection classifier on tool results; honours on_flag when the screener itself is unavailable |
| Limits | spec.limits |
Cap tool calls, wall clock, peer hops, input/output tokens (absolute ceilings apply) |
| Guardrails | spec.guardrails |
PII-style filters on input/output/(final response); redact or block |
| LLM judges | spec.guardrails.judges |
Score tool (or final) output against criteria; below threshold → deny |
| Approvals | spec.approvals |
Human gate by call signature; Postgres is system of record |
When a control cannot run
A control that cannot run has not cleared anything, so unavailability is never treated as a pass.
- The LLM injection screener is tri-state: flagged, clean, or unavailable. A missing key, an
expired credential, a 429, a provider outage, or a reply whose score cannot be parsed all yield
unavailable, which honours
content_screening.on_flag—blockdenies (inbound:503),quarantinereplaces the content — and emitsfelix_control_unavailable{control="content_screening"}. Marker-based screening still runs regardless of model availability. - The PII guardrail falls back to three regexes (email, US SSN, card-like digit runs) when
Presidio or its spaCy model is absent. That fallback is announced at
WARNINGwithfelix_control_degraded{control="pii"}. Only deterministic outcomes are cached — a transient engine-init failure retries on next use rather than pinning the process to the regex path. guardrails.providersis a closed set, so a typo is a compile error rather than a manifest that reports guardrails enabled while applying no wrapper.
Alert on felix_control_unavailable and felix_control_degraded: both mean a control named in a
manifest is not the control actually in force.
Limits state
LimitState lives on RequestContext (contextvars): tool-call / peer-hop counters, wall-clock abort, cumulative tokens, audit count. Patterns check token budgets before each model call; the limits wrapper checks wall clock / tool calls / peer hops before each tool invoke. Thread ctx.signal through outbound HTTP so wall-clock aborts cancel in-flight work.
Approvals
- Canonicalize args → SHA-256 call signature (optionally bound to principal).
- Lookup / insert
pendingrow; return[approval required] …to the model. - Operator decides via
POST /approvals/{id}/decide. - Retry with same args finds
approved/denied/ stillpending.
Unattended runs (scheduler, eval replay) refuse approval-gated tools unless allow_unattended: true. TTL / one-shot options bound standing grants.
Fatal tools
fatal=True on a tool aborts the loop with the error as the final message (no model recovery). Use sparingly — quotas, hard security failures, unusable config.
Tool targeting for policies, approvals, and judges matches by glob so MCP tools named server__* stay gated even if the remote renames suffixes.