MDL Shield

AI agent

tool_aiagent

Published by LMSCloud

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.

Plugin Information

An AI agent (admin tool) that embeds a chat assistant into Moodle. It drives Moodle AI providers (OpenAI, Claude/Anthropic, Gemini, Azure, OpenAI-compatible) through per-provider adapters and lets the model call Moodle web service functions and a large catalogue of bundled "built-in tools" (e.g. creating activities, editing books/lessons, managing the gradebook) on behalf of the logged-in user, with an approval/auto-approve flow. It is a commercial, licensed plugin that fetches its tool catalogue, overrides and skills as signed artefacts from the LMSCloud backend.

Version:2026101200
Release:4.5.8
Reviewed for:5.3
Privacy API
Unit Tests
Behat Tests
Reviewed:2026-10-12
493 files·62,534 lines
Grade Justification

This is a large, mature, and notably security-conscious plugin. The central risk — an AI agent that executes privileged operations on the user's behalf — is handled correctly: every tool execution path runs as the current user and enforces capabilities.

  • Web-service tools go through external_api::call_external_function, preserving each function's own require_capability/validate_context checks.
  • Built-in tools consistently begin their execute() with a shared context-loader (load_book_context, load_lesson_context, load_course, resolve_context, can_edit()) that performs validate_context and require_capability (verified across the book, lesson, module-create, gradebook, group, H5P and content-bank families).
  • Overrides never short-circuit execution (only the base override_execution exists, returning false); they transform arguments/responses around core functions that keep their checks, and the one override doing extra writes re-checks moodle/course:managegroups.

Other controls are sound: conversations are strictly scoped to userid (no IDOR); all SQL is parameterized; assistant output is rendered via purified format_text, user/error text via s(), and tool content via escaped templates / sandboxed iframe srcdoc; file names are PARAM_FILE-cleaned; outbound HTTP uses sanctioned wrappers (\core\http_client, \curl with CURLOPT_SSL_VERIFYPEER => 1, download_file_content) with no TLS weakening; the licence/artefact channel verifies TLS and integrity-checks downloads with hash_equals; all DDL is confined to db/upgrade.php; a Privacy API provider is implemented and discloses the external AI/feedback data flows; and db/uninstall.php cleans the dataroot cache.

No security vulnerabilities were identified. The only graded issue is a defensive-hardening item (unserialize() of a trusted core column without allowed_classes => false), plus informational observations about defence-in-depth and reviewability. The ceiling is set by these low/info items with no exploitable weaknesses found.

AI Summary

Overview

tool_aiagent is a feature-rich AI agent for Moodle that lets a language model call Moodle web services and a large catalogue of bundled "built-in tools" on behalf of the current user. Because the agent performs privileged actions, the review focused on whether its promise — "the agent can never do more than you could do yourself" — actually holds.

Key conclusion

It holds. Capability enforcement is applied consistently and correctly across every execution path:

  • Web-service tools run through core's call_external_function, so each function's internal require_capability/validate_context applies, running as the logged-in $USER.
  • Built-in tools enforce capabilities via shared context-loader helpers (load_*_context / load_course) that call validate_context and require_capability before any write. I verified this across the book, lesson (all 20 tools), module-create, gradebook, scales, group, H5P and content-bank families.
  • The override_webservice_execution hook is used only to reshape arguments/results; no concrete override bypasses a capability check, and the one with extra writes re-checks moodle/course:managegroups.
  • Discovery-time filtering (is_restricted_by_capability) is a coarse pre-filter; the real enforcement is the per-context require_capability at execution, so a user cannot reach a tool outside their rights.

