Agent Guild Preflight plugin thumbnail

Agent Guild Preflight

Deny-only preflight for operator-selected remote MCP tools. Checks the configured public endpoint through Agent Guild before invocation. Default policy blocks do_not_delegate, unproven required checks, caution and unavailable evidence; existing tool permissions remain in force. Inactive until configured. Retains observation evidence and rechecks the endpoint binding before allowing native execution.

Author AgentTanuki 0 stars Version 0.1.0 Updated

README

agent_guild_preflight — Agent Zero plugin

A deny-only gate for remote MCP tools in Agent Zero. Immediately before a tool from an operator-protected remote MCP server executes, the plugin asks Agent Guild's free, unauthenticated GET /preflight what that endpoint proves right now (reachability, a real protocol handshake, card and payment claims, independent evidence) and blocks the call when the verdict is do_not_delegate, when a required check is not proven, or — by default — when the evidence is unavailable or inconsistent.

It never grants anything. Agent Zero's own tool-access policy still runs first (and again inside MCPTool.execute), a denial from this plugin is a normal RepairableException the agent can react to, and installing the plugin without configuring it produces zero network traffic.

Built and tested against Agent Zero v2.11 (6a6cecff8527b164668c7a6ab2f76b6b1ed7cfa1).

What it hooks

extensions/python/tool_execute_before/_45_agent_guild_preflight.py — the native tool_execute_before extension point that agent.py awaits right before tool.execute in both the native function-call path (_execute_tool_request) and the legacy text path (process_tools). The unique _45_ filename keeps the built-in _10_enforce_tool_policy.py untouched and ahead of it. The hook reads agent.loop_data.current_tool and acts only when it is a real helpers.mcp_handler.MCPTool whose server name is listed in protected_servers.

Install

  1. In your Agent Zero environment, clone the versioned plugin into its plugin directory:

    git clone --branch v0.1.0 https://github.com/AgentTanuki/a0-agent-guild-preflight.git /a0/usr/plugins/agent_guild_preflight
    

    The directory root must contain plugin.yaml. The Plugin Hub route becomes available if maintainers accept the index submission.

  2. Agent Zero discovers the extension automatically. Nothing happens yet: protected_servers is empty.

  3. Create /a0/usr/plugins/agent_guild_preflight/config.json, for example:

{
  "protected_servers": { "public_mcp": "https://mcp.example.com/mcp" },
  "policy": { "required_proven": ["endpoint_reachable", "protocol_handshake"],
              "on_caution": "deny", "on_unavailable": "deny" }
}

public_mcp must be the name of a remote MCP server in Agent Zero's MCP settings (matched after Agent Zero's own name normalization) and the URL must be identical to the one that server is configured with. Only that URL is ever sent to Guild.

Restart Agent Zero after installing or updating the plugin so its helper modules are reloaded. Configuration uses Agent Zero's native file lookup. The first existing config.json wins in this order: project plus agent profile, project, agent profile, global; otherwise the plugin's default_config.yaml supplies the defaults. Files at different scopes are not merged. A project file belongs at /a0/usr/projects/<project>/.a0proj/plugins/agent_guild_preflight/config.json; a profile file belongs at /a0/usr/agents/<profile>/plugins/agent_guild_preflight/config.json; a combined file belongs under the project's .a0proj/agents/<profile>/plugins/ directory. This plugin ships no editable settings form; the Plugins dialog can list and remove existing scoped files. See the pinned configuration loader.

To remove: delete the folder and restart. No other state is written (evidence is kept on the agent object and in the chat log only).

Binding rules (why a name is not enough)

Model-supplied tool names or descriptions do not prove which server a tool belongs to. For every protected call the plugin resolves the bound MCPTool through the same project-aware MCPConfig.get_for_agent(agent) that native execution uses, and requires that:

  • exactly one configured server carries that normalized name (a collision such as Pub-Server vs pub server is ambiguous and denies);
  • it is a remote server (a local/stdio server under a protected name denies);
  • its url equals the operator-approved URL byte-for-byte (a changed binding denies);
  • it currently exposes the tool;
  • the approved URL passes the syntactic screen (http/https, public host, no userinfo, query, fragment, whitespace or control characters). Ineligible URLs are refused before any disclosure and are never echoed into diagnostics.

