Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/technical.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,8 @@ Rules:
- Use `tracker.api_key: $GITHUB_TOKEN`.
- Use container Linux paths for Docker mode.
- Use host paths for local process mode.
- Validate generated workflow source, provisioning paths, port, and required GitHub/OpenAI secret references before provisioning starts.
- Block provisioning when a required credential is disabled, unresolved, or inlined in the generated workflow source.

### 8.5 Instance Lifecycle

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using Conductor.Core.Common;
using Conductor.Core.Domain;
using Conductor.Core.Domain.Ids;
using Conductor.Core.Domain.Repositories;
using Conductor.Core.Domain.Secrets;
using Conductor.Core.Domain.Workflows;

namespace Conductor.Core.Application.Workflows;

public interface IWorkflowValidationService
{
WorkflowValidationResult Validate(WorkflowValidationRequest request);
}

public sealed record WorkflowValidationRequest(
GitHubRepositoryFullName Repository,
WorkflowProfile WorkflowProfile,
ExecutionMode ExecutionMode,
int? Port,
string WorkflowPath,
string DataPath,
IReadOnlyCollection<WorkflowSecretReference> SecretReferences);

public sealed record WorkflowSecretReference
{
public WorkflowSecretReference(
SecretType secretType,
CredentialInheritanceMode inheritanceMode,
SecretId? secretId = null,
bool isResolved = false)
{
if (!Enum.IsDefined(inheritanceMode))
{
throw new ArgumentOutOfRangeException(
nameof(inheritanceMode),
inheritanceMode,
"Credential inheritance mode is not supported.");
}

if (inheritanceMode == CredentialInheritanceMode.SpecificSecret)
{
if (secretId is null)
{
throw new ArgumentException("A specific workflow secret reference requires a secret id.", nameof(secretId));
}

SecretId = new SecretId(Guard.NotEmpty(secretId.Value.Value, nameof(secretId)));
}
else if (secretId is not null)
{
throw new ArgumentException("Secret ids can only be set for specific workflow secret references.", nameof(secretId));
}

SecretType = secretType;
InheritanceMode = inheritanceMode;
IsResolved = isResolved || inheritanceMode == CredentialInheritanceMode.SpecificSecret;
}

public SecretType SecretType { get; }

public CredentialInheritanceMode InheritanceMode { get; }

public SecretId? SecretId { get; }

public bool IsResolved { get; }

public string EnvironmentVariableName => SecretTypeMetadata.Get(SecretType).EnvironmentVariableName;
}

public sealed record WorkflowValidationResult(IReadOnlyDictionary<string, string[]> Errors)
{
public bool IsValid => Errors.Count == 0;

public static WorkflowValidationResult Success { get; } =
new(new Dictionary<string, string[]>(StringComparer.Ordinal));
}
167 changes: 167 additions & 0 deletions src/Conductor.Core/Application/Workflows/WorkflowValidationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
using Conductor.Core.Domain;
using Conductor.Core.Domain.Secrets;

namespace Conductor.Core.Application.Workflows;

