mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-01 17:49:14 +00:00
Added enterprise-managed activation for assistant plugins (#939)
This commit is contained in:
parent
3940ad7f29
commit
c9b7f224cc
@ -3316,6 +3316,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T2906887599"
|
||||
-- No audit yet
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3138877447"] = "No audit yet"
|
||||
|
||||
-- Your organization requires this assistant to stay enabled
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3240350158"] = "Your organization requires this assistant to stay enabled"
|
||||
|
||||
-- Confidence
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3243388657"] = "Confidence"
|
||||
|
||||
@ -3325,6 +3328,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3424652889"
|
||||
-- Close
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3448155331"] = "Close"
|
||||
|
||||
-- Enabled by your organization, you may switch it off
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3528104897"] = "Enabled by your organization, you may switch it off"
|
||||
|
||||
-- No stored audit details are available yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3647137899"] = "No stored audit details are available yet."
|
||||
|
||||
@ -3340,6 +3346,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T3916957031"
|
||||
-- Audited at
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T4103354206"] = "Audited at"
|
||||
|
||||
-- Required by your organization
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T4148393979"] = "Required by your organization"
|
||||
|
||||
-- Approved hash
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T4170340306"] = "Approved hash"
|
||||
|
||||
@ -3352,6 +3361,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T4289123040"
|
||||
-- Audit hash
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T53507304"] = "Audit hash"
|
||||
|
||||
-- Activation
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T561695293"] = "Activation"
|
||||
|
||||
-- {0} Finding(s)
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTPLUGINSECURITYCARD::T631393016"] = "{0} Finding(s)"
|
||||
|
||||
@ -9019,6 +9031,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3143506997"] = "The assistant plugin
|
||||
-- An error occurred while sharing the plugin.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3184210266"] = "An error occurred while sharing the plugin."
|
||||
|
||||
-- Your organization requires this assistant to stay enabled
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3240350158"] = "Your organization requires this assistant to stay enabled"
|
||||
|
||||
-- Your organization has disabled exporting plugins.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3342440765"] = "Your organization has disabled exporting plugins."
|
||||
|
||||
|
||||
@ -65,6 +65,15 @@
|
||||
<MudIcon Icon="@Icons.Material.Filled.Business" Size="Size.Small" Color="@state.SourceColor" />
|
||||
<MudText Typo="Typo.body2">@T("Enterprise approval is active")</MudText>
|
||||
</MudStack>
|
||||
|
||||
@if (state.IsActivationEnforcedByOrganization)
|
||||
{
|
||||
<MudDivider Vertical="@true" FlexItem="@true" />
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Lock" Size="Size.Small" Color="Color.Success" />
|
||||
<MudText Typo="Typo.body2">@T("Your organization requires this assistant to stay enabled")</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -176,6 +185,21 @@
|
||||
<td><MudText Typo="Typo.body2">@state.EnterpriseApproval.Comment</MudText></td>
|
||||
</tr>
|
||||
}
|
||||
@if (state.IsActivationEnforcedByOrganization || state.IsActivatedByOrganizationDefault)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<MudText Typo="Typo.body2"><b>@T("Activation")</b></MudText>
|
||||
</td>
|
||||
<td>
|
||||
<MudText Typo="Typo.body2">
|
||||
@(state.IsActivationEnforcedByOrganization
|
||||
? T("Required by your organization")
|
||||
: T("Enabled by your organization, you may switch it off"))
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
@if (state.Audit is not null)
|
||||
{
|
||||
|
||||
@ -101,6 +101,15 @@ public partial class Plugins : MSGComponentBase
|
||||
|
||||
private async Task PluginActivationStateChanged(IPluginMetadata pluginMeta)
|
||||
{
|
||||
//
|
||||
// The switch is disabled for these, so this cannot be reached through the user interface. We
|
||||
// check anyway: removing the plugin from the enabled list would achieve nothing, because the
|
||||
// activation is decided live, but it would leave the settings in a state which claims the
|
||||
// opposite of what the user sees:
|
||||
//
|
||||
if (PluginFactory.IsAssistantActivationEnforced(pluginMeta.Id))
|
||||
return;
|
||||
|
||||
if (this.SettingsManager.IsPluginEnabled(pluginMeta))
|
||||
{
|
||||
this.SettingsManager.ConfigurationData.EnabledPlugins.Remove(pluginMeta.Id);
|
||||
@ -190,6 +199,10 @@ public partial class Plugins : MSGComponentBase
|
||||
|
||||
private bool IsActivationSwitchDisabled(IPluginMetadata pluginMeta, bool isEnabled)
|
||||
{
|
||||
// An assistant plugin your organization requires to stay enabled has no switch to offer:
|
||||
if (PluginFactory.IsAssistantActivationEnforced(pluginMeta.Id))
|
||||
return true;
|
||||
|
||||
if (isEnabled || pluginMeta.Type is not PluginType.ASSISTANT)
|
||||
return false;
|
||||
|
||||
@ -203,6 +216,9 @@ public partial class Plugins : MSGComponentBase
|
||||
|
||||
private string GetActivationTooltip(IPluginMetadata pluginMeta, bool isEnabled)
|
||||
{
|
||||
if (PluginFactory.IsAssistantActivationEnforced(pluginMeta.Id))
|
||||
return this.T("Your organization requires this assistant to stay enabled");
|
||||
|
||||
if (isEnabled)
|
||||
return this.T("Disable plugin");
|
||||
|
||||
|
||||
@ -610,6 +610,29 @@ CONFIG["SETTINGS"] = {}
|
||||
-- department configuration can approve additional assistant plugins without repeating
|
||||
-- the approvals of the base configuration. Each configuration keeps its own approvals,
|
||||
-- so removing one of them only withdraws the approvals it had granted.
|
||||
--
|
||||
-- An approval only says that a plugin is safe. Whether it is enabled is a second
|
||||
-- decision, and without the optional Activate field it stays with your colleagues: the
|
||||
-- assistant is approved, and everybody switches it on themselves. Set Activate to have
|
||||
-- AI Studio enable it instead. AllowUserOverride works as it does for every setting:
|
||||
-- without it, your colleagues cannot switch the assistant off; with it, you only provide
|
||||
-- a default, which AI Studio applies once and then leaves alone.
|
||||
--
|
||||
-- Activate AllowUserOverride Result
|
||||
-- ------------------------------------------------------------------------
|
||||
-- absent any approved, everybody enables it themselves
|
||||
-- true true enabled for everybody, may be switched off
|
||||
-- true absent enabled for everybody, cannot be switched off
|
||||
--
|
||||
-- Activating needs more than the approval: AI Studio only enables an assistant plugin
|
||||
-- your organization actually rolled out, i.e. one below .config or .config-tests, or one
|
||||
-- marked with DEPLOYED_USING_CONFIG_SERVER. An approval alone is matched by hash and would
|
||||
-- otherwise also cover a copy a user placed themselves, which you can neither update nor
|
||||
-- withdraw. Such a copy stays approved, but nobody's settings are changed for it.
|
||||
--
|
||||
-- When two of your configurations approve the same hash, any Activate wins, while the
|
||||
-- freedom to switch the assistant off survives only if every configuration asking for the
|
||||
-- activation grants it.
|
||||
-- CONFIG["SETTINGS"]["DataAssistantPluginAudit.EnterpriseApprovedPlugins"] = {
|
||||
-- {
|
||||
-- ["PluginHash"] = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF",
|
||||
@ -617,6 +640,8 @@ CONFIG["SETTINGS"] = {}
|
||||
-- ["Comment"] = "Optional comment",
|
||||
-- ["ApprovedBy"] = "Optional Approver",
|
||||
-- ["ApprovedAtUtc"] = "2026-07-02T09:30:00Z",
|
||||
-- ["Activate"] = true,
|
||||
-- ["AllowUserOverride"] = true,
|
||||
-- }
|
||||
-- }
|
||||
|
||||
|
||||
@ -85,6 +85,19 @@ public sealed class Data
|
||||
/// </summary>
|
||||
public List<PluginAssistantAudit> AssistantPluginAudits { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The assistant plugin hashes whose organization default for the activation was already applied.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An organization may enable an assistant plugin it approved while still letting the user switch
|
||||
/// it off again. That is a default, not a rule, so it must be applied exactly once: applying it on
|
||||
/// every start would keep switching the assistant back on against the user's decision. We remember
|
||||
/// the hashes it was applied for, and forget one as soon as no approval asks for it anymore, so a
|
||||
/// later rollout of the same plugin takes effect again. Activations the user may not override are
|
||||
/// not listed here: those are decided live and never touch the list of enabled plugins.
|
||||
/// </remarks>
|
||||
public List<string> AppliedEnterpriseAssistantActivations { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The next provider number to use.
|
||||
/// </summary>
|
||||
|
||||
@ -10,4 +10,27 @@ public sealed class DataAssistantPluginEnterpriseApproval
|
||||
public string Comment { get; init; } = string.Empty;
|
||||
public string ApprovedBy { get; init; } = string.Empty;
|
||||
public DateTimeOffset? ApprovedAtUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the organization wants this assistant plugin to be enabled, instead of leaving that
|
||||
/// to the user.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An approval only ever states that a plugin is safe. Enabling it is a separate decision, and
|
||||
/// without this field it stays with the user: a rolled-out assistant is approved, but every
|
||||
/// colleague still has to switch it on. This field is how an organization makes that decision
|
||||
/// instead.
|
||||
/// </remarks>
|
||||
public bool Activate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the user may switch an assistant plugin the organization activated off again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This follows the AllowUserOverride convention of every managed setting: without it, what the
|
||||
/// organization set is locked; with it, the organization only provides a default the user may
|
||||
/// change. It has no meaning of its own while Activate is false, because there is nothing to
|
||||
/// override then.
|
||||
/// </remarks>
|
||||
public bool AllowUserOverride { get; init; }
|
||||
}
|
||||
|
||||
@ -385,9 +385,16 @@ public sealed class SettingsManager
|
||||
/// <summary>
|
||||
/// Checks if the given plugin is enabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Which plugins are enabled is the user's decision, with two exceptions. Configuration plugins
|
||||
/// have no switch at all: they carry what an organization configured, so turning them off would
|
||||
/// mean opting out of that configuration. And an organization may require one of the assistant
|
||||
/// plugins it approved to stay enabled, which is decided live from its approvals rather than from
|
||||
/// the user's list.
|
||||
/// </remarks>
|
||||
/// <param name="plugin">The plugin to check.</param>
|
||||
/// <returns>True, when the plugin is enabled, false otherwise.</returns>
|
||||
public bool IsPluginEnabled(IPluginMetadata plugin) => plugin.Type is PluginType.CONFIGURATION || this.ConfigurationData.EnabledPlugins.Contains(plugin.Id);
|
||||
public bool IsPluginEnabled(IPluginMetadata plugin) => plugin.Type is PluginType.CONFIGURATION || this.ConfigurationData.EnabledPlugins.Contains(plugin.Id) || PluginFactory.IsAssistantActivationEnforced(plugin.Id);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the active language plugin.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
- Added protection against prompt injection. Documents, web pages, and retrieved content can carry instructions written for the AI rather than for you, for example, text telling it to ignore its rules or to hand over its instructions. AI Studio now always removes such passages before the content reaches a model, while the rest of your document stays intact and usable. When something was removed, AI Studio tells you and can show you which passages it took out. You can turn off the detailed dialog in the app settings. For IT departments: the new setting `DataApp.ShowPromptInjectionAlert` lets you configure the detailed dialog for your organization. Many thanks to Sabrina `Sabrina-devops` for implementing this feature and to Simon `SimonBpunkt` for his work on the detection patterns and their translations.
|
||||
- Added configurable direct-chat launchers for assistant plugins. Plugin authors and the Assistant Builder can now open a chat with a chosen workspace, provider, profile, chat template, and data sources, while unavailable or unauthorized selections are reported before a chat is created.
|
||||
- Added provider logos throughout AI Studio, making models easier to recognize at a glance. Configuration plugins can now give managed LLM, transcription, and embedding providers their own project icon with the optional `IconPath` field.
|
||||
- Added the option for IT departments to enable assistant plugins they rolled out. Approving an assistant only stated that it is safe, so everybody still had to switch it on themselves. An approval can now also enable the assistant, either as a default which you may switch off again, or in a way your IT department keeps in place. The plugin page and the security card of the assistant tell you which of the two applies.
|
||||
- Improved the safety of plugin symbols: AI Studio now shows the symbol of a plugin in isolation, so nothing inside a symbol can reach the rest of the app.
|
||||
- Improved how much memory AI Studio needs. Working with large documents used to grow the app to several gigabytes, and on macOS that memory was never handed back. AI Studio now stays at a fraction of that and returns memory to your system. This matters most on devices with little memory, such as a Raspberry Pi.
|
||||
- Improved the preview for large documents. It now shows you the beginning of your document instead of loading all of it, so the dialog opens right away. Your complete document still goes to the AI.
|
||||
|
||||
@ -395,7 +395,7 @@ AI Studio extracts the whole tree and picks up every `plugin.lua` in it. A few r
|
||||
- **Only the configuration plugin carries the configuration ID.** Every other plugin has its own `ID`, as any plugin does. The archive does not have to contain a configuration plugin at all: an archive that only ships an assistant plugin is fine.
|
||||
- **Everything in the archive belongs to your organization.** Users cannot delete, edit, share, or replace any of it, whatever the individual plugins declare about themselves.
|
||||
- **The withdrawal takes the whole deployment.** Once you stop referencing the configuration ID, AI Studio removes that directory including every plugin you shipped in it. See [Withdrawing a configuration](#withdrawing-a-configuration).
|
||||
- **Assistant plugins still need an approval or an audit.** Deploying an assistant does not approve it. List its hash in `CONFIG["SETTINGS"]["DataAssistantPluginAudit.EnterpriseApprovedPlugins"]` of a configuration you deploy, otherwise users have to run a local security audit before they can activate it. See [Enterprise approval for assistant plugins](#enterprise-approval-for-assistant-plugins).
|
||||
- **Assistant plugins still need an approval or an audit.** Deploying an assistant does not approve it. List its hash in `CONFIG["SETTINGS"]["DataAssistantPluginAudit.EnterpriseApprovedPlugins"]` of a configuration you deploy, otherwise users have to run a local security audit before they can activate it. An approval does not enable the assistant either; add `Activate` to the approval for that. See [Enterprise approval for assistant plugins](#enterprise-approval-for-assistant-plugins) and [Enabling an assistant plugin for your colleagues](#enabling-an-assistant-plugin-for-your-colleagues).
|
||||
|
||||
If you would rather not use the configuration server for this, roll the plugin out with your MDM solution into the ordinary plugin directory and mark it with `DEPLOYED_USING_CONFIG_SERVER = true`. It is then protected against changes just the same, but it is not tied to a configuration ID, so you have to remove it the same way you placed it.
|
||||
|
||||
@ -471,7 +471,47 @@ CONFIG["SETTINGS"]["DataAssistantPluginAudit.EnterpriseApprovedPlugins"] = {
|
||||
}
|
||||
```
|
||||
|
||||
`PluginHash` is required. All other fields are optional and are shown in the UI as approval metadata.
|
||||
`PluginHash` is required. All other fields are optional. `DisplayName`, `Comment`, `ApprovedBy`, and `ApprovedAtUtc` are shown in the UI as approval metadata; `Activate` and `AllowUserOverride` are described in the next section.
|
||||
|
||||
### Enabling an assistant plugin for your colleagues
|
||||
|
||||
An approval only says that an assistant plugin is safe. Whether it is enabled is a second decision, and it stays with your colleagues unless you make it: after a rollout the assistant is approved, but everybody still has to find it on the plugin page and switch it on. Two optional fields of an approval let you make that decision instead:
|
||||
|
||||
```lua
|
||||
CONFIG["SETTINGS"]["DataAssistantPluginAudit.EnterpriseApprovedPlugins"] = {
|
||||
{
|
||||
["PluginHash"] = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF",
|
||||
["DisplayName"] = "Corporate Translation Assistant",
|
||||
["Activate"] = true,
|
||||
["AllowUserOverride"] = true,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`AllowUserOverride` works exactly as it does for every other managed setting: without it, what you set is locked; with it, you only provide a default.
|
||||
|
||||
| `Activate` | `AllowUserOverride` | Result |
|
||||
| --- | --- | --- |
|
||||
| absent | any | Approved. Everybody enables the assistant themselves. This is the behavior of every approval written before these fields existed. |
|
||||
| `true` | `true` | AI Studio enables the assistant once. Your colleagues may switch it off again, and their decision survives every restart. |
|
||||
| `true` | absent | AI Studio enables the assistant and keeps it enabled. The switch on the plugin page is greyed out, and the security card says why. |
|
||||
|
||||
A default is applied exactly once per plugin, not on every start: otherwise it would keep overruling a colleague who deliberately switched the assistant off. AI Studio forgets that it applied the default as soon as no approval asks for it anymore, so rolling the same plugin out again later takes effect again.
|
||||
|
||||
#### Activating needs the rollout, not only the approval
|
||||
|
||||
AI Studio only enables an assistant plugin your organization actually rolled out: one below `.config` or `.config-tests`, or one you marked with `DEPLOYED_USING_CONFIG_SERVER`, as described in [Deploying other plugin types](#deploying-other-plugin-types).
|
||||
|
||||
The reason is the hash. An approval is matched by the plugin content alone, so it also covers a byte-identical copy a user placed in their own plugin directory. For an approval that is correct, because the hash is the code. For enabling a plugin on somebody's behalf it is not enough: you would be enforcing a copy you never shipped, cannot update, and cannot withdraw. Such a copy therefore stays approved, and nobody's settings are changed for it. AI Studio notes this in the log.
|
||||
|
||||
#### When several configurations approve the same plugin
|
||||
|
||||
`Activate` and `AllowUserOverride` are combined in opposite directions, so a department cannot quietly take back what your base configuration locked:
|
||||
|
||||
- One configuration asking for the activation is enough. Not asking for it says nothing against it.
|
||||
- The freedom to switch the assistant off survives only when every configuration that asks for the activation grants it.
|
||||
|
||||
An approval that does not ask for the activation at all says nothing about that freedom and is not counted.
|
||||
|
||||
### Generating the hash
|
||||
|
||||
@ -509,7 +549,7 @@ Place the files **while AI Studio is running**: the test directory is emptied wh
|
||||
1. Start AI Studio. It creates `<data directory>/plugins/.config-tests/` if it does not exist yet.
|
||||
2. Create a directory below it and place your `plugin.lua` there, e.g. `.config-tests/my-department-draft/`. The directory name is up to you here: a plugin is identified by the `ID` field inside it, not by the directory it lives in.
|
||||
3. Place every other plugin of the deployment in a subdirectory of it, e.g. `.config-tests/my-department-draft/translation-assistant/`. This mirrors the archive you will serve later, as described in [Deploying other plugin types](#deploying-other-plugin-types).
|
||||
4. AI Studio watches the plugin directory and picks everything up without a restart. The security card of the assistant then states that your organization approved it, exactly as it will after the rollout.
|
||||
4. AI Studio watches the plugin directory and picks everything up without a restart. The security card of the assistant then states that your organization approved it, exactly as it will after the rollout. If your approval sets `Activate`, the assistant is enabled right away, so you see the state your colleagues will start from.
|
||||
|
||||
You can also keep an assistant plugin you are only iterating on in `<data directory>/plugins/assistants/<any name>/`. Your test configuration approves it by hash either way. The difference is that a plugin outside `.config-tests` is not protected against the user, so this variant no longer mirrors the later rollout.
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user