release orchestrator v1 draft and build fixes

This commit is contained in:
master
2026-01-12 12:24:17 +02:00
parent f3de858c59
commit 9873f80830
1598 changed files with 240385 additions and 5944 deletions

View File

@@ -0,0 +1,282 @@
using System.Text.Json;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using StellaOps.Plugin.Abstractions.Health;
namespace StellaOps.Plugin.Sandbox.Communication;
/// <summary>
/// gRPC-based plugin bridge implementation.
/// </summary>
public sealed class GrpcPluginBridge : IGrpcPluginBridge
{
private readonly ILogger<GrpcPluginBridge> _logger;
private readonly JsonSerializerOptions _jsonOptions;
private GrpcChannel? _channel;
private PluginBridge.PluginBridgeClient? _client;
private bool _disposed;
/// <inheritdoc />
public bool IsConnected => _channel != null && _channel.State == ConnectivityState.Ready;
/// <summary>
/// Creates a new gRPC plugin bridge.
/// </summary>
public GrpcPluginBridge(ILogger<GrpcPluginBridge> logger)
{
_logger = logger;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}
/// <inheritdoc />
public async Task ConnectAsync(string socketPath, CancellationToken ct)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var address = GetAddress(socketPath);
_logger.LogDebug("Connecting to plugin at {Address}", address);
var handler = new SocketsHttpHandler
{
ConnectCallback = async (context, token) =>
{
if (OperatingSystem.IsWindows())
{
// Named pipe connection
var pipeStream = new System.IO.Pipes.NamedPipeClientStream(
".",
socketPath.Replace("\\\\.\\pipe\\", ""),
System.IO.Pipes.PipeDirection.InOut,
System.IO.Pipes.PipeOptions.Asynchronous);
await pipeStream.ConnectAsync(token);
return pipeStream;
}
else
{
// Unix socket connection
var socket = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.Unix,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Unspecified);
var endpoint = new System.Net.Sockets.UnixDomainSocketEndPoint(socketPath);
await socket.ConnectAsync(endpoint, token);
return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true);
}
}
};
_channel = GrpcChannel.ForAddress(address, new GrpcChannelOptions
{
HttpHandler = handler,
DisposeHttpClient = true
});
_client = new PluginBridge.PluginBridgeClient(_channel);
// Wait for connection
await _channel.ConnectAsync(ct);
_logger.LogDebug("Connected to plugin at {Address}", address);
}
/// <inheritdoc />
public async Task DisconnectAsync(CancellationToken ct)
{
if (_channel != null)
{
await _channel.ShutdownAsync();
_channel.Dispose();
_channel = null;
_client = null;
}
}
/// <inheritdoc />
public async Task InitializePluginAsync(
Abstractions.Manifest.PluginManifest manifest,
CancellationToken ct)
{
EnsureConnected();
var manifestJson = JsonSerializer.Serialize(manifest, _jsonOptions);
var configJson = string.Empty; // Configuration is handled separately
var request = new InitializeRequest
{
ManifestJson = manifestJson,
ConfigJson = configJson,
SandboxId = string.Empty // Will be set by the bridge
};
var response = await _client!.InitializeAsync(request, cancellationToken: ct);
if (!response.Success)
{
throw new InvalidOperationException(
$"Plugin initialization failed: {response.Error}");
}
_logger.LogDebug("Plugin {PluginId} initialized successfully", manifest.Info.Id);
}
/// <inheritdoc />
public async Task ShutdownPluginAsync(CancellationToken ct)
{
EnsureConnected();
var request = new ShutdownRequest
{
TimeoutMs = 30000,
Reason = "Host requested shutdown"
};
var response = await _client!.ShutdownAsync(request, cancellationToken: ct);
if (!response.Success)
{
_logger.LogWarning("Plugin shutdown reported failure: {Message}", response.Error);
}
}
/// <inheritdoc />
public async Task<T> InvokeAsync<T>(
string operationName,
object? parameters,
CancellationToken ct)
{
EnsureConnected();
var request = new InvokeRequest
{
Operation = operationName,
CorrelationId = Guid.NewGuid().ToString("N")
};
if (parameters != null)
{
request.ParametersJson = JsonSerializer.Serialize(parameters, _jsonOptions);
}
var response = await _client!.InvokeAsync(request, cancellationToken: ct);
if (!response.Success)
{
throw new InvalidOperationException(
$"Operation '{operationName}' failed: {response.Error}");
}
if (string.IsNullOrEmpty(response.ResultJson))
{
return default!;
}
return JsonSerializer.Deserialize<T>(response.ResultJson, _jsonOptions)!;
}
/// <inheritdoc />
public async IAsyncEnumerable<TEvent> InvokeStreamingAsync<TEvent>(
string operationName,
object? parameters,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
EnsureConnected();
var request = new InvokeRequest
{
Operation = operationName,
CorrelationId = Guid.NewGuid().ToString("N")
};
if (parameters != null)
{
request.ParametersJson = JsonSerializer.Serialize(parameters, _jsonOptions);
}
using var call = _client!.InvokeStreaming(request, cancellationToken: ct);
await foreach (var evt in call.ResponseStream.ReadAllAsync(ct))
{
if (!string.IsNullOrEmpty(evt.PayloadJson))
{
yield return JsonSerializer.Deserialize<TEvent>(evt.PayloadJson, _jsonOptions)!;
}
}
}
/// <inheritdoc />
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
{
EnsureConnected();
var request = new HealthCheckRequest
{
IncludeDetails = true
};
var response = await _client!.HealthCheckAsync(request, cancellationToken: ct);
var status = response.Status.ToLowerInvariant() switch
{
"healthy" => HealthStatus.Healthy,
"degraded" => HealthStatus.Degraded,
"unhealthy" => HealthStatus.Unhealthy,
_ => HealthStatus.Unknown
};
var details = new Dictionary<string, object>();
if (!string.IsNullOrEmpty(response.DetailsJson))
{
var parsed = JsonSerializer.Deserialize<Dictionary<string, object>>(
response.DetailsJson, _jsonOptions);
if (parsed != null)
{
foreach (var kvp in parsed)
{
details[kvp.Key] = kvp.Value;
}
}
}
return new HealthCheckResult(
status,
response.Message,
TimeSpan.FromMilliseconds(response.DurationMs),
details);
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_channel?.Dispose();
}
private void EnsureConnected()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_client == null)
{
throw new InvalidOperationException("Not connected to plugin");
}
}
private static string GetAddress(string socketPath)
{
// gRPC requires an http:// address even for Unix sockets
// The actual connection is handled by the custom handler
return "http://localhost";
}
}

View File

@@ -0,0 +1,68 @@
using StellaOps.Plugin.Abstractions.Health;
using StellaOps.Plugin.Abstractions.Manifest;
namespace StellaOps.Plugin.Sandbox.Communication;
/// <summary>
/// Interface for gRPC communication with sandboxed plugins.
/// </summary>
public interface IGrpcPluginBridge : IDisposable
{
/// <summary>
/// Connect to the plugin host at the specified address.
/// </summary>
/// <param name="address">Socket path or address to connect to.</param>
/// <param name="ct">Cancellation token.</param>
Task ConnectAsync(string address, CancellationToken ct);
/// <summary>
/// Disconnect from the plugin host.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task DisconnectAsync(CancellationToken ct);
/// <summary>
/// Initialize the plugin with its manifest.
/// </summary>
/// <param name="manifest">Plugin manifest.</param>
/// <param name="ct">Cancellation token.</param>
Task InitializePluginAsync(PluginManifest manifest, CancellationToken ct);
/// <summary>
/// Request graceful shutdown of the plugin.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task ShutdownPluginAsync(CancellationToken ct);
/// <summary>
/// Perform a health check on the plugin.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Health check result.</returns>
Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct);
/// <summary>
/// Invoke an operation on the plugin.
/// </summary>
/// <typeparam name="T">Result type.</typeparam>
/// <param name="operationName">Name of the operation.</param>
/// <param name="parameters">Operation parameters.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Operation result.</returns>
Task<T> InvokeAsync<T>(string operationName, object? parameters, CancellationToken ct);
/// <summary>
/// Invoke a streaming operation on the plugin.
/// </summary>
/// <typeparam name="TEvent">Event type.</typeparam>
/// <param name="operationName">Name of the operation.</param>
/// <param name="parameters">Operation parameters.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Async enumerable of events.</returns>
IAsyncEnumerable<TEvent> InvokeStreamingAsync<TEvent>(string operationName, object? parameters, CancellationToken ct);
/// <summary>
/// Whether currently connected to the plugin.
/// </summary>
bool IsConnected { get; }
}

View File

@@ -0,0 +1,200 @@
syntax = "proto3";
package stellaops.plugin.bridge;
option csharp_namespace = "StellaOps.Plugin.Sandbox.Communication";
// Service for communicating with sandboxed plugins.
service PluginBridge {
// Initialize the plugin with its manifest and configuration.
rpc Initialize(InitializeRequest) returns (InitializeResponse);
// Request graceful shutdown of the plugin.
rpc Shutdown(ShutdownRequest) returns (ShutdownResponse);
// Perform a health check on the plugin.
rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
// Invoke a plugin operation.
rpc Invoke(InvokeRequest) returns (InvokeResponse);
// Invoke a streaming plugin operation.
rpc InvokeStreaming(InvokeRequest) returns (stream StreamingEvent);
// Stream log entries from the plugin.
rpc StreamLogs(LogStreamRequest) returns (stream LogEntry);
// Get plugin capabilities.
rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse);
}
// Request to initialize a plugin.
message InitializeRequest {
// JSON-serialized plugin manifest.
string manifest_json = 1;
// JSON-serialized plugin configuration.
string config_json = 2;
// Sandbox ID for correlation.
string sandbox_id = 3;
}
// Response from plugin initialization.
message InitializeResponse {
// Whether initialization succeeded.
bool success = 1;
// Error message if initialization failed.
string error = 2;
// Plugin version.
string version = 3;
// Capabilities provided by the plugin.
repeated string capabilities = 4;
}
// Request for graceful shutdown.
message ShutdownRequest {
// Maximum time in milliseconds to wait for shutdown.
int32 timeout_ms = 1;
// Reason for shutdown.
string reason = 2;
}
// Response from shutdown request.
message ShutdownResponse {
// Whether shutdown was successful.
bool success = 1;
// Error message if shutdown failed.
string error = 2;
}
// Request for health check.
message HealthCheckRequest {
// Whether to include detailed diagnostics.
bool include_details = 1;
}
// Response from health check.
message HealthCheckResponse {
// Health status: healthy, degraded, unhealthy.
string status = 1;
// Optional message describing the status.
string message = 2;
// Duration of the health check in milliseconds.
int32 duration_ms = 3;
// JSON-serialized additional details.
string details_json = 4;
}
// Request to invoke a plugin operation.
message InvokeRequest {
// Name of the operation to invoke.
string operation = 1;
// JSON-serialized operation parameters.
string parameters_json = 2;
// Maximum time in milliseconds for the operation.
int32 timeout_ms = 3;
// Correlation ID for tracking.
string correlation_id = 4;
}
// Response from an operation invocation.
message InvokeResponse {
// Whether the operation succeeded.
bool success = 1;
// JSON-serialized result.
string result_json = 2;
// Error message if operation failed.
string error = 3;
// Error code if operation failed.
string error_code = 4;
// Execution time in milliseconds.
int32 execution_time_ms = 5;
}
// Event from a streaming operation.
message StreamingEvent {
// Type of event.
string event_type = 1;
// JSON-serialized event payload.
string payload_json = 2;
// Unix timestamp in milliseconds.
int64 timestamp_unix_ms = 3;
// Sequence number for ordering.
int64 sequence = 4;
}
// Request to stream logs.
message LogStreamRequest {
// Minimum log level to stream.
string min_level = 1;
// Maximum number of historical entries to include.
int32 history_count = 2;
}
// A log entry from the plugin.
message LogEntry {
// Unix timestamp in milliseconds.
int64 timestamp_unix_ms = 1;
// Log level.
string level = 2;
// Log message.
string message = 3;
// JSON-serialized structured properties.
string properties_json = 4;
// Logger category/name.
string category = 5;
// Exception details if present.
string exception = 6;
}
// Request to get plugin capabilities.
message GetCapabilitiesRequest {}
// Response with plugin capabilities.
message GetCapabilitiesResponse {
// List of capability types provided.
repeated CapabilityInfo capabilities = 1;
}
// Information about a single capability.
message CapabilityInfo {
// Capability type.
string type = 1;
// Capability ID.
string id = 2;
// JSON-serialized capability configuration schema.
string config_schema_json = 3;
// JSON-serialized capability input schema.
string input_schema_json = 4;
// JSON-serialized capability output schema.
string output_schema_json = 5;
}

View File

@@ -0,0 +1,106 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using StellaOps.Plugin.Sandbox.Communication;
using StellaOps.Plugin.Sandbox.Network;
using StellaOps.Plugin.Sandbox.Process;
using StellaOps.Plugin.Sandbox.Resources;
namespace StellaOps.Plugin.Sandbox.Extensions;
/// <summary>
/// Extension methods for registering sandbox services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds plugin sandbox services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">Optional configuration for plugin process manager.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddPluginSandbox(
this IServiceCollection services,
Action<PluginProcessManagerOptions>? configureOptions = null)
{
// Configure options
if (configureOptions != null)
{
services.Configure(configureOptions);
}
else
{
services.Configure<PluginProcessManagerOptions>(_ => { });
}
// Register TimeProvider if not already registered
services.TryAddSingleton(TimeProvider.System);
// Register process manager
services.AddSingleton<IPluginProcessManager, PluginProcessManager>();
// Register network policy enforcer
services.AddSingleton<INetworkPolicyEnforcer, NetworkPolicyEnforcer>();
// Register resource limiter (platform-specific)
services.AddSingleton<IResourceLimiter>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
if (OperatingSystem.IsWindows())
{
return new WindowsResourceLimiter(
loggerFactory.CreateLogger<WindowsResourceLimiter>());
}
else
{
return new LinuxResourceLimiter(
loggerFactory.CreateLogger<LinuxResourceLimiter>());
}
});
// Register sandbox factory
services.AddSingleton<ISandboxFactory, SandboxFactory>();
// Register gRPC bridge as transient (one per sandbox)
services.AddTransient<IGrpcPluginBridge, GrpcPluginBridge>();
return services;
}
/// <summary>
/// Adds plugin sandbox services with custom resource limiter.
/// </summary>
/// <typeparam name="TResourceLimiter">The resource limiter type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddPluginSandbox<TResourceLimiter>(
this IServiceCollection services)
where TResourceLimiter : class, IResourceLimiter
{
services.AddPluginSandbox();
// Replace resource limiter registration
services.AddSingleton<IResourceLimiter, TResourceLimiter>();
return services;
}
/// <summary>
/// Adds plugin sandbox services with custom network policy enforcer.
/// </summary>
/// <typeparam name="TNetworkEnforcer">The network policy enforcer type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddPluginSandboxWithNetworkEnforcer<TNetworkEnforcer>(
this IServiceCollection services)
where TNetworkEnforcer : class, INetworkPolicyEnforcer
{
services.AddPluginSandbox();
// Replace network enforcer registration
services.AddSingleton<INetworkPolicyEnforcer, TNetworkEnforcer>();
return services;
}
}

View File