Other areas (all clean)

  • XSS: assistant markdown via purified format_text; user/error text via s(); tool call data carried in escaped data-attributes and shown through escaped templates and a scriptless iframe sandbox="" srcdoc; file names constrained by PARAM_FILE (strips `< > " ' ``).
  • IDOR: conversations are fetched with userid = $USER->id; file access checks is_own(); rename/feedback are owner-gated.
  • SQL injection: none — all queries use placeholders / get_in_or_equal.
  • SSRF / outbound HTTP: the plugin uses sanctioned wrappers (\core\http_client, \curl, download_file_content) that pass through curl_security_helper; TLS verification is left at the secure defaults and never disabled.
  • Licence/cloud channel: TLS-verified, artefacts integrity-checked with hash_equals(sha256), no zip extraction to disk, endpoint config is admin-only.
  • Privacy, DDL hygiene, uninstall cleanup, scheduled tasks, admin-only external APIs — all correctly implemented.

Findings

Only one low defensive-hardening finding (unserialize() without allowed_classes => false on a trusted core column) and informational defence-in-depth/reviewability observations. No exploitable vulnerabilities were found.

Findings

code qualityLow
unserialize() of the lesson conditions column without allowed_classes hardening

Two built-in lesson tools call @unserialize() on the conditions field of a lesson record without the ['allowed_classes' => false] option.

PHP's unserialize() will instantiate objects named in the serialized string and can trigger __wakeup()/__destruct() on them, which is the basis of PHP object-injection (POP-gadget) attacks. Passing ['allowed_classes' => false] reduces any serialized object to __PHP_Incomplete_Class, eliminating that risk.

In this plugin the input is not attacker-controlled: the value comes from get_moduleinfo_data() (the existing lesson row, written by Moodle core as a plain stdClass), the call sites run only after require_capability('moodle/course:manageactivities', ...), and the result is used only when it is instanceof stdClass. There is therefore no realistic exploit today. It is flagged purely as defence-in-depth and as a modern secure-coding default.

Risk Assessment

Low risk. This is a hardening/best-practice item, not an exploitable vulnerability. To abuse it an attacker would need to place a crafted serialized object into lesson.conditions, but Moodle core writes that column itself (as a stdClass) and the exposed tool schema does not accept a raw conditions string. The call path is additionally gated by moodle/course:manageactivities. Adding ['allowed_classes' => false] removes even the theoretical object-injection surface at no functional cost.

Context

Both classes extend the lesson module-info base. In the update flow (g63e567c784::execute), the module info object is loaded from the existing activity via core's get_moduleinfo_data() after require_capability('moodle/course:manageactivities', ...). prepare_lesson_fields() then unserializes the legacy conditions column to preserve timespent/completed/gradebetterthan. The serialized bytes therefore originate from Moodle core's own storage of that field, not from the AI arguments or any request parameter.

Identified Code
        if (!empty($o0->conditions) && is_string($o0->conditions)) {
            $o5 = @unserialize($o0->conditions);
            if ($o5 instanceof \stdClass) {
Suggested Fix

Disable object instantiation when unserializing data you only expect to be a plain record:

$o5 = @unserialize($o0->conditions, ['allowed_classes' => false]);

The subsequent instanceof \stdClass check still works because a disallowed class becomes __PHP_Incomplete_Class, which will simply fail the check.

Identified Code
            $o18 = @unserialize($o8->conditions);
Suggested Fix

Same change as the other location:

$o18 = @unserialize($o8->conditions, ['allowed_classes' => false]);
best practiceInfo
AI-directed outbound fetches rely on the site's egress configuration (defence-in-depth)

Several built-in tools let the model fetch content by URL on the server's behalf — for example the PDF-to-image tool (p57e1bf418f, which is marked is_safe() and is therefore auto-approvable in "safe" mode) and the file-reading/attachment tools that call file::create_from_any_url() / download_file_content().

These all correctly use Moodle's sanctioned HTTP layer (\core\http_client, download_file_content), which routes every request through curl_security_helper::url_is_blocked() and applies the site's proxy settings. The plugin never sets ignoresecurity and never disables TLS verification, so this is the intended, correct approach — it is not a raw-client SSRF.

The residual consideration is purely at the site-configuration layer: Moodle ships with an empty curlsecurityblockedhosts by default, so a site that has not configured egress filtering allows these AI-directed fetches (like any other Moodle URL fetch) to reach internal or cloud-metadata endpoints. Response feedback to the attacker is limited (e.g. the PDF tool only reveals the MIME type of a non-PDF, and the draft-file importer returns sizes rather than bodies), so this is at most a blind/low-signal vector.

This is recorded as an informational, defence-in-depth note rather than a defect in the plugin.

Risk Assessment

Informational. The plugin follows the correct pattern (sanctioned wrapper, TLS intact, no ignoresecurity), so there is no plugin-side SSRF to fix. Whether an internal host is reachable depends entirely on the site's curlsecurityblockedhosts/allowed-port configuration, which governs all Moodle outbound requests, not just this plugin. Impact is further limited by the tools returning little of the fetched body. Included so operators are aware of the interaction between auto-approve, prompt injection, and core egress controls.

Context

file::fetch_external_url() issues a Guzzle request through \core\di::get(http_client::class). Core's check_request middleware instantiates \core\files\curl_security_helper by default (unless ignoresecurity is set, which the plugin never does) and blocks URLs the site has listed. The agent is reachable by any user holding tool/aiagent:usechat; the URL is chosen by the model, which can be steered by prompt content.

Identified Code
        $o5 = \tool_aiagent\local\entities\file::create_from_any_url($o2, $o1);
Suggested Fix

No code change is required in the plugin — it already uses the sanctioned client. As defence-in-depth, documentation could recommend that operators:

  • Configure Site administration → Server → HTTP security (curlsecurityblockedhosts and curlsecurityallowedport) to block internal ranges and the cloud metadata address (169.254.169.254).
  • Review whether URL-fetching tools should be marked auto-approvable (is_safe()), given that "safe" auto-approve mode executes them without a per-call confirmation.
Additional AI Notes

Reviewability. The PHP is deliberately obfuscated: local variables are machine-named ($o0, $o1, …) and most tool/override classes have opaque hash filenames (e.g. classes/local/builtin_tools/other/aaa0925812d.php) whose human-readable tool name, description and JSON schema live in a cloud-delivered artefact bundle rather than in the source. This is a legitimate choice for a commercial plugin but materially raises the cost of security auditing and of verifying that each hash-named execute() enforces the right capability. A mapping (even in comments) from hash to tool name/purpose would make future audits and incident response considerably easier.

Capability model is the right one and is applied uniformly. The design relies on each tool re-checking Moodle capabilities in the specific context at execution time (not merely the coarse discovery-time is_restricted_by_capability pre-filter). This is correct and was verified across the write-capable tool families. Maintainers should treat "every new built-in tool/override must call a validate_context + require_capability loader before any read of protected data or any write" as an invariant, ideally enforced by a unit/behat test, since a single omission would become a privilege-escalation path.

External data flow is disclosed. Conversation content and attachments are sent to the configured Moodle AI provider, and feedback snapshots (email-masked, with most tool results omitted) are sent to the LMSCloud backend over TLS. Both are declared in the Privacy API provider (add_external_location_link for aiprovider and lmscloud_feedback), which is appropriate and complete.

Licence/artefact channel is handled carefully. Downloaded artefacts are integrity-checked with hash_equals() against a SHA-256 the backend supplies, read from the zip via getFromName() without extracting to disk (no zip-slip), and the class names derived from the bundle are constrained to the plugin's own builtin_tools/overrides namespaces with is_subclass_of and instantiability checks, so a tampered bundle cannot instantiate arbitrary classes.

This review was generated by an AI system and may contain inaccuracies. Findings should be verified by a human reviewer before acting on them.

Published reviews of this plugin

2026-10-124.5.8CurrentA
2026-10-024.5.7A2026-07-164.5.1B+