Skip to content

Shadow AI Discovery

The gateway governs traffic that points at it. Shadow AI Discovery answers the other question: what AI is running in the organization that doesn’t? Collectors and scanner adapters run in your infrastructure — CI, a host, a scheduled job — and send cryptographically signed discovery events to the platform, which reconciles them against the real gateway configuration and surfaces the result in the Asset Register as governed, ungoverned or unknown.

Discovery Integrations — BYO-scanner adapters and collector keys

Collectors POST events to POST /v1/discovery/events on the Control Plane. There is no admin JWT on this endpoint — the Ed25519 signature is the authentication:

  1. Register a collector key (POST /v1/admin/discovery/collector-keys) — a base64 Ed25519 public key with a trust_tier: first_party · channel · customer · community.
  2. The collector signs each event (canonical JSON, minus the signature block) with its private key and sends signature: {alg: "ed25519", key_id, value}.
  3. The platform verifies the signature against the registered key. Missing/bad signature or unknown/revoked key → 401.

Idempotency and dedup are built in: event_id is unique per tenant (a duplicate is a {"status": "duplicate"} no-op), and events deduplicate into one discovered asset per (tenant, asset_key) — re-discovering a dismissed asset re-activates it.

Two properties are platform-computed and never taken from the event:

  • Confidence — strongest evidence wins (passive network 0.9 > active probe 0.7 > host-derived 0.5), multiplied by the collector key’s trust tier. A collector’s own confidence claim is ignored.
  • Governance status — reconciled against live config: an llm_endpoint is matched to llm_providers by host, an mcp_server to mcp_servers.base_url, an a2a_agent to agent_cards.source_url. Matched to an enabled row → governed; unmatched or disabled → ungoverned; no host to match → unknown.

The broadest first-party collector reads destination hostnames from egress proxy, firewall or DNS logs (Zscaler, Palo Alto, Squid) and emits an llm_endpoint event per known AI-provider host — OpenAI, Azure OpenAI, Anthropic, Google (Gemini + Vertex), AWS Bedrock, Mistral, Cohere, Groq, Perplexity.

Terminal window
pip install brutor-discovery
brutor-egress-collector --input /var/log/squid/access.log \
--gateway https://control.example.com --key collector.key

Evidence lands as tls_sni, passive — the highest-confidence class.

Where the egress collector sees the network, Brutor Scout sees the workstation. It’s a small signed-event collector you run on a developer’s machine (one-shot or on a timer). It inventories the AI clients installed there — Claude Code, Claude Desktop, Cursor, VS Code, Codex, Goose, Windsurf — and, crucially, parses each client’s MCP server configuration, so the unmanaged MCP servers developers have wired up surface as discovered assets too. Purely read-only filesystem inspection; it never intercepts traffic or reads payloads.

Scout emits two asset types, both observe-only:

  • local_ai_client — an AI client found on a device
  • local_mcp_server — an MCP server a client is configured to use (the shadow-MCP surface), with an endpoint block for remote (http) servers
  1. Mint a collector key (once per device):

    Terminal window
    brutor-scout keygen --collector-id scout-$(hostname)
    # → register the printed Public key via POST /v1/admin/discovery/collector-keys
    # → store the Seed as BRUTOR_SCOUT_SEED
  2. Preview without emitting:

    Terminal window
    brutor-scout scan --dry-run --tenant acme
  3. Emit to the gateway (idempotent — re-running only advances last_seen):

    Terminal window
    export BRUTOR_SCOUT_SEED=
    brutor-scout scan --gateway https://control.example.com \
    --tenant acme --collector-id scout-$(hostname)

Discovered devices roll up in the Admin UI under Asset Register → Devices: one row per device with its AI-client and MCP-server counts, drilling into the clients on a device and the MCP servers each is wired to. Evidence lands as config_file, host-derived.

Inventory tells you a coding agent is installed. It does not tell you whether anyone uses it, or which of those unmanaged MCP servers they actually call. Scout can answer that too, by registering hooks the agent invokes before and after every tool call:

Terminal window
brutor-scout hooks install # merges into ~/.claude/settings.json
brutor-scout hooks status # where the spool is, how much is queued
brutor-scout submit --gateway https://gateway.acme.com --tenant acme

Records spool locally and are batch-submitted under the same Ed25519 signature as every other discovery event. Delivery is at-least-once — a laptop that sleeps mid-upload re-sends, and the gateway deduplicates on event_id, so a reconnecting device drains its backlog without double-counting.

Two more design points worth knowing:

  • The hook always exits 0, including on failure. It runs in the developer’s critical path on every tool call; a hook that can block a tool is a hook that gets uninstalled. A dropped audit record is the cheaper failure.
  • Installing merges rather than overwrites. Hooks a developer already has are preserved, and re-installing replaces our entry instead of stacking duplicates.

