Skip to content

feat(sidecar): govern Composio tool execution - #370

Open
veeso wants to merge 21 commits into
mainfrom
feat/fir-435-govern-composio-gateway-with-openfirma-enforcement
Open

feat(sidecar): govern Composio tool execution#370
veeso wants to merge 21 commits into
mainfrom
feat/fir-435-govern-composio-gateway-with-openfirma-enforcement

Conversation

@veeso

@veeso veeso commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Why

The personal assistant reaches Gmail and Google Calendar through Composio, which means every one of those actions leaves the agent over the same two hostnames and the same handful of web addresses. Looking at the address alone cannot tell reading an inbox apart from emptying it, so none of those actions could be governed by policy. Closes FIR-435. Slack and Notion are out of scope here and tracked in FIR-458.

What Changed

  • The Sidecar now reads Composio requests and works out which tool was actually asked for, then runs that tool through the normal permission and policy checks before anything leaves the machine.
  • Reviewed catalogs for Gmail (63 tools) and Google Calendar (49) are shipped with the Sidecar. All 112 tools were classified by hand, and a tool that is not in a catalog is refused. That includes every Slack and Notion tool until FIR-458 lands reviewed catalogs for them.
  • Linking, changing, or removing a connected account or auth configuration is treated as a permission change and goes through the same checks, so an agent cannot quietly widen which accounts it can reach. Read-only listing still passes through.
  • Policies and the audit log now name a tool in a way that does not depend on how the request was sent, and they can also match on the toolkit, the account selector, the session, and the position of a tool inside a batch.
  • A request that asks for several tools at once is all or nothing. Every tool in it is checked and recorded, and if any one of them is refused, nothing is sent upstream at all.
  • Google Calendar needed four new permission categories, so reading, creating, changing, and deleting events can be granted separately.
  • Anything the Sidecar cannot understand is refused rather than let through: unknown addresses, unreadable payloads, custom tools, raw pass-through calls, and remote shell or workbench tools. In monitor mode those requests are forwarded and recorded instead of blocked, so observe-only stays observe-only.
  • If the configuration names the Composio hostnames but leaves them uninspected, the Sidecar says so at startup instead of silently losing visibility.

How to Review

This change cannot be usefully split. The parts depend on each other in a straight line, so any smaller pull request would land code on main that nothing calls yet, which is the exact problem this branch was written to avoid. Instead, here is the short path through it.

Most of the added snapshot lines are machine-generated tool snapshots that nobody needs to read. They are marked as generated in .gitattributes, so GitHub already collapses them. The real reading is the Rust, in five sittings, in this order.

1. The shared code path, about 25 lines. crates/firma-core/src/envelope.rs adds policy_resource_display(), and it replaces resource_display() at 12 call sites, one in crates/firma-sidecar/src/enforcement/capability_validation.rs, four in crates/firma-sidecar/src/enforcement/constraint_enforcement.rs, and seven in crates/firma-sidecar/src/pipeline.rs. This is the only part of the branch that runs for traffic that has nothing to do with Composio, so it deserves the most attention per line. What to confirm: with no policy_resource key present, the new method returns exactly what the old one did, which makes every one of those call sites a no-op for existing traffic.

2. The trust boundary, about 800 lines. crates/firma-sidecar/src/composio.rs is where attacker-controlled bytes are interpreted. Read it asking two questions. Does every shape the decoder does not recognise end in a refusal rather than a fallthrough? Do any tool arguments survive into an envelope, a resource, or a log line? There is a custom JSON parser at the bottom of the file, and the reason it exists is that the Sidecar decides on a parse of the body but forwards the original bytes, so a duplicate key that this parser and Composio's parser resolve differently would be an authorisation bypass. The account lifecycle handling is new in this revision: writes to the connected-account and auth-configuration routes become governed permission changes instead of passing through.

3. The all-or-nothing rule, about 600 lines. enforce_composite in crates/firma-sidecar/src/pipeline.rs and the handle_composio_* functions in crates/firma-sidecar/src/handler.rs. One thing matters here: the upstream request is sent at most once when everything is allowed, and exactly zero times when anything is refused. Monitor mode is the awkward case, because it has to forward the request while still recording what would have been blocked, and it now does that for undecodable requests and protocol upgrades too, not only for policy refusals.

