summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/Design.md6
-rw-r--r--plans/ImplementationPlan.md3
-rw-r--r--src/Agent.Windows/Security/TaskAclManager.cs23
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs32
-rw-r--r--tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs6
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs147
6 files changed, 169 insertions, 48 deletions
diff --git a/doc/Design.md b/doc/Design.md
index 693cdeb..a908441 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -477,8 +477,10 @@ network:
- AppContainer外の通常user tokenに戻るbreakawayを許可しない。
### 11.2 NTFS ACL
- Policy Engineが許可pathから必要ACEを導出する。
-- 既存DACL全体を書き換えず、アプリケーションSID向けの必要ACEだけをtransactionalに追加する。
-- 元security descriptorまたは追加ACEの逆操作情報をRecovery Journalへ記録する。
+- 許可pathがdirectoryの場合、そのrootへ`ContainerInherit | ObjectInherit`付きのApplication SID ACEを1件だけtransactionalに追加し、既存およびTask中に作成されたdescendantへ継承させる。descendantごとのDACL列挙・snapshot・明示ACE追加は行わない。
+- 許可pathがfileの場合は、そのfileだけへ継承なしACEを追加する。異なる権限を持つnested rootは別scopeとして逆順cleanupする。
+- protected DACLを持つdescendantの継承保護は解除しない。そのpathはOS拒否のままとし、Sandbox権限を暗黙に拡張しない。
+- rootの元security descriptorをRecovery Journalへ1件記録する。
- Task終了で追加分を除去し、元状態との整合を検査する。
### 11.3 Job Object
- TaskごとにJob Objectを1つ作成する。
diff --git a/plans/ImplementationPlan.md b/plans/ImplementationPlan.md
index 8127701..5af7606 100644
--- a/plans/ImplementationPlan.md
+++ b/plans/ImplementationPlan.md
@@ -364,7 +364,7 @@ LLM→Tool→Policyの境界が固定され、Policyを通らない副作用経è
- FilesystemPolicy R/W/E/Delete
- canonical path
- reparse point防御
-- NTFS ACL
+- NTFS ACL(承認directory rootへの継承ACE、root単位snapshot、protected descendantのfail-safe維持)
- Process approval canonical path + SHA-256
- shell toggle
- Job Object
@@ -407,6 +407,7 @@ ST-01〜11、16全PASS。Security不具合を残したまま進行禁止。
- Recovery Journal
- CLEAN / PREPARING / APPLIED / RUNNING / CLEANING / RECOVERY_REQUIRED
- ACL適用前journal flush
+- ACL journalは承認root単位とし、既存・新規descendantの継承とroot復元を検証
- Task Resource ownership
- cleanup逆順処理
- 起動前Recovery
diff --git a/src/Agent.Windows/Security/TaskAclManager.cs b/src/Agent.Windows/Security/TaskAclManager.cs
index f01e859..2b091a6 100644
--- a/src/Agent.Windows/Security/TaskAclManager.cs
+++ b/src/Agent.Windows/Security/TaskAclManager.cs
@@ -26,7 +26,13 @@ public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurity
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() }));
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.apply", "applied", new Dictionary<string, string>
+ {
+ ["path"] = canonical,
+ ["sid"] = appContainerSid.Value,
+ ["rights"] = rights.ToString(),
+ ["scope"] = directory ? "root_inheritance" : "file"
+ }));
return new(canonical, original, journalEntry);
}
@@ -39,21 +45,6 @@ public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurity
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);
diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs
index a21544b..c0d1561 100644
--- a/src/Agent.Windows/Tools/WindowsToolBackend.cs
+++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs
@@ -49,8 +49,10 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
// Do not rewrite a file ACL when execute is the only requested capability; CreateProcess remains
// constrained by AppContainer access plus canonical path/SHA-256 approval.
if (File.Exists(path) && capabilities == FileCapability.Execute) continue;
- context.Roots.Add(path);
- foreach (string target in ExistingAclTargets(path)) context.Acls.Add(acl.Grant(taskId, target, capabilities));
+ // A single inheritable ACE on an approved directory covers existing and newly-created
+ // descendants. Snapshotting and rewriting every child ACL is both redundant and
+ // prohibitively expensive for runtime trees such as Python installations.
+ context.Acls.Add(acl.Grant(taskId, path, capabilities));
}
IReadOnlyList<NetworkEndpoint> allowances = settings.NetworkMode == "allow"
? await NetworkAllowanceSnapshot.ResolveAsync(settings.NetworkAllowances, cancellationToken).ConfigureAwait(false)
@@ -193,11 +195,6 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
List<Exception> failures = [];
try { context.Network?.Dispose(); } catch (Exception ex) { failures.Add(ex); }
foreach (AclSnapshot snapshot in context.Acls.AsEnumerable().Reverse()) try { context.Acl.Restore(taskId, snapshot); } catch (Exception ex) { failures.Add(ex); }
- var existingSnapshots = context.Acls.Select(static snapshot => snapshot.Path).ToHashSet(StringComparer.OrdinalIgnoreCase);
- foreach (string entry in context.Roots.SelectMany(ExistingAclTargets).Distinct(StringComparer.OrdinalIgnoreCase)
- .Where(entry => !existingSnapshots.Contains(entry))
- .OrderBy(static entry => entry.Count(character => character == Path.DirectorySeparatorChar)))
- try { context.Acl.NormalizeNewEntry(taskId, entry); } catch (Exception ex) { failures.Add(ex); }
if (failures.Count == 0) context.Journal.CompleteAndDelete();
await Task.CompletedTask;
if (failures.Count > 0) throw new AggregateException("Task resource cleanup failed.", failures);
@@ -215,26 +212,6 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
var regex = new Regex(query, RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
return files.Where(path => IsTextMatch(path, regex.IsMatch));
}
- private static IEnumerable<string> ExistingAclTargets(string path)
- {
- if (File.Exists(path))
- {
- yield return path;
- yield break;
- }
- if (!Directory.Exists(path)) yield break;
- var options = new EnumerationOptions
- {
- RecurseSubdirectories = true,
- IgnoreInaccessible = false,
- ReturnSpecialDirectories = false,
- AttributesToSkip = FileAttributes.ReparsePoint
- };
- foreach (string entry in Directory.EnumerateFileSystemEntries(path, "*", options)
- .OrderByDescending(static entry => entry.Count(character => character == Path.DirectorySeparatorChar)))
- yield return entry;
- yield return path;
- }
private static string ApplyPatch(string path, string patch)
{
string current = File.Exists(path) ? File.ReadAllText(path) : string.Empty;
@@ -251,7 +228,6 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
public TaskResourceJournal Journal { get; } = journal;
public TaskAclManager Acl { get; } = acl;
public List<AclSnapshot> Acls { get; } = [];
- public HashSet<string> Roots { get; } = new(StringComparer.OrdinalIgnoreCase);
public TaskNetworkPolicy? Network { get; set; }
}
}
diff --git a/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs
index 7dd3629..dd170ab 100644
--- a/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs
+++ b/tests/Agent.Windows.Tests/CrashScenarioBootstrap.cs
@@ -26,6 +26,12 @@ internal static class CrashScenarioBootstrap
var acl = new TaskAclManager(AppContainerProfile.EnsureFixed().Sid, new MemorySecurityAuditSink(), journal);
_ = acl.Grant(taskId, Path.Combine(root, "resource"), FileCapability.Read | FileCapability.Write);
break;
+ case "acl-inheritance":
+ var inheritedAcl = new TaskAclManager(
+ new System.Security.Principal.SecurityIdentifier("S-1-15-2-330000001-330000002-330000003-330000004-330000005-330000006-330000007"),
+ new MemorySecurityAuditSink(), journal);
+ _ = inheritedAcl.Grant(taskId, Path.Combine(root, "resource"), FileCapability.Read | FileCapability.Write);
+ break;
case "acl-before":
byte[] descriptor = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(Path.Combine(root, "resource")), AccessControlSections.Access)
.GetSecurityDescriptorBinaryForm();
diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
index d54ab13..8d920b2 100644
--- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
+++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
@@ -192,6 +192,75 @@ public sealed class SecurityPolicyTests
}
[Fact]
+ public void Root_acl_inheritance_covers_existing_and_new_descendants_and_restores_exactly()
+ {
+ using TestDirectory directory = new();
+ using TestDirectory state = new();
+ string existingDirectory = Path.Combine(directory.Path, "existing");
+ string existingFile = Path.Combine(existingDirectory, "before.txt");
+ Directory.CreateDirectory(existingDirectory);
+ File.WriteAllText(existingFile, "before");
+ byte[] rootBefore = AccessDescriptor(directory.Path);
+ byte[] existingBefore = AccessDescriptor(existingFile);
+ var sid = new System.Security.Principal.SecurityIdentifier(
+ "S-1-15-2-310000001-310000002-310000003-310000004-310000005-310000006-310000007");
+ var audit = new MemorySecurityAuditSink();
+ Guid taskId = Guid.NewGuid();
+ TaskResourceJournal journal = TaskResourceJournal.Create(Path.Combine(state.Path, "journals"), taskId);
+ TaskAclManager manager = new(sid, audit, journal);
+
+ AclSnapshot snapshot = manager.Grant(taskId, directory.Path, FileCapability.Read | FileCapability.Write);
+ JournalEntry aclEntry = Assert.Single(journal.Entries);
+ Assert.Equal("acl", aclEntry.Kind);
+ Assert.Equal(directory.Path, aclEntry.Target, ignoreCase: true);
+ Assert.Contains(AccessRules(directory.Path, sid), static rule => !rule.IsInherited);
+ Assert.Contains(AccessRules(existingFile, sid), static rule => rule.IsInherited);
+
+ string createdDirectory = Path.Combine(directory.Path, "created");
+ string createdFile = Path.Combine(createdDirectory, "during.txt");
+ Directory.CreateDirectory(createdDirectory);
+ File.WriteAllText(createdFile, "during");
+ Assert.Contains(AccessRules(createdFile, sid), static rule => rule.IsInherited);
+
+ manager.Restore(taskId, snapshot);
+ journal.CompleteAndDelete();
+
+ Assert.Equal(Convert.ToHexString(rootBefore), Convert.ToHexString(AccessDescriptor(directory.Path)));
+ Assert.Equal(Convert.ToHexString(existingBefore), Convert.ToHexString(AccessDescriptor(existingFile)));
+ Assert.Empty(AccessRules(existingFile, sid));
+ Assert.Empty(AccessRules(createdFile, sid));
+ SecurityAuditEvent applied = Assert.Single(audit.Events, item => item.Event == "acl.apply");
+ Assert.Equal("root_inheritance", applied.Data["scope"]);
+ Assert.Single(audit.Events, item => item.Event == "acl.restore");
+ Assert.Empty(Directory.EnumerateFiles(Path.Combine(state.Path, "journals")));
+ }
+
+ [Fact]
+ public void Root_acl_inheritance_does_not_override_a_protected_descendant()
+ {
+ using TestDirectory directory = new();
+ string protectedDirectory = Path.Combine(directory.Path, "protected");
+ Directory.CreateDirectory(protectedDirectory);
+ DirectorySecurity protectedAcl = FileSystemAclExtensions.GetAccessControl(
+ new DirectoryInfo(protectedDirectory), AccessControlSections.Access);
+ protectedAcl.SetAccessRuleProtection(isProtected: true, preserveInheritance: true);
+ FileSystemAclExtensions.SetAccessControl(new DirectoryInfo(protectedDirectory), protectedAcl);
+ var sid = new System.Security.Principal.SecurityIdentifier(
+ "S-1-15-2-320000001-320000002-320000003-320000004-320000005-320000006-320000007");
+ TaskAclManager manager = new(sid, new MemorySecurityAuditSink());
+
+ AclSnapshot snapshot = manager.Grant(Guid.NewGuid(), directory.Path, FileCapability.Read);
+ try
+ {
+ Assert.Empty(AccessRules(protectedDirectory, sid));
+ }
+ finally
+ {
+ manager.Restore(Guid.NewGuid(), snapshot);
+ }
+ }
+
+ [Fact]
public void Brokered_process_runs_in_fixed_app_container_with_task_acl()
{
AppContainerProfile profile = AppContainerProfile.EnsureFixed();
@@ -273,14 +342,18 @@ public sealed class SecurityPolicyTests
string journals = Path.Combine(directory.Path, "journals");
string resource = Path.Combine(directory.Path, "resource");
Directory.CreateDirectory(resource);
+ string child = Path.Combine(resource, "existing.txt");
+ File.WriteAllText(child, "existing");
byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resource), AccessControlSections.Access)
.GetSecurityDescriptorBinaryForm();
+ byte[] childAclBefore = AccessDescriptor(child);
var audit = new MemorySecurityAuditSink();
Guid taskId = Guid.NewGuid();
TaskResourceJournal journal = TaskResourceJournal.Create(journals, taskId);
var acl = new TaskAclManager(profile.Sid, audit, journal);
_ = acl.Grant(taskId, resource, FileCapability.Read | FileCapability.Write);
+ Assert.Contains(AccessRules(child, profile.Sid), static rule => rule.IsInherited);
Assert.False(LoopbackExemption.Contains(profile.Sid));
Guid loopbackEntry = journal.Prepare("network.loopback_exemption", profile.Sid.Value,
@@ -307,6 +380,8 @@ public sealed class SecurityPolicyTests
byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resource), AccessControlSections.Access)
.GetSecurityDescriptorBinaryForm();
Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter));
+ Assert.Equal(Convert.ToHexString(childAclBefore), Convert.ToHexString(AccessDescriptor(child)));
+ Assert.Empty(AccessRules(child, profile.Sid));
Assert.Empty(Directory.EnumerateFiles(journals));
}
@@ -324,9 +399,12 @@ public sealed class SecurityPolicyTests
string resourceDirectory = Path.Combine(directory.Path, "resource");
string resourceFile = Path.Combine(directory.Path, "resource.txt");
Directory.CreateDirectory(resourceDirectory);
+ string childFile = Path.Combine(resourceDirectory, "child.txt");
+ File.WriteAllText(childFile, "child-before-crash");
File.WriteAllText(resourceFile, "before-crash");
byte[] aclBefore = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resourceDirectory), AccessControlSections.Access)
.GetSecurityDescriptorBinaryForm();
+ byte[] childAclBefore = AccessDescriptor(childFile);
Guid taskId = Guid.NewGuid();
using var child = new Process
{
@@ -358,6 +436,8 @@ public sealed class SecurityPolicyTests
byte[] aclAfter = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(resourceDirectory), AccessControlSections.Access)
.GetSecurityDescriptorBinaryForm();
Assert.Equal(Convert.ToHexString(aclBefore), Convert.ToHexString(aclAfter));
+ Assert.Equal(Convert.ToHexString(childAclBefore), Convert.ToHexString(AccessDescriptor(childFile)));
+ Assert.Empty(AccessRules(childFile, AppContainerProfile.EnsureFixed().Sid));
Assert.False(LoopbackExemption.Contains(AppContainerProfile.EnsureFixed().Sid));
string backups = Path.Combine(directory.Path, "backups");
Assert.True(!Directory.Exists(backups) || !Directory.EnumerateFileSystemEntries(backups).Any());
@@ -371,6 +451,51 @@ public sealed class SecurityPolicyTests
}
}
+ [Fact]
+ public void Independent_process_crash_recovers_root_inheritance_from_existing_child()
+ {
+ using TestDirectory directory = new();
+ string resource = Path.Combine(directory.Path, "resource");
+ string childFile = Path.Combine(resource, "child.txt");
+ Directory.CreateDirectory(resource);
+ File.WriteAllText(childFile, "before-crash");
+ byte[] rootBefore = AccessDescriptor(resource);
+ byte[] childBefore = AccessDescriptor(childFile);
+ var sid = new System.Security.Principal.SecurityIdentifier(
+ "S-1-15-2-330000001-330000002-330000003-330000004-330000005-330000006-330000007");
+ Guid taskId = Guid.NewGuid();
+ using var child = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = Environment.ProcessPath!,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ Environment =
+ {
+ ["AGENT_MCP_CRASH_SCENARIO"] = "acl-inheritance",
+ ["AGENT_MCP_CRASH_ROOT"] = directory.Path,
+ ["AGENT_MCP_CRASH_TASK"] = taskId.ToString("D")
+ }
+ }
+ };
+
+ Assert.True(child.Start());
+ Assert.True(child.WaitForExit(30_000));
+ Assert.NotEqual(0, child.ExitCode);
+ Assert.False(File.Exists(Path.Combine(directory.Path, "crash-error.txt")),
+ File.Exists(Path.Combine(directory.Path, "crash-error.txt")) ? File.ReadAllText(Path.Combine(directory.Path, "crash-error.txt")) : string.Empty);
+ Assert.Contains(AccessRules(childFile, sid), static rule => rule.IsInherited);
+
+ RecoveryResult recovered = new RecoveryManager(Path.Combine(directory.Path, "journals"), new MemorySecurityAuditSink()).RecoverAll();
+
+ Assert.True(recovered.IsClean, string.Join(Environment.NewLine, recovered.Errors));
+ Assert.Equal(Convert.ToHexString(rootBefore), Convert.ToHexString(AccessDescriptor(resource)));
+ Assert.Equal(Convert.ToHexString(childBefore), Convert.ToHexString(AccessDescriptor(childFile)));
+ Assert.Empty(AccessRules(childFile, sid));
+ Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals")));
+ }
+
private static bool ProcessExists(int pid)
{
try { using Process process = Process.GetProcessById(pid); return !process.HasExited; }
@@ -440,7 +565,8 @@ public sealed class SecurityPolicyTests
string backups = Path.Combine(directory.Path, "backups");
Assert.True(!Directory.Exists(backups) || !Directory.EnumerateFileSystemEntries(backups).Any());
Assert.Contains(audit.Events, item => item.Event == "wfp.remove" && item.Data["residual_filters"] == "0");
- Assert.Contains(audit.Events, item => item.Event == "acl.restore");
+ Assert.Single(audit.Events, item => item.Event == "acl.apply");
+ Assert.Single(audit.Events, item => item.Event == "acl.restore");
}
[Fact(Timeout = 60_000)]
@@ -514,6 +640,25 @@ public sealed class SecurityPolicyTests
private static RestrictedProcessResult Curl(RestrictedProcessLauncher launcher, Guid taskId, string curl, string url, string cwd) =>
launcher.Run(taskId, curl, ["--noproxy", "*", "--silent", "--show-error", "--max-time", "2", url], cwd, TimeSpan.FromSeconds(10));
+ private static byte[] AccessDescriptor(string path)
+ {
+ FileSystemSecurity security = Directory.Exists(path)
+ ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access)
+ : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access);
+ return security.GetSecurityDescriptorBinaryForm();
+ }
+
+ private static FileSystemAccessRule[] AccessRules(string path, System.Security.Principal.SecurityIdentifier sid)
+ {
+ FileSystemSecurity security = Directory.Exists(path)
+ ? FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(path), AccessControlSections.Access)
+ : FileSystemAclExtensions.GetAccessControl(new FileInfo(path), AccessControlSections.Access);
+ return security.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(System.Security.Principal.SecurityIdentifier))
+ .OfType<FileSystemAccessRule>()
+ .Where(rule => rule.IdentityReference == sid && rule.AccessControlType == AccessControlType.Allow)
+ .ToArray();
+ }
+
private sealed class TestDirectory : IDisposable
{
public TestDirectory()