Scrub ambient GitHub auth from dispatch turns
This commit is contained in:
parent
f0661a8995
commit
390dcf16d2
5 changed files with 179 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue