diff --git a/plugins/magpie-security/hooks/public-surface-scrub.test.ts b/plugins/magpie-security/hooks/public-surface-scrub.test.ts index e05dc7fa7..c244749d9 100644 --- a/plugins/magpie-security/hooks/public-surface-scrub.test.ts +++ b/plugins/magpie-security/hooks/public-surface-scrub.test.ts @@ -2210,6 +2210,216 @@ upstream_repo: apache/kafka }); assert(res?.result && res.result.stdout === 'executed', `Expected tool call to execute: ${JSON.stringify(res)}`); }); + + test('a bare tracker git commit is blocked without --repo advice when only the root is known', async ($: any, on: any) => { + setupTrackerEnv(on); + const res = await $.tool.call({ tool: 'Bash', command: 'git commit -m "Allocated CVE-2026-1234"' }); + assert(typeof res?.deny === 'string', `Expected deny: ${JSON.stringify(res)}`); + assert(!res.deny.includes('--repo'), `git block must not suggest --repo: ${res.deny}`); + }); + }); + + describe('register() reads the current directory from $.session.cwd()', () => { + const trackerProject = '\n| Key | Value |\n| tracker_repo | airflow-s/airflow-s |\n| upstream_repo | apache/airflow |\n'; + const trackerGitConfig = '\n[remote "origin"]\n url = https://github.com/airflow-s/airflow-s.git\n'; + const upstreamGitConfig = '\n[remote "origin"]\n url = https://github.com/apache/airflow.git\n'; + const msgFile = '/home/dev/msg.txt'; + + // Session root is always the tracker checkout; $.session.cwd() says where the shell is now. + function setupEnv(on: any, currentDir: string): void { + on('session.root', () => ({ value: '/home/dev/airflow-s' })); + on('session.cwd', () => ({ value: currentDir })); + on('fs.read', (_: any, e: any) => { + const p = (e.path || '').replace(/\\/g, '/'); + if (p.endsWith('/.apache-magpie-overrides/project.md')) return { value: trackerProject }; + if (p === '/home/dev/airflow-s/.git/config') return { value: trackerGitConfig }; + if (p === '/home/dev/scratch/airflow-wt/.git/config') return { value: upstreamGitConfig }; + if (p === msgFile) return { value: 'Note the CVE-2026-1234 advisory in the tracker config' }; + return { value: null }; + }); + on('env.get', () => ({ value: undefined })); + on('tool.call', () => ({ result: { stdout: 'executed' } })); + } + + test('a bare git commit -F passes when the current directory is the tracker checkout', async ($: any, on: any) => { + setupEnv(on, '/home/dev/airflow-s'); + const res = await $.tool.call({ tool: 'Bash', command: `git commit -F ${msgFile}` }); + assert(res?.result && res.result.stdout === 'executed', `Expected tool call to execute: ${JSON.stringify(res)}`); + }); + + test('a bare git commit is blocked after an earlier call moved the shell into an upstream worktree', async ($: any, on: any) => { + setupEnv(on, '/home/dev/scratch/airflow-wt'); + const res = await $.tool.call({ tool: 'Bash', command: `git commit -F ${msgFile}` }); + assert(typeof res?.deny === 'string', `Expected deny in upstream worktree: ${JSON.stringify(res)}`); + }); + + test('a cd on the same line still moves the scope away from the tracker', async ($: any, on: any) => { + setupEnv(on, '/home/dev/airflow-s'); + const res = await $.tool.call({ + tool: 'Bash', + command: 'cd /home/dev/scratch/airflow-wt && git commit -m "Fix CVE-2026-1234"', + }); + assert(typeof res?.deny === 'string', `Expected deny after cd: ${JSON.stringify(res)}`); + }); + + test('an event cwd takes precedence over $.session.cwd()', async ($: any, on: any) => { + setupEnv(on, '/home/dev/airflow-s'); + const res = await $.tool.call({ + tool: 'Bash', + cwd: '/home/dev/scratch/airflow-wt', + command: `git commit -F ${msgFile}`, + }); + assert(typeof res?.deny === 'string', `Expected deny on event cwd: ${JSON.stringify(res)}`); + }); + + test('gh pr create in the tracker directory is exempt like any verified tracker cwd', async ($: any, on: any) => { + // Not fallback any more, so the tracker cwd exemption applies like any verified cwd + setupEnv(on, '/home/dev/airflow-s'); + const res = await $.tool.call({ tool: 'Bash', command: `gh pr create -t "Config" --body-file ${msgFile}` }); + assert(res?.result && res.result.stdout === 'executed', `Expected verified tracker cwd to exempt gh: ${JSON.stringify(res)}`); + }); + }); + + describe('an earlier environment change on the line redirects a bare git or gh command', () => { + const config = { trackerRepo: 'airflow-s/airflow-s', upstreamRepo: 'apache/airflow' }; + const trackerGitConfig = '\n[remote "origin"]\n url = https://github.com/airflow-s/airflow-s.git\n'; + const readFile = async (p: string) => (p.endsWith('.git/config') ? trackerGitConfig : null); + + // Verified tracker cwd (not the session-root fallback), so only the GIT_* rule can block. + function evaluate(cmd: string) { + return evaluatePublicSurfaceCommand(cmd, { cwd: '/home/dev/airflow-s', projectConfig: config, readFile }); + } + + test('export, declare, typeset, readonly, local and bare assignments of GIT_* block', async () => { + for (const cmd of [ + 'export GIT_DIR=../u/.git && git commit -m "Fix CVE-2026-1234"', + 'GIT_WORK_TREE=x; git commit -m "Fix CVE-2026-1234"', + 'declare -x GIT_DIR=../u/.git && git commit -m "Fix CVE-2026-1234"', + 'typeset -x GIT_COMMON_DIR=../u/.git; git commit -m "Fix CVE-2026-1234"', + 'readonly GIT_INDEX_FILE=../u/.git/index && git commit -m "Fix CVE-2026-1234"', + 'local GIT_OBJECT_DIRECTORY=../u/.git/objects; git tag -a v1 -m "Fix CVE-2026-1234"', + 'GIT_DIR=../u/.git; export GIT_DIR; git commit -m "Fix CVE-2026-1234"', + 'export FOO=1 GIT_NAMESPACE=x && git merge topic -m "Fix CVE-2026-1234"', + 'builtin export GIT_DIR=../u/.git; git tag -a v1 -m "Fix CVE-2026-1234"', + 'command export GIT_DIR=../u/.git; git commit -m "Fix CVE-2026-1234"', + 'GIT_DIR+=x; git commit -m "Fix CVE-2026-1234"', + 'GIT_DIR=../u/.git ls && git commit -m "Fix CVE-2026-1234"', + 'eval "$SETUP"; git commit -m "Fix CVE-2026-1234"', + 'source ./env.sh && git commit -m "Fix CVE-2026-1234"', + '. ./env.sh && git commit -m "Fix CVE-2026-1234"', + 'set -a; git commit -m "Fix CVE-2026-1234"', + 'printf -v X %s y; git commit -m "Fix CVE-2026-1234"', + 'export "$V"; git commit -m "Fix CVE-2026-1234"', + 'export FOO=1 && git commit -m "Fix CVE-2026-1234"', + 'if source ./env.sh; then git commit -m "Fix CVE-2026-1234"; fi', + 'GIT_COMMON_DIR=/home/dev/upstream/.git git tag -a v1 -m "Fix CVE-2026-1234"', + 'git remote set-url origin https://github.com/apache/airflow.git && git commit -m "Fix CVE-2026-1234"', + 'git config core.worktree ../u && git commit -m "Fix CVE-2026-1234"', + 'pushd /home/dev/upstream && pushd /home/dev/airflow-s && popd && git commit -m "Fix CVE-2026-1234"', + 'f() { export GIT_DIR=../u/.git; }; f; git commit -m "Fix CVE-2026-1234"', + 'PATH=/tmp/x:$PATH; git commit -m "Fix CVE-2026-1234"', + 'env GIT_COMMON_DIR=/home/dev/upstream/.git git tag -a v1 -m "Fix CVE-2026-1234"', + 'echo "[core] worktree = ../u" >> .git/config && git commit -m "Fix CVE-2026-1234"', + 'echo $(git remote set-url origin https://github.com/apache/airflow.git) && git commit -m "Fix CVE-2026-1234"', + 'echo `git config core.worktree ../u` && git commit -m "Fix CVE-2026-1234"', + 'cat <(git config core.worktree ../u) && git commit -m "Fix CVE-2026-1234"', + 'git diff --output=.git/config HEAD~1 && git commit -m "Fix CVE-2026-1234"', + 'if true; then GIT_COMMON_DIR=/home/dev/upstream/.git git tag -a v1 -m "Fix CVE-2026-1234"; fi', + 'env -i GIT_COMMON_DIR=/home/dev/upstream/.git git tag -a v1 -m "Fix CVE-2026-1234"', + ]) { + const res = await evaluate(cmd); + assertStrictEqual(res.blocked, true, `Expected block for: ${cmd}`); + } + }); + + test('an earlier GH_REPO or GH_HOST blocks a later bare gh command', async () => { + for (const cmd of [ + 'export GH_REPO=apache/airflow; gh issue create -t "CVE-2026-1234" -b "details"', + 'GH_HOST=example.com; gh pr create -t "Fix CVE-2026-1234" -b "x"', + 'git remote set-url origin https://github.com/apache/airflow.git && gh issue create -t "CVE-2026-1234" -b "d"', + 'GH_REPO=apache/airflow gh issue create -t "CVE-2026-1234" -b "d"', + 'printf -vGH_REPO %s apache/airflow; gh issue create -t "CVE-2026-1234" -b "d"', + 'cd /home/dev/upstream || cd /home/dev/airflow-s; gh issue create -t "CVE-2026-1234" -b "d"', + 'false && cd /home/dev/airflow-s; gh issue create -t "CVE-2026-1234" -b "d"', + 'false && { true; cd /home/dev/airflow-s; }; gh issue create -t "CVE-2026-1234" -b "d"', + 'if false; then cd /home/dev/airflow-s; fi; gh issue create -t "CVE-2026-1234" -b "d"', + 'if true; then :; else cd /home/dev/airflow-s; fi; git commit -m "Fix CVE-2026-1234"', + 'while false; do cd /home/dev/airflow-s; done; gh issue create -t "CVE-2026-1234" -b "d"', + 'for d in; do cd /home/dev/airflow-s; done; git commit -m "Fix CVE-2026-1234"', + 'case x in y) cd /home/dev/airflow-s;; esac; gh issue create -t "CVE-2026-1234" -b "d"', + 'gh issue create -t "$(git remote set-url origin https://github.com/apache/airflow.git; echo CVE-2026-1234)" -b d', + 'git commit -m "`git config core.worktree ../u` Fix CVE-2026-1234"', + 'gh issue create -t "${GH_REPO:=apache/airflow} CVE-2026-1234" -b d', + 'gh issue create -t "CVE-2026-1234" -b d --body-file >(git remote set-url origin https://github.com/apache/airflow.git)', + 'cd /home/dev/airflow-s unused extra; gh issue create -t "CVE-2026-1234" -b "d"', + 'cd -; git commit -m "Fix CVE-2026-1234"', + 'cd -P /home/dev/airflow-s; git commit -m "Fix CVE-2026-1234"', + 'test -d /nope && cd /home/dev/airflow-s; git commit -m "Fix CVE-2026-1234"', + 'echo "${GH_REPO:=apache/airflow}"; gh issue create -t "CVE-2026-1234" -b "d"', + 'gh repo set-default apache/airflow && gh issue create -t "CVE-2026-1234" -b "d"', + 'CDPATH=/home/dev/public; cd child; gh issue create -t "CVE-2026-1234" -b "d"', + 'export CDPATH=/home/dev/public; cd child; git commit -m "Fix CVE-2026-1234"', + "printf '[remote \"origin\"]\\nurl = https://github.com/apache/airflow.git\\n' > .git/config && gh issue create -t 'CVE-2026-1234' -b d", + ]) { + const res = await evaluate(cmd); + assertStrictEqual(res.blocked, true, `Expected block for: ${cmd}`); + } + }); + + test('a repository-selecting variable in the session environment removes the exemption', async () => { + for (const env of [{ GH_REPO: 'apache/airflow' }, { GIT_DIR: '/home/dev/upstream/.git' }]) { + for (const cmd of ['gh issue create -t "CVE-2026-1234" -b "d"', 'git commit -m "Fix CVE-2026-1234"']) { + const res = await evaluatePublicSurfaceCommand(cmd, { + cwd: '/home/dev/airflow-s', + projectConfig: config, + readFile, + env, + }); + assertStrictEqual(res.blocked, true, `Expected block for ${cmd} with ${JSON.stringify(env)}`); + } + } + }); + + test('an inherited CDPATH makes a relative cd a redirect', async () => { + const env = { CDPATH: '/home/dev/public' }; + for (const [cmd, blocked] of [ + ['cd child && gh issue create -t "CVE-2026-1234" -b "d"', true], + ['cd .hidden && gh issue create -t "CVE-2026-1234" -b "d"', true], + ['cd ./child && gh issue create -t "CVE-2026-1234" -b "d"', false], + ['cd /home/dev/airflow-s && git commit -m "Fix CVE-2026-1234"', false], + ] as const) { + const res = await evaluatePublicSurfaceCommand(cmd, { cwd: '/home/dev/airflow-s', projectConfig: config, readFile, env }); + assertStrictEqual(res.blocked, blocked, `Expected blocked=${blocked} for ${cmd}: ${JSON.stringify(res)}`); + } + }); + + test('bash -c and eval invoking git or gh get no cwd exemption', async () => { + for (const cmd of [ + `bash -c 'cd /home/dev/upstream; git commit -m "CVE-2026-1234"'`, + `sh -c 'gh issue create -t "CVE-2026-1234" -b d'`, + `eval 'cd /home/dev/upstream; git commit -m "CVE-2026-1234"'`, + ]) { + const res = await evaluate(cmd); + assertStrictEqual(res.blocked, true, `Expected block for: ${cmd}`); + } + }); + + test('unrelated earlier commands keep the exemption', async () => { + for (const cmd of [ + 'echo ready && git commit -m "Fix CVE-2026-1234"', + 'git add file.md && git commit -m "Fix CVE-2026-1234"', + 'git status && git commit -m "Fix CVE-2026-1234"', + 'git commit -m "Fix CVE-2026-1234"', + ]) { + const res = await evaluate(cmd); + assertStrictEqual(res.blocked, false, `Expected tracker exemption for: ${cmd}: ${JSON.stringify(res)}`); + } + }); + + test('a neutral message still passes after a GIT_* assignment', async () => { + const res = await evaluate('export GIT_DIR=../u/.git && git commit -m "Refactor config loading"'); + assertStrictEqual(res.blocked, false, `Expected pass on neutral text: ${JSON.stringify(res)}`); + }); }); }); diff --git a/plugins/magpie-security/hooks/public-surface-scrub.ts b/plugins/magpie-security/hooks/public-surface-scrub.ts index 6bddadc22..7bceacfc0 100644 --- a/plugins/magpie-security/hooks/public-surface-scrub.ts +++ b/plugins/magpie-security/hooks/public-surface-scrub.ts @@ -673,6 +673,8 @@ export interface ParsedSubcommand { isGraphqlMutation?: boolean; isLiteralUnknownEndpoint?: boolean; hasGitDirOrWorkTree?: boolean; + /** The command runs with environment set for it: a leading `NAME=value` or an `env` wrapper, after any prefix. */ + setsOwnEnvironment?: boolean; } /** @@ -1131,6 +1133,7 @@ export function parseSubcommand(subcommand: string): ParsedSubcommand { cmdIdx++; } } else if (t === 'env') { + result.setsOwnEnvironment = true; cmdIdx++; while (cmdIdx < tokens.length && tokens[cmdIdx].startsWith('-')) { const envTok = tokens[cmdIdx]; @@ -1154,6 +1157,7 @@ export function parseSubcommand(subcommand: string): ParsedSubcommand { } else if (/^[a-zA-Z_][a-zA-Z0-9_]*=/.test(t)) { const eq = t.indexOf('='); leadingEnv[t.slice(0, eq)] = t.slice(eq + 1); + result.setsOwnEnvironment = true; cmdIdx++; } else { break; @@ -2073,6 +2077,98 @@ const SAFE_READONLY_COMMANDS = new Set([ 'readonly', ]); +/** + * Commands that cannot change the shell's environment or the repository's configuration for the + * commands after them on the line. `cd` changes only the directory, which the evaluation loop + * tracks itself (`pushd` / `popd` are not tracked, so they are not here). `git` is allowed only + * with a read-or-stage subcommand ({@link REPOSITORY_PRESERVING_GIT_SUBCOMMANDS}): others, such as + * `git remote set-url` or `git config`, can repoint a later command at another repository. + */ +const ENVIRONMENT_PRESERVING_COMMANDS = new Set([ + 'git', + 'echo', + 'test', + '[', + 'true', + 'false', + ':', + 'pwd', + 'ls', + 'cat', + 'cd', +]); + +const REPOSITORY_PRESERVING_GIT_SUBCOMMANDS = new Set(['add', 'status', 'diff', 'log', 'show']); + +/** + * Environment variables that choose which repository git or gh acts on. Set in the session's own + * environment, any of them removes the directory-based exemption for every bare git / gh command. + */ +const REPOSITORY_SELECTING_ENV_VARS = [ + 'GH_REPO', + 'GH_HOST', + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_COMMON_DIR', + 'GIT_INDEX_FILE', + 'GIT_OBJECT_DIRECTORY', + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_NAMESPACE', + 'GIT_CEILING_DIRECTORIES', + 'GIT_CONFIG_GLOBAL', + 'GIT_CONFIG_SYSTEM', + 'GIT_CONFIG_COUNT', +]; + +/** `gh ` verbs that only read (`gh issue view`); others, such as `gh repo set-default`, can repoint gh. */ +const READ_ONLY_GH_VERBS = new Set(['view', 'list', 'status', 'diff']); + +/** + * True when a subcommand may change which repository a LATER git or gh subcommand on the line acts + * on, without changing directory. An allow-list, failing closed: the shell has too many ways to + * change the environment (`export GIT_DIR=...` and its `builtin` / `command` / `declare` / `+=` + * forms, `export GH_REPO=...`, `eval`, `source`, function and alias definitions, `PATH=...`, ...) + * for a deny-list to stay complete. So only a segment whose command is on + * {@link ENVIRONMENT_PRESERVING_COMMANDS}, with no leading variable assignment, + * keeps a later git or gh command's directory-based exemption. A legitimate tracker commit is run + * on its own or after one of those. + */ +function mayRedirectLaterCommands(subcommand: string): boolean { + // Any output redirection, expansion, command substitution or process substitution can run, write + // or assign something (`printf ... > .git/config`, `echo $(git remote set-url ...)`, + // `echo ${GH_REPO:=...}`) before the later command runs, while remote inspection already read + // the old state: fail closed on all of them. + if (/[>`$]|<\(/.test(subcommand)) { + return true; + } + // A brace group runs in the current shell; its braces change nothing themselves. + const words = tokenizeShellWords(subcommand.trim()).filter((w) => w !== '{' && w !== '}'); + if (words.length === 0) { + return false; + } + if (words[0] === 'gh') { + return !READ_ONLY_GH_VERBS.has(words[2] ?? ''); + } + if (!ENVIRONMENT_PRESERVING_COMMANDS.has(words[0])) { + return true; // includes a leading `VAR=value`, `export`, `builtin`, `eval`, `if`, `f() {`, ... + } + if (words[0] === 'cd') { + // Only a plain `cd ` is tracked as a directory change; `cd a b` fails (too many operands) + // and leaves the shell where it was, and options or `cd -` move it somewhere the tracking + // does not model. + return words.length !== 2 || words[1].startsWith('-'); + } + if (words[0] === 'git') { + // `git diff --output=.git/config` writes a file; any `--output` / `-o` option disqualifies. + return ( + !REPOSITORY_PRESERVING_GIT_SUBCOMMANDS.has(words[1] ?? '') || + words.slice(2).some((w) => w.startsWith('--output') || w === '-o') + ); + } + return false; +} + + /** * Evaluates whether a public-surface command violates embargo terms. * Evaluates per subcommand, respects project.md config for tracker/upstream resolution, @@ -2099,7 +2195,12 @@ export async function evaluatePublicSurfaceCommand( const subshellCwdStack: (string | undefined)[] = []; const seenRedirectedFiles = new Set(); let hasPotentiallyMutatingPriorCommand = false; + // Set once an earlier subcommand on the line may have changed the environment (a GIT_* / GH_* + // variable, or an opaque builtin), which can point a later bare git or gh command at another + // repository without changing directory. + let hasPriorRedirect = REPOSITORY_SELECTING_ENV_VARS.some((name) => Boolean(options?.env?.[name])); + let hasSeenConditional = false; for (const item of subcommands) { if (item.subshellDepthDeltaBefore) { for (let d = 0; d < item.subshellDepthDeltaBefore; d++) { @@ -2126,6 +2227,30 @@ export async function evaluatePublicSurfaceCommand( const rawSub = item.raw; const parsed = parseSubcommand(rawSub); + const isRedirectedByPriorCommand = + // An own-environment git or gh command (`GIT_COMMON_DIR=... git tag`, `env ... git tag`, after + // any prefix): parseSubcommand acts only on GIT_DIR / GIT_WORK_TREE, and git reads many more. + // An expansion or substitution inside the git or gh command itself (`gh issue create -t + // "$(git remote set-url ...)"`, `"${GH_REPO:=...}"`, `>(...)`) runs before the command + // resolves its repository; same fail-closed set as mayRedirectLaterCommands. + (hasPriorRedirect || parsed.setsOwnEnvironment === true || /[`$]|[<>]\(/.test(rawSub)) && + (parsed.commandType.startsWith('git ') || parsed.commandType.startsWith('gh ')); + // The directory tracking follows every `cd`, but once an `&&` or `||` has appeared on the line a + // later `cd` may run only conditionally (`false && cd /tracker`, `cd /upstream || cd /tracker`, + // `false && { true; cd /tracker; }`), so the tracked directory can disagree with the shell: + // any such `cd`, or anything followed by `||`, is a redirect. + const isConditionalCd = Boolean(parsed.cdDir) && hasSeenConditional; + // With CDPATH set, `cd name` searches the CDPATH entries before the current directory, so the + // tracked directory can name a different checkout than the one the shell lands in. Bash skips + // the search only for `/...`, `.`, `..`, `./...` and `../...` (`.hidden` is still searched). + const isCdPathCd = + Boolean(parsed.cdDir) && Boolean(options?.env?.CDPATH) && !/^(\/|\.\.?(\/|$))/.test(parsed.cdDir ?? ''); + if (mayRedirectLaterCommands(rawSub) || item.separatorAfter === '||' || isConditionalCd || isCdPathCd) { + hasPriorRedirect = true; + } + if (item.separatorAfter === '&&' || item.separatorAfter === '||') { + hasSeenConditional = true; + } // Track cd transitions: update virtualCwd across sequential operators (&&, ;, \n, ||) // Only exclude pipeline (|) and background (&) which run in subshells @@ -2256,7 +2381,10 @@ export async function evaluatePublicSurfaceCommand( parsed.commandType.includes('git-dir') || parsed.commandType.includes('work-tree') || parsed.commandType.includes('GIT_DIR') || - parsed.commandType.includes('wrapped') + parsed.commandType.includes('wrapped') || + // `bash -c '...'` and `eval '...'` can cd or set GIT_DIR inside the string before git / gh runs + parsed.commandType.includes('invoking git/gh') || + isRedirectedByPriorCommand ) { // Repository context is redirected or indeterminate — cwd remoteInfo cannot exempt it // Proceed directly to inspection and dynamic-input fail-closed check @@ -2349,7 +2477,15 @@ export async function evaluatePublicSurfaceCommand( if (trackerFallbackHint) { // The Bash tool call carries no cwd, so a command run in the tracker checkout cannot be // told apart from one run after an earlier `cd ../`; an explicit target can. - reason += `\nIf this command targets the private tracker, name it explicitly: \`--repo ${trackerFallbackHint}\`.`; + if (parsed.commandType.startsWith('git ')) { + // git commit/tag/merge has no --repo flag; only the directory it runs in identifies the repository. + reason += + '\nThe current directory of this command could not be determined, so the private-tracker exemption ' + + 'was not applied. A git command has no repository flag; the exemption needs a known working directory ' + + 'inside the tracker checkout.'; + } else { + reason += `\nIf this command targets the private tracker, name it explicitly: \`--repo ${trackerFallbackHint}\`.`; + } } return { blocked: true, @@ -2376,6 +2512,20 @@ export function register(on: any): void { if (typeof command === 'string') { let initialCwd: string | undefined = e?.cwd || e?.directory; let isSessionRootFallback = false; + if (!initialCwd) { + // The Bash tool event carries no directory. $.session.cwd() is the session's current + // directory: the shell's directory as left by earlier Bash calls (a `cd` that persists + // across calls moves it), or the calling agent's own directory. $.session.root() is only + // where the session started, so it is used just when the current directory is unknown. + try { + const current = await $.session.cwd(); + if (typeof current === 'string' && current) { + initialCwd = current; + } + } catch { + // $.session.cwd may be absent in mock harnesses or older engines + } + } if (!initialCwd) { try { initialCwd = await $.session.root(); @@ -2390,7 +2540,38 @@ export function register(on: any): void { } catch { // $.env may be absent in mock harnesses } - const hookEnv = allowBypass ? { MAGPIE_ALLOW_EMBARGO_TERMS: allowBypass } : {}; + const hookEnv: { [key: string]: string | undefined } = allowBypass + ? { MAGPIE_ALLOW_EMBARGO_TERMS: allowBypass } + : {}; + // A repository-selecting variable inherited by the session (Claude launched with GH_REPO or + // GIT_DIR set) repoints every bare git / gh command, whatever the directory says. + // The mod API needs a literal name at every $.env.get call, so each variable is spelled out; + // keep this list in step with REPOSITORY_SELECTING_ENV_VARS. + const inherited: [string, () => Promise][] = [ + ['GH_REPO', () => $.env.get('GH_REPO')], + ['GH_HOST', () => $.env.get('GH_HOST')], + ['GIT_DIR', () => $.env.get('GIT_DIR')], + ['GIT_WORK_TREE', () => $.env.get('GIT_WORK_TREE')], + ['GIT_COMMON_DIR', () => $.env.get('GIT_COMMON_DIR')], + ['GIT_INDEX_FILE', () => $.env.get('GIT_INDEX_FILE')], + ['GIT_OBJECT_DIRECTORY', () => $.env.get('GIT_OBJECT_DIRECTORY')], + ['GIT_ALTERNATE_OBJECT_DIRECTORIES', () => $.env.get('GIT_ALTERNATE_OBJECT_DIRECTORIES')], + ['GIT_NAMESPACE', () => $.env.get('GIT_NAMESPACE')], + ['GIT_CEILING_DIRECTORIES', () => $.env.get('GIT_CEILING_DIRECTORIES')], + ['GIT_CONFIG_GLOBAL', () => $.env.get('GIT_CONFIG_GLOBAL')], + ['GIT_CONFIG_SYSTEM', () => $.env.get('GIT_CONFIG_SYSTEM')], + ['GIT_CONFIG_COUNT', () => $.env.get('GIT_CONFIG_COUNT')], + // Not repository-selecting by itself, but it moves a relative `cd` (see isCdPathCd). + ['CDPATH', () => $.env.get('CDPATH')], + ]; + for (const [name, read] of inherited) { + try { + const value = await read(); + if (value) hookEnv[name] = value; + } catch { + // $.env may be absent in mock harnesses + } + } const evaluation = await evaluatePublicSurfaceCommand(command, { cwd: initialCwd, isSessionRootFallback,