MDL Shield

AI Activity Filter

local_activityfilter

Print Report
Plugin Information

A local plugin that adds an AI-powered activity search to the course activity chooser. A teacher enters a natural-language request, the plugin builds a prompt from the installed activity types (their titles and help text) plus an admin-configured system prompt, sends it to an AI backend (Moodle core AI subsystem, the third-party local_ai_manager plugin, a demo/dummy mode, or disabled), and renders a ranked list of suggested activities with reasons, hints, and usage popularity.

Privacy API
Unit Tests
Behat Tests
Reviewed:2026-07-13
199 files·21,990 lines
Grade Justification

The plugin demonstrates a strong, consistent security posture. Both external (web service) entry points — filter_activities and get_max_content_item_occurrence — correctly validate parameters, resolve and validate the course context with validate_context(), and enforce require_capability() before doing any work. All database access uses the $DB API with either fully static SQL or bound placeholders; there is no direct DB connection, no raw HTTP, and no filesystem access beyond reading a bundled fixture via __DIR__. AI requests are delegated to the Moodle core AI subsystem (core_ai\manager) or the optional local_ai_manager plugin rather than any hand-rolled HTTP client. Output is rendered through Mustache templates with proper escaping; the single unescaped placeholder ({{{activityicon}}}) only ever receives core-generated activity-chooser icon HTML keyed to an installed module, so it is not attacker-controllable. AJAX calls go through core/ajax, which supplies session and sesskey protection automatically.

No security vulnerabilities were identified. The findings are all low or informational: the Privacy API uses null_provider even though the plugin's purpose is to forward user-entered prompts to an AI service (a transparency nuance, mitigated by the subsystems owning and declaring their own data flows), a per-course-capability web service returns a site-wide aggregate count (a negligible, non-sensitive information exposure), and several backend classes carry unused use statements. Unit tests, a Privacy provider, language files, and a declared thirdpartylibs.xml are all present, reflecting a mature, well-structured codebase.

AI Summary

Overview

local_activityfilter (release 2.1.1) adds an AI-powered activity search to the course activity chooser. Teachers type a natural-language request; the plugin assembles a prompt from the installed activity types plus an admin-configured system prompt, sends it to a configurable AI backend, and renders a ranked list of suggested activities.

The code is cleanly structured around interfaces and dependency injection (via core\di and the di_configuration hook), with a pluggable backend strategy (core_ai, local_ai_manager, demo_ai, no_ai).

Security assessment

The security fundamentals are handled correctly throughout:

  • Both web services enforce access control. filter_activities and get_max_content_item_occurrence each call validate_parameters(), context_course::instance(), validate_context(), and require_capability() before performing any privileged work.
  • No SQL injection. The one dynamic query uses a bound placeholder (:activityname); the aggregate query is fully static.
  • No unsafe I/O. AI calls go through core_ai\manager / local_ai_manager; there is no raw HTTP, no direct database connection, and no schema manipulation outside the (absent, unneeded) upgrade path. The only filesystem read is a bundled JSON fixture via __DIR__.
  • Output is escaped. Mustache auto-escaping covers all AI-derived fields (reason, hint, pluginname). The single unescaped field ({{{activityicon}}}) receives only core-generated get_icon() HTML for an installed module, so it cannot carry attacker input.
  • CSRF/sesskey is handled by the core/ajax transport for both AJAX web service calls.

Findings

No security vulnerabilities were found. Three low/info findings were raised:

  1. Privacy transparencynull_provider is used although user prompts are transmitted to an external AI service. Defensible (the subsystems declare their own data flows) but worth documenting via a subsystem link.
  2. Scope mismatchget_max_content_item_occurrence is authorised per-course but returns a site-wide aggregate. The value is a single non-sensitive integer used only for UI normalisation.
  3. Unused imports — several backend classes declare use statements that are never referenced.

Third-party libraries

Two Composer libraries are bundled and declared in thirdpartylibs.xml: nlp-tools (v0.1.3, WTFPL) and voku/stop-words (v1.2.0, MIT), used for prompt/text compression (tokenisation and stop-word removal).

Conclusion

A well-engineered, security-conscious plugin. The issues are minor code-quality and compliance nuances rather than exploitable weaknesses.

Findings

complianceLow
Privacy provider declares no data while user prompts are sent to an external AI service

