Gate public bridge actions on Bifrost identity

This commit is contained in:
Meta Vens 2026-06-22 14:54:14 +01:00
commit 445c27978b
9 changed files with 325 additions and 0 deletions

View file

@ -79,6 +79,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`, and `reddit-post` 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` 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.
- 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

@ -58,6 +58,13 @@ The hosted app now exposes a first-class bridge action ledger for governed trans
- `POST /bridge/actions/{id}/fail`
The owner is Bifrost, not the local script. The script or agent may execute the crossing, but Bifrost records the request, policy decision, lifecycle state, and receipt.
Persona and agent actions that cross into Discord or Reddit must carry a Bifrost
identity plus a Heimdall-backed capability/account reference at request time.
The local bridge token only proves that the actuator may call the ledger; it is
not the actor identity and not outside-provider authority. The ledger stores the
Bifrost identity, Heimdall reference, Epiphany run id, lane id, and agent
identity beside the action so later receipts can answer which agent did what
without opening local transcripts or trusting helper-script folklore.
The hosted app also exposes a dispatch-run ledger for the older Bifrost-launched worker bridge:

View file

@ -415,6 +415,11 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
entity.Property(x => x.SourceKind).HasMaxLength(120);
entity.Property(x => x.SourceId).HasMaxLength(240);
entity.Property(x => x.AuthorityReference).HasMaxLength(240);
entity.Property(x => x.BifrostIdentity).HasMaxLength(160);
entity.Property(x => x.HeimdallCapabilityReference).HasMaxLength(240);
entity.Property(x => x.EpiphanyRunId).HasMaxLength(160);
entity.Property(x => x.EpiphanyLaneId).HasMaxLength(120);
entity.Property(x => x.EpiphanyAgentIdentity).HasMaxLength(160);
entity.Property(x => x.PolicyDecision).HasMaxLength(500);
entity.Property(x => x.Title).HasMaxLength(240);
entity.Property(x => x.ReceiptUrl).HasMaxLength(500);
@ -423,6 +428,8 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
entity.HasIndex(x => new { x.TargetSurface, x.ActionKind, x.Status });
entity.HasIndex(x => new { x.ActorKind, x.ActorName });
entity.HasIndex(x => new { x.SourceKind, x.SourceId });
entity.HasIndex(x => x.BifrostIdentity);
entity.HasIndex(x => x.HeimdallCapabilityReference);
entity.HasOne(x => x.ActorUserAccount)
.WithMany(x => x.BridgeActions)
.HasForeignKey(x => x.ActorUserAccountId)

View file

@ -0,0 +1,96 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
public partial class AddBridgeActionIdentityProvenance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BifrostIdentity",
table: "BridgeActions",
type: "character varying(160)",
maxLength: 160,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "EpiphanyAgentIdentity",
table: "BridgeActions",
type: "character varying(160)",
maxLength: 160,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "EpiphanyLaneId",
table: "BridgeActions",
type: "character varying(120)",
maxLength: 120,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "EpiphanyRunId",
table: "BridgeActions",
type: "character varying(160)",
maxLength: 160,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "HeimdallCapabilityReference",
table: "BridgeActions",
type: "character varying(240)",
maxLength: 240,
nullable: false,
defaultValue: "");
migrationBuilder.CreateIndex(
name: "IX_BridgeActions_BifrostIdentity",
table: "BridgeActions",
column: "BifrostIdentity");
migrationBuilder.CreateIndex(
name: "IX_BridgeActions_HeimdallCapabilityReference",
table: "BridgeActions",
column: "HeimdallCapabilityReference");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_BridgeActions_BifrostIdentity",
table: "BridgeActions");
migrationBuilder.DropIndex(
name: "IX_BridgeActions_HeimdallCapabilityReference",
table: "BridgeActions");
migrationBuilder.DropColumn(
name: "BifrostIdentity",
table: "BridgeActions");
migrationBuilder.DropColumn(
name: "EpiphanyAgentIdentity",
table: "BridgeActions");
migrationBuilder.DropColumn(
name: "EpiphanyLaneId",
table: "BridgeActions");
migrationBuilder.DropColumn(
name: "EpiphanyRunId",
table: "BridgeActions");
migrationBuilder.DropColumn(
name: "HeimdallCapabilityReference",
table: "BridgeActions");
}
}
}

View file

