Require approved governance before github bridge
This commit is contained in:
parent
a22092feff
commit
0c53f70e25
5 changed files with 126 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue