Gate dispatched GitHub CLI mutations

This commit is contained in:
Meta Vens 2026-06-17 07:53:38 +01:00
commit 01bc5558af
10 changed files with 423 additions and 28 deletions

View file

@ -74,7 +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
- 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` and mutating `gh` commands stay blocked, and only `tools/bifrost-bridge.mjs` is allowed to authorize the bridge-owned GitHub mutation path
- `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,7 +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.
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.
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,7 +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
- 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
- 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

@ -225,13 +225,23 @@ console.log(JSON.stringify({
await RunGitAsync(["commit", "-m", "Initial commit"], workDir);
await RunGitAsync(["remote", "add", "origin", remoteDir], workDir);
var blocked = await RunGitAsync(
["push", "-u", "origin", "main"],
var blocked = await RunCommandAsync(
"powershell",
["-NoProfile", "-Command", "git push -u origin main"],
workDir,
BuildDispatchedGitHookEnv());
Assert.NotEqual(0, blocked.ExitCode);
Assert.Contains("Bifrost blocked git push", blocked.Stderr, StringComparison.OrdinalIgnoreCase);
var blockedNoVerify = await RunCommandAsync(
"powershell",
["-NoProfile", "-Command", "git push --no-verify -u origin main"],
workDir,
BuildDispatchedGitGateEnv());
Assert.NotEqual(0, blockedNoVerify.ExitCode);
Assert.Contains("Bifrost blocked git push", blockedNoVerify.Stderr, StringComparison.OrdinalIgnoreCase);
}
finally
{
@ -263,17 +273,13 @@ console.log(JSON.stringify({
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);
console.log("https://github.com/GameCult/Bifrost/pull/999");
""", Encoding.UTF8);
var environment = BuildDispatchedGitHookEnv();
var environment = BuildDispatchedGitGateEnv();
environment["BIFROST_ALLOW_UNGATED_GITHUB"] = "true";
environment["BIFROST_REAL_GH"] = ResolveExecutable("node");
environment["BIFROST_REAL_GH_ARGS"] = fakeGhPath;
var result = await RunNodeAsync([
"tools/bifrost-bridge.mjs",
@ -285,8 +291,6 @@ process.exit(1);
"--content", "Bridge-owned push.",
"--body", "Bifrost bridge test PR.",
"--base", "main",
"--gh-executable", "node",
"--gh-exec-args", fakeGhPath,
], environment);
Assert.Equal(0, result.ExitCode);
@ -306,6 +310,40 @@ process.exit(1);
}
}
[Fact]
public async Task Dispatched_turn_github_cli_mutation_is_blocked_without_bridge_authorization()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"bifrost-gh-gate-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
var fakeGhPath = Path.Combine(tempDir, "fake-gh.cmd");
await File.WriteAllTextAsync(fakeGhPath, """
@echo off
echo should-not-run
exit /b 0
""", Encoding.UTF8);
var environment = BuildDispatchedGitGateEnv();
environment["BIFROST_REAL_GH"] = fakeGhPath;
var blocked = await RunCommandAsync(
"powershell",
["-NoProfile", "-Command", "gh pr comment 42 --body blocked"],
RepoRoot,
environment);
Assert.NotEqual(0, blocked.ExitCode);
Assert.Contains("Bifrost blocked GitHub CLI mutation", blocked.Stderr, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("should-not-run", blocked.Stdout, StringComparison.OrdinalIgnoreCase);
}
finally
{
DeleteDirectoryIfPresent(tempDir);
}
}
private static string RepoRoot =>
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
@ -349,12 +387,19 @@ process.exit(1);
}
private static async Task<ProcessResult> RunGitAsync(string[] args, string workingDirectory, IReadOnlyDictionary<string, string?>? environmentOverrides = null)
=> await RunCommandAsync("git", args, workingDirectory, environmentOverrides);
private static async Task<ProcessResult> RunCommandAsync(
string fileName,
string[] args,
string workingDirectory,
IReadOnlyDictionary<string, string?>? environmentOverrides = null)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "git",
FileName = fileName,
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
@ -386,15 +431,70 @@ process.exit(1);
private static Dictionary<string, string?> BuildDispatchedGitHookEnv()
{
var hooksPath = Path.Combine(RepoRoot, "tools", "git-hooks");
var gatePath = Path.Combine(RepoRoot, "tools", "git-gate");
var path = Environment.GetEnvironmentVariable("Path")
?? Environment.GetEnvironmentVariable("PATH")
?? string.Empty;
var nextPath = string.IsNullOrWhiteSpace(path)
? gatePath
: $"{gatePath};{path}";
return new Dictionary<string, string?>
{
["BIFROST_ENFORCE_GITHUB_GATE"] = "true",
["BIFROST_GIT_EXECUTABLE"] = Path.Combine(gatePath, "git.cmd"),
["BIFROST_GH_EXECUTABLE"] = Path.Combine(gatePath, "gh.cmd"),
["BIFROST_REAL_GIT"] = ResolveRealGitExecutable(),
["BIFROST_NODE_EXECUTABLE"] = ResolveExecutable("node"),
["GIT_CONFIG_COUNT"] = "1",
["GIT_CONFIG_KEY_0"] = "core.hooksPath",
["GIT_CONFIG_VALUE_0"] = hooksPath,
["PATH"] = nextPath,
["Path"] = nextPath,
};
}
private static Dictionary<string, string?> BuildDispatchedGitGateEnv() => BuildDispatchedGitHookEnv();
private static string ResolveRealGitExecutable() => ResolveExecutable("git");
private static string ResolveExecutable(string command)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "where.exe",
WorkingDirectory = RepoRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
}
};
process.StartInfo.ArgumentList.Add(command);
process.Start();
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"Could not resolve {command} for tests: {stderr}{stdout}");
}
var path = stdout
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim())
.FirstOrDefault(line => !string.IsNullOrWhiteSpace(line));
if (string.IsNullOrWhiteSpace(path))
{
throw new InvalidOperationException($"Could not resolve {command} for tests.");
}
return path;
}
private static void DeleteDirectoryIfPresent(string path)
{
if (!Directory.Exists(path))

View file

@ -129,7 +129,11 @@ async function createGitHubDraftPr(options) {
base,
"--head",
branch,
], repoRoot);
], repoRoot, {
env: {
BIFROST_GITHUB_MUTATION_AUTHORIZED: "true",
},
});
prUrl = pr.stdout.trim();
await bridgeAction?.complete({
receiptUrl: prUrl,
@ -218,6 +222,11 @@ async function commentGitHubPr(options) {
"--input", payloadPath,
],
repoRoot,
{
env: {
BIFROST_GITHUB_MUTATION_AUTHORIZED: "true",
},
},
).stdout,
"GitHub PR comment response",
);
@ -867,7 +876,7 @@ async function readOptionText(options, inlineName, fileName, fallback) {
}
function git(args, cwd, options = {}) {
return run("git", args, cwd, options);
return run(resolveGitCommand(), args, cwd, options);
}
function runGitHubCli(options, args, cwd, runOptions = {}) {
@ -876,16 +885,25 @@ function runGitHubCli(options, args, cwd, runOptions = {}) {
}
function run(command, args, cwd, options = {}) {
const result = spawnSync(command, args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
...(options.env ?? {}),
},
windowsHide: true,
});
const env = {
...process.env,
...(options.env ?? {}),
};
const result = isWindowsCommandScript(command)
? spawnSync("cmd.exe", ["/d", "/s", "/c", command, ...args], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env,
windowsHide: true,
})
: spawnSync(command, args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env,
windowsHide: true,
});
if (result.status !== 0 && !options.allowFailure) {
throw new Error(`${command} ${args.join(" ")} failed with ${result.status ?? "unknown"}:\n${result.stderr || result.stdout}`);
@ -936,6 +954,10 @@ function optionalString(value) {
return trimmed.length > 0 ? trimmed : undefined;
}
function resolveGitCommand() {
return optionalString(process.env.BIFROST_GIT_EXECUTABLE) ?? "git";
}
async function beginBridgeAction(options, action) {
const baseUrl = optionalString(options["bifrost-base-url"]) ?? optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
if (!baseUrl) {
@ -1085,6 +1107,10 @@ function resolveGitHubCommand(options) {
};
}
function isWindowsCommandScript(command) {
return /\.cmd$/i.test(command) || /\.bat$/i.test(command);
}
function splitCommandArgs(value) {
if (typeof value !== "string" || value.trim().length === 0) {
return [];

View file

@ -561,12 +561,19 @@ function renderDispatchReceiptContent(request) {
function buildBridgeContextEnv(request) {
const hooksPath = resolve(bifrostRoot, "tools", "git-hooks");
const gitGatePath = resolve(bifrostRoot, "tools", "git-gate");
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",
BIFROST_GIT_EXECUTABLE: resolve(gitGatePath, "git.cmd"),
BIFROST_GH_EXECUTABLE: resolve(gitGatePath, "gh.cmd"),
BIFROST_REAL_GIT: resolveRealGitExecutable(),
...buildRealGitHubCliEnv(),
BIFROST_NODE_EXECUTABLE: process.execPath,
...buildGitHooksConfigEnv(hooksPath),
...buildPathPrependEnv(gitGatePath),
};
}
@ -587,6 +594,50 @@ function buildGitHooksConfigEnv(hooksPath) {
};
}
function buildPathPrependEnv(directory) {
const currentPath = process.env.Path ?? process.env.PATH ?? "";
const nextPath = currentPath ? `${directory};${currentPath}` : directory;
return {
PATH: nextPath,
Path: nextPath,
};
}
function buildRealGitHubCliEnv() {
const realGh = tryResolveExecutable("gh");
return realGh ? { BIFROST_REAL_GH: realGh } : {};
}
function resolveRealGitExecutable() {
const resolved = tryResolveExecutable("git");
if (!resolved) {
throw new Error("Could not resolve the real git executable for dispatched turn gating.");
}
return resolved;
}
function tryResolveExecutable(command) {
const locator = process.platform === "win32" ? "where.exe" : "which";
const result = spawnSync(locator, [command], {
cwd: bifrostRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
if (result.status !== 0) {
return "";
}
const matches = `${result.stdout ?? ""}`
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
return matches[0] ?? "";
}
class CodexAppServerClient {
constructor({ command, env, logPath }) {
this.command = command;

118
tools/git-gate/gh-proxy.mjs Normal file
View file

@ -0,0 +1,118 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
const args = process.argv.slice(2);
const realGh = process.env.BIFROST_REAL_GH;
const realGhArgs = splitCommandArgs(process.env.BIFROST_REAL_GH_ARGS ?? "");
const mutatingCommands = new Set([
"gist create",
"gist delete",
"gist edit",
"issue close",
"issue comment",
"issue create",
"issue delete",
"issue edit",
"issue lock",
"issue reopen",
"pr close",
"pr comment",
"pr create",
"pr edit",
"pr lock",
"pr merge",
"pr ready",
"pr reopen",
"pr review",
"release create",
"release delete",
"release edit",
"repo archive",
"repo create",
"repo delete",
"repo edit",
"repo fork",
"repo rename",
"secret delete",
"secret set",
"variable delete",
"variable set",
]);
if (!realGh) {
process.stderr.write("Bifrost GitHub gate has no real GitHub CLI executable configured.\n");
process.exit(1);
}
if (
process.env.BIFROST_ENFORCE_GITHUB_GATE === "true" &&
isMutatingGitHubCliCommand(args) &&
process.env.BIFROST_GITHUB_MUTATION_AUTHORIZED !== "true"
) {
process.stderr.write(
"Bifrost blocked GitHub CLI mutation in this dispatched turn. Use tools/bifrost-bridge.mjs so GitHub publication is gated and receipted.\n",
);
process.exit(1);
}
const result = isWindowsCommandScript(realGh)
? spawnSync("cmd.exe", ["/d", "/s", "/c", realGh, ...realGhArgs, ...args], {
stdio: "inherit",
env: process.env,
windowsHide: true,
})
: spawnSync(realGh, [...realGhArgs, ...args], {
stdio: "inherit",
env: process.env,
windowsHide: true,
});
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 1);
function isWindowsCommandScript(command) {
return /\.cmd$/i.test(command) || /\.bat$/i.test(command);
}
function isMutatingGitHubCliCommand(input) {
const [scope = "", action = ""] = input;
if (!scope) {
return false;
}
if (scope === "api") {
return apiMethod(input) !== "GET";
}
return mutatingCommands.has(`${scope} ${action}`);
}
function apiMethod(input) {
for (let index = 0; index < input.length; index += 1) {
const token = input[index];
if (token === "--method") {
return `${input[index + 1] ?? "GET"}`.trim().toUpperCase();
}
if (token.startsWith("--method=")) {
return token.slice("--method=".length).trim().toUpperCase();
}
}
return input.includes("--input") ? "POST" : "GET";
}
function splitCommandArgs(value) {
if (!value) {
return [];
}
const matches = value.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [];
return matches
.map((part) => part.trim())
.filter(Boolean)
.map((part) => (part.startsWith("\"") && part.endsWith("\"")) ? part.slice(1, -1) : part);
}

18
tools/git-gate/gh.cmd Normal file
View file

@ -0,0 +1,18 @@
@echo off
setlocal
set "REAL_GH=%BIFROST_REAL_GH%"
if not defined REAL_GH (
echo Bifrost GitHub gate has no real GitHub CLI executable configured.>&2
exit /b 1
)
set "NODE_EXE=%BIFROST_NODE_EXECUTABLE%"
if not defined NODE_EXE (
echo Bifrost GitHub gate has no Node executable configured.>&2
exit /b 1
)
set "NODE_SCRIPT=%~dp0gh-proxy.mjs"
"%NODE_EXE%" "%NODE_SCRIPT%" %*
exit /b %ERRORLEVEL%

View file

@ -0,0 +1,64 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
const args = process.argv.slice(2);
const realGit = process.env.BIFROST_REAL_GIT;
if (!realGit) {
process.stderr.write("Bifrost Git gate has no real git executable configured.\n");
process.exit(1);
}
const subcommand = findGitSubcommand(args);
if (
process.env.BIFROST_ENFORCE_GITHUB_GATE === "true" &&
subcommand === "push" &&
process.env.BIFROST_GITHUB_PUSH_AUTHORIZED !== "true"
) {
process.stderr.write(
"Bifrost blocked git push in this dispatched turn. Use tools/bifrost-bridge.mjs so GitHub publication is gated and receipted.\n",
);
process.exit(1);
}
const result = spawnSync(realGit, args, {
stdio: "inherit",
env: process.env,
windowsHide: true,
});
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 1);
function findGitSubcommand(input) {
const optionsWithSeparateValues = new Set([
"-C",
"-c",
"--exec-path",
"--git-dir",
"--work-tree",
"--namespace",
"--super-prefix",
"--config-env",
]);
for (let index = 0; index < input.length; index += 1) {
const token = input[index];
if (token === "--") {
return input[index + 1] ?? "";
}
if (!token.startsWith("-")) {
return token;
}
if (optionsWithSeparateValues.has(token)) {
index += 1;
}
}
return "";
}

18
tools/git-gate/git.cmd Normal file
View file

@ -0,0 +1,18 @@
@echo off
setlocal
set "REAL_GIT=%BIFROST_REAL_GIT%"
if not defined REAL_GIT (
echo Bifrost Git gate has no real git executable configured.>&2
exit /b 1
)
set "NODE_EXE=%BIFROST_NODE_EXECUTABLE%"
if not defined NODE_EXE (
echo Bifrost Git gate has no Node executable configured.>&2
exit /b 1
)
set "NODE_SCRIPT=%~dp0git-proxy.mjs"
"%NODE_EXE%" "%NODE_SCRIPT%" %*
exit /b %ERRORLEVEL%