New: Agent approvals, red teaming and model scanning. See how
MITRITY
Docs / Policies / Writing Policies

Writing Policies

Policies are the governance rules at the core of MITRITY. Before an agent's action runs, MITRITY checks it against your policies. This guide shows you how to write them: how a rule is built, how it matches actions, how priority decides which rule applies, and practical examples.

Policy Structure

A policy contains rules. Every rule has these fields:

FieldTypeDescription
namestringA name people can read, for example allow-crm-reads
tool_patternstringA glob or regex pattern that matches action types
actionenumallow or deny
alertbooleanEmit an alert event when the rule matches
holdbooleanPause the action until a human approves it
priorityintegerEvaluation order. Higher values are evaluated first
constraintsobjectOptional conditions on the action's arguments. See Constraints

Policy types

A rule that matches has one of four outcomes:

  • Allow: the action is permitted. The gateway forwards the request and logs the event.
  • Deny: the action is blocked. The gateway returns an error to the agent and logs the event. If you have set up notifications, they are sent.
  • Alert: the action is permitted, and an alert is raised. The event is flagged in the audit log, and notifications go to the channels you have set up (Slack, email, SIEM).
  • Hold: the action is paused and queued for human approval. The gateway holds the request until an authorized user approves or denies it in the dashboard, or until the hold timeout expires.

These four outcomes are not one enum field. action is allow or deny, and alert and hold are booleans on top of it:

  • an alert is action: allow with alert: true;
  • a hold is action: deny with hold: true. The action stays denied unless a human releases it.

Prompt injection modes

Besides its rules, a policy has settings of its own. Two of them control the prompt-injection scan:

  • injection_mode (off · log · enforce) covers tool calls;
  • llm_injection_mode (enforce · alert · off, default alert) covers the prompts an agent sends through the LLM Gateway.

The three values of llm_injection_mode work like this:

  • under enforce, a prompt that carries an injection never reaches the model;
  • under alert, the call goes ahead and the event is recorded;
  • off skips the scan, for prompts only.

The two settings are separate for two reasons. First, an injection in a prompt does no harm by itself. The harm is the tool call it leads to, and that call is still judged under the tool-call mode. Second, agents that read logs, web pages or tickets legitimately see text that looks like an injection.

injection_mode: off turns the scan off everywhere, prompts included.

Action Pattern Matching

A rule's action pattern matches action types. You can write it in glob or in regex syntax.

What an action type looks like

Every action that the edge (the MITRITY runtime beside your agents) evaluates starts with a surface prefix. MITRITY does not add the prefix to your pattern for you, so:

  • a glob must cover the whole action type, prefix included;
  • a regex must account for the prefix itself. Anchor it with ^ if you mean the start.
SurfaceAction typeExample
MCP tool call through the sidecarmcp:<tool>mcp:read_crm_contact
MCP tool call through the gatewaymcp:<namespace>__<tool>mcp:fs__write_file, mcp:shell__run_command
MCP call across the mesh (Mesh Authorizer)mcp:<service>__<operation>mcp:content-tool__read_file, mcp:content-tool__GET /
LLM call through the LLM gatewayllm:send:<provider>:<model>llm:send:openai:gpt-4o
Framework built-in tool decided through the hookbuiltin:<tool>builtin:bash
Governed shell (v0.21)shell:executeshell:execute

A gateway tool's name joins its upstream's namespace and the tool's own name with a double underscore: fs__write_file. That is the only form the model APIs can call:

  • Claude Code and the Claude Agent SDK register MCP tools as mcp__<server>__<tool>;
  • the Anthropic API accepts tool names matching ^[a-zA-Z0-9_-]{1,128}$;
  • OpenAI's cap is 64, so keep namespaces short.

The gateway serves no other form. The Mesh Authorizer names an east-west call (one service calling another inside the cluster) the same way: the in-cluster service and the operation, joined by __. So one rule shape covers a tool, whichever way the agent reaches it.

