diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md
index 0867fa45e9..e378722ee2 100644
--- a/docs/WINDOWS_NODE_TESTING.md
+++ b/docs/WINDOWS_NODE_TESTING.md
@@ -66,6 +66,21 @@ owns the package upgrade and removal gates.
Every new Windows node call must be exposed through local MCP and `winnode`: register the capability, update `McpToolBridge.CommandDescriptions`, update `.agents/skills/winnode/SKILL.md`, add focused tests, and prove discovery/invocation with `winnode` or raw MCP JSON-RPC.
+### Permissions exec policy editor
+
+The Permissions page shows the `main`, wildcard (`*`), then defaults cascade
+and lists both wildcard and main allowlist entries. Removing a displayed entry
+edits its original bucket, not every matching pattern. A duplicate in the other
+bucket remains effective until it is removed separately. Adding a rule writes
+to `main` and preserves inherited wildcard security and ask settings, except
+for the existing transition from effective Deny to a main allowlist.
+
+Use an isolated profile for visual proof: seed distinguishable wildcard and
+main rules, remove the wildcard row, then reopen the page and confirm only
+that entry is gone from its original bucket. Also check equal patterns in both
+buckets and adding a main rule while inheriting wildcard Full. These editor
+checks do not require executing a command and are not MXC containment proof.
+
### 1. Settings Toggle
- Verify the toggle appears in Settings under "ADVANCED"
- Verify it saves and persists across app restarts
diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml
index 14d1c80b3e..90404144e1 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml
@@ -285,7 +285,7 @@
- new
- {
- Rule = rule,
- rule.Pattern,
- RemoveRuleAutomationName = $"Remove allowlist entry {rule.Pattern}",
- RemoveRuleAutomationId = $"RemoveExecPolicyRuleButton_{index}",
- Action = "allow",
- ActionBrush = (Brush)Application.Current.Resources["SystemFillColorSuccessBrush"],
+ var inactiveAction = LocalizationHelper.GetString("PermissionsPage_ExecRuleAction_Inactive");
+ var action = _viewModel.ExecApprovalRulesActive ? "allow" : inactiveAction;
+ var actionBrushKey = _viewModel.ExecApprovalRulesActive
+ ? "SystemFillColorSuccessBrush"
+ : "SystemFillColorNeutralBrush";
+ PolicyRulesList.ItemsSource = _viewModel.ExecApprovalRules.Select((rule, index) =>
+ {
+ var scopeLabel = LocalizationHelper.GetString(
+ rule.IsWildcard
+ ? "PermissionsPage_ExecRuleScope_Wildcard"
+ : "PermissionsPage_ExecRuleScope_Main");
+ return new
+ {
+ Rule = rule,
+ rule.Pattern,
+ DisplayText = $"{rule.Pattern} ({scopeLabel})",
+ RemoveRuleAutomationName = LocalizationHelper.Format(
+ "PermissionsPage_RemoveRuleAutomationNameFormat",
+ scopeLabel,
+ rule.Pattern),
+ RemoveRuleAutomationId = $"RemoveExecPolicyRuleButton_{index}",
+ Action = action,
+ ActionBrush = (Brush)Application.Current.Resources[actionBrushKey],
+ };
}).ToList();
var count = _viewModel.ExecApprovalRules.Count;
diff --git a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPagePresentationTypes.cs b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPagePresentationTypes.cs
index a2109096c0..ddea2edaee 100644
--- a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPagePresentationTypes.cs
+++ b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPagePresentationTypes.cs
@@ -68,7 +68,8 @@ internal sealed record PermissionsExecApprovalRule(
string Pattern,
string? ArgPattern,
double? LastUsedAt,
- string? LastResolvedPath);
+ string? LastResolvedPath,
+ bool IsWildcard);
internal sealed record PermissionsRuntimeSourceSnapshot(
GatewayConnectionSnapshot ConnectionSnapshot,
diff --git a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs
index f04674394e..9a23a9888a 100644
--- a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs
+++ b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs
@@ -57,6 +57,7 @@ internal sealed class PermissionsPageViewModel : INavigationAware, IDisposable,
private PermissionsGatewayAllowlistState _gatewayAllowlistState;
private string _defaultExecActionTag = "deny";
private IReadOnlyList _execApprovalRules = Array.Empty();
+ private bool _execApprovalRulesActive;
private ExecApprovalsSnapshotFailure? _execApprovalsFailure;
private PermissionsExecApprovalsStatus _execApprovalsStatus;
private long _execApprovalsStatusVersion;
@@ -136,6 +137,7 @@ public bool McpEnabled
public string DefaultExecActionTag => _defaultExecActionTag;
public IReadOnlyList ExecApprovalRules => _execApprovalRules;
+ public bool ExecApprovalRulesActive => _execApprovalRulesActive;
public ExecApprovalsSnapshotFailure? ExecApprovalsFailure => _execApprovalsFailure;
public PermissionsExecApprovalsStatus ExecApprovalsStatus => _execApprovalsStatus;
public long ExecApprovalsStatusVersion => _execApprovalsStatusVersion;
@@ -244,7 +246,8 @@ public Task RemoveExecApprovalRuleAsync(PermissionsExecApprovalRule rule)
null,
rule.Pattern,
rule.Id,
- rule.ArgPattern));
+ rule.ArgPattern,
+ rule.IsWildcard));
}
private void PersistSetting(Action edit)
@@ -623,6 +626,22 @@ private void CompleteExecApprovalsMutation(ExecApprovalsSnapshot updated, long m
private void ApplyMutation(ExecApprovalsFile file, ExecApprovalsMutation mutation)
{
+ if (mutation.Kind == ExecApprovalsMutationKind.RemoveRule)
+ {
+ if (file.Agents is not null
+ && file.Agents.TryGetValue(mutation.IsWildcard ? "*" : "main", out var agent))
+ {
+ agent?.Allowlist?.RemoveAll(entry =>
+ mutation.RuleId.HasValue
+ ? entry.Id == mutation.RuleId
+ : entry.Id is null
+ && string.Equals(entry.Pattern?.Trim(), mutation.Pattern, StringComparison.OrdinalIgnoreCase)
+ && string.Equals(entry.ArgPattern, mutation.ArgPattern, StringComparison.Ordinal));
+ }
+
+ return;
+ }
+
file.Version = 1;
file.Defaults ??= new ExecApprovalsDefaults();
file.Agents ??= new Dictionary(StringComparer.Ordinal);
@@ -654,10 +673,15 @@ private void ApplyMutation(ExecApprovalsFile file, ExecApprovalsMutation mutatio
var allowlist = main.Allowlist ??= new List();
if (mutation.Kind == ExecApprovalsMutationKind.AddRule)
{
- if ((main.Security ?? file.Defaults.Security ?? ExecSecurity.Deny) == ExecSecurity.Deny)
+ var displayed = ResolveDisplayedExecPolicy(file);
+ var inheritedDenyWithDormantRules = main.Security is null
+ && displayed.Security == ExecSecurity.Deny
+ && displayed.Ask != ExecAsk.Always
+ && displayed.Allowlist.Count > 0;
+ if (displayed.Security == ExecSecurity.Deny && !inheritedDenyWithDormantRules)
{
main.Security = ExecSecurity.Allowlist;
- main.Ask = ExecAsk.Off;
+ main.Ask = displayed.Ask;
main.AskFallback = ExecSecurity.Deny;
}
@@ -675,13 +699,6 @@ private void ApplyMutation(ExecApprovalsFile file, ExecApprovalsMutation mutatio
return;
}
-
- allowlist.RemoveAll(entry =>
- mutation.RuleId.HasValue
- ? entry.Id == mutation.RuleId
- : entry.Id is null
- && string.Equals(entry.Pattern?.Trim(), mutation.Pattern, StringComparison.OrdinalIgnoreCase)
- && string.Equals(entry.ArgPattern, mutation.ArgPattern, StringComparison.Ordinal));
}
private void LoadExecApprovals(ExecApprovalsReadOnlySnapshotResult result)
@@ -701,6 +718,7 @@ private void LoadExecApprovals(ExecApprovalsReadOnlySnapshotResult result)
_execApprovalsBaseHash = result.Failure?.Hash;
SetField(ref _defaultExecActionTag, "deny", nameof(DefaultExecActionTag));
SetField(ref _execApprovalRules, Array.Empty(), nameof(ExecApprovalRules));
+ SetField(ref _execApprovalRulesActive, false, nameof(ExecApprovalRulesActive));
SetExecApprovalsFailure(result.Failure, PermissionsExecApprovalsStatus.None, bumpVersion: false);
}
}
@@ -708,19 +726,21 @@ private void LoadExecApprovals(ExecApprovalsReadOnlySnapshotResult result)
private void ApplyExecSnapshot(ExecApprovalsSnapshot snapshot)
{
_execApprovalsBaseHash = snapshot.Hash;
- SetField(ref _defaultExecActionTag, MapDefaultAction(snapshot.File), nameof(DefaultExecActionTag));
- var rules = ((IEnumerable)(snapshot.File.Agents is not null
- && snapshot.File.Agents.TryGetValue("main", out var main)
- && main?.Allowlist is not null
- ? main.Allowlist
- : Array.Empty()))
- .Where(entry => !string.IsNullOrWhiteSpace(entry.Pattern))
- .Select(entry => new PermissionsExecApprovalRule(
- entry.Id,
- entry.Pattern!,
- entry.ArgPattern,
- entry.LastUsedAt,
- entry.LastResolvedPath))
+ var displayed = ResolveDisplayedExecPolicy(snapshot.File);
+ SetField(ref _defaultExecActionTag, MapDefaultAction(displayed), nameof(DefaultExecActionTag));
+ SetField(
+ ref _execApprovalRulesActive,
+ displayed.Security != ExecSecurity.Deny,
+ nameof(ExecApprovalRulesActive));
+ var rules = displayed.Allowlist
+ .Where(rule => !string.IsNullOrWhiteSpace(rule.Entry.Pattern))
+ .Select(rule => new PermissionsExecApprovalRule(
+ rule.Entry.Id,
+ rule.Entry.Pattern!,
+ rule.Entry.ArgPattern,
+ rule.Entry.LastUsedAt,
+ rule.Entry.LastResolvedPath,
+ rule.IsWildcard))
.ToArray();
SetField(ref _execApprovalRules, rules, nameof(ExecApprovalRules));
}
@@ -758,20 +778,41 @@ private void HandleOnUiThread(Action action)
_ => null,
};
- private static string MapDefaultAction(ExecApprovalsFile file)
+ private readonly record struct DisplayedExecPolicy(
+ ExecSecurity Security,
+ ExecAsk Ask,
+ IReadOnlyList<(ExecAllowlistEntry Entry, bool IsWildcard)> Allowlist);
+
+ private static DisplayedExecPolicy ResolveDisplayedExecPolicy(ExecApprovalsFile file)
{
- file.Defaults ??= new ExecApprovalsDefaults();
- file.Agents ??= new Dictionary(StringComparer.Ordinal);
- file.Agents.TryGetValue("main", out var main);
- var security = main?.Security ?? file.Defaults.Security ?? ExecSecurity.Deny;
- var ask = main?.Ask ?? file.Defaults.Ask ?? ExecAsk.OnMiss;
- return security switch
+ ExecApprovalsAgent? main = null;
+ ExecApprovalsAgent? wildcard = null;
+ file.Agents?.TryGetValue("main", out main);
+ file.Agents?.TryGetValue("*", out wildcard);
+ var allowlist = new List<(ExecAllowlistEntry Entry, bool IsWildcard)>();
+ if (wildcard?.Allowlist is not null)
+ {
+ allowlist.AddRange(wildcard.Allowlist.Select(entry => (entry, true)));
+ }
+
+ if (main?.Allowlist is not null)
+ {
+ allowlist.AddRange(main.Allowlist.Select(entry => (entry, false)));
+ }
+
+ return new DisplayedExecPolicy(
+ main?.Security ?? wildcard?.Security ?? file.Defaults?.Security ?? ExecSecurity.Allowlist,
+ main?.Ask ?? wildcard?.Ask ?? file.Defaults?.Ask ?? ExecAsk.OnMiss,
+ allowlist);
+ }
+
+ private static string MapDefaultAction(DisplayedExecPolicy displayed) =>
+ displayed.Security switch
{
ExecSecurity.Full => "allow",
- ExecSecurity.Allowlist when ask is ExecAsk.OnMiss or ExecAsk.Always => "prompt",
+ ExecSecurity.Allowlist when displayed.Ask is ExecAsk.OnMiss or ExecAsk.Always => "prompt",
_ => "deny",
};
- }
private static string NormalizeAction(string? action)
{
@@ -860,5 +901,6 @@ private sealed record ExecApprovalsMutation(
string? Action,
string? Pattern,
Guid? RuleId = null,
- string? ArgPattern = null);
+ string? ArgPattern = null,
+ bool IsWildcard = false);
}
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index e497cf4b1b..bfe76c84f8 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -3510,6 +3510,18 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo
{0} entries
+
+ this agent
+
+
+ all agents
+
+
+ Remove {0} allowlist entry {1}
+
+
+ inactive
+
No allowlist data available. Connect to a gateway first.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 8831930633..1ec7affd4f 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -3463,6 +3463,18 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo
{0} entrées
+
+ cet agent
+
+
+ tous les agents
+
+
+ Supprimer l’entrée {1} de la liste d’autorisation pour {0}
+
+
+ désactivée
+
No allowlist data available. Connect to a gateway first.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index a89ee705c6..45826ac230 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -3464,6 +3464,18 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik
{0} vermeldingen
+
+ deze agent
+
+
+ alle agents
+
+
+ Vermelding {1} uit de toestaanlijst voor {0} verwijderen
+
+
+ inactief
+
No allowlist data available. Connect to a gateway first.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw
index 9587146490..409e9e2df9 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw
@@ -3482,6 +3482,18 @@ Os comandos são bloqueados enquanto o sandboxing está indisponível porque o b
{0} entradas
+
+ este agente
+
+
+ todos os agentes
+
+
+ Remover a entrada {1} da lista de permissões de {0}
+
+
+ inativa
+
No allowlist data available. Connect to a gateway first.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 61dc1e8a9b..71078acb82 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -3463,6 +3463,18 @@
{0} 个条目
+
+ 此代理
+
+
+ 所有代理
+
+
+ 移除{0}的允许列表条目 {1}
+
+
+ 未启用
+
No allowlist data available. Connect to a gateway first.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index a1005986c8..7f676d0d8d 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -3463,6 +3463,18 @@
{0} 個項目
+
+ 此代理
+
+
+ 所有代理
+
+
+ 移除{0}的允許清單項目 {1}
+
+
+ 未啟用
+
No allowlist data available. Connect to a gateway first.
diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
index 5f6361a587..e9497a43a4 100644
--- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
@@ -991,7 +991,12 @@ public void PermissionsPage_ExecPolicyRemoveButtons_HaveAccessibleNames()
Assert.Contains("AutomationProperties.Name=\"{Binding RemoveRuleAutomationName}\"", xaml);
Assert.Contains("AutomationProperties.AutomationId=\"{Binding RemoveRuleAutomationId}\"", xaml);
- Assert.Contains("RemoveRuleAutomationName = $\"Remove allowlist entry {rule.Pattern}\"", codeBehind);
+ Assert.Contains("\"PermissionsPage_ExecRuleScope_Wildcard\"", codeBehind);
+ Assert.Contains("\"PermissionsPage_ExecRuleScope_Main\"", codeBehind);
+ Assert.Contains("DisplayText = $\"{rule.Pattern} ({scopeLabel})\"", codeBehind);
+ Assert.Contains("\"PermissionsPage_RemoveRuleAutomationNameFormat\"", codeBehind);
+ Assert.Contains("\"PermissionsPage_ExecRuleAction_Inactive\"", codeBehind);
+ Assert.Contains("\"SystemFillColorNeutralBrush\"", codeBehind);
Assert.Contains("RemoveRuleAutomationId = $\"RemoveExecPolicyRuleButton_{index}\"", codeBehind);
}
diff --git a/tests/OpenClaw.Tray.Tests/PermissionsPageContractTests.cs b/tests/OpenClaw.Tray.Tests/PermissionsPageContractTests.cs
index 1f1f533d63..b862e52df3 100644
--- a/tests/OpenClaw.Tray.Tests/PermissionsPageContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/PermissionsPageContractTests.cs
@@ -42,7 +42,9 @@ public void PermissionsPage_ExecPolicyControls_PreserveDefaultChoicesAndAllowOnl
Assert.Equal(new[] { "deny", "allow", "prompt" }, combos["DefaultActionCombo"]);
Assert.DoesNotContain("NewRuleAction", combos.Keys);
- Assert.Contains("Action = \"allow\"", ReadPermissionsCodeBehind());
+ var codeBehind = ReadPermissionsCodeBehind();
+ Assert.Contains("_viewModel.ExecApprovalRulesActive ? \"allow\" : inactiveAction", codeBehind);
+ Assert.Contains("\"PermissionsPage_ExecRuleAction_Inactive\"", codeBehind);
}
[Fact]
diff --git a/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs
index 4f1c24b3c2..44cd6eeb9b 100644
--- a/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs
+++ b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs
@@ -923,6 +923,366 @@ public void ExternalValidChange_UpdatesOnce_AndCorruptRetainsLastValidDisplay()
Assert.Equal(PermissionsExecApprovalsStatus.ExternalInvalid, harness.ViewModel.ExecApprovalsStatus);
}
+ [Fact]
+ public void ExecSnapshot_WildcardSecurityAndAllowlist_DisplayWhenMainSecurityUnset()
+ {
+ var file = new ExecApprovalsFile
+ {
+ Version = 1,
+ Agents = new Dictionary(StringComparer.Ordinal)
+ {
+ ["main"] = new ExecApprovalsAgent(),
+ ["*"] = new ExecApprovalsAgent
+ {
+ Security = ExecSecurity.Full,
+ Allowlist =
+ [
+ new ExecAllowlistEntry { Pattern = " " },
+ new ExecAllowlistEntry { Pattern = @"C:\tools\*" },
+ ],
+ },
+ },
+ };
+
+ using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot("wildcard-only", file));
+ harness.ViewModel.Activate(null);
+
+ Assert.Equal("allow", harness.ViewModel.DefaultExecActionTag);
+ Assert.Equal(@"C:\tools\*", Assert.Single(harness.ViewModel.ExecApprovalRules).Pattern);
+ }
+
+ [Fact]
+ public void ExecSnapshot_MainSecurityWins_AndBothAllowlistsStayVisible()
+ {
+ var file = new ExecApprovalsFile
+ {
+ Version = 1,
+ Agents = new Dictionary(StringComparer.Ordinal)
+ {
+ ["main"] = new ExecApprovalsAgent
+ {
+ Security = ExecSecurity.Deny,
+ Allowlist = [new ExecAllowlistEntry { Pattern = @"C:\main\*" }],
+ },
+ ["*"] = new ExecApprovalsAgent
+ {
+ Security = ExecSecurity.Full,
+ Allowlist = [new ExecAllowlistEntry { Pattern = @"C:\tools\*" }],
+ },
+ },
+ };
+
+ using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot("main-over-wildcard", file));
+ harness.ViewModel.Activate(null);
+
+ Assert.Equal("deny", harness.ViewModel.DefaultExecActionTag);
+ Assert.Equal(
+ new[] { @"C:\tools\*", @"C:\main\*" },
+ harness.ViewModel.ExecApprovalRules.Select(rule => rule.Pattern).ToArray());
+ }
+
+ [Fact]
+ public void ExecSnapshot_UnsetSecurityUsesRuntimeAllowlistFallback()
+ {
+ var file = new ExecApprovalsFile
+ {
+ Version = 1,
+ Agents = new Dictionary(StringComparer.Ordinal)
+ {
+ ["main"] = new ExecApprovalsAgent
+ {
+ Allowlist = [new ExecAllowlistEntry { Pattern = "**/git.exe" }],
+ },
+ },
+ };
+
+ using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot("runtime-fallback", file));
+ harness.ViewModel.Activate(null);
+
+ Assert.Equal("prompt", harness.ViewModel.DefaultExecActionTag);
+ Assert.True(harness.ViewModel.ExecApprovalRulesActive);
+ Assert.Equal("**/git.exe", Assert.Single(harness.ViewModel.ExecApprovalRules).Pattern);
+ }
+
+ [Theory]
+ [InlineData("*", false)]
+ [InlineData("*", true)]
+ [InlineData("main", false)]
+ [InlineData("main", true)]
+ public async Task RemoveDisplayedRule_PreservesOtherBucketAndArgumentVariants(string agentId, bool idless)
+ {
+ Guid? ruleId = idless ? null : Guid.NewGuid();
+ var file = BuildFile("prompt", otherAgentPath: "**/rg.exe");
+ foreach (var bucket in new[] { "*", "main" })
+ {
+ file.Agents![bucket] = new ExecApprovalsAgent
+ {
+ Security = ExecSecurity.Full,
+ Ask = ExecAsk.Always,
+ AskFallback = ExecSecurity.Deny,
+ AutoAllowSkills = true,
+ Allowlist =
+ [
+ new ExecAllowlistEntry { Pattern = "**/git.exe" },
+ new ExecAllowlistEntry
+ {
+ Id = ruleId,
+ Pattern = "**/git.exe",
+ ArgPattern = "^status$",
+ CommandText = "git.exe status",
+ Source = "allow-always",
+ LastUsedAt = 123,
+ LastResolvedPath = @"C:\tools\git.exe",
+ LastUsedCommand = "git.exe status",
+ },
+ ],
+ };
+ }
+
+ using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot("base", file));
+ harness.ViewModel.Activate(null);
+ var displayed = harness.ViewModel.ExecApprovalRules.Where(rule => rule.ArgPattern == "^status$").ToArray();
+ var selected = displayed[agentId == "*" ? 0 : 1];
+
+ Assert.True(await harness.ViewModel.RemoveExecApprovalRuleAsync(selected));
+
+ file.Agents![agentId].Allowlist!.RemoveAt(1);
+ Assert.Equal(JsonSerializer.Serialize(file),
+ JsonSerializer.Serialize(harness.RecordingExecStore!.CurrentSnapshot.File));
+ Assert.Equal(3, harness.ViewModel.ExecApprovalRules.Count);
+ Assert.Equal(PermissionsExecApprovalsStatus.Saved, harness.ViewModel.ExecApprovalsStatus);
+ }
+
+ [Theory]
+ [InlineData("*", false)]
+ [InlineData("*", true)]
+ [InlineData("main", false)]
+ [InlineData("main", true)]
+ public async Task RemoveDisplayedRule_CasRetry_PreservesConcurrentReplacement(string agentId, bool idless)
+ {
+ var file = BuildFile("prompt", otherAgentPath: "**/rg.exe");
+ file.Agents![agentId] = new ExecApprovalsAgent
+ {
+ Allowlist = [new ExecAllowlistEntry { Id = idless ? null : Guid.NewGuid(), Pattern = "**/git.exe" }],
+ };
+ using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot("base", file));
+ harness.ViewModel.Activate(null);
+ var selected = Assert.Single(harness.ViewModel.ExecApprovalRules);
+ file.Agents[agentId].Allowlist![0].Id = Guid.NewGuid();
+ file.Agents["other"].Ask = ExecAsk.Always;
+ harness.RecordingExecStore!.OnReplace = call =>
+ {
+ if (call.Attempt == 1)
+ {
+ harness.RecordingExecStore.ReplaceCurrentSnapshot(BuildSnapshot("fresh", file));
+ return null;
+ }
+
+ return harness.RecordingExecStore.Commit(call.Replacement, "saved");
+ };
+
+ Assert.True(await harness.ViewModel.RemoveExecApprovalRuleAsync(selected));
+
+ Assert.Equal(JsonSerializer.Serialize(file),
+ JsonSerializer.Serialize(harness.RecordingExecStore.CurrentSnapshot.File));
+ Assert.Equal(new[] { "base", "fresh" },
+ harness.RecordingExecStore.ReplaceCalls.Select(call => call.BaseHash));
+ }
+
+ [Theory]
+ [InlineData(ExecSecurity.Full, ExecAsk.Off)]
+ [InlineData(ExecSecurity.Full, ExecAsk.Always)]
+ [InlineData(ExecSecurity.Allowlist, ExecAsk.OnMiss)]
+ [InlineData(ExecSecurity.Allowlist, ExecAsk.Always)]
+ public async Task AddRule_PreservesInheritedWildcardPolicy(ExecSecurity security, ExecAsk ask)
+ {
+ var file = BuildFile("deny", otherAgentPath: "**/rg.exe");
+ file.Defaults!.Security = ExecSecurity.Deny;
+ file.Agents!["main"] = new ExecApprovalsAgent();
+ file.Agents["*"] = new ExecApprovalsAgent
+ {
+ Security = security,
+ Ask = ask,
+ AskFallback = ExecSecurity.Deny,
+ AutoAllowSkills = true,
+ Allowlist = [new ExecAllowlistEntry { Pattern = "**/existing.exe" }],
+ };
+ using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot("base", file));
+ harness.ViewModel.Activate(null);
+ var displayedAction = harness.ViewModel.DefaultExecActionTag;
+
+ Assert.True(await harness.ViewModel.TryAddExecApprovalRuleAsync("**/git.exe"));
+
+ var saved = harness.RecordingExecStore!.CurrentSnapshot.File;
+ var added = Assert.Single(saved.Agents!["main"].Allowlist!);
+ Assert.Equal("**/git.exe", added.Pattern);
+ Assert.NotNull(added.Id);
+ file.Agents["main"].Allowlist = [added];
+ Assert.Equal(JsonSerializer.Serialize(file), JsonSerializer.Serialize(saved));
+ Assert.Equal(displayedAction, harness.ViewModel.DefaultExecActionTag);
+ }
+
+ [Fact]
+ public async Task RemoveWildcardRule_DoesNotCreateMainOrDefaults()
+ {
+ var file = new ExecApprovalsFile
+ {
+ Version = 1,
+ Agents = new Dictionary
+ {
+ ["*"] = new() { Allowlist = [new() { Pattern = "**/git.exe" }] },
+ },
+ };
+ using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot("base", file));
+ harness.ViewModel.Activate(null);
+
+ Assert.True(await harness.ViewModel.RemoveExecApprovalRuleAsync(
+ Assert.Single(harness.ViewModel.ExecApprovalRules)));
+
+ file.Agents["*"].Allowlist!.Clear();
+ Assert.Equal(JsonSerializer.Serialize(file),
+ JsonSerializer.Serialize(harness.RecordingExecStore!.CurrentSnapshot.File));
+ }
+
+ [Theory]
+ [InlineData(ExecAsk.Off, true)]
+ [InlineData(ExecAsk.Off, false)]
+ [InlineData(ExecAsk.OnMiss, true)]
+ [InlineData(ExecAsk.OnMiss, false)]
+ public async Task AddRule_FromInheritedWildcardDeny_DoesNotActivateDormantAllowlist(
+ ExecAsk wildcardAsk,
+ bool existingRuleIsWildcard)
+ {
+ var file = BuildFile("allow", otherAgentPath: "**/rg.exe");
+ file.Agents!["main"] = new ExecApprovalsAgent
+ {
+ Allowlist = existingRuleIsWildcard
+ ? null
+ : [new ExecAllowlistEntry { Pattern = "**/other.exe" }],
+ };
+ file.Agents["*"] = new ExecApprovalsAgent
+ {
+ Security = ExecSecurity.Deny,
+ Ask = wildcardAsk,
+ Allowlist = existingRuleIsWildcard
+ ? [new ExecAllowlistEntry { Pattern = "**/other.exe" }]
+ : null,
+ };
+ using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot("base", file));
+ harness.ViewModel.Activate(null);
+
+ Assert.True(await harness.ViewModel.TryAddExecApprovalRuleAsync("**/git.exe"));
+
+ var saved = harness.RecordingExecStore!.CurrentSnapshot.File;
+ Assert.Null(saved.Agents!["main"].Security);
+ Assert.Null(saved.Agents["main"].Ask);
+ Assert.Equal(ExecSecurity.Deny, saved.Agents["*"].Security);
+ Assert.Equal(wildcardAsk, saved.Agents["*"].Ask);
+ Assert.Contains(saved.Agents["main"].Allowlist!, entry => entry.Pattern == "**/git.exe");
+ Assert.Contains(
+ harness.ViewModel.ExecApprovalRules,
+ rule => rule.IsWildcard == existingRuleIsWildcard && rule.Pattern == "**/other.exe");
+ Assert.Contains(harness.ViewModel.ExecApprovalRules, rule => !rule.IsWildcard && rule.Pattern == "**/git.exe");
+ Assert.Equal("deny", harness.ViewModel.DefaultExecActionTag);
+ Assert.False(harness.ViewModel.ExecApprovalRulesActive);
+ }
+
+ [Theory]
+ [InlineData(ExecAsk.Off, true)]
+ [InlineData(ExecAsk.Off, false)]
+ [InlineData(ExecAsk.OnMiss, true)]
+ [InlineData(ExecAsk.OnMiss, false)]
+ public async Task AddRule_FromInheritedWildcardDeny_RealStoreRemainsDenied(
+ ExecAsk wildcardAsk,
+ bool existingRuleIsWildcard)
+ {
+ var file = BuildFile("allow");
+ file.Agents!["main"] = new ExecApprovalsAgent
+ {
+ Allowlist = existingRuleIsWildcard
+ ? null
+ : [new ExecAllowlistEntry { Pattern = "**/other.exe" }],
+ };
+ file.Agents["*"] = new ExecApprovalsAgent
+ {
+ Security = ExecSecurity.Deny,
+ Ask = wildcardAsk,
+ Allowlist = existingRuleIsWildcard
+ ? [new ExecAllowlistEntry { Pattern = "**/other.exe" }]
+ : null,
+ };
+ using var harness = PermissionsHarness.CreateReal();
+ harness.ViewModel.Activate(null);
+ var initial = await harness.RealExecStore!.GetSnapshotReadOnlyAsync();
+ Assert.NotNull(await harness.RealExecStore.ReplaceAsync(initial.Snapshot!.Hash, file, origin: null));
+
+ Assert.True(await harness.ViewModel.TryAddExecApprovalRuleAsync("**/git.exe"));
+
+ using var reopened = new ExecApprovalsStore(harness.Temp.Path, NullLogger.Instance);
+ var resolved = reopened.ResolveReadOnly("main");
+ Assert.Equal(ExecSecurity.Deny, resolved.Defaults.Security);
+ Assert.Equal(wildcardAsk, resolved.Defaults.Ask);
+ Assert.Contains(resolved.Allowlist, entry => entry.Pattern == "**/other.exe");
+ Assert.Contains(resolved.Allowlist, entry => entry.Pattern == "**/git.exe");
+ Assert.False(harness.ViewModel.ExecApprovalRulesActive);
+ }
+
+ [Fact]
+ public async Task AddRule_FromInheritedWildcardDeny_EnablesOnlyMainAllowlist()
+ {
+ var file = BuildFile("allow", otherAgentPath: "**/rg.exe");
+ file.Agents!["main"] = new ExecApprovalsAgent();
+ file.Agents["*"] = new ExecApprovalsAgent { Security = ExecSecurity.Deny, Ask = ExecAsk.Always };
+ using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot("base", file));
+ harness.ViewModel.Activate(null);
+
+ Assert.True(await harness.ViewModel.TryAddExecApprovalRuleAsync("**/git.exe"));
+
+ var saved = harness.RecordingExecStore!.CurrentSnapshot.File;
+ Assert.Equal(ExecSecurity.Allowlist, saved.Agents!["main"].Security);
+ Assert.Equal(ExecAsk.Always, saved.Agents["main"].Ask);
+ Assert.Equal(ExecSecurity.Deny, saved.Agents["main"].AskFallback);
+ Assert.Equal(ExecSecurity.Full, saved.Defaults!.Security);
+ Assert.Equal(ExecSecurity.Deny, saved.Agents["*"].Security);
+ Assert.Equal(ExecAsk.Always, saved.Agents["*"].Ask);
+ Assert.True(harness.ViewModel.ExecApprovalRulesActive);
+ }
+
+ [Fact]
+ public async Task RemoveDisplayedRules_RealStore_PersistsOnlySelectedBucketRemoval()
+ {
+ var file = BuildFile("prompt",
+ allowlist: [new ExecAllowlistEntry { Pattern = "**/git.exe", ArgPattern = "^status$" }],
+ otherAgentPath: "**/rg.exe");
+ file.Agents!["*"] = new ExecApprovalsAgent
+ {
+ Security = ExecSecurity.Full,
+ Ask = ExecAsk.Always,
+ Allowlist = [new ExecAllowlistEntry { Pattern = "**/git.exe", ArgPattern = "^status$" }],
+ };
+ using var harness = PermissionsHarness.CreateReal();
+ harness.ViewModel.Activate(null);
+ var initial = await harness.RealExecStore!.GetSnapshotReadOnlyAsync();
+ Assert.NotNull(await harness.RealExecStore.ReplaceAsync(initial.Snapshot!.Hash, file, origin: null));
+ var wildcard = Assert.Single(harness.ViewModel.ExecApprovalRules, rule => rule.IsWildcard);
+ Assert.Equal(2, harness.RealExecStore.ResolveReadOnly("other").Allowlist.Count);
+
+ Assert.True(await harness.ViewModel.RemoveExecApprovalRuleAsync(wildcard));
+
+ using var reopened = new ExecApprovalsStore(harness.Temp.Path, NullLogger.Instance);
+ var saved = (await reopened.GetSnapshotReadOnlyAsync()).Snapshot!.File;
+ Assert.Null(saved.Agents!["*"].Allowlist);
+ Assert.Single(saved.Agents["main"].Allowlist!);
+ Assert.Single(reopened.ResolveReadOnly("main").Allowlist);
+ Assert.Equal(ExecSecurity.Full, saved.Agents["*"].Security);
+ Assert.Equal(ExecAsk.Always, saved.Agents["*"].Ask);
+ Assert.Equal("**/rg.exe", Assert.Single(saved.Agents["other"].Allowlist!).Pattern);
+ Assert.Equal("**/rg.exe", Assert.Single(reopened.ResolveReadOnly("other").Allowlist).Pattern);
+ Assert.True(await harness.ViewModel.RemoveExecApprovalRuleAsync(
+ Assert.Single(harness.ViewModel.ExecApprovalRules)));
+ Assert.Empty(reopened.ResolveReadOnly("main").Allowlist);
+ }
+
private static ExecApprovalsSnapshot BuildSnapshot(string hash, ExecApprovalsFile file) =>
new("D:\\exec-approvals.json", true, hash, file);