Scrub ambient GitHub auth from dispatch turns

This commit is contained in:
Meta Vens 2026-06-17 09:13:26 +01:00
commit 390dcf16d2
5 changed files with 179 additions and 19 deletions

View file

@ -81,6 +81,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
- 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
- GitHub bridge mutations now fail closed without those values; `--allow-ungated-github true` is reserved for explicit operator recovery
- dispatched Codex turns now lock those recovery hatches too: local-only `--allow-ungated-github` and `--allow-unreceipted-activity` escapes are refused inside dispatched work so a worker cannot silently downgrade itself out of Bifrost receipts
- dispatched Codex turns also now scrub ambient GitHub auth state from the child environment: `GH_TOKEN` / `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompting is disabled before the worker starts
- set `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optionally `BIFROST_REDDIT_CLIENT_SECRET` before posting Reddit organizing threads
- set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production
- default connection string points at local PostgreSQL

View file

@ -128,6 +128,7 @@ Dispatch receipts should use Bifrost's own public persona in the Bifrost governa
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 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.
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.
`apply-snapshot` is treated as a mutating import path too. It now fails closed without that same bridge configuration and derives request-lane receipts from the imported state delta, so a snapshot can no longer rewrite queue or claim state behind Bifrost's back.
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.

View file

@ -94,6 +94,7 @@ Current CLI wiring:
- 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 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
- 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

View file

