Gate future public bridge surfaces

This commit is contained in:
Meta Vens 2026-06-22 15:42:43 +01:00
commit 675c475752
3 changed files with 103 additions and 14 deletions

View file

@ -60,13 +60,14 @@ 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.
Persona and agent actions that cross into Discord, Reddit, or a future
outside-world surface (`Other` until the surface has a first-class enum value)
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:
@ -102,7 +103,7 @@ Current gate behavior:
- agent, Persona, and service actions must cite provenance: `authorityReference`, `sourceKind` plus `sourceId`, `workItemId`, or `motionId`
- completed actions must report a receipt URL, external receipt id, or serialized receipt payload
- GitHub mutation commands fail closed unless Bifrost authorization is configured; `--allow-ungated-github true` is the explicit operator-recovery hatch
- the future blessed credential for public-world crossings is a Bifrost identity plus Heimdall-issued capability token or verifiable capability/account reference. The local bridge token is a transitional service actuator credential, not a substitute for Bifrost identity or Heimdall-backed outside-account authority.
- the future blessed credential for public-world crossings is a Bifrost identity plus Heimdall-issued capability token or verifiable capability/account reference. New outside-world bridge surfaces must start behind that same identity/capability gate, using `Other` only as a temporary enum bucket until the target surface gets a named contract. The local bridge token is a transitional service actuator credential, not a substitute for Bifrost identity or Heimdall-backed outside-account authority.
Current CLI wiring:

View file

@ -210,7 +210,7 @@ public sealed class BridgeActionService(
{
if (string.IsNullOrWhiteSpace(request.BifrostIdentity))
{
return BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions must carry a Bifrost identity.");
return BridgePolicyDecision.Reject("Persona and agent outside-world bridge actions must carry a Bifrost identity.");
}
var identityResult = await ResolveRegisteredBifrostIdentityAsync(request.BifrostIdentity, cancellationToken);
@ -221,7 +221,7 @@ public sealed class BridgeActionService(
if (string.IsNullOrWhiteSpace(request.HeimdallCapabilityReference))
{
return BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions must carry a Heimdall-backed capability or account reference.");
return BridgePolicyDecision.Reject("Persona and agent outside-world bridge actions must carry a Heimdall-backed capability or account reference.");
}
var capabilityDecision = EvaluateHeimdallCapabilityReference(request, identityResult.UserAccount!);
@ -291,7 +291,7 @@ public sealed class BridgeActionService(
{
return new RegisteredBifrostIdentityResult(
null,
BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions must carry a registered Bifrost identity."));
BridgePolicyDecision.Reject("Persona and agent outside-world bridge actions must carry a registered Bifrost identity."));
}
return new RegisteredBifrostIdentityResult(userAccount, null);
@ -303,13 +303,13 @@ public sealed class BridgeActionService(
{
if (string.IsNullOrWhiteSpace(userAccount.HeimdallAccountId))
{
return BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions require the Bifrost identity to be linked to a Heimdall account.");
return BridgePolicyDecision.Reject("Persona and agent outside-world bridge actions require the Bifrost identity to be linked to a Heimdall account.");
}
var capabilityReference = NormalizeText(request.HeimdallCapabilityReference);
if (!capabilityReference.StartsWith("heimdall:", StringComparison.OrdinalIgnoreCase))
{
return BridgePolicyDecision.Reject("Persona and agent Discord/Reddit bridge actions must carry a Heimdall-backed capability or account reference.");
return BridgePolicyDecision.Reject("Persona and agent outside-world bridge actions must carry a Heimdall-backed capability or account reference.");
}
var expectedSurface = request.TargetSurface switch
@ -440,7 +440,7 @@ public sealed class BridgeActionService(
private static bool RequiresExternalAccountCapability(BridgeActionRequest request) =>
request.ActorKind is BridgeActorKind.Agent or BridgeActorKind.Persona &&
request.TargetSurface is BridgeTargetSurface.Discord or BridgeTargetSurface.Reddit;
request.TargetSurface is BridgeTargetSurface.Discord or BridgeTargetSurface.Reddit or BridgeTargetSurface.Other;
private static string NormalizeRepository(string value) => NormalizeText(value).ToLowerInvariant();

View file

@ -969,6 +969,94 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
Assert.Equal("epiphany.Persona", savedAction.EpiphanyAgentIdentity);
}
[Fact]
public async Task Persona_other_surface_action_without_bifrost_identity_is_denied()
{
using var client = _factory.CreateClient();
var requestPayload = JsonSerializer.Serialize(new
{
actorKind = "Persona",
actorName = "Epiphany Persona",
targetSurface = "Other",
actionKind = "Other",
targetLocator = "future://public-surface/channel",
sourceKind = "epiphany_persona_public_surface",
sourceId = "persona-speech-audit-future-surface-missing-identity",
authorityReference = "epiphany.persona_speech_audit",
title = "Persona future public surface post",
summary = "This future outside-world crossing must not pass without identity provenance."
});
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("outside-world", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Bifrost identity", 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 = "future://public-surface/channel",
sourceKind = "epiphany_persona_public_surface",
sourceId = "persona-speech-audit-future-surface-authorized",
authorityReference = "epiphany.persona_speech_audit",
bifrostIdentity = "epiphany.Persona",
heimdallCapabilityReference = "heimdall:future-surface:capability:epiphany-persona",
epiphanyRunId = "epiphany-run-future-surface",
epiphanyLaneId = "Persona",
epiphanyAgentIdentity = "epiphany.Persona",
title = "Persona future public surface post",
summary = "This future outside-world crossing is allowed only with Bifrost and Heimdall provenance."
});
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(BridgeTargetSurface.Other, savedAction.TargetSurface);
Assert.Equal("epiphany.Persona", savedAction.BifrostIdentity);
Assert.Equal("heimdall:future-surface:capability:epiphany-persona", savedAction.HeimdallCapabilityReference);
Assert.Equal("epiphany-run-future-surface", 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()
{