Skip to content
BLACKLAKE

Policy Guide

How to write policies that precisely and predictably govern agent behaviour.

Policies can be created, edited, deleted, and toggled (enabled/disabled) from the console at http://localhost:3200/policies, via the API (POST /v1/policies, PATCH /v1/policies/:id, DELETE /v1/policies/:id), or via the SDK (bl.policies.create(), bl.policies.update(), bl.policies.delete()). All three interfaces are equivalent — changes from any source take effect immediately.


How Selectors Work

A policy has two selectors: agent_selector and tool_selector. Each is a flat key-value object matched against the corresponding resource record.

Matching rules:

  • Every key in the selector must match the exact value of that field on the agent or tool record.
  • An empty selector ({}) matches everything.
  • All fields must match — it is an AND condition, not OR.

Example agent selector:

{ "environment": "production", "risk_classification": "high" }

This matches any agent where environment is "production" and risk_classification is "high". An agent in production with a medium classification would not match.

Fields available for matching:

Agents: name, environment, risk_classification, status, approval_mode

Tools: name, risk_classification


Priority Ordering

Policies are evaluated in ascending priority order — lower number = evaluated first.

The engine walks through the sorted list and stops at the first policy where both selectors match. That policy's outcome is applied. No further policies are checked.

Specific policies should have lower priority numbers than broad catch-all policies.

Priority 1   →  evaluated first  (most specific rules)
Priority 10  →  evaluated second
Priority 100 →  evaluated last   (broadest catch-all)

The Three Outcomes

OutcomeMeaning
allowThe tool invocation is permitted. Proceed.
denyThe tool invocation is blocked. Do not proceed.
approval_requiredThe invocation requires human review before proceeding. Surface creates a pending approval record and fires an approval.created webhook. Your application must wait for or receive the decision before executing the tool.

Default Deny

If no policy matches, the outcome is default_deny. This is not a policy — it is the engine's fallback. It means you have not written a policy that covers this agent-tool combination.

There is no implicit allow. Every permitted invocation requires an explicit policy.


MCP Policies

When you add a server to ~/.blacklake/mcp-config.json, you can set a policy field that creates an initial policy for that server at startup:

{
  "servers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/you/Documents"],
      "policy": "ask"
    }
  }
}

The policy field is a shorthand that creates a single catch-all policy for the server:

ValueEffect
"allow"All tools from this server are allowed
"deny"All tools from this server are denied
"ask"All tools require human approval before executing

Policies created from mcp-config.json are ordinary policy rows in the database. You can create, edit, delete, and toggle them from the console at http://localhost:3200/policies or via the API at any time. Changes take effect immediately — the policy cache is flushed on every write — and are not overwritten on restart.

Example: allow reads, require approval for writes

To allow some tools but require approval for others, start with "policy": "allow" in mcp-config.json and then create specific policies from the console or API. For example, to require approval for write operations on a filesystem server:

# Allow read_file
curl -X POST http://localhost:3100/v1/policies \
  -H "x-api-key: local" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "filesystem-allow-reads",
    "priority": 10,
    "agent_selector": { "name": "filesystem" },
    "tool_selector": { "name": "read_file" },
    "outcome": "allow"
  }'

# Require approval for write_file
curl -X POST http://localhost:3100/v1/policies \
  -H "x-api-key: local" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "filesystem-approve-writes",
    "priority": 5,
    "agent_selector": { "name": "filesystem" },
    "tool_selector": { "name": "write_file" },
    "outcome": "approval_required"
  }'

With these two policies in place, read_file calls are allowed immediately and appear in the Evaluations page as allow. write_file calls pause and create an approval record. Approve or reject from the console at http://localhost:3200/approvals.

Example: deny all tool calls from a specific MCP server

To fully block a server while you review it:

curl -X POST http://localhost:3100/v1/policies \
  -H "x-api-key: local" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "block-untrusted-server",
    "priority": 1,
    "agent_selector": { "name": "untrusted-mcp-server" },
    "tool_selector": {},
    "outcome": "deny"
  }'

Priority 1 means this is evaluated before any other policy. The empty tool_selector matches all tools. Every call from untrusted-mcp-server is denied.

To lift the block: disable or delete the policy in the console. You do not need to edit mcp-config.json.


Common Patterns

Block all high-risk tools in production

High-risk tools should not be callable by any agent in production without explicit review. Place this at a low priority number so it is evaluated before broader allow rules.

await bl.policies.create({
  name: 'block-high-risk-in-prod',
  priority: 1,
  agent_selector: { environment: 'production' },
  tool_selector: { risk_classification: 'high' },
  outcome: 'deny',
});

Require approval for medium-risk tools in production

await bl.policies.create({
  name: 'approve-medium-risk-in-prod',
  priority: 2,
  agent_selector: { environment: 'production' },
  tool_selector: { risk_classification: 'medium' },
  outcome: 'approval_required',
});

Allow everything in development

Development environments typically need unrestricted access for testing. Place this at a high priority number so specific deny rules for other environments are not overridden.

