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:
parent
57ebebc725
commit
7c63ba862d
92
src/OpenClaw.Shared/ExecApprovals/ExecApprovalsContracts.cs
Normal file
92
src/OpenClaw.Shared/ExecApprovals/ExecApprovalsContracts.cs
Normal 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; }
|
||||
}
|
||||
305
src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs
Normal file
305
src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs
Normal 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;
|
||||
}
|
||||
706
tests/OpenClaw.Shared.Tests/ExecApprovalsStoreTests.cs
Normal file
706
tests/OpenClaw.Shared.Tests/ExecApprovalsStoreTests.cs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user