After the awaited preflight the configuration is read again and must still match. Remaining race: an administrative change to the MCP configuration that lands between that re-check and tool.execute cannot be excluded by this hook; the framework offers no atomic identity guarantee across the two. Initialization and list_tools happened earlier and are not covered — this protects tools/call only.

Policy

key default meaning
policy.required_proven [endpoint_reachable, protocol_handshake] each must be proven; unknown is not proven
policy.tolerate_unknown_required false accept unknown (never failed) on required checks — must be a real boolean; "false" (a string) is a configuration error
policy.on_caution deny delegate_with_caution (a non-blocking check failed)
policy.on_unavailable deny Guild unreachable, deadline, HTTP error, malformed / inconsistent / unrecognized body

do_not_delegate always blocks, whatever the policy. Unknown stays unknown: it is reported in the evidence and never counted as either failure or trust. Boolean settings (tolerate_unknown_required, allow_private_hosts) accept real booleans. Omitted or null settings use the defaults; other types make the configuration invalid, which denies every protected call before any preflight. Every binding or policy denial — including a binding that changed while the preflight was in flight — is recorded exactly once with its actual reason and the observation obtained, replacing any earlier record. Configuration errors deny MCP calls before a preflight but do not create an observation record.

Evidence

For binding and policy decisions, the plugin stores a decision record and, when a preflight completed, its full observation (verdict, failed, unknowns, scored, checks, method, limits, guild_origin, observed_at, cached, age_seconds, error) under agent.get_data("agent_guild_preflight.last_observation") (last 20 under …decisions) and logs a fixed-field summary to the chat log. That stored evidence is untrusted operator data: it is never placed in the prompt. The model-visible denial message is a projection of controlled values only — validated status/verdict enums, the operator-approved endpoint (redacted form), our own timestamp, and check identifiers only if they are ones the Guild contract defines (endpoint_reachable, protocol_handshake, agent_card_resolves, agent_card_signed, payment_claim_holds, independent_evidence); any other identifier the response carries is reduced to a count (+N unlisted), and headline/detail/method/limits text is never included. This separation reduces the model's exposure to remote text; it is not a general prompt-injection guarantee, and tool results that the framework itself returns from an allowed call are outside this plugin's control.

A preflight is an unsigned observation of one moment: it is not identity, signature verification, protocol conformance, permission, or a guarantee about what the endpoint does with your data.

Network behaviour

  • Only GET {guild_origin}/preflight?url=<approved URL>; headers sent: User-Agent, Accept. No tool arguments, MCP headers, prompts, operator identity, cookies or credentials.
  • guild_origin is fixed to the production origin. The only accepted override (fixture_guild_origin) is a literal http://127.0.0.1:<port> / http://[::1]:<port> for offline tests; anything else makes the configuration invalid, which denies protected tools.
  • One wall-clock deadline (timeout_seconds, default 5) over name resolution, connect, TLS, headers and the streamed body (1 MiB cap) via httpx under asyncio.timeout; at the deadline the request is cancelled and its connection closed, so no further bytes are read or sent. A name lookup already running inside the resolver's worker thread cannot be interrupted: it completes in the background, its result is discarded, and no connect follows. No redirects, retries or environment proxies (trust_env=False).
  • Observations are cached per exact URL for cache_ttl_seconds (default 300) and labelled cached with their original observed_at; they are never presented as current.

Not covered: A2A (a2a_chat) calls, local/stdio MCP servers, tools that are not MCPTool instances, and anything a caller does after disabling the plugin.

Tests

The release-support/native-tests/tests/ directory contains 49 tests through the real v2.11 framework runtime: real Agent dispatch, loopback MCP servers and a local Guild fixture, with deterministic tool requests and no model provider. Only the embedding dependency is stubbed at import. See TESTING.md for the pinned runtime, reproducible commands and test limits.

Attribution

The preflight client (helpers/guild_preflight.py) is adapted from the reviewed agentscope-agentguild 0.1.2 client (AgentTanuki, Apache-2.0). AgentScope is not a dependency. Licensed under Apache-2.0 (see LICENSE). Not affiliated with the Agent Zero project.