diff --git a/ai_tool_read/README.rst b/ai_tool_read/README.rst new file mode 100644 index 00000000..4fa932bc --- /dev/null +++ b/ai_tool_read/README.rst @@ -0,0 +1,251 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============== +AI Tool - Read +============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:fe66b1297c9c53a47b95b8407598bb5fab4d963c5e90afea0c614688365f21fc + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fai-lightgray.png?logo=github + :target: https://github.com/OCA/ai/tree/16.0/ai_tool_read + :alt: OCA/ai +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/ai-16-0/ai-16-0-ai_tool_read + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Adds ``ai_tool`` tools to let an AI agent read any model and field it is +allowed to access, while keeping personal or otherwise sensitive fields +out of the results. + +A flag **AI Read Forbidden** (``ai_no_read``) is added to fields. The +tools never return nor describe a forbidden field, nor any field +exposing its value: related fields, including those delegated through +``_inherits``, and computed fields depending on it. Flagging +``res.partner.email`` thus also hides ``email_formatted``, +``email_normalized`` and ``res.users.email``. + +Three tools are provided: + +- **list_models**: lists the models the current user is allowed to read. +- **get_fields**: describes a model's readable fields (label, type, + help, selection options and relation target), and lists the forbidden + ones under ``hidden``. +- **search_read**: searches and reads records of a model. It returns the + stored fields unless a field list is given, relational fields as + record IDs, and dates in UTC. Results are paged with ``limit`` and + ``offset``, and ``has_more`` tells whether more records match. A field + whose computation is refused for the user is left out and listed in + ``inaccessible_fields``, and a record the search finds but the user + cannot read (as ``ir.attachment`` allows) is left out without notice, + as record rules would. + +The tools run with the calling user's own access rights (no ``sudo``), +so Odoo ACLs, record rules and field ``groups`` still apply. Connect the +agent as a user with no more rights than it needs: an administrator sees +every field restricted by ``groups``. + +Besides forbidden fields, the results leave out binary fields, fields +pointing to a model the user cannot read (reading them would fail), and +display names computed on the fly, including the one Odoo adds to +many2one values: they are built from other records' fields, which may be +forbidden there. + +The flag keeps values out of the results, not out of searches: see +*Known issues*. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To forbid a field, tick **AI Read Forbidden** on it. As the +administrator, either: + +- Go to *Settings > Technical > Database Structure > Fields*, find the + field and tick the flag (the **AI Read Forbidden** filter and list + column help you review what is already flagged), or +- Open the model in *Settings > Technical > Database Structure > Models* + and tick the flag on the line of the field, in the *Fields* tab. + +The flag applies to base fields as well as custom ones, and takes effect +immediately. A flag set on a mixin's field, such as ``email_normalized`` +on ``mail.thread.blacklist``, applies to every model inheriting it. + +A forbidden field takes along the fields that expose its value through +their definition, on any model: related fields, the fields a model +delegates through ``_inherits``, and computed fields that depend on it. +The ``hidden`` list returned by the **get_fields** tool shows the result +on a model. A value copied into another record when that record is +written is not followed (see *Known issues*): flag such fields as well. + +This errs on the safe side: a computed field is hidden as soon as it +depends on a forbidden field, even when its value does not contain it. +Forbidding ``res.partner.email`` also hides ``is_blacklisted``, a yes/no +computed from the address, and ``hr.employee.mobile_phone``, computed by +the same method as the work email. For the same reason, flag the fields +that hold the sensitive values themselves (email, phone, address), not +fields that many others are computed from, such as ``parent_id`` or +``company_id``: those would hide far more than intended. + +Fields restricted to a user group (``groups=`` in their definition) are +already left out of the results for a user outside that group, without +any flag, and are not reported in the ``hidden`` list either. **AI Read +Forbidden** is only needed for fields that the user is otherwise allowed +to read. + +Usage +===== + +The tools are registered as ``ai.tool`` records and are callable through +any ``ai_tool`` integration: ``ai_oca_mcp``, for instance, exposes them +to MCP clients such as Claude Desktop. This module provides the tools +themselves; how they are reached depends on the transport module +installed. + +A typical agent workflow: + +1. Call ``list_models`` to discover the models the bound user may read. + Each entry gives the technical ``model`` name (e.g. ``res.partner``) + to pass to the other tools. + +2. Call ``get_fields`` with ``model`` to learn what each readable field + means (label, type, help, selection options, relation target), and + which fields are listed under ``hidden``. + +3. Call ``search_read`` to fetch records: + + - ``model`` (required): the technical model name. + - ``domain`` (optional): an Odoo domain as a list, e.g. + ``[["is_company", "=", true]]``. + - ``fields`` (optional): an explicit field list; the stored readable + fields are returned otherwise. An empty list returns ids only. + - ``limit`` (optional): page size, 200 by default and at most; a + value of 0 or less, or past the cap, is treated as the maximum. + - ``offset`` (optional): skip that many matching records. + - ``order`` (optional): e.g. ``"name asc"``. + + Relational fields come back as record ids, dates and datetimes as + ISO-8601 strings in UTC. ``has_more`` tells whether another page + exists under the same domain and order; fields the call could not + read are listed in ``inaccessible_fields``, while forbidden or + restricted fields are simply absent from the records. + +Example ``search_read`` arguments: + +.. code:: json + + { + "model": "res.partner", + "domain": [["is_company", "=", true]], + "fields": ["name", "email", "country_id"], + "limit": 20, + "order": "name asc" + } + +Every call executes with the access rights of the calling user — the +API-key owner when reached through ``ai_oca_mcp`` — so Odoo ACLs, record +rules and field ``groups`` apply as usual. Connect the agent as a +dedicated user holding only the rights it needs. + +Known issues / Roadmap +====================== + +The tools keep forbidden values out of their results, not out of the +searches an agent runs: they are not meant to stop an agent that probes +for hidden values on purpose. Each case below still lets a forbidden +value through: + +- **Filtering on a forbidden field.** + ``[("email", "ilike", "@example.com")]`` lists the contacts whose + address matches, and repeating it on longer fragments rebuilds a whole + address. The same works on fields restricted by ``groups=``, which + Odoo does not check when searching, and on search-only fields: + ``phone_mobile_search`` rebuilds a phone number even when ``phone`` is + forbidden. +- **Searching a relation by text.** + ``[("partner_id", "ilike", "@example.com")]`` searches contacts by + name, email included, and finds the orders of the contacts whose + address matches. +- **Sorting.** Ordering on a forbidden field, or on a many2one (sorted + by the related record's display name), reveals the order of the hidden + values. +- **Copied values.** A value copied into another record is not linked to + its source: the author's address in a chatter message + (``mail.message.email_from``) is returned unless that field is flagged + too. + +One more limit, which leaks nothing: a requested field that is +forbidden, binary or restricted is simply missing from the records, with +no reason given; ``get_fields`` tells them apart. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Quartile + +Contributors +------------ + +- `Quartile `__: + + - Toshikimi Shigenobu + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-nobuQuartile| image:: https://github.com/nobuQuartile.png?size=40px + :target: https://github.com/nobuQuartile + :alt: nobuQuartile + +Current `maintainer `__: + +|maintainer-nobuQuartile| + +This module is part of the `OCA/ai `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/ai_tool_read/__init__.py b/ai_tool_read/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/ai_tool_read/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/ai_tool_read/__manifest__.py b/ai_tool_read/__manifest__.py new file mode 100644 index 00000000..614a5f8c --- /dev/null +++ b/ai_tool_read/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "AI Tool - Read", + "summary": "Generic ai_tool to read any model/field, with a per-field opt-out flag", + "version": "16.0.1.0.0", + "website": "https://github.com/OCA/ai", + "license": "AGPL-3", + "author": "Quartile,Odoo Community Association (OCA)", + "maintainers": ["nobuQuartile"], + "depends": [ + "ai_tool", + ], + "data": [ + "views/ir_model_fields_views.xml", + "data/ai_tools.xml", + ], +} diff --git a/ai_tool_read/data/ai_tools.xml b/ai_tool_read/data/ai_tools.xml new file mode 100644 index 00000000..8571ab99 --- /dev/null +++ b/ai_tool_read/data/ai_tools.xml @@ -0,0 +1,30 @@ + + + + list_models + List the models the current user is allowed to read, as {model, name} entries. Use the technical "model" value (e.g. res.partner) with the get_fields and search_read tools. + + _ai_list_models + generic + + + get_fields + Describe a model's readable fields (label, type, help, selection options and relation target) so their values can be interpreted. Returns "fields" (readable) and "hidden" (fields forbidden to AI). + + _ai_get_fields + generic + + + search_read + Search and read records of a model. Accepts an optional Odoo domain such as [["is_company", "=", true]], an explicit field list, a limit (200 at most, and by default), an offset and an order. Without a field list, the stored fields are returned; ask for computed ones explicitly. "has_more" tells whether more records match: read the next page with the offset increased by the limit, keeping the same domain and order. Relational fields are returned as record IDs; read the related model to get the details of those records. Dates and times are in UTC. A requested field missing from the records is not available (forbidden to AI, binary, or not readable for the current user), so do not retry it; get_fields lists the readable fields, and the forbidden ones under "hidden". A field whose value cannot be computed for the current user is left out and listed in "inaccessible_fields"; do not retry it either. + + _ai_search_read + generic + + diff --git a/ai_tool_read/models/__init__.py b/ai_tool_read/models/__init__.py new file mode 100644 index 00000000..9ecdc485 --- /dev/null +++ b/ai_tool_read/models/__init__.py @@ -0,0 +1,2 @@ +from . import ir_model_fields +from . import ir_model diff --git a/ai_tool_read/models/ir_model.py b/ai_tool_read/models/ir_model.py new file mode 100644 index 00000000..76aef6c3 --- /dev/null +++ b/ai_tool_read/models/ir_model.py @@ -0,0 +1,252 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import date + +from odoo import _, models +from odoo.exceptions import UserError + +from odoo.addons.ai_tool.tools import aitool + +MAX_LIMIT = 200 + + +class IrModel(models.Model): + _inherit = "ir.model" + + def _ai_forbidden_fields(self): + """Return the fields forbidden to AI. + + A flag set on a mixin's field applies to the models inheriting it. Every + field that depends on a flagged one is forbidden too, as it exposes its + value: related fields, ``_inherits`` included, and computed fields, + transitively (``res.partner.email`` takes ``email_formatted`` and + ``res.users.email`` along). The flags are metadata regular users cannot + read, hence the sudo. + """ + registry = self.env.registry + forbidden = set() + flags = self.env["ir.model.fields"].sudo().search([("ai_no_read", "=", True)]) + if not flags: + return forbidden + # Skip a flag left on a model or field that is gone from the code. + for flag in flags.filtered( + lambda rec: rec.model in registry + and rec.name in registry[rec.model]._fields + ): + flagged_class = registry[flag.model] + forbidden.update( + model_class._fields[flag.name] + for model_class in registry.models.values() + if issubclass(model_class, flagged_class) + ) + for field in list(forbidden): + forbidden.update(registry.get_dependent_fields(field)) + return forbidden + + def _ai_readable_field_names(self, model): + """Return the field names of ``model`` the tools may return. + + A field is left out when it is forbidden to AI, binary, restricted by + ``groups=`` for the caller (as ``fields_get`` does), or relational to a + model the caller cannot read, which would fail the whole read. So is a + ``display_name`` computed on the fly: ``name_get`` may show another + record's name (``mail.followers``) with nothing in its dependencies to + tell. A stored one declares its dependencies, like any computed field. + """ + target = self.env[model] + forbidden = self._ai_forbidden_fields() + return { + name + for name, field in target._fields.items() + if field not in forbidden + and field.type != "binary" + and (field.store or name != "display_name") + and ( + not field.groups or self.env.su or target.user_has_groups(field.groups) + ) + and ( + not field.relational + or self.env[field.comodel_name].check_access_rights( + "read", raise_exception=False + ) + ) + } + + def _ai_is_queryable_model(self, model): + """Whether ``model`` is a concrete model. ``ir.model`` also lists + abstract models, some with a read ACL (``board.board``), and wizards: + neither can be searched.""" + return model in self.env and not ( + self.env[model]._abstract or self.env[model]._transient + ) + + def _ai_target(self, model): + """Resolve ``model`` to a recordset, checking that it can be searched + and that the caller has read access (data is never read as sudo).""" + if not self._ai_is_queryable_model(model): + raise UserError( + _("Model '%(model)s' does not exist or cannot be read.", model=model) + ) + target = self.env[model] + target.check_access_rights("read") + return target + + def _ai_can_read(self, records, field_names): + """Whether ``field_names`` can be read on ``records`` by the caller.""" + try: + records.read(field_names, load=None) + except UserError: + return False + return True + + def _ai_read(self, records, field_names): + """Read ``field_names`` on ``records``; return the rows and the names of + the fields left out. + + Searching and reading do not always agree: a record can be found and + then refused (``ir.attachment``), and a computed field can fail from its + compute (``signup_url`` of a partner with a user). When the plain read + fails, refused records are dropped without notice, as record rules would + drop them, and refused fields are probed one by one and left out. + ``AccessError`` is a ``UserError``; anything else still propagates. + """ + try: + return records.read(field_names, load=None), [] + except UserError: + records = records.filtered(lambda rec: self._ai_can_read(rec, ["id"])) + refused = [ + name for name in field_names if not self._ai_can_read(records, [name]) + ] + kept = [name for name in field_names if name not in refused] + # Never read an empty list: Odoo reads *all* fields for it. + return records.read(kept or ["id"], load=None), refused + + def _ai_jsonify(self, row): + """Make a ``search_read`` row JSON-serializable (dates to ISO strings). + + ``isinstance(value, date)`` also covers ``datetime``, a subclass.""" + + return { + key: value.isoformat() if isinstance(value, date) else value + for key, value in row.items() + } + + @aitool( + input_schema={}, + output_schema={ + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + }, + }, + ) + def _ai_list_models(self): + """List the concrete models the caller is allowed to read, leaving out + those gone from the registry.""" + result = [] + for record in self.sudo().search([], order="model"): + model = record.model + if self._ai_is_queryable_model(model) and self.env[ + model + ].check_access_rights("read", raise_exception=False): + result.append({"model": model, "name": record.name}) + return {"models": result} + + @aitool( + input_schema={"model": {"type": "string"}}, + required_inputs=["model"], + output_schema={ + "fields": {"type": "object", "additionalProperties": True}, + "hidden": {"type": "array", "items": {"type": "string"}}, + }, + ) + def _ai_get_fields(self, model): + """Describe the readable fields of ``model``, and list under ``hidden`` + the fields forbidden to AI. A field the caller could not see anyway + (``groups=``) is not listed, so as not to disclose it.""" + target = self._ai_target(model) + readable = self._ai_readable_field_names(model) + forbidden = self._ai_forbidden_fields() + meta = target.fields_get( + attributes=["string", "help", "type", "selection", "relation", "required"] + ) + return { + "fields": {name: info for name, info in meta.items() if name in readable}, + # fields_get() also lists virtual fields (res.users' sel_groups_*). + # Reporting forbidden fields under ``hidden`` is deliberate: it + # tells the agent they exist but are off-limits, unlike + # group-restricted fields which are not disclosed at all. + "hidden": sorted( + name for name in meta if target._fields.get(name) in forbidden + ), + } + + @aitool( + input_schema={ + "model": {"type": "string"}, + "domain": {"type": "array"}, + "fields": {"type": "array", "items": {"type": "string"}}, + "limit": {"type": "integer", "minimum": 1, "maximum": MAX_LIMIT}, + "offset": {"type": "integer", "minimum": 0}, + "order": {"type": "string"}, + }, + required_inputs=["model"], + output_schema={ + "records": { + "type": "array", + "items": {"type": "object", "additionalProperties": True}, + }, + "has_more": {"type": "boolean"}, + "inaccessible_fields": {"type": "array", "items": {"type": "string"}}, + }, + ) + def _ai_search_read( + self, model, domain=None, fields=None, limit=MAX_LIMIT, offset=0, order=None + ): + """Search and read records of ``model`` with the caller's rights. + + Only readable fields are returned, the stored ones unless ``fields`` is + given, which keeps the default read clear of computations that may fail + for the caller; fields refused at read time are listed in + ``inaccessible_fields`` (:meth:`_ai_read`). Many2one values are plain + IDs (``load=None``): the display name core would add is built from the + related record's fields, which may be forbidden there. ``has_more`` + tells whether a next page exists, read with ``offset`` under the same + domain and order. + """ + target = self._ai_target(model) + readable = self._ai_readable_field_names(model) + if fields is None: + output_fields = [name for name in readable if target._fields[name].store] + else: + # An explicit empty list asks for ids only, not the defaults. + output_fields = [name for name in fields if name in readable] + # The schema bounds are not enforced by the caller (ai_oca_mcp passes the + # arguments through). Odoo reads every record for a limit of 0 or less, + # past the cap, and agents send -1 to mean "all": cap those too. + if not limit or limit < 1 or limit > MAX_LIMIT: + limit = MAX_LIMIT + # PostgreSQL rejects a negative offset, failing the whole call. + if not offset or offset < 0: + offset = 0 + # One record past the page tells whether there is a next one. + records = target.search( + domain or [], offset=offset, limit=limit + 1, order=order + ) + # Never read an empty list: Odoo reads *all* fields for it. ``id`` comes + # with every row anyway. + rows, inaccessible_fields = self._ai_read( + records[:limit], output_fields or ["id"] + ) + return { + "records": [self._ai_jsonify(row) for row in rows], + "has_more": len(records) > limit, + "inaccessible_fields": inaccessible_fields, + } diff --git a/ai_tool_read/models/ir_model_fields.py b/ai_tool_read/models/ir_model_fields.py new file mode 100644 index 00000000..8a46a7b9 --- /dev/null +++ b/ai_tool_read/models/ir_model_fields.py @@ -0,0 +1,35 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class IrModelFields(models.Model): + _inherit = "ir.model.fields" + + ai_no_read = fields.Boolean( + string="AI Read Forbidden", + default=False, + index=True, + help="If enabled, the generic AI read tools leave this field out of the " + "records they return and of the fields described by get_fields. It does not " + "stop an agent from filtering or sorting on the field (see the module's Known " + "issues). Use it to keep personal or otherwise sensitive values out of the " + "results given to AI agents.", + ) + + def write(self, vals): + # ``ai_no_read`` is an add-on flag with no schema/ORM side effects, so it + # must stay settable even on base (non-manual) fields -- which is the + # whole point (e.g. flagging res.partner.email). Core write() forbids + # altering base fields, so route just this key through the plain ORM + # write and let everything else keep the standard validation. Copy the + # dict first: popping the key must not be visible to the caller, which + # may reuse the same ``vals`` for another write. + vals = dict(vals) + if "ai_no_read" in vals: + flag_value = vals.pop("ai_no_read") + models.Model.write(self, {"ai_no_read": flag_value}) + if vals: + return super().write(vals) + return True diff --git a/ai_tool_read/readme/CONFIGURE.md b/ai_tool_read/readme/CONFIGURE.md new file mode 100644 index 00000000..384ece99 --- /dev/null +++ b/ai_tool_read/readme/CONFIGURE.md @@ -0,0 +1,30 @@ +To forbid a field, tick **AI Read Forbidden** on it. As the administrator, either: + +- Go to *Settings > Technical > Database Structure > Fields*, find the field and tick + the flag (the **AI Read Forbidden** filter and list column help you review what is + already flagged), or +- Open the model in *Settings > Technical > Database Structure > Models* and tick the + flag on the line of the field, in the *Fields* tab. + +The flag applies to base fields as well as custom ones, and takes effect immediately. A +flag set on a mixin's field, such as `email_normalized` on `mail.thread.blacklist`, +applies to every model inheriting it. + +A forbidden field takes along the fields that expose its value through their definition, +on any model: related fields, the fields a model delegates through `_inherits`, and +computed fields that depend on it. The `hidden` list returned by the **get_fields** tool +shows the result on a model. A value copied into another record when that record is +written is not followed (see *Known issues*): flag such fields as well. + +This errs on the safe side: a computed field is hidden as soon as it depends on a +forbidden field, even when its value does not contain it. Forbidding `res.partner.email` +also hides `is_blacklisted`, a yes/no computed from the address, and +`hr.employee.mobile_phone`, computed by the same method as the work email. For the same +reason, flag the fields that hold the sensitive values themselves (email, phone, +address), not fields that many others are computed from, such as `parent_id` or +`company_id`: those would hide far more than intended. + +Fields restricted to a user group (`groups=` in their definition) are already left out +of the results for a user outside that group, without any flag, and are not reported in +the `hidden` list either. **AI Read Forbidden** is only needed for fields that the user +is otherwise allowed to read. diff --git a/ai_tool_read/readme/CONTRIBUTORS.md b/ai_tool_read/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..04192ea8 --- /dev/null +++ b/ai_tool_read/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Quartile](https://www.quartile.co): + - Toshikimi Shigenobu diff --git a/ai_tool_read/readme/DESCRIPTION.md b/ai_tool_read/readme/DESCRIPTION.md new file mode 100644 index 00000000..03c4311b --- /dev/null +++ b/ai_tool_read/readme/DESCRIPTION.md @@ -0,0 +1,36 @@ +Adds `ai_tool` tools to let an AI agent read any model and field it +is allowed to access, while keeping personal or otherwise sensitive fields out +of the results. + +A flag **AI Read Forbidden** (`ai_no_read`) is added to fields. The tools never +return nor describe a forbidden field, nor any field exposing its value: +related fields, including those delegated through `_inherits`, and computed +fields depending on it. Flagging `res.partner.email` thus also hides +`email_formatted`, `email_normalized` and `res.users.email`. + +Three tools are provided: + +- **list_models**: lists the models the current user is allowed to read. +- **get_fields**: describes a model's readable fields (label, type, help, + selection options and relation target), and lists the forbidden ones under + `hidden`. +- **search_read**: searches and reads records of a model. It returns the + stored fields unless a field list is given, relational fields as record + IDs, and dates in UTC. Results are paged with `limit` and `offset`, and + `has_more` tells whether more records match. A field whose computation is + refused for the user is left out and listed in `inaccessible_fields`, and a + record the search finds but the user cannot read (as `ir.attachment` allows) + is left out without notice, as record rules would. + +The tools run with the calling user's own access rights (no `sudo`), so Odoo +ACLs, record rules and field `groups` still apply. Connect the agent as a user +with no more rights than it needs: an administrator sees every field restricted +by `groups`. + +Besides forbidden fields, the results leave out binary fields, fields pointing +to a model the user cannot read (reading them would fail), and display names +computed on the fly, including the one Odoo adds to many2one values: they are +built from other records' fields, which may be forbidden there. + +The flag keeps values out of the results, not out of searches: see *Known +issues*. diff --git a/ai_tool_read/readme/ROADMAP.md b/ai_tool_read/readme/ROADMAP.md new file mode 100644 index 00000000..e8890e21 --- /dev/null +++ b/ai_tool_read/readme/ROADMAP.md @@ -0,0 +1,22 @@ +The tools keep forbidden values out of their results, not out of the searches +an agent runs: they are not meant to stop an agent that probes for hidden +values on purpose. Each case below still lets a forbidden value through: + +- **Filtering on a forbidden field.** `[("email", "ilike", "@example.com")]` + lists the contacts whose address matches, and repeating it on longer + fragments rebuilds a whole address. The same works on fields restricted by + `groups=`, which Odoo does not check when searching, and on search-only + fields: `phone_mobile_search` rebuilds a phone number even when `phone` is + forbidden. +- **Searching a relation by text.** `[("partner_id", "ilike", "@example.com")]` + searches contacts by name, email included, and finds the orders of the + contacts whose address matches. +- **Sorting.** Ordering on a forbidden field, or on a many2one (sorted by the + related record's display name), reveals the order of the hidden values. +- **Copied values.** A value copied into another record is not linked to its + source: the author's address in a chatter message + (`mail.message.email_from`) is returned unless that field is flagged too. + +One more limit, which leaks nothing: a requested field that is forbidden, +binary or restricted is simply missing from the records, with no reason given; +`get_fields` tells them apart. diff --git a/ai_tool_read/readme/USAGE.md b/ai_tool_read/readme/USAGE.md new file mode 100644 index 00000000..afa23120 --- /dev/null +++ b/ai_tool_read/readme/USAGE.md @@ -0,0 +1,47 @@ +The tools are registered as `ai.tool` records and are callable through any +`ai_tool` integration: `ai_oca_mcp`, for instance, exposes them to MCP clients +such as Claude Desktop. This module provides the tools themselves; how they are +reached depends on the transport module installed. + +A typical agent workflow: + +1. Call `list_models` to discover the models the bound user may read. Each + entry gives the technical `model` name (e.g. `res.partner`) to pass to the + other tools. +2. Call `get_fields` with `model` to learn what each readable field means + (label, type, help, selection options, relation target), and which fields + are listed under `hidden`. +3. Call `search_read` to fetch records: + + - `model` (required): the technical model name. + - `domain` (optional): an Odoo domain as a list, e.g. + `[["is_company", "=", true]]`. + - `fields` (optional): an explicit field list; the stored readable fields + are returned otherwise. An empty list returns ids only. + - `limit` (optional): page size, 200 by default and at most; a value of 0 + or less, or past the cap, is treated as the maximum. + - `offset` (optional): skip that many matching records. + - `order` (optional): e.g. `"name asc"`. + + Relational fields come back as record ids, dates and datetimes as ISO-8601 + strings in UTC. `has_more` tells whether another page exists under the same + domain and order; fields the call could not read are listed in + `inaccessible_fields`, while forbidden or restricted fields are simply + absent from the records. + +Example `search_read` arguments: + +```json +{ + "model": "res.partner", + "domain": [["is_company", "=", true]], + "fields": ["name", "email", "country_id"], + "limit": 20, + "order": "name asc" +} +``` + +Every call executes with the access rights of the calling user — the API-key +owner when reached through `ai_oca_mcp` — so Odoo ACLs, record rules and field +`groups` apply as usual. Connect the agent as a dedicated user holding only +the rights it needs. diff --git a/ai_tool_read/static/description/index.html b/ai_tool_read/static/description/index.html new file mode 100644 index 00000000..539881ab --- /dev/null +++ b/ai_tool_read/static/description/index.html @@ -0,0 +1,586 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

