Enforce bridge-owned GitHub pushes in dispatch turns

This commit is contained in:
Meta Vens 2026-06-17 06:51:08 +01:00
commit d93267eb47
7 changed files with 207 additions and 1 deletions

View file

@ -74,6 +74,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
- set `Bridge:LocalBridgeToken` to allow the transitional local bridge actuator to register and receipt governed actions through `/bridge/actions/*`
- set `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` when `tools/bifrost-bridge.mjs` should round-trip every action through the app ledger before mutating GitHub, Discord, or Reddit
- the same bridge URL/token pair lets `tools/dispatch-agent-requests.mjs` record `/dispatch/runs/*` receipts for launched Codex work, so Bifrost keeps a live run trail even when a turn fails
- Bifrost-dispatched Codex turns also inject a Git `pre-push` hook through environment config, so raw `git push` is blocked unless `tools/bifrost-bridge.mjs` explicitly authorizes the bridge-owned push
- `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
- `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

View file

@ -126,6 +126,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 a Bifrost-owned Git `pre-push` hook through environment config. That turns "use the bridge" from etiquette into substrate: raw `git push` is blocked inside dispatched work unless the bridge explicitly authorizes the push it owns.
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.
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

@ -92,6 +92,7 @@ Current CLI wiring:
- GitHub commands now require both values by default, even for dry-run, so the normal path cannot silently bypass the gate
- 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 a Bifrost-owned Git `pre-push` hook through environment config, so raw `git push` is blocked and `tools/bifrost-bridge.mjs` becomes the only normal publication path
- 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

