summaryrefslogtreecommitdiff
path: root/src/Agent.App/Program.cs
diff options
context:
space:
mode:
Diffstat (limited to 'src/Agent.App/Program.cs')
-rw-r--r--src/Agent.App/Program.cs225
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"]
+ };
}