Skip to main content

Adding Policies

Now that you understand how to configure the AgentVisor™ runtime, let's see its security model in action. We'll give the chatbot from Your First Agent a tool that makes HTTP requests, and control what it can reach with real policies.

What You'll Learn​

  • Giving the agent a tool that makes HTTP requests
  • Policy-based allow/deny decisions
  • How MPE policies work

Prerequisites​

This chapter continues in the chatbot-agent project you created in Your First Agent. Temporal and Ollama were started there with docker compose up -d and run detached — they're still up unless you've since run docker compose down. If you need to restart them:

cd chatbot-agent
docker compose up -d

If you removed the localhost:11434 selector from policies/domain.yml to see a denial in chapter 1, put it back before continuing.

Starting here instead?

agentvisor template create langgraph/tutorial-agent scaffolds the project as it stands at the end of this tutorial. To follow this chapter from the beginning, use langgraph/chatbot-agent as in chapter 1.

Add an HTTP Tool​

The chatbot can only talk to its LLM. Let's give it a fetch_url tool that calls out to the internet. Replace agent.py with:

agent.py​

"""Chatbot with an HTTP fetch tool, going through the policy proxy."""

import logging
import os
import re
import uuid
from typing import Annotated, TypedDict

import requests
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode

from agentvisor.langgraph import AgentVisorCheckpointer

logger = logging.getLogger(__name__)

OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.2:1b")

SYSTEM_PROMPT = """You are a helpful, friendly assistant. Keep your responses concise and conversational.
When a tool returns a result, report what it returned, including any error."""

# "fetch <url>" calls the fetch_url tool directly, so the allow/deny demos
# below don't depend on a small local model choosing to call it.
FETCH_COMMAND = re.compile(r"^\s*fetch\s+(https?://\S+)\s*$", re.IGNORECASE)


class ChatState(TypedDict):
messages: Annotated[list, add_messages]


@tool
def fetch_url(url: str) -> str:
"""Fetch a URL over HTTP(S) and return the start of the response body."""
try:
# This request goes through AgentVisor's policy proxy.
response = requests.get(url, timeout=10)
# raise_for_status() converts 4xx/5xx responses to exceptions,
# including the 403 Forbidden returned when policy denies the request.
response.raise_for_status()
return f"HTTP {response.status_code}: {response.text[:500]}"
except requests.exceptions.RequestException as e:
return f"Request blocked or failed: {e}"


tools = [fetch_url]


def _to_message(msg) -> BaseMessage | None:
if isinstance(msg, BaseMessage):
return msg
if isinstance(msg, dict):
role, content = msg.get("role", "user"), msg.get("content", "")
if role == "user":
return HumanMessage(content=content)
if role == "assistant":
return AIMessage(content=content)
if role == "system":
return SystemMessage(content=content)
return None


def chat(state: ChatState) -> dict:
messages = [m for m in (_to_message(m) for m in state.get("messages", [])) if m]

# "fetch <url>" from the user calls the tool directly.
if messages and isinstance(messages[-1], HumanMessage):
match = FETCH_COMMAND.match(str(messages[-1].content))
if match:
call = {"name": "fetch_url", "args": {"url": match.group(1)}, "id": f"call_{uuid.uuid4().hex[:12]}"}
return {"messages": [AIMessage(content="", tool_calls=[call])]}

llm = ChatOllama(base_url=OLLAMA_BASE_URL, model=OLLAMA_MODEL, temperature=0).bind_tools(tools)
try:
return {"messages": [llm.invoke([SystemMessage(content=SYSTEM_PROMPT), *messages])]}
except Exception as e:
logger.error("LLM call failed", extra={"error": str(e)})
return {"messages": [AIMessage(content=f"Error calling LLM: {str(e)[:200]}")]}


def should_continue(state: ChatState) -> str:
messages = state.get("messages", [])
if messages and isinstance(messages[-1], AIMessage) and messages[-1].tool_calls:
return "tools"
return "end"


builder = StateGraph(ChatState)
builder.add_node("chat", chat)
builder.add_node("tools", ToolNode(tools))
builder.set_entry_point("chat")
builder.add_conditional_edges("chat", should_continue, {"tools": "tools", "end": END})
builder.add_edge("tools", "chat")

graph = builder.compile(checkpointer=AgentVisorCheckpointer())

Two things changed from chapter 1: the graph now has a tools node that loops back to chat, and fetch_url makes an ordinary requests.get call. The agent has no special network access — HTTPS_PROXY is set by AgentVisor inside the sandbox, so the request is sent to the host's proxy, which asks the policy engine for a decision first.

Add requests to requirements.txt:

langgraph>=0.2.0
langchain-core>=0.3.0
langchain-ollama>=0.2.0
requests>=2.31.0
pydantic>=2.0.0

langgraph.json is unchanged.

See It Denied by Default​

Restart the agent (Ctrl+C, then agentvisor serve .) and ask it to fetch a page. The chatbot's policy from chapter 1 only allows Ollama, so everything else falls to the default resource group, which denies:

THREAD=$(curl -sX POST http://localhost:8090/threads | jq -r '.thread_id')

curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=60s" \
-H "Content-Type: application/json" \
-d '{"input": {"messages": [{"role": "user", "content": "fetch https://httpbin.org/get"}]}}' \
| jq -r '[.output.messages[] | select(.type == "tool")] | last | .content'

Expected output:

Request blocked or failed: 403 Client Error: Forbidden for url: https://httpbin.org/get

The tool's result is a tool message in the run output; the final assistant message is the model's summary of it.

Allow One Host​

Now allow httpbin.org (a test HTTP service) and nothing else. AgentVisor uses the Manetu PolicyEngine (MPE) for access control, with policies defined in YAML using MPE's PolicyDomain format. Make three additions to policies/domain.yml, next to the matching Ollama entries.

Under policies:, add a policy that matches the request's host:

# HTTP access policy for the fetch_url tool - allows only httpbin.org
- mrn: &policy-http-fetch "mrn:iam:policy:http-fetch"
name: http-fetch
description: "HTTP access policy for the fetch_url tool"
dependencies:
- "mrn:iam:library:agentvisor-helpers"
rego: |
package authz

import rego.v1
import data.helpers

default allow := false

http_target := substring(input.resource.id, count("mrn:agentvisor:http:"), -1)

allow if {
helpers.is_authenticated
regex.match("^httpbin\\.org(:\\d+)?(/.*)?$", http_target)
}

Under resource-groups:, bind a resource group to it (above the default group):

- mrn: "mrn:agentvisor:resourcegroup:fetch"
name: fetch
description: "HTTP endpoints the fetch_url tool may reach"
policy: *policy-http-fetch

Under resources:, route httpbin.org requests to that group:

- name: fetch-endpoints
description: "Hosts the fetch_url tool may reach"
selector:
- "mrn:agentvisor:http:httpbin\\.org.*"
group: "mrn:agentvisor:resourcegroup:fetch"

Understanding the Policy​

Let's break down the key pieces of the whole domain:

Policies are written in Rego, a declarative policy language. Each policy evaluates to allow = true, allow = false, or a numeric value for operation-phase policies.

The operation policy (agentvisor-operation) uses MPE's tri-level policy operation phase: negative values deny, zero grants, and positive values grant with override. It is evaluated first for every request and here grants any authenticated principal — which includes the anonymous one.

Roles map identities to policies. The anonymous role is used by agentvisor serve for local development.

Scopes are a separate phase that must also grant the request, independent of roles — a policy domain with no scopes: section denies every request regardless of what the role and resource phases decide.

Operations define which actions are subject to policy. The selector: ["agentvisor:.*"] pattern matches all AgentVisor operations.

Resource groups bind policies to sets of resources. The resources section maps MRN patterns to resource groups — here, requests to httpbin.org go to the fetch group, whose policy allows only that host. Anything that matches no selector lands in the default group, which denies.

Putting it all together for a fetch_url call:

  1. The operation policy grants the HTTP request (operation phase)
  2. The anonymous role is allowed (role phase)
  3. httpbin.org matches the fetch group and its host-matching policy allows it; any other host falls to the default group and is denied (resource phase)
  4. The default scope grants the request (scope phase)

Why the Anonymous Role?​

By default, agentvisor serve runs without authentication — every request uses an anonymous principal:

"principal": {
"sub": "anonymous",
"mroles": ["mrn:agentvisor:role:anonymous"]
}

Your policies grant access to this role for development. In production, you'd configure real authentication so each request has a verified identity. See Authentication for details.

Run and Test​

Restart the agent (Ctrl+C, then agentvisor serve .), then fetch the allowed host:

THREAD=$(curl -sX POST http://localhost:8090/threads | jq -r '.thread_id')

curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=60s" \
-H "Content-Type: application/json" \
-d '{"input": {"messages": [{"role": "user", "content": "fetch https://httpbin.org/get"}]}}' \
| jq -r '[.output.messages[] | select(.type == "tool")] | last | .content | split("\n")[0]'

Expected output (the first line of the tool's result — httpbin's JSON response follows it):

HTTP 200: {

See Policy Denial​

Now fetch a host that isn't on the allow list:

curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=60s" \
-H "Content-Type: application/json" \
-d '{"input": {"messages": [{"role": "user", "content": "fetch https://example.com/"}]}}' \
| jq -r '[.output.messages[] | select(.type == "tool")] | last | .content'

Expected output:

Request blocked or failed: 403 Client Error: Forbidden for url: https://example.com/

The policy proxy blocked the request before it reached the external server. The tool caught the 403 and returned it as its result instead of crashing the run. (The jq filter picks the newest tool message, since a thread's history accumulates.)

tip

Use --access-log-pretty-print (covered in Debugging Your Agent) to see the full policy evaluation for each request — which phases passed, which denied, and why.

Next Steps​