diff --git a/README.md b/README.md index d4eefea..b36142b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/agent-transport.md b/docs/agent-transport.md index d53f157..d1f10c5 100644 --- a/docs/agent-transport.md +++ b/docs/agent-transport.md @@ -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. diff --git a/docs/bridge.md b/docs/bridge.md index 010833c..1daf16e 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -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 diff --git a/tests/Bifrost.Web.Tests/BridgeCliTests.cs b/tests/Bifrost.Web.Tests/BridgeCliTests.cs index 16bff27..63e8d0d 100644 --- a/tests/Bifrost.Web.Tests/BridgeCliTests.cs +++ b/tests/Bifrost.Web.Tests/BridgeCliTests.cs @@ -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 RunGitAsync(string[] args, string workingDirectory, IReadOnlyDictionary? 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 BuildDispatchedGitHookEnv() + { + var hooksPath = Path.Combine(RepoRoot, "tools", "git-hooks"); + return new Dictionary + { + ["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); } diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index 8e50bc7..3b3cbc2 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -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, }); diff --git a/tools/dispatch-agent-requests.mjs b/tools/dispatch-agent-requests.mjs index 56ccc9a..0c12859 100644 --- a/tools/dispatch-agent-requests.mjs +++ b/tools/dispatch-agent-requests.mjs @@ -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, }; } diff --git a/tools/git-hooks/pre-push b/tools/git-hooks/pre-push new file mode 100644 index 0000000..0d6536a --- /dev/null +++ b/tools/git-hooks/pre-push @@ -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