AI Tool - Read

+ +

Beta License: AGPL-3 OCA/ai Translate me on Weblate Try me on Runboat

+

Adds ai_tool tools to let an AI agent read any model and field it is +allowed to access, while keeping personal or otherwise sensitive fields +out of the results.

+

A flag AI Read Forbidden (ai_no_read) is added to fields. The +tools never return nor describe a forbidden field, nor any field +exposing its value: related fields, including those delegated through +_inherits, and computed fields depending on it. Flagging +res.partner.email thus also hides email_formatted, +email_normalized and res.users.email.

+

Three tools are provided:

+
    +
  • list_models: lists the models the current user is allowed to read.
  • +
  • get_fields: describes a model’s readable fields (label, type, +help, selection options and relation target), and lists the forbidden +ones under hidden.
  • +
  • search_read: searches and reads records of a model. It returns the +stored fields unless a field list is given, relational fields as +record IDs, and dates in UTC. Results are paged with limit and +offset, and has_more tells whether more records match. A field +whose computation is refused for the user is left out and listed in +inaccessible_fields, and a record the search finds but the user +cannot read (as ir.attachment allows) is left out without notice, +as record rules would.
  • +
+

The tools run with the calling user’s own access rights (no sudo), +so Odoo ACLs, record rules and field groups still apply. Connect the +agent as a user with no more rights than it needs: an administrator sees +every field restricted by groups.

