Added support for organization-approved assistant plugins (#821)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Peer Hogeterp authored and GitHub committed 2026-07-05 15:20:29 +02:00
1 parent 972ed73fe8
commit 13cc2f837a
35 files changed
+954 -62

No files matched your search

@@ -0,0 +1,7 @@
namespace AIStudio.Tools.PluginSystem.Assistants;
public enum AssistantPluginLaunchBehavior
{
NONE,
OPEN_WORKSPACE_CHAT_BY_NAME,
}
@@ -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("Approved by your organization"),
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()),
@@ -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;
@@ -0,0 +1,8 @@
namespace AIStudio.Tools.PluginSystem.Assistants;
public enum PluginAssistantSecurityStatusSource
{
NONE,
USER_AUDIT,
ENTERPRISE_APPROVAL,
}
@@ -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;
@@ -36,6 +36,9 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
public bool AllowProfiles { get; private set; } = true;
public bool HasEmbeddedProfileSelection { get; private set; }
public bool HasCustomPromptBuilder => this.buildPromptFunction is not null;
public AssistantPluginLaunchBehavior LaunchBehavior { get; private set; }
public string LaunchWorkspaceName { get; private set; } = string.Empty;
public bool StartsChatDirectly => this.LaunchBehavior is AssistantPluginLaunchBehavior.OPEN_WORKSPACE_CHAT_BY_NAME;
public const int TEXT_AREA_MAX_VALUE = 524288;
private LuaFunction? buildPromptFunction;
@@ -61,6 +64,8 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
message = string.Empty;
this.HasEmbeddedProfileSelection = false;
this.buildPromptFunction = null;
this.LaunchBehavior = AssistantPluginLaunchBehavior.NONE;
this.LaunchWorkspaceName = string.Empty;
this.RegisterLuaHelpers();
@@ -123,6 +128,12 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
this.SubmitText = assistantSubmitText;
this.AllowProfiles = assistantAllowProfiles;
if (!this.TryReadLaunchConfiguration(assistantTable, out var launchConfigIssue))
{
message = launchConfigIssue;
return false;
}
// Ensure that the UI table exists nested in the ASSISTANT table and is a valid Lua table:
if (!assistantTable.TryGetValue("UI", out var uiVal) || !uiVal.TryRead<LuaTable>(out var uiTable))
{
@@ -140,6 +151,51 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
return true;
}
private bool TryReadLaunchConfiguration(LuaTable assistantTable, out string message)
{
message = string.Empty;
if (!assistantTable.TryGetValue("LaunchBehavior", out var launchBehaviorValue))
return true;
if (!launchBehaviorValue.TryRead<string>(out var launchBehaviorText) ||
!Enum.TryParse<AssistantPluginLaunchBehavior>(launchBehaviorText, true, out var launchBehavior))
{
message = TB("The ASSISTANT table contains an invalid LaunchBehavior value.");
return false;
}
this.LaunchBehavior = launchBehavior;
if (launchBehavior is AssistantPluginLaunchBehavior.NONE)
return true;
switch (launchBehavior)
{
case AssistantPluginLaunchBehavior.OPEN_WORKSPACE_CHAT_BY_NAME:
if (!assistantTable.TryGetValue("WorkspaceName", out var workspaceNameValue) ||
!workspaceNameValue.TryRead<string>(out var workspaceName))
{
message = TB("The ASSISTANT table contains the LaunchBehavior 'OPEN_WORKSPACE_CHAT_BY_NAME' but no valid WorkspaceName.");
return false;
}
workspaceName = workspaceName.Trim();
if (string.IsNullOrWhiteSpace(workspaceName))
{
message = TB("The ASSISTANT table contains an empty WorkspaceName for LaunchBehavior 'OPEN_WORKSPACE_CHAT_BY_NAME'.");
return false;
}
this.LaunchWorkspaceName = workspaceName;
return true;
default:
message = TB("The ASSISTANT table contains an unsupported LaunchBehavior value.");
return false;
}
}
public async Task<string?> TryBuildPromptAsync(LuaTable input, CancellationToken cancellationToken = default)
{
if (this.buildPromptFunction is null)
@@ -224,33 +280,7 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
/// sequence of relative path length, relative path, content length, and content
/// for each file in ordinal path order.
/// </summary>
public string ComputeAuditHash()
{
var luaFiles = this.ReadAllLuaFiles();
if (luaFiles.Count == 0)
return string.Empty;
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
foreach (var (relativePath, content) in luaFiles.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
var normalizedPath = relativePath.Replace('\\', '/');
var pathBytes = Encoding.UTF8.GetBytes(normalizedPath);
var contentBytes = Encoding.UTF8.GetBytes(content);
writer.Write(pathBytes.Length);
writer.Write(pathBytes);
writer.Write(contentBytes.Length);
writer.Write(contentBytes);
}
writer.Flush();
var bytes = SHA256.HashData(stream.ToArray());
return Convert.ToHexString(bytes);
}
public string ComputeAuditHash() => AssistantPluginHash.Compute(this.PluginPath);
private static string BuildSecureSystemPrompt(string pluginSystemPrompt)
{