Require receipts for non-github bridge activity
This commit is contained in:
parent
f32c9127a0
commit
a22092feff
8 changed files with 131 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
|
||||
- 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
|
||||
- `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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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.
|
||||
Those dispatched turns also scrub ambient GitHub auth state from the child environment before the worker starts. `GH_TOKEN` and `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompts are disabled. That does not prove every imaginable direct-HTTP escape is impossible, but it removes the normal ambient credential path from dispatched work.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ It intentionally uses boring local credentials while Heimdall's managed GitHub c
|
|||
- Reddit self-posts use `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optional `BIFROST_REDDIT_CLIENT_SECRET`.
|
||||
|
||||
The CLI is not the final permission system. It is the working bridge actuator Bifrost owns now, so VoidBot and repo Personas can stop carrying this machinery themselves.
|
||||
It now fails closed for all external write surfaces by default. GitHub mutations already required the hosted bridge gate; Discord posts, Discord DMs, and Reddit self-posts now require the same `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` receipt path unless an operator explicitly uses `--allow-unreceipted-activity true` for local recovery. Dispatched work is not allowed to use that recovery hatch.
|
||||
|
||||
## Bridge Action Ledger
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,88 @@ console.log(JSON.stringify({
|
|||
Assert.Equal("123456", payload.RootElement.GetProperty("externalReceiptId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discord_bridge_command_fails_closed_without_bifrost_receipt_gate()
|
||||
{
|
||||
var result = await RunNodeAsync([
|
||||
"tools/bifrost-bridge.mjs",
|
||||
"discord-post",
|
||||
"--channel-id", "1501196543150264332",
|
||||
"--content", "test message",
|
||||
"--dry-run", "true",
|
||||
]);
|
||||
|
||||
Assert.NotEqual(0, result.ExitCode);
|
||||
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discord_bridge_command_allows_explicit_operator_recovery_dry_run()
|
||||
{
|
||||
var result = await RunNodeAsync([
|
||||
"tools/bifrost-bridge.mjs",
|
||||
"discord-post",
|
||||
"--channel-id", "1501196543150264332",
|
||||
"--content", "test message",
|
||||
"--allow-unreceipted-activity", "true",
|
||||
"--dry-run", "true",
|
||||
]);
|
||||
|
||||
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
|
||||
using var payload = JsonDocument.Parse(result.Stdout);
|
||||
Assert.Equal("discord-post", payload.RootElement.GetProperty("action").GetString());
|
||||
Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discord_bridge_recovery_hatch_is_rejected_inside_dispatched_turn()
|
||||
{
|
||||
var result = await RunNodeAsync([
|
||||
"tools/bifrost-bridge.mjs",
|
||||
"discord-post",
|
||||
"--channel-id", "1501196543150264332",
|
||||
"--content", "test message",
|
||||
"--allow-unreceipted-activity", "true",
|
||||
"--dry-run", "true",
|
||||
], new Dictionary<string, string?>
|
||||
{
|
||||
["BIFROST_LOCK_RECOVERY_HATCHES"] = "true",
|
||||
});
|
||||
|
||||
Assert.NotEqual(0, result.ExitCode);
|
||||
Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reddit_bridge_command_fails_closed_without_bifrost_receipt_gate()
|
||||
{
|
||||
var result = await RunNodeAsync([
|
||||
"tools/bifrost-bridge.mjs",
|
||||
"reddit-post",
|
||||
"--title", "Test thread",
|
||||
"--content", "test message",
|
||||
"--dry-run", "true",
|
||||
]);
|
||||
|
||||
Assert.NotEqual(0, result.ExitCode);
|
||||
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discord_dm_bridge_command_fails_closed_without_bifrost_receipt_gate()
|
||||
{
|
||||
var result = await RunNodeAsync([
|
||||
"tools/bifrost-bridge.mjs",
|
||||
"discord-dm",
|
||||
"--recipient-id", "123456789012345678",
|
||||
"--content", "test message",
|
||||
"--dry-run", "true",
|
||||
]);
|
||||
|
||||
Assert.NotEqual(0, result.ExitCode);
|
||||
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_transport_mutation_fails_closed_without_bifrost_receipt_gate()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -248,6 +248,7 @@ async function mirrorEnqueuedRequestOrThrow(request, options) {
|
|||
...optionalArg("--target-repository-full-name", request.targetRepositoryFullName ?? request.targetRepoName),
|
||||
...optionalArg("--persona-avatar-url", personaAvatarUrl),
|
||||
...optionalArg("--reply-to-message-id", options["mirror-reply-to-message-id"]),
|
||||
...bridgeRecoveryArgs(options),
|
||||
...(options["mirror-dry-run"] === "true" ? ["--dry-run", "true"] : []),
|
||||
], repoRoot);
|
||||
}
|
||||
|
|
@ -264,6 +265,10 @@ function allowsUnmirrored(options) {
|
|||
return options["allow-unmirrored"] === "true" || process.env.BIFROST_ALLOW_UNMIRRORED_GOVERNANCE === "true";
|
||||
}
|
||||
|
||||
function bridgeRecoveryArgs(options) {
|
||||
return allowsUnreceiptedActivity(options) ? ["--allow-unreceipted-activity", "true"] : [];
|
||||
}
|
||||
|
||||
function renderRequestMirrorFallback(request) {
|
||||
const actor = request.createdByAgent ? `${request.createdByAgent} queued` : "Queued";
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -267,6 +267,7 @@ async function commentGitHubPr(options) {
|
|||
}
|
||||
|
||||
async function postDiscordMessage(options) {
|
||||
ensureBridgeReceiptGate(options);
|
||||
const token = process.env.BIFROST_DISCORD_BOT_TOKEN ?? process.env.DISCORD_BOT_TOKEN;
|
||||
const channelId = requireOption(options, "channel-id");
|
||||
const content = await readOptionText(options, "content", "content-file");
|
||||
|
|
@ -339,6 +340,7 @@ async function postDiscordMessage(options) {
|
|||
}
|
||||
|
||||
async function sendDiscordDm(options) {
|
||||
ensureBridgeReceiptGate(options);
|
||||
const token = process.env.BIFROST_DISCORD_BOT_TOKEN ?? process.env.DISCORD_BOT_TOKEN;
|
||||
const recipientId = requireOption(options, "recipient-id");
|
||||
const content = await readOptionText(options, "content", "content-file");
|
||||
|
|
@ -401,6 +403,7 @@ async function sendDiscordDm(options) {
|
|||
}
|
||||
|
||||
async function postRedditThread(options) {
|
||||
ensureBridgeReceiptGate(options);
|
||||
const subreddit = normalizeSubreddit(options.subreddit ?? process.env.BIFROST_REDDIT_SUBREDDIT ?? "GameCultOrg");
|
||||
const title = requireOption(options, "title");
|
||||
const content = await readOptionText(options, "content", "content-file");
|
||||
|
|
@ -1057,6 +1060,34 @@ function ensureGitHubBridgeGate(options) {
|
|||
);
|
||||
}
|
||||
|
||||
function ensureBridgeReceiptGate(options) {
|
||||
const baseUrl = optionalString(options["bifrost-base-url"]) ?? optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
|
||||
const token = optionalString(options["bifrost-token"]) ?? optionalString(process.env.BIFROST_BRIDGE_TOKEN);
|
||||
const allowUnreceipted =
|
||||
options["allow-unreceipted-activity"] === "true" ||
|
||||
process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true";
|
||||
|
||||
if (allowUnreceipted && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") {
|
||||
throw new Error(
|
||||
"Dispatched Bifrost work cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
|
||||
"External bridge activity from a dispatched turn must go through the normal Bifrost receipt path.",
|
||||
);
|
||||
}
|
||||
|
||||
if (baseUrl && token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowUnreceipted) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Bridge activity requires BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can receipt the external crossing. " +
|
||||
"Set BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN, or use --allow-unreceipted-activity true only for explicit operator recovery.",
|
||||
);
|
||||
}
|
||||
|
||||
async function postBridgeJson(baseUrl, token, path, payload, allowedStatuses) {
|
||||
const url = new URL(path, ensureTrailingSlash(baseUrl));
|
||||
const response = await fetch(url, {
|
||||
|
|
|
|||
|
|
@ -528,6 +528,7 @@ function postDispatchReceipt(request, dispatchRecord, options) {
|
|||
"--authority-ref", "bifrost_dispatch_started_receipt",
|
||||
...optionalArg("--target-repository-full-name", request.targetRepositoryFullName),
|
||||
...optionalArg("--persona-avatar-url", personaAvatarUrl),
|
||||
...bridgeRecoveryArgs(options),
|
||||
], bifrostRoot);
|
||||
}
|
||||
|
||||
|
|
@ -540,6 +541,10 @@ function resolveDispatchReceiptChannelId(options) {
|
|||
);
|
||||
}
|
||||
|
||||
function bridgeRecoveryArgs(options) {
|
||||
return allowsUnreceiptedActivity(options) ? ["--allow-unreceipted-activity", "true"] : [];
|
||||
}
|
||||
|
||||
function renderDispatchReceiptContent(request) {
|
||||
const topic = summarizeRequestTopic(request);
|
||||
const actor = request.targetAgentIdentity
|
||||
|
|
|
|||
|
|
@ -414,6 +414,7 @@ async function mirrorTopicActivityOrThrow(cache, topic, input) {
|
|||
"--target-repository-full-name", topic.jurisdictionRepoName,
|
||||
...optionalArg("--persona-avatar-url", personaAvatarUrl),
|
||||
...optionalArg("--reply-to-message-id", input.options["mirror-reply-to-message-id"]),
|
||||
...bridgeRecoveryArgs(input.options),
|
||||
...(input.options["mirror-dry-run"] === "true" ? ["--dry-run", "true"] : []),
|
||||
], repoRoot);
|
||||
const bodyMarkdown = receipt.dryRun
|
||||
|
|
@ -445,6 +446,10 @@ function allowsUnmirrored(options) {
|
|||
return options["allow-unmirrored"] === "true" || process.env.BIFROST_ALLOW_UNMIRRORED_GOVERNANCE === "true";
|
||||
}
|
||||
|
||||
function bridgeRecoveryArgs(options) {
|
||||
return allowsUnreceiptedActivity(options) ? ["--allow-unreceipted-activity", "true"] : [];
|
||||
}
|
||||
|
||||
function renderGovernanceMirrorContent(topic, eventLabel, content) {
|
||||
const event = governanceMirrorEvent(eventLabel);
|
||||
return [
|
||||
|
|
|
|||
Loading…
Reference in a new issue