+

Besides forbidden fields, the results leave out binary fields, fields +pointing to a model the user cannot read (reading them would fail), and +display names computed on the fly, including the one Odoo adds to +many2one values: they are built from other records’ fields, which may be +forbidden there.

+

The flag keeps values out of the results, not out of searches: see +Known issues.

+

Table of contents

+ +
+

Configuration

+

To forbid a field, tick AI Read Forbidden on it. As the +administrator, either:

+
    +
  • Go to Settings > Technical > Database Structure > Fields, find the +field and tick the flag (the AI Read Forbidden filter and list +column help you review what is already flagged), or
  • +
  • Open the model in Settings > Technical > Database Structure > Models +and tick the flag on the line of the field, in the Fields tab.
  • +
+

The flag applies to base fields as well as custom ones, and takes effect +immediately. A flag set on a mixin’s field, such as email_normalized +on mail.thread.blacklist, applies to every model inheriting it.

+

A forbidden field takes along the fields that expose its value through +their definition, on any model: related fields, the fields a model +delegates through _inherits, and computed fields that depend on it. +The hidden list returned by the get_fields tool shows the result +on a model. A value copied into another record when that record is +written is not followed (see Known issues): flag such fields as well.

+

This errs on the safe side: a computed field is hidden as soon as it +depends on a forbidden field, even when its value does not contain it. +Forbidding res.partner.email also hides is_blacklisted, a yes/no +computed from the address, and hr.employee.mobile_phone, computed by +the same method as the work email. For the same reason, flag the fields +that hold the sensitive values themselves (email, phone, address), not +fields that many others are computed from, such as parent_id or +company_id: those would hide far more than intended.

