Enforce bridge-owned GitHub pushes in dispatch turns
This commit is contained in:
parent
70f323c7db
commit
d93267eb47
7 changed files with 207 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
8
tools/git-hooks/pre-push
Normal 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
|
||||
Loading…
Reference in a new issue