Skip to main content

Authorization Enrichment: Argument-Aware MCP Policy

Out of the box, policy sees an MCP tool call as filesystem/read_file. It can allow or deny the tool, but it can't tell a read of a public file from a read of a private one, because the file is an argument. In this module you write a small enrichment policy that turns the tool call and its path into a resource-scoped identifier, then write a policy that allows one directory and denies the other.

What You'll Learn​

  • Writing a Rego enrichment policy that maps a tool call and its arguments to an operation and a resource MRN
  • Attaching the policy to an MCP server with an enrichment: block
  • Allowing a public scope and denying a private scope in the PolicyDomain
  • Reading the before and after operation and resource, and context.mcp.original_*, in the decision logs
  • Making the policy hold against .. path tricks

Prerequisites​

This module continues in the project you finished in the Basic tutorial. If you skipped it, scaffold the same agent:

agentvisor template create langgraph/tutorial-agent
cd tutorial-agent

Docker must be running, and Temporal and Ollama must be up. In the project directory:

docker compose up -d

No API key is needed. The filesystem MCP server from Connecting External Tools is the tool you'll govern, because its tools take a path argument that names the resource.

How It Works​

Before enrichment
operation: agentvisor:mcp:tool:call
resource: mrn:agentvisor:mcp:filesystem/read_file

After enrichment
operation: mcp:fs:read
resource: mrn:agentvisor:fs:private:/tmp/mcp-data/private/salaries.txt

On the host, before the policy engine decides, the enrichment policy receives the tool name and its arguments and returns a new operation and resource. The policy engine then evaluates the enriched values with ordinary selectors, with no knowledge of how any tool's arguments are shaped. The originals are kept in the policy context so you can still see them.

Step 1: Give the Server Some Files to Protect​

The filesystem server runs inside AgentVisor's mcp-tools sandbox, which has its own empty /tmp: files you create on your machine aren't visible to it. Instead, have the server create its sample data when it starts. In agentvisor.yaml, replace the command of the filesystem server:

mcp:
servers:
- name: filesystem
transport: stdio
command:
- sh
- -c
- |
mkdir -p /tmp/mcp-data/public /tmp/mcp-data/private
echo 'Hello from MCP!' > /tmp/mcp-data/public/readme.txt
echo 'alice: 180000' > /tmp/mcp-data/private/salaries.txt
exec npx -y @modelcontextprotocol/server-filesystem /tmp/mcp-data

The server is the same, and it still serves /tmp/mcp-data. It now has a public directory and a private one.

Let a Message Read a File Directly​

As with fetch <url>, let a message of the form read <path> call the tool directly, so the results don't depend on a small local model choosing to call it. In agent.py, add this pattern beside FETCH_COMMAND:

READ_COMMAND = re.compile(r"^\s*read\s+(/\S+)\s*$", re.IGNORECASE)

and this block in chat, just before the line that builds conversation:

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

Step 2: Write the Enrichment Policy​

Create enrichment/filesystem.rego:

package agentvisor.authz.enrichment

import rego.v1

# Tools that take a `path` argument, grouped by what they do to it.
read_tools := {"read_file", "read_text_file", "read_media_file", "list_directory", "directory_tree", "search_files", "get_file_info"}

write_tools := {"write_file", "edit_file", "create_directory", "move_file"}

# Everything under this directory is public; everything else is private.
public_root := "/tmp/mcp-data/public"

# A path is public only if it is inside the public root and has no ".."
# segment that could climb back out of it.
is_public(p) if {
p == public_root
}

is_public(p) if {
startswith(p, concat("", [public_root, "/"]))
not regex.match(`(^|/)\.\.(/|$)`, p)
}

scope(p) := "public" if is_public(p)

scope(p) := "private" if not is_public(p)

result := {"operation": op, "resource": res} if {
read_tools[input.tool_name]
p := input.arguments.path
op := "mcp:fs:read"
res := sprintf("mrn:agentvisor:fs:%s:%s", [scope(p), p])
}

result := {"operation": op, "resource": res} if {
write_tools[input.tool_name]
p := input.arguments.path
op := "mcp:fs:write"
res := sprintf("mrn:agentvisor:fs:%s:%s", [scope(p), p])
}

A few things to notice:

  • The package name and the result rule are fixed. The host evaluates data.agentvisor.authz.enrichment.result, and result may carry operation, resource and context.
  • The policy reads input.tool_name and input.arguments, which describe the call. See Input Signals for the full list.
  • The two result rules have mutually exclusive conditions: a tool is either in read_tools or in write_tools. If two rules could both fire with different values, Rego reports a conflict and enrichment is skipped.
  • Anything that isn't plainly inside the public root is classed private, including a path with a .. segment. The policy sorts a path into a scope. It doesn't try to prove a path is dangerous.
  • A tool with no rule, such as list_allowed_directories, produces no result, and its original operation and resource are used unchanged.

You can try the policy without running AgentVisor. If you have the opa CLI:

echo '{"tool_name":"read_file","arguments":{"path":"/tmp/mcp-data/private/salaries.txt"}}' > input.json
opa eval -d enrichment/filesystem.rego -i input.json 'data.agentvisor.authz.enrichment.result' -f raw
{"operation":"mcp:fs:read","resource":"mrn:agentvisor:fs:private:/tmp/mcp-data/private/salaries.txt"}

Step 3: Attach the Policy to the Server​

Add an enrichment: block to the same server in agentvisor.yaml:

mcp:
servers:
- name: filesystem
transport: stdio
command:
# ... unchanged from Step 1 ...
enrichment:
provider: rego
rego_policy: ./enrichment/filesystem.rego