@ -279,9 +279,29 @@ namespace Bifrost.Web.Data.Migrations
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("BifrostIdentity")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<DateTimeOffset?>("CompletedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("EpiphanyAgentIdentity")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("EpiphanyLaneId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("EpiphanyRunId")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("ExternalReceiptId")
.IsRequired()
.HasMaxLength(240)
@ -291,6 +311,11 @@ namespace Bifrost.Web.Data.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("HeimdallCapabilityReference")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<Guid?>("MotionId")
.HasColumnType("uuid");
@ -361,6 +386,10 @@ namespace Bifrost.Web.Data.Migrations
b.HasIndex("ActorUserAccountId");
b.HasIndex("BifrostIdentity");
b.HasIndex("HeimdallCapabilityReference");
b.HasIndex("MotionId");
b.HasIndex("Status");

View file

@ -926,6 +926,16 @@ public sealed class BridgeAction
public string AuthorityReference { get; set; } = string.Empty;
public string BifrostIdentity { get; set; } = string.Empty;
public string HeimdallCapabilityReference { get; set; } = string.Empty;
public string EpiphanyRunId { get; set; } = string.Empty;
public string EpiphanyLaneId { get; set; } = string.Empty;
public string EpiphanyAgentIdentity { get; set; } = string.Empty;
public string PolicyDecision { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;

View file

@ -38,6 +38,11 @@ public sealed class BridgeActionService(
SourceKind = NormalizeText(normalizedRequest.SourceKind),
SourceId = NormalizeText(normalizedRequest.SourceId),
AuthorityReference = NormalizeText(normalizedRequest.AuthorityReference),
BifrostIdentity = NormalizeText(normalizedRequest.BifrostIdentity),
HeimdallCapabilityReference = NormalizeText(normalizedRequest.HeimdallCapabilityReference),
EpiphanyRunId = NormalizeText(normalizedRequest.EpiphanyRunId),
EpiphanyLaneId = NormalizeText(normalizedRequest.EpiphanyLaneId),
EpiphanyAgentIdentity = NormalizeText(normalizedRequest.EpiphanyAgentIdentity),
PolicyDecision = policy.Decision,
Title = NormalizeText(normalizedRequest.Title),
Summary = NormalizeText(normalizedRequest.Summary),
@ -200,6 +205,19 @@ public sealed class BridgeActionService(
}
}
if (RequiresExternalAccountCapability(request))
{
if (string.IsNullOrWhiteSpace(request.BifrostIdentity))
{
return BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions must carry a Bifrost identity.");
}
if (string.IsNullOrWhiteSpace(request.HeimdallCapabilityReference))
{
return BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions must carry a Heimdall-backed capability or account reference.");
}
}
if (request.WorkItemId is not null)
{
var workItem = await dbContext.WorkItems
@ -350,6 +368,10 @@ public sealed class BridgeActionService(
return null;
}
private static bool RequiresExternalAccountCapability(BridgeActionRequest request) =>
request.ActorKind is BridgeActorKind.Agent or BridgeActorKind.Persona &&
request.TargetSurface is BridgeTargetSurface.Discord or BridgeTargetSurface.Reddit;
private static string NormalizeRepository(string value) => NormalizeText(value).ToLowerInvariant();
private static string ShortRepositoryName(string normalizedRepositoryFullName)
@ -391,6 +413,11 @@ public sealed record BridgeActionRequest(
string SourceKind,
string SourceId,
string AuthorityReference,
string BifrostIdentity,
string HeimdallCapabilityReference,
string EpiphanyRunId,
string EpiphanyLaneId,
string EpiphanyAgentIdentity,
string Title,
string Summary,
Guid? WorkItemId = null,
@ -408,6 +435,11 @@ public sealed record BridgeActionRequest(
SourceKind = NormalizeText(SourceKind),
SourceId = NormalizeText(SourceId),
AuthorityReference = NormalizeText(AuthorityReference),
BifrostIdentity = NormalizeText(BifrostIdentity),
HeimdallCapabilityReference = NormalizeText(HeimdallCapabilityReference),
EpiphanyRunId = NormalizeText(EpiphanyRunId),
EpiphanyLaneId = NormalizeText(EpiphanyLaneId),
EpiphanyAgentIdentity = NormalizeText(EpiphanyAgentIdentity),
Title = NormalizeText(Title),
Summary = NormalizeText(Summary)
};
@ -434,6 +466,11 @@ public sealed record BridgeActionResult(
string SourceKind,
string SourceId,
string AuthorityReference,
string BifrostIdentity,
string HeimdallCapabilityReference,
string EpiphanyRunId,
string EpiphanyLaneId,
string EpiphanyAgentIdentity,
string PolicyDecision,
string Title,
string ReceiptUrl,
@ -453,6 +490,11 @@ public sealed record BridgeActionResult(
action.SourceKind,
action.SourceId,
action.AuthorityReference,
action.BifrostIdentity,
action.HeimdallCapabilityReference,
action.EpiphanyRunId,
action.EpiphanyLaneId,
action.EpiphanyAgentIdentity,
action.PolicyDecision,
action.Title,
action.ReceiptUrl,

View file

@ -702,6 +702,130 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
Assert.Equal("Authorized", action.Status);
}
[Fact]
public async Task Persona_reddit_action_without_bifrost_identity_or_heimdall_reference_is_denied()
{
using var client = _factory.CreateClient();
var requestPayload = JsonSerializer.Serialize(new
{
actorKind = "Persona",
actorName = "Epiphany Persona",
targetSurface = "Reddit",
actionKind = "RedditPost",
targetLocator = "r/GameCultOrg",
sourceKind = "epiphany_persona_reddit",
sourceId = "persona-speech-audit-missing-identity",
authorityReference = "epiphany.persona_speech_audit",
title = "Persona Reddit thread",
summary = "This should not pass without Bifrost identity plus Heimdall reference."
});
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("Bifrost identity", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Persona_discord_action_requires_heimdall_reference_after_bifrost_identity()
{
using var client = _factory.CreateClient();
var requestPayload = JsonSerializer.Serialize(new
{
actorKind = "Persona",
actorName = "Epiphany Persona",
targetSurface = "Discord",
actionKind = "DiscordPost",
targetLocator = "channel/1501196543150264332",
sourceKind = "epiphany_persona_speech",
sourceId = "persona-speech-audit-missing-heimdall",
authorityReference = "epiphany.persona_speech_audit",
bifrostIdentity = "epiphany.Persona",
title = "Persona Discord post",
summary = "This should not pass without an outside-account capability reference."
});
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("Heimdall", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Persona_reddit_action_with_bifrost_identity_and_heimdall_reference_is_authorized_and_stored()
{
using var client = _factory.CreateClient();
var requestPayload = JsonSerializer.Serialize(new
{
actorKind = "Persona",
actorName = "Epiphany Persona",
targetSurface = "Reddit",
actionKind = "RedditPost",
targetLocator = "r/GameCultOrg",
sourceKind = "epiphany_persona_reddit",
sourceId = "persona-speech-audit-authorized",
authorityReference = "epiphany.persona_speech_audit",
bifrostIdentity = "epiphany.Persona",
heimdallCapabilityReference = "heimdall:reddit:capability:epiphany-persona",
epiphanyRunId = "epiphany-run-identity-gate",
epiphanyLaneId = "Persona",
epiphanyAgentIdentity = "epiphany.Persona",
title = "Persona Reddit thread",
summary = "This should pass with Bifrost identity and Heimdall reference."
});
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.Accepted, response.StatusCode);
var action = await response.Content.ReadFromJsonAsync<BridgeActionHttpResult>();
Assert.NotNull(action);
Assert.Equal("Authorized", 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.Authorized, savedAction.Status);
Assert.Equal("epiphany.Persona", savedAction.BifrostIdentity);
Assert.Equal("heimdall:reddit:capability:epiphany-persona", savedAction.HeimdallCapabilityReference);
Assert.Equal("epiphany-run-identity-gate", savedAction.EpiphanyRunId);
Assert.Equal("Persona", savedAction.EpiphanyLaneId);
Assert.Equal("epiphany.Persona", savedAction.EpiphanyAgentIdentity);
}
[Fact]
public async Task Local_bridge_token_can_record_dispatch_run_lifecycle()
{

View file

@ -1026,6 +1026,15 @@ async function beginBridgeAction(options, action) {
sourceKind: optionalString(options["source-kind"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_KIND) ?? "",
sourceId: optionalString(options["source-id"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_ID) ?? "",
authorityReference: optionalString(options["authority-ref"]) ?? optionalString(process.env.BIFROST_BRIDGE_AUTHORITY_REF) ?? "",
bifrostIdentity: optionalString(options.identity) ?? optionalString(process.env.BIFROST_IDENTITY) ?? "",
heimdallCapabilityReference:
optionalString(options["heimdall-capability-ref"]) ??
optionalString(process.env.HEIMDALL_CAPABILITY_REF) ??
optionalString(process.env.BIFROST_HEIMDALL_CAPABILITY_REF) ??
"",
epiphanyRunId: optionalString(options["epiphany-run-id"]) ?? optionalString(process.env.EPIPHANY_RUN_ID) ?? "",
epiphanyLaneId: optionalString(options["epiphany-lane-id"]) ?? optionalString(process.env.EPIPHANY_LANE_ID) ?? "",
epiphanyAgentIdentity: optionalString(options["epiphany-agent-identity"]) ?? optionalString(process.env.EPIPHANY_AGENT_IDENTITY) ?? "",
workItemId: optionalString(options["work-item-id"]) ?? null,
motionId: optionalString(options["motion-id"]) ?? null,
};