mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-16 13:00:39 -07:00
feat(sandbox): dynamic Linux sandbox expansion and worktree support (#23692)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { parse as shellParse } from 'shell-quote';
|
||||
import {
|
||||
extractStringFromParseEntry,
|
||||
initializeShellParsers,
|
||||
splitCommands,
|
||||
stripShellWrapper,
|
||||
} from '../../utils/shell-utils.js';
|
||||
|
||||
/**
|
||||
* Determines if a command is strictly approved for execution on macOS.
|
||||
* A command is approved if it's composed entirely of tools explicitly listed in `approvedTools`
|
||||
* OR if it's composed of known safe, read-only POSIX commands.
|
||||
*
|
||||
* @param command - The full command string to execute.
|
||||
* @param args - The arguments for the command.
|
||||
* @param approvedTools - A list of explicitly approved tool names (e.g., ['npm', 'git']).
|
||||
* @returns true if the command is strictly approved, false otherwise.
|
||||
*/
|
||||
export async function isStrictlyApproved(
|
||||
command: string,
|
||||
args: string[],
|
||||
approvedTools?: string[],
|
||||
): Promise<boolean> {
|
||||
const tools = approvedTools ?? [];
|
||||
|
||||
await initializeShellParsers();
|
||||
|
||||
const fullCmd = [command, ...args].join(' ');
|
||||
const stripped = stripShellWrapper(fullCmd);
|
||||
|
||||
const pipelineCommands = splitCommands(stripped);
|
||||
|
||||
// Fallback for simple commands or parsing failures
|
||||
if (pipelineCommands.length === 0) {
|
||||
// For simple commands, we check the root command.
|
||||
// If it's explicitly approved OR it's a known safe POSIX command, we allow it.
|
||||
return tools.includes(command) || isKnownSafeCommand([command, ...args]);
|
||||
}
|
||||
|
||||
// Check every segment of the pipeline
|
||||
return pipelineCommands.every((cmdString) => {
|
||||
const trimmed = cmdString.trim();
|
||||
if (!trimmed) return true;
|
||||
|
||||
const parsedArgs = shellParse(trimmed).map(extractStringFromParseEntry);
|
||||
if (parsedArgs.length === 0) return true;
|
||||
|
||||
const root = parsedArgs[0];
|
||||
// The segment is approved if the root tool is in the allowlist OR if the whole segment is safe.
|
||||
return tools.includes(root) || isKnownSafeCommand(parsedArgs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a command with its arguments is known to be safe to execute
|
||||
* without requiring user confirmation. This is primarily used to allow
|
||||
* harmless, read-only commands to run silently in the macOS sandbox.
|
||||
*
|
||||
* It handles raw command execution as well as wrapped commands like `bash -c "..."` or `bash -lc "..."`.
|
||||
* For wrapped commands, it parses the script and ensures all individual
|
||||
* sub-commands are in the known-safe list and no dangerous shell operators
|
||||
* (like subshells or redirection) are used.
|
||||
*
|
||||
* @param args - The command and its arguments (e.g., ['ls', '-la'])
|
||||
* @returns true if the command is considered safe, false otherwise.
|
||||
*/
|
||||
export function isKnownSafeCommand(args: string[]): boolean {
|
||||
if (!args || args.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize zsh to bash
|
||||
const normalizedArgs = args.map((a) => (a === 'zsh' ? 'bash' : a));
|
||||
|
||||
if (isSafeToCallWithExec(normalizedArgs)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Support `bash -lc "..."`
|
||||
if (
|
||||
normalizedArgs.length === 3 &&
|
||||
normalizedArgs[0] === 'bash' &&
|
||||
(normalizedArgs[1] === '-lc' || normalizedArgs[1] === '-c')
|
||||
) {
|
||||
try {
|
||||
const script = normalizedArgs[2];
|
||||
|
||||
// Basic check for dangerous operators that could spawn subshells or redirect output
|
||||
// We allow &&, ||, |, ; but explicitly block subshells () and redirection >, >>, <
|
||||
if (/[()<>]/g.test(script)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const commands = splitCommands(script);
|
||||
if (commands.length === 0) return false;
|
||||
|
||||
return commands.every((cmd) => {
|
||||
const trimmed = cmd.trim();
|
||||
if (!trimmed) return true;
|
||||
|
||||
const parsed = shellParse(trimmed).map(extractStringFromParseEntry);
|
||||
if (parsed.length === 0) return true;
|
||||
|
||||
return isSafeToCallWithExec(parsed);
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core validation logic that checks a single command and its arguments
|
||||
* against an allowlist of known safe operations. It performs deep validation
|
||||
* for specific tools like `base64`, `find`, `rg`, `git`, and `sed` to ensure
|
||||
* unsafe flags (like `--output`, `-exec`, or mutating options) are not used.
|
||||
*
|
||||
* @param args - The command and its arguments.
|
||||
* @returns true if the command is strictly read-only and safe.
|
||||
*/
|
||||
function isSafeToCallWithExec(args: string[]): boolean {
|
||||
if (!args || args.length === 0) return false;
|
||||
const cmd = args[0];
|
||||
|
||||
const safeCommands = new Set([
|
||||
'cat',
|
||||
'cd',
|
||||
'cut',
|
||||
'echo',
|
||||
'expr',
|
||||
'false',
|
||||
'grep',
|
||||
'head',
|
||||
'id',
|
||||
'ls',
|
||||
'nl',
|
||||
'paste',
|
||||
'pwd',
|
||||
'rev',
|
||||
'seq',
|
||||
'stat',
|
||||
'tail',
|
||||
'tr',
|
||||
'true',
|
||||
'uname',
|
||||
'uniq',
|
||||
'wc',
|
||||
'which',
|
||||
'whoami',
|
||||
'numfmt',
|
||||
'tac',
|
||||
]);
|
||||
|
||||
if (safeCommands.has(cmd)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cmd === 'base64') {
|
||||
const unsafeOptions = new Set(['-o', '--output']);
|
||||
return !args
|
||||
.slice(1)
|
||||
.some(
|
||||
(arg) =>
|
||||
unsafeOptions.has(arg) ||
|
||||
arg.startsWith('--output=') ||
|
||||
(arg.startsWith('-o') && arg !== '-o'),
|
||||
);
|
||||
}
|
||||
|
||||
if (cmd === 'find') {
|
||||
const unsafeOptions = new Set([
|
||||
'-exec',
|
||||
'-execdir',
|
||||
'-ok',
|
||||
'-okdir',
|
||||
'-delete',
|
||||
'-fls',
|
||||
'-fprint',
|
||||
'-fprint0',
|
||||
'-fprintf',
|
||||
]);
|
||||
return !args.some((arg) => unsafeOptions.has(arg));
|
||||
}
|
||||
|
||||
if (cmd === 'rg') {
|
||||
const unsafeWithArgs = new Set(['--pre', '--hostname-bin']);
|
||||
const unsafeWithoutArgs = new Set(['--search-zip', '-z']);
|
||||
|
||||
return !args.some((arg) => {
|
||||
if (unsafeWithoutArgs.has(arg)) return true;
|
||||
for (const opt of unsafeWithArgs) {
|
||||
if (arg === opt || arg.startsWith(opt + '=')) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (cmd === 'git') {
|
||||
if (gitHasConfigOverrideGlobalOption(args)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { idx, subcommand } = findGitSubcommand(args, [
|
||||
'status',
|
||||
'log',
|
||||
'diff',
|
||||
'show',
|
||||
'branch',
|
||||
]);
|
||||
if (!subcommand) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subcommandArgs = args.slice(idx + 1);
|
||||
|
||||
if (['status', 'log', 'diff', 'show'].includes(subcommand)) {
|
||||
return gitSubcommandArgsAreReadOnly(subcommandArgs);
|
||||
}
|
||||
|
||||
if (subcommand === 'branch') {
|
||||
return (
|
||||
gitSubcommandArgsAreReadOnly(subcommandArgs) &&
|
||||
gitBranchIsReadOnly(subcommandArgs)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cmd === 'sed') {
|
||||
// Special-case sed -n {N|M,N}p
|
||||
if (args.length <= 4 && args[1] === '-n' && isValidSedNArg(args[2])) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to identify which git subcommand is being executed, skipping over
|
||||
* global git options like `-c` or `--git-dir`.
|
||||
*
|
||||
* @param args - The full git command arguments.
|
||||
* @param subcommands - A list of subcommands to look for.
|
||||
* @returns An object containing the index of the subcommand and its name.
|
||||
*/
|
||||
function findGitSubcommand(
|
||||
args: string[],
|
||||
subcommands: string[],
|
||||
): { idx: number; subcommand: string | null } {
|
||||
let skipNext = false;
|
||||
|
||||
for (let idx = 1; idx < args.length; idx++) {
|
||||
if (skipNext) {
|
||||
skipNext = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const arg = args[idx];
|
||||
|
||||
if (
|
||||
arg.startsWith('--config-env=') ||
|
||||
arg.startsWith('--exec-path=') ||
|
||||
arg.startsWith('--git-dir=') ||
|
||||
arg.startsWith('--namespace=') ||
|
||||
arg.startsWith('--super-prefix=') ||
|
||||
arg.startsWith('--work-tree=') ||
|
||||
((arg.startsWith('-C') || arg.startsWith('-c')) && arg.length > 2)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
arg === '-C' ||
|
||||
arg === '-c' ||
|
||||
arg === '--config-env' ||
|
||||
arg === '--exec-path' ||
|
||||
arg === '--git-dir' ||
|
||||
arg === '--namespace' ||
|
||||
arg === '--super-prefix' ||
|
||||
arg === '--work-tree'
|
||||
) {
|
||||
skipNext = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--' || arg.startsWith('-')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (subcommands.includes(arg)) {
|
||||
return { idx, subcommand: arg };
|
||||
}
|
||||
|
||||
return { idx: -1, subcommand: null };
|
||||
}
|
||||
|
||||
return { idx: -1, subcommand: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a git command contains global configuration override flags
|
||||
* (e.g., `-c` or `--config-env`) which could be used maliciously to
|
||||
* execute arbitrary code via git config.
|
||||
*
|
||||
* @param args - The git command arguments.
|
||||
* @returns true if config overrides are present.
|
||||
*/
|
||||
function gitHasConfigOverrideGlobalOption(args: string[]): boolean {
|
||||
return args.some(
|
||||
(arg) =>
|
||||
arg === '-c' ||
|
||||
arg === '--config-env' ||
|
||||
(arg.startsWith('-c') && arg.length > 2) ||
|
||||
arg.startsWith('--config-env='),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the arguments for safe git subcommands (like `status`, `log`,
|
||||
* `diff`, `show`) do not contain flags that could cause mutations or execute
|
||||
* arbitrary commands (e.g., `--output`, `--exec`).
|
||||
*
|
||||
* @param args - Arguments passed to the git subcommand.
|
||||
* @returns true if the arguments only represent read-only operations.
|
||||
*/
|
||||
function gitSubcommandArgsAreReadOnly(args: string[]): boolean {
|
||||
const unsafeFlags = new Set([
|
||||
'--output',
|
||||
'--ext-diff',
|
||||
'--textconv',
|
||||
'--exec',
|
||||
'--paginate',
|
||||
]);
|
||||
|
||||
return !args.some(
|
||||
(arg) =>
|
||||
unsafeFlags.has(arg) ||
|
||||
arg.startsWith('--output=') ||
|
||||
arg.startsWith('--exec='),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that `git branch` is only used for read operations
|
||||
* (e.g., listing branches) rather than creating, deleting, or renaming branches.
|
||||
*
|
||||
* @param args - Arguments passed to `git branch`.
|
||||
* @returns true if it's purely a listing/read-only branch command.
|
||||
*/
|
||||
function gitBranchIsReadOnly(args: string[]): boolean {
|
||||
if (args.length === 0) return true;
|
||||
|
||||
let sawReadOnlyFlag = false;
|
||||
for (const arg of args) {
|
||||
if (
|
||||
[
|
||||
'--list',
|
||||
'-l',
|
||||
'--show-current',
|
||||
'-a',
|
||||
'--all',
|
||||
'-r',
|
||||
'--remotes',
|
||||
'-v',
|
||||
'-vv',
|
||||
'--verbose',
|
||||
].includes(arg)
|
||||
) {
|
||||
sawReadOnlyFlag = true;
|
||||
} else if (arg.startsWith('--format=')) {
|
||||
sawReadOnlyFlag = true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return sawReadOnlyFlag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that a `sed` command argument is a valid line-printing instruction
|
||||
* (e.g., `10p` or `5,10p`), preventing unsafe script execution in `sed`.
|
||||
*
|
||||
* @param arg - The script argument passed to `sed -n`.
|
||||
* @returns true if it's a valid, safe print command.
|
||||
*/
|
||||
function isValidSedNArg(arg: string | undefined): boolean {
|
||||
if (!arg) return false;
|
||||
|
||||
if (!arg.endsWith('p')) return false;
|
||||
const core = arg.slice(0, -1);
|
||||
|
||||
const parts = core.split(',');
|
||||
if (parts.length === 1) {
|
||||
const num = parts[0];
|
||||
return num.length > 0 && /^\d+$/.test(num);
|
||||
} else if (parts.length === 2) {
|
||||
const a = parts[0];
|
||||
const b = parts[1];
|
||||
return a.length > 0 && b.length > 0 && /^\d+$/.test(a) && /^\d+$/.test(b);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a command with its arguments is explicitly known to be dangerous
|
||||
* and should be blocked or require strict user confirmation. This catches
|
||||
* destructive commands like `rm -rf`, `sudo`, and commands with execution
|
||||
* flags like `find -exec`.
|
||||
*
|
||||
* @param args - The command and its arguments.
|
||||
* @returns true if the command is identified as dangerous, false otherwise.
|
||||
*/
|
||||
export function isDangerousCommand(args: string[]): boolean {
|
||||
if (!args || args.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cmd = args[0];
|
||||
|
||||
if (cmd === 'rm') {
|
||||
return args[1] === '-f' || args[1] === '-rf' || args[1] === '-fr';
|
||||
}
|
||||
|
||||
if (cmd === 'sudo') {
|
||||
return isDangerousCommand(args.slice(1));
|
||||
}
|
||||
|
||||
if (cmd === 'find') {
|
||||
const unsafeOptions = new Set([
|
||||
'-exec',
|
||||
'-execdir',
|
||||
'-ok',
|
||||
'-okdir',
|
||||
'-delete',
|
||||
'-fls',
|
||||
'-fprint',
|
||||
'-fprint0',
|
||||
'-fprintf',
|
||||
]);
|
||||
return args.some((arg) => unsafeOptions.has(arg));
|
||||
}
|
||||
|
||||
if (cmd === 'rg') {
|
||||
const unsafeWithArgs = new Set(['--pre', '--hostname-bin']);
|
||||
const unsafeWithoutArgs = new Set(['--search-zip', '-z']);
|
||||
|
||||
return args.some((arg) => {
|
||||
if (unsafeWithoutArgs.has(arg)) return true;
|
||||
for (const opt of unsafeWithArgs) {
|
||||
if (arg === opt || arg.startsWith(opt + '=')) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (cmd === 'git') {
|
||||
if (gitHasConfigOverrideGlobalOption(args)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { idx, subcommand } = findGitSubcommand(args, [
|
||||
'status',
|
||||
'log',
|
||||
'diff',
|
||||
'show',
|
||||
'branch',
|
||||
]);
|
||||
if (!subcommand) {
|
||||
// It's a git command we don't recognize as explicitly safe.
|
||||
return false;
|
||||
}
|
||||
|
||||
const subcommandArgs = args.slice(idx + 1);
|
||||
|
||||
if (['status', 'log', 'diff', 'show'].includes(subcommand)) {
|
||||
return !gitSubcommandArgsAreReadOnly(subcommandArgs);
|
||||
}
|
||||
|
||||
if (subcommand === 'branch') {
|
||||
return !(
|
||||
gitSubcommandArgsAreReadOnly(subcommandArgs) &&
|
||||
gitBranchIsReadOnly(subcommandArgs)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cmd === 'base64') {
|
||||
const unsafeOptions = new Set(['-o', '--output']);
|
||||
return args
|
||||
.slice(1)
|
||||
.some(
|
||||
(arg) =>
|
||||
unsafeOptions.has(arg) ||
|
||||
arg.startsWith('--output=') ||
|
||||
(arg.startsWith('-o') && arg !== '-o'),
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type SandboxRequest } from '../../services/sandboxManager.js';
|
||||
import {
|
||||
getCommandRoots,
|
||||
initializeShellParsers,
|
||||
splitCommands,
|
||||
stripShellWrapper,
|
||||
} from '../../utils/shell-utils.js';
|
||||
import { isKnownSafeCommand } from './commandSafety.js';
|
||||
import { parse as shellParse } from 'shell-quote';
|
||||
import path from 'node:path';
|
||||
|
||||
export async function isStrictlyApproved(
|
||||
req: SandboxRequest,
|
||||
approvedTools?: string[],
|
||||
): Promise<boolean> {
|
||||
if (!approvedTools || approvedTools.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await initializeShellParsers();
|
||||
|
||||
const fullCmd = [req.command, ...req.args].join(' ');
|
||||
const stripped = stripShellWrapper(fullCmd);
|
||||
|
||||
const roots = getCommandRoots(stripped);
|
||||
if (roots.length === 0) return false;
|
||||
|
||||
const allRootsApproved = roots.every((root) => approvedTools.includes(root));
|
||||
if (allRootsApproved) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pipelineCommands = splitCommands(stripped);
|
||||
if (pipelineCommands.length === 0) return false;
|
||||
|
||||
for (const cmdString of pipelineCommands) {
|
||||
const parsedArgs = shellParse(cmdString).map(String);
|
||||
if (!isKnownSafeCommand(parsedArgs)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getCommandName(req: SandboxRequest): Promise<string> {
|
||||
await initializeShellParsers();
|
||||
const fullCmd = [req.command, ...req.args].join(' ');
|
||||
const stripped = stripShellWrapper(fullCmd);
|
||||
const roots = getCommandRoots(stripped).filter(
|
||||
(r) => r !== 'shopt' && r !== 'set',
|
||||
);
|
||||
if (roots.length > 0) {
|
||||
return roots[0];
|
||||
}
|
||||
return path.basename(req.command);
|
||||
}
|
||||
|
||||
export function verifySandboxOverrides(
|
||||
allowOverrides: boolean,
|
||||
policy: SandboxRequest['policy'],
|
||||
) {
|
||||
if (!allowOverrides) {
|
||||
if (
|
||||
policy?.networkAccess ||
|
||||
policy?.allowedPaths?.length ||
|
||||
policy?.additionalPermissions?.network ||
|
||||
policy?.additionalPermissions?.fileSystem?.read?.length ||
|
||||
policy?.additionalPermissions?.fileSystem?.write?.length
|
||||
) {
|
||||
throw new Error(
|
||||
'Sandbox request rejected: Cannot override readonly/network/filesystem restrictions in Plan mode.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export function isErrnoException(e: unknown): e is NodeJS.ErrnoException {
|
||||
return e instanceof Error && 'code' in e;
|
||||
}
|
||||
|
||||
export function tryRealpath(p: string): string {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch (_e) {
|
||||
if (isErrnoException(_e) && _e.code === 'ENOENT') {
|
||||
const parentDir = path.dirname(p);
|
||||
if (parentDir === p) {
|
||||
return p;
|
||||
}
|
||||
return path.join(tryRealpath(parentDir), path.basename(p));
|
||||
}
|
||||
throw _e;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGitWorktreePaths(workspacePath: string): {
|
||||
worktreeGitDir?: string;
|
||||
mainGitDir?: string;
|
||||
} {
|
||||
try {
|
||||
const gitPath = path.join(workspacePath, '.git');
|
||||
const gitStat = fs.lstatSync(gitPath);
|
||||
if (gitStat.isFile()) {
|
||||
const gitContent = fs.readFileSync(gitPath, 'utf8');
|
||||
const match = gitContent.match(/^gitdir:\s+(.+)$/m);
|
||||
if (match && match[1]) {
|
||||
let worktreeGitDir = match[1].trim();
|
||||
if (!path.isAbsolute(worktreeGitDir)) {
|
||||
worktreeGitDir = path.resolve(workspacePath, worktreeGitDir);
|
||||
}
|
||||
const resolvedWorktreeGitDir = tryRealpath(worktreeGitDir);
|
||||
|
||||
// Security check: Verify the bidirectional link to prevent sandbox escape
|
||||
let isValid = false;
|
||||
try {
|
||||
const backlinkPath = path.join(resolvedWorktreeGitDir, 'gitdir');
|
||||
const backlink = fs.readFileSync(backlinkPath, 'utf8').trim();
|
||||
// The backlink must resolve to the workspace's .git file
|
||||
if (tryRealpath(backlink) === tryRealpath(gitPath)) {
|
||||
isValid = true;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback for submodules: check core.worktree in config
|
||||
try {
|
||||
const configPath = path.join(resolvedWorktreeGitDir, 'config');
|
||||
const config = fs.readFileSync(configPath, 'utf8');
|
||||
const match = config.match(/^\s*worktree\s*=\s*(.+)$/m);
|
||||
if (match && match[1]) {
|
||||
const worktreePath = path.resolve(
|
||||
resolvedWorktreeGitDir,
|
||||
match[1].trim(),
|
||||
);
|
||||
if (tryRealpath(worktreePath) === tryRealpath(workspacePath)) {
|
||||
isValid = true;
|
||||
}
|
||||
}
|
||||
} catch (_e2) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
return {}; // Reject: valid worktrees/submodules must have a readable backlink
|
||||
}
|
||||
|
||||
const mainGitDir = tryRealpath(
|
||||
path.dirname(path.dirname(resolvedWorktreeGitDir)),
|
||||
);
|
||||
return {
|
||||
worktreeGitDir: resolvedWorktreeGitDir,
|
||||
mainGitDir: mainGitDir.endsWith('.git') ? mainGitDir : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore if .git doesn't exist, isn't readable, etc.
|
||||
}
|
||||
return {};
|
||||
}
|
||||
Reference in New Issue
Block a user