diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-12 05:44:54 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-12 05:44:54 +0900 |
| commit | b56614c814b904c224315a11f9686f0d47abd3d3 (patch) | |
| tree | e6ae4feb852ea4492bf947eabba1e11ab157686a /src/Agent.App/Configuration | |
| parent | 98b6a2aeedd591058c1d81e401d24f3ded162926 (diff) | |
feat: revise sandbox usability around semantic policy and LPAC process trees
Rewrite the specification and design vocabulary around User Sandbox Configuration, Environment Resolution, Executable Approval, Effective Sandbox Policy, and Windows Enforcement. Move the Internal Tool catalog to the design without treating its count as a requirement, and update README, implementation planning, traceability, audit history, prompts, and the Python skill.
Introduce schema_version 1 profiles, workspaces, mounts, executable aliases, network modes, and resource presets. Add local override merging, resolution and approval stores with atomic user-only persistence, fail-closed fingerprints, localized diagnostics, and executable/sandbox management CLI commands.
Change Windows execution to LPAC entry approval with inherited descendant confinement. Separate read and execute ACLs, preserve protected OS DACLs, apply Job process/memory/timeout limits to the tree, enforce WFP network modes, prevent credential environment inheritance, disable out-of-tree MSBuild reuse, and harden transactional ACL/recovery cleanup.
Isolate planning prompts from execution instructions, reject split coding plans that cannot satisfy task-scoped evidence, preserve configured thinking, and prevent optional task_update failures from corrupting completion checks.
Add and update configuration, resolution, approval, LPAC, descendant, readonly/development, Registry/COM, WFP, recovery, .NET/Python/PowerShell process-tree, and Operational E2E coverage. Publish reproducible bundles while preserving deployment-local configuration, prompts, skills, logs, state, approvals, and resolutions.
Diffstat (limited to 'src/Agent.App/Configuration')
| -rw-r--r-- | src/Agent.App/Configuration/AgentConfiguration.cs | 31 | ||||
| -rw-r--r-- | src/Agent.App/Configuration/ConfigurationException.cs | 11 | ||||
| -rw-r--r-- | src/Agent.App/Configuration/ConfigurationLoader.cs | 86 |
3 files changed, 93 insertions, 35 deletions
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(); } |
