Credential Helper Isolation: Secure Git Auth in Sandboxed Environments

How Soleur authenticates git operations inside sandboxed AI agent sessions using temporary credential helpers, GitHub App installation tokens, and randomized paths.

engineering security github credential-isolation

Sandboxed AI agents need to push and pull from git repositories. The agent runs in a constrained environment. It must not hold long-lived credentials. It must not be able to access repositories beyond its scope. And the authentication mechanism must be invisible to the agent — no interactive prompts, no manual token entry, no environment variable leakage.

This is the credential helper isolation pattern we built for Soleur's repository connection feature.

The Problem

A Soleur agent session needs to:

  1. Pull the latest changes from the user's GitHub repository at session start
  2. Push any changes the agent made at session end
  3. Do both without storing credentials in the workspace, the environment, or any file the agent can read

The standard approaches fail in this context:

Personal Access Tokens (PATs) are long-lived, user-scoped, and grant access to every repository the user owns. A leaked PAT in a sandboxed environment is a full-scope credential compromise. PATs also require the user to generate and manage tokens manually — a friction point for non-technical founders.

Deploy keys are repository-scoped but SSH-based, require key pair management, and cannot be rotated programmatically. They also grant permanent access until manually revoked.

OAuth tokens require an interactive browser flow that cannot run inside a headless agent session. The token refresh cycle adds complexity without solving the scope problem.

GitHub App installation tokens are the right fit: automatically scoped to the repositories the user selected during app installation, expire after one hour, and can be generated programmatically from a server-side JWT.

The Credential Helper Pattern

Git supports custom credential helpers via the GIT_ASKPASS environment variable or the credential.helper configuration option. A credential helper is any executable that outputs username and password lines when git needs authentication.

The pattern:

  1. Generate a short-lived GitHub App installation token on the server
  2. Write a temporary shell script that echoes the token as git credentials
  3. Pass the script path to git via -c credential.helper=!<path>
  4. Run the git operation (clone, pull, or push)
  5. Delete the credential helper in a finally block

Here is the credential helper writer from session-sync.ts:

function writeCredentialHelper(token: string): string {
  const helperPath = randomCredentialPath();
  writeFileSync(
    helperPath,
    `#!/bin/sh\necho "username=x-access-token"\necho "password=${token}"`,
    { mode: 0o700 },
  );
  return helperPath;
}

The x-access-token username is GitHub's convention for installation token authentication. The shell script is executable (0o700) and owned by the process user.

The cleanup is unconditional:

function cleanupCredentialHelper(helperPath: string): void {
  try {
    unlinkSync(helperPath);
  } catch {
    // Best-effort cleanup
  }
}

Every git operation wraps the credential lifecycle in a try/finally:

let helperPath: string | null = null;
try {
  const token = await generateInstallationToken(installationId);
  helperPath = writeCredentialHelper(token);

  execFileSync("git", [
    "-c", `credential.helper=!${helperPath}`,
    "pull", "--no-rebase", "--autostash",
  ], { cwd: workspacePath, stdio: "pipe", timeout: 60_000 });
} catch (err) {
  log.warn({ err, userId }, "Sync pull failed — continuing with local state");
} finally {
  if (helperPath) cleanupCredentialHelper(helperPath);
}

The credential helper exists on disk for the duration of the git operation — seconds for pulls and pushes, up to two minutes for initial clones. After the finally block, the token is gone.

Security Hardening

Randomized paths prevent symlink attacks

If the credential helper path were predictable (e.g., /tmp/git-credentials), an attacker with write access to /tmp could plant a symlink before the helper is written. The writeFileSync call would follow the symlink and overwrite the target file, or the attacker could read the token from the known path.

The path uses crypto.randomUUID():

export function randomCredentialPath(): string {
  return `/tmp/git-cred-${randomUUID()}`;
}

The UUID is generated by Node's crypto module, which uses the operating system's cryptographic random number generator. The path is unpredictable — an attacker cannot race the write.

UUID validation prevents path traversal

Every function that takes a userId parameter validates it against a UUID regex before constructing file paths:

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

if (!UUID_RE.test(userId)) {
  throw new Error(`Invalid userId format: ${userId}`);
}

const workspacePath = join(getWorkspacesRoot(), userId);

Without this check, a userId of ../../etc would construct a workspace path outside the expected directory. The UUID regex enforces that userId contains only hex characters and hyphens — no path separators, no dots, no special characters.

Token expiry limits blast radius

GitHub App installation tokens expire after one hour. The token cache adds a five-minute safety margin:

const TOKEN_SAFETY_MARGIN_MS = 5 * 60 * 1000;

const cached = tokenCache.get(installationId);
if (cached && cached.expiresAt > Date.now() + TOKEN_SAFETY_MARGIN_MS) {
  return cached.token;
}

If a token leaks despite the randomized paths and immediate cleanup, the exposure window is at most 55 minutes. Compare this to a PAT, which has no expiry by default.

GitHub App JWT Flow

Installation tokens are exchanged from a JWT signed with the GitHub App's private key. The JWT is built with Node's crypto module — no external JWT library:

