From f58a0cbc1b60ef5cc5849f85edfa60efd8f1d260 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 17 Jun 2026 11:22:29 +0100 Subject: [PATCH] Validate runtime receipt linkage --- README.md | 1 + docs/agent-transport.md | 1 + docs/bridge.md | 1 + .../Bridge/AgentDispatchRunService.cs | 75 +++++++++ .../Bridge/AgentTransportReceiptService.cs | 103 ++++++++++++ .../GovernanceActivityReceiptService.cs | 86 ++++++++++ src/Bifrost.Web/Program.cs | 63 ++++++-- .../PlatformEndpointsTests.cs | 151 ++++++++++++++++++ 8 files changed, 467 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 8e5ff7d..c071487 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ For the full staged roadmap, including contribution points, revenue share, patro - set `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` when `tools/bifrost-bridge.mjs` should round-trip every action through the app ledger before mutating GitHub, Discord, or Reddit - the same bridge URL/token pair lets `tools/dispatch-agent-requests.mjs` record `/dispatch/runs/*` receipts for launched Codex work, so Bifrost keeps a live run trail even when a turn fails - those hosted runtime receipt lanes are local-bridge-owned writes, not ordinary member-session writes: `/dispatch/runs/*`, `/transport/receipts`, and `/governance/receipts` now require the configured bridge token and do not inherit browser-user identity into agent activity rows +- those hosted runtime receipts also validate linkage instead of accepting free-floating text: dispatch runs require an existing request-lane receipt for the same request id, governance promotion receipts require a known promoted dispatch request, and request-lane rows cannot silently drift to a different repo or agent identity midstream - Bifrost-dispatched Codex turns also inject Bifrost-owned GitHub gates through environment config: `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` stays blocked, and direct `gh` usage is limited to explicit read-only commands unless `tools/bifrost-bridge.mjs` authorizes the bridge-owned GitHub write path - dispatched Codex turns now default to `workspace-write` instead of full machine access, and the app-server sandbox policy disables network access unless the operator explicitly chooses a broader sandbox - `tools/agent-transport.mjs` also uses that bridge configuration to record `/transport/receipts` entries for queue, claim, release, and close events on the request lane diff --git a/docs/agent-transport.md b/docs/agent-transport.md index 5a25e0f..b3796c2 100644 --- a/docs/agent-transport.md +++ b/docs/agent-transport.md @@ -127,6 +127,7 @@ node .\tools\agent-transport.mjs apply-snapshot --in E:\tmp\bifrost-agent-transp Dispatch receipts should use Bifrost's own public persona in the Bifrost governance channel. `tools/dispatch-agent-requests.mjs` reads `BIFROST_DISCORD_CHANNEL_ID` or `DISCORD_BIFROST_CHANNEL_ID` for the receipt target, uses persona name `Bifrost`, and defaults the persona avatar to the public `src/Bifrost.Web/wwwroot/img/bifrost-profile.png` raw GitHub URL unless `BIFROST_DISCORD_PERSONA_AVATAR_URL` or `DISCORD_PERSONA_AVATAR_URL_BIFROST` overrides it. Receipt text must lead with the concrete repo and work title, not a generic "recent consensus" summary, and must not expose request ids, workspace paths, log paths, or other debugging debris in Discord. The dispatcher now tags those Discord receipts with the Bifrost request id as bridge provenance, and dispatched Codex turns inherit default bridge provenance environment variables so any explicit `tools/bifrost-bridge.mjs` crossing inside the turn can land on the same request trail without hand-carried flags. When `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` are configured, the dispatcher also records `/dispatch/runs/*` receipts in the hosted app so Bifrost keeps a first-class run ledger for launch, completion, cancellation, and failure. Those hosted runtime receipt endpoints are owned by the local bridge token, not by ordinary member sessions. A signed-in member can govern work and request crossings, but they cannot POST `/dispatch/runs/*`, `/transport/receipts`, or `/governance/receipts` without the configured bridge token and pretend to be the worker runtime. +Those hosted receipt endpoints now also cross-check each other. Dispatch starts require an existing request-lane receipt for the same request id, governance promotions that cite `dispatchRequestId` must point at a known request-lane request, and additional request-lane receipts for one request id cannot drift onto a different repo or agent identity. The ledger is still transitional, but it has stopped politely accepting shape-shifting runtime history. Those dispatched turns also inherit Bifrost-owned GitHub gates through environment config. `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` stays blocked inside dispatched work even with `--no-verify`, and direct `gh` usage is limited to explicit read-only commands unless the bridge explicitly authorizes the GitHub mutation it owns. Those dispatched turns also lock the local-only recovery hatches. `--allow-ungated-github`, `BIFROST_ALLOW_UNGATED_GITHUB`, `--allow-unreceipted-activity`, and `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY` are refused inside dispatched work so an agent cannot quietly downgrade itself out of Bifrost's gate or receipt trail. Those dispatched turns also scrub ambient GitHub auth state from the child environment before the worker starts. `GH_TOKEN` and `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompts are disabled. That does not prove every imaginable direct-HTTP escape is impossible, but it removes the normal ambient credential path from dispatched work. diff --git a/docs/bridge.md b/docs/bridge.md index 1991142..1132f6f 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -78,6 +78,7 @@ The hosted app also exposes a governance activity receipt ledger: This covers the other side of the swarm's work: topic opens, comments, approvals, and promotions to dispatch. The topic and comment documents are still the governance substrate. The hosted receipt row is the Bifrost-side witness that an agent or operator performed the governance action. These runtime receipt lanes are not generic member write surfaces. In the current machine, `/dispatch/runs/*`, `/transport/receipts`, and `/governance/receipts` are owned by the configured local bridge token. Active members may govern and request bridge actions through the app, but they may not mint worker-history rows directly through a browser session and impersonate runtime activity. +They also now validate linkage across the hosted ledger instead of accepting isolated claims. A dispatch run must point at an already-seen request-lane receipt for the same request id. A governance promotion receipt that cites `dispatchRequestId` must point at a known request-lane request. Repeated request-lane rows for one request id may add activity, but they may not quietly change the repo or agent identity the request belongs to. Current gate behavior: diff --git a/src/Bifrost.Web/Features/Bridge/AgentDispatchRunService.cs b/src/Bifrost.Web/Features/Bridge/AgentDispatchRunService.cs index 8e23c74..36d0b64 100644 --- a/src/Bifrost.Web/Features/Bridge/AgentDispatchRunService.cs +++ b/src/Bifrost.Web/Features/Bridge/AgentDispatchRunService.cs @@ -15,6 +15,9 @@ public sealed class AgentDispatchRunService( Guid? startedByUserAccountId, CancellationToken cancellationToken) { + ValidateStartRequest(request); + await EnsureLinkedTransportRequestAsync(request, cancellationToken); + var now = timeProvider.GetUtcNow(); var run = new AgentDispatchRun { @@ -126,6 +129,76 @@ public sealed class AgentDispatchRunService( return AgentDispatchRunResult.From(run); } + private async Task EnsureLinkedTransportRequestAsync( + AgentDispatchRunStartRequest request, + CancellationToken cancellationToken) + { + var linkedReceipt = await dbContext.AgentTransportReceipts + .AsNoTracking() + .Where(x => x.RequestId == request.RequestId) + .OrderByDescending(x => x.OccurredAtUtc) + .FirstOrDefaultAsync(cancellationToken); + + if (linkedReceipt is null) + { + throw new AgentDispatchRunException( + $"Dispatch runs require an existing request-lane receipt for request {request.RequestId}."); + } + + EnsureSameIdentity( + "dispatch run target repo", + request.TargetRepoName, + "request-lane target repo", + linkedReceipt.TargetRepoName); + EnsureSameIdentity( + "dispatch run target repository", + request.TargetRepositoryFullName, + "request-lane target repository", + linkedReceipt.TargetRepositoryFullName); + EnsureSameIdentity( + "dispatch run target agent", + request.TargetAgentIdentity, + "request-lane target agent", + linkedReceipt.TargetAgentIdentity); + } + + private static void ValidateStartRequest(AgentDispatchRunStartRequest request) + { + if (string.IsNullOrWhiteSpace(request.RequestId)) + { + throw new AgentDispatchRunException("Dispatch runs require a request id."); + } + + if (string.IsNullOrWhiteSpace(request.TargetRepoName)) + { + throw new AgentDispatchRunException("Dispatch runs require a target repo name."); + } + + if (string.IsNullOrWhiteSpace(request.LaunchMode)) + { + throw new AgentDispatchRunException("Dispatch runs require a launch mode."); + } + } + + private static void EnsureSameIdentity( + string currentLabel, + string currentValue, + string linkedLabel, + string linkedValue) + { + var normalizedCurrent = NormalizeText(currentValue); + var normalizedLinked = NormalizeText(linkedValue); + if (string.IsNullOrWhiteSpace(normalizedCurrent) || string.IsNullOrWhiteSpace(normalizedLinked)) + { + return; + } + + if (!string.Equals(normalizedCurrent, normalizedLinked, StringComparison.OrdinalIgnoreCase)) + { + throw new AgentDispatchRunException($"{currentLabel} does not match the linked {linkedLabel}."); + } + } + private static string NormalizeText(string? value) => value?.Trim() ?? string.Empty; } @@ -194,3 +267,5 @@ public sealed record AgentDispatchRunResult( run.UpdatedAtUtc, run.CompletedAtUtc); } + +public sealed class AgentDispatchRunException(string message) : InvalidOperationException(message); diff --git a/src/Bifrost.Web/Features/Bridge/AgentTransportReceiptService.cs b/src/Bifrost.Web/Features/Bridge/AgentTransportReceiptService.cs index a9736d5..1564cd6 100644 --- a/src/Bifrost.Web/Features/Bridge/AgentTransportReceiptService.cs +++ b/src/Bifrost.Web/Features/Bridge/AgentTransportReceiptService.cs @@ -1,6 +1,7 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; namespace Bifrost.Web.Features.Bridge; @@ -14,6 +15,9 @@ public sealed class AgentTransportReceiptService( Guid? actorUserAccountId, CancellationToken cancellationToken) { + ValidateRequest(request); + await EnsureConsistentIdentityAsync(request, cancellationToken); + var receipt = new AgentTransportReceipt { RequestId = NormalizeText(request.RequestId), @@ -42,6 +46,103 @@ public sealed class AgentTransportReceiptService( return AgentTransportReceiptResult.From(receipt); } + private async Task EnsureConsistentIdentityAsync( + AgentTransportReceiptRequest request, + CancellationToken cancellationToken) + { + var priorReceipt = await dbContext.AgentTransportReceipts + .AsNoTracking() + .Where(x => x.RequestId == request.RequestId) + .OrderByDescending(x => x.OccurredAtUtc) + .FirstOrDefaultAsync(cancellationToken); + + if (priorReceipt is not null) + { + EnsureSameIdentity( + "request-lane target repo", + request.TargetRepoName, + "prior request-lane target repo", + priorReceipt.TargetRepoName); + EnsureSameIdentity( + "request-lane target repository", + request.TargetRepositoryFullName, + "prior request-lane target repository", + priorReceipt.TargetRepositoryFullName); + EnsureSameIdentity( + "request-lane target agent", + request.TargetAgentIdentity, + "prior request-lane target agent", + priorReceipt.TargetAgentIdentity); + } + + var linkedRun = await dbContext.AgentDispatchRuns + .AsNoTracking() + .Where(x => x.RequestId == request.RequestId) + .OrderByDescending(x => x.StartedAtUtc) + .FirstOrDefaultAsync(cancellationToken); + + if (linkedRun is not null) + { + EnsureSameIdentity( + "request-lane target repo", + request.TargetRepoName, + "dispatch-run target repo", + linkedRun.TargetRepoName); + EnsureSameIdentity( + "request-lane target repository", + request.TargetRepositoryFullName, + "dispatch-run target repository", + linkedRun.TargetRepositoryFullName); + EnsureSameIdentity( + "request-lane target agent", + request.TargetAgentIdentity, + "dispatch-run target agent", + linkedRun.TargetAgentIdentity); + } + } + + private static void ValidateRequest(AgentTransportReceiptRequest request) + { + if (string.IsNullOrWhiteSpace(request.RequestId)) + { + throw new AgentTransportReceiptException("Request-lane receipts require a request id."); + } + + if (string.IsNullOrWhiteSpace(request.Title)) + { + throw new AgentTransportReceiptException("Request-lane receipts require a title."); + } + + if (string.IsNullOrWhiteSpace(request.TargetRepoName)) + { + throw new AgentTransportReceiptException("Request-lane receipts require a target repo name."); + } + + if (string.IsNullOrWhiteSpace(request.ActorName)) + { + throw new AgentTransportReceiptException("Request-lane receipts require an actor name."); + } + } + + private static void EnsureSameIdentity( + string currentLabel, + string currentValue, + string linkedLabel, + string linkedValue) + { + var normalizedCurrent = NormalizeText(currentValue); + var normalizedLinked = NormalizeText(linkedValue); + if (string.IsNullOrWhiteSpace(normalizedCurrent) || string.IsNullOrWhiteSpace(normalizedLinked)) + { + return; + } + + if (!string.Equals(normalizedCurrent, normalizedLinked, StringComparison.OrdinalIgnoreCase)) + { + throw new AgentTransportReceiptException($"{currentLabel} does not match the linked {linkedLabel}."); + } + } + private static string BuildAuditDetail(AgentTransportReceipt receipt) { var actorName = string.IsNullOrWhiteSpace(receipt.ActorName) ? "unknown actor" : receipt.ActorName; @@ -89,3 +190,5 @@ public sealed record AgentTransportReceiptResult( receipt.Note, receipt.OccurredAtUtc); } + +public sealed class AgentTransportReceiptException(string message) : InvalidOperationException(message); diff --git a/src/Bifrost.Web/Features/Bridge/GovernanceActivityReceiptService.cs b/src/Bifrost.Web/Features/Bridge/GovernanceActivityReceiptService.cs index ac8a568..ffa1b3e 100644 --- a/src/Bifrost.Web/Features/Bridge/GovernanceActivityReceiptService.cs +++ b/src/Bifrost.Web/Features/Bridge/GovernanceActivityReceiptService.cs @@ -1,6 +1,7 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; namespace Bifrost.Web.Features.Bridge; @@ -14,6 +15,9 @@ public sealed class GovernanceActivityReceiptService( Guid? actorUserAccountId, CancellationToken cancellationToken) { + ValidateRequest(request); + await EnsureLinkedDispatchRequestAsync(request, cancellationToken); + var receipt = new GovernanceActivityReceipt { TopicId = NormalizeText(request.TopicId), @@ -43,6 +47,86 @@ public sealed class GovernanceActivityReceiptService( return GovernanceActivityReceiptResult.From(receipt); } + private async Task EnsureLinkedDispatchRequestAsync( + GovernanceActivityReceiptRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.DispatchRequestId)) + { + return; + } + + var linkedReceipt = await dbContext.AgentTransportReceipts + .AsNoTracking() + .Where(x => x.RequestId == request.DispatchRequestId) + .OrderByDescending(x => x.OccurredAtUtc) + .FirstOrDefaultAsync(cancellationToken); + + if (linkedReceipt is null) + { + throw new GovernanceActivityReceiptException( + $"Governance receipt references unknown dispatch request {request.DispatchRequestId}."); + } + + EnsureSameIdentity( + "governance jurisdiction repo", + request.JurisdictionRepoName, + "dispatch request repo", + linkedReceipt.TargetRepoName); + EnsureSameIdentity( + "governance jurisdiction agent", + request.JurisdictionAgentIdentity, + "dispatch request agent", + linkedReceipt.TargetAgentIdentity); + } + + private static void ValidateRequest(GovernanceActivityReceiptRequest request) + { + if (string.IsNullOrWhiteSpace(request.TopicId)) + { + throw new GovernanceActivityReceiptException("Governance receipts require a topic id."); + } + + if (string.IsNullOrWhiteSpace(request.Title)) + { + throw new GovernanceActivityReceiptException("Governance receipts require a title."); + } + + if (string.IsNullOrWhiteSpace(request.JurisdictionRepoName)) + { + throw new GovernanceActivityReceiptException("Governance receipts require a jurisdiction repo name."); + } + + if (string.IsNullOrWhiteSpace(request.ActorKind)) + { + throw new GovernanceActivityReceiptException("Governance receipts require an actor kind."); + } + + if (string.IsNullOrWhiteSpace(request.ActorName)) + { + throw new GovernanceActivityReceiptException("Governance receipts require an actor name."); + } + } + + private static void EnsureSameIdentity( + string currentLabel, + string currentValue, + string linkedLabel, + string linkedValue) + { + var normalizedCurrent = NormalizeText(currentValue); + var normalizedLinked = NormalizeText(linkedValue); + if (string.IsNullOrWhiteSpace(normalizedCurrent) || string.IsNullOrWhiteSpace(normalizedLinked)) + { + return; + } + + if (!string.Equals(normalizedCurrent, normalizedLinked, StringComparison.OrdinalIgnoreCase)) + { + throw new GovernanceActivityReceiptException($"{currentLabel} does not match the linked {linkedLabel}."); + } + } + private static string ToAuditSuffix(GovernanceActivityReceiptKind activityKind) => activityKind switch { GovernanceActivityReceiptKind.TopicOpened => "topic-opened", @@ -102,3 +186,5 @@ public sealed record GovernanceActivityReceiptResult( receipt.Note, receipt.OccurredAtUtc); } + +public sealed class GovernanceActivityReceiptException(string message) : InvalidOperationException(message); diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index d58a0ef..49070a2 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -490,8 +490,15 @@ dispatchRuns.MapPost("/start", async ( return Results.Unauthorized(); } - var result = await agentDispatchRunService.StartAsync(command, null, cancellationToken); - return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted); + try + { + var result = await agentDispatchRunService.StartAsync(command, null, cancellationToken); + return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted); + } + catch (AgentDispatchRunException error) + { + return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest); + } }); dispatchRuns.MapPost("/{id:guid}/complete", async ( @@ -507,10 +514,17 @@ dispatchRuns.MapPost("/{id:guid}/complete", async ( return Results.Unauthorized(); } - var result = await agentDispatchRunService.CompleteAsync(id, command, null, cancellationToken); - return result is null - ? Results.NotFound() - : JsonResult(result, appJsonSerializerOptions); + try + { + var result = await agentDispatchRunService.CompleteAsync(id, command, null, cancellationToken); + return result is null + ? Results.NotFound() + : JsonResult(result, appJsonSerializerOptions); + } + catch (AgentDispatchRunException error) + { + return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest); + } }); dispatchRuns.MapPost("/{id:guid}/fail", async ( @@ -526,10 +540,17 @@ dispatchRuns.MapPost("/{id:guid}/fail", async ( return Results.Unauthorized(); } - var result = await agentDispatchRunService.FailAsync(id, command, null, cancellationToken); - return result is null - ? Results.NotFound() - : JsonResult(result, appJsonSerializerOptions); + try + { + var result = await agentDispatchRunService.FailAsync(id, command, null, cancellationToken); + return result is null + ? Results.NotFound() + : JsonResult(result, appJsonSerializerOptions); + } + catch (AgentDispatchRunException error) + { + return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest); + } }); var transportReceipts = app.MapGroup("/transport/receipts"); @@ -546,8 +567,15 @@ transportReceipts.MapPost("", async ( return Results.Unauthorized(); } - var result = await agentTransportReceiptService.RecordAsync(command, null, cancellationToken); - return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted); + try + { + var result = await agentTransportReceiptService.RecordAsync(command, null, cancellationToken); + return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted); + } + catch (AgentTransportReceiptException error) + { + return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest); + } }); var governanceReceipts = app.MapGroup("/governance/receipts"); @@ -564,8 +592,15 @@ governanceReceipts.MapPost("", async ( return Results.Unauthorized(); } - var result = await governanceActivityReceiptService.RecordAsync(command, null, cancellationToken); - return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted); + try + { + var result = await governanceActivityReceiptService.RecordAsync(command, null, cancellationToken); + return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted); + } + catch (GovernanceActivityReceiptException error) + { + return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest); + } }); var eveGovernance = app.MapGroup("/eve/governance") diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs index b6e5e95..306a0b1 100644 --- a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -397,6 +397,18 @@ public sealed class PlatformEndpointsTests : IClassFixture