diff --git a/cli/__tests__/commit-attribution.test.mjs b/cli/__tests__/commit-attribution.test.mjs index 7d1cb47bdc..b5d8115b04 100644 --- a/cli/__tests__/commit-attribution.test.mjs +++ b/cli/__tests__/commit-attribution.test.mjs @@ -1,15 +1,18 @@ +import { jest } from '@jest/globals'; import { execFileSync as execFileSyncWithEnv, spawnSync, } from 'node:child_process'; import { accessSync, + appendFileSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, + renameSync, rmSync, writeFileSync, } from 'node:fs'; @@ -18,9 +21,19 @@ import { join } from 'node:path'; import { prepareCommitAttribution } from '../src/lib/commit-attribution.js'; -const fixtureEnv = Object.fromEntries(Object.entries(process.env).filter(([name]) => ( - !name.startsWith('GIT_CONFIG_') && !name.startsWith('COMMONLY_AGENT_') -))); +// A developer's global or system core.hooksPath would move every expected +// hooks directory, so the fixtures read only repository config. An inherited +// GIT_CONFIG would also send each fixture `git config` write to that file. +const isolateGitConfig = (env) => ({ + ...Object.fromEntries(Object.entries(env).filter(([name]) => ( + name !== 'GIT_CONFIG' + && !name.startsWith('GIT_CONFIG_') + && !name.startsWith('COMMONLY_AGENT_') + ))), + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', +}); +const fixtureEnv = isolateGitConfig(process.env); const execFileSync = (command, args, options = {}) => execFileSyncWithEnv(command, args, { env: fixtureEnv, ...options, @@ -31,6 +44,9 @@ const makeExecutable = (path, content) => { accessSync(path, constants.X_OK); }; +// A git that refuses the repository at once, as on a "dubious ownership" error. +const FAILING_GIT = '#!/bin/sh\necho "fatal: detected dubious ownership" >&2\nexit 128\n'; + describe('prepareCommitAttribution', () => { test('adds both trailers, preserves Git identity and delegates the repository hooks', () => { const root = mkdtempSync(join(tmpdir(), 'commonly-commit-attribution-')); @@ -286,14 +302,46 @@ describe('prepareCommitAttribution', () => { } }); + test('fixtures read and write only repository config despite inherited GIT_CONFIG variables', () => { + const root = mkdtempSync(join(tmpdir(), 'commonly-commit-attribution-git-config-')); + const repo = join(root, 'repo'); + const outside = join(root, 'outside.gitconfig'); + try { + mkdirSync(repo); + writeFileSync(outside, '[user]\n\tname = Outside\n'); + const env = isolateGitConfig({ + ...process.env, + GIT_CONFIG: outside, + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.hooksPath', + GIT_CONFIG_VALUE_0: join(root, 'env-hooks'), + }); + execFileSync('git', ['init', '--quiet'], { cwd: repo, env }); + execFileSync('git', ['config', 'core.hooksPath', 'team-hooks'], { cwd: repo, env }); + expect(readFileSync(outside, 'utf8')).toBe('[user]\n\tname = Outside\n'); + expect(readFileSync(join(repo, '.git', 'config'), 'utf8')).toContain('hooksPath = team-hooks'); + expect(execFileSync('git', ['config', '--get', 'core.hooksPath'], { + cwd: repo, + env, + encoding: 'utf8', + })).toBe('team-hooks\n'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test('a git child that never exits cannot block the turn', () => { const root = mkdtempSync(join(tmpdir(), 'commonly-commit-attribution-hung-git-')); const stubBin = join(root, 'bin'); mkdirSync(stubBin); + // A repository whose hooks a fallback would find, so an absent path below + // shows that a timed-out probe reads nothing. + execFileSync('git', ['init', '--quiet'], { cwd: root }); // Stands in for a git whose open() never returns, as on a seat whose // gitdir sits under a consent-protected folder. It ignores SIGTERM, so // only the SIGKILL the probe sends can end it. makeExecutable(join(stubBin, 'git'), '#!/bin/sh\ntrap "" TERM\nexec sleep 60\n'); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); let attribution; try { const startedAt = Date.now(); @@ -307,12 +355,269 @@ describe('prepareCommitAttribution', () => { expect(attribution.env.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH).toBeUndefined(); expect(attribution.env.GIT_CONFIG_VALUE_0).toBe(attribution.hooksDirectory); expect(existsSync(join(attribution.hooksDirectory, 'prepare-commit-msg'))).toBe(true); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('did not answer in 5s'); } finally { + warn.mockRestore(); attribution?.cleanup(); rmSync(root, { recursive: true, force: true }); } }, 30000); + test.each([ + { + name: 'the default hooks folder', + setup: (repo) => { + mkdirSync(join(repo, '.git', 'hooks'), { recursive: true }); + return { cwd: repo, expected: join(repo, '.git', 'hooks') }; + }, + }, + { + name: 'a relative core.hooksPath, resolved from the top of the tree', + setup: (repo) => { + mkdirSync(join(repo, '.husky', '_'), { recursive: true }); + mkdirSync(join(repo, 'src')); + execFileSync('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: repo }); + return { cwd: join(repo, 'src'), expected: join(repo, '.husky', '_') }; + }, + }, + { + name: 'a quoted core.hooksPath that ends in a space, with a trailing comment', + setup: (repo, root) => { + const hooks = join(root, 'shared hooks '); + mkdirSync(hooks); + appendFileSync(join(repo, '.git', 'config'), `[core]\n\thooksPath = "${hooks}" ; set by hand\n`); + return { cwd: repo, expected: hooks }; + }, + }, + { + name: "a linked worktree's main repository hooks", + setup: (repo, root) => { + mkdirSync(join(repo, '.git', 'hooks'), { recursive: true }); + execFileSync('git', ['commit', '--allow-empty', '--quiet', '-m', 'init'], { cwd: repo }); + const worktree = join(root, 'worktree'); + execFileSync('git', ['worktree', 'add', '--quiet', '-b', 'side', worktree], { cwd: repo }); + return { cwd: worktree, expected: join(repo, '.git', 'hooks') }; + }, + }, + { + name: 'the hooks of a gitdir: pointer relative to the .git file', + setup: (repo, root) => { + // Submodules write a relative pointer, and git worktree add does not. + const store = join(root, 'store'); + renameSync(join(repo, '.git'), store); + writeFileSync(join(repo, '.git'), 'gitdir: ../store\n'); + mkdirSync(join(store, 'hooks'), { recursive: true }); + mkdirSync(join(repo, 'src')); + return { cwd: join(repo, 'src'), expected: join(store, 'hooks') }; + }, + }, + { + name: "a ~/ core.hooksPath under the probe's HOME", + setup: (repo, root) => { + const home = join(root, 'home'); + mkdirSync(join(home, 'team-hooks'), { recursive: true }); + execFileSync('git', ['config', 'core.hooksPath', '~/team-hooks'], { cwd: repo }); + return { cwd: repo, expected: join(home, 'team-hooks'), env: { HOME: home } }; + }, + }, + ])('a git that fails at once still yields $name', ({ setup }) => { + const root = mkdtempSync(join(tmpdir(), 'commonly-commit-attribution-fallback-')); + const repo = join(root, 'repo'); + const stubBin = join(root, 'bin'); + mkdirSync(repo); + mkdirSync(stubBin); + execFileSync('git', ['init', '--quiet'], { cwd: repo }); + execFileSync('git', ['config', 'user.name', 'Fixture Author'], { cwd: repo }); + execFileSync('git', ['config', 'user.email', 'fixture@example.test'], { cwd: repo }); + const { cwd, expected, env = {} } = setup(repo, root); + // The fallback stands in for the probe, so real git checks the fixture + // and the probe's reading of git's answer. + const reported = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-path', 'hooks'], { + cwd, env: { ...fixtureEnv, ...env }, encoding: 'utf8', + }).replace(/\n$/, ''); + expect(realpathSync(reported)).toBe(realpathSync(expected)); + const probed = prepareCommitAttribution({ + cwd, env: { ...fixtureEnv, ...env }, agentName: 'forge', adapter: 'claude', + }); + probed.cleanup(); + expect(realpathSync(probed.env.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH)).toBe(realpathSync(expected)); + makeExecutable(join(stubBin, 'git'), FAILING_GIT); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let attribution; + try { + attribution = prepareCommitAttribution({ + cwd, + env: { ...fixtureEnv, ...env, PATH: `${stubBin}:${fixtureEnv.PATH}` }, + agentName: 'forge', + adapter: 'claude', + }); + expect(realpathSync(attribution.env.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH)) + .toBe(realpathSync(expected)); + expect(attribution.sandboxEnv.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH) + .toBe(attribution.env.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('(exit 128)'); + expect(warn.mock.calls[0][0]).toContain('read from the repository'); + } finally { + warn.mockRestore(); + attribution?.cleanup(); + rmSync(root, { recursive: true, force: true }); + } + }); + + test('a git that fails at once outside any repository grants nothing and says nothing', () => { + const root = mkdtempSync(join(tmpdir(), 'commonly-commit-attribution-no-repo-')); + const stubBin = join(root, 'bin'); + mkdirSync(stubBin); + makeExecutable(join(stubBin, 'git'), FAILING_GIT); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let attribution; + try { + attribution = prepareCommitAttribution({ + cwd: root, + env: { ...fixtureEnv, PATH: `${stubBin}:${fixtureEnv.PATH}` }, + agentName: 'forge', + adapter: 'claude', + }); + expect(attribution.env.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH).toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + attribution?.cleanup(); + rmSync(root, { recursive: true, force: true }); + } + }); + + // Each .git holds a config whose core.hooksPath names a directory of + // executable hooks, and real git refuses each one as a repository. + test.each([ + { name: 'a .git directory holding only config and hooks/', omit: ['HEAD', 'objects', 'refs'] }, + { name: 'a .git directory without objects/', omit: ['objects'] }, + { name: 'a .git directory without refs/', omit: ['refs'] }, + { name: 'a .git directory whose HEAD names no ref', head: 'main\n' }, + { + name: 'a .git file pointing at a directory holding only config and hooks/', + omit: ['HEAD', 'objects', 'refs'], + pointer: true, + }, + ])('a git that fails at once under $name grants nothing and says nothing', ({ omit = [], head, pointer }) => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'commonly-commit-attribution-not-a-repo-'))); + const top = join(root, 'a'); + const cwd = join(top, 'sub'); + const granted = join(root, 'granted'); + const gitDir = pointer ? join(root, 'store') : join(top, '.git'); + const stubBin = join(root, 'bin'); + mkdirSync(cwd, { recursive: true }); + mkdirSync(granted); + mkdirSync(stubBin); + makeExecutable(join(granted, 'pre-commit'), '#!/bin/sh\nexit 0\n'); + mkdirSync(join(gitDir, 'hooks'), { recursive: true }); + writeFileSync(join(gitDir, 'config'), `[core]\n\thooksPath = ${granted}\n`); + if (!omit.includes('HEAD')) writeFileSync(join(gitDir, 'HEAD'), head ?? 'ref: refs/heads/main\n'); + if (!omit.includes('objects')) mkdirSync(join(gitDir, 'objects')); + if (!omit.includes('refs')) mkdirSync(join(gitDir, 'refs')); + if (pointer) writeFileSync(join(top, '.git'), `gitdir: ${gitDir}\n`); + const real = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { + cwd, env: { ...fixtureEnv, GIT_CEILING_DIRECTORIES: root }, encoding: 'utf8', + }); + // Git versions word the refusal of a bad gitfile differently. + expect(real.status).not.toBe(0); + expect(real.stderr).toMatch(/not a git repository|gitfile does not point to a valid repository/); + makeExecutable(join(stubBin, 'git'), FAILING_GIT); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let attribution; + try { + attribution = prepareCommitAttribution({ + cwd, + env: { ...fixtureEnv, PATH: `${stubBin}:${fixtureEnv.PATH}` }, + agentName: 'forge', + adapter: 'claude', + }); + expect(attribution.env.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH).toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + attribution?.cleanup(); + rmSync(root, { recursive: true, force: true }); + } + }); + + // No second uid is available here, so a different effective uid stands in + // for another user. The same fixture under the real uid is the control. + test('a git that fails at once in a repository another user owns grants nothing and says nothing', () => { + const root = mkdtempSync(join(tmpdir(), 'commonly-commit-attribution-foreign-')); + const stubBin = join(root, 'bin'); + mkdirSync(stubBin); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + mkdirSync(join(root, '.git', 'hooks'), { recursive: true }); + makeExecutable(join(stubBin, 'git'), FAILING_GIT); + const env = { ...fixtureEnv, PATH: `${stubBin}:${fixtureEnv.PATH}` }; + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const attributions = []; + try { + attributions.push(prepareCommitAttribution({ + cwd: root, env, agentName: 'forge', adapter: 'claude', + })); + expect(realpathSync(attributions[0].env.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH)) + .toBe(realpathSync(join(root, '.git', 'hooks'))); + warn.mockClear(); + jest.spyOn(process, 'geteuid').mockReturnValue(process.geteuid() + 1); + attributions.push(prepareCommitAttribution({ + cwd: root, env, agentName: 'forge', adapter: 'claude', + })); + expect(attributions[1].env.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH).toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + } finally { + jest.restoreAllMocks(); + attributions.forEach((attribution) => attribution.cleanup()); + rmSync(root, { recursive: true, force: true }); + } + }); + + // A FIFO where git keeps a file would block a plain read forever, so each + // case runs in a child that is killed if it outlives the probe's own bound. + // The FIFO has no writer, and the repository is this user's own, so the + // ownership check cannot stand between the fallback and the read. + test.each([ + { name: 'HEAD', granted: false }, + { name: 'config', granted: true }, + ])('a git that fails at once beside a FIFO $name finishes without reading it', ({ name, granted }) => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'commonly-commit-attribution-fifo-'))); + const stubBin = join(root, 'bin'); + mkdirSync(stubBin); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + mkdirSync(join(root, '.git', 'hooks'), { recursive: true }); + rmSync(join(root, '.git', name)); + execFileSync('mkfifo', [join(root, '.git', name)]); + makeExecutable(join(stubBin, 'git'), FAILING_GIT); + const moduleUrl = new URL('../src/lib/commit-attribution.js', import.meta.url).href; + const script = ` + const { prepareCommitAttribution } = await import(${JSON.stringify(moduleUrl)}); + console.warn = () => {}; + const attribution = prepareCommitAttribution({ + cwd: process.argv[1], env: process.env, agentName: 'forge', adapter: 'claude', + }); + process.stdout.write(JSON.stringify(attribution.env.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH ?? null)); + attribution.cleanup(); + `; + try { + const child = spawnSync(process.execPath, ['--input-type=module', '-e', script, root], { + env: { ...fixtureEnv, PATH: `${stubBin}:${fixtureEnv.PATH}` }, + encoding: 'utf8', + timeout: 8000, + killSignal: 'SIGKILL', + }); + expect(child.signal).toBeNull(); + expect(child.status).toBe(0); + const hooksPath = JSON.parse(child.stdout); + if (granted) expect(realpathSync(hooksPath)).toBe(join(root, '.git', 'hooks')); + else expect(hooksPath).toBeNull(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, 15000); + test('keeps long seat IDs inside the email local-part limit with a stable hash', () => { const root = mkdtempSync(join(tmpdir(), 'commonly-commit-attribution-long-seat-')); execFileSync('git', ['init', '--quiet'], { cwd: root }); diff --git a/cli/__tests__/seatbelt.commit-hooks.smoke.test.mjs b/cli/__tests__/seatbelt.commit-hooks.smoke.test.mjs new file mode 100644 index 0000000000..533e9c2c77 --- /dev/null +++ b/cli/__tests__/seatbelt.commit-hooks.smoke.test.mjs @@ -0,0 +1,127 @@ +/** + * seatbelt.commit-hooks.smoke.test.mjs + * + * A confined seat commits under real sandbox-exec (TASK-322). When git cannot + * report the hooks directory before the turn, the seat is still granted the + * repository's hooks from the repository's own files, so a hook that lives + * outside the workspace runs. The control commits in a jail without that + * grant, where the hook does not run, so the test is not vacuous. + */ + +import { jest } from '@jest/globals'; +import { execFileSync, spawnSync } from 'child_process'; +import { + accessSync, constants, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { prepareCommitAttribution, supportedGitHooks } from '../src/lib/commit-attribution.js'; +import { wrapArgvWithSeatbelt } from '../src/lib/sandbox/seatbelt.js'; + +const macTest = process.platform === 'darwin' ? test : test.skip; + +const fixtureEnv = Object.fromEntries(Object.entries(process.env).filter(([name]) => ( + !name.startsWith('GIT_') && !name.startsWith('COMMONLY_AGENT_') +))); +const git = (args, cwd) => execFileSync('git', args, { cwd, env: fixtureEnv, encoding: 'utf8' }); +const makeExecutable = (path, content) => { + writeFileSync(path, content, { encoding: 'utf8', mode: 0o700 }); +}; +const isExecutable = (path) => { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +}; + +describe('a confined seat whose git hooks probe failed', () => { + let root; + afterEach(() => { if (root) rmSync(root, { recursive: true, force: true }); }); + + macTest('still runs a repository hook that lives outside the workspace', () => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'commonly-commit-hooks-seatbelt-'))); + const workspace = join(root, 'workspace'); + const state = join(root, 'state'); + const hooks = join(root, 'repo-hooks'); + const stubBin = join(root, 'bin'); + for (const dir of [workspace, state, hooks, stubBin]) mkdirSync(dir); + git(['init', '--quiet'], workspace); + git(['config', 'user.name', 'Fixture Author'], workspace); + git(['config', 'user.email', 'fixture@example.test'], workspace); + git(['config', 'core.hooksPath', hooks], workspace); + // Git runs hooks from the top of the working tree, which the jail can write. + makeExecutable(join(hooks, 'pre-commit'), '#!/bin/sh\nprintf ran > hook-ran\n'); + makeExecutable(join(stubBin, 'git'), '#!/bin/sh\nexit 128\n'); + // The jail runs the binary git itself runs; /usr/bin/git is an xcrun shim. + const realGit = realpathSync(join(git(['--exec-path'], workspace).trim(), 'git')); + + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const attribution = prepareCommitAttribution({ + cwd: workspace, + env: { ...fixtureEnv, PATH: `${stubBin}:${fixtureEnv.PATH}` }, + agentName: 'forge', + displayName: 'Forge', + adapter: 'claude', + }); + warn.mockRestore(); + try { + expect(attribution.env.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH).toBe(hooks); + + // The grant both adapters build from the attribution env + // (claude.js and codex.js): every proxy hook, plus each original hook + // that is executable. + const commitInJail = (originalHooksPath, message) => { + writeFileSync(join(workspace, `${message}.txt`), `${message}\n`); + git(['add', `${message}.txt`], workspace); + const executablePaths = [ + realGit, + ...supportedGitHooks().map((name) => join(attribution.hooksDirectory, name)), + ...(originalHooksPath ? supportedGitHooks() + .map((name) => join(originalHooksPath, name)) + .filter(isExecutable) : []), + ]; + const argv = wrapArgvWithSeatbelt([realGit, 'commit', '--quiet', '-m', message], { + workspacePath: workspace, + workspaceAccess: 'write', + executablePath: realGit, + statePath: state, + executablePaths, + }); + return spawnSync(argv[0], argv.slice(1), { + cwd: workspace, + env: { + ...attribution.env, + PATH: fixtureEnv.PATH, + HOME: state, + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', + }, + encoding: 'utf8', + timeout: 30_000, + }); + }; + const lastMessage = () => git(['show', '-s', '--format=%B', 'HEAD'], workspace); + + // The jail lets a hook under /private/var pass `[ -x ]`, because the + // baseline admits metadata there, but not run, so this commit is refused. + // A hook whose metadata the jail hides fails `[ -x ]` and is skipped + // instead. The hook runs in neither case. + const control = commitInJail(null, 'without-grant'); + expect(control.status).not.toBe(0); + expect(control.stderr).toContain('Operation not permitted'); + expect(existsSync(join(workspace, 'hook-ran'))).toBe(false); + git(['reset', '--quiet'], workspace); + + const granted = commitInJail(attribution.env.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH, 'with-grant'); + expect(granted.stderr).toBe(''); + expect(granted.status).toBe(0); + expect(lastMessage()).toContain('Co-authored-by: Forge '); + expect(existsSync(join(workspace, 'hook-ran'))).toBe(true); + } finally { + attribution.cleanup(); + } + }, 60_000); +}); diff --git a/cli/src/lib/commit-attribution.js b/cli/src/lib/commit-attribution.js index 3801ca4ed7..ac2fde1aca 100644 --- a/cli/src/lib/commit-attribution.js +++ b/cli/src/lib/commit-attribution.js @@ -2,14 +2,20 @@ import { Buffer } from 'node:buffer'; import { createHash } from 'node:crypto'; import { accessSync, + closeSync, constants, + fstatSync, + lstatSync, mkdtempSync, + openSync, + readFileSync, + readlinkSync, rmSync, statSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; const GIT_HOOKS = [ @@ -77,6 +83,145 @@ const seatAddressId = (agentName, instanceId) => { // resolves the original hooks itself at commit time. const GIT_PROBE_TIMEOUT_MS = 5000; +const directoryOrNull = (path) => { + try { + return statSync(path).isDirectory() ? path : null; + } catch { + return null; + } +}; + +// A regular file's text, or null. The open does not block, so a FIFO or a +// device where git keeps a file is refused instead of hanging the caller. +const readRegularFile = (path) => { + let fd = null; + try { + fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK); + return fstatSync(fd).isFile() ? readFileSync(fd, 'utf8') : null; + } catch { + return null; + } finally { + if (fd !== null) closeSync(fd); + } +}; + +// One git config value: quotes group, a backslash escapes the next character, +// and an unquoted # or ; starts a comment. Whitespace around the value is +// dropped unless it is quoted. +const configValue = (raw) => { + let value = ''; + let kept = 0; + let quoted = false; + for (let index = 0; index < raw.length; index += 1) { + const character = raw[index]; + if (character === '\\' && index + 1 < raw.length) { + index += 1; + value += raw[index]; + kept = value.length; + } else if (character === '"') { + quoted = !quoted; + } else if (!quoted && (character === '#' || character === ';')) { + break; + } else if (quoted || value || !/\s/.test(character)) { + value += character; + if (quoted) kept = value.length; + } + } + return value.slice(0, kept) + value.slice(kept).trimEnd(); +}; + +// The last core.hooksPath in one config file, or null. `include` and +// conditional includes are not followed. +const configuredHooksPath = (configPath) => { + const text = readRegularFile(configPath); + if (text === null) return null; + let inCore = false; + let hooksPath = null; + for (const rawLine of text.split(/\r?\n/)) { + let line = rawLine.trim(); + const section = /^\[\s*([A-Za-z0-9.-]+)\s*("[^"]*")?\s*\]/.exec(line); + if (section) { + inCore = section[1].toLowerCase() === 'core' && !section[2]; + line = line.slice(section[0].length).trim(); + } + const entry = inCore && /^hookspath\s*=(.*)$/i.exec(line); + if (entry) hooksPath = configValue(entry[1]); + } + return hooksPath || null; +}; + +// Git opens a git directory only if its HEAD names a ref or an object id and +// its common directory holds objects/ and refs/. +const isGitDirectory = (gitDir, commonDir) => { + if (!directoryOrNull(join(commonDir, 'objects')) || !directoryOrNull(join(commonDir, 'refs'))) { + return false; + } + const head = join(gitDir, 'HEAD'); + try { + if (lstatSync(head).isSymbolicLink()) return readlinkSync(head).startsWith('refs/'); + const line = (readRegularFile(head) ?? '').split('\n')[0]; + return /^ref:\s*refs\//.test(line) || /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(line); + } catch { + return false; + } +}; + +// Git refuses a repository whose files another user owns, unless global +// safe.directory config allows it. That config is not read here, so such a +// repository yields no hooks, which is git's own answer without it. +const ownedByThisUser = (path) => { + if (typeof process.geteuid !== 'function') return false; + try { + return lstatSync(path).uid === process.geteuid(); + } catch { + return false; + } +}; + +// The hooks directory a commit in `cwd` would use, read from the repository's +// own files without running git: core.hooksPath from the repository config, +// else the hooks folder of the common git directory (a linked worktree shares +// its main repository's). Global config and includes are not read. `~` expands +// against the HOME the probe gave git, as git itself would. The nearest .git +// must be a repository this user owns, or there are no hooks. Git would walk +// on past a .git directory that is not a repository, and this stops there. +const hooksPathFromGitFiles = (cwd, env) => { + let top = resolve(cwd); + let entry = join(top, '.git'); + let entryStat = null; + for (;;) { + try { + entryStat = statSync(entry); + break; + } catch { + if (dirname(top) === top) return null; + top = dirname(top); + entry = join(top, '.git'); + } + } + // Ownership is settled before any of the repository's files is read. + if (!ownedByThisUser(top) || !ownedByThisUser(entry)) return null; + let gitDir = entry; + if (entryStat.isFile()) { + const pointer = /^gitdir:\s*(.+?)\s*$/m.exec(readRegularFile(entry) ?? ''); + if (!pointer) return null; + gitDir = resolve(top, pointer[1]); + if (!ownedByThisUser(gitDir)) return null; + } + // A main repository has no commondir file. + const commonDirFile = readRegularFile(join(gitDir, 'commondir')); + const commonDir = commonDirFile === null ? gitDir : resolve(gitDir, commonDirFile.trim()); + if (!isGitDirectory(gitDir, commonDir)) return null; + const configured = configuredHooksPath(join(commonDir, 'config')); + if (!configured) return directoryOrNull(join(commonDir, 'hooks')); + if (configured === '~' || configured.startsWith('~/')) { + return env.HOME ? directoryOrNull(join(env.HOME, configured.slice(1))) : null; + } + // Git runs hooks from the top of the working tree, so a relative path + // resolves there. + return directoryOrNull(resolve(top, configured)); +}; + const resolveOriginalHooksPath = (cwd, env) => { const result = spawnSync( 'git', @@ -90,13 +235,31 @@ const resolveOriginalHooksPath = (cwd, env) => { killSignal: 'SIGKILL', }, ); - if (result.error || result.status !== 0 || !result.stdout?.trim()) return null; - const hooksPath = resolve(result.stdout.trim()); - try { - return statSync(hooksPath).isDirectory() ? hooksPath : null; - } catch { + if (result.error?.code === 'ETIMEDOUT') { + // The open() that stalled git would stall this process too, and a + // synchronous read cannot be timed out, so nothing is read here. A git + // that cannot open the repository cannot commit in it either. + // eslint-disable-next-line no-console + console.warn(`[commit-attribution] git did not answer in ${GIT_PROBE_TIMEOUT_MS / 1000}s in ${cwd}, ` + + 'so a confined seat is not granted this repository\'s hooks this turn'); return null; } + if (result.error || result.status !== 0 || !result.stdout?.trim()) { + // A confined seat can only run the hooks it is granted. A hook it is not + // granted is skipped without a word, or refuses the commit where the jail + // can see the file but not run it. So read the hooks directory from the + // repository's files instead. + const fallback = hooksPathFromGitFiles(cwd, env); + if (fallback) { + const reason = result.error?.code || `exit ${result.status}`; + // eslint-disable-next-line no-console + console.warn(`[commit-attribution] git could not report the hooks directory (${reason}), ` + + `so ${fallback} was read from the repository's files`); + } + return fallback; + } + // Only git's newline ends the line, since a path can end in a space. + return directoryOrNull(resolve(result.stdout.replace(/\r?\n$/, ''))); }; const resolveGitConfigCount = (env) => { @@ -192,8 +355,8 @@ export const prepareCommitAttribution = ({ COMMONLY_AGENT_GIT_CONFIG_BASE_COUNT: String(configIndex), COMMONLY_AGENT_HOOKS_PATH: hooksDirectory, // The sandbox adapters allow the workspace's existing hook files. The - // proxy resolves the target repo's path at hook time; a confined seat may - // fail closed if that repo's hook lies outside the workspace allowlist. + // proxy resolves the target repo's path at hook time, and a confined seat + // cannot run a hook outside what it was granted. ...(originalHooksPath ? { COMMONLY_AGENT_ORIGINAL_HOOKS_PATH: originalHooksPath } : {}), COMMONLY_AGENT_SEAT_NAME: cleanSingleLine(displayName || agentName, 'agent'), COMMONLY_AGENT_SEAT_ID: seatAddressId(agentName, instanceId),