4. The classifications, two files, no code. The .mapping.json files under crates/firma-sidecar/config/composio/. Each line assigns one Composio tool to one permission category. This is judgement, not correctness, and it is the part most likely to be wrong in a way tests cannot catch. The known judgement calls, such as the vacation auto-responder counting as sending mail, are written down in docs/architecture/composio-enforcement.md.

5. Everything else can be skimmed. The Cedar policy context is built in insert_composio_context in crates/firma-sidecar/src/enforcement/constraint_enforcement.rs and declared in crates/firma-core/firma.cedarschema. Catalog loading and validation is crates/firma-sidecar/src/composio/catalog.rs. The refresh tool is scripts/composio_catalog.py. Tests mostly enumerate refusal cases, and one drives a refusal through the real proxy and certificate interception path end to end.

Risks / Notes

  • The classification of each tool is a judgement call and is the most useful thing to review. The judgement calls with side effects, like mail filters that can forward messages elsewhere and calendar invitations that email arbitrary people, are documented for policy authors.
  • Both Composio hostnames must be intercepted rather than passed through untouched. If they are passed through, the tool name is hidden and nothing can be governed. The Sidecar now warns at startup when a configuration that references those hostnames leaves them uninspected, bypassed, or without strict interception.
  • The catalogs are built into the Sidecar, so adding newly released Composio tools means refreshing a catalog, classifying the new tools, and rebuilding. That is deliberate: a Composio release cannot quietly widen what an agent is allowed to do. Version pinning is strict for direct execution; the session routes carry no version, and that limit is documented.
  • No change to the shared audit format. A tool is identified by its category and its name in the audit record. Credentials, tool arguments, and provider responses are never recorded.

AI Assistance

Claude Opus 5 (claude-opus-5); review fixes with Claude Fable 5 (claude-fable-5).

https://claude.ai/code/session_01NRAaNueJjd9Z7rJsSjBW3e

@veeso
veeso force-pushed the feat/fir-435-govern-composio-gateway-with-openfirma-enforcement branch 4 times, most recently from f389105 to b2ca308 Compare July 27, 2026 14:16

struct StrictJsonVisitor;

