feat(memory): add Auto Memory inbox flow with canonical-patch contract (#26338)

This commit is contained in:
Sandy Tao
2026-05-04 12:07:13 -07:00
committed by GitHub
parent 60a6a47d56
commit a7beb890d0
26 changed files with 4279 additions and 115 deletions
+166
View File
@@ -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', () => {
+100 -1
View File
@@ -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)) {
+39
View File
@@ -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.
-4
View File
@@ -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');
}