From b56614c814b904c224315a11f9686f0d47abd3d3 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Wed, 12 Aug 2026 05:44:54 +0900 Subject: 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. --- src/Agent.App/Program.cs | 225 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 190 insertions(+), 35 deletions(-) (limited to 'src/Agent.App/Program.cs') 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 { ["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 { ["message"] = error.Message }); + string message = error is ConfigurationException configuration + ? FormatConfigurationError(locale, configuration) + : error.Message; + return locale.Format(key, new Dictionary { ["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 { ["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(); - 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)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 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 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 or --no-runtime when approving an Executable Alias."); + IReadOnlyList 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 + { + ["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 { ["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 { ["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 { ["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 { ["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 + { ["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 + { ["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"] + }; } -- cgit v1.2.3