diff --git a/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0247_sealed_agent_task_and_child_run_payload.sql b/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0247_sealed_agent_task_and_child_run_payload.sql new file mode 100644 index 000000000..83e4a18b7 --- /dev/null +++ b/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0247_sealed_agent_task_and_child_run_payload.sql @@ -0,0 +1,27 @@ +-- 0247_sealed_agent_task_and_child_run_payload.sql +-- +-- Two public columns held resolved workflow secrets in plaintext because they are also what the platform executes: +-- +-- * agent_run.task_jsonb — an agent.run goal that references {{team.X}} / {{wf.X}} / {{project.p.X}} is resolved +-- before staging, and the envelope was persisted whole. Viewers read it back as GetAgentRunQuery.Goal. +-- * workflow_run_request.normalized_payload_json — a flow.subworkflow child's inputs, resolved in the parent, are +-- the child's trigger payload. Viewers read it back as the child run's NormalizedPayload, and rerun copied it. +-- +-- Both columns now hold a MASKED copy (every secret value the staging scope resolved replaced with [REDACTED]), and +-- the exact copy plus the list of secret values it embeds live in the encrypted column added here. The executor and +-- the child's scope build open that copy; every other reader keeps reading the masked column. The secret values ride +-- with the exact copy so the agent run's redactor and the child run's leak guard keep treating them as secrets. +-- +-- NULLABLE with NO DEFAULT on purpose: a catalog-only ADD COLUMN on two hot tables, and NULL is exactly "nothing was +-- masked" — every row written before this migration, and every row whose payload carries no secret, keeps executing +-- from its public column unchanged. + +ALTER TABLE agent_run ADD COLUMN IF NOT EXISTS sealed_task_ciphertext TEXT NULL; + +COMMENT ON COLUMN agent_run.sealed_task_ciphertext IS + 'Data Protection ciphertext of the exact AgentTask envelope and the workflow secret values it embeds. Set only when task_jsonb had a secret masked; the executor launches from it. Never returned by any read API.'; + +ALTER TABLE workflow_run_request ADD COLUMN IF NOT EXISTS sealed_payload_ciphertext TEXT NULL; + +COMMENT ON COLUMN workflow_run_request.sealed_payload_ciphertext IS + 'Data Protection ciphertext of the exact normalized payload and the inherited secret values it embeds (a flow.subworkflow child). Set only when normalized_payload_json had a secret masked; the engine builds the run scope from it. Never returned by any read API.'; diff --git a/backend/src/CodeSpace.Core/Persistence/Entities/AgentRun.cs b/backend/src/CodeSpace.Core/Persistence/Entities/AgentRun.cs index 328c7ddf3..686b446ff 100644 --- a/backend/src/CodeSpace.Core/Persistence/Entities/AgentRun.cs +++ b/backend/src/CodeSpace.Core/Persistence/Entities/AgentRun.cs @@ -61,6 +61,13 @@ public class AgentRun : IEntity, IAuditable /// The full AgentTask envelope as JSON — the run's inputs, kept whole so the envelope can evolve without a migration. public string TaskJson { get; set; } = "{}"; + /// + /// The exact task envelope, encrypted, when had a workflow secret masked (0247). Carries the + /// secret values the task embeds too, so the executor's redactor treats them as secrets. NULL — every pre-0247 row + /// and every task with no secret in it — means is itself exact. Never projected by a read API. + /// + public string? SealedTaskCiphertext { get; set; } + /// The normalized AgentRunResult as JSON, written on completion. NULL while in-flight. public string? ResultJson { get; set; } diff --git a/backend/src/CodeSpace.Core/Persistence/Entities/WorkflowRunRequest.cs b/backend/src/CodeSpace.Core/Persistence/Entities/WorkflowRunRequest.cs index b85353d45..309399dbc 100644 --- a/backend/src/CodeSpace.Core/Persistence/Entities/WorkflowRunRequest.cs +++ b/backend/src/CodeSpace.Core/Persistence/Entities/WorkflowRunRequest.cs @@ -87,6 +87,13 @@ public class WorkflowRunRequest : IEntity /// public string NormalizedPayloadJson { get; set; } = "{}"; + /// + /// The exact payload, encrypted, when had an inherited secret masked (0247 — a + /// flow.subworkflow child whose inputs resolved a parent secret). Carries those secret values too, so the run + /// keeps treating them as secrets. NULL means is itself exact. Never projected. + /// + public string? SealedPayloadCiphertext { get; set; } + /// IP, user-agent, schedule fire-time, etc. — anything that's about the request itself rather than its content. public string RequestMetadataJson { get; set; } = "{}"; diff --git a/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/AgentRunConfiguration.cs b/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/AgentRunConfiguration.cs index 67e398040..e60fad18b 100644 --- a/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/AgentRunConfiguration.cs +++ b/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/AgentRunConfiguration.cs @@ -19,6 +19,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(r => r.IterationKey).HasColumnName("iteration_key"); builder.Property(r => r.TaskJson).HasColumnName("task_jsonb").HasColumnType("jsonb"); + builder.Property(r => r.SealedTaskCiphertext).HasColumnName("sealed_task_ciphertext"); builder.Property(r => r.ResultJson).HasColumnName("result_jsonb").HasColumnType("jsonb"); // P3.1a — the harness-native CLI session/thread id, promoted from result_jsonb so the rerun CONTINUE lookup diff --git a/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowRunRequestConfiguration.cs b/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowRunRequestConfiguration.cs index 58a000db3..1328bb374 100644 --- a/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowRunRequestConfiguration.cs +++ b/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowRunRequestConfiguration.cs @@ -17,6 +17,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(r => r.ActivationSnapshotJson).HasColumnName("activation_snapshot_json").HasColumnType("jsonb"); builder.Property(r => r.RawHeadersRedactedJson).HasColumnName("raw_headers_redacted_json").HasColumnType("jsonb"); builder.Property(r => r.NormalizedPayloadJson).HasColumnName("normalized_payload_json").HasColumnType("jsonb"); + builder.Property(r => r.SealedPayloadCiphertext).HasColumnName("sealed_payload_ciphertext"); builder.Property(r => r.RequestMetadataJson).HasColumnName("request_metadata_json").HasColumnType("jsonb"); builder.Property(r => r.VerificationResultJson).HasColumnName("verification_result_json").HasColumnType("jsonb"); } diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs index ece57c551..aa8cef97d 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs @@ -212,9 +212,13 @@ public sealed class AgentRunExecutor : IAgentRunExecutor, IScopedDependency // when the process has exited or the runner killed it. While raised, a process this pass started may still be working // in the clone, so the executor-error arm asks the runner before the clone may go (AgentMayStillRunInCloneAsync). private bool _launchUnobserved; + // Opens a task whose workflow secrets were sealed out of task_jsonb (AgentTaskSecrets). Optional like the rest — a + // hand-built test double that never stages a sealed run does not have to know about it; a sealed run reaching an + // executor without one fails loudly instead of launching the masked goal. + private readonly Workflows.Runtime.WorkflowSensitivePayloadSealer? _sealer; private readonly ILogger _logger; - public AgentRunExecutor(IAgentRunService runs, IAgentHarnessRegistry harnesses, IHarnessModelReconciler harnessReconciler, ISandboxRunnerRegistry runners, IAgentWorkspaceResolver workspaceResolver, IModelCredentialResolver modelCredentials, IWorkspaceProviderRegistry workspaces, IAgentRunCompletionNotifier notifier, IServiceScopeFactory scopeFactory, CodeSpaceDbContext db, IStructuredCritic critic, IArtifactOffloader offloader, Workflows.Artifacts.IArtifactStore artifacts, IPublishManifestStore manifests, IArtifactManifestStore artifactManifests, Capture.ICaptureIntentService captureIntents, IEnumerable publishGuards, ILogger logger, IAgentRunLogCaptureBridge? logCapture = null, INativeRecordPlane? nativeRecords = null, AgentDefaultRunnerSetting? defaultRunner = null, Services.RunData.IRunDataCompletenessWriter? completeness = null, Credentials.IModelCredentialBroker? credentialBroker = null, AgentRunLogging.IAgentRunLogService? logs = null, Microsoft.Extensions.Hosting.IHostApplicationLifetime? lifetime = null, Recovery.IAgentSessionTranscriptCheckpointer? sessionCheckpointer = null, TimeProvider? clock = null) + public AgentRunExecutor(IAgentRunService runs, IAgentHarnessRegistry harnesses, IHarnessModelReconciler harnessReconciler, ISandboxRunnerRegistry runners, IAgentWorkspaceResolver workspaceResolver, IModelCredentialResolver modelCredentials, IWorkspaceProviderRegistry workspaces, IAgentRunCompletionNotifier notifier, IServiceScopeFactory scopeFactory, CodeSpaceDbContext db, IStructuredCritic critic, IArtifactOffloader offloader, Workflows.Artifacts.IArtifactStore artifacts, IPublishManifestStore manifests, IArtifactManifestStore artifactManifests, Capture.ICaptureIntentService captureIntents, IEnumerable publishGuards, ILogger logger, IAgentRunLogCaptureBridge? logCapture = null, INativeRecordPlane? nativeRecords = null, AgentDefaultRunnerSetting? defaultRunner = null, Services.RunData.IRunDataCompletenessWriter? completeness = null, Credentials.IModelCredentialBroker? credentialBroker = null, AgentRunLogging.IAgentRunLogService? logs = null, Microsoft.Extensions.Hosting.IHostApplicationLifetime? lifetime = null, Recovery.IAgentSessionTranscriptCheckpointer? sessionCheckpointer = null, TimeProvider? clock = null, Workflows.Runtime.WorkflowSensitivePayloadSealer? sealer = null) { _runs = runs; _harnesses = harnesses; @@ -241,6 +245,7 @@ public AgentRunExecutor(IAgentRunService runs, IAgentHarnessRegistry harnesses, _lifetime = lifetime; _sessionCheckpointer = sessionCheckpointer; _clock = clock ?? TimeProvider.System; + _sealer = sealer; // Tolerate a null enumerable (a hand-built test double that never exercises the push path) — zero guards // registered is a legitimate state (every push clears), not a constructor-time crash. _publishGuards = (publishGuards ?? Enumerable.Empty()).OrderBy(g => g.Order).ToList(); @@ -324,7 +329,7 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // of escaping uncaught to leave the run Running for the reconciler to later abandon with a generic reason. if (await AbortIfParentTerminalAsync(owner, run.TeamId, run.WorkflowRunId, cancellationToken).ConfigureAwait(false)) return; - var task = JsonSerializer.Deserialize(run.TaskJson, AgentJson.Options) + var task = AgentTaskSecrets.Open(run, _sealer) ?? throw new InvalidOperationException($"AgentRun {agentRunId} has an empty task envelope."); // Reconcile the authored harness with the model's provider (from the pinned credential, or — for the @@ -875,7 +880,7 @@ public async Task ReattachAsync(AgentRunReattachReservation reservation, Cancell if (DeserializeHandle(run.RunnerHandleJson) is not { } handle) return; // no durable handle — the reconciler marker-recovers it instead - var task = JsonSerializer.Deserialize(run.TaskJson, AgentJson.Options) + var task = AgentTaskSecrets.Open(run, _sealer) ?? throw new InvalidOperationException($"AgentRun {agentRunId} has an empty task envelope."); // Re-attach must redact + fold against the SAME harness the original run reconciled to (so the right model @@ -3056,8 +3061,8 @@ private async Task GradeAgainstOracleAsync(AcceptanceInvocation // with. The patch lane always had this anchor; the BRANCH lane discarded it and graded the candidate's // bytes as the judge. grade = hasBranch - ? await grader.GradeAsync(new RepositoryAcceptanceGradeRequest { RepositoryId = repositoryId, TeamId = run.TeamId, Branch = result.ProducedBranch!, Spec = fullSpec, TimeoutSeconds = timeoutSeconds, Anchor = new OracleAnchor(result.BaseSha, OracleFloorPrograms(fullSpec)), Posture = posture }, cancellationToken).ConfigureAwait(false) - : await grader.GradePatchAsync(new PatchAcceptanceGradeRequest { RepositoryId = repositoryId, TeamId = run.TeamId, BaseSha = result.BaseSha!, InlinePatch = result.Patch, PatchArtifactId = result.PatchArtifactId, Spec = fullSpec, TimeoutSeconds = timeoutSeconds, OracleFloorPrograms = OracleFloorPrograms(fullSpec), Posture = posture }, cancellationToken).ConfigureAwait(false); + ? await grader.GradeAsync(new RepositoryAcceptanceGradeRequest { RepositoryId = repositoryId, TeamId = run.TeamId, Branch = result.ProducedBranch!, Spec = fullSpec, TimeoutSeconds = timeoutSeconds, Anchor = new OracleAnchor(result.BaseSha, OracleFloorPrograms(fullSpec)), Posture = posture, WorkflowSecrets = task.WorkflowSecrets }, cancellationToken).ConfigureAwait(false) + : await grader.GradePatchAsync(new PatchAcceptanceGradeRequest { RepositoryId = repositoryId, TeamId = run.TeamId, BaseSha = result.BaseSha!, InlinePatch = result.Patch, PatchArtifactId = result.PatchArtifactId, Spec = fullSpec, TimeoutSeconds = timeoutSeconds, OracleFloorPrograms = OracleFloorPrograms(fullSpec), Posture = posture, WorkflowSecrets = task.WorkflowSecrets }, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException and not AgentRunOwnershipLostException) { @@ -3152,7 +3157,7 @@ private async Task GradeMultiRepoAcceptanceAsync(AgentRun run, A try { // C3: each repo's own recorded base anchors ITS oracle restore — same protection as the single-repo lane. - grade = await grader.GradeAsync(new RepositoryAcceptanceGradeRequest { RepositoryId = target.RepositoryId!.Value, TeamId = run.TeamId, Branch = target.ProducedBranch!, Spec = fullSpec, TimeoutSeconds = timeoutSeconds, Anchor = new OracleAnchor(target.BaseSha, OracleFloorPrograms(fullSpec)), Posture = posture }, cancellationToken).ConfigureAwait(false); + grade = await grader.GradeAsync(new RepositoryAcceptanceGradeRequest { RepositoryId = target.RepositoryId!.Value, TeamId = run.TeamId, Branch = target.ProducedBranch!, Spec = fullSpec, TimeoutSeconds = timeoutSeconds, Anchor = new OracleAnchor(target.BaseSha, OracleFloorPrograms(fullSpec)), Posture = posture, WorkflowSecrets = task.WorkflowSecrets }, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException and not AgentRunOwnershipLostException) { @@ -3292,7 +3297,8 @@ private async Task ReachOutputVerdictAsync(AgentRunOwnerToken own if (!agentVerdict.Failed && !agentVerdict.Approved) return agentVerdict; var review = await BuildReviewRequestAsync(task, result, run, cancellationToken).ConfigureAwait(false); - var modelVerdict = await CoveringTheWholeResultAsync(await ReviewRecordedAsync(review.Request, run, task, cancellationToken).ConfigureAwait(false), review, run, cancellationToken).ConfigureAwait(false); + var reviewed = MaskedVerdict(await ReviewRecordedAsync(review.Request, run, task, cancellationToken).ConfigureAwait(false), task); + var modelVerdict = await CoveringTheWholeResultAsync(reviewed, review, run, cancellationToken).ConfigureAwait(false); return agentVerdict.Failed ? modelVerdict : CoSigned(agentVerdict, modelVerdict); } @@ -3423,7 +3429,7 @@ private async Task ReviewRecordedAsync(CriticRequest request, Age using var recordingScope = _scopeFactory.CreateScope(); - using (LlmCallContext.Push(await BuildCriticCallScopeAsync(recordingScope, workflowRunId, run, cancellationToken).ConfigureAwait(false))) + using (LlmCallContext.Push(await BuildCriticCallScopeAsync(recordingScope, workflowRunId, run, task, cancellationToken).ConfigureAwait(false))) return await _critic.ReviewAsync(request, run.TeamId, task.ReviewerModelId, cancellationToken).ConfigureAwait(false); } @@ -3439,12 +3445,12 @@ private async Task ReviewRecordedAsync(CriticRequest request, Age /// the task cap was refused, and the critic's refusal contract silently degraded it to ReviewFailed. /// A cap and the sum it is compared against must always be the same grain. /// - private async Task BuildCriticCallScopeAsync(IServiceScope recordingScope, Guid workflowRunId, AgentRun run, CancellationToken cancellationToken) + private async Task BuildCriticCallScopeAsync(IServiceScope recordingScope, Guid workflowRunId, AgentRun run, AgentTask task, CancellationToken cancellationToken) { var logger = recordingScope.ServiceProvider.GetRequiredService(); var offloader = recordingScope.ServiceProvider.GetRequiredService(); var ledger = recordingScope.ServiceProvider.GetRequiredService(); - var scope = new LlmCallScope(workflowRunId, run.TeamId, run.NodeId, run.IterationKey, "agent.critic", logger, offloader, Budget: ledger); + var scope = new LlmCallScope(workflowRunId, run.TeamId, run.NodeId, run.IterationKey, "agent.critic", logger, offloader, Budget: ledger, CaptureRedactor: CriticCaptureRedactor(task)); if (await RunCostCapAsync(workflowRunId, cancellationToken).ConfigureAwait(false) is not { } capUsd) return scope.Unbudgeted("the workflow run declares no cost cap for its agent's output-review critic"); @@ -3452,6 +3458,36 @@ private async Task BuildCriticCallScopeAsync(IServiceScope recordi return scope with { CapUsd = capUsd, ModelPrices = await ModelPriceResolver.LoadAsync(_db, run.TeamId, cancellationToken).ConfigureAwait(false) }; } + /// + /// The critic's prompt carries the producer's goal, so its interaction.* capture must mask the workflow + /// secrets that goal embeds — the masking every node scope's capture gets from the engine, which this scope (pushed + /// by the executor, outside any node) would otherwise lack. Null when the task embeds none: nothing to mask. + /// + private static Workflows.Runtime.PersistenceSecretRedactor? CriticCaptureRedactor(AgentTask task) => + task.WorkflowSecrets is { Count: > 0 } secrets ? new Workflows.Runtime.PersistenceSecretRedactor(secrets) : null; + + /// + /// The model critic's verdict with the task's workflow secrets masked out of every word it says: the rationale, each + /// issue and its evidence, and the critique. The critic is shown the producer's EXACT goal, and a model restates the + /// task it judges, so a verdict can quote a goal's resolved {{team.X}}. The capture of the call is masked + /// (), but the verdict outlives that scope. It becomes the review.completed beat, + /// the flagged warning event, the result's ReviewFeedback and an unreviewed hold's reason. Masked once here, all of + /// them hold the mask. A task that embeds no secret gets its verdict back unchanged. + /// + internal static CriticVerdict MaskedVerdict(CriticVerdict verdict, AgentTask task) + { + if (CriticCaptureRedactor(task) is not { } redactor) return verdict; + + string Mask(string text) => redactor.Redact(text).Value ?? text; + + return verdict with + { + Rationale = Mask(verdict.Rationale), + Critique = verdict.Critique is null ? null : Mask(verdict.Critique), + Issues = verdict.Issues.Select(issue => issue with { Text = Mask(issue.Text), Evidence = Mask(issue.Evidence) }).ToList(), + }; + } + /// The owning workflow run's declared cost ceiling, read from the launch-stamped route the Room displays it from. One indexed single-column read per review pass (a review makes at most a verdict + a co-sign call), so it is not worth a cache that could go stale against a re-launched cap. private async Task RunCostCapAsync(Guid workflowRunId, CancellationToken cancellationToken) => Workflows.Budget.RunCostCap.Of(await RunRoutePlanJsonAsync(workflowRunId, cancellationToken).ConfigureAwait(false)); @@ -4093,7 +4129,7 @@ private async Task ResolveModelCredentialEnvAsync(Age // this point and folds in via WithModelBrokerRunToken (the same reason the MCP token is folded there), so // that a re-attach rebuilding from the handle reproduces this exact fingerprint. The upstream key stays a // needle either way — the broker holds it, and a broker error can echo it. - var redactor = BuildRunRedactor(MergeEnvironment(task.Environment, brokered is null ? env : EmptySecretEnv), credential); + var redactor = WithWorkflowSecrets(BuildRunRedactor(MergeEnvironment(task.Environment, brokered is null ? env : EmptySecretEnv), credential), task.WorkflowSecrets); // The non-secret base URL + provider tag flow out so a restricted (Allowlist) run can pin its model-API host // in the egress allowlist (B3.3b) — the UPSTREAM ones even under brokerage, deliberately: the allowlist is @@ -4280,6 +4316,24 @@ internal static SecretRedactor BuildRunRedactor(IReadOnlyDictionary + /// A redactor widened by the workflow secrets the run's task embeds ( — a goal + /// that resolved {{team.X}}, opened from the run's sealed task). The agent was handed them, so it can echo + /// them into every sink this run persists — events, native records, result, error, the MCP ledger and its decision + /// requests — exactly like an injected key. Folded inside the credential resolve, which every redactor build + /// passes through (launch, re-attach fold, re-opened endpoint), so each rebuilds the SAME set from the sealed copy + /// and the fingerprint stamped on the handle keeps matching. Under stays readable, + /// as for any needle; ordinal-sorted and capped at so the fold is a function of the set. + /// A run whose task embeds none (every run staged before sealing existed) keeps its redactor — and fingerprint — + /// unchanged. + /// + internal static SecretRedactor WithWorkflowSecrets(SecretRedactor redactor, IReadOnlyList? workflowSecrets) + { + var needles = (workflowSecrets ?? []).Where(secret => secret.Length >= MinimumNeedleLength).Distinct(StringComparer.Ordinal).OrderBy(secret => secret, StringComparer.Ordinal).Take(MaximumNeedles).ToList(); + + return needles.Count == 0 ? redactor : redactor.With(needles); + } + /// /// A redactor widened by the run's per-run MCP capability token — the one secret the launch mints AFTER the /// credential resolve, so cannot see it. A no-op when the run has no token (no @@ -4338,12 +4392,15 @@ private static IEnumerable UrlEmbeddedSecrets(string? baseUrl) } } - /// Re-persist the run's stored task with its RESOLVED model filled, so the live projection shows what an "auto" run actually dispatches from the moment it starts (mirrors the harness-reconciliation write). The task is the ORIGINAL (no injected secret env) with only set, so serializing it is safe. - private Task PersistResolvedModelAsync(AgentRunOwnerToken owner, AgentTask taskWithModel, CancellationToken cancellationToken) => PersistRuntimeIdentityAsync(owner, null, JsonSerializer.Serialize(taskWithModel, AgentJson.Options), cancellationToken); + /// Re-persist the run's stored task with its RESOLVED model filled, so the live projection shows what an "auto" run actually dispatches from the moment it starts (mirrors the harness-reconciliation write). The task is the ORIGINAL (no injected secret env) with only set — but it is the OPENED task, so it is re-sealed exactly as the store first persisted it: written back raw, the workflow secrets it embeds would land in task_jsonb again. + private Task PersistResolvedModelAsync(AgentRunOwnerToken owner, AgentTask taskWithModel, CancellationToken cancellationToken) => PersistRuntimeIdentityAsync(owner, null, AgentTaskSecrets.Seal(taskWithModel, _sealer), cancellationToken); - private async Task PersistRuntimeIdentityAsync(AgentRunOwnerToken owner, string? harness, string? taskJson, CancellationToken cancellationToken) + /// The masked task and its sealed copy move together; a harness-only write (null task) leaves both untouched. + private async Task PersistRuntimeIdentityAsync(AgentRunOwnerToken owner, string? harness, Messages.Workflows.SealedPayload? task, CancellationToken cancellationToken) { - var changed = await _db.Database.ExecuteSqlInterpolatedAsync($"WITH locked AS MATERIALIZED (SELECT id FROM agent_run WHERE id = {owner.RunId} FOR UPDATE) UPDATE agent_run AS target SET harness = COALESCE({harness}, target.harness), task_jsonb = COALESCE(CAST({taskJson} AS jsonb), target.task_jsonb) FROM locked WHERE target.id = locked.id AND target.status = 'Running' AND target.owner_id = {owner.OwnerId} AND target.fence_epoch = {owner.Epoch} AND target.lease_expires_at > clock_timestamp()", cancellationToken).ConfigureAwait(false); + var taskJson = task?.PublicJson; + var ciphertext = task?.Ciphertext; + var changed = await _db.Database.ExecuteSqlInterpolatedAsync($"WITH locked AS MATERIALIZED (SELECT id FROM agent_run WHERE id = {owner.RunId} FOR UPDATE) UPDATE agent_run AS target SET harness = COALESCE({harness}, target.harness), task_jsonb = COALESCE(CAST({taskJson} AS jsonb), target.task_jsonb), sealed_task_ciphertext = CASE WHEN CAST({taskJson} AS text) IS NULL THEN target.sealed_task_ciphertext ELSE CAST({ciphertext} AS text) END FROM locked WHERE target.id = locked.id AND target.status = 'Running' AND target.owner_id = {owner.OwnerId} AND target.fence_epoch = {owner.Epoch} AND target.lease_expires_at > clock_timestamp()", cancellationToken).ConfigureAwait(false); if (changed != 1) throw new AgentRunOwnershipLostException(owner.RunId); } @@ -5094,7 +5151,7 @@ internal static bool IsRebindable(SandboxHandle handle) => /// private async Task FoldAndLandLostModelAccessAsync(AgentRunOwnerToken owner, AgentRun run, ISandboxDurableRunner durable, SandboxHandle handle, DateTimeOffset deadline, CancellationToken cancellationToken) { - var task = JsonSerializer.Deserialize(run.TaskJson, AgentJson.Options) ?? throw new InvalidOperationException($"AgentRun {owner.RunId} has an empty task envelope."); + var task = AgentTaskSecrets.Open(run, _sealer) ?? throw new InvalidOperationException($"AgentRun {owner.RunId} has an empty task envelope."); var harness = _harnesses.Resolve((await _harnessReconciler.ReconcileAsync(task, run.TeamId, cancellationToken).ConfigureAwait(false)).HarnessKind); var folded = await ReattachAndFoldAsync(new ReattachFoldContext { Owner = owner, TeamId = run.TeamId, ActorId = run.CreatedBy, Durable = durable, Handle = handle, Task = task, Harness = harness }, cancellationToken).ConfigureAwait(false); diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunService.Review.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunService.Review.cs index 20da3463e..cc6ee25b4 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunService.Review.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunService.Review.cs @@ -1,5 +1,6 @@ using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.Agents.Review; +using CodeSpace.Messages.Agents; using Microsoft.EntityFrameworkCore; namespace CodeSpace.Core.Services.Agents; @@ -17,9 +18,14 @@ public async Task CreateReviewAsync(AgentReviewCreation request, Cance var parent = await _db.AgentRun.AsNoTracking().SingleAsync(r => r.Id == request.ParentOwner.RunId, cancellationToken).ConfigureAwait(false); var childId = Guid.NewGuid(); var task = await _authority.AdmitReviewAsync(new(request.Task, request.TeamId, childId, parent.WorkflowRunId), parent, cancellationToken).ConfigureAwait(false); + task = InheritParentSecrets(task, parent); var child = await PersistCreatedAsync(new AgentRunCreation { Task = task, TeamId = request.TeamId, RunId = childId, WorkflowRunId = parent.WorkflowRunId, NodeId = parent.NodeId, IterationKey = AgentOutputReviewer.ReviewIterationKey(parent.IterationKey) }, cancellationToken).ConfigureAwait(false); await AssertOwnershipAsync(request.ParentOwner, cancellationToken).ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); return child; } + + /// A reviewer is briefed with its producer's work, goal included, so it embeds whatever workflow secrets the producer's sealed task does — and is sealed and redacted the same way. + private AgentTask InheritParentSecrets(AgentTask task, AgentRun parent) => + AgentTaskSecrets.Open(parent, _sealer)?.WorkflowSecrets is { Count: > 0 } secrets ? AgentTaskSecrets.Embed(task, new Workflows.Runtime.PersistenceSecretRedactor(secrets)) : task; } diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunService.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunService.cs index de3371253..9e2323f9d 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunService.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunService.cs @@ -206,8 +206,12 @@ public sealed partial class AgentRunService : IAgentRunService, IRunningAgentCan // Withdraws a cancelled run's brokered model credential before its process is killed. Optional like _completeness // — a deployment (or a hand-built double) with no broker simply has nothing to withdraw. private readonly Credentials.IModelCredentialBroker? _credentialBroker; + // Seals a task that embeds workflow secrets (AgentTaskSecrets) — task_jsonb masked, the exact copy encrypted. Optional + // like the two above: a hand-built double that never stages such a task does not have to know about it, and one that + // does without a sealer is refused rather than persisted in the clear. + private readonly Workflows.Runtime.WorkflowSensitivePayloadSealer? _sealer; - public AgentRunService(CodeSpaceDbContext db, AgentRunRuntimeServices runtime, ExecutionAuthorityService authority, ILogger logger, Services.RunData.IRunDataCompletenessWriter? completeness = null, Credentials.IModelCredentialBroker? credentialBroker = null) + public AgentRunService(CodeSpaceDbContext db, AgentRunRuntimeServices runtime, ExecutionAuthorityService authority, ILogger logger, Services.RunData.IRunDataCompletenessWriter? completeness = null, Credentials.IModelCredentialBroker? credentialBroker = null, Workflows.Runtime.WorkflowSensitivePayloadSealer? sealer = null) { _db = db; _admissionController = runtime.Admission; @@ -222,6 +226,7 @@ public AgentRunService(CodeSpaceDbContext db, AgentRunRuntimeServices runtime, E _logger = logger; _completeness = completeness; _credentialBroker = credentialBroker; + _sealer = sealer; } public async Task CreateAsync(AgentTask task, Guid teamId, Guid? workflowRunId, string? nodeId, string iterationKey = "", CancellationToken cancellationToken = default) @@ -239,6 +244,12 @@ public async Task CreateAsync(AgentTask task, Guid teamId, Guid? workf private async Task PersistCreatedAsync(AgentRunCreation creation, CancellationToken cancellationToken) { + // A task staged while a workflow node executes (a supervisor's spawn, a plan reviewer) embeds that node scope's + // secrets exactly like one the engine stages for agent.run — the node's ambient capture redactor IS its scope's + // persistence redactor — so it is masked the same way whichever caller created it. + var task = AgentTaskSecrets.Embed(creation.Task, Workflows.Llm.LlmCallContext.Current?.CaptureRedactor); + var sealedTask = AgentTaskSecrets.Seal(task, _sealer); + var run = new AgentRun { Id = creation.RunId, @@ -254,8 +265,10 @@ private async Task PersistCreatedAsync(AgentRunCreation creation, Canc // The task envelope is NOT all our own words: the P5-2 retry path folds the prior attempt's acceptance // check output — a raw subprocess tail — into task.Goal, so the incident's own retry can arrive here // carrying the very byte that killed the first attempt. Sanitized at THIS seam rather than at the splice - // because this is where the bytes meet the column. - TaskJson = PersistedText.SanitizeJson(JsonSerializer.Serialize(creation.Task, AgentJson.Options))!, + // because this is where the bytes meet the column (AgentTaskSecrets.Seal). A task that embeds workflow + // secrets lands MASKED here; the executor launches from the sealed exact copy beside it. + TaskJson = sealedTask.PublicJson, + SealedTaskCiphertext = sealedTask.Ciphertext, }; _db.AgentRun.Add(run); @@ -263,7 +276,10 @@ private async Task PersistCreatedAsync(AgentRunCreation creation, Canc _logger.LogInformation("Agent run created. RunId={RunId} Harness={Harness} TeamId={TeamId}", run.Id, run.Harness, creation.TeamId); - await StakeWorkflowAgentObligationsAsync(creation.Task, creation.TeamId, creation.WorkflowRunId, creation.NodeId, creation.IterationKey, cancellationToken).ConfigureAwait(false); + // Staked from the PERSISTED task: the completion composer re-derives the contract hash from task_jsonb, so a + // goal masked there must be the goal hashed here or the two would never agree. + var stakedTask = sealedTask.Ciphertext is null ? task : JsonSerializer.Deserialize(sealedTask.PublicJson, AgentJson.Options)!; + await StakeWorkflowAgentObligationsAsync(stakedTask, creation.TeamId, creation.WorkflowRunId, creation.NodeId, creation.IterationKey, cancellationToken).ConfigureAwait(false); return run; } diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentTaskSecrets.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentTaskSecrets.cs new file mode 100644 index 000000000..fb5443995 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentTaskSecrets.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using CodeSpace.Core.Persistence; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Workflows.Runtime; +using CodeSpace.Messages.Agents; +using CodeSpace.Messages.Workflows; + +namespace CodeSpace.Core.Services.Agents; + +/// +/// The one rule for a workflow secret that rides an agent's task: the stager names it +/// (), the store persists the task masked with the exact copy sealed beside it, +/// and the executor opens that copy — so the agent works with the real value while task_jsonb, the goal a +/// Viewer reads and every log the run writes hold only the mask. +/// +public static class AgentTaskSecrets +{ + /// + /// naming every value of its text embeds, added to any it already + /// names. Only values long enough to be an agent-plane needle () + /// count: the same values are folded into the run's stream redactor, which leaves shorter fragments readable rather + /// than shredding every line that happens to contain them, and keeping the two sets equal keeps every structural + /// field of the persisted task (an enum, a flag, a count) intact. A null scope or a task embedding nothing returns + /// the task unchanged. + /// + public static AgentTask Embed(AgentTask task, PersistenceSecretRedactor? scope) + { + if (scope is null) return task; + + var found = scope.FoundIn(JsonSerializer.SerializeToElement(task, AgentJson.Options)).Where(secret => secret.Length >= AgentRunExecutor.MinimumNeedleLength); + var secrets = (task.WorkflowSecrets ?? []).Concat(found).Distinct(StringComparer.Ordinal).ToArray(); + + return secrets.Length == 0 ? task : task with { WorkflowSecrets = secrets }; + } + + /// The task as the store persists it: the masked task_jsonb and, when the task names secrets it embeds, the sealed exact copy. Both are the column-safe text (), so the copy a run executes is the one its public row describes. + public static SealedPayload Seal(AgentTask task, WorkflowSensitivePayloadSealer? sealer) + { + var exact = PersistedText.SanitizeJson(JsonSerializer.Serialize(task, AgentJson.Options))!; + + if (task.WorkflowSecrets is not { Count: > 0 } secrets) return new SealedPayload(exact, null); + + var seal = sealer ?? throw new InvalidOperationException("An agent task embeds workflow secrets but no payload sealer is available to persist it masked."); + + using var document = JsonDocument.Parse(exact); + + return seal.Seal(document.RootElement, new PersistenceSecretRedactor(secrets)); + } + + /// The exact task a run executes — opened from its sealed copy, naming the secrets it embeds, when the row was sealed; else its task_jsonb, which is then itself exact. Null for an empty envelope. + public static AgentTask? Open(AgentRun run, WorkflowSensitivePayloadSealer? sealer) + { + if (run.SealedTaskCiphertext is null) return JsonSerializer.Deserialize(run.TaskJson, AgentJson.Options); + + var seal = sealer ?? throw new InvalidOperationException($"AgentRun {run.Id} holds a sealed task but no payload sealer is available to open it."); + var opened = seal.Open(run.TaskJson, run.SealedTaskCiphertext); + + return JsonSerializer.Deserialize(opened.Json, AgentJson.Options) is { } task ? task with { WorkflowSecrets = opened.Secrets } : null; + } +} diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/LocalAcceptanceVerifier.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/LocalAcceptanceVerifier.cs index e943de315..dee303a58 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Workspace/LocalAcceptanceVerifier.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/LocalAcceptanceVerifier.cs @@ -22,7 +22,7 @@ private protected LocalAcceptanceContext() { } public abstract void Dispose(); } /// Verifies an already selected local invocation world under fresh run authority and ownership. Directory continuity is observed, not a new filesystem access grant. No context is reconstructed from a durable handle after host loss. -public sealed class LocalAcceptanceVerifier(IAgentRunService runs, ExecutionAuthorityService authority, ISupervisorAcceptanceGrader grader, IArtifactManifestStore manifests, IArtifactContentVerifier contentVerifier) : IScopedDependency +public sealed class LocalAcceptanceVerifier(IAgentRunService runs, ExecutionAuthorityService authority, ISupervisorAcceptanceGrader grader, IArtifactManifestStore manifests, IArtifactContentVerifier contentVerifier, Workflows.Runtime.WorkflowSensitivePayloadSealer sealer) : IScopedDependency { public async Task PrepareAsync(LocalAcceptancePreparation request, CancellationToken cancellationToken) { @@ -57,7 +57,7 @@ public async Task GradeAsync(LocalAcceptanceRequest request, Can if (observation.Failure != null) return observation.Failure; var context = (Context)request.Context!; BenchmarkGrade grade; - try { grade = await grader.GradeDirectoryAsync(new DirectoryAcceptanceGradeRequest { Directory = observation.Directory!, Spec = context.Spec!, TeamId = request.TeamId, TimeoutSeconds = context.Spec!.TimeoutSeconds ?? SupervisorLane.AcceptanceGradeTimeoutSeconds, Posture = AcceptanceGradingPosturePolicy.For(request.Task) }, cancellationToken).ConfigureAwait(false); } + try { grade = await grader.GradeDirectoryAsync(new DirectoryAcceptanceGradeRequest { Directory = observation.Directory!, Spec = context.Spec!, TeamId = request.TeamId, TimeoutSeconds = context.Spec!.TimeoutSeconds ?? SupervisorLane.AcceptanceGradeTimeoutSeconds, Posture = AcceptanceGradingPosturePolicy.For(request.Task), WorkflowSecrets = request.Task.WorkflowSecrets }, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException and not Exceptions.AgentRunOwnershipLostException and not AgentAuthorityDeniedException) { return Failed($"local-oracle-unavailable: {ex.GetType().Name}", GradeFailureClass.GraderFault); @@ -121,7 +121,9 @@ public async Task ObserveAsync(LocalAcceptanceReques await authority.EnsureAgentActionAsync(request.Owner.RunId, request.TeamId, cancellationToken).ConfigureAwait(false); await runs.AssertOwnershipAsync(request.Owner, cancellationToken).ConfigureAwait(false); var run = await runs.GetAsync(request.Owner.RunId, cancellationToken).ConfigureAwait(false); - var task = JsonSerializer.Deserialize(run.TaskJson, AgentJson.Options); + // The EXACT stored contract — the executor's own copy was opened from the sealed task, so a masked goal would + // never hash equal to it. + var task = AgentTaskSecrets.Open(run, sealer); if (run.TeamId != request.TeamId || task == null || AgentAcceptanceContract.Hash(task) != AgentAcceptanceContract.Hash(request.Task) || task.WorkspaceDirectory is { } stored && !SamePath(stored, request.Task.WorkspaceDirectory)) return Failed("local-context-mismatch", GradeFailureClass.GraderFault); diff --git a/backend/src/CodeSpace.Core/Services/Supervisor/Executors/RealSupervisorActionExecutor.cs b/backend/src/CodeSpace.Core/Services/Supervisor/Executors/RealSupervisorActionExecutor.cs index 085deed4a..e82d18387 100644 --- a/backend/src/CodeSpace.Core/Services/Supervisor/Executors/RealSupervisorActionExecutor.cs +++ b/backend/src/CodeSpace.Core/Services/Supervisor/Executors/RealSupervisorActionExecutor.cs @@ -193,11 +193,20 @@ internal static string DescribeEdges(IReadOnlyList sub WorkflowRunId = context.SupervisorRunId, OriginKind = WorkPlanOrigins.Supervisor, OriginKey = $"{(string.IsNullOrEmpty(context.NodeId) ? "sup" : context.NodeId)}#turn{context.TurnNumber}", - Goal = string.IsNullOrWhiteSpace(plan.Goal) ? context.Goal : plan.Goal, + Goal = MaskedForDisplay(string.IsNullOrWhiteSpace(plan.Goal) ? context.Goal : plan.Goal), Items = plan.Subtasks.Select(WorkPlanItem.From).ToList(), }, cancellationToken).ConfigureAwait(false); } + /// + /// The plan row is a projection Viewers read, never an execution input (the turn executes from its tape), and its + /// goal is the supervisor's RESOLVED config goal — or the model restating it — so a {{team.X}} in it is masked + /// with the node scope's secrets (the ambient capture redactor every model call of this turn is recorded under) + /// before it lands. Outside a node scope there is nothing to mask with, and the goal is stored as given. + /// + private static string MaskedForDisplay(string goal) => + LlmCallContext.Current?.CaptureRedactor?.Redact(goal).Value ?? goal; + /// A terminal stop records its outcome marker; the turn service finishes the loop (this never re-suspends). private static SupervisorExecution ExecuteStop(SupervisorDecision decision) { diff --git a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorAcceptanceGradeRequest.cs b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorAcceptanceGradeRequest.cs index e8215964f..7074f53a8 100644 --- a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorAcceptanceGradeRequest.cs +++ b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorAcceptanceGradeRequest.cs @@ -16,6 +16,9 @@ public sealed record RepositoryAcceptanceGradeRequest /// The producing run's sandbox posture the grade runs under. Required so no lane can forget it; null grades with network off under the Confined tier's ceilings. public required AcceptanceGradingPosture? Posture { get; init; } + + /// The exact workflow-secret values the producing run's task embeds (AgentTask.WorkflowSecrets). An authored acceptance argv can carry one, and the grade's evidence repeats the argv and the check's output, so everything the grade persists masks them. The check itself runs with the exact argv. Null masks nothing. + public IReadOnlyList? WorkflowSecrets { get; init; } } /// A delayed captured-deliverable acceptance grade with the producer identity that authored the files. @@ -46,6 +49,9 @@ public sealed record PatchAcceptanceGradeRequest /// The producing run's sandbox posture the grade runs under. Required so no lane can forget it; null grades with network off under the Confined tier's ceilings. public required AcceptanceGradingPosture? Posture { get; init; } + + /// The exact workflow-secret values the producing run's task embeds (AgentTask.WorkflowSecrets). An authored acceptance argv can carry one, and the grade's evidence repeats the argv and the check's output, so everything the grade persists masks them. The check itself runs with the exact argv. Null masks nothing. + public IReadOnlyList? WorkflowSecrets { get; init; } } /// A baseline grade of a unit's base tree, under the SAME posture as the candidate it is compared against, so the differential measures the work and not two different sandboxes. @@ -71,4 +77,7 @@ public sealed record DirectoryAcceptanceGradeRequest /// The producing run's sandbox posture the grade runs under. Required so no lane can forget it; null grades with network off under the Confined tier's ceilings. public required AcceptanceGradingPosture? Posture { get; init; } + + /// The exact workflow-secret values the producing run's task embeds (AgentTask.WorkflowSecrets). An authored acceptance argv can carry one, and the grade's evidence repeats the argv and the check's output, so everything the grade persists masks them. The check itself runs with the exact argv. Null masks nothing. + public IReadOnlyList? WorkflowSecrets { get; init; } } diff --git a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorAcceptanceGrader.cs b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorAcceptanceGrader.cs index 237b52b4f..01310a88c 100644 --- a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorAcceptanceGrader.cs +++ b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorAcceptanceGrader.cs @@ -89,7 +89,7 @@ public async Task GradeAsync(RepositoryAcceptanceGradeRequest re if (protection.Failure is not null) return protection.Failure; - return await GradeWorkspaceAsync(new WorkspaceGradeRequest(workspace.Directory, spec, teamId, timeoutSeconds, request.Posture, request.ProducerModel, protection), cancellationToken).ConfigureAwait(false); + return await GradeWorkspaceAsync(new WorkspaceGradeRequest(workspace.Directory, spec, teamId, timeoutSeconds, request.Posture, request.ProducerModel, protection, request.WorkflowSecrets), cancellationToken).ConfigureAwait(false); } catch (WorkspaceException ex) { @@ -115,7 +115,7 @@ public async Task GradeDirectoryAsync(DirectoryAcceptanceGradeRe if (!Directory.Exists(request.Directory)) return new BenchmarkGrade { Passed = false, Detail = "grade-error: the workspace directory no longer exists", Class = Messages.Agents.Benchmark.GradeFailureClass.Environment }; - return await GradeWorkspaceAsync(new WorkspaceGradeRequest(request.Directory, request.Spec, request.TeamId, request.TimeoutSeconds, request.Posture), cancellationToken).ConfigureAwait(false); + return await GradeWorkspaceAsync(new WorkspaceGradeRequest(request.Directory, request.Spec, request.TeamId, request.TimeoutSeconds, request.Posture, WorkflowSecrets: request.WorkflowSecrets), cancellationToken).ConfigureAwait(false); } public Task GradeCapturedAsync(Guid agentRunId, Guid teamId, SupervisorAcceptanceSpec spec, int timeoutSeconds, CancellationToken cancellationToken) => @@ -281,7 +281,7 @@ public async Task GradePatchAsync(PatchAcceptanceGradeRequest re if (protection.Failure is not null) return protection.Failure; - return await GradeWorkspaceAsync(new WorkspaceGradeRequest(directory, spec, teamId, timeoutSeconds, request.Posture, request.ProducerModel, protection), cancellationToken).ConfigureAwait(false); + return await GradeWorkspaceAsync(new WorkspaceGradeRequest(directory, spec, teamId, timeoutSeconds, request.Posture, request.ProducerModel, protection, request.WorkflowSecrets), cancellationToken).ConfigureAwait(false); } catch (WorkspaceException ex) { @@ -699,7 +699,7 @@ private static int BoundedGradeWindow(int timeoutSeconds) => /// private async Task GradeWorkspaceAsync(WorkspaceGradeRequest request, CancellationToken cancellationToken) { - var (directory, spec, teamId, authoredTimeoutSeconds, posture, producerModel, protection) = request; + var (directory, spec, _, authoredTimeoutSeconds, posture, producerModel, protection, _) = request; var timeoutSeconds = BoundedGradeWindow(authoredTimeoutSeconds); var runner = AcceptanceGradingPosturePolicy.Bind(_runners.Resolve(GradingRunnerKind), posture ?? AcceptanceGradingPosturePolicy.FailClosed); var setupSpec = spec.SetupCommand is { Count: > 0 } setupCommand ? SetupSpec(setupCommand, directory, timeoutSeconds) : null; @@ -713,7 +713,7 @@ private async Task GradeWorkspaceAsync(WorkspaceGradeRequest req var beforeSetup = protection.Restored?.Fingerprint(directory); var setupFailure = await RunSetupCommandAsync(runner, setupSpec, severed: setupEgress == SandboxEgressMode.None, cancellationToken).ConfigureAwait(false); - if (setupFailure is not null) return await CaptureEvidenceAsync(WithSetupNotice(setupFailure, setupNotice), teamId, cancellationToken).ConfigureAwait(false); + if (setupFailure is not null) return await CaptureEvidenceAsync(WithSetupNotice(setupFailure, setupNotice), request, cancellationToken).ConfigureAwait(false); protection = await ReassertOracleAsync(directory, protection, beforeSetup, timeoutSeconds, cancellationToken).ConfigureAwait(false); if (protection.Failure is not null) return protection.Failure; @@ -721,7 +721,7 @@ private async Task GradeWorkspaceAsync(WorkspaceGradeRequest req var grade = await RunGuardedCheckAsync(request, runner, timeoutSeconds, protection.Restored, cancellationToken).ConfigureAwait(false); - return await CaptureEvidenceAsync(Annotate(grade, protection, setupNotice), teamId, cancellationToken).ConfigureAwait(false); + return await CaptureEvidenceAsync(Annotate(grade, protection, setupNotice), request, cancellationToken).ConfigureAwait(false); } /// @@ -766,7 +766,7 @@ private static BenchmarkGrade Annotate(BenchmarkGrade grade, OracleProtectionOut return grade with { OracleNote = OracleRuntime.CombineNotes(protection.IntegrityNote, grade.OracleNote) }; } - private sealed record WorkspaceGradeRequest(string Directory, SupervisorAcceptanceSpec Spec, Guid TeamId, int TimeoutSeconds, AcceptanceGradingPosture? Posture, ReviewModelIdentity? ProducerModel = null, OracleProtectionOutcome Protection = default); + private sealed record WorkspaceGradeRequest(string Directory, SupervisorAcceptanceSpec Spec, Guid TeamId, int TimeoutSeconds, AcceptanceGradingPosture? Posture, ReviewModelIdentity? ProducerModel = null, OracleProtectionOutcome Protection = default, IReadOnlyList? WorkflowSecrets = null); /// The narrowed-setup notice this grade owes, logged once, or null when the contract has no setup step or the sandbox left it the network it asks for. private string? SetupNotice(SandboxSpec? setupSpec, AcceptanceGradingPosture posture, SandboxEgressMode? enforced, string directory) @@ -799,15 +799,17 @@ private static BenchmarkGrade WithSetupNotice(BenchmarkGrade grade, string? noti /// P5-2: the clipped EvidenceTail is folded BEFORE the store attempt, so the repair loop's diagnosis /// survives a store fault that the receipt's evidence binding does not. /// - private async Task CaptureEvidenceAsync(BenchmarkGrade grade, Guid teamId, CancellationToken cancellationToken) + private async Task CaptureEvidenceAsync(BenchmarkGrade grade, WorkspaceGradeRequest request, CancellationToken cancellationToken) { + grade = MaskedGrade(grade, request.WorkflowSecrets); + if (string.IsNullOrEmpty(grade.EvidenceText)) return grade; grade = WithClippedEvidenceTail(grade); try { - var id = await _artifacts.PutAsync(teamId, System.Text.Encoding.UTF8.GetBytes(grade.EvidenceText!), "text/plain", cancellationToken).ConfigureAwait(false); + var id = await _artifacts.PutAsync(request.TeamId, System.Text.Encoding.UTF8.GetBytes(grade.EvidenceText!), "text/plain", cancellationToken).ConfigureAwait(false); return grade with { EvidenceArtifactId = id, EvidenceText = null }; } @@ -818,6 +820,28 @@ private async Task CaptureEvidenceAsync(BenchmarkGrade grade, Gu } } + /// + /// The grade with the producing run's workflow secrets () masked + /// out of everything that outlives the check: the evidence text (the authored argv line and both output tails), + /// before it is clipped onto the tail and stored as a team artifact a Viewer can download, the oracle note and the + /// detail (a failed setup's detail carries its stderr). The check ran with the exact argv; only what is kept is + /// masked. No secrets: the grade unchanged. Internal for direct unit pinning. + /// + internal static BenchmarkGrade MaskedGrade(BenchmarkGrade grade, IReadOnlyList? secrets) + { + if (secrets is not { Count: > 0 }) return grade; + + var redactor = new Workflows.Runtime.PersistenceSecretRedactor(secrets); + + return grade with + { + Detail = redactor.Redact(grade.Detail).Value ?? grade.Detail, + EvidenceText = redactor.Redact(grade.EvidenceText).Value, + EvidenceTail = redactor.Redact(grade.EvidenceTail).Value, + OracleNote = redactor.Redact(grade.OracleNote).Value, + }; + } + /// The inline diagnosis budget (P5-2): the trailing slice of the oracle's output kept on the grade for prompt/repair consumers. Small enough to ride the tape and the decider prompt per failed unit; the FULL text is always behind the CAS id. public const int EvidenceTailMaxChars = Agents.AcceptanceEvidenceRenderer.TailMaxChars; diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Engine/ActorIdentityRequirementGate.cs b/backend/src/CodeSpace.Core/Services/Workflows/Engine/ActorIdentityRequirementGate.cs index cb99999c8..01fe5a616 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Engine/ActorIdentityRequirementGate.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Engine/ActorIdentityRequirementGate.cs @@ -47,9 +47,10 @@ public sealed class ActorIdentityRequirementGate : IActorIdentityRequirementGate private readonly IProviderRegistry _providers; private readonly IProviderModuleCatalog _modules; private readonly IScopeChecker _scopeChecker; + private readonly Runtime.WorkflowSensitivePayloadSealer _sealer; private readonly ILogger _logger; - public ActorIdentityRequirementGate(CodeSpaceDbContext db, INodeRegistry nodeRegistry, IActorIdentityResolver actorIdentity, IProviderRegistry providers, IProviderModuleCatalog modules, IScopeChecker scopeChecker, ILogger logger) + public ActorIdentityRequirementGate(CodeSpaceDbContext db, INodeRegistry nodeRegistry, IActorIdentityResolver actorIdentity, IProviderRegistry providers, IProviderModuleCatalog modules, IScopeChecker scopeChecker, Runtime.WorkflowSensitivePayloadSealer sealer, ILogger logger) { _db = db; _nodeRegistry = nodeRegistry; @@ -57,6 +58,7 @@ public ActorIdentityRequirementGate(CodeSpaceDbContext db, INodeRegistry nodeReg _providers = providers; _modules = modules; _scopeChecker = scopeChecker; + _sealer = sealer; _logger = logger; } @@ -88,11 +90,14 @@ public async Task EnsureResponderCanActAsUserAsync(Guid runId, string waitNodeId var definition = definitionJson == null ? null : JsonSerializer.Deserialize(definitionJson, WorkflowJson.Options); if (definition == null) return Array.Empty(); - var payloadJson = await _db.WorkflowRunRequest.AsNoTracking() + // The EXACT payload the run executes with — a sealed child payload is masked in its public column, and the + // requirement is derived from the inputs the act-as node will actually resolve. + var payload = await _db.WorkflowRunRequest.AsNoTracking() .Where(q => q.Id == run.RunRequestId) - .Select(q => q.NormalizedPayloadJson) + .Select(q => new { q.NormalizedPayloadJson, q.SealedPayloadCiphertext }) .SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false); + var payloadJson = payload is null ? null : _sealer.Open(payload.NormalizedPayloadJson, payload.SealedPayloadCiphertext).Json; var inputScope = BuildInputScope(definition, ParsePayloadObject(payloadJson)); return ActorIdentityRequirementPlan.Derive(definition, waitNodeId, ActsAsUserOf, inputScope); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Engine/SubworkflowService.cs b/backend/src/CodeSpace.Core/Services/Workflows/Engine/SubworkflowService.cs index 809e7b9de..d47dcf6e0 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Engine/SubworkflowService.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Engine/SubworkflowService.cs @@ -6,6 +6,7 @@ using CodeSpace.Core.Services.Workflows.RunSources; using CodeSpace.Messages.Constants; using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Workflows; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -21,12 +22,13 @@ public interface ISubworkflowService { /// /// Create (but do NOT dispatch) a child run of with - /// as its normalized payload, linked to + /// as its normalized payload — the public copy in the request's payload column, the + /// sealed exact copy (when a parent secret was masked) beside it — linked to /// via parent_run_id. Returns the new child run id. Throws /// when the child can't be started (not found / not in /// the parent's team / no published version / nesting too deep). /// - Task StageChildRunAsync(WorkflowRun parent, Guid childWorkflowId, int? version, string inputsJson, CancellationToken cancellationToken); + Task StageChildRunAsync(WorkflowRun parent, Guid childWorkflowId, int? version, SealedPayload inputs, CancellationToken cancellationToken); /// Dispatch a previously-staged child run (Pending → Enqueued → engine). Called once the parent is committed Suspended. Task DispatchChildRunAsync(Guid childRunId, CancellationToken cancellationToken); @@ -64,7 +66,7 @@ public SubworkflowService(CodeSpaceDbContext db, IRunStarter runStarter, IWorkfl _logger = logger; } - public async Task StageChildRunAsync(WorkflowRun parent, Guid childWorkflowId, int? version, string inputsJson, CancellationToken cancellationToken) + public async Task StageChildRunAsync(WorkflowRun parent, Guid childWorkflowId, int? version, SealedPayload inputs, CancellationToken cancellationToken) { var child = await _db.Workflow.AsNoTracking() .Where(w => w.Id == childWorkflowId && w.TeamId == parent.TeamId && w.DeletedDate == null) @@ -89,7 +91,8 @@ public async Task StageChildRunAsync(WorkflowRun parent, Guid childWorkflo // The originating actor travels down the chain so the child's audit trail attributes // back to the human who started the root run (System actor + the root user's id). ActorId = parent.CreatedBy == Guid.Empty ? null : parent.CreatedBy, - NormalizedPayloadJson = string.IsNullOrWhiteSpace(inputsJson) ? "{}" : inputsJson, + NormalizedPayloadJson = string.IsNullOrWhiteSpace(inputs.PublicJson) ? "{}" : inputs.PublicJson, + SealedPayloadCiphertext = inputs.Ciphertext, ParentRunId = parent.Id, CreatedBy = parent.CreatedBy, }, cancellationToken).ConfigureAwait(false); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Engine/WorkflowEngine.cs b/backend/src/CodeSpace.Core/Services/Workflows/Engine/WorkflowEngine.cs index 24419ac01..cd08334be 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Engine/WorkflowEngine.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Engine/WorkflowEngine.cs @@ -539,7 +539,8 @@ private static (WorkflowDefinition Definition, string DefinitionHash) LoadDefini /// private async Task BuildScopeFreshAndPersistSnapshotAsync(WorkflowRun run, WorkflowDefinition definition, string releaseHash, CancellationToken cancellationToken) { - var trigger = ParsePayloadObject(run.RunRequest?.NormalizedPayloadJson); + var payload = OpenRunPayload(run); + var trigger = ParsePayloadObject(payload.Json); var input = BuildInputScope(definition, trigger); // Fail-fast: surface required inputs the caller didn't supply AND that have no Default @@ -566,10 +567,26 @@ private async Task BuildScopeFreshAndPersistSnapshotAsync(Workflow var (projects, projectSecretPaths) = await LoadReferencedProjectVariablesAsync(run.TeamId, WorkflowScopeId(run), definition, cancellationToken).ConfigureAwait(false); var secretPaths = CollectSecretPaths(wfResolved, teamResolved, projectSecretPaths); + secretPaths.UnionWith(InheritedSecretTaint.Paths(trigger, input, payload.Secrets)); await PersistFirstRunSnapshotAsync(run, releaseHash, wfResolved, teamResolved, cancellationToken).ConfigureAwait(false); - return new NodeRunScope { Trigger = trigger, Input = input, Wf = wf, Sys = sys, Team = team, Projects = projects, SecretPaths = secretPaths }; + return new NodeRunScope { Trigger = trigger, Input = input, Wf = wf, Sys = sys, Team = team, Projects = projects, SecretPaths = secretPaths, InheritedSecretValues = payload.Secrets }; + } + + /// + /// The run's exact trigger payload and the secrets it inherited — opened from the request's sealed copy when the + /// payload was sealed (a flow.subworkflow child whose inputs carried a parent secret), else read from the + /// public column, which is then itself exact and inherits nothing. Every scope build reads it here, so the child + /// executes with the real value on its first walk, on every resume and on a rerun (which copies the sealed copy). + /// + private OpenedPayload OpenRunPayload(WorkflowRun run) + { + var request = run.RunRequest; + + if (request?.SealedPayloadCiphertext is null) return new OpenedPayload(request?.NormalizedPayloadJson ?? "{}", []); + + return _lifetimeScope.Resolve().Open(request.NormalizedPayloadJson, request.SealedPayloadCiphertext); } /// @@ -584,7 +601,8 @@ private async Task BuildScopeFreshAndPersistSnapshotAsync(Workflow /// private async Task BuildScopeForReplayAsync(WorkflowRun run, WorkflowDefinition definition, CancellationToken cancellationToken) { - var trigger = ParsePayloadObject(run.RunRequest?.NormalizedPayloadJson); + var payload = OpenRunPayload(run); + var trigger = ParsePayloadObject(payload.Json); var input = BuildInputScope(definition, trigger); var sys = BuildSysScope(run); @@ -612,6 +630,7 @@ private async Task BuildScopeForReplayAsync(WorkflowRun run, Workf // freshly-fetched set to drive the paths bag — the snapshot itself stores no plaintext. var secretPaths = new HashSet(wfSecretNames.Select(n => $"wf.{n}").Concat(teamSecretNames.Select(n => $"team.{n}"))); foreach (var p in projectSecretPaths) secretPaths.Add(p); + secretPaths.UnionWith(InheritedSecretTaint.Paths(trigger, input, payload.Secrets)); _logger.LogInformation( "Replay scope built. RunId={RunId} ParentRunId={ParentRunId} SnapshotCount={SnapshotCount}", @@ -626,6 +645,7 @@ private async Task BuildScopeForReplayAsync(WorkflowRun run, Workf Team = team, Projects = projects, SecretPaths = secretPaths, + InheritedSecretValues = payload.Secrets, }; } @@ -2447,7 +2467,7 @@ internal static NodeRunScope BuildMapBranchScope(NodeRunScope outer, JsonElement var scope = new NodeRunScope { Trigger = outer.Trigger, Team = outer.Team, Wf = outer.Wf, Input = outer.Input, - Sys = outer.Sys, Projects = outer.Projects, SecretPaths = outer.SecretPaths, + Sys = outer.Sys, Projects = outer.Projects, SecretPaths = outer.SecretPaths, InheritedSecretValues = outer.InheritedSecretValues, Iteration = iteration, }; @@ -2778,7 +2798,7 @@ internal static NodeRunScope BuildLoopScope(NodeRunScope outer, IEnumerable /// workflow_run_wait row, and — for a Timer wait — schedule the resume at wake_at. The walk /// then throws so the run lands in Suspended. /// - private async Task SuspendNodeAsync(WorkflowRun run, NodeDefinition node, NodeResult result, CancellationToken cancellationToken, string iterationKey = NoIteration) + private async Task SuspendNodeAsync(NodeExecution exec, NodeResult result, CancellationToken cancellationToken) { + var (run, node, iterationKey) = (exec.Run, exec.Node, exec.IterationKey); var token = result.SuspendUntil ?? throw new NodeFailureException($"Node '{node.Id}' returned Suspended without a SuspensionToken."); @@ -3037,8 +3058,8 @@ private async Task SuspendNodeAsync(WorkflowRun run, NodeDefinition node, NodeRe // failure throws SubworkflowStartException, which ExecuteNodeAsync turns into a node failure. var correlationToken = waitKind switch { - WorkflowWaitKinds.Subworkflow => (await StageSubworkflowChildAsync(run, token, stagingCt).ConfigureAwait(false)).ToString(), - WorkflowWaitKinds.AgentRun => (await StageAgentRunAsync(run, node, token, iterationKey, stagingCt).ConfigureAwait(false)).ToString(), + WorkflowWaitKinds.Subworkflow => (await StageSubworkflowChildAsync(exec, token, stagingCt).ConfigureAwait(false)).ToString(), + WorkflowWaitKinds.AgentRun => (await StageAgentRunAsync(exec, token, iterationKey, stagingCt).ConfigureAwait(false)).ToString(), _ => token.CorrelationToken ?? Guid.NewGuid().ToString("N"), }; @@ -3056,7 +3077,7 @@ private async Task SuspendNodeAsync(WorkflowRun run, NodeDefinition node, NodeRe // Stash the node's suspend payload (e.g. an approval prompt) so the run-detail UI can // render the right affordance while parked. Overwritten with the resume payload on // resolve — the engine only reads Resolved waits, so there's no conflict. - PayloadJson = token.Payload.ValueKind == JsonValueKind.Undefined ? null : token.Payload.GetRawText(), + PayloadJson = PersistedSuspendPayload(token, exec.Scope), CreatedAt = DateTimeOffset.UtcNow, }; @@ -3080,6 +3101,16 @@ private async Task SuspendNodeAsync(WorkflowRun run, NodeDefinition node, NodeRe _backgroundJobClient.Schedule(s => s.ResumeByDeadlineAsync(waitId, timeoutPayload.GetRawText(), CancellationToken.None), token.DeadlineAt.Value); } + /// + /// The wait row is a persistence boundary like every ledger row: a Viewer reads it back as the run's pending wait, + /// and the payload a node suspends with is built from RESOLVED inputs and config — an agent's task, a child's + /// inputs — so it is masked with the node scope's secrets here, whatever the wait kind. Nothing executes from the + /// pending row: the staging above takes the in-memory token (and seals what it hands on), and a resolve overwrites + /// the row with its answer. + /// + private static string? PersistedSuspendPayload(SuspensionToken token, NodeRunScope scope) => + token.Payload.ValueKind == JsonValueKind.Undefined ? null : PersistenceSecretRedactor.FromScope(scope).Redact(token.Payload).Value.GetRawText(); + /// The error a staged agent is cancelled with when its step's park was refused because a Continue overtook the step. private const string OvertakenParkAgentReason = "The workflow run was continued while this step was parking; the continued run stages its own agent."; @@ -3131,8 +3162,9 @@ internal static string ValidateWaitKind(string nodeId, string kind) /// the wait it resumes through exists. Returns the agent-run id, which becomes the wait's /// correlation token. A malformed envelope is a clean node failure. /// - private async Task StageAgentRunAsync(WorkflowRun run, NodeDefinition node, SuspensionToken token, string iterationKey, CancellationToken cancellationToken) + private async Task StageAgentRunAsync(NodeExecution exec, SuspensionToken token, string iterationKey, CancellationToken cancellationToken) { + var (run, node) = (exec.Run, exec.Node); AgentTask task; try @@ -3163,6 +3195,10 @@ private async Task StageAgentRunAsync(WorkflowRun run, NodeDefinition node // byte-identical to a fresh run. task = await ApplyResumeHintAsync(task, run, node.Id, iterationKey, cancellationToken).ConfigureAwait(false); + // The task was built from the node's RESOLVED config: name the scope secrets it embeds, so the store persists it + // masked (the exact copy sealed beside it) and the executor masks them in everything the agent echoes. + task = AgentTaskSecrets.Embed(task, PersistenceSecretRedactor.FromScope(exec.Scope)); + // iterationKey is the engine's authoritative effective cell key for this suspension (computed in // SuspendNodeAsync from the branch key ?? the token's own key) — identical to the value stamped on the // wait row + workflow_run_node cell, so the agent run joins back to its EXACT cell (D4). @@ -3201,7 +3237,7 @@ private async Task ApplyResumeHintAsync(AgentTask task, WorkflowRun r /// the suspend payload and create the child via (NOT dispatched /// yet). Returns the child run id, which becomes the wait's correlation token. /// - private async Task StageSubworkflowChildAsync(WorkflowRun run, SuspensionToken token, CancellationToken cancellationToken) + private async Task StageSubworkflowChildAsync(NodeExecution exec, SuspensionToken token, CancellationToken cancellationToken) { var payload = token.Payload; @@ -3211,11 +3247,20 @@ private async Task StageSubworkflowChildAsync(WorkflowRun run, SuspensionT throw new SubworkflowStartException("Sub-workflow node is missing a valid 'workflowId'."); int? version = payload.TryGetProperty("version", out var vEl) && vEl.ValueKind == JsonValueKind.Number && vEl.TryGetInt32(out var v) ? v : null; - var inputsJson = payload.TryGetProperty("inputs", out var inEl) && inEl.ValueKind == JsonValueKind.Object ? inEl.GetRawText() : "{}"; + var inputs = payload.TryGetProperty("inputs", out var inEl) && inEl.ValueKind == JsonValueKind.Object ? inEl : EmptyJsonObject(); - return await _subworkflowService.StageChildRunAsync(run, childWorkflowId, version, inputsJson, cancellationToken).ConfigureAwait(false); + return await _subworkflowService.StageChildRunAsync(exec.Run, childWorkflowId, version, SealChildInputs(inputs, exec.Scope), cancellationToken).ConfigureAwait(false); } + /// + /// The child's inputs were resolved in THIS scope, so its request row gets them masked and the exact copy sealed + /// together with the parent secret values they embed — the child opens that copy to build its scope and keeps + /// those values as secrets (), which its own variable rows could + /// never name. Inputs embedding no secret are staged byte-identical, unsealed. + /// + private SealedPayload SealChildInputs(JsonElement inputs, NodeRunScope scope) => + _lifetimeScope.Resolve().Seal(inputs, PersistenceSecretRedactor.FromScope(scope)); + /// For a Timer suspension the node puts an ISO-8601 wake_at in the token payload. private static DateTimeOffset? ReadWakeAt(SuspensionToken token) { @@ -3332,7 +3377,7 @@ private async Task ExecuteNodeAsync(WorkflowRun run, NodeDefinit { try { - await SuspendNodeAsync(run, node, result, cancellationToken, iterationKey).ConfigureAwait(false); + await SuspendNodeAsync(exec, result, cancellationToken).ConfigureAwait(false); return new NodeRunOutcome(NodeStatus.Suspended, null, null, null); } catch (SubworkflowStartException ex) @@ -3407,7 +3452,7 @@ private async Task ExecuteNodeAsync(WorkflowRun run, NodeDefinit if (exec.ResumePayload is not { } approval) { var token = Rerun.RerunSideEffectGate.BuildApprovalToken(exec.Node.Id, exec.Runtime.Manifest.DisplayName); - await SuspendNodeAsync(exec.Run, exec.Node, NodeResult.Suspend(token), cancellationToken, exec.IterationKey).ConfigureAwait(false); + await SuspendNodeAsync(exec, NodeResult.Suspend(token), cancellationToken).ConfigureAwait(false); return new NodeRunOutcome(NodeStatus.Suspended, null, null, null); } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/FlowIterateNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/FlowIterateNode.cs index 7e3f627b5..87a393a3b 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/FlowIterateNode.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/FlowIterateNode.cs @@ -156,6 +156,7 @@ internal static NodeRunScope BuildIterationScope(NodeRunScope outer, string item Sys = outer.Sys, Projects = outer.Projects, SecretPaths = outer.SecretPaths, + InheritedSecretValues = outer.InheritedSecretValues, Iteration = iteration }; diff --git a/backend/src/CodeSpace.Core/Services/Workflows/RunSources/RunSourceEnvelope.cs b/backend/src/CodeSpace.Core/Services/Workflows/RunSources/RunSourceEnvelope.cs index 17f6733ed..70c715d91 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/RunSources/RunSourceEnvelope.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/RunSources/RunSourceEnvelope.cs @@ -40,6 +40,9 @@ public sealed record RunSourceEnvelope /// The normalised payload the engine sees as {{trigger.*}}. Must be valid JSON. public required string NormalizedPayloadJson { get; init; } + /// The sealed exact copy of when that column holds a masked one — a flow.subworkflow child whose inputs carried a parent secret, or a fork of such a run. Null: the payload is itself exact. + public string? SealedPayloadCiphertext { get; init; } + /// Audit/audit-write user id stamped on the run row. Same as ActorId for user-driven runs; SystemUsers.SeederId for system-driven. public required Guid CreatedBy { get; init; } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/RunSources/RunStarter.cs b/backend/src/CodeSpace.Core/Services/Workflows/RunSources/RunStarter.cs index 5b98ca56e..ceed19bfd 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/RunSources/RunStarter.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/RunSources/RunStarter.cs @@ -70,6 +70,7 @@ public async Task StartAsync(RunSourceEnvelope envelope, CancellationToken ActorType = envelope.ActorType, ActorId = envelope.ActorId, NormalizedPayloadJson = envelope.NormalizedPayloadJson, + SealedPayloadCiphertext = envelope.SealedPayloadCiphertext, Status = WorkflowRunRequestStatus.Consumed, ReceivedAt = now, VerifiedAt = now, diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Runtime/InheritedSecretTaint.cs b/backend/src/CodeSpace.Core/Services/Workflows/Runtime/InheritedSecretTaint.cs new file mode 100644 index 000000000..7e97b5f19 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/Runtime/InheritedSecretTaint.cs @@ -0,0 +1,25 @@ +using System.Text.Json; + +namespace CodeSpace.Core.Services.Workflows.Runtime; + +/// +/// The template paths of a run's own scope that carry a secret it inherited — the trigger / input keys of a +/// flow.subworkflow child whose value holds a parent's secret. Added to , +/// they make the path-based guards (the node.started template redactor, the Terminal output mapping check, a map's +/// sensitivity class) treat {{trigger.token}} exactly like the {{wf.TOKEN}} it came from. +/// +public static class InheritedSecretTaint +{ + /// Every trigger.<key> and input.<key> whose value embeds one of . Empty when nothing was inherited. + public static IReadOnlyList Paths(IReadOnlyDictionary trigger, IReadOnlyDictionary input, IReadOnlyList secrets) + { + if (secrets.Count == 0) return []; + + var redactor = new PersistenceSecretRedactor(secrets); + + return TaintedKeys("trigger", trigger, redactor).Concat(TaintedKeys("input", input, redactor)).ToList(); + } + + private static IEnumerable TaintedKeys(string head, IReadOnlyDictionary bag, PersistenceSecretRedactor redactor) => + bag.Where(entry => redactor.FoundIn(entry.Value).Count > 0).Select(entry => $"{head}.{entry.Key}"); +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Runtime/NodeRunScope.cs b/backend/src/CodeSpace.Core/Services/Workflows/Runtime/NodeRunScope.cs index 9ed98492e..cfce1eb70 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Runtime/NodeRunScope.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Runtime/NodeRunScope.cs @@ -140,6 +140,17 @@ public sealed class NodeRunScope /// fine — they're sent over the wire to whatever provider but never persisted to OutputsJson. /// public IReadOnlySet SecretPaths { get; init; } = new HashSet(); + + /// + /// Exact secret values this run inherited rather than resolved — a flow.subworkflow child receives the + /// parent's wf.* / project.* / team.* values its inputs carry, which none of this run's own + /// buckets name. Not a template head: they only widen , so every + /// persistence boundary and the Terminal leak guard treat them exactly like this run's own secrets, and they pass + /// on to whatever this run hands further down. The input keys that carry them are tainted in + /// (trigger.X / input.X) for the path-based guards. Every scope derived from + /// another (a map branch, a loop pass, an iterate item) copies it alongside . + /// + public IReadOnlyList InheritedSecretValues { get; init; } = []; } /// diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Runtime/PersistenceSecretRedactor.cs b/backend/src/CodeSpace.Core/Services/Workflows/Runtime/PersistenceSecretRedactor.cs index de988d48f..c4c395052 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Runtime/PersistenceSecretRedactor.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Runtime/PersistenceSecretRedactor.cs @@ -20,7 +20,7 @@ public PersistenceSecretRedactor(IEnumerable secrets) => _secrets = secr public static PersistenceSecretRedactor FromScope(NodeRunScope scope) { - var secrets = new List(); + var secrets = new List(scope.InheritedSecretValues); foreach (var path in scope.SecretPaths) { if (TryResolve(scope, path, out var value)) CollectScalarValues(value, secrets); @@ -28,6 +28,20 @@ public static PersistenceSecretRedactor FromScope(NodeRunScope scope) return new PersistenceSecretRedactor(secrets); } + /// + /// The secret values that occur anywhere in — inside a string or a property name, or as an + /// exact non-string scalar: precisely the ones would mask. A payload that crosses + /// into another run (an agent's task, a child's inputs) carries exactly these, so the receiving run treats as + /// secret what it was handed and nothing more of this scope. + /// + public IReadOnlyList FoundIn(JsonElement value) + { + var texts = new List(); + var scalars = new HashSet(StringComparer.Ordinal); + CollectTexts(value, texts, scalars); + return _secrets.Where(secret => scalars.Contains(secret) || texts.Any(text => text.Contains(secret, StringComparison.Ordinal))).ToArray(); + } + public PersistenceRedaction Redact(string? value) { if (string.IsNullOrEmpty(value)) return new PersistenceRedaction(value, false); @@ -98,6 +112,31 @@ private void WriteRedacted(Utf8JsonWriter writer, JsonElement value, ref bool ch } } + private static void CollectTexts(JsonElement value, ICollection texts, ISet scalars) + { + switch (value.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in value.EnumerateObject()) + { + texts.Add(property.Name); + CollectTexts(property.Value, texts, scalars); + } + break; + case JsonValueKind.Array: + foreach (var item in value.EnumerateArray()) CollectTexts(item, texts, scalars); + break; + case JsonValueKind.String: + texts.Add(value.GetString() ?? ""); + break; + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + scalars.Add(value.GetRawText()); + break; + } + } + private static bool TryResolve(NodeRunScope scope, string path, out JsonElement value) { value = default; diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Runtime/WorkflowSensitivePayloadSealer.cs b/backend/src/CodeSpace.Core/Services/Workflows/Runtime/WorkflowSensitivePayloadSealer.cs new file mode 100644 index 000000000..a4fdc876d --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/Runtime/WorkflowSensitivePayloadSealer.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using CodeSpace.Core.DependencyInjection; +using CodeSpace.Messages.Workflows; + +namespace CodeSpace.Core.Services.Workflows.Runtime; + +/// +/// Seals a payload that one run hands to another — an agent's task, a child workflow's inputs — before it lands in a +/// column other readers see. The public copy has every given secret value masked the way a ledger row is; the exact +/// copy, together with those values, is encrypted under the same Data Protection purpose as the node-output sidecar. +/// The receiving run opens the exact copy to execute and keeps the values as secrets in everything it persists. +/// +/// A payload that carries none of the given secrets is returned verbatim with no ciphertext, so a row written +/// for it is byte-identical to one written before sealing existed — and reads such a row (and every +/// pre-sealing row) straight from its public column. +/// +public sealed class WorkflowSensitivePayloadSealer : ISingletonDependency +{ + /// The versioned shape inside the ciphertext. Checked on open, so a ciphertext of another kind sharing the protector purpose (a node-output sidecar) is refused rather than misread. + internal const string EnvelopeKind = "sealed.payload.v1"; + + private readonly IWorkflowSensitivePayloadProtector _protector; + + public WorkflowSensitivePayloadSealer(IWorkflowSensitivePayloadProtector protector) => _protector = protector; + + /// Mask the embeds for its public column; encrypt the exact payload together with exactly those values when anything was masked. + public SealedPayload Seal(JsonElement payload, PersistenceSecretRedactor secrets) + { + var embedded = secrets.FoundIn(payload); + + if (embedded.Count == 0) return new SealedPayload(payload.GetRawText(), null); + + var masked = secrets.Redact(payload).Value.GetRawText(); + var envelope = JsonSerializer.Serialize(new SealedEnvelope(EnvelopeKind, payload, embedded)); + + return new SealedPayload(masked, _protector.Protect(envelope)); + } + + /// The exact payload and the secret values it embeds — read from when the row was sealed, else is itself exact and embeds none. + public OpenedPayload Open(string publicJson, string? ciphertext) + { + if (string.IsNullOrEmpty(ciphertext)) return new OpenedPayload(publicJson, []); + + var envelope = JsonSerializer.Deserialize(_protector.Unprotect(ciphertext)); + + if (envelope is null || envelope.Kind != EnvelopeKind) + throw new InvalidOperationException($"A sealed payload has an unexpected envelope kind '{envelope?.Kind}'."); + + return new OpenedPayload(envelope.Payload.GetRawText(), envelope.Secrets); + } + + private sealed record SealedEnvelope(string Kind, JsonElement Payload, IReadOnlyList Secrets); +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs index 9349015b6..d3e10b7ef 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs @@ -625,6 +625,9 @@ private async Task StageForkedRunAsync(WorkflowRun original, List public IReadOnlyDictionary Environment { get; init; } = new Dictionary(); + /// + /// The resolved workflow secret values this task's text embeds — a goal that referenced {{team.X}}, a child + /// of a parent that handed one down. In-memory only, never serialized: whoever stages the task fills it from + /// the scope that resolved it; the agent-run store masks exactly these values in the persisted task_jsonb + /// and keeps the exact task, with them, in the encrypted sealed column; the executor opens that copy and folds + /// them into the run's redactor, so nothing the agent echoes freezes them into its log or result. Null — every + /// task with no secret in it — changes nothing. + /// + [JsonIgnore] + public IReadOnlyList? WorkflowSecrets { get; init; } + /// Wall-clock cap for the whole agent run, in seconds. Default 3600 (1h). null ⇒ NO wall-clock (unbounded) — the run is then bounded only by the stall watchdog (no-progress) + the cost cap; use for a genuinely long task. Never defaulted to null: an absent value falls back to the bounded default, so only an EXPLICIT null (the operator's "no timeout" choice) is unbounded. public int? TimeoutSeconds { get; init; } = 3600; diff --git a/backend/src/CodeSpace.Messages/Workflows/SealedPayload.cs b/backend/src/CodeSpace.Messages/Workflows/SealedPayload.cs new file mode 100644 index 000000000..c13cf7a30 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Workflows/SealedPayload.cs @@ -0,0 +1,15 @@ +namespace CodeSpace.Messages.Workflows; + +/// +/// A payload prepared for a column other readers see: with every embedded secret value +/// masked, and the exact copy encrypted in . Null ciphertext means nothing was masked, so the +/// public copy is itself exact — the shape every payload without a secret keeps. +/// +public sealed record SealedPayload(string PublicJson, string? Ciphertext); + +/// +/// A sealed payload opened for execution: the exact and the it embeds, which +/// the consumer keeps treating as secrets wherever it persists what it derives from the payload. Empty secrets for a +/// payload that was never sealed. +/// +public sealed record OpenedPayload(string Json, IReadOnlyList Secrets); diff --git a/backend/tests/CodeSpace.E2ETests/Workflows/SecretTaintRunSurfaceE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Workflows/SecretTaintRunSurfaceE2ETests.cs new file mode 100644 index 000000000..ab995a505 --- /dev/null +++ b/backend/tests/CodeSpace.E2ETests/Workflows/SecretTaintRunSurfaceE2ETests.cs @@ -0,0 +1,295 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Workflows.Artifacts; +using CodeSpace.Core.Services.Workflows.Runtime; +using CodeSpace.E2ETests.Infrastructure; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace CodeSpace.E2ETests.Workflows; + +/// +/// The operator's whole path through HTTP: an Owner stores a team secret and a workflow secret, authors a workflow whose +/// agent.run goal and flow.subworkflow inputs reference them, and runs it; the run executes end to end +/// (engine → real executor → agent CLI → resume → child run → resume → terminal). Then a VIEWER of the same team reads +/// every run surface the SPA reads — run detail, records, timeline, pending wait, the agent run and its events, the +/// child's detail — and an all-table + artifact sweep checks what is at rest. Neither may hold either secret, while the +/// agent was launched with both and the child executed with both. +/// +/// Tier: 🟢 High-fidelity — the real ASP.NET pipeline (JWT, X-Team-Id, membership/permission behaviors, +/// controllers), real Postgres, real engine, real AgentRunExecutor + LocalProcessRunner spawning a real +/// process; only the CLI's intelligence is faked at the binary () and the Hangfire +/// transport is the in-test deferred queue. POSIX-only (Rule 12.1). +/// +[Trait("Category", "E2E")] +[Trait("Surface", "Http")] +[Collection(PostgresCollection.Name)] // serial with every fake-arming class in this assembly — they share the process-wide CLI command env vars +public sealed class SecretTaintRunSurfaceE2ETests : IClassFixture +{ + private readonly ReviewedApiFactory _factory; + + public SecretTaintRunSurfaceE2ETests(ReviewedApiFactory factory) { _factory = factory; } + + [Fact] + public async Task A_viewer_reads_no_secret_on_any_run_surface_and_none_lands_at_rest_while_the_run_uses_both() + { + if (OperatingSystem.IsWindows()) return; + + using var cli = new GoalCapturingFakeCli(); + var team = await SeedTeamAsync(); + var teamSecret = $"CANARY-e2e-team-{Guid.NewGuid():N}"; + var workflowSecret = $"CANARY-e2e-wf-{Guid.NewGuid():N}"; + + await SendOkAsync(HttpMethod.Put, "/api/team-variables/DEPLOY_TOKEN", team.OwnerId, team.TeamId, new { name = "DEPLOY_TOKEN", valueType = "Secret", value = teamSecret }); + var childId = await CreateWorkflowAsync(team, ChildDefinition()); + var parentId = await CreateWorkflowAsync(team, ParentDefinition(childId)); + await SendOkAsync(HttpMethod.Put, $"/api/workflows/{parentId}/variables/WF_TOKEN", team.OwnerId, team.TeamId, new { name = "WF_TOKEN", valueType = "Secret", value = workflowSecret }); + + var runId = (await SendOkAsync(HttpMethod.Post, $"/api/workflows/{parentId}/run", team.OwnerId, team.TeamId, new { })).GetProperty("runId").GetGuid(); + await _factory.JobClient.DrainAsync(); + + var (agentRunId, childRunId) = await AssertRanToSuccessAsync(runId); + cli.CapturedGoals.ShouldContain(teamSecret, customMessage: "the agent must be launched with the EXACT team secret its goal referenced"); + cli.CapturedGoals.ShouldContain(workflowSecret, customMessage: "… and the exact workflow secret"); + (await ChildStartOutputsAsync(childRunId))["wfToken"].GetString().ShouldBe(workflowSecret, "the child must execute with the parent's exact workflow secret"); + + var viewerReads = await ViewerReadsAsync(team, runId, childRunId, agentRunId); + + foreach (var secret in new[] { teamSecret, workflowSecret }) + { + viewerReads.ShouldNotContain(secret, customMessage: "a Viewer read a resolved secret off a run surface"); + (await SweepAsync(team.TeamId, secret)).ShouldBeEmpty("a resolved secret froze into a table or an artifact"); + } + } + + [Fact] + public async Task A_viewer_reads_no_secret_in_the_reviewers_verdict_or_the_acceptance_evidence_it_can_download() + { + // The goal and the acceptance argv both carry the team secret. The argv runs with it (the check passes only on + // the exact value), the output reviewer is shown it and quotes it, and both the verdict's words and the grade's + // evidence artifact reach surfaces a Viewer reads or downloads. + if (OperatingSystem.IsWindows()) return; + + using var cli = new GoalCapturingFakeCli(); + var team = await SeedTeamAsync(); + var teamSecret = $"CANARY-e2e-review-{Guid.NewGuid():N}"; + var reviewerModelId = await SeedReviewerModelAsync(team.TeamId); + + await SendOkAsync(HttpMethod.Put, "/api/team-variables/DEPLOY_TOKEN", team.OwnerId, team.TeamId, new { name = "DEPLOY_TOKEN", valueType = "Secret", value = teamSecret }); + var workflowId = await CreateWorkflowAsync(team, ReviewedAgentDefinition(reviewerModelId)); + + var runId = (await SendOkAsync(HttpMethod.Post, $"/api/workflows/{workflowId}/run", team.OwnerId, team.TeamId, new { })).GetProperty("runId").GetGuid(); + await _factory.JobClient.DrainAsync(); + + var (agentRunId, evidenceId) = await AssertReviewedAndGradedAsync(runId); + cli.CapturedGoals.ShouldContain(teamSecret, customMessage: "the agent must be launched with the EXACT team secret its goal referenced"); + _factory.Services.GetRequiredService().Quoted.ShouldContain(quote => quote.Contains(teamSecret), "the reviewer was shown the EXACT goal and quoted it"); + + var viewerReads = await ViewerReadsAsync(team, runId, null, agentRunId); + var evidence = await SendAsync(HttpMethod.Get, $"/api/artifacts/{evidenceId}", team.ViewerId, team.TeamId); + evidence.StatusCode.ShouldBe(HttpStatusCode.OK, "a Viewer downloads the acceptance evidence"); + var evidenceText = await evidence.Content.ReadAsStringAsync(); + + evidenceText.ShouldContain($"graded with {PersistenceSecretRedactor.Marker}", customMessage: "the evidence still shows what the check printed — masked, not dropped"); + viewerReads.ShouldContain("The task was: ", customMessage: "the reviewer's verdict reaches the Viewer — masked, not dropped"); + evidenceText.ShouldNotContain(teamSecret, customMessage: "the acceptance evidence a Viewer downloads held the resolved secret"); + viewerReads.ShouldNotContain(teamSecret, customMessage: "a Viewer read a resolved secret off a run surface"); + (await SweepAsync(team.TeamId, teamSecret)).ShouldBeEmpty("a resolved secret froze into a table or an artifact"); + } + + // ── Definitions ────────────────────────────────────────────────────────────────────────────────────────── + + private static WorkflowDefinition ReviewedAgentDefinition(Guid reviewerModelId) => new() + { + SchemaVersion = 1, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = Json("{}"), Inputs = Json("{}") }, + new() { Id = "agent", TypeKey = "agent.run", Inputs = Json("{}"), + Config = Json($$$"""{ "goal": "Deploy with bearer {{team.DEPLOY_TOKEN}} {{{DeterministicCriticLlmClient.QuoteGoalMarker}}}", "harness": "codex-cli", "runnerKind": "local", "timeoutSeconds": 300, "outputReviewMode": 1, "reviewerModelId": "{{{reviewerModelId}}}", "acceptance": { "kind": "TestsPass", "command": ["sh", "-c", "test '{{team.DEPLOY_TOKEN}}' != '[REDACTED]' && echo graded with {{team.DEPLOY_TOKEN}}"] } }""") }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = Json("{}"), Inputs = Json("""{ "status": "ok" }""") }, + }, + Edges = new List { new() { From = "start", To = "agent" }, new() { From = "agent", To = "end" } }, + }; + + private static WorkflowDefinition ParentDefinition(Guid childWorkflowId) => new() + { + SchemaVersion = 1, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = Json("{}"), Inputs = Json("{}") }, + new() { Id = "agent", TypeKey = "agent.run", Inputs = Json("{}"), + Config = Json("""{ "goal": "Deploy with bearer {{team.DEPLOY_TOKEN}} and sign with {{wf.WF_TOKEN}}", "harness": "codex-cli", "runnerKind": "local", "timeoutSeconds": 300 }""") }, + new() { Id = "sub", TypeKey = "flow.subworkflow", Config = Json($$"""{ "workflowId": "{{childWorkflowId}}" }"""), + Inputs = Json("""{ "inputs": { "token": "{{team.DEPLOY_TOKEN}}", "wfToken": "{{wf.WF_TOKEN}}" } }""") }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = Json("{}"), Inputs = Json("""{ "status": "ok" }""") }, + }, + Edges = new List { new() { From = "start", To = "agent" }, new() { From = "agent", To = "sub" }, new() { From = "sub", To = "end" } }, + }; + + private static WorkflowDefinition ChildDefinition() => new() + { + SchemaVersion = 1, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = Json("{}"), Inputs = Json("{}") }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = Json("{}"), Inputs = Json("""{ "status": "ok" }""") }, + }, + Edges = new List { new() { From = "start", To = "end" } }, + }; + + private static JsonElement Json(string raw) => JsonDocument.Parse(raw).RootElement.Clone(); + + // ── HTTP ───────────────────────────────────────────────────────────────────────────────────────────────── + + private sealed record Team(Guid TeamId, Guid OwnerId, Guid ViewerId); + + private async Task CreateWorkflowAsync(Team team, WorkflowDefinition definition) => + (await SendOkAsync(HttpMethod.Post, "/api/workflows", team.OwnerId, team.TeamId, new { name = $"taint-{Guid.NewGuid():N}"[..14], definition, activations = Array.Empty(), enabled = true })).GetProperty("id").GetGuid(); + + /// One request through the real pipeline, as in ; asserts a 2xx and hands back the JSON body (an empty object for 204). + private async Task SendOkAsync(HttpMethod method, string url, Guid userId, Guid teamId, object? body = null) + { + var response = await SendAsync(method, url, userId, teamId, body); + var text = await response.Content.ReadAsStringAsync(); + + response.IsSuccessStatusCode.ShouldBeTrue($"{method} {url} answered {(int)response.StatusCode}: {text}"); + + return JsonDocument.Parse(string.IsNullOrWhiteSpace(text) ? "{}" : text).RootElement.Clone(); + } + + private async Task SendAsync(HttpMethod method, string url, Guid userId, Guid teamId, object? body = null) + { + var request = new HttpRequestMessage(method, url) { Content = body is null ? null : JsonContent.Create(body) }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", TestToken.Mint(userId, TestToken.SeedStamp)); + request.Headers.Add("X-Team-Id", teamId.ToString()); + + return await _factory.CreateClient().SendAsync(request); + } + + /// Every run surface the SPA reads, fetched as the VIEWER and concatenated. The main reads must answer 200, so an empty body can never pass for "nothing leaked". + private async Task ViewerReadsAsync(Team team, Guid runId, Guid? childRunId, Guid agentRunId) + { + var childReads = childRunId is { } child ? new[] { $"/api/workflows/runs/{child}", $"/api/workflows/runs/{child}/records" } : []; + var mustAnswer = new[] + { + $"/api/workflows/runs/{runId}", $"/api/workflows/runs/{runId}/records", $"/api/workflows/runs/{runId}/timeline", + $"/api/agents/runs/{agentRunId}", $"/api/agents/runs/{agentRunId}/events", + }.Concat(childReads); + var reads = new List(); + + foreach (var url in mustAnswer) + { + var response = await SendAsync(HttpMethod.Get, url, team.ViewerId, team.TeamId); + response.StatusCode.ShouldBe(HttpStatusCode.OK, $"a Viewer reads {url}"); + reads.Add(await response.Content.ReadAsStringAsync()); + } + + reads.Add(await (await SendAsync(HttpMethod.Get, $"/api/workflows/runs/{runId}/pending-wait", team.ViewerId, team.TeamId)).Content.ReadAsStringAsync()); + + return string.Join("\n", reads); + } + + // ── State ──────────────────────────────────────────────────────────────────────────────────────────────── + + private async Task SeedTeamAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var suffix = Guid.NewGuid().ToString("N")[..8]; + var team = new Team(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()); + + db.Team.Add(new Core.Persistence.Entities.Team { Id = team.TeamId, Slug = $"taint-{suffix}", Name = "Taint", Kind = TeamKind.Workspace, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + foreach (var (userId, role) in new[] { (team.OwnerId, TeamRole.Owner), (team.ViewerId, TeamRole.Viewer) }) + { + db.User.Add(new User { Id = userId, SecurityStamp = TestToken.SeedStamp, Email = $"taint-{role}-{suffix}@test.local", Name = $"Taint {role}", CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + db.TeamMembership.Add(new TeamMembership { Id = Guid.NewGuid(), TeamId = team.TeamId, UserId = userId, Role = role, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + } + + await db.SaveChangesAsync(); + return team; + } + + /// The run reached Success, and the ids of its agent run and its child run — the two runs the secrets were handed to. + private async Task<(Guid AgentRunId, Guid ChildRunId)> AssertRanToSuccessAsync(Guid runId) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var run = await db.WorkflowRun.AsNoTracking().SingleAsync(candidate => candidate.Id == runId); + + run.Status.ShouldBe(WorkflowRunStatus.Success, $"the whole chain ran to terminal; error: {run.Error}"); + + var agentRunId = await db.AgentRun.AsNoTracking().Where(agent => agent.WorkflowRunId == runId).Select(agent => agent.Id).SingleAsync(); + var childRunId = await db.WorkflowRun.AsNoTracking().Where(child => child.ParentRunId == runId).Select(child => child.Id).SingleAsync(); + + return (agentRunId, childRunId); + } + + /// The run reached Success with its agent run reviewed and approved and its acceptance graded with stored evidence; the agent run's id and that evidence's artifact id. + private async Task<(Guid AgentRunId, Guid EvidenceId)> AssertReviewedAndGradedAsync(Guid runId) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var run = await db.WorkflowRun.AsNoTracking().SingleAsync(candidate => candidate.Id == runId); + var agent = await db.AgentRun.AsNoTracking().SingleAsync(candidate => candidate.WorkflowRunId == runId); + + run.Status.ShouldBe(WorkflowRunStatus.Success, $"the run ran to terminal; error: {run.Error}; agent result: {agent.ResultJson}"); + agent.Status.ShouldBe(AgentRunStatus.Succeeded); + var result = JsonDocument.Parse(agent.ResultJson!).RootElement; + result.GetProperty("acceptancePassed").GetBoolean().ShouldBeTrue("the check passes only with the exact value"); + + return (agent.Id, result.GetProperty("acceptanceEvidenceId").GetGuid()); + } + + private async Task SeedReviewerModelAsync(Guid teamId) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var credentialId = Guid.NewGuid(); + var rowId = Guid.NewGuid(); + + db.ModelCredential.Add(new ModelCredential { Id = credentialId, TeamId = teamId, Provider = DeterministicCriticLlmClient.ProviderTag, DisplayName = "reviewer", Status = CredentialStatus.Active, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + db.ModelCredentialModel.Add(new ModelCredentialModel { Id = rowId, ModelCredentialId = credentialId, ModelId = "critic-model", Source = ModelSource.Manual, Enabled = true }); + await db.SaveChangesAsync(); + + return rowId; + } + + /// The exact outputs of the child's trigger node, from the encrypted sidecar the engine writes only when it masked the public row. + private async Task> ChildStartOutputsAsync(Guid childRunId) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var child = await db.WorkflowRun.AsNoTracking().SingleAsync(run => run.Id == childRunId); + var recordId = await db.WorkflowRunRecord.AsNoTracking().Where(record => record.RunId == childRunId && record.NodeId == "start" && record.RecordType == WorkflowRunRecordTypes.NodeCompleted).Select(record => record.Id).SingleAsync(); + + return (await scope.ServiceProvider.GetRequiredService().ReadNodeOutputsAsync(recordId, childRunId, child.TeamId, CancellationToken.None)).ShouldNotBeNull(); + } + + /// The launch host plus the deterministic output-review critic (provider ), the only model fake these runs reach. A team that seeds no reviewer model never resolves it. + public sealed class ReviewedApiFactory : TaskLaunchApiFactory + { + protected override void ConfigureContractTestServices(ContainerBuilder builder) + { + builder.RegisterType().AsSelf().SingleInstance(); + builder.RegisterType().As().As().SingleInstance(); + } + } + + private async Task> SweepAsync(Guid teamId, string secret) + { + using var scope = _factory.Services.CreateScope(); + return await SecretCanarySweep.FindAsync(_factory.ConnectionString, scope.ServiceProvider.GetRequiredService(), scope.ServiceProvider.GetRequiredService(), teamId, secret); + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Agents/AgentTaskSealingFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Agents/AgentTaskSealingFlowTests.cs new file mode 100644 index 000000000..c072d35ae --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Agents/AgentTaskSealingFlowTests.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Agents; +using CodeSpace.Core.Services.Agents.Review; +using CodeSpace.Core.Services.Workflows.Artifacts; +using CodeSpace.Core.Services.Workflows.Lifecycle; +using CodeSpace.Core.Services.Workflows.Llm; +using CodeSpace.Core.Services.Workflows.Runtime; +using CodeSpace.Core.Services.Workflows.RunSources; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using CodeSpace.Messages.Agents; +using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Enums; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Agents; + +/// +/// The agent-run store's half of the secret-taint rule, against real Postgres: a task created while a workflow node +/// executes (a supervisor's spawn, a plan reviewer — every caller that is not the engine's own agent.run staging) is +/// sealed with that node scope's secrets, read from the ambient capture redactor the engine pushes for the node; and +/// a review child, briefed with its producer's goal, inherits the producer's sealed secrets. In both, task_jsonb is +/// masked, the sealed copy opens to the exact task, and the opened task still names the secrets for the executor. +/// +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class AgentTaskSealingFlowTests +{ + private readonly PostgresFixture _fixture; + + public AgentTaskSealingFlowTests(PostgresFixture fixture) => _fixture = fixture; + + [Fact] + public async Task A_task_created_inside_a_node_scope_is_sealed_with_that_scopes_secrets() + { + var canary = Canary(); + var seed = await SeedAsync(); + using var scope = _fixture.BeginScope(); + var runId = await scope.Resolve().StartAsync(Manual(seed), CancellationToken.None); + var runs = scope.Resolve(); + + AgentRun inNode; + using (LlmCallContext.Push(NodeScope(scope, seed, runId, new PersistenceSecretRedactor([canary, "a-scope-secret-the-task-never-names"])))) + inNode = await runs.CreateAsync(Task($"Deploy with {canary}"), seed.TeamId, runId, "sup", cancellationToken: CancellationToken.None); + + var stored = await LoadAsync(inNode.Id); + stored.TaskJson.ShouldNotContain(canary); + stored.SealedTaskCiphertext.ShouldNotBeNull(); + + var opened = AgentTaskSecrets.Open(stored, scope.Resolve())!; + opened.Goal.ShouldBe($"Deploy with {canary}", "the executor opens the exact goal"); + opened.WorkflowSecrets.ShouldBe([canary], "only the scope values the task embeds ride it"); + } + + [Fact] + public async Task A_task_created_outside_any_node_scope_is_stored_as_given() + { + // No node scope, no secret this store could know of: a standalone launch persists byte-for-byte as before. + var seed = await SeedAsync(); + using var scope = _fixture.BeginScope(); + var runId = await scope.Resolve().StartAsync(Manual(seed), CancellationToken.None); + + var created = await scope.Resolve().CreateAsync(Task("Summarize the release"), seed.TeamId, runId, "agent", cancellationToken: CancellationToken.None); + + var stored = await LoadAsync(created.Id); + stored.SealedTaskCiphertext.ShouldBeNull(); + JsonSerializer.Deserialize(stored.TaskJson, AgentJson.Options)!.Goal.ShouldBe("Summarize the release"); + } + + [Fact] + public async Task A_review_child_inherits_its_producers_sealed_secrets() + { + var canary = Canary(); + var seed = await SeedAsync(); + using var scope = _fixture.BeginScope(); + var repositoryId = await SeedRepositoryAsync(scope, seed.TeamId); + var runId = await scope.Resolve().StartAsync(Manual(seed), CancellationToken.None); + var runs = scope.Resolve(); + + var producer = await runs.CreateAsync(Task($"Deploy with {canary}") with { RepositoryId = repositoryId, WorkflowSecrets = [canary] }, seed.TeamId, runId, "producer", cancellationToken: CancellationToken.None); + var owner = (await runs.ClaimOwnershipAsync(producer.Id, CancellationToken.None)).ShouldNotBeNull(); + var reviewTask = AgentReviewRunner.BuildReviewTask(new AgentReviewSpec { ParentOwner = owner, RepositoryId = repositoryId, TeamId = seed.TeamId, SubjectInstructions = $"Review the work for: Deploy with {canary}", IterationKey = "#review" }, "test"); + + var child = await runs.CreateReviewAsync(new AgentReviewCreation(owner, seed.TeamId, reviewTask), CancellationToken.None); + + var stored = await LoadAsync(child.Id); + stored.TaskJson.ShouldNotContain(canary, customMessage: "the reviewer is briefed with the producer's goal, so its task carries the producer's secret too"); + AgentTaskSecrets.Open(stored, scope.Resolve())!.WorkflowSecrets.ShouldBe([canary]); + } + + private static string Canary() => $"CANARY-sealing-{Guid.NewGuid():N}"; + + private static AgentTask Task(string goal) => new() { Goal = goal, Harness = "test", Autonomy = AgentAutonomyLevel.Unleashed, Permissions = AgentAutonomyPolicy.Derive(AgentAutonomyLevel.Unleashed) }; + + private static LlmCallScope NodeScope(ILifetimeScope scope, Seed seed, Guid runId, PersistenceSecretRedactor redactor) => + new(runId, seed.TeamId, "sup", "", "agent.supervisor", scope.Resolve(), scope.Resolve(), CaptureRedactor: redactor); + + private async Task LoadAsync(Guid agentRunId) + { + using var scope = _fixture.BeginScope(); + return await scope.Resolve().AgentRun.AsNoTracking().SingleAsync(run => run.Id == agentRunId); + } + + private async Task SeedAsync() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + using var scope = _fixture.BeginScopeAs(userId, teamId); + var workflowId = await scope.Resolve().Send(new CreateWorkflowCommand { Name = $"sealing-{Guid.NewGuid():N}", Definition = WorkflowsTestSeed.MinimalDefinition(), Activations = [], Enabled = true }); + + return new Seed(teamId, userId, workflowId); + } + + private static async Task SeedRepositoryAsync(ILifetimeScope scope, Guid teamId) + { + var db = scope.Resolve(); + var provider = new ProviderInstance { Id = Guid.NewGuid(), TeamId = teamId, Provider = ProviderKind.GitHub, DisplayName = "review", BaseUrl = "https://example.test" }; + var repository = new Repository { Id = Guid.NewGuid(), TeamId = teamId, ProviderInstanceId = provider.Id, ExternalId = Guid.NewGuid().ToString(), NamespacePath = "team", Name = "repo", FullPath = "team/repo", WebUrl = "https://example.test/repo" }; + + db.ProviderInstance.Add(provider); + db.Repository.Add(repository); + await db.SaveChangesAsync(); + + return repository.Id; + } + + private static RunSourceEnvelope Manual(Seed seed) => new() { TeamId = seed.TeamId, WorkflowId = seed.WorkflowId, WorkflowVersion = 1, SourceType = WorkflowRunSourceTypes.Manual, ActorType = WorkflowRunActorTypes.User, ActorId = seed.UserId, CreatedBy = seed.UserId, NormalizedPayloadJson = "{}" }; + + private sealed record Seed(Guid TeamId, Guid UserId, Guid WorkflowId); +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Infrastructure/SecretCanarySweep.cs b/backend/tests/CodeSpace.IntegrationTests/Infrastructure/SecretCanarySweep.cs new file mode 100644 index 000000000..712b60fd7 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Infrastructure/SecretCanarySweep.cs @@ -0,0 +1,89 @@ +using System.Text; +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Workflows.Artifacts; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace CodeSpace.IntegrationTests.Infrastructure; + +/// +/// Where a unique secret canary landed at rest: every row of EVERY base table whose text form carries it (so every +/// text / jsonb / bytea column — a bytea renders as hex, which is searched too), plus the stored bytes of every +/// artifact the team owns. A test asserts the list is empty, so a new table, column or offload path that freezes a +/// resolved secret reds here without the test having to know it exists. Encrypted columns hold ciphertext, never the +/// canary's bytes, so they never match. +/// +public static class SecretCanarySweep +{ + public static async Task> FindAsync(PostgresFixture fixture, Guid teamId, string canary) + { + using var scope = fixture.BeginScope(); + return await FindAsync(fixture.ConnectionString, scope.Resolve(), scope.Resolve(), teamId, canary); + } + + /// The same sweep against any host's database — an API host under test hands its own connection string and services. + public static async Task> FindAsync(string connectionString, IArtifactStore store, CodeSpaceDbContext db, Guid teamId, string canary) + { + var hits = new List(); + + hits.AddRange(await FindInTablesAsync(connectionString, canary)); + hits.AddRange(await FindInArtifactsAsync(store, db, teamId, canary)); + + return hits; + } + + private static async Task> FindInTablesAsync(string connectionString, string canary) + { + var hits = new List(); + var hex = Convert.ToHexString(Encoding.UTF8.GetBytes(canary)).ToLowerInvariant(); + + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + + foreach (var table in await BaseTablesAsync(connection)) + { + await using var command = new NpgsqlCommand($"SELECT x::text FROM {table} x WHERE x::text LIKE @plain OR x::text LIKE @hex LIMIT 5", connection); + command.Parameters.AddWithValue("plain", "%" + canary + "%"); + command.Parameters.AddWithValue("hex", "%" + hex + "%"); + + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) hits.Add($"{table}: {Around(reader.GetString(0), canary, hex)}"); + } + + return hits; + } + + private static async Task> BaseTablesAsync(NpgsqlConnection connection) + { + var tables = new List(); + + await using var command = new NpgsqlCommand("SELECT quote_ident(table_schema) || '.' || quote_ident(table_name) FROM information_schema.tables WHERE table_type = 'BASE TABLE' AND table_schema NOT IN ('pg_catalog', 'information_schema')", connection); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) tables.Add(reader.GetString(0)); + + return tables; + } + + private static async Task> FindInArtifactsAsync(IArtifactStore store, CodeSpaceDbContext db, Guid teamId, string canary) + { + var artifactIds = await db.WorkflowArtifact.AsNoTracking().Where(artifact => artifact.TeamId == teamId).Select(artifact => artifact.Id).ToListAsync(); + var hits = new List(); + + foreach (var artifactId in artifactIds) + { + var bytes = (await store.GetBytesAsync(teamId, artifactId, CancellationToken.None))?.Bytes; + if (bytes is not null && Encoding.UTF8.GetString(bytes).Contains(canary, StringComparison.Ordinal)) hits.Add($"artifact {artifactId}"); + } + + return hits; + } + + private static string Around(string text, string canary, string hex) + { + var index = text.IndexOf(canary, StringComparison.Ordinal); + if (index < 0) index = text.IndexOf(hex, StringComparison.Ordinal); + + return "…" + text[Math.Max(0, index - 160)..Math.Min(text.Length, index + canary.Length + 40)] + "…"; + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/Infrastructure/DeterministicCriticLlmClient.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/Infrastructure/DeterministicCriticLlmClient.cs index b4273cb94..2e0811e91 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/Infrastructure/DeterministicCriticLlmClient.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/Infrastructure/DeterministicCriticLlmClient.cs @@ -26,6 +26,15 @@ public sealed class DeterministicCriticLlmClient : ILLMClient, IStructuredLLMCli /// An artifact carrying this marker gets a verdict with NO observed wire identity — the honest fake's ONE deliberate unknown-identity flow (a missing/untrusted provider observation), kept alongside the default known-identity answer below so both branches of ObservedLlmModel.FromWire stay exercised by the shared fake. public const string UnknownIdentityMarker = "NO-WIRE-IDENTITY"; + /// + /// A prompt carrying this marker draws a verdict that QUOTES the first prompt line carrying it — the goal, which + /// leads the prompt — in its rationale and, when flagged, in its issue evidence: a model restating the task it + /// judges, which is how a goal's resolved secret reaches the verdict's words. The quote is recorded on + /// . Unlike it repeats whatever the goal holds, so it + /// is for single-pass (Gate) reviews only. + /// + public const string QuoteGoalMarker = "QUOTE-THE-GOAL"; + /// The deterministic critique the disapproval carries — the revise loop feeds it back, so the E2Es assert it verbatim in the revise instruction. public const string Critique = "the change still carries a placeholder hack"; @@ -59,11 +68,23 @@ public Task CompleteStructuredAsync(StructuredLLMComple ? JsonSerializer.SerializeToElement(new { approved = false, score = 8, issues = new[] { new { issue = "a terse local name could be clearer", evidence = "the naming in the diff", severity = "minor" } }, rationale = "sound; one cosmetic nit" }) : JsonSerializer.SerializeToElement(new { approved = true, score = 9, issues = Array.Empty(), rationale = "clean and goal-aligned" }); + if (QuotedLine(request.UserPrompt) is { } quote) + { + _script.Quoted.Add(quote); + json = Quoting(flawed, quote); + } + // Known identity by default — an honest fake echoes what a real provider's wire response would carry, so // the critic's ObservedLlmModel.FromWire policy has a real name to accept on every OTHER flow through this // fake (the blind spot a null-only ObservedModel used to leave). UnknownIdentityMarker opts one flow OUT. return Task.FromResult(new StructuredLLMCompletion { Json = json, Model = request.Model, ObservedModel = unknownIdentity ? null : request.Model, Usage = new() { InputTokens = 11, OutputTokens = 7 } }); } + + private static string? QuotedLine(string prompt) => prompt.Split('\n').FirstOrDefault(line => line.Contains(QuoteGoalMarker, StringComparison.Ordinal))?.Trim(); + + private static JsonElement Quoting(bool flawed, string quote) => flawed + ? JsonSerializer.SerializeToElement(new { approved = false, score = 3, issues = new[] { new { issue = "the result does not do what the task asked", evidence = quote, severity = "blocker" } }, rationale = $"The task was: \"{quote}\"." }) + : JsonSerializer.SerializeToElement(new { approved = true, score = 9, issues = Array.Empty(), rationale = $"The task was: \"{quote}\". The result does it." }); } /// The fixture-singleton counter the critic fake bumps per structured call — how a test proves the ORDER of billing (e.g. a failed oracle never bills a review). Reset it at test start (the fixture is shared across the Postgres collection). @@ -71,5 +92,12 @@ public sealed class CriticReviewScript { public int Calls { get; set; } - public void Reset() => Calls = 0; + /// Every prompt line a verdict quoted, in call order — what the reviewer was actually shown. + public List Quoted { get; } = new(); + + public void Reset() + { + Calls = 0; + Quoted.Clear(); + } } diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/Infrastructure/GoalCapturingFakeCli.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/Infrastructure/GoalCapturingFakeCli.cs new file mode 100644 index 000000000..ac1c66bbc --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/Infrastructure/GoalCapturingFakeCli.cs @@ -0,0 +1,65 @@ +using CodeSpace.Core.Services.Agents.Harnesses.Claude; +using CodeSpace.Core.Services.Agents.Harnesses.Codex; + +namespace CodeSpace.IntegrationTests.Workflows.Infrastructure; + +/// +/// A fake agent CLI that does the two things a secret-taint test needs from a real agent: it RECORDS the exact goal it +/// was launched with (appended to , a file outside the database), and it ECHOES that goal +/// into every stream the executor persists — a reasoning line, the assistant message the result folds into its +/// summary, and the closing completion. So a test can prove both halves at once: the agent was handed the real secret, +/// and nothing the run stored kept it. +/// +/// The REAL AgentRunExecutor + LocalProcessRunner spawn this script (both harness command env vars +/// point at it, and it serves both dialects via ), so only the CLI's intelligence is +/// faked. Codex event shapes mirror , whose drift detector pins them. +/// +public sealed class GoalCapturingFakeCli : IDisposable +{ + /// The prefix of the assistant message the script prints in front of the goal. + public const string EchoPrefix = "I will call the API with "; + + private readonly string _originalCommand; + private readonly string _originalClaudeCommand; + private readonly string _dir; + + public GoalCapturingFakeCli() + { + _dir = Path.Combine(Path.GetTempPath(), "cs-goalcapture-fakecli-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + + var script = Path.Combine(_dir, "fake-agent.sh"); + File.WriteAllText(script, ScriptBody(CapturedGoalsPath)); + File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + + _originalCommand = Environment.GetEnvironmentVariable(CodexHarness.CommandEnvVar) ?? ""; + _originalClaudeCommand = Environment.GetEnvironmentVariable(ClaudeCodeHarness.CommandEnvVar) ?? ""; + Environment.SetEnvironmentVariable(CodexHarness.CommandEnvVar, script); + Environment.SetEnvironmentVariable(ClaudeCodeHarness.CommandEnvVar, script); + } + + private string CapturedGoalsPath => Path.Combine(_dir, "goals.txt"); + + /// Every goal an agent was launched with, in launch order — read from the file the script appended to, never from the database. + public string CapturedGoals => File.Exists(CapturedGoalsPath) ? File.ReadAllText(CapturedGoalsPath) : ""; + + public void Dispose() + { + Environment.SetEnvironmentVariable(CodexHarness.CommandEnvVar, _originalCommand.Length == 0 ? null : _originalCommand); + Environment.SetEnvironmentVariable(ClaudeCodeHarness.CommandEnvVar, _originalClaudeCommand.Length == 0 ? null : _originalClaudeCommand); + try { Directory.Delete(_dir, recursive: true); } catch { /* best-effort */ } + } + + /// Strictly POSIX: read the goal, append it to the capture file, JSON-escape it, and print a stream whose every event repeats it. + private static string ScriptBody(string capturePath) => + "#!/bin/sh\n" + + FakeAgentCliDialect.ReadGoal + + "printf '%s\\n' \"$goal\" >> '" + capturePath + "'\n" + + "esc=$(printf '%s' \"$goal\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g')\n" + + FakeAgentCliDialect.Dialects( + "printf '{\"type\":\"agent_reasoning\",\"message\":\"Planning work for: %s\"}\\n' \"$esc\"\n" + + "printf '{\"type\":\"agent_message\",\"message\":\"" + EchoPrefix + "%s\"}\\n' \"$esc\"\n" + + "printf '{\"type\":\"task_complete\",\"message\":\"completed\"}\\n'\n", + EchoPrefix + "%s") + + "exit 0\n"; +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SecretTaintAcrossSuspendsFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SecretTaintAcrossSuspendsFlowTests.cs new file mode 100644 index 000000000..5eea39382 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SecretTaintAcrossSuspendsFlowTests.cs @@ -0,0 +1,682 @@ +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Agents; +using CodeSpace.Core.Services.Variables; +using CodeSpace.Core.Services.Workflows.Engine; +using CodeSpace.Core.Services.Workflows.Runtime; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Infrastructure.Jobs; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using CodeSpace.Messages.Agents; +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Commands.Projects; +using CodeSpace.Messages.Commands.Variables; +using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Queries.Agents; +using CodeSpace.Messages.Queries.Tasks; +using CodeSpace.Messages.Queries.Workflows; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +/// +/// A resolved workflow secret that crosses a suspend into ANOTHER run — an agent.run goal referencing +/// {{team|wf|project.X}}, a flow.subworkflow child handed one in its inputs — must reach that run's +/// execution EXACTLY and never land at rest: not in the wait row, the agent's task, the child's request, the agent's +/// events / native records / result, the child's ledger or outputs, nor in any response a Viewer can read. Each test +/// proves both halves: the executing side saw the real value (the fake agent's capture file, the child's encrypted +/// node-output sidecar), and an ALL-TABLE + artifact canary sweep () plus a Viewer's +/// reads of every run surface find nothing. +/// +/// Real engine, real executor + LocalProcessRunner spawning , real +/// Postgres; jobs are recorded and driven by hand so each suspend boundary can be inspected. POSIX-only (the fake CLI +/// is a /bin/sh script — Rule 12.1). +/// +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class SecretTaintAcrossSuspendsFlowTests +{ + private readonly PostgresFixture _fixture; + + public SecretTaintAcrossSuspendsFlowTests(PostgresFixture fixture) { _fixture = fixture; } + + [Fact] + public async Task An_agent_goals_secrets_reach_the_agent_exactly_but_never_land_at_rest_or_reach_a_viewer() + { + if (OperatingSystem.IsWindows()) return; + + using var cli = new GoalCapturingFakeCli(); + var canaries = await SeedSecretsAsync(); + var workflowId = await CreateWorkflowAsync(canaries, AgentGoalDefinition()); + await SetWorkflowSecretAsync(canaries, workflowId); + + // A team-default credential with a model, so the executor re-persists the task with its RESOLVED model at + // launch — the write that would put the opened (exact) task back into task_jsonb if it were not re-sealed. + await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, canaries.TeamId, ResolvedModel, provider: "OpenAI"); + + using var manual = ResolveJobClient().ManualExecution(); + var runId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, workflowId, canaries.TeamId); + + await RunEngineAsync(runId); + var agentRun = await LoadStagedAgentRunAsync(runId); + + agentRun.SealedTaskCiphertext.ShouldNotBeNull("the exact task rides the encrypted sealed column the executor launches from"); + await AssertNoCanaryAsync(canaries, "after the agent was staged", runId, agentRun.Id); + (await ViewerAgentRunAsync(canaries, agentRun.Id))!.Goal.ShouldContain(PersistenceSecretRedactor.Marker, customMessage: "a Viewer still reads the goal — masked, not withheld"); + + await ExecuteAgentAsync(agentRun.Id); + + foreach (var canary in canaries.All) + cli.CapturedGoals.ShouldContain(canary, customMessage: "the agent must be launched with the EXACT goal — masking may never corrupt a value the platform itself needs"); + + await RunEngineAsync(runId); + + (await LoadRunAsync(runId)).Status.ShouldBe(WorkflowRunStatus.Success, "the run resumes with the agent's (masked) result and completes"); + var finished = await LoadAgentRunAsync(agentRun.Id); + finished.Status.ShouldBe(AgentRunStatus.Succeeded); + JsonSerializer.Deserialize(finished.TaskJson, AgentJson.Options)!.Model.ShouldBe(ResolvedModel, "precondition: the launch re-persisted the task with its resolved model"); + await AssertNoCanaryAsync(canaries, "after the agent echoed its goal into every stream and the run completed", runId, agentRun.Id); + } + + [Fact] + public async Task An_agent_with_a_local_acceptance_contract_still_grades_against_its_exact_goal() + { + // The local acceptance verifier re-reads the STORED contract and hashes it against the one the executor runs. + // The stored task_jsonb is masked now, so a verifier still reading it would see a different goal and fail every + // contract-bearing run whose goal embeds a secret — masking corrupting a value the platform itself needs. + if (OperatingSystem.IsWindows()) return; + + using var cli = new GoalCapturingFakeCli(); + var canaries = await SeedSecretsAsync(); + var workflowId = await CreateWorkflowAsync(canaries, AgentGoalDefinition(acceptance: """{ "kind": "TestsPass", "command": ["sh", "-c", "exit 0"] }""")); + await SetWorkflowSecretAsync(canaries, workflowId); + + using var manual = ResolveJobClient().ManualExecution(); + var runId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, workflowId, canaries.TeamId); + await RunEngineAsync(runId); + var agentRunId = (await LoadStagedAgentRunAsync(runId)).Id; + + await ExecuteAgentAsync(agentRunId); + + var finished = await LoadAgentRunAsync(agentRunId); + finished.Status.ShouldBe(AgentRunStatus.Succeeded, $"the contract graded against the exact goal; result: {finished.ResultJson}"); + finished.ResultJson.ShouldNotBeNull().ShouldNotContain("local-context-mismatch"); + } + + [Fact] + public async Task A_subworkflows_inputs_reach_the_child_exactly_but_never_land_at_rest_or_reach_a_viewer() + { + var canaries = await SeedSecretsAsync(); + var childWorkflowId = await CreateWorkflowAsync(canaries, ChildDefinition(terminalInputs: """{ "status": "ok" }""")); + var parentWorkflowId = await CreateWorkflowAsync(canaries, ParentDefinition(childWorkflowId)); + await SetWorkflowSecretAsync(canaries, parentWorkflowId); + + using var manual = ResolveJobClient().ManualExecution(); + var parentRunId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, parentWorkflowId, canaries.TeamId); + + await RunEngineAsync(parentRunId); + var childRunId = await LoadChildRunIdAsync(parentRunId); + + (await LoadRunRequestAsync(childRunId)).SealedPayloadCiphertext.ShouldNotBeNull("the child's exact inputs ride the request's encrypted sealed column"); + await AssertNoCanaryAsync(canaries, "after the child was staged", parentRunId, childRunId); + + await RunEngineAsync(childRunId); + + (await LoadRunAsync(childRunId)).Status.ShouldBe(WorkflowRunStatus.Success); + var exact = await ReadSealedNodeOutputsAsync(childRunId, "emit"); + exact["t"].GetString().ShouldBe(canaries.Team, "the child must execute with the parent's EXACT team secret"); + exact["w"].GetString().ShouldBe(canaries.Workflow, "… and its exact wf secret, which the child's own variable rows cannot name"); + exact["p"].GetString().ShouldBe(canaries.Project, "… and its exact project secret"); + + await RunEngineAsync(parentRunId); + + (await LoadRunAsync(parentRunId)).Status.ShouldBe(WorkflowRunStatus.Success); + await AssertNoCanaryAsync(canaries, "after the child ran and the parent resumed", parentRunId, childRunId); + } + + [Theory] + [InlineData("""{ "echo": "{{trigger.wfToken}}" }""")] // the PATH guard: the trigger key that carried the parent secret is tainted + [InlineData("""{ "echo": "{{nodes.emit.outputs.w}}" }""")] // the VALUE guard: an upstream node re-emitted the inherited value + public async Task A_child_cannot_surface_an_inherited_parent_secret_as_its_output(string terminalInputs) + { + var canaries = await SeedSecretsAsync(); + var childWorkflowId = await CreateWorkflowAsync(canaries, ChildDefinition(terminalInputs)); + var parentWorkflowId = await CreateWorkflowAsync(canaries, ParentDefinition(childWorkflowId)); + await SetWorkflowSecretAsync(canaries, parentWorkflowId); + + using var manual = ResolveJobClient().ManualExecution(); + var parentRunId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, parentWorkflowId, canaries.TeamId); + + await RunEngineAsync(parentRunId); + var childRunId = await LoadChildRunIdAsync(parentRunId); + await RunEngineAsync(childRunId); + + var child = await LoadRunAsync(childRunId); + child.Status.ShouldBe(WorkflowRunStatus.Failure, "a parent's wf secret is still a secret inside the child, so the child's Terminal leak guard must refuse to publish it"); + child.Error.ShouldNotBeNull().ShouldContain("Terminal node 'end'"); + await AssertNoCanaryAsync(canaries, "after the child's terminal refused the inherited secret", parentRunId, childRunId); + } + + [Fact] + public async Task A_child_map_over_inherited_secrets_freezes_none_of_them() + { + // A Plain map freezes its resolved items in workflow_run_map_input; only a SecretDerived one re-resolves live. + // The child's items binding names {{trigger.tokens}} — no secret head of the child's own — so it is the taint on + // that inherited key that must classify it, and the inherited values that must mask every branch's records. + var canaries = await SeedSecretsAsync(); + var childWorkflowId = await CreateWorkflowAsync(canaries, ChildMapDefinition()); + var parentWorkflowId = await CreateWorkflowAsync(canaries, ParentDefinition(childWorkflowId, """{ "inputs": { "tokens": [ "{{wf.WF_TOKEN}}", "{{project.billing.TOKEN}}" ] } }""")); + await SetWorkflowSecretAsync(canaries, parentWorkflowId); + + using var manual = ResolveJobClient().ManualExecution(); + var parentRunId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, parentWorkflowId, canaries.TeamId); + await RunEngineAsync(parentRunId); + var childRunId = await LoadChildRunIdAsync(parentRunId); + await RunEngineAsync(childRunId); + + (await LoadRunAsync(childRunId)).Status.ShouldBe(WorkflowRunStatus.Success); + (await LoadMapInputSensitivityAsync(childRunId)).ShouldBe("SecretDerived", "a map over an inherited secret stores no frozen array"); + await AssertNoCanaryAsync(canaries, "after the child mapped over its inherited secrets", parentRunId, childRunId); + } + + [Fact] + public async Task A_replay_of_a_sealed_child_runs_with_the_exact_inputs_and_stays_sealed() + { + var canaries = await SeedSecretsAsync(); + var childWorkflowId = await CreateWorkflowAsync(canaries, ChildDefinition(terminalInputs: """{ "status": "ok" }""")); + var parentWorkflowId = await CreateWorkflowAsync(canaries, ParentDefinition(childWorkflowId)); + await SetWorkflowSecretAsync(canaries, parentWorkflowId); + + using var manual = ResolveJobClient().ManualExecution(); + var parentRunId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, parentWorkflowId, canaries.TeamId); + await RunEngineAsync(parentRunId); + var childRunId = await LoadChildRunIdAsync(parentRunId); + await RunEngineAsync(childRunId); + + var replayRunId = await ReplayAsync(canaries, childRunId); + await RunEngineAsync(replayRunId); + + (await LoadRunAsync(replayRunId)).Status.ShouldBe(WorkflowRunStatus.Success); + (await LoadRunRequestAsync(replayRunId)).SealedPayloadCiphertext.ShouldNotBeNull("a fork carries the sealed copy over, never just the masked column"); + (await ReadSealedNodeOutputsAsync(replayRunId, "emit"))["w"].GetString().ShouldBe(canaries.Workflow, "the replay re-runs with the EXACT inherited value, not the mask"); + await AssertNoCanaryAsync(canaries, "after the replay", childRunId, replayRunId); + } + + [Fact] + public async Task A_supervisors_resolved_goal_is_masked_in_the_plan_a_viewer_reads() + { + // agent.supervisor reads its goal from the RESOLVED config exactly like agent.run, and its plan row takes that + // goal (or the model restating it) verbatim. The row is a projection for humans — the turn executes from its + // tape — so it is masked with the node scope's secrets. + var canaries = await SeedSecretsAsync(); + using (var script = _fixture.BeginScope()) script.Resolve().PlanThenStop(); + var workflowId = await CreateWorkflowAsync(canaries, SupervisorDefinition("""{ "goal": "Ship the release with {{team.DEPLOY_TOKEN}}" }""")); + + using var manual = ResolveJobClient().ManualExecution(); + var runId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, workflowId, canaries.TeamId); + await RunEngineAsync(runId); + + var goal = await LoadWorkPlanGoalAsync(runId); + goal.ShouldNotContain(canaries.Team); + goal.ShouldBe($"Ship the release with {PersistenceSecretRedactor.Marker}"); + + using var viewer = _fixture.BeginScopeAs(canaries.ViewerId, canaries.TeamId); + JsonSerializer.Serialize(await viewer.Resolve().Send(new GetRunWorkPlanQuery { RunId = runId })).ShouldNotContain(canaries.Team); + } + + [Fact] + public async Task An_agent_run_staged_before_sealing_still_executes_from_its_public_task() + { + if (OperatingSystem.IsWindows()) return; + + using var cli = new GoalCapturingFakeCli(); + var (teamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + const string legacyGoal = "Summarize the release notes"; + + Guid agentRunId; + using (var scope = await WorkflowsTestSeed.BeginSeedOperatorScopeAsync(_fixture, teamId)) + agentRunId = (await scope.Resolve().CreateAsync(new AgentTask { Goal = legacyGoal, Harness = "codex-cli", TimeoutSeconds = 300 }, teamId, null, null, iterationKey: "", CancellationToken.None)).Id; + + (await LoadAgentRunAsync(agentRunId)).SealedTaskCiphertext.ShouldBeNull("a task embedding no secret is stored exactly as every pre-sealing row was"); + + await ExecuteAgentAsync(agentRunId); + + (await LoadAgentRunAsync(agentRunId)).Status.ShouldBe(AgentRunStatus.Succeeded); + cli.CapturedGoals.ShouldContain(legacyGoal, customMessage: "an unsealed row launches from task_jsonb, unchanged"); + } + + [Theory] + [InlineData(true)] // approved: the review.completed beat carries the verdict's words + [InlineData(false)] // flagged: so do the warning event and the result's reviewFeedback + public async Task An_output_reviewer_quoting_the_goal_leaves_its_secrets_in_no_verdict_sink(bool approve) + { + // The critic is shown the EXACT goal, and a model restates the task it judges. Its call's capture is masked by + // the critic scope; the executor then writes the verdict's words into the ledger, the timeline and the result + // after that scope is gone, so the verdict itself must be masked. + if (OperatingSystem.IsWindows()) return; + + using var cli = new GoalCapturingFakeCli(); + var canaries = await SeedSecretsAsync(); + var (_, reviewerModelId) = await WorkflowsTestSeed.SeedCredentialedModelAsync(_fixture, canaries.TeamId, "critic-model", provider: DeterministicCriticLlmClient.ProviderTag); + var goal = $"{SecretGoal} {DeterministicCriticLlmClient.QuoteGoalMarker}" + (approve ? "" : $" {DeterministicCriticLlmClient.RejectMarker}"); + var workflowId = await CreateWorkflowAsync(canaries, AgentGoalDefinition(goal: goal, review: $", \"outputReviewMode\": 1, \"reviewerModelId\": \"{reviewerModelId}\"")); + await SetWorkflowSecretAsync(canaries, workflowId); + var script = ResolveCriticScript(); + script.Reset(); + + using var manual = ResolveJobClient().ManualExecution(); + var runId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, workflowId, canaries.TeamId); + await RunEngineAsync(runId); + var agentRunId = (await LoadStagedAgentRunAsync(runId)).Id; + + await ExecuteAgentAsync(agentRunId); + + var quoted = script.Quoted.ShouldHaveSingleItem("the reviewer ran once and quoted the goal it was shown"); + foreach (var canary in canaries.All) quoted.ShouldContain(canary, customMessage: "the reviewer was shown the EXACT goal, so its verdict carried the real values"); + (await LoadAgentRunAsync(agentRunId)).Status.ShouldBe(approve ? AgentRunStatus.Succeeded : AgentRunStatus.NeedsReview); + (await ViewerReadsAsync(canaries, runId, agentRunId)).ShouldContain("The task was: ", customMessage: "the verdict's words still reach the Viewer — masked, not dropped"); + await AssertNoCanaryAsync(canaries, "after the reviewer quoted the goal into its verdict", runId, agentRunId); + } + + [Theory] + [InlineData("test '{{team.DEPLOY_TOKEN}}' != '[REDACTED]'", true)] // passes ONLY with the exact value + [InlineData("test -n '{{team.DEPLOY_TOKEN}}' && exit 1", false)] // prints nothing: the argv line alone carries it + [InlineData("echo calling with Authorization: Bearer {{team.DEPLOY_TOKEN}}; exit 1", false)] // echoes it into the stdout tail + public async Task An_acceptance_argv_carrying_a_secret_grades_with_it_and_keeps_only_the_mask(string check, bool passes) + { + // The grader's evidence repeats the argv and both output tails, then is stored as a team artifact a Viewer can + // download and clipped onto the result. The task is sealed, so the argv runs with the real value; the evidence + // must keep only the mask. + if (OperatingSystem.IsWindows()) return; + + using var cli = new GoalCapturingFakeCli(); + var canaries = await SeedSecretsAsync(); + var workflowId = await CreateWorkflowAsync(canaries, AgentGoalDefinition(acceptance: $$"""{ "kind": "TestsPass", "command": ["sh", "-c", "{{check}}"] }""")); + await SetWorkflowSecretAsync(canaries, workflowId); + + using var manual = ResolveJobClient().ManualExecution(); + var runId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, workflowId, canaries.TeamId); + await RunEngineAsync(runId); + var agentRunId = (await LoadStagedAgentRunAsync(runId)).Id; + + await ExecuteAgentAsync(agentRunId); + + var finished = await LoadAgentRunAsync(agentRunId); + finished.Status.ShouldBe(passes ? AgentRunStatus.Succeeded : AgentRunStatus.Failed, $"the check ran with the exact argv; result: {finished.ResultJson}"); + var evidenceId = JsonDocument.Parse(finished.ResultJson!).RootElement.GetProperty("acceptanceEvidenceId").GetGuid(); + + using (var viewer = _fixture.BeginScopeAs(canaries.ViewerId, canaries.TeamId)) + { + var evidence = System.Text.Encoding.UTF8.GetString((await viewer.Resolve().Send(new CodeSpace.Messages.Queries.Artifacts.GetArtifactQuery { ArtifactId = evidenceId })).ShouldNotBeNull().Bytes); + evidence.ShouldContain("$ sh -c ", customMessage: "the evidence is still there for the Viewer"); + evidence.ShouldContain(PersistenceSecretRedactor.Marker, customMessage: "the argv line keeps its shape, masked"); + evidence.ShouldNotContain(canaries.Team, customMessage: "the evidence a Viewer downloads holds only the mask"); + } + + await RunEngineAsync(runId); + await AssertNoCanaryAsync(canaries, "after the acceptance check graded and the run resumed", runId, agentRunId); + } + + [Theory] + [InlineData(true)] // a map over the inherited secrets: SecretDerived, so no frozen array + [InlineData(false)] // a Terminal publishing an inherited secret: the leak guard refuses it + public async Task A_child_resumed_through_its_variable_snapshot_still_treats_inherited_secrets_as_secrets(bool map) + { + // A plain team variable gives every run a variable snapshot, so a resume rebuilds the child's scope on the + // REPLAY path, not the fresh one its first walk took. The nodes under test run only after the gate resumes. + var canaries = await SeedSecretsAsync(); + using (var scope = _fixture.BeginScope()) + await scope.Resolve().SetAsync(VariableScope.Team, canaries.TeamId, canaries.TeamId, "REGION", VariableValueType.String, JsonSerializer.SerializeToElement("eu-west-1"), null, canaries.OwnerId, CancellationToken.None); + + var gateWorkflowId = await CreateWorkflowAsync(canaries, PassThroughDefinition()); + var childWorkflowId = await CreateWorkflowAsync(canaries, ChildResumingDefinition(gateWorkflowId, map)); + var parentWorkflowId = await CreateWorkflowAsync(canaries, ParentDefinition(childWorkflowId, """{ "inputs": { "tokens": [ "{{wf.WF_TOKEN}}", "{{project.billing.TOKEN}}" ], "wfToken": "{{wf.WF_TOKEN}}" } }""")); + await SetWorkflowSecretAsync(canaries, parentWorkflowId); + + using var manual = ResolveJobClient().ManualExecution(); + var parentRunId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, parentWorkflowId, canaries.TeamId); + await RunEngineAsync(parentRunId); + var childRunId = await LoadChildRunIdAsync(parentRunId); + await RunEngineAsync(childRunId); + await RunEngineAsync(await LoadChildRunIdAsync(childRunId)); + await RunEngineAsync(childRunId); + + (await HasRecordAsync(childRunId, WorkflowRunRecordTypes.RunReplayed)).ShouldBeTrue("fixture check: the resumed walk rebuilt its scope on the replay path"); + var child = await LoadRunAsync(childRunId); + + if (map) + { + child.Status.ShouldBe(WorkflowRunStatus.Success); + (await LoadMapInputSensitivityAsync(childRunId)).ShouldBe("SecretDerived", "a map over an inherited secret stores no frozen array on the resumed walk either"); + } + else + { + child.Status.ShouldBe(WorkflowRunStatus.Failure, "the resumed walk's Terminal guard still knows the inherited key is a secret"); + child.Error.ShouldNotBeNull().ShouldContain("Terminal node 'end'"); + } + + await AssertNoCanaryAsync(canaries, "after the child resumed through its replay scope", parentRunId, childRunId); + } + + private const string ResolvedModel = "gpt-taint-test-model"; + + // ── Definitions ────────────────────────────────────────────────────────────────────────────────────────── + + private const string SecretGoal = "Deploy with bearer {{team.DEPLOY_TOKEN}}, sign with {{wf.WF_TOKEN}} and fetch with {{project.billing.TOKEN}}"; + + private static WorkflowDefinition AgentGoalDefinition(string? acceptance = null, string goal = SecretGoal, string review = "") => new() + { + SchemaVersion = 1, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "agent", TypeKey = "agent.run", Inputs = WorkflowsTestSeed.EmptyJson(), + Config = WorkflowsTestSeed.Json($$"""{ "goal": "{{goal}}", "harness": "codex-cli", "runnerKind": "local", "timeoutSeconds": 300 {{review}}""" + (acceptance is null ? "" : $", \"acceptance\": {acceptance}") + " }") }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json("""{ "summary": "{{nodes.agent.outputs.summary}}" }""") }, + }, + Edges = new List { new() { From = "start", To = "agent" }, new() { From = "agent", To = "end" } }, + }; + + private const string SecretInputs = """{ "inputs": { "token": "{{team.DEPLOY_TOKEN}}", "wfToken": "{{wf.WF_TOKEN}}", "projToken": "{{project.billing.TOKEN}}" } }"""; + + private static WorkflowDefinition ParentDefinition(Guid childWorkflowId, string subInputs = SecretInputs) => new() + { + SchemaVersion = 1, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "sub", TypeKey = "flow.subworkflow", Config = WorkflowsTestSeed.Json($$"""{ "workflowId": "{{childWorkflowId}}" }"""), + Inputs = WorkflowsTestSeed.Json(subInputs) }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json("""{ "status": "ok" }""") }, + }, + Edges = new List { new() { From = "start", To = "sub" }, new() { From = "sub", To = "end" } }, + }; + + private static WorkflowDefinition ChildDefinition(string terminalInputs) => new() + { + SchemaVersion = 1, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "emit", TypeKey = JsonEmitNode.Key, Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json("""{ "t": "{{trigger.token}}", "w": "{{trigger.wfToken}}", "p": "{{trigger.projToken}}" }""") }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json(terminalInputs) }, + }, + Edges = new List { new() { From = "start", To = "emit" }, new() { From = "emit", To = "end" } }, + }; + + private static WorkflowDefinition ChildMapDefinition() => new() + { + SchemaVersion = 1, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "map", TypeKey = "flow.map", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json("""{ "items": "{{trigger.tokens}}" }""") }, + new() { Id = "ms", TypeKey = "flow.map_start", ParentId = "map", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "echo", TypeKey = JsonEmitNode.Key, ParentId = "map", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json("""{ "value": "{{item}}" }""") }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json("""{ "count": "{{nodes.map.outputs.count}}" }""") }, + }, + Edges = new List { new() { From = "start", To = "map" }, new() { From = "map", To = "end" }, new() { From = "ms", To = "echo" } }, + }; + + /// A child that first parks on a sub-workflow gate, so everything after it runs on the RESUMED walk — whose scope a run with a variable snapshot rebuilds on the replay path — then either maps over its inherited tokens or publishes one from its Terminal. + private static WorkflowDefinition ChildResumingDefinition(Guid gateWorkflowId, bool map) => new() + { + SchemaVersion = 1, + Nodes = map + ? new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "gate", TypeKey = "flow.subworkflow", Config = WorkflowsTestSeed.Json($$"""{ "workflowId": "{{gateWorkflowId}}" }"""), Inputs = WorkflowsTestSeed.Json("""{ "inputs": {} }""") }, + new() { Id = "map", TypeKey = "flow.map", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json("""{ "items": "{{trigger.tokens}}" }""") }, + new() { Id = "ms", TypeKey = "flow.map_start", ParentId = "map", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "echo", TypeKey = JsonEmitNode.Key, ParentId = "map", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json("""{ "value": "{{item}}" }""") }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json("""{ "count": "{{nodes.map.outputs.count}}" }""") }, + } + : new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "gate", TypeKey = "flow.subworkflow", Config = WorkflowsTestSeed.Json($$"""{ "workflowId": "{{gateWorkflowId}}" }"""), Inputs = WorkflowsTestSeed.Json("""{ "inputs": {} }""") }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json("""{ "echo": "{{trigger.wfToken}}" }""") }, + }, + Edges = map + ? new List { new() { From = "start", To = "gate" }, new() { From = "gate", To = "map" }, new() { From = "map", To = "end" }, new() { From = "ms", To = "echo" } } + : new List { new() { From = "start", To = "gate" }, new() { From = "gate", To = "end" } }, + }; + + private static WorkflowDefinition PassThroughDefinition() => new() + { + SchemaVersion = 1, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json("""{ "status": "ok" }""") }, + }, + Edges = new List { new() { From = "start", To = "end" } }, + }; + + private static WorkflowDefinition SupervisorDefinition(string supervisorConfig) => new() + { + SchemaVersion = 1, + CompletionMode = WorkflowDefinition.CompletionModeShadow, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "sup", TypeKey = "agent.supervisor", Config = WorkflowsTestSeed.Json(supervisorConfig), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + }, + Edges = new List { new() { From = "start", To = "sup" }, new() { From = "sup", To = "end" } }, + }; + + // ── Seeding ────────────────────────────────────────────────────────────────────────────────────────────── + + /// One unique canary per secret scope a goal or an input can reference, plus a Viewer who must never read any of them. + private sealed record Canaries(Guid TeamId, Guid OwnerId, Guid ViewerId, string Team, string Workflow, string Project) + { + public IReadOnlyList All => [Team, Workflow, Project]; + } + + private async Task SeedSecretsAsync() + { + var (teamId, ownerId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var canaries = new Canaries(teamId, ownerId, await SeedViewerAsync(teamId), Canary("team"), Canary("wf"), Canary("project")); + + using (var scope = _fixture.BeginScope()) + await scope.Resolve().SetAsync(VariableScope.Team, teamId, teamId, "DEPLOY_TOKEN", VariableValueType.Secret, JsonSerializer.SerializeToElement(canaries.Team), null, ownerId, CancellationToken.None); + + using (var scope = _fixture.BeginScopeAs(ownerId, teamId, Roles.Admin)) + { + var mediator = scope.Resolve(); + var projectId = await mediator.Send(new CreateProjectCommand { Name = "Billing" }); + await mediator.Send(new SetProjectVariableCommand { ProjectId = projectId, Name = "TOKEN", ValueType = VariableValueType.Secret, Value = JsonSerializer.SerializeToElement(canaries.Project) }); + } + + return canaries; + } + + private async Task SetWorkflowSecretAsync(Canaries canaries, Guid workflowId) + { + using var scope = _fixture.BeginScope(); + await scope.Resolve().SetAsync(VariableScope.Workflow, workflowId, canaries.TeamId, "WF_TOKEN", VariableValueType.Secret, JsonSerializer.SerializeToElement(canaries.Workflow), null, canaries.OwnerId, CancellationToken.None); + } + + private static string Canary(string scope) => $"CANARY-taint-{scope}-{Guid.NewGuid():N}"; + + private async Task SeedViewerAsync(Guid teamId) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var viewerId = Guid.NewGuid(); + + db.User.Add(new User { Id = viewerId, Email = $"viewer-{viewerId:N}@test.local", Name = $"viewer-{viewerId:N}", CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + db.TeamMembership.Add(new TeamMembership { Id = Guid.NewGuid(), TeamId = teamId, UserId = viewerId, Role = TeamRole.Viewer, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + await db.SaveChangesAsync(); + + return viewerId; + } + + private async Task CreateWorkflowAsync(Canaries canaries, WorkflowDefinition definition) + { + using var scope = _fixture.BeginScopeAs(canaries.OwnerId, canaries.TeamId, Roles.Admin); + + return await scope.Resolve().Send(new CreateWorkflowCommand + { + Name = "taint-" + Guid.NewGuid().ToString("N")[..8], Description = null, Definition = definition, + Activations = new List(), Enabled = true, + }); + } + + // ── Driving ────────────────────────────────────────────────────────────────────────────────────────────── + + private async Task RunEngineAsync(Guid runId) + { + using var scope = _fixture.BeginScope(); + await scope.Resolve().ExecuteRunAsync(runId, CancellationToken.None); + } + + private async Task ExecuteAgentAsync(Guid agentRunId) + { + using var scope = _fixture.BeginScope(); + await scope.Resolve().ExecuteAsync(agentRunId, CancellationToken.None); + } + + private async Task ReplayAsync(Canaries canaries, Guid runId) + { + using var scope = _fixture.BeginScopeAs(canaries.OwnerId, canaries.TeamId, Roles.Admin); + return await scope.Resolve().Send(new ReplayRunCommand { OriginalRunId = runId }); + } + + private CriticReviewScript ResolveCriticScript() + { + using var scope = _fixture.BeginScope(); + return scope.Resolve(); + } + + private InMemoryBackgroundJobClient ResolveJobClient() + { + using var scope = _fixture.BeginScope(); + return scope.Resolve(); + } + + // ── Reading ────────────────────────────────────────────────────────────────────────────────────────────── + + private async Task LoadRunAsync(Guid runId) + { + using var scope = _fixture.BeginScope(); + return await scope.Resolve().WorkflowRun.AsNoTracking().SingleAsync(run => run.Id == runId); + } + + private async Task LoadRunRequestAsync(Guid runId) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var requestId = await db.WorkflowRun.AsNoTracking().Where(run => run.Id == runId).Select(run => run.RunRequestId).SingleAsync(); + + return await db.WorkflowRunRequest.AsNoTracking().SingleAsync(request => request.Id == requestId); + } + + private async Task LoadAgentRunAsync(Guid agentRunId) + { + using var scope = _fixture.BeginScope(); + return await scope.Resolve().AgentRun.AsNoTracking().SingleAsync(run => run.Id == agentRunId); + } + + private async Task LoadStagedAgentRunAsync(Guid runId) + { + using var scope = _fixture.BeginScope(); + return await scope.Resolve().AgentRun.AsNoTracking().SingleAsync(run => run.WorkflowRunId == runId); + } + + private async Task LoadWorkPlanGoalAsync(Guid runId) + { + using var scope = _fixture.BeginScope(); + return await scope.Resolve().WorkPlan.AsNoTracking().Where(plan => plan.WorkflowRunId == runId).OrderByDescending(plan => plan.Version).Select(plan => plan.Goal).FirstAsync(); + } + + private async Task LoadMapInputSensitivityAsync(Guid runId) + { + using var scope = _fixture.BeginScope(); + return await scope.Resolve().WorkflowRunMapInput.AsNoTracking().Where(input => input.RunId == runId).Select(input => input.Sensitivity).SingleAsync(); + } + + private async Task LoadChildRunIdAsync(Guid parentRunId) + { + using var scope = _fixture.BeginScope(); + var token = await scope.Resolve().WorkflowRunWait.AsNoTracking().Where(wait => wait.RunId == parentRunId && wait.WaitKind == WorkflowWaitKinds.Subworkflow).Select(wait => wait.Token).SingleAsync(); + + return Guid.Parse(token); + } + + /// The exact outputs a node produced, from the encrypted recovery sidecar the engine writes ONLY when it masked the public copy — so finding the canary here proves both that the node ran with the real value and that its ledger row was masked. + private async Task> ReadSealedNodeOutputsAsync(Guid runId, string nodeId) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var run = await db.WorkflowRun.AsNoTracking().SingleAsync(candidate => candidate.Id == runId); + var recordId = await db.WorkflowRunRecord.AsNoTracking().Where(record => record.RunId == runId && record.NodeId == nodeId && record.RecordType == WorkflowRunRecordTypes.NodeCompleted).Select(record => record.Id).SingleAsync(); + + return (await scope.Resolve().ReadNodeOutputsAsync(recordId, runId, run.TeamId, CancellationToken.None)).ShouldNotBeNull("the node's outputs carried a secret, so its public row was masked and the exact copy sealed beside it"); + } + + private async Task ViewerAgentRunAsync(Canaries canaries, Guid agentRunId) + { + using var viewer = _fixture.BeginScopeAs(canaries.ViewerId, canaries.TeamId); + return await viewer.Resolve().Send(new GetAgentRunQuery { AgentRunId = agentRunId }); + } + + /// Everything a Viewer can read about these runs, serialized — the run detail (pending wait + request payload included), its ledger and timeline, the pending-wait observation, and for an agent run its summary and event log. + private async Task ViewerReadsAsync(Canaries canaries, Guid runId, Guid? agentRunId) + { + using var viewer = _fixture.BeginScopeAs(canaries.ViewerId, canaries.TeamId); + var mediator = viewer.Resolve(); + var reads = new List + { + await mediator.Send(new GetWorkflowRunQuery { RunId = runId }), + await mediator.Send(new GetWorkflowRunPendingWaitObservationQuery { RunId = runId }), + await mediator.Send(new GetRunRecordsQuery { RunId = runId }), + await mediator.Send(new GetRunTimelineQuery { RunId = runId }), + }; + + if (agentRunId is { } id) + { + reads.Add(await mediator.Send(new GetAgentRunQuery { AgentRunId = id })); + reads.Add(await mediator.Send(new ListAgentRunEventsQuery { AgentRunId = id })); + } + + return JsonSerializer.Serialize(reads); + } + + /// + /// No canary anywhere at rest (every table, every artifact of the team) and in nothing a Viewer reads about either + /// run. is the second run of the pair — the child, or the agent run. + /// + private async Task AssertNoCanaryAsync(Canaries canaries, string moment, Guid runId, Guid otherRunId) + { + var agentRunId = await IsAgentRunAsync(otherRunId) ? otherRunId : (Guid?)null; + var viewerReads = await ViewerReadsAsync(canaries, runId, agentRunId) + (agentRunId is null ? await ViewerReadsAsync(canaries, otherRunId, null) : ""); + + foreach (var canary in canaries.All) + { + (await SecretCanarySweep.FindAsync(_fixture, canaries.TeamId, canary)).ShouldBeEmpty($"{moment}: a resolved secret froze into a table or artifact"); + viewerReads.ShouldNotContain(canary, customMessage: $"{moment}: a Viewer read a resolved secret"); + } + } + + private async Task HasRecordAsync(Guid runId, string recordType) + { + using var scope = _fixture.BeginScope(); + return await scope.Resolve().WorkflowRunRecord.AsNoTracking().AnyAsync(record => record.RunId == runId && record.RecordType == recordType); + } + + private async Task IsAgentRunAsync(Guid id) + { + using var scope = _fixture.BeginScope(); + return await scope.Resolve().AgentRun.AsNoTracking().AnyAsync(run => run.Id == id); + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/SubworkflowFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/SubworkflowFlowTests.cs index a0eeda3af..54df133d8 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/SubworkflowFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/SubworkflowFlowTests.cs @@ -10,6 +10,7 @@ using CodeSpace.Messages.Dtos.Workflows; using CodeSpace.Messages.Enums; using CodeSpace.Messages.Queries.Workflows; +using CodeSpace.Messages.Workflows; using MediatR; using Microsoft.EntityFrameworkCore; using Shouldly; @@ -223,7 +224,7 @@ public async Task Nesting_deeper_than_the_cap_is_refused() var deepest = await db.WorkflowRun.AsNoTracking().SingleAsync(r => r.Id == deepestId); await Should.ThrowAsync( - async () => await svc.StageChildRunAsync(deepest, workflowId, null, "{}", CancellationToken.None)); + async () => await svc.StageChildRunAsync(deepest, workflowId, null, new SealedPayload("{}", null), CancellationToken.None)); } } @@ -257,7 +258,7 @@ public async Task Successive_reruns_do_not_inflate_the_nesting_depth_guard() var deepest = await db.WorkflowRun.AsNoTracking().SingleAsync(r => r.Id == deepestId); // No throw: every ancestor hop is rerun lineage, so the nesting depth stays 0 and the child stages fine. - var childRunId = await svc.StageChildRunAsync(deepest, workflowId, null, "{}", CancellationToken.None); + var childRunId = await svc.StageChildRunAsync(deepest, workflowId, null, new SealedPayload("{}", null), CancellationToken.None); childRunId.ShouldNotBe(Guid.Empty, "a deep rerun-lineage chain stages a child fine — rerun hops are NOT nesting levels"); } } diff --git a/backend/tests/CodeSpace.UnitTests/Agents/AgentRunWorkflowSecretRedactorTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/AgentRunWorkflowSecretRedactorTests.cs new file mode 100644 index 000000000..2fa5add67 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Agents/AgentRunWorkflowSecretRedactorTests.cs @@ -0,0 +1,49 @@ +using CodeSpace.Core.Services.Agents; +using CodeSpace.Messages.Agents; +using Shouldly; + +namespace CodeSpace.UnitTests.Agents; + +/// +/// The workflow secrets an agent's task embeds become needles of the run's redactor — the agent was handed them in +/// its goal, so its events, native records, result, error and MCP ledger can echo them like an injected key. A run +/// whose task embeds none keeps its redactor and its handle fingerprint exactly as before, which is what lets a run +/// launched before this change re-attach after it. +/// +[Trait("Category", "Unit")] +public sealed class AgentRunWorkflowSecretRedactorTests +{ + private const string Secret = "CANARY-wf-secret-51c8e2"; + + private static readonly ResolvedModelCredential Credential = new() { Provider = "Anthropic", ApiKey = "sk-live-9f3a7c21" }; + + [Fact] + public void Workflow_secrets_become_needles_beside_the_credential() + { + var subject = AgentRunExecutor.WithWorkflowSecrets(AgentRunExecutor.BuildRunRedactor(new Dictionary(), Credential), [Secret]); + + subject.Redact($"calling deploy with {Secret} using sk-live-9f3a7c21").ShouldBe("calling deploy with *** using ***"); + } + + [Fact] + public void A_task_embedding_no_workflow_secret_keeps_the_redactor_and_its_fingerprint() + { + var baseline = AgentRunExecutor.BuildRunRedactor(new Dictionary(), Credential); + + AgentRunExecutor.WithWorkflowSecrets(baseline, null).ShouldBeSameAs(baseline); + AgentRunExecutor.WithWorkflowSecrets(baseline, []).ShouldBeSameAs(baseline); + AgentRunExecutor.WithWorkflowSecrets(baseline, ["short"]).Fingerprint.ShouldBe(baseline.Fingerprint, "a value under the needle length stays readable and does not change the stamped fingerprint"); + } + + [Fact] + public void The_fold_is_a_function_of_the_secret_set_so_a_reattach_rebuilds_the_launch_fingerprint() + { + var baseline = AgentRunExecutor.BuildRunRedactor(new Dictionary(), Credential); + + var launch = AgentRunExecutor.WithWorkflowSecrets(baseline, [Secret, "CANARY-second-0b9d7a44"]); + var reattach = AgentRunExecutor.WithWorkflowSecrets(baseline, ["CANARY-second-0b9d7a44", Secret, Secret]); + + launch.Fingerprint.ShouldNotBe(baseline.Fingerprint, "a fingerprint blind to the workflow secrets would let a re-attach that lost them re-tail unmasked"); + reattach.Fingerprint.ShouldBe(launch.Fingerprint); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Agents/AgentTaskSecretsTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/AgentTaskSecretsTests.cs new file mode 100644 index 000000000..493bd111e --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Agents/AgentTaskSecretsTests.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Agents; +using CodeSpace.Core.Services.Workflows.Runtime; +using CodeSpace.Messages.Agents; +using Microsoft.AspNetCore.DataProtection; +using Shouldly; + +namespace CodeSpace.UnitTests.Agents; + +/// +/// The rule for a workflow secret riding an agent's task: the stager names the values the task embeds, the store +/// persists a masked task with the exact copy sealed beside it, and the executor opens the exact copy back — with the +/// values still named, so the run's redactor keeps treating them as secrets. Values never serialize on their own. +/// +[Trait("Category", "Unit")] +public sealed class AgentTaskSecretsTests +{ + private const string Secret = "CANARY-goal-7d2e91b4c0"; + + private static readonly WorkflowSensitivePayloadSealer Sealer = new(new DataProtectionWorkflowSensitivePayloadProtector(new EphemeralDataProtectionProvider())); + + private static AgentTask Task(string goal) => new() { Goal = goal, Harness = "codex-cli" }; + + [Fact] + public void Embed_names_only_the_scope_values_the_task_carries_and_long_enough_to_be_needles() + { + var scope = new PersistenceSecretRedactor([Secret, "absent-scope-secret", "short1"]); + + var embedded = AgentTaskSecrets.Embed(Task($"Deploy with {Secret} and pin short1"), scope); + + embedded.WorkflowSecrets.ShouldBe([Secret], + "a scope value the task does not embed never rides it, and a value under the needle length stays out of both the mask and the stream redactor"); + } + + [Fact] + public void Embed_with_no_scope_or_nothing_embedded_returns_the_task_unchanged() + { + var task = Task("Nothing secret here"); + + AgentTaskSecrets.Embed(task, null).ShouldBeSameAs(task); + AgentTaskSecrets.Embed(task, new PersistenceSecretRedactor([Secret])).ShouldBeSameAs(task); + } + + [Fact] + public void Named_secrets_are_never_serialized_with_the_task() + { + var json = JsonSerializer.Serialize(Task("goal") with { WorkflowSecrets = [Secret] }, AgentJson.Options); + + json.ShouldNotContain(Secret); + json.ShouldNotContain("workflowSecrets", Case.Insensitive); + } + + [Fact] + public void A_sealed_task_persists_masked_and_opens_to_the_exact_task_still_naming_its_secrets() + { + var task = Task($"Call the deploy API with bearer token {Secret}") with { WorkflowSecrets = [Secret] }; + + var sealedTask = AgentTaskSecrets.Seal(task, Sealer); + var run = new AgentRun { Id = Guid.NewGuid(), TaskJson = sealedTask.PublicJson, SealedTaskCiphertext = sealedTask.Ciphertext, Harness = "codex-cli" }; + + run.TaskJson.ShouldNotContain(Secret); + JsonSerializer.Deserialize(run.TaskJson, AgentJson.Options)!.Goal.ShouldBe("Call the deploy API with bearer token [REDACTED]"); + + var opened = AgentTaskSecrets.Open(run, Sealer)!; + + opened.Goal.ShouldBe(task.Goal, "the agent executes with the exact value"); + opened.WorkflowSecrets.ShouldBe([Secret], "the opened task still names what the run's redactor must mask"); + } + + [Fact] + public void A_task_naming_no_secret_persists_exactly_as_before_and_opens_from_its_public_column() + { + var sealedTask = AgentTaskSecrets.Seal(Task("Plain goal"), sealer: null); + var run = new AgentRun { Id = Guid.NewGuid(), TaskJson = sealedTask.PublicJson, Harness = "codex-cli" }; + + sealedTask.Ciphertext.ShouldBeNull(); + sealedTask.PublicJson.ShouldBe(JsonSerializer.Serialize(Task("Plain goal"), AgentJson.Options)); + AgentTaskSecrets.Open(run, sealer: null)!.Goal.ShouldBe("Plain goal"); + AgentTaskSecrets.Open(run, sealer: null)!.WorkflowSecrets.ShouldBeNull(); + } + + [Fact] + public void A_sealed_row_cannot_be_opened_without_a_sealer_rather_than_running_the_masked_goal() + { + var sealedTask = AgentTaskSecrets.Seal(Task($"goal {Secret}") with { WorkflowSecrets = [Secret] }, Sealer); + var run = new AgentRun { Id = Guid.NewGuid(), TaskJson = sealedTask.PublicJson, SealedTaskCiphertext = sealedTask.Ciphertext, Harness = "codex-cli" }; + + Should.Throw(() => AgentTaskSecrets.Open(run, sealer: null)); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Supervisor/AcceptanceEvidenceSecretMaskingTests.cs b/backend/tests/CodeSpace.UnitTests/Supervisor/AcceptanceEvidenceSecretMaskingTests.cs new file mode 100644 index 000000000..b3176671d --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Supervisor/AcceptanceEvidenceSecretMaskingTests.cs @@ -0,0 +1,116 @@ +using CodeSpace.Core.Services.Agents.Eval.Benchmark; +using CodeSpace.Core.Services.Agents.Eval.Benchmark.Graders; +using CodeSpace.Core.Services.Agents.Sandbox; +using CodeSpace.Core.Services.Supervisor; +using CodeSpace.Core.Services.Workflows.Runtime; +using CodeSpace.Messages.Agents; +using CodeSpace.Messages.Agents.Benchmark; +using CodeSpace.UnitTests.Agents; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace CodeSpace.UnitTests.Supervisor; + +/// +/// 🟢 Unit: an acceptance argv authored with {{team.X}} runs with the exact value, and nothing the grade keeps +/// holds it. The evidence repeats the argv ($ sh -c …) and both output tails, then becomes a team artifact a +/// Viewer can download and a tail on the agent's result. The real and +/// run against a recording runner, so the argv the check ran and the bytes the store +/// received are both observed. +/// +[Trait("Category", "Unit")] +public sealed class AcceptanceEvidenceSecretMaskingTests +{ + private const string Secret = "CANARY-grade-5d3c0f81e2"; + + [Theory] + [InlineData(SandboxStatus.Success)] + [InlineData(SandboxStatus.Failed)] + public async Task The_check_runs_with_the_exact_secret_and_its_evidence_keeps_only_the_mask(SandboxStatus checkStatus) + { + var runner = new RecordingRunner { Check = new SandboxResult { Status = checkStatus, ExitCode = checkStatus == SandboxStatus.Success ? 0 : 1, Stdout = $"sent Authorization: Bearer {Secret}", Stderr = $"401 for {Secret}" } }; + var artifacts = new SupervisorAcceptanceGraderTests.FakeArtifactStore(); + + var grade = await GradeAsync(runner, artifacts, new[] { "sh", "-c", $"curl -H 'Authorization: Bearer {Secret}' https://deploy.test" }, setup: null, secrets: [Secret]); + + string.Join(' ', runner.Specs.Last().Args).ShouldContain(Secret, customMessage: "the check runs with the EXACT argv — masking may never corrupt a value the platform itself needs"); + grade.Passed.ShouldBe(checkStatus == SandboxStatus.Success); + + var evidence = artifacts.Puts.ShouldHaveSingleItem("both verdicts store their evidence").Text; + evidence.ShouldContain($"Bearer {PersistenceSecretRedactor.Marker}", customMessage: "the argv line and the output tails survive, masked"); + evidence.ShouldNotContain(Secret); + (grade.EvidenceTail ?? "").ShouldNotContain(Secret); + (grade.OracleNote ?? "").ShouldNotContain(Secret); + grade.Detail.ShouldNotContain(Secret); + } + + [Fact] + public async Task A_failed_setup_keeps_its_detail_class_with_the_echoed_secret_masked() + { + // A failed setup's detail carries its stderr, and the detail lands on the result and its error line. Its + // prefix is what the infra classifier reads, so the mask must leave it intact. + var runner = new RecordingRunner { Setup = new SandboxResult { Status = SandboxStatus.Failed, ExitCode = 1, Stdout = "", Stderr = $"npm ERR! 401 token {Secret} rejected" } }; + + var grade = await GradeAsync(runner, new SupervisorAcceptanceGraderTests.FakeArtifactStore(), new[] { "npm", "test" }, setup: new[] { "sh", "-c", $"NPM_TOKEN={Secret} npm ci" }, secrets: [Secret]); + + grade.Detail.ShouldBe($"setup-failed: npm ERR! 401 token {PersistenceSecretRedactor.Marker} rejected"); + grade.Class.ShouldBe(GradeFailureClass.Environment); + } + + [Fact] + public async Task A_grade_for_a_task_embedding_no_secret_keeps_its_evidence_verbatim() + { + var runner = new RecordingRunner { Check = new SandboxResult { Status = SandboxStatus.Failed, ExitCode = 1, Stdout = $"echo {Secret}", Stderr = "" } }; + var artifacts = new SupervisorAcceptanceGraderTests.FakeArtifactStore(); + + await GradeAsync(runner, artifacts, new[] { "sh", "-c", $"echo {Secret}; exit 1" }, setup: null, secrets: null); + + artifacts.Puts.ShouldHaveSingleItem().Text.ShouldContain(Secret, customMessage: "a value nobody named as a secret is not masked — the grade is byte-identical to before"); + } + + [Fact] + public void Every_kept_field_of_a_grade_is_masked_and_a_grade_with_no_secrets_is_returned_as_is() + { + var grade = new BenchmarkGrade { Passed = false, Detail = $"setup-failed: {Secret}", EvidenceText = $"$ sh -c {Secret}", EvidenceTail = $"tail {Secret}", OracleNote = $"note {Secret}" }; + + var masked = SupervisorAcceptanceGrader.MaskedGrade(grade, [Secret]); + + masked.Detail.ShouldBe("setup-failed: [REDACTED]"); + masked.EvidenceText.ShouldBe("$ sh -c [REDACTED]"); + masked.EvidenceTail.ShouldBe("tail [REDACTED]"); + masked.OracleNote.ShouldBe("note [REDACTED]"); + SupervisorAcceptanceGrader.MaskedGrade(grade, null).ShouldBeSameAs(grade); + } + + private static async Task GradeAsync(RecordingRunner runner, SupervisorAcceptanceGraderTests.FakeArtifactStore artifacts, string[] command, string[]? setup, IReadOnlyList? secrets) + { + var directory = Directory.CreateTempSubdirectory("cs-grade-mask-").FullName; + try + { + // Only the seams GradeDirectoryAsync reaches are real or recorded: the runner, the oracle registry and the + // evidence store. + var grader = new SupervisorAcceptanceGrader(null!, null!, runner, new BenchmarkGraderRegistry(new IBenchmarkGrader[] { new TestsPassGrader() }), null!, artifacts, null!, NullLogger.Instance); + var spec = new SupervisorAcceptanceSpec { Command = command, SetupCommand = setup }; + + return await grader.GradeDirectoryAsync(new DirectoryAcceptanceGradeRequest { Directory = directory, Spec = spec, TeamId = Guid.NewGuid(), TimeoutSeconds = 30, Posture = null, WorkflowSecrets = secrets }, CancellationToken.None); + } + finally { Directory.Delete(directory, recursive: true); } + } + + /// Records every spec it runs; a spec carrying the setup argv answers , any other . + private sealed class RecordingRunner : ISandboxRunnerRegistry, ISandboxRunner + { + public SandboxResult Setup { get; init; } = new() { Status = SandboxStatus.Success, ExitCode = 0, Stdout = "", Stderr = "" }; + public SandboxResult Check { get; init; } = new() { Status = SandboxStatus.Success, ExitCode = 0, Stdout = "", Stderr = "" }; + public List Specs { get; } = new(); + public string Kind => SandboxKinds.Local; + public IReadOnlyList All => new ISandboxRunner[] { this }; + public ISandboxRunner Resolve(string kind) => this; + + public Task RunAsync(SandboxSpec spec, CancellationToken cancellationToken) + { + Specs.Add(spec); + return Task.FromResult(spec.Args.Any(arg => arg.Contains("npm ci", StringComparison.Ordinal)) ? Setup : Check); + } + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorAcceptanceTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorAcceptanceTests.cs index 02def4a1b..ebc42d036 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorAcceptanceTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorAcceptanceTests.cs @@ -676,6 +676,30 @@ public async Task Every_repository_lane_grades_under_the_producing_runs_posture( expected.AllowNetwork.ShouldBe(tier == AgentAutonomyLevel.Trusted, "fixture check: the two tiers really differ on the network"); } + [Theory] + [InlineData("branch")] + [InlineData("patch")] + [InlineData("multi-repo")] + public async Task Every_repository_lane_hands_the_grade_the_workflow_secrets_its_task_embeds(string lane) + { + // The task's acceptance argv may carry a {{team.X}} the task was sealed over; the grade runs it exactly and + // must mask it out of the evidence it stores, so every lane passes the opened task's secrets along. + const string secret = "CANARY-lane-7e2a9c41"; + var (executor, grader) = NewExecutor(new BenchmarkGrade { Passed = true, Detail = "tests-passed" }); + var task = TaskWith(Spec("sh", "-c", $"curl -H 'Authorization: Bearer {secret}' https://deploy.test")) with { WorkflowSecrets = [secret] }; + var result = lane switch + { + "branch" => Succeeded(), + "patch" => SucceededPatchOnly(), + _ => Succeeded() with { RepositoryResults = new[] { new RepositoryRunResult { RepositoryId = Guid.NewGuid(), Alias = "web", ProducedBranch = "agent/web" }, new RepositoryRunResult { RepositoryId = Guid.NewGuid(), Alias = "api", ProducedBranch = "agent/api" } } }, + }; + + await executor.GradeAcceptanceIfPresentAsync(Run(), task, result, workspace: null, CancellationToken.None); + + grader.WorkflowSecrets.Count.ShouldBe(lane == "multi-repo" ? 2 : 1, "fixture check: the lane under test actually graded"); + grader.WorkflowSecrets.ShouldAllBe(secrets => secrets != null && secrets.SequenceEqual(new[] { secret })); + } + // ─── fixtures ──────────────────────────────────────────────────────────────── private static AgentRun Run() => new() { Id = Guid.NewGuid(), TeamId = Guid.NewGuid() }; @@ -786,15 +810,20 @@ private sealed class FakeGrader : ISupervisorAcceptanceGrader /// The producing-run posture every request-form grade carried, in call order — a lane that dropped it records null. public List Postures { get; } = new(); + /// The workflow secrets every request-form grade was told to mask, in call order — a lane that dropped them records null. + public List?> WorkflowSecrets { get; } = new(); + public Task GradeAsync(RepositoryAcceptanceGradeRequest request, CancellationToken cancellationToken) { Postures.Add(request.Posture); + WorkflowSecrets.Add(request.WorkflowSecrets); return GradeAsync(request.RepositoryId, request.TeamId, request.Branch, request.Spec, request.TimeoutSeconds, request.Anchor, cancellationToken); } public Task GradePatchAsync(PatchAcceptanceGradeRequest request, CancellationToken cancellationToken) { Postures.Add(request.Posture); + WorkflowSecrets.Add(request.WorkflowSecrets); return GradePatchAsync(request.RepositoryId, request.TeamId, request.BaseSha, request.InlinePatch, request.PatchArtifactId, request.Spec, request.TimeoutSeconds, cancellationToken); } diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorOutputReviewTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorOutputReviewTests.cs index 61032e676..69fe44999 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorOutputReviewTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/AgentRunExecutorOutputReviewTests.cs @@ -236,6 +236,75 @@ public async Task A_workflow_run_pushes_an_agent_critic_scope_keyed_to_the_cell_ customMessage: "the OUTPUT review names its own kind at THIS call site — the Room's 'did anything check the result?' probe reads critic.output, and the plan/decision critics' generic critic.review must never satisfy it"); } + [Theory] + [InlineData(true)] // the task embeds a workflow secret → the critic's capture masks it + [InlineData(false)] // it embeds none → no capture redactor, byte-identical to before + public async Task The_agent_critic_scope_masks_the_workflow_secrets_the_reviewed_goal_embeds(bool embedsSecret) + { + // The critic's prompt carries the producer's goal, and this scope is pushed by the executor OUTSIDE any node, so + // it does not inherit the node scope's capture redactor the engine gives every node's model calls — without its + // own, interaction.started would freeze a goal-borne {{team.X}} into the ledger. + const string secret = "CANARY-critic-6a0f3d92"; + var (runId, executor, _, critic) = NewExecutor(new CriticVerdict { Mode = ReviewMode.Gate, Approved = true }); + var task = GatedTask with { Goal = $"deploy with {secret}", WorkflowSecrets = embedsSecret ? [secret] : null }; + + await executor.ReviewOutputIfEnabledAsync(new(runId, runId, 1), task, SucceededWithChanges(), Run(runId, workflowRunId: Guid.NewGuid(), nodeId: "agent"), CancellationToken.None); + + var redactor = critic.ObservedScope!.CaptureRedactor; + + if (embedsSecret) redactor!.Redact($"goal: deploy with {secret}").Value.ShouldBe("goal: deploy with [REDACTED]"); + else redactor.ShouldBeNull(); + } + + [Theory] + [InlineData(true)] // approved: the review.completed beat still carries the verdict's words + [InlineData(false)] // flagged: the beat, the warning event and the result's ReviewFeedback all carry them + public async Task A_model_verdict_quoting_the_goals_secret_reaches_every_sink_masked(bool approve) + { + // The critic is shown the EXACT goal and a model restates the task it judges. Its capture is masked by the + // critic scope, but the verdict outlives that scope: the executor writes its words into the ledger beat, the + // flagged warning and the result after the scope is gone. + const string secret = "CANARY-verdict-3b1e7c55"; + var quote = $"call the staging API with {secret}"; + var issues = approve ? Array.Empty() : new[] { new CriticIssue { Text = $"never sent {secret}", Evidence = quote, Severity = CriticSeverity.Blocker } }; + var verdict = new CriticVerdict { Mode = ReviewMode.Gate, Approved = approve, Rationale = $"The task was: \"{quote}\".", Issues = issues, ReviewerModel = "stub-reviewer" }; + var (runId, executor, runs, _, _, ledger) = NewExecutorWithStore(verdict); + var task = GatedTask with { Goal = quote, WorkflowSecrets = [secret] }; + + var reviewed = await executor.ReviewOutputIfEnabledAsync(new(runId, runId, 1), task, SucceededWithChanges(), Run(runId, workflowRunId: Guid.NewGuid(), nodeId: "agent"), CancellationToken.None); + + reviewed.OutputReview.ShouldBe(approve ? OutputReviewState.Approved : OutputReviewState.Flagged, "fixture check: the verdict was reached and applied"); + var beat = ledger.Records.ShouldHaveSingleItem().Payload; + var sinks = string.Join("\n", runs.AppendedEvents.Select(e => e.Text).Append(beat).Append(reviewed.ReviewFeedback ?? "")); + sinks.ShouldContain("The task was:", customMessage: "the verdict's words still reach every sink — masked, not dropped"); + sinks.ShouldContain(CodeSpace.Core.Services.Workflows.Runtime.PersistenceSecretRedactor.Marker); + sinks.ShouldNotContain(secret); + + if (!approve) runs.AppendedEvents.ShouldHaveSingleItem().Text.ShouldContain($"never sent {CodeSpace.Core.Services.Workflows.Runtime.PersistenceSecretRedactor.Marker}"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void A_verdict_is_masked_only_by_the_secrets_its_task_embeds(bool embedsSecret) + { + const string secret = "CANARY-critique-90d1aa2e"; + var verdict = new CriticVerdict { Mode = ReviewMode.Improve, Rationale = $"r {secret}", Critique = $"c {secret}", Issues = new[] { new CriticIssue { Text = $"t {secret}", Evidence = $"e {secret}" } } }; + + var masked = AgentRunExecutor.MaskedVerdict(verdict, GatedTask with { WorkflowSecrets = embedsSecret ? [secret] : null }); + + if (!embedsSecret) + { + masked.ShouldBeSameAs(verdict, "a task embedding no secret keeps its verdict byte-identical"); + return; + } + + masked.Rationale.ShouldBe("r [REDACTED]"); + masked.Critique.ShouldBe("c [REDACTED]", "an Improve round feeds the critique back, and a flagged result persists it"); + masked.Issues.ShouldHaveSingleItem().ToString().ShouldBe("t [REDACTED] (evidence: e [REDACTED])"); + masked.Mode.ShouldBe(ReviewMode.Improve); + } + [Theory] [InlineData(7, 7)] // the launch stamped a $7 ceiling on the RUN → the critic is admitted against THAT [InlineData(null, null)] // no route ⇒ the run declares no ceiling ⇒ Unbudgeted, which still records diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/InheritedSecretTaintTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/InheritedSecretTaintTests.cs new file mode 100644 index 000000000..4bd72b5f1 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/InheritedSecretTaintTests.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using CodeSpace.Core.Services.Workflows.Runtime; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +/// +/// A child run's own template heads that carry a parent's secret: the trigger / input keys whose value embeds an +/// inherited value become secret paths, so {{trigger.token}} trips the same path-based guards +/// {{wf.TOKEN}} does in the parent. A key with no inherited value in it stays plain. +/// +[Trait("Category", "Unit")] +public sealed class InheritedSecretTaintTests +{ + private const string Secret = "CANARY-taint-a8e4c1d9"; + + private static Dictionary Bag(object value) => + JsonSerializer.Deserialize>(JsonSerializer.Serialize(value))!; + + [Fact] + public void Every_key_whose_value_embeds_an_inherited_secret_is_tainted_under_both_heads() + { + var trigger = Bag(new { token = Secret, header = new { auth = $"Bearer {Secret}" }, plain = "public" }); + var input = Bag(new { token = Secret, plain = "public" }); + + var paths = InheritedSecretTaint.Paths(trigger, input, [Secret]); + + paths.ShouldBe(["trigger.token", "trigger.header", "input.token"], ignoreOrder: true); + } + + [Fact] + public void Nothing_inherited_taints_nothing() + { + var trigger = Bag(new { token = Secret }); + + InheritedSecretTaint.Paths(trigger, trigger, []).ShouldBeEmpty(); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/MapBranchScopeTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/MapBranchScopeTests.cs index 2140e3a1a..2c7a9ca49 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/MapBranchScopeTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/MapBranchScopeTests.cs @@ -76,4 +76,32 @@ public void Loop_scope_inherits_the_enclosing_iteration_unlike_a_map() loopScope.Iteration.ShouldBeSameAs(outer.Iteration, "the loop passes the enclosing iteration through by reference"); loopScope.Iteration!["item"].GetString().ShouldBe("outer-el"); } + + [Fact] + public void Every_derived_scope_keeps_the_secrets_its_run_inherited() + { + // A sub-workflow child's inherited parent secrets are no bucket a template names, so a derived scope that + // dropped them would let a map / loop / iterate body persist them unmasked — the gap the Projects + + // SecretPaths copy once had (see FlowIterateNode.BuildIterationScope). + var outer = OuterWithIteration(JsonSerializer.SerializeToElement("outer-el")); + var tainted = new NodeRunScope + { + Trigger = outer.Trigger, Iteration = outer.Iteration, + SecretPaths = new HashSet { "trigger.token" }, InheritedSecretValues = ["inherited-parent-secret"], + }; + var element = JsonSerializer.SerializeToElement("el"); + + var derived = new[] + { + WorkflowEngine.BuildMapBranchScope(tainted, element, index: 0), + WorkflowEngine.BuildLoopScope(tainted, tainted.Nodes, new Dictionary(), index: 0), + CodeSpace.Core.Services.Workflows.Nodes.Builtin.FlowIterateNode.BuildIterationScope(tainted, "item", element, index: 0), + }; + + foreach (var scope in derived) + { + scope.InheritedSecretValues.ShouldBe(["inherited-parent-secret"]); + PersistenceSecretRedactor.FromScope(scope).Redact("x inherited-parent-secret x").Value.ShouldBe("x [REDACTED] x"); + } + } } diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/PersistenceSecretRedactorTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/PersistenceSecretRedactorTests.cs index 867ce4a45..9251e8d62 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/PersistenceSecretRedactorTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/PersistenceSecretRedactorTests.cs @@ -3,6 +3,7 @@ namespace CodeSpace.UnitTests.Workflows; +[Trait("Category", "Unit")] public sealed class PersistenceSecretRedactorTests { [Fact] @@ -43,4 +44,38 @@ public void Redact_BagReportsUnchangedWithoutMutatingRuntimeValues() Assert.Equal(original.GetRawText(), result.Value["result"].GetRawText()); Assert.Equal("{\"value\":\"public\"}", values["result"].GetRawText()); } + + [Fact] + public void FoundIn_NamesExactlyTheSecretsRedactWouldMask() + { + var subject = new PersistenceSecretRedactor(["token-123", "42", "absent-secret", "key-x"]); + var value = JsonSerializer.SerializeToElement(new Dictionary + { + ["note"] = "prefix token-123 suffix", + ["key-x-name"] = 420, + ["count"] = 42, + }); + + var found = subject.FoundIn(value); + + Assert.Equal(["42", "key-x", "token-123"], found.OrderBy(secret => secret, StringComparer.Ordinal).ToArray()); + Assert.True(subject.Redact(value).Changed); + Assert.Empty(new PersistenceSecretRedactor(["42"]).FoundIn(JsonSerializer.SerializeToElement(new { count = 420 }))); + } + + [Fact] + public void FromScope_MasksValuesTheRunInheritedAsWellAsItsOwn() + { + var scope = new NodeRunScope + { + Trigger = new Dictionary(), + Team = new Dictionary { ["OWN"] = JsonSerializer.SerializeToElement("own-team-secret") }, + SecretPaths = new HashSet { "team.OWN", "trigger.token" }, + InheritedSecretValues = ["inherited-parent-wf-secret"], + }; + + var redacted = PersistenceSecretRedactor.FromScope(scope).Redact("own-team-secret and inherited-parent-wf-secret"); + + Assert.Equal("[REDACTED] and [REDACTED]", redacted.Value); + } } diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/WorkflowSensitivePayloadSealerTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/WorkflowSensitivePayloadSealerTests.cs new file mode 100644 index 000000000..da7997616 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/WorkflowSensitivePayloadSealerTests.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using CodeSpace.Core.Services.Workflows.Runtime; +using Microsoft.AspNetCore.DataProtection; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +/// +/// The sealer is the seam that keeps a resolved workflow secret out of a payload's PUBLIC column while the run it is +/// handed to still executes with the exact value: the public copy is masked, the exact copy and the values it embeds +/// are encrypted, and a payload carrying no secret is left byte-identical with no ciphertext at all. +/// +[Trait("Category", "Unit")] +public sealed class WorkflowSensitivePayloadSealerTests +{ + private const string Secret = "CANARY-sealer-3f1c9a7e5b2d"; + + private static readonly WorkflowSensitivePayloadSealer Sealer = new(new DataProtectionWorkflowSensitivePayloadProtector(new EphemeralDataProtectionProvider())); + + private static JsonElement Payload(object value) => JsonSerializer.SerializeToElement(value); + + [Fact] + public void A_payload_without_any_given_secret_stays_verbatim_and_unsealed() + { + var payload = Payload(new { token = "public", nested = new { n = 42 } }); + + var sealedPayload = Sealer.Seal(payload, new PersistenceSecretRedactor([Secret])); + + sealedPayload.PublicJson.ShouldBe(payload.GetRawText()); + sealedPayload.Ciphertext.ShouldBeNull("nothing was masked, so the public copy is itself exact — a row written for it is byte-identical to a pre-sealing row"); + } + + [Fact] + public void A_secret_bearing_payload_is_masked_in_public_and_opens_to_the_exact_payload_and_its_secrets() + { + var payload = Payload(new { token = Secret, header = $"Bearer {Secret}", other = "kept" }); + + var sealedPayload = Sealer.Seal(payload, new PersistenceSecretRedactor([Secret, "never-present-secret"])); + + sealedPayload.PublicJson.ShouldNotContain(Secret); + sealedPayload.PublicJson.ShouldContain("Bearer [REDACTED]"); + sealedPayload.PublicJson.ShouldContain("kept"); + sealedPayload.Ciphertext.ShouldNotBeNull(); + sealedPayload.Ciphertext.ShouldNotContain(Secret, customMessage: "the exact copy is encrypted, never stored as text beside the mask"); + + var opened = Sealer.Open(sealedPayload.PublicJson, sealedPayload.Ciphertext); + + JsonDocument.Parse(opened.Json).RootElement.GetProperty("header").GetString().ShouldBe($"Bearer {Secret}"); + opened.Secrets.ShouldBe([Secret], "only the values the payload actually embeds travel with it — never the rest of the sealing scope"); + } + + [Fact] + public void An_unsealed_row_opens_from_its_public_column_with_no_secrets() + { + var opened = Sealer.Open("""{"token":"legacy"}""", null); + + opened.Json.ShouldBe("""{"token":"legacy"}"""); + opened.Secrets.ShouldBeEmpty(); + } + + [Fact] + public void A_ciphertext_of_another_kind_under_the_same_purpose_is_refused() + { + var protector = new DataProtectionWorkflowSensitivePayloadProtector(new EphemeralDataProtectionProvider()); + var sealer = new WorkflowSensitivePayloadSealer(protector); + var nodeOutputs = protector.Protect("""{"outputs":{"x":"y"}}"""); + + Should.Throw(() => sealer.Open("{}", nodeOutputs)); + } +}