Skip to content

Security

New in v1.6.0

  • The sandbox API key: on Odoo 14.0 to 19.0, scripts/deploy_local.py has Odoo create an API key for the sandbox's admin and keeps it, never printed, in the sandbox's .env. See Local infrastructure.
  • The copies check and the options check (--check-copies and --check-options) run no program, contact nothing and never print a sensitive value, whatever the input holds. See Credentials.
  • Known limit 10 is closed, but for one case: on Odoo 10 to 12, a context key with the name of one of the model's fields is read as a value. See Permission policy.

New in v1.5.1

  • A session-start hook that reads no plugin option and no credential: hooks/session_start.py replaces hooks/check_options.py. See Credentials.
  • The server reads only the credentials that the plugin options pass to it: it no longer reads ODOO_HTTP_BEARER.
  • Local diagnostics, metadata only, checked against an allowlist. See Privacy.
  • What the plugin directory's checks report, and where to verify each finding.
  • A new known limit of the policy: a context passed positionally on Odoo 10 to 12 (see Permission policy).

New in v1.5.0

  • Permission policy on top of the Odoo user's own rights, with the standard profile by default. See Permission policy below.
  • Local audit trail, hash-chained, with no values and no secrets.
  • Sessions keyed by the whole identity: the session key includes a per-process HMAC of the secret, and the header-based multi-tenant path was removed.
  • odoo_connections no longer returns the user:pass@ of the URL.
  • The options hook also checks the two policy options.

Credentials

  • As a plugin, credentials are plugin options (/plugin → odoo-tools → Configure options). The API key and password are sensitive options: stored in the operating system's secure credential store, never in settings.json or your shell profile, and passed only to the local MCP server process, which sends them only to the Odoo URL you configured. Do not export ODOO_* in your shell for the plugin.
  • The plugin has two SessionStart hooks, and neither contacts anything:
    • the first runs python3 --version and prints a warning only when python3 is not runnable (the MCP server could not start);
    • the second, hooks/session_start.py (new in v1.5.1, it replaces hooks/check_options.py), starts only when python3 is 3.9 or newer and reads no plugin option and no credential. It reads only CLAUDE_PLUGIN_DATA and CLAUDE_CODE_ENTRYPOINT (the name of the Claude app that started the session), the event's source and session id, the end of the server's diagnostics (diagnostics/server.jsonl and its rotated server.jsonl.1), whether the container mode's marker exists, its daily timestamps and the plugin's version: nothing outside the plugin's folders. When the last 14 days record neither a successful connection to Odoo nor a server start with every connection setting, it says where the Odoo tools run (not in Cowork or Claude chat), at most once a day at the start of a session, and tells Claude, with one diagnostics line for the doctor (on the first session after an install or an upgrade, and wherever it has no data folder, it tells Claude only); when the last server start was locked by the permission policy, it says so with its E-code. It writes one metadata line per session to diagnostics/hook.jsonl (never through a symbolic link), keeps two timestamp files for the daily notices and never stops the session (it always exits 0). With the container mode of /odoo-tools:deploy-local it says nothing.
  • Claude Code exports every saved plugin option, sensitive ones included, to the environment of each plugin hook process as CLAUDE_PLUGIN_OPTION_*. This plugin's hooks read none of them (checked by server/tests/test_product_rules.py).
  • The copies check and the options check (run_stdio.py --check-copies and --check-options, which the guided route and /odoo-tools:odoo-doctor run in Claude Code) read on stdin the JSON that a Claude Code command prints (claude plugin list --json or claude plugin configure <id> --json): they run no program, open no file of Claude Code's, contact nothing and never print a sensitive value, whatever the input holds. Of the Odoo URL, the options check repeats only the scheme, host and port, never a path, a query or a fragment, which can hold a portal link's token, and it refuses a URL with any @ as holding a user name or password, without showing any part of it. Both read UTF-8, also from a Windows pipe. See Two copies of odoo-tools.
  • In standalone mode (Docker, python -m odoo_mcp), the server reads credentials from the environment (ODOO_URL, ODOO_DB, ODOO_LOGIN, ODOO_API_KEY/ODOO_PASSWORD). The server never takes an Odoo URL or secret from a client request: the header-based multi-tenant path was removed in v1.5.0, and ODOO_MULTITENANT is parsed but reserved. The server reads no other credential: ODOO_HTTP_BEARER, which it read from its environment but never used, was removed in v1.5.1.
  • The MCP server does not persist secrets to disk or write them to logs. Sessions are keyed by url|db|login plus a per-process HMAC of the secret, so the same URL, database and login with another secret never reuses an authenticated session; the secret itself is never part of a key or a log line.
  • The Settings object redacts credentials in repr()/str() so that an accidental log does not leak them (guaranteed by tests/test_security_redaction.py).
  • Prefer API keys (Odoo >= 14) over passwords. Use least-privilege Odoo users and turn on Read-only mode in the plugin options for exploration or demos.
  • Which data is processed and which services are contacted: see Privacy.

The fallback CLI does write a file with credentials

Unlike the MCP server, the fallback CLI (/odoo-tools:odoo-setup-cli) stores your credentials in a local .env file in ~/.claude/tools/odoo-cli. The setup creates it readable only by your user, with placeholders: you paste the secret yourself in your own editor; Claude never asks for it in the chat. If you use that surface: keep those restrictive permissions (equivalent to chmod 600), do not commit it to version control, and delete it when you stop using the CLI. Rotate the API key with the same cadence you would apply to any other secret stored on disk.

Dedicated user for the agent

Create a specific Odoo user for the agent (for example ai-bot@yourcompany.com) with only the permissions it needs. Generate the API key from that user and rotate it periodically. This limits the blast radius if the key leaks.

What happens to your company's data

It is worth distinguishing two data paths:

  1. From the MCP server to Odoo (direct). The server runs on your infrastructure and talks directly to your Odoo instance over JSON-2 / JSON-RPC / XML-RPC. Transgenia does not act as an intermediary: we do not proxy, we do not store, and we do not see your data or your credentials.
  2. From the AI agent to the model provider. When you operate Odoo through an agent (Claude Code or another MCP client), the results of each tool (the Odoo records you query) are returned to the client and sent to the model provider (for example Anthropic, in the case of Claude) so the model can process them. This is inherent to any workflow with AI agents; it is not something this plugin adds.

Practical implications: you decide what you query and with which user of Odoo (least privilege). Review your model provider's data-handling policies and avoid bringing sensitive fields the agent does not need. For regulated data, query only what is essential and consider anonymizing at the source.

Supply chain

  • Zero runtime dependencies. Since v1.2.0, the MCP server uses only the Python standard library (the MCP protocol and the JSON-2, JSON-RPC and XML-RPC transports are implemented on it). The plugin launches it with python3 ${CLAUDE_PLUGIN_ROOT}/server/run_stdio.py: no package launcher (uv, uvx, npx), no install step and no downloads at start. The code that runs is exactly the readable code shipped in the plugin folder.
  • run_stdio.py puts the bundled source first on sys.path, so another odoo_mcp package installed on the machine cannot shadow the reviewed code.
  • The optional extras (cache, metrics, otel) are only used if you install them (standalone mode); the plugin installs none.
  • The optional fallback CLI is the only piece that installs packages: it runs npm install when you set it up with /odoo-tools:odoo-setup-cli.

What the plugin directory's checks report

The Claude plugin directory lists these findings for this plugin. Each one, with its name as the directory shows it, and where to verify it:

  • Uses a credential from the user's machine (.claude-plugin/plugin.json, server/src/odoo_mcp/config.py). The MCP server reads two credentials, the Odoo API key and the Odoo password, from the environment that .claude-plugin/plugin.json fills from the sensitive options odoo_api_key and odoo_password. It sends them only to the Odoo URL you configured, the service they belong to: the three transports in server/src/odoo_mcp/transport/ connect to that URL only and refuse redirects. server/tests/test_product_rules.py fails if the server reads any other credential from its environment.
  • scripts/deploy_local.py generates random passwords (secrets.token_urlsafe) for the local Docker sandbox of /odoo-tools:deploy-local, keeps them in an .env readable by your user alone in the sandbox folder, and uses them only with that sandbox's containers on 127.0.0.1. They are not your credentials. On Odoo 14.0 and newer it also has the sandbox's Odoo create an API key for its administrator user (odoo shell in a one-off container of the sandbox, with Odoo's own res.users.apikeys), checks that the key signs in, and keeps it in the same .env as ODOO_API_KEY, never printed. You copy it into the plugin's Odoo API key option; Claude never reads it.
  • Tests (server/tests/): they set fake canary credentials to prove that no log line, audit line, diagnostics record, support report or hook output ever contains a credential. The plugin never runs them.
  • Uses hooks. The two SessionStart hooks of Credentials: hooks/session_start.py reads the plugin's data folder and CLAUDE_CODE_ENTRYPOINT only, never a plugin option or a credential (checked by server/tests/test_product_rules.py), and always exits 0; the other one only runs python3 --version.
  • Runs a local program for an MCP server. python3 ${CLAUDE_PLUGIN_ROOT}/server/run_stdio.py: standard library only, nothing installed or downloaded (see Supply chain).
  • Contains a download-and-run command. Only two commands download and run code on your computer, and only when you invoke them and confirm: /odoo-tools:deploy-local pulls and runs Docker images (Transgenia's unchanged copies of the official PostgreSQL and Odoo images, by series tag, and the MCP server image at the plugin's version), and /odoo-tools:odoo-setup-cli runs npm install for the optional CLI. The MCP server and the hooks download and install nothing.

Automatic checks (CI)

The .github/workflows/security.yml workflow runs on every push/PR:

  • gitleaks: scans history and diffs for committed secrets. It is built with go install at a fixed tag (v8.18.4, every module verified against the Go checksum database), with no curl download-and-run of binaries.
  • leak-guard: looks for forbidden internal or tenant markers (customer data, infrastructure hosts, tax identifiers, hardcoded credentials) and fails the build if they appear. The repository is vendor-neutral: it contains only the generic tooling plus the public brand/authorship and the services offering. The organization-specific markers are read from a repository secret rather than written in the workflow (the plugin folder is the repository root, so the workflow reaches every installer), and matches on them are reported by file name only.
  • .gitignore: excludes .env, *.env, virtual environments, and build output so that local credentials are not committed by accident.

Transport

  • Use Odoo URLs with HTTPS. JSON-2, JSON-RPC and XML-RPC all run over the URL you provide; a plain http:// URL is sent unencrypted.
  • JSON-2 (Odoo saas~18.4 / 19.0+) sends the API key as Authorization: bearer on each request, never on a redirect (redirects are refused), sends no cookies, and never shows Odoo's server traceback (the debug field of an error) in a tool result. It accepts API keys only, so a password configuration keeps the legacy endpoints.
  • All transports verify the TLS certificate (hostname included) against the system trust store (honoring SSL_CERT_FILE; if the optional certifi package is installed, its CA bundle is used too). There is no option to turn verification off. All of them apply ODOO_TIMEOUT (120 s by default; Request timeout plugin option).
  • JSON-RPC does not follow HTTP redirects (a redirected POST would become a body-less GET); configure your Odoo's final URL.
  • Basic auth in front of Odoo: ODOO_URL may carry user:pass@; JSON-RPC sends it as an Authorization header and no error, log line or tool result echoes the password (since v1.5.0, odoo_connections strips the user:pass@ from the URL it reports). JSON-2 needs that same header for its API key, so such a URL stays on the legacy endpoints. Odoo URL is not a sensitive option: that password is stored in the plugin settings, not in the secure credential store.
  • The server logs only to stderr; stdout is reserved for the MCP channel.

Audit trail

Since v1.5.0, every tool call, refusals included, leaves one JSON line: the tool, the model and operation names it touched (op:model[:method]), the layer and rule of a refusal, counts, the duration, the exception class name of an error and the policy digest. It never holds argument values, domains, field values, ids, file contents, the URL, tokens, keys, passwords or error message texts; a name that fails the policy's name check is written as ?. The Odoo database and login appear only on the start line written when the server starts.

  • The plugin writes daily files (odoo-tools-audit-YYYY-MM-DD.jsonl) to its data folder (ODOO_AUDIT_DIR), readable by your user alone (folder 0700, files 0600, never through a symlink), and keeps them 90 days (ODOO_AUDIT_RETENTION_DAYS; 0 keeps them).
  • Without a folder, the lines go to the odoo_mcp.audit logger on stderr; ODOO_AUDIT_DIR=off writes none.
  • Each line carries a hash of the previous one, and python3 server/run_stdio.py --verify-audit FILE names the first line that was edited, reordered or inserted, or an incomplete final line. From one file alone it cannot detect whole trailing lines removed from a day or a deleted day file (the surviving prefix still chains). Locally this is evidence, not prevention: whoever can write the folder can rewrite the files.
  • A failure to write never breaks a tool call.

Details and format in Audit trail.

Permission policy

In the local (stdio) mode the permission policy limits what Claude can do through the odoo-tools MCP tools. It protects against mistakes and against instructions hidden in data Claude reads. It does not limit the person who holds the API key, and it is not a sandbox for an agent that has unrestricted shell access on the same machine. The boundary that holds against everyone is the Odoo user itself: give the agent a dedicated, least-privilege Odoo user (not an administrator) and an API key with an expiry. In a future remote mode, where the server keeps the Odoo credential, the same policy becomes real enforcement.

  • What it is. A ceiling on top of Odoo's access rights, record rules and field groups, set by the options Permission profile (readonly, standard by default, builder, full) and an optional Permission policy file, read once at start from an explicit absolute path (never from a default location). It never grants what Odoo refuses and is never weaker than Read-only mode. full keeps the 1.4 behavior. Reference: Permission policy.
  • What standard closes. Stored secrets (system parameters, mail-server, LDAP and payment-provider credentials, IAP and bank-sync tokens, CFDI certificates and keys, the outgoing mail queue) cannot be read; users, groups, access rules, modules, bank accounts, partner merges, chatter history and fiscal sequences are read-only; the models that send email, SMS, letters or share links, and the outbound methods (message_post except internal notes, message_subscribe, send_mail, and so on), are closed; server actions, automations, views and other ir.* records are read-only; deletes are refused; credential-like fields are withheld from reads and refused in arguments. full still refuses creating, changing or revoking API keys and uninstalling modules and, to enforce that, a call whose arguments or related models the policy cannot check.
  • Two layers. The declared accesses of each tool are decided from its arguments before any session exists (Layer A: nothing reaches Odoo). Then every Odoo call the tool makes goes through a guarded session (Layer B) that checks it again with its values and paths (x2many commands, domain and export paths, the parent of an attachment, a user's contact email) before it is sent, and stops as a bug any call the tool did not declare. A tool without a permission mapping cannot be registered, listed or called.
  • An invalid policy locks the server: only odoo_version works, and it explains the error without contacting Odoo.
  • Seatbelt, not sandbox. In stdio, an agent with an unrestricted shell can read the server's environment, edit the policy file or the plugin options, or call Odoo directly. Claude Code permissions.deny rules on the policy folder, Bash approval prompts and the instructions line (a policy refusal is final) make that harder, not impossible. odoo_version warns when the Odoo user is an administrator, because Odoo then narrows nothing.
  • Known limits (detailed in Known limits): side effects inside business methods (a confirmation email, existing automations) are Odoo's; strings returned by methods (a portal URL with a token) are not redacted; pointer models other than ir.attachment matter only once a file refuses business reads; related fields with harmless names can carry withheld data; many2one links are not checked as touches; published content is an outbound channel by effect; counts and aggregates allow inference; builder equals full for confidentiality, because automations run code on the server; on Odoo 10 to 12, a key of an object passed positionally to a business method outside the policy's table that is the name of a field of the model is read as a value, so it is not held to the context allowlist.

Read-only mode

  • With Read-only mode (ODOO_READONLY=1) the server no longer lists the tools that are not read-only (odoo_create, odoo_write, odoo_unlink, odoo_execute, odoo_translate_set, both Studio-style tools and both import tools: odoo_import_preview too, because its rolled-back dry run can still consume sequence numbers and fire webhooks), and refuses them with a clear error before touching Odoo when one is called by name. Reads, exports, odoo_telemetry_preview and the read-only Online tools keep working. No permission profile or policy file can bring a write back while it is on.
  • The fallback CLI has no equivalent, nor a permission policy: its write command always attempts the call. Use a least-privilege Odoo user there.
  • odoo_online_profile lists the user's API keys by name, scope and dates only: Odoo keeps no key material in a readable field, and the plugin never creates, rotates or revokes keys. odoo_record_documents returns a file only for an attachment of the record asked for, and only up to max_bytes (at most 5 MiB).

Local infrastructure (/odoo-tools:deploy-local)

  • It runs only when you invoke it and confirm; no hook runs Docker.
  • Odoo is published on 127.0.0.1 only; it is not exposed to your network.
  • Passwords are generated locally with Python's secrets, stored in ~/.odoo-tools/sandbox/.env (0600, folder 0700) and never printed; Claude does not read that file.
  • On Odoo 14.0 and newer, the API key that Odoo creates for the sandbox's admin is kept in that same file as ODOO_API_KEY and is not printed either, not even in an error message. You copy it into the plugin's Odoo API key option; never paste it in the chat. Odoo lists it as "odoo-tools sandbox" under Preferences > Account Security; if you remove it there, the next sandbox up creates another.
  • Images come from ghcr.io/transgenia (unchanged copies of the official ones, refreshed weekly) with a Docker Hub fallback.
  • The ghcr.io/transgenia/odoo-mcp-tools image runs as an unprivileged user and receives credentials through --env-file, never on the command line.

Reporting a vulnerability

Write to dev@transgenia.org with details and reproduction steps. Please do not open public issues for security reports. We aim to acknowledge receipt within 5 business days.