feat(sidecar): govern Composio tool execution - #370
Open
veeso wants to merge 21 commits into
Open
Conversation
veeso
force-pushed
the
feat/fir-435-govern-composio-gateway-with-openfirma-enforcement
branch
4 times, most recently
from
July 27, 2026 14:16
f389105 to
b2ca308
Compare
veeso
commented
Jul 27, 2026
|
|
||
| struct StrictJsonVisitor; | ||
|
|
||
| impl<'de> Visitor<'de> for StrictJsonVisitor { |
Contributor
Author
There was a problem hiding this comment.
prevents duplicate object keys
veeso
marked this pull request as ready for review
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
force-pushed
the
feat/fir-435-govern-composio-gateway-with-openfirma-enforcement
branch
from
July 27, 2026 15:06
b2ca308 to
7b9ba77
Compare
LukeMathWalker
self-requested a review
July 27, 2026 18:07
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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
mainthat 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.rsaddspolicy_resource_display(), and it replacesresource_display()at 12 call sites, one incrates/firma-sidecar/src/enforcement/capability_validation.rs, four incrates/firma-sidecar/src/enforcement/constraint_enforcement.rs, and seven incrates/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 nopolicy_resourcekey 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.rsis 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_compositeincrates/firma-sidecar/src/pipeline.rsand thehandle_composio_*functions incrates/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.jsonfiles undercrates/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 indocs/architecture/composio-enforcement.md.5. Everything else can be skimmed. The Cedar policy context is built in
insert_composio_contextincrates/firma-sidecar/src/enforcement/constraint_enforcement.rsand declared incrates/firma-core/firma.cedarschema. Catalog loading and validation iscrates/firma-sidecar/src/composio/catalog.rs. The refresh tool isscripts/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
AI Assistance
Claude Opus 5 (claude-opus-5); review fixes with Claude Fable 5 (claude-fable-5).
https://claude.ai/code/session_01NRAaNueJjd9Z7rJsSjBW3e