Lock recovery hatches in dispatched turns

This commit is contained in:
Meta Vens 2026-06-17 08:38:21 +01:00
commit f0661a8995
8 changed files with 101 additions and 0 deletions

View file

@ -80,6 +80,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
- `tools/governance-threads.mjs` uses that same bridge configuration to record `/governance/receipts` entries for topic opens, comments, approvals, and promotions
- those agent activity scripts now fail closed without the bridge URL/token pair; `--allow-unreceipted-activity true` or `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY=true` is the explicit operator-recovery hatch
- GitHub bridge mutations now fail closed without those values; `--allow-ungated-github true` is reserved for explicit operator recovery
- dispatched Codex turns now lock those recovery hatches too: local-only `--allow-ungated-github` and `--allow-unreceipted-activity` escapes are refused inside dispatched work so a worker cannot silently downgrade itself out of Bifrost receipts
- set `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optionally `BIFROST_REDDIT_CLIENT_SECRET` before posting Reddit organizing threads
- set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production
- default connection string points at local PostgreSQL

View file

@ -127,6 +127,7 @@ node .\tools\agent-transport.mjs apply-snapshot --in E:\tmp\bifrost-agent-transp
Dispatch receipts should use Bifrost's own public persona in the Bifrost governance channel. `tools/dispatch-agent-requests.mjs` reads `BIFROST_DISCORD_CHANNEL_ID` or `DISCORD_BIFROST_CHANNEL_ID` for the receipt target, uses persona name `Bifrost`, and defaults the persona avatar to the public `src/Bifrost.Web/wwwroot/img/bifrost-profile.png` raw GitHub URL unless `BIFROST_DISCORD_PERSONA_AVATAR_URL` or `DISCORD_PERSONA_AVATAR_URL_BIFROST` overrides it. Receipt text must lead with the concrete repo and work title, not a generic "recent consensus" summary, and must not expose request ids, workspace paths, log paths, or other debugging debris in Discord.
The dispatcher now tags those Discord receipts with the Bifrost request id as bridge provenance, and dispatched Codex turns inherit default bridge provenance environment variables so any explicit `tools/bifrost-bridge.mjs` crossing inside the turn can land on the same request trail without hand-carried flags. When `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` are configured, the dispatcher also records `/dispatch/runs/*` receipts in the hosted app so Bifrost keeps a first-class run ledger for launch, completion, cancellation, and failure.
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 mutating `gh` commands are blocked too 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.
When that same bridge configuration is present, `tools/agent-transport.mjs` records `/transport/receipts` entries for queue, claim, release, and close operations on the request lane. The packet still lives in CultCache. The hosted receipt row is the Bifrost-side witness that an agent or dispatcher touched it.
`apply-snapshot` is treated as a mutating import path too. It now fails closed without that same bridge configuration and derives request-lane receipts from the imported state delta, so a snapshot can no longer rewrite queue or claim state behind Bifrost's back.
Those mutating scripts now fail closed without that bridge configuration. Use `--allow-unreceipted-activity true` or `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY=true` only for explicit local recovery work where you are intentionally accepting a blind spot.

View file

@ -93,6 +93,7 @@ Current CLI wiring:
- pass `--source-kind`, `--source-id`, `--authority-ref`, `--work-item-id`, or `--motion-id` so agent actions satisfy policy
- the dispatcher now preloads `BIFROST_BRIDGE_SOURCE_KIND`, `BIFROST_BRIDGE_SOURCE_ID`, and `BIFROST_BRIDGE_AUTHORITY_REF` into Codex turns launched from Bifrost update requests, so normal bridge use inside a dispatched turn carries request provenance by default
- those dispatched 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 even when `--no-verify` is used, and mutating `gh` commands are blocked unless the bridge explicitly authorizes them
- those dispatched turns also lock 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 a worker cannot opt itself out of Bifrost gate or receipt policy
- the dispatcher also uses that same bridge configuration to post dispatch-run start/complete/fail receipts, including the worker pid, thread/turn ids when available, and result/log paths for the local operator trail
- the agent transport CLI uses that same bridge configuration to post request-lane receipts for queue, claim, release, and close events, so Bifrost does not go blind between intake and dispatch
- the governance threads CLI uses that same bridge configuration to post governance receipts for topic opens, comments, approvals, and promotions, so Bifrost does not lose the pre-dispatch reasoning trail

View file

@ -44,6 +44,28 @@ public sealed class BridgeCliTests
Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean());
}
[Fact]
public async Task GitHub_bridge_recovery_hatch_is_rejected_inside_dispatched_turn()
{
var result = await RunNodeAsync([
"tools/bifrost-bridge.mjs",
"github-pr-comment",
"--repo-root", RepoRoot,
"--identity", "nibu",
"--pr", "1",
"--content", "test comment",
"--target-repository-full-name", "GameCult/Bifrost",
"--allow-ungated-github", "true",
"--dry-run", "true",
], new Dictionary<string, string?>
{
["BIFROST_LOCK_RECOVERY_HATCHES"] = "true",
});
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("cannot use --allow-ungated-github", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task GitHub_pr_comment_returns_concrete_github_receipt()
{
@ -118,6 +140,29 @@ console.log(JSON.stringify({
Assert.Equal("queued", payload.RootElement.GetProperty("status").GetString());
}
[Fact]
public async Task Agent_transport_recovery_hatch_is_rejected_inside_dispatched_turn()
{
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc");
var result = await RunNodeAsync([
"tools/agent-transport.mjs",
"enqueue",
"--repo", "Bifrost",
"--agent", "nibu",
"--title", "Receipt gate recovery",
"--request", "Do the thing.",
"--store", storePath,
"--allow-unmirrored", "true",
"--allow-unreceipted-activity", "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 Agent_transport_apply_snapshot_fails_closed_without_bifrost_receipt_gate()
{
@ -244,6 +289,29 @@ console.log(JSON.stringify({
Assert.Equal("open", payload.RootElement.GetProperty("status").GetString());
}
[Fact]
public async Task Governance_recovery_hatch_is_rejected_inside_dispatched_turn()
{
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc");
var result = await RunNodeAsync([
"tools/governance-threads.mjs",
"open",
"--repo", "Bifrost",
"--agent", "nibu",
"--title", "Governance receipt recovery",
"--summary", "A topic body.",
"--store", storePath,
"--allow-unmirrored", "true",
"--allow-unreceipted-activity", "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 Dispatch_worker_fails_closed_without_bifrost_receipt_gate()
{

View file

@ -137,6 +137,13 @@ function isMutatingCommand(command) {
}
function ensureTransportReceiptGate(options) {
if (wantsUnreceiptedActivity(options) && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") {
throw new Error(
"Dispatched Bifrost work cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
"Request-lane mutations from a dispatched turn must keep Bifrost receipts.",
);
}
if (hasBifrostBridgeConfig() || allowsUnreceiptedActivity(options)) {
return;
}
@ -152,6 +159,10 @@ function hasBifrostBridgeConfig() {
}
function allowsUnreceiptedActivity(options) {
return wantsUnreceiptedActivity(options);
}
function wantsUnreceiptedActivity(options) {
return options["allow-unreceipted-activity"] === "true" || process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true";
}

View file

@ -1036,6 +1036,13 @@ function ensureGitHubBridgeGate(options) {
options["allow-ungated-github"] === "true" ||
process.env.BIFROST_ALLOW_UNGATED_GITHUB === "true";
if (allowUngated && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") {
throw new Error(
"Dispatched Bifrost work cannot use --allow-ungated-github or BIFROST_ALLOW_UNGATED_GITHUB. " +
"GitHub mutations from a dispatched turn must go through the normal Bifrost gate and receipt path.",
);
}
if (baseUrl && token) {
return;
}

View file

@ -567,6 +567,7 @@ function buildBridgeContextEnv(request) {
BIFROST_BRIDGE_SOURCE_ID: request.id,
BIFROST_BRIDGE_AUTHORITY_REF: "bifrost_dispatch_execution",
BIFROST_ENFORCE_GITHUB_GATE: "true",
BIFROST_LOCK_RECOVERY_HATCHES: "true",
BIFROST_GIT_EXECUTABLE: resolve(gitGatePath, "git.cmd"),
BIFROST_GH_EXECUTABLE: resolve(gitGatePath, "gh.cmd"),
BIFROST_NODE_EXECUTABLE: process.execPath,

View file

@ -533,6 +533,13 @@ function isMutatingCommand(command) {
}
function ensureGovernanceReceiptGate(options) {
if (wantsUnreceiptedActivity(options) && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") {
throw new Error(
"Dispatched Bifrost work cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
"Governance mutations from a dispatched turn must keep Bifrost receipts.",
);
}
if (hasBifrostBridgeConfig() || allowsUnreceiptedActivity(options)) {
return;
}
@ -548,6 +555,10 @@ function hasBifrostBridgeConfig() {
}
function allowsUnreceiptedActivity(options) {
return wantsUnreceiptedActivity(options);
}
function wantsUnreceiptedActivity(options) {
return options["allow-unreceipted-activity"] === "true" || process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true";
}