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
+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)) {