Tighten dispatched gh gate parsing

This commit is contained in:
Meta Vens 2026-06-17 10:34:38 +01:00
commit 4468591d6c
5 changed files with 204 additions and 48 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 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
- 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` stays blocked, and direct `gh` usage is limited to explicit read-only commands unless `tools/bifrost-bridge.mjs` authorizes the bridge-owned GitHub write path
- dispatched Codex turns now default to `workspace-write` instead of full machine access, and the app-server sandbox policy disables network access unless the operator explicitly chooses a broader sandbox
- `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 apply-snapshot` is also treated as a mutating import path now: it fails closed without the bridge receipt config and derives request-lane receipts from the imported state delta

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 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.
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 direct `gh` usage is limited to explicit read-only commands unless the bridge explicitly authorizes the GitHub mutation it owns.
Those dispatched turns also lock the local-only recovery hatches. `--allow-ungated-github`, `BIFROST_ALLOW_UNGATED_GITHUB`, `--allow-unreceipted-activity`, and `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY` are refused inside dispatched work so an agent cannot quietly downgrade itself out of Bifrost's gate or receipt trail.
Those dispatched turns also scrub ambient GitHub auth state from the child environment before the worker starts. `GH_TOKEN` and `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompts are disabled. That does not prove every imaginable direct-HTTP escape is impossible, but it removes the normal ambient credential path from dispatched work.
Those dispatched turns now also default to the `workspace-write` sandbox instead of full machine access, and the app-server launch path now tells Codex to keep `networkAccess: false` unless the operator explicitly chooses a broader sandbox. The worker can still read the repo and do its job, but GitHub authority no longer leaks in through the default launch posture.

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 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
- 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 direct `gh` usage is limited to explicit read-only commands unless the bridge explicitly authorizes the GitHub write path
- those dispatched turns also lock local-only recovery hatches: `--allow-ungated-github`, `BIFROST_ALLOW_UNGATED_GITHUB`, `--allow-unreceipted-activity`, and `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY` are refused inside dispatched work so a worker cannot opt itself out of Bifrost gate or receipt policy
- those dispatched turns also scrub ambient GitHub auth state from the child environment before the worker starts: `GH_TOKEN` and `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompts are disabled
- those dispatched turns now default to the `workspace-write` sandbox in both `codex exec` and app-server launch paths, and the non-danger app-server sandbox policy sets `networkAccess: false` so GitHub mutation authority stays with Bifrost instead of bleeding back into the child runtime by default

View file

@ -773,6 +773,107 @@ exit /b 0
}
}
[Fact]
public async Task Dispatched_turn_github_cli_mutation_with_global_repo_flag_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 -R GameCult/Bifrost pr comment 42 --body blocked"],
RepoRoot,
environment);
Assert.NotEqual(0, blocked.ExitCode);
Assert.Contains("Bifrost blocked GitHub CLI", blocked.Stderr, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("should-not-run", blocked.Stdout, StringComparison.OrdinalIgnoreCase);
}
finally
{
DeleteDirectoryIfPresent(tempDir);
}
}
[Fact]
public async Task Dispatched_turn_github_workflow_run_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 workflow run ci.yml"],
RepoRoot,
environment);
Assert.NotEqual(0, blocked.ExitCode);
Assert.Contains("Bifrost blocked GitHub CLI", blocked.Stderr, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("should-not-run", blocked.Stdout, StringComparison.OrdinalIgnoreCase);
}
finally
{
DeleteDirectoryIfPresent(tempDir);
}
}
[Fact]
public async Task Dispatched_turn_github_cli_read_only_view_is_allowed_under_gate()
{
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 read-only-ok
exit /b 0
""", Encoding.UTF8);
var environment = BuildDispatchedGitGateEnv();
environment["BIFROST_REAL_GH"] = fakeGhPath;
var allowed = await RunCommandAsync(
"powershell",
["-NoProfile", "-Command", "gh -R GameCult/Bifrost pr view 42"],
RepoRoot,
environment);
Assert.Equal(0, allowed.ExitCode);
Assert.Contains("read-only-ok", allowed.Stdout, StringComparison.OrdinalIgnoreCase);
}
finally
{
DeleteDirectoryIfPresent(tempDir);
}
}
private static string RepoRoot =>
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));

View file

@ -7,39 +7,30 @@ const args = process.argv.slice(2);
const wrapperDir = dirname(fileURLToPath(import.meta.url));
const realGh = process.env.BIFROST_REAL_GH || resolveExecutable("gh", wrapperDir);
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",
const readOnlyTopLevelCommands = new Set([
"",
"help",
"version",
"status",
"browse",
"search",
]);
const readOnlyScopedCommands = new Map([
["auth", new Set(["status"])],
["issue", new Set(["list", "status", "view"])],
["pr", new Set(["checks", "diff", "list", "status", "view"])],
["release", new Set(["download", "list", "view"])],
["repo", new Set(["list", "view"])],
["run", new Set(["download", "list", "view", "watch"])],
["secret", new Set(["list"])],
["variable", new Set(["list"])],
["workflow", new Set(["list", "view"])],
]);
const globalOptionsWithSeparateValues = new Set([
"-R",
"--repo",
"-h",
"--hostname",
]);
if (!realGh) {
@ -49,11 +40,11 @@ if (!realGh) {
if (
process.env.BIFROST_ENFORCE_GITHUB_GATE === "true" &&
isMutatingGitHubCliCommand(args) &&
requiresBridgeAuthorization(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",
"Bifrost blocked GitHub CLI mutation or unclassified command in this dispatched turn. Use tools/bifrost-bridge.mjs so GitHub publication is gated and receipted.\n",
);
process.exit(1);
}
@ -108,32 +99,96 @@ function isWindowsCommandScript(command) {
return /\.cmd$/i.test(command) || /\.bat$/i.test(command);
}
function isMutatingGitHubCliCommand(input) {
const [scope = "", action = ""] = input;
if (!scope) {
return false;
}
function requiresBridgeAuthorization(input) {
const { scope, action } = parseCommand(input);
if (scope === "api") {
return apiMethod(input) !== "GET";
}
return mutatingCommands.has(`${scope} ${action}`);
if (readOnlyTopLevelCommands.has(scope)) {
return false;
}
const readOnlyActions = readOnlyScopedCommands.get(scope);
if (readOnlyActions?.has(action)) {
return false;
}
return true;
}
function parseCommand(input) {
let index = 0;
while (index < input.length) {
const token = input[index];
if (!token) {
index += 1;
continue;
}
if (token === "--") {
index += 1;
break;
}
if (!token.startsWith("-")) {
break;
}
if (globalOptionsWithSeparateValues.has(token)) {
index += 2;
continue;
}
if (token.startsWith("--repo=") || token.startsWith("--hostname=")) {
index += 1;
continue;
}
index += 1;
}
return {
scope: input[index] ?? "",
action: input[index + 1] ?? "",
};
}
function apiMethod(input) {
for (let index = 0; index < input.length; index += 1) {
const token = input[index];
if (token === "--method") {
if (token === "--method" || token === "-X") {
return `${input[index + 1] ?? "GET"}`.trim().toUpperCase();
}
if (token.startsWith("--method=")) {
return token.slice("--method=".length).trim().toUpperCase();
if (token.startsWith("--method=") || token.startsWith("-X=")) {
const separator = token.includes("=") ? "=" : "";
const value = separator ? token.slice(token.indexOf("=") + 1) : "";
return value.trim().toUpperCase();
}
if (
token === "--input" ||
token === "-f" ||
token === "-F" ||
token === "--field" ||
token === "--raw-field"
) {
return "POST";
}
if (
token.startsWith("--input=") ||
token.startsWith("-f=") ||
token.startsWith("-F=") ||
token.startsWith("--field=") ||
token.startsWith("--raw-field=")
) {
return "POST";
}
}
return input.includes("--input") ? "POST" : "GET";
return "GET";
}
function splitCommandArgs(value) {