Enforce named future bridge surfaces

This commit is contained in:
Meta Vens 2026-06-22 16:44:14 +01:00
commit 30e594570d
10 changed files with 186 additions and 6 deletions

View file

@ -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

View file

@ -415,6 +415,7 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> 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<BifrostDbContext> 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);

View file

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
public partial class AddBridgeActionTargetSurfaceName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_BridgeActions_TargetSurface_TargetSurfaceName_Status",
table: "BridgeActions");
migrationBuilder.DropColumn(
name: "TargetSurfaceName",
table: "BridgeActions");
}
}
}

View file

@ -367,6 +367,11 @@ namespace Bifrost.Web.Data.Migrations
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("TargetSurfaceName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("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");
});

View file

@ -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;

View file

@ -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,

View file

@ -1058,6 +1058,7 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
targetSurface = "Other",
actionKind = "Other",
targetLocator = "future://public-surface/channel",
targetSurfaceName = "future-surface",
sourceKind = "epiphany_persona_public_surface",
sourceId = "persona-speech-audit-future-surface-missing-identity",
authorityReference = "epiphany.persona_speech_audit",
@ -1086,7 +1087,7 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
}
[Fact]
public async Task Persona_other_surface_action_with_bifrost_identity_and_heimdall_reference_is_authorized()
public async Task Persona_other_surface_action_without_target_surface_name_is_denied()
{
using var client = _factory.CreateClient();
await EnsureBifrostIdentityRegisteredAsync("epiphany.Persona", "heimdall-account-epiphany-persona");
@ -1099,10 +1100,95 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
actionKind = "Other",
targetLocator = "future://public-surface/channel",
sourceKind = "epiphany_persona_public_surface",
sourceId = "persona-speech-audit-future-surface-authorized",
sourceId = "persona-speech-audit-future-surface-missing-name",
authorityReference = "epiphany.persona_speech_audit",
bifrostIdentity = "epiphany.Persona",
heimdallCapabilityReference = "heimdall:future-surface:capability:epiphany-persona",
title = "Persona future public surface post",
summary = "Bifrost must know which future surface this Other action targets."
});
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<BridgeActionHttpResult>();
Assert.NotNull(action);
Assert.Equal("Denied", action.Status);
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
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<BridgeActionHttpResult>();
Assert.NotNull(action);
Assert.Equal("Denied", action.Status);
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
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<TestWebApplicationFac
Assert.Equal(BridgeActionStatus.Authorized, savedAction.Status);
Assert.Equal(BridgeTargetSurface.Other, savedAction.TargetSurface);
Assert.Equal("bluesky", savedAction.TargetSurfaceName);
Assert.Equal("epiphany.Persona", savedAction.BifrostIdentity);
Assert.Equal("heimdall:future-surface:capability:epiphany-persona", savedAction.HeimdallCapabilityReference);
Assert.Equal("heimdall:bluesky:capability:epiphany-persona", savedAction.HeimdallCapabilityReference);
Assert.Equal("epiphany-run-future-surface", savedAction.EpiphanyRunId);
Assert.Equal("Persona", savedAction.EpiphanyLaneId);
Assert.Equal("epiphany.Persona", savedAction.EpiphanyAgentIdentity);

View file

@ -26,7 +26,7 @@ public sealed class ProviderAdvertisementTests
AssertSchemaPurpose(root, "bifrost.bridge_receipt.v0", "current hosted governed crossing result witness");
AssertSchemaPurpose(root, "bifrost.patron_support_event.v0", "current hosted Heimdall-signed patron support fact consumed by Bifrost");
AssertBoundaryCommand(root, "bridge", "open GitHub draft PRs and PR comments through Bifrost gate");
AssertBoundaryCommand(root, "bridge", "record receipt-only future-surface requests before named actuators exist");
AssertBoundaryCommand(root, "bridge", "record receipt-only future-surface requests with named-surface Heimdall capability matching before named actuators exist");
AssertBoundaryCommand(root, "patron", "consume Heimdall-signed Patreon and PayPal support facts");
AssertBoundaryForbiddenAuthority(root, "patron", "does not store Patreon or PayPal provider tokens");
}

View file

@ -534,6 +534,7 @@ async function requestOtherBridgeAction(options) {
actorName: identity,
targetSurface: "Other",
actionKind: "Other",
targetSurfaceName: surfaceName,
targetRepositoryFullName: options["target-repository-full-name"] ?? "",
targetLocator,
title,

View file

@ -365,7 +365,7 @@ function buildAdvertisement(options) {
"execute approved handoffs",
"record receipts",
"open GitHub draft PRs and PR comments through Bifrost gate",
"record receipt-only future-surface requests before named actuators exist",
"record receipt-only future-surface requests with named-surface Heimdall capability matching before named actuators exist",
"post Persona-flaired Reddit organizing threads",
], [
"does not treat local protocol JSON as work authority",