Skip to content

Governance

Every tool call runs through wrappers composed in build_agent. Source: felix.manifests.builder in felix-run/felix.

model → Approvals → LLM Judge → Guardrails → Limits
→ Content screen → Command screen → Policies → Secret masking → inner tool

Applied 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.

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

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 — block denies (inbound: 503), quarantine replaces the content — and emits felix_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 remote http(s) image — which the provider fetches itself, so it cannot be screened as sent — are unavailable: block answers 503, quarantine removes the image. A transcriber error emits felix_control_unavailable{control="image_screening"}. An image a tool returns is screened by the content-screening wrapper on its own tool_image surface and is always quarantined — block denies 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 as felix_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 /internal that is not a user message.
  • 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 WARNING with felix_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.providers is 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.

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.

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.

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.

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.

  1. Canonicalize args → SHA-256 call signature (optionally bound to principal).
  2. Lookup / insert pending row; return [approval required] … to the model.
  3. Operator decides via POST /approvals/{id}/decide.
  4. Retry with same args finds approved / denied / still pending.

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 with emit_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.

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.

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.

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:

  1. Re-read. Every file is read back from the object store and checked against the digest its skill_file row recorded at save. Missing or altered bytes fail the gate.
  2. Re-validate. The bundle is validated again; a version that no longer validates is blocked.
  3. 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.
  4. Policy. A fail scan (any critical or high finding) blocks always, whoever asks — no setting turns that off. The policy then raises the bar: min_quality, block_on_advisory, require_eval and min_eval_uplift, from FELIX_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.

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 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.

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.

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.

  • 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’s evals/ 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=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) and felix eval
  • FELIX_AUTH_MODE=none, which is what make dev sets
  • 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.