+

Fields restricted to a user group (groups= in their definition) are +already left out of the results for a user outside that group, without +any flag, and are not reported in the hidden list either. AI Read +Forbidden is only needed for fields that the user is otherwise allowed +to read.

+
+
+

Usage

+

The tools are registered as ai.tool records and are callable through +any ai_tool integration: ai_oca_mcp, for instance, exposes them +to MCP clients such as Claude Desktop. This module provides the tools +themselves; how they are reached depends on the transport module +installed.

+

A typical agent workflow:

+
    +
  1. Call list_models to discover the models the bound user may read. +Each entry gives the technical model name (e.g. res.partner) +to pass to the other tools.

    +
  2. +
  3. Call get_fields with model to learn what each readable field +means (label, type, help, selection options, relation target), and +which fields are listed under hidden.

    +
  4. +
  5. Call search_read to fetch records:

    +
      +
    • model (required): the technical model name.
    • +
    • domain (optional): an Odoo domain as a list, e.g. +[["is_company", "=", true]].
    • +
    • fields (optional): an explicit field list; the stored readable +fields are returned otherwise. An empty list returns ids only.
    • +
    • limit (optional): page size, 200 by default and at most; a +value of 0 or less, or past the cap, is treated as the maximum.
    • +
    • offset (optional): skip that many matching records.
    • +
    • order (optional): e.g. "name asc".
    • +
    +

    Relational fields come back as record ids, dates and datetimes as +ISO-8601 strings in UTC. has_more tells whether another page +exists under the same domain and order; fields the call could not +read are listed in inaccessible_fields, while forbidden or +restricted fields are simply absent from the records.

    +
  6. +
