Skip to main content
Glama
erp-agent-guard

erp-agent-guard

by martinvelezf

erp-agent-guard

A policy gateway between AI agents (MCP clients) and an ERP. The agent gets a small set of MCP tools. Every call goes through a YAML policy before it reaches the ERP:

  • Allowlists. Models and fields must be listed. Everything else is denied, including unknown agents.

  • Read-only by default. An agent or a model has to be switched to read-write explicitly.

  • Two-step writes. erp_propose_write / erp_propose_create are dry-runs: they write nothing and return a field-by-field diff. erp_commit applies the change.

  • Human approval token. The token is an HMAC over the exact diff. A person creates it with erp-agent-guard approve <id> after seeing the diff. The agent never holds the secret, and a token for one diff cannot commit another.

  • Stale-write check. At commit, the gateway reads the records again and refuses if they changed since the dry-run.

  • PII masking. Strategies are email, last4, initials, hash (salted; a policy that uses it will not start without EAG_MASK_SALT) and redact. A masked field cannot be filtered or sorted on, so the agent cannot rebuild it with a search oracle such as vat =like '17%'.

  • Quotas. Each agent has a calls-per-minute limit and a record-writes-per-day limit. The counters live in the gateway process, so a restart resets them (shared state is on the roadmap).

  • Hash-chained audit log. The log is append-only JSONL. It records reads, denials, proposals, approvals and commits. erp-agent-guard verify-audit detects edited, deleted or reordered lines. Cutting lines off the end is only caught if you keep the chain head it prints somewhere else.

  • Built-in deny lists. Some models and fields are always denied, whatever the YAML says. Models: ir.config_parameter, ir.cron, ir.rule, res.users.apikeys, ir.module.module and more. Fields: password, api_key, totp_secret and more.

The first adapter is Odoo JSON-RPC (/jsonrpc, Odoo 14 to 19). The guard only talks to an ERPAdapter interface (search_read, read, write, create), so NetSuite or another ERP can be added later (see ROADMAP.md).

Intended use: put your own agents in front of your own ERP, or one you are authorized to administer. This tool limits what an agent can do. It does not replace Odoo's own access rights, so connect it with a dedicated Odoo user that has minimal ACLs.

Install

pip install -e .            # Python >= 3.10, depends only on PyYAML
erp-agent-guard --help

Related MCP server: enterprise-agent-lab

Quick start

  1. Write a policy. examples/policy.yaml is a complete one:

version: 1
defaults: {mode: read-only, max_records: 50, quota: {calls_per_minute: 30, writes_per_day: 20}}
deny: {models: ["res.users", "hr.*"], fields: ["*_token"]}
agents:
  support-bot:
    models:
      res.partner:
        read: [name, email, phone, country_code]
        mask: {email: email, phone: last4}
  sales-ops:
    mode: read-write
    models:
      res.partner:
        read: [name, phone, comment, vat]
        write: [phone, comment]
        mask: {vat: last4}
approval: {required: true, secret_env: EAG_APPROVAL_SECRET, ttl_seconds: 900}
audit: {path: audit.jsonl}
proposals: {path: proposals.json}
  1. Check the policy:

erp-agent-guard check-policy policy.yaml
  1. Register the gateway as an MCP server in your client. The agent identity is fixed by --agent when the server starts, and the model cannot change it:

{
  "mcpServers": {
    "erp": {
      "command": "erp-agent-guard",
      "args": ["serve", "--policy", "/path/policy.yaml", "--agent", "sales-ops"],
      "env": {
        "ODOO_URL": "https://erp.example.com", "ODOO_DB": "prod",
        "ODOO_USER": "ai-gateway", "ODOO_API_KEY": "<api key of a least-privilege user>",
        "EAG_APPROVAL_SECRET": "<32+ random chars>"
      }
    }
  }
}
  1. When the agent proposes a change, a human reviews it and approves it:

erp-agent-guard pending --policy policy.yaml
erp-agent-guard approve p_1234abcd --policy policy.yaml    # shows the diff, asks y/N, prints a token
erp-agent-guard verify-audit audit.jsonl

MCP tools

tool

what it does

erp_describe

What this agent may read, write and create, and which fields are masked

erp_search_read

Search with an Odoo-style domain. Fields, filters and order are checked against the policy, and the output is masked

erp_propose_write

Dry-run an update and return a diff and a proposal id

erp_propose_create

