summaryrefslogtreecommitdiff
path: root/src/Agent.App/Configuration/ConfigurationLoader.cs
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-11 21:44:53 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-11 21:44:53 +0900
commit6c3458a5ec37a5b69957ad17c84de58984698f25 (patch)
treebe5c5716e7488fcf1bc1a58de0ded0b9238f34cb /src/Agent.App/Configuration/ConfigurationLoader.cs
parentb5ccecca01007f2a2459265df6d749fbfd31aa84 (diff)
Audit and align v1 runtime design
Diffstat (limited to 'src/Agent.App/Configuration/ConfigurationLoader.cs')
-rw-r--r--src/Agent.App/Configuration/ConfigurationLoader.cs69
1 files changed, 68 insertions, 1 deletions
diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs
index 298a143..efb3db1 100644
--- a/src/Agent.App/Configuration/ConfigurationLoader.cs
+++ b/src/Agent.App/Configuration/ConfigurationLoader.cs
@@ -2,6 +2,7 @@ using System.Net;
using System.Text.RegularExpressions;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
+using YamlDotNet.RepresentationModel;
namespace Agent.App.Configuration;
@@ -17,10 +18,14 @@ public static partial class ConfigurationLoader
try
{
+ string yaml = File.ReadAllText(absolutePath);
+ string localPath = Path.Combine(Path.GetDirectoryName(absolutePath)!, "agent.local.yaml");
+ if (!string.Equals(absolutePath, localPath, StringComparison.OrdinalIgnoreCase) && File.Exists(localPath))
+ yaml = MergeYaml(yaml, File.ReadAllText(localPath));
var deserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.Build();
- AgentConfiguration configuration = deserializer.Deserialize<AgentConfiguration>(File.ReadAllText(absolutePath))
+ AgentConfiguration configuration = deserializer.Deserialize<AgentConfiguration>(yaml)
?? throw new ConfigurationException("Configuration document is empty.");
Validate(configuration, Path.GetDirectoryName(absolutePath)!);
return configuration;
@@ -60,6 +65,16 @@ public static partial class ConfigurationLoader
foreach (ExecutableApproval approval in value.Sandbox.Process.Approvals)
{
if (!Path.IsPathFullyQualified(approval.Path) || !Sha256Regex().IsMatch(approval.Sha256)) Throw("process approval requires an absolute path and 64 hexadecimal SHA-256 characters.");
+ string approvalPath = Path.GetFullPath(approval.Path);
+ bool executable = value.Sandbox.Filesystem.Execute.Any(entry => ContainsPath(baseDirectory, entry, approvalPath));
+ if (!executable) Throw($"sandbox.process.approvals path is not covered by sandbox.filesystem.execute: {approvalPath}. Add the path or an ancestor directory to sandbox.filesystem.execute.");
+ }
+ foreach (string executeEntry in value.Sandbox.Filesystem.Execute)
+ {
+ string executePath = Path.IsPathFullyQualified(executeEntry) ? Path.GetFullPath(executeEntry) : Resolve(baseDirectory, executeEntry);
+ if (File.Exists(executePath) && !value.Sandbox.Process.Approvals.Any(approval =>
+ string.Equals(Path.GetFullPath(approval.Path), executePath, StringComparison.OrdinalIgnoreCase)))
+ Throw($"sandbox.filesystem.execute file has no sandbox.process.approvals entry: {executePath}. Add canonical path and SHA-256 under sandbox.process.approvals.");
}
if (value.FileEdit.MemoryBackupLimitMb <= 0 || value.Limits.WholeReadBytes <= 0 || value.Limits.SearchMaxResults <= 0 || value.Limits.TailMaxLines <= 0 || value.Limits.ContextSummaryRatio is <= 0 or >= 1) Throw("file/context limits are invalid.");
if (value.Logging.MaxFileBytes <= 0 || value.Logging.RetainedFiles < 1) Throw("logging limits are invalid.");
@@ -81,6 +96,58 @@ public static partial class ConfigurationLoader
}
public static string Resolve(string baseDirectory, string path) => Path.GetFullPath(path, baseDirectory);
+
+ private static bool ContainsPath(string baseDirectory, string configuredPath, string target)
+ {
+ string allowed = Path.IsPathFullyQualified(configuredPath) ? Path.GetFullPath(configuredPath) : Resolve(baseDirectory, configuredPath);
+ if (string.Equals(allowed, target, StringComparison.OrdinalIgnoreCase)) return true;
+ string prefix = allowed.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
+ return target.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string MergeYaml(string baseYaml, string localYaml)
+ {
+ YamlNode baseRoot = LoadRoot(baseYaml, "base configuration");
+ YamlNode localRoot = LoadRoot(localYaml, "agent.local.yaml");
+ YamlNode merged = MergeNode(baseRoot, localRoot);
+ var stream = new YamlStream(new YamlDocument(merged));
+ using var writer = new StringWriter(System.Globalization.CultureInfo.InvariantCulture);
+ stream.Save(writer, assignAnchors: false);
+ return writer.ToString();
+ }
+
+ private static YamlNode LoadRoot(string yaml, string source)
+ {
+ var stream = new YamlStream();
+ using var reader = new StringReader(yaml);
+ stream.Load(reader);
+ if (stream.Documents.Count != 1) Throw($"{source} must contain exactly one YAML document.");
+ return stream.Documents[0].RootNode;
+ }
+
+ private static YamlNode MergeNode(YamlNode baseNode, YamlNode localNode)
+ {
+ if (baseNode is not YamlMappingNode baseMap || localNode is not YamlMappingNode localMap)
+ return Clone(localNode);
+
+ var result = (YamlMappingNode)Clone(baseMap);
+ foreach ((YamlNode key, YamlNode localValue) in localMap.Children)
+ {
+ YamlNode? existingKey = result.Children.Keys.FirstOrDefault(candidate => candidate is YamlScalarNode a && key is YamlScalarNode b && string.Equals(a.Value, b.Value, StringComparison.Ordinal));
+ if (existingKey is null) result.Add(Clone(key), Clone(localValue));
+ else result.Children[existingKey] = MergeNode(result.Children[existingKey], localValue);
+ }
+ return result;
+ }
+
+ private static YamlNode Clone(YamlNode node)
+ {
+ var stream = new YamlStream(new YamlDocument(node));
+ using var writer = new StringWriter(System.Globalization.CultureInfo.InvariantCulture);
+ stream.Save(writer, assignAnchors: false);
+ return LoadRoot(writer.ToString(), "merged configuration");
+ }
+
private static void Throw(string message) => throw new ConfigurationException(message);
[GeneratedRegex("^[0-9a-fA-F]{64}$", RegexOptions.CultureInvariant)]
private static partial Regex Sha256Regex();