From 1e4cac32194acf4c247e311e47f22ba13cf818f1 Mon Sep 17 00:00:00 2001 From: nobuQuartile Date: Mon, 5 Oct 2026 23:07:54 +0000 Subject: [PATCH 1/4] [ADD] ai_tool_read: generic read tools with per-field AI opt-out ai_tool lets modules expose tools to AI agents, but each one has to be written for a specific model. This module adds three generic tools, list_models, get_fields and search_read, so that an agent can read any model the calling user is allowed to read, with the user's own access rights (ACLs, record rules and field groups still apply). Personal or otherwise sensitive data can be kept out of the results with a new "AI Read Forbidden" flag on ir.model.fields. A flagged field is never returned nor described, and neither is any field exposing its value: related fields, _inherits included, and computed fields depending on it, as the registry reports them. Many2one values are returned as plain IDs, and display names computed on the fly are left out, as both come from name_get, which may show another record's forbidden field. Searches are not restricted, so an agent probing on purpose can still find records by a forbidden value; the README lists those cases. search_read returns the stored fields unless a field list is given, pages its results with limit, offset and has_more, and does not fail as a whole because of one field or record the user may not read. Assisted-by: Claude Opus 5.5 --- ai_tool_read/README.rst | 197 +++++++ ai_tool_read/__init__.py | 1 + ai_tool_read/__manifest__.py | 19 + ai_tool_read/data/ai_tools.xml | 47 ++ ai_tool_read/models/__init__.py | 2 + ai_tool_read/models/ir_model.py | 240 +++++++++ ai_tool_read/models/ir_model_fields.py | 33 ++ ai_tool_read/readme/CONFIGURE.md | 30 ++ ai_tool_read/readme/CONTRIBUTORS.md | 2 + ai_tool_read/readme/DESCRIPTION.md | 36 ++ ai_tool_read/readme/ROADMAP.md | 22 + ai_tool_read/static/description/index.html | 534 +++++++++++++++++++ ai_tool_read/tests/__init__.py | 1 + ai_tool_read/tests/test_ai_tool_read.py | 256 +++++++++ ai_tool_read/views/ir_model_fields_views.xml | 58 ++ setup/ai_tool_read/odoo/addons/ai_tool_read | 1 + setup/ai_tool_read/setup.py | 6 + 17 files changed, 1485 insertions(+) create mode 100644 ai_tool_read/README.rst create mode 100644 ai_tool_read/__init__.py create mode 100644 ai_tool_read/__manifest__.py create mode 100644 ai_tool_read/data/ai_tools.xml create mode 100644 ai_tool_read/models/__init__.py create mode 100644 ai_tool_read/models/ir_model.py create mode 100644 ai_tool_read/models/ir_model_fields.py create mode 100644 ai_tool_read/readme/CONFIGURE.md create mode 100644 ai_tool_read/readme/CONTRIBUTORS.md create mode 100644 ai_tool_read/readme/DESCRIPTION.md create mode 100644 ai_tool_read/readme/ROADMAP.md create mode 100644 ai_tool_read/static/description/index.html create mode 100644 ai_tool_read/tests/__init__.py create mode 100644 ai_tool_read/tests/test_ai_tool_read.py create mode 100644 ai_tool_read/views/ir_model_fields_views.xml create mode 120000 setup/ai_tool_read/odoo/addons/ai_tool_read create mode 100644 setup/ai_tool_read/setup.py diff --git a/ai_tool_read/README.rst b/ai_tool_read/README.rst new file mode 100644 index 00000000..332d26fc --- /dev/null +++ b/ai_tool_read/README.rst @@ -0,0 +1,197 @@ +.. 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| + +This module adds generic ``ai_tool`` tools that 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. + +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..aa9cd022 --- /dev/null +++ b/ai_tool_read/data/ai_tools.xml @@ -0,0 +1,47 @@ + + + + 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, + 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..506359dd --- /dev/null +++ b/ai_tool_read/models/ir_model.py @@ -0,0 +1,240 @@ +# 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)]) + # 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).""" + 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_*). + "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: + output_fields = [name for name in fields if name in readable] + else: + output_fields = [name for name in readable if target._fields[name].store] + # 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 + # 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..c952aed7 --- /dev/null +++ b/ai_tool_read/models/ir_model_fields.py @@ -0,0 +1,33 @@ +# 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 will exclude this field from " + "records, schema introspection and domain/order filtering. Use it to keep " + "personal or otherwise sensitive fields away from 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..8851a412 --- /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..99c4cfbb --- /dev/null +++ b/ai_tool_read/readme/DESCRIPTION.md @@ -0,0 +1,36 @@ +This module adds generic `ai_tool` tools that 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/static/description/index.html b/ai_tool_read/static/description/index.html new file mode 100644 index 00000000..632a9cef --- /dev/null +++ b/ai_tool_read/static/description/index.html @@ -0,0 +1,534 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

