Added the ability to mark an assistant plugin as secure based on the Enterprise IT

This commit is contained in:
Peer Schütt 2026-07-02 20:56:41 +02:00
parent c41a0cf74c
commit 687cf9119a
20 changed files with 595 additions and 55 deletions

View File

@ -0,0 +1,47 @@
using SharedTools;
namespace Build.Commands;
public sealed class AssistantPluginHashCommand
{
[Command("assistant-plugin-hash", Description = "Compute the canonical assistant-plugin hash for a plugin directory")]
public void ComputeAssistantPluginHash(
[Argument(Description = "Path to the assistant plugin directory")] string pluginDir,
[Option("lua-snippet", Description = "Also print a Lua snippet for CONFIG[\"SETTINGS\"]")] bool luaSnippet = false)
{
if (!Environment.IsWorkingDirectoryValid())
return;
var resolvedPath = Path.GetFullPath(pluginDir, Directory.GetCurrentDirectory());
if (!Directory.Exists(resolvedPath))
{
Console.WriteLine($"- Error: The plugin directory '{resolvedPath}' does not exist.");
return;
}
var pluginHash = AssistantPluginHash.Compute(resolvedPath);
if (string.IsNullOrWhiteSpace(pluginHash))
{
Console.WriteLine($"- Error: No Lua files were found in '{resolvedPath}'.");
return;
}
Console.WriteLine(pluginHash);
if (!luaSnippet)
return;
var displayName = Path.GetFileName(resolvedPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
var approvedAtUtc = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
Console.WriteLine();
Console.WriteLine("""CONFIG["SETTINGS"]["DataAssistantPluginAudit.EnterpriseApprovedPlugins"] = {""");
Console.WriteLine(" {");
Console.WriteLine($""" ["PluginHash"] = "{pluginHash}",""");
Console.WriteLine($""" ["DisplayName"] = "{displayName}",""");
Console.WriteLine(""" ["Comment"] = "<optional comment>",""");
Console.WriteLine(""" ["ApprovedBy"] = "<optional approver>",""");
Console.WriteLine($""" ["ApprovedAtUtc"] = "{approvedAtUtc}",""");
Console.WriteLine(" }");
Console.WriteLine("}");
}
}

View File

@ -6,4 +6,5 @@ app.AddCommands<CheckRidsCommand>();
app.AddCommands<UpdateMetadataCommands>();
app.AddCommands<UpdateWebAssetsCommand>();
app.AddCommands<CollectI18NKeysCommand>();
app.Run();
app.AddCommands<AssistantPluginHashCommand>();
app.Run();

View File

@ -1984,6 +1984,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Open Set
-- Show or hide the detailed security information.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1045105126"] = "Show or hide the detailed security information."
-- This plugin is approved by your organization. A manual security audit is not required.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1213338416"] = "This plugin is approved by your organization. A manual security audit is not required."
-- Assistant Audit
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1506922856"] = "Assistant Audit"
@ -1999,15 +2002,24 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1805629238"
-- Assistant Security
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1841954939"] = "Assistant Security"
-- Company approved
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2036497459"] = "Company approved"
-- Required minimum
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2354026284"] = "Required minimum"
-- Audit provider
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2757790517"] = "Audit provider"
-- Approval name
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T276341849"] = "Approval name"
-- Technical Details
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2769062110"] = "Technical Details"
-- Approval comment
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2906887599"] = "Approval comment"
-- No audit yet
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3138877447"] = "No audit yet"
@ -2023,21 +2035,39 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3448155331"
-- No stored audit details are available yet.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3647137899"] = "No stored audit details are available yet."
-- Enterprise approval is active
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3816183955"] = "Enterprise approval is active"
-- Current hash
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3896860082"] = "Current hash"
-- No user audit required
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3916957031"] = "No user audit required"
-- Audited at
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T4103354206"] = "Audited at"
-- Approved hash
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T4170340306"] = "Approved hash"
-- No security findings were stored for this assistant plugin.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T4256679240"] = "No security findings were stored for this assistant plugin."
-- Status source
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T4289123040"] = "Status source"
-- Audit hash
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T53507304"] = "Audit hash"
-- {0} Finding(s)
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T631393016"] = "{0} Finding(s)"
-- Approved by
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T894543751"] = "Approved by"
-- Approved at
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T978873131"] = "Approved at"
-- Click the paperclip to attach files, or click the number to see your attached files.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T1358313858"] = "Click the paperclip to attach files, or click the number to see your attached files."
@ -3418,6 +3448,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T3652671056"] =
-- Unavailable
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T3662391977"] = "Unavailable"
-- This assistant plugin is approved by your organization. A manual security audit is not required.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T3680374624"] = "This assistant plugin is approved by your organization. A manual security audit is not required."
-- Plugin Structure
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T371537943"] = "Plugin Structure"
@ -7561,6 +7594,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECUR
-- This assistant is currently locked.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T123211529"] = "This assistant is currently locked."
-- Company Approved
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T1397784595"] = "Company Approved"
-- Audit Required
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T1669285905"] = "Audit Required"
@ -7591,6 +7627,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECUR
-- The current audit result is '{0}', which is below your required minimum level '{1}'. Audit enforcement is currently disabled, so this assistant plugin can still be enabled or used.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T2774333862"] = "The current audit result is '{0}', which is below your required minimum level '{1}'. Audit enforcement is currently disabled, so this assistant plugin can still be enabled or used."
-- The current plugin hash matches an enterprise-managed approval. No manual security audit is required for activation or usage.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T2824524534"] = "The current plugin hash matches an enterprise-managed approval. No manual security audit is required for activation or usage."
-- Not Audited
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T2828154864"] = "Not Audited"
@ -7600,6 +7639,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECUR
-- Open Security Check
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T290241209"] = "Open Security Check"
-- User Audit
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T3293963409"] = "User Audit"
-- Restricted
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T3325062668"] = "Restricted"
@ -7621,9 +7663,24 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECUR
-- No security audit exists yet. Your current security settings do not require an audit before this assistant plugin may be used.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T3899951594"] = "No security audit exists yet. Your current security settings do not require an audit before this assistant plugin may be used."
-- No Approval
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T515592229"] = "No Approval"
-- This assistant was approved by your organization.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T538196816"] = "This assistant was approved by your organization."
-- Safe
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T760494712"] = "Safe"
-- Open Security Details
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T803119455"] = "Open Security Details"
-- Start Security Check
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T811648299"] = "Start Security Check"
-- This assistant was approved by your organization as '{0}'.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T834246718"] = "This assistant was approved by your organization as '{0}'."
-- This assistant currently has no stored audit.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T921972844"] = "This assistant currently has no stored audit."

View File

@ -33,6 +33,12 @@
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled" Color="@state.AuditColor">
@state.AuditLabel
</MudChip>
@if (!string.IsNullOrWhiteSpace(state.SourceLabel))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled" Color="@state.SourceColor" Icon="@state.SourceIcon">
@state.SourceLabel
</MudChip>
}
@if (!string.IsNullOrWhiteSpace(state.AvailabilityLabel))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@state.AvailabilityColor" Icon="@state.AvailabilityIcon">
@ -53,18 +59,28 @@
<MudCardContent Class="pt-0 pb-2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="4" Class="flex-wrap">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" />
<MudText Typo="Typo.body2">@T("Confidence"):</MudText>
<MudProgressLinear Color="@state.AuditColor"
Value="@this.GetConfidencePercentage()"
Rounded="@true"
Size="Size.Medium"
Style="width: 80px; min-width: 80px;" />
<MudText Typo="Typo.caption" Class="mud-text-secondary">
@this.GetConfidenceLabel()
</MudText>
</MudStack>
@if (state.IsEnterpriseApproved)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Business" Size="Size.Small" Color="@state.SourceColor" />
<MudText Typo="Typo.body2">@T("Enterprise approval is active")</MudText>
</MudStack>
}
else
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" />
<MudText Typo="Typo.body2">@T("Confidence"):</MudText>
<MudProgressLinear Color="@state.AuditColor"
Value="@this.GetConfidencePercentage()"
Rounded="@true"
Size="Size.Medium"
Style="width: 80px; min-width: 80px;" />
<MudText Typo="Typo.caption" Class="mud-text-secondary">
@this.GetConfidenceLabel()
</MudText>
</MudStack>
}
<MudDivider Vertical="@true" FlexItem="@true" />
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.BugReport" Size="Size.Small" Color="@state.AuditColor" />
@ -104,12 +120,63 @@
</td>
<td><code style="font-size: 0.8rem;">@this.Plugin.Id</code></td>
</tr>
<tr>
<td>
<MudText Typo="Typo.body2"><b>@T("Status source")</b></MudText>
</td>
<td><MudText Typo="Typo.body2">@state.SourceLabel</MudText></td>
</tr>
<tr>
<td>
<MudText Typo="Typo.body2"><b>@T("Current hash")</b></MudText>
</td>
<td><code style="font-size: 0.8rem;">@GetShortHash(state.CurrentHash)</code></td>
</tr>
@if (state.EnterpriseApproval is not null)
{
<tr>
<td>
<MudText Typo="Typo.body2"><b>@T("Approved hash")</b></MudText>
</td>
<td><code style="font-size: 0.8rem;">@GetShortHash(state.EnterpriseApproval.PluginHash)</code></td>
</tr>
@if (!string.IsNullOrWhiteSpace(state.EnterpriseApproval.DisplayName))
{
<tr>
<td>
<MudText Typo="Typo.body2"><b>@T("Approval name")</b></MudText>
</td>
<td><MudText Typo="Typo.body2">@state.EnterpriseApproval.DisplayName</MudText></td>
</tr>
}
@if (!string.IsNullOrWhiteSpace(state.EnterpriseApproval.ApprovedBy))
{
<tr>
<td>
<MudText Typo="Typo.body2"><b>@T("Approved by")</b></MudText>
</td>
<td><MudText Typo="Typo.body2">@state.EnterpriseApproval.ApprovedBy</MudText></td>
</tr>
}
@if (state.EnterpriseApproval.ApprovedAtUtc is not null)
{
<tr>
<td>
<MudText Typo="Typo.body2"><b>@T("Approved at")</b></MudText>
</td>
<td><MudText Typo="Typo.body2">@this.FormatFileTimestamp(state.EnterpriseApproval.ApprovedAtUtc.Value.ToLocalTime().DateTime)</MudText></td>
</tr>
}
@if (!string.IsNullOrWhiteSpace(state.EnterpriseApproval.Comment))
{
<tr>
<td>
<MudText Typo="Typo.body2"><b>@T("Approval comment")</b></MudText>
</td>
<td><MudText Typo="Typo.body2">@state.EnterpriseApproval.Comment</MudText></td>
</tr>
}
}
@if (state.Audit is not null)
{
<tr>
@ -156,9 +223,18 @@
@if (state.Audit is null)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="@true">
@T("No stored audit details are available yet.")
</MudAlert>
@if (state.IsEnterpriseApproved)
{
<MudAlert Severity="Severity.Success" Variant="Variant.Text" Dense="@true">
@T("This plugin is approved by your organization. A manual security audit is not required.")
</MudAlert>
}
else
{
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="@true">
@T("No stored audit details are available yet.")
</MudAlert>
}
}
else if (state.Audit.Findings.Count == 0)
{

View File

@ -103,12 +103,23 @@ public partial class AssistantPluginSecurityCard : MSGComponentBase
private string GetFindingSummary()
{
if (this.SecurityState.IsEnterpriseApproved)
return this.T("No user audit required");
var count = this.SecurityState.Audit?.Findings.Count ?? 0;
return string.Format(this.T("{0} Finding(s)"), count);
}
private string GetAuditTimestampLabel()
{
if (this.SecurityState.IsEnterpriseApproved)
{
var approvedAt = this.SecurityState.EnterpriseApproval?.ApprovedAtUtc;
return approvedAt is null
? this.T("Company approved")
: this.FormatFileTimestamp(approvedAt.Value.ToLocalTime().DateTime);
}
var auditedAt = this.SecurityState.Audit?.AuditedAtUtc;
return auditedAt is null
? this.T("No audit yet")

View File

@ -12,9 +12,18 @@
else
{
<MudStack Spacing="2">
<MudAlert Severity="Severity.Info" Dense="true">
@T("This security check uses a sample prompt preview. Empty or placeholder values in the preview are expected.")
</MudAlert>
@if (this.securityState.IsEnterpriseApproved)
{
<MudAlert Severity="Severity.Success" Dense="true">
@T("This assistant plugin is approved by your organization. A manual security audit is not required.")
</MudAlert>
}
else
{
<MudAlert Severity="Severity.Info" Dense="true">
@T("This security check uses a sample prompt preview. Empty or placeholder values in the preview are expected.")
</MudAlert>
}
<MudPaper Class="pa-3 border-dashed border rounded-lg">
<MudText Typo="Typo.h6">@this.plugin.Name</MudText>
@ -298,9 +307,12 @@
<MudButton OnClick="@this.CloseWithoutActivation" Variant="Variant.Filled">
@(this.audit is null ? T("Cancel") : T("Close"))
</MudButton>
<MudButton OnClick="@this.RunAudit" Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!this.CanRunAudit || this.justAudited)">
@T("Start Security Check")
</MudButton>
@if (!this.securityState.IsEnterpriseApproved)
{
<MudButton OnClick="@this.RunAudit" Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!this.CanRunAudit || this.justAudited)">
@T("Start Security Check")
</MudButton>
}
@if (this.CanEnablePlugin)
{
<MudButton OnClick="@this.EnablePlugin" Variant="Variant.Filled" Color="@this.EnableButtonColor">

View File

@ -37,6 +37,7 @@ public partial class AssistantPluginAuditDialog : MSGComponentBase
private IReadOnlyCollection<TreeItemData<ITreeItem>> fileSystemTreeItems = [];
private CultureInfo currentCultureInfo = CultureInfo.InvariantCulture;
private bool isAuditing;
private PluginAssistantSecurityState securityState = new();
private AIStudio.Settings.Provider CurrentProvider => this.SettingsManager.GetPreselectedProvider(Tools.Components.AGENT_ASSISTANT_PLUGIN_AUDIT, null, true);
@ -50,7 +51,7 @@ public partial class AssistantPluginAuditDialog : MSGComponentBase
private string MinimumLevelLabel => this.MinimumLevel.GetName();
private bool CanRunAudit => this.plugin is not null && this.CurrentProvider != AIStudio.Settings.Provider.NONE && !this.isAuditing;
private bool CanRunAudit => this.plugin is not null && this.CurrentProvider != AIStudio.Settings.Provider.NONE && !this.isAuditing && !this.securityState.IsEnterpriseApproved;
private bool IsAuditBelowMinimum => this.audit is not null && this.audit.Level < this.MinimumLevel;
@ -74,6 +75,7 @@ public partial class AssistantPluginAuditDialog : MSGComponentBase
.FirstOrDefault(x => x.Id == this.PluginId);
if (this.plugin is not null)
{
this.securityState = PluginAssistantSecurityResolver.Resolve(this.SettingsManager, this.plugin);
this.promptPreview = await this.plugin.BuildAuditPromptPreviewAsync();
this.promptFallbackPreview = this.plugin.BuildAuditPromptFallbackPreview();
this.plugin.CreateAuditComponentSummary();
@ -96,6 +98,7 @@ public partial class AssistantPluginAuditDialog : MSGComponentBase
try
{
this.audit = await this.AssistantPluginAuditService.RunAuditAsync(this.plugin);
this.securityState = PluginAssistantSecurityResolver.Resolve(this.SettingsManager, this.plugin);
}
finally
{

View File

@ -280,6 +280,23 @@ CONFIG["SETTINGS"] = {}
-- DOCUMENT_ANALYSIS_ASSISTANT, SLIDE_BUILDER_ASSISTANT, I18N_ASSISTANT
-- CONFIG["SETTINGS"]["DataApp.HiddenAssistants"] = { "ERI_ASSISTANT", "I18N_ASSISTANT" }
-- Configure enterprise approvals for assistant plugins.
-- Each approval is matched only by the current SHA-256 hash over all Lua files
-- in the assistant plugin folder, in canonical sorted order.
-- When the hash matches, the assistant plugin is treated as SAFE immediately and
-- no user-run security audit is required.
-- You can generate the exact hash with the build-script command:
-- dotnet run --project app/Build -- assistant-plugin-hash "<plugin-dir>" --lua-snippet
-- CONFIG["SETTINGS"]["DataAssistantPluginAudit.EnterpriseApprovedPlugins"] = {
-- {
-- ["PluginHash"] = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF",
-- ["DisplayName"] = "Name of Plugin",
-- ["Comment"] = "Optional comment",
-- ["ApprovedBy"] = "Optional Approver",
-- ["ApprovedAtUtc"] = "2026-07-02T09:30:00Z",
-- }
-- }
-- Configure a global shortcut for starting and stopping dictation.
--
-- The format follows the Rust and Tauri conventions. Especially,

View File

@ -39,5 +39,10 @@ public sealed class DataAssistantPluginAudit(Expression<Func<Data, DataAssistant
/// <summary>
/// If true, the security audit will be hidden from the user and done in the background
/// </summary>
public bool AutomaticallyAuditAssistants { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AutomaticallyAuditAssistants, true);
public bool AutomaticallyAuditAssistants { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AutomaticallyAuditAssistants, false);
/// <summary>
/// Enterprise-managed assistant plugin hashes that are approved without requiring a user audit.
/// </summary>
public IList<DataAssistantPluginEnterpriseApproval> EnterpriseApprovedPlugins { get; set; } = ManagedConfiguration.Register(configSelection, n => n.EnterpriseApprovedPlugins, []);
}

View File

@ -0,0 +1,13 @@
namespace AIStudio.Settings.DataModel;
/// <summary>
/// Enterprise-managed approval entry for an assistant plugin hash.
/// </summary>
public sealed class DataAssistantPluginEnterpriseApproval
{
public string PluginHash { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string Comment { get; init; } = string.Empty;
public string ApprovedBy { get; init; } = string.Empty;
public DateTimeOffset? ApprovedAtUtc { get; init; }
}

View File

@ -31,12 +31,27 @@ public static class CommonTools
if (string.IsNullOrWhiteSpace(ietfTag))
return CultureInfo.InvariantCulture;
var normalizedTag = ietfTag.Trim().Replace('_', '-');
try
{
return CultureInfo.GetCultureInfo(ietfTag);
return CultureInfo.GetCultureInfo(normalizedTag);
}
catch (CultureNotFoundException)
{
var separatorIndex = normalizedTag.IndexOf('-');
if (separatorIndex > 0)
{
var neutralLanguageTag = normalizedTag[..separatorIndex];
try
{
return CultureInfo.GetCultureInfo(neutralLanguageTag);
}
catch (CultureNotFoundException)
{
}
}
return CultureInfo.InvariantCulture;
}
}

View File

@ -8,6 +8,8 @@ public static class PluginAssistantSecurityResolver
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginAssistantSecurityResolver).Namespace, nameof(PluginAssistantSecurityResolver));
private static string NormalizeHash(string hash) => string.IsNullOrWhiteSpace(hash) ? string.Empty : hash.Trim().ToUpperInvariant();
private static string GetAvailabilityLabel(bool requiresAudit, bool hasAudit, bool hasHashMismatch, bool isBlocked, bool canOverride)
{
if (hasHashMismatch)
@ -75,10 +77,55 @@ public static class PluginAssistantSecurityResolver
var auditSettings = settingsManager.ConfigurationData.AssistantPluginAudit;
var enforceAuditBeforeActivation = auditSettings.RequireAuditBeforeActivation;
var isEnforcementDisabled = !enforceAuditBeforeActivation;
var currentHash = plugin.ComputeAuditHash();
var currentHash = NormalizeHash(plugin.ComputeAuditHash());
var enterpriseApproval = auditSettings.EnterpriseApprovedPlugins
.FirstOrDefault(x => string.Equals(NormalizeHash(x.PluginHash), currentHash, StringComparison.Ordinal));
if (enterpriseApproval is not null)
{
var enterpriseHeadline = string.IsNullOrWhiteSpace(enterpriseApproval.DisplayName)
? TB("This assistant was approved by your organization.")
: string.Format(TB("This assistant was approved by your organization as '{0}'."), enterpriseApproval.DisplayName);
return new PluginAssistantSecurityState
{
Plugin = plugin,
Audit = null,
EnterpriseApproval = enterpriseApproval,
Settings = auditSettings,
Source = PluginAssistantSecurityStatusSource.ENTERPRISE_APPROVAL,
CurrentHash = currentHash,
HashMatches = true,
HasHashMismatch = false,
IsBelowMinimum = false,
MeetsMinimumLevel = true,
RequiresAudit = false,
IsBlocked = false,
CanOverride = false,
CanActivatePlugin = true,
CanStartAssistant = true,
AuditLabel = TB("Safe"),
AuditColor = AssistantAuditLevel.SAFE.GetColor(),
AuditIcon = AssistantAuditLevel.SAFE.GetIcon(),
AvailabilityLabel = GetAvailabilityLabel(requiresAudit: false, hasAudit: true, hasHashMismatch: false, isBlocked: false, canOverride: false),
AvailabilityColor = GetAvailabilityColor(requiresAudit: false, hasAudit: true, hasHashMismatch: false, isBlocked: false, canOverride: false),
AvailabilityIcon = GetAvailabilityIcon(requiresAudit: false, hasAudit: true, hasHashMismatch: false, isBlocked: false, canOverride: false),
StatusLabel = TB("Unlocked"),
SourceLabel = TB("Company Approved"),
SourceColor = Color.Success,
SourceIcon = MudBlazor.Icons.Material.Filled.Business,
BadgeIcon = MudBlazor.Icons.Material.Filled.Business,
Headline = enterpriseHeadline,
Description = TB("The current plugin hash matches an enterprise-managed approval. No manual security audit is required for activation or usage."),
StatusColor = Color.Success,
StatusIcon = MudBlazor.Icons.Material.Filled.VerifiedUser,
ActionLabel = TB("Open Security Details"),
};
}
var audit = settingsManager.ConfigurationData.AssistantPluginAudits.FirstOrDefault(x => x.PluginId == plugin.Id);
var hasAudit = audit is not null && audit.Level is not AssistantAuditLevel.UNKNOWN;
var hashMatches = hasAudit && string.Equals(audit!.PluginHash, currentHash, StringComparison.Ordinal);
var hashMatches = hasAudit && string.Equals(NormalizeHash(audit!.PluginHash), currentHash, StringComparison.Ordinal);
var hasHashMismatch = hasAudit && !hashMatches;
var isBelowMinimum = hashMatches && audit is not null && audit.Level < auditSettings.MinimumLevel;
var meetsMinimum = hashMatches && audit is not null && audit.Level >= auditSettings.MinimumLevel;
@ -94,6 +141,7 @@ public static class PluginAssistantSecurityResolver
Plugin = plugin,
Audit = null,
Settings = auditSettings,
Source = PluginAssistantSecurityStatusSource.NONE,
CurrentHash = currentHash,
HashMatches = false,
HasHashMismatch = false,
@ -111,6 +159,9 @@ public static class PluginAssistantSecurityResolver
AvailabilityColor = GetAvailabilityColor(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false),
AvailabilityIcon = GetAvailabilityIcon(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false),
StatusLabel = GetAvailabilityLabel(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false),
SourceLabel = TB("No Approval"),
SourceColor = Color.Default,
SourceIcon = MudBlazor.Icons.Material.Filled.HelpOutline,
BadgeIcon = GetSecurityBadgeIcon(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false),
Headline = requiresAudit ? TB("This assistant is currently locked.") : TB("This assistant currently has no stored audit."),
Description = requiresAudit
@ -129,6 +180,7 @@ public static class PluginAssistantSecurityResolver
Plugin = plugin,
Audit = audit,
Settings = auditSettings,
Source = PluginAssistantSecurityStatusSource.NONE,
CurrentHash = currentHash,
HashMatches = false,
HasHashMismatch = true,
@ -146,6 +198,9 @@ public static class PluginAssistantSecurityResolver
AvailabilityColor = GetAvailabilityColor(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false),
AvailabilityIcon = GetAvailabilityIcon(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false),
StatusLabel = GetAvailabilityLabel(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false),
SourceLabel = TB("No Approval"),
SourceColor = Color.Default,
SourceIcon = MudBlazor.Icons.Material.Filled.Warning,
BadgeIcon = GetSecurityBadgeIcon(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false),
Headline = requiresAudit ? TB("This assistant is locked until it is audited again.") : TB("This assistant changed after its last audit."),
Description = requiresAudit
@ -167,6 +222,7 @@ public static class PluginAssistantSecurityResolver
Plugin = plugin,
Audit = audit,
Settings = auditSettings,
Source = PluginAssistantSecurityStatusSource.USER_AUDIT,
CurrentHash = currentHash,
HashMatches = true,
HasHashMismatch = false,
@ -184,6 +240,9 @@ public static class PluginAssistantSecurityResolver
AvailabilityColor = GetAvailabilityColor(requiresAudit: false, hasAudit, hasHashMismatch: false, isBlockedByMinimum, canOverride),
AvailabilityIcon = GetAvailabilityIcon(requiresAudit: false, hasAudit, hasHashMismatch: false, isBlockedByMinimum, canOverride),
StatusLabel = GetAvailabilityLabel(requiresAudit: false, hasAudit, hasHashMismatch: false, isBlockedByMinimum, canOverride),
SourceLabel = TB("User Audit"),
SourceColor = auditLevel.GetColor(),
SourceIcon = MudBlazor.Icons.Material.Filled.Verified,
BadgeIcon = GetSecurityBadgeIcon(requiresAudit: false, hasAudit, hasHashMismatch: false, isBlockedByMinimum, canOverride),
Headline = isBlockedByMinimum
? TB("This assistant is currently locked.")
@ -208,6 +267,7 @@ public static class PluginAssistantSecurityResolver
Plugin = plugin,
Audit = audit,
Settings = auditSettings,
Source = PluginAssistantSecurityStatusSource.USER_AUDIT,
CurrentHash = currentHash,
HashMatches = true,
HasHashMismatch = false,
@ -225,6 +285,9 @@ public static class PluginAssistantSecurityResolver
AvailabilityColor = GetAvailabilityColor(requiresAudit: false, hasAudit, hasHashMismatch: false, isBlocked: false, canOverride: false),
AvailabilityIcon = GetAvailabilityIcon(requiresAudit: false, hasAudit, hasHashMismatch: false, isBlocked: false, canOverride: false),
StatusLabel = GetAvailabilityLabel(requiresAudit: false, hasAudit, hasHashMismatch: false, isBlocked: false, canOverride: false),
SourceLabel = TB("User Audit"),
SourceColor = auditLevelDefault.GetColor(),
SourceIcon = MudBlazor.Icons.Material.Filled.Verified,
BadgeIcon = GetSecurityBadgeIcon(requiresAudit: false, hasAudit, hasHashMismatch: false, isBlocked: false, canOverride: false),
Headline = TB("This assistant is currently unlocked."),
Description = string.Format(TB("The stored audit matches the current plugin code and meets your required minimum level '{0}'."), auditSettings.MinimumLevel.GetName()),

View File

@ -13,9 +13,12 @@ public sealed class PluginAssistantSecurityState
{
public PluginAssistants Plugin { get; init; } = null!;
public PluginAssistantAudit? Audit { get; init; }
public DataAssistantPluginEnterpriseApproval? EnterpriseApproval { get; init; }
public DataAssistantPluginAudit Settings { get; init; } = new();
public PluginAssistantSecurityStatusSource Source { get; init; } = PluginAssistantSecurityStatusSource.NONE;
public string CurrentHash { get; init; } = string.Empty;
public bool HasAudit => this.Audit is not null;
public bool IsEnterpriseApproved => this.Source is PluginAssistantSecurityStatusSource.ENTERPRISE_APPROVAL;
public bool HashMatches { get; init; }
public bool HasHashMismatch { get; init; }
public bool IsBelowMinimum { get; init; }
@ -32,6 +35,9 @@ public sealed class PluginAssistantSecurityState
public Color AvailabilityColor { get; init; } = Color.Info;
public string AvailabilityIcon { get; init; } = MudBlazor.Icons.Material.Filled.Lock;
public string StatusLabel { get; init; } = string.Empty;
public string SourceLabel { get; init; } = string.Empty;
public Color SourceColor { get; init; } = Color.Info;
public string SourceIcon { get; init; } = MudBlazor.Icons.Material.Filled.Info;
public string Headline { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public Color StatusColor { get; init; } = Color.Info;

View File

@ -0,0 +1,8 @@
namespace AIStudio.Tools.PluginSystem.Assistants;
public enum PluginAssistantSecurityStatusSource
{
NONE,
USER_AUDIT,
ENTERPRISE_APPROVAL,
}

View File

@ -2,8 +2,8 @@ using System.Collections.Immutable;
using AIStudio.Tools.PluginSystem.Assistants.DataModel;
using AIStudio.Tools.PluginSystem.Assistants.DataModel.Layout;
using Lua;
using System.Security.Cryptography;
using System.Text;
using AssistantPluginHash = SharedTools.AssistantPluginHash;
namespace AIStudio.Tools.PluginSystem.Assistants;
@ -280,33 +280,7 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
/// sequence of relative path length, relative path, content length, and content
/// for each file in ordinal path order.
/// </summary>
public string ComputeAuditHash()
{
var luaFiles = this.ReadAllLuaFiles();
if (luaFiles.Count == 0)
return string.Empty;
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
foreach (var (relativePath, content) in luaFiles.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
var normalizedPath = relativePath.Replace('\\', '/');
var pathBytes = Encoding.UTF8.GetBytes(normalizedPath);
var contentBytes = Encoding.UTF8.GetBytes(content);
writer.Write(pathBytes.Length);
writer.Write(pathBytes);
writer.Write(contentBytes.Length);
writer.Write(contentBytes);
}
writer.Flush();
var bytes = SHA256.HashData(stream.ToArray());
return Convert.ToHexString(bytes);
}
public string ComputeAuditHash() => AssistantPluginHash.Compute(this.PluginPath);
private static string BuildSecureSystemPrompt(string pluginSystemPrompt)
{

View File

@ -1,3 +1,4 @@
using System.Globalization;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Services;
@ -204,6 +205,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: data source security settings
ManagedConfiguration.TryProcessConfiguration(x => x.DataSourceSecurity, x => x.TrustedProviderIds, this.Id, settingsTable, dryRun);
// Config: enterprise-managed approvals for assistant plugins
this.TryProcessEnterpriseApprovedAssistantPlugins(settingsTable, dryRun);
// Handle configured LLM providers:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun);
@ -251,6 +255,120 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
return true;
}
private void TryProcessEnterpriseApprovedAssistantPlugins(LuaTable settingsTable, bool dryRun)
{
if (!ManagedConfiguration.TryGet(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, out ConfigMeta<DataAssistantPluginAudit, IList<DataAssistantPluginEnterpriseApproval>> configMeta))
return;
var settingName = SettingsManager.ToSettingName<DataAssistantPluginAudit, IList<DataAssistantPluginEnterpriseApproval>>(x => x.EnterpriseApprovedPlugins);
var successful = false;
IList<DataAssistantPluginEnterpriseApproval> configuredApprovals = [];
if (settingsTable.TryGetValue(settingName, out var configuredLuaValue)
&& configuredLuaValue.Type is LuaValueType.Table
&& configuredLuaValue.TryRead<LuaTable>(out var approvalsTable))
{
var approvals = new List<DataAssistantPluginEnterpriseApproval>(approvalsTable.ArrayLength);
for (var index = 1; index <= approvalsTable.ArrayLength; index++)
{
var entryValue = approvalsTable[index];
if (entryValue.TryRead<string>(out var hashText))
{
var normalizedHash = NormalizeApprovalHash(hashText);
if (!string.IsNullOrWhiteSpace(normalizedHash))
approvals.Add(new() { PluginHash = normalizedHash });
else
LOG.LogWarning("The enterprise assistant approval entry at index {Index} contains an empty hash (config plugin id: {ConfigPluginId}).", index, this.Id);
continue;
}
if (!entryValue.TryRead<LuaTable>(out var entryTable))
{
LOG.LogWarning("The enterprise assistant approval entry at index {Index} is neither a string nor a table (config plugin id: {ConfigPluginId}).", index, this.Id);
continue;
}
if (!TryParseEnterpriseApprovedAssistantPlugin(index, entryTable, this.Id, out var approval))
continue;
approvals.Add(approval);
}
configuredApprovals = approvals;
successful = true;
}
if (dryRun)
return;
switch (successful)
{
case true:
configMeta.SetValue(configuredApprovals);
configMeta.LockConfiguration(this.Id);
break;
case false when configMeta.IsLocked && configMeta.LockedByConfigPluginId == this.Id:
configMeta.ResetLockedConfiguration();
break;
}
}
private static bool TryParseEnterpriseApprovedAssistantPlugin(int index, LuaTable table, Guid configPluginId, out DataAssistantPluginEnterpriseApproval approval)
{
approval = new();
if (!table.TryGetValue("PluginHash", out var pluginHashValue) || !pluginHashValue.TryRead<string>(out var pluginHash))
{
LOG.LogWarning("The enterprise assistant approval entry at index {Index} is missing a valid PluginHash (config plugin id: {ConfigPluginId}).", index, configPluginId);
return false;
}
var normalizedHash = NormalizeApprovalHash(pluginHash);
if (string.IsNullOrWhiteSpace(normalizedHash))
{
LOG.LogWarning("The enterprise assistant approval entry at index {Index} contains an empty PluginHash (config plugin id: {ConfigPluginId}).", index, configPluginId);
return false;
}
var displayName = TryReadOptionalString(table, "DisplayName");
var comment = TryReadOptionalString(table, "Comment");
var approvedBy = TryReadOptionalString(table, "ApprovedBy");
var approvedAtUtc = TryReadOptionalDateTimeOffset(table, "ApprovedAtUtc", index, configPluginId);
approval = new()
{
PluginHash = normalizedHash,
DisplayName = displayName,
Comment = comment,
ApprovedBy = approvedBy,
ApprovedAtUtc = approvedAtUtc,
};
return true;
}
private static string TryReadOptionalString(LuaTable table, string key)
{
return table.TryGetValue(key, out var value) && value.TryRead<string>(out var text)
? text
: string.Empty;
}
private static DateTimeOffset? TryReadOptionalDateTimeOffset(LuaTable table, string key, int index, Guid configPluginId)
{
if (!table.TryGetValue(key, out var value))
return null;
if (value.TryRead<string>(out var text) && DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
return parsed.ToUniversalTime();
LOG.LogWarning("The enterprise assistant approval entry at index {Index} contains an invalid {Key} value (config plugin id: {ConfigPluginId}).", index, key, configPluginId);
return null;
}
private static string NormalizeApprovalHash(string hash) => string.IsNullOrWhiteSpace(hash) ? string.Empty : hash.Trim().ToUpperInvariant();
private void TryReadMandatoryInfos(LuaTable mainTable)
{
if (!mainTable.TryGetValue("MANDATORY_INFOS", out var mandatoryInfosValue) || !mandatoryInfosValue.TryRead<LuaTable>(out var mandatoryInfosTable))

View File

@ -319,6 +319,10 @@ public static partial class PluginFactory
// Check if security audits are invoked automatically and transparent for the user
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.AutomaticallyAuditAssistants, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check enterprise-managed assistant plugin approvals
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if (wasConfigurationChanged)
{

View File

@ -1,4 +1,7 @@
using System.Text;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem.Assistants;
namespace AIStudio.Tools.PluginSystem;
@ -78,11 +81,36 @@ public static partial class PluginFactory
LOG.LogError(e, $"An error occurred while starting the plugin: Id='{availablePlugin.Id}', Type='{availablePlugin.Type}', Name='{availablePlugin.Name}', Version='{availablePlugin.Version}'.");
}
}
LogAssistantPluginStartupState();
// Inform all components that the plugins have been reloaded or started:
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.PLUGINS_RELOADED);
return configObjects;
}
private static void LogAssistantPluginStartupState()
{
ManagedConfiguration.TryGet(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, out ConfigMeta<DataAssistantPluginAudit, IList<DataAssistantPluginEnterpriseApproval>>? configMeta);
var approvedByConfigPluginId = configMeta is { IsLocked: true } ? configMeta.LockedByConfigPluginId : Guid.Empty;
var approvedByConfigPluginName = approvedByConfigPluginId == Guid.Empty
? string.Empty
: AVAILABLE_PLUGINS.FirstOrDefault(x => x.Id == approvedByConfigPluginId)?.Name ?? string.Empty;
foreach (var assistantPlugin in RUNNING_PLUGINS.OfType<PluginAssistants>())
{
var securityState = PluginAssistantSecurityResolver.Resolve(SettingsManagerAccess, assistantPlugin);
if (securityState.IsEnterpriseApproved)
{
LOG.LogInformation(
$"Successfully started assistant plugin: Id='{assistantPlugin.Id}', Type='{assistantPlugin.Type}', Name='{assistantPlugin.Name}', Version='{assistantPlugin.Version}', SecuritySource='EnterpriseApproval', ApprovedByConfigPluginId='{approvedByConfigPluginId}', ApprovedByConfigPluginName='{approvedByConfigPluginName}'");
continue;
}
LOG.LogInformation(
$"Successfully started assistant plugin: Id='{assistantPlugin.Id}', Type='{assistantPlugin.Type}', Name='{assistantPlugin.Name}', Version='{assistantPlugin.Version}'");
}
}
private static async Task<PluginBase> Start(IAvailablePlugin meta, CancellationToken cancellationToken = default)
{

View File

@ -0,0 +1,40 @@
using System.Security.Cryptography;
using System.Text;
namespace SharedTools;
/// <summary>
/// Computes the canonical assistant-plugin hash across all Lua files in a plugin directory.
/// </summary>
public static class AssistantPluginHash
{
public static string Compute(string pluginDirectory)
{
if (string.IsNullOrWhiteSpace(pluginDirectory) || !Directory.Exists(pluginDirectory))
return string.Empty;
var luaFiles = Directory.EnumerateFiles(pluginDirectory, "*.lua", SearchOption.AllDirectories)
.OrderBy(path => path, StringComparer.Ordinal)
.ToList();
if (luaFiles.Count == 0)
return string.Empty;
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
foreach (var filePath in luaFiles)
{
var relativePath = Path.GetRelativePath(pluginDirectory, filePath).Replace('\\', '/');
var pathBytes = Encoding.UTF8.GetBytes(relativePath);
var contentBytes = File.ReadAllBytes(filePath);
writer.Write(pathBytes.Length);
writer.Write(pathBytes);
writer.Write(contentBytes.Length);
writer.Write(contentBytes);
}
writer.Flush();
return Convert.ToHexString(SHA256.HashData(stream.ToArray()));
}
}

View File

@ -287,11 +287,53 @@ Currently, you can configure the following things:
- Any number of LLM providers (self-hosted or cloud providers with encrypted API keys)
- Any number of transcription providers for voice-to-text functionality
- Any number of embedding providers for RAG
- Enterprise hash approvals for assistant plugins
- The update behavior of AI Studio
- Various UI and feature settings (see the example configuration for details)
All other settings can be made by the user themselves. If you need additional settings, feel free to create an issue in our planning repository: https://github.com/MindWorkAI/Planning/issues
## Enterprise approval for assistant plugins
Enterprise configurations can approve assistant plugins by hash so that users do not need to run a local assistant audit before activation. The approval is based only on the current plugin content, not on the plugin GUID.
AI Studio computes the approval hash as a SHA-256 digest over all `.lua` files in the assistant plugin directory:
- recursively
- sorted by relative path in ordinal order
- using canonical `/` path separators
- hashing relative-path length, relative path, content length, and file content for each Lua file
If any Lua file changes, the hash changes automatically and the enterprise approval no longer applies.
### Configuration example
Add the approval list to `CONFIG["SETTINGS"]` in your configuration plugin:
```lua
CONFIG["SETTINGS"]["DataAssistantPluginAudit.EnterpriseApprovedPlugins"] = {
{
["PluginHash"] = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF",
["DisplayName"] = "Corporate Translation Assistant",
["Comment"] = "Approved for internal rollout",
["ApprovedBy"] = "AI Governance Board",
["ApprovedAtUtc"] = "2026-07-02T09:30:00Z",
}
}
```
`PluginHash` is required. All other fields are optional and are shown in the UI as approval metadata.
### Generating the hash
Use the build-script command from the repository root:
```bash
dotnet run --project app/Build -- assistant-plugin-hash "<plugin-dir>" --lua-snippet
```
This prints the canonical hash and, with `--lua-snippet`, also prints a ready-to-paste Lua snippet for `CONFIG["SETTINGS"]`.
## Encrypted API Keys
You can include encrypted API keys in your configuration plugins for cloud providers (like OpenAI, Anthropic) or secured on-premise models. This feature provides obfuscation to prevent casual exposure of API keys in configuration files.