The payoff is a question a gateway structurally cannot answer:

MCP server Local calls this week
github 125
filesystem 75
nordica-claims-core 67
notion 48

An inventory row nobody calls is noise. One called hundreds of times a week is a priority — and GET /v1/admin/discovery/agent-events returns exactly that roll-up alongside the raw records.

Already running an AI scanner? brutor-ingest converts its output into signed discovery events — no bespoke collector required. Four adapters ship in the Discovery SDK (catalog: GET /v1/admin/discovery/ingest-adapters):

Adapter Modality Ingests
cyclonedx-aibom static CycloneDX 1.5/1.6 ML/AI-BOM from any code/container scanner (Cisco AI-BOM, Trusera, cdxgen) — models, SDKs, agent frameworks, MCP servers
agentsonar runtime Knostic AgentSonar process→domain AI classifications — traffic bypassing the gateway
snyk-agent-scan config Snyk agent-scan / mcp-scan — MCP servers from local client configs (Claude Desktop, Cursor, …) incl. tool-poisoning findings, skills, agents
safedep-vet static SafeDep vet ai discover / vet code scan — coding agents, MCP servers, IDE extensions, AI-SDK usage in Go/Python/JS-TS
Terminal window
# Feed a CycloneDX AI-BOM from CI
brutor-ingest --adapter cyclonedx-aibom -i aibom.json
# Feed AgentSonar runtime classifications, thresholded
brutor-ingest --adapter agentsonar -i pairs.jsonl --set min_ai_score=0.6

asset_typellm_endpoint · mcp_server · a2a_agent · inference_runtime · agent_framework · sdk_usage · unknown_ai, with provider/endpoint details, computed_confidence, governance_status (+ governed_ref when matched), lifecycle status (active · onboarded · dismissed), evidence count and first/last seen. Evidence rows carry the type (dns_resolution, tls_sni, netflow, process, env_var, package_manifest, config_file, well_known_probe, registry_entry, api_inventory), the collector and its trust tier.

Method Path Purpose
GET /v1/admin/discovery/assets List discovered assets + coverage counts
GET /v1/admin/discovery/coverage The coverage attestation (see below)
GET /v1/admin/discovery/agent-events Local tool-call activity + most-called MCP servers
POST /v1/discovery/agent-events Scout submits a signed batch (signature-auth, no admin JWT)
GET /v1/admin/discovery/assets/{id} Detail with evidence
POST /v1/admin/discovery/assets/{id}/onboard Returns a pre-fill config payload; marks onboarded
POST /v1/admin/discovery/assets/{id}/dismiss Mark not-relevant
POST /v1/admin/discovery/reconcile Re-run governance matching for all assets
GET / POST /v1/admin/discovery/collector-keys Manage collector keys (POST .../{key_id}/revoke to revoke)
Terminal window
curl -s -X POST http://localhost:5050/v1/admin/discovery/assets/dasset-01K0.../onboard \
-H "Authorization: Bearer $ADMIN_JWT"
{
"status": "onboarded",
"prefill": {
"kind": "mcp_server",
"name": "internal-crm-mcp",
"base_url": "http://crm-tools.internal:8080"
}
}

Discovery’s real output is not a list of assets — it is the answer to what fraction of our AI activity is actually governed? That answer is the population proof under every Assurance Report: a report evidences the traffic it observed, and coverage states how much of the estate that was.

Terminal window
curl -s http://localhost:5050/v1/admin/discovery/coverage \
-H "Authorization: Bearer $ADMIN_JWT"
{
"basis": "measured",
"coverage_pct": 0.87,
"governed": 27,
"ungoverned": 3,
"unknown": 1,
"dismissed": 4,
"endpoint_plane_observed": false,
"statement": "87% of the 31 discovered AI assets are routed through the gateway and therefore in scope for this report. 4 are not, and nothing in this report evidences their behaviour."
}

unknown (unreconciled) assets count against coverage — an asset we could not match to a configured route is not a routed asset. Dismissed assets leave the denominator but are reported alongside it, because an operator can raise the percentage by dismissing and that must stay auditable. A collector that has gone quiet for more than seven days degrades basis to stale: the ratio is still real, but it describes the estate as last seen.

The attestation lives on Discovery Integrations → Coverage in the Admin Console, and is embedded in the Assurance Report’s coverage section and its not_covered gaps.

Discoveries surface in two places: the Asset Register (the ungoverned list, coverage scorecard, and onboard/dismiss actions — Discovery Integrations is a drill-down from its header) and Mission Control’s Governance tab (shadow-AI signals).