Action classes
This page explains what action classes are, where the vocabulary comes from, and how a raw (method, host, path) tuple gets turned into one. If you’ve read The enforcement pipeline, you’ll recognize this as what the normalizer does.
Why a fixed vocabulary?
Section titled “Why a fixed vocabulary?”Imagine writing a policy that says “the agent cannot exfiltrate data”. Without a fixed vocabulary, you’d have to enumerate every host and path that could be used for exfiltration: every paste service, every issue tracker, every webhook endpoint, every SMTP relay. The list is infinite, and the next service is always invented after you wrote your policy.
Action classes invert the problem. Instead of policies enumerating destinations, they speak in terms of what the agent is doing semantically. A POST to paste.rs/, a POST to gist.github.com/api/v1/gists, and an SMTP send are all instances of communication.external.send — and a single forbid rule covers all of them.
Three properties make this useful in practice:
- Transport-independent. The class describes the action, not how it’s delivered.
payment.transferis the same class whether it’s a Stripe API call or a banking SOAP request. The protocol rule is simple: transport names and provider names do not appear in action identifiers. - Closed set. New classes are added by editing a registry, not at runtime. The list is finite and reviewable.
- Deterministic mapping. A given
(method, host, path)always produces the same class. There’s no LLM in the loop on the hot path — see Architecture & invariants for why this matters.
Benefits and tradeoffs
Section titled “Benefits and tradeoffs”Benefits:
- Portable policies. If your rule is “don’t send data outside the company”, it should keep working when the agent switches from one email API to another, or when a connector changes from REST to gRPC. The policy cares about
communication.external.send; the connector and mapping table care about the transport details. - Readable audit logs. Instead of asking whether
POST /v1/payment_intents/:id/confirmis dangerous, you seepayment.transferand can group similar events across providers. - Predictable enforcement. The hot path stays a table lookup plus policy evaluation. There is no probabilistic classifier deciding what the action means at request time.
Costs:
- Mapping maintenance. Somebody has to keep provider endpoints classified. A fixed vocabulary is only useful when important endpoints map to the right class.
- Coarse semantics. A class like
communication.external.sendis intentionally broad. If a policy needs provider-specific nuance, that nuance has to live in resource fields or provider-specific classes. - Slower evolution. New action semantics require a registry change and a mapping update. OpenFirma chooses that maintenance burden deliberately because the alternative is policy logic tied to every vendor URL shape.
The registry
Section titled “The registry”OpenFirma ships with 44 canonical action classes. The base 15 come from FEP v0.1, the versioned Firma protocol specification that defines the canonical registry and its invariants. The remaining classes extend that base for GitHub (12), Stripe (12), and Gmail (5).
The 15 FEP v0.1 base classes give you a feel for the granularity:
account.permission.change filesystem.deletebrowser.purchase filesystem.readcommunication.external.send filesystem.writecommunication.internal.send memory.cross_namespace.readcredential.read memory.cross_namespace.writecredential.write payment.purchase payment.transfer system.execute system.installCategories are dotted: category.subcategory.verb. The category captures what kind of capability (communication, credentials, filesystem, payment, memory, system, and so on). The subcategory and verb capture the specific action. A policy can match a single class (forbid payment.transfer) or, with Cedar, a set (action in [filesystem.write, filesystem.delete]).
The provider-specific extensions (GitHub, Stripe, Gmail) cover endpoints that don’t map cleanly onto the FEP base — for example, GitHub’s repo.lifecycle or Stripe’s payment.refund. These have their own subtree but follow the same naming rule: still no transport, still no service name in the identifier itself. The provider lives elsewhere — see “Resources” below.
How a request becomes a class
Section titled “How a request becomes a class”The Sidecar’s normalizer holds a mapping table loaded from TOML at startup. Each entry binds a (method, host, path) tuple to an action class:
[[rules]]method = "POST"host = "api.stripe.com"path = "/v1/payment_intents"action_class = "payment.transfer"
[[rules]]method = "GET"host = "wttr.in"path = "*"action_class = "communication.external.send"path may be exact, may contain * wildcards, and host may use leading wildcards (*.anthropic.com). The lookup is deterministic: the first matching rule wins, and tie-breaking is left-to-right specificity.
OpenFirma ships three default mapping files under crates/firma-sidecar/config/mappings/:
| File | Covers |
|---|---|
github.toml | 44 GitHub REST endpoints → 12 action classes |
stripe.toml | 88 Stripe REST endpoints → 14 action classes |
gmail.toml | 41 Gmail REST endpoints → 7 action classes |
You compose them, plus any project-specific rules, in firma.toml:
[sidecar.mapping]rules_path = "config/mappings/default.toml"rules_paths = [ "crates/firma-sidecar/config/mappings/github.toml", "crates/firma-sidecar/config/mappings/stripe.toml",]default_protected = truedefault_protected = true means a request that matches no rule is treated as a protected action with no class assigned — and because the pipeline is fail-closed, that is a DENY. Set it to false only when you explicitly want unmapped traffic to pass through (the bundled demo does this).
For a hands-on walkthrough of writing your own rules, see Extend the action-class mapping.
Resources: the other half of the picture
Section titled “Resources: the other half of the picture”A class tells the policy what kind of action. The resource tells the policy what it’s acting on. The Sidecar attaches a resource to every envelope as a small key-value map:
host: "api.github.com"path: "/repos/octocat/hello-world/issues"provider: "github"host and path are always present. provider is set only when the request host exact-matches a known allowlist:
| Host pattern | provider |
|---|---|
api.github.com / github.com | github |
api.stripe.com | stripe |
gmail.googleapis.com | gmail |
This split is intentional. Identifying the provider lets policies write rules like “no Stripe transfers above a threshold” without binding the rule to a specific URL path. Not setting it for unknown hosts keeps the namespace honest — the Sidecar refuses to guess.
In Cedar, the resource shows up as a Firma::Resource::"<host><path>" UID:
forbid ( principal, action == Firma::Action::"communication.external.send", resource == Firma::Resource::"paste.rs/");Where to go next
Section titled “Where to go next”- Capabilities — how a token authorizes a specific
action_class+resource_scopefor an agent. - Policies — how to write Cedar rules in terms of classes and resources.
- Extend the action-class mapping — add a new SaaS provider without forking.