await bl.policies.create({
  name: 'allow-all-in-dev',
  priority: 100,
  agent_selector: { environment: 'development' },
  tool_selector: {},
  outcome: 'allow',
});

Block a specific agent from a specific tool

Name-based selectors let you block a single agent from a single tool regardless of its environment or classification.

await bl.policies.create({
  name: 'block-agent-x-from-delete-record',
  priority: 5,
  agent_selector: { name: 'legacy-agent' },
  tool_selector: { name: 'delete-record' },
  outcome: 'deny',
});

Allow everything for trusted agents

An agent classified as critical may represent a fully audited system that should have unrestricted access. Allow it explicitly, but place it at a high priority number so narrower deny rules still take effect when they match first.

await bl.policies.create({
  name: 'allow-critical-agents',
  priority: 90,
  agent_selector: { risk_classification: 'critical' },
  tool_selector: {},
  outcome: 'allow',
});

Policy Evaluation Walkthrough

Consider this policy set, ordered by priority:

PriorityNameAgent SelectorTool SelectorOutcome
1block-high-risk-in-prod{ environment: production }{ risk_classification: high }deny
10approve-medium-risk-in-prod{ environment: production }{ risk_classification: medium }approval_required
50allow-support-agent{ name: customer-support-agent }{}allow
100allow-all-dev{ environment: development }{}allow

Scenario A: customer-support-agent (production, medium) tries to use send-email (medium).

  1. Priority 1: agent selector matches (production). Tool selector requires highsend-email is medium. No match.
  2. Priority 10: agent selector matches (production). Tool selector requires mediumsend-email is medium. Match. Outcome: approval_required.

Scenario B: customer-support-agent (production, medium) tries to use read-knowledge-base (low).

  1. Priority 1: tool is low, not high. No match.
  2. Priority 10: tool is low, not medium. No match.
  3. Priority 50: agent name is customer-support-agent. Tool selector is {} — matches everything. Match. Outcome: allow.

Scenario C: data-pipeline-agent (production, high) tries to use write-to-s3 (high).

  1. Priority 1: agent is production. Tool is high. Both match. Outcome: deny.

Evaluation stops at the first match. Priorities 10, 50, and 100 are never reached in Scenario C.

Scenario D: new-agent (staging, low) tries to use send-notification (low).

  1. Priority 1: agent is staging, not production. No match.
  2. Priority 10: agent is staging, not production. No match.
  3. Priority 50: agent name is not customer-support-agent. No match.
  4. Priority 100: agent environment is staging, not development. No match.
  5. No match. Outcome: default_deny.

Practical Tips

Space priorities apart. Use values like 1, 10, 20, 50, 100 rather than 1, 2, 3. This gives you room to insert rules between existing ones without renumbering everything.

Put catch-all allows at the highest priority number. Broad permissive rules should be evaluated last, after all specific deny and approval rules have had a chance to match.

Test before relying on a policy. Use POST /v1/govern directly to verify a policy behaves as expected before deploying the agent that depends on it.

# Test: does the new deny rule block the expected agent?
curl -X POST http://localhost:3100/v1/govern \
  -H "x-api-key: local" \
  -H "Content-Type: application/json" \
  -d '{
    "agent": "customer-support-agent",
    "tool": "delete-record",
    "context": { "test": true }
  }'

Disable rather than delete. Set enabled: false to temporarily remove a policy from evaluation without losing its definition. Use DELETE only when you are sure the rule is no longer needed.

Combine selectors carefully. A selector with multiple keys requires all keys to match. If you want different rules for different combinations, write separate policies — do not try to express OR logic in a single selector.

Audit with evaluations. After deploying a new policy, query the evaluations endpoint to confirm that traffic is being matched and decided as expected. Look for unexpected default_deny outcomes as a signal that a required allow policy is missing.


approval_required in Practice

When a policy with outcome: 'approval_required' matches, Surface:

  1. Records the evaluation (as always).
  2. Creates a pending approval record with a 24-hour expiry.
  3. Fires an approval.created webhook to any registered subscribers.
  4. Returns decision: 'approval_required' and approval_id in the govern response.

The caller must act on this before executing the tool. Two patterns:

  • Short-running workflows: Call bl.approvals.wait(approval_id) to block and poll for up to 5 minutes. If a decision arrives in time, proceed or abort based on the result.
  • Long-running workflows: Store the approval_id and register a webhook to receive approval.approved or approval.rejected events when a human makes a decision.

For MCP tool calls: the proxy holds the request open and waits automatically. When you approve or reject from the console, the proxy responds to the MCP client immediately.

Example — require approval for high-value payments:

await bl.policies.create({
  name: 'require-approval-for-large-payments',
  priority: 5,
  agent_selector: { name: 'expense-bot' },
  tool_selector: { name: 'payments.send' },
  outcome: 'approval_required',
});

With this policy in place, every call to bl.govern({ agent: 'expense-bot', tool: 'payments.send', ... }) returns approval_required with an approval_id. Your agent code must not execute the payment until an approved decision is confirmed. The approval expires after 24 hours if no decision is made.