Require approved governance before github bridge

This commit is contained in:
Meta Vens 2026-06-17 12:27:22 +01:00
commit 0c53f70e25
5 changed files with 126 additions and 0 deletions

View file

@ -77,6 +77,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
- those hosted runtime receipt lanes are local-bridge-owned writes, not ordinary member-session writes: `/dispatch/runs/*`, `/transport/receipts`, and `/governance/receipts` now require the configured bridge token and do not inherit browser-user identity into agent activity rows
- those hosted runtime receipts also validate linkage instead of accepting free-floating text: dispatch runs require an existing request-lane receipt for the same request id, governance promotion receipts require a known promoted dispatch request, and request-lane rows cannot silently drift to a different repo or agent identity midstream
- 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
- 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

View file

@ -129,6 +129,7 @@ The dispatcher now tags those Discord receipts with the Bifrost request id as br
Those hosted runtime receipt endpoints are owned by the local bridge token, not by ordinary member sessions. A signed-in member can govern work and request crossings, but they cannot POST `/dispatch/runs/*`, `/transport/receipts`, or `/governance/receipts` without the configured bridge token and pretend to be the worker runtime.
Those hosted receipt endpoints now also cross-check each other. Dispatch starts require an existing request-lane receipt for the same request id, governance promotions that cite `dispatchRequestId` must point at a known request-lane request, and additional request-lane receipts for one request id cannot drift onto a different repo or agent identity. The ledger is still transitional, but it has stopped politely accepting shape-shifting runtime history.
That same Bifrost request provenance now matters at the GitHub crossing too. When a dispatched worker uses `tools/bifrost-bridge.mjs` with the inherited `bifrost_agent_transport_request` source, the hosted bridge checks that the cited request exists in the request-lane ledger and still belongs to the same repo before it authorizes the GitHub mutation.
Governance-backed GitHub publication is stricter now too. If a bridge action cites a Bifrost governance topic as its source, the hosted bridge requires that the topic has actually been approved or promoted before it will authorize a GitHub mutation. An opened topic or comment thread by itself is no longer enough to claim GitHub authority.
The rest of the bridge-owned external activity now follows the same receipt discipline. If dispatched work posts a Discord receipt, sends a Discord DM, or opens a Reddit organizing thread through `tools/bifrost-bridge.mjs`, the normal path requires the hosted bridge base URL/token so Bifrost records the crossing. `--allow-unreceipted-activity true` is now an explicit local recovery hatch and is refused inside dispatched work.
Those dispatched turns also inherit 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 inside dispatched work even with `--no-verify`, and direct `gh` usage is limited to explicit read-only commands unless the bridge explicitly authorizes the GitHub mutation it owns.
Those dispatched turns also lock the local-only recovery hatches. `--allow-ungated-github`, `BIFROST_ALLOW_UNGATED_GITHUB`, `--allow-unreceipted-activity`, and `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY` are refused inside dispatched work so an agent cannot quietly downgrade itself out of Bifrost's gate or receipt trail.

View file

@ -81,6 +81,7 @@ This covers the other side of the swarm's work: topic opens, comments, approvals
These runtime receipt lanes are not generic member write surfaces. In the current machine, `/dispatch/runs/*`, `/transport/receipts`, and `/governance/receipts` are owned by the configured local bridge token. Active members may govern and request bridge actions through the app, but they may not mint worker-history rows directly through a browser session and impersonate runtime activity.
They also now validate linkage across the hosted ledger instead of accepting isolated claims. A dispatch run must point at an already-seen request-lane receipt for the same request id. A governance promotion receipt that cites `dispatchRequestId` must point at a known request-lane request. Repeated request-lane rows for one request id may add activity, but they may not quietly change the repo or agent identity the request belongs to.
GitHub bridge actions that cite Bifrost-owned provenance now validate that provenance too. If a bridge action says its source is `bifrost_agent_transport_request`, the hosted bridge checks that the request exists in the request-lane ledger and still belongs to the same repo. If a bridge action says its source is `bifrost_governance_topic` or legacy `governance_topic`, the hosted bridge checks that the topic exists in the governance receipt trail before authorizing the crossing.
For GitHub publication, mere topic existence is no longer enough. A governance-backed GitHub bridge action now requires that the cited topic has reached at least `TopicApproved` or `TopicPromoted` in the governance receipt trail. Opened/commented-only topics are evidence of discussion, not yet permission to mutate GitHub.
Current gate behavior:

View file

@ -306,6 +306,19 @@ public sealed class BridgeActionService(
{
return BridgePolicyDecision.Reject(repoMismatch);
}
if (request.TargetSurface == BridgeTargetSurface.GitHub &&
!await dbContext.GovernanceActivityReceipts
.AsNoTracking()
.AnyAsync(
x => x.TopicId == request.SourceId &&
(x.ActivityKind == GovernanceActivityReceiptKind.TopicApproved ||
x.ActivityKind == GovernanceActivityReceiptKind.TopicPromoted),
cancellationToken))
{
return BridgePolicyDecision.Reject(
$"GitHub bridge action references governance topic {request.SourceId}, but that topic has not been approved or promoted.");
}
}
return null;

View file

@ -494,6 +494,103 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
Assert.Contains("target repository does not match", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Agent_github_action_with_unapproved_governance_topic_is_denied_and_recorded()
{
using var client = _factory.CreateClient();
await PostGovernanceReceiptAsync(client, new
{
topicId = "topic_open_only_123",
commentId = "comment_open_only_123",
dispatchRequestId = string.Empty,
title = "Open topic only",
jurisdictionRepoName = "Bifrost",
jurisdictionAgentIdentity = "nibu",
activityKind = "TopicOpened",
actorKind = "face",
actorName = "nibu",
note = "Topic opened."
});
var requestPayload = JsonSerializer.Serialize(new
{
actorKind = "Agent",
actorName = "nibu",
targetSurface = "GitHub",
actionKind = "GitHubDraftPullRequest",
targetRepositoryFullName = "GameCult/Bifrost",
targetLocator = "pulls",
sourceKind = "governance_topic",
sourceId = "topic_open_only_123",
authorityReference = "dispatch-approved",
title = "Open-only topic bridge action",
summary = "This should not pass policy."
});
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("not been approved or promoted", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Agent_github_action_with_approved_governance_topic_is_authorized()
{
using var client = _factory.CreateClient();
await PostGovernanceReceiptAsync(client, new
{
topicId = "topic_approved_123",
commentId = "comment_approved_123",
dispatchRequestId = string.Empty,
title = "Approved governance topic",
jurisdictionRepoName = "Bifrost",
jurisdictionAgentIdentity = "nibu",
activityKind = "TopicApproved",
actorKind = "face",
actorName = "nibu",
note = "Topic approved."
});
var requestPayload = JsonSerializer.Serialize(new
{
actorKind = "Agent",
actorName = "nibu",
targetSurface = "GitHub",
actionKind = "GitHubDraftPullRequest",
targetRepositoryFullName = "GameCult/Bifrost",
targetLocator = "pulls",
sourceKind = "bifrost_governance_topic",
sourceId = "topic_approved_123",
authorityReference = "dispatch-approved",
title = "Approved topic bridge action",
summary = "This should pass policy."
});
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);
}
[Fact]
public async Task Local_bridge_token_can_record_dispatch_run_lifecycle()
{
@ -945,6 +1042,19 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
$"Expected transport receipt seed to succeed but got {(int)response.StatusCode}: {body}");
}
private static async Task PostGovernanceReceiptAsync(HttpClient client, object payload)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/governance/receipts");
request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.True(
response.StatusCode == HttpStatusCode.Accepted,
$"Expected governance receipt seed to succeed but got {(int)response.StatusCode}: {body}");
}
private sealed class BridgeActionHttpResult
{
public Guid Id { get; init; }