impl<'de> Visitor<'de> for StrictJsonVisitor {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prevents duplicate object keys

@veeso
veeso marked this pull request as ready for review July 27, 2026 14:38
@veeso
veeso requested a review from a team July 27, 2026 14:38
Composio multiplexes every connected app onto two hosts and a handful of URL
shapes, so host and path alone cannot tell an inbox read from a mailbox
purge. The Sidecar now decodes Composio execution requests after TLS
termination, resolves the requested tool through a pinned local catalog, and
evaluates each logical action with the existing capability and Cedar stages.

Decoding covers hosted MCP `tools/call` under both protected hosts, direct
execution, Tool Router session execution, and the supported execution
meta-tools. Discovery and lifecycle requests pass through. Unknown routes,
JSON-RPC batches, malformed payloads, unpinned or unknown tools, custom
tools, raw proxy execution, and remote shell or workbench execution all fail
closed.

Policy and audit use a transport-independent
`composio://<toolkit>/<tool_slug>` resource, and Cedar gains optional
toolkit, slug, user, account, session, and batch-position context. Raw tool
arguments never reach envelopes, resources, or diagnostics.

`COMPOSIO_MULTI_EXECUTE_TOOL` is atomic: every child is evaluated and
audited, the original request is dispatched exactly once when all children
are admitted, and any blocking child means zero upstream dispatches.

Reviewed catalogs for Gmail, Google Calendar, and Slack pin toolkit version
20260721_00 and classify all 279 tools by hand. They are compiled into the
Sidecar and installed at startup, so governance does not depend on operator
configuration and a tool outside the catalogs is denied until a maintainer
classifies it. Google Calendar coverage adds four canonical action classes.

Claude-Session: https://claude.ai/code/session_01LN3ZoyBfmGnh9MP59ShBSu
@veeso
veeso force-pushed the feat/fir-435-govern-composio-gateway-with-openfirma-enforcement branch from b2ca308 to 7b9ba77 Compare July 27, 2026 15:06
@LukeMathWalker
LukeMathWalker self-requested a review July 27, 2026 18:07
veeso added 20 commits July 28, 2026 10:24
Slack was moved out of FIR-435 scope to FIR-458, so shipping 167
hand-classified Slack tools would put unreviewed authorization
decisions live at merge. Slack tool calls now fail closed with
unknown_tool like Notion until FIR-458 lands a reviewed catalog.

BREAKING CHANGE: Slack Composio tool calls are now denied at the
boundary instead of being classified by the shipped catalog.
Claude-Session: https://claude.ai/code/session_01NRAaNueJjd9Z7rJsSjBW3e
An empty action slice produced Dispatch { transport: None } with no
blocker, which the handler forwards upstream unenforced. The decoder
never emits empty batches, but that invariant lived in another module;
composite enforcement now denies FAIL_CLOSED with an audit record.
Claude-Session: https://claude.ai/code/session_01NRAaNueJjd9Z7rJsSjBW3e
Protocol-level denials (malformed payloads, unknown tools, protocol
upgrades on protected hosts) hard-blocked even in monitor mode,
contradicting the observe-only contract the pipeline gives every other
decision. Monitor mode now forwards these requests once, with no
injected credentials, and keeps the would-deny reason in the audit
record under the monitor_mode prefix.
Claude-Session: https://claude.ai/code/session_01NRAaNueJjd9Z7rJsSjBW3e
POST/PATCH/PUT/DELETE on connected_accounts and auth_configs, and the
Tool Router session link route, passed through with no capability or
Cedar evaluation, letting an agent expand its own reachable account
surface ungoverned. These writes now decode into one
account.permission.change logical action with a
composio://composio/<slug> resource. Read-only discovery stays
passthrough.
Claude-Session: https://claude.ai/code/session_01NRAaNueJjd9Z7rJsSjBW3e
The pinned catalogs load unconditionally, but the decoder only sees
traffic the proxy terminates, so a deployment could reference the
Composio hosts in its mapping rules while leaving them bypassed,
unintercepted, or non-strict and silently lose governance. Startup now
logs one warning per coverage gap for such deployments; configurations
that never reference the Composio hosts stay quiet.
Claude-Session: https://claude.ai/code/session_01NRAaNueJjd9Z7rJsSjBW3e
The vacation auto-responder emails an arbitrary body to every
correspondent, so communication.external.filter understated it: a
policy denying send but granting filter still had an outbound email
channel. Document the remaining escalation channels (filter
forwarding, calendar invite emails, batch-events multiplexing) for
policy authors.
Claude-Session: https://claude.ai/code/session_01NRAaNueJjd9Z7rJsSjBW3e
The envelope resource map kept the raw request path including the
query string, contradicting the module contract that logical envelopes
never retain tool arguments; a client could stuff arguments after '?'
and have them travel inside denial decisions. Also document the
version-pin asymmetry across routes, batch session-history counting,
and the credential-free monitor dispatch for fully-blocked batches.
Claude-Session: https://claude.ai/code/session_01NRAaNueJjd9Z7rJsSjBW3e
Add port-agnostic protected-host matching so a nonstandard port cannot
demote backend.composio.dev to generic normalization, note that the
custom-tools marker is best-effort next to the catalog gate, and drive
a Composio denial through the real CONNECT + strict MITM + decode
path that the in-process handler tests bypass.
Claude-Session: https://claude.ai/code/session_01NRAaNueJjd9Z7rJsSjBW3e
…utes

The logical envelope dropped the query string, so an admitted dispatch
would forward input the policy decision never saw; governed requests
now deny query_string_unsupported instead of mutating silently, while
read-only passthrough keeps its query. Extension methods such as TRACE
on lifecycle routes fail closed instead of auditing as recognized
passthrough. Also qualify the monitor-mode docs (operational aborts
still block) and document /api/v3.1 lifecycle coverage.
Claude-Session: https://claude.ai/code/session_01NRAaNueJjd9Z7rJsSjBW3e
The startup coverage check compared mapping rule hosts to the
protected Composio hosts by exact string, so a rule written as
*.composio.dev, backend.composio.dev., or a host:port form matched
Composio traffic at runtime without ever triggering the warning. The
gate now reuses the MITM wildcard matcher and is covered by tests,
including one that pins the shipped composio.toml mapping as an
opt-in.
Claude-Session: https://claude.ai/code/session_01NRAaNueJjd9Z7rJsSjBW3e
…outes

Extension methods audited as recognized passthrough on MCP and
discovery routes, the same hole previously closed only for lifecycle
families. Recognized routes now allow GET/HEAD/OPTIONS (MCP paths also
DELETE for teardown) and deny everything else. MCP paths deny query
strings on every method, discovery included, so a query-carrying MCP
URL fails at the handshake instead of breaking only on tool calls. The
lifecycle route shapes now live in one classifier so a new family or
API version cannot be added to one copy and missed by another.
Claude-Session: https://claude.ai/code/session_019utUt23qovSfNXTgQedMSr
…gate

Mapping rules match hosts at runtime with the normalizer glob, which
allows a wildcard anywhere, while the coverage gate reused the MITM
matcher that only understands leading wildcards; a backend.* rule
governed Composio traffic without ever triggering the warning. The
gate now canonicalizes each rule host like the decoder and matches it
with the same glob the mapping table uses. A catch-all * rule counts
as a reference on purpose: it does govern Composio traffic.
Claude-Session: https://claude.ai/code/session_019utUt23qovSfNXTgQedMSr
DELETE on a Tool Router session resource flipped from passthrough to
denial when the read-method allowlists landed, silently breaking REST
session teardown. Mutating or deleting a session reshapes the agent's
reachable surface, the same argument that governs the link route, so
PATCH/PUT/DELETE on session/{id} now decode into governed
COMPOSIO_UPDATE_SESSION / COMPOSIO_DELETE_SESSION actions. Session
creation stays a recognized passthrough and MCP-path DELETE teardown
stays transport-level, both now pinned by tests. The lifecycle
classifier borrows from the path so detection no longer allocates.
Claude-Session: https://claude.ai/code/session_019utUt23qovSfNXTgQedMSr
Runtime hosts are lowercased with the trailing dot stripped, so a rule
written as API.GitHub.COM. could never match anything and sat in the
table as a dead rule. Host patterns are now normalized when the table
loads, duplicate detection compares the normalized form so case
variants of the same rule fail at startup, and the glob_match doc
comment now states the real semantics: the wildcard crosses
separators.
Claude-Session: https://claude.ai/code/session_019utUt23qovSfNXTgQedMSr
A rule host like backend.composio.dev:8* governs Composio traffic on a
nonstandard port at runtime but kept its colon through
canonicalization, so the gate never warned about missing MITM
coverage. Port segments made of digits and wildcards are now stripped
before matching, and the guide documents that wildcard and catch-all
rule hosts count as referencing the Composio hosts.
Claude-Session: https://claude.ai/code/session_019utUt23qovSfNXTgQedMSr
Three host normalizers disagreed on edge spellings: a trailing dot
hidden behind a port (host.:443) survived both the runtime normalizer
and the decoder, padded whitespace evaded the Composio coverage gate,
and default-port rule hosts stayed dead in the mapping table. All
three now trim whitespace, strip the port, and strip the trailing dot
in the same order, so no spelling can dodge host-scoped rules or the
coverage warning. Degenerate patterns like *. or *:443 that would
silently normalize into the bare catch-all are rejected at validation
instead. The extend-mapping guide now documents the real wildcard
semantics: * crosses separators, and there is no single-label form.
Claude-Session: https://claude.ai/code/session_019utUt23qovSfNXTgQedMSr
The degenerate catch-all check re-derived normalization by hand and
missed composed spellings like *.:443, and the runtime normalizer kept
a trailing dot hidden behind a nonstandard port, letting host.:8443
evade a host-scoped rule by one dot. Rule loading, request-host
normalization, and config validation now share a single normalization
(trim, lowercase, dot-strip on the name part, drop default ports,
keep nonstandard ones), and validation rejects hosts that normalize to
the bare catch-all or to an empty name.
Claude-Session: https://claude.ai/code/session_019utUt23qovSfNXTgQedMSr
The previous normalization split the port before trimming dots, so a
trailing dot after the port (host:443.) hid the port from the split
and the spelling stopped matching rules for the bare host. Dots are
now trimmed both before the split and from the name part after it.
Validation also rejects port-scoped catch-all promotions such as
*.:8443, which would silently match every host on that port, and the
extend-mapping guide documents the normalization and the startup
rejections.
Claude-Session: https://claude.ai/code/session_019utUt23qovSfNXTgQedMSr
The written-name extraction failed to split a dotted port (*:8443.),
rejecting a legitimate spelling with a misleading message. Trailing
dots are now trimmed only from the whole spelling before the port
split, never from the name part, so *:8443. loads while *.:8443 stays
rejected. The guard on the normalized side gains the missing
!port.is_empty() symmetry, and a positive test pins that a literal
*:8443 rule loads and matches only that port.
Claude-Session: https://claude.ai/code/session_019utUt23qovSfNXTgQedMSr
@veeso
veeso requested a review from falcucci July 28, 2026 15:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant