diff --git a/README.md b/README.md index 31dc0b4..0935365 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ For the full staged roadmap, including contribution points, revenue share, patro - GitHub bridge actions that claim Bifrost-owned provenance now validate that provenance too: a `bifrost_agent_transport_request` source must point at a known request-lane request for the same repo, and governance-backed bridge actions must point at a known Bifrost topic instead of a decorative source string - governance-backed GitHub publication is also no longer allowed to jump the queue: a Bifrost governance topic must have been approved or promoted before it can justify a GitHub bridge action - non-GitHub bridge activity is no longer allowed to drift off-book either: `discord-post`, `discord-dm`, `reddit-post`, and `other-request` now require `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` by default, with `--allow-unreceipted-activity true` reserved for explicit local recovery and refused inside dispatched work -- Persona and agent `discord-post`, `reddit-post`, and `other-request` bridge actions also require a Bifrost identity plus Heimdall-backed capability/account reference in the hosted ledger; pass `--identity`, `--heimdall-capability-ref`, and the Epiphany provenance flags or set the matching environment variables. The bridge token is an actuator credential, not identity or provider authority. +- Persona and agent `discord-post`, `reddit-post`, and `other-request` bridge actions also require a Bifrost identity plus Heimdall-backed capability/account reference in the hosted ledger; pass `--identity`, `--heimdall-capability-ref`, and the Epiphany provenance flags or set the matching environment variables. Future-surface `other-request` actions must also carry `--surface-name`, and Bifrost rejects Heimdall references that do not match that named surface. The bridge token is an actuator credential, not identity or provider authority. - 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/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 0d9d841..06c5e67 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -415,6 +415,7 @@ public sealed class BifrostDbContext(DbContextOptions options) entity.Property(x => x.ActorName).HasMaxLength(160); entity.Property(x => x.TargetRepositoryFullName).HasMaxLength(240); entity.Property(x => x.TargetLocator).HasMaxLength(500); + entity.Property(x => x.TargetSurfaceName).HasMaxLength(120); entity.Property(x => x.SourceKind).HasMaxLength(120); entity.Property(x => x.SourceId).HasMaxLength(240); entity.Property(x => x.AuthorityReference).HasMaxLength(240); @@ -429,6 +430,7 @@ public sealed class BifrostDbContext(DbContextOptions options) entity.Property(x => x.ExternalReceiptId).HasMaxLength(240); entity.HasIndex(x => x.Status); entity.HasIndex(x => new { x.TargetSurface, x.ActionKind, x.Status }); + entity.HasIndex(x => new { x.TargetSurface, x.TargetSurfaceName, x.Status }); entity.HasIndex(x => new { x.ActorKind, x.ActorName }); entity.HasIndex(x => new { x.SourceKind, x.SourceId }); entity.HasIndex(x => x.BifrostIdentity); diff --git a/src/Bifrost.Web/Data/Migrations/20260622202000_AddBridgeActionTargetSurfaceName.cs b/src/Bifrost.Web/Data/Migrations/20260622202000_AddBridgeActionTargetSurfaceName.cs new file mode 100644 index 0000000..0eceae2 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20260622202000_AddBridgeActionTargetSurfaceName.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + /// + public partial class AddBridgeActionTargetSurfaceName : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TargetSurfaceName", + table: "BridgeActions", + type: "character varying(120)", + maxLength: 120, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateIndex( + name: "IX_BridgeActions_TargetSurface_TargetSurfaceName_Status", + table: "BridgeActions", + columns: new[] { "TargetSurface", "TargetSurfaceName", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_BridgeActions_TargetSurface_TargetSurfaceName_Status", + table: "BridgeActions"); + + migrationBuilder.DropColumn( + name: "TargetSurfaceName", + table: "BridgeActions"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 5de58b7..ef56f07 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -367,6 +367,11 @@ namespace Bifrost.Web.Data.Migrations .HasMaxLength(240) .HasColumnType("character varying(240)"); + b.Property("TargetSurfaceName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + b.Property("TargetSurface") .IsRequired() .HasColumnType("text"); @@ -402,6 +407,8 @@ namespace Bifrost.Web.Data.Migrations b.HasIndex("TargetSurface", "ActionKind", "Status"); + b.HasIndex("TargetSurface", "TargetSurfaceName", "Status"); + b.ToTable("BridgeActions"); }); diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 2fe93aa..dabfebf 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -924,6 +924,8 @@ public sealed class BridgeAction public string TargetLocator { get; set; } = string.Empty; + public string TargetSurfaceName { get; set; } = string.Empty; + public string SourceKind { get; set; } = string.Empty; public string SourceId { get; set; } = string.Empty; diff --git a/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs b/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs index 3cf1326..d58ea27 100644 --- a/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs +++ b/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs @@ -36,6 +36,7 @@ public sealed class BridgeActionService( MotionId = normalizedRequest.MotionId, TargetRepositoryFullName = NormalizeRepository(normalizedRequest.TargetRepositoryFullName), TargetLocator = NormalizeText(normalizedRequest.TargetLocator), + TargetSurfaceName = NormalizeSurfaceName(normalizedRequest.TargetSurfaceName), SourceKind = NormalizeText(normalizedRequest.SourceKind), SourceId = NormalizeText(normalizedRequest.SourceId), AuthorityReference = NormalizeText(normalizedRequest.AuthorityReference), @@ -316,8 +317,15 @@ public sealed class BridgeActionService( { BridgeTargetSurface.Discord => "discord", BridgeTargetSurface.Reddit => "reddit", + BridgeTargetSurface.Other => NormalizeSurfaceName(request.TargetSurfaceName), _ => string.Empty }; + if (request.TargetSurface is BridgeTargetSurface.Other && + string.IsNullOrWhiteSpace(expectedSurface)) + { + return BridgePolicyDecision.Reject("Future outside-world bridge actions must name the requested target surface."); + } + if (!string.IsNullOrWhiteSpace(expectedSurface) && !capabilityReference.Contains($":{expectedSurface}:", StringComparison.OrdinalIgnoreCase)) { @@ -444,6 +452,21 @@ public sealed class BridgeActionService( private static string NormalizeRepository(string value) => NormalizeText(value).ToLowerInvariant(); + private static string NormalizeSurfaceName(string? value) + { + var normalized = NormalizeText(value).ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return string.Empty; + } + + return normalized.All(character => + char.IsAsciiLetterOrDigit(character) || + character is '-' or '_' or '.') + ? normalized + : string.Empty; + } + private static string ShortRepositoryName(string normalizedRepositoryFullName) { if (string.IsNullOrWhiteSpace(normalizedRepositoryFullName)) @@ -484,6 +507,7 @@ public sealed record BridgeActionRequest( BridgeActionKind ActionKind, string TargetRepositoryFullName, string TargetLocator, + string TargetSurfaceName, string SourceKind, string SourceId, string AuthorityReference, @@ -506,6 +530,7 @@ public sealed record BridgeActionRequest( : NormalizeText(ActorName), TargetRepositoryFullName = NormalizeText(TargetRepositoryFullName), TargetLocator = NormalizeText(TargetLocator), + TargetSurfaceName = NormalizeSurfaceName(TargetSurfaceName), SourceKind = NormalizeText(SourceKind), SourceId = NormalizeText(SourceId), AuthorityReference = NormalizeText(AuthorityReference), @@ -519,6 +544,21 @@ public sealed record BridgeActionRequest( }; private static string NormalizeText(string? value) => value?.Trim() ?? string.Empty; + + private static string NormalizeSurfaceName(string? value) + { + var normalized = NormalizeText(value).ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return string.Empty; + } + + return normalized.All(character => + char.IsAsciiLetterOrDigit(character) || + character is '-' or '_' or '.') + ? normalized + : string.Empty; + } } public sealed record BridgeActionReceiptRequest( @@ -537,6 +577,7 @@ public sealed record BridgeActionResult( BridgeActionStatus Status, string TargetRepositoryFullName, string TargetLocator, + string TargetSurfaceName, string SourceKind, string SourceId, string AuthorityReference, @@ -561,6 +602,7 @@ public sealed record BridgeActionResult( action.Status, action.TargetRepositoryFullName, action.TargetLocator, + action.TargetSurfaceName, action.SourceKind, action.SourceId, action.AuthorityReference, diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs index 6aeddb6..e1fafba 100644 --- a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -1058,6 +1058,7 @@ public sealed class PlatformEndpointsTests : IClassFixture(); + Assert.NotNull(action); + Assert.Equal("Denied", action.Status); + + using var scope = _factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); + + Assert.Equal(BridgeActionStatus.Denied, savedAction.Status); + Assert.Contains("must name", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Persona_other_surface_action_with_wrong_surface_heimdall_reference_is_denied() + { + using var client = _factory.CreateClient(); + await EnsureBifrostIdentityRegisteredAsync("epiphany.Persona", "heimdall-account-epiphany-persona"); + + var requestPayload = JsonSerializer.Serialize(new + { + actorKind = "Persona", + actorName = "Epiphany Persona", + targetSurface = "Other", + actionKind = "Other", + targetLocator = "at://did:example/app.bsky.feed.post/123", + targetSurfaceName = "bluesky", + sourceKind = "epiphany_persona_public_surface", + sourceId = "persona-speech-audit-future-surface-wrong-ref", + authorityReference = "epiphany.persona_speech_audit", + bifrostIdentity = "epiphany.Persona", + heimdallCapabilityReference = "heimdall:mastodon:capability:epiphany-persona", + title = "Persona future public surface post", + summary = "The Heimdall reference must match the named future surface." + }); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); + request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); + request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var action = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(action); + Assert.Equal("Denied", action.Status); + + using var scope = _factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); + + Assert.Equal(BridgeActionStatus.Denied, savedAction.Status); + Assert.Equal("bluesky", savedAction.TargetSurfaceName); + Assert.Contains("does not match", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Persona_other_surface_action_with_bifrost_identity_and_heimdall_reference_is_authorized() + { + using var client = _factory.CreateClient(); + await EnsureBifrostIdentityRegisteredAsync("epiphany.Persona", "heimdall-account-epiphany-persona"); + + var requestPayload = JsonSerializer.Serialize(new + { + actorKind = "Persona", + actorName = "Epiphany Persona", + targetSurface = "Other", + actionKind = "Other", + targetLocator = "at://did:example/app.bsky.feed.post/123", + targetSurfaceName = "bluesky", + sourceKind = "epiphany_persona_public_surface", + sourceId = "persona-speech-audit-future-surface-authorized", + authorityReference = "epiphany.persona_speech_audit", + bifrostIdentity = "epiphany.Persona", + heimdallCapabilityReference = "heimdall:bluesky:capability:epiphany-persona", epiphanyRunId = "epiphany-run-future-surface", epiphanyLaneId = "Persona", epiphanyAgentIdentity = "epiphany.Persona", @@ -1127,8 +1213,9 @@ public sealed class PlatformEndpointsTests : IClassFixture