summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Security/PathPolicy.cs
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-12 05:44:54 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-12 05:44:54 +0900
commitb56614c814b904c224315a11f9686f0d47abd3d3 (patch)
treee6ae4feb852ea4492bf947eabba1e11ab157686a /src/Agent.Windows/Security/PathPolicy.cs
parent98b6a2aeedd591058c1d81e401d24f3ded162926 (diff)
feat: revise sandbox usability around semantic policy and LPAC process trees
Rewrite the specification and design vocabulary around User Sandbox Configuration, Environment Resolution, Executable Approval, Effective Sandbox Policy, and Windows Enforcement. Move the Internal Tool catalog to the design without treating its count as a requirement, and update README, implementation planning, traceability, audit history, prompts, and the Python skill. Introduce schema_version 1 profiles, workspaces, mounts, executable aliases, network modes, and resource presets. Add local override merging, resolution and approval stores with atomic user-only persistence, fail-closed fingerprints, localized diagnostics, and executable/sandbox management CLI commands. Change Windows execution to LPAC entry approval with inherited descendant confinement. Separate read and execute ACLs, preserve protected OS DACLs, apply Job process/memory/timeout limits to the tree, enforce WFP network modes, prevent credential environment inheritance, disable out-of-tree MSBuild reuse, and harden transactional ACL/recovery cleanup. Isolate planning prompts from execution instructions, reject split coding plans that cannot satisfy task-scoped evidence, preserve configured thinking, and prevent optional task_update failures from corrupting completion checks. Add and update configuration, resolution, approval, LPAC, descendant, readonly/development, Registry/COM, WFP, recovery, .NET/Python/PowerShell process-tree, and Operational E2E coverage. Publish reproducible bundles while preserving deployment-local configuration, prompts, skills, logs, state, approvals, and resolutions.
Diffstat (limited to 'src/Agent.Windows/Security/PathPolicy.cs')
-rw-r--r--src/Agent.Windows/Security/PathPolicy.cs37
1 files changed, 25 insertions, 12 deletions
diff --git a/src/Agent.Windows/Security/PathPolicy.cs b/src/Agent.Windows/Security/PathPolicy.cs
index 3de4804..67c7340 100644
--- a/src/Agent.Windows/Security/PathPolicy.cs
+++ b/src/Agent.Windows/Security/PathPolicy.cs
@@ -3,23 +3,23 @@ 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 record FilePolicyEntry(string Path, FileCapability Capabilities, string Origin = "unspecified");
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();
+ private readonly IReadOnlyList<FilePolicyEntry> _entries = entries.Select(entry => entry with { Path = ResolvedPath.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);
+ string canonical = ResolvedPath.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}. Add the canonical path to sandbox.filesystem.{required.ToString().ToLowerInvariant()} and restart the server.",
- "filesystem", Path.GetFullPath(path), required.ToString().ToLowerInvariant(), $"sandbox.filesystem.{required.ToString().ToLowerInvariant()}");
+ throw new SandboxPolicyException("policy.deny", $"Resolved Path '{path}' is outside the Effective Sandbox Policy for {required}.",
+ "filesystem", Path.GetFullPath(path), required.ToString().ToLowerInvariant(), "Confirm a Workspace or Mount that grants the required Filesystem Capability, then restart the server.");
}
private static bool IsAtOrBelow(string candidate, string root) =>
@@ -27,7 +27,7 @@ public sealed class PathPolicy(IEnumerable<FilePolicyEntry> entries)
candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
}
-public static class CanonicalPath
+public static class ResolvedPath
{
public static string Resolve(string path, bool requireExisting = true)
{
@@ -35,21 +35,34 @@ public static class CanonicalPath
string fullPath = Path.GetFullPath(path);
string? root = Path.GetPathRoot(fullPath);
if (string.IsNullOrEmpty(root)) throw new SandboxPolicyException("path.invalid", "Path has no filesystem root.");
+ if (root.StartsWith("\\\\", StringComparison.Ordinal))
+ throw new SandboxPolicyException("path.non_local", "Only local filesystem paths are supported.", "filesystem", fullPath, null, "Select a local Workspace, Mount, Runtime Root, or Executable.");
string current = root;
+ var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
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)
+ while ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
{
+ string reparsePath = Path.GetFullPath(current);
+ if (!visited.Add(reparsePath))
+ throw new SandboxPolicyException("path.reparse_cycle", $"Reparse resolution cycle detected at '{reparsePath}'.", "filesystem", reparsePath, null, "Remove the reparse cycle.");
FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current);
- string? target = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? info.LinkTarget;
- throw new SandboxPolicyException("path.reparse", $"Reparse points are not allowed: component='{current}'. Approve the resolved canonical target{(string.IsNullOrWhiteSpace(target) ? string.Empty : $" '{Path.GetFullPath(target)}'")} instead.",
- "filesystem", current, null, string.IsNullOrWhiteSpace(target) ? "sandbox.filesystem" : Path.GetFullPath(target));
+ string? target;
+ try { target = info.ResolveLinkTarget(returnFinalTarget: false)?.FullName; }
+ catch (IOException ex)
+ {
+ throw new SandboxPolicyException("path.reparse_unresolved", $"Could not resolve reparse component '{current}': {ex.Message}",
+ "filesystem", current, null, "Repair or remove the reparse point.");
+ }
+ if (string.IsNullOrWhiteSpace(target))
+ throw new SandboxPolicyException("path.reparse_unresolved", $"Could not resolve reparse component '{current}'.", "filesystem", current, null, "Repair or remove the reparse point.");
+ current = Path.GetFullPath(target);
}
}
- if (requireExisting && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Approved path does not exist.", fullPath);
- return Path.TrimEndingDirectorySeparator(fullPath);
+ if (requireExisting && !File.Exists(current) && !Directory.Exists(current)) throw new FileNotFoundException("Resolved Path does not exist.", current);
+ return Path.TrimEndingDirectorySeparator(current);
}
}