fix(docker): gate gh/az seeding on its switch; no shared git sign-in for non-admin clones

Addresses the review on #472.

- CRED_STORES: `.config/gh` and `.azure` now carry `enabledByEnv`
  (CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ), and resolveDockerCredentialArtifacts
  skips a store unless that variable is exactly `1`, read at container
  create. A host that merely has ~/.config/gh/hosts.yml or a plaintext MSAL
  cache no longer copies them into every case container. Tests: the default
  environment seeds neither even with the files present, and each store
  follows only its own switch.
- Multi-user mode: a non-admin's Clone Repo clone and preflight run with
  `git -c credential.helper=` (GIT_NO_CREDENTIAL_HELPERS, placed before the
  subcommand), so the server account's helpers are never lent to them.
  Verified against a real private repo that it also clears the URL-scoped
  credential.<url>.helper entries, and that public clones still work.
  Tests: the argv in test/git-clone.test.ts, and the route decision
  (non-admin cleared; admin and single-user kept) in
  test/routes/case-clone-credential-helpers.test.ts.
- Docs: recreate the case container to pick up seeds (docker/README.md,
  Docker-Cases wiki, docker-cases.md); the multi-user behaviour in
  docker/README.md and security-architecture.md; "functionally unchanged"
  instead of "unchanged" for an image built with both switches off
  (server.Dockerfile comment, README, changeset).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167CiuzLrmjYWxwKp3rMWjw