The surface prefix (mcp:, llm:send:, builtin:, shell:execute) is MITRITY's own grammar. It is never sent to a model.

So a pattern written as read_crm_* never matches an MCP action. Write mcp:read_crm_* instead.

The API enforces this when you save a rule. It rejects a glob with HTTP 422, naming tool_pattern, when the glob:

  • does not start with a known surface (mcp, llm, http, egress, delegate, builtin or shell) before its first :;
  • puts a wildcard in the surface position (*:read_file would match every surface);
  • keeps a colon after the mcp surface, as in mcp:github:*.

That last colon is the former namespace separator, and no action type carries it any more: write mcp:github__* instead. The dashboard flags these patterns before you submit.

* alone (every action) and regex: patterns are accepted as written. The colons of the other surfaces, as in llm:send:openai:*, are their own grammar and are fine.

Glob patterns

A glob uses * as a wildcard for simple matching:

PatternMatchesDoes not match
mcp:read_*mcp:read_crm_contact, mcp:read_databasemcp:write_crm_contact
mcp:*_export_*mcp:bulk_export_contacts, mcp:data_export_csvmcp:read_contacts
mcp:db.*mcp:db.query, mcp:db.insert, mcp:db.deletemcp:api.query
mcp:*every MCP tool callllm:send:openai:gpt-4o

Globs follow Go's filepath.Match rules:

  • * matches any run of characters, including : and ., but never / (which matters for path constraints such as paths);
  • ? matches one character;
  • [...] matches a character class;
  • the glob must cover the entire action type.

Regex patterns

When you need alternation or anchors, start the pattern with regex:. The rest of the pattern is a Go RE2 regular expression:

PatternMatches
regex:^mcp:(read|list)_crm_.*$mcp:read_crm_contact, mcp:list_crm_deals
regex:^mcp:db\.(query|read)$mcp:db.query, mcp:db.read
regex:^mcp:.*_(delete|drop|truncate)_.*$Any destructive operation

How regex patterns behave:

  • Matching is unanchored: regex:crm matches mcp:read_crm_contact. Anchor with ^ and $ when you mean the whole action type, as every example here does.
  • RE2 syntax has no backreferences or lookaround, and no pathological backtracking. A pattern is limited to 1024 bytes.
  • A pattern that does not compile is rejected when you save the rule, with HTTP 422 naming the field. If one reaches the edge anyway, it matches nothing, so a broken deny never turns into an allow.
  • The same syntax works in list-valued constraints such as paths, resolved_commands and script_paths. There, a leading ! negates the entry: "paths": ["regex:^/workspace/.*$", "!regex:^/workspace/secrets/"]. It does not apply to *_contains constraints, which are plain substrings.

Priority System

Rules are evaluated in descending priority order: the highest priority number goes first. The first rule that matches decides the outcome.

Priority 300: deny  mcp:bulk_export_*   (evaluated first)
Priority 200: alert mcp:write_*         (evaluated second)
Priority 100: allow mcp:read_crm_*      (evaluated third)
Priority  50: allow mcp:send_email      (evaluated fourth)
Priority   1: deny  *                   (catch-all, evaluated last)

Best practice: add a catch-all deny rule with the lowest priority (priority: 1) as a safety net. Then add allow and alert rules with higher priorities for the actions you want to permit or monitor.

Constraints

Constraints narrow a rule by the arguments of the action, not by who made it or when. A rule matches only if its action pattern matches and every one of its constraints is satisfied.

A constraint that is not satisfied makes the rule not match at all. Evaluation moves on to the next rule; it does not deny.

Constraints are the part of the policy model that is easiest to get wrong. This section describes what the edge actually implements today, including where it falls short.

The vocabulary

KeyValueMatches against
pathslist of globsthe action's path argument
commandslist of globsDeprecated alias of resolved_commands from edge v0.20. See Semantic command constraints. Before v0.20: the first word of the command argument (see the limitation below)
<param>_containslist of substringsthe named argument, case-insensitively, for example commands_contains or query_contains
domainslist of globsthe action's domain argument
channelslist of globsthe action's channel argument
modelslist of globsthe action's model argument
max_tokensnumberthe action's max_tokens argument, as an upper bound
patternstringfree-form, for custom tools

