/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as Diff from 'diff'; import type { StructuredPatch } from 'diff'; import type { Config } from '../config/config.js'; import { Storage } from '../config/storage.js'; import { getGlobalMemoryFilePath, PROJECT_MEMORY_INDEX_FILENAME, } from '../tools/memoryTool.js'; import { isNodeError } from '../utils/errors.js'; import { debugLogger } from '../utils/debugLogger.js'; import { isSubpath } from '../utils/paths.js'; export function getAllowedSkillPatchRoots(config: Config): string[] { return Array.from( new Set( [Storage.getUserSkillsDir(), config.storage.getProjectSkillsDir()].map( (root) => path.resolve(root), ), ), ); } async function resolvePathWithExistingAncestors( targetPath: string, ): Promise { const missingSegments: string[] = []; let currentPath = path.resolve(targetPath); while (true) { try { const realCurrentPath = await fs.realpath(currentPath); return path.join(realCurrentPath, ...missingSegments.reverse()); } catch (error) { if ( !isNodeError(error) || (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') ) { return undefined; } const parentPath = path.dirname(currentPath); if (parentPath === currentPath) { return undefined; } missingSegments.push(path.basename(currentPath)); currentPath = parentPath; } } } async function getCanonicalAllowedSkillPatchRoots( config: Config, ): Promise { const canonicalRoots = await Promise.all( getAllowedSkillPatchRoots(config).map((root) => resolvePathWithExistingAncestors(root), ), ); return Array.from( new Set( canonicalRoots.filter((root): root is string => typeof root === 'string'), ), ); } export async function resolveAllowedSkillPatchTarget( targetPath: string, config: Config, ): Promise { const canonicalTargetPath = await resolvePathWithExistingAncestors(targetPath); if (!canonicalTargetPath) { return undefined; } const allowedRoots = await getCanonicalAllowedSkillPatchRoots(config); if (allowedRoots.some((root) => isSubpath(root, canonicalTargetPath))) { return canonicalTargetPath; } return undefined; } export async function isAllowedSkillPatchTarget( targetPath: string, config: Config, ): Promise { return ( (await resolveAllowedSkillPatchTarget(targetPath, config)) !== undefined ); } function isAbsoluteSkillPatchPath(targetPath: string): boolean { return targetPath !== '/dev/null' && path.isAbsolute(targetPath); } const GIT_DIFF_PREFIX_RE = /^[ab]\//; /** * Strips git-style `a/` or `b/` prefixes from a patch filename. * Logs a warning when stripping occurs so we can track LLM formatting issues. */ function stripGitDiffPrefix(fileName: string): string { if (GIT_DIFF_PREFIX_RE.test(fileName)) { const stripped = fileName.replace(GIT_DIFF_PREFIX_RE, ''); debugLogger.warn( `[memoryPatchUtils] Stripped git diff prefix from patch header: "${fileName}" → "${stripped}"`, ); return stripped; } return fileName; } interface ValidatedSkillPatchHeader { targetPath: string; isNewFile: boolean; } type ValidateParsedSkillPatchHeadersResult = | { success: true; patches: ValidatedSkillPatchHeader[]; } | { success: false; reason: 'missingTargetPath' | 'invalidPatchHeaders'; targetPath?: string; }; export function validateParsedSkillPatchHeaders( parsedPatches: StructuredPatch[], ): ValidateParsedSkillPatchHeadersResult { const validatedPatches: ValidatedSkillPatchHeader[] = []; for (const patch of parsedPatches) { const oldFileName = patch.oldFileName ? stripGitDiffPrefix(patch.oldFileName) : patch.oldFileName; const newFileName = patch.newFileName ? stripGitDiffPrefix(patch.newFileName) : patch.newFileName; if (!oldFileName || !newFileName) { return { success: false, reason: 'missingTargetPath', }; } if (oldFileName === '/dev/null') { if (!isAbsoluteSkillPatchPath(newFileName)) { return { success: false, reason: 'invalidPatchHeaders', targetPath: newFileName, }; } validatedPatches.push({ targetPath: newFileName, isNewFile: true, }); continue; } if ( !isAbsoluteSkillPatchPath(oldFileName) || !isAbsoluteSkillPatchPath(newFileName) || oldFileName !== newFileName ) { return { success: false, reason: 'invalidPatchHeaders', targetPath: newFileName, }; } validatedPatches.push({ targetPath: newFileName, isNewFile: false, }); } return { success: true, patches: validatedPatches, }; } export async function isProjectSkillPatchTarget( targetPath: string, config: Config, ): Promise { const canonicalTargetPath = await resolvePathWithExistingAncestors(targetPath); if (!canonicalTargetPath) { return false; } const canonicalProjectSkillsDir = await resolvePathWithExistingAncestors( config.storage.getProjectSkillsDir(), ); if (!canonicalProjectSkillsDir) { return false; } return isSubpath(canonicalProjectSkillsDir, canonicalTargetPath); } export function hasParsedPatchHunks(parsedPatches: StructuredPatch[]): boolean { return ( parsedPatches.length > 0 && parsedPatches.every((patch) => patch.hunks.length > 0) ); } export type InboxMemoryPatchKind = 'private' | 'global'; export function getMemoryPatchRoot( memoryDir: string, kind: InboxMemoryPatchKind, ): string { return path.join(memoryDir, '.inbox', kind); } function isSubpathOrSame(childPath: string, parentPath: string): boolean { return isSubpath(parentPath, childPath); } export function normalizeInboxMemoryPatchPath( relativePath: string, ): string | undefined { if ( relativePath.length === 0 || path.isAbsolute(relativePath) || relativePath.includes('\\') ) { return undefined; } const normalizedPath = path.posix.normalize(relativePath); if ( normalizedPath === '.' || normalizedPath.startsWith('../') || normalizedPath === '..' || !normalizedPath.endsWith('.patch') ) { return undefined; } return normalizedPath; } /** * Returns coarse directory roots (or single-file roots) used for canonical * containment checks before the kind-specific target validator runs. * * - `private` is rooted at the project memory directory, then narrowed to * direct memory markdown documents by `isAllowedPrivateMemoryDocumentPath`. * - `global` is intentionally a single-file allowlist: the only writeable * global file is the personal `~/.gemini/GEMINI.md`. Other files under * `~/.gemini/` (settings, credentials, oauth, keybindings, etc.) are off-limits. */ export function getAllowedMemoryPatchRoots( config: Config, kind: InboxMemoryPatchKind, ): string[] { switch (kind) { case 'private': return [path.resolve(config.storage.getProjectMemoryTempDir())]; case 'global': return [path.resolve(getGlobalMemoryFilePath())]; default: throw new Error(`Unknown memory patch kind: ${kind as string}`); } } export interface MemoryPatchTargetValidationContext { kind: InboxMemoryPatchKind; allowedRoots: string[]; privateMemoryDirs: string[]; globalMemoryFiles: string[]; } function hasMarkdownExtension(fileName: string): boolean { return fileName.toLowerCase().endsWith('.md'); } function isAllowedPrivateMemoryFileName(fileName: string): boolean { if (fileName === PROJECT_MEMORY_INDEX_FILENAME) { return true; } return !fileName.startsWith('.') && hasMarkdownExtension(fileName); } function uniqueResolvedPaths(paths: readonly string[]): string[] { return Array.from(new Set(paths.map((filePath) => path.resolve(filePath)))); } function isSamePath(leftPath: string, rightPath: string): boolean { return isSubpath(leftPath, rightPath) && isSubpath(rightPath, leftPath); } function includesSamePath( paths: readonly string[], targetPath: string, ): boolean { return paths.some((candidate) => isSamePath(candidate, targetPath)); } function isAllowedPrivateMemoryDocumentPath( targetPath: string, memoryDirs: readonly string[], ): boolean { const resolvedTargetPath = path.resolve(targetPath); const targetDir = path.dirname(resolvedTargetPath); if (!includesSamePath(memoryDirs, targetDir)) { return false; } return isAllowedPrivateMemoryFileName(path.basename(resolvedTargetPath)); } function isAllowedGlobalMemoryDocumentPath( targetPath: string, globalMemoryFiles: readonly string[], ): boolean { const resolvedTargetPath = path.resolve(targetPath); return includesSamePath(globalMemoryFiles, resolvedTargetPath); } export async function getMemoryPatchTargetValidationContext( config: Config, kind: InboxMemoryPatchKind, ): Promise { const allowedRoots = await canonicalizeAllowedPatchRoots( getAllowedMemoryPatchRoots(config, kind), ); if (kind === 'global') { const rawGlobalMemoryFile = path.resolve(getGlobalMemoryFilePath()); const canonicalGlobalMemoryFiles = await canonicalizeAllowedPatchRoots([ rawGlobalMemoryFile, ]); return { kind, allowedRoots, privateMemoryDirs: [], globalMemoryFiles: uniqueResolvedPaths([ rawGlobalMemoryFile, ...canonicalGlobalMemoryFiles, ]), }; } const rawPrivateMemoryDir = path.resolve( config.storage.getProjectMemoryTempDir(), ); const canonicalPrivateMemoryDirs = await canonicalizeAllowedPatchRoots([ rawPrivateMemoryDir, ]); const privateMemoryDirs = uniqueResolvedPaths([ rawPrivateMemoryDir, ...canonicalPrivateMemoryDirs, ]); return { kind, allowedRoots, privateMemoryDirs, globalMemoryFiles: [] }; } export function isResolvedMemoryPatchTargetAllowed( resolvedTargetPath: string, context: MemoryPatchTargetValidationContext, ): boolean { if (context.kind === 'global') { return isAllowedGlobalMemoryDocumentPath( resolvedTargetPath, context.globalMemoryFiles, ); } if (context.kind === 'private') { return isAllowedPrivateMemoryDocumentPath( resolvedTargetPath, context.privateMemoryDirs, ); } return true; } export async function resolveMemoryPatchTargetWithinAllowedSet( targetPath: string, context: MemoryPatchTargetValidationContext, ): Promise { const resolvedTargetPath = await resolveTargetWithinAllowedRoots( targetPath, context.allowedRoots, ); if (!resolvedTargetPath) { return undefined; } if ( context.kind === 'private' && (!isAllowedPrivateMemoryDocumentPath( targetPath, context.privateMemoryDirs, ) || !isAllowedPrivateMemoryDocumentPath( resolvedTargetPath, context.privateMemoryDirs, )) ) { return undefined; } if ( context.kind === 'global' && (!isAllowedGlobalMemoryDocumentPath( targetPath, context.globalMemoryFiles, ) || !isAllowedGlobalMemoryDocumentPath( resolvedTargetPath, context.globalMemoryFiles, )) ) { return undefined; } return resolvedTargetPath; } export async function findDisallowedMemoryPatchTarget( parsedPatches: StructuredPatch[], context: MemoryPatchTargetValidationContext, ): Promise { const validated = validateParsedSkillPatchHeaders(parsedPatches); if (!validated.success) { return undefined; } for (const header of validated.patches) { if ( !(await resolveMemoryPatchTargetWithinAllowedSet( header.targetPath, context, )) ) { return header.targetPath; } } return undefined; } export async function getInboxMemoryPatchSourcePath( config: Config, kind: InboxMemoryPatchKind, relativePath: string, ): Promise { const normalizedPath = normalizeInboxMemoryPatchPath(relativePath); if (!normalizedPath) { return undefined; } const patchRoot = path.resolve( getMemoryPatchRoot(config.storage.getProjectMemoryTempDir(), kind), ); const sourcePath = path.resolve(patchRoot, ...normalizedPath.split('/')); if (!isSubpathOrSame(sourcePath, patchRoot)) { return undefined; } return sourcePath; } /** * Returns the absolute paths of every `.patch` file currently in the kind's * inbox directory (sorted by basename for stable ordering at apply time). * * NOTE: this is a raw filesystem listing — it does NOT validate patch shape * or that targets fall inside the kind's allowed root. Callers that need * "what the user actually sees in the inbox" should use `listValidInboxPatchFiles`. */ export async function listInboxPatchFiles( config: Config, kind: InboxMemoryPatchKind, ): Promise { const patchRoot = getMemoryPatchRoot( config.storage.getProjectMemoryTempDir(), kind, ); const found: string[] = []; async function walk(currentDir: string): Promise { let dirEntries: Array; try { dirEntries = await fs.readdir(currentDir, { withFileTypes: true }); } catch { return; } for (const entry of dirEntries) { const entryPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { await walk(entryPath); continue; } if (entry.isFile() && entry.name.endsWith('.patch')) { found.push(entryPath); } } } await walk(patchRoot); return found.sort(); } export type ValidateInboxMemoryPatchFileResult = | { valid: true } | { valid: false; reason: string }; /** * Checks whether a memory inbox patch passes the same validation as * `/memory inbox`: parseable unified diff, at least one hunk per parsed file, * valid absolute headers, and all targets inside the kind's allowed target set. */ export async function validateInboxMemoryPatchFile( config: Config, kind: InboxMemoryPatchKind, sourcePath: string, ): Promise { let content: string; try { content = await fs.readFile(sourcePath, 'utf-8'); } catch (error) { return { valid: false, reason: `failed to read patch: ${error instanceof Error ? error.message : String(error)}`, }; } let parsed: StructuredPatch[]; try { parsed = Diff.parsePatch(content); } catch (error) { return { valid: false, reason: `failed to parse patch: ${error instanceof Error ? error.message : String(error)}`, }; } if (!hasParsedPatchHunks(parsed)) { return { valid: false, reason: 'no hunks found in patch' }; } const validated = validateParsedSkillPatchHeaders(parsed); if (!validated.success) { switch (validated.reason) { case 'missingTargetPath': return { valid: false, reason: 'missing target file path in patch header', }; case 'invalidPatchHeaders': return { valid: false, reason: `invalid diff headers${validated.targetPath ? `: ${validated.targetPath}` : ''}`, }; default: return { valid: false, reason: 'invalid patch headers' }; } } const validationContext = await getMemoryPatchTargetValidationContext( config, kind, ); for (const header of validated.patches) { if ( !(await resolveMemoryPatchTargetWithinAllowedSet( header.targetPath, validationContext, )) ) { return { valid: false, reason: `target file is outside ${kind} memory roots: ${header.targetPath}`, }; } } return { valid: true }; } /** * Returns only the inbox patch files that pass the same validation as the * inbox listing (parseable, has hunks, valid headers, targets in the kind's * allowed target set). Used by aggregate apply and memory-service notification * counting so the user only ever sees results for patches the inbox actually * surfaced. */ export async function listValidInboxPatchFiles( config: Config, kind: InboxMemoryPatchKind, ): Promise { const patchFiles = await listInboxPatchFiles(config, kind); if (patchFiles.length === 0) { return []; } const valid: string[] = []; for (const sourcePath of patchFiles) { const validation = await validateInboxMemoryPatchFile( config, kind, sourcePath, ); if (validation.valid) { valid.push(sourcePath); } } return valid; } export interface AppliedSkillPatchTarget { targetPath: string; original: string; patched: string; isNewFile: boolean; } export type ApplyParsedSkillPatchesResult = | { success: true; results: AppliedSkillPatchTarget[]; } | { success: false; reason: | 'missingTargetPath' | 'invalidPatchHeaders' | 'outsideAllowedRoots' | 'newFileAlreadyExists' | 'targetNotFound' | 'doesNotApply'; targetPath?: string; isNewFile?: boolean; }; export async function applyParsedSkillPatches( parsedPatches: StructuredPatch[], config: Config, ): Promise { const allowedRoots = await getCanonicalAllowedSkillPatchRoots(config); return applyParsedPatchesWithAllowedRoots(parsedPatches, allowedRoots); } export interface ApplyParsedPatchesWithAllowedRootsOptions { /** * Optional fine-grained allowlist for callers whose allowed root is broader * than their actual target surface. Receives the canonical target path after * root containment has already passed. */ isResolvedTargetAllowed?: (resolvedTargetPath: string) => boolean; } /** * Applies parsed unified diff patches against any caller-supplied set of * allowed root directories. This is the kind-agnostic core used by both the * skill patch flow and the memory patch flow. * * The patch headers must reference absolute paths inside one of the allowed * roots (after canonical resolution) and pass any caller-supplied fine-grained * target predicate. Update patches must reference an existing target; creation * patches (`/dev/null` source) must reference a path that does not yet exist. * * Returns the per-target before/after content so callers can stage commits * and roll back on failure. */ export async function applyParsedPatchesWithAllowedRoots( parsedPatches: StructuredPatch[], allowedRoots: string[], options: ApplyParsedPatchesWithAllowedRootsOptions = {}, ): Promise { const results = new Map(); const patchedContentByTarget = new Map(); const originalContentByTarget = new Map(); const validatedHeaders = validateParsedSkillPatchHeaders(parsedPatches); if (!validatedHeaders.success) { return validatedHeaders; } for (const [index, patch] of parsedPatches.entries()) { const { targetPath, isNewFile } = validatedHeaders.patches[index]; const resolvedTargetPath = await resolveTargetWithinAllowedRoots( targetPath, allowedRoots, ); if ( !resolvedTargetPath || (options.isResolvedTargetAllowed && !options.isResolvedTargetAllowed(resolvedTargetPath)) ) { return { success: false, reason: 'outsideAllowedRoots', targetPath, }; } let source: string; if (patchedContentByTarget.has(resolvedTargetPath)) { source = patchedContentByTarget.get(resolvedTargetPath)!; } else if (isNewFile) { try { await fs.lstat(resolvedTargetPath); return { success: false, reason: 'newFileAlreadyExists', targetPath, isNewFile: true, }; } catch (error) { if ( !isNodeError(error) || (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') ) { return { success: false, reason: 'targetNotFound', targetPath, isNewFile: true, }; } } source = ''; originalContentByTarget.set(resolvedTargetPath, source); } else { try { source = await fs.readFile(resolvedTargetPath, 'utf-8'); originalContentByTarget.set(resolvedTargetPath, source); } catch { return { success: false, reason: 'targetNotFound', targetPath, }; } } const applied = Diff.applyPatch(source, patch); if (applied === false) { return { success: false, reason: 'doesNotApply', targetPath, isNewFile: results.get(resolvedTargetPath)?.isNewFile ?? isNewFile, }; } patchedContentByTarget.set(resolvedTargetPath, applied); results.set(resolvedTargetPath, { targetPath: resolvedTargetPath, original: originalContentByTarget.get(resolvedTargetPath) ?? '', patched: applied, isNewFile: results.get(resolvedTargetPath)?.isNewFile ?? isNewFile, }); } return { success: true, results: Array.from(results.values()), }; } /** * Canonicalizes a caller-supplied allowed root list once so callers can pass * raw `Storage` paths without each call doing realpath traversal. */ export async function canonicalizeAllowedPatchRoots( roots: string[], ): Promise { const canonicalRoots = await Promise.all( roots.map((root) => resolvePathWithExistingAncestors(root)), ); return Array.from( new Set( canonicalRoots.filter((root): root is string => typeof root === 'string'), ), ); } /** * Returns the canonical target path if it falls inside (or exactly equals) * one of the supplied allowed roots, otherwise `undefined`. Allowed roots may * be either directories (subtree allowlist) or single file paths * (single-file allowlist) — `isSubpath(file, file)` returns true for the * same-path case. * * Exported so that `listInboxMemoryPatches` can pre-filter patches whose * headers escape the kind's allowed root, instead of surfacing them in the * UI just to fail at Apply time. */ export async function resolveTargetWithinAllowedRoots( targetPath: string, allowedRoots: string[], ): Promise { const canonicalTargetPath = await resolvePathWithExistingAncestors(targetPath); if (!canonicalTargetPath) { return undefined; } if (allowedRoots.some((root) => isSubpath(root, canonicalTargetPath))) { return canonicalTargetPath; } return undefined; }