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 (ruleunverified). 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,standardandbuilderrefuse a little more, and the tier data version that feeds the digest changes, so every profile's digest changes (standardwithout a file:21524d1933f3).fullkeeps its behavior. Still 28 tools. odoo_versionnames the odoo-tools copy that answers (pluginblock), also when the policy is locked. An answer withoutpolicycomes 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,standardandbuilderrefuse a little more, and the tier data version that feeds the digest changes, so every profile's digest changes (standardwithout a file:9f3166860347).fullkeeps 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
fullprofile.
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 |
inspectis 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 (thesecretstier) also hides its field list.- Export is
read: an export returns whatodoo_search_readreturns. - Imports need both
createandwrite, because Odoo'sload()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:
builderequalsfullfor confidentiality and integrity. It lets the agent create server actions and automations: Python that runs on the Odoo server, withsudo()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 underbuilderstop mistakes through the generic tools; they do not stop an agent that writes automation code. Usebuilderon sandboxes and staging only. Loading it raises warning W6.fullis 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 toread_groupthroughodoo_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,
falseornullin a write), are read first withfields_get: anapi_key_idscommand onres.usersreachesres.users.apikeys, which NEVER closes. When Odoo refuses that lookup, the call is refused (ruleunverified). A plain field name, or a plain value, needs no lookup.
- arguments the policy cannot read with certainty are refused (rule
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.cronandbase.automationrun Python on later triggers, as other users, withsudo()available;ir.server.object.linesholds Python expressions.ir.ui.viewQWeb is compiled to code (a stored script on the public website is one write away);ir.assetinjects JavaScript and CSS.- Manual
ir.model.fieldscan carrycomputecode;ir.model.datare-points external ids;ir.defaultsets 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) andir.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, soodoo_executecannot drive the importer'sload()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_fetchand the pivot and panel readersread_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 allowread, and no leaf onres_field. The guard adds["res_field", "=", False], so a search never sees files stored in fields. Asearch_panel_*reader carries no top-level domain, so onir.attachmentit can never name the parent and is refused. - A call by ids first reads
res_modelandres_fieldof those ids (an internal read the agent does not get). No id may have ares_field, and everyres_modelmust allowread(for reads) orwrite(for changes). - A create (or a write of these values) cannot be
publicand cannot setres_field; itsres_modelmust allowwrite. This holds when the write reachesir.attachmentthrough a delegating child too:product.documentanddocuments.documentexpose the parent'spublic,res_fieldanddatas, so creating or flipping a world-downloadable public attachment through one of them is refused, exactly as it is onir.attachmentdirectly.
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.
Related records: x2many commands and field paths¶
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:
invalid-name: the model or method fails its name check.never:<id>: a NEVER entry matches.readonly: Read-only mode is on and the operation is notreadorinspect.locked: the policy could not be loaded.tool-disabled: the tool is intools.disabled(checked per tool).file:<model>: an exact, unexpired entry that sets the operation. Forcall,falserefuses, a list decides by membership (and opens tiers and outbound methods for the methods it lists), andtrueopens the model's tiers forcallbut not the outbound methods, which still need a list.tier:<name>: a tier of the model (its own, an alias's or a delegation parent's) withholds the operation under the profile.outbound-method: acallof an outbound method that is not an internal note.file:<pattern>: the most specific pattern that sets the operation (the longestprefix.*first, then*).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
calldecides. - Openings need a reason. An exact entry that opens a tier operation, or
that lists an outbound method, needs a
reasonof at least 10 characters (E15).reasonandexpiresare for exact entries only (E19). - Expiry fails closed. After its date (end of day, UTC) an entry's
allowances (a
truevalue and acallmethod list alike) are ignored and only itsfalserefusals still apply, so the operation falls through to the profile. An expired method list does not become a blanketcallrefusal: the business verbs it named keep working understandard, and an expired entry never allows more than the same file without its allowances. Openings show inodoo_version(opened_sensitive), in--check-policyand 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.jsonon macOS and Linux,%APPDATA%\odoo-tools\policy.jsonon 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_checkreturnspolicy({operation: true or false}) next to Odoo's own answer, with a note when Odoo allows what the policy refuses.odoo_list_modelsleaves out the models the policy allows nothing on and says how many (hidden_by_policy).odoo_online_profilereports a section the policy withholds inunavailableinstead 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 adefault_<field>that holds a list or an object are refused here (rulecontext). - 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 (rulecontext, 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:
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}turnsodoo_executeinto curated verbs only; its read methods are refused too, because they needcall.- The
hr.*,mail.messageandmail.activityrefusals 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¶
- Side effects inside methods.
action_confirmmay 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 limitscallto method lists. - Strings returned by methods are not redacted, for example a portal URL that carries a token.
- 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 alongsidehr.*. - Related and computed fields with harmless names, and display names, can carry data of a withheld model. Odoo field groups remain the control there.
- Many2one links are not checked as touches. Setting
partner_bank_idto an existing bank account is a write on the parent only. - Public-facing fields are an outbound channel by effect, for example a
published website description. Hardened files can refuse
website.*andproduct.templatewrites. - Inference from counts and aggregates is not prevented.
- In stdio, an agent with a shell can go around the policy, and
builderequalsfullfor confidentiality. - A nested attachment can be bound to a record through a context
default.
default_res_modelanddefault_res_idstay allowed, because activity scheduling and the mail composer pass them, so a call that creates a nestedir.attachment(directly, or through a delegating child such asproduct.document) can set that attachment'sres_modelandres_idfrom the context. A direct create of the attachment with these values is still checked (the parent must allowwrite); only the nested-create path, which no check against the call's top model can follow, is accepted.publicandres_fieldare refused as defaults, so this binds an attachment, it does not expose one. -
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, throughodoo_executewith or withoutids, and in the guarded session again on 10 to 12. Those series build the context withdict(), 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,writeand the other methods it reads), an object after the parameters, or at a parameter that never holds an object (search_count'slimit, 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 (ruleshape) 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. Itsdefault_<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 astracking_disable, can only be a context key and is refused (rulecontext). 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 (ruleunverified). 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, inkwargs["context"], and the method's other arguments by name, or to put the values in the record'svalues. 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, inkwargs["context"]. - For a method whose parameters the policy knows (