summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Security
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
commitb07b41cd435991cd254957af79055080c85b346e (patch)
tree27720d3ce14e4d0b84670a55ac26a2f877ce3a20 /src/Agent.Windows/Security
parent2fefd29e86c3b926d0cf4a362251d3f119688612 (diff)
feat: AI Agent Sandbox MCP Server v1を実装
Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。 .NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。 Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。 Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。 Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。
Diffstat (limited to 'src/Agent.Windows/Security')
-rw-r--r--src/Agent.Windows/Security/AppContainerProfile.cs41
-rw-r--r--src/Agent.Windows/Security/ExecutablePolicy.cs51
-rw-r--r--src/Agent.Windows/Security/PathPolicy.cs52
-rw-r--r--src/Agent.Windows/Security/RestrictedProcessLauncher.cs255
-rw-r--r--src/Agent.Windows/Security/SecurityAudit.cs29
-rw-r--r--src/Agent.Windows/Security/TaskAclManager.cs76
-rw-r--r--src/Agent.Windows/Security/UserOnlyDirectory.cs20
7 files changed, 524 insertions, 0 deletions
diff --git a/src/Agent.Windows/Security/AppContainerProfile.cs b/src/Agent.Windows/Security/AppContainerProfile.cs
new file mode 100644
index 0000000..47aa64e
--- /dev/null
+++ b/src/Agent.Windows/Security/AppContainerProfile.cs
@@ -0,0 +1,41 @@
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+
+namespace Agent.Windows.Security;
+
+public sealed partial class AppContainerProfile
+{
+ public const string FixedName = "AgentSandboxMcp.V1";
+ private const int AlreadyExists = unchecked((int)0x800700B7);
+
+ private AppContainerProfile(string name, SecurityIdentifier sid) { Name = name; Sid = sid; }
+ public string Name { get; }
+ public SecurityIdentifier Sid { get; }
+
+ public static AppContainerProfile EnsureFixed()
+ {
+ int result = Native.CreateAppContainerProfile(FixedName, "Agent Sandbox MCP v1", "Fixed sandbox profile for Agent Sandbox MCP", 0, 0, out nint sidPointer);
+ if (result == AlreadyExists) result = Native.DeriveAppContainerSidFromAppContainerName(FixedName, out sidPointer);
+ if (result < 0) Marshal.ThrowExceptionForHR(result);
+ try
+ {
+ return new(FixedName, new SecurityIdentifier(sidPointer));
+ }
+ finally
+ {
+ if (sidPointer != 0) _ = Native.FreeSid(sidPointer);
+ }
+ }
+
+ private static partial class Native
+ {
+ [LibraryImport("userenv.dll", StringMarshalling = StringMarshalling.Utf16)]
+ internal static partial int CreateAppContainerProfile(string name, string displayName, string description, nint capabilities, uint capabilityCount, out nint sid);
+
+ [LibraryImport("userenv.dll", StringMarshalling = StringMarshalling.Utf16)]
+ internal static partial int DeriveAppContainerSidFromAppContainerName(string name, out nint sid);
+
+ [LibraryImport("advapi32.dll")]
+ internal static partial nint FreeSid(nint sid);
+ }
+}
diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs
new file mode 100644
index 0000000..3d20bfa
--- /dev/null
+++ b/src/Agent.Windows/Security/ExecutablePolicy.cs
@@ -0,0 +1,51 @@
+using System.Security.Cryptography;
+
+namespace Agent.Windows.Security;
+
+public sealed record ExecutableApproval(string Path, string Sha256);
+public sealed record VerifiedExecutable(string CanonicalPath, string Sha256);
+
+public sealed class ExecutablePolicy(IEnumerable<ExecutableApproval> approvals, bool allowShell)
+{
+ private readonly IReadOnlyDictionary<string, string> _approvals = approvals.ToDictionary(
+ approval => CanonicalPath.Resolve(approval.Path), approval => NormalizeHash(approval.Sha256), StringComparer.OrdinalIgnoreCase);
+
+ public VerifiedExecutable Verify(string executable)
+ {
+ string canonical = CanonicalPath.Resolve(executable);
+ if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled.");
+ if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", "Executable path is not approved.");
+ using FileStream stream = new(canonical, FileMode.Open, FileAccess.Read, FileShare.Read);
+ string actualHash = Convert.ToHexString(SHA256.HashData(stream));
+ if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approvedHash), Convert.FromHexString(actualHash)))
+ throw new SandboxPolicyException("process.hash_denied", "Executable SHA-256 does not match its approval.");
+ return new(canonical, actualHash);
+ }
+
+ public static string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments)
+ {
+ if (!Path.GetFileName(executable).Equals("dotnet.exe", StringComparison.OrdinalIgnoreCase) || arguments.Count == 0) return null;
+ if (arguments[0].Equals("exec", StringComparison.OrdinalIgnoreCase))
+ {
+ if (arguments.Count < 2 || arguments[1].StartsWith("-", StringComparison.Ordinal))
+ throw new SandboxPolicyException("process.managed_entry_invalid", "dotnet exec requires an explicit managed entry assembly as its next argument.");
+ return arguments[1];
+ }
+ return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? arguments[0] : null;
+ }
+
+ public static string ComputeSha256(string executable)
+ {
+ using FileStream stream = new(CanonicalPath.Resolve(executable), FileMode.Open, FileAccess.Read, FileShare.Read);
+ return Convert.ToHexString(SHA256.HashData(stream));
+ }
+
+ private static bool IsShell(string path) => Path.GetFileName(path) is string name &&
+ (name.Equals("cmd.exe", StringComparison.OrdinalIgnoreCase) || name.Equals("powershell.exe", StringComparison.OrdinalIgnoreCase) || name.Equals("pwsh.exe", StringComparison.OrdinalIgnoreCase));
+ private static string NormalizeHash(string hash)
+ {
+ string normalized = hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant();
+ if (normalized.Length != 64 || !normalized.All(Uri.IsHexDigit)) throw new ArgumentException("SHA-256 approval must contain 64 hexadecimal characters.", nameof(hash));
+ return normalized;
+ }
+}
diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs
new file mode 100644
index 0000000..32a949c
--- /dev/null
+++ b/src/Agent.Windows/Security/PathPolicy.cs
@@ -0,0 +1,52 @@
+namespace Agent.Windows.Security;
+
+[Flags]
+public enum FileCapability { None = 0, Read = 1, Write = 2, Execute = 4, Delete = 8 }
+
+public sealed record FilePolicyEntry(string Path, FileCapability Capabilities);
+
+public sealed class PathPolicy(IEnumerable<FilePolicyEntry> entries)
+{
+ private readonly IReadOnlyList<FilePolicyEntry> _entries = entries.Select(entry => entry with { Path = CanonicalPath.Resolve(entry.Path, requireExisting: false) }).ToArray();
+
+ public bool IsAllowed(string path, FileCapability required)
+ {
+ string canonical = CanonicalPath.Resolve(path, requireExisting: required is FileCapability.Read or FileCapability.Execute or FileCapability.Delete);
+ return _entries.Any(entry => IsAtOrBelow(canonical, entry.Path) && (entry.Capabilities & required) == required);
+ }
+
+ public void Demand(string path, FileCapability required)
+ {
+ if (!IsAllowed(path, required)) throw new SandboxPolicyException("policy.deny", $"'{path}' is not approved for {required}.");
+ }
+
+ private static bool IsAtOrBelow(string candidate, string root) =>
+ candidate.Equals(root, StringComparison.OrdinalIgnoreCase) ||
+ candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
+}
+
+public static class CanonicalPath
+{
+ public static string Resolve(string path, bool requireExisting = true)
+ {
+ if (string.IsNullOrWhiteSpace(path)) throw new SandboxPolicyException("path.invalid", "Path is required.");
+ string fullPath = Path.GetFullPath(path);
+ string? root = Path.GetPathRoot(fullPath);
+ if (string.IsNullOrEmpty(root)) throw new SandboxPolicyException("path.invalid", "Path has no filesystem root.");
+ string current = root;
+ foreach (string segment in fullPath[root.Length..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries))
+ {
+ current = Path.Combine(current, segment);
+ if (!File.Exists(current) && !Directory.Exists(current)) continue;
+ if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
+ throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: '{current}'.");
+ }
+ if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath);
+ return Path.TrimEndingDirectorySeparator(fullPath);
+ }
+}
+
+public sealed class SandboxPolicyException(string code, string message) : UnauthorizedAccessException(message)
+{
+ public string Code { get; } = code;
+}
diff --git a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
new file mode 100644
index 0000000..0786150
--- /dev/null
+++ b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
@@ -0,0 +1,255 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+using System.Text;
+using Microsoft.Win32.SafeHandles;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.Security;
+using Windows.Win32.System.JobObjects;
+using Windows.Win32.System.Threading;
+
+namespace Agent.Windows.Security;
+
+public sealed record RestrictedProcessResult(uint ProcessId, uint ExitCode, string StandardOutput, string StandardError);
+public sealed record SandboxJobLimits(uint MaxProcesses = 16, ulong MaxJobMemoryBytes = 4UL * 1024 * 1024 * 1024);
+
+public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executablePolicy, ISecurityAuditSink audit, SecurityIdentifier? appContainerSid = null, SandboxJobLimits? jobLimits = null)
+{
+ private const uint ProcessCreationChildProcessRestricted = 0x1;
+ private readonly SandboxJobLimits _jobLimits = jobLimits ?? new();
+
+ public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout, string? standardInput = null)
+ {
+ VerifiedExecutable verified;
+ VerifiedExecutable? managedEntry = null;
+ try
+ {
+ verified = executablePolicy.Verify(executable);
+ string? managedEntryPath = ExecutablePolicy.ManagedEntryPoint(verified.CanonicalPath, arguments);
+ if (managedEntryPath is not null) managedEntry = executablePolicy.Verify(managedEntryPath);
+ }
+ catch (SandboxPolicyException ex)
+ {
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string> { ["code"] = ex.Code, ["executable"] = executable }));
+ throw;
+ }
+
+ string cwd = CanonicalPath.Resolve(workingDirectory);
+ using FileStream stdin = CaptureFile();
+ using FileStream stdout = CaptureFile();
+ using FileStream stderr = CaptureFile();
+ if (!string.IsNullOrEmpty(standardInput))
+ {
+ byte[] input = Encoding.UTF8.GetBytes(standardInput);
+ stdin.Write(input);
+ stdin.Flush(true);
+ }
+ stdin.Position = 0;
+ MakeInheritable(stdin.SafeFileHandle);
+ MakeInheritable(stdout.SafeFileHandle);
+ MakeInheritable(stderr.SafeFileHandle);
+ using SafeFileHandle job = PInvoke.CreateJobObject(null, null);
+ if (job.IsInvalid) throw LastError("CreateJobObject");
+ try
+ {
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = default;
+ limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE |
+ JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_JOB_MEMORY;
+ limits.BasicLimitInformation.ActiveProcessLimit = _jobLimits.MaxProcesses;
+ limits.JobMemoryLimit = (nuint)_jobLimits.MaxJobMemoryBytes;
+ if (!PInvoke.SetInformationJobObject(new HANDLE(job.DangerousGetHandle()), JOBOBJECTINFOCLASS.JobObjectExtendedLimitInformation, &limits, (uint)sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)))
+ throw LastError("SetInformationJobObject");
+
+ nuint attributeSize = 0;
+ uint attributeCount = appContainerSid is null ? 2u : 3u;
+ _ = PInvoke.InitializeProcThreadAttributeList(default, attributeCount, 0, &attributeSize);
+ LPPROC_THREAD_ATTRIBUTE_LIST attributes = (LPPROC_THREAD_ATTRIBUTE_LIST)NativeMemory.Alloc(attributeSize);
+ void* sidBuffer = null;
+ void* internetClientSidBuffer = null;
+ void* privateNetworkSidBuffer = null;
+ try
+ {
+ if (!PInvoke.InitializeProcThreadAttributeList(attributes, attributeCount, 0, &attributeSize)) throw LastError("InitializeProcThreadAttributeList");
+ uint childPolicy = ProcessCreationChildProcessRestricted;
+ if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, &childPolicy, sizeof(uint), null, null))
+ throw LastError("UpdateProcThreadAttribute");
+ HANDLE[] inheritedHandles =
+ [
+ new(stdin.SafeFileHandle.DangerousGetHandle()),
+ new(stdout.SafeFileHandle.DangerousGetHandle()),
+ new(stderr.SafeFileHandle.DangerousGetHandle())
+ ];
+ fixed (HANDLE* inheritedHandlePointer = inheritedHandles)
+ {
+ if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
+ inheritedHandlePointer, (nuint)(sizeof(HANDLE) * inheritedHandles.Length), null, null))
+ throw LastError("UpdateProcThreadAttribute(HandleList)");
+ }
+ SecurityCapabilities securityCapabilities = default;
+ if (appContainerSid is not null)
+ {
+ byte[] sidBytes = new byte[appContainerSid.BinaryLength];
+ appContainerSid.GetBinaryForm(sidBytes, 0);
+ sidBuffer = NativeMemory.Alloc((nuint)sidBytes.Length);
+ Marshal.Copy(sidBytes, 0, (nint)sidBuffer, sidBytes.Length);
+ securityCapabilities.AppContainerSid = (nint)sidBuffer;
+ internetClientSidBuffer = AllocateSid(new SecurityIdentifier("S-1-15-3-1"));
+ privateNetworkSidBuffer = AllocateSid(new SecurityIdentifier("S-1-15-3-3"));
+ SID_AND_ATTRIBUTES* capabilities = stackalloc SID_AND_ATTRIBUTES[2];
+ capabilities[0].Sid = new PSID(internetClientSidBuffer);
+ capabilities[0].Attributes = 0x00000004;
+ capabilities[1].Sid = new PSID(privateNetworkSidBuffer);
+ capabilities[1].Attributes = 0x00000004;
+ securityCapabilities.Capabilities = (nint)capabilities;
+ securityCapabilities.CapabilityCount = 2;
+ if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES,
+ &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LastError("UpdateProcThreadAttribute(SecurityCapabilities)");
+ }
+ STARTUPINFOEXW startup = default;
+ startup.StartupInfo.cb = (uint)sizeof(STARTUPINFOEXW);
+ startup.StartupInfo.dwFlags = STARTUPINFOW_FLAGS.STARTF_USESTDHANDLES;
+ startup.StartupInfo.hStdInput = inheritedHandles[0];
+ startup.StartupInfo.hStdOutput = inheritedHandles[1];
+ startup.StartupInfo.hStdError = inheritedHandles[2];
+ startup.lpAttributeList = attributes;
+ PROCESS_INFORMATION process = default;
+ string commandLine = BuildCommandLine(verified.CanonicalPath, arguments);
+ Span<char> commandBuffer = new char[commandLine.Length + 1];
+ commandLine.AsSpan().CopyTo(commandBuffer);
+ if (!PInvoke.CreateProcess(null, ref commandBuffer, null, null, true,
+ PROCESS_CREATION_FLAGS.EXTENDED_STARTUPINFO_PRESENT | PROCESS_CREATION_FLAGS.CREATE_SUSPENDED,
+ null, cwd, in startup.StartupInfo, out process)) throw LastError("CreateProcess");
+ try
+ {
+ using var processView = new SafeFileHandle(process.hProcess, ownsHandle: false);
+ if (!PInvoke.AssignProcessToJobObject(job, processView)) throw LastError("AssignProcessToJobObject");
+ try
+ {
+ VerifiedExecutable postCreate = executablePolicy.Verify(executable);
+ if (!string.Equals(postCreate.CanonicalPath, verified.CanonicalPath, StringComparison.OrdinalIgnoreCase) ||
+ !string.Equals(postCreate.Sha256, verified.Sha256, StringComparison.Ordinal))
+ throw new SandboxPolicyException("process.identity_changed", "Executable identity changed during process creation.");
+ if (managedEntry is not null)
+ {
+ VerifiedExecutable postCreateManaged = executablePolicy.Verify(managedEntry.CanonicalPath);
+ if (!string.Equals(postCreateManaged.Sha256, managedEntry.Sha256, StringComparison.Ordinal))
+ throw new SandboxPolicyException("process.managed_identity_changed", "Managed entry assembly identity changed during process creation.");
+ }
+ }
+ catch (SandboxPolicyException ex)
+ {
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "denied", new Dictionary<string, string>
+ {
+ ["code"] = ex.Code, ["executable"] = executable, ["stage"] = "post_create_pre_resume"
+ }));
+ throw;
+ }
+ if (PInvoke.ResumeThread(process.hThread) == uint.MaxValue) throw LastError("ResumeThread");
+ var processAudit = new Dictionary<string, string>
+ {
+ ["path"] = verified.CanonicalPath, ["sha256"] = verified.Sha256, ["child_process_policy"] = "restricted",
+ ["app_container_sid"] = appContainerSid?.Value ?? "none", ["network_capabilities"] = appContainerSid is null ? "none" : "internetClient,privateNetworkClientServer",
+ ["pid"] = process.dwProcessId.ToString()
+ };
+ if (managedEntry is not null)
+ {
+ processAudit["managed_entry_path"] = managedEntry.CanonicalPath;
+ processAudit["managed_entry_sha256"] = managedEntry.Sha256;
+ }
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit));
+ WAIT_EVENT wait = PInvoke.WaitForSingleObject(process.hProcess, checked((uint)Math.Clamp(timeout.TotalMilliseconds, 1, uint.MaxValue - 1)));
+ if (wait == WAIT_EVENT.WAIT_TIMEOUT)
+ {
+ _ = PInvoke.TerminateJobObject(job, 1460);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.timeout", "terminated", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString() }));
+ throw new TimeoutException("Sandbox process exceeded its timeout.");
+ }
+ if (!PInvoke.GetExitCodeProcess(processView, out uint exitCode)) throw LastError("GetExitCodeProcess");
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.exit", "observed", new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString(), ["exit_code"] = exitCode.ToString() }));
+ if (exitCode != 0)
+ {
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "observed", new Dictionary<string, string>
+ {
+ ["code"] = "restricted_process_failure", ["pid"] = process.dwProcessId.ToString(),
+ ["child_process_policy"] = "restricted", ["exit_code"] = exitCode.ToString()
+ }));
+ }
+ return new(process.dwProcessId, exitCode, ReadCapture(stdout), ReadCapture(stderr));
+ }
+ finally
+ {
+ PInvoke.CloseHandle(process.hThread);
+ PInvoke.CloseHandle(process.hProcess);
+ }
+ }
+ finally
+ {
+ if (attributes.Value is not null) PInvoke.DeleteProcThreadAttributeList(attributes);
+ NativeMemory.Free(sidBuffer);
+ NativeMemory.Free(internetClientSidBuffer);
+ NativeMemory.Free(privateNetworkSidBuffer);
+ NativeMemory.Free(attributes.Value);
+ }
+ }
+ finally { }
+ }
+
+ private static Win32Exception LastError(string operation)
+ {
+ int code = Marshal.GetLastWin32Error();
+ return new(code, $"{operation} failed with Win32 error {code}.");
+ }
+ private static FileStream CaptureFile()
+ {
+ string path = Path.Combine(Path.GetTempPath(), "agent-mcp-process-" + Guid.NewGuid().ToString("N") + ".tmp");
+ return new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete, 4096,
+ FileOptions.DeleteOnClose | FileOptions.SequentialScan);
+ }
+ private static void* AllocateSid(SecurityIdentifier sid)
+ {
+ byte[] bytes = new byte[sid.BinaryLength];
+ sid.GetBinaryForm(bytes, 0);
+ void* buffer = NativeMemory.Alloc((nuint)bytes.Length);
+ Marshal.Copy(bytes, 0, (nint)buffer, bytes.Length);
+ return buffer;
+ }
+ private static void MakeInheritable(SafeFileHandle handle)
+ {
+ if (!PInvoke.SetHandleInformation(handle, (uint)HANDLE_FLAGS.HANDLE_FLAG_INHERIT, HANDLE_FLAGS.HANDLE_FLAG_INHERIT))
+ throw LastError("SetHandleInformation");
+ }
+ private static string ReadCapture(FileStream stream)
+ {
+ const int maxCharacters = 1024 * 1024;
+ stream.Position = 0;
+ using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
+ char[] buffer = new char[maxCharacters + 1];
+ int count = reader.ReadBlock(buffer, 0, buffer.Length);
+ return count > maxCharacters ? new string(buffer, 0, maxCharacters) + "\n[output truncated]" : new string(buffer, 0, count);
+ }
+ private static string BuildCommandLine(string executable, IReadOnlyList<string> arguments) => string.Join(' ', [Quote(executable), .. arguments.Select(Quote)]);
+ private static string Quote(string value)
+ {
+ if (value.Length > 0 && !value.Any(static character => char.IsWhiteSpace(character) || character == '"')) return value;
+ var result = new System.Text.StringBuilder().Append('"');
+ int slashes = 0;
+ foreach (char character in value)
+ {
+ if (character == '\\') { slashes++; continue; }
+ if (character == '"') result.Append('\\', (slashes * 2) + 1).Append('"');
+ else result.Append('\\', slashes).Append(character);
+ slashes = 0;
+ }
+ return result.Append('\\', slashes * 2).Append('"').ToString();
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct SecurityCapabilities
+ {
+ public nint AppContainerSid;
+ public nint Capabilities;
+ public uint CapabilityCount;
+ public uint Reserved;
+ }
+}
diff --git a/src/Agent.Windows/Security/SecurityAudit.cs b/src/Agent.Windows/Security/SecurityAudit.cs
new file mode 100644
index 0000000..8858c2b
--- /dev/null
+++ b/src/Agent.Windows/Security/SecurityAudit.cs
@@ -0,0 +1,29 @@
+using System.Text.Json;
+
+namespace Agent.Windows.Security;
+
+public sealed record SecurityAuditEvent(DateTimeOffset Timestamp, Guid TaskId, string Event, string Outcome, IReadOnlyDictionary<string, string> Data);
+
+public interface ISecurityAuditSink
+{
+ void Write(SecurityAuditEvent auditEvent);
+}
+
+public sealed class JsonLinesSecurityAuditSink(string path) : ISecurityAuditSink
+{
+ private readonly object _sync = new();
+ public void Write(SecurityAuditEvent auditEvent)
+ {
+ string? directory = Path.GetDirectoryName(Path.GetFullPath(path));
+ if (directory is not null) Directory.CreateDirectory(directory);
+ string line = JsonSerializer.Serialize(auditEvent);
+ lock (_sync) File.AppendAllText(path, line + Environment.NewLine);
+ }
+}
+
+public sealed class MemorySecurityAuditSink : ISecurityAuditSink
+{
+ private readonly List<SecurityAuditEvent> _events = [];
+ public IReadOnlyList<SecurityAuditEvent> Events => _events;
+ public void Write(SecurityAuditEvent auditEvent) => _events.Add(auditEvent);
+}
diff --git a/src/Agent.Windows/Security/TaskAclManager.cs b/src/Agent.Windows/Security/TaskAclManager.cs
new file mode 100644
index 0000000..f01e859
--- /dev/null
+++ b/src/Agent.Windows/Security/TaskAclManager.cs
@@ -0,0 +1,76 @@
+using System.Security.AccessControl;
+using System.Security.Principal;
+using Agent.Windows.Recovery;
+
+namespace Agent.Windows.Security;
+
+public sealed record AclSnapshot(string Path, byte[] SecurityDescriptor, Guid? JournalEntryId = null);
+
+public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurityAuditSink audit, TaskResourceJournal? journal = null)
+{
+ public AclSnapshot Grant(Guid taskId, string path, FileCapability capabilities)
+ {
+ string canonical = CanonicalPath.Resolve(path);
+ FileSystemSecurity security = ReadSecurity(canonical);
+ byte[] original = security.GetSecurityDescriptorBinaryForm();
+ Guid? journalEntry = journal?.Prepare("acl", canonical, new Dictionary<string, string>
+ {
+ ["security_descriptor"] = Convert.ToBase64String(original)
+ });
+ FileSystemRights rights = ToRights(capabilities);
+ if (rights == 0) throw new ArgumentOutOfRangeException(nameof(capabilities));
+ bool directory = Directory.Exists(canonical);
+ var rule = new FileSystemAccessRule(appContainerSid, rights,
+ directory ? InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit : InheritanceFlags.None,
+ PropagationFlags.None, AccessControlType.Allow);
+ security.AddAccessRule(rule);
+ WriteSecurity(canonical, security);
+ if (journalEntry is not null) journal!.MarkApplied(journalEntry.Value);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.apply", "applied", new Dictionary<string, string> { ["path"] = canonical, ["sid"] = appContainerSid.Value, ["rights"] = rights.ToString() }));
+ return new(canonical, original, journalEntry);
+ }
+
+ public void Restore(Guid taskId, AclSnapshot snapshot)
+ {
+ FileSystemSecurity security = Directory.Exists(snapshot.Path) ? new DirectorySecurity() : new FileSecurity();
+ security.SetSecurityDescriptorBinaryForm(snapshot.SecurityDescriptor, AccessControlSections.Access);
+ WriteSecurity(snapshot.Path, security);
+ if (snapshot.JournalEntryId is not null) journal!.MarkCleaned(snapshot.JournalEntryId.Value);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.restore", "restored", new Dictionary<string, string> { ["path"] = snapshot.Path }));
+ }
+
+ public void NormalizeNewEntry(Guid taskId, string path)
+ {
+ string canonical = CanonicalPath.Resolve(path);
+ FileSystemSecurity security = ReadSecurity(canonical);
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+ foreach (FileSystemAccessRule rule in security.GetAccessRules(includeExplicit: true, includeInherited: false, typeof(SecurityIdentifier))
+ .OfType<FileSystemAccessRule>()
+ .Where(rule => rule.IdentityReference == appContainerSid)
+ .ToArray())
+ security.RemoveAccessRuleSpecific(rule);
+ security.SetAccessRuleProtection(isProtected: false, preserveInheritance: false);
+ WriteSecurity(canonical, security);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.normalize", "normalized", new Dictionary<string, string> { ["path"] = canonical }));
+ }
+
+ private static FileSystemSecurity ReadSecurity(string path) => Directory.Exists(path)
+ ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access)
+ : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access);
+
+ private static void WriteSecurity(string path, FileSystemSecurity security)
+ {
+ if (security is DirectorySecurity directorySecurity) FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(path), directorySecurity);
+ else FileSystemAclExtensions.SetAccessControl(new FileInfo(path), (FileSecurity)security);
+ }
+
+ private static FileSystemRights ToRights(FileCapability capabilities)
+ {
+ FileSystemRights rights = 0;
+ if ((capabilities & FileCapability.Read) != 0) rights |= FileSystemRights.ReadAndExecute | FileSystemRights.ListDirectory;
+ if ((capabilities & FileCapability.Write) != 0) rights |= FileSystemRights.Write;
+ if ((capabilities & FileCapability.Execute) != 0) rights |= FileSystemRights.ReadAndExecute;
+ if ((capabilities & FileCapability.Delete) != 0) rights |= FileSystemRights.Delete | FileSystemRights.DeleteSubdirectoriesAndFiles;
+ return rights;
+ }
+}
diff --git a/src/Agent.Windows/Security/UserOnlyDirectory.cs b/src/Agent.Windows/Security/UserOnlyDirectory.cs
new file mode 100644
index 0000000..a9ae5ae
--- /dev/null
+++ b/src/Agent.Windows/Security/UserOnlyDirectory.cs
@@ -0,0 +1,20 @@
+using System.Security.AccessControl;
+using System.Security.Principal;
+
+namespace Agent.Windows.Security;
+
+public static class UserOnlyDirectory
+{
+ public static void Ensure(string path)
+ {
+ Directory.CreateDirectory(path);
+ using WindowsIdentity identity = WindowsIdentity.GetCurrent();
+ SecurityIdentifier user = identity.User ?? throw new InvalidOperationException("Current Windows identity has no user SID.");
+ var security = new DirectorySecurity();
+ security.SetOwner(user);
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+ security.AddAccessRule(new FileSystemAccessRule(user, FileSystemRights.FullControl,
+ InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
+ FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(path), security);
+ }
+}