Skip to content

Policy & the tool catalogue

Every decision the gateway makes is the evaluation of a signed policy bundle: Cedar rules plus a tool catalogue, compiled and published by the control plane from a git checkout. Default deny throughout.

A tool the bundle does not describe is refused, even if the MCP server advertises it. An updated server, or a compromised one, can publish new tools at any time; if they are not in the catalogue, nobody approved their use.

Discovery is filtered to match: tools/list, resources/list and prompts/list only show what policy allows. An invisible tool is never attempted, which means that many fewer refusals to handle and that much less surface offered to a prompt injection.

Tool-level rules cannot say send_message, but only to #support. Argument rules can. The catalogue declares, per tool, which arguments the policy may read (policy_args: a name, a JSON pointer, a type, an optional default); the gateway extracts exactly those and Cedar sees them as context.args:

@id("support_channel_only")
forbid (principal, action == Action::"tool_call", resource == Tool::"send_message")
when { context.args.channel != "#support" };

What makes this safe:

  • The declared allowlist is the privacy boundary. Values exist in memory for the decision; the log keeps args_hash; a field nobody declared is never even read.
  • Extraction is total. Every declared argument arrives extracted, defaulted, or the call is refused before Cedar runs. A crafted argument shape is a plain recorded denial, never an evaluation error. The fail mode stays reserved for failures of Obsign’s own machinery, out of the agent’s reach.
  • Visibility and permission are different questions. tools/list evaluates without arguments, so an argument-restricted tool stays visible; the call path is the enforcement point.

Argument declarations require bundle format obsign-policy/2; the control plane emits /2 only when a tool actually declares arguments, so a fleet that never uses the feature never forces a gateway upgrade.

Cedar numbers its rules policy0, policy1… by file order. Since that identifier is engraved in the log, inserting a rule at the top would silently rename all the following ones and make every earlier record wrong. Every rule must therefore carry an @id("...") annotation; a bundle missing one is rejected at load time.

  • Read channelsresources/read, resources/subscribe, prompts/get move data exactly as tools/call does, so they go through the same gate (Cedar actions resource_read, prompt_get, default deny). Resource URIs are minted at runtime, so policies match exactly (resource == Resource::"docs://runbook") or by pattern (context.target like "docs://*").
  • Completionscompletion/complete reuses the capability of what it completes (ref/resourceresource_read, ref/promptprompt_get): complete only what you could read. Left unarbitrated it would walk around the listing filter.
  • Server-initiated channelssampling/createMessage borrows the agent’s model; elicitation/create puts a question to the human. Both are arbitrated under their own Cedar actions, granted per server, default deny. Refused, the gateway answers the server in the agent’s place and records the attempt.
  • Everything else — a fixed allowlist of protocol machinery (initialize, ping, the discoveries, defined notifications) is relayed as-is; any other method is refused with -32601 and recorded, with no policy_id so a scope refusal reads differently from a rule’s.