The plugin implements the Privacy API as a null_provider, which formally asserts that the plugin neither stores nor processes any personal data. However, the plugin's entire function is to take a user-entered prompt (which may contain personal or free-text information) and forward it to an AI backend — either the Moodle core AI subsystem or the third-party local_ai_manager plugin — which in turn transmits it to an external LLM provider and, in the core AI case, persists the prompt and the requesting user's id.

Because the plugin stores nothing in its own tables and the subsystems own and declare their own data flows, null_provider is defensible. But for a plugin whose sole purpose is to hand user input to an AI, the more transparent and accurate declaration is a metadata provider that documents the transmission to the AI subsystem (for example via link_subsystem() in get_metadata()), rather than asserting that no data is handled at all.

The plugin's own privacy reason string even reads "sends data to the ai provider", which is inconsistent with the null_provider claim.

Risk Assessment

Low risk. This is a compliance/transparency nuance, not a data-security vulnerability. The personal data that is stored (the prompt and user id) lives in core AI tables that are covered by the core AI subsystem's own privacy provider, and the external transmission to the LLM is declared by that subsystem. No data is stored or leaked improperly. The gap is purely that the plugin's own privacy declaration understates the fact that it initiates a flow of user-entered content into an AI system, which reduces transparency for site administrators completing GDPR/records-of-processing documentation.

Context

The data flow is: filter_activities::execute()ai_searcher::filter_activities()ai_searcher::build_prompt_text() (concatenates the admin system prompt, activity descriptions, and the compressed user request) → the selected ai_backend::send_request(). For the core_ai backend, core_ai\manager::process_action() runs a generate_text action, whose store() method writes the prompt and userid to the core ai_action_generate_text table and forwards the prompt to the configured external provider. The plugin itself defines no database tables and stores nothing directly.

