diff options
Diffstat (limited to 'src/Agent.App/Program.cs')
| -rw-r--r-- | src/Agent.App/Program.cs | 225 |
1 files changed, 190 insertions, 35 deletions
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"] + }; } |
