diff --git a/docs/technical.md b/docs/technical.md index cea14fe..d385c5e 100644 --- a/docs/technical.md +++ b/docs/technical.md @@ -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 diff --git a/src/Conductor.Core/Application/Workflows/IWorkflowValidationService.cs b/src/Conductor.Core/Application/Workflows/IWorkflowValidationService.cs new file mode 100644 index 0000000..3660cec --- /dev/null +++ b/src/Conductor.Core/Application/Workflows/IWorkflowValidationService.cs @@ -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 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 Errors) +{ + public bool IsValid => Errors.Count == 0; + + public static WorkflowValidationResult Success { get; } = + new(new Dictionary(StringComparer.Ordinal)); +} diff --git a/src/Conductor.Core/Application/Workflows/WorkflowValidationService.cs b/src/Conductor.Core/Application/Workflows/WorkflowValidationService.cs new file mode 100644 index 0000000..3f0f949 --- /dev/null +++ b/src/Conductor.Core/Application/Workflows/WorkflowValidationService.cs @@ -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> 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> 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> 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> 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> 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> 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> errors, + string fieldName, + string message) + { + if (!errors.TryGetValue(fieldName, out List? messages)) + { + messages = []; + errors[fieldName] = messages; + } + + messages.Add(message); + } +} diff --git a/tests/Conductor.Core.Tests/WorkflowValidationServiceTests.cs b/tests/Conductor.Core.Tests/WorkflowValidationServiceTests.cs new file mode 100644 index 0000000..f45257e --- /dev/null +++ b/tests/Conductor.Core.Tests/WorkflowValidationServiceTests.cs @@ -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(() => + new WorkflowSecretReference(SecretType.GitHubToken, CredentialInheritanceMode.SpecificSecret)); + var inheritedSecretError = Assert.Throws(() => + 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); +}