Fixed a security issue in how AI Studio runs plugins (#1054)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-12 14:54:30 +02:00
1 parent 57dda9ff8b
commit b371b3d54f
6 files changed
+370 -16

No files matched your search

@@ -79,6 +79,8 @@ Each assistant plugin lives in its own directory under the assistants plugin roo
└── plugin.lua
```
A plugin may split its Lua code into several files and load them with `require`. Name the file relative to the plugin directory and without `.lua`, and separate subdirectories with a slash: `require("icon")` loads `icon.lua`, and `require("lib/helpers")` loads `lib/helpers.lua`. A plugin can only load files from its own directory, and `dofile` and `loadfile` are not available.
## Structure
- `ASSISTANT` is the root table. Every assistant requires `Title` and `Description`.
- Form assistants additionally require `SystemPrompt`, `SubmitText`, `AllowProfiles`, and a nested `UI` definition.
@@ -4,6 +4,23 @@ These instructions cover the plugin system in `app/MindWork AI Studio/Tools/Plug
configuration plugins can provide. They add to the `AGENTS.md` in the repository root, which applies here
as well.
## Lua runtime
Every plugin runs in a Lua runtime of its own, created by `PluginFactory.CreateLuaState`, whatever its type
and whoever runs it: loading, starting, checking an import, and the assistant builder.
- A plugin loads the files of its own directory with `require`, through `PluginLoader`, also from
subdirectories: `require("lib/helpers")` loads `lib/helpers.lua`. The dot notation `require("lib.helpers")`
is not supported.
- No other file is within reach. The runtime gets `NoLuaFileSystem` as its file system, `dofile` and
`loadfile` are removed, and code without a directory gets `NoModuleLoader`. With the default file system
of Lua-CSharp, `require` would look for a module it does not find next to the plugin along `package.path`,
relative to the working directory of the app, and `dofile` and `loadfile` would open any path.
- Do not open the `io`, `os`, or `debug` library, and do not give the runtime a file system. Whatever a
plugin needs to reach outside of Lua comes as a host function which checks every access.
- `PluginModuleLoadingTests` checks both sides: what plugins must load, and what they must not reach. Run
it after every change to the runtime, the module loader, or the opened libraries.
## Configuration plugins
Plugins can configure:
@@ -0,0 +1,35 @@
using Lua.IO;
namespace AIStudio.Tools.PluginSystem;
/// <summary>
/// The file system of the Lua runtime of plugins, in which no file exists.
/// </summary>
/// <remarks>
/// The Lua runtime reaches files through its file system: dofile and loadfile do, and so does require
/// for every module the module loader does not know, which it then looks for along package.path,
/// relative to the working directory of the app. Plugins are shared between users, and the assistant
/// builder runs code a model wrote, so none of them may reach a file this way.<br/><br/>
/// A plugin still loads the files of its own directory with require: the module loader, see
/// PluginLoader, reads them without this file system.
/// </remarks>
public sealed class NoLuaFileSystem : ILuaFileSystem
{
private const string NO_FILES = "Plugins cannot reach files through the Lua runtime.";
#region Implementation of ILuaFileSystem
public bool IsReadable(string path) => false;
public ValueTask<ILuaStream> Open(string path, LuaFileOpenMode mode, CancellationToken cancellationToken) => throw new NotSupportedException(NO_FILES);
public ValueTask Rename(string oldName, string newName, CancellationToken cancellationToken) => throw new NotSupportedException(NO_FILES);
public ValueTask Remove(string path, CancellationToken cancellationToken) => throw new NotSupportedException(NO_FILES);
public string GetTempFileName() => throw new NotSupportedException(NO_FILES);
public ValueTask<ILuaStream> OpenTempFileStream(CancellationToken cancellationToken) => throw new NotSupportedException(NO_FILES);
#endregion
}
@@ -5,6 +5,7 @@ using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem.Assistants;
using Lua;
using Lua.Platforms;
using Lua.Standard;
namespace AIStudio.Tools.PluginSystem;
@@ -26,6 +27,11 @@ public static partial class PluginFactory
/// </remarks>
private static readonly TimeSpan PLUGIN_TIMEOUT = TimeSpan.FromSeconds(10);
/// <summary>
/// The platform of the Lua runtime of every plugin: the default one, but without any file.
/// </summary>
private static readonly LuaPlatform PLUGIN_PLATFORM = LuaPlatform.Default with { FileSystem = new NoLuaFileSystem() };
/// <summary>
/// A list of all available plugins.
/// </summary>
@@ -419,6 +425,49 @@ public static partial class PluginFactory
return pluginTimeout;
}
/// <summary>
/// Creates the Lua runtime a plugin runs in, with its libraries opened and no code run yet.
/// </summary>
/// <remarks>
/// Every plugin, whatever its type, gets its runtime from here, so all of them see the same
/// libraries and the same limits.<br/><br/>
/// A plugin may split its code into several files and load them with require, also from
/// subdirectories of its own directory, such as require("lib/helpers"). The module loader only
/// finds files inside the plugin's directory.<br/><br/>
/// No other file is within reach: the runtime has no file system, see NoLuaFileSystem, and neither
/// dofile nor loadfile. Code without a directory, such as a plugin a model just wrote, loads no
/// module at all.
/// </remarks>
/// <param name="pluginPath">The directory the plugin is located in, or null when the code has no directory yet.</param>
/// <param name="allowedBaseDirectory">The directory the plugin path must be nested in, see Load.</param>
/// <returns>The runtime for a single plugin.</returns>
internal static LuaState CreateLuaState(string? pluginPath, string? allowedBaseDirectory = null)
{
var state = LuaState.Create(PLUGIN_PLATFORM);
state.ModuleLoader = string.IsNullOrWhiteSpace(pluginPath)
? new NoModuleLoader()
: new PluginLoader(pluginPath, allowedBaseDirectory);
// Add some useful libraries:
state.OpenBasicLibrary();
state.OpenModuleLibrary();
state.OpenStringLibrary();
state.OpenTableLibrary();
state.OpenMathLibrary();
state.OpenBitwiseLibrary();
state.OpenCoroutineLibrary();
//
// Both read a file by its path, relative to the working directory of the app. Without a file
// system they would fail anyway, but with an exception of .NET instead of a Lua error. Such an
// exception leaves Load, instead of becoming the reason why the plugin could not run:
//
state.Environment["dofile"] = LuaValue.Nil;
state.Environment["loadfile"] = LuaValue.Nil;
return state;
}
/// <param name="pluginPath">The directory the plugin is located in, or null when the code has no directory yet.</param>
/// <param name="code">The Lua code of the plugin's main file.</param>
/// <param name="allowedBaseDirectory">
@@ -432,22 +481,7 @@ public static partial class PluginFactory
if(ForbiddenPlugins.Check(code) is { IsForbidden: true } forbiddenState)
return new NoPlugin($"This plugin is forbidden: {forbiddenState.Message}");
var state = LuaState.Create();
if (!string.IsNullOrWhiteSpace(pluginPath))
{
// Add the module loader so that the plugin can load other Lua modules:
state.ModuleLoader = new PluginLoader(pluginPath, allowedBaseDirectory);
}
// Add some useful libraries:
state.OpenBasicLibrary();
state.OpenModuleLibrary();
state.OpenStringLibrary();
state.OpenTableLibrary();
state.OpenMathLibrary();
state.OpenBitwiseLibrary();
state.OpenCoroutineLibrary();
var state = CreateLuaState(pluginPath, allowedBaseDirectory);
try
{
await state.DoStringAsync(code, cancellationToken: cancellationToken);
@@ -35,6 +35,7 @@
- Fixed the log file of AI Studio showing the wrong time for some entries when AI Studio was busy. Each entry now shows when it actually happened.
- Fixed AI Studio pausing for a moment while it opened the local index of a data source, such as a mailbox. Other work, for example applying the configuration of your organization, had to wait until then.
- Fixed a security issue in how AI Studio prepares images for the Visual Briefing assistant. AI Studio now protects all parts of its internal connection in one central place, and an automated test checks that no part is left out.
- Fixed a security issue in how AI Studio runs plugins. A plugin can now only load files from its own folder, and automated tests check this.
- Fixed the transcripts of your audio and video files disappearing when you edited your last message in a chat. You no longer need to attach the files again. Thanks, Bernhard, for reporting this issue.
- Fixed the message you had not sent yet losing its text or the transcripts of your audio and video files when you moved the chat to a workspace.
- Fixed the workspace list no longer showing the chats of your workspaces after you moved a chat to another workspace.
@@ -0,0 +1,265 @@
using AIStudio.Tools.PluginSystem;
using Lua;
using Lua.Standard;
namespace AIStudio.Tests.Tools.PluginSystem;
/// <summary>
/// Checks that a plugin can split its code into several Lua files and load them with require, and that
/// it reaches no other file.
/// </summary>
/// <remarks>
/// Long plugins keep parts of their code in files of their own: the plugins AI Studio ships keep their
/// icon in icon.lua, and larger plugins arrange their modules in subdirectories. Whatever the sandbox
/// of the plugins blocks, loading these files from the plugin's own directory has to keep working.<br/><br/>
/// Every other file stays out of reach: the Lua runtime would otherwise look for a module it does not
/// find next to the plugin in the working directory of the app, and dofile and loadfile would open any
/// path. Plugins are shared between users, and code written by a model runs in the same runtime.
/// </remarks>
[TestFixture]
public sealed class PluginModuleLoadingTests
{
private string testDirectory = string.Empty;
private string pluginDirectory = string.Empty;
[SetUp]
public void CreatePluginDirectory()
{
//
// The module loader only works inside a base directory, which is the plugins directory of the
// app for an installed plugin. The plugin therefore gets a base directory of its own here:
//
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-plugin-modules-{Guid.NewGuid():N}");
this.pluginDirectory = Path.Combine(this.testDirectory, "plugin");
Directory.CreateDirectory(this.pluginDirectory);
}
[TearDown]
public void RemovePluginDirectory()
{
try
{
Directory.Delete(this.testDirectory, true);
}
catch (IOException)
{
// A temporary directory we could not remove says nothing about the code under test.
}
}
[Test]
public async Task AModuleNextToThePluginLoads()
{
this.WriteModule("helpers.lua", "return { answer = 42 }");
var answer = await this.RunAsync("""return require("helpers").answer""");
Assert.That(answer.Read<double>(), Is.EqualTo(42));
}
[Test]
public async Task AModuleInASubdirectoryLoads()
{
this.WriteModule("lib/helpers.lua", "return { answer = 42 }");
var answer = await this.RunAsync("""return require("lib/helpers").answer""");
Assert.That(answer.Read<double>(), Is.EqualTo(42));
}
[Test]
public async Task AModuleTwoSubdirectoriesDownLoads()
{
this.WriteModule("lib/deep/more.lua", "return { answer = 42 }");
var answer = await this.RunAsync("""return require("lib/deep/more").answer""");
Assert.That(answer.Read<double>(), Is.EqualTo(42));
}
[Test]
public async Task AModuleLoadsAnotherModule()
{
this.WriteModule("lib/outer.lua", """
local inner = require("lib/inner")
return { answer = inner.answer + 1 }
""");
this.WriteModule("lib/inner.lua", "return { answer = 41 }");
var answer = await this.RunAsync("""return require("lib/outer").answer""");
Assert.That(answer.Read<double>(), Is.EqualTo(42));
}
[Test]
public async Task TheGlobalsOfAModuleAreVisibleToThePlugin()
{
//
// This is how the shipped plugins use their icon.lua: it returns nothing and sets a global.
//
this.WriteModule("icon.lua", """SVG = "<svg/>" """);
var icon = await this.RunAsync("""
require("icon")
return SVG
""");
Assert.That(icon.Read<string>(), Is.EqualTo("<svg/>"));
}
[TestCaseSource(nameof(ShippedPluginsWithAnIconFile))]
public async Task AShippedPluginLoadsItsIconFromItsOwnFile(string relativeDirectory)
{
//
// Only the code of the plugin runs here, not the initialization of its type: a configuration
// plugin, for instance, needs the services of the running app for that. What counts is
// that the icon arrives from icon.lua in the runtime every plugin gets.
//
var directory = Path.Combine(AppContext.BaseDirectory, relativeDirectory);
using var state = PluginFactory.CreateLuaState(directory, directory);
await state.DoStringAsync(await File.ReadAllTextAsync(Path.Combine(directory, "plugin.lua")));
Assert.That(state.Environment["ICON_SVG"].Read<string>(), Is.EqualTo(await ReadIconAsync(directory)), "The plugin did not get its icon from its icon.lua.");
}
[Test]
public void AModuleOutsideThePluginDirectoryIsNotLoaded()
{
this.WriteModuleOutsideThePlugin();
Assert.That(async () => await this.RunAsync("""return require("../other/secret").answer"""), Throws.InstanceOf<LuaRuntimeException>());
}
[Test]
public void AModuleNamedByItsAbsolutePathIsNotLoaded()
{
var path = Path.ChangeExtension(this.WriteModuleOutsideThePlugin(), null);
Assert.That(async () => await this.RunAsync($"return require([[{path}]]).answer"), Throws.InstanceOf<LuaRuntimeException>());
}
[Test]
public void AModuleInTheWorkingDirectoryIsNotLoaded()
{
var moduleName = $"ai-studio-module-probe-{Guid.NewGuid():N}";
var path = Path.Combine(Environment.CurrentDirectory, $"{moduleName}.lua");
File.WriteAllText(path, "return { answer = 42 }");
try
{
Assert.That(async () => await this.RunAsync($"""return require("{moduleName}").answer"""), Throws.InstanceOf<LuaRuntimeException>(), "A plugin with a directory found a module in the working directory.");
Assert.That(async () => await RunWithoutPluginDirectoryAsync($"""return require("{moduleName}").answer"""), Throws.InstanceOf<LuaRuntimeException>(), "Code without a directory found a module in the working directory.");
}
finally
{
File.Delete(path);
}
}
[Test]
public void APluginCannotRedirectTheSearchForModules()
{
var directory = Path.GetDirectoryName(this.WriteModuleOutsideThePlugin());
Assert.That(async () => await this.RunAsync($"""
package.path = [[{directory}/?.lua]]
return require("secret").answer
"""), Throws.InstanceOf<LuaRuntimeException>());
}
[Test]
public async Task APluginCannotProbeForFiles()
{
var directory = Path.GetDirectoryName(this.WriteModuleOutsideThePlugin());
var probe = $"""return package.searchpath("secret", [[{directory}/?.lua]], ".", "/")""";
//
// A runtime with the default file system finds the file. Without this counter check, a call
// which fails for another reason would pass this test without proving anything:
//
using var defaultState = LuaState.Create();
defaultState.OpenModuleLibrary();
var foundByDefault = await defaultState.DoStringAsync(probe);
Assert.That(foundByDefault[0].Type, Is.EqualTo(LuaValueType.String), "The probe itself is broken.");
var found = await this.RunAsync(probe);
Assert.That(found.Type, Is.EqualTo(LuaValueType.Nil), "The plugin learned that a file outside its directory exists.");
}
[TestCase("dofile", true)]
[TestCase("dofile", false)]
[TestCase("loadfile", true)]
[TestCase("loadfile", false)]
public void AFileCannotBeRunByItsPath(string function, bool insideThePluginDirectory)
{
//
// Even inside the plugin's directory, since such a path would be read relative to the working
// directory of the app, not to the plugin. Plugins load their own files with require instead:
//
this.WriteModule("helpers.lua", "return { answer = 42 }");
var path = insideThePluginDirectory ? Path.Combine(this.pluginDirectory, "helpers.lua") : this.WriteModuleOutsideThePlugin();
Assert.That(async () => await this.RunAsync($"local result = {function}([[{path}]])"), Throws.InstanceOf<LuaRuntimeException>());
}
/// <summary>
/// The plugins shipped with AI Studio which keep their icon in an icon.lua next to their plugin.lua.
/// </summary>
private static IEnumerable<string> ShippedPluginsWithAnIconFile() =>
Directory.EnumerateFiles(Path.Combine(AppContext.BaseDirectory, "Plugins"), "icon.lua", SearchOption.AllDirectories)
.Select(iconFile => Path.GetDirectoryName(iconFile)!)
.Where(directory => File.Exists(Path.Combine(directory, "plugin.lua")))
.Select(directory => Path.GetRelativePath(AppContext.BaseDirectory, directory))
.Order(StringComparer.Ordinal);
/// <summary>
/// Reads the icon of an icon.lua on its own, without the plugin around it.
/// </summary>
private static async Task<string> ReadIconAsync(string directory)
{
using var state = LuaState.Create();
await state.DoStringAsync(await File.ReadAllTextAsync(Path.Combine(directory, "icon.lua")));
return state.Environment["SVG"].Read<string>();
}
private void WriteModule(string relativePath, string code)
{
var path = Path.Combine(this.pluginDirectory, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, code);
}
/// <summary>
/// Writes a module next to the plugin's directory, inside the same base directory.
/// </summary>
/// <returns>The path of the module, with forward slashes, so it can stand in Lua code on every system.</returns>
private string WriteModuleOutsideThePlugin()
{
var path = Path.Combine(this.testDirectory, "other", "secret.lua");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, "return { answer = 42 }");
return path.Replace('\\', '/');
}
/// <summary>
/// Runs Lua code in the same runtime a plugin from the plugin directory runs in.
/// </summary>
private async Task<LuaValue> RunAsync(string code)
{
using var state = PluginFactory.CreateLuaState(this.pluginDirectory, this.testDirectory);
var results = await state.DoStringAsync(code);
return results.Length > 0 ? results[0] : LuaValue.Nil;
}
/// <summary>
/// Runs Lua code in the runtime of a plugin which has no directory, such as one a model just wrote.
/// </summary>
private static async Task<LuaValue> RunWithoutPluginDirectoryAsync(string code)
{
using var state = PluginFactory.CreateLuaState(null);
var results = await state.DoStringAsync(code);
return results.Length > 0 ? results[0] : LuaValue.Nil;
}
}