public sealed class WorkflowValidationService : IWorkflowValidationService
{
private const string WorkflowFileName = "WORKFLOW.md";
private static readonly SecretType[] RequiredSecretTypes =
[
SecretType.GitHubToken,
SecretType.OpenAiApiKey,
];

public WorkflowValidationResult Validate(WorkflowValidationRequest request)
{
ArgumentNullException.ThrowIfNull(request);

Dictionary<string, List<string>> errors = new(StringComparer.Ordinal);

ValidateSettings(request, errors);
ValidateWorkflowSource(request, errors);
ValidateSecretReferences(request, errors);

if (errors.Count == 0)
{
return WorkflowValidationResult.Success;
}

return new WorkflowValidationResult(errors.ToDictionary(
item => item.Key,
item => item.Value.ToArray(),
StringComparer.Ordinal));
}

private static void ValidateSettings(
WorkflowValidationRequest request,
Dictionary<string, List<string>> errors)
{
if (!Enum.IsDefined(request.ExecutionMode))
{
AddError(errors, nameof(request.ExecutionMode), $"ExecutionMode value '{request.ExecutionMode}' is not supported.");
}

if (request.ExecutionMode is ExecutionMode.AzureContainer)
{
AddError(errors, nameof(request.ExecutionMode), "Azure Container workflow provisioning is not supported yet.");
}

if (request.Port is null)
{
AddError(errors, nameof(request.Port), "A port is required before provisioning a generated workflow.");
}
else if (request.Port is < 1 or > 65535)
{
AddError(errors, nameof(request.Port), "A TCP port must be between 1 and 65535.");
}

ValidatePath(request.WorkflowPath, nameof(request.WorkflowPath), mustEndWithWorkflowFile: true, errors);
ValidatePath(request.DataPath, nameof(request.DataPath), mustEndWithWorkflowFile: false, errors);
}

private static void ValidateWorkflowSource(
WorkflowValidationRequest request,
Dictionary<string, List<string>> errors)
{
string source = request.WorkflowProfile.WorkflowSource;

RequireSourceToken(source, request.Repository.Owner, "workflow repository owner", errors);
RequireSourceToken(source, request.Repository.Name, "workflow repository name", errors);
RequireSourceToken(source, "$GITHUB_TOKEN", "GitHub token environment reference", errors);

if (ContainsKnownSecretLiteral(source))
{
AddError(
errors,
nameof(request.WorkflowProfile.WorkflowSource),
"Workflow source must reference secret environment variables and must not inline credential values.");
}
}

private static void ValidateSecretReferences(
WorkflowValidationRequest request,
Dictionary<string, List<string>> errors)
{
foreach (SecretType secretType in RequiredSecretTypes)
{
WorkflowSecretReference? reference = request.SecretReferences
.FirstOrDefault(secretReference => secretReference.SecretType == secretType);
string fieldName = $"{nameof(request.SecretReferences)}.{secretType}";
string label = SecretTypeMetadata.Get(secretType).Label;

if (reference is null)
{
AddError(errors, fieldName, $"{label} is required before provisioning a generated workflow.");
continue;
}

if (reference.InheritanceMode is CredentialInheritanceMode.None)
{
AddError(errors, fieldName, $"{label} cannot be disabled for generated workflow provisioning.");
continue;
}

if (!reference.IsResolved)
{
AddError(errors, fieldName, $"{label} must resolve to a stored secret before provisioning.");
}
}
}

private static void ValidatePath(
string value,
string fieldName,
bool mustEndWithWorkflowFile,
Dictionary<string, List<string>> errors)
{
if (string.IsNullOrWhiteSpace(value))
{
AddError(errors, fieldName, "A path is required before provisioning a generated workflow.");
return;
}

string trimmed = value.Trim();
if (trimmed.IndexOfAny(Path.GetInvalidPathChars()) >= 0)
{
AddError(errors, fieldName, "Path contains invalid characters.");
}

if (mustEndWithWorkflowFile &&
!trimmed.EndsWith(WorkflowFileName, StringComparison.OrdinalIgnoreCase))
{
AddError(errors, fieldName, $"Workflow path must point to {WorkflowFileName}.");
}
}

private static void RequireSourceToken(
string source,
string expectedToken,
string label,
Dictionary<string, List<string>> errors)
{
if (!source.Contains(expectedToken, StringComparison.Ordinal))
{
AddError(errors, nameof(WorkflowValidationRequest.WorkflowProfile), $"Generated workflow is missing {label}.");
}
}

private static bool ContainsKnownSecretLiteral(string source) =>
source.Contains("github_pat_", StringComparison.Ordinal) ||
source.Contains("ghp_", StringComparison.Ordinal) ||
source.Contains("sk-", StringComparison.Ordinal);

private static void AddError(
Dictionary<string, List<string>> errors,
string fieldName,
string message)
{
if (!errors.TryGetValue(fieldName, out List<string>? messages))
{
messages = [];
errors[fieldName] = messages;
}

messages.Add(message);
}
}
125 changes: 125 additions & 0 deletions tests/Conductor.Core.Tests/WorkflowValidationServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using Conductor.Core.Application.Workflows;
using Conductor.Core.Domain;
using Conductor.Core.Domain.Ids;
using Conductor.Core.Domain.Repositories;
using Conductor.Core.Domain.Secrets;
using Conductor.Core.Domain.Workflows;