+

Example search_read arguments:

+
+{
+  "model": "res.partner",
+  "domain": [["is_company", "=", true]],
+  "fields": ["name", "email", "country_id"],
+  "limit": 20,
+  "order": "name asc"
+}
+
+

Every call executes with the access rights of the calling user — the +API-key owner when reached through ai_oca_mcp — so Odoo ACLs, record +rules and field groups apply as usual. Connect the agent as a +dedicated user holding only the rights it needs.

+
+
+

Known issues / Roadmap

+

The tools keep forbidden values out of their results, not out of the +searches an agent runs: they are not meant to stop an agent that probes +for hidden values on purpose. Each case below still lets a forbidden +value through:

+
    +
  • Filtering on a forbidden field. +[("email", "ilike", "@example.com")] lists the contacts whose +address matches, and repeating it on longer fragments rebuilds a whole +address. The same works on fields restricted by groups=, which +Odoo does not check when searching, and on search-only fields: +phone_mobile_search rebuilds a phone number even when phone is +forbidden.
  • +
  • Searching a relation by text. +[("partner_id", "ilike", "@example.com")] searches contacts by +name, email included, and finds the orders of the contacts whose +address matches.
  • +
  • Sorting. Ordering on a forbidden field, or on a many2one (sorted +by the related record’s display name), reveals the order of the hidden +values.
  • +
  • Copied values. A value copied into another record is not linked to +its source: the author’s address in a chatter message +(mail.message.email_from) is returned unless that field is flagged +too.
  • +