@@ -0,0 +1,169 @@
namespace StellaOps.Plugin.Sandbox.Filesystem;
/// <summary>
/// Interface for filesystem access control in sandboxes.
/// </summary>
public interface IFilesystemPolicy
{
/// <summary>
/// Check if read access is allowed to a path.
/// </summary>
/// <param name="path">Path to check.</param>
/// <returns>True if read access is allowed.</returns>
bool CanRead(string path);
/// <summary>
/// Check if write access is allowed to a path.
/// </summary>
/// <param name="path">Path to check.</param>
/// <returns>True if write access is allowed.</returns>
bool CanWrite(string path);
/// <summary>
/// Check if a path is blocked.
/// </summary>
/// <param name="path">Path to check.</param>
/// <returns>True if the path is blocked.</returns>
bool IsBlocked(string path);
/// <summary>
/// Get the sandboxed working directory.
/// </summary>
/// <returns>Path to the sandbox working directory.</returns>
string GetWorkingDirectory();
/// <summary>
/// Get the current write usage in bytes.
/// </summary>
/// <returns>Total bytes written.</returns>
long GetWriteUsage();
/// <summary>
/// Check if write quota is exceeded.
/// </summary>
/// <returns>True if write quota is exceeded.</returns>
bool IsWriteQuotaExceeded();
}
/// <summary>
/// Implementation of filesystem policy enforcement.
/// </summary>
public sealed class SandboxedFilesystem : IFilesystemPolicy
{
private readonly FilesystemPolicy _policy;
private readonly string _workingDirectory;
private long _bytesWritten;
private readonly object _lock = new();
/// <summary>
/// Creates a new sandboxed filesystem policy.
/// </summary>
/// <param name="policy">Policy configuration.</param>
/// <param name="workingDirectory">Working directory for the sandbox.</param>
public SandboxedFilesystem(FilesystemPolicy policy, string workingDirectory)
{
_policy = policy;
_workingDirectory = workingDirectory;
}
/// <inheritdoc />
public bool CanRead(string path)
{
var normalizedPath = Path.GetFullPath(path);
if (IsBlocked(normalizedPath))
return false;
// Allow reads from working directory
if (normalizedPath.StartsWith(_workingDirectory, StringComparison.OrdinalIgnoreCase))
return true;
// Allow reads from explicitly allowed read-only paths
foreach (var readOnlyPath in _policy.ReadOnlyPaths)
{
if (normalizedPath.StartsWith(Path.GetFullPath(readOnlyPath), StringComparison.OrdinalIgnoreCase))
return true;
}
// Allow reads from writable paths
foreach (var writablePath in _policy.WritablePaths)
{
if (normalizedPath.StartsWith(Path.GetFullPath(writablePath), StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <inheritdoc />
public bool CanWrite(string path)
{
var normalizedPath = Path.GetFullPath(path);
if (IsBlocked(normalizedPath))
return false;
if (IsWriteQuotaExceeded())
return false;
// Allow writes to working directory
if (normalizedPath.StartsWith(_workingDirectory, StringComparison.OrdinalIgnoreCase))
return true;
// Allow writes to explicitly writable paths
foreach (var writablePath in _policy.WritablePaths)
{
if (normalizedPath.StartsWith(Path.GetFullPath(writablePath), StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <inheritdoc />
public bool IsBlocked(string path)
{
var normalizedPath = Path.GetFullPath(path);
foreach (var blockedPath in _policy.BlockedPaths)
{
if (normalizedPath.StartsWith(Path.GetFullPath(blockedPath), StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <inheritdoc />
public string GetWorkingDirectory() => _workingDirectory;
/// <inheritdoc />
public long GetWriteUsage()
{
lock (_lock)
{
return _bytesWritten;
}
}
/// <inheritdoc />
public bool IsWriteQuotaExceeded()
{
lock (_lock)
{
return _bytesWritten >= _policy.MaxWriteBytes;
}
}
/// <summary>
/// Record bytes written.
/// </summary>
/// <param name="bytes">Number of bytes written.</param>
public void RecordWrite(long bytes)
{
lock (_lock)
{
_bytesWritten += bytes;
}
}
}

View File

@@ -0,0 +1,85 @@
using StellaOps.Plugin.Abstractions.Health;
using StellaOps.Plugin.Abstractions.Manifest;
using StellaOps.Plugin.Sandbox.Resources;
namespace StellaOps.Plugin.Sandbox;
/// <summary>
/// Provides isolated execution environment for untrusted plugins.
/// </summary>
public interface ISandbox : IAsyncDisposable
{
/// <summary>
/// Unique identifier for this sandbox instance.
/// </summary>
string Id { get; }
/// <summary>
/// Current sandbox state.
/// </summary>
SandboxState State { get; }
/// <summary>
/// Current resource usage statistics.
/// </summary>
ResourceUsage CurrentUsage { get; }
/// <summary>
/// Start the sandbox and load the plugin.
/// </summary>
/// <param name="manifest">Plugin manifest to load.</param>
/// <param name="ct">Cancellation token.</param>
Task StartAsync(PluginManifest manifest, CancellationToken ct);
/// <summary>
/// Stop the sandbox gracefully within the specified timeout.
/// </summary>
/// <param name="timeout">Maximum time to wait for graceful shutdown.</param>
/// <param name="ct">Cancellation token.</param>
Task StopAsync(TimeSpan timeout, CancellationToken ct);
/// <summary>
/// Execute an operation in the sandbox and return the result.
/// </summary>
/// <typeparam name="T">Result type.</typeparam>
/// <param name="operationName">Name of the operation to execute.</param>
/// <param name="parameters">Operation parameters.</param>
/// <param name="timeout">Maximum time to wait for the operation.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The operation result.</returns>
Task<T> ExecuteAsync<T>(
string operationName,
object? parameters,
TimeSpan timeout,
CancellationToken ct);
/// <summary>
/// Execute a streaming operation in the sandbox.
/// </summary>
/// <typeparam name="TEvent">Event type for the stream.</typeparam>
/// <param name="operationName">Name of the operation to execute.</param>
/// <param name="parameters">Operation parameters.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Async enumerable of events.</returns>
IAsyncEnumerable<TEvent> ExecuteStreamingAsync<TEvent>(
string operationName,
object? parameters,
CancellationToken ct);
/// <summary>
/// Perform health check on the sandboxed plugin.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Health check result.</returns>
Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct);
/// <summary>
/// Event raised when sandbox state changes.
/// </summary>
event EventHandler<SandboxStateChangedEventArgs>? StateChanged;
/// <summary>
/// Event raised when resource limits are approached.
/// </summary>
event EventHandler<ResourceWarningEventArgs>? ResourceWarning;
}

View File

@@ -0,0 +1,27 @@
namespace StellaOps.Plugin.Sandbox;
/// <summary>
/// Factory for creating sandbox instances.
/// </summary>
public interface ISandboxFactory
{
/// <summary>
/// Create a new sandbox with the specified configuration.
/// </summary>
/// <param name="configuration">Sandbox configuration.</param>
/// <returns>A new sandbox instance.</returns>
ISandbox Create(SandboxConfiguration configuration);
/// <summary>
/// Create a new sandbox with default configuration.
/// </summary>
/// <returns>A new sandbox instance with default settings.</returns>
ISandbox CreateDefault();
/// <summary>
/// Create a sandbox with configuration appropriate for the given trust level.
/// </summary>
/// <param name="trustLevel">Trust level of the plugin.</param>
/// <returns>A sandbox configured for the trust level.</returns>
ISandbox CreateForTrustLevel(Abstractions.PluginTrustLevel trustLevel);
}

View File

@@ -0,0 +1,38 @@
namespace StellaOps.Plugin.Sandbox.Network;
/// <summary>
/// Interface for enforcing network policies on sandboxes.
/// </summary>
public interface INetworkPolicyEnforcer
{
/// <summary>
/// Apply a network policy to a sandbox.
/// </summary>
/// <param name="sandboxId">Sandbox identifier.</param>
/// <param name="policy">Network policy to apply.</param>
/// <param name="ct">Cancellation token.</param>
Task ApplyPolicyAsync(string sandboxId, NetworkPolicy policy, CancellationToken ct);
/// <summary>
/// Remove network policy from a sandbox.
/// </summary>
/// <param name="sandboxId">Sandbox identifier.</param>
/// <param name="ct">Cancellation token.</param>
Task RemovePolicyAsync(string sandboxId, CancellationToken ct);
/// <summary>
/// Check if a connection is allowed by the policy.
/// </summary>
/// <param name="sandboxId">Sandbox identifier.</param>
/// <param name="host">Target host.</param>
/// <param name="port">Target port.</param>
/// <returns>True if the connection is allowed.</returns>
bool IsAllowed(string sandboxId, string host, int port);
/// <summary>
/// Get the current policy for a sandbox.
/// </summary>
/// <param name="sandboxId">Sandbox identifier.</param>
/// <returns>The active policy, or null if not found.</returns>
NetworkPolicy? GetPolicy(string sandboxId);
}

View File

@@ -0,0 +1,378 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace StellaOps.Plugin.Sandbox.Network;
/// <summary>
/// Network policy enforcer implementation.
/// Uses iptables on Linux, Windows Firewall on Windows.
/// </summary>
public sealed class NetworkPolicyEnforcer : INetworkPolicyEnforcer
{
private readonly ILogger<NetworkPolicyEnforcer> _logger;
private readonly ConcurrentDictionary<string, NetworkPolicy> _policies = new();
private readonly ConcurrentDictionary<string, List<string>> _ruleIds = new();
/// <summary>
/// Creates a new network policy enforcer.
/// </summary>
public NetworkPolicyEnforcer(ILogger<NetworkPolicyEnforcer> logger)
{
_logger = logger;
}
/// <inheritdoc />
public async Task ApplyPolicyAsync(string sandboxId, NetworkPolicy policy, CancellationToken ct)
{
_policies[sandboxId] = policy;
var ruleIds = new List<string>();
try
{
if (OperatingSystem.IsLinux())
{
await ApplyLinuxPolicyAsync(sandboxId, policy, ruleIds, ct);
}
else if (OperatingSystem.IsWindows())
{
await ApplyWindowsPolicyAsync(sandboxId, policy, ruleIds, ct);
}
else
{
_logger.LogWarning(
"Network policy enforcement not supported on this platform for sandbox {SandboxId}",
sandboxId);
}
_ruleIds[sandboxId] = ruleIds;
_logger.LogInformation(
"Applied network policy to sandbox {SandboxId}: AllowedHosts={AllowedHosts}, BlockedPorts={BlockedPorts}",
sandboxId,
policy.AllowedHosts.Count,
policy.BlockedPorts.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to apply network policy to sandbox {SandboxId}", sandboxId);
throw;
}
}
/// <inheritdoc />
public async Task RemovePolicyAsync(string sandboxId, CancellationToken ct)
{
_policies.TryRemove(sandboxId, out _);
if (!_ruleIds.TryRemove(sandboxId, out var ruleIds))
return;
try
{
if (OperatingSystem.IsLinux())
{
await RemoveLinuxRulesAsync(ruleIds, ct);
}
else if (OperatingSystem.IsWindows())
{
await RemoveWindowsRulesAsync(ruleIds, ct);
}
_logger.LogDebug("Removed network policy for sandbox {SandboxId}", sandboxId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to remove network policy for sandbox {SandboxId}", sandboxId);
}
}
/// <inheritdoc />
public bool IsAllowed(string sandboxId, string host, int port)
{
if (!_policies.TryGetValue(sandboxId, out var policy))
return true; // No policy = allow all
// Check blocked ports
if (policy.BlockedPorts.Contains(port))
return false;
// Check if network is disabled
if (!policy.AllowAllHosts && policy.AllowedHosts.Count == 0)
return false;
// If no allowed hosts specified, allow all (except blocked)
if (policy.AllowedHosts.Count == 0)
return true;
// Check allowed hosts
foreach (var pattern in policy.AllowedHosts)
{
if (MatchesHostPattern(host, pattern))
return true;
}
return false;
}
/// <inheritdoc />
public NetworkPolicy? GetPolicy(string sandboxId)
{
return _policies.GetValueOrDefault(sandboxId);
}
private async Task ApplyLinuxPolicyAsync(
string sandboxId,
NetworkPolicy policy,
List<string> ruleIds,
CancellationToken ct)
{
var chainName = $"STELLAOPS_{SanitizeForChain(sandboxId)}";
// Create custom chain
await RunCommandAsync("iptables", $"-N {chainName}", ct, ignoreError: true);
ruleIds.Add($"chain:{chainName}");
if (!policy.AllowAllHosts && policy.AllowedHosts.Count == 0)
{
// Block all outbound traffic
await RunCommandAsync("iptables",
$"-A {chainName} -j DROP",
ct);
return;
}
// Block specific ports
foreach (var port in policy.BlockedPorts)
{
await RunCommandAsync("iptables",
$"-A {chainName} -p tcp --dport {port} -j DROP",
ct);
await RunCommandAsync("iptables",
$"-A {chainName} -p udp --dport {port} -j DROP",
ct);
}
// Allow specific hosts (if specified)
if (policy.AllowedHosts.Count > 0)
{
// Default drop, then allow specific
foreach (var host in policy.AllowedHosts)
{
if (TryResolveHost(host, out var ip))
{
await RunCommandAsync("iptables",
$"-A {chainName} -d {ip} -j ACCEPT",
ct);
}
}
await RunCommandAsync("iptables",
$"-A {chainName} -j DROP",
ct);
}
else
{
// Allow all (except blocked ports above)
await RunCommandAsync("iptables",
$"-A {chainName} -j ACCEPT",
ct);
}
// Add jump to custom chain from OUTPUT
await RunCommandAsync("iptables",
$"-A OUTPUT -j {chainName}",
ct);
ruleIds.Add($"jump:{chainName}");
}
private async Task RemoveLinuxRulesAsync(List<string> ruleIds, CancellationToken ct)
{
foreach (var ruleId in ruleIds.AsEnumerable().Reverse())
{
if (ruleId.StartsWith("jump:", StringComparison.Ordinal))
{
var chainName = ruleId[5..];
await RunCommandAsync("iptables",
$"-D OUTPUT -j {chainName}",
ct, ignoreError: true);
}
else if (ruleId.StartsWith("chain:", StringComparison.Ordinal))
{
var chainName = ruleId[6..];
await RunCommandAsync("iptables",
$"-F {chainName}",
ct, ignoreError: true);
await RunCommandAsync("iptables",
$"-X {chainName}",
ct, ignoreError: true);
}
}
}
private async Task ApplyWindowsPolicyAsync(
string sandboxId,
NetworkPolicy policy,
List<string> ruleIds,
CancellationToken ct)
{
var rulePrefix = $"StellaOps_Sandbox_{SanitizeForRule(sandboxId)}";
if (!policy.AllowAllHosts && policy.AllowedHosts.Count == 0)
{
// Block all outbound
var ruleName = $"{rulePrefix}_BlockAll";
await RunCommandAsync("netsh",
$"advfirewall firewall add rule name=\"{ruleName}\" dir=out action=block",
ct);
ruleIds.Add(ruleName);
return;
}
// Block specific ports
var portIndex = 0;
foreach (var port in policy.BlockedPorts)
{
var ruleName = $"{rulePrefix}_BlockPort{portIndex++}";
await RunCommandAsync("netsh",
$"advfirewall firewall add rule name=\"{ruleName}\" dir=out action=block protocol=tcp remoteport={port}",
ct);
ruleIds.Add(ruleName);
ruleName = $"{rulePrefix}_BlockPort{portIndex++}";
await RunCommandAsync("netsh",
$"advfirewall firewall add rule name=\"{ruleName}\" dir=out action=block protocol=udp remoteport={port}",
ct);
ruleIds.Add(ruleName);
}
// Allow specific hosts (if specified)
if (policy.AllowedHosts.Count > 0)
{
var hostIndex = 0;
foreach (var host in policy.AllowedHosts)
{
if (TryResolveHost(host, out var ip))
{
var ruleName = $"{rulePrefix}_AllowHost{hostIndex++}";
await RunCommandAsync("netsh",
$"advfirewall firewall add rule name=\"{ruleName}\" dir=out action=allow remoteip={ip}",
ct);
ruleIds.Add(ruleName);
}
}
// Block all other
var blockRuleName = $"{rulePrefix}_BlockOther";
await RunCommandAsync("netsh",
$"advfirewall firewall add rule name=\"{blockRuleName}\" dir=out action=block",
ct);
ruleIds.Add(blockRuleName);
}
}
private async Task RemoveWindowsRulesAsync(List<string> ruleIds, CancellationToken ct)
{
foreach (var ruleName in ruleIds)
{
await RunCommandAsync("netsh",
$"advfirewall firewall delete rule name=\"{ruleName}\"",
ct, ignoreError: true);
}
}
private async Task RunCommandAsync(
string command,
string arguments,
CancellationToken ct,
bool ignoreError = false)
{
var startInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new System.Diagnostics.Process { StartInfo = startInfo };
try
{
process.Start();
await process.WaitForExitAsync(ct);
if (process.ExitCode != 0 && !ignoreError)
{
var error = await process.StandardError.ReadToEndAsync(ct);
_logger.LogWarning(
"Command '{Command} {Arguments}' failed with exit code {ExitCode}: {Error}",
command, arguments, process.ExitCode, error);
}
}
catch (Exception ex) when (ignoreError)
{
_logger.LogDebug(ex, "Command '{Command} {Arguments}' failed (ignored)", command, arguments);
}
}
private static bool MatchesHostPattern(string host, string pattern)
{
// Simple wildcard matching
if (pattern == "*")
return true;
if (pattern.StartsWith("*.", StringComparison.Ordinal))
{
var suffix = pattern[1..];
return host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
}
return string.Equals(host, pattern, StringComparison.OrdinalIgnoreCase);
}
private static bool TryResolveHost(string host, out string ip)
{
ip = string.Empty;
// Check if already an IP
if (IPAddress.TryParse(host, out var address))
{
ip = address.ToString();
return true;
}
// Skip wildcards
if (host.Contains('*'))
return false;
try
{
var addresses = Dns.GetHostAddresses(host);
if (addresses.Length > 0)
{
ip = addresses[0].ToString();
return true;
}
}
catch
{
// DNS resolution failed
}
return false;
}
private static string SanitizeForChain(string input)
{
return Regex.Replace(input, "[^a-zA-Z0-9_]", "_")[..Math.Min(input.Length, 20)];
}
private static string SanitizeForRule(string input)
{
return Regex.Replace(input, "[^a-zA-Z0-9_-]", "_");
}
}

View File

@@ -0,0 +1,80 @@
using StellaOps.Plugin.Sandbox.Resources;
using SystemProcess = System.Diagnostics.Process;
namespace StellaOps.Plugin.Sandbox.Process;
/// <summary>
/// Interface for managing plugin host processes.
/// </summary>
public interface IPluginProcessManager
{
/// <summary>
/// Start a new plugin host process.
/// </summary>
/// <param name="request">Process start request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The started process.</returns>
Task<SystemProcess> StartAsync(ProcessStartRequest request, CancellationToken ct);
/// <summary>
/// Stop a plugin host process gracefully.
/// </summary>
/// <param name="process">Process to stop.</param>
/// <param name="timeout">Maximum time to wait for graceful shutdown.</param>
/// <param name="ct">Cancellation token.</param>
Task StopAsync(SystemProcess process, TimeSpan timeout, CancellationToken ct);
/// <summary>
/// Kill a plugin host process immediately.
/// </summary>
/// <param name="process">Process to kill.</param>
Task KillAsync(SystemProcess process);
/// <summary>
/// Get the path to the plugin host executable.
/// </summary>
/// <returns>Path to the executable.</returns>
string GetHostExecutablePath();
}
/// <summary>
/// Request to start a plugin host process.
/// </summary>
public sealed record ProcessStartRequest
{
/// <summary>
/// Path to the plugin assembly.
/// </summary>
public required string PluginAssemblyPath { get; init; }
/// <summary>
/// Plugin entry point type name.
/// </summary>
public string? EntryPoint { get; init; }
/// <summary>
/// Working directory for the process.
/// </summary>
public required string WorkingDirectory { get; init; }
/// <summary>
/// Socket path for IPC communication.
/// </summary>
public required string SocketPath { get; init; }
/// <summary>
/// Resource configuration to apply.
/// </summary>
public required ResourceConfiguration ResourceConfiguration { get; init; }
/// <summary>
/// Environment variables to set.
/// </summary>
public IReadOnlyDictionary<string, string> EnvironmentVariables { get; init; } =
new Dictionary<string, string>();
/// <summary>
/// Arguments to pass to the host process.
/// </summary>
public IReadOnlyList<string> Arguments { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,251 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SystemProcess = System.Diagnostics.Process;
namespace StellaOps.Plugin.Sandbox.Process;
/// <summary>
/// Options for the plugin process manager.
/// </summary>
public sealed class PluginProcessManagerOptions
{
/// <summary>
/// Path to the plugin host executable.
/// If not specified, will look for StellaOps.Plugin.Host in the application directory.
/// </summary>
public string? HostExecutablePath { get; set; }
/// <summary>
/// Default timeout for graceful shutdown.
/// </summary>
public TimeSpan DefaultShutdownTimeout { get; set; } = TimeSpan.FromSeconds(30);
}
/// <summary>
/// Plugin process manager implementation.
/// </summary>
public sealed class PluginProcessManager : IPluginProcessManager
{
private readonly PluginProcessManagerOptions _options;
private readonly ILogger<PluginProcessManager> _logger;
/// <summary>
/// Creates a new plugin process manager.
/// </summary>
public PluginProcessManager(
IOptions<PluginProcessManagerOptions> options,
ILogger<PluginProcessManager> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public Task<SystemProcess> StartAsync(ProcessStartRequest request, CancellationToken ct)
{
var hostPath = GetHostExecutablePath();
if (!File.Exists(hostPath))
{
throw new FileNotFoundException(
$"Plugin host executable not found at {hostPath}");
}
var arguments = BuildArguments(request);
var startInfo = new ProcessStartInfo
{
FileName = hostPath,
Arguments = arguments,
WorkingDirectory = request.WorkingDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true
};
// Set environment variables
foreach (var kvp in request.EnvironmentVariables)
{
startInfo.Environment[kvp.Key] = kvp.Value;
}
// Add standard sandbox environment variables
startInfo.Environment["STELLAOPS_SANDBOX_MODE"] = "true";
startInfo.Environment["STELLAOPS_PLUGIN_SOCKET"] = request.SocketPath;
startInfo.Environment["STELLAOPS_PLUGIN_ASSEMBLY"] = request.PluginAssemblyPath;
if (!string.IsNullOrEmpty(request.EntryPoint))
{
startInfo.Environment["STELLAOPS_PLUGIN_ENTRYPOINT"] = request.EntryPoint;
}
var process = new SystemProcess { StartInfo = startInfo };
// Set up output handling
process.OutputDataReceived += (_, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
_logger.LogDebug("[Plugin:{Pid}] {Output}", process.Id, e.Data);
}
};
process.ErrorDataReceived += (_, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
_logger.LogWarning("[Plugin:{Pid}] {Error}", process.Id, e.Data);
}
};
try
{
if (!process.Start())
{
throw new InvalidOperationException("Failed to start plugin host process");
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
_logger.LogInformation(
"Started plugin host process {Pid} for assembly {Assembly}",
process.Id,
Path.GetFileName(request.PluginAssemblyPath));
return Task.FromResult(process);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start plugin host process");
process.Dispose();
throw;
}
}
/// <inheritdoc />
public async Task StopAsync(SystemProcess process, TimeSpan timeout, CancellationToken ct)
{
if (process.HasExited)
{
_logger.LogDebug("Process {Pid} already exited", process.Id);
return;
}
try
{
// Try graceful shutdown first via stdin
if (!process.StandardInput.BaseStream.CanWrite)
{
_logger.LogDebug("Cannot write to stdin for process {Pid}", process.Id);
}
else
{
await process.StandardInput.WriteLineAsync("SHUTDOWN");
await process.StandardInput.FlushAsync(ct);
}
// Wait for graceful exit
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
try
{
await process.WaitForExitAsync(timeoutCts.Token);
_logger.LogDebug("Process {Pid} exited gracefully with code {ExitCode}",
process.Id, process.ExitCode);
return;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
_logger.LogWarning("Process {Pid} did not exit gracefully within timeout", process.Id);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error during graceful shutdown of process {Pid}", process.Id);
}
// Force kill if still running
await KillAsync(process);
}
/// <inheritdoc />
public Task KillAsync(SystemProcess process)
{
if (process.HasExited)
return Task.CompletedTask;
try
{
process.Kill(entireProcessTree: true);
_logger.LogWarning("Killed process {Pid}", process.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to kill process {Pid}", process.Id);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public string GetHostExecutablePath()
{
if (!string.IsNullOrEmpty(_options.HostExecutablePath))
{
return _options.HostExecutablePath;
}
var appDir = AppContext.BaseDirectory;
var hostName = OperatingSystem.IsWindows()
? "StellaOps.Plugin.Host.exe"
: "StellaOps.Plugin.Host";
var hostPath = Path.Combine(appDir, hostName);
// Also check in tools subdirectory
if (!File.Exists(hostPath))
{
hostPath = Path.Combine(appDir, "tools", hostName);
}
return hostPath;
}
private static string BuildArguments(ProcessStartRequest request)
{
var args = new List<string>
{
"--assembly", QuoteArgument(request.PluginAssemblyPath),
"--socket", QuoteArgument(request.SocketPath),
"--workdir", QuoteArgument(request.WorkingDirectory)
};
if (!string.IsNullOrEmpty(request.EntryPoint))
{
args.Add("--entrypoint");
args.Add(QuoteArgument(request.EntryPoint));
}
// Add custom arguments
foreach (var arg in request.Arguments)
{
args.Add(arg);
}
return string.Join(" ", args);
}
private static string QuoteArgument(string arg)
{
if (arg.Contains(' ') || arg.Contains('"'))
{
return $"\"{arg.Replace("\"", "\\\"")}\"";
}
return arg;
}
}

View File

@@ -0,0 +1,473 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using StellaOps.Plugin.Abstractions.Health;
using StellaOps.Plugin.Abstractions.Manifest;
using StellaOps.Plugin.Sandbox.Communication;
using StellaOps.Plugin.Sandbox.Filesystem;
using StellaOps.Plugin.Sandbox.Network;
using StellaOps.Plugin.Sandbox.Process;
using StellaOps.Plugin.Sandbox.Resources;
using SystemProcess = System.Diagnostics.Process;
namespace StellaOps.Plugin.Sandbox;
/// <summary>
/// Process-based sandbox implementation for untrusted plugins.
/// </summary>
public sealed class ProcessSandbox : ISandbox
{
private readonly SandboxConfiguration _config;
private readonly IPluginProcessManager _processManager;
private readonly IGrpcPluginBridge _bridge;
private readonly IResourceLimiter _resourceLimiter;
private readonly INetworkPolicyEnforcer _networkEnforcer;
private readonly ILogger<ProcessSandbox> _logger;
private readonly TimeProvider _timeProvider;
private SystemProcess? _process;
private SandboxState _state = SandboxState.Created;
private ResourceUsage _currentUsage = ResourceUsage.Empty;
private CancellationTokenSource? _monitoringCts;
private Task? _monitoringTask;
private string? _workingDirectory;
/// <inheritdoc />
public string Id { get; }
/// <inheritdoc />
public SandboxState State => _state;
/// <inheritdoc />
public ResourceUsage CurrentUsage => _currentUsage;
/// <inheritdoc />
public event EventHandler<SandboxStateChangedEventArgs>? StateChanged;
/// <inheritdoc />
public event EventHandler<ResourceWarningEventArgs>? ResourceWarning;
/// <summary>
/// Creates a new process sandbox.
/// </summary>
public ProcessSandbox(
string id,
SandboxConfiguration config,
IPluginProcessManager processManager,
IGrpcPluginBridge bridge,
IResourceLimiter resourceLimiter,
INetworkPolicyEnforcer networkEnforcer,
ILogger<ProcessSandbox> logger,
TimeProvider timeProvider)
{
Id = id;
_config = config;
_processManager = processManager;
_bridge = bridge;
_resourceLimiter = resourceLimiter;
_networkEnforcer = networkEnforcer;
_logger = logger;
_timeProvider = timeProvider;
}
/// <inheritdoc />
public async Task StartAsync(PluginManifest manifest, CancellationToken ct)
{
TransitionState(SandboxState.Starting);
try
{
// 1. Create isolated working directory
_workingDirectory = PrepareWorkingDirectory(manifest);
// 2. Configure resource limits
var resourceConfig = _resourceLimiter.CreateConfiguration(_config.ResourceLimits);
// 3. Configure network policy
await _networkEnforcer.ApplyPolicyAsync(Id, _config.NetworkPolicy, ct);
// 4. Start the plugin host process
var socketPath = GetSocketPath();
_process = await _processManager.StartAsync(new ProcessStartRequest
{
PluginAssemblyPath = manifest.AssemblyPath!,
EntryPoint = manifest.EntryPoint,
WorkingDirectory = _workingDirectory,
SocketPath = socketPath,
ResourceConfiguration = resourceConfig,
EnvironmentVariables = _config.EnvironmentVariables
}, ct);
// 5. Apply resource limits to the process
await _resourceLimiter.ApplyLimitsAsync(_process, resourceConfig, ct);
// 6. Wait for the process to be ready and connect
await WaitForReadyAsync(socketPath, ct);
// 7. Initialize the plugin
await _bridge.InitializePluginAsync(manifest, ct);
// 8. Start resource monitoring
StartResourceMonitoring();
TransitionState(SandboxState.Running);
_logger.LogInformation("Sandbox {Id} started for plugin {PluginId}",
Id, manifest.Info.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start sandbox {Id}", Id);
TransitionState(SandboxState.Failed, ex.Message);
await CleanupAsync();
throw;
}
}
/// <inheritdoc />
public async Task StopAsync(TimeSpan timeout, CancellationToken ct)
{
if (_state is SandboxState.Stopped or SandboxState.Failed or SandboxState.Killed)
return;
TransitionState(SandboxState.Stopping);
try
{
// Stop monitoring
_monitoringCts?.Cancel();
if (_monitoringTask != null)
{
try { await _monitoringTask; } catch { /* Ignore */ }
}
// 1. Signal graceful shutdown via gRPC
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
try
{
if (_bridge.IsConnected)
{
await _bridge.ShutdownPluginAsync(timeoutCts.Token);
}
}
catch (OperationCanceledException)
{
_logger.LogWarning("Sandbox {Id} did not shutdown gracefully, killing", Id);
}
// 2. Disconnect bridge
await _bridge.DisconnectAsync(ct);
// 3. Stop the process
if (_process != null)
{
await _processManager.StopAsync(_process, timeout, ct);
}
// 4. Cleanup resources
await CleanupAsync();
TransitionState(SandboxState.Stopped);
_logger.LogInformation("Sandbox {Id} stopped", Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error stopping sandbox {Id}", Id);
TransitionState(SandboxState.Failed, ex.Message);
await CleanupAsync();
throw;
}
}
/// <inheritdoc />
public async Task<T> ExecuteAsync<T>(
string operationName,
object? parameters,
TimeSpan timeout,
CancellationToken ct)
{
EnsureRunning();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
try
{
return await _bridge.InvokeAsync<T>(operationName, parameters, timeoutCts.Token);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
{
throw new TimeoutException($"Operation '{operationName}' timed out after {timeout}");
}
}
/// <inheritdoc />
public async IAsyncEnumerable<TEvent> ExecuteStreamingAsync<TEvent>(
string operationName,
object? parameters,
[EnumeratorCancellation] CancellationToken ct)
{
EnsureRunning();
await foreach (var evt in _bridge.InvokeStreamingAsync<TEvent>(operationName, parameters, ct))
{
yield return evt;
}
}
/// <inheritdoc />
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
{
if (_state != SandboxState.Running)
{
return HealthCheckResult.Unhealthy($"Sandbox is in state {_state}");
}
try
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(_config.Timeouts.HealthCheckTimeout);
var result = await _bridge.HealthCheckAsync(timeoutCts.Token);
// Add resource usage to details
var details = new Dictionary<string, object>(result.Details ?? new Dictionary<string, object>())
{
["sandboxId"] = Id,
["memoryUsageMb"] = _currentUsage.MemoryUsageMb,
["cpuUsagePercent"] = _currentUsage.CpuUsagePercent
};
return result with { Details = details };
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(ex);
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_state == SandboxState.Running)
{
await StopAsync(_config.Timeouts.ShutdownTimeout, CancellationToken.None);
}
_bridge.Dispose();
_monitoringCts?.Dispose();
}
private void EnsureRunning()
{
if (_state != SandboxState.Running)
{
throw new InvalidOperationException($"Sandbox is not running (state: {_state})");
}
}
private void TransitionState(SandboxState newState, string? reason = null)
{
var oldState = _state;
_state = newState;
_logger.LogDebug("Sandbox {Id} state changed: {OldState} -> {NewState} ({Reason})",
Id, oldState, newState, reason ?? "N/A");
StateChanged?.Invoke(this, new SandboxStateChangedEventArgs
{
OldState = oldState,
NewState = newState,
Reason = reason
});
}
private string PrepareWorkingDirectory(PluginManifest manifest)
{
var workDir = _config.WorkingDirectory
?? Path.Combine(Path.GetTempPath(), "stellaops-sandbox", Id);
if (Directory.Exists(workDir))
Directory.Delete(workDir, recursive: true);
Directory.CreateDirectory(workDir);
// Copy plugin files to sandbox directory
if (!string.IsNullOrEmpty(manifest.AssemblyPath))
{
var pluginDir = Path.GetDirectoryName(manifest.AssemblyPath);
if (!string.IsNullOrEmpty(pluginDir) && Directory.Exists(pluginDir))
{
CopyDirectory(pluginDir, workDir);
}
}
return workDir;
}
private async Task CleanupAsync()
{
// Cleanup network policy
try
{
await _networkEnforcer.RemovePolicyAsync(Id, CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cleanup network policy for sandbox {Id}", Id);
}
// Cleanup resource limits
if (_process != null)
{
try
{
await _resourceLimiter.RemoveLimitsAsync(_process, CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cleanup resource limits for sandbox {Id}", Id);
}
}
// Cleanup working directory
CleanupWorkingDirectory();
}
private void CleanupWorkingDirectory()
{
var workDir = _workingDirectory
?? Path.Combine(Path.GetTempPath(), "stellaops-sandbox", Id);
if (Directory.Exists(workDir))
{
try
{
Directory.Delete(workDir, recursive: true);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cleanup sandbox directory {WorkDir}", workDir);
}
}
}
private string GetSocketPath()
{
if (OperatingSystem.IsWindows())
{
return $"\\\\.\\pipe\\stellaops-sandbox-{Id}";
}
else
{
return Path.Combine(Path.GetTempPath(), $"stellaops-sandbox-{Id}.sock");
}
}
private async Task WaitForReadyAsync(string socketPath, CancellationToken ct)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(_config.Timeouts.StartupTimeout);
while (!timeoutCts.IsCancellationRequested)
{
if (_process?.HasExited == true)
{
throw new InvalidOperationException(
$"Plugin process exited with code {_process.ExitCode}");
}
// Try to connect
try
{
await _bridge.ConnectAsync(socketPath, timeoutCts.Token);
return;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Not ready yet, wait and retry
await Task.Delay(100, timeoutCts.Token);
}
}
throw new TimeoutException("Plugin process did not become ready in time");
}
private void StartResourceMonitoring()
{
_monitoringCts = new CancellationTokenSource();
_monitoringTask = Task.Run(async () =>
{
while (!_monitoringCts.Token.IsCancellationRequested && _state == SandboxState.Running)
{
try
{
if (_process != null && !_process.HasExited)
{
_currentUsage = await _resourceLimiter.GetUsageAsync(_process, _monitoringCts.Token);
// Check thresholds
CheckResourceThreshold(ResourceType.Memory,
_currentUsage.MemoryUsageMb,
_config.ResourceLimits.MaxMemoryMb);
CheckResourceThreshold(ResourceType.Cpu,
_currentUsage.CpuUsagePercent,
_config.ResourceLimits.MaxCpuPercent);
// Check if limits exceeded
var limitCheck = await _resourceLimiter.CheckLimitsAsync(
_process, _config.ResourceLimits, _monitoringCts.Token);
if (limitCheck.IsExceeded)
{
_logger.LogWarning("Sandbox {Id} exceeded resource limit: {Message}",
Id, limitCheck.Message);
}
}
await Task.Delay(1000, _monitoringCts.Token);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error monitoring resources for sandbox {Id}", Id);
}
}
});
}
private void CheckResourceThreshold(ResourceType resource, double current, double max)
{
if (max <= 0) return;
var percent = (current / max) * 100;
if (percent >= 80)
{
ResourceWarning?.Invoke(this, new ResourceWarningEventArgs
{
Resource = resource,
CurrentUsagePercent = percent,
ThresholdPercent = 80
});
}
}
private static void CopyDirectory(string source, string destination)
{
foreach (var dir in Directory.GetDirectories(source, "*", SearchOption.AllDirectories))
{
Directory.CreateDirectory(dir.Replace(source, destination));
}
foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories))
{
File.Copy(file, file.Replace(source, destination), overwrite: true);
}
}
}

View File

@@ -0,0 +1,96 @@
using SystemProcess = System.Diagnostics.Process;
namespace StellaOps.Plugin.Sandbox.Resources;
/// <summary>
/// Interface for applying and monitoring resource limits on processes.
/// </summary>
public interface IResourceLimiter
{
/// <summary>
/// Create a resource configuration from the specified limits.
/// </summary>
/// <param name="limits">Resource limits to configure.</param>
/// <returns>Platform-specific resource configuration.</returns>
ResourceConfiguration CreateConfiguration(ResourceLimits limits);
/// <summary>
/// Apply resource limits to a process.
/// </summary>
/// <param name="process">Process to limit.</param>
/// <param name="config">Resource configuration.</param>
/// <param name="ct">Cancellation token.</param>
Task ApplyLimitsAsync(SystemProcess process, ResourceConfiguration config, CancellationToken ct);
/// <summary>
/// Get current resource usage for a process.
/// </summary>
/// <param name="process">Process to monitor.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Current resource usage.</returns>
Task<ResourceUsage> GetUsageAsync(SystemProcess process, CancellationToken ct);
/// <summary>
/// Remove resource limits from a process.
/// </summary>
/// <param name="process">Process to unlimit.</param>
/// <param name="ct">Cancellation token.</param>
Task RemoveLimitsAsync(SystemProcess process, CancellationToken ct);
/// <summary>
/// Check if the process has exceeded any limits.
/// </summary>
/// <param name="process">Process to check.</param>
/// <param name="limits">Limits to check against.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if any limit is exceeded.</returns>
Task<LimitCheckResult> CheckLimitsAsync(SystemProcess process, ResourceLimits limits, CancellationToken ct);
}
/// <summary>
/// Result of a limit check.
/// </summary>
public sealed record LimitCheckResult
{
/// <summary>
/// Whether any limit was exceeded.
/// </summary>
public required bool IsExceeded { get; init; }
/// <summary>
/// Which resource exceeded its limit, if any.
/// </summary>
public ResourceType? ExceededResource { get; init; }
/// <summary>
/// Current value of the exceeded resource.
/// </summary>
public double? CurrentValue { get; init; }
/// <summary>
/// Limit value that was exceeded.
/// </summary>
public double? LimitValue { get; init; }
/// <summary>
/// Message describing the exceeded limit.
/// </summary>
public string? Message { get; init; }
/// <summary>
/// Result indicating no limits were exceeded.
/// </summary>
public static LimitCheckResult Ok => new() { IsExceeded = false };
/// <summary>
/// Create a result for an exceeded limit.
/// </summary>
public static LimitCheckResult Exceeded(ResourceType resource, double current, double limit) => new()
{
IsExceeded = true,
ExceededResource = resource,
CurrentValue = current,
LimitValue = limit,
Message = $"{resource} limit exceeded: {current:F2} > {limit:F2}"
};
}

View File

@@ -0,0 +1,300 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using SystemProcess = System.Diagnostics.Process;
namespace StellaOps.Plugin.Sandbox.Resources;
/// <summary>
/// Linux cgroups v2 resource limiter implementation.
/// </summary>
public sealed class LinuxResourceLimiter : IResourceLimiter
{
private const string CgroupBasePath = "/sys/fs/cgroup";
private const string StellaOpsCgroupName = "stellaops-sandbox";
private readonly ILogger<LinuxResourceLimiter> _logger;
private readonly Dictionary<int, string> _processCgroups = new();
private readonly object _lock = new();
/// <summary>
/// Creates a new Linux resource limiter.
/// </summary>
public LinuxResourceLimiter(ILogger<LinuxResourceLimiter> logger)
{
_logger = logger;
}
/// <inheritdoc />
public ResourceConfiguration CreateConfiguration(ResourceLimits limits)
{
var cpuQuotaUs = limits.MaxCpuPercent > 0
? (long)(limits.MaxCpuPercent / 100.0 * 100_000)
: 0;
return new ResourceConfiguration
{
MemoryLimitBytes = limits.MaxMemoryMb * 1024 * 1024,
CpuQuotaUs = cpuQuotaUs,
CpuPeriodUs = 100_000,
MaxProcesses = limits.MaxProcesses,
MaxOpenFiles = limits.MaxOpenFiles
};
}
/// <inheritdoc />
public async Task ApplyLimitsAsync(
SystemProcess process,
ResourceConfiguration config,
CancellationToken ct)
{
if (!OperatingSystem.IsLinux())
{
_logger.LogWarning("LinuxResourceLimiter called on non-Linux platform");
return;
}
var cgroupPath = Path.Combine(CgroupBasePath, $"{StellaOpsCgroupName}-{process.Id}");
try
{
// Create cgroup directory
if (!Directory.Exists(cgroupPath))
{
Directory.CreateDirectory(cgroupPath);
}
// Configure memory limit
if (config.MemoryLimitBytes > 0)
{
await WriteControlFileAsync(
cgroupPath,
"memory.max",
config.MemoryLimitBytes.ToString(CultureInfo.InvariantCulture),
ct);
// Also set high watermark for throttling before kill
var highMark = (long)(config.MemoryLimitBytes * 0.9);
await WriteControlFileAsync(
cgroupPath,
"memory.high",
highMark.ToString(CultureInfo.InvariantCulture),
ct);
}
// Configure CPU limit
if (config.CpuQuotaUs > 0)
{
await WriteControlFileAsync(
cgroupPath,
"cpu.max",
$"{config.CpuQuotaUs} {config.CpuPeriodUs}",
ct);
}
// Configure process limit
if (config.MaxProcesses > 0)
{
await WriteControlFileAsync(
cgroupPath,
"pids.max",
config.MaxProcesses.ToString(CultureInfo.InvariantCulture),
ct);
}
// Add process to cgroup
await WriteControlFileAsync(
cgroupPath,
"cgroup.procs",
process.Id.ToString(CultureInfo.InvariantCulture),
ct);
lock (_lock)
{
_processCgroups[process.Id] = cgroupPath;
}
_logger.LogDebug(
"Applied cgroup limits to process {ProcessId}: Memory={MemoryBytes}B, CPU quota={CpuQuotaUs}us",
process.Id,
config.MemoryLimitBytes,
config.CpuQuotaUs);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to apply cgroup limits to process {ProcessId}", process.Id);
throw;
}
}
/// <inheritdoc />
public async Task RemoveLimitsAsync(SystemProcess process, CancellationToken ct)
{
if (!OperatingSystem.IsLinux())
return;
string? cgroupPath;
lock (_lock)
{
if (!_processCgroups.TryGetValue(process.Id, out cgroupPath))
return;
_processCgroups.Remove(process.Id);
}
try
{
// Move process to root cgroup first (if still running)
if (!process.HasExited)
{
await WriteControlFileAsync(
CgroupBasePath,
"cgroup.procs",
process.Id.ToString(CultureInfo.InvariantCulture),
ct);
}
// Remove cgroup directory
if (Directory.Exists(cgroupPath))
{
Directory.Delete(cgroupPath);
}
_logger.LogDebug("Removed cgroup for process {ProcessId}", process.Id);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cleanup cgroup for process {ProcessId}", process.Id);
}
}
/// <inheritdoc />
public async Task<ResourceUsage> GetUsageAsync(SystemProcess process, CancellationToken ct)
{
if (!OperatingSystem.IsLinux())
{
return GetFallbackUsage(process);
}
string? cgroupPath;
lock (_lock)
{
if (!_processCgroups.TryGetValue(process.Id, out cgroupPath))
{
return GetFallbackUsage(process);
}
}
try
{
// Read memory usage
var memoryCurrentStr = await ReadControlFileAsync(cgroupPath, "memory.current", ct);
var memoryCurrent = long.Parse(memoryCurrentStr.Trim(), CultureInfo.InvariantCulture);
// Calculate CPU percentage (simplified - would need time delta for accurate calculation)
var cpuPercent = 0.0; // Requires tracking over time
return new ResourceUsage
{
MemoryUsageMb = memoryCurrent / (1024.0 * 1024.0),
CpuUsagePercent = cpuPercent,
ProcessCount = process.Threads.Count,
OpenFileHandles = GetHandleCount(process),
Timestamp = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read cgroup stats for process {ProcessId}", process.Id);
return GetFallbackUsage(process);
}
}
/// <inheritdoc />
public async Task<LimitCheckResult> CheckLimitsAsync(
SystemProcess process,
ResourceLimits limits,
CancellationToken ct)
{
var usage = await GetUsageAsync(process, ct);
// Check memory
if (limits.MaxMemoryMb > 0 && usage.MemoryUsageMb > limits.MaxMemoryMb)
{
return LimitCheckResult.Exceeded(
ResourceType.Memory,
usage.MemoryUsageMb,
limits.MaxMemoryMb);
}
// Check CPU
if (limits.MaxCpuPercent > 0 && usage.CpuUsagePercent > limits.MaxCpuPercent)
{
return LimitCheckResult.Exceeded(
ResourceType.Cpu,
usage.CpuUsagePercent,
limits.MaxCpuPercent);
}
// Check processes
if (limits.MaxProcesses > 0 && usage.ProcessCount > limits.MaxProcesses)
{
return LimitCheckResult.Exceeded(
ResourceType.Cpu, // Use Cpu as proxy for process limits
usage.ProcessCount,
limits.MaxProcesses);
}
return LimitCheckResult.Ok;
}
private static async Task WriteControlFileAsync(
string cgroupPath,
string fileName,
string value,
CancellationToken ct)
{
var filePath = Path.Combine(cgroupPath, fileName);
await File.WriteAllTextAsync(filePath, value, ct);
}
private static async Task<string> ReadControlFileAsync(
string cgroupPath,
string fileName,
CancellationToken ct)
{
var filePath = Path.Combine(cgroupPath, fileName);
return await File.ReadAllTextAsync(filePath, ct);
}
private static ResourceUsage GetFallbackUsage(SystemProcess process)
{
try
{
process.Refresh();
return new ResourceUsage
{
MemoryUsageMb = process.WorkingSet64 / (1024.0 * 1024.0),
CpuUsagePercent = 0, // Can't easily get without tracking
ProcessCount = process.Threads.Count,
OpenFileHandles = GetHandleCount(process),
Timestamp = DateTimeOffset.UtcNow
};
}
catch
{
return ResourceUsage.Empty;
}
}
private static int GetHandleCount(SystemProcess process)
{
try
{
return process.HandleCount;
}
catch
{
return 0;
}
}
}

View File

@@ -0,0 +1,86 @@
namespace StellaOps.Plugin.Sandbox.Resources;
/// <summary>
/// Current resource usage statistics for a sandbox.
/// </summary>
public sealed record ResourceUsage
{
/// <summary>
/// Memory usage in megabytes.
/// </summary>
public double MemoryUsageMb { get; init; }
/// <summary>
/// CPU usage as a percentage.
/// </summary>
public double CpuUsagePercent { get; init; }
/// <summary>
/// Number of active processes/threads.
/// </summary>
public int ProcessCount { get; init; }
/// <summary>
/// Disk usage in bytes.
/// </summary>
public long DiskUsageBytes { get; init; }
/// <summary>
/// Network bytes received.
/// </summary>
public long NetworkBytesIn { get; init; }
/// <summary>
/// Network bytes sent.
/// </summary>
public long NetworkBytesOut { get; init; }
/// <summary>
/// Number of open file handles.
/// </summary>
public int OpenFileHandles { get; init; }
/// <summary>
/// Timestamp when this usage snapshot was taken.
/// </summary>
public DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Empty resource usage.
/// </summary>
public static ResourceUsage Empty => new()
{
Timestamp = DateTimeOffset.UtcNow
};
}
/// <summary>
/// Resource configuration for process limits.
/// </summary>
public sealed record ResourceConfiguration
{
/// <summary>
/// Memory limit in bytes.
/// </summary>
public long MemoryLimitBytes { get; init; }
/// <summary>
/// CPU quota in microseconds.
/// </summary>
public long CpuQuotaUs { get; init; }
/// <summary>
/// CPU period in microseconds.
/// </summary>
public long CpuPeriodUs { get; init; }
/// <summary>
/// Maximum number of processes.
/// </summary>
public int MaxProcesses { get; init; }
/// <summary>
/// Maximum number of open files.
/// </summary>
public int MaxOpenFiles { get; init; }
}

View File

@@ -0,0 +1,353 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Microsoft.Extensions.Logging;
using Microsoft.Win32.SafeHandles;
using SystemProcess = System.Diagnostics.Process;
namespace StellaOps.Plugin.Sandbox.Resources;
/// <summary>
/// Windows Job Object resource limiter implementation.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class WindowsResourceLimiter : IResourceLimiter, IDisposable
{
private readonly ILogger<WindowsResourceLimiter> _logger;
private readonly Dictionary<int, SafeFileHandle> _jobHandles = new();
private readonly object _lock = new();
private bool _disposed;
/// <summary>
/// Creates a new Windows resource limiter.
/// </summary>
public WindowsResourceLimiter(ILogger<WindowsResourceLimiter> logger)
{
_logger = logger;
}
/// <inheritdoc />
public ResourceConfiguration CreateConfiguration(ResourceLimits limits)
{
var cpuQuotaUs = limits.MaxCpuPercent > 0
? (long)(limits.MaxCpuPercent / 100.0 * 100_000)
: 0;
return new ResourceConfiguration
{
MemoryLimitBytes = limits.MaxMemoryMb * 1024 * 1024,
CpuQuotaUs = cpuQuotaUs,
CpuPeriodUs = 100_000,
MaxProcesses = limits.MaxProcesses,
MaxOpenFiles = limits.MaxOpenFiles
};
}
/// <inheritdoc />
public Task ApplyLimitsAsync(
SystemProcess process,
ResourceConfiguration config,
CancellationToken ct)
{
if (!OperatingSystem.IsWindows())
{
_logger.LogWarning("WindowsResourceLimiter called on non-Windows platform");
return Task.CompletedTask;
}
var jobName = $"StellaOps_Sandbox_{process.Id}";
try
{
// Create Job Object
var jobHandle = NativeMethods.CreateJobObject(IntPtr.Zero, jobName);
if (jobHandle.IsInvalid)
{
throw new InvalidOperationException(
$"Failed to create Job Object: {Marshal.GetLastWin32Error()}");
}
// Configure limits
var extendedInfo = new NativeMethods.JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
extendedInfo.BasicLimitInformation.LimitFlags = 0;
// Memory limit
if (config.MemoryLimitBytes > 0)
{
extendedInfo.ProcessMemoryLimit = (UIntPtr)config.MemoryLimitBytes;
extendedInfo.JobMemoryLimit = (UIntPtr)config.MemoryLimitBytes;
extendedInfo.BasicLimitInformation.LimitFlags |=
NativeMethods.JOB_OBJECT_LIMIT_PROCESS_MEMORY |
NativeMethods.JOB_OBJECT_LIMIT_JOB_MEMORY;
}
// Process limit
if (config.MaxProcesses > 0)
{
extendedInfo.BasicLimitInformation.ActiveProcessLimit = (uint)config.MaxProcesses;
extendedInfo.BasicLimitInformation.LimitFlags |=
NativeMethods.JOB_OBJECT_LIMIT_ACTIVE_PROCESS;
}
// Apply extended limits
var extendedInfoSize = Marshal.SizeOf<NativeMethods.JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
var success = NativeMethods.SetInformationJobObject(
jobHandle,
NativeMethods.JobObjectInfoType.ExtendedLimitInformation,
ref extendedInfo,
extendedInfoSize);
if (!success)
{
jobHandle.Dispose();
throw new InvalidOperationException(
$"Failed to set Job Object limits: {Marshal.GetLastWin32Error()}");
}
// Configure CPU rate control (Windows 8+)
if (config.CpuQuotaUs > 0)
{
var cpuRate = (uint)(config.CpuQuotaUs / (double)config.CpuPeriodUs * 10000);
var cpuInfo = new NativeMethods.JOBOBJECT_CPU_RATE_CONTROL_INFORMATION
{
ControlFlags = NativeMethods.JOB_OBJECT_CPU_RATE_CONTROL_ENABLE |
NativeMethods.JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP,
CpuRate = cpuRate
};
var cpuInfoSize = Marshal.SizeOf<NativeMethods.JOBOBJECT_CPU_RATE_CONTROL_INFORMATION>();
NativeMethods.SetInformationJobObject(
jobHandle,
NativeMethods.JobObjectInfoType.CpuRateControlInformation,
ref cpuInfo,
cpuInfoSize);
// CPU rate control may fail on older Windows versions - non-fatal
}
// Assign process to Job Object
success = NativeMethods.AssignProcessToJobObject(jobHandle, process.Handle);
if (!success)
{
jobHandle.Dispose();
throw new InvalidOperationException(
$"Failed to assign process to Job Object: {Marshal.GetLastWin32Error()}");
}
lock (_lock)
{
_jobHandles[process.Id] = jobHandle;
}
_logger.LogDebug(
"Applied Job Object limits to process {ProcessId}: Memory={MemoryBytes}B",
process.Id,
config.MemoryLimitBytes);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to apply Job Object limits to process {ProcessId}", process.Id);
throw;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RemoveLimitsAsync(SystemProcess process, CancellationToken ct)
{
if (!OperatingSystem.IsWindows())
return Task.CompletedTask;
SafeFileHandle? jobHandle;
lock (_lock)
{
if (!_jobHandles.TryGetValue(process.Id, out jobHandle))
return Task.CompletedTask;
_jobHandles.Remove(process.Id);
}
try
{
// Terminate job object (will terminate all processes in job)
NativeMethods.TerminateJobObject(jobHandle, 0);
jobHandle.Dispose();
_logger.LogDebug("Removed Job Object for process {ProcessId}", process.Id);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cleanup Job Object for process {ProcessId}", process.Id);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<ResourceUsage> GetUsageAsync(SystemProcess process, CancellationToken ct)
{
if (!OperatingSystem.IsWindows())
{
return Task.FromResult(ResourceUsage.Empty);
}
try
{
process.Refresh();
return Task.FromResult(new ResourceUsage
{
MemoryUsageMb = process.WorkingSet64 / (1024.0 * 1024.0),
CpuUsagePercent = GetCpuUsage(process),
ProcessCount = process.Threads.Count,
OpenFileHandles = process.HandleCount,
Timestamp = DateTimeOffset.UtcNow
});
}
catch
{
return Task.FromResult(ResourceUsage.Empty);
}
}
/// <inheritdoc />
public async Task<LimitCheckResult> CheckLimitsAsync(
SystemProcess process,
ResourceLimits limits,
CancellationToken ct)
{
var usage = await GetUsageAsync(process, ct);
// Check memory
if (limits.MaxMemoryMb > 0 && usage.MemoryUsageMb > limits.MaxMemoryMb)
{
return LimitCheckResult.Exceeded(
ResourceType.Memory,
usage.MemoryUsageMb,
limits.MaxMemoryMb);
}
// Check CPU
if (limits.MaxCpuPercent > 0 && usage.CpuUsagePercent > limits.MaxCpuPercent)
{
return LimitCheckResult.Exceeded(
ResourceType.Cpu,
usage.CpuUsagePercent,
limits.MaxCpuPercent);
}
return LimitCheckResult.Ok;
}
/// <summary>
/// Disposes resources.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
lock (_lock)
{
foreach (var handle in _jobHandles.Values)
{
handle.Dispose();
}
_jobHandles.Clear();
}
}
private static double GetCpuUsage(SystemProcess process)
{
// Simplified CPU usage - accurate measurement requires time-based sampling
try
{
return process.TotalProcessorTime.TotalMilliseconds /
(Environment.ProcessorCount * process.TotalProcessorTime.TotalMilliseconds + 1) * 100;
}
catch
{
return 0;
}
}
private static class NativeMethods
{
public const uint JOB_OBJECT_LIMIT_PROCESS_MEMORY = 0x00000100;
public const uint JOB_OBJECT_LIMIT_JOB_MEMORY = 0x00000200;
public const uint JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008;
public const uint JOB_OBJECT_CPU_RATE_CONTROL_ENABLE = 0x00000001;
public const uint JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP = 0x00000004;
public enum JobObjectInfoType
{
BasicLimitInformation = 2,
ExtendedLimitInformation = 9,
CpuRateControlInformation = 15
}
[StructLayout(LayoutKind.Sequential)]
public struct JOBOBJECT_BASIC_LIMIT_INFORMATION
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
public uint LimitFlags;
public UIntPtr MinimumWorkingSetSize;
public UIntPtr MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass;
public uint SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)]
public struct IO_COUNTERS
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
public ulong OtherOperationCount;
public ulong ReadTransferCount;
public ulong WriteTransferCount;
public ulong OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public UIntPtr ProcessMemoryLimit;
public UIntPtr JobMemoryLimit;
public UIntPtr PeakProcessMemoryUsed;
public UIntPtr PeakJobMemoryUsed;
}
[StructLayout(LayoutKind.Sequential)]
public struct JOBOBJECT_CPU_RATE_CONTROL_INFORMATION
{
public uint ControlFlags;
public uint CpuRate;
}
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern SafeFileHandle CreateJobObject(IntPtr lpJobAttributes, string? lpName);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool SetInformationJobObject(
SafeFileHandle hJob,
JobObjectInfoType infoType,
ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION lpJobObjectInfo,
int cbJobObjectInfoLength);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool SetInformationJobObject(
SafeFileHandle hJob,
JobObjectInfoType infoType,
ref JOBOBJECT_CPU_RATE_CONTROL_INFORMATION lpJobObjectInfo,
int cbJobObjectInfoLength);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool AssignProcessToJobObject(SafeFileHandle hJob, IntPtr hProcess);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool TerminateJobObject(SafeFileHandle hJob, uint uExitCode);
}
}

View File

@@ -0,0 +1,243 @@
namespace StellaOps.Plugin.Sandbox;
/// <summary>
/// Configuration for plugin sandbox.
/// </summary>
public sealed record SandboxConfiguration
{
/// <summary>
/// Resource limits for the sandbox.
/// </summary>
public required ResourceLimits ResourceLimits { get; init; }
/// <summary>
/// Network policy for the sandbox.
/// </summary>
public required NetworkPolicy NetworkPolicy { get; init; }
/// <summary>
/// Filesystem policy for the sandbox.
/// </summary>
public required FilesystemPolicy FilesystemPolicy { get; init; }
/// <summary>
/// Timeouts for sandbox operations.
/// </summary>
public required SandboxTimeouts Timeouts { get; init; }
/// <summary>
/// Whether to enable process isolation.
/// </summary>
public bool ProcessIsolation { get; init; } = true;
/// <summary>
/// Working directory for the sandbox. If null, a temporary directory is created.
/// </summary>
public string? WorkingDirectory { get; init; }
/// <summary>
/// Environment variables to pass to the sandbox.
/// </summary>
public IReadOnlyDictionary<string, string> EnvironmentVariables { get; init; } =
new Dictionary<string, string>();
/// <summary>
/// Whether to enable log streaming from the sandbox.
/// </summary>
public bool EnableLogStreaming { get; init; } = true;
/// <summary>
/// Default configuration for untrusted plugins.
/// </summary>
public static SandboxConfiguration Default => new()
{
ResourceLimits = new ResourceLimits
{
MaxMemoryMb = 512,
MaxCpuPercent = 25,
MaxDiskMb = 100,
MaxNetworkBandwidthMbps = 10
},
NetworkPolicy = new NetworkPolicy
{
AllowedHosts = new HashSet<string>(),
BlockedPorts = new HashSet<int> { 22, 3389, 5432, 27017, 6379 }
},
FilesystemPolicy = new FilesystemPolicy
{
ReadOnlyPaths = new List<string>(),
WritablePaths = new List<string>(),
BlockedPaths = new List<string> { "/etc", "/var", "/root", "C:\\Windows" }
},
Timeouts = new SandboxTimeouts
{
StartupTimeout = TimeSpan.FromSeconds(30),
OperationTimeout = TimeSpan.FromSeconds(60),
ShutdownTimeout = TimeSpan.FromSeconds(10),
HealthCheckTimeout = TimeSpan.FromSeconds(5)
}
};
/// <summary>
/// Configuration for trusted plugins with relaxed limits.
/// </summary>
public static SandboxConfiguration Trusted => new()
{
ResourceLimits = new ResourceLimits
{
MaxMemoryMb = 2048,
MaxCpuPercent = 50,
MaxDiskMb = 1024,
MaxNetworkBandwidthMbps = 100
},
NetworkPolicy = new NetworkPolicy
{
AllowedHosts = new HashSet<string>(),
BlockedPorts = new HashSet<int>(),
AllowAllHosts = true
},
FilesystemPolicy = new FilesystemPolicy
{
ReadOnlyPaths = new List<string>(),
WritablePaths = new List<string>(),
BlockedPaths = new List<string>()
},
Timeouts = new SandboxTimeouts
{
StartupTimeout = TimeSpan.FromSeconds(60),
OperationTimeout = TimeSpan.FromMinutes(5),
ShutdownTimeout = TimeSpan.FromSeconds(30),
HealthCheckTimeout = TimeSpan.FromSeconds(10)
},
ProcessIsolation = false
};
}
/// <summary>
/// Resource limits for sandbox execution.
/// </summary>
public sealed record ResourceLimits
{
/// <summary>
/// Maximum memory in megabytes.
/// </summary>
public int MaxMemoryMb { get; init; } = 512;
/// <summary>
/// Maximum CPU usage as a percentage.
/// </summary>
public int MaxCpuPercent { get; init; } = 25;
/// <summary>
/// Maximum disk usage in megabytes.
/// </summary>
public int MaxDiskMb { get; init; } = 100;
/// <summary>
/// Maximum network bandwidth in Mbps.
/// </summary>
public int MaxNetworkBandwidthMbps { get; init; } = 10;
/// <summary>
/// Maximum number of open files.
/// </summary>
public int MaxOpenFiles { get; init; } = 1000;
/// <summary>
/// Maximum number of processes/threads.
/// </summary>
public int MaxProcesses { get; init; } = 10;
}
/// <summary>
/// Network policy for sandbox execution.
/// </summary>
public sealed record NetworkPolicy
{
/// <summary>
/// Hosts that are explicitly allowed.
/// </summary>
public IReadOnlySet<string> AllowedHosts { get; init; } = new HashSet<string>();
/// <summary>
/// Hosts that are explicitly blocked.
/// </summary>
public IReadOnlySet<string> BlockedHosts { get; init; } = new HashSet<string>();
/// <summary>
/// Ports that are explicitly allowed.
/// </summary>
public IReadOnlySet<int> AllowedPorts { get; init; } = new HashSet<int> { 80, 443 };
/// <summary>
/// Ports that are explicitly blocked.
/// </summary>
public IReadOnlySet<int> BlockedPorts { get; init; } = new HashSet<int>();
/// <summary>
/// Whether to allow DNS resolution.
/// </summary>
public bool AllowDns { get; init; } = true;
/// <summary>
/// Whether to allow all hosts (ignores AllowedHosts).
/// </summary>
public bool AllowAllHosts { get; init; }
/// <summary>
/// Maximum connections per host.
/// </summary>
public int MaxConnectionsPerHost { get; init; } = 10;
}
/// <summary>
/// Filesystem policy for sandbox execution.
/// </summary>
public sealed record FilesystemPolicy
{
/// <summary>
/// Paths that can be read but not written.
/// </summary>
public IReadOnlyList<string> ReadOnlyPaths { get; init; } = new List<string>();
/// <summary>
/// Paths that can be written to.
/// </summary>
public IReadOnlyList<string> WritablePaths { get; init; } = new List<string>();
/// <summary>
/// Paths that cannot be accessed at all.
/// </summary>
public IReadOnlyList<string> BlockedPaths { get; init; } = new List<string>();
/// <summary>
/// Maximum total bytes that can be written.
/// </summary>
public long MaxWriteBytes { get; init; } = 100 * 1024 * 1024; // 100 MB
}
/// <summary>
/// Timeout configuration for sandbox operations.
/// </summary>
public sealed record SandboxTimeouts
{
/// <summary>
/// Maximum time to wait for sandbox startup.
/// </summary>
public TimeSpan StartupTimeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Default timeout for operations.
/// </summary>
public TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Maximum time to wait for graceful shutdown.
/// </summary>
public TimeSpan ShutdownTimeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Timeout for health checks.
/// </summary>
public TimeSpan HealthCheckTimeout { get; init; } = TimeSpan.FromSeconds(5);
}

View File

@@ -0,0 +1,167 @@
using Microsoft.Extensions.Logging;
using StellaOps.Plugin.Abstractions;
using StellaOps.Plugin.Sandbox.Communication;
using StellaOps.Plugin.Sandbox.Network;
using StellaOps.Plugin.Sandbox.Process;
using StellaOps.Plugin.Sandbox.Resources;
namespace StellaOps.Plugin.Sandbox;
/// <summary>
/// Factory for creating sandbox instances.
/// </summary>
public sealed class SandboxFactory : ISandboxFactory
{
private readonly IPluginProcessManager _processManager;
private readonly INetworkPolicyEnforcer _networkEnforcer;
private readonly ILoggerFactory _loggerFactory;
private readonly TimeProvider _timeProvider;
private int _sandboxCounter;
/// <summary>
/// Creates a new sandbox factory.
/// </summary>
public SandboxFactory(
IPluginProcessManager processManager,
INetworkPolicyEnforcer networkEnforcer,
ILoggerFactory loggerFactory,
TimeProvider timeProvider)
{
_processManager = processManager;
_networkEnforcer = networkEnforcer;
_loggerFactory = loggerFactory;
_timeProvider = timeProvider;
}
/// <inheritdoc />
public ISandbox Create(SandboxConfiguration configuration)
{
var id = GenerateSandboxId();
return CreateInternal(id, configuration);
}
/// <inheritdoc />
public ISandbox CreateDefault()
{
var id = GenerateSandboxId();
return CreateInternal(id, SandboxConfiguration.Default);
}
/// <inheritdoc />
public ISandbox CreateForTrustLevel(PluginTrustLevel trustLevel)
{
var id = GenerateSandboxId();
var config = GetConfigurationForTrustLevel(trustLevel);
return CreateInternal(id, config);
}
private ISandbox CreateInternal(string id, SandboxConfiguration config)
{
// Create platform-specific resource limiter
var resourceLimiter = CreateResourceLimiter();
// Create gRPC bridge
var bridge = new GrpcPluginBridge(
_loggerFactory.CreateLogger<GrpcPluginBridge>());
return new ProcessSandbox(
id,
config,
_processManager,
bridge,
resourceLimiter,
_networkEnforcer,
_loggerFactory.CreateLogger<ProcessSandbox>(),
_timeProvider);
}
private IResourceLimiter CreateResourceLimiter()
{
if (OperatingSystem.IsWindows())
{
return new WindowsResourceLimiter(
_loggerFactory.CreateLogger<WindowsResourceLimiter>());
}
else
{
return new LinuxResourceLimiter(
_loggerFactory.CreateLogger<LinuxResourceLimiter>());
}
}
private string GenerateSandboxId()
{
var counter = Interlocked.Increment(ref _sandboxCounter);
return $"sandbox-{_timeProvider.GetUtcNow():yyyyMMdd-HHmmss}-{counter:D4}";
}
private static SandboxConfiguration GetConfigurationForTrustLevel(PluginTrustLevel trustLevel)
{
return trustLevel switch
{
PluginTrustLevel.Untrusted => new SandboxConfiguration
{
ResourceLimits = new ResourceLimits
{
MaxMemoryMb = 256,
MaxCpuPercent = 25,
MaxProcesses = 20,
MaxOpenFiles = 50,
MaxDiskMb = 50
},
NetworkPolicy = new NetworkPolicy
{
AllowAllHosts = false,
AllowedHosts = new HashSet<string>(),
BlockedPorts = new HashSet<int> { 22, 23, 25, 53, 135, 139, 445, 1433, 3306, 5432 }
},
FilesystemPolicy = new FilesystemPolicy
{
MaxWriteBytes = 10 * 1024 * 1024 // 10 MB
},
Timeouts = new SandboxTimeouts
{
StartupTimeout = TimeSpan.FromSeconds(10),
ShutdownTimeout = TimeSpan.FromSeconds(5),
OperationTimeout = TimeSpan.FromSeconds(15),
HealthCheckTimeout = TimeSpan.FromSeconds(3)
}
},
PluginTrustLevel.Trusted => SandboxConfiguration.Trusted,
PluginTrustLevel.BuiltIn => new SandboxConfiguration
{
// BuiltIn plugins get generous limits (though they typically run in-process)
ResourceLimits = new ResourceLimits
{
MaxMemoryMb = 4096,
MaxCpuPercent = 100,
MaxProcesses = 500,
MaxOpenFiles = 1000,
MaxDiskMb = 2048
},
NetworkPolicy = new NetworkPolicy
{
AllowAllHosts = true,
BlockedPorts = new HashSet<int>()
},
FilesystemPolicy = new FilesystemPolicy
{
MaxWriteBytes = 1024 * 1024 * 1024 // 1 GB
},
Timeouts = new SandboxTimeouts
{
StartupTimeout = TimeSpan.FromMinutes(5),
ShutdownTimeout = TimeSpan.FromMinutes(2),
OperationTimeout = TimeSpan.FromMinutes(10),
HealthCheckTimeout = TimeSpan.FromSeconds(15)
},
ProcessIsolation = false
},
_ => SandboxConfiguration.Default
};
}
}

View File

@@ -0,0 +1,110 @@
namespace StellaOps.Plugin.Sandbox;
/// <summary>
/// Represents the current state of a plugin sandbox.
/// </summary>
public enum SandboxState
{
/// <summary>
/// Sandbox has been created but not started.
/// </summary>
Created,
/// <summary>
/// Sandbox is in the process of starting.
/// </summary>
Starting,
/// <summary>
/// Sandbox is running and ready for operations.
/// </summary>
Running,
/// <summary>
/// Sandbox is in the process of stopping gracefully.
/// </summary>
Stopping,
/// <summary>
/// Sandbox has stopped gracefully.
/// </summary>
Stopped,
/// <summary>
/// Sandbox has failed due to an error.
/// </summary>
Failed,
/// <summary>
/// Sandbox was forcefully killed.
/// </summary>
Killed
}
/// <summary>
/// Event arguments for sandbox state changes.
/// </summary>
public sealed class SandboxStateChangedEventArgs : EventArgs
{
/// <summary>
/// Previous state before the change.
/// </summary>
public required SandboxState OldState { get; init; }
/// <summary>
/// New state after the change.
/// </summary>
public required SandboxState NewState { get; init; }
/// <summary>
/// Optional reason for the state change.
/// </summary>
public string? Reason { get; init; }
}
/// <summary>
/// Event arguments for resource usage warnings.
/// </summary>
public sealed class ResourceWarningEventArgs : EventArgs
{
/// <summary>
/// Type of resource approaching its limit.
/// </summary>
public required ResourceType Resource { get; init; }
/// <summary>
/// Current usage as a percentage of the limit.
/// </summary>
public required double CurrentUsagePercent { get; init; }
/// <summary>
/// Threshold percentage that triggered the warning.
/// </summary>
public required double ThresholdPercent { get; init; }
}
/// <summary>
/// Types of resources that can be monitored and limited.
/// </summary>
public enum ResourceType
{
/// <summary>
/// Memory usage.
/// </summary>
Memory,
/// <summary>
/// CPU usage.
/// </summary>
Cpu,
/// <summary>
/// Disk usage.
/// </summary>
Disk,
/// <summary>
/// Network bandwidth usage.
/// </summary>
Network
}

View File

@@ -0,0 +1,95 @@
namespace StellaOps.Plugin.Sandbox.Secrets;
/// <summary>
/// Interface for proxying secret access to sandboxed plugins.
/// </summary>
public interface ISecretProxy
{
/// <summary>
/// Get a secret value by key.
/// </summary>
/// <param name="key">Secret key.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Secret value, or null if not found or not allowed.</returns>
Task<string?> GetSecretAsync(string key, CancellationToken ct);
/// <summary>
/// Check if access to a secret is allowed.
/// </summary>
/// <param name="key">Secret key.</param>
/// <returns>True if access is allowed.</returns>
bool IsAllowed(string key);
/// <summary>
/// Get the list of allowed secret prefixes.
/// </summary>
/// <returns>Allowed secret prefixes.</returns>
IReadOnlyList<string> GetAllowedPrefixes();
}
/// <summary>
/// Scoped secret proxy that limits access to specific prefixes.
/// </summary>
public sealed class ScopedSecretProxy : ISecretProxy
{
private readonly ISecretProvider _provider;
private readonly IReadOnlyList<string> _allowedPrefixes;
private readonly string _sandboxId;
/// <summary>
/// Creates a new scoped secret proxy.
/// </summary>
/// <param name="provider">Underlying secret provider.</param>
/// <param name="allowedPrefixes">Allowed secret key prefixes.</param>
/// <param name="sandboxId">Sandbox identifier for auditing.</param>
public ScopedSecretProxy(
ISecretProvider provider,
IReadOnlyList<string> allowedPrefixes,
string sandboxId)
{
_provider = provider;
_allowedPrefixes = allowedPrefixes;
_sandboxId = sandboxId;
}
/// <inheritdoc />
public async Task<string?> GetSecretAsync(string key, CancellationToken ct)
{
if (!IsAllowed(key))
return null;
return await _provider.GetSecretAsync(key, ct);
}
/// <inheritdoc />
public bool IsAllowed(string key)
{
if (_allowedPrefixes.Count == 0)
return false;
foreach (var prefix in _allowedPrefixes)
{
if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <inheritdoc />
public IReadOnlyList<string> GetAllowedPrefixes() => _allowedPrefixes;
}
/// <summary>
/// Interface for providing secrets.
/// </summary>
public interface ISecretProvider
{
/// <summary>
/// Get a secret value by key.
/// </summary>
/// <param name="key">Secret key.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Secret value, or null if not found.</returns>
Task<string?> GetSecretAsync(string key, CancellationToken ct);
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Description>Plugin sandbox infrastructure for process isolation, resource limits, and security boundaries</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Grpc.Net.Client" />
<PackageReference Include="Grpc.Tools">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Google.Protobuf" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Communication\Proto\plugin_bridge.proto" GrpcServices="Client" />
</ItemGroup>
</Project>