feat: add ExecApprovalsStore read path

New store for exec-approvals.json, separate from legacy ExecApprovalPolicy.
Read path only: ResolveReadOnly (no side effects, for the evaluator) and
ResolveAsync (ensureFile side-effects, for startup/settings UI).

Key behaviors:
- Cascade: agentEntry → wildcard → defaults → systemDefault
- System defaults: security=deny, ask=on-miss, askFallback=deny, autoAllowSkills=false
- Malformed JSON or version != 1 → Warn + default-deny, no throw
- Normalization on read: socket trim, default→main migration, allowlist dedup
- Atomic write via write-to-temp + File.Move (NTFS MoveFileExW)
- SemaphoreSlim(1,1) for intra-process serialization
- socket preserved but never auto-generated in Windows v1
- lastUsedAt as double? (Unix epoch ms, matches macOS)
- KebabCaseLower for all enum values to match macOS wire format

41 tests covering cascade, normalization, malformed/version guards, ensureFile,
allowlist resolution, serialization round-trip, atomic write, and no-side-effects contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
github-actions[bot] 2026-05-08 13:05:12 +00:00 committed by GitHub
parent 57ebebc725
commit 7c63ba862d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 1103 additions and 0 deletions

View File

@ -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<ExecAllowlistEntry>? 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<string, ExecApprovalsAgent>? 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<ExecAllowlistEntry> Allowlist { get; init; } = [];
public string? SocketToken { get; init; }
}

View File

@ -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<ExecApprovalsResolved> 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<ExecApprovalsFile>(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<ExecApprovalsFile> 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<string, ExecApprovalsAgent>(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<ExecAllowlistEntry>();
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<ExecAllowlistEntry> NormalizeAllowlistEntries(
IEnumerable<ExecAllowlistEntry> entries, bool dropInvalid)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<ExecAllowlistEntry>();
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<string, ExecApprovalsAgent>();
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<ExecAllowlistEntry>();
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;
}

View File

@ -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<string> Infos { get; } = [];
public List<string> Warnings { get; } = [];
public List<string> 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);
}