+

One more limit, which leaks nothing: a requested field that is +forbidden, binary or restricted is simply missing from the records, with +no reason given; get_fields tells them apart.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Quartile
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

nobuQuartile

+

This module is part of the OCA/ai project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/ai_tool_read/tests/__init__.py b/ai_tool_read/tests/__init__.py new file mode 100644 index 00000000..b4517d16 --- /dev/null +++ b/ai_tool_read/tests/__init__.py @@ -0,0 +1 @@ +from . import test_ai_tool_read diff --git a/ai_tool_read/tests/test_ai_tool_read.py b/ai_tool_read/tests/test_ai_tool_read.py new file mode 100644 index 00000000..b12e1700 --- /dev/null +++ b/ai_tool_read/tests/test_ai_tool_read.py @@ -0,0 +1,263 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError, UserError +from odoo.tests.common import TransactionCase, new_test_user + +from odoo.addons.ai_tool_read.models.ir_model import MAX_LIMIT + + +class TestAiToolRead(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + IrModelFields = cls.env["ir.model.fields"] + # ``ai_no_read`` is configuration data an administrator may already have + # set in the database, so start from a known state: only res.partner.email + # is forbidden for the whole test class. + IrModelFields.search([("ai_no_read", "=", True)]).ai_no_read = False + cls.email_field = IrModelFields.search( + [("model", "=", "res.partner"), ("name", "=", "email")], limit=1 + ) + cls.email_field.ai_no_read = True + cls.partner = cls.env["res.partner"].create( + {"name": "Tanaka", "email": "tanaka@example.com"} + ) + cls.user = new_test_user(cls.env, login="ai_reader", groups="base.group_user") + cls.system_user = new_test_user( + cls.env, login="ai_admin", groups="base.group_user,base.group_system" + ) + + def _tool(self, name): + return self.env.ref("ai_tool_read.%s_tool" % name).with_user(self.user) + + def _search_read(self, model="res.partner", user=None, **kwargs): + return ( + self.env["ir.model"] + .with_user(user or self.user) + ._ai_search_read(model, **kwargs) + ) + + def _get_fields(self, model): + return self.env["ir.model"].with_user(self.user)._ai_get_fields(model) + + def test_agent_workflow(self): + # The way an agent uses the module, through the ai.tool records: find a + # model, learn its fields, then read records. + for name in ("list_models", "get_fields", "search_read"): + definition = self._tool(name)._get_tool_definition() + self.assertIn("properties", definition["inputSchema"]) + models = [ + m["model"] for m in self._tool("list_models")._execute_tool()["models"] + ] + self.assertIn("res.partner", models) + self.assertNotIn("ir.cron", models) # no read access + self.assertNotIn("mail.compose.message", models) # wizard, though readable + res = self._tool("get_fields")._execute_tool(model="res.partner") + self.assertEqual(res["fields"]["parent_id"]["relation"], "res.partner") + self.assertNotIn("email", res["fields"]) + self.assertIn("email", res["hidden"]) + res = self._tool("search_read")._execute_tool( + model="res.partner", domain=[("id", "=", self.partner.id)] + ) + record = res["records"][0] + self.assertEqual(record["name"], "Tanaka") + self.assertNotIn("email", record) # forbidden + self.assertNotIn("image_1920", record) # binary + self.assertNotIn("contact_address", record) # computed, not asked for + self.assertIsInstance(record["create_date"], str) # JSON-serializable + self.assertFalse(res["has_more"]) + + def test_explicit_fields(self): + # A field computed on the fly is read when asked for, and a forbidden or + # binary one is left out, the record still coming with its id. + domain = [("id", "=", self.partner.id)] + res = self._search_read( + domain=domain, fields=["name", "email", "contact_address"] + ) + self.assertEqual(set(res["records"][0]), {"id", "name", "contact_address"}) + res = self._search_read(domain=domain, fields=["email", "image_1920"]) + self.assertEqual(res["records"], [{"id": self.partner.id}]) + + def test_no_readable_field(self): + # With every field forbidden, records come with their id only: an empty + # field list must not reach read(), which reads all fields for it. + partner_fields = self.env["ir.model.fields"].search( + [("model", "=", "res.partner")] + ) + partner_fields.ai_no_read = True + res = self._search_read(domain=[("id", "=", self.partner.id)]) + self.assertEqual(res["records"], [{"id": self.partner.id}]) + + def test_relational_read_as_id(self): + # Core adds the display name of a many2one, built from fields of the + # related record that may be forbidden there (a partner's name). + child = self.env["res.partner"].create( + {"name": "Suzuki", "parent_id": self.partner.id} + ) + res = self._search_read( + domain=[("id", "=", child.id)], fields=["parent_id", "child_ids"] + ) + self.assertEqual( + res["records"], + [{"id": child.id, "parent_id": self.partner.id, "child_ids": []}], + ) + + def test_paging(self): + partners = self.env["res.partner"].create( + [{"name": "Page %s" % i} for i in range(3)] + ) + kwargs = { + "domain": [("id", "in", partners.ids)], + "fields": ["name"], + "order": "id", + } + res = self._search_read(limit=2, **kwargs) + self.assertEqual([r["id"] for r in res["records"]], partners[:2].ids) + self.assertTrue(res["has_more"]) + res = self._search_read(limit=2, offset=2, **kwargs) + self.assertEqual([r["id"] for r in res["records"]], partners[2:].ids) + self.assertFalse(res["has_more"]) + + def test_limit_capped(self): + # A missing or out-of-range limit falls back to the cap, which Odoo would + # otherwise lift for a limit of 0 or less. + partners = self.env["res.partner"].create( + [{"name": "Cap %s" % i} for i in range(MAX_LIMIT + 1)] + ) + for limit in (None, -1, 0, MAX_LIMIT + 1): + with self.subTest(limit=limit): + res = self._search_read( + domain=[("id", "in", partners.ids)], fields=["id"], limit=limit + ) + self.assertEqual(len(res["records"]), MAX_LIMIT) + self.assertTrue(res["has_more"]) + # A missing or negative offset starts from the first record. + domain = [("id", "in", partners.ids)] + first_page = self._search_read(domain=domain, fields=["id"])["records"] + for offset in (None, -1): + with self.subTest(offset=offset): + res = self._search_read(domain=domain, fields=["id"], offset=offset) + self.assertEqual(res["records"], first_page) + + def test_forbidden_value_followed(self): + # A field exposing the value of a forbidden one is forbidden too: one + # computed from it, related to it, or delegated through _inherits. + res = self._get_fields("res.partner") + for name in ("email_normalized", "email_formatted"): + with self.subTest(name=name): + self.assertIn(name, res["hidden"]) + self.assertNotIn(name, res["fields"]) + for model in ("res.users", "mail.followers"): + with self.subTest(model=model): + self.assertNotIn("email", self._get_fields(model)["fields"]) + # Followed through several levels. + self.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "name")] + ).ai_no_read = True + res = self._get_fields("res.partner") + for name in ("display_name", "commercial_company_name", "parent_name"): + with self.subTest(name=name): + self.assertIn(name, res["hidden"]) + # A field merely pointing to the record is not affected. + self.assertIn("parent_id", res["fields"]) + + def test_flag_on_mixin(self): + # A flag set on a mixin's field applies to the models inheriting it. + self.email_field.ai_no_read = False + self.assertIn("email_normalized", self._get_fields("res.partner")["fields"]) + self.env["ir.model.fields"].search( + [("model", "=", "mail.thread.blacklist"), ("name", "=", "email_normalized")] + ).ai_no_read = True + self.assertIn("email_normalized", self._get_fields("res.partner")["hidden"]) + + def test_display_name_hidden(self): + # A display name computed on the fly comes from name_get, which may show + # another record's name with nothing in the field dependencies to tell: + # a follower shows its partner's name. A stored one declares them. + self.assertNotIn("display_name", self._get_fields("mail.followers")["fields"]) + self.assertIn("display_name", self._get_fields("res.partner")["fields"]) + + def test_unreadable_fields_not_exposed(self): + # A group-restricted field is not even reported as hidden. + res = self._get_fields("mail.guest") + self.assertNotIn("access_token", res["fields"]) + self.assertNotIn("access_token", res["hidden"]) + # A field pointing to a model the user cannot read is left out, so that + # reading the model does not fail as a whole. + self.assertNotIn("model_access", self._get_fields("res.groups")["fields"]) + group_user = self.env.ref("base.group_user") + res = self._search_read("res.groups", domain=[("id", "=", group_user.id)]) + self.assertNotIn("model_access", res["records"][0]) + # A user who may read them gets them. + res = self._search_read( + "res.groups", + user=self.system_user, + domain=[("id", "=", group_user.id)], + fields=["model_access"], + ) + self.assertIn("model_access", res["records"][0]) + + def test_refused_field_left_out(self): + # A computed field can be refused only when it is computed, typically + # because its compute looks up a model the user cannot read. Simulated, + # as which real fields do so depends on the installed modules, and a + # value already in the cache would not be computed at all. + def compute_refused(partners): + partners.env["ir.model.access"].check_access_rights("read") + + self.patch( + type(self.env["res.partner"]), "_compute_contact_address", compute_refused + ) + self.partner.invalidate_recordset(["contact_address"]) + domain = [("id", "=", self.partner.id)] + res = self._search_read(domain=domain, fields=["name", "contact_address"]) + self.assertEqual(res["records"], [{"id": self.partner.id, "name": "Tanaka"}]) + self.assertEqual(res["inaccessible_fields"], ["contact_address"]) + + def test_refused_record_dropped(self): + # ir.attachment search finds attachments that its read then refuses (no + # res_model, not public, created by someone else). + Attachment = self.env["ir.attachment"] + private = Attachment.create({"name": "private.txt", "raw": b"x"}) + public = Attachment.create({"name": "public.txt", "raw": b"x", "public": True}) + res = self._search_read( + "ir.attachment", + domain=[("id", "in", (private | public).ids)], + fields=["name"], + ) + self.assertEqual([r["name"] for r in res["records"]], ["public.txt"]) + self.assertEqual(res["inaccessible_fields"], []) + + def test_unqueryable_model_rejected(self): + for model in ("no.such.model", "mail.thread", "base.language.install"): + with self.subTest(model=model): + with self.assertRaises(UserError) as catcher: + self._search_read(model) + self.assertNotIsInstance(catcher.exception, AccessError) + with self.assertRaises(AccessError): + self._search_read("ir.cron") + # An abstract model is not listed even when an ACL grants read on it. + self.env["ir.model.access"].create( + { + "name": "ai_tool_read_test_access", + "model_id": self.env["ir.model"]._get("mail.thread").id, + "group_id": self.env.ref("base.group_user").id, + "perm_read": True, + } + ) + models = [ + m["model"] for m in self._tool("list_models")._execute_tool()["models"] + ] + self.assertNotIn("mail.thread", models) + + def test_flag_write_on_base_field(self): + # Core refuses to alter a base field, but the flag goes through. + self.email_field.write({"ai_no_read": False}) + res = self._search_read(domain=[("id", "=", self.partner.id)], fields=["email"]) + self.assertEqual(res["records"][0]["email"], "tanaka@example.com") + # Any other key keeps core's validation, with or without the flag. + for vals in ({"index": True}, {"ai_no_read": True, "index": True}): + with self.subTest(vals=vals): + with self.assertRaises(UserError), self.cr.savepoint(): + self.email_field.write(vals) diff --git a/ai_tool_read/views/ir_model_fields_views.xml b/ai_tool_read/views/ir_model_fields_views.xml new file mode 100644 index 00000000..eebbc06d --- /dev/null +++ b/ai_tool_read/views/ir_model_fields_views.xml @@ -0,0 +1,58 @@ + + + + + ir.model.fields + + + + + + + + + + ir.model + + + + + + + + + + + + + ir.model.fields + + + + + + + + + + ir.model.fields + + + + + + + + + + diff --git a/setup/ai_tool_read/odoo/addons/ai_tool_read b/setup/ai_tool_read/odoo/addons/ai_tool_read new file mode 120000 index 00000000..e64fbd47 --- /dev/null +++ b/setup/ai_tool_read/odoo/addons/ai_tool_read @@ -0,0 +1 @@ +../../../../ai_tool_read \ No newline at end of file diff --git a/setup/ai_tool_read/setup.py b/setup/ai_tool_read/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/ai_tool_read/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)