This is an older published review of this plugin. View the latest review →
tool_aiagent is listed on the Moodle Marketplace as a paid plugin. These reviews were performed on the publisher's source repository, not the Marketplace-distributed package.
tool_aiagent ("AI Agent") is a large commercial admin tool by LMSCloud that embeds an agentic AI chat assistant into Moodle. Authenticated users — by default managers, gated by the tool/aiagent:usechat capability — converse with an LLM that can call Moodle web service functions and a large catalogue of bundled "built-in tools" to read and modify course content (activities, questions, lessons, books, glossaries, feedback, LTI tools, access restrictions, files/images, and more) on the user's behalf.
It builds on the core core_ai subsystem and ships enhancement providers for OpenAI, Anthropic/Claude, Azure, Gemini, DeepSeek, Groq, Ollama and OpenWebUI. The tool catalogue, system instructions and schema corrections are downloaded as signed "artefacts" from the LMSCloud cloud and cached under $CFG->dataroot. This review covers release 4.5.7 (version 2026093000), which supports Moodle 4.5–5.3.
This is an unusually large and feature-rich agentic-AI plugin, yet the review surfaced no exploitable security vulnerabilities and only one low-severity hardening item.
The security architecture is consistent and correct throughout:
- Authorization is enforced per operation. The chat endpoints (
ajax.php,chat.php,index.php) all callrequire_login(), validate thetool/aiagent:usechatcapability viaaccess::require_use_anywhere(), andajax.phpadditionally callsrequire_sesskey(). Crucially, every one of the ~100 built-in tools and every proxied core web service function re-enforces the target context's own capabilities (validate_context()+require_capability()/question_require_capability_on()/require_can_manage()), so the AI only ever acts within the invoking user's own privileges. There is no privilege escalation path. - User data is owner-scoped. Conversations are retrieved only by
(hash|id) AND userid = $USER->id, and thepluginfilecallback serves attachments only afterrequire_use_anywhere()and anis_own()ownership check. - Outbound HTTP uses sanctioned clients. LLM calls go through
\core\http_client; license/feedback/artefact traffic uses the core\curlclass anddownload_file_content(). Both apply core'scurl_security_helperegress controls and keep TLS peer verification enabled (CURLOPT_SSL_VERIFYPEER => 1); none setignoresecurity. - Output is escaped. AI markdown is run through
format_text(FORMAT_MARKDOWN)(HTML Purifier) with explicit<-escaping outside code regions; tool results and arguments are rendered through auto-escaped Mustache; context/skill/placement labels pass throughformat_string/format_text/s(). - SQL is parameterized everywhere (the only interpolations are report-builder table aliases and hard-coded fragments), the Privacy API implementation is thorough (including external-location declarations for the AI providers and the LMSCloud feedback endpoint), and
db/uninstall.phpcleans up the dataroot cache.
The single finding is a defence-in-depth note about unserialize() of a core-managed lesson.conditions field without restricting allowed classes — not currently exploitable. The grade reflects a very well-engineered, security-conscious codebase with only minor hardening and maintainability observations.
Overview
tool_aiagent is a commercial, agentic AI assistant for Moodle. A user with the tool/aiagent:usechat capability (managers by default) chats with an LLM that can invoke Moodle web service functions and a large library of bundled "built-in tools" to read and modify site content on the user's behalf. The design is sophisticated, and — importantly for security — it is implemented with great care.
Security posture
The central security property is that the agent never exceeds the invoking user's own permissions. Every tool that touches data resolves the concrete target context and calls \core_external\external_api::validate_context() followed by a specific require_capability() (or question_require_capability_on(), require_can_manage(), etc.). Proxied core web service functions self-enforce their own capabilities through the standard call_external_function() path, and the override layer only transforms arguments/responses — it never short-circuits the real function (generic::override_execution() always returns false).
Other controls verified against Moodle core source:
- Entry points:
require_login()+ capability gate on all three PHP entry points;require_sesskey()on the AJAX endpoint; web services requiremoodle/site:config; forms usedynamic_form/admin_externalpage_setup. - Data isolation: conversations and their files are strictly owner-scoped; the
pluginfilecallback enforcesis_own(). - File access: local file reads are authorised via a cookie-authenticated loopback to
pluginfile.php(delegating to core), or a course-visibility check for curated content areas; external fetches go through the SSRF-protected\core\http_client/\curl. - XSS: AI output is sanitised server-side (
format_text+ HTML Purifier); untrusted tool output is rendered escaped ({{resultjson}},{{errormessage}}); all raw{{{ }}}template slots are fed byformat_string/format_text/s()-escaped values. - SQL injection: none — parameterized queries throughout.
- Privacy/GDPR: a complete provider that declares the DB tables, the AI provider and LMSCloud feedback as external locations, user preferences, and implements export/delete (including provider-side purge).
Findings
One low, defence-in-depth finding: two tools call @unserialize() on the core lesson.conditions column without allowed_classes. The data is core-written and the code only reads scalar \stdClass properties, so there is no demonstrable object-injection path, but restricting allowed classes would harden it.
Notable observations (non-defects)
- The agent is inherently subject to prompt injection, but the impact is bounded by the per-tool capability enforcement and (for writes) human approval.
- The plugin makes user-prompt-influenced server-side HTTP requests; it correctly uses core's egress-controlled clients, but the default site blocklist is empty, so admins who broaden
usechatshould configurecurlsecurityblockedhosts. - The code is heavily obfuscated (single-token variable names, hash-based class/file names) and is distributed under a proprietary (non-GPL) license; the shipped package contains no
tests/directory despite extensive in-code test hooks.
Findings
Two lesson-related built-in tools read the core lesson.conditions column and pass it straight to @unserialize() without the allowed_classes option.
Using unserialize() without restricting allowed_classes is a recognised hardening gap: if an attacker can control the serialized string, PHP object injection becomes possible (instantiating arbitrary classes and triggering their magic methods / destructors).
In this plugin the input is not attacker-controlled in any demonstrated way — lesson.conditions is written by mod_lesson core as a fixed \stdClass of numeric settings, and both call sites immediately check instanceof \stdClass and read only scalar properties. The code also mirrors how mod_lesson core itself handles this field. It is therefore reported as defence-in-depth hardening, not a live vulnerability.
Low risk. There is no traced path by which an attacker can place an arbitrary serialized object into lesson.conditions: the field is produced by core from a fixed structure, and both call sites immediately constrain the result with instanceof \stdClass and read only scalar members. Exploitation would additionally require a usable POP gadget chain in the loaded codebase. The finding is a code-hygiene / defence-in-depth improvement rather than an exploitable weakness; adding ['allowed_classes' => [\stdClass::class]] removes the theoretical object-injection surface at no functional cost.
Both call sites run inside lesson tools (reachable through the chat once the mod_lesson tools are enabled). n3969774340.php builds a read-only description of a lesson's settings; ia13e078edb.php prepares the lesson update form data. In each case the conditions value comes from $DB->get_record('lesson', ...), i.e. a column that mod_lesson serialises from its own edit form into a fixed {timespent, completed, gradebetterthan} shape. Teachers influence the values through the lesson settings form, but the form constructs the object server-side, so the serialized structure is not under their control.
if (!empty($o8->conditions) && is_string($o8->conditions)) {
$o18 = @unserialize($o8->conditions);
if ($o18 instanceof \stdClass) {
Restrict the classes that may be instantiated. Because the code only ever expects a plain \stdClass, allow just that one and nothing else:
$o18 = @unserialize($o8->conditions, ['allowed_classes' => [\stdClass::class]]);
The subsequent instanceof \stdClass check continues to work, and any other class in the payload is reduced to __PHP_Incomplete_Class, which fails the check.
if (!empty($o0->conditions) && is_string($o0->conditions)) {
$o5 = @unserialize($o0->conditions);
if ($o5 instanceof \stdClass) {
Apply the same restriction:
$o5 = @unserialize($o0->conditions, ['allowed_classes' => [\stdClass::class]]);
Prompt injection is an inherent, well-mitigated risk. Because the agent reads untrusted content (student submissions, fetched web pages, file contents) and can call tools, a crafted payload could attempt to steer it into calling tools the user did not intend. The blast radius is bounded by the plugin's strongest control: every tool re-enforces the invoking user's own capabilities on the concrete target context, and state-changing tools require explicit human approval unless the user opted into an auto-approve mode (and even all-mode downgrades to safe without the tool/aiagent:autoapproveall capability). A loop-guard caps repeated identical calls. This is the correct architecture for an LLM agent; no change is required, but operators should understand the agent can only ever do what the current user could do manually.
User-influenced outbound requests rely on the site's egress configuration. Chat prompts can cause the server to fetch URLs (e.g. the PDF/image/file tools via file::create_from_any_url() / download_file_content()). The plugin correctly uses core's sanctioned clients (\core\http_client, \curl), which pass every request through curl_security_helper and keep TLS verification on — so this is not an SSRF defect in the plugin. However, Moodle's curlsecurityblockedhosts / curlsecurityallowedport are empty by default, so out of the box internal hosts are reachable by any URL-fetching feature. Administrators who grant tool/aiagent:usechat beyond trusted managers should configure the cURL security blocklist as a hardening measure.
Proprietary, non-GPL license. The plugin ships under the "LMSCloud Commercial License" and replaces Moodle's GPL boilerplate with a proprietary header (suppressing moodle.Files.BoilerplateComment). Moodle plugins extend GPL code and are normally expected to be GPL-compatible; a proprietary license is unusual and would make the plugin ineligible for the moodle.org plugins directory. This is a licensing/compliance observation, not a code defect.
Heavy code obfuscation reduces reviewability. Most identifiers are mechanically renamed ($o0, $o1, …) and the built-in tool / override / handler classes use opaque hash-based file and class names. This appears intentional for IP protection, and it is not a moodle-cs violation, but it materially slows security review and future maintenance, and makes it harder for a site operator to audit exactly what each tool does before enabling it.
No tests/ directory in the shipped package. Despite extensive in-code test affordances (PHPUNIT_TEST/BEHAT_SITE_RUNNING branches, a behatfailsend setting, and \tool_aiagent\local\api\dev / testing callbacks that are absent from the release), the distributed plugin contains no automated tests. Tests appear to be maintained in a separate development build; including them in the release would improve confidence for site operators and reviewers.
Functionality depends on an external cloud service. The built-in tool catalogue, system instructions, schema corrections and override map are downloaded as license-gated "artefacts" from the LMSCloud API and cached under $CFG->dataroot/tool_aiagent/cloud. Downloaded artefacts are integrity-checked with a SHA-256 hash_equals() comparison and the ZIP is read in-memory (no extraction to disk, so no zip-slip). The dependency and the external transmission of conversation snapshots/feedback are disclosed in the Privacy API provider, but operators should be aware the plugin's behaviour is partly driven by remotely-served data.