diff --git a/docs/bridge.md b/docs/bridge.md index 09a5f51..d5a87ba 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -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: diff --git a/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs b/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs index ea04bb0..3cf1326 100644 --- a/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs +++ b/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs @@ -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(); diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs index 51b17ee..89f722c 100644 --- a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -969,6 +969,94 @@ 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("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(); + Assert.NotNull(action); + Assert.Equal("Authorized", 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.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() {