diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-13 19:15:01 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-13 19:15:01 +0900 |
| commit | 36ae1a4cba791ec633a81b38b1d8345af8487276 (patch) | |
| tree | 91cef96efac7eb8c2754658ce5af5181b1a15898 /src/Agent.App/Program.cs | |
| parent | 76fdcb43585a4c4d288c1026d318a0ddbe3e26ab (diff) | |
feat!: unify sandbox configuration and permission application
Replace the split executable approval and sandbox resolution workflows with schema_version 2 and a YAML-driven execution.entries model. Resolve workspace, mount, executable, runtime, network, and resource settings into one config-scoped Applied Configuration snapshot protected by a user-only DACL and written with atomic replacement.
Reduce the public CLI to implicit server startup, show, and apply. Remove serve/check, executable and sandbox subcommands, and configuration override flags including --config, --lang, --transport, --path, and --runtime-root. Add migration diagnostics for every removed schema and command surface, and keep server preflight mandatory for both stdio and HTTP.
Update effective-policy compilation to validate the applied fingerprint, resolved paths, and executable hashes before startup. Preserve fail-closed behavior while allowing execution entries and runtime roots inside a development workspace to inherit that workspace capability without creating an impossible narrower ACL boundary.
Rewrite the README around the consolidated permission matrix and document configuration, application, store location, startup, transport-specific elevation, Python 3.14, PowerShell, and cmd usage. Align specifications, design, implementation plan, traceability, audit report, localization, publish scripts, and operational verification with the breaking schema.
Verification: Release build completed with zero warnings and errors; Agent.Core.Tests 104/104 and Agent.Planner.Tests 10/10 passed; administrator Windows Security/Recovery 56/56 and README operational 19/19 passed with no skips; published HTTP MCP completed a cmd/curl/venv-Python task with network deny and zero ACL, WFP, journal, backup, or process residue.
Diffstat (limited to 'src/Agent.App/Program.cs')
| -rw-r--r-- | src/Agent.App/Program.cs | 247 |
1 files changed, 74 insertions, 173 deletions
diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs index b58c914..7372a13 100644 --- a/src/Agent.App/Program.cs +++ b/src/Agent.App/Program.cs @@ -12,7 +12,6 @@ using Agent.Core.Tools; using Agent.Windows.Recovery; using Agent.Windows.Security; using Agent.Windows.Tools; -using System.Text.Json; namespace Agent.App; @@ -27,40 +26,22 @@ internal static class Program ? Path.Combine(AppContext.BaseDirectory, "agent.yaml") : Path.GetFullPath(commandLine.ConfigPath); AgentConfiguration configuration = ConfigurationLoader.Load(configPath); - if (commandLine.Transport is not null) configuration.Mcp.Transport = commandLine.Transport; ConfigurationLoader.Validate(configuration, Path.GetDirectoryName(configPath)!); - string language = commandLine.Language ?? configuration.Application.Language ?? "en"; + string language = configuration.Application.Language ?? "en"; string baseDirectory = Path.GetDirectoryName(configPath)!; string localeDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Localization.Directory); LocaleCatalog locale = LocaleCatalog.Load(localeDirectory, language, configuration.Localization.Fallback); _ = FixedPrompt.Load(); string logDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Logging.Directory); - if (commandLine.Command is not (AppCommand.Serve or AppCommand.Check)) - return RunManagementCommand(commandLine, configuration, configPath, logDirectory, locale); + if (commandLine.Command is AppCommand.Apply or AppCommand.Show) + return RunUnifiedManagementCommand(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) + EffectiveSandboxPolicy effectiveSandboxPolicy = new AppliedSandboxConfiguration(configPath, configuration) .Compile(journalDirectory, backupDirectory); - if (commandLine.Command == AppCommand.Check) - { - WindowsPlatformCheckResult platform = WindowsPlatformCheck.Run(); - Console.Out.WriteLine($"administrator={platform.IsAdministrator.ToString().ToLowerInvariant()}"); - Console.Out.WriteLine($"bfe_engine_open={platform.BfeEngineOpen.ToString().ToLowerInvariant()}"); - Console.Out.WriteLine($"wfp_write_transaction={platform.WfpWriteTransaction.ToString().ToLowerInvariant()}"); - Console.Out.WriteLine($"wfp_filter_add_delete={platform.WfpFilterAddDelete.ToString().ToLowerInvariant()}"); - if (!platform.Success) - { - 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 }); - Console.Error.WriteLine(failure); - return 2; - } - 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); @@ -130,8 +111,6 @@ internal static class Program internal static string FormatStartupError(string[] args, Exception error) { string language = "en"; - for (int i = 0; i + 1 < args.Length; i++) - if (args[i] == "--lang") language = args[i + 1]; try { string directory = Path.Combine(AppContext.BaseDirectory, "config", "locales"); @@ -158,12 +137,7 @@ internal static class Program { 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", + "sandbox.configuration_not_applied" => "sandbox.error.configuration_not_applied", _ => null }; string localized = key is null ? error.Message : locale.Format(key, new Dictionary<string, object?> { ["target"] = error.Target ?? "-" }); @@ -171,6 +145,75 @@ internal static class Program return $"code={error.Code}; category={error.Category}; target={error.Target ?? "-"}; message={localized}; resolution={resolution}"; } + private static int RunUnifiedManagementCommand(CommandLine command, AgentConfiguration configuration, + string configPath, string logDirectory, LocaleCatalog locale) + { + var appliedConfiguration = new AppliedSandboxConfiguration(configPath, configuration); + AppliedConfigurationPreview preview = appliedConfiguration.Resolve(); + AppliedConfigurationDocument? applied; + try { applied = appliedConfiguration.LoadApplied(); } + catch (ConfigurationException error) when (command.Command == AppCommand.Apply) + { + applied = null; + Console.Out.WriteLine(locale.Format("apply.store_unreadable", new Dictionary<string, object?> { ["message"] = error.Message })); + } + bool current = appliedConfiguration.IsApplied(preview, applied); + PrintAppliedState(preview, applied, current, locale); + if (command.Command == AppCommand.Show) return 0; + + Console.Out.WriteLine("\n" + locale.Get(current ? "apply.no_changes" : "apply.replace_snapshot")); + ConfirmMutation(command.Yes, locale.Get("apply.prompt")); + UserOnlyDirectory.Ensure(logDirectory); + AppliedConfigurationDocument document = appliedConfiguration.Apply(); + var audit = new JsonLinesSecurityAuditSink(Path.Combine(logDirectory, "audit.jsonl")); + audit.Write(new(DateTimeOffset.UtcNow, Guid.Empty, "sandbox.configuration.apply", "applied", + new Dictionary<string, string> + { + ["config_fingerprint"] = document.ConfigFingerprint, + ["execution_entries"] = document.Execution.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["non_interactive"] = command.Yes.ToString() + })); + Console.Out.WriteLine("\n" + locale.Get("apply.success")); + return 0; + } + + private static void PrintAppliedState(AppliedConfigurationPreview preview, AppliedConfigurationDocument? applied, + bool current, LocaleCatalog locale) + { + Console.Out.WriteLine(locale.Format("show.configuration", new Dictionary<string, object?> { ["path"] = preview.ConfigPath })); + Console.Out.WriteLine(locale.Format("show.applied", new Dictionary<string, object?> { ["value"] = locale.Get(current ? "show.yes" : "show.no") })); + Console.Out.WriteLine(locale.Format("show.fingerprint", new Dictionary<string, object?> { ["value"] = locale.Get(applied is null ? "show.not_applied" : current ? "show.matched" : "show.changed") })); + Console.Out.WriteLine(locale.Get("show.platform")); + Console.Out.WriteLine("\n" + locale.Get("show.filesystem")); + Console.Out.WriteLine(locale.Get("show.filesystem_columns")); + foreach (ResolutionItem item in preview.Filesystem) + Console.Out.WriteLine($"{item.Kind}\t{item.LogicalPath}\t{item.ResolvedPath}\t{item.Access}\t{locale.Get("show.status." + FilesystemStatus(item, applied))}"); + foreach (ResolutionItem item in applied?.Filesystem.Except(preview.Filesystem) ?? []) + Console.Out.WriteLine($"{item.Kind}\t{item.LogicalPath}\t{item.ResolvedPath}\t{item.Access}\t{locale.Get("show.status.removed")}"); + Console.Out.WriteLine("\n" + locale.Get("show.execution")); + Console.Out.WriteLine(locale.Get("show.execution_columns")); + foreach (AppliedExecutionEntry item in preview.Execution) + Console.Out.WriteLine($"{item.Alias}\t{item.Classification}\t{item.ResolvedPath}\t{item.ResolvedRuntimeRoot ?? "-"}\t{item.Sha256}\t{locale.Get("show.status." + ExecutionStatus(item, applied))}"); + foreach (AppliedExecutionEntry item in applied?.Execution.Where(old => preview.Execution.All(now => !now.Alias.Equals(old.Alias, StringComparison.OrdinalIgnoreCase))) ?? []) + Console.Out.WriteLine($"{item.Alias}\t{item.Classification}\t{item.ResolvedPath}\t{item.ResolvedRuntimeRoot ?? "-"}\t{item.Sha256}\t{locale.Get("show.status.removed")}"); + Console.Out.WriteLine("\n" + locale.Get("show.network")); + string networkStatus = applied is not null && AppliedSandboxConfiguration.NetworkEquivalent(applied.Network, preview.Network) ? "active" : applied is null ? "added" : "changed"; + Console.Out.WriteLine($"access={preview.Network.Access} endpoints={string.Join(',', preview.Network.Endpoints.Select(item => $"{item.Host}:{string.Join('/', item.Ports)}"))} status={locale.Get("show.status." + networkStatus)}"); + Console.Out.WriteLine("\n" + locale.Get("show.resources")); + string resourceStatus = applied?.Resources == preview.Resources ? "active" : applied is null ? "added" : "changed"; + Console.Out.WriteLine($"profile={preview.Resources.Profile} memory_mb={preview.Resources.MemoryMb} process_limit={preview.Resources.ProcessLimit} timeout_seconds={preview.Resources.ExecutionTimeoutSeconds} status={locale.Get("show.status." + resourceStatus)}"); + } + + private static string FilesystemStatus(ResolutionItem item, AppliedConfigurationDocument? applied) => + applied is null ? "added" : applied.Filesystem.Contains(item) ? "active" : "changed"; + + private static string ExecutionStatus(AppliedExecutionEntry item, AppliedConfigurationDocument? applied) + { + if (applied is null) return "added"; + AppliedExecutionEntry? previous = applied.Execution.FirstOrDefault(old => old.Alias.Equals(item.Alias, StringComparison.OrdinalIgnoreCase)); + return previous is null ? "added" : previous == item ? "active" : "changed"; + } + private static InternalToolDispatcher CreateToolDispatcher(AgentConfiguration configuration, EffectiveSandboxPolicy effectiveSandboxPolicy, string logDirectory, SessionStore? sessionStore = null) { Directory.CreateDirectory(effectiveSandboxPolicy.JournalDirectory); @@ -197,109 +240,6 @@ internal static class Program 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; @@ -308,43 +248,4 @@ internal static class Program 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"] - }; } |
