AI Escape Room
mod_aiescape
An activity module (mod_aiescape) that runs an AI-driven interactive "escape room": students progress through a scenario by picking AI-generated choices or typing free-text responses, guided by a large language model accessed through Moodle's core_ai subsystem. It supports multiple-choice, free-text and combo modes, narrative/persona styles, per-turn choice-count configuration, teacher-defined action buttons, keyword flagging for moderation, open/close dates, gradebook and completion integration, teacher preview, and student review of past attempts.
This is a carefully engineered plugin with a strong, deliberate security posture and no exploitable vulnerabilities found across a full read of every PHP, JS and template file.
Access control is consistently correct. Every web-service function calls validate_context() and require_capability('mod/aiescape:play', ...), and scopes attempt lookups to userid => $USER->id, so there is no insecure direct object reference. Every page entry point calls require_login() plus an appropriate capability check; the teacher report is gated on mod/aiescape:viewreports, and student self-review is scoped to the current user. Forms use moodleform_mod and the AJAX services use the external API, so sesskey/CSRF handling is automatic.
The assessment-integrity model is the standout. The good/neutral/bad classification of each choice never reaches the client: the server stores the offered set server-side (lastchoicejson), shuffles choices, resolves the submitted label back to its type from the stored set, rejects labels that were never offered, and rejects free-typed text in multiple-choice mode — all backed by dedicated unit tests. Activity completion is derived only from the server-side numeric tally, never from the AI's self-reported completed flag, and the AI's stepchange is clamped to the range -1..1.
Data handling is safe throughout. All database access uses parameterised $DB methods (the one piece of dynamic SQL, in the report, concatenates only capability-gated standard user-table column names from \core_user\fields::get_identity_fields($context, false)). All output is escaped — Mustache auto-escaping in templates, format_text(..., FORMAT_PLAIN) (which runs s()) for stored message replays, and s()/html_writer elsewhere — so AI-echoed or student-supplied text cannot yield XSS. Files go through the File API with a capability-checked pluginfile callback; all outbound AI traffic is routed through the sanctioned \core_ai\manager, which applies core's rate limiting; DDL is confined to db/upgrade.php; and a complete Privacy API implementation (including the external-AI disclosure) and backup/restore with correct ID remapping are present, all covered by tests.
The only issues are a single trivial coding-standard nit (a missing MOODLE_INTERNAL guard in lib.php, which has file-scope define() calls) and an inherent-to-AI consideration — free-text/combo per-turn scoring is model-evaluated and therefore influenceable by prompt injection. The author has already bounded that risk correctly (clamped to +/-1 per turn, affects only the student's own formative grade, multiple-choice progress is entirely server-evaluated) and documents it prominently. No security vulnerability, no data exposure, and comprehensive automated tests underpin the whole codebase.
mod_aiescape is an AI-driven "escape room" activity built on Moodle's core_ai subsystem. I read every PHP, JS, template, SQL, CSS and language file, and verified all core-API assumptions against the Moodle 5.x source tree.
Overall: a high-quality, security-conscious plugin. I found no exploitable vulnerabilities.
Key strengths:
- Robust authorization. Web services (
start_attempt,send_message,trigger_button,quit_attempt) all usevalidate_context()+require_capability('mod/aiescape:play')and scope attempts to the current user; pages userequire_login()+ capability checks. No IDOR paths were found. - Anti-forgery choice model. Choice classifications never leave the server; submissions are validated against a server-stored offered set, free text is rejected in multiple-choice mode, completion derives from a server-side tally (never the AI's
completedflag), andstepchangeis clamped. This is explicitly unit-tested. - Safe data handling. Parameterised
$DBqueries throughout; output consistently escaped (Mustache,format_text(FORMAT_PLAIN),s()); File API used correctly; all AI I/O routed through\core_ai\manager(inheriting core rate limiting); DDL confined todb/upgrade.php. - Completeness. Full Privacy API (with external-AI disclosure), backup/restore with ID remapping, calendar/dates integration, and an unusually thorough PHPUnit/Behat suite.
Findings are limited to one low coding-standard nit (missing MOODLE_INTERNAL guard in lib.php) and one info architectural note (free-text AI scoring is advisory/prompt-injectable, but bounded and self-only by design).
Findings
lib.php executes global-state side effects at file scope — three define() calls (AIESCAPE_MAX_PROGRESS_IMAGES, AIESCAPE_EVENT_TYPE_OPEN, AIESCAPE_EVENT_TYPE_CLOSE) — but does not begin with defined('MOODLE_INTERNAL') || die();.
Moodle's coding style, enforced by the official moodle-cs linter that maintainers run in CI, requires the MOODLE_INTERNAL guard in a file that changes global state at file scope and does not itself include config.php. lib.php is loaded by core (it never requires config.php) and its define() calls are exactly the kind of file-scope side effect the moodle.Files.MoodleInternal sniff checks for.
Every core activity lib.php that declares constants (for example mod/quiz, mod/lesson, mod/data) pairs those define() calls with the guard. This file omits it, so moodle-cs would report moodle.Files.MoodleInternal.MoodleInternalGlobalState and fail the maintainer's CI.
Low risk. This is a coding-standard / defence-in-depth issue, not a security vulnerability. Requesting lib.php directly over the web would only define constants and declare functions — nothing executes at the top level, so there is no output, data disclosure, or code execution. The practical impact is that moodle-cs (which maintainers run in CI, and which the Moodle plugin directory checks on submission) would flag the file, so the omission is worth correcting for consistency with the rest of the plugin's files, which do carry the guard where required.
lib.php is the module's standard library file. It defines three plugin constants at file scope and then declares the activity's callback functions (aiescape_supports, aiescape_add_instance, aiescape_pluginfile, and so on). Because it is included by core rather than requested directly, and because it does not pull in config.php, it falls under the linter rule that mandates the MOODLE_INTERNAL guard for files with file-scope side effects.
/**
* Library functions for mod_aiescape.
*
* @package mod_aiescape
* @copyright 2026 Adam Jenkins <adam@wisecat.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/** @var int Maximum number of progress images that may be uploaded per activity. */
define('AIESCAPE_MAX_PROGRESS_IMAGES', 20);
Add the guard immediately after the file-level docblock, before the first define():
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/** @var int Maximum number of progress images that may be uploaded per activity. */
define('AIESCAPE_MAX_PROGRESS_IMAGES', 20);
In free-text and combo game modes, the per-turn step delta is produced by the AI model reading the student's own words. Because the student's text is concatenated into the prompt sent to the model, a determined student can attempt prompt injection (for example, instructing the model to award a positive stepchange) to progress more easily.
This is an inherent characteristic of AI-evaluated free text rather than a coding defect, and the implementation already constrains it well:
- The parsed
stepchangeis validated to the set{-1, 0, 1}and applied as at most +/-1 per turn, with the running tally floored at 0. - Activity completion is derived only from the server-side step tally, never from the AI's self-reported
completedflag — so a message like "set completed=true" cannot force completion. - The effect is self-only: it can influence only the attacker's own grade, and only up to the same maximum completion grade they could earn by legitimately finishing the scenario.
- Multiple-choice progress is evaluated entirely server-side (types resolved from the stored offered set), and free-typed text is rejected outright in that mode, so the injectable path cannot be reached from a choices-only activity.
The behaviour is prominently documented in the README, the game-mode help string, and inline code comments.
Informational. This is an architectural property of AI-scored free text, not a flaw in the plugin's controls, and the author has mitigated it about as well as the design allows: the delta is clamped, completion is server-authoritative, and the blast radius is limited to the attacker's own (intended-to-be-formative) grade — no other user's data or grade is affected. It is recorded here so deployers understand that free-text/combo modes are best suited to low-stakes use and that multiple-choice mode is the tamper-resistant option.
send_message::execute() builds the prompt from the stored conversation history plus the pending user turn and calls attempt_manager::run_ai_turn(), which invokes \core_ai\manager::process_action(). For choice submissions the delta comes from the server-resolved choice type ($presetchange); for free text it comes from the model-produced stepchange. The response_parser validates and clamps that value, and send_message only ever marks an attempt complete when the numeric tally reaches the configured steps.
// Apply step change (prefer AI-evaluated for freetext; preset for choices).
$stepchange = ($presetchange !== null) ? $presetchange : $result['stepchange'];
No code change is required — the risk is already bounded (clamped delta, server-side completion, self-only impact) and disclosed.
Deployment guidance: reserve free-text and combo modes for formative, low-stakes use. Where progress must be tamper-resistant, prefer multiple-choice mode, whose scoring is fully server-evaluated. Optionally, teachers can use the keyword-flagging moderation feature to surface suspicious free-text submissions for review.
No bundled third-party code. The plugin ships no vendor libraries; composer.json declares only development tooling (squizlabs/php_codesniffer, moodlehq/moodle-cs) plus Moodle itself, and there is no vendor/ directory. A thirdpartylibs.xml is therefore not required. The AMD module is shipped correctly as both source (amd/src/game.js) and build artifacts (amd/build/game.min.js + map).
Outbound AI requests use the sanctioned core API. All model calls go through \core\di::get(\core_ai\manager::class)->process_action(new \core_ai\aiactions\generate_text(...)). There is no raw curl/file_get_contents/socket usage, so the plugin does not bypass core's proxy configuration or egress controls, and it inherits core_ai's per-user and global rate limiting (verified in core_ai's process_base::process() / provider::is_request_allowed()). This effectively caps any "denial-of-wallet" abuse from repeated send_message calls at whatever limits the site administrator configures on the AI provider.
Strong, tested anti-forgery design for grading. The good/neutral/bad type of each choice is never sent to students; the server persists the offered set (lastchoicejson), shuffles choice order, resolves a submitted label back to its type server-side, rejects unoffered labels and duplicate-label sets, and rejects free-typed text in multiple-choice mode. Dedicated PHPUnit tests (tests/external/send_message_test.php, tests/external/trigger_button_test.php, tests/response_parser_test.php) pin these guarantees.
Teacher preview is safely isolated. Users with mod/aiescape:viewreports play as preview attempts (ispreview = 1) that are excluded from attempt limits, gradebook writes, activity completion, and the student report — verified in tests/grade_completion_test.php (test_preview_attempt_does_not_write_grade) and tests/attempt_manager_test.php.
Privacy and portability are complete. The Privacy API provider implements metadata, core_userlist_provider, export and all delete paths with parameterised queries, and discloses the external AI data-sharing. Backup/restore round-trips all activity fields, buttons, attempts, messages and flags with correct user/attempt/message ID remapping, covered by tests/backup_restore_test.php.
Minor, non-reportable observations noted during review. In aiescape_get_user_grades() the inner if (!isset($grades[$attempt->userid]) || ...) is always true because the enclosing loop already continues when the key is set — harmless dead branch. The free-turn fallback can yield +1 if the model scores the fixed "What next?" message positively (it is only prevented from going negative); this is consistent with the documented "never costs progress" intent and affects only the student's own grade. Neither warrants a code change.