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 `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
|
- 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
|
- 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/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
|
- `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
|
- 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.
|
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.
|
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.
|
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.
|
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
|
- 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
|
- 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
|
- 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 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 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
|
- 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);
|
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 =>
|
private static string RepoRoot =>
|
||||||
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
|
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
|
||||||
|
|
||||||
|
|
@ -249,5 +348,73 @@ console.log(JSON.stringify({
|
||||||
return new ProcessResult(process.ExitCode, stdout, stderr);
|
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);
|
private sealed record ProcessResult(int ExitCode, string Stdout, string Stderr);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,11 @@ async function createGitHubDraftPr(options) {
|
||||||
await writeFile(targetPath, content, "utf8");
|
await writeFile(targetPath, content, "utf8");
|
||||||
git(["add", "--", relativePath], repoRoot);
|
git(["add", "--", relativePath], repoRoot);
|
||||||
git(["commit", "-m", commitMessage], 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;
|
pushed = true;
|
||||||
|
|
||||||
const pr = runGitHubCli(options, [
|
const pr = runGitHubCli(options, [
|
||||||
|
|
@ -876,6 +880,10 @@ function run(command, args, cwd, options = {}) {
|
||||||
cwd,
|
cwd,
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...(options.env ?? {}),
|
||||||
|
},
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -560,10 +560,30 @@ function renderDispatchReceiptContent(request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBridgeContextEnv(request) {
|
function buildBridgeContextEnv(request) {
|
||||||
|
const hooksPath = resolve(bifrostRoot, "tools", "git-hooks");
|
||||||
return {
|
return {
|
||||||
BIFROST_BRIDGE_SOURCE_KIND: "bifrost_agent_transport_request",
|
BIFROST_BRIDGE_SOURCE_KIND: "bifrost_agent_transport_request",
|
||||||
BIFROST_BRIDGE_SOURCE_ID: request.id,
|
BIFROST_BRIDGE_SOURCE_ID: request.id,
|
||||||
BIFROST_BRIDGE_AUTHORITY_REF: "bifrost_dispatch_execution",
|
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