AI Tool - Read

+ +

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

+

This module adds generic ai_tool tools that 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.

+
+
+

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..bdabb6d2 --- /dev/null +++ b/ai_tool_read/tests/test_ai_tool_read.py @@ -0,0 +1,256 @@ +# 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"]) + + 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, +) From abf339834a3c8c53e161947b1c780898aa126bd6 Mon Sep 17 00:00:00 2001 From: Daniel Reis Date: Wed, 7 Oct 2026 18:29:10 +0100 Subject: [PATCH 2/4] [FIX] ai_tool_read: review follow-ups - _ai_jsonify: note that isinstance(date) also covers datetime. - _ai_get_fields: document that reporting forbidden fields under "hidden" is deliberate, unlike group-restricted fields which are not disclosed. - _ai_forbidden_fields: return early when no field is flagged. - _ai_search_read: an explicit empty fields list now returns ids only; previously it fell back to the stored-fields default, same as no fields argument at all. Assisted-by: Devin:SWE-2 High --- ai_tool_read/data/ai_tools.xml | 5 +++-- ai_tool_read/models/ir_model.py | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/ai_tool_read/data/ai_tools.xml b/ai_tool_read/data/ai_tools.xml index aa9cd022..ec0faacb 100644 --- a/ai_tool_read/data/ai_tools.xml +++ b/ai_tool_read/data/ai_tools.xml @@ -25,8 +25,9 @@ search_read - Search and read records of a model. Accepts an optional Odoo domain, - an explicit field list, a limit (200 at most, and by default), an + 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 diff --git a/ai_tool_read/models/ir_model.py b/ai_tool_read/models/ir_model.py index 506359dd..86811951 100644 --- a/ai_tool_read/models/ir_model.py +++ b/ai_tool_read/models/ir_model.py @@ -27,6 +27,8 @@ def _ai_forbidden_fields(self): 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 @@ -121,7 +123,10 @@ def _ai_read(self, records, field_names): 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).""" + """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() @@ -175,6 +180,9 @@ def _ai_get_fields(self, model): 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 ), @@ -215,10 +223,11 @@ def _ai_search_read( """ target = self._ai_target(model) readable = self._ai_readable_field_names(model) - if fields: - output_fields = [name for name in fields if name in readable] - else: + 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. From 609e40b0ce96d4611e5646cb8bc512571ef9b182 Mon Sep 17 00:00:00 2001 From: Daniel Reis Date: Wed, 7 Oct 2026 18:47:47 +0100 Subject: [PATCH 3/4] [IMP] ai_tool_read: revamp documentation Add a USAGE section covering how the registered ai.tool records are consumed (e.g. via ai_oca_mcp), the list_models / get_fields / search_read agent workflow, and the search_read arguments with an example. Drop the "This module" opening from the description. Assisted-by: Devin:SWE-2 High --- ai_tool_read/README.rst | 60 +++++++++++++++- ai_tool_read/readme/DESCRIPTION.md | 6 +- ai_tool_read/readme/USAGE.md | 47 +++++++++++++ ai_tool_read/static/description/index.html | 82 ++++++++++++++++++---- 4 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 ai_tool_read/readme/USAGE.md diff --git a/ai_tool_read/README.rst b/ai_tool_read/README.rst index 332d26fc..691fd401 100644 --- a/ai_tool_read/README.rst +++ b/ai_tool_read/README.rst @@ -32,9 +32,9 @@ AI Tool - Read |badge1| |badge2| |badge3| |badge4| |badge5| -This module adds generic ``ai_tool`` tools that 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. +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 @@ -115,6 +115,60 @@ 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 ====================== diff --git a/ai_tool_read/readme/DESCRIPTION.md b/ai_tool_read/readme/DESCRIPTION.md index 99c4cfbb..03c4311b 100644 --- a/ai_tool_read/readme/DESCRIPTION.md +++ b/ai_tool_read/readme/DESCRIPTION.md @@ -1,6 +1,6 @@ -This module adds generic `ai_tool` tools that 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. +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: 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 index 632a9cef..a554a097 100644 --- a/ai_tool_read/static/description/index.html +++ b/ai_tool_read/static/description/index.html @@ -375,9 +375,9 @@

AI Tool - Read

