diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsContracts.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsContracts.cs new file mode 100644 index 0000000..a41a2d5 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsContracts.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace OpenClaw.Shared.ExecApprovals; + +// ── Config enums ────────────────────────────────────────────────────────────── + +public enum ExecSecurity +{ + Deny, + Allowlist, + Full, +} + +public enum ExecAsk +{ + Off, + OnMiss, + Always, + Deny, +} + +public enum ExecApprovalDecision +{ + Allow, + Deny, + AllowOnce, + AllowAlways, +} + +// ── Allowlist contracts ─────────────────────────────────────────────────────── + +public sealed class ExecAllowlistEntry +{ + public Guid? Id { get; set; } + public string? Pattern { get; set; } + public double? LastUsedAt { get; set; } + public string? LastUsedCommand { get; set; } + public string? LastResolvedPath { get; set; } +} + +// ── Persisted config contracts ──────────────────────────────────────────────── + +public sealed class ExecApprovalsSocketConfig +{ + public string? Path { get; set; } + public string? Token { get; set; } +} + +public sealed class ExecApprovalsDefaults +{ + public ExecSecurity? Security { get; set; } + public ExecAsk? Ask { get; set; } + public ExecAsk? AskFallback { get; set; } + public bool? AutoAllowSkills { get; set; } +} + +public sealed class ExecApprovalsAgent +{ + public ExecSecurity? Security { get; set; } + public ExecAsk? Ask { get; set; } + public ExecAsk? AskFallback { get; set; } + public bool? AutoAllowSkills { get; set; } + public List? Allowlist { get; set; } +} + +public sealed class ExecApprovalsFile +{ + public int Version { get; set; } = 1; + public ExecApprovalsSocketConfig? Socket { get; set; } + public ExecApprovalsDefaults? Defaults { get; set; } + public Dictionary? Agents { get; set; } +} + +// ── Resolved/runtime contracts (not serialized) ─────────────────────────────── + +public sealed class ExecApprovalsResolvedDefaults +{ + public ExecSecurity Security { get; init; } + public ExecAsk Ask { get; init; } + public ExecAsk AskFallback { get; init; } + public bool AutoAllowSkills { get; init; } +} + +public sealed class ExecApprovalsResolved +{ + public string AgentId { get; init; } = string.Empty; + public ExecApprovalsResolvedDefaults Defaults { get; init; } = null!; + public IReadOnlyList Allowlist { get; init; } = []; + public string? SocketToken { get; init; } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs new file mode 100644 index 0000000..530a9ed --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Shared.ExecApprovals; + +// New store for exec-approvals.json. Separate from legacy ExecApprovalPolicy (exec-policy.json). +// PR4 scope: read path only. Write path (recordAllowlistUse) is added in PR9. +public sealed class ExecApprovalsStore +{ + // KebabCaseLower covers all macOS enum values: deny, allowlist, full, off, on-miss, always, + // allow-once, allow-always. CamelCase would fail for "on-miss" and "allow-once". + internal static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.KebabCaseLower) }, + }; + + private readonly string _filePath; + private readonly IOpenClawLogger _logger; + private readonly SemaphoreSlim _lock = new(1, 1); + + public ExecApprovalsStore(string dataPath, IOpenClawLogger logger) + { + _filePath = Path.Combine(dataPath, "exec-approvals.json"); + _logger = logger; + } + + // ── Public API ──────────────────────────────────────────────────────────── + + // No side effects; does not create the file. Used by the evaluator (PR5). + public ExecApprovalsResolved ResolveReadOnly(string? agentId) + { + var file = LoadFile(); + return file is null + ? DefaultResolved(NormalizeAgentId(agentId)) + : ResolveFromFile(file, agentId); + } + + // Side-effecting resolve: creates the file if missing, initializes agents dict. + // For startup / settings UI. Not used by the evaluator. + public async Task ResolveAsync(string? agentId) + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + var file = await EnsureFileAsync().ConfigureAwait(false); + return ResolveFromFile(file, agentId); + } + finally + { + _lock.Release(); + } + } + + // ── File I/O ────────────────────────────────────────────────────────────── + + private ExecApprovalsFile? LoadFile() + { + if (!File.Exists(_filePath)) return null; + try + { + var json = File.ReadAllText(_filePath); + var file = JsonSerializer.Deserialize(json, JsonOptions); + if (file is null) + { + _logger.Warn("[EXEC-APPROVALS] exec-approvals.json deserialized to null; applying default-deny"); + return null; + } + if (file.Version != 1) + { + _logger.Warn($"[EXEC-APPROVALS] exec-approvals.json has unsupported version {file.Version}; applying default-deny"); + return null; + } + return Normalize(file); + } + catch (JsonException ex) + { + _logger.Warn($"[EXEC-APPROVALS] exec-approvals.json is malformed ({ex.Message}); applying default-deny"); + return null; + } + catch (Exception ex) + { + _logger.Warn($"[EXEC-APPROVALS] Failed to load exec-approvals.json ({ex.Message}); applying default-deny"); + return null; + } + } + + private async Task EnsureFileAsync() + { + var file = LoadFile(); + if (file is not null) + { + if (file.Agents is null) + { + file = new ExecApprovalsFile + { + Version = file.Version, + Socket = file.Socket, + Defaults = CopyDefaults(file.Defaults), + Agents = [], + }; + await SaveFileAsync(file).ConfigureAwait(false); + } + return file; + } + + // socket intentionally omitted in Windows v1 (research doc 02 decision 3). + var replacing = File.Exists(_filePath); + var newFile = new ExecApprovalsFile { Version = 1, Agents = [] }; + await SaveFileAsync(newFile).ConfigureAwait(false); + if (replacing) + _logger.Warn($"[EXEC-APPROVALS] Replaced unreadable exec-approvals.json with empty store at {_filePath}"); + else + _logger.Info($"[EXEC-APPROVALS] Created {_filePath}"); + return newFile; + } + + private async Task SaveFileAsync(ExecApprovalsFile file) + { + var dir = Path.GetDirectoryName(_filePath)!; + if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); + + var tmp = Path.Combine(dir, $".exec-approvals-{Guid.NewGuid():N}.tmp"); + try + { + var json = JsonSerializer.Serialize(file, JsonOptions); + await File.WriteAllTextAsync(tmp, json).ConfigureAwait(false); + // Atomic replace on NTFS via MoveFileExW (MOVEFILE_REPLACE_EXISTING). + File.Move(tmp, _filePath, overwrite: true); + } + catch (Exception ex) + { + _logger.Error($"[EXEC-APPROVALS] Failed to save {_filePath} ({ex.Message})"); + try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } + throw; + } + } + + // ── Normalization ───────────────────────────────────────────────────────── + + private static ExecApprovalsFile Normalize(ExecApprovalsFile file) + { + // Trim socket fields; nullify if both are empty after trim. + var socket = file.Socket is null ? null : NormalizeSocket(file.Socket); + + // Migrate agents["default"] → agents["main"]; "main" wins on conflicting fields. + // Null agents stays null here — EnsureFileAsync is responsible for initialization. + var defaults = CopyDefaults(file.Defaults); + + if (file.Agents is null) + return new ExecApprovalsFile { Version = 1, Socket = socket, Defaults = defaults, Agents = null }; + + var agents = new Dictionary(file.Agents); + + if (agents.TryGetValue("default", out var defaultAgent)) + { + agents.Remove("default"); + agents["main"] = agents.TryGetValue("main", out var mainAgent) + ? MergeAgent(fallback: defaultAgent, winner: mainAgent) + : defaultAgent; + } + + // Normalize allowlist entries (dropInvalid: false — keep non-empty invalids). + foreach (var key in agents.Keys.ToList()) + { + var agent = agents[key]; + if (agent.Allowlist is not null) + agents[key] = WithNormalizedAllowlist(agent, dropInvalid: false); + } + + return new ExecApprovalsFile { Version = 1, Socket = socket, Defaults = defaults, Agents = agents }; + } + + private static ExecApprovalsDefaults? CopyDefaults(ExecApprovalsDefaults? d) => + d is null ? null : new ExecApprovalsDefaults + { + Security = d.Security, + Ask = d.Ask, + AskFallback = d.AskFallback, + AutoAllowSkills = d.AutoAllowSkills, + }; + + private static ExecApprovalsSocketConfig? NormalizeSocket(ExecApprovalsSocketConfig s) + { + var path = string.IsNullOrWhiteSpace(s.Path) ? null : s.Path.Trim(); + var token = string.IsNullOrWhiteSpace(s.Token) ? null : s.Token.Trim(); + return (path is null && token is null) ? null : new ExecApprovalsSocketConfig { Path = path, Token = token }; + } + + // winner's non-null fields take precedence; allowlists are concatenated (fallback first). + private static ExecApprovalsAgent MergeAgent(ExecApprovalsAgent fallback, ExecApprovalsAgent winner) + { + var allowlist = new List(); + if (fallback.Allowlist is not null) allowlist.AddRange(fallback.Allowlist); + if (winner.Allowlist is not null) allowlist.AddRange(winner.Allowlist); + + return new ExecApprovalsAgent + { + Security = winner.Security ?? fallback.Security, + Ask = winner.Ask ?? fallback.Ask, + AskFallback = winner.AskFallback ?? fallback.AskFallback, + AutoAllowSkills = winner.AutoAllowSkills ?? fallback.AutoAllowSkills, + Allowlist = allowlist.Count > 0 ? allowlist : null, + }; + } + + private static ExecApprovalsAgent WithNormalizedAllowlist(ExecApprovalsAgent agent, bool dropInvalid) => + new() + { + Security = agent.Security, + Ask = agent.Ask, + AskFallback = agent.AskFallback, + AutoAllowSkills = agent.AutoAllowSkills, + Allowlist = NormalizeAllowlistEntries(agent.Allowlist!, dropInvalid) + is { Count: > 0 } list ? list : null, + }; + + // Mirrors macOS normalizeAllowlistEntries. + // dropInvalid=false: discard only null/empty patterns; keep non-empty ones regardless of validity. + // dropInvalid=true: same in v1 — pattern validity beyond non-empty is enforced by the allowlist + // matcher in PR5, not here. The flag is preserved for API symmetry with macOS. + internal static List NormalizeAllowlistEntries( + IEnumerable entries, bool dropInvalid) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(); + foreach (var entry in entries) + { + var pattern = entry.Pattern?.Trim(); + if (string.IsNullOrEmpty(pattern)) continue; + if (!seen.Add(pattern)) continue; + result.Add(pattern == entry.Pattern ? entry : new ExecAllowlistEntry + { + Id = entry.Id, + Pattern = pattern, + LastUsedAt = entry.LastUsedAt, + LastUsedCommand = entry.LastUsedCommand, + LastResolvedPath = entry.LastResolvedPath, + }); + } + return result; + } + + // ── Cascade resolution ──────────────────────────────────────────────────── + + private static ExecApprovalsResolved ResolveFromFile(ExecApprovalsFile file, string? agentId) + { + var id = NormalizeAgentId(agentId); + var agents = file.Agents ?? new Dictionary(); + agents.TryGetValue(id, out var agentEntry); + agents.TryGetValue("*", out var wildcardEntry); + var defaults = file.Defaults; + + // Cascade: agentEntry → wildcard → defaults → systemDefault + var security = agentEntry?.Security ?? wildcardEntry?.Security ?? defaults?.Security ?? ExecSecurity.Deny; + var ask = agentEntry?.Ask ?? wildcardEntry?.Ask ?? defaults?.Ask ?? ExecAsk.OnMiss; + var askFallback = agentEntry?.AskFallback ?? wildcardEntry?.AskFallback ?? defaults?.AskFallback ?? ExecAsk.Deny; + var autoAllowSkills = agentEntry?.AutoAllowSkills ?? wildcardEntry?.AutoAllowSkills ?? defaults?.AutoAllowSkills ?? false; + + // Allowlist: wildcard first, then agent; then normalize dropInvalid=true. + var combined = new List(); + if (wildcardEntry?.Allowlist is not null) combined.AddRange(wildcardEntry.Allowlist); + if (agentEntry?.Allowlist is not null) combined.AddRange(agentEntry.Allowlist); + + return new ExecApprovalsResolved + { + AgentId = id, + Defaults = new ExecApprovalsResolvedDefaults + { + Security = security, + Ask = ask, + AskFallback = askFallback, + AutoAllowSkills = autoAllowSkills, + }, + Allowlist = NormalizeAllowlistEntries(combined, dropInvalid: true), + SocketToken = file.Socket?.Token, + }; + } + + private static ExecApprovalsResolved DefaultResolved(string agentId) => + new() + { + AgentId = agentId, + Defaults = new ExecApprovalsResolvedDefaults + { + Security = ExecSecurity.Deny, + Ask = ExecAsk.OnMiss, + AskFallback = ExecAsk.Deny, + AutoAllowSkills = false, + }, + Allowlist = [], + }; + + // null/empty agentId → "main". Mirrors macOS. Evaluator does not need to know this. + private static string NormalizeAgentId(string? agentId) => + string.IsNullOrWhiteSpace(agentId) ? "main" : agentId; +} diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalsStoreTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalsStoreTests.cs new file mode 100644 index 0000000..d6af6de --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalsStoreTests.cs @@ -0,0 +1,706 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using OpenClaw.Shared; +using OpenClaw.Shared.ExecApprovals; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +// Tests for PR4: ExecApprovalsStore read path. +// Coverage: deserialization, normalization, cascade resolution, malformed/version guards, +// default-deny semantics, and ensureFile behavior. +public class ExecApprovalsStoreTests : IDisposable +{ + private readonly string _dir; + private readonly CapturingLogger _log; + + public ExecApprovalsStoreTests() + { + _dir = Path.Combine(Path.GetTempPath(), $"oca-store-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_dir); + _log = new CapturingLogger(); + } + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + private ExecApprovalsStore Store() => new(_dir, _log); + + private string FilePath => Path.Combine(_dir, "exec-approvals.json"); + + private void WriteFile(string json) => File.WriteAllText(FilePath, json); + + // ── Default-deny when file absent ──────────────────────────────────────── + + [Fact] + public void ResolveReadOnly_NoFile_ReturnsDefaultDeny() + { + var resolved = Store().ResolveReadOnly(null); + + Assert.Equal("main", resolved.AgentId); + Assert.Equal(ExecSecurity.Deny, resolved.Defaults.Security); + Assert.Equal(ExecAsk.OnMiss, resolved.Defaults.Ask); + Assert.Equal(ExecAsk.Deny, resolved.Defaults.AskFallback); + Assert.False(resolved.Defaults.AutoAllowSkills); + Assert.Empty(resolved.Allowlist); + Assert.Null(resolved.SocketToken); + } + + [Fact] + public void ResolveReadOnly_NullAgentId_NormalizesToMain() + { + WriteFile(MinimalFile()); + var resolved = Store().ResolveReadOnly(null); + Assert.Equal("main", resolved.AgentId); + } + + [Fact] + public void ResolveReadOnly_EmptyAgentId_NormalizesToMain() + { + WriteFile(MinimalFile()); + var resolved = Store().ResolveReadOnly(" "); + Assert.Equal("main", resolved.AgentId); + } + + // ── Malformed JSON → default-deny + warning ─────────────────────────────── + + [Fact] + public void ResolveReadOnly_MalformedJson_ReturnsDefaultDenyAndWarns() + { + WriteFile("{ not valid json }"); + var resolved = Store().ResolveReadOnly("main"); + + Assert.Equal(ExecSecurity.Deny, resolved.Defaults.Security); + Assert.Contains(_log.Warnings, w => w.Contains("malformed")); + } + + // ── Unsupported version → default-deny + warning ───────────────────────── + + [Fact] + public void ResolveReadOnly_Version2_ReturnsDefaultDenyAndWarns() + { + WriteFile("""{"version":2,"agents":{}}"""); + var resolved = Store().ResolveReadOnly("main"); + + Assert.Equal(ExecSecurity.Deny, resolved.Defaults.Security); + Assert.Contains(_log.Warnings, w => w.Contains("unsupported version")); + } + + [Fact] + public void ResolveReadOnly_Version0_ReturnsDefaultDenyAndWarns() + { + WriteFile("""{"version":0,"agents":{}}"""); + var resolved = Store().ResolveReadOnly("main"); + + Assert.Equal(ExecSecurity.Deny, resolved.Defaults.Security); + Assert.Contains(_log.Warnings, w => w.Contains("unsupported version")); + } + + // ── Deserialization: enum values ────────────────────────────────────────── + + [Fact] + public void ResolveReadOnly_EnumValues_DeserializedCorrectly() + { + WriteFile(""" + { + "version": 1, + "defaults": { "security": "allowlist", "ask": "on-miss", "askFallback": "deny" }, + "agents": {} + } + """); + + var resolved = Store().ResolveReadOnly("main"); + + Assert.Equal(ExecSecurity.Allowlist, resolved.Defaults.Security); + Assert.Equal(ExecAsk.OnMiss, resolved.Defaults.Ask); + Assert.Equal(ExecAsk.Deny, resolved.Defaults.AskFallback); + } + + [Fact] + public void ResolveReadOnly_FullSecurityAndAlwaysAsk_DeserializedCorrectly() + { + WriteFile(""" + { + "version": 1, + "defaults": { "security": "full", "ask": "always" }, + "agents": {} + } + """); + + var resolved = Store().ResolveReadOnly("main"); + + Assert.Equal(ExecSecurity.Full, resolved.Defaults.Security); + Assert.Equal(ExecAsk.Always, resolved.Defaults.Ask); + } + + // ── Cascade resolution ──────────────────────────────────────────────────── + + [Fact] + public void ResolveReadOnly_AgentOverridesDefault() + { + WriteFile(""" + { + "version": 1, + "defaults": { "security": "deny" }, + "agents": { + "main": { "security": "allowlist" } + } + } + """); + + var resolved = Store().ResolveReadOnly("main"); + Assert.Equal(ExecSecurity.Allowlist, resolved.Defaults.Security); + } + + [Fact] + public void ResolveReadOnly_WildcardFillsGapsWhenNoAgentEntry() + { + WriteFile(""" + { + "version": 1, + "agents": { + "*": { "security": "full", "ask": "always" } + } + } + """); + + var resolved = Store().ResolveReadOnly("unknown-agent"); + Assert.Equal(ExecSecurity.Full, resolved.Defaults.Security); + Assert.Equal(ExecAsk.Always, resolved.Defaults.Ask); + } + + [Fact] + public void ResolveReadOnly_AgentWinsOverWildcard() + { + WriteFile(""" + { + "version": 1, + "agents": { + "*": { "security": "full" }, + "main": { "security": "deny" } + } + } + """); + + var resolved = Store().ResolveReadOnly("main"); + Assert.Equal(ExecSecurity.Deny, resolved.Defaults.Security); + } + + [Fact] + public void ResolveReadOnly_CascadeOrder_AgentWildcardDefaultsSystem() + { + // Only system defaults apply — nothing in file overrides. + WriteFile("""{"version":1,"agents":{}}"""); + + var resolved = Store().ResolveReadOnly("main"); + + Assert.Equal(ExecSecurity.Deny, resolved.Defaults.Security); + Assert.Equal(ExecAsk.OnMiss, resolved.Defaults.Ask); + Assert.Equal(ExecAsk.Deny, resolved.Defaults.AskFallback); + Assert.False(resolved.Defaults.AutoAllowSkills); + } + + // ── Allowlist resolution ────────────────────────────────────────────────── + + [Fact] + public void ResolveReadOnly_AllowlistFromAgent_Returned() + { + WriteFile(""" + { + "version": 1, + "agents": { + "main": { + "security": "allowlist", + "allowlist": [ + { "id": "11111111-0000-0000-0000-000000000000", "pattern": "/usr/bin/git" } + ] + } + } + } + """); + + var resolved = Store().ResolveReadOnly("main"); + + Assert.Single(resolved.Allowlist); + Assert.Equal("/usr/bin/git", resolved.Allowlist[0].Pattern); + } + + [Fact] + public void ResolveReadOnly_AllowlistWildcardPlusAgent_Concatenated() + { + WriteFile(""" + { + "version": 1, + "agents": { + "*": { "allowlist": [{ "pattern": "/usr/bin/rg" }] }, + "main": { "allowlist": [{ "pattern": "/usr/bin/git" }] } + } + } + """); + + var resolved = Store().ResolveReadOnly("main"); + + Assert.Equal(2, resolved.Allowlist.Count); + // wildcard first + Assert.Equal("/usr/bin/rg", resolved.Allowlist[0].Pattern); + Assert.Equal("/usr/bin/git", resolved.Allowlist[1].Pattern); + } + + [Fact] + public void ResolveReadOnly_AllowlistDeduplicatedCaseInsensitive() + { + WriteFile(""" + { + "version": 1, + "agents": { + "*": { "allowlist": [{ "pattern": "/usr/bin/git" }] }, + "main": { "allowlist": [{ "pattern": "/USR/BIN/GIT" }] } + } + } + """); + + var resolved = Store().ResolveReadOnly("main"); + Assert.Single(resolved.Allowlist); + } + + [Fact] + public void ResolveReadOnly_AllowlistEmptyPatternDropped() + { + WriteFile(""" + { + "version": 1, + "agents": { + "main": { + "allowlist": [ + { "pattern": "" }, + { "pattern": "/usr/bin/git" } + ] + } + } + } + """); + + var resolved = Store().ResolveReadOnly("main"); + Assert.Single(resolved.Allowlist); + Assert.Equal("/usr/bin/git", resolved.Allowlist[0].Pattern); + } + + // ── Normalization: default→main migration ───────────────────────────────── + + [Fact] + public void Normalize_DefaultAgentMigratedToMain() + { + WriteFile(""" + { + "version": 1, + "agents": { + "default": { "security": "allowlist" } + } + } + """); + + var resolved = Store().ResolveReadOnly("main"); + Assert.Equal(ExecSecurity.Allowlist, resolved.Defaults.Security); + } + + [Fact] + public void Normalize_MainWinsOverDefaultOnConflict() + { + WriteFile(""" + { + "version": 1, + "agents": { + "default": { "security": "full" }, + "main": { "security": "deny" } + } + } + """); + + var resolved = Store().ResolveReadOnly("main"); + Assert.Equal(ExecSecurity.Deny, resolved.Defaults.Security); + } + + [Fact] + public void Normalize_DefaultAllowlistMergedIntoMain() + { + WriteFile(""" + { + "version": 1, + "agents": { + "default": { "allowlist": [{ "pattern": "/usr/bin/rg" }] }, + "main": { "allowlist": [{ "pattern": "/usr/bin/git" }] } + } + } + """); + + // After normalization "default" is gone; "main" has both entries (default first). + var resolved = Store().ResolveReadOnly("main"); + Assert.Equal(2, resolved.Allowlist.Count); + Assert.Equal("/usr/bin/rg", resolved.Allowlist[0].Pattern); + Assert.Equal("/usr/bin/git", resolved.Allowlist[1].Pattern); + } + + // ── Normalization: socket ───────────────────────────────────────────────── + + [Fact] + public void Normalize_SocketTokenPreserved() + { + WriteFile(""" + { + "version": 1, + "socket": { "token": "abc123" }, + "agents": {} + } + """); + + var resolved = Store().ResolveReadOnly("main"); + Assert.Equal("abc123", resolved.SocketToken); + } + + [Fact] + public void Normalize_EmptySocketTokenNulled() + { + WriteFile(""" + { + "version": 1, + "socket": { "token": " " }, + "agents": {} + } + """); + + var resolved = Store().ResolveReadOnly("main"); + Assert.Null(resolved.SocketToken); + } + + // ── lastUsedAt as double? ───────────────────────────────────────────────── + + [Fact] + public void Deserialize_LastUsedAt_AsDouble() + { + WriteFile(""" + { + "version": 1, + "agents": { + "main": { + "allowlist": [ + { "pattern": "/usr/bin/git", "lastUsedAt": 1714000000000.0 } + ] + } + } + } + """); + + var resolved = Store().ResolveReadOnly("main"); + Assert.Single(resolved.Allowlist); + Assert.Equal(1714000000000.0, resolved.Allowlist[0].LastUsedAt); + } + + // ── EnsureFile (ResolveAsync) ───────────────────────────────────────────── + + [Fact] + public async Task ResolveAsync_NoFile_CreatesFileAndReturnsDefaultDeny() + { + var resolved = await Store().ResolveAsync(null); + + Assert.True(File.Exists(FilePath)); + Assert.Equal("main", resolved.AgentId); + Assert.Equal(ExecSecurity.Deny, resolved.Defaults.Security); + Assert.Contains(_log.Infos, i => i.Contains("Created")); + } + + [Fact] + public async Task ResolveAsync_MalformedFile_ReplacesWithEmptyAndWarns() + { + WriteFile("{ bad json }"); + await Store().ResolveAsync(null); + + // File replaced with valid empty store. + var json = File.ReadAllText(FilePath); + Assert.Contains("\"version\"", json); + // Logged as a replacement warning, not a "Created" info. + Assert.Contains(_log.Warnings, w => w.Contains("Replaced")); + Assert.DoesNotContain(_log.Infos, i => i.Contains("Created")); + } + + [Fact] + public async Task ResolveAsync_ExistingFile_DoesNotRecreate() + { + WriteFile(MinimalFileWithAgent("main", "allowlist")); + var store = Store(); + + var resolved = await store.ResolveAsync("main"); + + Assert.Equal(ExecSecurity.Allowlist, resolved.Defaults.Security); + // No "Created" log — file was not recreated. + Assert.DoesNotContain(_log.Infos, i => i.Contains("Created")); + } + + [Fact] + public async Task ResolveAsync_NullAgentsField_InitializesAgentsAndSaves() + { + // File exists but agents is null — ensureFile should add agents:{} and save. + WriteFile("""{"version":1}"""); + await Store().ResolveAsync(null); + + var json = File.ReadAllText(FilePath); + Assert.Contains("\"agents\"", json); + } + + // ── Atomic write ────────────────────────────────────────────────────────── + + [Fact] + public async Task ResolveAsync_AtomicWrite_NoTempFileLeft() + { + await Store().ResolveAsync(null); + + var temps = Directory.GetFiles(_dir, "*.tmp"); + Assert.Empty(temps); + } + + // ── AutoAllowSkills ─────────────────────────────────────────────────────── + + [Fact] + public void ResolveReadOnly_AutoAllowSkills_True_WhenSetInAgent() + { + WriteFile(""" + { + "version": 1, + "agents": { "main": { "autoAllowSkills": true } } + } + """); + + var resolved = Store().ResolveReadOnly("main"); + Assert.True(resolved.Defaults.AutoAllowSkills); + } + + // ── Serialization round-trip ────────────────────────────────────────────── + + [Fact] + public void JsonOptions_SerializesEnumValues_MatchMacOS() + { + var file = new ExecApprovalsFile + { + Version = 1, + Defaults = new ExecApprovalsDefaults + { + Security = ExecSecurity.Allowlist, + Ask = ExecAsk.OnMiss, + AskFallback = ExecAsk.Off, + AutoAllowSkills = false, + }, + Agents = [], + }; + + var json = JsonSerializer.Serialize(file, ExecApprovalsStore.JsonOptions); + + Assert.Contains("\"allowlist\"", json); + Assert.Contains("\"on-miss\"", json); + Assert.Contains("\"off\"", json); + } + + [Fact] + public void JsonOptions_SerializesDenyAndFull_MatchMacOS() + { + var defaults = new ExecApprovalsDefaults + { + Security = ExecSecurity.Deny, + Ask = ExecAsk.Always, + }; + + var json = JsonSerializer.Serialize(defaults, ExecApprovalsStore.JsonOptions); + + Assert.Contains("\"deny\"", json); + Assert.Contains("\"always\"", json); + } + + // ── No side-effects contract ────────────────────────────────────────────── + + [Fact] + public void ResolveReadOnly_NoFile_DoesNotCreateFile() + { + Store().ResolveReadOnly(null); + Assert.False(File.Exists(FilePath)); + } + + [Fact] + public void ResolveReadOnly_MalformedFile_DoesNotOverwriteFile() + { + WriteFile("{ bad }"); + Store().ResolveReadOnly(null); + Assert.Equal("{ bad }", File.ReadAllText(FilePath)); + } + + // ── Cascade: defaults level ─────────────────────────────────────────────── + + [Fact] + public void ResolveReadOnly_DefaultsLevel_UsedWhenNoAgentOrWildcard() + { + WriteFile(""" + { + "version": 1, + "defaults": { "security": "full", "ask": "always", "askFallback": "on-miss", "autoAllowSkills": true }, + "agents": {} + } + """); + + var resolved = Store().ResolveReadOnly("main"); + + Assert.Equal(ExecSecurity.Full, resolved.Defaults.Security); + Assert.Equal(ExecAsk.Always, resolved.Defaults.Ask); + Assert.Equal(ExecAsk.OnMiss, resolved.Defaults.AskFallback); + Assert.True(resolved.Defaults.AutoAllowSkills); + } + + [Fact] + public void ResolveReadOnly_AgentWinsOverDefaultsLevel() + { + WriteFile(""" + { + "version": 1, + "defaults": { "security": "full", "ask": "always" }, + "agents": { "main": { "security": "deny", "ask": "off" } } + } + """); + + var resolved = Store().ResolveReadOnly("main"); + + Assert.Equal(ExecSecurity.Deny, resolved.Defaults.Security); + Assert.Equal(ExecAsk.Off, resolved.Defaults.Ask); + } + + [Fact] + public void ResolveReadOnly_WildcardWinsOverDefaultsLevel() + { + WriteFile(""" + { + "version": 1, + "defaults": { "security": "full" }, + "agents": { "*": { "security": "deny" } } + } + """); + + var resolved = Store().ResolveReadOnly("unknown"); + Assert.Equal(ExecSecurity.Deny, resolved.Defaults.Security); + } + + // ── Cascade: wildcard covers Ask/AskFallback/AutoAllowSkills ───────────── + + [Fact] + public void ResolveReadOnly_WildcardAsk_CascadesToUnknownAgent() + { + WriteFile(""" + { + "version": 1, + "agents": { "*": { "ask": "always", "askFallback": "on-miss", "autoAllowSkills": true } } + } + """); + + var resolved = Store().ResolveReadOnly("any-agent"); + + Assert.Equal(ExecAsk.Always, resolved.Defaults.Ask); + Assert.Equal(ExecAsk.OnMiss, resolved.Defaults.AskFallback); + Assert.True(resolved.Defaults.AutoAllowSkills); + } + + // ── Explicit non-main agentId ───────────────────────────────────────────── + + [Fact] + public void ResolveReadOnly_ExplicitAgentId_PreservedInResult() + { + WriteFile(""" + { + "version": 1, + "agents": { "agent-abc": { "security": "full" } } + } + """); + + var resolved = Store().ResolveReadOnly("agent-abc"); + + Assert.Equal("agent-abc", resolved.AgentId); + Assert.Equal(ExecSecurity.Full, resolved.Defaults.Security); + } + + // ── Socket path ─────────────────────────────────────────────────────────── + + [Fact] + public void Normalize_SocketPathPreserved() + { + WriteFile(""" + { + "version": 1, + "socket": { "path": "/run/openclaw.sock", "token": "tok" }, + "agents": {} + } + """); + + // path is not exposed via ExecApprovalsResolved (only token is), so we verify + // indirectly: socket token is intact when path is also present. + var resolved = Store().ResolveReadOnly("main"); + Assert.Equal("tok", resolved.SocketToken); + } + + [Fact] + public void Normalize_BothSocketFieldsEmpty_SocketBecomesNull() + { + WriteFile(""" + { + "version": 1, + "socket": { "path": " ", "token": "" }, + "agents": {} + } + """); + + var resolved = Store().ResolveReadOnly("main"); + Assert.Null(resolved.SocketToken); + } + + // ── WhenWritingNull: null fields omitted from written JSON ──────────────── + + [Fact] + public async Task ResolveAsync_WrittenFile_OmitsNullFields() + { + await Store().ResolveAsync(null); + + var json = File.ReadAllText(FilePath); + Assert.DoesNotContain("\"socket\"", json); + Assert.DoesNotContain("\"defaults\"", json); + Assert.DoesNotContain("null", json); + } + + // ── Serialization: ExecAsk.Deny serializes as "deny" ───────────────────── + + [Fact] + public void JsonOptions_ExecAskDeny_SerializesAsDeny() + { + var defaults = new ExecApprovalsDefaults { AskFallback = ExecAsk.Deny }; + var json = JsonSerializer.Serialize(defaults, ExecApprovalsStore.JsonOptions); + Assert.Contains("\"deny\"", json); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static string MinimalFile() => """{"version":1,"agents":{}}"""; + + private static string MinimalFileWithAgent(string agentId, string security) => $$""" + { + "version": 1, + "agents": { + "{{agentId}}": { "security": "{{security}}" } + } + } + """; +} + +internal sealed class CapturingLogger : IOpenClawLogger +{ + public List Infos { get; } = []; + public List Warnings { get; } = []; + public List Errors { get; } = []; + + public void Info(string message) => Infos.Add(message); + public void Debug(string message) { } + public void Warn(string message) => Warnings.Add(message); + public void Error(string message, Exception? ex = null) => Errors.Add(message); +}