summaryrefslogtreecommitdiff
path: root/src/Agent.App/Program.cs
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-13 19:15:01 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-13 19:15:01 +0900
commit36ae1a4cba791ec633a81b38b1d8345af8487276 (patch)
tree91cef96efac7eb8c2754658ce5af5181b1a15898 /src/Agent.App/Program.cs
parent76fdcb43585a4c4d288c1026d318a0ddbe3e26ab (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.cs247
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"]
- };
}