These are the keys the dashboard's rule editor writes. The edge does not keep an allowlist of keys, so any key of the right shape works the same way. The edge resolves <key> in this order:

  1. the argument named <key> with a trailing s stripped (paths → path);
  2. then the argument named <key> itself.

Egress destinations are a separate mechanism. The domains constraint compares a tool argument. To control which hosts an agent may reach on the network, use destination allowlists. They are enforced against the tool's attested destination, not against an argument value.

Value types, and one trap

JSON valueEffect
Array of stringsPattern list
StringPattern list with one entry. paths, commands, resolved_commands, script_paths and the _contains keys take arrays only, and refuse a bare string with HTTP 422
NumberNumeric upper bound
Object, boolean or nullRefused with HTTP 422

The API checks every value when you save a rule. A value of the wrong type is refused with HTTP 422, naming the key: constraints.<key>, or rules[<i>].constraints.<key> for a rule in the rules array of a new policy. An array that holds anything other than strings is refused too. So a constraint written as {"time": {"after": "18:00"}} is refused, rather than stored and ignored.

The trap is a constraint that is valid but cannot mean what you intended. The vocabulary is open (see "The vocabulary" above), so a key of the right shape, such as "time": "18:00", is accepted. It is compared like any other pattern: against a tool argument named time, or against the tool's name when the action has no such argument. There is no time-of-day, rate-limit, per-agent or hold-timeout constraint today. Use the mechanisms that do exist instead:

Instead ofUse
A time-window constraintNothing equivalent today. Scope by action pattern, or use an alert rule and review the audit log
A rate constraintLLM Gateway rate limits for LLM calls: a per-policy cap on calls per minute per agent
An agent_id constraintAssign a different policy to that agent. An agent_id key is compared against a tool argument, not the calling agent, so it never matches what you meant
A hold_timeout constrainthold_timeout_minutes on the rule itself. See approval workflows

Glob matching and ! exclusion

