diff --git a/app/Build/Commands/AssistantPluginHashCommand.cs b/app/Build/Commands/AssistantPluginHashCommand.cs new file mode 100644 index 00000000..8d7552e4 --- /dev/null +++ b/app/Build/Commands/AssistantPluginHashCommand.cs @@ -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"] = "","""); + Console.WriteLine(""" ["ApprovedBy"] = "","""); + Console.WriteLine($""" ["ApprovedAtUtc"] = "{approvedAtUtc}","""); + Console.WriteLine(" }"); + Console.WriteLine("}"); + } +} diff --git a/app/Build/Program.cs b/app/Build/Program.cs index e7744b36..f56078de 100644 --- a/app/Build/Program.cs +++ b/app/Build/Program.cs @@ -6,4 +6,5 @@ app.AddCommands(); app.AddCommands(); app.AddCommands(); app.AddCommands(); -app.Run(); \ No newline at end of file +app.AddCommands(); +app.Run(); diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 0bfe6563..bc47539e 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -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." diff --git a/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor b/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor index 01012365..a43c0c34 100644 --- a/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor +++ b/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor @@ -33,6 +33,12 @@ @state.AuditLabel + @if (!string.IsNullOrWhiteSpace(state.SourceLabel)) + { + + @state.SourceLabel + + } @if (!string.IsNullOrWhiteSpace(state.AvailabilityLabel)) { @@ -53,18 +59,28 @@ - - - @T("Confidence"): - - - @this.GetConfidenceLabel() - - + @if (state.IsEnterpriseApproved) + { + + + @T("Enterprise approval is active") + + } + else + { + + + @T("Confidence"): + + + @this.GetConfidenceLabel() + + + } @@ -104,12 +120,63 @@ @this.Plugin.Id + + + @T("Status source") + + @state.SourceLabel + @T("Current hash") @GetShortHash(state.CurrentHash) + @if (state.EnterpriseApproval is not null) + { + + + @T("Approved hash") + + @GetShortHash(state.EnterpriseApproval.PluginHash) + + @if (!string.IsNullOrWhiteSpace(state.EnterpriseApproval.DisplayName)) + { + + + @T("Approval name") + + @state.EnterpriseApproval.DisplayName + + } + @if (!string.IsNullOrWhiteSpace(state.EnterpriseApproval.ApprovedBy)) + { + + + @T("Approved by") + + @state.EnterpriseApproval.ApprovedBy + + } + @if (state.EnterpriseApproval.ApprovedAtUtc is not null) + { + + + @T("Approved at") + + @this.FormatFileTimestamp(state.EnterpriseApproval.ApprovedAtUtc.Value.ToLocalTime().DateTime) + + } + @if (!string.IsNullOrWhiteSpace(state.EnterpriseApproval.Comment)) + { + + + @T("Approval comment") + + @state.EnterpriseApproval.Comment + + } + } @if (state.Audit is not null) { @@ -156,9 +223,18 @@ @if (state.Audit is null) { - - @T("No stored audit details are available yet.") - + @if (state.IsEnterpriseApproved) + { + + @T("This plugin is approved by your organization. A manual security audit is not required.") + + } + else + { + + @T("No stored audit details are available yet.") + + } } else if (state.Audit.Findings.Count == 0) { diff --git a/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor.cs b/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor.cs index 412ca3e8..d1d56291 100644 --- a/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor.cs +++ b/app/MindWork AI Studio/Components/AssistantPluginSecurityCard.razor.cs @@ -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") diff --git a/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor b/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor index 637f3329..64867e09 100644 --- a/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor +++ b/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor @@ -12,9 +12,18 @@ else { - - @T("This security check uses a sample prompt preview. Empty or placeholder values in the preview are expected.") - + @if (this.securityState.IsEnterpriseApproved) + { + + @T("This assistant plugin is approved by your organization. A manual security audit is not required.") + + } + else + { + + @T("This security check uses a sample prompt preview. Empty or placeholder values in the preview are expected.") + + } @this.plugin.Name @@ -298,9 +307,12 @@ @(this.audit is null ? T("Cancel") : T("Close")) - - @T("Start Security Check") - + @if (!this.securityState.IsEnterpriseApproved) + { + + @T("Start Security Check") + + } @if (this.CanEnablePlugin) { diff --git a/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor.cs b/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor.cs index 122b6e40..2d85e400 100644 --- a/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/AssistantPluginAuditDialog.razor.cs @@ -37,6 +37,7 @@ public partial class AssistantPluginAuditDialog : MSGComponentBase private IReadOnlyCollection> 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 { diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index aa4241c7..f14dd2f9 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -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 "" --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, diff --git a/app/MindWork AI Studio/Settings/DataModel/DataAssistantPluginAudit.cs b/app/MindWork AI Studio/Settings/DataModel/DataAssistantPluginAudit.cs index 6e25b310..599e87a6 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataAssistantPluginAudit.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataAssistantPluginAudit.cs @@ -39,5 +39,10 @@ public sealed class DataAssistantPluginAudit(Expression /// If true, the security audit will be hidden from the user and done in the background /// - public bool AutomaticallyAuditAssistants { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AutomaticallyAuditAssistants, true); + public bool AutomaticallyAuditAssistants { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AutomaticallyAuditAssistants, false); + + /// + /// Enterprise-managed assistant plugin hashes that are approved without requiring a user audit. + /// + public IList EnterpriseApprovedPlugins { get; set; } = ManagedConfiguration.Register(configSelection, n => n.EnterpriseApprovedPlugins, []); } diff --git a/app/MindWork AI Studio/Settings/DataModel/DataAssistantPluginEnterpriseApproval.cs b/app/MindWork AI Studio/Settings/DataModel/DataAssistantPluginEnterpriseApproval.cs new file mode 100644 index 00000000..12aa7d38 --- /dev/null +++ b/app/MindWork AI Studio/Settings/DataModel/DataAssistantPluginEnterpriseApproval.cs @@ -0,0 +1,13 @@ +namespace AIStudio.Settings.DataModel; + +/// +/// Enterprise-managed approval entry for an assistant plugin hash. +/// +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; } +} diff --git a/app/MindWork AI Studio/Tools/CommonTools.cs b/app/MindWork AI Studio/Tools/CommonTools.cs index fd3542b5..efbd987a 100644 --- a/app/MindWork AI Studio/Tools/CommonTools.cs +++ b/app/MindWork AI Studio/Tools/CommonTools.cs @@ -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; } } diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistantSecurityResolver.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistantSecurityResolver.cs index 8259bb29..cc517f68 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistantSecurityResolver.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistantSecurityResolver.cs @@ -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()), diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistantSecurityState.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistantSecurityState.cs index 937b3737..fe8638a2 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistantSecurityState.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistantSecurityState.cs @@ -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; diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistantSecurityStatusSource.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistantSecurityStatusSource.cs new file mode 100644 index 00000000..8a43dc7a --- /dev/null +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistantSecurityStatusSource.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Tools.PluginSystem.Assistants; + +public enum PluginAssistantSecurityStatusSource +{ + NONE, + USER_AUDIT, + ENTERPRISE_APPROVAL, +} diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistants.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistants.cs index a224746f..488acddf 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistants.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/PluginAssistants.cs @@ -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. /// - 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) { diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index 92634f7b..e800380f 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -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> configMeta)) + return; + + var settingName = SettingsManager.ToSettingName>(x => x.EnterpriseApprovedPlugins); + var successful = false; + IList configuredApprovals = []; + + if (settingsTable.TryGetValue(settingName, out var configuredLuaValue) + && configuredLuaValue.Type is LuaValueType.Table + && configuredLuaValue.TryRead(out var approvalsTable)) + { + var approvals = new List(approvalsTable.ArrayLength); + for (var index = 1; index <= approvalsTable.ArrayLength; index++) + { + var entryValue = approvalsTable[index]; + if (entryValue.TryRead(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(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(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(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(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(out var mandatoryInfosTable)) diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index 3f74c556..5fa0a81d 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -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) { diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs index 3df2e224..45c8f134 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Starting.cs @@ -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(null, Event.PLUGINS_RELOADED); return configObjects; } + + private static void LogAssistantPluginStartupState() + { + ManagedConfiguration.TryGet(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, out ConfigMeta>? 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()) + { + 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 Start(IAvailablePlugin meta, CancellationToken cancellationToken = default) { diff --git a/app/SharedTools/AssistantPluginHash.cs b/app/SharedTools/AssistantPluginHash.cs new file mode 100644 index 00000000..abf116b0 --- /dev/null +++ b/app/SharedTools/AssistantPluginHash.cs @@ -0,0 +1,40 @@ +using System.Security.Cryptography; +using System.Text; + +namespace SharedTools; + +/// +/// Computes the canonical assistant-plugin hash across all Lua files in a plugin directory. +/// +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())); + } +} diff --git a/documentation/Enterprise IT.md b/documentation/Enterprise IT.md index d5f626d9..889f397e 100644 --- a/documentation/Enterprise IT.md +++ b/documentation/Enterprise IT.md @@ -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 "" --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.