From fc90d280c5226bfe0a1a3a30973cafc12d83784b Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 23 Sep 2026 18:37:24 -0700 Subject: [PATCH 1/7] fix(permissions): show the wildcard exec policy the executor uses - Resolve the Permissions default action through main, then *, then defaults - List wildcard allowlist entries as well as main Signed-off-by: Sebastien Tardif --- .../Presentation/PermissionsPageViewModel.cs | 48 ++++++++++----- .../PermissionsPageViewModelTests.cs | 58 +++++++++++++++++++ 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs index f04674394..53cf7380e 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs @@ -708,12 +708,9 @@ 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())) + var displayed = ResolveDisplayedExecPolicy(snapshot.File); + SetField(ref _defaultExecActionTag, MapDefaultAction(displayed), nameof(DefaultExecActionTag)); + var rules = displayed.Allowlist .Where(entry => !string.IsNullOrWhiteSpace(entry.Pattern)) .Select(entry => new PermissionsExecApprovalRule( entry.Id, @@ -758,20 +755,41 @@ private void HandleOnUiThread(Action action) _ => null, }; - private static string MapDefaultAction(ExecApprovalsFile file) + private readonly record struct DisplayedExecPolicy( + ExecSecurity Security, + ExecAsk Ask, + IReadOnlyList 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(); + if (wildcard?.Allowlist is not null) + { + allowlist.AddRange(wildcard.Allowlist); + } + + if (main?.Allowlist is not null) + { + allowlist.AddRange(main.Allowlist); + } + + return new DisplayedExecPolicy( + main?.Security ?? wildcard?.Security ?? file.Defaults?.Security ?? ExecSecurity.Deny, + 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) { diff --git a/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs index 4f1c24b3c..aeb05a4f3 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs @@ -923,6 +923,64 @@ 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()); + } + private static ExecApprovalsSnapshot BuildSnapshot(string hash, ExecApprovalsFile file) => new("D:\\exec-approvals.json", true, hash, file); From cba7b9b0c9dc3978cd1f282704e6f54c384082c8 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Thu, 24 Sep 2026 06:13:05 +0200 Subject: [PATCH 2/7] fix(permissions): keep wildcard rule edits in their source bucket Preserve wildcard/main rule provenance through CAS removal and use the displayed policy cascade when adding a main rule. Cover cross-bucket duplicate IDs, ID-less argument variants, concurrent replacement, inherited policy, and fresh-store persistence. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e7c9166-338e-44fb-828a-577c05de49b7 --- docs/WINDOWS_NODE_TESTING.md | 15 ++ .../PermissionsPagePresentationTypes.cs | 3 +- .../Presentation/PermissionsPageViewModel.cs | 54 +++-- .../PermissionsPageViewModelTests.cs | 195 ++++++++++++++++++ 4 files changed, 245 insertions(+), 22 deletions(-) diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index 0867fa45e..e378722ee 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/Presentation/PermissionsPagePresentationTypes.cs b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPagePresentationTypes.cs index a2109096c..ddea2edae 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 53cf7380e..212d127bf 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs @@ -244,7 +244,8 @@ public Task RemoveExecApprovalRuleAsync(PermissionsExecApprovalRule rule) null, rule.Pattern, rule.Id, - rule.ArgPattern)); + rule.ArgPattern, + rule.IsWildcard)); } private void PersistSetting(Action edit) @@ -623,6 +624,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,7 +671,7 @@ 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) + if (ResolveDisplayedExecPolicy(file).Security == ExecSecurity.Deny) { main.Security = ExecSecurity.Allowlist; main.Ask = ExecAsk.Off; @@ -675,13 +692,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) @@ -711,13 +721,14 @@ private void ApplyExecSnapshot(ExecApprovalsSnapshot snapshot) var displayed = ResolveDisplayedExecPolicy(snapshot.File); SetField(ref _defaultExecActionTag, MapDefaultAction(displayed), nameof(DefaultExecActionTag)); var rules = displayed.Allowlist - .Where(entry => !string.IsNullOrWhiteSpace(entry.Pattern)) - .Select(entry => new PermissionsExecApprovalRule( - entry.Id, - entry.Pattern!, - entry.ArgPattern, - entry.LastUsedAt, - entry.LastResolvedPath)) + .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,7 +769,7 @@ private void HandleOnUiThread(Action action) private readonly record struct DisplayedExecPolicy( ExecSecurity Security, ExecAsk Ask, - IReadOnlyList Allowlist); + IReadOnlyList<(ExecAllowlistEntry Entry, bool IsWildcard)> Allowlist); private static DisplayedExecPolicy ResolveDisplayedExecPolicy(ExecApprovalsFile file) { @@ -766,15 +777,15 @@ private static DisplayedExecPolicy ResolveDisplayedExecPolicy(ExecApprovalsFile ExecApprovalsAgent? wildcard = null; file.Agents?.TryGetValue("main", out main); file.Agents?.TryGetValue("*", out wildcard); - var allowlist = new List(); + var allowlist = new List<(ExecAllowlistEntry Entry, bool IsWildcard)>(); if (wildcard?.Allowlist is not null) { - allowlist.AddRange(wildcard.Allowlist); + allowlist.AddRange(wildcard.Allowlist.Select(entry => (entry, true))); } if (main?.Allowlist is not null) { - allowlist.AddRange(main.Allowlist); + allowlist.AddRange(main.Allowlist.Select(entry => (entry, false))); } return new DisplayedExecPolicy( @@ -878,5 +889,6 @@ private sealed record ExecApprovalsMutation( string? Action, string? Pattern, Guid? RuleId = null, - string? ArgPattern = null); + string? ArgPattern = null, + bool IsWildcard = false); } diff --git a/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs index aeb05a4f3..74c81502a 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs @@ -981,6 +981,201 @@ public void ExecSnapshot_MainSecurityWins_AndBothAllowlistsStayVisible() harness.ViewModel.ExecApprovalRules.Select(rule => rule.Pattern).ToArray()); } + [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)); + } + + [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.Off, 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); + } + + [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); From aae72e42f8b723955f4b8d23ede3ac03c5ed41a5 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 24 Sep 2026 12:21:23 -0700 Subject: [PATCH 3/7] fix(permissions): keep Ask=Always when adding a rule under wildcard Deny Signed-off-by: Sebastien Tardif --- .../Presentation/PermissionsPageViewModel.cs | 5 +++-- .../Presentation/PermissionsPageViewModelTests.cs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs index 212d127bf..336a4b4bf 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs @@ -671,10 +671,11 @@ private void ApplyMutation(ExecApprovalsFile file, ExecApprovalsMutation mutatio var allowlist = main.Allowlist ??= new List(); if (mutation.Kind == ExecApprovalsMutationKind.AddRule) { - if (ResolveDisplayedExecPolicy(file).Security == ExecSecurity.Deny) + var displayed = ResolveDisplayedExecPolicy(file); + if (displayed.Security == ExecSecurity.Deny) { main.Security = ExecSecurity.Allowlist; - main.Ask = ExecAsk.Off; + main.Ask = displayed.Ask; main.AskFallback = ExecSecurity.Deny; } diff --git a/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs index 74c81502a..c1372c06d 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs @@ -1134,7 +1134,7 @@ public async Task AddRule_FromInheritedWildcardDeny_EnablesOnlyMainAllowlist() var saved = harness.RecordingExecStore!.CurrentSnapshot.File; Assert.Equal(ExecSecurity.Allowlist, saved.Agents!["main"].Security); - Assert.Equal(ExecAsk.Off, saved.Agents["main"].Ask); + 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); From e046a2c08eccee98fe9dd7e5a01f1c10381ea03f Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 27 Sep 2026 10:03:37 -0700 Subject: [PATCH 4/7] fix(permissions): label rule scope and keep inherited Deny/Off closed A wildcard row and a main row with the same pattern now show wildcard or main in the list and in the Remove name. Adding a rule while wildcard security is Deny and Ask is Off, and main security is unset, stores the new pattern without switching main to Allowlist. Inherited Deny/Always still enables only the main allowlist and keeps Ask Always. ./build.ps1 exit 0. Shared tests: 4107 passed, 32 skipped. Tray tests: 3133 passed, 0 failed. Signed-off-by: Sebastien Tardif --- .../Pages/PermissionsPage.xaml | 2 +- .../Pages/PermissionsPage.xaml.cs | 5 +++- .../Presentation/PermissionsPageViewModel.cs | 5 +++- .../AppRefactorContractTests.cs | 5 +++- .../PermissionsPageViewModelTests.cs | 26 +++++++++++++++++++ 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml index 14d1c80b3..90404144e 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml @@ -285,7 +285,7 @@ - entry.Pattern == "**/git.exe"); + Assert.Contains(harness.ViewModel.ExecApprovalRules, rule => rule.IsWildcard && rule.Pattern == "**/other.exe"); + Assert.Contains(harness.ViewModel.ExecApprovalRules, rule => !rule.IsWildcard && rule.Pattern == "**/git.exe"); + } + [Fact] public async Task AddRule_FromInheritedWildcardDeny_EnablesOnlyMainAllowlist() { From d00350c6dfa715e043979037133fbeb9ecbeadf7 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Sun, 27 Sep 2026 10:27:34 -0700 Subject: [PATCH 5/7] fix(permissions): localize rule scope labels Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ba54a2d-477b-4b8e-994c-5fb3755d8b01 --- .../Pages/PermissionsPage.xaml.cs | 30 ++++++++++++------- .../Strings/en-us/Resources.resw | 9 ++++++ .../Strings/fr-fr/Resources.resw | 9 ++++++ .../Strings/nl-nl/Resources.resw | 9 ++++++ .../Strings/pt-br/Resources.resw | 9 ++++++ .../Strings/zh-cn/Resources.resw | 9 ++++++ .../Strings/zh-tw/Resources.resw | 9 ++++++ .../AppRefactorContractTests.cs | 8 ++--- .../PermissionsPageViewModelTests.cs | 27 +++++++++++++++++ 9 files changed, 104 insertions(+), 15 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs index cbdd0862f..ebde27c50 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs @@ -386,17 +386,25 @@ private void UpdateExecApprovals() SelectComboBoxTag(DefaultActionCombo, _viewModel.DefaultExecActionTag); _suppressDefaultActionChange = false; - PolicyRulesList.ItemsSource = _viewModel.ExecApprovalRules.Select((rule, index) => new - { - Rule = rule, - rule.Pattern, - DisplayText = rule.IsWildcard ? $"{rule.Pattern} (wildcard)" : $"{rule.Pattern} (main)", - RemoveRuleAutomationName = rule.IsWildcard - ? $"Remove wildcard allowlist entry {rule.Pattern}" - : $"Remove main allowlist entry {rule.Pattern}", - RemoveRuleAutomationId = $"RemoveExecPolicyRuleButton_{index}", - Action = "allow", - ActionBrush = (Brush)Application.Current.Resources["SystemFillColorSuccessBrush"], + 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 = "allow", + ActionBrush = (Brush)Application.Current.Resources["SystemFillColorSuccessBrush"], + }; }).ToList(); var count = _viewModel.ExecApprovalRules.Count; diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index e497cf4b1..44654f509 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -3510,6 +3510,15 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo {0} entries + + this agent + + + all agents + + + Remove {0} allowlist entry {1} + 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 883193063..67de550b2 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -3463,6 +3463,15 @@ 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} + 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 a89ee705c..e0707ed8a 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -3464,6 +3464,15 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik {0} vermeldingen + + deze agent + + + alle agents + + + Vermelding {1} uit de toestaanlijst voor {0} verwijderen + 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 958714649..c4a7574bc 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -3482,6 +3482,15 @@ 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} + 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 61dc1e8a9..12f0a987b 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -3463,6 +3463,15 @@ {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 a1005986c..f4c74696f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -3463,6 +3463,15 @@ {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 f02374585..b27d2484a 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -991,10 +991,10 @@ public void PermissionsPage_ExecPolicyRemoveButtons_HaveAccessibleNames() Assert.Contains("AutomationProperties.Name=\"{Binding RemoveRuleAutomationName}\"", xaml); Assert.Contains("AutomationProperties.AutomationId=\"{Binding RemoveRuleAutomationId}\"", xaml); - Assert.Contains("Remove wildcard allowlist entry {rule.Pattern}", codeBehind); - Assert.Contains("Remove main allowlist entry {rule.Pattern}", codeBehind); - Assert.Contains("(wildcard)", codeBehind); - Assert.Contains("(main)", 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("RemoveRuleAutomationId = $\"RemoveExecPolicyRuleButton_{index}\"", codeBehind); } diff --git a/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs index dcad1d8b6..5535a9f94 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs @@ -1145,6 +1145,33 @@ public async Task AddRule_FromInheritedWildcardDenyOff_DoesNotActivateAllowlist( Assert.Contains(saved.Agents["main"].Allowlist!, entry => entry.Pattern == "**/git.exe"); Assert.Contains(harness.ViewModel.ExecApprovalRules, rule => rule.IsWildcard && rule.Pattern == "**/other.exe"); Assert.Contains(harness.ViewModel.ExecApprovalRules, rule => !rule.IsWildcard && rule.Pattern == "**/git.exe"); + Assert.Equal("deny", harness.ViewModel.DefaultExecActionTag); + } + + [Fact] + public async Task AddRule_FromInheritedWildcardDenyOff_RealStoreRemainsDenied() + { + var file = BuildFile("allow"); + file.Agents!["main"] = new ExecApprovalsAgent(); + file.Agents["*"] = new ExecApprovalsAgent + { + Security = ExecSecurity.Deny, + Ask = ExecAsk.Off, + Allowlist = [new ExecAllowlistEntry { Pattern = "**/other.exe" }], + }; + 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(ExecAsk.Off, resolved.Defaults.Ask); + Assert.Contains(resolved.Allowlist, entry => entry.Pattern == "**/other.exe"); + Assert.Contains(resolved.Allowlist, entry => entry.Pattern == "**/git.exe"); } [Fact] From 5c4bb63e06315905ad4ef0f59e3c0d0de0620d0c Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Sun, 27 Sep 2026 11:04:51 -0700 Subject: [PATCH 6/7] fix(permissions): keep inherited deny rules inactive Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ba54a2d-477b-4b8e-994c-5fb3755d8b01 --- .../Pages/PermissionsPage.xaml.cs | 9 ++++++-- .../Presentation/PermissionsPageViewModel.cs | 14 ++++++++--- .../Strings/en-us/Resources.resw | 3 +++ .../Strings/fr-fr/Resources.resw | 3 +++ .../Strings/nl-nl/Resources.resw | 3 +++ .../Strings/pt-br/Resources.resw | 3 +++ .../Strings/zh-cn/Resources.resw | 3 +++ .../Strings/zh-tw/Resources.resw | 3 +++ .../AppRefactorContractTests.cs | 2 ++ .../PermissionsPageContractTests.cs | 4 +++- .../PermissionsPageViewModelTests.cs | 23 ++++++++++++------- 11 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs index ebde27c50..8647e6b4e 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs @@ -386,6 +386,11 @@ private void UpdateExecApprovals() SelectComboBoxTag(DefaultActionCombo, _viewModel.DefaultExecActionTag); _suppressDefaultActionChange = false; + 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( @@ -402,8 +407,8 @@ private void UpdateExecApprovals() scopeLabel, rule.Pattern), RemoveRuleAutomationId = $"RemoveExecPolicyRuleButton_{index}", - Action = "allow", - ActionBrush = (Brush)Application.Current.Resources["SystemFillColorSuccessBrush"], + Action = action, + ActionBrush = (Brush)Application.Current.Resources[actionBrushKey], }; }).ToList(); diff --git a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs index c1792582a..82d6d2151 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; @@ -672,10 +674,11 @@ private void ApplyMutation(ExecApprovalsFile file, ExecApprovalsMutation mutatio if (mutation.Kind == ExecApprovalsMutationKind.AddRule) { var displayed = ResolveDisplayedExecPolicy(file); - var inheritedDenyOff = main.Security is null + var inheritedDenyWithDormantWildcardRules = main.Security is null && displayed.Security == ExecSecurity.Deny - && displayed.Ask == ExecAsk.Off; - if (displayed.Security == ExecSecurity.Deny && !inheritedDenyOff) + && displayed.Ask != ExecAsk.Always + && displayed.Allowlist.Any(rule => rule.IsWildcard); + if (displayed.Security == ExecSecurity.Deny && !inheritedDenyWithDormantWildcardRules) { main.Security = ExecSecurity.Allowlist; main.Ask = displayed.Ask; @@ -715,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); } } @@ -724,6 +728,10 @@ private void ApplyExecSnapshot(ExecApprovalsSnapshot snapshot) _execApprovalsBaseHash = snapshot.Hash; 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( diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 44654f509..bfe76c84f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -3519,6 +3519,9 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo 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 67de550b2..1ec7affd4 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -3472,6 +3472,9 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo 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 e0707ed8a..45826ac23 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -3473,6 +3473,9 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik 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 c4a7574bc..409e9e2df 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -3491,6 +3491,9 @@ Os comandos são bloqueados enquanto o sandboxing está indisponível porque o b 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 12f0a987b..71078acb8 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -3472,6 +3472,9 @@ 移除{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 f4c74696f..7f676d0d8 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -3472,6 +3472,9 @@ 移除{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 b27d2484a..e9497a43a 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -995,6 +995,8 @@ public void PermissionsPage_ExecPolicyRemoveButtons_HaveAccessibleNames() 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 1f1f533d6..b862e52df 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 5535a9f94..64484d31d 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs @@ -1121,15 +1121,17 @@ public async Task RemoveWildcardRule_DoesNotCreateMainOrDefaults() JsonSerializer.Serialize(harness.RecordingExecStore!.CurrentSnapshot.File)); } - [Fact] - public async Task AddRule_FromInheritedWildcardDenyOff_DoesNotActivateAllowlist() + [Theory] + [InlineData(ExecAsk.Off)] + [InlineData(ExecAsk.OnMiss)] + public async Task AddRule_FromInheritedWildcardDeny_DoesNotActivateDormantAllowlist(ExecAsk wildcardAsk) { var file = BuildFile("allow", otherAgentPath: "**/rg.exe"); file.Agents!["main"] = new ExecApprovalsAgent(); file.Agents["*"] = new ExecApprovalsAgent { Security = ExecSecurity.Deny, - Ask = ExecAsk.Off, + Ask = wildcardAsk, Allowlist = [new ExecAllowlistEntry { Pattern = "**/other.exe" }], }; using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot("base", file)); @@ -1141,22 +1143,25 @@ public async Task AddRule_FromInheritedWildcardDenyOff_DoesNotActivateAllowlist( Assert.Null(saved.Agents!["main"].Security); Assert.Null(saved.Agents["main"].Ask); Assert.Equal(ExecSecurity.Deny, saved.Agents["*"].Security); - Assert.Equal(ExecAsk.Off, saved.Agents["*"].Ask); + 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 && 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); } - [Fact] - public async Task AddRule_FromInheritedWildcardDenyOff_RealStoreRemainsDenied() + [Theory] + [InlineData(ExecAsk.Off)] + [InlineData(ExecAsk.OnMiss)] + public async Task AddRule_FromInheritedWildcardDeny_RealStoreRemainsDenied(ExecAsk wildcardAsk) { var file = BuildFile("allow"); file.Agents!["main"] = new ExecApprovalsAgent(); file.Agents["*"] = new ExecApprovalsAgent { Security = ExecSecurity.Deny, - Ask = ExecAsk.Off, + Ask = wildcardAsk, Allowlist = [new ExecAllowlistEntry { Pattern = "**/other.exe" }], }; using var harness = PermissionsHarness.CreateReal(); @@ -1169,9 +1174,10 @@ public async Task AddRule_FromInheritedWildcardDenyOff_RealStoreRemainsDenied() using var reopened = new ExecApprovalsStore(harness.Temp.Path, NullLogger.Instance); var resolved = reopened.ResolveReadOnly("main"); Assert.Equal(ExecSecurity.Deny, resolved.Defaults.Security); - Assert.Equal(ExecAsk.Off, resolved.Defaults.Ask); + 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] @@ -1192,6 +1198,7 @@ public async Task AddRule_FromInheritedWildcardDeny_EnablesOnlyMainAllowlist() 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] From 40cc5e61be4c945e63784a72639eebaa50bcad24 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Sun, 27 Sep 2026 11:23:00 -0700 Subject: [PATCH 7/7] fix(permissions): keep dormant main rules denied Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ba54a2d-477b-4b8e-994c-5fb3755d8b01 --- .../Presentation/PermissionsPageViewModel.cs | 8 +-- .../PermissionsPageViewModelTests.cs | 69 ++++++++++++++++--- 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs index 82d6d2151..9a23a9888 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/PermissionsPageViewModel.cs @@ -674,11 +674,11 @@ private void ApplyMutation(ExecApprovalsFile file, ExecApprovalsMutation mutatio if (mutation.Kind == ExecApprovalsMutationKind.AddRule) { var displayed = ResolveDisplayedExecPolicy(file); - var inheritedDenyWithDormantWildcardRules = main.Security is null + var inheritedDenyWithDormantRules = main.Security is null && displayed.Security == ExecSecurity.Deny && displayed.Ask != ExecAsk.Always - && displayed.Allowlist.Any(rule => rule.IsWildcard); - if (displayed.Security == ExecSecurity.Deny && !inheritedDenyWithDormantWildcardRules) + && displayed.Allowlist.Count > 0; + if (displayed.Security == ExecSecurity.Deny && !inheritedDenyWithDormantRules) { main.Security = ExecSecurity.Allowlist; main.Ask = displayed.Ask; @@ -801,7 +801,7 @@ private static DisplayedExecPolicy ResolveDisplayedExecPolicy(ExecApprovalsFile } return new DisplayedExecPolicy( - main?.Security ?? wildcard?.Security ?? file.Defaults?.Security ?? ExecSecurity.Deny, + main?.Security ?? wildcard?.Security ?? file.Defaults?.Security ?? ExecSecurity.Allowlist, main?.Ask ?? wildcard?.Ask ?? file.Defaults?.Ask ?? ExecAsk.OnMiss, allowlist); } diff --git a/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs index 64484d31d..44cd6eeb9 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/PermissionsPageViewModelTests.cs @@ -981,6 +981,29 @@ public void ExecSnapshot_MainSecurityWins_AndBothAllowlistsStayVisible() 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)] @@ -1122,17 +1145,28 @@ public async Task RemoveWildcardRule_DoesNotCreateMainOrDefaults() } [Theory] - [InlineData(ExecAsk.Off)] - [InlineData(ExecAsk.OnMiss)] - public async Task AddRule_FromInheritedWildcardDeny_DoesNotActivateDormantAllowlist(ExecAsk wildcardAsk) + [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(); + file.Agents!["main"] = new ExecApprovalsAgent + { + Allowlist = existingRuleIsWildcard + ? null + : [new ExecAllowlistEntry { Pattern = "**/other.exe" }], + }; file.Agents["*"] = new ExecApprovalsAgent { Security = ExecSecurity.Deny, Ask = wildcardAsk, - Allowlist = [new ExecAllowlistEntry { Pattern = "**/other.exe" }], + Allowlist = existingRuleIsWildcard + ? [new ExecAllowlistEntry { Pattern = "**/other.exe" }] + : null, }; using var harness = PermissionsHarness.CreateWithRecordingStore(BuildSnapshot("base", file)); harness.ViewModel.Activate(null); @@ -1145,24 +1179,37 @@ public async Task AddRule_FromInheritedWildcardDeny_DoesNotActivateDormantAllowl 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 && rule.Pattern == "**/other.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)] - [InlineData(ExecAsk.OnMiss)] - public async Task AddRule_FromInheritedWildcardDeny_RealStoreRemainsDenied(ExecAsk wildcardAsk) + [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(); + file.Agents!["main"] = new ExecApprovalsAgent + { + Allowlist = existingRuleIsWildcard + ? null + : [new ExecAllowlistEntry { Pattern = "**/other.exe" }], + }; file.Agents["*"] = new ExecApprovalsAgent { Security = ExecSecurity.Deny, Ask = wildcardAsk, - Allowlist = [new ExecAllowlistEntry { Pattern = "**/other.exe" }], + Allowlist = existingRuleIsWildcard + ? [new ExecAllowlistEntry { Pattern = "**/other.exe" }] + : null, }; using var harness = PermissionsHarness.CreateReal(); harness.ViewModel.Activate(null);