Building AI Agents for DevOps Part 17: Opening the Platform Without Opening the Gates

Until Orrery 0.6.0, I had written every tool the agent could call. Each one was a Python function in this repository, reviewed and labelled with @confirm or @destructive when it changed something....

Until Orrery 0.6.0, I had written every tool the agent could call. Each one was a Python function in this repository, reviewed and labelled with @confirm or @destructive when it changed something. Every gate described in this series reads those labels: RBAC, the autonomy levels and the approval card.

0.6.0 opens the platform in four places:

It also lets calls reach Kubernetes and Elasticsearch as the person who asked. Each of these is a way for something I never reviewed to reach the gates. This post covers how each one fails closed, and the bug that showed it didn't, at first.

Orrery 0.6.0 architecture: surfaces, the gateway, the ordered plugin chain, two roots and the target systems

A Tool With No Function

The gates found a tool's guard level on its Python function. An MCP tool has no Python function: it is discovered at runtime from a server. Writing the MCP integration made me read every gate's first lines, and all four began the same way:

def callback(*, tool, args, tool_context):
    func = getattr(tool, "func", None)
    if func is None:
        return None          # no function: let it through

No shipped tool was affected, because every one of them was a function. But every MCP tool would have taken that early return and run without a gate, whatever it did. A viewer could have called a server's delete_repository and never seen an approval card. Nothing failed, because no test had ever exercised a tool that wasn't a function.

The fix has three parts:

The MCP tests start a real stdio MCP server and prove that a destructive call never reaches it without approval.


MCP: Fail Closed on Someone Else's Tools

Each configured MCP server becomes a specialist on the chat root. Every tool it exposes gets a guard level as it is discovered, with one rule above the others: when no rule says otherwise, the tool is destructive.

The five rules that give an MCP tool its guard level, and what each level means
servers:
  - name: github                      # tools appear as github_*
    description: GitHub issues and pull requests for the platform repos
    transport: streamable_http
    url: https://api.githubcopilot.com/mcp/
    headers_from_env:                 # never inline a secret
      Authorization: GITHUB_MCP_AUTHORIZATION
    tools: [list_issues, get_issue, create_issue]   # required allowlist
    read_only: [list_issues, get_issue]
    # create_issue is not classified, so it is destructive

The rule that took the most thought is about MCP annotations. A server can describe its tools as readOnlyHint or destructiveHint, and it is tempting to believe it. But an annotation is the server describing itself. So Orrery trusts annotations in one direction only: a hint that raises protection is always applied, and a hint that lowers it is ignored unless the operator sets trust_annotations. A reviewed classification in the config always wins. If you classify a tool, it can only get cheaper to call, never more dangerous.

The transport is locked down too. HTTP servers must use https unless explicitly allowed. Header secrets come from environment variables, never the file. Stdio servers start with an empty environment, so a local MCP process doesn't inherit the agent's cloud credentials. The Helm chart can render the config and refuses to enable MCP with no servers.


Specialists From Anywhere

The chat root used to import each specialist by name, so adding one meant editing the orchestrator. Specialists now register through a Python entry point, and the root discovers what is installed:

[project.entry-points."orrery.specialists"]
k8s = "k8s_health_agent.agent:root_agent"

A specialist from another package needs no change to Orrery. ORRERY_SPECIALISTS and ORRERY_DISABLED_SPECIALISTS choose what runs, and an unknown name stops startup instead of being skipped quietly.

Often a new specialist is just a narrower brief over tools that already exist. For that, you don't need a package. A declarative agent is a few lines of YAML:

agents:
  - name: first_responder
    description: First look at a paging alert, before anyone changes anything
    instruction: Check events, then the alerts, then the affected workload.
    tools: [k8s.get_events, get_active_alerts]

The important limit is that a declarative agent can only recombine reviewed code. Its tools come from the catalogue of installed specialists, each keeps its own guard level, and the gate is attached automatically. An unknown or ambiguous tool name, or a name collision, stops startup. The console's new Agents tab lists every specialist, where it came from (built-in, plugin, YAML or MCP), and the guard level of each tool.


Editing Agents in a Running System

With ORRERY_ADMIN_EDITING=true, admins can do the same from that tab: switch specialists off, and compose, edit or remove declarative agents, with a tool picker that shows guard levels. The change applies without a restart.

An admin edit is built, then saved with a version check, then swapped in; other replicas poll the store and follow

The whole design rests on one invariant: a replica only ever serves an agent tree that was built successfully. So the order is build, then save, then swap:

Other replicas poll the store and follow within ORRERY_CATALOG_POLL_SECONDS. The review before the release found the weak spot in that loop: one unexpected error would end the polling task for good, and that replica would keep serving a stale catalog, silently, until its next restart. A background loop that can die needs a test that makes it fail once and checks it is still polling afterwards. It has one now.


Acting as the User

Orrery's RBAC decides whether a tool may run. With ORRERY_IMPERSONATION=kubernetes,elasticsearch, the call itself also reaches the target system as the person who asked: Kubernetes receives Impersonate-User and Impersonate-Group, and Elasticsearch receives es-security-runas-user. The cluster's own permissions and audit log then apply to that person, on top of Orrery's.

The identity comes only from what a transport verified: the JWT in the console, the signed sender in Google Chat, or the Slack user's email from users.info. It is kept in a ContextVar for the duration of the turn, never in session state, because tools can write session state. Three rules keep it fail-closed:

A mistyped ORRERY_IMPERSONATION value now stops startup, instead of failing on the first Kubernetes call. Kafka and MCP servers use tokens rather than impersonation headers. ADR-004 proposes token exchange for them, and until then they run as the service account.


Hardening the Pipeline That Ships It

All of this runs through a CI pipeline whose tokens can push images, sign them and publish releases, so it got the same review:


The Takeaway

Every extension point is a place where something unreviewed enters a reviewed system. The question for each one is the same: when nobody has looked at it, what does it default to? The answer should always be "the most protected thing": a destructive tool, a refused call, the last agent tree that built. And the bug at the start is why every default needs a test that actually goes through it. That early return None had been in the gates for months. It was harmless only because nothing had ever reached it.

The full list is in the v0.6.0 release notes. The MCP setup is documented in the MCP guide, and impersonation in the impersonation guide.

Read on Tirraflow