Dry-run a creation

erp_commit

Apply a proposal, with the approval token when one is required

The server speaks MCP over stdio (newline-delimited JSON-RPC 2.0: initialize, ping, tools/list, tools/call). It uses only the standard library, with no SDK dependency. An optional transport built on the official mcp SDK is on the roadmap.

Demo

python examples/demo.py starts a fake Odoo with synthetic data and runs the real serve command as a subprocess. It then plays an agent over stdio. Here is the output, trimmed:

>>> erp_search_read {"model": "res.partner", "domain": [["country_code", "=", "EC"]], "fields": ["name", "phone", "vat"]}
{ "count": 2, "records": [
  {"id": 1, "name": "Andes Hardware S.A.", "phone": "+593 2 555 0101", "vat": "***2001"},
  {"id": 2, "name": "Lucia Paredes",       "phone": "+593 99 555 0199", "vat": "***5678"} ] }

>>> erp_search_read {"model": "res.partner", "domain": [["vat", "=like", "17%"]]}
ERROR {"error": "policy_denied", "message": "domain: field 'vat' is masked and cannot be used as a filter"}

>>> erp_search_read {"model": "ir.config_parameter"}
ERROR {"error": "policy_denied", "message": "model 'ir.config_parameter' is on the deny list"}

>>> erp_propose_write {"model": "res.partner", "ids": [1], "values": {"credit_limit": 999999}}
ERROR {"error": "policy_denied", "message": "write: fields not allowed: ['credit_limit']"}

>>> erp_propose_write {"model": "res.partner", "ids": [2], "values": {"phone": "+593 99 555 0000"}}
{ "proposal_id": "p_a89781a6033c417a", "op": "write",
  "diff": [{"id": 2, "changes": {"phone": {"from": "+593 99 555 0199", "to": "+593 99 555 0000"}}}],
  "digest": "f0121bef...d695406", "approval_required": true,
  "next": "ask a human to run: erp-agent-guard approve p_a89781a6033c417a, then call erp_commit with the token" }

>>> erp_commit {"proposal_id": "p_a89781a6033c417a"}
ERROR {"error": "approval_invalid", "message": "this write needs a human approval token"}

$ erp-agent-guard approve p_a89781a6033c417a --policy policy.yaml --yes
proposal p_a89781a6033c417a  agent=sales-ops  write res.partner  status=pending
created 2026-11-14 00:28:18, expires 00:43:18
  record 2:
    phone: "+593 99 555 0199" -> "+593 99 555 0000"
p_a89781a6033c417a.1794634997.bf0a94cb61681e608cb72729ceb24c5781f2a0d4df6c9e2d3626131d05d6ab0e

>>> erp_commit {"proposal_id": "p_a89781a6033c417a", "approval_token": "p_a89781a6033c417a.1794634997.bf0a94cb..."}
{"proposal_id": "p_a89781a6033c417a", "status": "committed", "written": [2]}

$ erp-agent-guard verify-audit audit.jsonl
OK: 8 entries, chain intact, head=e1a0422e5a42fdd6

Audit lines look like this (one JSON object per line, chained by prev and hash):

{"agent": "sales-ops", "approved": true, "digest": "f0121bef...", "event": "commit", "hash": "e1a0422e...", "model": "res.partner", "op": "write", "prev": "6fcd5e40...", "proposal": "p_a89781a6033c417a", "ts": 1794634099.001, "written": [2]}

Tests

pip install -e ".[dev]"
pytest -q        # 80 tests, about 5 s

The tests run against erp_agent_guard.fake_odoo, an in-memory imitation of Odoo's /jsonrpc endpoint with synthetic data. No real Odoo or credentials are needed. They cover:

  • policy validation

  • every masking strategy

  • domain-oracle and path-traversal refusals

  • the dry-run, approve and commit flow

  • token forgery, replay, expiry and cross-proposal reuse

  • stale writes and a tampered proposal file

  • quotas

  • audit-chain tampering, including two processes writing one log

  • a full MCP session over stdio with a real subprocess

To try the gateway by hand, run erp-agent-guard fake-odoo --port 8069.

Threat model (short)

What the gateway defends against:

  • an agent that is confused or prompt-injected and tries to read more than it should: hidden fields, PII, secrets models, or bulk dumps (limits are capped)

  • writes to fields or models it should not touch

  • writing without a human seeing the diff

  • replaying or reusing an approval

  • writing over a change someone made after the dry-run

  • quietly editing the audit trail

What it does not do:

  • It does not protect against someone with shell access to the gateway host. They can read the approval secret and the ERP key.

  • It does not make the audit log tamper-proof. The log is tamper-evident: ship it to WORM storage or a SIEM.

  • It does not check business logic inside the ERP. A permitted write can still trigger server-side automations.

  • It does not replace Odoo ACLs and record rules. The gateway is the second layer.

Why this exists / prior art

There are many Odoo MCP servers. Most expose the full ORM (execute_kw) to the model. Their write protection is usually a global on/off flag, or the protections are spread across a large feature set. The closest projects:

  • odoo-mcp-gateway (Python, MIT) is a security-first Odoo MCP server. It has YAML model and field restrictions, RBAC, rate limiting, audit logging and a dry_run flag, plus far more tools than this project.

    • It is Odoo-only, and the agent logs in through a tool call with Odoo credentials.

    • If you want a feature-rich Odoo MCP server with security settings, use it.

  • odoo-mcp / ERPipe (Python, MIT) is a popular Odoo MCP server. It gates writes with an environment variable and approval tokens, and also exists as a hosted product.

    • I have not checked whether its tokens are bound to the content of the diff.

  • erp-agent-guardrails (TypeScript) uses the same "propose, then confirm" pattern for a Brazilian financial ERP. Its confirmation is gated by chat channel and role.

  • Generic MCP gateways such as Obot MCP Gateway, evav-gateway and keyless-agents (for Microsoft 365) apply policy at the level of tool calls. They do not know about ERP models, fields, domains or record diffs.

  • Odoo's own access rights (ir.model.access, ir.rule, field groups) are the first layer. Keep using them. They do not give you diff review, approval bound to content, per-agent quotas or masking of fields the agent may read.

What erp-agent-guard does differently:

  • The core is ERP-agnostic. Policy, diff, approval and audit sit on an adapter interface, so the same policy model will cover NetSuite.

  • The approval token is cryptographically bound to the exact diff (proposal digest), carries an expiry, and the stale check runs again at commit.

  • It treats domains and ordering as a data-exfiltration channel: no filtering or sorting on hidden or masked fields, and no dotted paths.

  • The audit log is hash-chained, and a verifier command is included.

  • The agent identity is fixed by the process. It is not something the model can say.

  • It is small enough to audit: about 1,700 lines, standard library plus PyYAML.

Resumen en español

erp-agent-guard es una pasarela de políticas entre agentes de IA (clientes MCP) y un ERP. Odoo vía JSON-RPC es el primer adaptador, y NetSuite está planificado.

Una política YAML define qué hace cada agente:

  • qué modelos y campos puede leer

  • qué puede escribir; por defecto todo es solo lectura

  • qué campos con datos personales se enmascaran

  • cuántas llamadas y escrituras puede hacer

Toda escritura es un dry-run que devuelve un diff, y se aplica solo con un token de aprobación humana firmado con HMAC sobre ese diff exacto. Si el registro cambió entre el dry-run y el commit, la escritura se rechaza. Todo queda en un log de auditoría JSONL encadenado por hashes, que se verifica con erp-agent-guard verify-audit.

Los tests usan un Odoo falso en memoria con datos sintéticos (pytest -q). Úsalo solo con tus propios sistemas o con los que estés autorizado a administrar.

License

Apache-2.0. See LICENSE.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      B
      quality
      B
      maintenance
      A governance proxy for AI tools — every MCP/agent tool call is policy-gated, secret-redacted, and written to a hash-chained, offline-verifiable audit trail.
      13
      MIT
    • F
      license
      Not graded
      quality
      C
      maintenance
      Enables controlled AI-agent access to enterprise-shaped tools with a deny-by-default gated write path, human approval, dry-run execution, and append-only audit logging.
      1
      -
    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables MCP-compatible AI agents to safely act on business backends by enforcing per-agent permissions, autonomy thresholds, human approval with review-and-edit, and full audit trails.
      MIT
    • A
      license
      B
      quality
      B
      maintenance
      Enables AI agents to interact with an Odoo ERP through tool calls that are allowlist-policy-checked, risk-scored and throttled before touching the ERP, covering record reads, writes, deletes and method execution. Every request and decision is recorded to a hash-chained, tamper-evident audit log with optional HMAC keys and anchor exports.
      19
      MIT