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<UpdateMetadataCommands>();
app.AddCommands<UpdateWebAssetsCommand>(); app.AddCommands<UpdateWebAssetsCommand>();
app.AddCommands<CollectI18NKeysCommand>(); app.AddCommands<CollectI18NKeysCommand>();
app.AddCommands<AssistantPluginHashCommand>();
app.Run(); app.Run();

View File

@ -1984,6 +1984,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Open Set
-- Show or hide the detailed security information. -- Show or hide the detailed security information.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1045105126"] = "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 -- Assistant Audit
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1506922856"] = "Assistant Audit" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1506922856"] = "Assistant Audit"
@ -1999,15 +2002,24 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1805629238"
-- Assistant Security -- Assistant Security
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1841954939"] = "Assistant Security" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T1841954939"] = "Assistant Security"
-- Company approved
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2036497459"] = "Company approved"
-- Required minimum -- Required minimum
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2354026284"] = "Required minimum" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2354026284"] = "Required minimum"
-- Audit provider -- Audit provider
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2757790517"] = "Audit provider" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2757790517"] = "Audit provider"
-- Approval name
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T276341849"] = "Approval name"
-- Technical Details -- Technical Details
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2769062110"] = "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 -- No audit yet
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3138877447"] = "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. -- No stored audit details are available yet.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3647137899"] = "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 -- Current hash
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3896860082"] = "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 -- Audited at
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T4103354206"] = "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. -- 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." 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 -- Audit hash
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T53507304"] = "Audit hash" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T53507304"] = "Audit hash"
-- {0} Finding(s) -- {0} Finding(s)
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T631393016"] = "{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. -- 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." 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 -- Unavailable
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T3662391977"] = "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 -- Plugin Structure
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T371537943"] = "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. -- This assistant is currently locked.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T123211529"] = "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 -- Audit Required
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T1669285905"] = "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. -- 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." 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 -- Not Audited
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T2828154864"] = "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 -- Open Security Check
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T290241209"] = "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 -- Restricted
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T3325062668"] = "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. -- 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." 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 -- Start Security Check
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T811648299"] = "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. -- This assistant currently has no stored audit.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTSECURITYRESOLVER::T921972844"] = "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"> <MudChip T="string" Size="Size.Small" Variant="Variant.Filled" Color="@state.AuditColor">
@state.AuditLabel @state.AuditLabel
</MudChip> </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)) @if (!string.IsNullOrWhiteSpace(state.AvailabilityLabel))
{ {
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@state.AvailabilityColor" Icon="@state.AvailabilityIcon"> <MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@state.AvailabilityColor" Icon="@state.AvailabilityIcon">
@ -53,6 +59,15 @@
<MudCardContent Class="pt-0 pb-2"> <MudCardContent Class="pt-0 pb-2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="4" Class="flex-wrap"> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="4" Class="flex-wrap">
@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"> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" /> <MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" />
<MudText Typo="Typo.body2">@T("Confidence"):</MudText> <MudText Typo="Typo.body2">@T("Confidence"):</MudText>
@ -65,6 +80,7 @@
@this.GetConfidenceLabel() @this.GetConfidenceLabel()
</MudText> </MudText>
</MudStack> </MudStack>
}
<MudDivider Vertical="@true" FlexItem="@true" /> <MudDivider Vertical="@true" FlexItem="@true" />
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1"> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.BugReport" Size="Size.Small" Color="@state.AuditColor" /> <MudIcon Icon="@Icons.Material.Filled.BugReport" Size="Size.Small" Color="@state.AuditColor" />
@ -104,12 +120,63 @@
</td> </td>
<td><code style="font-size: 0.8rem;">@this.Plugin.Id</code></td> <td><code style="font-size: 0.8rem;">@this.Plugin.Id</code></td>
</tr> </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> <tr>
<td> <td>
<MudText Typo="Typo.body2"><b>@T("Current hash")</b></MudText> <MudText Typo="Typo.body2"><b>@T("Current hash")</b></MudText>
</td> </td>
<td><code style="font-size: 0.8rem;">@GetShortHash(state.CurrentHash)</code></td> <td><code style="font-size: 0.8rem;">@GetShortHash(state.CurrentHash)</code></td>
</tr> </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) @if (state.Audit is not null)
{ {
<tr> <tr>
@ -155,11 +222,20 @@
</MudPaper> </MudPaper>
@if (state.Audit is null) @if (state.Audit is null)
{
@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"> <MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="@true">
@T("No stored audit details are available yet.") @T("No stored audit details are available yet.")
</MudAlert> </MudAlert>
} }
}
else if (state.Audit.Findings.Count == 0) else if (state.Audit.Findings.Count == 0)
{ {
<MudAlert Severity="Severity.Success" Variant="Variant.Text" Dense="@true"> <MudAlert Severity="Severity.Success" Variant="Variant.Text" Dense="@true">

View File

@ -103,12 +103,23 @@ public partial class AssistantPluginSecurityCard : MSGComponentBase
private string GetFindingSummary() private string GetFindingSummary()
{ {
if (this.SecurityState.IsEnterpriseApproved)
return this.T("No user audit required");
var count = this.SecurityState.Audit?.Findings.Count ?? 0; var count = this.SecurityState.Audit?.Findings.Count ?? 0;
return string.Format(this.T("{0} Finding(s)"), count); return string.Format(this.T("{0} Finding(s)"), count);
} }
private string GetAuditTimestampLabel() 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; var auditedAt = this.SecurityState.Audit?.AuditedAtUtc;
return auditedAt is null return auditedAt is null
? this.T("No audit yet") ? this.T("No audit yet")

View File

@ -12,9 +12,18 @@
else else
{ {
<MudStack Spacing="2"> <MudStack Spacing="2">
@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"> <MudAlert Severity="Severity.Info" Dense="true">
@T("This security check uses a sample prompt preview. Empty or placeholder values in the preview are expected.") @T("This security check uses a sample prompt preview. Empty or placeholder values in the preview are expected.")
</MudAlert> </MudAlert>
}
<MudPaper Class="pa-3 border-dashed border rounded-lg"> <MudPaper Class="pa-3 border-dashed border rounded-lg">
<MudText Typo="Typo.h6">@this.plugin.Name</MudText> <MudText Typo="Typo.h6">@this.plugin.Name</MudText>
@ -298,9 +307,12 @@
<MudButton OnClick="@this.CloseWithoutActivation" Variant="Variant.Filled"> <MudButton OnClick="@this.CloseWithoutActivation" Variant="Variant.Filled">
@(this.audit is null ? T("Cancel") : T("Close")) @(this.audit is null ? T("Cancel") : T("Close"))
</MudButton> </MudButton>
@if (!this.securityState.IsEnterpriseApproved)
{
<MudButton OnClick="@this.RunAudit" Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!this.CanRunAudit || this.justAudited)"> <MudButton OnClick="@this.RunAudit" Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!this.CanRunAudit || this.justAudited)">
@T("Start Security Check") @T("Start Security Check")
</MudButton> </MudButton>
}
@if (this.CanEnablePlugin) @if (this.CanEnablePlugin)
{ {
<MudButton OnClick="@this.EnablePlugin" Variant="Variant.Filled" Color="@this.EnableButtonColor"> <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 IReadOnlyCollection<TreeItemData<ITreeItem>> fileSystemTreeItems = [];
private CultureInfo currentCultureInfo = CultureInfo.InvariantCulture; private CultureInfo currentCultureInfo = CultureInfo.InvariantCulture;
private bool isAuditing; private bool isAuditing;
private PluginAssistantSecurityState securityState = new();
private AIStudio.Settings.Provider CurrentProvider => this.SettingsManager.GetPreselectedProvider(Tools.Components.AGENT_ASSISTANT_PLUGIN_AUDIT, null, true); 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 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; 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); .FirstOrDefault(x => x.Id == this.PluginId);
if (this.plugin is not null) if (this.plugin is not null)
{ {
this.securityState = PluginAssistantSecurityResolver.Resolve(this.SettingsManager, this.plugin);
this.promptPreview = await this.plugin.BuildAuditPromptPreviewAsync(); this.promptPreview = await this.plugin.BuildAuditPromptPreviewAsync();
this.promptFallbackPreview = this.plugin.BuildAuditPromptFallbackPreview(); this.promptFallbackPreview = this.plugin.BuildAuditPromptFallbackPreview();
this.plugin.CreateAuditComponentSummary(); this.plugin.CreateAuditComponentSummary();
@ -96,6 +98,7 @@ public partial class AssistantPluginAuditDialog : MSGComponentBase
try try
{ {
this.audit = await this.AssistantPluginAuditService.RunAuditAsync(this.plugin); this.audit = await this.AssistantPluginAuditService.RunAuditAsync(this.plugin);
this.securityState = PluginAssistantSecurityResolver.Resolve(this.SettingsManager, this.plugin);
} }
finally finally
{ {

View File

@ -280,6 +280,23 @@ CONFIG["SETTINGS"] = {}
-- DOCUMENT_ANALYSIS_ASSISTANT, SLIDE_BUILDER_ASSISTANT, I18N_ASSISTANT -- DOCUMENT_ANALYSIS_ASSISTANT, SLIDE_BUILDER_ASSISTANT, I18N_ASSISTANT
-- CONFIG["SETTINGS"]["DataApp.HiddenAssistants"] = { "ERI_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. -- Configure a global shortcut for starting and stopping dictation.
-- --
-- The format follows the Rust and Tauri conventions. Especially, -- The format follows the Rust and Tauri conventions. Especially,

View File

@ -39,5 +39,10 @@ public sealed class DataAssistantPluginAudit(Expression<Func<Data, DataAssistant
/// <summary> /// <summary>
/// If true, the security audit will be hidden from the user and done in the background /// If true, the security audit will be hidden from the user and done in the background
/// </summary> /// </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)) if (string.IsNullOrWhiteSpace(ietfTag))
return CultureInfo.InvariantCulture; return CultureInfo.InvariantCulture;
var normalizedTag = ietfTag.Trim().Replace('_', '-');
try try
{ {
return CultureInfo.GetCultureInfo(ietfTag); return CultureInfo.GetCultureInfo(normalizedTag);
} }
catch (CultureNotFoundException) catch (CultureNotFoundException)
{ {
var separatorIndex = normalizedTag.IndexOf('-');
if (separatorIndex > 0)
{
var neutralLanguageTag = normalizedTag[..separatorIndex];
try
{
return CultureInfo.GetCultureInfo(neutralLanguageTag);
}
catch (CultureNotFoundException)
{
}
}
return CultureInfo.InvariantCulture; 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 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) private static string GetAvailabilityLabel(bool requiresAudit, bool hasAudit, bool hasHashMismatch, bool isBlocked, bool canOverride)
{ {
if (hasHashMismatch) if (hasHashMismatch)
@ -75,10 +77,55 @@ public static class PluginAssistantSecurityResolver
var auditSettings = settingsManager.ConfigurationData.AssistantPluginAudit; var auditSettings = settingsManager.ConfigurationData.AssistantPluginAudit;
var enforceAuditBeforeActivation = auditSettings.RequireAuditBeforeActivation; var enforceAuditBeforeActivation = auditSettings.RequireAuditBeforeActivation;
var isEnforcementDisabled = !enforceAuditBeforeActivation; 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 audit = settingsManager.ConfigurationData.AssistantPluginAudits.FirstOrDefault(x => x.PluginId == plugin.Id);
var hasAudit = audit is not null && audit.Level is not AssistantAuditLevel.UNKNOWN; 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 hasHashMismatch = hasAudit && !hashMatches;
var isBelowMinimum = hashMatches && audit is not null && audit.Level < auditSettings.MinimumLevel; var isBelowMinimum = hashMatches && audit is not null && audit.Level < auditSettings.MinimumLevel;
var meetsMinimum = 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, Plugin = plugin,
Audit = null, Audit = null,
Settings = auditSettings, Settings = auditSettings,
Source = PluginAssistantSecurityStatusSource.NONE,
CurrentHash = currentHash, CurrentHash = currentHash,
HashMatches = false, HashMatches = false,
HasHashMismatch = false, HasHashMismatch = false,
@ -111,6 +159,9 @@ public static class PluginAssistantSecurityResolver
AvailabilityColor = GetAvailabilityColor(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false), AvailabilityColor = GetAvailabilityColor(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false),
AvailabilityIcon = GetAvailabilityIcon(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false), AvailabilityIcon = GetAvailabilityIcon(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false),
StatusLabel = GetAvailabilityLabel(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), BadgeIcon = GetSecurityBadgeIcon(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false),
Headline = requiresAudit ? TB("This assistant is currently locked.") : TB("This assistant currently has no stored audit."), Headline = requiresAudit ? TB("This assistant is currently locked.") : TB("This assistant currently has no stored audit."),
Description = requiresAudit Description = requiresAudit
@ -129,6 +180,7 @@ public static class PluginAssistantSecurityResolver
Plugin = plugin, Plugin = plugin,
Audit = audit, Audit = audit,
Settings = auditSettings, Settings = auditSettings,
Source = PluginAssistantSecurityStatusSource.NONE,
CurrentHash = currentHash, CurrentHash = currentHash,
HashMatches = false, HashMatches = false,
HasHashMismatch = true, HasHashMismatch = true,
@ -146,6 +198,9 @@ public static class PluginAssistantSecurityResolver
AvailabilityColor = GetAvailabilityColor(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false), AvailabilityColor = GetAvailabilityColor(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false),
AvailabilityIcon = GetAvailabilityIcon(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false), AvailabilityIcon = GetAvailabilityIcon(requiresAudit, hasAudit, hasHashMismatch, isBlocked, canOverride: false),
StatusLabel = GetAvailabilityLabel(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), 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."), Headline = requiresAudit ? TB("This assistant is locked until it is audited again.") : TB("This assistant changed after its last audit."),
Description = requiresAudit Description = requiresAudit
@ -167,6 +222,7 @@ public static class PluginAssistantSecurityResolver
Plugin = plugin, Plugin = plugin,
Audit = audit, Audit = audit,
Settings = auditSettings, Settings = auditSettings,
Source = PluginAssistantSecurityStatusSource.USER_AUDIT,
CurrentHash = currentHash, CurrentHash = currentHash,
HashMatches = true, HashMatches = true,
HasHashMismatch = false, HasHashMismatch = false,
@ -184,6 +240,9 @@ public static class PluginAssistantSecurityResolver
AvailabilityColor = GetAvailabilityColor(requiresAudit: false, hasAudit, hasHashMismatch: false, isBlockedByMinimum, canOverride), AvailabilityColor = GetAvailabilityColor(requiresAudit: false, hasAudit, hasHashMismatch: false, isBlockedByMinimum, canOverride),
AvailabilityIcon = GetAvailabilityIcon(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), 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), BadgeIcon = GetSecurityBadgeIcon(requiresAudit: false, hasAudit, hasHashMismatch: false, isBlockedByMinimum, canOverride),
Headline = isBlockedByMinimum Headline = isBlockedByMinimum
? TB("This assistant is currently locked.") ? TB("This assistant is currently locked.")
@ -208,6 +267,7 @@ public static class PluginAssistantSecurityResolver
Plugin = plugin, Plugin = plugin,
Audit = audit, Audit = audit,
Settings = auditSettings, Settings = auditSettings,
Source = PluginAssistantSecurityStatusSource.USER_AUDIT,
CurrentHash = currentHash, CurrentHash = currentHash,
HashMatches = true, HashMatches = true,
HasHashMismatch = false, HasHashMismatch = false,
@ -225,6 +285,9 @@ public static class PluginAssistantSecurityResolver
AvailabilityColor = GetAvailabilityColor(requiresAudit: false, hasAudit, hasHashMismatch: false, isBlocked: false, canOverride: false), AvailabilityColor = GetAvailabilityColor(requiresAudit: false, hasAudit, hasHashMismatch: false, isBlocked: false, canOverride: false),
AvailabilityIcon = GetAvailabilityIcon(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), 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), BadgeIcon = GetSecurityBadgeIcon(requiresAudit: false, hasAudit, hasHashMismatch: false, isBlocked: false, canOverride: false),
Headline = TB("This assistant is currently unlocked."), 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()), 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 PluginAssistants Plugin { get; init; } = null!;
public PluginAssistantAudit? Audit { get; init; } public PluginAssistantAudit? Audit { get; init; }
public DataAssistantPluginEnterpriseApproval? EnterpriseApproval { get; init; }
public DataAssistantPluginAudit Settings { get; init; } = new(); public DataAssistantPluginAudit Settings { get; init; } = new();
public PluginAssistantSecurityStatusSource Source { get; init; } = PluginAssistantSecurityStatusSource.NONE;
public string CurrentHash { get; init; } = string.Empty; public string CurrentHash { get; init; } = string.Empty;
public bool HasAudit => this.Audit is not null; public bool HasAudit => this.Audit is not null;
public bool IsEnterpriseApproved => this.Source is PluginAssistantSecurityStatusSource.ENTERPRISE_APPROVAL;
public bool HashMatches { get; init; } public bool HashMatches { get; init; }
public bool HasHashMismatch { get; init; } public bool HasHashMismatch { get; init; }
public bool IsBelowMinimum { get; init; } public bool IsBelowMinimum { get; init; }
@ -32,6 +35,9 @@ public sealed class PluginAssistantSecurityState
public Color AvailabilityColor { get; init; } = Color.Info; public Color AvailabilityColor { get; init; } = Color.Info;
public string AvailabilityIcon { get; init; } = MudBlazor.Icons.Material.Filled.Lock; public string AvailabilityIcon { get; init; } = MudBlazor.Icons.Material.Filled.Lock;
public string StatusLabel { get; init; } = string.Empty; 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 Headline { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty; public string Description { get; init; } = string.Empty;
public Color StatusColor { get; init; } = Color.Info; 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;
using AIStudio.Tools.PluginSystem.Assistants.DataModel.Layout; using AIStudio.Tools.PluginSystem.Assistants.DataModel.Layout;
using Lua; using Lua;
using System.Security.Cryptography;
using System.Text; using System.Text;
using AssistantPluginHash = SharedTools.AssistantPluginHash;
namespace AIStudio.Tools.PluginSystem.Assistants; 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 /// sequence of relative path length, relative path, content length, and content
/// for each file in ordinal path order. /// for each file in ordinal path order.
/// </summary> /// </summary>
public string ComputeAuditHash() public string ComputeAuditHash() => AssistantPluginHash.Compute(this.PluginPath);
{
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);
}
private static string BuildSecureSystemPrompt(string pluginSystemPrompt) private static string BuildSecureSystemPrompt(string pluginSystemPrompt)
{ {

View File

@ -1,3 +1,4 @@
using System.Globalization;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
@ -205,6 +206,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: data source security settings // Config: data source security settings
ManagedConfiguration.TryProcessConfiguration(x => x.DataSourceSecurity, x => x.TrustedProviderIds, this.Id, settingsTable, dryRun); 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: // Handle configured LLM providers:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun); 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; 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) private void TryReadMandatoryInfos(LuaTable mainTable)
{ {
if (!mainTable.TryGetValue("MANDATORY_INFOS", out var mandatoryInfosValue) || !mandatoryInfosValue.TryRead<LuaTable>(out var mandatoryInfosTable)) if (!mainTable.TryGetValue("MANDATORY_INFOS", out var mandatoryInfosValue) || !mandatoryInfosValue.TryRead<LuaTable>(out var mandatoryInfosTable))

View File

@ -320,6 +320,10 @@ public static partial class PluginFactory
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.AutomaticallyAuditAssistants, AVAILABLE_PLUGINS)) if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.AutomaticallyAuditAssistants, AVAILABLE_PLUGINS))
wasConfigurationChanged = true; wasConfigurationChanged = true;
// Check enterprise-managed assistant plugin approvals
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if (wasConfigurationChanged) if (wasConfigurationChanged)
{ {
await SettingsManagerAccess.StoreSettings(); await SettingsManagerAccess.StoreSettings();

View File

@ -1,4 +1,7 @@
using System.Text; using System.Text;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem.Assistants;
namespace AIStudio.Tools.PluginSystem; namespace AIStudio.Tools.PluginSystem;
@ -79,11 +82,36 @@ public static partial class PluginFactory
} }
} }
LogAssistantPluginStartupState();
// Inform all components that the plugins have been reloaded or started: // Inform all components that the plugins have been reloaded or started:
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.PLUGINS_RELOADED); await MessageBus.INSTANCE.SendMessage<bool>(null, Event.PLUGINS_RELOADED);
return configObjects; 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) private static async Task<PluginBase> Start(IAvailablePlugin meta, CancellationToken cancellationToken = default)
{ {
var pluginMainFile = Path.Join(meta.LocalPath, "plugin.lua"); var pluginMainFile = Path.Join(meta.LocalPath, "plugin.lua");

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 LLM providers (self-hosted or cloud providers with encrypted API keys)
- Any number of transcription providers for voice-to-text functionality - Any number of transcription providers for voice-to-text functionality
- Any number of embedding providers for RAG - Any number of embedding providers for RAG
- Enterprise hash approvals for assistant plugins
- The update behavior of AI Studio - The update behavior of AI Studio
- Various UI and feature settings (see the example configuration for details) - 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 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 ## 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. 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.