diff --git a/README.md b/README.md index 544796f..acefccb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/agent-transport.md b/docs/agent-transport.md index c49c6f5..490f3de 100644 --- a/docs/agent-transport.md +++ b/docs/agent-transport.md @@ -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. diff --git a/docs/bridge.md b/docs/bridge.md index 007357c..7e4c732 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -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 diff --git a/tests/Bifrost.Web.Tests/BridgeCliTests.cs b/tests/Bifrost.Web.Tests/BridgeCliTests.cs index 6ba0225..e7a50db 100644 --- a/tests/Bifrost.Web.Tests/BridgeCliTests.cs +++ b/tests/Bifrost.Web.Tests/BridgeCliTests.cs @@ -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 + { + ["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() { diff --git a/tools/agent-transport.mjs b/tools/agent-transport.mjs index ca6e047..c79140e 100644 --- a/tools/agent-transport.mjs +++ b/tools/agent-transport.mjs @@ -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 [ diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index 09e783f..850fe4f 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -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, { diff --git a/tools/dispatch-agent-requests.mjs b/tools/dispatch-agent-requests.mjs index b4be727..b22f466 100644 --- a/tools/dispatch-agent-requests.mjs +++ b/tools/dispatch-agent-requests.mjs @@ -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 diff --git a/tools/governance-threads.mjs b/tools/governance-threads.mjs index 1c506ed..cebcc93 100644 --- a/tools/governance-threads.mjs +++ b/tools/governance-threads.mjs @@ -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 [