Pattern lists use glob syntax (*, ?, [...]) with three refinements:

  • Paths are canonicalised first. /workspace/../../etc/shadow resolves to /etc/shadow, so it does not match a /workspace pattern. A path traversal cannot slip past a prefix.
  • A pattern ending in /* matches recursively. /tmp/* matches /tmp/a/b/c, not just /tmp/a.
  • A pattern with no glob characters matches as a directory prefix. /workspace matches /workspace and anything beneath it.

A ! prefix makes an entry an exclusion. The edge applies three rules, in order:

  1. If the value matches any excluded pattern, the constraint fails.
  2. Otherwise, if there is at least one positive pattern, the value must match one of them.
  3. If only exclusions were given and none matched, the constraint passes.

So ["/workspace/*", "!/workspace/.git/*"] means "anywhere under /workspace except .git". And ["!/etc/*"] on its own means "anywhere except /etc".

_contains matching

A key ending in _contains switches to case-insensitive substring matching. It reads the argument named by the prefix: query_contains reads the query argument. Exclusion and the three rules above work the same way.

{
  "name": "alert-on-destructive-sql",
  "action": "allow",
  "alert": true,
  "tool_pattern": "mcp:query_database",
  "priority": 200,
  "constraints": {
    "query_contains": ["drop table", "truncate", "delete from"]
  }
}

Numeric limits

A numeric constraint is an upper bound. The constraint fails, and so the rule does not match, when the argument parses as an integer strictly greater than the limit. An argument that is missing, non-numeric or zero never trips it.

What is compared when the argument is missing

If the action's arguments contain neither <key> nor its singular form, the edge falls back to the action's target: the qualified tool name.

This matters more than it looks. Take a paths constraint on a tool whose argument is called file rather than path. The edge compares your globs against the string fs__write_file. They will not match, so a deny rule will not fire and an allow rule will not allow. Check the tool's actual argument names in the tool catalog before you write a constraint against them.

The commands limitation

This section describes edge releases before v0.20, the release that brought the semantic command parser below. From edge release v0.20, commands is an alias of resolved_commands, and the evasion described here no longer works. See Semantic command constraints.

commands does not see the whole command line. The value is normalised to the base name of the first word, and truncated at the first null byte. /bin/rm -rf / and ./rm -rf / both become rm.

That normalisation is what makes commands easy to evade:

{
  "name": "deny-rm",
  "action": "deny",
  "tool_pattern": "mcp:shell__run_command",
  "priority": 300,
  "constraints": { "commands": ["rm", "dd", "mkfs"] }
}

This rule does not match bash -c "rm -rf /", because the first word normalises to bash. It also does not match:

  • rmdir, which is correct;
  • a tool that names its argument cmd, args or script, which is not.

On those releases, the workaround is two rules instead of one:

  • one rule denies the wrappers by name;
  • the other denies the dangerous strings wherever they appear.
[
  {
    "name": "deny-shell-wrappers",
    "action": "deny",
    "tool_pattern": "mcp:shell__run_command",
    "priority": 310,
    "constraints": { "commands": ["bash", "sh", "zsh", "env", "sudo", "nohup", "xargs"] }
  },
  {
    "name": "deny-destructive-strings",
    "action": "deny",
    "tool_pattern": "mcp:shell__run_command",
    "priority": 305,
    "constraints": { "commands_contains": ["rm -rf", "mkfs", "dd if=", "| sh"] }
  }
]

Treat this as mitigation, not enforcement:

  • denying the wrappers by name also blocks legitimate shell use;
  • quoting, variables and encoding defeat substring matching. rm -rf with two spaces already slips past the list above.

commands is deprecated and is not extended. The keys below replace it.

Semantic command constraints

These constraints are available from edge release v0.20, and what follows describes shipped behavior. The parser reads the command line with a real shell grammar. It unwraps wrappers, pipelines and inline interpreters, and exposes the result as the constraint keys below. The wrapper evasion in the previous section is blocked.

commands is now an alias of resolved_commands, with the same semantics: the same globs, the same any/all matching and the same unwrapping. A rule that still carries commands now matches the whole parsed tree instead of basename(argv[0]), so bash -c "rm -rf /" no longer slips past it. The key stays deprecated: write resolved_commands in new rules, because the name says what it matches. Nothing you have already written breaks.

The parser reads whichever command-shaped argument the tool exposes:

  • command, cmd or script, as a command line;
  • args or argv, as a JSON array of words.

A code argument is content, not a command:

  • A code-authoring tool's body is treated like a written script. It is hashed, scanned for sensitive data (DLP) and for injection, and parsed as shell when it is shell. See the write-time findings below.
  • Only a tool whose name says it executes that code has the body judged as a command line.
  • On every other tool, a code body that no rule judged carries code_body_unjudged, with the tool and the key in its metadata. That covers a call without a path, and a written body that is not a shell program.
  • A shell body written through a tool that does not execute it is judged as the script being authored (script_authoring), and needs no stamp.

Two policy settings govern this. See Code bodies on tools that do not execute them.

KeyValueSemantics
resolved_commandslist of globsEvery command the parser resolves after unwrapping. Deny rules match if any resolved command matches; allow rules match only if every resolved command matches. So bash -c "rm -rf /" resolves to bash and rm, and a deny on rm fires
commandslist of globsDeprecated alias of resolved_commands, identical in behavior
resolved_commands_containslist of substringsThe substring form of the above, with the same any/all semantics
exec_featureslist of feature namesNamed properties of the parsed command line: pipe_to_shell, remote_fetch_to_shell, base64_decode, eval, cmd_subst, subshell, inline_interpreter, redirect_write, heredoc, background, find_exec, xargs, unparseable, depth_exceeded, nul_byte, unsupported_dialect, unresolved_command, paths_truncated
script_pathslist of globsScript files the command line references, matched with the same glob and ! semantics as paths
max_pipeline_depthnumberUpper bound on pipeline and nesting depth
on_unparseabledeny | hold | allowWhat to do when the command line cannot be parsed, or when the command word itself cannot be resolved. Defaults to deny

Two of the features describe the analysis, not the command. Read these before you write a rule against either:

  • unresolved_command: the command word is dynamic (a variable, a substitution or an unreadable token), so no base command could be resolved. $CMD -rf / and $(which rm) -rf / add nothing to resolved_commands, which means a denylist never sees them. The edge fails closed on this through on_unparseable. Naming the feature lets you say so explicitly, or alert on it while still allowing. A variable in argument position (echo $HOME) raises nothing.
  • paths_truncated: a single command referenced more than 64 paths, so that command's path list was truncated. The coverage is not: the parsed tree, the resolved commands and the script references are all complete. You lose audit detail, not coverage. It is deliberately not depth_exceeded, which does mean the analysis stopped early. Treating the two alike would deny an ordinary git add over a long file list.

Constraint validation ships with the same work. The control plane rejects the following with a 422 naming the offending key, instead of accepting them and silently dropping them at the edge:

  • object, boolean and null constraint values;
  • any exec_features entry outside the list above.

Unknown keys are still accepted, because the constraint vocabulary is open by design (see "The vocabulary" above).

For what is and is not enforced across the rest of the product, see Governance Coverage.

Scripts, write-then-run and autorun paths

Parsing the command line answers "what does this command run?". It does not answer "what happens when the agent puts the commands in a file first?". That covers two cases:

  • the split write-and-run, where the agent writes a file in one action and runs it in another;
  • the smuggled script, where the commands sit inside a file that the command line only names.

Three mechanisms on the edge close these gaps. Each is governed by a policy-level setting, not by a rule constraint. The settings live on the policy itself (Policies → edit → Command Analysis), beside command_analysis_mode:

SettingValuesDefaultGoverns
command_analysis_modeoff · enforce · logenforceWhether the parser, the taint store, script inspection and the write-time findings run at all. Under log, every finding is recorded and none of the actions below is consulted.
write_then_run_window_minutes1–144030How long a governed write stays linked to a later execution of the same path.
taint_actionalert · hold · denyalertWhat happens to an execution that reads a file this agent wrote earlier (write_then_run), or whose script changed after that write (modified_outside_governance).
autorun_write_actionalert · hold · denyalertWhat happens to a write whose destination runs by itself (write_to_autorun_path).
script_inspection_rootsup to 32 absolute directoriesempty = the working directoryWhere the edge may read script files from before deciding.
unjudged_code_actionalert · hold · denyalertWhat happens to an action carrying code_body_unjudged: a code body handed to a tool the edge does not treat as executing.
code_execution_toolsup to 64 tool patterns, each at most 1024 bytesempty = execution-named tools onlyWhich tools execute their code argument. On a matching tool, the body is parsed and judged as the command, and code_body_unjudged is never raised. Same grammar as a rule's tool_pattern.

The three actions work like this:

  • alert records the finding on the event and lets the rules decide;
  • hold parks the action for approval, like any other hold;
  • deny blocks it with blocked_by: command.

A rule that denies the resolved commands wins over a policy-level alert. The settings are the floor for findings that no rule mentions. They are not a cap on what a rule may do.

The taint store

The taint store remembers the files an agent wrote, so the edge can recognise a later run of the same file.

Every allowed write-shaped action leaves an entry in a store kept per agent and per session. That covers builtin:write, builtin:edit, an MCP tool with a path and content, a shell tree with a redirect, tee, cp, mv, curl -o and chmod. Each entry records:

  • the path;
  • the writer tool;
  • the time;
  • the SHA-256 of the bytes, when the edge saw them in full.

Entries expire after write_then_run_window_minutes. When a later execution references one of those paths, the event carries write_then_run and a taint hit (path, age, writer, hash status). If the file's current hash no longer matches the governed write, the event carries modified_outside_governance too. taint_action decides the outcome.

The hash status has three values: matches, changed and unknown. The status is unknown when the edge never saw the file in full, as with an Edit-style patch, a redirect or a cp. Then no hash is recorded, and nothing is claimed either way.

An MCP write that carries more than one distinct content body (content beside code) is unknown too. Every body is judged, but which one lands is not the edge's to know. So the write carries ambiguous_write_content, naming the keys, and modified_outside_governance cannot fire for that path.

Script inspection

Script inspection lets the edge read the scripts a command line runs, and judge the commands inside a shell script.

Every script reference in the parsed tree is resolved against the action's working directory, and read under script_inspection_roots. Script references are forms such as sh f, bash f, . f, source f, python f and a bare ./f. The read is strict: absolute, cleaned paths, symlinks resolved, regular files only, and a 256 KiB cap.

Shell scripts are parsed with the same grammar and merged into the evaluated tree. So resolved_commands, exec_features, script_paths and the threat pack all see the commands inside them. For example, a deny rm rule fires on bash deploy.sh when deploy.sh contains rm -rf /. Python, Node and other interpreter scripts are hashed and scanned, not analyzed.

A script the edge cannot read is script_unresolvable. That happens when the script is:

  • outside every root;
  • missing;
  • oversize;
  • reached through a symlink that leaves the roots.

Its path is listed, no hash is recorded, and the rule's on_unparseable applies (deny by default). The execution.script_unresolvable notification tells you, so you can widen the roots when the denial is a configuration gap rather than an attack. Widening the roots lets the edge read more, and so judge more. It never makes a script trusted.

Write-time findings

Two findings are raised on the write itself, where a hold is cheapest:

  • script_authoring: the written content is a program. The edge recognises one by a shebang, a shell extension, .git/hooks/, or content that parses as shell with a command and an exec feature. The content is parsed and evaluated by every rule whose tool_pattern matches the write. So a deny on builtin:write with resolved_commands: ["rm"] stops the agent from authoring rm -rf / into a script.
  • write_to_autorun_path: the destination runs without anyone launching it: cron, systemd units, launchd plists, shell rc and profile files, .git/hooks/**, package.json lifecycle scripts, .claude/settings*.json and .mcp.json. autorun_write_action decides what happens.

Code bodies on tools that do not execute them

A code argument is content (see Semantic command constraints). Only a tool that the edge treats as executing has it parsed as the command line.

Out of the box, that is the execution-name gate: a tool whose name carries a token such as execute, run, eval or repl. code_execution_tools extends it with patterns of your own, such as mcp:pyodide, mcp:*repl* or regex:^mcp:.*(exec|run). You write them in the same grammar as a rule's tool_pattern, and they are validated the same way. A pattern without a surface prefix is refused with a 422 naming the entry, code_execution_tools[<i>].

A code body handed to a matching tool is judged as the command the tool runs. The resolved_commands rules, the threat pack and on_unparseable all apply, and the body never carries the stamp.

On every other tool, a non-blank code body that no rule judged carries code_body_unjudged. unjudged_code_action then decides:

  • alert (the default) records the coverage finding and leaves the outcome to the rules;
  • hold parks the action for approval;
  • deny blocks it.

If one tool keeps raising code_body_unjudged, name that tool in code_execution_tools rather than tightening the action. The body is then judged instead of stamped. Both settings are on the same policy screen as the settings above, under Code bodies.

Example A: deny the split write-and-run

Policy settings:

{
  "command_analysis_mode": "enforce",
  "write_then_run_window_minutes": 60,
  "taint_action": "deny",
  "script_inspection_roots": ["/workspace"]
}

Rule:

{
  "name": "deny-destructive-anywhere",
  "action": "deny",
  "tool_pattern": "regex:^(builtin:(bash|write|edit)|mcp:shell__.*)$",
  "priority": 300,
  "constraints": { "resolved_commands": ["rm", "dd", "mkfs*"] }
}

The agent writes /workspace/cleanup.sh, containing rm -rf /data, through builtin:write:

  1. The content is a program, so the write carries script_authoring.
  2. The rule evaluates the content's tree, because its pattern matches builtin:write.
  3. rm resolves, and the write is denied. Nothing reaches disk.

Had the rule not matched the write action, the write would have been allowed and tainted. Then:

  1. the later bash /workspace/cleanup.sh would be read under /workspace;
  2. rm would resolve from the merged tree;
  3. the rule would deny it.

Even without the rule, write_then_run with taint_action: deny blocks it. Either way, the audit event shows the canonical command, script_authoring or write_then_run, the script's hash and the taint hit.

Example B: hold writes to autorun paths, watch the rest

Policy settings:

{
  "command_analysis_mode": "enforce",
  "taint_action": "alert",
  "autorun_write_action": "hold",
  "script_inspection_roots": ["/workspace", "/opt/ci/bin"]
}

No rule is needed. Here is how three actions turn out:

  • A builtin:write to /workspace/.git/hooks/pre-commit is a write to an autorun path. The event carries write_to_autorun_path, and script_authoring too, since a hook is a program. The action is held for approval: the reviewer sees the canonical script text and decides.
  • A bash /opt/ci/bin/release.sh that reads a file the agent wrote ten minutes earlier is allowed. It carries write_then_run and its taint hit, and produces one execution.write_then_run_observed notification per path within the window.
  • A bash /srv/other.sh outside both roots is script_unresolvable. The default on_unparseable denies it, and an execution.script_unresolvable notice names the path.

Unless the first-party governed shell runs the execution, the hash is recorded at decision time, and a file can still change before the framework runs it. modified_outside_governance catches the change on the next decision; it does not prevent it. See Governance Coverage for the boundary.

Practical Examples

Example 1: Basic CRM governance

This policy denies bulk exports, alerts on CRM writes, allows CRM reads, and denies everything else:

[
  {
    "name": "deny-bulk-export",
    "action": "deny",
    "tool_pattern": "mcp:bulk_export_*",
    "priority": 300
  },
  {
    "name": "alert-write-operations",
    "action": "allow",
    "alert": true,
    "tool_pattern": "mcp:write_crm_*",
    "priority": 200
  },
  {
    "name": "allow-crm-reads",
    "action": "allow",
    "tool_pattern": "mcp:read_crm_*",
    "priority": 100
  },
  {
    "name": "deny-all-default",
    "action": "deny",
    "tool_pattern": "*",
    "priority": 1
  }
]

Example 2: Hold for privilege escalation

{
  "name": "hold-iam-changes",
  "action": "deny",
  "hold": true,
  "tool_pattern": "regex:^mcp:iam\\.(create_role|assign_role|escalate_permissions)$",
  "priority": 400
}

The hold timeout is not a constraint. hold_timeout_minutes and timeout_action are fields on the rule itself. See approval workflows.

Example 3: Writes confined to a workspace

[
  {
    "name": "deny-writes-to-git-internals",
    "action": "deny",
    "tool_pattern": "mcp:fs__write_file",
    "priority": 310,
    "constraints": {
      "paths": ["/workspace/.git/*"]
    }
  },
  {
    "name": "deny-writes-outside-workspace",
    "action": "deny",
    "tool_pattern": "mcp:fs__write_file",
    "priority": 300,
    "constraints": {
      "paths": ["!/workspace/*"]
    }
  }
]

The second rule denies any write whose path argument is not under /workspace. Its list is exclusion-only, so everything outside the workspace satisfies it. The first rule has the higher priority, so it runs first, and it denies writes to .git inside the workspace. Both rules depend on the tool naming its argument path, so check the tool catalog first.

Managing Policies

You can create and manage policies in three ways:

  • Dashboard: Policies > Create Policy, a visual editor with validation.
  • REST API: POST /api/v1/policies, for programmatic management.
  • Policy-as-code: define policies in JSON files and apply them through CI/CD.

For the full API reference, see the API overview.