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:
| Field | Type | Description |
|---|---|---|
name | string | A name people can read, for example allow-crm-reads |
tool_pattern | string | A glob or regex pattern that matches action types |
action | enum | allow or deny |
alert | boolean | Emit an alert event when the rule matches |
hold | boolean | Pause the action until a human approves it |
priority | integer | Evaluation order. Higher values are evaluated first |
constraints | object | Optional 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: allowwithalert: true; - a hold is
action: denywithhold: 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, defaultalert) 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; offskips 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.
| Surface | Action type | Example |
|---|---|---|
| MCP tool call through the sidecar | mcp:<tool> | mcp:read_crm_contact |
| MCP tool call through the gateway | mcp:<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 gateway | llm:send:<provider>:<model> | llm:send:openai:gpt-4o |
| Framework built-in tool decided through the hook | builtin:<tool> | builtin:bash |
| Governed shell (v0.21) | shell:execute | shell: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,builtinorshell) before its first:; - puts a wildcard in the surface position (
*:read_filewould match every surface); - keeps a colon after the
mcpsurface, as inmcp: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:
| Pattern | Matches | Does not match |
|---|---|---|
mcp:read_* | mcp:read_crm_contact, mcp:read_database | mcp:write_crm_contact |
mcp:*_export_* | mcp:bulk_export_contacts, mcp:data_export_csv | mcp:read_contacts |
mcp:db.* | mcp:db.query, mcp:db.insert, mcp:db.delete | mcp:api.query |
mcp:* | every MCP tool call | llm: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 aspaths);?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:
| Pattern | Matches |
|---|---|
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:crmmatchesmcp: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_commandsandscript_paths. There, a leading!negates the entry:"paths": ["regex:^/workspace/.*$", "!regex:^/workspace/secrets/"]. It does not apply to*_containsconstraints, 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
| Key | Value | Matches against |
|---|---|---|
paths | list of globs | the action's path argument |
commands | list of globs | Deprecated 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>_contains | list of substrings | the named argument, case-insensitively, for example commands_contains or query_contains |
domains | list of globs | the action's domain argument |
channels | list of globs | the action's channel argument |
models | list of globs | the action's model argument |
max_tokens | number | the action's max_tokens argument, as an upper bound |
pattern | string | free-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:
- the argument named
<key>with a trailingsstripped (paths→path); - then the argument named
<key>itself.
Egress destinations are a separate mechanism. The
domainsconstraint 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 value | Effect |
|---|---|
| Array of strings | Pattern list |
| String | Pattern 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 |
| Number | Numeric upper bound |
Object, boolean or null | Refused 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 of | Use |
|---|---|
| A time-window constraint | Nothing equivalent today. Scope by action pattern, or use an alert rule and review the audit log |
| A rate constraint | LLM Gateway rate limits for LLM calls: a per-policy cap on calls per minute per agent |
An agent_id constraint | Assign 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 constraint | hold_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/shadowresolves to/etc/shadow, so it does not match a/workspacepattern. 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.
/workspacematches/workspaceand anything beneath it.
A ! prefix makes an entry an exclusion. The edge applies three rules, in order:
- If the value matches any excluded pattern, the constraint fails.
- Otherwise, if there is at least one positive pattern, the value must match one of them.
- 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,
commandsis an alias ofresolved_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,argsorscript, 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 -rfwith 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,cmdorscript, as a command line;argsorargv, 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
codebody that no rule judged carriescode_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.
| Key | Value | Semantics |
|---|---|---|
resolved_commands | list of globs | Every 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 |
commands | list of globs | Deprecated alias of resolved_commands, identical in behavior |
resolved_commands_contains | list of substrings | The substring form of the above, with the same any/all semantics |
exec_features | list of feature names | Named 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_paths | list of globs | Script files the command line references, matched with the same glob and ! semantics as paths |
max_pipeline_depth | number | Upper bound on pipeline and nesting depth |
on_unparseable | deny | hold | allow | What 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 toresolved_commands, which means a denylist never sees them. The edge fails closed on this throughon_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 notdepth_exceeded, which does mean the analysis stopped early. Treating the two alike would deny an ordinarygit addover 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
nullconstraint values; - any
exec_featuresentry 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:
| Setting | Values | Default | Governs |
|---|---|---|---|
command_analysis_mode | off · enforce · log | enforce | Whether 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_minutes | 1–1440 | 30 | How long a governed write stays linked to a later execution of the same path. |
taint_action | alert · hold · deny | alert | What 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_action | alert · hold · deny | alert | What happens to a write whose destination runs by itself (write_to_autorun_path). |
script_inspection_roots | up to 32 absolute directories | empty = the working directory | Where the edge may read script files from before deciding. |
unjudged_code_action | alert · hold · deny | alert | What happens to an action carrying code_body_unjudged: a code body handed to a tool the edge does not treat as executing. |
code_execution_tools | up to 64 tool patterns, each at most 1024 bytes | empty = execution-named tools only | Which 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:
alertrecords the finding on the event and lets the rules decide;holdparks the action for approval, like any other hold;denyblocks it withblocked_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 whosetool_patternmatches the write. So adenyonbuiltin:writewithresolved_commands: ["rm"]stops the agent from authoringrm -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.jsonlifecycle scripts,.claude/settings*.jsonand.mcp.json.autorun_write_actiondecides 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;holdparks the action for approval;denyblocks 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:
- The content is a program, so the write carries
script_authoring. - The rule evaluates the content's tree, because its pattern matches
builtin:write. rmresolves, 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:
- the later
bash /workspace/cleanup.shwould be read under/workspace; rmwould resolve from the merged tree;- 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:writeto/workspace/.git/hooks/pre-commitis a write to an autorun path. The event carrieswrite_to_autorun_path, andscript_authoringtoo, 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.shthat reads a file the agent wrote ten minutes earlier is allowed. It carrieswrite_then_runand its taint hit, and produces oneexecution.write_then_run_observednotification per path within the window. - A
bash /srv/other.shoutside both roots isscript_unresolvable. The defaulton_unparseabledenies it, and anexecution.script_unresolvablenotice 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.