mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 21:32:56 -07:00
feat(memory): add Auto Memory inbox flow with canonical-patch contract (#26338)
This commit is contained in:
@@ -72,6 +72,10 @@ import {
|
||||
} from './models.js';
|
||||
import { Storage } from './storage.js';
|
||||
import type { AgentLoopContext } from './agent-loop-context.js';
|
||||
import {
|
||||
runWithScopedAutoMemoryExtractionWriteAccess,
|
||||
runWithScopedMemoryInboxAccess,
|
||||
} from './scoped-config.js';
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
@@ -3656,6 +3660,168 @@ describe('Config JIT Initialization', () => {
|
||||
config.isPathAllowed(path.join(globalDir, 'oauth_creds.json')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT allow isPathAllowed to write into the auto-memory inbox', () => {
|
||||
// <projectMemoryDir>/.inbox/ is owned by the extraction agent and the
|
||||
// /memory inbox review flow. The main agent must not be able to drop
|
||||
// patches in there directly, even though it falls inside <projectTempDir>.
|
||||
// We bypass Config.initialize() (the GitService init path is independently
|
||||
// flaky in this suite) by spying on the storage methods isPathAllowed
|
||||
// actually consults.
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
|
||||
const fakeMemoryTempDir = '/tmp/test-fake-temp/memory';
|
||||
const fakeProjectTempDir = '/tmp/test-fake-temp';
|
||||
vi.spyOn(config.storage, 'getProjectMemoryTempDir').mockReturnValue(
|
||||
fakeMemoryTempDir,
|
||||
);
|
||||
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue(
|
||||
fakeProjectTempDir,
|
||||
);
|
||||
|
||||
const inboxRoot = path.join(fakeMemoryTempDir, '.inbox');
|
||||
|
||||
// The inbox directory itself and any path under it are denied.
|
||||
expect(config.isPathAllowed(inboxRoot)).toBe(false);
|
||||
expect(
|
||||
config.isPathAllowed(path.join(inboxRoot, 'private', 'foo.patch')),
|
||||
).toBe(false);
|
||||
expect(
|
||||
config.isPathAllowed(path.join(inboxRoot, 'global', 'bar.patch')),
|
||||
).toBe(false);
|
||||
|
||||
// Sibling files under <projectMemoryDir> stay reachable so the main
|
||||
// agent can edit MEMORY.md and topic notes directly.
|
||||
expect(
|
||||
config.isPathAllowed(path.join(fakeMemoryTempDir, 'MEMORY.md')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
config.isPathAllowed(path.join(fakeMemoryTempDir, 'some-topic.md')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow scoped extraction access only to canonical inbox patches', () => {
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
|
||||
const fakeMemoryTempDir = '/tmp/test-fake-temp/memory';
|
||||
const fakeProjectTempDir = '/tmp/test-fake-temp';
|
||||
vi.spyOn(config.storage, 'getProjectMemoryTempDir').mockReturnValue(
|
||||
fakeMemoryTempDir,
|
||||
);
|
||||
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue(
|
||||
fakeProjectTempDir,
|
||||
);
|
||||
|
||||
const inboxRoot = path.join(fakeMemoryTempDir, '.inbox');
|
||||
const privateExtractionPatch = path.join(
|
||||
inboxRoot,
|
||||
'private',
|
||||
'extraction.patch',
|
||||
);
|
||||
const globalExtractionPatch = path.join(
|
||||
inboxRoot,
|
||||
'global',
|
||||
'extraction.patch',
|
||||
);
|
||||
|
||||
expect(config.isPathAllowed(privateExtractionPatch)).toBe(false);
|
||||
|
||||
runWithScopedMemoryInboxAccess(() => {
|
||||
expect(config.isPathAllowed(privateExtractionPatch)).toBe(true);
|
||||
expect(config.validatePathAccess(privateExtractionPatch)).toBeNull();
|
||||
expect(config.isPathAllowed(globalExtractionPatch)).toBe(true);
|
||||
expect(
|
||||
config.isPathAllowed(path.join(inboxRoot, 'private', 'other.patch')),
|
||||
).toBe(false);
|
||||
expect(
|
||||
config.isPathAllowed(
|
||||
path.join(inboxRoot, 'private', 'nested', 'extraction.patch'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
expect(config.isPathAllowed(privateExtractionPatch)).toBe(false);
|
||||
});
|
||||
|
||||
it('should restrict scoped auto-memory extraction writes to generated artifacts', () => {
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
|
||||
const fakeMemoryTempDir = '/tmp/test-fake-temp/memory';
|
||||
const fakeProjectTempDir = '/tmp/test-fake-temp';
|
||||
const fakeSkillsMemoryDir = path.join(fakeMemoryTempDir, 'skills');
|
||||
vi.spyOn(config.storage, 'getProjectMemoryTempDir').mockReturnValue(
|
||||
fakeMemoryTempDir,
|
||||
);
|
||||
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue(
|
||||
fakeProjectTempDir,
|
||||
);
|
||||
vi.spyOn(config.storage, 'getProjectSkillsMemoryDir').mockReturnValue(
|
||||
fakeSkillsMemoryDir,
|
||||
);
|
||||
|
||||
const inboxRoot = path.join(fakeMemoryTempDir, '.inbox');
|
||||
const privateExtractionPatch = path.join(
|
||||
inboxRoot,
|
||||
'private',
|
||||
'extraction.patch',
|
||||
);
|
||||
const skillArtifact = path.join(
|
||||
fakeSkillsMemoryDir,
|
||||
'my-skill',
|
||||
'SKILL.md',
|
||||
);
|
||||
const activeMemoryPath = path.join(fakeMemoryTempDir, 'MEMORY.md');
|
||||
const projectTempPath = path.join(fakeProjectTempDir, 'logs', 'run.log');
|
||||
const workspaceMemoryPath = path.join('/tmp/test', 'GEMINI.md');
|
||||
|
||||
expect(config.validatePathAccess(activeMemoryPath)).toBeNull();
|
||||
|
||||
runWithScopedAutoMemoryExtractionWriteAccess(() => {
|
||||
expect(config.validatePathAccess(skillArtifact)).toBeNull();
|
||||
expect(config.validatePathAccess(activeMemoryPath)).toContain(
|
||||
'Auto-memory extraction write denied',
|
||||
);
|
||||
expect(config.validatePathAccess(projectTempPath)).toContain(
|
||||
'Auto-memory extraction write denied',
|
||||
);
|
||||
expect(config.validatePathAccess(workspaceMemoryPath)).toContain(
|
||||
'Auto-memory extraction write denied',
|
||||
);
|
||||
|
||||
// Reads still use the normal workspace/temp allowlists.
|
||||
expect(config.validatePathAccess(activeMemoryPath, 'read')).toBeNull();
|
||||
});
|
||||
|
||||
runWithScopedMemoryInboxAccess(() => {
|
||||
runWithScopedAutoMemoryExtractionWriteAccess(() => {
|
||||
expect(config.validatePathAccess(privateExtractionPatch)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAutoMemoryEnabled', () => {
|
||||
|
||||
@@ -140,7 +140,11 @@ import type { GenerateContentParameters } from '@google/genai';
|
||||
export type { MCPOAuthConfig, AnyToolInvocation, AnyDeclarativeTool };
|
||||
import type { AnyToolInvocation, AnyDeclarativeTool } from '../tools/tools.js';
|
||||
import { WorkspaceContext } from '../utils/workspaceContext.js';
|
||||
import { getWorkspaceContextOverride } from './scoped-config.js';
|
||||
import {
|
||||
getWorkspaceContextOverride,
|
||||
hasScopedAutoMemoryExtractionWriteAccess,
|
||||
hasScopedMemoryInboxAccess,
|
||||
} from './scoped-config.js';
|
||||
import { Storage } from './storage.js';
|
||||
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
||||
import { FileExclusions } from '../utils/ignorePatterns.js';
|
||||
@@ -3063,6 +3067,52 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.ideMode = value;
|
||||
}
|
||||
|
||||
private isScopedMemoryInboxPatchPathAllowed(
|
||||
absolutePath: string,
|
||||
resolvedPath: string,
|
||||
inboxRoot: string,
|
||||
): boolean {
|
||||
if (!hasScopedMemoryInboxAccess()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedPath = path.resolve(absolutePath);
|
||||
const isCanonicalPatchPath = (['private', 'global'] as const).some(
|
||||
(kind) =>
|
||||
normalizedPath === path.resolve(inboxRoot, kind, 'extraction.patch'),
|
||||
);
|
||||
if (!isCanonicalPatchPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedMemoryRoot = resolveToRealPath(
|
||||
this.storage.getProjectMemoryTempDir(),
|
||||
);
|
||||
return isSubpath(resolvedMemoryRoot, resolvedPath);
|
||||
}
|
||||
|
||||
private isScopedAutoMemoryExtractionWritePathAllowed(
|
||||
absolutePath: string,
|
||||
resolvedPath: string,
|
||||
): boolean {
|
||||
if (!hasScopedAutoMemoryExtractionWriteAccess()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedSkillsMemoryDir = resolveToRealPath(
|
||||
this.storage.getProjectSkillsMemoryDir(),
|
||||
);
|
||||
if (isSubpath(resolvedSkillsMemoryDir, resolvedPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.isScopedMemoryInboxPatchPathAllowed(
|
||||
absolutePath,
|
||||
resolvedPath,
|
||||
path.join(this.storage.getProjectMemoryTempDir(), '.inbox'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current FileSystemService
|
||||
*/
|
||||
@@ -3077,12 +3127,48 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
* file (the latter is the only file under `~/.gemini/` that is reachable —
|
||||
* settings, credentials, keybindings, etc. remain disallowed).
|
||||
*
|
||||
* One subtree is *carved back out*: `<projectMemoryDir>/.inbox/` is owned by
|
||||
* the auto-memory extraction agent and the `/memory inbox` review flow. The
|
||||
* main agent is denied access to it even though it falls inside the project
|
||||
* temp dir; the extraction agent receives a narrow execution-scoped exception
|
||||
* for `.inbox/{private,global}/extraction.patch`.
|
||||
*
|
||||
* @param absolutePath The absolute path to check.
|
||||
* @returns true if the path is allowed, false otherwise.
|
||||
*/
|
||||
isPathAllowed(absolutePath: string): boolean {
|
||||
const resolvedPath = resolveToRealPath(absolutePath);
|
||||
|
||||
// The auto-memory inbox (`<projectMemoryDir>/.inbox/`) is owned by the
|
||||
// background extraction agent and the `/memory inbox` review flow. The
|
||||
// main agent must NOT drop files into it directly (that would let the
|
||||
// model bypass review). Deny first, even if the path also satisfies the
|
||||
// workspace or project-temp allowlists below.
|
||||
const inboxRoot = path.join(
|
||||
this.storage.getProjectMemoryTempDir(),
|
||||
'.inbox',
|
||||
);
|
||||
const resolvedInboxRoot = resolveToRealPath(inboxRoot);
|
||||
const normalizedPath = path.resolve(absolutePath);
|
||||
const normalizedInboxRoot = path.resolve(inboxRoot);
|
||||
if (
|
||||
resolvedPath === resolvedInboxRoot ||
|
||||
isSubpath(resolvedInboxRoot, resolvedPath) ||
|
||||
normalizedPath === normalizedInboxRoot ||
|
||||
isSubpath(normalizedInboxRoot, normalizedPath)
|
||||
) {
|
||||
if (
|
||||
this.isScopedMemoryInboxPatchPathAllowed(
|
||||
absolutePath,
|
||||
resolvedPath,
|
||||
inboxRoot,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const workspaceContext = this.getWorkspaceContext();
|
||||
if (workspaceContext.isPathWithinWorkspace(resolvedPath)) {
|
||||
return true;
|
||||
@@ -3122,6 +3208,19 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
absolutePath: string,
|
||||
checkType: 'read' | 'write' = 'write',
|
||||
): string | null {
|
||||
if (checkType === 'write' && hasScopedAutoMemoryExtractionWriteAccess()) {
|
||||
const resolvedPath = resolveToRealPath(absolutePath);
|
||||
if (
|
||||
this.isScopedAutoMemoryExtractionWritePathAllowed(
|
||||
absolutePath,
|
||||
resolvedPath,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return `Auto-memory extraction write denied: Attempted path "${absolutePath}" is outside the extraction write allowlist. Extraction may only write extracted skills under ${this.storage.getProjectSkillsMemoryDir()} and canonical inbox patches under ${path.join(this.storage.getProjectMemoryTempDir(), '.inbox', '{private,global}', 'extraction.patch')}.`;
|
||||
}
|
||||
|
||||
// For read operations, check read-only paths first
|
||||
if (checkType === 'read') {
|
||||
if (this.getWorkspaceContext().isPathReadable(absolutePath)) {
|
||||
|
||||
@@ -19,6 +19,9 @@ import { WorkspaceContext } from '../utils/workspaceContext.js';
|
||||
* This follows the same pattern as `toolCallContext` and `promptIdContext`.
|
||||
*/
|
||||
const workspaceContextOverride = new AsyncLocalStorage<WorkspaceContext>();
|
||||
const memoryInboxAccessOverride = new AsyncLocalStorage<boolean>();
|
||||
const autoMemoryExtractionWriteAccessOverride =
|
||||
new AsyncLocalStorage<boolean>();
|
||||
|
||||
/**
|
||||
* Returns the current workspace context override, if any.
|
||||
@@ -44,6 +47,42 @@ export function runWithScopedWorkspaceContext<T>(
|
||||
return workspaceContextOverride.run(scopedContext, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the current async execution is allowed to access the
|
||||
* canonical auto-memory inbox patch files.
|
||||
*/
|
||||
export function hasScopedMemoryInboxAccess(): boolean {
|
||||
return memoryInboxAccessOverride.getStore() === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a function with access to the canonical auto-memory inbox patch files.
|
||||
* This is intended for the background extraction agent only; the main agent
|
||||
* continues to have the inbox carved out of its normal temp-dir access.
|
||||
*/
|
||||
export function runWithScopedMemoryInboxAccess<T>(fn: () => T): T {
|
||||
return memoryInboxAccessOverride.run(true, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the current async execution is using the narrow
|
||||
* auto-memory extraction write allowlist.
|
||||
*/
|
||||
export function hasScopedAutoMemoryExtractionWriteAccess(): boolean {
|
||||
return autoMemoryExtractionWriteAccessOverride.getStore() === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a function with the auto-memory extraction write allowlist active.
|
||||
* This prevents the background extractor from writing active memory files
|
||||
* directly; it may only write extracted skills and canonical inbox patches.
|
||||
*/
|
||||
export function runWithScopedAutoMemoryExtractionWriteAccess<T>(
|
||||
fn: () => T,
|
||||
): T {
|
||||
return autoMemoryExtractionWriteAccessOverride.run(true, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link WorkspaceContext} that extends a parent's directories
|
||||
* with additional ones.
|
||||
|
||||
@@ -106,10 +106,6 @@ export class Storage {
|
||||
return path.join(Storage.getGlobalAgentsDir(), 'skills');
|
||||
}
|
||||
|
||||
static getGlobalMemoryFilePath(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'memory.md');
|
||||
}
|
||||
|
||||
static getUserPoliciesDir(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'policies');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user