@ -38,7 +38,7 @@ public sealed class BridgeCliTests
"--dry-run", "true",
]);
Assert.Equal(0, result.ExitCode);
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
using var payload = JsonDocument.Parse(result.Stdout);
Assert.Equal("github-pr-comment", payload.RootElement.GetProperty("action").GetString());
Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean());
@ -93,7 +93,7 @@ console.log(JSON.stringify({
"--allow-ungated-github", "true",
]);
Assert.Equal(0, result.ExitCode);
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
using var payload = JsonDocument.Parse(result.Stdout);
Assert.Equal("github-pr-comment", payload.RootElement.GetProperty("action").GetString());
Assert.Equal("https://github.com/GameCult/Bifrost/pull/1#issuecomment-123456", payload.RootElement.GetProperty("receiptUrl").GetString());
@ -135,7 +135,7 @@ console.log(JSON.stringify({
"--allow-unreceipted-activity", "true",
]);
Assert.Equal(0, result.ExitCode);
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
using var payload = JsonDocument.Parse(result.Stdout);
Assert.Equal("queued", payload.RootElement.GetProperty("status").GetString());
}
@ -361,6 +361,144 @@ console.log(JSON.stringify({
Assert.Contains("requires BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Dispatch_worker_sanitizes_github_auth_state_for_codex_exec()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"bifrost-dispatch-env-{Guid.NewGuid():N}");
var transportStorePath = Path.Combine(RepoRoot, ".bifrost", "agent-transport.cc");
var transportStoreBackup = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-backup-{Guid.NewGuid():N}.cc");
var hadTransportStore = File.Exists(transportStorePath);
Directory.CreateDirectory(tempDir);
var requestPath = Path.Combine(tempDir, "request.json");
var promptPath = Path.Combine(tempDir, "prompt.md");
var logPath = Path.Combine(tempDir, "codex.log");
var dumpPath = Path.Combine(tempDir, "env.json");
var fakeCodexPath = Path.Combine(tempDir, "fake-codex.mjs");
if (hadTransportStore)
{
File.Copy(transportStorePath, transportStoreBackup, overwrite: true);
}
try
{
await File.WriteAllTextAsync(requestPath, """
{
"id": "req_dispatch_env_123",
"targetRepoName": "Bifrost",
"targetRepositoryFullName": "GameCult/Bifrost",
"targetAgentIdentity": "nibu",
"title": "Dispatch env test",
"requestMarkdown": "## Request\n\nInspect dispatch env.",
"priority": 50,
"status": "claimed",
"sourceKind": "manual",
"sourceChannelId": "",
"sourceMessageIds": [],
"sourcePacketPath": "",
"sourcePromptPath": "",
"createdByAgent": "tester",
"claimedByAgent": "tester",
"closeNote": "",
"createdAt": "2026-06-17T00:00:00Z",
"updatedAt": "2026-06-17T00:00:00Z",
"claimedAt": "2026-06-17T00:00:00Z",
"closedAt": ""
}
""", new UTF8Encoding(false));
await File.WriteAllTextAsync(promptPath, "Hello", new UTF8Encoding(false));
await File.WriteAllTextAsync(fakeCodexPath, """
import { writeFileSync } from "node:fs";
const configEntries = [];
for (const key of Object.keys(process.env)) {
const match = /^GIT_CONFIG_KEY_(\d+)$/.exec(key);
if (!match) {
continue;
}
const index = Number.parseInt(match[1], 10);
configEntries.push({
index,
key: process.env[key] ?? "",
value: process.env[`GIT_CONFIG_VALUE_${index}`] ?? "",
});
}
configEntries.sort((left, right) => left.index - right.index);
writeFileSync(process.env.DISPATCH_ENV_DUMP, JSON.stringify({
GH_CONFIG_DIR: process.env.GH_CONFIG_DIR ?? "",
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL ?? "",
GH_TOKEN: process.env.GH_TOKEN ?? "",
GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? "",
GIT_TERMINAL_PROMPT: process.env.GIT_TERMINAL_PROMPT ?? "",
GCM_INTERACTIVE: process.env.GCM_INTERACTIVE ?? "",
BIFROST_LOCK_RECOVERY_HATCHES: process.env.BIFROST_LOCK_RECOVERY_HATCHES ?? "",
configEntries,
}, null, 2));
""", Encoding.UTF8);
await RunNodeAsync([
"tools/agent-transport.mjs",
"enqueue",
"--id", "req_dispatch_env_123",
"--repo", "Bifrost",
"--agent", "nibu",
"--title", "Dispatch env seed",
"--request", "Seed the live request lane.",
"--allow-unmirrored", "true",
"--allow-unreceipted-activity", "true",
]);
var result = await RunNodeAsync([
"tools/dispatch-agent-requests.mjs",
"run-claimed",
"--request-file", requestPath,
"--repo-root", RepoRoot,
"--prompt-file", promptPath,
"--log", logPath,
"--launch-mode", "codex-exec",
"--codex-executable", "node",
"--codex-exec-args", fakeCodexPath,
], new Dictionary<string, string?>
{
["BIFROST_ALLOW_UNRECEIPTED_ACTIVITY"] = "true",
["GH_TOKEN"] = "parent-gh-token",
["GITHUB_TOKEN"] = "parent-github-token",
["DISPATCH_ENV_DUMP"] = dumpPath,
});
Assert.Equal(0, result.ExitCode);
Assert.True(File.Exists(dumpPath), $"Dispatch env dump was not created. stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
using var payload = JsonDocument.Parse(await File.ReadAllTextAsync(dumpPath, Encoding.UTF8));
Assert.EndsWith("github-gh-config", payload.RootElement.GetProperty("GH_CONFIG_DIR").GetString(), StringComparison.OrdinalIgnoreCase);
Assert.EndsWith("github-gitconfig", payload.RootElement.GetProperty("GIT_CONFIG_GLOBAL").GetString(), StringComparison.OrdinalIgnoreCase);
Assert.Equal(string.Empty, payload.RootElement.GetProperty("GH_TOKEN").GetString());
Assert.Equal(string.Empty, payload.RootElement.GetProperty("GITHUB_TOKEN").GetString());
Assert.Equal("0", payload.RootElement.GetProperty("GIT_TERMINAL_PROMPT").GetString());
Assert.Equal("never", payload.RootElement.GetProperty("GCM_INTERACTIVE").GetString());
Assert.Equal("true", payload.RootElement.GetProperty("BIFROST_LOCK_RECOVERY_HATCHES").GetString());
var configEntries = payload.RootElement.GetProperty("configEntries").EnumerateArray().ToArray();
Assert.Contains(configEntries, entry => entry.GetProperty("key").GetString() == "core.hooksPath");
Assert.Contains(configEntries, entry => entry.GetProperty("key").GetString() == "credential.helper" && entry.GetProperty("value").GetString() == string.Empty);
Assert.Contains(configEntries, entry => entry.GetProperty("key").GetString() == "credential.interactive" && entry.GetProperty("value").GetString() == "never");
}
finally
{
if (hadTransportStore)
{
File.Copy(transportStoreBackup, transportStorePath, overwrite: true);
File.Delete(transportStoreBackup);
}
else if (File.Exists(transportStorePath))
{
File.Delete(transportStorePath);
}
}
}
[Fact]
public async Task Dispatched_turn_git_hook_blocks_raw_git_push()
{

View file

@ -161,9 +161,9 @@ async function runClaimedViaCodexExec(request, repoRoot, promptPath, logPath, op
const reasoningEffort = options["reasoning-effort"] ?? process.env.CODEX_MODEL_REASONING_EFFORT ?? "medium";
const sandbox = options.sandbox ?? "workspace-write";
const startedAt = new Date().toISOString();
const bridgeContextEnv = buildBridgeContextEnv(request);
const resultPath = resolve(dirname(logPath), "result.json");
await mkdir(dirname(logPath), { recursive: true });
const bridgeContextEnv = buildBridgeContextEnv(request, dirname(logPath));
appendLog(logPath, `started ${startedAt}`);
appendLog(logPath, `request ${request.id}: ${request.title}`);
appendLog(logPath, `repoRoot ${repoRoot}`);
@ -289,13 +289,13 @@ async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, op
const startedAt = new Date().toISOString();
const prompt = await readFile(promptPath, "utf8");
const resultPath = resolve(dirname(logPath), "result.json");
await mkdir(dirname(logPath), { recursive: true });
const client = new CodexAppServerClient({
logPath,
command: resolveCodexCommand(options),
env: buildBridgeContextEnv(request),
env: buildBridgeContextEnv(request, dirname(logPath)),
});
await mkdir(dirname(logPath), { recursive: true });
appendLog(logPath, `started ${startedAt}`);
appendLog(logPath, `request ${request.id}: ${request.title}`);
appendLog(logPath, `repoRoot ${repoRoot}`);
@ -559,9 +559,15 @@ function renderDispatchReceiptContent(request) {
].join("\n");
}
function buildBridgeContextEnv(request) {
function buildBridgeContextEnv(request, runtimeDir) {
const hooksPath = resolve(bifrostRoot, "tools", "git-hooks");
const gitGatePath = resolve(bifrostRoot, "tools", "git-gate");
const githubConfigDir = resolve(runtimeDir, "github-gh-config");
const gitGlobalConfigPath = resolve(runtimeDir, "github-gitconfig");
mkdirSync(githubConfigDir, { recursive: true });
if (!existsSync(gitGlobalConfigPath)) {
writeFileSync(gitGlobalConfigPath, "", "utf8");
}
return {
BIFROST_BRIDGE_SOURCE_KIND: "bifrost_agent_transport_request",
BIFROST_BRIDGE_SOURCE_ID: request.id,
@ -573,24 +579,37 @@ function buildBridgeContextEnv(request) {
BIFROST_NODE_EXECUTABLE: process.execPath,
...buildGitHooksConfigEnv(hooksPath),
...buildPathPrependEnv(gitGatePath),
GH_CONFIG_DIR: githubConfigDir,
GIT_CONFIG_GLOBAL: gitGlobalConfigPath,
GH_TOKEN: "",
GITHUB_TOKEN: "",
GIT_ASKPASS: "",
SSH_ASKPASS: "",
SSH_AUTH_SOCK: "",
GIT_TERMINAL_PROMPT: "0",
GCM_INTERACTIVE: "never",
};
}
function buildGitHooksConfigEnv(hooksPath) {
const entries = [
["core.hooksPath", hooksPath],
["credential.helper", ""],
["credential.interactive", "never"],
];
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,
const startIndex = Number.isInteger(existingCount) && existingCount >= 0 ? existingCount : 0;
const env = {
GIT_CONFIG_COUNT: String(startIndex + entries.length),
};
entries.forEach(([key, value], index) => {
const slot = startIndex + index;
env[`GIT_CONFIG_KEY_${slot}`] = key;
env[`GIT_CONFIG_VALUE_${slot}`] = value;
});
return env;
}
function buildPathPrependEnv(directory) {