classes/privacy/provider.php:28Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
class provider implements null_provider {
Suggested Fix

Consider implementing \core_privacy\local\metadata\provider and declaring the data handed to the AI subsystem, e.g.:

use core_privacy\local\metadata\collection;
use core_privacy\local\metadata\provider as metadata_provider;

class provider implements metadata_provider {
    public static function get_metadata(collection $collection): collection {
        $collection->link_subsystem(
            'core_ai',
            'privacy:metadata:core_ai'
        );
        return $collection;
    }
}

If the maintainers prefer to keep null_provider, that is acceptable given the subsystems own the data — but the reason string should not claim data is sent while the interface claims none is.

best practiceLow
get_max_content_item_occurrence is authorised per-course but returns a site-wide aggregate

The get_max_content_item_occurrence web service enforces its capability at course context (CONTEXT_COURSE), but the SQL it runs aggregates module usage across the entire site — it counts every course_modules row grouped by module name across all courses and returns the maximum. A teacher authorised in a single course therefore receives a figure derived from all courses on the platform.

The returned value is a single integer used only to normalise the "popularity" indicator shown in the results modal (it maps each activity type's global usage count onto a five-level rarity scale). It contains no identifying or course-specific information.

The scope is internally consistent with the plugin's design — content_item_info::get_usage_amount() is also site-wide — so this is a deliberate global-popularity feature rather than a bug. It is flagged only because granting a per-course capability access to a site-wide aggregate is a minor authorisation-scope mismatch worth acknowledging.

Risk Assessment

Low risk. The only information disclosed is the maximum instance count of the single most-used activity type across the whole site — a coarse, non-sensitive aggregate with no user, course, or content identifiers, and one a teacher could largely infer anyway. There is no realistic exploit or harm; the finding is a design-consistency/least-privilege observation rather than a meaningful data exposure.

Context

The service is called from repository.js (fetchMaxContentItemOccurrence) whenever the results list is rendered, purely to compute the divisor for the getOccurranceString() rarity buckets in content_item_ranking.js. Access requires the local/activityfilter:get_max_content_item_occurrence capability, granted to teacher/editingteacher archetypes, and a valid course context.

classes/external/get_max_content_item_occurrence.php:51Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
        $db = di::get(moodle_database::class);
        $record = $db->get_record_sql(
            'SELECT MAX(module_count_table.mc) AS maxcount
                 FROM (
                    SELECT COUNT(*) AS mc
                    FROM {course_modules} cm
                    JOIN {modules} m ON m.id = cm.module
                    GROUP BY m.name
                ) module_count_table'
        );
Suggested Fix

If a site-wide popularity signal is intended, this is acceptable — consider documenting that intent. If the popularity indicator should reflect the current course, scope the count to the course, e.g. add WHERE cm.course = :courseid (and pass the validated courseid), keeping the same rarity-normalisation logic on the client.

code qualityLow
Unused use (import) statements in AI backend classes

Several backend classes declare use statements for classes that are never referenced in the file body (dead imports). This is a minor code-cleanliness issue: it adds noise, can mislead readers about a class's actual dependencies, and is the kind of violation the Moodle code checker configured in this plugin's own CI (phpcs ... --max-warnings 0) is intended to keep at zero.

Unused imports identified:

  • ai_backend.phpcontext, core\exception\moodle_exception, moodle_url
  • demo_ai.phpmoodle_url, core\context
  • no_ai.phpcore\context, moodle_url
  • core_ai.phpcore\context, core_privacy\local\sitepolicy\manager as policy_manager, moodle_url
Risk Assessment

Low risk. Purely a code-quality/coding-standard matter with no functional or security impact. Worth cleaning up to keep the plugin's own CI code-checker green and to accurately reflect each class's dependencies.

Context

These are the strategy implementations of the ai_backend interface. The imports appear to be copy-paste leftovers (e.g. moodle_url and context recur across all four files even where unrelated to the class's logic). None affect runtime behaviour.

classes/activity_searcher/backend/ai_backend.php:19Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
use context;
use core\exception\moodle_exception;
use Exception;
use moodle_url;
Suggested Fix

Remove the unused imports, keeping only Exception (referenced in the @throws docblock):

use Exception;
classes/activity_searcher/backend/demo_ai.php:19Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
use moodle_url;
use core\context;
Suggested Fix

Remove both lines — neither moodle_url nor context is used in the class.

classes/activity_searcher/backend/no_ai.php:19Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
use core\context;
use moodle_url;
use RuntimeException;
Suggested Fix

Remove the unused imports, keeping only RuntimeException:

use RuntimeException;
classes/activity_searcher/backend/core_ai.php:19Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
use core\context;
use core_ai\aiactions\generate_text;
use core_privacy\local\sitepolicy\manager as policy_manager;
use core_ai\manager;
use Exception;
use moodle_url;
Suggested Fix

Remove context, policy_manager, and moodle_url (unused), keeping the referenced imports:

use core_ai\aiactions\generate_text;
use core_ai\manager;
use Exception;
Third-Party Libraries (2)
LibraryVersionLicenseDeclared
nlp-tools
Natural-language processing utilities. The plugin uses the PennTreeBank tokenizer and stop-word transformation to compress the user prompt and activity descriptions before sending them to the AI backend.
0.1.3WTFPL
stop-words
Provides multilingual stop-word lists (English and German are used) consumed by the text compressor to strip filler words from text prior to the AI request.
1.2.0MIT
Additional AI Notes

README documents a non-existent web service. The README.md "Web Services" table lists local_activityfilter_prepare_results, but db/services.php actually registers local_activityfilter_filter_activities and local_activityfilter_get_max_content_item_occurrence. Update the documentation to match the implemented functions.

Composer autoloader scaffolding is bundled but not declared. thirdpartylibs.xml correctly declares the two libraries (vendor/nlp-tools/nlp-tools, vendor/voku/stop-words), but the generated Composer runtime files (vendor/autoload.php, vendor/composer/*) are third-party code that sits outside the declared library directories. This is a very minor completeness point; the loader is standard Composer glue loaded via require_once(__DIR__ . '/../../vendor/autoload.php') in stopword_remover.php.

filter_activities is declared type => 'read' but the core AI backend performs a write. When the core_ai backend is active, generate_text::store() persists a row to the core ai_action_generate_text table, so the call is not strictly read-only. In practice $DB routes writes to the primary regardless of the declared type, so there is no functional problem — but 'write' would more accurately describe the side effect.

Service location inside a value object. content_item_info resolves moodle_database and i_text_compressor from the DI container in its constructor (di::get(...)). Injecting these dependencies explicitly instead would decouple the data object from global container state and simplify testing. Architectural observation only.

Strengths worth noting. The plugin is a good example of defensive design: interface-driven backends with a clean strategy pattern, consistent capability + context validation on every external entry point, parameterised/static SQL, escaped template output, delegation to the core AI subsystem instead of custom HTTP, a Privacy provider, PHPUnit tests with resetAfterTest(), and a full moodle-plugin-ci pipeline covering phpcs, phpdoc, mustache lint, grunt, and Behat.

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