Tighten dispatched gh gate parsing
This commit is contained in:
parent
1fdf6b8a87
commit
4468591d6c
5 changed files with 204 additions and 48 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 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, "..", "..", "..", "..", ".."));
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue