Skip to content

Permission policy

New in v1.6.0

  • Known limit 10 is closed, but for one case. On Odoo 10 to 12, the guarded session decides with the model's fields the keys of an object passed positionally to a business method: a key that is neither a field of the model nor an allowed context key is refused (rule context), and when the fields cannot be read, the call is refused (rule unverified). What remains: a context key with the name of one of the model's fields is read as a value. See Known limits.
  • New digest: readonly, standard and builder refuse a little more, and the tier data version that feeds the digest changes, so every profile's digest changes (standard without a file: 21524d1933f3). full keeps its behavior. Still 28 tools.
  • odoo_version names the odoo-tools copy that answers (plugin block), also when the policy is locked. An answer without policy comes from a copy older than 1.5.0, with no permission policy. See What the tools report.

New in v1.5.1

  • A context passed positionally, on Odoo 10 to 12: those series also take a call's context from the positional argument that follows the method's parameters. The context rules now apply to that argument: at Layer A on every series and, on 10 to 12, again in the guarded session. What remained open was known limit 10, which v1.6.0 closes but for one case.
  • New digest: readonly, standard and builder refuse a little more, and the tier data version that feeds the digest changes, so every profile's digest changes (standard without a file: 9f3166860347). full keeps its behavior. Still 28 tools.
  • The session-start hook no longer reads the policy options: it says when the last server start was locked, with its E-code, from the server's local diagnostics.

New in v1.5.0

  • Permission policy: the MCP server applies a permission profile on top of the Odoo user's own access rights. The default, standard, reads, creates, updates and runs business methods, but does not delete, make Studio-style changes, send email or SMS (internal notes are fine), change users, rights, modules, settings, bank accounts, fiscal sequences or chatter history, or read stored secrets.
  • Two new options: Permission profile (odoo_policy) and Permission policy file (odoo_policy_file), both with defaults.
  • Audit log: one JSON line per tool call, refusals included, with no values and no secrets.
  • To keep the 1.4 behavior, choose the full profile.

The odoo-tools MCP server applies a permission policy on top of the Odoo user's own access rights. It is a ceiling: it never grants what Odoo refuses, it only removes rights, and it is never weaker than Read-only mode. It works the same on Community, Enterprise and Odoo Online, because it runs in the MCP server and needs no Odoo module. Odoo's access rules, record rules and field groups always still apply.

The authoritative reference is the repository's docs/policy.md; this page summarizes and explains it.

The two options

Plugin option Environment variable Default What it is
Permission profile (odoo_policy) ODOO_POLICY standard readonly, standard, builder or full
Permission policy file (odoo_policy_file) ODOO_POLICY_FILE empty optional absolute path to a JSON file with per-model rules on top of the profile

You change them in /plugin → odoo-tools → Configure options. In a container or a standalone server, use the environment variables.

To keep the 1.4 behavior, choose full

The default profile tightens inside this minor release. If you want the agent to keep every right the Odoo user has (for example, on your own company's instance), set Permission profile to full (ODOO_POLICY=full in a container or a standalone run). full still refuses the short NEVER list (API-key changes and module uninstall) and keeps the two argument checks that enforce it (see Profiles).

The policy is read once, when the server starts. A change needs a reconnect of the server (/mcp in Claude Code); the new digest then shows in odoo_version and on the audit start line.

What it protects, and what it does not

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.

It is also not record-level (record rules belong to Odoo) and not a sandbox for what a business method does inside Odoo (see Known limits). odoo_version warns (W10) when the Odoo user is an administrator (base.group_system): Odoo's access rights then narrow nothing, and this policy is the only narrowing layer.

Operations

Operation Meaning Comes from
read Record data: search, read, count, group, export, translations, attachments of a record the read tools; read-class methods through odoo_execute
create Create records, including copy, name_create and imports that insert rows odoo_create, the imports, create-class methods
write Change existing records, including archiving, translations and imports that update rows odoo_write, odoo_translate_set, the imports, write-class methods
unlink Delete records, including x2many commands that delete lines odoo_unlink, unlink through odoo_execute, x2many commands
call Run a model method through odoo_execute, optionally limited to a method list odoo_execute only
schema A Studio-style change to a model (a new x_ field, an automation on the model) odoo_add_field, odoo_add_automation
inspect Field metadata and access checks about one model odoo_fields_get, odoo_api_catalog, odoo_access_check, and the tools' own fields_get calls
  • inspect is never granted by a rule: it is allowed on a model when any other operation is allowed there. A model whose every operation is refused (the secrets tier) also hides its field list.
  • Export is read: an export returns what odoo_search_read returns.
  • Imports need both create and write, because Odoo's load() inserts and updates.

Profiles

Operation by model class readonly standard (default) builder full
read, business models yes yes yes yes
create, write, business models no yes yes yes
unlink, business models no no no yes
call, business methods no yes yes yes
call of an outbound method no internal notes only internal notes only yes
schema, business models no no yes yes
read of security, outbound and customization models yes yes yes yes
any change on security and outbound models no no no yes
any change on customization models no no yes yes
anything on secrets models no no no yes
credential-like fields withheld withheld withheld (the code fields of automations are released) returned
attachment, identity, delegation-link and context rules on on on off
NEVER, the name checks and the guard's envelope on on on on
argument shapes and related-model lookups (rules shape and unverified) on on on on

Two facts to keep in mind:

  • builder equals full for confidentiality and integrity. It lets the agent create server actions and automations: Python that runs on the Odoo server, with sudo() available, possibly later as an administrator who triggers it. Such code can read a system parameter and copy it into a readable field. The tiers under builder stop mistakes through the generic tools; they do not stop an agent that writes automation code. Use builder on sandboxes and staging only. Loading it raises warning W6.
  • full is the 1.4 behavior, except NEVER, the name checks, the guard's envelope (which only stops a bug in odoo-tools itself) and two argument checks that NEVER and a policy file depend on. So a few calls that 1.4 sent need another form:
    • arguments the policy cannot read with certainty are refused (rule shape), for example more than three positional arguments to read_group through odoo_execute (the later positions differ between Odoo series), or a keyword it does not know on a read or write method (a business method's arguments are not read). Pass the arguments by name, or use the dedicated tool;
    • the field definitions behind a dotted path, and behind a value that may be x2many commands (a list, false or null in a write), are read first with fields_get: an api_key_ids command on res.users reaches res.users.apikeys, which NEVER closes. When Odoo refuses that lookup, the call is refused (rule unverified). A plain field name, or a plain value, needs no lookup.

standard refuses unlink but allows call: deleting is the one generic irreversible verb (Odoo has no trash, and archiving is the reversible path the refusal suggests), while refusing call would break action_confirm, action_post and wizard flows. The outbound methods are closed instead. For production with an administrator user, the hardened example limits call to method lists.

Tools each profile lists

tools/list hides a tool the policy forbids on every model, as Read-only mode hides the write tools. Hiding only over-approximates: a listed tool can still be refused for a given model, and a hidden tool called by name returns an error that says why (it is never an unknown tool). Read-only mode (odoo_readonly, ODOO_READONLY=1) hides the 9 write tools in every profile.

Profile Tools not listed Listed
readonly odoo_create, odoo_write, odoo_unlink, odoo_execute, odoo_translate_set, odoo_import_preview, odoo_import, odoo_add_field, odoo_add_automation 19
standard odoo_unlink, odoo_add_field, odoo_add_automation 25
builder none 28
full none 28
any profile with Read-only mode on odoo_create, odoo_write, odoo_unlink, odoo_execute, odoo_translate_set, odoo_import_preview, odoo_import, odoo_add_field, odoo_add_automation 19

A policy file can hide more: a tool in tools.disabled is no longer listed.

The server instructions gain one line that names the profile, its digest and the hidden tools, and says that a refusal naming the permission policy is final. Outside full, the description of each tool that takes a model ends with a short reminder of the profile, for example [Policy 'standard': no deletes or secrets; users, rights, mail-out and customization read-only. See odoo_version.].

Sensitive tiers

Four tiers are closed by default. A model matches a tier by an exact name or a prefix.* pattern (which matches the names under the prefix, not the prefix itself), through its aliases and through its delegation parents. When any tier of a model withholds an operation, the operation is refused, and the refusal names the first tier in this order. Names that do not exist on a given version never match; the ones marked best-effort are confirmed against live databases. The columns say what each profile allows inside the tier; full allows everything.

Tier Models readonly standard builder Why
secrets ir.config_parameter, res.config, res.config.*, ir.mail_server, fetchmail.server, payment.provider, payment.acquirer, payment.token, iap.account, res.users.apikeys.show, res.company.ldap, auth_totp.device (best-effort), l10n_mx_edi.certificate (best-effort), certificate.certificate (best-effort), certificate.key (best-effort), account.online.link (best-effort), account.online.account (best-effort), mail.mail nothing nothing nothing They hold credentials, or values that act as credentials (system parameters, mail-server and LDAP passwords, payment-provider keys and tokens, IAP tokens, a new API key, bank-sync tokens, CFDI certificates and keys, queued reset emails). Reading them is already the damage.
security res.users, res.users.*, res.groups, res.groups.*, ir.model.access, ir.rule, ir.module.module, base.module.*, change.password.*, portal.wizard, portal.wizard.*, auth_totp.wizard, auth.oauth.provider, res.partner.bank, base.partner.merge.automatic.wizard, mail.message, mail.tracking.value, ir.sequence, ir.sequence.date_range read read read Identity and rights, login redirection, bank accounts, partner merges, chatter history and fiscal sequences: reads stay open; changes need profile full or an entry naming the model exactly.
outbound mail.compose.message, mail.followers, mail.template, sms.sms, sms.composer, sms.template, portal.share, mailing.* (best-effort), snailmail.letter, account.move.send, account.move.send.* (best-effort), account.invoice.send, mail.mail read read read They send email, SMS, letters or share links, or decide what is sent.
customization ir.*, base.automation, base.action.rule, studio.*, base_import.import, base.language.install, base.language.import, except ir.attachment read read everything Server actions, automations, scheduled actions, views, assets, manual fields, defaults and other ir.* records run code or change how Odoo behaves later, and the bulk import and language engines insert, update or install across models; they need profile builder.

Why the customization models are closed:

  • ir.actions.server, ir.cron and base.automation run Python on later triggers, as other users, with sudo() available; ir.server.object.lines holds Python expressions.
  • ir.ui.view QWeb is compiled to code (a stored script on the public website is one write away); ir.asset injects JavaScript and CSS.
  • Manual ir.model.fields can carry compute code; ir.model.data re-points external ids; ir.default sets defaults on any model; ir.property (up to 17) holds company-dependent values such as default accounts.
  • ir.filters, ir.embedded.actions, ir.ui.menu, ir.actions.* (URL redirects, printed reports) and ir.translation (up to 15). As a prefix, ir.* also covers the models nobody listed.
  • base_import.import (the bulk import engine) and the language wizards are in this tier and are reachable only through their tools, so odoo_execute cannot drive the importer's load() into a closed model.

The overlaps are intentional: ir.config_parameter and ir.mail_server are both ir.* and secrets, so builder keeps them closed; ir.model.access, ir.rule, ir.module.module and ir.sequence are both ir.* and security, so builder cannot change rights, install modules or renumber invoices.

Behavior in v1.5.0: account.journal

In v1.5.0, account.journal (with its bank account and its sequences) is a business model: its writes belong to no tier. Whether those writes join a tier will be decided after the live check of CFDI invoicing with v1.5.0.

NEVER

No profile and no rule allows these, full included. The loader refuses a file that tries to (E18).

Model Operations Why
res.users.apikeys create, write, unlink, call odoo-tools never creates, changes or revokes API keys
res.users.apikeys.description read, create, write, unlink, call, schema the API-key creation wizard
ir.module.module call (button_immediate_uninstall, button_uninstall, module_uninstall) uninstalling a module deletes its data
base.module.uninstall read, create, write, unlink, call, schema the uninstall wizard

Private methods need no entry: the method name check (^[A-Za-z][A-Za-z0-9_]{0,127}$) refuses a leading _ on every transport, as Odoo does. Model names must match ^[a-z0-9_.]{1,128}$.

Built-in rules

Profile full turns off the delegation-link, ir.attachment, identity, outbound-method and context rules below. Two parts stay on in every profile, because NEVER and the rules of a policy file apply under full too: a delegating child still takes its parent's decision, and related records are still checked.

Delegation (_inherits)

A child model that delegates to a closed parent writes the parent's fields: website.page delegates to ir.ui.view, so writing its arch writes a QWeb view. A child therefore inherits its parent's tiers for every operation except read: website.page writes need builder, while reads stay open. The children are website.page and website.controller.page (ir.ui.view), ir.cron and base.automation on 11-16 (ir.actions.server), and product.document and documents.document (ir.attachment).

Behavior in v1.5.0: the parent's decision includes the file rules

The parent's whole decision applies to the child as well, file rules included: an exact rule on website.page (or ir.cron) does not open its writes by itself. Open the parent too (ir.ui.view, or ir.actions.server), or use profile builder.

The link field of a child (view_id, ir_attachment_id, and so on) cannot be set through the generic tools (rule delegation-link): pointing it at an existing record would expose or change that record through the child.

ir.attachment is pinned to its parent

ir.attachment holds the binary fields of other models, including certificate keys, so a generic read of it could reach the secrets tier through the back door. A generic call on ir.attachment must name its parent:

  • A search (every read-class model method that takes a top-level domain: search, search_read, search_count, read_group, formatted_read_group, web_search_read, web_read_group, name_search, web_name_search, read_grouping_sets, formatted_read_grouping_sets, search_fetch and the pivot and panel readers read_progress_bar, search_panel_select_range, search_panel_select_multi_range) needs a top-level ["res_model", "=", X] or ["res_model", "in", [...]] whose models all allow read, and no leaf on res_field. The guard adds ["res_field", "=", False], so a search never sees files stored in fields. A search_panel_* reader carries no top-level domain, so on ir.attachment it can never name the parent and is refused.
  • A call by ids first reads res_model and res_field of those ids (an internal read the agent does not get). No id may have a res_field, and every res_model must allow read (for reads) or write (for changes).
  • A create (or a write of these values) cannot be public and cannot set res_field; its res_model must allow write. This holds when the write reaches ir.attachment through a delegating child too: product.document and documents.document expose the parent's public, res_field and datas, so creating or flipping a world-downloadable public attachment through one of them is refused, exactly as it is on ir.attachment directly.

Field attachments stay readable through their parent model (odoo_read of image_1920 on product.template), and odoo_record_documents keeps working through its own pinned internal calls.

Identity (a user's contact email)

The password-reset link of a user goes to the email of the user's contact (partner), so changing that email through res.partner would be an account takeover. A write on res.partner that sets email makes the guard read user_ids of those contacts first; when one of them is a user (portal users included), the call also needs write on res.users, which the security tier withholds.

Behavior in v1.5.0: imports that update contacts

An import into res.partner with an email column and an id or .id column (an update of existing contacts) is refused, and the refusal points to odoo_write, which checks each contact.

Outbound methods

A business method can send data out: message_post with a contact the agent chose emails anyone, and message_subscribe forwards every future message. These methods are refused as a call unless an entry naming the model exactly lists the method, with a reason:

action_send_and_print, action_send_mail, message_mail_with_source, message_notify, message_post, message_post_with_source, message_post_with_template, message_post_with_view, message_subscribe, send_and_print_action, send_mail

Internal notes are allowed. message_post passes as a plain call when its keyword arguments carry subtype_xmlid "mail.mt_note" (13+) or subtype "mail.mt_note" (10-12), there are no partner_ids, message_type is absent or "comment", and no other positional argument is given. An internal note notifies nobody by email.

Context keys

A call may carry only these context keys, plus default_<field> with a plain value (text, number, boolean or null) on a field that is not withheld:

active_id, active_ids, active_model, active_test, allowed_company_ids, bin_size, button_validate_picking_ids, lang, location, mail_create_nosubscribe, mo_ids_to_backorder, picking_ids_not_to_backorder, read_group_expand, skip_backorder, skip_immediate, tz, uid, validate_analytic, warehouse

The stock and manufacturing validation wizards need some of these keys: without button_validate_picking_ids or skip_backorder, for example, the wizard returns True and validates nothing. The invoice Confirm/Post button sends validate_analytic (16+), which account.move._post reads to force analytic-distribution validation, and the Odoo web client puts the signed-in user's id in uid on every call; it is informational, since the acting user is the authenticated session, not context['uid']. None of the allowed keys suppress chatter tracking. tracking_disable, mail_notrack and mail_create_nolog are absent on purpose: they silence the chatter tracking that the security tier protects as evidence.

Behavior in v1.5.0: a context default_<field> must be a plain value

Under readonly, standard and builder, a default_<field> that holds a list or an object is refused (rule context) before anything is sent to Odoo, for every tool, method and model, and wherever the context sits (kwargs["context"], a context nested in a read specification, or one nested in onchange's fields_spec): x2many commands ([(0, 0, {...})], [(4, id)], [(6, 0, ids)]), a bare id list, nested values, even an empty list. Odoo applies a context default to every record created while the call runs: the record the call names, the lines its x2many commands create and the records of other models that a business method, a wizard or an import creates (action_confirm, a validation wizard, load). Such a value cannot be checked against the one model the call names. The refusal names the key, never its value. Pass those values in the record's values instead, where each command is decided against the model it reaches, or use profile full. For example, create the manufacturing backorder wizard with its lines in values:

{"model": "mrp.production.backorder", "method": "create",
 "args": [{"mrp_production_ids": [[6, 0, [5]]],
           "mrp_production_backorder_line_ids": [[0, 0, {"mrp_production_id": 5,
                                                          "to_backorder": true}]]}]}

instead of its action's default_mrp_production_ids and default_mrp_production_backorder_line_ids keys, and the stock backorder wizard with pick_ids: [(4, id)] in values instead of default_pick_ids.

In addition, a plain default_<field> naming a structural field is refused by construction on a call that can create a record (not on a read, where it fills nothing): the link field of a delegation whose parent is sensitive (default_ir_attachment_id, default_view_id and the other links to ir.attachment, a tier or a NEVER model), and default_public or default_res_field. A benign default (default_move_type: "out_invoice") still runs, unless its field is withheld. default_res_model and default_res_id stay allowed, because activity scheduling and the mail composer pass them (see known limit 9). full is unchanged.

Since v1.5.1, these rules also apply to an object passed positionally, which Odoo 10 to 12 take as the context, and since v1.6.0 the guarded session decides the other keys of such an object with the model's fields: see known limit 10.

A write is checked for every record it changes, not only the model it names. In values (create, write, copy defaults, web_save):

Command one2many many2many
(0, _, vals) create on the comodel, then its vals the same
(1, id, vals) write on the comodel, then its vals the same
(2, id) unlink on the comodel the same
(3, id), (5,), (6, _, ids), a bare id list, False in a write: unlink on the comodel (Odoo deletes removed lines when the inverse is ondelete='cascade'); in a create: write on the comodel write on the comodel when a tier, a file rule or NEVER refuses write there, else read
(4, id) write on the comodel (it re-parents the line) as above
anything else, depth over 4, or a field Odoo does not report while it carries commands refused (shape) the same

So, on res.partner, user_ids: [(1, uid, {"groups_id": ...})] is a write on res.users and res.groups, bank_ids changes are writes on res.partner.bank, and order_line: [(5,)] is a delete under standard.

Behavior in v1.5.0: many2many links to users

A many2many link to a write-withheld model counts as a change to it, and so is refused under standard, for example a user's groups (res.users.groups_id) or a company's users (res.company.user_ids). Two links that only reference users count as a read of res.users and run under standard: a task's assignees (project.task.user_ids) and the members of a helpdesk team (helpdesk.team.member_ids). Any other many2many to res.users still counts as a write.

Reads are checked along their paths: every hop of a domain leaf (including any and not any), the nested specification of web_read and web_search_read, export_data paths, import columns, and dotted groupby and order terms. Each model reached must allow read, and no segment may be a withheld field. In import columns, a path into a one2many (order_line/product_id) creates and writes lines on the comodel. When the field metadata needed for these checks cannot be read, the call is refused (rule unverified), in every profile.

Credential-like fields

Outside full, a field whose name matches (^|_)(password|passwd|pass|pwd|secret|token|apikey|api_key|private_key|privkey|credentials?)($|_), or is signup_url, smtp_pass or totp_secret, is withheld. A name that denotes a token relation or count rather than a token value is not: the exact names payment_token_id, payment_token_ids and token_count, and any name ending in token_id, token_ids or token_count (so token_id, suitable_payment_token_ids and payment_token_count too). They only reference the secrets-tier payment.token, whose own values stay sealed. The code of ir.actions.server, ir.cron and base.automation, the webhook_url of server actions and the webhook_uuid of automations are withheld too, except under builder (integrations often hard-code tokens there).

A withheld field is refused wherever it appears: field lists, the field of the translation tools, values keys (nested command values included), import columns and export paths (every segment), specification keys at any depth, groupby, the source fields of aggregates, order, and every segment of a domain path. A filter on a secret would leak it one guess at a time. Reads lose withheld keys at any depth, and the tool result then names them in policy_withheld_fields (for example ["res.company.l10n_mx_edi_pac_password"]), so nothing disappears silently.

Every public method that reads record data or filters it goes through these checks, not only search/read: search_fetch, fetch, onchange, read_progress_bar, search_panel_select_range, search_panel_select_multi_range, copy_data, the web client's web_name_search, the pivot readers read_grouping_sets and formatted_read_grouping_sets, and filtered_domain are reads, so their domains, grouping sets, fields and specifications are parsed and refused like any other. read_progress_bar buckets its records by progress_bar["field"] (and sums sum_field), a second grouping dimension that is checked as its group_by is; its result is not redacted, since its keys are the distinct values of those (already checked) fields, not field names.

A method that stays a plain call (a business verb such as action_confirm, or a method no version of odoo-tools lists) has two backstops. Its result is redacted at any depth, the same as a read's. And its arguments are scanned with the model's field metadata before it is sent: a string that is a field path of the model is checked as a read of that path (so mapped("provider_id.name") into a payment provider, or signup_token:count, is refused); a Mapping whose keys are fields is checked as write values (except on onchange, a simulation that writes nothing); and a domain-shaped argument goes through the read-path analysis. A string that is not a field of the model (a message body, a subject, an attachment file name, a status word) is left alone, so an internal note and a business verb with a plain word argument still run.

The policy file can change the list: fields.withhold adds names (model.field or *.field, for data minimization), and fields.allow releases exact model.field names, each with a reason. withhold beats allow, which beats the built-in list.

Precedence

decide(op, model, method) runs these steps in order, and the first that decides, decides:

  1. invalid-name: the model or method fails its name check.
  2. never:<id>: a NEVER entry matches.
  3. readonly: Read-only mode is on and the operation is not read or inspect.
  4. locked: the policy could not be loaded.
  5. tool-disabled: the tool is in tools.disabled (checked per tool).
  6. file:<model>: an exact, unexpired entry that sets the operation. For call, false refuses, a list decides by membership (and opens tiers and outbound methods for the methods it lists), and true opens the model's tiers for call but not the outbound methods, which still need a list.
  7. tier:<name>: a tier of the model (its own, an alias's or a delegation parent's) withholds the operation under the profile.
  8. outbound-method: a call of an outbound method that is not an internal note.
  9. file:<pattern>: the most specific pattern that sets the operation (the longest prefix.* first, then *).
  10. profile:<name>: the profile default.

Steps 7 and 8 never see a pattern's allowance, so a pattern can never open a tier or an outbound method: only an entry naming the model exactly, with a reason, can. Adding a false never turns a refusal into an allowance.

Aliases. A rule on either name of a renamed or merged pair applies to both, on every version: account.invoice and account.move, their .line models, stock.quant.package and stock.package, base.automation and base.action.rule, and the other renames of the compatibility map. A refusal on either name wins. On Odoo 10 to 12 a rule on account.move (journal entries) therefore also restricts account.invoice, and the other way round.

The policy file

{
  "version": 1,
  "comment": "Acme production: confirm and post only, no imports",
  "models": {
    "*":                {"call": false},
    "sale.order":       {"call": ["action_confirm", "action_cancel"]},
    "account.move":     {"call": ["action_post", "message_post"],
                         "reason": "Invoices are sent from the chatter by the billing agent"},
    "res.partner.bank": {"create": true, "write": true,
                         "reason": "Ticket 4312: supplier onboarding", "expires": "2026-11-15"},
    "hr.*":             {"read": false, "create": false, "write": false, "call": false}
  },
  "fields": {
    "withhold": ["hr.employee.private_email", "*.vat"],
    "allow": [{"field": "sale.order.access_token", "reason": "Portal links for the sales agent"}]
  },
  "tools": {"disabled": ["odoo_import", "odoo_import_preview"]}
}

The file layers rules on the profile chosen in the option and carries no profile of its own. The schema is strict: any unknown key is an error, so a typo cannot silently drop a refusal. An editor schema is in docs/policy.schema.json (the server validates in code).

Key Rule
top level version (required, 1), models, fields, tools, comment
models keys are an exact model, prefix.* or *; at most 1000 entries
an entry read, create, write, unlink, schema (true or false); call (true, false, or a list of 1 to 200 public method names); reason (10 to 200 characters); expires (YYYY-MM-DD); comment
fields withhold: a list of model.field or *.field; allow: a list of {"field": "model.field", "reason": "..."}
tools {"disabled": [tool names]}
parsing at most 256 KiB, UTF-8 (a leading BOM is accepted); a duplicate key is an error
  • An entry sets only the operations it names. Method lists are not merged: the most specific entry that sets call decides.
  • Openings need a reason. An exact entry that opens a tier operation, or that lists an outbound method, needs a reason of at least 10 characters (E15). reason and expires are for exact entries only (E19).
  • Expiry fails closed. After its date (end of day, UTC) an entry's allowances (a true value and a call method list alike) are ignored and only its false refusals still apply, so the operation falls through to the profile. An expired method list does not become a blanket call refusal: the business verbs it named keep working under standard, and an expired entry never allows more than the same file without its allowances. Openings show in odoo_version (opened_sensitive), in --check-policy and on the audit start line.
  • The digest is a SHA-256 over the profile, the normalized rules, the field rules, the disabled tools, the read-only flag and the tier data version, cut to 12 hex characters. Comments, reasons and key order do not change it; any operation value does.

Where the file lives

  • An explicit absolute path only. The server looks in no default location, because an agent could create a file there and have it picked up silently.
  • Recommended: ~/.config/odoo-tools/policy.json on macOS and Linux, %APPDATA%\odoo-tools\policy.json on Windows. Keep it outside every project folder, and make it writable by you alone (warning W3 otherwise).
  • In a container: -v ~/.odoo-tools/policy.json:/policy/policy.json:ro -e ODOO_POLICY_FILE=/policy/policy.json (see Local deployment).

Protecting the file and the options

None of this is required, and in stdio none of it stops an agent with an unrestricted shell (see What it protects, and what it does not); it makes the seatbelt harder to unbuckle:

  • Claude Code permission rules such as {"permissions": {"deny": ["Edit(~/.config/odoo-tools/**)", "Write(~/.config/odoo-tools/**)"]}} cover Claude Code's file tools and the file commands it recognizes in Bash, not arbitrary subprocesses. Keep Bash approval prompts on.
  • Plugin options from project files are ignored: Claude Code reads plugin options from user or managed settings only, so a cloned repository cannot set the profile to full.
  • The server instructions tell the agent that a policy refusal is final and that it must not look for a workaround through another tool or the shell.

Loading errors and warnings

The server never crashes on a bad policy. An unknown profile, a relative path or an invalid file lock it: tools/list lists only odoo_version, which returns the policy block with the error without contacting Odoo; every other tool refuses with a text that names the error and the option that fixes it, and says nothing was sent to Odoo; the startup log and the instructions line carry the error, and the audit start line its E-code. The server's local diagnostics (server.jsonl) also keep that E-code, and in Claude Code the plugin's SessionStart hook reads it there: when the last start was locked, it tells you the E-code at most once a day (it reads neither the policy options nor the file). See Troubleshooting.

Code Text
E1 unknown permission profile '{value}' (use readonly, standard, builder, full)
E2 the policy file path must be absolute (got '{value}')
E3 policy file {path}: cannot be read ({strerror})
E4 policy file {path}: is larger than 262144 bytes
E5 policy file {path}: is not valid JSON (line {line}, column {column}: {msg})
E6 policy file {path}: duplicate key '{key}' (a repeated entry could hide a rule)
E7 policy file {path}: the top level must be an object with "version": 1
E8 policy file {path}: unknown key '{key}' at the top level (use version, models, fields, tools, comment)
E9 policy file {path}: models['{key}']: not a model name, 'prefix.*' or '*'
E10 policy file {path}: models['{key}']: unknown key '{op}' (use read, create, write, unlink, call, schema, reason, expires, comment)
E11 policy file {path}: models['{key}'].{op}: must be true or false
E12 policy file {path}: models['{key}'].call: must be true, false or a list of method names
E13 policy file {path}: models['{key}'].call: '{method}' is not a public method name
E14 policy file {path}: tools.disabled: unknown tool '{name}'
E15 policy file {path}: models['{key}'] opens {ops} on {article} {tier}-tier model and needs "reason" (at least 10 characters): it is shown in odoo_version and written to the audit log
E16 policy file {path}: models['{key}'].expires: '{value}' is not a date (YYYY-MM-DD)
E17 policy file {path}: fields.{list}[{index}]: '{value}' is not model.field (withhold also accepts *.field)
E18 policy file {path}: models['{key}'].{op}: cannot be granted: {reason}
E19 policy file {path}: models['{key}']: "reason" and "expires" only apply to an entry that names one model exactly
E20 policy file {path}: fields.allow[{index}] needs "reason" (at least 10 characters)

A file whose entire content is the JSON value null also locks with E7.

Warnings do not lock. They appear in odoo_version.policy.warnings, in --check-policy and on the audit start line. W10 comes from odoo_version at run time, when the Odoo user is an administrator.

Code Text
W1 models['{key}'] refuses create/write/unlink but allows call: business methods (e.g. action_post) can still change these records; set "call": false or list the allowed methods
W2 models['{model}'] opens {ops} on '{model}' ({tier} tier){until}: {reason}
W3 the policy file can be changed by other users of this machine (mode {octal})
W4 models['{key}'] expired on {date}: its allowances are ignored, its refusals still apply
W5 models['{key}'] sets {op} on a pattern; models of the {tiers} tiers are not affected: name a model exactly to open one
W6 profile 'builder' lets the agent create automations and server actions, which run Python on the Odoo server with sudo() available: for confidentiality it equals 'full'. Use it on sandboxes and staging.
W7 fields.allow releases {field}: {reason}
W10 the Odoo user of this connection is an administrator (base.group_system): Odoo's access rights narrow nothing, so this policy is the only narrowing layer. Prefer a dedicated user with the fewest groups that work.

Checking a file offline: --check-policy

python3 "<plugin>/server/run_stdio.py" --check-policy FILE [--profile standard] \
        [--explain MODEL:OP[:METHOD] ...] [--json]

It loads the file as the server would, with Read-only mode off, never contacts Odoo, and prints the profile and the digest, the normalized rules, the openings with their reason and expiry, the warnings, the hidden tools and one line per --explain:

explain: res.users write: REFUSED (tier:security)
explain: sale.order call action_confirm: ALLOWED (file:sale.order)
explain: account.move read: REFUSED (file:account.invoice, alias of account.move)
explain: website.page write: REFUSED (tier:customization, through ir.ui.view, which website.page delegates to)

Exit codes: 0 when the file is valid, 1 when it has an error (the server would start locked), 2 on bad usage. A relative FILE is resolved against the current folder for the check (the option itself must be absolute), and an empty FILE (--check-policy "") checks the profile alone. --json prints the same report as JSON. The digest covers the read-only flag, so a server running with ODOO_READONLY=1 reports another digest for the same file and profile. A team's policy repository can run it in its CI. Inside the plugin, <plugin> is ${CLAUDE_PLUGIN_ROOT}.

What the tools report

odoo_version returns a policy block:

"policy": {"profile": "standard", "file": null, "digest": "21524d1933f3", "readonly": false,
           "hidden_tools": ["odoo_unlink", "odoo_add_field", "odoo_add_automation"],
           "opened_sensitive": [], "released_fields": [], "warnings": [],
           "error": null, "error_code": null}

An opened_sensitive entry looks like {"model": "res.partner.bank", "ops": ["create", "write"], "tier": "security", "reason": "...", "expires": "2026-11-15"}. Each warning is a {"code": ..., "text": ...} object. With the policy locked, odoo_version returns only the plugin block, the policy block with error and error_code, and a note, without contacting Odoo.

Since v1.6.0, odoo_version also returns a plugin block: the odoo-tools version that answered and, in Claude Code, its installed copy (plugin@marketplace). An answer without policy comes from a copy older than 1.5.0, which has no permission policy: the guided route and /odoo-tools:odoo-doctor stop there (see Two copies of odoo-tools).

  • odoo_access_check returns policy ({operation: true or false}) next to Odoo's own answer, with a note when Odoo allows what the policy refuses.
  • odoo_list_models leaves out the models the policy allows nothing on and says how many (hidden_by_policy).
  • odoo_online_profile reports a section the policy withholds in unavailable instead of failing.

Refusals

A refusal is a tool error. It names the operation, the model, the profile and the rule that decided; then it says where the call stopped, gives the remedy, and asks the agent not to reach the same result through another tool. Only validated names are echoed (model, field, method, operation and context key names), never an argument value.

  • Layer A, before sign-in (most refusals): decided from the tool's declared accesses and the arguments alone, before any session exists. It ends with Nothing was sent to Odoo. and is not counted as a tool call. A context key outside the list and a default_<field> that holds a list or an object are refused here (rule context).
  • Layer B, after sign-in (the guard): every Odoo call a tool makes is checked again, with its values and paths, as it is sent. Such a refusal ends with The call was stopped before Odoo ran it: nothing was changed and no record data was returned. The server may have read field definitions to decide. On Odoo 10 to 12, a key of an object passed positionally to a business method that is neither a field of the model nor an allowed context key is refused here (rule context, see known limit 10).
  • A call outside a tool's own declared mapping is stopped as a bug in odoo-tools (rule envelope), in every profile; please report it to dev@transgenia.org.

A refusal starts like this, and goes on with the remedy (here, archiving with odoo_write {"active": false}):

the odoo-tools permission policy refuses 'unlink' on 'res.partner' (profile 'standard',
rule profile:standard). Nothing was sent to Odoo.

The agent must treat a refusal that names the permission policy as final: it tells you, and you can change the profile or the policy file.

Audit trail

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. The Odoo database and login appear only on the start line written when the server starts, which also carries the profile, the file path, the warnings (codes) and the openings with their reasons.

{"v":1,"ts":"2026-10-21T15:02:11Z","seq":42,"prev":"9c1e0b7d2a44f1e3","event":"call",
 "tool":"odoo_write","layer":"B","decision":"deny","rule":"relational",
 "touches":["write:res.partner"],"effective":["write:res.users"],"n":3,"status":"refused",
 "ms":41,"policy":"a4f1c0d2e9b7"}

The keys are v, ts, seq, prev, event (start, call, chain_reset), tool, layer (tool, A or B), decision, rule, touches, effective, n, withheld, status (ok, refused, error, invalid), error, ms, policy, and on the start line profile, readonly, version, pid, source, db, login, opened and warnings.

ODOO_AUDIT_DIR Where the lines go
a folder (the plugin sets ${CLAUDE_PLUGIN_DATA}/audit) one file per UTC day, odoo-tools-audit-YYYY-MM-DD.jsonl, folder 0700, files 0600, never through a symlink
unset or stderr the odoo_mcp.audit logger on stderr (docker logs in a container)
off nothing

ODOO_AUDIT_RETENTION_DAYS (default 90; 0 keeps files forever) prunes old daily files when the server starts and at the first write after UTC midnight; only regular files with an audit file name are ever removed. The plugin pins the 90 days. Claude Code deletes the plugin data folder when the plugin is uninstalled from its last scope. A failure to write never breaks a tool call.

Each line's prev is the first 16 hex characters of the SHA-256 of the previous line of the same file (genesis on the first). To check that chain:

python3 "${CLAUDE_PLUGIN_ROOT}/server/run_stdio.py" --verify-audit FILE

It exits with 0 when the chain holds; with 1 at the first line whose content was edited, whose order changed, or that was inserted, or when the final line is incomplete (which it names); with 2 when the file cannot be read. Because each line only links to the one before, checking a single file cannot by itself detect whole trailing lines removed from the end of a day, or a day file deleted outright: the surviving prefix still chains. Locally this is evidence, not prevention: whoever can write the folder can rewrite the files and recompute the chain. A sink that anchors the chain across files and restarts is a remote-mode addition, not shipped here.

Examples

The repository ships four files in docs/policy-examples/, and CI loads each one:

File Profile For
production-hardened.json standard production with an administrator user, until a least-privilege user exists: curated verbs only, no HR data, no chatter reads, no website changes, no imports
sales-team.json standard a sales agent: CRM and quotations, replies to customers from the chatter, no accounting changes
read-only-analyst.json readonly reporting: aggregates and exports without HR data, chatter or contact details
sandbox-builder.json builder a training sandbox: Studio-style changes, deletes and test users

The hardened file, for production with an administrator user:

{
  "version": 1,
  "comment": "Production hardening: business reads and writes, curated verbs only, no imports",
  "models": {
    "*":            {"call": false},
    "res.partner":  {"call": ["message_post"], "reason": "Agents reply to customers from the chatter"},
    "crm.lead":     {"call": ["action_set_won", "action_set_lost"]},
    "sale.order":   {"call": ["action_confirm", "action_cancel"]},
    "account.move": {"call": ["action_post"]},
    "hr.*":         {"read": false, "create": false, "write": false, "call": false},
    "mail.message": {"read": false},
    "mail.activity":{"read": false},
    "website.*":    {"write": false, "create": false}
  },
  "fields": {"withhold": ["*.vat", "hr.employee.private_email"]},
  "tools": {"disabled": ["odoo_import", "odoo_import_preview"]}
}
  • "*": {"call": false} turns odoo_execute into curated verbs only; its read methods are refused too, because they need call.
  • The hr.*, mail.message and mail.activity refusals keep HR data out of reach through the chatter as well (see the known limits).
  • The website.* refusal closes the published-content channel.

To use it, copy the file outside every project (for example to ~/.config/odoo-tools/policy.json), check it with --check-policy and put its absolute path in Permission policy file.

Tool mapping

Every tool declares what it touches, and a tool without a mapping cannot be registered, listed or called. Layer A decides what is declared from the arguments, before any session exists. The fixed mechanics calls a tool makes are internal entries, each pinned by a predicate on the actual call and given a reason; the guard (Layer B) refuses any call that is neither declared nor an internal whose predicate holds.

Tool Read-only Declared (Layer A) Hidden in readonly / standard / builder
odoo_access_check yes inspect on the model asked for no / no / no
odoo_add_automation no schema on the model asked for; create base.automation; create ir.actions.server yes / yes / no
odoo_add_field no schema on the model asked for; inspect on the model in relation, when given yes / yes / no
odoo_api_catalog yes inspect on the model asked for no / no / no
odoo_connections yes no model no / no / no
odoo_create no create on the model asked for yes / no / no
odoo_execute no call of the method asked for, plus its class (for example unlink) yes / no / no
odoo_export_records_csv yes read on the model asked for no / no / no
odoo_export_records_json yes read on the model asked for no / no / no
odoo_fields_get yes inspect on the model asked for no / no / no
odoo_import no create and write on the model asked for; the paths of the fields columns yes / no / no
odoo_import_preview no create and write on the model asked for; the paths of the fields columns yes / no / no
odoo_list_models yes read ir.model no / no / no
odoo_module_info yes read ir.module.module no / no / no
odoo_online_profile yes read res.users, res.company, res.users.apikeys and ir.module.module; inspect res.partner; a refused section is reported and the tool still runs no / no / no
odoo_read yes read on the model asked for no / no / no
odoo_read_group yes read on the model asked for no / no / no
odoo_record_documents yes read on the model asked for; read ir.attachment no / no / no
odoo_report yes never calls Odoo no / no / no
odoo_search yes read on the model asked for no / no / no
odoo_search_count yes read on the model asked for no / no / no
odoo_search_read yes read on the model asked for no / no / no
odoo_telemetry_preview yes no model no / no / no
odoo_translate_get yes read on the model asked for no / no / no
odoo_translate_set no write on the model asked for yes / no / no
odoo_unlink no unlink on the model asked for yes / yes / no
odoo_version yes no model no / no / no
odoo_write no write on the model asked for yes / no / no

To add a tool to the server, see Contributing.

Known limits

  1. Side effects inside methods. action_confirm may send a confirmation email when Odoo is configured to, and existing automations run on allowed writes. Odoo governs these for the signed-in user. The hardened file limits call to method lists.
  2. Strings returned by methods are not redacted, for example a portal URL that carries a token.
  3. Pointer models other than ir.attachment (mail.message, mail.activity, mail.followers, rating.rating) matter once a file refuses reads on business models; the hardened example refuses them alongside hr.*.
  4. Related and computed fields with harmless names, and display names, can carry data of a withheld model. Odoo field groups remain the control there.
  5. Many2one links are not checked as touches. Setting partner_bank_id to an existing bank account is a write on the parent only.
  6. Public-facing fields are an outbound channel by effect, for example a published website description. Hardened files can refuse website.* and product.template writes.
  7. Inference from counts and aggregates is not prevented.
  8. In stdio, an agent with a shell can go around the policy, and builder equals full for confidentiality.
  9. A nested attachment can be bound to a record through a context default. default_res_model and default_res_id stay allowed, because activity scheduling and the mail composer pass them, so a call that creates a nested ir.attachment (directly, or through a delegating child such as product.document) can set that attachment's res_model and res_id from the context. A direct create of the attachment with these values is still checked (the parent must allow write); only the nested-create path, which no check against the call's top model can follow, is accepted. public and res_field are refused as defaults, so this binds an attachment, it does not expose one.
  10. A context passed positionally, on Odoo 10 to 12. These series also take a call's context from the positional argument that follows the method's own parameters; from 13 only kwargs["context"] is read. The policy reads such an argument as a context: at Layer A on every series, before anything is sent, through odoo_execute with or without ids, and in the guarded session again on 10 to 12. Those series build the context with dict(), so a list of [key, value] pairs counts as an object below, read as the context it builds; a list of field names, ids or domain terms builds none.

    • For a method whose parameters the policy knows (search_read, create, write and the other methods it reads), an object after the parameters, or at a parameter that never holds an object (search_count's limit, which Odoo 10 to 12 do not have), meets every context rule. A call with an argument after the parameters is still refused as before (rule shape) when the context rules let it pass.
    • For any other method (a business method such as action_confirm), the policy cannot know where the parameters end, so every object passed positionally may be the context or one of the method's values. Its default_<field> keys meet the context rules on every series (a list or an object, a structural field on a call that can create, a withheld field), and a values object such as {"default_code": "A-1"} still runs. Since v1.6.0 the guarded session also decides its other keys on Odoo 10 to 12, with the model's fields: a key that is a field of the model is one of the method's values, checked as the method's other arguments are (see Credential-like fields); an allowed context key passes; any other key, such as tracking_disable, can only be a context key and is refused (rule context). The fields are the definitions the guarded session reads for its other checks (fields_get, cached), read only when such a key is present; when Odoo refuses that lookup, the call is refused (rule unverified). A key that is not text, which only a list of pairs can carry (x2many links such as [4, 5]), names no context key Odoo reads and is left alone. A list of text pairs whose first items are neither fields of the model nor allowed context keys (selection pairs, a row of two columns) is therefore refused there too when it is passed positionally: pass the method's arguments by name.

    The refusals (rule context) name the key, never its value, and say to pass the context by name, in kwargs["context"], and the method's other arguments by name, or to put the values in the record's values. What remains: on Odoo 10 to 12, a key of such an object that is a field of the model is read as a value, so a context key that has the name of one of the model's fields is not held to the allowlist. The keys the allowlist leaves out on purpose (tracking_disable, mail_notrack, mail_create_nolog) are refused unless the model has a field of that name. Pass a context by name, in kwargs["context"].