Gate dispatched GitHub CLI mutations
This commit is contained in:
parent
d93267eb47
commit
01bc5558af
10 changed files with 423 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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
118
tools/git-gate/gh-proxy.mjs
Normal 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
18
tools/git-gate/gh.cmd
Normal 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%
|
||||
64
tools/git-gate/git-proxy.mjs
Normal file
64
tools/git-gate/git-proxy.mjs
Normal 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
18
tools/git-gate/git.cmd
Normal 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%
|
||||
Loading…
Reference in a new issue