Skip to content

Approval System

Some tool calls should not run until a person says yes: deleting data, running a shell command, approving a tool an agent wrote. Matimo’s approval system decides which calls need a human, and who answers. It works the same way in the TypeScript and Python SDKs and for every provider.

Approval vs. HITL quarantine. This page covers per-call approval (onApproval). The policy engine’s separate, risk-based checkpoint, enableHITL + hitlMinRiskLevel + onHITL, is described in Policy and Lifecycle. When both apply, the HITL check runs first, then approval.

A call needs approval when any of these is true:

Trigger Example
The tool’s YAML says requires_approval: true github-delete-repository, execute, matimo_create_tool
The YAML doesn’t set requires_approval, and the tool is an HTTP DELETE or a type: command tool (secure mode, the 0.2.0 default) a delete_post HTTP tool you define (no built-in tool is a command tool)
A destructive keyword appears in the call’s sql argument, or in command for a command tool { sql: 'DROP TABLE users' }

requires_approval: false opts a tool out of the first two triggers. It does not turn off the keyword scan: a call whose sql contains DELETE still asks.

Check a definition without running it:

import { definitionRequiresApproval } from '@matimo/core';
definitionRequiresApproval(tool, 'secure'); // true for DELETE/command tools without requires_approval
definitionRequiresApproval(tool, 'legacy'); // only true when the YAML says requires_approval: true
from matimo import definition_requires_approval
definition_requires_approval(tool, "secure")

governanceMode: 'legacy' (governance_mode="legacy", or governanceMode: legacy in policy.yaml) restores the pre-0.2.0 default, where DELETE and command tools ask only if their YAML says so. See the migration guide.

When a call needs approval, Matimo checks these in order and uses the first that applies:

  1. Pre-approval: the tool’s name matches a pattern in MATIMO_APPROVED_PATTERNS (or MATIMO_AUTO_APPROVE=true). The call runs without asking.
  2. onApproval passed to execute() for this one call.
  3. onApproval passed to MatimoInstance.init() (or set later with setApprovalCallback).
  4. The process-wide handler’s callback: getGlobalApprovalHandler().setApprovalCallback(), kept for older code.
  5. Nobody: the call is rejected.

matimo_approve_tool is never pre-approved, whatever the patterns or MATIMO_AUTO_APPROVE say: approving a tool lets agent-written YAML run, so a person always sees it.

An execute call that passes a shell is held to the same rule, because the command then runs through that shell and pipes, redirection and expansion are live. Nothing skips it: not MATIMO_AUTO_APPROVE, not MATIMO_APPROVED_PATTERNS (not even *), not approved: true (including the MCP server’s trustClientApproval). With no onApproval configured the call is rejected. execute without a shell runs the command directly, with no shell, and is pre-approved like any other tool.

import { MatimoInstance, type ApprovalRequest } from '@matimo/core';
const matimo = await MatimoInstance.init({
autoDiscover: true,
onApproval: async (request: ApprovalRequest) => {
// request.toolName, request.description, request.params
return await askReviewer(request); // true = run it, false = refuse
},
});
from matimo import ApprovalRequest, Matimo
async def on_approval(request: ApprovalRequest) -> bool:
# request.tool_name, request.description, request.params
return await ask_reviewer(request)
matimo = await Matimo.init(auto_discover=True, on_approval=on_approval)

Each instance has its own reviewer, so two tenants in one process never share one. The callback can return false/False or throw to refuse.

await matimo.execute('delete_post', { id: 1 }, { onApproval: async () => userClickedConfirm });
await matimo.execute("delete_post", {"id": 1}, on_approval=confirm_in_ui)

Use it when the reviewer depends on the request, a web handler that already has the user’s confirmation, for example.

Terminal window
export MATIMO_APPROVED_PATTERNS="get_*,list_*,calculator"

Comma-separated globs (* matches anything), case-insensitive. A matching tool runs without asking. The variable is read when the process-wide handler is created, so set it before your app starts; in a running process use getGlobalApprovalHandler().addApprovedPattern('get_*') (add_approved_pattern in Python).

MATIMO_AUTO_APPROVE=true approves every request unseen (except matimo_approve_tool and execute calls that pass a shell); each instance logs a warning when it starts with it on. It is meant for throwaway test environments only. For CI, prefer an onApproval that encodes the test’s intent, or MATIMO_APPROVED_PATTERNS listing the tools the test may run.

execute(tool, params, { approved: true }) (approved=True) skips the approval prompt for that call, for a host that has already confirmed the call through its own UI. It skips only the prompt: policy denials, draft gates and HITL quarantine still apply. The one prompt it cannot skip is an execute call that passes a shell, which always asks.

interface ApprovalRequest {
toolName: string; // 'github-delete-repository'
description?: string; // the tool's description from its YAML
params: Record<string, unknown>; // the arguments of this call
}

Python’s ApprovalRequest dataclass has the same fields in snake_case: tool_name, description, params.

Outcome Result Event
Approved The call runs tool:approval_granted
Callback returns false MatimoError: Operation rejected by approval handler: <tool> tool:approval_denied
No one to ask MatimoError: Destructive operation requires approval: <tool>, with a hint naming onApproval and MATIMO_APPROVED_PATTERNS tool:approval_denied