function createAppJwt(): string {
  const now = Math.floor(Date.now() / 1000);
  const header = { alg: "RS256", typ: "JWT" };
  const payload = {
    iss: getAppId(),
    iat: now - 60,   // Clock skew tolerance
    exp: now + 10 * 60, // 10-minute JWT lifetime
  };

  const headerB64 = base64url(Buffer.from(JSON.stringify(header)));
  const payloadB64 = base64url(Buffer.from(JSON.stringify(payload)));
  const signingInput = `${headerB64}.${payloadB64}`;

  const signer = createSign("RSA-SHA256");
  signer.update(signingInput);
  signer.end();
  const signature = base64url(signer.sign(getPrivateKey()));

  return `${signingInput}.${signature}`;
}

The JWT has a 10-minute lifetime. The iat is backdated by 60 seconds to handle clock skew between the server and GitHub's API. The private key is loaded from an environment variable (GITHUB_APP_PRIVATE_KEY) stored in Doppler — it never touches the workspace or the agent sandbox.

The JWT is exchanged for an installation token via GitHub's REST API:

const response = await githubFetch(
  `${GITHUB_API}/app/installations/${installationId}/access_tokens`,
  {
    method: "POST",
    headers: { Authorization: `Bearer ${jwt}` },
  },
);

The returned token is cached in memory with its expiry timestamp. Subsequent operations within the same server process reuse the cached token until five minutes before expiry.

Best-Effort Sync Philosophy

The sync operations follow a strict principle: a failed sync is recoverable; a blocked session is not.

Both syncPull and syncPush catch all errors and log warnings instead of throwing:

export async function syncPull(
  userId: string,
  workspacePath: string,
): Promise<void> {
  // ... setup ...
  try {
    // ... pull logic ...
  } catch (err) {
    log.warn({ err, userId },
      "Sync pull failed — continuing with local state");
  } finally {
    if (helperPath) cleanupCredentialHelper(helperPath);
  }
}

If the pull fails — network outage, token error, merge conflict — the session starts with whatever local state exists. The agent works against a slightly stale codebase rather than not starting at all.

If the push fails, the commit message includes context for the next session:

log.warn({ err, userId },
  "Sync push failed — next session will retry");

The next syncPull auto-commits any local changes before pulling, so work that accumulated between sessions — whether or not the previous push succeeded — is preserved.

Why merge instead of rebase

The pull uses --no-rebase:

execFileSync("git", [
  "-c", `credential.helper=!${helperPath}`,
  "pull", "--no-rebase", "--autostash",
], { cwd: workspacePath, stdio: "pipe", timeout: 60_000 });

Shallow clones (--depth 1) lack sufficient history for rebase operations. A rebase against a shallow clone can fail unpredictably when the common ancestor is not in the local history. Merge is the safe default — it produces a merge commit but never fails due to missing history.

The --autostash flag handles the case where the agent has uncommitted changes that the auto-commit missed (e.g., files matching .gitignore patterns that were later un-ignored).

The Full Lifecycle

Putting it together, the credential lifecycle for a single agent session:

  1. Session start: Server calls syncPull(userId, workspacePath)
  2. syncPull fetches the user's github_installation_id from the database
  3. generateInstallationToken signs a JWT with the App's private key, exchanges it for an installation token, caches the result
  4. writeCredentialHelper writes the token to a randomized /tmp path
  5. Git pulls using the credential helper, merging remote changes
  6. cleanupCredentialHelper deletes the helper script
  7. Agent session runs — all git operations within the session use the workspace's existing git config (no credentials needed for local operations)
  8. Session end: Server calls syncPush(userId, workspacePath)
  9. Steps 3-6 repeat for the push operation
  10. The installation token expires within the hour. The credential helper no longer exists on disk.

At no point does the agent sandbox contain a reusable credential. The token exists in a shell script for the duration of a git command — milliseconds to seconds. The shell script path is unpredictable. The token itself expires.


Q: Why not use GIT_ASKPASS instead of credential.helper?

GIT_ASKPASS works but requires setting an environment variable that persists for the duration of the process. The -c credential.helper=!<path> flag is scoped to a single git invocation. If the process spawns other git operations (e.g., the agent running git commands), they do not inherit the credential helper.

Q: What happens if the credential helper is not cleaned up?

The token in the helper expires after one hour regardless. The randomized filename means it cannot be targeted without directory listing access. But the finally block ensures cleanup in all normal and exceptional exit paths — the only scenario where cleanup fails is a hard process kill (SIGKILL), in which case the file persists until the next /tmp cleanup cycle.

Q: Why not use GitHub's built-in credential caching?

GitHub's credential.helper store and credential.helper cache persist credentials across git invocations — the opposite of what we want. The isolated helper pattern ensures credentials exist only for the duration of one operation.

Q: Does the shallow clone limit what agents can do?

Shallow clones (--depth 1) lack full git history. Agents cannot run git log with history beyond the latest commit, git blame across old revisions, or rebase against distant ancestors. For the intended use case — reading and modifying current project state — shallow clones are sufficient. The trade-off is clone speed (seconds vs. minutes for large repositories) against history depth.

Stay in the loop

Monthly updates about Soleur — new agents, skills, and what we're building next.