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

Writing Policies

Policies are the core governance rules in MITRITY. Every action an agent performs is evaluated against your policies before it executes. This guide covers policy structure, pattern matching, the priority system, and practical examples.

Policy Structure

Every rule has these components:

FieldTypeDescription
namestringA human-readable name for the rule (e.g., allow-crm-reads)
tool_patternstringA glob or regex pattern matching action types
actionenumallow or deny
alertbooleanEmit an alert event when the rule matches
holdbooleanSuspend the action pending human approval
priorityintegerEvaluation order — higher values are evaluated first
constraintsobjectOptional conditions on the action's arguments — see Constraints

Policy types

  • 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. Notifications are sent if configured.
  • Alert: The action is permitted, but an alert is generated. The event is flagged in the audit log and notifications are sent to configured channels (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 a single enum field. action is allow or deny, and alert and hold are booleans layered on top of it: an alert is action: allow with alert: true, and a hold is action: deny with hold: true — denied unless a human releases it.

Prompt injection modes

Beside its rules, a policy carries settings of its own, and two of them govern 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: under enforce a prompt that carries an injection never reaches the model, under alert the call proceeds and the event is recorded, and off skips the scan for prompts only. The two are separate because an injection in a prompt does no harm by itself — the harm is the tool call it induces, and that call keeps the tool-call mode — while agents that read logs, web pages or tickets legitimately see injection-shaped text. injection_mode: off turns the scan off everywhere, prompts included.

Action Pattern Matching

Action patterns support both glob and regex syntax for matching action types.

What an action type looks like

Every action the edge evaluates carries a surface prefix, and nothing is prepended for you: a glob must cover the whole action type, and a regex must account for the prefix itself (anchor 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 is its upstream's namespace and the tool's own name joined by a double underscore (fs__write_file), because that is the only form the model APIs can call — Claude Code and the Claude Agent SDK register MCP tools as mcp__<server>__<tool>, and 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, and the Mesh Authorizer names an east-west call the same way — the in-cluster service and the operation joined by __ — so one rule shape covers a tool reached either way. The surface prefix (mcp:, llm:send:, builtin:, shell:execute) is MITRITY's own grammar and is never sent to a model.

A pattern written as read_crm_* therefore never matches an MCP action; write mcp:read_crm_*.

The API enforces this when a rule is saved: a glob that does not start with a known surface — one of mcp, llm, http, egress, delegate, builtin or shell before its first : — or that puts a wildcard in the surface position (*:read_file would match every surface), or that keeps a colon after the mcp surface (mcp:github:* — the former namespace separator, which no action type carries any more; write mcp:github__*) is rejected with HTTP 422 naming tool_pattern, and the dashboard flags it before you submit. * alone (every action) and regex: patterns are accepted as written; the colons of the other surfaces (llm:send:openai:*) are their own grammar and are fine.

Glob patterns

Glob patterns use * 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, and the glob must cover the entire action type.

Regex patterns

For alternations and anchors, prefix the pattern with regex:. The remainder 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: no backreferences or lookaround, and no pathological backtracking. Patterns are limited to 1024 bytes.
  • A pattern that does not compile is rejected when the rule is saved (HTTP 422 with the field named). If one ever reaches the edge anyway, it matches nothing — a broken deny never turns into an allow.
  • The same syntax works in list-valued constraints such as paths, resolved_commands and script_paths, where a leading ! negates the entry: "paths": ["regex:^/workspace/.*$", "!regex:^/workspace/secrets/"]. It does not apply to *_contains constraints, which are plain substrings.

Priority System

Policies are evaluated in descending priority order — the highest priority number is evaluated first. The first matching policy determines 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: Use a catch-all deny policy with the lowest priority (priority: 1) as a safety net. Then add explicit allow and alert policies with higher priorities for 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 with the sharpest edges. This section documents 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 — e.g. commands_contains, 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: <key> resolves to the argument named <key> with a trailing s stripped (paths → path), then to <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, which are enforced against the tool's attested destination rather than an argument value.

Value types, and one trap

JSON valueEffect
Array of stringsPattern list
StringPattern list with one entry
NumberNumeric upper bound
ObjectSilently ignored

The last row is the trap. The control plane stores constraints verbatim without validating them, and the edge drops any key whose value is an object. A constraint written as {"time": {"after": "18:00"}} is accepted by the API, never evaluated, and the rule behaves as if the constraint were absent.

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. 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, and the three rules are applied 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/*"] alone means "anywhere except /etc".

_contains matching

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

{
  "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 neither <key> nor its singular form is present in the action's arguments, the edge falls back to the action's target, which is the qualified tool name.

This matters more than it looks. A paths constraint on a tool whose argument is called file rather than path will compare your globs against the string fs__write_file. It 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 writing a constraint against them.

The commands limitation

This section describes edge releases before the semantic command parser below. From the next release 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 — /bin/rm -rf / and ./rm -rf / both become rm — and truncated at the first null byte.

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, and it does not match a tool that names its argument cmd, args or script, which is not.

Until commands is replaced, the workaround is two rules rather than one — deny the wrappers by name, and deny 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, and substring matching is defeated by quoting, variables, and encoding — 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 are available from edge release v0.21. What follows describes shipped behavior. The parser reads the command line with a real shell grammar, unwraps wrappers, pipelines and inline interpreters, and exposes the result as the constraint keys below. The wrapper evasion in the previous section becomes a block.

commands is now an alias of resolved_commands, with the same semantics: the same globs, the same any/all matching, the same unwrapping. A rule that still carries commands therefore starts matching the whole parsed tree instead of basename(argv[0]), and bash -c "rm -rf /" stops slipping past it. The key stays deprecated — write resolved_commands in new rules, because the name says what it matches — but 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 (hashed, DLP- and injection-scanned, and parsed as shell when it is one — see the write-time findings below), and only a tool whose name says it executes that code has it judged as a command line. On every other tool a code body no rule judged — a path-less call, or a written body that is not a shell program — carries code_body_unjudged, with the tool and the key in its metadata; a shell body written through such a tool 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 rather than the command, and they are worth reading 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 / contribute nothing to resolved_commands, which means a denylist never sees them. The edge fails closed on this through on_unparseable, and 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 — more than 64 paths were referenced by a single command, 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, and this is lost audit detail rather than a hole. It is deliberately not depth_exceeded, which does mean the analysis stopped early — treating them alike would deny an ordinary git add over a long file list.

Constraint validation ships with the same work. The control plane rejects object and boolean constraint values, and any exec_features entry outside the list above, with a 422 naming the offending key — instead of accepting them and silently dropping them at the edge. 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?" — the split write-and-run, and the smuggled script. Those are closed by three mechanisms on the edge, each governed by a policy-level setting rather than 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.

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 no rule mentions, not a cap on what a rule may do.

The taint store

Every allowed write-shaped action — builtin:write, builtin:edit, an MCP tool with a path and content, a shell tree with a redirect, tee, cp, mv, curl -o, chmod — leaves an entry in a per-agent, per-session store: the path, the writer tool, the time, and 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, modified_outside_governance too. taint_action decides the outcome.

The hash status has three values: matches, changed, and unknown — an Edit-style patch, a redirect or a cp produces a file the edge never saw in full, so 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

Every script reference in the parsed tree — sh f, bash f, . f, source f, python f, a bare ./f — is resolved against the action's working directory and read under script_inspection_roots (absolute, cleaned, symlinks resolved, regular files only, 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: 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 — outside every root, missing, oversize, or reached through a symlink that leaves the roots — is script_unresolvable: its path is listed, no hash is recorded, and the rule's on_unparseable applies (deny by default). execution.script_unresolvable notifies 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 therefore judge more; it never makes a script trusted.

Write-time findings

Two findings are raised on the write, where a hold is cheapest. script_authoring: the written content is a program (a shebang, a shell extension, .git/hooks/, or content that parses as shell with a command and an exec feature) — it 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, .mcp.json — and autorun_write_action decides.

Code bodies on tools that do not execute them

A code argument is content (see Semantic command constraints): only a tool 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: mcp:pyodide, mcp:*repl*, regex:^mcp:.*(exec|run), written in the same grammar as a rule's tool_pattern and 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 never carries the stamp.

On every other tool a non-blank code body no rule judged carries code_body_unjudged, and unjudged_code_action decides: alert (the default) records the coverage finding and leaves the outcome to the rules; hold parks the action for approval; deny blocks it. A recurring code_body_unjudged on one tool is a signal to name that tool in code_execution_tools rather than to tighten the action: the body is then judged instead of stamped. Both settings ride 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, its tree is evaluated by the rule (the pattern matches builtin:write), 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; the later bash /workspace/cleanup.sh would then be read under /workspace, rm would resolve from the merged tree and the rule would deny it — and 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. 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, since a hook is a program), and 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, 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 and denied by the default on_unparseable, with an execution.script_unresolvable notice naming the path.

Until 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

[
  {
    "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: the list is exclusion-only, so everything outside the workspace satisfies it. The first rule runs ahead of it on priority and carves .git back out. Both depend on the tool naming its argument path — check the tool catalog first.

Managing Policies

Policies can be created and managed through:

  • Dashboard: Policies > Create Policy — visual editor with validation
  • REST API: POST /api/v1/policies — programmatic management
  • Policy-as-code: Define policies in JSON files and apply through CI/CD

For the full API reference, see the API overview.