This commit is contained in:
Devvyn
2026-09-23 14:44:26 +08:00
co-authored by Claude Opus 5.5
parent 5cf5a45438
commit 02e40f506b
13 changed files with 250 additions and 35 deletions
+22 -6
View File
@@ -812,6 +812,14 @@ interface CredStorePolicy {
seedFiles?: string[];
/** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */
seedWhole?: boolean;
/**
* Seed this store ONLY when this environment variable is exactly `1`, read when
* the container is created. For credentials that belong to an opt-in tool rather
* than to an agent CLI every case already trusts: they are not inert just because
* the image lacks the tool (a gh `hosts.yml` token or an Azure refresh token is
* usable by anything in the container, and the agent in it is prompt-injectable).
*/
enabledByEnv?: string;
}
const CRED_STORES: CredStorePolicy[] = [
@@ -857,18 +865,22 @@ const CRED_STORES: CredStorePolicy[] = [
{ rel: '.config/gcloud', seedWhole: true },
{ rel: '.config/opencode', seedWhole: true },
// GitHub CLI: `hosts.yml` holds the token wherever no system keyring exists (the
// Docker server image, a headless Linux host), `config.yml` the preferences. The
// agent image routes github.com git credentials through `gh`, so this seed is what
// lets an agent clone/push a private repo. A token that lives in a desktop keyring
// is not in `hosts.yml` and does not carry in; sign `gh` in inside the container.
{ rel: '.config/gh', seedFiles: ['hosts.yml', 'config.yml'] },
// Docker server image, a headless Linux host), `config.yml` the preferences. An
// agent image built with CODEMAN_INSTALL_GH=1 routes github.com git credentials
// through `gh`, so this seed is what lets an agent clone/push a private repo. A
// token that lives in a desktop keyring is not in `hosts.yml` and does not carry
// in; sign `gh` in inside the container. OPT-IN: seeded only when the same switch
// that builds gh into the agent image is on, never merely because the file exists.
{ rel: '.config/gh', seedFiles: ['hosts.yml', 'config.yml'], enabledByEnv: 'CODEMAN_AGENT_IMAGE_INSTALL_GH' },
// Azure CLI: only the sign-in state. `~/.azure` also accumulates `logs/`,
// `commands/`, telemetry and (on a bare host) `cliextensions/`, none of which is
// needed to authenticate; the agent image carries its own extensions outside HOME.
// `msal_token_cache.json` is plaintext only on Linux (Windows/macOS encrypt it), so
// this carries a sign-in from the Docker server image or a Linux host.
// OPT-IN like gh: the MSAL cache holds refresh tokens for the whole Azure account.
{
rel: '.azure',
enabledByEnv: 'CODEMAN_AGENT_IMAGE_INSTALL_AZ',
seedFiles: [
'azureProfile.json',
'msal_token_cache.json',
@@ -901,10 +913,14 @@ const CRED_STORES: CredStorePolicy[] = [
* session state back into the host). Every path is existsSync-gated (on most hosts
* only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs).
*/
export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts {
export function resolveDockerCredentialArtifacts(
home: string = homedir(),
env: NodeJS.ProcessEnv = process.env
): DockerClaudeArtifacts {
const mounts: DockerMount[] = [];
const seedCopies: DockerSeedCopy[] = [];
for (const store of CRED_STORES) {
if (store.enabledByEnv && env[store.enabledByEnv] !== '1') continue;
const hostBase = join(home, store.rel);
if (!existsSync(hostBase)) continue;
const containerBase = `${CONTAINER_HOME}/${store.rel}`;
+29 -5
View File
@@ -195,6 +195,8 @@ export interface CloneOptions {
/** `--depth 1`: history-less but much faster on large repos. */
shallow?: boolean;
timeoutMs?: number;
/** Clear every git credential helper for this run (see `GIT_NO_CREDENTIAL_HELPERS`). */
withoutCredentialHelpers?: boolean;
}
export type CloneResult = { ok: true; stderr: string } | { ok: false; failure: GitFailure };
@@ -436,12 +438,27 @@ export function isSafeGitRef(ref: string): boolean {
// ─── Pure: argv + env ────────────────────────────────────────────────────────
/**
* Global git options that empty the credential-helper list for one run.
*
* Every Codeman user in multi-user mode runs git as the SAME OS account, so a
* helper that account has (the Docker image's opt-in `gh`/`az` helpers, or a
* user's own `gh auth setup-git`) would read private repositories on the
* signed-in admin's behalf for anyone who can reach Clone Repo. An empty
* `credential.helper` resets the helper list, and a command-line `-c` is read
* last, so it also drops the URL-scoped `credential.<url>.helper` entries the
* image configures (verified against a real private repo: refs with the helper,
* `could not read Username` with it cleared). Public repositories are
* unaffected. It must precede the subcommand.
*/
export const GIT_NO_CREDENTIAL_HELPERS: readonly string[] = ['-c', 'credential.helper='];
/**
* argv for the clone. `--` separates flags from operands so neither the
* repository nor the destination can ever be read as an option.
*/
export function buildCloneArgs(opts: CloneOptions): string[] {
const args = ['clone'];
const args = [...(opts.withoutCredentialHelpers ? GIT_NO_CREDENTIAL_HELPERS : []), 'clone'];
// `--single-branch` is what makes "just this tag/branch" cheap on a big repo.
if (opts.ref) args.push('--single-branch', '--branch', opts.ref);
if (opts.shallow) args.push('--depth', '1');
@@ -450,8 +467,14 @@ export function buildCloneArgs(opts: CloneOptions): string[] {
}
/** argv for the preflight. `--symref` is what reveals the remote's default branch. */
export function buildLsRemoteArgs(repository: string): string[] {
return ['ls-remote', '--symref', '--', repository];
export function buildLsRemoteArgs(repository: string, opts: { withoutCredentialHelpers?: boolean } = {}): string[] {
return [
...(opts.withoutCredentialHelpers ? GIT_NO_CREDENTIAL_HELPERS : []),
'ls-remote',
'--symref',
'--',
repository,
];
}
/**
@@ -790,7 +813,8 @@ export function isGitAvailable(): boolean {
*/
export async function probeGitRemote(
repository: string,
timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS
timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS,
opts: { withoutCredentialHelpers?: boolean } = {}
): Promise<GitRemoteProbe> {
if (!isGitAvailable()) {
return {
@@ -800,7 +824,7 @@ export async function probeGitRemote(
failure: classifyGitFailure('', false, 'ENOENT: git not found'),
};
}
const run = await runGit(buildLsRemoteArgs(repository), timeoutMs, MAX_LS_REMOTE_BYTES);
const run = await runGit(buildLsRemoteArgs(repository, opts), timeoutMs, MAX_LS_REMOTE_BYTES);
if (run.code !== 0 || run.spawnError) {
return {
reachable: false,
+20 -3
View File
@@ -128,6 +128,18 @@ const APP_VERSION = (() => {
const LOCAL_CLONE_ADMIN_ONLY =
'Cloning from a local path is admin-only in multi-user mode. Use a repository URL instead.';
/**
* Whether a clone or preflight must run with git's credential helpers cleared:
* a non-admin in multi-user mode. Every user's git runs as the one server
* account, so its helpers (the Docker image's opt-in `gh`/`az` ones, or any
* `gh auth setup-git`) would otherwise read a private repository with the
* signed-in admin's credentials, the same boundary the local-transport rule
* above guards. Admins and single-user mode keep the account's own helpers.
*/
export function cloneWithoutCredentialHelpers(req: FastifyRequest): boolean {
return isMultiUserMode() && !isAdmin(req);
}
/**
* The one line of git's stderr worth appending to an error message.
*
@@ -475,7 +487,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
if (!isGitAvailable()) {
return { success: true, data: { parse: parsed, gitAvailable: false } };
}
const remote = await probeGitRemote(parsed.repository);
const remote = await probeGitRemote(parsed.repository, undefined, {
withoutCredentialHelpers: cloneWithoutCredentialHelpers(req),
});
return { success: true, data: { parse: parsed, remote, gitAvailable: true } };
}
);
@@ -491,8 +505,10 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
* request died mid-clone still sees the case appear over SSE when git finishes.
*
* Deliberately NOT admin-gated in multi-user mode: unlike `/api/cases/link`,
* this writes only inside the caller's own `resolveCasesDir`. The one exception
* is a `local`-transport source, which would read through that boundary.
* this writes only inside the caller's own `resolveCasesDir`. Two things would
* otherwise read through that boundary: a `local`-transport source (refused for
* non-admins) and the server account's git credential helpers, which every user
* shares (cleared for non-admins, see `cloneWithoutCredentialHelpers`).
*
* Repository contents win over scaffolding: an existing CLAUDE.md is left
* alone, and hooks are MERGED into whatever `.claude/settings.local.json` the
@@ -562,6 +578,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const clone = await cloneRepository({
repository: parsed.repository,
destination: casePath,
withoutCredentialHelpers: cloneWithoutCredentialHelpers(req),
...(ref ? { ref } : {}),
...(shallow ? { shallow: true } : {}),
});