!! source digest: sha256:fe66b1297c9c53a47b95b8407598bb5fab4d963c5e90afea0c614688365f21fc !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

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

-

This module adds generic ai_tool tools that 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.

+

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 @@ -414,12 +414,13 @@

AI Tool - 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

+

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 @@ -489,7 +541,7 @@

Known issues / Roadmap

no reason given; get_fields tells them apart.

-

Bug Tracker

+

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 @@ -497,15 +549,15 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • Quartile
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association From 425e42a8edd395e6688882d3ca4bb34e8146357f Mon Sep 17 00:00:00 2001 From: nobuQuartile Date: Tue, 13 Oct 2026 03:18:32 +0000 Subject: [PATCH 4/4] [FIX] ai_tool_read: field help, negative offset and tool descriptions - The help of "AI Read Forbidden" still said that flagged fields are kept out of domains and orderings. They are not since the tools stopped rejecting them (see Known issues): say that the flag keeps values out of the results only, so that an administrator does not rely on it against filtering. - A negative offset reached PostgreSQL and failed the call with a SQL error. Treat it as 0, next to the limit guard. - The tool descriptions kept the leading newline and indentation of the XML text, shown as is in the form view and sent to MCP clients. Write each one as a single paragraph. - CONFIGURE.md: start the list items with a capital letter. Assisted-by: Claude Opus 5.5 --- ai_tool_read/README.rst | 4 +-- ai_tool_read/data/ai_tools.xml | 36 ++++++---------------- ai_tool_read/models/ir_model.py | 3 ++ ai_tool_read/models/ir_model_fields.py | 8 +++-- ai_tool_read/readme/CONFIGURE.md | 4 +-- ai_tool_read/static/description/index.html | 4 +-- ai_tool_read/tests/test_ai_tool_read.py | 7 +++++ 7 files changed, 30 insertions(+), 36 deletions(-) diff --git a/ai_tool_read/README.rst b/ai_tool_read/README.rst index 691fd401..4fa932bc 100644 --- a/ai_tool_read/README.rst +++ b/ai_tool_read/README.rst @@ -83,10 +83,10 @@ Configuration To forbid a field, tick **AI Read Forbidden** on it. As the administrator, either: -- go to *Settings > Technical > Database Structure > Fields*, find the +- 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* +- 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 diff --git a/ai_tool_read/data/ai_tools.xml b/ai_tool_read/data/ai_tools.xml index ec0faacb..8571ab99 100644 --- a/ai_tool_read/data/ai_tools.xml +++ b/ai_tool_read/data/ai_tools.xml @@ -2,45 +2,27 @@ 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. - + 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). - + 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. - + 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/ir_model.py b/ai_tool_read/models/ir_model.py index 86811951..76aef6c3 100644 --- a/ai_tool_read/models/ir_model.py +++ b/ai_tool_read/models/ir_model.py @@ -233,6 +233,9 @@ def _ai_search_read( # 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 diff --git a/ai_tool_read/models/ir_model_fields.py b/ai_tool_read/models/ir_model_fields.py index c952aed7..8a46a7b9 100644 --- a/ai_tool_read/models/ir_model_fields.py +++ b/ai_tool_read/models/ir_model_fields.py @@ -11,9 +11,11 @@ class IrModelFields(models.Model): string="AI Read Forbidden", default=False, index=True, - help="If enabled, the generic AI read tools will exclude this field from " - "records, schema introspection and domain/order filtering. Use it to keep " - "personal or otherwise sensitive fields away from AI agents.", + 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): diff --git a/ai_tool_read/readme/CONFIGURE.md b/ai_tool_read/readme/CONFIGURE.md index 8851a412..384ece99 100644 --- a/ai_tool_read/readme/CONFIGURE.md +++ b/ai_tool_read/readme/CONFIGURE.md @@ -1,9 +1,9 @@ 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 +- 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 +- 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 diff --git a/ai_tool_read/static/description/index.html b/ai_tool_read/static/description/index.html index a554a097..539881ab 100644 --- a/ai_tool_read/static/description/index.html +++ b/ai_tool_read/static/description/index.html @@ -430,10 +430,10 @@

Configuration

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

    -
  • go to Settings > Technical > Database Structure > Fields, find the +
  • 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 +
  • 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 diff --git a/ai_tool_read/tests/test_ai_tool_read.py b/ai_tool_read/tests/test_ai_tool_read.py index bdabb6d2..b12e1700 100644 --- a/ai_tool_read/tests/test_ai_tool_read.py +++ b/ai_tool_read/tests/test_ai_tool_read.py @@ -132,6 +132,13 @@ def test_limit_capped(self): ) 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