namespace Conductor.Core.Tests;

public sealed class WorkflowValidationServiceTests
{
private static readonly DateTimeOffset CreatedAtUtc = DateTimeOffset.Parse("2026-04-29T00:00:00Z");

[Fact]
public void Validate_Accepts_Docker_Workflow_With_Resolved_Required_Secrets()
{
WorkflowValidationService service = new();

WorkflowValidationResult result = service.Validate(ValidRequest());

Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}

[Fact]
public void Validate_Rejects_Missing_Provisioning_Settings()
{
WorkflowValidationService service = new();
WorkflowValidationRequest request = ValidRequest() with
{
Port = null,
WorkflowPath = "config/workflow.txt",
DataPath = " ",
};

WorkflowValidationResult result = service.Validate(request);

Assert.False(result.IsValid);
Assert.Contains(nameof(WorkflowValidationRequest.Port), result.Errors.Keys);
Assert.Contains(nameof(WorkflowValidationRequest.WorkflowPath), result.Errors.Keys);
Assert.Contains(nameof(WorkflowValidationRequest.DataPath), result.Errors.Keys);
}

[Fact]
public void Validate_Rejects_Workflow_Source_That_Misses_Repository_Or_Secret_Environment_Reference()
{
WorkflowValidationService service = new();
WorkflowValidationRequest request = ValidRequest() with
{
WorkflowProfile = Profile(
"""
tracker:
owner: OtherOrg
repo: OtherRepo
api_key: github_pat_literal-secret
"""),
};

WorkflowValidationResult result = service.Validate(request);

Assert.False(result.IsValid);
Assert.Contains(nameof(WorkflowValidationRequest.WorkflowProfile), result.Errors.Keys);
Assert.Contains(nameof(WorkflowProfile.WorkflowSource), result.Errors.Keys);
}

[Fact]
public void Validate_Rejects_Disabled_Or_Unresolved_Required_Secret_References()
{
WorkflowValidationService service = new();
WorkflowValidationRequest request = ValidRequest() with
{
SecretReferences =
[
new WorkflowSecretReference(SecretType.GitHubToken, CredentialInheritanceMode.None),
new WorkflowSecretReference(SecretType.OpenAiApiKey, CredentialInheritanceMode.InheritDefault),
],
};

WorkflowValidationResult result = service.Validate(request);

Assert.False(result.IsValid);
Assert.Contains("SecretReferences.GitHubToken", result.Errors.Keys);
Assert.Contains("SecretReferences.OpenAiApiKey", result.Errors.Keys);
}

[Fact]
public void WorkflowSecretReference_Requires_Secret_Id_Only_For_Specific_Mode()
{
var missingSecretError = Assert.Throws<ArgumentException>(() =>
new WorkflowSecretReference(SecretType.GitHubToken, CredentialInheritanceMode.SpecificSecret));
var inheritedSecretError = Assert.Throws<ArgumentException>(() =>
new WorkflowSecretReference(
SecretType.GitHubToken,
CredentialInheritanceMode.InheritDefault,
SecretId.New()));

Assert.Equal("secretId", missingSecretError.ParamName);
Assert.Equal("secretId", inheritedSecretError.ParamName);
}

private static WorkflowValidationRequest ValidRequest() =>
new(
new GitHubRepositoryFullName("ReleasedGroup", "TheConductor"),
Profile(
"""
tracker:
owner: ReleasedGroup
repo: TheConductor
api_key: $GITHUB_TOKEN
agent:
max_turns: 20
"""),
ExecutionMode.Docker,
Port: 8080,
WorkflowPath: "/config/WORKFLOW.md",
DataPath: "/data",
SecretReferences:
[
new WorkflowSecretReference(SecretType.GitHubToken, CredentialInheritanceMode.InheritDefault, isResolved: true),
new WorkflowSecretReference(SecretType.OpenAiApiKey, CredentialInheritanceMode.SpecificSecret, SecretId.New()),
]);

private static WorkflowProfile Profile(string source) =>
new(WorkflowProfileId.New(), "Default", source, CreatedAtUtc);
}
Loading