Governance
Every tool call runs through wrappers composed in build_agent. Source: felix.manifests.builder in felix-run/felix.
Stack (call direction)
Section titled “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
Section titled “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, case-sensitive) |
| 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 — every untrusted tool, plus whatever tools names; 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
Section titled “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 decider screener (
content_screening.decider) is held to the same rule and independently of the model: with both set, either one flagging flags and either one unable to run leaves the text unscreened. A decider whose route no longer resolves counts as unavailable. Both read the whole text a window at a time; tool output longer than eight windows is reported unavailable rather than screened on its first window and admitted. - The image screener (
content_screening.image_model) is held to the same rule per image. A transcriber error, a transcript that did not finish normally (truncated, refused, filtered) or came back empty, and any remotehttp(s)image — which the provider fetches itself, so it cannot be screened as sent — are unavailable:blockanswers503,quarantineremoves the image. A transcriber error emitsfelix_control_unavailable{control="image_screening"}. An image a tool returns is screened by the content-screening wrapper on its owntool_imagesurface and is always quarantined —blockdenies the call on its text, not on its images — and quarantined tool text takes the tool’s images with it.- Without
image_model, an untrusted tool’s images are quarantined outright (fail closed), and the action is counted asfelix_content_screening{action="image_unscreened"}. - An after-tool hook that rewrites the text removes the images too.
- At the door, images on a caller’s non-user messages (
role: tool,assistant) are removed, because inbound screening reads only user turns. The same applies to a queue write-back to/internalthat is not a user message.
- Without
- Decider judges fall back to the model judge and then the heuristic on any failure, and do not judge text longer than 8,000 characters — a prefix judge would pass filler followed by a payload.
- 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.
Egress
Section titled “Egress”Outbound URLs are checked in two halves, deliberately in different places.
At parse. Scheme, plain http outside development, internal names and suffixes
(.svc, .cluster.local, .internal, metadata.google.internal), and IP literals —
including the decimal form http://2130706433/, which is 127.0.0.1 to a resolver but not an
IP literal to ipaddress. None of this needs DNS. Resolving here used to run a blocking
getaddrinfo inside a pydantic validator, on the API event loop, once per ref on every
manifest read and write.
At dial. The address check. felix.security.egress supplies an httpx transport whose
network backend resolves once, validates every returned address, and connects to one of the
addresses it validated. That is the difference between enforcement and advice: resolving in a
guard and letting the client resolve again is two independent lookups, and a name that answers
them differently — DNS rebinding with a short TTL, or a nameserver that simply drops the
guard’s query while answering the client’s — reaches whatever it likes. One blocked answer
refuses the whole name, so a round-robin containing a private address cannot be reached by
retrying. A lookup that fails or times out refuses the dial, which is safe precisely because
this is the connection.
TLS is unaffected: certificate verification and SNI run against the hostname the caller asked for while the socket goes to the pinned address.
proxy, mounts and uds are refused rather than ignored, since each chooses a destination
the guard never sees. Supplying a transport also disables httpx’s environment proxies, so
HTTP_PROXY and HTTPS_PROXY do not apply to these calls — worth knowing if your egress
containment is a proxy allowlist.
Per agent: spec.network.allow_hosts
Section titled “Per agent: spec.network.allow_hosts”The halves above decide whether an address is safe for anyone; allow_hosts
(felix.security.host_allowlist) decides whether a host is one this agent may reach. The builder
turns it into an EgressBound: the agent’s list beneath every list above it. Its children compile
inside that bound, and the compile memo records the bound each child was built under, so a child
shared by two parents is rebuilt rather than reused from the looser one. The fetch tool checks the
bound before opening a client, and GuardedAsyncTransport checks it again for every request the
client sends, so redirect hops are covered. The browser is refused under a list rather than bound by it: its request interceptor does not
see every socket a page opens (WebRTC over TCP, WebTransport in a worker, worklet module fetches),
so a list it enforced would look like a control and not be one. Every check runs before anything resolves, since a
lookup of a refused name is itself a leak to that name’s nameserver. Matching uses the request’s
ASCII raw_host, the form patterns are written in. The bound is a plain value: a background
task child is compiled in the worker with none of its parents on the stack, so their lists ride
its durable run (egress_ceilings), are re-validated onto the run’s context, and seed the root of
that compile once.
The bound is opt-in per client: only the clients a tool builds for the model carry one, so the operator’s own traffic (hooks, webhooks, push) is never refused by an agent’s list.
The browser is a partial exception
Section titled “The browser is a partial exception”spec.browser_tools hands its URL to Chromium, which resolves for itself. The navigation host
is pinned — the browser launches with --host-resolver-rules=MAP <host> <validated address> —
but cross-host subresources and redirects are checked per request without being pinned, because
denying them outright breaks any page that loads assets from a CDN. This is the only outbound
path where the URL comes from the model rather than a manifest, so treat a page’s cross-host
assets as reaching whatever they resolve to at load time.
Limits state
Section titled “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
Section titled “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.
Reaching the human. Step 2 emits approval_required as a side event — a per-thread
in-process asyncio.Queue — which the react loop drains into the stream. That works when the agent
and the stream are in one process, and a durable run is exactly when they are not: the agent is
in the worker, the stream is served by the API. Two things follow, and they are the whole shape of
the problem:
- The queue is not the API↔worker channel. Both ends of it are already in the worker.
- A fiber calls
agent.invoke, which runs the loop withemit_events=False, so the drain never executes at all.
So the row written in step 2 is the only durable record, and both channels read from it.
GET /approvals always has. Since felix-run/felix#246 the durable stream does too — it reads the
pending rows for the run’s thread and rebuilds the frame, gated on approvals:read. Rebuilding
rather than bridging is deliberate: a pub/sub bridge is at-most-once and here the message is the
prompt, so a dropped one costs a decision rather than latency. The same reasoning is why the
transcript tail reads the session log instead of forwarding display events.
Skills
Section titled “Skills”Skill bodies are the one place model-written text can become instructions, so they are held to
a review step. The catalog names skills in the system tier, and activate_skill returns a body as
instructions. A body from the bundled directory, FELIX_SKILLS_DIR or an uploaded object-store key
is operator-written. A body from the tenant skill library reaches a catalog only as a published
version, and an agent’s save (create_skill, update_skill) is a draft no catalog loads.
Publishing is done by an operator, or — with spec.skill_authoring.mode: publish — by the agent
itself, which the schema allows only behind an approvals rule that gates every create_skill and
update_skill call. So every library body in a catalog was either reviewed by an operator or
approved by a person before it was saved. An agent’s edit of a version an operator wrote is never
published automatically, whatever the mode.
create_skill and update_skill are trusted local tools and are not content-screened by default;
their output is the library’s own JSON. What they save is scanned on save and again on publish,
and their approval preview is the SKILL.md the harness renders from the arguments, so an approver
reads the instructions rather than the model’s summary of them.
Approvals bind parent_version
Section titled “Approvals bind parent_version”update_skill names the version it edits in a required parent_version argument, which must be the
skill’s newest version that is not a rejected draft. Its approval preview shows that version and
every file the edit keeps from it, each by sha256. The call signature is a hash of the arguments, so
a grant found later — by a retry, another replica or a resumed fiber — authorizes an edit of that
version only, and the call is refused parent_changed if the skill has moved past it. Naming a
rejected draft is refused parent_rejected, so a rejected draft’s files — a bad scripts/ file,
say — cannot ride into the next agent draft. An operator’s save keeps building on the absolute newest
version, consciously.
An agent’s save may carry evals/ files only unchanged from its parent, byte for byte, so every
evals/ file traces back to an operator’s save.
The skill publish gate
Section titled “The skill publish gate”Every publish and rollback — an operator’s through /skill-library, or an agent’s in publish
mode — runs the same gate on the stored version, and the management preview runs the same check
without acting on it:
- Re-read. Every file is read back from the object store and checked against the digest its
skill_filerow recorded at save. Missing or altered bytes fail the gate. - Re-validate. The bundle is validated again; a version that no longer validates is blocked.
- Re-scan. The quality review and the security scan run again on the bytes, not from the row: an operator can write the store directly, and the scanner’s rules may have grown since the draft was saved.
- Policy. A
failscan (anycriticalorhighfinding) blocks always, whoever asks — no setting turns that off. The policy then raises the bar:min_quality,block_on_advisory,require_evalandmin_eval_uplift, fromFELIX_SKILL_PUBLISH_*tightened by the tenant’s own policy. A tenant can never lower the deployment’s bar.
A rollback is judged without require_eval and min_eval_uplift: it returns to a version that was
already live, often under time pressure, and the requirement may have been set after it went live.
The scan, validation and quality floor still apply. A blocked publish is audited with its reasons.
Imported text is judged as an import. A version fetched from GitHub (source: import), and every
version built on one (lineage_import) — an agent’s or operator’s edit, an operator save naming no
parent, an agent’s save carrying a copy of a file of imported text — is gated as an import whoever
saved it: an advisory scan blocks it whatever block_on_advisory says, only the bundle’s own
evals/ count toward an evaluation requirement, and an agent’s edit of one always waits for a
person. The skill tools mark what they relay of such a skill as untrusted, so content screening
reads it as it reads an untrusted tool’s output, and a manifest with screening off still gets the
injection-marker floor over it. The skill suggester’s decision model sees such a skill only by its
listed description, fenced as third-party text, never by its body. See
Skill import.
Promoted text is judged as an agent’s. A version a person proposes from their personal library
(source: promoted, see Personal libraries) has been
read by no reviewer of the tenant’s, and an agent may have written it there. So the copy rule runs
on it and it inherits the personal version’s lineage_import, only an evaluation on its bundle’s own
evals/ counts, its evals/ must equal the tenant parent’s exactly, an undecided one cannot be
adopted, and an agent’s edit of it, or of any undecided draft built on it, waits for a person. The
rules per source are one table, skills/sources.py. A personal publish runs this same gate,
except that evaluations are kept for the tenant’s library only: where the policy requires one, a
personal version is blocked rather than let through.
The copy rule (skills/copy_rule.py) decides what counts as a copy in an agent’s save. A file
matches a file of imported text by its bytes (every non-empty binary asset, every text file that is
not empty or whitespace-only), or by a digest of its normalized text: NFKC, format characters
(Cf) removed, casefolded, whitespace runs collapsed, stripped, over a SKILL.md’s body alone, and
only from 32 normalized characters. A file kept byte for byte from a parent that carries no
imported text is not a copy. A paraphrase, a partial copy and any change of a non-whitespace
character (a look-alike letter included) pass. Both digests are a stored format on skill_file:
changing how either is computed needs a migration that re-hashes the rows. Versions saved before
migration 0032 match by bytes only.
Adopting clears the mark going forward. POST /skill-library/{name}/versions/{version}/adopt
(skills:write, a required reason, audited skill_adopted) saves the version’s files unchanged as
an operator draft with adopted_from set and lineage_import false. From there the gate judges it
by the tenant’s own policy, the skill tools stop marking it untrusted, so content screening and the
marker floor no longer apply to it, and the suggester describes it like any operator skill. Edits
built on it stay clean. The adopted version and everything before it keep the mark; an adopted
version’s files still count as imported text to the copy rule, so an agent copying them into
another skill is still caught. save_draft holds any save carrying adopted_from to the version it
names (its parent, an adoptable version, exactly its files) whoever calls it, and no agent tool
reaches the adopt.
Redaction on management reads
Section titled “Redaction on management reads”skills:read is the lower scope, and an agent writes some of what it reads. So every
/skill-library read and write response is secret-redacted: file text, and the reason, description,
decision note, review-check and security-issue messages a saver or the scan wrote, and feedback
bodies, suggested patches, evaluation scenarios, judge reasons and job errors. Redaction is the
default — only a fixed structural allowlist (ids, names, versions, statuses, sources, paths,
digests) passes through — so a field added later is redacted unless someone decides otherwise.
Feedback cannot start a rewrite
Section titled “Feedback cannot start a rewrite”Feedback changes nothing: it waits as pending until a person decides it. An agent’s feedback
cannot start a rewrite. Only a person’s accept with improve lets the worker rewrite the skill,
and every rewrite lands as a draft (source: agent, author skill-improver) in the review
queue, published only through the same gate as any other draft. The improvement model is handed the
current SKILL.md, the feedback and any suggested patch, each fenced as untrusted data in a region it
cannot close; its output is validated like any save and must keep the skill’s name, or the feedback
fails. If the skill has moved past the version the feedback names, the feedback fails
parent_changed rather than rebasing onto what may be another agent’s unreviewed draft.
Evaluation trust
Section titled “Evaluation trust”The evaluation’s answering model gets the skill body fenced as “reference skill instructions
(untrusted data)”. The judge (FELIX_SKILL_EVAL_JUDGE_MODEL, the eval judge every rubric uses)
reads the scenario prompt, the criteria and each answer fenced, told all three are data and to
ignore any instruction or score inside them; it never sees the skill and does not know which answer
had it. A judge call that yields no usable score fails the evaluation instead of falling back to the
heuristic, so a broken judge route cannot satisfy an eval gate.
For an agent’s version, only bundle scenarios count. Generated and default scenarios are written
from the skill’s own text — the agent’s — so an agent could steer the test it is graded on. Only an
evaluation on the bundle’s own evals/ scenarios counts toward require_eval and min_eval_uplift
for a version an agent wrote; the others still run and are shown with counts_for_gate: false. A
version’s first evaluation fixes its scenario set, and every later evaluation reuses it, so
rerunning cannot shop for a kinder set.
Skill jobs are bounded
Section titled “Skill jobs are bounded”The worker’s improvements and evaluations (skill_jobs) are not a manifest’s run, so spec.limits
does not apply. Every model call is metered to the skill’s tenant. What bounds them:
| Bound | Value |
|---|---|
| Output tokens per call | FELIX_SKILL_EVAL_MAX_TOKENS (2048), FELIX_SKILL_IMPROVE_MAX_TOKENS (8192) |
| Calls per evaluation | four per scenario, at most FELIX_SKILL_EVAL_MAX_SCENARIOS (10) scenarios |
| Wall clock per job | FELIX_SKILL_JOB_DEADLINE_SECONDS (1200); past it the job is failed |
| Attempts per job | three claims; the next fails it attempts_exhausted |
| Per tenant | FELIX_SKILL_JOBS_MAX_QUEUED (20) queued or running, FELIX_SKILL_JOBS_DAILY_LIMIT (200) created per UTC day; 429 skill_jobs_cap_reached past either |
| Per sweep | one sweep at a time across every worker, at most five jobs of each kind |
A job heartbeats its claim between model calls and stops as soon as the heartbeat does not land, so a worker whose claim lapsed (no heartbeat for ten minutes) and was taken over cannot write over the one that took it. Claims are fair across tenants: the next job is the oldest due job of the tenant whose last claim is oldest, so a tenant with a deep queue cannot starve one with a single job.
Residual risks
Section titled “Residual risks”- An eval gate is a quality bar, not a security control. In
deploy/GOVERNANCE.md’s words: the scenario prompt and criteria still come from text someone wrote — an operator’sevals/for a counting run — and the judge is a model; a skill body that persuades the answering model to write an answer that flatters itself is not something fencing can prevent. - Feedback reaches a reviewer. An agent that absorbed injected text can get that text in front of a reviewer, and no further.
- Unlisted operator uploads. The object store has no listing, so an upload at a version the
library never held is not flagged as
shadows_operator_upload— though a ref pinning it is still served the upload. - Some caps count, then insert. Two requests racing at the feedback cap or a job cap can both get in; those caps bound a looping agent and a tenant’s spend, not an exact count. The pending-draft cap recounts after the save and backs out, so it errs the other way and may refuse one too many.
- The scan is heuristic. It looks for credentials, risky script constructs, prompt-injection phrasing, obfuscation, URLs to executables or piped remote content, and oversized files, with bounded patterns. A body it does not recognise as dangerous passes it; the review step is the control.
Fatal tools
Section titled “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.
Targeting, precedence, and who can hold a scope
Section titled “Targeting, precedence, and who can hold a scope”content_screening.tools is additive. Screening covers every untrusted tool — anything
whose transport is not local, plus anything sourced from mcp:, peer:, a2a: or queue:
— and, in addition, whatever tools names. Naming a trusted local tool extends screening to
it; it cannot narrow screening away from anything. There is deliberately no way to turn
screening off for an untrusted tool, because that is the thing screening exists to prevent.
For cost, the levers are content_screening.model and on_flag.
Approvals is the only control that selects one rule — policies and judges apply every match, so for them more matches only tighten. When several approval rules match a tool, a rule naming it literally wins over one matching by pattern, and among equals the last declared wins. That makes glob targeting non-weakening: a pattern can only gate a tool nothing gated before, and never displaces a stricter literal rule.
Policies need a caller who can hold a scope
Section titled “Policies need a caller who can hold a scope”apply_policies denies when a required scope is absent — “no scopes” must not read as “all
scopes”. Several contexts carry an empty scope set by construction, and in those a policied
tool always denies:
- scheduled jobs (
cron) andfelix eval FELIX_AUTH_MODE=none, which is whatmake devsets- a durable fiber with no recorded caller — one enqueued outside a request context, or before fibers began recording authority
A durable fiber started from a request is not in that list: start_durable_chat records the
caller’s scopes on the row and the resume rebuilds an AuthContext from them, so spec.policies
and execution.mode: durable do work together. The principal stays fiber rather than the person
— on_behalf_of carries who it is for — so an audit row never claims a human took an action a
worker took. Authority in durable state is bounded three ways: never wider than the caller’s own
scope set, never outliving the run’s expires_at, and clamped to the token’s exp where it has
one. See deploy/GOVERNANCE.md in the harness repo for the table.
manifests/governed.yaml policies calculator on tools:calc, so it denies its own
calculator under make dev. Mint a token carrying the scope rather than removing the policy.
A manifest in this shape is named at compile with a WARNING and felix_policy_unsatisfiable.
Policy scopes are matched literally: the admin / * bypass and the x:write implies
x:read rule that the management API applies do not apply here.