summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Agent.App/Agent.App.csproj1
-rw-r--r--src/Agent.App/Cli/CommandLine.cs102
-rw-r--r--src/Agent.App/Configuration/AgentConfiguration.cs31
-rw-r--r--src/Agent.App/Configuration/ConfigurationException.cs11
-rw-r--r--src/Agent.App/Configuration/ConfigurationLoader.cs86
-rw-r--r--src/Agent.App/Program.cs225
-rw-r--r--src/Agent.App/Sandbox/SandboxServices.cs361
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs12
-rw-r--r--src/Agent.Core/Runtime/PlannerService.cs38
-rw-r--r--src/Agent.Windows/NativeMethods.txt9
-rw-r--r--src/Agent.Windows/Network/TaskNetworkPolicy.cs28
-rw-r--r--src/Agent.Windows/Recovery/TransactionalFileEditor.cs12
-rw-r--r--src/Agent.Windows/Security/ExecutablePolicy.cs87
-rw-r--r--src/Agent.Windows/Security/PathPolicy.cs37
-rw-r--r--src/Agent.Windows/Security/RestrictedProcessLauncher.cs208
-rw-r--r--src/Agent.Windows/Security/TaskAclManager.cs74
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs60
17 files changed, 1151 insertions, 231 deletions
diff --git a/src/Agent.App/Agent.App.csproj b/src/Agent.App/Agent.App.csproj
index 17b7bc9..e3447c5 100644
--- a/src/Agent.App/Agent.App.csproj
+++ b/src/Agent.App/Agent.App.csproj
@@ -12,6 +12,7 @@
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Agent.Core.Tests" />
+ <InternalsVisibleTo Include="Agent.Windows.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
diff --git a/src/Agent.App/Cli/CommandLine.cs b/src/Agent.App/Cli/CommandLine.cs
index 3fdac9f..3b3fde1 100644
--- a/src/Agent.App/Cli/CommandLine.cs
+++ b/src/Agent.App/Cli/CommandLine.cs
@@ -1,38 +1,104 @@
namespace Agent.App.Cli;
-public enum AppCommand { Serve, Check }
+public enum AppCommand
+{
+ Serve,
+ Check,
+ ExecutableResolve,
+ ExecutableApprove,
+ ExecutableList,
+ ExecutableRevoke,
+ SandboxResolve,
+ SandboxConfirm,
+ SandboxShow
+}
-public sealed record CommandLine(AppCommand Command, string? ConfigPath, string? Language, string? Transport)
+public sealed record CommandLine(
+ AppCommand Command,
+ string? ConfigPath,
+ string? Language,
+ string? Transport,
+ string? Alias = null,
+ string? ExecutablePath = null,
+ string? RuntimeRoot = null,
+ bool NoRuntime = false,
+ bool Yes = false,
+ string Format = "text")
{
public static CommandLine Parse(string[] args)
{
bool implicitServe = args.Length == 0 || args[0].StartsWith("--", StringComparison.Ordinal);
- AppCommand command = implicitServe ? AppCommand.Serve : args[0] switch
+ int index = implicitServe ? 0 : 1;
+ AppCommand command;
+ string? alias = null;
+ if (implicitServe) command = AppCommand.Serve;
+ else if (args[0] == "serve") command = AppCommand.Serve;
+ else if (args[0] == "check") command = AppCommand.Check;
+ else if (args[0] is "executable" or "sandbox")
{
- "serve" => AppCommand.Serve,
- "check" => AppCommand.Check,
- _ => throw new CommandLineException("Expected 'serve' or 'check'.")
- };
+ if (index >= args.Length) throw new CommandLineException($"Expected a subcommand after '{args[0]}'.");
+ string subcommand = args[index++];
+ command = (args[0], subcommand) switch
+ {
+ ("executable", "resolve") => AppCommand.ExecutableResolve,
+ ("executable", "approve") => AppCommand.ExecutableApprove,
+ ("executable", "list") => AppCommand.ExecutableList,
+ ("executable", "revoke") => AppCommand.ExecutableRevoke,
+ ("sandbox", "resolve") => AppCommand.SandboxResolve,
+ ("sandbox", "confirm") => AppCommand.SandboxConfirm,
+ ("sandbox", "show") => AppCommand.SandboxShow,
+ _ => throw new CommandLineException($"Unknown subcommand '{args[0]} {subcommand}'.")
+ };
+ if (command is AppCommand.ExecutableResolve or AppCommand.ExecutableApprove or AppCommand.ExecutableRevoke)
+ {
+ if (index >= args.Length || args[index].StartsWith("--", StringComparison.Ordinal))
+ throw new CommandLineException($"Executable Alias is required for '{args[0]} {subcommand}'.");
+ alias = args[index++];
+ }
+ }
+ else throw new CommandLineException("Expected 'serve', 'check', 'executable', or 'sandbox'.");
string? config = null;
string? language = null;
string? transport = null;
- for (int i = implicitServe ? 0 : 1; i < args.Length; i++)
+ string? executablePath = null;
+ string? runtimeRoot = null;
+ bool noRuntime = false;
+ bool yes = false;
+ string format = "text";
+ while (index < args.Length)
{
- string option = args[i];
- if (i + 1 >= args.Length) throw new CommandLineException($"Missing value for '{option}'.");
- string value = args[++i];
+ string option = args[index++];
switch (option)
{
- case "--config" when config is null: config = value; break;
- case "--lang" when language is null: language = value; break;
- case "--transport" when command == AppCommand.Serve && transport is null && value is "stdio" or "http": transport = value; break;
- case "--transport" when command == AppCommand.Check: throw new CommandLineException("--transport is only valid for serve.");
- case "--transport": throw new CommandLineException("Transport must be stdio or http.");
- default: throw new CommandLineException($"Unknown or duplicate option '{option}'.");
+ case "--yes" when !yes: yes = true; break;
+ case "--no-runtime" when !noRuntime && runtimeRoot is null: noRuntime = true; break;
+ case "--config" when config is null: config = Value(option); break;
+ case "--lang" when language is null: language = Value(option); break;
+ case "--transport" when command == AppCommand.Serve && transport is null:
+ transport = Value(option);
+ if (transport is not ("stdio" or "http")) throw new CommandLineException("Transport must be stdio or http.");
+ break;
+ case "--path" when executablePath is null: executablePath = Value(option); break;
+ case "--runtime-root" when runtimeRoot is null && !noRuntime: runtimeRoot = Value(option); break;
+ case "--format" when command == AppCommand.SandboxShow:
+ format = Value(option);
+ if (format is not ("text" or "json")) throw new CommandLineException("Format must be text or json.");
+ break;
+ default: throw new CommandLineException($"Unknown, duplicate, or invalid option '{option}' for this command.");
}
}
- return new CommandLine(command, config, language, transport);
+ bool management = command is not (AppCommand.Serve or AppCommand.Check);
+ if (management && config is null) throw new CommandLineException("--config <file> is required for executable and sandbox commands.");
+ if (yes && command is not (AppCommand.ExecutableApprove or AppCommand.ExecutableRevoke or AppCommand.SandboxConfirm))
+ throw new CommandLineException("--yes is valid only for approve, revoke, and confirm.");
+ return new(command, config, language, transport, alias, executablePath, runtimeRoot, noRuntime, yes, format);
+
+ string Value(string optionName)
+ {
+ if (index >= args.Length) throw new CommandLineException($"Missing value for '{optionName}'.");
+ return args[index++];
+ }
}
}
diff --git a/src/Agent.App/Configuration/AgentConfiguration.cs b/src/Agent.App/Configuration/AgentConfiguration.cs
index c5f720b..b2f94a7 100644
--- a/src/Agent.App/Configuration/AgentConfiguration.cs
+++ b/src/Agent.App/Configuration/AgentConfiguration.cs
@@ -64,13 +64,32 @@ public sealed class OllamaGenerationOptions
}
public sealed class PlannerOptions { public int MaxTasks { get; set; } = 32; }
-public sealed class SandboxOptions { public FilesystemOptions Filesystem { get; set; } = new(); public ProcessOptions Process { get; set; } = new(); public NetworkOptions Network { get; set; } = new(); public ResourceOptions Resources { get; set; } = new(); }
-public sealed class FilesystemOptions { public List<string> Read { get; set; } = []; public List<string> Write { get; set; } = []; public List<string> Execute { get; set; } = []; public List<string> Delete { get; set; } = []; }
-public sealed class ProcessOptions { public bool AllowShell { get; set; } = true; public List<ExecutableApproval> Approvals { get; set; } = []; public int MaxProcesses { get; set; } = 16; }
-public sealed class ExecutableApproval { public string Path { get; set; } = string.Empty; public string Sha256 { get; set; } = string.Empty; }
-public sealed class NetworkOptions { public string Mode { get; set; } = "deny"; public List<NetworkAllowance> Allow { get; set; } = []; }
+public sealed class SandboxOptions
+{
+ public string Profile { get; set; } = "readonly";
+ public List<string> Workspaces { get; set; } = [];
+ public List<MountOptions> Mounts { get; set; } = [];
+ public ExecutionOptions Execution { get; set; } = new();
+ public NetworkOptions Network { get; set; } = new();
+ public ResourceOptions Resources { get; set; } = new();
+}
+public sealed class MountOptions { public string Path { get; set; } = string.Empty; public string Access { get; set; } = "read_only"; }
+public sealed class ExecutionOptions { public List<string> Executables { get; set; } = []; public List<string> Shells { get; set; } = []; }
+public sealed class NetworkOptions
+{
+ public string Access { get; set; } = "none";
+ public List<LocalEndpointOptions> LocalEndpoints { get; set; } = [];
+ public List<NetworkAllowance> RestrictedEndpoints { get; set; } = [];
+}
+public sealed class LocalEndpointOptions { public int Port { get; set; } }
public sealed class NetworkAllowance { public string Host { get; set; } = string.Empty; public List<int> Ports { get; set; } = []; }
-public sealed class ResourceOptions { public int MemoryMb { get; set; } = 4096; public int ProcessTimeoutSeconds { get; set; } = 1800; }
+public sealed class ResourceOptions
+{
+ public string Profile { get; set; } = "normal";
+ public int? MemoryMb { get; set; }
+ public int? ProcessLimit { get; set; }
+ public int? ExecutionTimeoutSeconds { get; set; }
+}
public sealed class FileEditOptions { public int MemoryBackupLimitMb { get; set; } = 16; }
public sealed class LimitOptions { public long WholeReadBytes { get; set; } = 1024 * 1024; public int SearchMaxResults { get; set; } = 1000; public int TailMaxLines { get; set; } = 10000; public double ContextSummaryRatio { get; set; } = 0.8; }
public sealed class LocalizationOptions { public string Directory { get; set; } = "config/locales"; public string Fallback { get; set; } = "en"; }
diff --git a/src/Agent.App/Configuration/ConfigurationException.cs b/src/Agent.App/Configuration/ConfigurationException.cs
index 67f38a5..dd14ea9 100644
--- a/src/Agent.App/Configuration/ConfigurationException.cs
+++ b/src/Agent.App/Configuration/ConfigurationException.cs
@@ -1,4 +1,11 @@
namespace Agent.App.Configuration;
-public sealed class ConfigurationException(string message, Exception? innerException = null)
- : Exception(message, innerException);
+public sealed class ConfigurationException(string message, Exception? innerException = null,
+ string code = "configuration.invalid", string category = "configuration", string? target = null,
+ string? resolution = null) : Exception(message, innerException)
+{
+ public string Code { get; } = code;
+ public string Category { get; } = category;
+ public string? Target { get; } = target;
+ public string? Resolution { get; } = resolution;
+}
diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs
index 26c56e1..6e15904 100644
--- a/src/Agent.App/Configuration/ConfigurationLoader.cs
+++ b/src/Agent.App/Configuration/ConfigurationLoader.cs
@@ -22,6 +22,7 @@ public static partial class ConfigurationLoader
string localPath = Path.Combine(Path.GetDirectoryName(absolutePath)!, "agent.local.yaml");
if (!string.Equals(absolutePath, localPath, StringComparison.OrdinalIgnoreCase) && File.Exists(localPath))
yaml = MergeYaml(yaml, File.ReadAllText(localPath));
+ RejectLegacySandbox(yaml);
var deserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.Build();
@@ -61,26 +62,7 @@ public static partial class ConfigurationLoader
if (value.Ollama.Options.NumCtx is <= 0) Throw("ollama.options.num_ctx must be positive.");
if (value.Ollama.Options.Temperature is < 0 or > 2) Throw("ollama.options.temperature must be in 0..2.");
if (value.Planner.MaxTasks is < 1 or > 32) Throw("planner.max_tasks must be in 1..32.");
- if (value.Sandbox.Process.MaxProcesses <= 0 || value.Sandbox.Resources.MemoryMb <= 0 || value.Sandbox.Resources.ProcessTimeoutSeconds <= 0) Throw("sandbox resource limits must be positive.");
- if (value.Sandbox.Network.Mode is not ("deny" or "allow")) Throw("sandbox.network.mode must be deny or allow.");
- foreach (NetworkAllowance allowance in value.Sandbox.Network.Allow)
- {
- if (string.IsNullOrWhiteSpace(allowance.Host) || allowance.Ports.Count == 0 || allowance.Ports.Any(static p => p is < 1 or > 65535)) Throw("sandbox.network.allow contains an invalid host or port.");
- }
- foreach (ExecutableApproval approval in value.Sandbox.Process.Approvals)
- {
- if (!Path.IsPathFullyQualified(approval.Path) || !Sha256Regex().IsMatch(approval.Sha256)) Throw("process approval requires an absolute path and 64 hexadecimal SHA-256 characters.");
- string approvalPath = Path.GetFullPath(approval.Path);
- bool executable = value.Sandbox.Filesystem.Execute.Any(entry => ContainsPath(baseDirectory, entry, approvalPath));
- if (!executable) Throw($"sandbox.process.approvals path is not covered by sandbox.filesystem.execute: {approvalPath}. Add the path or an ancestor directory to sandbox.filesystem.execute.");
- }
- foreach (string executeEntry in value.Sandbox.Filesystem.Execute)
- {
- string executePath = Path.IsPathFullyQualified(executeEntry) ? Path.GetFullPath(executeEntry) : Resolve(baseDirectory, executeEntry);
- if (File.Exists(executePath) && !value.Sandbox.Process.Approvals.Any(approval =>
- string.Equals(Path.GetFullPath(approval.Path), executePath, StringComparison.OrdinalIgnoreCase)))
- Throw($"sandbox.filesystem.execute file has no sandbox.process.approvals entry: {executePath}. Add canonical path and SHA-256 under sandbox.process.approvals.");
- }
+ ValidateSandbox(value.Sandbox);
if (value.FileEdit.MemoryBackupLimitMb <= 0 || value.Limits.WholeReadBytes <= 0 || value.Limits.SearchMaxResults <= 0 || value.Limits.TailMaxLines <= 0 || value.Limits.ContextSummaryRatio is <= 0 or >= 1) Throw("file/context limits are invalid.");
if (value.Logging.MaxFileBytes <= 0 || value.Logging.RetainedFiles < 1) Throw("logging limits are invalid.");
if (value.Logging.Level is not ("information" or "warning" or "error" or "fatal")) Throw("logging.level must be information, warning, error, or fatal.");
@@ -102,12 +84,62 @@ public static partial class ConfigurationLoader
public static string Resolve(string baseDirectory, string path) => Path.GetFullPath(path, baseDirectory);
- private static bool ContainsPath(string baseDirectory, string configuredPath, string target)
+ private static void ValidateSandbox(SandboxOptions sandbox)
+ {
+ if (sandbox.Profile is not ("readonly" or "development")) Throw("sandbox.profile must be readonly or development.");
+ if (sandbox.Workspaces.Any(string.IsNullOrWhiteSpace)) Throw("sandbox.workspaces contains an empty Logical Path.");
+ foreach (MountOptions mount in sandbox.Mounts)
+ {
+ if (string.IsNullOrWhiteSpace(mount.Path) || mount.Access is not ("read_only" or "read_write" or "read_write_delete"))
+ Throw("sandbox.mounts requires a Logical Path and access read_only, read_write, or read_write_delete.");
+ if (sandbox.Profile == "readonly" && mount.Access != "read_only")
+ Throw("readonly sandbox.profile permits only read_only mounts.");
+ }
+ string[] aliases = sandbox.Execution.Executables.Concat(sandbox.Execution.Shells).ToArray();
+ if (aliases.Any(alias => !AliasRegex().IsMatch(alias))) Throw("Executable Alias must match ^[a-z0-9][a-z0-9._-]{0,63}$.");
+ if (aliases.Distinct(StringComparer.OrdinalIgnoreCase).Count() != aliases.Length)
+ Throw("Executable Alias values must be unique across sandbox.execution.executables and shells.");
+
+ if (sandbox.Network.Access is not ("none" or "local" or "restricted"))
+ Throw("sandbox.network.access must be none, local, or restricted.");
+ if (sandbox.Network.LocalEndpoints.Any(static endpoint => endpoint.Port is < 1 or > 65535))
+ Throw("sandbox.network.local_endpoints contains an invalid port.");
+ foreach (NetworkAllowance endpoint in sandbox.Network.RestrictedEndpoints)
+ if (string.IsNullOrWhiteSpace(endpoint.Host) || endpoint.Ports.Count == 0 || endpoint.Ports.Any(static port => port is < 1 or > 65535))
+ Throw("sandbox.network.restricted_endpoints contains an invalid host or port.");
+ if (sandbox.Network.Access == "none" && (sandbox.Network.LocalEndpoints.Count != 0 || sandbox.Network.RestrictedEndpoints.Count != 0))
+ Throw("sandbox.network.access none cannot contain endpoints.");
+ if (sandbox.Network.Access == "local" && (sandbox.Network.LocalEndpoints.Count == 0 || sandbox.Network.RestrictedEndpoints.Count != 0))
+ Throw("sandbox.network.access local requires local_endpoints and forbids restricted_endpoints.");
+ if (sandbox.Network.Access == "restricted" && (sandbox.Network.RestrictedEndpoints.Count == 0 || sandbox.Network.LocalEndpoints.Count != 0))
+ Throw("sandbox.network.access restricted requires restricted_endpoints and forbids local_endpoints.");
+
+ if (sandbox.Resources.Profile is not ("constrained" or "normal" or "extended"))
+ Throw("sandbox.resources.profile must be constrained, normal, or extended.");
+ if (sandbox.Resources.MemoryMb is <= 0 || sandbox.Resources.ProcessLimit is <= 0 || sandbox.Resources.ExecutionTimeoutSeconds is <= 0)
+ Throw("sandbox resource overrides must be positive.");
+ }
+
+ private static void RejectLegacySandbox(string yaml)
{
- string allowed = Path.IsPathFullyQualified(configuredPath) ? Path.GetFullPath(configuredPath) : Resolve(baseDirectory, configuredPath);
- if (string.Equals(allowed, target, StringComparison.OrdinalIgnoreCase)) return true;
- string prefix = allowed.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
- return target.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
+ YamlNode root = LoadRoot(yaml, "merged configuration");
+ if (root is not YamlMappingNode rootMap || !TryGet(rootMap, "sandbox", out YamlNode? sandboxNode) || sandboxNode is not YamlMappingNode sandbox) return;
+ if (TryGet(sandbox, "filesystem", out _) || TryGet(sandbox, "process", out _))
+ Throw("Legacy sandbox.filesystem/sandbox.process configuration is no longer supported. Migrate to sandbox.profile, workspaces, mounts, execution, network, and resources; then run 'sandbox resolve' and 'sandbox confirm'.");
+ }
+
+ private static bool TryGet(YamlMappingNode map, string name, out YamlNode? value)
+ {
+ foreach (KeyValuePair<YamlNode, YamlNode> pair in map.Children)
+ {
+ if (pair.Key is YamlScalarNode key && key.Value == name)
+ {
+ value = pair.Value;
+ return true;
+ }
+ }
+ value = null;
+ return false;
}
private static string MergeYaml(string baseYaml, string localYaml)
@@ -154,6 +186,6 @@ public static partial class ConfigurationLoader
}
private static void Throw(string message) => throw new ConfigurationException(message);
- [GeneratedRegex("^[0-9a-fA-F]{64}$", RegexOptions.CultureInvariant)]
- private static partial Regex Sha256Regex();
+ [GeneratedRegex("^[a-z0-9][a-z0-9._-]{0,63}$", RegexOptions.CultureInvariant)]
+ private static partial Regex AliasRegex();
}
diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs
index 3d2d2db..b58c914 100644
--- a/src/Agent.App/Program.cs
+++ b/src/Agent.App/Program.cs
@@ -1,6 +1,7 @@
using Agent.App.Cli;
using Agent.App.Configuration;
using Agent.App.Logging;
+using Agent.App.Sandbox;
using Agent.Core.Localization;
using Agent.Core.Prompts;
using Agent.Core.Sessions;
@@ -11,6 +12,7 @@ using Agent.Core.Tools;
using Agent.Windows.Recovery;
using Agent.Windows.Security;
using Agent.Windows.Tools;
+using System.Text.Json;
namespace Agent.App;
@@ -35,8 +37,13 @@ internal static class Program
_ = FixedPrompt.Load();
string logDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Logging.Directory);
- UserOnlyDirectory.Ensure(logDirectory);
- using var log = new RollingFileLog(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles, configuration.Logging.Level);
+ if (commandLine.Command is not (AppCommand.Serve or AppCommand.Check))
+ return RunManagementCommand(commandLine, configuration, configPath, logDirectory, locale);
+ string stateDirectory = Path.Combine(baseDirectory, "state");
+ string journalDirectory = Path.Combine(stateDirectory, "journals");
+ string backupDirectory = Path.Combine(stateDirectory, "backups");
+ EffectiveSandboxPolicy effectiveSandboxPolicy = new SandboxEnvironmentResolver(configPath, configuration)
+ .Compile(journalDirectory, backupDirectory);
if (commandLine.Command == AppCommand.Check)
{
WindowsPlatformCheckResult platform = WindowsPlatformCheck.Run();
@@ -48,15 +55,15 @@ internal static class Program
{
string detail = $"{locale.Get("check.wfp_admin_required")} error_code={platform.ErrorCode}; operation={platform.ErrorOperation}";
string failure = locale.Format("check.failure", new Dictionary<string, object?> { ["message"] = detail });
- log.Error(failure);
Console.Error.WriteLine(failure);
return 2;
}
- log.Information(locale.Get("check.success"));
Console.Out.WriteLine(locale.Get("check.success"));
return 0;
}
+ UserOnlyDirectory.Ensure(logDirectory);
+ using var log = new RollingFileLog(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles, configuration.Logging.Level);
log.Information(locale.Get("app.starting"));
WindowsPlatformCheckResult servePlatform = WindowsPlatformCheck.Run();
if (!servePlatform.Success)
@@ -75,7 +82,7 @@ internal static class Program
}
var sessionStore = new SessionStore();
- InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, baseDirectory, logDirectory, sessionStore);
+ InternalToolDispatcher toolDispatcher = CreateToolDispatcher(configuration, effectiveSandboxPolicy, logDirectory, sessionStore);
using var runtimeObserver = new JsonLinesRuntimeObserver(logDirectory, configuration.Logging.MaxFileBytes, configuration.Logging.RetainedFiles);
string taskStateDirectory = Path.Combine(baseDirectory, "state", "mcp-tasks");
UserOnlyDirectory.Ensure(taskStateDirectory);
@@ -136,7 +143,10 @@ internal static class Program
LocaleException => "locale.invalid",
_ => "app.io_error"
};
- return locale.Format(key, new Dictionary<string, object?> { ["message"] = error.Message });
+ string message = error is ConfigurationException configuration
+ ? FormatConfigurationError(locale, configuration)
+ : error.Message;
+ return locale.Format(key, new Dictionary<string, object?> { ["message"] = message });
}
catch
{
@@ -144,39 +154,33 @@ internal static class Program
}
}
- private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, string baseDirectory, string logDirectory, SessionStore? sessionStore = null)
+ private static string FormatConfigurationError(LocaleCatalog locale, ConfigurationException error)
{
- string stateDirectory = Path.Combine(baseDirectory, "state");
- string journalDirectory = Path.Combine(stateDirectory, "journals");
- string backupDirectory = Path.Combine(stateDirectory, "backups");
- Directory.CreateDirectory(journalDirectory);
- Directory.CreateDirectory(backupDirectory);
+ string? key = error.Code switch
+ {
+ "sandbox.resolution_unconfirmed" => "sandbox.error.resolution_unconfirmed",
+ "sandbox.resolution_changed" => "sandbox.error.resolution_changed",
+ "executable.approval_missing" => "sandbox.error.approval_missing",
+ "executable.resolution_changed" => "sandbox.error.executable_resolution_changed",
+ "executable.hash_changed" => "sandbox.error.executable_hash_changed",
+ "executable.shell_unsupported" => "sandbox.error.shell_unsupported",
+ _ => null
+ };
+ string localized = key is null ? error.Message : locale.Format(key, new Dictionary<string, object?> { ["target"] = error.Target ?? "-" });
+ string resolution = key is null || error.Resolution is null ? error.Resolution ?? "-" : locale.Get(key + ".resolution");
+ return $"code={error.Code}; category={error.Category}; target={error.Target ?? "-"}; message={localized}; resolution={resolution}";
+ }
+
+ private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, EffectiveSandboxPolicy effectiveSandboxPolicy, string logDirectory, SessionStore? sessionStore = null)
+ {
+ Directory.CreateDirectory(effectiveSandboxPolicy.JournalDirectory);
+ Directory.CreateDirectory(effectiveSandboxPolicy.BackupDirectory);
var audit = new JsonLinesSecurityAuditSink(Path.Combine(logDirectory, "audit.jsonl"));
- RecoveryResult recovery = new RecoveryManager(journalDirectory, audit).RecoverAll();
+ RecoveryResult recovery = new RecoveryManager(effectiveSandboxPolicy.JournalDirectory, audit).RecoverAll();
if (!recovery.IsClean) throw new InvalidOperationException("Recovery did not reach a clean state: " + string.Join(" | ", recovery.Errors));
- var fileEntries = new List<FilePolicyEntry>();
- Add(configuration.Sandbox.Filesystem.Read, FileCapability.Read);
- Add(configuration.Sandbox.Filesystem.Write, FileCapability.Write);
- Add(configuration.Sandbox.Filesystem.Execute, FileCapability.Execute);
- Add(configuration.Sandbox.Filesystem.Delete, FileCapability.Delete);
- var settings = new WindowsSandboxSettings(
- fileEntries,
- configuration.Sandbox.Process.Approvals.Select(approval => new Agent.Windows.Security.ExecutableApproval(approval.Path, approval.Sha256)).ToArray(),
- configuration.Sandbox.Process.AllowShell,
- new SandboxJobLimits((uint)configuration.Sandbox.Process.MaxProcesses, checked((ulong)configuration.Sandbox.Resources.MemoryMb * 1024 * 1024)),
- configuration.Sandbox.Network.Mode,
- configuration.Sandbox.Network.Allow.Select(item => (item.Host, (IReadOnlyList<int>)item.Ports)).ToArray(),
- journalDirectory, backupDirectory, checked((long)configuration.FileEdit.MemoryBackupLimitMb * 1024 * 1024),
- configuration.Limits.WholeReadBytes, configuration.Limits.SearchMaxResults, configuration.Limits.TailMaxLines,
- configuration.Sandbox.Resources.ProcessTimeoutSeconds);
- var backend = new WindowsToolBackend(settings, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks, sessionStore));
+ var backend = new WindowsToolBackend(effectiveSandboxPolicy, audit, new TaskGraphToolHandler(configuration.Planner.MaxTasks, sessionStore));
var allowed = InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal);
return new InternalToolDispatcher(new DenyByDefaultPolicy(allowed), backend);
-
- void Add(IEnumerable<string> paths, FileCapability capability)
- {
- foreach (string path in paths) fileEntries.Add(new FilePolicyEntry(Path.IsPathFullyQualified(path) ? path : ConfigurationLoader.Resolve(baseDirectory, path), capability));
- }
}
internal static AgentRuntimeSettings CreateRuntimeSettings(AgentConfiguration configuration, string userPrompt, string language,
@@ -184,7 +188,7 @@ internal static class Program
userPrompt,
language,
skills,
- "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.",
+ "Windows LPAC, ACL, Job Object process-tree, and WFP sandbox capabilities.",
configuration.Agent.MaxIterations,
configuration.Agent.SameFailureLimit,
locale.Get("ollama.vision_unsupported"),
@@ -192,4 +196,155 @@ internal static class Program
configuration.Limits.ContextSummaryRatio,
MaxTasks: configuration.Planner.MaxTasks,
Model: configuration.Ollama.Model);
+
+ private static int RunManagementCommand(CommandLine command, AgentConfiguration configuration, string configPath, string logDirectory, LocaleCatalog locale)
+ {
+ var resolver = new SandboxEnvironmentResolver(configPath, configuration);
+ var approvals = new ExecutableApprovalStore();
+ var audit = new JsonLinesSecurityAuditSink(Path.Combine(logDirectory, "audit.jsonl"));
+ switch (command.Command)
+ {
+ case AppCommand.ExecutableResolve:
+ {
+ IReadOnlyList<string> candidates = ExecutableResolver.ResolveCandidates(command.Alias!, ResolveCliPath(command.ExecutablePath, configPath));
+ foreach (string candidate in candidates) Console.Out.WriteLine(candidate);
+ if (candidates.Count > 1) Console.Out.WriteLine(locale.Get("management.ambiguous_candidates"));
+ return 0;
+ }
+ case AppCommand.ExecutableApprove:
+ {
+ UserOnlyDirectory.Ensure(logDirectory);
+ EnsureConfiguredAlias(configuration, command.Alias!, out bool shell);
+ if (command.RuntimeRoot is null && !command.NoRuntime)
+ throw new CommandLineException("Specify either --runtime-root <path> or --no-runtime when approving an Executable Alias.");
+ IReadOnlyList<string> candidates = ExecutableResolver.ResolveCandidates(command.Alias!, ResolveCliPath(command.ExecutablePath, configPath));
+ if (candidates.Count != 1)
+ throw new ConfigurationException($"Executable Alias '{command.Alias}' is ambiguous. Re-run with --path and one of:{Environment.NewLine}{string.Join(Environment.NewLine, candidates)}");
+ string resolved = candidates[0];
+ if (shell && !ExecutableResolver.IsSupportedShell(resolved))
+ throw new ConfigurationException($"Shell Alias '{command.Alias}' must resolve to cmd.exe, powershell.exe, or pwsh.exe.",
+ code: "executable.shell_unsupported", category: "sandbox", target: resolved,
+ resolution: "Select a supported Shell executable and approve it explicitly.");
+ string? runtimeRoot = command.RuntimeRoot is null ? null : ResolvedPath.Resolve(ResolveCliPath(command.RuntimeRoot, configPath)!);
+ if (runtimeRoot is not null && !Directory.Exists(runtimeRoot)) throw new ConfigurationException($"Runtime Root is not a directory: '{runtimeRoot}'.");
+ string sha256 = EntryExecutablePolicy.ComputeSha256(resolved);
+ Console.Out.WriteLine(locale.Format("management.approval_diff", new Dictionary<string, object?>
+ {
+ ["alias"] = command.Alias, ["classification"] = shell ? "shell" : "executable",
+ ["path"] = resolved, ["sha256"] = sha256, ["runtime_root"] = runtimeRoot ?? "(none)"
+ }));
+ ConfirmMutation(command.Yes, locale.Get("management.approve_prompt"));
+ approvals.Approve(new(command.Alias!, shell ? "shell" : "executable", command.ExecutablePath ?? resolved,
+ resolved, sha256, runtimeRoot, DateTimeOffset.UtcNow));
+ audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "executable.approval.add", "approved",
+ new Dictionary<string, string> { ["alias"] = command.Alias!, ["resolved_path"] = resolved, ["non_interactive"] = command.Yes.ToString() }));
+ return 0;
+ }
+ case AppCommand.ExecutableList:
+ foreach (ExecutableApprovalRecord item in approvals.List())
+ Console.Out.WriteLine($"{item.Alias}\t{item.Classification}\t{item.ResolvedPath}\t{item.Sha256}\t{item.RuntimeRoot ?? "-"}");
+ return 0;
+ case AppCommand.ExecutableRevoke:
+ {
+ UserOnlyDirectory.Ensure(logDirectory);
+ ExecutableApprovalRecord record = approvals.Find(command.Alias!) ?? throw new ConfigurationException($"Executable Alias '{command.Alias}' is not approved.");
+ Console.Out.WriteLine(locale.Format("management.revoke_diff", new Dictionary<string, object?> { ["alias"] = record.Alias, ["path"] = record.ResolvedPath }));
+ ConfirmMutation(command.Yes, locale.Get("management.revoke_prompt"));
+ _ = approvals.Revoke(record.Alias);
+ audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "executable.approval.remove", "revoked",
+ new Dictionary<string, string> { ["alias"] = record.Alias, ["resolved_path"] = record.ResolvedPath, ["non_interactive"] = command.Yes.ToString() }));
+ return 0;
+ }
+ case AppCommand.SandboxResolve:
+ PrintResolution(resolver.Resolve(), "text", locale);
+ return 0;
+ case AppCommand.SandboxConfirm:
+ {
+ UserOnlyDirectory.Ensure(logDirectory);
+ SandboxResolutionPreview preview = resolver.Resolve();
+ PrintResolution(preview, "text", locale);
+ ConfirmMutation(command.Yes, locale.Get("management.sandbox_confirm_prompt"));
+ SandboxResolutionDocument document = resolver.Confirm();
+ audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "sandbox.resolution.confirm", "confirmed",
+ new Dictionary<string, string> { ["config_fingerprint"] = document.ConfigFingerprint, ["non_interactive"] = command.Yes.ToString() }));
+ return 0;
+ }
+ case AppCommand.SandboxShow:
+ {
+ SandboxResolutionPreview preview = resolver.Resolve();
+ SandboxResolutionDocument? confirmed = new SandboxResolutionStore(configPath).Load();
+ bool isConfirmed = confirmed is not null && confirmed.ConfigFingerprint == preview.Fingerprint;
+ EffectiveSandboxPolicy? policy = isConfirmed
+ ? resolver.Compile(Path.Combine(Path.GetDirectoryName(configPath)!, "state", "journals"),
+ Path.Combine(Path.GetDirectoryName(configPath)!, "state", "backups"))
+ : null;
+ object? effective = policy is null ? null : EffectivePolicyView(policy);
+ if (command.Format == "json") Console.Out.WriteLine(JsonSerializer.Serialize(new { current = preview, confirmed, effective_policy = effective }, new JsonSerializerOptions { WriteIndented = true }));
+ else
+ {
+ PrintResolution(preview, "text", locale);
+ Console.Out.WriteLine(locale.Format("management.confirmed", new Dictionary<string, object?>
+ { ["value"] = locale.Get(isConfirmed ? "management.yes" : "management.no") }));
+ if (effective is not null) Console.Out.WriteLine(JsonSerializer.Serialize(effective, new JsonSerializerOptions { WriteIndented = true }));
+ }
+ return 0;
+ }
+ default: throw new CommandLineException("Unsupported management command.");
+ }
+ }
+
+ private static void EnsureConfiguredAlias(AgentConfiguration configuration, string alias, out bool shell)
+ {
+ bool executable = configuration.Sandbox.Execution.Executables.Contains(alias, StringComparer.OrdinalIgnoreCase);
+ shell = configuration.Sandbox.Execution.Shells.Contains(alias, StringComparer.OrdinalIgnoreCase);
+ if (!executable && !shell) throw new ConfigurationException($"Executable Alias '{alias}' is not declared in sandbox.execution.executables or sandbox.execution.shells.");
+ }
+
+ private static void ConfirmMutation(bool yes, string prompt)
+ {
+ if (yes) return;
+ if (Console.IsInputRedirected) throw new CommandLineException("Interactive confirmation is unavailable; review the displayed diff and re-run with --yes.");
+ Console.Out.Write($"{prompt} [y/N] ");
+ if (!string.Equals(Console.ReadLine(), "y", StringComparison.OrdinalIgnoreCase)) throw new CommandLineException("Operation cancelled.");
+ }
+
+ private static void PrintResolution(SandboxResolutionPreview preview, string format, LocaleCatalog locale)
+ {
+ if (format == "json") { Console.Out.WriteLine(JsonSerializer.Serialize(preview, new JsonSerializerOptions { WriteIndented = true })); return; }
+ Console.Out.WriteLine(locale.Format("management.resolution_header", new Dictionary<string, object?>
+ { ["config"] = preview.ConfigPath, ["fingerprint"] = preview.Fingerprint }));
+ foreach (ResolutionItem item in preview.Items)
+ Console.Out.WriteLine($"{item.Kind}\t{item.Access}\t{item.LogicalPath} -> {item.ResolvedPath}");
+ }
+
+ private static string? ResolveCliPath(string? path, string configPath) => path is null ? null :
+ (Path.IsPathFullyQualified(path) ? path : Path.Combine(Path.GetDirectoryName(configPath)!, path));
+
+ private static object EffectivePolicyView(EffectiveSandboxPolicy policy) => new
+ {
+ filesystem_roots = policy.Files.Select(item => new { resolved_path = item.Path, capabilities = item.Capabilities.ToString(), origin = item.Origin }),
+ approved_entry_executables = policy.EntryExecutables.Select(item => new
+ {
+ alias = item.Alias,
+ resolved_path = item.ResolvedPath,
+ sha256 = item.Sha256,
+ classification = item.IsShell ? "approved_shell_executable" : "approved_entry_executable",
+ runtime_root = item.RuntimeRoot
+ }),
+ runtime_roots = policy.RuntimeRoots,
+ artifact_roots = policy.ArtifactRoots,
+ network = new
+ {
+ access = policy.NetworkAccess,
+ endpoints = policy.NetworkAllowances.Select(item => new { host = item.Host, ports = item.Ports })
+ },
+ resources = new
+ {
+ profile = policy.ResourceProfile,
+ process_limit = policy.JobLimits.MaxProcesses,
+ memory_bytes = policy.JobLimits.MaxJobMemoryBytes,
+ execution_timeout_seconds = policy.ProcessTimeoutSeconds
+ },
+ baseline_capabilities = policy.BaselineCapabilities ?? ["registryRead", "lpacInstrumentation"]
+ };
}
diff --git a/src/Agent.App/Sandbox/SandboxServices.cs b/src/Agent.App/Sandbox/SandboxServices.cs
new file mode 100644
index 0000000..93211b8
--- /dev/null
+++ b/src/Agent.App/Sandbox/SandboxServices.cs
@@ -0,0 +1,361 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Agent.App.Configuration;
+using Agent.Windows.Security;
+using Agent.Windows.Tools;
+
+namespace Agent.App.Sandbox;
+
+public sealed record ExecutableApprovalRecord(
+ string Alias,
+ string Classification,
+ string LogicalPath,
+ string ResolvedPath,
+ string Sha256,
+ string? RuntimeRoot,
+ DateTimeOffset ApprovedAt);
+
+public sealed record ExecutableApprovalDocument(int SchemaVersion, IReadOnlyList<ExecutableApprovalRecord> Approvals);
+
+public sealed class ExecutableApprovalStore
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
+ public string StorePath { get; }
+ private readonly bool _protectDirectory;
+
+ public ExecutableApprovalStore(string? storePath = null) : this(storePath, protectDirectory: true) { }
+
+ internal ExecutableApprovalStore(string? storePath, bool protectDirectory)
+ {
+ StorePath = storePath ?? Path.Combine(UserStoreRoot(), "executable-approvals.json");
+ _protectDirectory = protectDirectory;
+ }
+
+ public IReadOnlyList<ExecutableApprovalRecord> List() => Load().Approvals;
+
+ public ExecutableApprovalRecord? Find(string alias) =>
+ Load().Approvals.SingleOrDefault(item => item.Alias.Equals(alias, StringComparison.OrdinalIgnoreCase));
+
+ public void Approve(ExecutableApprovalRecord approval)
+ {
+ List<ExecutableApprovalRecord> records = Load().Approvals
+ .Where(item => !item.Alias.Equals(approval.Alias, StringComparison.OrdinalIgnoreCase)).ToList();
+ records.Add(approval);
+ Save(new(1, records.OrderBy(item => item.Alias, StringComparer.OrdinalIgnoreCase).ToArray()));
+ }
+
+ public bool Revoke(string alias)
+ {
+ ExecutableApprovalDocument current = Load();
+ ExecutableApprovalRecord[] remaining = current.Approvals
+ .Where(item => !item.Alias.Equals(alias, StringComparison.OrdinalIgnoreCase)).ToArray();
+ if (remaining.Length == current.Approvals.Count) return false;
+ Save(new(1, remaining));
+ return true;
+ }
+
+ private ExecutableApprovalDocument Load()
+ {
+ if (!File.Exists(StorePath)) return new(1, []);
+ ExecutableApprovalDocument? document = JsonSerializer.Deserialize<ExecutableApprovalDocument>(File.ReadAllText(StorePath), JsonOptions);
+ if (document is null || document.SchemaVersion != 1)
+ throw new ConfigurationException($"Unsupported Executable Approval Store schema: '{StorePath}'.");
+ if (document.Approvals.GroupBy(item => item.Alias, StringComparer.OrdinalIgnoreCase).Any(group => group.Count() != 1))
+ throw new ConfigurationException($"Executable Approval Store contains duplicate Alias records: '{StorePath}'.");
+ foreach (ExecutableApprovalRecord item in document.Approvals)
+ {
+ if (!Regex.IsMatch(item.Alias, "^[a-z0-9][a-z0-9._-]{0,63}$", RegexOptions.CultureInvariant) ||
+ item.Classification is not ("executable" or "shell") || !Path.IsPathFullyQualified(item.ResolvedPath) ||
+ !Regex.IsMatch(item.Sha256, "^[A-Fa-f0-9]{64}$", RegexOptions.CultureInvariant) ||
+ (item.RuntimeRoot is not null && !Path.IsPathFullyQualified(item.RuntimeRoot)))
+ throw new ConfigurationException($"Executable Approval Store contains an invalid record for Alias '{item.Alias}': '{StorePath}'.");
+ }
+ return document;
+ }
+
+ private void Save(ExecutableApprovalDocument document)
+ {
+ string directory = Path.GetDirectoryName(StorePath) ?? throw new ConfigurationException("Executable Approval Store path has no directory.");
+ if (_protectDirectory) UserOnlyDirectory.Ensure(directory); else Directory.CreateDirectory(directory);
+ AtomicJson.Write(StorePath, document, JsonOptions);
+ }
+
+ internal static string UserStoreRoot() => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AgentSandboxMcp");
+}
+
+public sealed record ResolutionItem(string Kind, string LogicalPath, string ResolvedPath, string Access);
+public sealed record SandboxResolutionDocument(
+ int SchemaVersion,
+ string ConfigPath,
+ string ConfigFingerprint,
+ IReadOnlyList<ResolutionItem> Items,
+ DateTimeOffset ConfirmedAt);
+
+public sealed class SandboxResolutionStore
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
+ public string StorePath { get; }
+ private readonly bool _protectDirectory;
+
+ public SandboxResolutionStore(string configPath, string? storeRoot = null) : this(configPath, storeRoot, protectDirectory: true) { }
+
+ internal SandboxResolutionStore(string configPath, string? storeRoot, bool protectDirectory)
+ {
+ string normalized = Path.GetFullPath(configPath).ToUpperInvariant();
+ string name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))) + ".json";
+ StorePath = Path.Combine(storeRoot ?? Path.Combine(ExecutableApprovalStore.UserStoreRoot(), "environment-resolutions"), name);
+ _protectDirectory = protectDirectory;
+ }
+
+ public SandboxResolutionDocument? Load()
+ {
+ if (!File.Exists(StorePath)) return null;
+ SandboxResolutionDocument? document = JsonSerializer.Deserialize<SandboxResolutionDocument>(File.ReadAllText(StorePath), JsonOptions);
+ if (document is null || document.SchemaVersion != 1)
+ throw new ConfigurationException($"Unsupported Sandbox Environment Resolution Store schema: '{StorePath}'.");
+ if (!Path.IsPathFullyQualified(document.ConfigPath) ||
+ !Regex.IsMatch(document.ConfigFingerprint, "^[A-Fa-f0-9]{64}$", RegexOptions.CultureInvariant) ||
+ document.Items.Any(item => item.Kind is not ("workspace" or "mount") || !Path.IsPathFullyQualified(item.ResolvedPath)))
+ throw new ConfigurationException($"Sandbox Environment Resolution Store contains an invalid record: '{StorePath}'.");
+ return document;
+ }
+
+ public void Save(SandboxResolutionDocument document)
+ {
+ string directory = Path.GetDirectoryName(StorePath) ?? throw new ConfigurationException("Resolution Store path has no directory.");
+ if (_protectDirectory) UserOnlyDirectory.Ensure(directory); else Directory.CreateDirectory(directory);
+ AtomicJson.Write(StorePath, document, JsonOptions);
+ }
+}
+
+public sealed record SandboxResolutionPreview(string ConfigPath, string Fingerprint, IReadOnlyList<ResolutionItem> Items);
+
+public sealed class SandboxEnvironmentResolver
+{
+ private readonly string _configPath;
+ private readonly string _baseDirectory;
+ private readonly AgentConfiguration _configuration;
+ private readonly SandboxResolutionStore _resolutionStore;
+ private readonly ExecutableApprovalStore _approvalStore;
+
+ public SandboxEnvironmentResolver(string configPath, AgentConfiguration configuration,
+ SandboxResolutionStore? resolutionStore = null, ExecutableApprovalStore? approvalStore = null)
+ {
+ _configPath = Path.GetFullPath(configPath);
+ _baseDirectory = Path.GetDirectoryName(_configPath)!;
+ _configuration = configuration;
+ _resolutionStore = resolutionStore ?? new SandboxResolutionStore(_configPath);
+ _approvalStore = approvalStore ?? new ExecutableApprovalStore();
+ }
+
+ public SandboxResolutionPreview Resolve()
+ {
+ var items = new List<ResolutionItem>();
+ foreach (string workspace in _configuration.Sandbox.Workspaces)
+ items.Add(ResolveDirectory("workspace", workspace,
+ _configuration.Sandbox.Profile == "development" ? "read_write_delete_execute" : "read_only"));
+ foreach (MountOptions mount in _configuration.Sandbox.Mounts)
+ items.Add(ResolveDirectory("mount", mount.Path, mount.Access));
+ return new(_configPath, Fingerprint(), items);
+ }
+
+ public SandboxResolutionDocument Confirm()
+ {
+ SandboxResolutionPreview preview = Resolve();
+ var document = new SandboxResolutionDocument(1, preview.ConfigPath, preview.Fingerprint, preview.Items, DateTimeOffset.UtcNow);
+ _resolutionStore.Save(document);
+ return document;
+ }
+
+ public EffectiveSandboxPolicy Compile(string journalDirectory, string backupDirectory)
+ {
+ SandboxResolutionPreview current = Resolve();
+ SandboxResolutionDocument? confirmed = _resolutionStore.Load();
+ if (confirmed is null)
+ throw Error("sandbox.resolution_unconfirmed", "Sandbox Environment Resolution is not confirmed.", _configPath,
+ "Run 'agent-mcp sandbox resolve --config <file>' and then 'agent-mcp sandbox confirm --config <file>'.");
+ if (!confirmed.ConfigPath.Equals(current.ConfigPath, StringComparison.OrdinalIgnoreCase) ||
+ !confirmed.ConfigFingerprint.Equals(current.Fingerprint, StringComparison.Ordinal) ||
+ !Equivalent(confirmed.Items, current.Items))
+ throw Error("sandbox.resolution_changed", "Sandbox Environment Resolution changed after confirmation.", _configPath,
+ "Review 'sandbox resolve' and run 'sandbox confirm' again.");
+
+ var files = new List<FilePolicyEntry>();
+ var artifactRoots = new List<string>();
+ foreach (ResolutionItem item in current.Items)
+ {
+ FileCapability capabilities = item.Kind == "workspace"
+ ? (_configuration.Sandbox.Profile == "development"
+ ? FileCapability.Read | FileCapability.Write | FileCapability.Delete | FileCapability.Execute
+ : FileCapability.Read)
+ : item.Access switch
+ {
+ "read_only" => FileCapability.Read,
+ "read_write" => FileCapability.Read | FileCapability.Write,
+ "read_write_delete" => FileCapability.Read | FileCapability.Write | FileCapability.Delete,
+ _ => throw new ConfigurationException($"Unsupported Mount access '{item.Access}'.")
+ };
+ files.Add(new(item.ResolvedPath, capabilities, item.Kind));
+ if (item.Kind == "workspace" && _configuration.Sandbox.Profile == "development") artifactRoots.Add(item.ResolvedPath);
+ }
+
+ var approvals = new List<ApprovedEntryExecutable>();
+ var runtimeRoots = new List<string>();
+ foreach ((string alias, bool shell) in RequestedAliases())
+ {
+ ExecutableApprovalRecord record = _approvalStore.Find(alias) ??
+ throw Error("executable.approval_missing", $"Executable Alias '{alias}' has no approval.", alias,
+ $"Run 'agent-mcp executable resolve {alias} --config <file>' and 'executable approve'.");
+ if (shell != record.Classification.Equals("shell", StringComparison.Ordinal))
+ throw Error("executable.classification_changed", $"Executable Alias '{alias}' approval classification does not match sandbox.execution configuration.", alias,
+ "Revoke and re-approve the Executable Alias using its configured classification.");
+ if (shell && !ExecutableResolver.IsSupportedShell(record.ResolvedPath))
+ throw Error("executable.shell_unsupported", $"Executable Alias '{alias}' does not resolve to a supported Shell.", record.ResolvedPath,
+ "Select cmd.exe, powershell.exe, or pwsh.exe and explicitly approve it.");
+ string logicalExecutable = Path.IsPathFullyQualified(record.LogicalPath)
+ ? record.LogicalPath : Path.Combine(_baseDirectory, record.LogicalPath);
+ string resolved = ResolvedPath.Resolve(logicalExecutable);
+ if (!resolved.Equals(record.ResolvedPath, StringComparison.OrdinalIgnoreCase))
+ throw Error("executable.resolution_changed", $"Executable Alias '{alias}' resolution changed from '{record.ResolvedPath}' to '{resolved}'.", resolved,
+ "Review the new Resolved Path and explicitly re-approve the Executable Alias.");
+ string hash = EntryExecutablePolicy.ComputeSha256(resolved);
+ if (!hash.Equals(record.Sha256, StringComparison.OrdinalIgnoreCase))
+ throw Error("executable.hash_changed", $"Executable Alias '{alias}' SHA-256 changed.", resolved,
+ "Verify the Executable identity and explicitly re-approve it.");
+ string? runtimeRoot = record.RuntimeRoot is null ? null : ResolvedPath.Resolve(record.RuntimeRoot);
+ approvals.Add(new(alias, resolved, hash, shell, runtimeRoot));
+ files.Add(new(resolved, FileCapability.Read | FileCapability.Execute, shell ? "approved_shell_executable" : "approved_entry_executable"));
+ if (runtimeRoot is not null)
+ {
+ runtimeRoots.Add(runtimeRoot);
+ files.Add(new(runtimeRoot, FileCapability.Read | FileCapability.Execute, "runtime_root"));
+ }
+ }
+
+ string system32 = ResolvedPath.Resolve(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32"));
+ files.Add(new(system32, FileCapability.Read | FileCapability.Execute, "os_runtime_root"));
+ IReadOnlyList<FilePolicyEntry> normalizedFiles = NormalizeRoots(files);
+ (int memoryMb, int processes, int timeout) = ResourceLimits();
+ IReadOnlyList<(string Host, IReadOnlyList<int> Ports)> network = NetworkAllowances();
+ return new(normalizedFiles, approvals, runtimeRoots.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
+ artifactRoots, new((uint)processes, checked((ulong)memoryMb * 1024 * 1024)),
+ _configuration.Sandbox.Network.Access, network, journalDirectory, backupDirectory,
+ checked((long)_configuration.FileEdit.MemoryBackupLimitMb * 1024 * 1024),
+ _configuration.Limits.WholeReadBytes, _configuration.Limits.SearchMaxResults,
+ _configuration.Limits.TailMaxLines, timeout, _configuration.Sandbox.Resources.Profile,
+ ["registryRead", "lpacInstrumentation"]);
+ }
+
+ private ResolutionItem ResolveDirectory(string kind, string logicalPath, string access)
+ {
+ string absolute = Path.IsPathFullyQualified(logicalPath) ? logicalPath : Path.Combine(_baseDirectory, logicalPath);
+ string resolved = ResolvedPath.Resolve(absolute);
+ if (!Directory.Exists(resolved)) throw Error("sandbox.resolved_directory_missing", $"{kind} Resolved Path is not a directory: '{resolved}'.", resolved,
+ "Correct the Logical Path or restore the directory, then confirm the Sandbox Environment Resolution again.");
+ return new(kind, logicalPath, resolved, access);
+ }
+
+ private string Fingerprint()
+ {
+ string json = JsonSerializer.Serialize(_configuration.Sandbox, new JsonSerializerOptions(JsonSerializerDefaults.Web));
+ return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
+ }
+
+ private IEnumerable<(string Alias, bool Shell)> RequestedAliases() =>
+ _configuration.Sandbox.Execution.Executables.Select(alias => (alias, false))
+ .Concat(_configuration.Sandbox.Execution.Shells.Select(alias => (alias, true)));
+
+ private (int MemoryMb, int Processes, int Timeout) ResourceLimits()
+ {
+ (int memory, int processes, int timeout) = _configuration.Sandbox.Resources.Profile switch
+ {
+ "constrained" => (1024, 4, 300),
+ "normal" => (4096, 16, 1800),
+ "extended" => (8192, 32, 3600),
+ _ => throw new ConfigurationException($"Unsupported Resource Profile '{_configuration.Sandbox.Resources.Profile}'.")
+ };
+ return (_configuration.Sandbox.Resources.MemoryMb ?? memory,
+ _configuration.Sandbox.Resources.ProcessLimit ?? processes,
+ _configuration.Sandbox.Resources.ExecutionTimeoutSeconds ?? timeout);
+ }
+
+ private IReadOnlyList<(string Host, IReadOnlyList<int> Ports)> NetworkAllowances() =>
+ _configuration.Sandbox.Network.Access switch
+ {
+ "none" => [],
+ "local" => _configuration.Sandbox.Network.LocalEndpoints
+ .SelectMany(item => new[] { ("127.0.0.1", (IReadOnlyList<int>)[item.Port]), ("::1", (IReadOnlyList<int>)[item.Port]) }).ToArray(),
+ "restricted" => _configuration.Sandbox.Network.RestrictedEndpoints
+ .Select(item => (item.Host, (IReadOnlyList<int>)item.Ports)).ToArray(),
+ _ => throw new ConfigurationException($"Unsupported Network access '{_configuration.Sandbox.Network.Access}'.")
+ };
+
+ private static IReadOnlyList<FilePolicyEntry> NormalizeRoots(IEnumerable<FilePolicyEntry> input)
+ {
+ FilePolicyEntry[] roots = input.GroupBy(item => ResolvedPath.Resolve(item.Path), StringComparer.OrdinalIgnoreCase)
+ .Select(group => new FilePolicyEntry(group.Key,
+ group.Aggregate(FileCapability.None, (value, item) => value | item.Capabilities),
+ string.Join(',', group.Select(item => item.Origin).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)))).ToArray();
+ foreach (FilePolicyEntry parent in roots)
+ foreach (FilePolicyEntry child in roots)
+ {
+ if (ReferenceEquals(parent, child) || !IsBelow(child.Path, parent.Path)) continue;
+ if ((parent.Capabilities & child.Capabilities) != parent.Capabilities)
+ throw new ConfigurationException($"Nested Filesystem Root '{child.Path}' is narrower than inherited capabilities from '{parent.Path}' and cannot be enforced without widening it.");
+ }
+ return roots;
+ }
+
+ private static bool Equivalent(IReadOnlyList<ResolutionItem> left, IReadOnlyList<ResolutionItem> right) =>
+ left.OrderBy(Key, StringComparer.OrdinalIgnoreCase).SequenceEqual(right.OrderBy(Key, StringComparer.OrdinalIgnoreCase));
+ private static string Key(ResolutionItem item) => $"{item.Kind}|{item.LogicalPath}|{item.ResolvedPath}|{item.Access}";
+ private static bool IsBelow(string child, string parent) => child.StartsWith(parent.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
+ private static ConfigurationException Error(string code, string message, string? target, string resolution) =>
+ new(message, code: code, category: "sandbox", target: target, resolution: resolution);
+}
+
+public static class ExecutableResolver
+{
+ public static bool IsSupportedShell(string path) => Path.GetFileName(path).ToLowerInvariant() is "cmd.exe" or "powershell.exe" or "pwsh.exe";
+
+ public static IReadOnlyList<string> ResolveCandidates(string alias, string? explicitPath)
+ {
+ if (!string.IsNullOrWhiteSpace(explicitPath))
+ {
+ string resolved = ResolvedPath.Resolve(explicitPath);
+ if (!File.Exists(resolved)) throw new ConfigurationException($"Executable Resolved Path is not a file: '{resolved}'.");
+ return [resolved];
+ }
+ string fileName = Path.HasExtension(alias) ? alias : alias + ".exe";
+ string[] candidates = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
+ .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(directory => Path.Combine(directory, fileName)).Where(File.Exists)
+ .Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ if (candidates.Length == 0) throw new ConfigurationException($"No PATH candidate was found for Executable Alias '{alias}'. Use --path <path>.");
+ return candidates;
+ }
+}
+
+internal static class AtomicJson
+{
+ public static void Write<T>(string path, T value, JsonSerializerOptions options)
+ {
+ string temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
+ {
+ JsonSerializer.Serialize(stream, value, options);
+ stream.Flush(flushToDisk: true);
+ }
+ File.Move(temporary, path, overwrite: true);
+ }
+ finally
+ {
+ if (File.Exists(temporary)) File.Delete(temporary);
+ }
+ }
+}
diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs
index 5ca9d1c..676fe0d 100644
--- a/src/Agent.Core/Runtime/AgentRuntime.cs
+++ b/src/Agent.Core/Runtime/AgentRuntime.cs
@@ -303,7 +303,8 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
throw new PolicyDeniedException(toolResult.FailureFingerprint ?? $"policy:{call.Function.Name}:{toolResult.Output}", toolResult.Output,
toolResult.ErrorCode ?? "policy_denied", toolResult.ErrorCategory, toolResult.TargetPath,
toolResult.RequiredPermission, toolResult.Resolution);
- failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint;
+ if (!toolResult.Success && !isMetadataTool)
+ failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint;
messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name));
}
bool verified = plannerTask.RequiredTools.All(successfulRequiredTools.Contains) && (!requiresExternalEffect || externalEffectEvidence);
@@ -527,12 +528,19 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
private string LocalizePolicyDenial(PolicyDeniedException error)
{
if (locale is null) return error.Message;
+ string resolutionKey = error.ErrorCategory switch
+ {
+ "filesystem" => "runtime.policy_resolution.filesystem",
+ "process" => "runtime.policy_resolution.process",
+ "network" => "runtime.policy_resolution.network",
+ _ => "runtime.policy_resolution.default"
+ };
return locale.Format("runtime.policy_denied", new Dictionary<string, object?>
{
["code"] = error.ErrorCode,
["path"] = error.TargetPath ?? "-",
["permission"] = error.RequiredPermission ?? "-",
- ["resolution"] = error.Resolution ?? "-"
+ ["resolution"] = locale.Get(resolutionKey)
});
}
diff --git a/src/Agent.Core/Runtime/PlannerService.cs b/src/Agent.Core/Runtime/PlannerService.cs
index 3591259..756cc41 100644
--- a/src/Agent.Core/Runtime/PlannerService.cs
+++ b/src/Agent.Core/Runtime/PlannerService.cs
@@ -31,12 +31,13 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
var messages = new List<OllamaMessage>
{
- new("system", FixedPrompt.Load()),
- new("system", _settings.UserSystemPrompt),
+ // Planning has a dedicated read-only contract. Execution prompts contain instructions
+ // to inspect files and call tools, so mixing them here creates contradictory system
+ // instructions and causes models to execute narratively instead of returning a graph.
+ new("system", BuildPlanningInstruction()),
new("system", $"Respond in locale: {_settings.Locale}."),
new("system", ComposeSkillCatalog(_settings.Skills)),
new("system", _settings.SandboxCapabilitySummary),
- new("system", BuildPlanningInstruction()),
new("user", request, images)
};
@@ -58,11 +59,9 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
var messages = new List<OllamaMessage>
{
- new("system", FixedPrompt.Load()),
- new("system", _settings.UserSystemPrompt),
+ new("system", BuildReplanInstruction(existing, replanReason)),
new("system", $"Respond in locale: {_settings.Locale}."),
new("system", _settings.SandboxCapabilitySummary),
- new("system", BuildReplanInstruction(existing, replanReason)),
new("user", request)
};
@@ -74,6 +73,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
private TaskGraph BuildTaskGraph(PlanResult plan, string originalRequest)
{
+ ValidatePlanStructure(plan);
var graph = new TaskGraph(_settings.MaxTasks);
var idMap = new List<Guid>();
@@ -105,6 +105,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
private TaskGraph RebuildTaskGraph(TaskGraph existing, PlanResult plan, string originalRequest)
{
+ ValidatePlanStructure(plan);
PlannerTask[] completed = existing.Tasks.Where(static t => t.State == PlannerTaskState.Completed).ToArray();
var completedByDescription = completed.GroupBy(static t => t.Description, StringComparer.Ordinal)
.ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal);
@@ -182,6 +183,29 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
return tools;
}
+ private static void ValidatePlanStructure(PlanResult plan)
+ {
+ for (int index = 0; index < plan.Tasks.Count; index++)
+ {
+ PlannedTask task = plan.Tasks[index];
+ if (!task.RequiredTools.Contains("file_edit", StringComparer.Ordinal) ||
+ !task.RequiredTools.Contains("process_run", StringComparer.Ordinal)) continue;
+
+ var pending = new Stack<int>(task.Dependencies);
+ var visited = new HashSet<int>();
+ while (pending.TryPop(out int dependency))
+ {
+ if (dependency < 0 || dependency >= index || !visited.Add(dependency)) continue;
+ PlannedTask ancestor = plan.Tasks[dependency];
+ if (ancestor.RequiredTools.Contains("file_edit", StringComparer.Ordinal))
+ throw new PlannerValidationException(
+ "A coding implementation-and-verification Task cannot depend on a separate file_edit Task. " +
+ "Combine the edits, optional corrections, and process verification in one Task requiring file_edit and process_run.");
+ foreach (int parent in ancestor.Dependencies) pending.Push(parent);
+ }
+ }
+ }
+
private static PlanResult? ParsePlan(string content)
{
try
@@ -266,7 +290,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se
{
attemptMessages = messages.Concat([
new OllamaMessage("system",
- "The previous response was not a valid TaskGraph JSON object. Do not call or execute any Tool while planning. " +
+ $"The previous response was not a valid TaskGraph: {ex.Message} Do not call or execute any Tool while planning. " +
"Return only one JSON object that matches the supplied schema, beginning with {\"tasks\":[ and containing no Markdown or commentary.")
]).ToArray();
}
diff --git a/src/Agent.Windows/NativeMethods.txt b/src/Agent.Windows/NativeMethods.txt
index 471b47d..61efc53 100644
--- a/src/Agent.Windows/NativeMethods.txt
+++ b/src/Agent.Windows/NativeMethods.txt
@@ -4,18 +4,25 @@ CreateJobObject
CreateProcess
DeleteProcThreadAttributeList
GetExitCodeProcess
+GetTokenInformation
InitializeProcThreadAttributeList
+IsProcessInJob
JOBOBJECT_EXTENDED_LIMIT_INFORMATION
JOB_OBJECT_LIMIT
-PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY
PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES
+PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY
PROC_THREAD_ATTRIBUTE_HANDLE_LIST
+DeriveCapabilitySidsFromName
+LocalFree
+OpenProcessToken
ResumeThread
SetHandleInformation
SetInformationJobObject
TerminateJobObject
UpdateProcThreadAttribute
STARTUPINFOEXW
+TOKEN_INFORMATION_CLASS
+TOKEN_APPCONTAINER_INFORMATION
WaitForSingleObject
Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineOpen0
Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmEngineClose0
diff --git a/src/Agent.Windows/Network/TaskNetworkPolicy.cs b/src/Agent.Windows/Network/TaskNetworkPolicy.cs
index bc4d291..9f78b3d 100644
--- a/src/Agent.Windows/Network/TaskNetworkPolicy.cs
+++ b/src/Agent.Windows/Network/TaskNetworkPolicy.cs
@@ -11,24 +11,25 @@ public sealed class TaskNetworkPolicy : IDisposable
private readonly ISecurityAuditSink _audit;
private readonly Guid _entryId;
private readonly SecurityIdentifier _appContainerSid;
- private readonly Guid _loopbackEntryId;
+ private readonly Guid? _loopbackEntryId;
private readonly bool _loopbackAdded;
private readonly Guid? _sessionId;
private bool _disposed;
private TaskNetworkPolicy(WfpDynamicSession session, TaskResourceJournal journal, ISecurityAuditSink audit, Guid entryId,
- SecurityIdentifier appContainerSid, Guid loopbackEntryId, bool loopbackAdded, Guid? sessionId)
+ SecurityIdentifier appContainerSid, Guid? loopbackEntryId, bool loopbackAdded, Guid? sessionId)
{
_session = session; _journal = journal; _audit = audit; _entryId = entryId;
_appContainerSid = appContainerSid; _loopbackEntryId = loopbackEntryId; _loopbackAdded = loopbackAdded;
_sessionId = sessionId;
}
- public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances, ISecurityAuditSink audit, Guid? sessionId = null)
+ public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances,
+ ISecurityAuditSink audit, Guid? sessionId = null, bool enableLoopback = true)
{
- bool originallyExempt = LoopbackExemption.Contains(appContainerSid);
- Guid loopbackEntryId = journal.Prepare("network.loopback_exemption", appContainerSid.Value,
- new Dictionary<string, string> { ["originally_present"] = originallyExempt.ToString() });
+ bool originallyExempt = enableLoopback && LoopbackExemption.Contains(appContainerSid);
+ Guid? loopbackEntryId = enableLoopback ? journal.Prepare("network.loopback_exemption", appContainerSid.Value,
+ new Dictionary<string, string> { ["originally_present"] = originallyExempt.ToString() }) : null;
bool loopbackAdded = false;
Guid entryId = journal.Prepare("wfp.dynamic", appContainerSid.Value, new Dictionary<string, string>
{
@@ -37,15 +38,18 @@ public sealed class TaskNetworkPolicy : IDisposable
WfpDynamicSession session = WfpDynamicSession.Open();
try
{
- loopbackAdded = LoopbackExemption.Ensure(appContainerSid);
- journal.MarkApplied(loopbackEntryId, new Dictionary<string, string> { ["added"] = loopbackAdded.ToString() });
+ if (enableLoopback)
+ {
+ loopbackAdded = LoopbackExemption.Ensure(appContainerSid);
+ journal.MarkApplied(loopbackEntryId!.Value, new Dictionary<string, string> { ["added"] = loopbackAdded.ToString() });
+ }
IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(appContainerSid, allowances);
journal.MarkApplied(entryId, new Dictionary<string, string> { ["filter_ids"] = string.Join(',', ids) });
audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.rule.apply", "applied", new Dictionary<string, string>
{
["ipv4_ipv6_default_block"] = "true", ["filter_count"] = ids.Count.ToString(), ["session"] = "dynamic"
}, SessionId: sessionId));
- audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved",
+ if (enableLoopback) audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved",
new Dictionary<string, string> { ["app_container_sid"] = appContainerSid.Value }, SessionId: sessionId));
return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded, sessionId);
}
@@ -54,7 +58,7 @@ public sealed class TaskNetworkPolicy : IDisposable
session.Dispose();
journal.MarkCleaned(entryId);
if (loopbackAdded) LoopbackExemption.Remove(appContainerSid);
- journal.MarkCleaned(loopbackEntryId);
+ if (loopbackEntryId is not null) journal.MarkCleaned(loopbackEntryId.Value);
throw;
}
}
@@ -66,9 +70,9 @@ public sealed class TaskNetworkPolicy : IDisposable
_session.Dispose();
_journal.MarkCleaned(_entryId);
if (_loopbackAdded) LoopbackExemption.Remove(_appContainerSid);
- _journal.MarkCleaned(_loopbackEntryId);
+ if (_loopbackEntryId is not null) _journal.MarkCleaned(_loopbackEntryId.Value);
_audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.rule.remove", "removed", new Dictionary<string, string> { ["residual_filters"] = "0" }, SessionId: _sessionId));
- _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed",
+ if (_loopbackEntryId is not null) _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed",
new Dictionary<string, string> { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" }, SessionId: _sessionId));
_disposed = true;
}
diff --git a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
index 25c949d..ff4ac5a 100644
--- a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
+++ b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
@@ -11,7 +11,7 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string
public FileEditTransaction Replace(string path, ReadOnlySpan<byte> content, string? expectedSha256 = null)
{
- string canonical = CanonicalPath.Resolve(path, requireExisting: false);
+ string canonical = ResolvedPath.Resolve(path, requireExisting: false);
bool existed = File.Exists(canonical);
byte[]? original = existed ? File.ReadAllBytes(canonical) : null;
string originalHash = original is null ? string.Empty : Convert.ToHexString(SHA256.HashData(original));
@@ -45,7 +45,7 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string
public FileDeleteTransaction Delete(string path, string? expectedSha256 = null)
{
- string canonical = CanonicalPath.Resolve(path);
+ string canonical = ResolvedPath.Resolve(path);
byte[] original = File.ReadAllBytes(canonical);
string originalHash = Convert.ToHexString(SHA256.HashData(original));
if (expectedSha256 is not null && !originalHash.Equals(NormalizeHash(expectedSha256), StringComparison.Ordinal))
@@ -71,7 +71,7 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string
public DirectoryMkdirTransaction Mkdir(string path)
{
- string canonical = CanonicalPath.Resolve(path, requireExisting: false);
+ string canonical = ResolvedPath.Resolve(path, requireExisting: false);
bool existed = Directory.Exists(canonical);
if (existed) throw new InvalidOperationException($"Directory already exists: '{canonical}'.");
if (File.Exists(canonical)) throw new InvalidOperationException($"A file exists at the target path: '{canonical}'.");
@@ -83,7 +83,7 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string
public DirectoryRmdirTransaction Rmdir(string path)
{
- string canonical = CanonicalPath.Resolve(path);
+ string canonical = ResolvedPath.Resolve(path);
if (!Directory.Exists(canonical)) throw new DirectoryNotFoundException($"Directory does not exist: '{canonical}'.");
if (Directory.EnumerateFileSystemEntries(canonical, "*", SearchOption.TopDirectoryOnly).Any())
throw new ToolValidationException("Directory is not empty. Remove contents first before removing the directory.");
@@ -95,8 +95,8 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string
public FileRenameTransaction Rename(string sourcePath, string destinationPath)
{
- string source = CanonicalPath.Resolve(sourcePath);
- string destination = CanonicalPath.Resolve(destinationPath, requireExisting: false);
+ string source = ResolvedPath.Resolve(sourcePath);
+ string destination = ResolvedPath.Resolve(destinationPath, requireExisting: false);
if (!File.Exists(source)) throw new FileNotFoundException($"Source file does not exist: '{source}'.");
if (File.Exists(destination)) throw new ToolValidationException($"Destination already exists: '{destination}'. Rename overwrite is not allowed.");
if (!source.StartsWith(System.IO.Path.GetPathRoot(source)!, StringComparison.OrdinalIgnoreCase) ||
diff --git a/src/Agent.Windows/Security/ExecutablePolicy.cs b/src/Agent.Windows/Security/ExecutablePolicy.cs
index 6c72724..02306fd 100644
--- a/src/Agent.Windows/Security/ExecutablePolicy.cs
+++ b/src/Agent.Windows/Security/ExecutablePolicy.cs
@@ -2,46 +2,91 @@ 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 enum EntryExecutableClassification { ApprovedEntryExecutable, ApprovedShellExecutable, RuntimeComponent, WorkspaceArtifact }
-public sealed class ExecutablePolicy(IEnumerable<ExecutableApproval> approvals, bool allowShell)
+public sealed record ApprovedEntryExecutable(string Alias, string ResolvedPath, string Sha256, bool IsShell, string? RuntimeRoot);
+public sealed record VerifiedEntryExecutable(string ResolvedPath, string Sha256, EntryExecutableClassification Classification, string? Alias = null);
+
+public sealed class EntryExecutablePolicy(
+ IEnumerable<ApprovedEntryExecutable> approvals,
+ IEnumerable<string> runtimeRoots,
+ IEnumerable<string> artifactRoots)
{
- private readonly IReadOnlyDictionary<string, string> _approvals = approvals.ToDictionary(
- approval => CanonicalPath.Resolve(approval.Path), approval => NormalizeHash(approval.Sha256), StringComparer.OrdinalIgnoreCase);
+ private readonly IReadOnlyDictionary<string, ApprovedEntryExecutable> _aliases = approvals.ToDictionary(
+ approval => approval.Alias, Normalize, StringComparer.OrdinalIgnoreCase);
+ private readonly string[] _runtimeRoots = runtimeRoots.Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ private readonly string[] _artifactRoots = artifactRoots.Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
- public VerifiedExecutable Verify(string executable)
+ public VerifiedEntryExecutable VerifyEntry(string executable, string? baseDirectory = null)
{
- string canonical = CanonicalPath.Resolve(executable);
- if (!allowShell && IsShell(canonical)) throw new SandboxPolicyException("process.shell_denied", "Shell execution is disabled.", "process", canonical, "execute", "sandbox.process.allow_shell");
- if (!_approvals.TryGetValue(canonical, out string? approvedHash)) throw new SandboxPolicyException("process.path_denied", $"Executable path is not approved: '{canonical}'. Add its canonical path and SHA-256 to sandbox.process.approvals.", "process", canonical, "execute", "sandbox.process.approvals");
- 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: '{canonical}'. Recalculate SHA-256 and update sandbox.process.approvals.", "process", canonical, "execute", "sandbox.process.approvals");
- return new(canonical, actualHash);
+ if (_aliases.TryGetValue(executable, out ApprovedEntryExecutable? byAlias)) return VerifyApproved(byAlias);
+ string candidate = Path.IsPathFullyQualified(executable) ? executable : Path.Combine(baseDirectory ?? Environment.CurrentDirectory, executable);
+ string resolved;
+ try { resolved = ResolvedPath.Resolve(candidate); }
+ catch (FileNotFoundException)
+ {
+ throw new SandboxPolicyException("process.alias_or_path_unknown", $"Executable Alias or Logical Path does not resolve to an existing file: '{executable}'.",
+ "process", Path.GetFullPath(candidate), "execute", "Use a configured Executable Alias or an existing Runtime Component/development Workspace Artifact path.");
+ }
+ if (_runtimeRoots.Any(root => IsAtOrBelow(resolved, root)))
+ return new(resolved, ComputeSha256(resolved), EntryExecutableClassification.RuntimeComponent);
+ if (_artifactRoots.Any(root => IsAtOrBelow(resolved, root)))
+ return new(resolved, ComputeSha256(resolved), EntryExecutableClassification.WorkspaceArtifact);
+ throw new SandboxPolicyException("process.entry_unknown", $"External Unknown Executable cannot be started as an Entry Process: '{resolved}'.",
+ "process", resolved, "execute", "Configure an Executable Alias and approve it, or place the Workspace Artifact in a development Workspace.");
}
- public static string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments)
+ public string? ManagedEntryPoint(string executable, IReadOnlyList<string> arguments, string? baseDirectory = null)
{
- if (!Path.GetFileName(executable).Equals("dotnet.exe", StringComparison.OrdinalIgnoreCase) || arguments.Count == 0) return null;
+ string executablePath = Path.IsPathFullyQualified(executable) ? executable : Path.Combine(baseDirectory ?? Environment.CurrentDirectory, executable);
+ string resolved = _aliases.TryGetValue(executable, out ApprovedEntryExecutable? approval) ? approval.ResolvedPath : ResolvedPath.Resolve(executablePath);
+ if (!Path.GetFileName(resolved).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 ResolveArgument(arguments[1], baseDirectory);
}
- return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? arguments[0] : null;
+ return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? ResolveArgument(arguments[0], baseDirectory) : null;
+ }
+
+ public void VerifyManagedEntryPoint(string path)
+ {
+ string resolved = ResolvedPath.Resolve(path);
+ if (_runtimeRoots.Any(root => IsAtOrBelow(resolved, root)) || _artifactRoots.Any(root => IsAtOrBelow(resolved, root))) return;
+ throw new SandboxPolicyException("process.managed_entry_unknown", $"Managed Entry Artifact is outside every confirmed Runtime Root and development Workspace: '{resolved}'.",
+ "process", resolved, "execute", "Place the artifact in a development Workspace or confirm its Runtime Root.");
}
public static string ComputeSha256(string executable)
{
- using FileStream stream = new(CanonicalPath.Resolve(executable), FileMode.Open, FileAccess.Read, FileShare.Read);
+ using FileStream stream = new(ResolvedPath.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 ApprovedEntryExecutable Normalize(ApprovedEntryExecutable approval) => approval with
+ {
+ ResolvedPath = ResolvedPath.Resolve(approval.ResolvedPath),
+ Sha256 = NormalizeHash(approval.Sha256),
+ RuntimeRoot = approval.RuntimeRoot is null ? null : ResolvedPath.Resolve(approval.RuntimeRoot)
+ };
+
+ private static VerifiedEntryExecutable VerifyApproved(ApprovedEntryExecutable approval)
+ {
+ string resolved = ResolvedPath.Resolve(approval.ResolvedPath);
+ string actualHash = ComputeSha256(resolved);
+ if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approval.Sha256), Convert.FromHexString(actualHash)))
+ throw new SandboxPolicyException("process.hash_denied", $"Approved Entry Executable SHA-256 changed: '{resolved}'.",
+ "process", resolved, "execute", $"Re-run executable approve for Alias '{approval.Alias}'.");
+ return new(resolved, actualHash, approval.IsShell ? EntryExecutableClassification.ApprovedShellExecutable : EntryExecutableClassification.ApprovedEntryExecutable, approval.Alias);
+ }
+
+ private static bool IsAtOrBelow(string candidate, string root) =>
+ candidate.Equals(root, StringComparison.OrdinalIgnoreCase) ||
+ candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
+ private static string ResolveArgument(string path, string? baseDirectory) => Path.IsPathFullyQualified(path)
+ ? path : Path.GetFullPath(path, baseDirectory ?? Environment.CurrentDirectory);
+
private static string NormalizeHash(string hash)
{
string normalized = hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant();
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);
}
}
diff --git a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
index 687af94..c3a19da 100644
--- a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
+++ b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
@@ -14,21 +14,27 @@ 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)
+public sealed unsafe class RestrictedProcessLauncher(EntryExecutablePolicy executablePolicy, ISecurityAuditSink audit, SecurityIdentifier? appContainerSid = null,
+ SandboxJobLimits? jobLimits = null, bool allowNetwork = false)
{
- private const uint ProcessCreationChildProcessRestricted = 0x1;
+ private const uint ProcessCreationAllApplicationPackagesOptOut = 0x1;
private readonly SandboxJobLimits _jobLimits = jobLimits ?? new();
public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout,
- string? standardInput = null, Guid? sessionId = null)
+ string? standardInput = null, Guid? sessionId = null) =>
+ Run(taskId, executable, arguments, workingDirectory, timeout, standardInput, sessionId, CancellationToken.None);
+
+ public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout,
+ string? standardInput, Guid? sessionId, CancellationToken cancellationToken)
{
- VerifiedExecutable verified;
- VerifiedExecutable? managedEntry = null;
+ string cwd = ResolvedPath.Resolve(workingDirectory);
+ VerifiedEntryExecutable verified;
+ string? managedEntry = null;
try
{
- verified = executablePolicy.Verify(executable);
- string? managedEntryPath = ExecutablePolicy.ManagedEntryPoint(verified.CanonicalPath, arguments);
- if (managedEntryPath is not null) managedEntry = executablePolicy.Verify(managedEntryPath);
+ verified = executablePolicy.VerifyEntry(executable, cwd);
+ managedEntry = executablePolicy.ManagedEntryPoint(verified.ResolvedPath, arguments, cwd);
+ if (managedEntry is not null) executablePolicy.VerifyManagedEntryPoint(managedEntry);
}
catch (SandboxPolicyException ex)
{
@@ -36,7 +42,6 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
throw;
}
- string cwd = CanonicalPath.Resolve(workingDirectory);
using FileStream stdin = CaptureFile();
using FileStream stdout = CaptureFile();
using FileStream stderr = CaptureFile();
@@ -63,18 +68,14 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
throw LastError("SetInformationJobObject");
nuint attributeSize = 0;
- uint attributeCount = appContainerSid is null ? 2u : 3u;
+ uint attributeCount = appContainerSid is null ? 1u : 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;
+ var capabilityBuffers = new List<nint>();
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()),
@@ -95,17 +96,24 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
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;
+ SecurityIdentifier[] capabilitySids = allowNetwork
+ ? [DeriveCapabilitySid("registryRead"), DeriveCapabilitySid("lpacInstrumentation"), new("S-1-15-3-1"), new("S-1-15-3-3")]
+ : [DeriveCapabilitySid("registryRead"), DeriveCapabilitySid("lpacInstrumentation")];
+ SID_AND_ATTRIBUTES* capabilities = stackalloc SID_AND_ATTRIBUTES[capabilitySids.Length];
+ for (int i = 0; i < capabilitySids.Length; i++)
+ {
+ void* buffer = AllocateSid(capabilitySids[i]);
+ capabilityBuffers.Add((nint)buffer);
+ capabilities[i].Sid = new PSID(buffer);
+ capabilities[i].Attributes = 0x00000004;
+ }
securityCapabilities.Capabilities = (nint)capabilities;
- securityCapabilities.CapabilityCount = 2;
+ securityCapabilities.CapabilityCount = (uint)capabilitySids.Length;
if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES,
- &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LastError("UpdateProcThreadAttribute(SecurityCapabilities)");
+ &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LpacFailure("UpdateProcThreadAttribute(SecurityCapabilities)", verified.ResolvedPath);
+ uint allApplicationPackagesPolicy = ProcessCreationAllApplicationPackagesOptOut;
+ if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY,
+ &allApplicationPackagesPolicy, sizeof(uint), null, null)) throw LpacFailure("UpdateProcThreadAttribute(AllApplicationPackagesPolicy)", verified.ResolvedPath);
}
STARTUPINFOEXW startup = default;
startup.StartupInfo.cb = (uint)sizeof(STARTUPINFOEXW);
@@ -115,28 +123,29 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
startup.StartupInfo.hStdError = inheritedHandles[2];
startup.lpAttributeList = attributes;
PROCESS_INFORMATION process = default;
- string commandLine = BuildCommandLine(verified.CanonicalPath, arguments);
+ string commandLine = BuildCommandLine(verified.ResolvedPath, 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");
+ char[] environmentBlock = BuildEnvironmentBlock(cwd);
+ fixed (char* environment = environmentBlock)
+ {
+ if (!PInvoke.CreateProcess(null, ref commandBuffer, null, null, true,
+ PROCESS_CREATION_FLAGS.EXTENDED_STARTUPINFO_PRESENT | PROCESS_CREATION_FLAGS.CREATE_SUSPENDED |
+ PROCESS_CREATION_FLAGS.CREATE_UNICODE_ENVIRONMENT,
+ environment, 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) ||
+ VerifiedEntryExecutable postCreate = executablePolicy.VerifyEntry(executable, cwd);
+ if (!string.Equals(postCreate.ResolvedPath, verified.ResolvedPath, 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.");
- }
+ executablePolicy.VerifyManagedEntryPoint(managedEntry);
}
catch (SandboxPolicyException ex)
{
@@ -146,20 +155,41 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
}, SessionId: sessionId, ErrorCode: ex.Code));
throw;
}
+ (bool tokenIsAppContainer, string? tokenAppContainerSid, bool processInJob) = InspectConfinement(processView, job);
+ if (appContainerSid is not null && (!tokenIsAppContainer ||
+ !string.Equals(tokenAppContainerSid, appContainerSid.Value, StringComparison.Ordinal) || !processInJob))
+ throw new SandboxPolicyException("process.lpac_mismatch",
+ "Entry Process did not receive the required LPAC identity and Job Object confinement.",
+ "process", verified.ResolvedPath, "execute", "Verify LPAC support on this Windows host; no fallback is permitted.");
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",
+ ["path"] = verified.ResolvedPath, ["sha256"] = verified.Sha256, ["entry_classification"] = verified.Classification.ToString(),
+ ["alias"] = verified.Alias ?? string.Empty, ["descendant_policy"] = "lpac_inherited",
+ ["app_container_sid"] = appContainerSid?.Value ?? "none", ["lpac"] = (appContainerSid is not null).ToString(),
+ ["token_is_app_container"] = tokenIsAppContainer.ToString(), ["token_app_container_sid"] = tokenAppContainerSid ?? "none",
+ ["process_in_job"] = processInJob.ToString(), ["all_application_packages_opt_out"] = (appContainerSid is not null).ToString(),
+ ["network_capabilities"] = appContainerSid is null || !allowNetwork ? "none" : "internetClient,privateNetworkClientServer",
+ ["baseline_capabilities"] = appContainerSid is null ? "none" : "registryRead,lpacInstrumentation",
+ ["environment_policy"] = "minimal_no_broker_credentials",
+ ["arguments"] = "redacted", ["argument_count"] = arguments.Count.ToString(System.Globalization.CultureInfo.InvariantCulture),
["pid"] = process.dwProcessId.ToString()
};
if (managedEntry is not null)
{
- processAudit["managed_entry_path"] = managedEntry.CanonicalPath;
- processAudit["managed_entry_sha256"] = managedEntry.Sha256;
+ processAudit["managed_entry_path"] = ResolvedPath.Resolve(managedEntry);
}
audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit, SessionId: sessionId));
- WAIT_EVENT wait = PInvoke.WaitForSingleObject(process.hProcess, checked((uint)Math.Clamp(timeout.TotalMilliseconds, 1, uint.MaxValue - 1)));
+ long deadline = Environment.TickCount64 + checked((long)Math.Clamp(timeout.TotalMilliseconds, 1, int.MaxValue));
+ WAIT_EVENT wait;
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ long remaining = deadline - Environment.TickCount64;
+ if (remaining <= 0) { wait = WAIT_EVENT.WAIT_TIMEOUT; break; }
+ wait = PInvoke.WaitForSingleObject(process.hProcess, (uint)Math.Min(remaining, 100));
+ if (wait != WAIT_EVENT.WAIT_TIMEOUT) break;
+ }
if (wait == WAIT_EVENT.WAIT_TIMEOUT)
{
_ = PInvoke.TerminateJobObject(job, 1460);
@@ -172,12 +202,20 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
{
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()
- }, SessionId: sessionId, ErrorCode: "process.child_denied"));
+ ["code"] = "sandbox_process_failure", ["pid"] = process.dwProcessId.ToString(),
+ ["descendant_policy"] = "lpac_inherited", ["exit_code"] = exitCode.ToString()
+ }, SessionId: sessionId, ErrorCode: "process.nonzero_exit"));
}
return new(process.dwProcessId, exitCode, ReadCapture(stdout), ReadCapture(stderr));
}
+ catch (OperationCanceledException)
+ {
+ _ = PInvoke.TerminateJobObject(job, 1223);
+ _ = PInvoke.WaitForSingleObject(process.hProcess, 5000);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.cancel", "terminated",
+ new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString() }, SessionId: sessionId));
+ throw;
+ }
finally
{
PInvoke.CloseHandle(process.hThread);
@@ -188,8 +226,7 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
{
if (attributes.Value is not null) PInvoke.DeleteProcThreadAttributeList(attributes);
NativeMemory.Free(sidBuffer);
- NativeMemory.Free(internetClientSidBuffer);
- NativeMemory.Free(privateNetworkSidBuffer);
+ foreach (nint buffer in capabilityBuffers) NativeMemory.Free((void*)buffer);
NativeMemory.Free(attributes.Value);
}
}
@@ -201,6 +238,34 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
int code = Marshal.GetLastWin32Error();
return new(code, $"{operation} failed with Win32 error {code}.");
}
+
+ private static SandboxPolicyException LpacFailure(string operation, string executable)
+ {
+ int code = Marshal.GetLastWin32Error();
+ return new("process.lpac_unsupported", $"{operation} failed with Win32 error {code}; no fallback is permitted.",
+ "process", executable, "execute", "Use a supported Windows 11 host and verify LPAC capability derivation and process-attribute support.");
+ }
+
+ private static (bool IsAppContainer, string? AppContainerSid, bool InJob) InspectConfinement(SafeFileHandle process, SafeFileHandle job)
+ {
+ if (!PInvoke.IsProcessInJob(process, job, out BOOL inJob)) throw LastError("IsProcessInJob");
+ if (!PInvoke.OpenProcessToken(process, TOKEN_ACCESS_MASK.TOKEN_QUERY, out SafeFileHandle token)) throw LastError("OpenProcessToken");
+ using (token)
+ {
+ Span<byte> flag = stackalloc byte[sizeof(uint)];
+ if (!PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenIsAppContainer, flag, out _))
+ throw LastError("GetTokenInformation(TokenIsAppContainer)");
+ bool isAppContainer = MemoryMarshal.Read<uint>(flag) != 0;
+ _ = PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenAppContainerSid, Span<byte>.Empty, out uint sidInformationLength);
+ if (sidInformationLength < IntPtr.Size) throw LastError("GetTokenInformation(TokenAppContainerSid size)");
+ Span<byte> sidInformation = sidInformationLength <= 256 ? stackalloc byte[(int)sidInformationLength] : new byte[sidInformationLength];
+ if (!PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenAppContainerSid, sidInformation, out _))
+ throw LastError("GetTokenInformation(TokenAppContainerSid)");
+ nint sidPointer = MemoryMarshal.Read<nint>(sidInformation);
+ string? sid = sidPointer == 0 ? null : new SecurityIdentifier(sidPointer).Value;
+ return (isAppContainer, sid, inJob);
+ }
+ }
private static FileStream CaptureFile()
{
string path = Path.Combine(Path.GetTempPath(), "agent-mcp-process-" + Guid.NewGuid().ToString("N") + ".tmp");
@@ -215,6 +280,20 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
Marshal.Copy(bytes, 0, (nint)buffer, bytes.Length);
return buffer;
}
+
+ private static SecurityIdentifier DeriveCapabilitySid(string name)
+ {
+ if (!PInvoke.DeriveCapabilitySidsFromName(name, out PSID* groupSids, out uint groupCount, out PSID* capabilitySids, out uint capabilityCount) || capabilityCount != 1)
+ throw LastError($"DeriveCapabilitySidsFromName({name})");
+ try { return new SecurityIdentifier((nint)capabilitySids[0].Value); }
+ finally
+ {
+ for (uint i = 0; i < groupCount; i++) _ = PInvoke.LocalFree(new HLOCAL((nint)groupSids[i].Value));
+ for (uint i = 0; i < capabilityCount; i++) _ = PInvoke.LocalFree(new HLOCAL((nint)capabilitySids[i].Value));
+ _ = PInvoke.LocalFree(new HLOCAL((nint)groupSids));
+ _ = PInvoke.LocalFree(new HLOCAL((nint)capabilitySids));
+ }
+ }
private static void MakeInheritable(SafeFileHandle handle)
{
if (!PInvoke.SetHandleInformation(handle, (uint)HANDLE_FLAGS.HANDLE_FLAG_INHERIT, HANDLE_FLAGS.HANDLE_FLAG_INHERIT))
@@ -230,6 +309,45 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
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 char[] BuildEnvironmentBlock(string cwd)
+ {
+ string systemRoot = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
+ var values = new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase)
+ {
+ ["ComSpec"] = Path.Combine(systemRoot, "System32", "cmd.exe"),
+ ["DOTNET_CLI_HOME"] = cwd,
+ ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1",
+ ["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0",
+ ["DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE"] = "true",
+ ["DOTNET_NOLOGO"] = "1",
+ ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1",
+ ["DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK"] = "true",
+ ["MSBuildEnableWorkloadResolver"] = "false",
+ ["MSBUILDDISABLENODEREUSE"] = "1",
+ ["NUMBER_OF_PROCESSORS"] = Environment.ProcessorCount.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ ["OS"] = "Windows_NT",
+ ["PATH"] = Path.Combine(systemRoot, "System32"),
+ ["PATHEXT"] = ".COM;.EXE;.BAT;.CMD",
+ ["PROCESSOR_ARCHITECTURE"] = RuntimeInformation.ProcessArchitecture.ToString().ToUpperInvariant(),
+ ["SystemDrive"] = Path.GetPathRoot(systemRoot)!.TrimEnd(Path.DirectorySeparatorChar),
+ ["SystemRoot"] = systemRoot,
+ ["TEMP"] = cwd,
+ ["TMP"] = cwd,
+ ["windir"] = systemRoot
+ };
+ foreach (string name in new[]
+ {
+ "ALLUSERSPROFILE", "APPDATA", "CommonProgramFiles", "CommonProgramFiles(x86)", "CommonProgramW6432",
+ "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "ProgramData", "ProgramFiles", "ProgramFiles(x86)",
+ "ProgramW6432", "PUBLIC", "USERDOMAIN", "USERNAME", "USERPROFILE"
+ })
+ {
+ string? value = Environment.GetEnvironmentVariable(name);
+ if (!string.IsNullOrEmpty(value)) values[name] = value;
+ }
+ string block = string.Concat(values.Select(pair => $"{pair.Key}={pair.Value}\0")) + "\0";
+ return block.ToCharArray();
+ }
private static string Quote(string value)
{
if (value.Length > 0 && !value.Any(static character => char.IsWhiteSpace(character) || character == '"')) return value;
diff --git a/src/Agent.Windows/Security/TaskAclManager.cs b/src/Agent.Windows/Security/TaskAclManager.cs
index 2b091a6..be0080d 100644
--- a/src/Agent.Windows/Security/TaskAclManager.cs
+++ b/src/Agent.Windows/Security/TaskAclManager.cs
@@ -10,27 +10,64 @@ public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurity
{
public AclSnapshot Grant(Guid taskId, string path, FileCapability capabilities)
{
- string canonical = CanonicalPath.Resolve(path);
+ string canonical = ResolvedPath.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);
+ FileSystemRights auditRights;
+ if (directory)
+ {
+ FileSystemRights directoryRights = ToDirectoryRights(capabilities);
+ FileSystemRights fileRights = ToFileRights(capabilities);
+ if (directoryRights == 0 || fileRights == 0) throw new ArgumentOutOfRangeException(nameof(capabilities));
+ security.AddAccessRule(new FileSystemAccessRule(appContainerSid, directoryRights,
+ InheritanceFlags.ContainerInherit, PropagationFlags.None, AccessControlType.Allow));
+ security.AddAccessRule(new FileSystemAccessRule(appContainerSid, fileRights,
+ InheritanceFlags.ObjectInherit, PropagationFlags.InheritOnly, AccessControlType.Allow));
+ auditRights = directoryRights | fileRights;
+ }
+ else
+ {
+ auditRights = ToFileRights(capabilities);
+ if (auditRights == 0) throw new ArgumentOutOfRangeException(nameof(capabilities));
+ security.AddAccessRule(new FileSystemAccessRule(appContainerSid, auditRights,
+ InheritanceFlags.None, PropagationFlags.None, AccessControlType.Allow));
+ }
+ try
+ {
+ WriteSecurity(canonical, security);
+ if (journalEntry is not null) journal!.MarkApplied(journalEntry.Value);
+ }
+ catch (Exception applyFailure)
+ {
+ try
+ {
+ byte[] current = ReadSecurity(canonical).GetSecurityDescriptorBinaryForm();
+ if (!current.AsSpan().SequenceEqual(original))
+ {
+ FileSystemSecurity rollback = Directory.Exists(canonical) ? new DirectorySecurity() : new FileSecurity();
+ rollback.SetSecurityDescriptorBinaryForm(original, AccessControlSections.Access);
+ WriteSecurity(canonical, rollback);
+ byte[] restored = ReadSecurity(canonical).GetSecurityDescriptorBinaryForm();
+ if (!restored.AsSpan().SequenceEqual(original)) throw new InvalidOperationException("ACL rollback verification failed.");
+ }
+ if (journalEntry is not null) journal!.MarkCleaned(journalEntry.Value);
+ }
+ catch (Exception rollbackFailure)
+ {
+ throw new AggregateException("ACL apply failed and immediate rollback did not reach a verified clean state.", applyFailure, rollbackFailure);
+ }
+ throw;
+ }
audit.Write(new(DateTimeOffset.UtcNow, taskId, "acl.apply", "applied", new Dictionary<string, string>
{
["path"] = canonical,
["sid"] = appContainerSid.Value,
- ["rights"] = rights.ToString(),
+ ["rights"] = auditRights.ToString(),
["scope"] = directory ? "root_inheritance" : "file"
}));
return new(canonical, original, journalEntry);
@@ -55,12 +92,23 @@ public sealed class TaskAclManager(SecurityIdentifier appContainerSid, ISecurity
else FileSystemAclExtensions.SetAccessControl(new FileInfo(path), (FileSecurity)security);
}
- private static FileSystemRights ToRights(FileCapability capabilities)
+ private static FileSystemRights ToFileRights(FileCapability capabilities)
{
FileSystemRights rights = 0;
- if ((capabilities & FileCapability.Read) != 0) rights |= FileSystemRights.ReadAndExecute | FileSystemRights.ListDirectory;
+ if ((capabilities & FileCapability.Read) != 0)
+ rights |= FileSystemRights.ReadData | FileSystemRights.ReadAttributes | FileSystemRights.ReadExtendedAttributes | FileSystemRights.ReadPermissions;
if ((capabilities & FileCapability.Write) != 0) rights |= FileSystemRights.Write;
- if ((capabilities & FileCapability.Execute) != 0) rights |= FileSystemRights.ReadAndExecute;
+ if ((capabilities & FileCapability.Execute) != 0) rights |= FileSystemRights.ExecuteFile | FileSystemRights.ReadData | FileSystemRights.ReadAttributes;
+ if ((capabilities & FileCapability.Delete) != 0) rights |= FileSystemRights.Delete;
+ return rights;
+ }
+
+ private static FileSystemRights ToDirectoryRights(FileCapability capabilities)
+ {
+ FileSystemRights rights = FileSystemRights.Traverse | FileSystemRights.ReadAttributes | FileSystemRights.ReadPermissions;
+ if ((capabilities & FileCapability.Read) != 0) rights |= FileSystemRights.ListDirectory | FileSystemRights.ReadExtendedAttributes;
+ if ((capabilities & FileCapability.Write) != 0) rights |= FileSystemRights.CreateFiles | FileSystemRights.CreateDirectories |
+ FileSystemRights.WriteAttributes | FileSystemRights.WriteExtendedAttributes;
if ((capabilities & FileCapability.Delete) != 0) rights |= FileSystemRights.Delete | FileSystemRights.DeleteSubdirectoriesAndFiles;
return rights;
}
diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs
index 5cbf853..af4c906 100644
--- a/src/Agent.Windows/Tools/WindowsToolBackend.cs
+++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs
@@ -10,12 +10,13 @@ using Agent.Windows.Security;
namespace Agent.Windows.Tools;
-public sealed record WindowsSandboxSettings(
+public sealed record EffectiveSandboxPolicy(
IReadOnlyList<FilePolicyEntry> Files,
- IReadOnlyList<ExecutableApproval> Executables,
- bool AllowShell,
+ IReadOnlyList<ApprovedEntryExecutable> EntryExecutables,
+ IReadOnlyList<string> RuntimeRoots,
+ IReadOnlyList<string> ArtifactRoots,
SandboxJobLimits JobLimits,
- string NetworkMode,
+ string NetworkAccess,
IReadOnlyList<(string Host, IReadOnlyList<int> Ports)> NetworkAllowances,
string JournalDirectory,
string BackupDirectory,
@@ -23,17 +24,22 @@ public sealed record WindowsSandboxSettings(
long WholeReadLimitBytes,
int SearchResultLimit,
int TailLineLimit,
- int ProcessTimeoutSeconds = 300);
+ int ProcessTimeoutSeconds = 300,
+ string ResourceProfile = "normal",
+ IReadOnlyList<string>? BaselineCapabilities = null);
-public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null) : IInternalToolBackend
+public sealed class WindowsToolBackend(EffectiveSandboxPolicy settings, ISecurityAuditSink audit, ITaskUpdateHandler? taskUpdates = null) : IInternalToolBackend
{
private readonly ConcurrentDictionary<Guid, TaskContext> _contexts = new();
private readonly PathPolicy _paths = new(settings.Files);
- private readonly ExecutablePolicy _executables = new(settings.Executables, settings.AllowShell);
+ private readonly EntryExecutablePolicy _entryExecutables = new(settings.EntryExecutables, settings.RuntimeRoots, settings.ArtifactRoots);
private readonly ITaskUpdateHandler _taskUpdates = taskUpdates ?? new TaskGraphToolHandler();
public async Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken)
{
+ if (settings.BaselineCapabilities is not null &&
+ !settings.BaselineCapabilities.SequenceEqual(["registryRead", "lpacInstrumentation"], StringComparer.Ordinal))
+ throw new InvalidOperationException("Effective Sandbox Policy baseline capabilities do not match the fixed LPAC baseline.");
if (_contexts.Count != 0) throw new InvalidOperationException("Only one Task Resource scope may be active.");
AppContainerProfile profile = AppContainerProfile.EnsureFixed();
TaskResourceJournal journal = TaskResourceJournal.Create(settings.JournalDirectory, taskId);
@@ -42,23 +48,29 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
_taskUpdates.BeginTask(taskId, sessionId);
try
{
- foreach ((string path, FileCapability capabilities) in settings.Files
- .GroupBy(static entry => CanonicalPath.Resolve(entry.Path, false), StringComparer.OrdinalIgnoreCase)
- .Select(static group => (group.Key, group.Aggregate(FileCapability.None, static (value, item) => value | item.Capabilities))))
+ foreach ((string path, FileCapability capabilities, bool osRuntimeRoot) in settings.Files
+ .GroupBy(static entry => ResolvedPath.Resolve(entry.Path, false), StringComparer.OrdinalIgnoreCase)
+ .Select(static group => (group.Key,
+ group.Aggregate(FileCapability.None, static (value, item) => value | item.Capabilities),
+ group.All(static item => item.Origin == "os_runtime_root"))))
{
+ // System32 has a protected OS-owned DACL. Its existing LPAC access is verified by
+ // real process-tree tests; changing or unprotecting that DACL is forbidden.
+ if (osRuntimeRoot) continue;
// OS/runtime executables may already grant AppContainer execute through their protected system ACL.
// 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.
+ // constrained by LPAC access plus Resolved Path/SHA-256 Entry approval.
if (File.Exists(path) && capabilities == FileCapability.Execute) continue;
// 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"
+ IReadOnlyList<NetworkEndpoint> allowances = settings.NetworkAccess != "none"
? await NetworkAllowanceSnapshot.ResolveAsync(settings.NetworkAllowances, cancellationToken).ConfigureAwait(false)
: [];
- context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit, sessionId);
+ context.Network = TaskNetworkPolicy.Apply(journal, profile.Sid, allowances, audit, sessionId,
+ enableLoopback: settings.NetworkAccess != "none");
if (!_contexts.TryAdd(taskId, context)) throw new InvalidOperationException("Task context already exists.");
audit.Write(new(DateTimeOffset.UtcNow, taskId, "task.start", "running", new Dictionary<string, string>(), SessionId: sessionId));
}
@@ -82,7 +94,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
"file_search" => Task.FromResult(Search(arguments)),
"file_read" => Task.FromResult(Read(arguments)),
"file_edit" => Task.FromResult(Edit(context, arguments)),
- "process_run" => Task.FromResult(RunProcess(context, arguments)),
+ "process_run" => Task.FromResult(RunProcess(context, arguments, cancellationToken)),
"task_update" => Task.FromResult(_taskUpdates.Execute(taskId, arguments)),
"dir" => Task.FromResult(DirectoryOp(context, arguments)),
_ => throw new ToolValidationException($"Unknown internal tool '{toolName}'.")
@@ -128,7 +140,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
{
string root = arguments.GetProperty("root").GetString()!;
_paths.Demand(root, FileCapability.Read);
- string canonical = CanonicalPath.Resolve(root);
+ string canonical = ResolvedPath.Resolve(root);
string mode = arguments.GetProperty("mode").GetString()!;
string query = arguments.GetProperty("query").GetString()!;
int max = arguments.TryGetProperty("max_results", out JsonElement maxElement) ? maxElement.GetInt32() : 100;
@@ -164,10 +176,10 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
{
string path = arguments.GetProperty("path").GetString()!;
_paths.Demand(path, FileCapability.Read);
- string canonical = CanonicalPath.Resolve(path);
+ string canonical = ResolvedPath.Resolve(path);
string mode = arguments.GetProperty("mode").GetString()!;
FileInfo info = new(canonical);
- if (mode == "metadata") return Success(JsonSerializer.Serialize(new { path = canonical, info.Length, info.LastWriteTimeUtc, sha256 = ExecutablePolicy.ComputeSha256(canonical) }));
+ if (mode == "metadata") return Success(JsonSerializer.Serialize(new { path = canonical, info.Length, info.LastWriteTimeUtc, sha256 = EntryExecutablePolicy.ComputeSha256(canonical) }));
if (mode == "whole")
{
if (info.Length > settings.WholeReadLimitBytes) throw new IOException("Whole read exceeds configured limit; use range mode.");
@@ -245,7 +257,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
if (operation == "list")
{
_paths.Demand(path, FileCapability.Read);
- string canonical = CanonicalPath.Resolve(path);
+ string canonical = ResolvedPath.Resolve(path);
var entries = Directory.EnumerateFileSystemEntries(canonical, "*", SearchOption.TopDirectoryOnly)
.Select(Path.GetFullPath)
.ToArray();
@@ -254,20 +266,20 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
throw new ToolValidationException($"Unsupported dir operation '{operation}'.");
}
- private ToolExecutionResult RunProcess(TaskContext context, JsonElement arguments)
+ private ToolExecutionResult RunProcess(TaskContext context, JsonElement arguments, CancellationToken cancellationToken)
{
Guid taskId = context.Journal.TaskId;
string executable = arguments.GetProperty("executable").GetString()!;
string cwd = arguments.GetProperty("cwd").GetString()!;
- _paths.Demand(executable, FileCapability.Execute);
_paths.Demand(cwd, FileCapability.Read);
string[] args = arguments.GetProperty("args").EnumerateArray().Select(static item => item.GetString()!).ToArray();
- string? managedEntryPoint = ExecutablePolicy.ManagedEntryPoint(executable, args);
+ string? managedEntryPoint = _entryExecutables.ManagedEntryPoint(executable, args, cwd);
if (managedEntryPoint is not null) _paths.Demand(managedEntryPoint, FileCapability.Execute);
int seconds = ResolveProcessTimeout(arguments, settings.ProcessTimeoutSeconds);
string? stdin = arguments.TryGetProperty("stdin", out JsonElement stdinElement) && stdinElement.ValueKind == JsonValueKind.String ? stdinElement.GetString() : null;
- RestrictedProcessResult result = new RestrictedProcessLauncher(_executables, audit, AppContainerProfile.EnsureFixed().Sid, settings.JobLimits)
- .Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin, context.SessionId);
+ RestrictedProcessResult result = new RestrictedProcessLauncher(_entryExecutables, audit, AppContainerProfile.EnsureFixed().Sid, settings.JobLimits,
+ allowNetwork: settings.NetworkAccess != "none")
+ .Run(taskId, executable, args, cwd, TimeSpan.FromSeconds(seconds), stdin, context.SessionId, cancellationToken);
string output = $"Process exited with code {result.ExitCode} (PID {result.ProcessId}).\nstdout:\n{result.StandardOutput}\nstderr:\n{result.StandardError}";
return new(result.ExitCode == 0, output, true,
result.ExitCode == 0 ? null : $"process:{executable}:{result.ExitCode}");
@@ -278,7 +290,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
int seconds = arguments.TryGetProperty("timeout_seconds", out JsonElement timeout) ? timeout.GetInt32() : configuredTimeoutSeconds;
if (seconds <= 0) throw new ToolValidationException("timeout_seconds must be positive.");
if (seconds > configuredTimeoutSeconds)
- throw new ToolValidationException($"timeout_seconds exceeds configured sandbox.resources.process_timeout_seconds ({configuredTimeoutSeconds}).");
+ throw new ToolValidationException($"timeout_seconds exceeds configured sandbox.resources.execution_timeout_seconds ({configuredTimeoutSeconds}).");
return seconds;
}