Events reach onEvent and the audit sink; see Types for their fields.

The keyword scan upper-cases the sql argument (or command, for command tools) and looks for each keyword as a substring. Set MATIMO_APPROVAL_SCAN_ALL_PARAMS=true to scan every string argument instead.

Built-in keywords in both SDKs: CREATE, DELETE, DROP, ALTER, TRUNCATE, UPDATE, INSERT, UPSERT, REPLACE, MERGE, GRANT, REVOKE, EDIT, WRITE, APPEND, REMOVE, RENAME, SHUTDOWN, EXECUTE, EXEC. Python’s list also has DESTROY and PURGE.

Because the match is a substring of upper-cased text, a column such as created_at contains CREATE and makes a SELECT ask. Give such a tool its own approval rule rather than relying on the scan.

TypeScript reads the list from destructive-keywords.yaml, looked up in ./packages/core/, then ./node_modules/@matimo/core/, then the working directory, falling back to the built-in list. Write keywords in upper case: content is upper-cased before matching, so a lower-case entry never matches. Python uses the built-in list; change matimo.get_global_approval_handler().destructive_keywords to adjust it.

An MCP server asks the person behind the client through an MCP elicitation request. Clients that can’t show one get a clear error. See MCP for trustClientApproval and the server’s context option.

Variable Effect
MATIMO_APPROVED_PATTERNS Comma-separated globs of tools that never ask
MATIMO_AUTO_APPROVE true approves everything except matimo_approve_tool and execute calls with a shell, with a warning, test environments only
MATIMO_APPROVAL_SCAN_ALL_PARAMS true scans every string argument for destructive keywords
MATIMO_APPROVAL_SECRET HMAC secret that signs approvals. Without it each process makes a temporary one and approvals do not persist. Required when NODE_ENV or MATIMO_ENV is production
MATIMO_ENV Set to production to make MATIMO_APPROVAL_SECRET mandatory (both SDKs also read NODE_ENV)
MATIMO_APPROVAL_DIR Python only. Directory that holds .matimo-approvals.json; defaults to the working directory. In TypeScript use the approvalDir option
MATIMO_TOOL_DIR Folder matimo review reads and writes; defaults to the working directory

Reviewing agent-created tools from the terminal

Section titled “Reviewing agent-created tools from the terminal”

A tool an agent creates starts as a draft and waits for a person. matimo review is the command-line way to be that person, in both SDKs:

Terminal window
matimo review list # tools waiting for approval, and tools already approved
matimo review approve my_tool # approve one
matimo review reject my_tool # reject it, or revoke an earlier approval

It looks for .matimo-approvals.json and for <tool-name>/definition.yaml in the working directory, or in MATIMO_TOOL_DIR if set. approve refuses to run unless you set MATIMO_APPROVAL_SECRET yourself, so the signature comes from a person’s secret, not a generated one. It then sets status: approved in the definition and records the approval for the definition’s current content. Edit the file afterwards and the approval no longer matches, so the tool must be approved again.

test('asks before deleting', async () => {
const asked: string[] = [];
const matimo = await MatimoInstance.init({
toolPaths: ['./tools'],
onApproval: async (request) => {
asked.push(request.toolName);
return false;
},
});
await expect(matimo.execute('delete_post', { id: 1 })).rejects.toThrow(
'Operation rejected by approval handler'
);
expect(asked).toEqual(['delete_post']);
});
async def test_asks_before_deleting() -> None:
asked: list[str] = []
async def decline(request: ApprovalRequest) -> bool:
asked.append(request.tool_name)
return False
matimo = await Matimo.init("./tools", on_approval=decline)
with pytest.raises(MatimoError, match="rejected by approval handler"):
await matimo.execute("delete_post", {"id": 1})
assert asked == ["delete_post"]
Symptom Cause Fix
Destructive operation requires approval: <tool> The call needs approval and nothing can answer Pass onApproval to init(), or pre-approve the tool with MATIMO_APPROVED_PATTERNS
A read-only SQL query asks The text contains a keyword, e.g. created_at Expected with substring matching; approve it, or use a dedicated read-only tool
A tool with requires_approval: false still asks The keyword scan found a destructive keyword Expected: the flag doesn’t disable the scan
A DELETE tool started asking after upgrading Secure mode, new in 0.2.0 Add an onApproval, set requires_approval: false on the tool, or use governanceMode: 'legacy' while migrating
A custom keyword in destructive-keywords.yaml never triggers It is lower-case Write it in upper case
matimo_approve_tool asks even with MATIMO_AUTO_APPROVE=true By design A person must approve every tool approval
execute asks even with MATIMO_AUTO_APPROVE=true By design, when the call passes a shell A person must approve every shell call; drop shell to run the command directly
  • Policy and Lifecycle: risk levels, HITL quarantine, execution gates, migration to 0.2.0
  • Runnable demos (no API keys): typescript/examples/tools/policy/approval-modes-demo.ts, python/examples/native/policy/approval_modes_demo.py
  • Provider examples: typescript/examples/tools/postgres/postgres-with-approval.ts, typescript/examples/tools/github/github-with-approval.ts