Agent safety: gating destructive operations¶
Letting an AI agent touch your accounting is useful but risky: it can create
invoices, register payments, delete records, and emit electronic invoices to
DIAN/SAT. This page shows how to make sure an agent cannot run the
operations you don't want it to — whether it drives alegra through a shell or
through the alegra mcp server.
Be clear about what each layer actually does:
| Layer | Mechanism | What it does | Enforces? |
|---|---|---|---|
| 1. Tool annotations | alegra mcp marks each tool read-only or destructive |
Lets an annotation-aware host gate writes on its own | Advisory — depends on the host |
| 2. Host config | deny / ask rules and a PreToolUse hook | The hook inspects the real command and can't be dodged by shell tricks — the definitive block | Yes — this is the real gate |
| 3. CLI built-ins | --dry-run, delete confirmation |
Preview and a manual confirm in a terminal | Shell only (not the MCP surface) |
The honest summary: layer 1 makes good hosts behave well by default, but it is not a security boundary. Layer 2 is what actually blocks. Use both.
The hook is the layer that can't be bypassed
Permission deny/ask globs are easy to read, but a crafted shell command
can dodge them (quoting, subshells, &&). A PreToolUse hook runs your
code against the actual command or tool before it executes, so it is the
layer that definitively blocks — on both the shell and MCP surfaces.
alegra agent guard --host claude-code generates it for you. (Hooks are a
Claude Code feature; Codex blocks with a read-only sandbox, OpenCode with
deny rules.)
The quick way: alegra agent guard¶
alegra generates the layer-2 config for you, with the destructive operations
derived from the live command tree (so the list is always complete):
alegra agent guard --host claude-code # prints settings.json + the PreToolUse hook
alegra agent guard --host codex # prints config.toml
alegra agent guard --host opencode # prints opencode.json
By default it hard-blocks the irreversible actions (delete, void,
emit, stamp, close, and the *-delete actions) and makes ordinary writes
(create, update, import) require approval; reads stay allowed. Flags:
--all-writes— block every write, not just the irreversible ones.--write— install the files instead of printing them (it never overwrites an existing config; it prints that one for you to merge).
Review the output, drop it into your host, and you're done. The rest of this page explains what it generates and how to tune it by hand.
What counts as destructive¶
alegra read operations are list, get, and export (plus catalog,
reports, doctor, version). Everything else writes: create, update,
delete, import, and the resource actions void, emit, stamp, email,
open, close, transfer, …
Layer 1 — built-in MCP annotations¶
alegra mcp advertises each tool's nature through the standard MCP tool
annotations: read tools carry readOnlyHint: true, and writes/actions carry
destructiveHint: true. You get this for free — no configuration.
What that buys you depends entirely on the host:
- A host that honors annotations (e.g. Codex) will require approval for the destructive tools and let the read-only ones run freely, automatically.
- A host that gates by tool name (e.g. Claude Code) ignores the annotation for permission decisions — you configure rules instead (layer 2).
Annotations are advisory per the MCP spec: a host that ignores them runs everything. They are never a substitute for layer 2.
Tool names
The MCP tools are named alegra_<resource>_<subcommand> — e.g.
alegra_invoices_void, alegra_contacts_delete, alegra_invoices_list.
Hosts namespace them further; in Claude Code a tool is
mcp__<server>__alegra_<resource>_<subcommand>.
Layer 2 — per-host enforcement¶
Claude Code¶
Claude Code gates by tool/command name, and deny always wins over
allow. Put this in your project's .claude/settings.json (team-shared) or
~/.claude/settings.json (all projects).
Shell surface — block destructive alegra commands:
{
"permissions": {
"deny": [
"Bash(alegra * delete:*)",
"Bash(alegra * create:*)",
"Bash(alegra * update:*)",
"Bash(alegra invoices emit:*)",
"Bash(alegra invoices void:*)",
"Bash(alegra invoices stamp:*)",
"Bash(alegra invoices email:*)"
]
}
}
Use "ask" instead of "deny" to require approval rather than hard-block. A
denied command never runs; in a headless/CI session ask also fails closed
(no human to approve → blocked).
The definitive block is a PreToolUse hook. The deny globs above help, but
a crafted shell command can dodge them (quoting, subshells, &&); a hook runs
your code against the actual command before it executes, so it cannot be
bypassed — and the same hook also covers the MCP surface. This is the most
important piece on Claude Code. Create .claude/hooks/block-alegra-writes.sh:
#!/usr/bin/env bash
# Reads the hook payload from stdin and denies destructive alegra commands.
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
if printf '%s' "$cmd" | grep -qE 'alegra\b.*\b(delete|create|update|import|emit|void|stamp|email|open|close|transfer)\b'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive alegra operation blocked by policy."
}
}'
fi
exit 0
Register it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-alegra-writes.sh" }
]
}
]
}
}
A PreToolUse hook that prints permissionDecision: "deny" (with exit code 0)
blocks the call; exit code 2 also blocks and shows stderr to the model. This is
the strongest option — it runs your logic, not a glob.
MCP surface — when alegra runs as a local MCP server
(claude mcp add alegra -- alegra mcp start), deny the write tools by name.
Because deny wins over allow, deny the write verbs rather than trying to
allow-list reads:
{
"permissions": {
"deny": [
"mcp__alegra__alegra_.*_(create|update|delete|import|void|emit|stamp|email|open|close|transfer)"
]
}
}
MCP permission patterns are matched as regular expressions, so the .* and the
(...) group work as written; read tools (..._list, ..._get,
..._export) are untouched. A PreToolUse hook can also gate MCP tools — set
"matcher": "mcp__alegra__.*" and inspect .tool_name in the script for
allow-list semantics (deny anything that isn't a read verb).
Codex¶
Codex gates with two settings in config.toml (~/.codex/config.toml):
sandbox_mode and approval_policy.
Shell surface — the strongest, simplest gate is to take away write access:
sandbox_mode = "read-only" # Codex may read; edits/commands/network need approval
approval_policy = "untrusted" # only known-safe reads auto-run; everything else asks
With sandbox_mode = "read-only", Codex cannot run a state-changing alegra
command without an explicit approval — there is no per-command allow-list to
maintain. If you want writes to be possible but always reviewed, keep
approval_policy on a prompting mode so each write pauses for a human.
MCP surface — this is where layer 1 pays off. Codex's docs state that
destructive-annotated MCP tool calls always require approval. Since
alegra mcp marks void/emit/delete/update/… as destructive, Codex
prompts before running them automatically, while read-only tools run without
friction. No per-tool config needed; keep approval_policy on a prompting mode
so the prompt is honored.
Honest note: on Codex the gate is approval (a human says yes), not a hard
block — fine for interactive use, but it means an unattended Codex run should
use sandbox_mode = "read-only" so there is nothing to approve.
OpenCode¶
OpenCode gates with a permission block in opencode.json. Each rule is
"allow", "ask", or "deny", and the last matching pattern wins, so put
the catch-all first and the specific denials after.
Shell surface — bash takes per-pattern globs:
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"bash": {
"*": "allow",
"alegra * delete*": "deny",
"alegra * create*": "ask",
"alegra * update*": "ask",
"alegra invoices emit*": "deny",
"alegra invoices void*": "deny",
"alegra invoices stamp*": "deny"
}
}
}
deny blocks the command; ask prompts for approval before it runs.
MCP surface — the same permission keys match MCP tool names as wildcard patterns, so you can deny a server's write tools:
{
"permission": {
"alegra_*_delete": "deny",
"alegra_*_create": "ask",
"alegra_*_update": "ask",
"alegra_invoices_void": "deny",
"alegra_invoices_emit": "deny",
"alegra_invoices_stamp": "deny"
}
}
Confirm the exact tool prefix your OpenCode setup uses for the server, then
match it (the tool name is alegra_<resource>_<subcommand>).
Layer 3 — CLI built-ins¶
When the agent uses a shell, two built-ins help even without host config:
--dry-runon any command prints the exact request and sends nothing.deleteasks for confirmation unless-yis passed.
These do not cover the MCP surface: an MCP tool call has no terminal, so the
interactive delete confirmation does not apply. On the MCP surface, rely on
layers 1 and 2.
Recommended setup¶
Defense in depth:
- Let layer 1 do its job — it ships on by default.
- Add layer 2 for your host: a
deny(orask) rule and a hook on Claude Code;sandbox_mode = "read-only"on Codex;permissiondenials on OpenCode. - Keep
--dry-runin your own habits and scripts.
Test it: ask the agent to run alegra invoices void 1 (or call the
alegra_invoices_void tool) and confirm it is blocked or paused for approval
before anything reaches the API.