feat: add PolicyPackSelectorComponent with tests and integration

- Implemented PolicyPackSelectorComponent for selecting policy packs.
- Added unit tests for component behavior, including API success and error handling.
- Introduced monaco-workers type declarations for editor workers.
- Created acceptance tests for guardrails with stubs for AT1–AT10.
- Established SCA Failure Catalogue Fixtures for regression testing.
- Developed plugin determinism harness with stubs for PL1–PL10.
- Added scripts for evidence upload and verification processes.
This commit is contained in:
StellaOps Bot
2025-12-05 21:24:34 +02:00
parent 347c88342c
commit 18d87c64c5
220 changed files with 7700 additions and 518 deletions

View File

@@ -40,7 +40,7 @@ internal static class TaskPackPlanHasher
var json = CanonicalJson.Serialize(canonical);
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));
return ConvertToHex(hashBytes);
return $"sha256:{ConvertToHex(hashBytes)}";
}
private static string ConvertToHex(byte[] hashBytes)

View File

@@ -22,16 +22,17 @@ public sealed class TaskPackPlanner
this.egressPolicy = egressPolicy;
}
public TaskPackPlanResult Plan(TaskPackManifest manifest, IDictionary<string, JsonNode?>? providedInputs = null)
{
ArgumentNullException.ThrowIfNull(manifest);
var errors = ImmutableArray.CreateBuilder<TaskPackPlanError>();
var validation = validator.Validate(manifest);
if (!validation.IsValid)
{
foreach (var error in validation.Errors)
public TaskPackPlanResult Plan(TaskPackManifest manifest, IDictionary<string, JsonNode?>? providedInputs = null)
{
ArgumentNullException.ThrowIfNull(manifest);
var errors = ImmutableArray.CreateBuilder<TaskPackPlanError>();
ValidateSandboxAndSlo(manifest, errors);
var validation = validator.Validate(manifest);
if (!validation.IsValid)
{
foreach (var error in validation.Errors)
{
errors.Add(new TaskPackPlanError(error.Path, error.Message));
}
@@ -106,10 +107,70 @@ public sealed class TaskPackPlanner
return new TaskPackPlanResult(plan, ImmutableArray<TaskPackPlanError>.Empty);
}
private static void ValidateSandboxAndSlo(TaskPackManifest manifest, ImmutableArray<TaskPackPlanError>.Builder errors)
{
// TP6: sandbox quotas must be present.
var sandbox = manifest.Spec.Sandbox;
if (sandbox is null)
{
errors.Add(new TaskPackPlanError("spec.sandbox", "Sandbox settings are required (mode, egressAllowlist, CPU/memory, quotaSeconds)."));
}
else
{
if (string.IsNullOrWhiteSpace(sandbox.Mode))
{
errors.Add(new TaskPackPlanError("spec.sandbox.mode", "Sandbox mode is required (sealed or restricted)."));
}
if (sandbox.EgressAllowlist is null)
{
errors.Add(new TaskPackPlanError("spec.sandbox.egressAllowlist", "Egress allowlist must be declared (empty list allowed)."));
}
if (sandbox.CpuLimitMillicores <= 0)
{
errors.Add(new TaskPackPlanError("spec.sandbox.cpuLimitMillicores", "CPU limit must be > 0."));
}
if (sandbox.MemoryLimitMiB <= 0)
{
errors.Add(new TaskPackPlanError("spec.sandbox.memoryLimitMiB", "Memory limit must be > 0."));
}
if (sandbox.QuotaSeconds <= 0)
{
errors.Add(new TaskPackPlanError("spec.sandbox.quotaSeconds", "quotaSeconds must be > 0."));
}
}
// TP9: SLOs must be declared and positive.
var slo = manifest.Spec.Slo;
if (slo is null)
{
errors.Add(new TaskPackPlanError("spec.slo", "SLO section is required (runP95Seconds, approvalP95Seconds, maxQueueDepth)."));
return;
}
if (slo.RunP95Seconds <= 0)
{
errors.Add(new TaskPackPlanError("spec.slo.runP95Seconds", "runP95Seconds must be > 0."));
}
if (slo.ApprovalP95Seconds <= 0)
{
errors.Add(new TaskPackPlanError("spec.slo.approvalP95Seconds", "approvalP95Seconds must be > 0."));
}
if (slo.MaxQueueDepth <= 0)
{
errors.Add(new TaskPackPlanError("spec.slo.maxQueueDepth", "maxQueueDepth must be > 0."));
}
}
private Dictionary<string, JsonNode?> MaterializeInputs(
IReadOnlyList<TaskPackInput>? definitions,
IDictionary<string, JsonNode?>? providedInputs,
ImmutableArray<TaskPackPlanError>.Builder errors)
IDictionary<string, JsonNode?>? providedInputs,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
var effective = new Dictionary<string, JsonNode?>(StringComparer.Ordinal);

View File

@@ -54,11 +54,11 @@ public sealed class TaskPackMaintainer
public string? Email { get; init; }
}
public sealed class TaskPackSpec
{
[JsonPropertyName("inputs")]
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
public sealed class TaskPackSpec
{
[JsonPropertyName("inputs")]
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
[JsonPropertyName("secrets")]
public IReadOnlyList<TaskPackSecret>? Secrets { get; init; }
@@ -72,11 +72,17 @@ public sealed class TaskPackSpec
public IReadOnlyList<TaskPackOutput>? Outputs { get; init; }
[JsonPropertyName("success")]
public TaskPackSuccess? Success { get; init; }
[JsonPropertyName("failure")]
public TaskPackFailure? Failure { get; init; }
}
public TaskPackSuccess? Success { get; init; }
[JsonPropertyName("failure")]
public TaskPackFailure? Failure { get; init; }
[JsonPropertyName("sandbox")]
public TaskPackSandbox? Sandbox { get; init; }
[JsonPropertyName("slo")]
public TaskPackSlo? Slo { get; init; }
}
public sealed class TaskPackInput
{
@@ -255,11 +261,41 @@ public sealed class TaskPackFailure
public TaskPackRetryPolicy? Retries { get; init; }
}
public sealed class TaskPackRetryPolicy
{
[JsonPropertyName("maxAttempts")]
public int MaxAttempts { get; init; }
[JsonPropertyName("backoffSeconds")]
public int BackoffSeconds { get; init; }
}
public sealed class TaskPackRetryPolicy
{
[JsonPropertyName("maxAttempts")]
public int MaxAttempts { get; init; }
[JsonPropertyName("backoffSeconds")]
public int BackoffSeconds { get; init; }
}
public sealed class TaskPackSandbox
{
[JsonPropertyName("mode")]
public string? Mode { get; init; }
[JsonPropertyName("egressAllowlist")]
public IReadOnlyList<string>? EgressAllowlist { get; init; }
[JsonPropertyName("cpuLimitMillicores")]
public int CpuLimitMillicores { get; init; }
[JsonPropertyName("memoryLimitMiB")]
public int MemoryLimitMiB { get; init; }
[JsonPropertyName("quotaSeconds")]
public int QuotaSeconds { get; init; }
}
public sealed class TaskPackSlo
{
[JsonPropertyName("runP95Seconds")]
public int RunP95Seconds { get; init; }
[JsonPropertyName("approvalP95Seconds")]
public int ApprovalP95Seconds { get; init; }
[JsonPropertyName("maxQueueDepth")]
public int MaxQueueDepth { get; init; }
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using System.Text.RegularExpressions;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
@@ -34,6 +35,14 @@ public sealed class PackRunApprovalDecisionService
var runId = request.RunId.Trim();
var approvalId = request.ApprovalId.Trim();
if (!IsSha256Digest(request.PlanHash))
{
_logger.LogWarning(
"Approval decision for run {RunId} rejected plan hash format invalid (expected sha256:<64-hex>).",
runId);
return PackRunApprovalDecisionResult.PlanHashMismatch;
}
var state = await _stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false);
if (state is null)
{
@@ -101,6 +110,14 @@ public sealed class PackRunApprovalDecisionService
return PackRunApprovalDecisionResult.Applied;
}
private static bool IsSha256Digest(string value)
=> !string.IsNullOrWhiteSpace(value)
&& Sha256Pattern.IsMatch(value);
private static readonly Regex Sha256Pattern = new(
"^sha256:[0-9a-f]{64}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
}
public sealed record PackRunApprovalDecisionRequest(

View File

@@ -62,7 +62,7 @@ public sealed class PackRunApprovalDecisionServiceTests
NullLogger<PackRunApprovalDecisionService>.Instance);
var result = await service.ApplyAsync(
new PackRunApprovalDecisionRequest("missing", "approval", "hash", PackRunApprovalDecisionType.Approved, "actor", null),
new PackRunApprovalDecisionRequest("missing", "approval", "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", PackRunApprovalDecisionType.Approved, "actor", null),
CancellationToken.None);
Assert.Equal("not_found", result.Status);
@@ -107,6 +107,44 @@ public sealed class PackRunApprovalDecisionServiceTests
Assert.False(scheduler.ScheduledContexts.Any());
}
[Fact]
public async Task ApplyAsync_ReturnsPlanHashMismatchWhenFormatInvalid()
{
var plan = TestPlanFactory.CreatePlan();
var state = TestPlanFactory.CreateState("run-1", plan);
var approval = new PackRunApprovalState(
"security-review",
new[] { "Packs.Approve" },
new[] { "step-a" },
Array.Empty<string>(),
null,
DateTimeOffset.UtcNow.AddMinutes(-5),
PackRunApprovalStatus.Pending);
var approvalStore = new InMemoryApprovalStore(new Dictionary<string, IReadOnlyList<PackRunApprovalState>>
{
["run-1"] = new List<PackRunApprovalState> { approval }
});
var stateStore = new InMemoryStateStore(new Dictionary<string, PackRunState>
{
["run-1"] = state
});
var scheduler = new RecordingScheduler();
var service = new PackRunApprovalDecisionService(
approvalStore,
stateStore,
scheduler,
NullLogger<PackRunApprovalDecisionService>.Instance);
var result = await service.ApplyAsync(
new PackRunApprovalDecisionRequest("run-1", "security-review", "not-a-digest", PackRunApprovalDecisionType.Approved, "actor", null),
CancellationToken.None);
Assert.Equal("plan_hash_mismatch", result.Status);
Assert.False(scheduler.ScheduledContexts.Any());
}
private sealed class InMemoryApprovalStore : IPackRunApprovalStore
{
private readonly Dictionary<string, List<PackRunApprovalState>> _approvals;
@@ -214,7 +252,7 @@ internal static class TestPlanFactory
metadata,
new Dictionary<string, JsonNode?>(StringComparer.Ordinal),
new[] { step },
"hash-123",
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
new[]
{
new TaskPackPlanApproval("security-review", new[] { "Packs.Approve" }, null, null)

View File

@@ -36,9 +36,24 @@ public sealed class TaskPackPlannerTests
var resultB = planner.Plan(manifest, inputs);
Assert.True(resultB.Success);
Assert.Equal(plan.Hash, resultB.Plan!.Hash);
}
Assert.Equal(plan.Hash, resultB.Plan!.Hash);
}
[Fact]
public void PlanHash_IsPrefixedSha256Digest()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var result = planner.Plan(manifest);
Assert.True(result.Success);
var hash = result.Plan!.Hash;
Assert.StartsWith("sha256:", hash, StringComparison.Ordinal);
Assert.Equal(71, hash.Length); // "sha256:" + 64 hex characters
var hex = hash.Substring("sha256:".Length);
Assert.True(hex.All(c => Uri.IsHexDigit(c)), "Hash contains non-hex characters.");
}
[Fact]
public void Plan_WhenConditionEvaluatesFalse_DisablesStep()
{

View File

@@ -19,17 +19,27 @@ metadata:
version: 1.0.0
description: Sample pack for planner tests
tags: [tests]
spec:
inputs:
- name: dryRun
type: boolean
required: false
default: false
approvals:
spec:
inputs:
- name: dryRun
type: boolean
required: false
default: false
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
approvals:
- id: security-review
grants: ["packs.approve"]
steps:
- id: plan-step
steps:
- id: plan-step
name: Plan
run:
uses: builtin:plan
@@ -57,6 +67,16 @@ spec:
- name: sbomBundle
type: object
required: true
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: noop
run:
@@ -71,12 +91,22 @@ kind: TaskPack
metadata:
name: step-ref-pack
version: 1.0.0
spec:
steps:
- id: prepare
run:
uses: builtin:prepare
- id: consume
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: prepare
run:
uses: builtin:prepare
- id: consume
run:
uses: builtin:consume
with:
@@ -89,16 +119,26 @@ kind: TaskPack
metadata:
name: map-pack
version: 1.0.0
spec:
inputs:
- name: targets
type: array
required: true
steps:
- id: maintenance-loop
map:
items: "{{ inputs.targets }}"
step:
spec:
inputs:
- name: targets
type: array
required: true
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: maintenance-loop
map:
items: "{{ inputs.targets }}"
step:
id: echo-step
run:
uses: builtin:echo
@@ -112,16 +152,26 @@ kind: TaskPack
metadata:
name: secret-pack
version: 1.0.0
spec:
secrets:
spec:
secrets:
- name: apiKey
scope: packs.run
description: API authentication token
steps:
- id: use-secret
run:
uses: builtin:http
with:
description: API authentication token
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: use-secret
run:
uses: builtin:http
with:
token: "{{ secrets.apiKey }}"
""";
@@ -131,12 +181,22 @@ kind: TaskPack
metadata:
name: output-pack
version: 1.0.0
spec:
steps:
- id: generate
run:
uses: builtin:generate
outputs:
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: generate
run:
uses: builtin:generate
outputs:
- name: bundlePath
type: file
path: artifacts/report.txt
@@ -152,6 +212,16 @@ metadata:
name: failure-policy-pack
version: 1.0.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: build
run:
@@ -170,6 +240,16 @@ metadata:
name: parallel-pack
version: 1.1.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: fanout
parallel:
@@ -196,6 +276,16 @@ metadata:
name: policy-gate-pack
version: 1.0.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: prepare
run:
@@ -216,6 +306,16 @@ metadata:
name: egress-allowed
version: 1.0.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: fetch
run:
@@ -233,6 +333,16 @@ metadata:
name: egress-blocked
version: 1.0.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: fetch
run:
@@ -252,6 +362,16 @@ spec:
- name: targetUrl
type: string
required: false
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: fetch
run:

View File

@@ -319,6 +319,11 @@ async Task<IResult> HandleApplyApprovalDecision(
return Results.BadRequest(new { error = "planHash is required." });
}
if (!Regex.IsMatch(request.PlanHash, "^sha256:[0-9a-f]{64}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant))
{
return Results.BadRequest(new { error = "planHash must be sha256:<64-hex>." });
}
var result = await decisionService.ApplyAsync(
new PackRunApprovalDecisionRequest(runId, approvalId, request.PlanHash, decisionType, request.ActorId, request.Summary),
cancellationToken).ConfigureAwait(false);