Enforce named future bridge surfaces
This commit is contained in:
parent
718ae94caa
commit
30e594570d
10 changed files with 186 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -534,6 +534,7 @@ async function requestOtherBridgeAction(options) {
|
|||
actorName: identity,
|
||||
targetSurface: "Other",
|
||||
actionKind: "Other",
|
||||
targetSurfaceName: surfaceName,
|
||||
targetRepositoryFullName: options["target-repository-full-name"] ?? "",
|
||||
targetLocator,
|
||||
title,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue