# Quickstart: Zero-Trust Security via Access Policies

Enable authorization for HolmesGPT with access policies.

This quickstart demonstrates how to secure agent-to-agent communication using [Catalyst](https://catalyst.diagrid.io/) access policies. You'll run two apps — a **caller** that invokes a **server** running a HolmesGPT tool-calling loop — and then apply a deny policy to block unauthorized access.

:::note
This tutorial uses [**Catalyst Cloud**](https://docs.diagrid.io/operate/hosting/catalyst-cloud) to get started quickly with zero infrastructure setup. Catalyst Cloud uses a tunnel to route service invocation requests from the cloud to your local dev machine. For production or on-premises requirements, Diagrid also offers a [self-hosted enterprise option](https://docs.diagrid.io/operate/hosting/enterprise-self-hosted) where traffic stays within your private network.
:::

You will learn how to:

- Use Catalyst service invocation for agent-to-agent communication
- Apply an access control configuration to enforce zero-trust authorization
- Observe a `403 PermissionDenied` response when a caller is blocked by policy

**Diagram: Caller to server through Catalyst service invocation.** Catalyst checks the access policy before it forwards the call. A denied call gets a 403 and never reaches the server.

- **Catalyst**: caller (App ID), Access policy, server (App ID)
- Caller app, HolmesGPT (server)

- Caller app → caller (App ID)
- caller (App ID) → Access policy
- Access policy → server (App ID)
- server (App ID) → HolmesGPT (server)

Your apps run entirely on your local machine — you are not deploying code to Catalyst. `diagrid dev run` opens a secure tunnel between Catalyst Cloud and your local processes and registers each app with an ID. When the caller sends a service invocation request, it travels up to Catalyst Cloud (not directly to the server). Catalyst evaluates the access policy there, in the cloud: if the policy allows it, the request is forwarded back down through the tunnel to the local server; if the policy denies it, Catalyst returns a `403 PermissionDenied` immediately and the request **never reaches your local server process**.

## 1. Prerequisites

- [Diagrid Catalyst account](https://catalyst.diagrid.io/)
- [Diagrid CLI](https://docs.diagrid.io/references/catalyst/catalyst-cli-intro)
- [Python 3.12](https://www.python.org/downloads/) (HolmesGPT supports 3.10 to 3.13)

## 2. Log in to Catalyst

```bash
diagrid login
```

Confirm your identity:

```bash
diagrid whoami
```

## 3. Create project files

Create a new directory for the quickstart:

```bash
mkdir holmesgpt-access-policies
cd holmesgpt-access-policies
mkdir caller
mkdir server
```

### server/app.py

Create `server/app.py` with the following content. It runs HolmesGPT's tool-calling loop over a small `research` toolset behind a `/research` HTTP endpoint. A scripted LLM stands in for the model: it tells HolmesGPT to call `gather_sources`, then `summarize`, so the quickstart needs no LLM API key.

```python
"""Server app: exposes a HolmesGPT tool-calling loop via HTTP."""
import json

import uvicorn
from fastapi import FastAPI
from holmes.core.llm import LLM, ContextWindowUsage
from holmes.core.tool_calling_llm import ToolCallingLLM
from holmes.core.tools import (
    StructuredToolResult,
    StructuredToolResultStatus,
    Tool,
    ToolParameter,
    Toolset,
    ToolsetStatusEnum,
)
from holmes.core.tools_utils.tool_executor import ToolExecutor
from litellm import ModelResponse
from pydantic import BaseModel

# ── Tools ────────────────────────────────────────────────────
class ResearchTool(Tool):
    template: str

    def _invoke(self, params, context):
        result = self.template.format(topic=params["topic"])
        print(f">>> {self.name}: {result}", flush=True)
        return StructuredToolResult(status=StructuredToolResultStatus.SUCCESS, data=result)

    def get_parameterized_one_liner(self, params):
        return f"{self.name}({params['topic']})"

topic_param = {"topic": ToolParameter(type="string", description="Research topic")}
toolset = Toolset(
    name="research",
    description="Research tools",
    enabled=True,
    tools=[
        ResearchTool(name="gather_sources", description="Gather research sources on a topic.",
                     parameters=topic_param, template="Found 42 papers on '{topic}'"),
        ResearchTool(name="summarize", description="Summarize the research on a topic.",
                     parameters=topic_param, template="Summary: AI diagnostics up 40% for '{topic}'"),
    ],
)
toolset.status = ToolsetStatusEnum.ENABLED

# ── Scripted LLM (no API key, no network call) ───────────────
class ScriptedLLM(LLM):
    """Plays the model's part: call gather_sources, then summarize, then answer."""

    def __init__(self):
        self.model = "scripted"

    def get_context_window_size(self):
        return 128_000

    def get_maximum_output_token(self):
        return 4_096

    def count_tokens(self, messages, tools=None):
        return ContextWindowUsage(total_tokens=0, tools_tokens=0, system_tokens=0, user_tokens=0,
                                  tools_to_call_tokens=0, assistant_tokens=0, other_tokens=0)

    def completion(self, messages, tools=[], **kwargs):
        topic = next(m["content"] for m in reversed(messages) if m["role"] == "user")
        results = [m["content"] for m in messages if m["role"] == "tool"]
        steps = ["gather_sources", "summarize"]
        if len(results) < len(steps):
            message = {"role": "assistant", "content": None, "tool_calls": [{
                "id": f"call_{len(results) + 1}",
                "type": "function",
                "function": {"name": steps[len(results)], "arguments": json.dumps({"topic": topic})},
            }]}
        else:
            message = {"role": "assistant", "content": "Research complete."}
        return ModelResponse(model=self.model, choices=[{"index": 0, "message": message}])

holmes = ToolCallingLLM(
    tool_executor=ToolExecutor([toolset]),
    max_steps=5,
    llm=ScriptedLLM(),
    tool_results_dir=None,
)

# ── FastAPI ──────────────────────────────────────────────────
app = FastAPI()

class RunRequest(BaseModel):
    topic: str

@app.post("/research")
def research(req: RunRequest):
    result = holmes.call([
        {"role": "system", "content": "You are a research analyst."},
        {"role": "user", "content": req.topic},
    ])
    return {"messages": [call.result.data for call in result.tool_calls]}

@app.get("/health")
async def health():
    return {"status": "ok"}

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8002)
```

### caller/app.py

Create `caller/app.py` with the following content. This app invokes the server's `/research` endpoint through Catalyst service invocation:

```python
"""Caller app: invokes the server app through Catalyst service invocation."""
import os

import httpx
import uvicorn
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

# Catalyst injects these environment variables via `diagrid dev run`
DAPR_HTTP_ENDPOINT = os.getenv("DAPR_HTTP_ENDPOINT", "http://localhost:3500")
DAPR_API_TOKEN = os.getenv("DAPR_API_TOKEN", "")

class CallRequest(BaseModel):
    topic: str

@app.post("/call-server")
async def call_server(req: CallRequest):
    """Call the server app's /research endpoint via Catalyst service invocation."""
    url = f"{DAPR_HTTP_ENDPOINT}/v1.0/invoke/server/method/research"
    headers = {
        "dapr-api-token": DAPR_API_TOKEN,
        "Content-Type": "application/json",
    }
    print(f">>> Calling server via Catalyst: {url}", flush=True)
    async with httpx.AsyncClient() as client:
        resp = await client.post(url, json={"topic": req.topic}, headers=headers, timeout=30)
    print(f">>> Response status: {resp.status_code}", flush=True)
    print(f">>> Response body: {resp.text}", flush=True)
    return {"status_code": resp.status_code, "body": resp.json() if resp.status_code == 200 else resp.text}

@app.get("/health")
async def health():
    return {"status": "ok"}

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8001)
```

The caller uses `DAPR_HTTP_ENDPOINT` and `DAPR_API_TOKEN`, which are automatically injected by `diagrid dev run`. The service invocation URL follows the pattern:

```
{DAPR_HTTP_ENDPOINT}/v1.0/invoke/{target-app-id}/method/{endpoint}
```

### dev.yaml

Create `dev.yaml` in the project root:

**macOS/Linux**

```yaml
version: 1
common:
  appLogDestination: console
apps:
  - appID: caller
    appDirPath: ./caller
    command: [".venv/bin/python", "app.py"]
  - appID: server
    appDirPath: ./server
    appPort: 8002
    command: [".venv/bin/python", "app.py"]
```

**Windows**

```yaml
version: 1
common:
  appLogDestination: console
apps:
  - appID: caller
    appDirPath: ./caller
    command: [".venv\\Scripts\\python.exe", "app.py"]
  - appID: server
    appDirPath: ./server
    appPort: 8002
    command: [".venv\\Scripts\\python.exe", "app.py"]
```

:::tip
The `appPort` field on the server tells Catalyst which local port the app listens on. This is required for apps that receive service invocation requests. The caller does not need `appPort` because it only sends requests.
:::

## 4. Install dependencies

**macOS/Linux**

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install holmesgpt==0.43.0 fastapi uvicorn httpx
```

**Windows**

```powershell
python -m venv .venv
.venv\Scripts\python.exe -m pip install holmesgpt==0.43.0 fastapi uvicorn httpx
```

:::note
HolmesGPT pins the FastAPI, uvicorn and httpx versions it supports, so this install lets pip choose them. Install it in its own virtual environment, not one shared with other agent frameworks.
:::

Create symlinks so both apps share the same virtual environment:

**macOS/Linux**

```bash
ln -s ../.venv caller/.venv
ln -s ../.venv server/.venv
```

**Windows**

```powershell
cmd /c mklink /J caller\.venv .venv
cmd /c mklink /J server\.venv .venv
```

## 5. Run with default allow policy

Start both apps with Catalyst Cloud:

```bash
diagrid dev run -f dev.yaml --project holmesgpt-access-qs --approve
```

:::tip
Wait for both apps to show `Uvicorn running on ...` in the log output before proceeding.
:::

Open a **new terminal** and invoke the caller:

**macOS/Linux**

```bash
curl -X POST http://localhost:8001/call-server \
  -H "Content-Type: application/json" \
  -d '{"topic": "AI in healthcare"}'
```

**Windows**

```powershell
Invoke-RestMethod -Method Post -Uri "http://localhost:8001/call-server" `
  -ContentType "application/json" `
  -Body '{"topic": "AI in healthcare"}'
```

You should see a successful response:

```json
{
  "status_code": 200,
  "body": {
    "messages": [
      "Found 42 papers on 'AI in healthcare'",
      "Summary: AI diagnostics up 40% for 'AI in healthcare'"
    ]
  }
}
```

By default, Catalyst allows all agent-to-agent communication. The request traveled from your local caller → Catalyst Cloud → through the tunnel → your local server. The caller successfully invoked the server's HolmesGPT loop.

## 6. Apply a deny policy

Stop the running application by pressing `Ctrl+C`, then disconnect the apps from Catalyst:

```bash
diagrid dev stop --id caller --project holmesgpt-access-qs
diagrid dev stop --id server --project holmesgpt-access-qs
```

Deny every caller on the server app:

```bash
diagrid app access grant server --project holmesgpt-access-qs --caller '*' --action deny --wait
```

:::tip
If the command reports `local app connection already exists`, wait a few seconds and run it again.
:::

Passing `'*'` as the caller sets the policy's default action instead of naming one app, so this denies **all** incoming service invocation requests to the server, regardless of which app is calling it. Because authorization is enforced in Catalyst Cloud — not by the server process — a denied request never reaches your local server. The `403` is returned by Catalyst before any traffic enters the tunnel.

## 7. Verify access is denied

Restart both apps:

```bash
diagrid dev run -f dev.yaml --project holmesgpt-access-qs --approve
```

Open a **new terminal** and invoke the caller again:

**macOS/Linux**

```bash
curl -X POST http://localhost:8001/call-server \
  -H "Content-Type: application/json" \
  -d '{"topic": "AI in healthcare"}'
```

**Windows**

```powershell
Invoke-RestMethod -Method Post -Uri "http://localhost:8001/call-server" `
  -ContentType "application/json" `
  -Body '{"topic": "AI in healthcare"}'
```

This time, the caller receives a **403 PermissionDenied** error:

```json
{
  "status_code": 403,
  "body": "{\"errorCode\":\"ERR_DIRECT_INVOKE\",\"message\":\"failed to invoke, id: server, err: rpc error: code = PermissionDenied desc = access control policy has denied access to id: spiffe://<trust-domain>/ns/<project-id>/caller operation: research verb: POST\"}"
}
```

The access policy blocked the caller from invoking the server. The server's HolmesGPT loop never executed.

## 8. Clean up

Stop the running application by pressing `Ctrl+C`.

Delete the Catalyst Cloud project:

```bash
diagrid project delete holmesgpt-access-qs
```

## Summary

In this quickstart, you:

- Set up two apps communicating through Catalyst service invocation
- Verified that agent-to-agent calls succeed with the default allow policy
- Applied a deny-all access policy that blocked the caller with a `403 PermissionDenied` error

## Next steps

- Learn more about [Catalyst Enterprise](https://docs.diagrid.io/operate/hosting/enterprise-self-hosted) for fine-grained authorization rules
- Explore the [Catalyst web console](https://catalyst.diagrid.io/) for managing configurations and apps
