diff --git a/README.md b/README.md index 281a859..f6ad58a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/bridge.md b/docs/bridge.md index 683c179..0e19f3d 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -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: diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 8ddec75..7a72a16 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -415,6 +415,11 @@ public sealed class BifrostDbContext(DbContextOptions 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 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) diff --git a/src/Bifrost.Web/Data/Migrations/20260622142000_AddBridgeActionIdentityProvenance.cs b/src/Bifrost.Web/Data/Migrations/20260622142000_AddBridgeActionIdentityProvenance.cs new file mode 100644 index 0000000..1425791 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20260622142000_AddBridgeActionIdentityProvenance.cs @@ -0,0 +1,96 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + /// + public partial class AddBridgeActionIdentityProvenance : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BifrostIdentity", + table: "BridgeActions", + type: "character varying(160)", + maxLength: 160, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "EpiphanyAgentIdentity", + table: "BridgeActions", + type: "character varying(160)", + maxLength: 160, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "EpiphanyLaneId", + table: "BridgeActions", + type: "character varying(120)", + maxLength: 120, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "EpiphanyRunId", + table: "BridgeActions", + type: "character varying(160)", + maxLength: 160, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + 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"); + } + + /// + 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"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index fbf2804..62953e1 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -279,9 +279,29 @@ namespace Bifrost.Web.Data.Migrations .HasMaxLength(240) .HasColumnType("character varying(240)"); + b.Property("BifrostIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + b.Property("CompletedAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("EpiphanyAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("EpiphanyLaneId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EpiphanyRunId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + b.Property("ExternalReceiptId") .IsRequired() .HasMaxLength(240) @@ -291,6 +311,11 @@ namespace Bifrost.Web.Data.Migrations .IsRequired() .HasColumnType("text"); + b.Property("HeimdallCapabilityReference") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + b.Property("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"); diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 13483fc..e86c737 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -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; diff --git a/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs b/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs index d5baa7c..b1da506 100644 --- a/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs +++ b/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs @@ -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, diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs index 3e2bfd8..060fafb 100644 --- a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -702,6 +702,130 @@ 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("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(); + 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("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(); + 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("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() { diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index 24f1a7d..f49dc9f 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -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, };