rego_policy is a path on the host, resolved from the directory you run agentvisor serve in. Enrichment is configured per server, so other MCP servers are unaffected.

Step 4: Update the PolicyDomain​

The enriched resources, mrn:agentvisor:fs:public:... and mrn:agentvisor:fs:private:..., don't match any selector in policies/domain.yml yet. Make three changes.

Add a resource group for the public scope, above the default group under resource-groups::

- mrn: "mrn:agentvisor:resourcegroup:fs-public"
name: fs-public
description: "Files in the public directory"
policy: *policy-allow

Route the two scopes under resources:. The private scope goes to the default group, which denies:

- name: fs-public
description: "Files the enrichment policy placed in the public scope"
selector:
- "mrn:agentvisor:fs:public:.*"
group: "mrn:agentvisor:resourcegroup:fs-public"

- name: fs-private
description: "Files the enrichment policy placed in the private scope"
selector:
- "mrn:agentvisor:fs:private:.*"
group: "mrn:agentvisor:resourcegroup:default"

Finally, the enriched operations mcp:fs:read and mcp:fs:write are new names, so route them to the operation policy as well. Under operations:, add a selector beside agentvisor:.*:

operations:
- name: agentvisor-ops
selector:
- "agentvisor:.*"
- "mcp:fs:.*"
policy: *policy-operation

Step 5: Run It and Watch the Decisions​

Start the agent with pretty-printed access logs, so each decision prints in full:

agentvisor serve . --access-log-pretty-print

In a second terminal, read a file from each directory:

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

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

ask "read /tmp/mcp-data/public/readme.txt"
ask "read /tmp/mcp-data/private/salaries.txt"

The first returns the file. The second is refused:

Hello from MCP!

Tool call denied: denied by policy

Same tool, same server, same principal: the only difference is the argument.

Read the Decision Log​

Look at the host's output for the denied call. Two log lines already show the enriched values:

INFO [host] MPE authorization check {"principal": "anonymous", "operation": "mcp:fs:read", "resource": "mrn:agentvisor:fs:private:/tmp/mcp-data/private/salaries.txt"}
...
INFO [host] MPE authorization result {"allowed": false, "principal": "anonymous", "operation": "mcp:fs:read", "resource": "mrn:agentvisor:fs:private:/tmp/mcp-data/private/salaries.txt"}

Between them, the access log prints the full decision. These are the parts that matter, trimmed:

{
"decision": "DENY",
"operation": "mcp:fs:read",
"porc": {
"context": {
"mcp": {
"arguments": { "path": "/tmp/mcp-data/private/salaries.txt" },
"original_operation": "agentvisor:mcp:tool:call",
"original_resource": "mrn:agentvisor:mcp:filesystem/read_file",
"server": "filesystem",
"tool": "read_file"
}
},
"operation": "mcp:fs:read",
"resource": {
"group": "mrn:agentvisor:resourcegroup:default",
"id": "mrn:agentvisor:fs:private:/tmp/mcp-data/private/salaries.txt"
}
},
"references": [
{ "decision": "GRANT", "phase": "SYSTEM", "id": "mcp:fs:read" },
{ "decision": "GRANT", "phase": "IDENTITY", "id": "mrn:agentvisor:role:anonymous" },
{ "decision": "DENY", "phase": "RESOURCE", "id": "mrn:agentvisor:resourcegroup:default" }
]
}

Here is the before and after:

Before enrichmentAfter enrichment
Operationagentvisor:mcp:tool:call (context.mcp.original_operation)mcp:fs:read (operation)
Resourcemrn:agentvisor:mcp:filesystem/read_file (context.mcp.original_resource)mrn:agentvisor:fs:private:/tmp/mcp-data/private/salaries.txt (resource.id)

The operation and identity phases granted, and the resource phase denied because the resource landed in the default group. Find the matching entry for readme.txt too: the same log shows fs:public: routed to the fs-public group and a GRANT in the resource phase. Policy decides on the enriched values. The originals are there for policies and audits that still want them.

Try to Get Around It​

A path that starts in the public directory but climbs out of it would pass a naive prefix check:

ask "read /tmp/mcp-data/public/../private/salaries.txt"
Tool call denied: denied by policy

The decision log shows why: the resource is mrn:agentvisor:fs:private:/tmp/mcp-data/public/../private/salaries.txt. The .. segment made the policy class it as private, so the deny rule applied. Decide on a scope the policy computed, not on the raw string the agent sent.

Enrichment Is Fail-Open​

Enrichment can never block a request on its own. If no rule matches, or the policy errors, the original operation and resource are used. Here that means a failed enrichment falls back to mrn:agentvisor:mcp:filesystem/read_file, which this domain's existing mrn:agentvisor:mcp:.* selector allows. In a real deployment, pair enrichment with a coarse rule that stays safe when enrichment is skipped, such as restricting which tools the original MRN may reach. See Fail-Open Behavior.

Troubleshooting​

SymptomLikely cause
The decision log shows agentvisor:mcp:tool:call and the original MRNEnrichment produced no result. Check rego_policy's path, the package name, and that the tool name is in read_tools or write_tools.
Every read is denied, including the public fileThe enriched operation or resource doesn't match a selector. Compare the operation and resource in the log with Step 4.
The MCP server fails at startupThe server's data directory doesn't exist inside the sandbox. Keep the mkdir in Step 1.

Next Steps​

You've written a policy that decides on what a tool call touches, not only which tool it calls. The rego provider ran in-process and needed no external service. When the decision logic lives in a system you already operate, such as a CMDB or ticketing system, use the HTTP enrichment provider instead: AgentVisor sends the same signals to your service as JSON and uses its response, with the same fail-open behavior.