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.
When a call needs approval
Section titled “When a call needs 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_approvaldefinitionRequiresApproval(tool, 'legacy'); // only true when the YAML says requires_approval: truefrom matimo import definition_requires_approvaldefinition_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.
Who answers
Section titled “Who answers”When a call needs approval, Matimo checks these in order and uses the first that applies:
- Pre-approval: the tool’s name matches a pattern in
MATIMO_APPROVED_PATTERNS(orMATIMO_AUTO_APPROVE=true). The call runs without asking. onApprovalpassed toexecute()for this one call.onApprovalpassed toMatimoInstance.init()(or set later withsetApprovalCallback).- The process-wide handler’s callback:
getGlobalApprovalHandler().setApprovalCallback(), kept for older code. - 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.
Per-instance callback (recommended)
Section titled “Per-instance callback (recommended)”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.
Per-call callback
Section titled “Per-call callback”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.
Pre-approved patterns
Section titled “Pre-approved patterns”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
Section titled “MATIMO_AUTO_APPROVE”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.
Skipping the prompt for one call
Section titled “Skipping the prompt for one call”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.
What the reviewer sees
Section titled “What the reviewer sees”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.
Outcomes
Section titled “Outcomes”| 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.
Destructive keywords
Section titled “Destructive keywords”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.
In MCP servers
Section titled “In MCP servers”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.
Environment variables
Section titled “Environment variables”| 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:
matimo review list # tools waiting for approval, and tools already approvedmatimo review approve my_tool # approve onematimo review reject my_tool # reject it, or revoke an earlier approvalIt 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.
Testing approval
Section titled “Testing approval”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"]Troubleshooting
Section titled “Troubleshooting”| 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 |
See Also
Section titled “See Also”- 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