Added enterprise-managed activation for assistant plugins (#939)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-08-29 14:12:14 +02:00
1 parent 3940ad7f29
commit c9b7f224cc
13 files changed
+462 -17

No files matched your search

@@ -19,6 +19,22 @@ public sealed class PluginAssistantSecurityState
public string CurrentHash { get; init; } = string.Empty;
public bool HasAudit => this.Audit is not null;
public bool IsEnterpriseApproved => this.Source is PluginAssistantSecurityStatusSource.ENTERPRISE_APPROVAL;
/// <summary>
/// Whether your organization requires this assistant plugin to stay enabled.
/// </summary>
/// <remarks>
/// This asks the plugin factory instead of reading the approval, because an approval alone does
/// not activate anything: it is matched by hash, so it also covers a copy of the plugin your
/// organization never rolled out. The factory is the one place which knows both.
/// </remarks>
public bool IsActivationEnforcedByOrganization => PluginFactory.IsAssistantActivationEnforced(this.Plugin.Id);
/// <summary>
/// Whether your organization enabled this assistant plugin for you, leaving you free to switch it
/// off again.
/// </summary>
public bool IsActivatedByOrganizationDefault => PluginFactory.IsAssistantActivationOrganizationDefault(this.Plugin.Id);
public bool HashMatches { get; init; }
public bool HasHashMismatch { get; init; }
public bool IsBelowMinimum { get; init; }
@@ -410,7 +410,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
approvals.Add(approval);
}
configuredApprovals = approvals;
// A configuration may list the same hash more than once, e.g. once to describe the
// plugin and once to activate it. Combine those before anything else sees them:
configuredApprovals = CombineApprovals(approvals);
successful = true;
}
@@ -453,11 +455,7 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Merge into the stored list right away, so the approvals of this plugin take
// effect immediately. PluginFactory.LoadAll recomputes the authoritative list once
// every configuration plugin has contributed:
var mergedApprovals = new List<DataAssistantPluginEnterpriseApproval>(configMeta.GetValue());
var knownHashes = mergedApprovals.Select(approval => approval.PluginHash).ToHashSet(StringComparer.Ordinal);
mergedApprovals.AddRange(configuredApprovals.Where(approval => knownHashes.Add(approval.PluginHash)));
configMeta.SetValue(mergedApprovals);
configMeta.SetValue(CombineApprovals(configMeta.GetValue().Concat(configuredApprovals)));
configMeta.LockConfiguration(this.Id);
break;
@@ -487,15 +485,12 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
if (!ManagedConfiguration.TryGet(x => x.AssistantPluginAudit, x => x.EnterpriseApprovedPlugins, out ConfigMeta<DataAssistantPluginAudit, IList<DataAssistantPluginEnterpriseApproval>> configMeta))
return false;
var effectiveApprovals = new List<DataAssistantPluginEnterpriseApproval>();
var effectiveHashes = new HashSet<string>(StringComparer.Ordinal);
foreach (var approval in configMeta.PluginContributions.Values.SelectMany(contribution => contribution))
if (effectiveHashes.Add(approval.PluginHash))
effectiveApprovals.Add(approval);
var effectiveApprovals = CombineApprovals(configMeta.PluginContributions.Values.SelectMany(contribution => contribution));
// Compare by hash, so a different order alone does not rewrite the settings on every start:
// Compare by what an approval decides, so a different order alone does not rewrite the
// settings on every start, while a changed activation does reach the user:
var currentApprovals = configMeta.GetValue();
if (currentApprovals.Count == effectiveApprovals.Count && effectiveHashes.SetEquals(currentApprovals.Select(approval => approval.PluginHash)))
if (HaveApprovalsSameEffect(currentApprovals, effectiveApprovals))
return false;
LOG.LogInformation($"The enterprise approvals for assistant plugins changed from {currentApprovals.Count} to {effectiveApprovals.Count} entries, contributed by {configMeta.PluginContributions.Count} configuration plugin(s).");
@@ -503,6 +498,111 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
return true;
}
/// <summary>
/// Reduces approvals of several configuration plugins to one entry per assistant plugin hash.
/// </summary>
/// <remarks>
/// Approving the same plugin twice is normal: a base configuration approves it for the whole
/// organization, and a department configuration lists it again to activate it. Keeping only the
/// entry seen first would silently drop what the other one asked for, and the contributions
/// carry no guaranteed order, so which one that is could differ from start to start.
/// </remarks>
/// <param name="approvals">The approvals of all configuration plugins, in any order.</param>
/// <returns>One approval per hash, in the order the hashes were first seen.</returns>
private static List<DataAssistantPluginEnterpriseApproval> CombineApprovals(IEnumerable<DataAssistantPluginEnterpriseApproval> approvals)
{
var combined = new List<DataAssistantPluginEnterpriseApproval>();
var positionByHash = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var approval in approvals)
{
if (positionByHash.TryGetValue(approval.PluginHash, out var position))
{
combined[position] = MergeApprovals(combined[position], approval);
continue;
}
positionByHash[approval.PluginHash] = combined.Count;
combined.Add(approval);
}
return combined;
}
/// <summary>
/// Combines two approvals of the same assistant plugin hash into a single one.
/// </summary>
/// <remarks>
/// The two activation fields are combined in opposite directions on purpose. One configuration
/// asking for the activation is enough to activate, because not asking for it says nothing
/// against it. The freedom to switch the assistant off again, however, only survives when every
/// configuration which does ask for the activation grants it: otherwise a department could take
/// back a lock the organization deliberately set. An approval which does not ask for the
/// activation at all expresses nothing about that freedom and is therefore not counted.<br/><br/>
/// The result of these two fields does not depend on the order the approvals arrive in. For the
/// descriptive fields, the first value which says anything wins, and the approval date is the
/// earliest one given: the plugin has been approved since then.
/// </remarks>
/// <param name="first">The approval seen first.</param>
/// <param name="second">The approval to combine it with.</param>
/// <returns>The combined approval.</returns>
private static DataAssistantPluginEnterpriseApproval MergeApprovals(DataAssistantPluginEnterpriseApproval first, DataAssistantPluginEnterpriseApproval second) => new()
{
PluginHash = first.PluginHash,
DisplayName = string.IsNullOrWhiteSpace(first.DisplayName) ? second.DisplayName : first.DisplayName,
Comment = string.IsNullOrWhiteSpace(first.Comment) ? second.Comment : first.Comment,
ApprovedBy = string.IsNullOrWhiteSpace(first.ApprovedBy) ? second.ApprovedBy : first.ApprovedBy,
ApprovedAtUtc = EarliestApprovalTime(first.ApprovedAtUtc, second.ApprovedAtUtc),
Activate = first.Activate || second.Activate,
AllowUserOverride = (first.Activate, second.Activate) switch
{
(true, true) => first.AllowUserOverride && second.AllowUserOverride,
(true, false) => first.AllowUserOverride,
(false, true) => second.AllowUserOverride,
_ => false,
},
};
private static DateTimeOffset? EarliestApprovalTime(DateTimeOffset? first, DateTimeOffset? second) => (first, second) switch
{
(null, _) => second,
(_, null) => first,
_ => first <= second ? first : second,
};
/// <summary>
/// Checks whether two approval lists decide the same thing for every assistant plugin.
/// </summary>
/// <remarks>
/// This is what tells a rewrite of the settings apart from a mere reordering of the same
/// approvals. Only the hash and the two activation fields are compared: the descriptive fields
/// change nothing about what an approval does, and rewriting the settings because a comment was
/// reworded would store the file on every start.
/// </remarks>
/// <param name="currentApprovals">The approvals currently stored in the settings.</param>
/// <param name="effectiveApprovals">The approvals recomputed from the contributions.</param>
/// <returns>True when both lists have the same effect, otherwise false.</returns>
private static bool HaveApprovalsSameEffect(IList<DataAssistantPluginEnterpriseApproval> currentApprovals, IList<DataAssistantPluginEnterpriseApproval> effectiveApprovals)
{
if (currentApprovals.Count != effectiveApprovals.Count)
return false;
var currentByHash = new Dictionary<string, DataAssistantPluginEnterpriseApproval>(StringComparer.Ordinal);
foreach (var approval in currentApprovals)
currentByHash[approval.PluginHash] = approval;
foreach (var effectiveApproval in effectiveApprovals)
{
if (!currentByHash.TryGetValue(effectiveApproval.PluginHash, out var currentApproval))
return false;
if (currentApproval.Activate != effectiveApproval.Activate || currentApproval.AllowUserOverride != effectiveApproval.AllowUserOverride)
return false;
}
return true;
}
private static bool TryParseEnterpriseApprovedAssistantPlugin(int index, LuaTable table, Guid configPluginId, out DataAssistantPluginEnterpriseApproval approval)
{
approval = new();
@@ -524,6 +624,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
var comment = TryReadOptionalString(table, "Comment");
var approvedBy = TryReadOptionalString(table, "ApprovedBy");
var approvedAtUtc = TryReadOptionalDateTimeOffset(table, "ApprovedAtUtc", index, configPluginId);
var activate = TryReadOptionalBool(table, "Activate", index, configPluginId);
var allowUserOverride = TryReadOptionalBool(table, "AllowUserOverride", index, configPluginId);
if (allowUserOverride && !activate)
LOG.LogWarning("The enterprise assistant approval entry at index {Index} allows the user to override an activation it never asks for. 'AllowUserOverride' has no effect without 'Activate' (config plugin id: {ConfigPluginId}).", index, configPluginId);
approval = new()
{
@@ -532,6 +637,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
Comment = comment,
ApprovedBy = approvedBy,
ApprovedAtUtc = approvedAtUtc,
Activate = activate,
AllowUserOverride = allowUserOverride,
};
return true;
}
@@ -543,6 +650,18 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
: string.Empty;
}
private static bool TryReadOptionalBool(LuaTable table, string key, int index, Guid configPluginId)
{
if (!table.TryGetValue(key, out var value))
return false;
if (value.TryRead<bool>(out var flag))
return flag;
LOG.LogWarning("The enterprise assistant approval entry at index {Index} contains an invalid {Key} value. Expected a boolean (config plugin id: {ConfigPluginId}).", index, key, configPluginId);
return false;
}
private static DateTimeOffset? TryReadOptionalDateTimeOffset(LuaTable table, string key, int index, Guid configPluginId)
{
if (!table.TryGetValue(key, out var value))
@@ -0,0 +1,138 @@
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem.Assistants;
namespace AIStudio.Tools.PluginSystem;
public static partial class PluginFactory
{
/// <summary>
/// The assistant plugins your organization enabled without leaving the user a way to switch them off.
/// </summary>
/// <remarks>
/// This is deliberately not persisted. Such an activation is decided live from the approvals of
/// your organization, so it ends the moment the approval does, without anything to clean up. The
/// field is replaced as a whole instead of being edited in place, so a reload never lets the user
/// interface observe a half-built state.
/// </remarks>
private static IReadOnlySet<Guid> ENFORCED_ASSISTANT_ACTIVATIONS = new HashSet<Guid>();
/// <summary>
/// The assistant plugins your organization enabled while leaving the user free to switch them off.
/// </summary>
/// <remarks>
/// This is not what decides the activation: such a default is applied once and then belongs to the
/// user, which is what the applied activations in the settings remember. We keep the plugins it
/// concerns so that the user interface can say where the activation came from, whether the default
/// was applied just now or during an earlier start.
/// </remarks>
private static IReadOnlySet<Guid> DEFAULT_ASSISTANT_ACTIVATIONS = new HashSet<Guid>();
/// <summary>
/// Whether your organization requires this assistant plugin to stay enabled.
/// </summary>
/// <param name="pluginId">The ID of the plugin in question.</param>
/// <returns>True when the user may not switch this assistant plugin off.</returns>
public static bool IsAssistantActivationEnforced(Guid pluginId) => ENFORCED_ASSISTANT_ACTIVATIONS.Contains(pluginId);
/// <summary>
/// Whether your organization enables this assistant plugin by default, leaving you free to switch
/// it off again.
/// </summary>
/// <param name="pluginId">The ID of the plugin in question.</param>
/// <returns>True when the organization asked for this assistant plugin to be enabled by default.</returns>
public static bool IsAssistantActivationOrganizationDefault(Guid pluginId) => DEFAULT_ASSISTANT_ACTIVATIONS.Contains(pluginId);
/// <summary>
/// Applies what the approvals of your organization say about enabling assistant plugins.
/// </summary>
/// <remarks>
/// Approving an assistant plugin only states that it is safe. Whether it is enabled is a second
/// decision, and an organization expresses it with the Activate field of an approval. Without that
/// field nothing changes: the plugin is approved, and the user switches it on.<br/><br/>
/// We read the approvals as they are stored, which is the same source the security card uses. They
/// survive a configuration plugin which failed to load, so one broken configuration cannot
/// silently withdraw what an organization enabled.<br/><br/>
/// Call this once all plugins are running and the effective approvals were recomputed.
/// </remarks>
/// <returns>True when the settings were changed and have to be stored, otherwise false.</returns>
private static bool RefreshEnterpriseAssistantActivations()
{
var approvalsByHash = new Dictionary<string, DataAssistantPluginEnterpriseApproval>(StringComparer.Ordinal);
foreach (var approval in SettingsManagerAccess.ConfigurationData.AssistantPluginAudit.EnterpriseApprovedPlugins)
approvalsByHash[NormalizeAssistantHash(approval.PluginHash)] = approval;
var appliedActivations = SettingsManagerAccess.ConfigurationData.AppliedEnterpriseAssistantActivations;
var enforcedActivations = new HashSet<Guid>();
var defaultActivations = new HashSet<Guid>();
var wasConfigurationChanged = false;
foreach (var assistantPlugin in RUNNING_PLUGINS.OfType<PluginAssistants>())
{
var pluginHash = NormalizeAssistantHash(assistantPlugin.ComputeAuditHash());
if (!approvalsByHash.TryGetValue(pluginHash, out var approval) || !approval.Activate)
continue;
//
// An approval is matched by its hash alone, without looking at where the plugin is stored:
// a plugin the user placed themselves counts as approved as soon as its Lua files are the
// ones the organization approved. For an approval that is right, because the hash is the
// code. For enabling a plugin on the user's behalf it is not enough: the organization would
// then enforce a copy it never rolled out, cannot update, and cannot withdraw again. So we
// ask for the rollout in addition to the approval:
//
var pluginMetadata = AVAILABLE_PLUGINS.FirstOrDefault(plugin => plugin.Id == assistantPlugin.Id);
if (pluginMetadata is not { IsManagedByConfigServer: true })
{
LOG.LogInformation($"Your organization asks for the assistant plugin '{assistantPlugin.Name}' (id '{assistantPlugin.Id}') to be enabled, but it did not deploy this copy of the plugin. Ignoring the activation: the approval stays in place, and you decide about enabling it.");
continue;
}
if (!approval.AllowUserOverride)
{
enforcedActivations.Add(assistantPlugin.Id);
LOG.LogInformation($"Your organization requires the assistant plugin '{assistantPlugin.Name}' (id '{assistantPlugin.Id}') to stay enabled.");
continue;
}
defaultActivations.Add(assistantPlugin.Id);
// An organization default is applied once. Afterwards the decision belongs to the user:
if (appliedActivations.Contains(pluginHash))
continue;
appliedActivations.Add(pluginHash);
wasConfigurationChanged = true;
if (SettingsManagerAccess.ConfigurationData.EnabledPlugins.Contains(assistantPlugin.Id))
continue;
SettingsManagerAccess.ConfigurationData.EnabledPlugins.Add(assistantPlugin.Id);
LOG.LogInformation($"Enabled the assistant plugin '{assistantPlugin.Name}' (id '{assistantPlugin.Id}') because your organization enables it by default. You may switch it off again.");
}
ENFORCED_ASSISTANT_ACTIVATIONS = enforcedActivations;
DEFAULT_ASSISTANT_ACTIVATIONS = defaultActivations;
//
// Forget the defaults we applied for plugins no approval asks for anymore. Otherwise, an
// organization which rolls the same plugin out again later would find its default silently
// ignored, because we would still consider it applied:
//
var leftOverActivations = appliedActivations.Where(hash => !IsOrganizationDefaultActivation(approvalsByHash, hash)).ToList();
foreach (var leftOverActivation in leftOverActivations)
{
appliedActivations.Remove(leftOverActivation);
wasConfigurationChanged = true;
}
if (leftOverActivations.Count > 0)
LOG.LogInformation($"Forgot {leftOverActivations.Count} applied organization default(s) for assistant plugin activations, because your organization does not ask for them anymore.");
return wasConfigurationChanged;
}
private static bool IsOrganizationDefaultActivation(Dictionary<string, DataAssistantPluginEnterpriseApproval> approvalsByHash, string pluginHash)
=> approvalsByHash.TryGetValue(pluginHash, out var approval) && approval is { Activate: true, AllowUserOverride: true };
private static string NormalizeAssistantHash(string hash) => string.IsNullOrWhiteSpace(hash) ? string.Empty : hash.Trim().ToUpperInvariant();
}
@@ -301,6 +301,14 @@ public static partial class PluginFactory
if(unloadedEnterpriseConfigPluginIds.Count == 0 && PluginConfiguration.RefreshEnterpriseApprovedAssistantPlugins())
wasConfigurationChanged = true;
//
// Now that the approvals are final, we know which assistant plugins your organization wants
// enabled. This needs no guard of its own: it reads the stored approvals, which stay in place
// when a configuration plugin could not be loaded:
//
if(RefreshEnterpriseAssistantActivations())
wasConfigurationChanged = true;
// Compatibility shim, see documentation/compatibility-shims/2026-08-orphaned-config-locks.md (remove after 2027-08-06):
if (RepairLegacyConfigOnlySettings(unloadedEnterpriseConfigPluginIds.Count > 0))
wasConfigurationChanged = true;