@ -207,6 +207,105 @@ console.log(JSON.stringify({
Assert.Contains("requires BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Dispatched_turn_git_hook_blocks_raw_git_push()
{
var remoteDir = Path.Combine(Path.GetTempPath(), $"bifrost-remote-{Guid.NewGuid():N}.git");
var workDir = Path.Combine(Path.GetTempPath(), $"bifrost-work-{Guid.NewGuid():N}");
Directory.CreateDirectory(workDir);
try
{
await RunGitAsync(["init", "--bare", remoteDir], RepoRoot);
await RunGitAsync(["init", "-b", "main"], workDir);
await RunGitAsync(["config", "user.name", "Bifrost Tests"], workDir);
await RunGitAsync(["config", "user.email", "bifrost-tests@example.invalid"], workDir);
await File.WriteAllTextAsync(Path.Combine(workDir, "README.md"), "# Gate Test\n", Encoding.UTF8);
await RunGitAsync(["add", "README.md"], workDir);
await RunGitAsync(["commit", "-m", "Initial commit"], workDir);
await RunGitAsync(["remote", "add", "origin", remoteDir], workDir);
var blocked = await RunGitAsync(
["push", "-u", "origin", "main"],
workDir,
BuildDispatchedGitHookEnv());
Assert.NotEqual(0, blocked.ExitCode);
Assert.Contains("Bifrost blocked git push", blocked.Stderr, StringComparison.OrdinalIgnoreCase);
}
finally
{
DeleteDirectoryIfPresent(workDir);
DeleteDirectoryIfPresent(remoteDir);
}
}
[Fact]
public async Task Bridge_owned_github_draft_pr_authorizes_push_under_dispatch_gate()
{
var remoteDir = Path.Combine(Path.GetTempPath(), $"bifrost-remote-{Guid.NewGuid():N}.git");
var workDir = Path.Combine(Path.GetTempPath(), $"bifrost-work-{Guid.NewGuid():N}");
var fakeToolsDir = Path.Combine(Path.GetTempPath(), $"bifrost-gh-{Guid.NewGuid():N}");
Directory.CreateDirectory(workDir);
Directory.CreateDirectory(fakeToolsDir);
try
{
await RunGitAsync(["init", "--bare", remoteDir], RepoRoot);
await RunGitAsync(["init", "-b", "main"], workDir);
await RunGitAsync(["config", "user.name", "Bifrost Tests"], workDir);
await RunGitAsync(["config", "user.email", "bifrost-tests@example.invalid"], workDir);
await File.WriteAllTextAsync(Path.Combine(workDir, "README.md"), "# Bridge Gate Test\n", Encoding.UTF8);
await RunGitAsync(["add", "README.md"], workDir);
await RunGitAsync(["commit", "-m", "Initial commit"], workDir);
await RunGitAsync(["remote", "add", "origin", remoteDir], workDir);
await RunGitAsync(["push", "-u", "origin", "main"], workDir);
var fakeGhPath = Path.Combine(fakeToolsDir, "fake-gh.js");
await File.WriteAllTextAsync(fakeGhPath, """
if (process.argv.includes("create")) {
console.log("https://github.com/GameCult/Bifrost/pull/999");
process.exit(0);
}
console.error(`Unexpected gh args: ${process.argv.slice(2).join(" ")}`);
process.exit(1);
""", Encoding.UTF8);
var environment = BuildDispatchedGitHookEnv();
environment["BIFROST_ALLOW_UNGATED_GITHUB"] = "true";
var result = await RunNodeAsync([
"tools/bifrost-bridge.mjs",
"github-draft-pr",
"--repo-root", workDir,
"--identity", "nibu",
"--title", "Bridge gate test",
"--path", "docs/receipt.md",
"--content", "Bridge-owned push.",
"--body", "Bifrost bridge test PR.",
"--base", "main",
"--gh-executable", "node",
"--gh-exec-args", fakeGhPath,
], environment);
Assert.Equal(0, result.ExitCode);
using var payload = JsonDocument.Parse(result.Stdout);
Assert.Equal("github-draft-pr", payload.RootElement.GetProperty("action").GetString());
Assert.True(payload.RootElement.GetProperty("pushed").GetBoolean());
Assert.Equal("https://github.com/GameCult/Bifrost/pull/999", payload.RootElement.GetProperty("prUrl").GetString());
var heads = await RunGitAsync(["ls-remote", "--heads", "origin"], workDir);
Assert.Contains("refs/heads/bifrost/nibu/bridge-gate-test", heads.Stdout, StringComparison.OrdinalIgnoreCase);
}
finally
{
DeleteDirectoryIfPresent(workDir);
DeleteDirectoryIfPresent(remoteDir);
DeleteDirectoryIfPresent(fakeToolsDir);
}
}
private static string RepoRoot =>
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
@ -249,5 +348,73 @@ console.log(JSON.stringify({
return new ProcessResult(process.ExitCode, stdout, stderr);
}
private static async Task<ProcessResult> RunGitAsync(string[] args, string workingDirectory, IReadOnlyDictionary<string, string?>? environmentOverrides = null)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "git",
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
}
};
foreach (var arg in args)
{
process.StartInfo.ArgumentList.Add(arg);
}
if (environmentOverrides is not null)
{
foreach (var pair in environmentOverrides)
{
process.StartInfo.Environment[pair.Key] = pair.Value ?? string.Empty;
}
}
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
return new ProcessResult(process.ExitCode, stdout, stderr);
}
private static Dictionary<string, string?> BuildDispatchedGitHookEnv()
{
var hooksPath = Path.Combine(RepoRoot, "tools", "git-hooks");
return new Dictionary<string, string?>
{
["BIFROST_ENFORCE_GITHUB_GATE"] = "true",
["GIT_CONFIG_COUNT"] = "1",
["GIT_CONFIG_KEY_0"] = "core.hooksPath",
["GIT_CONFIG_VALUE_0"] = hooksPath,
};
}
private static void DeleteDirectoryIfPresent(string path)
{
if (!Directory.Exists(path))
{
return;
}
foreach (var file in Directory.GetFiles(path, "*", SearchOption.AllDirectories))
{
File.SetAttributes(file, FileAttributes.Normal);
}
foreach (var directory in Directory.GetDirectories(path, "*", SearchOption.AllDirectories))
{
File.SetAttributes(directory, FileAttributes.Normal);
}
File.SetAttributes(path, FileAttributes.Normal);
Directory.Delete(path, recursive: true);
}
private sealed record ProcessResult(int ExitCode, string Stdout, string Stderr);
}

View file

@ -110,7 +110,11 @@ async function createGitHubDraftPr(options) {
await writeFile(targetPath, content, "utf8");
git(["add", "--", relativePath], repoRoot);
git(["commit", "-m", commitMessage], repoRoot);
git(["push", "-u", "origin", branch], repoRoot);
git(["push", "-u", "origin", branch], repoRoot, {
env: {
BIFROST_GITHUB_PUSH_AUTHORIZED: "true",
},
});
pushed = true;
const pr = runGitHubCli(options, [
@ -876,6 +880,10 @@ function run(command, args, cwd, options = {}) {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
...(options.env ?? {}),
},
windowsHide: true,
});

View file

@ -560,10 +560,30 @@ function renderDispatchReceiptContent(request) {
}
function buildBridgeContextEnv(request) {
const hooksPath = resolve(bifrostRoot, "tools", "git-hooks");
return {
BIFROST_BRIDGE_SOURCE_KIND: "bifrost_agent_transport_request",
BIFROST_BRIDGE_SOURCE_ID: request.id,
BIFROST_BRIDGE_AUTHORITY_REF: "bifrost_dispatch_execution",
BIFROST_ENFORCE_GITHUB_GATE: "true",
...buildGitHooksConfigEnv(hooksPath),
};
}
function buildGitHooksConfigEnv(hooksPath) {
const existingCount = Number.parseInt(process.env.GIT_CONFIG_COUNT ?? "", 10);
if (Number.isInteger(existingCount) && existingCount >= 0) {
return {
GIT_CONFIG_COUNT: String(existingCount + 1),
[`GIT_CONFIG_KEY_${existingCount}`]: "core.hooksPath",
[`GIT_CONFIG_VALUE_${existingCount}`]: hooksPath,
};
}
return {
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "core.hooksPath",
GIT_CONFIG_VALUE_0: hooksPath,
};
}

8
tools/git-hooks/pre-push Normal file
View file

@ -0,0 +1,8 @@
#!/bin/sh
if [ "$BIFROST_ENFORCE_GITHUB_GATE" = "true" ] && [ "$BIFROST_GITHUB_PUSH_AUTHORIZED" != "true" ]; then
echo "Bifrost blocked git push in this dispatched turn. Use tools/bifrost-bridge.mjs so GitHub publication is gated and receipted." >&2
exit 1
fi
exit 0