diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts
index a35dc580b7..f8ddef3689 100644
--- a/packages/core/src/agents/local-executor.test.ts
+++ b/packages/core/src/agents/local-executor.test.ts
@@ -4199,6 +4199,49 @@ describe('LocalAgentExecutor', () => {
expect(memoryPart).toBeDefined();
expect(memoryPart?.text).toContain(mockMemory);
});
+
+ it('should omit extension context from session memory when disabled by the agent', async () => {
+ const definition = createTestDefinition();
+ definition.includeExtensionContext = false;
+ const executor = await LocalAgentExecutor.create(
+ definition,
+ mockConfig,
+ onActivity,
+ );
+
+ const getSessionMemorySpy = vi
+ .spyOn(mockConfig, 'getSessionMemory')
+ .mockImplementation(
+ (options?: { includeExtensionContext?: boolean }) =>
+ options?.includeExtensionContext === false
+ ? '\n\nProject memory rule\n\n'
+ : '\n\nExtension memory rule\n\n\nProject memory rule\n\n',
+ );
+ vi.spyOn(mockConfig, 'isJitContextEnabled').mockReturnValue(true);
+
+ mockModelResponse([
+ {
+ name: COMPLETE_TASK_TOOL_NAME,
+ args: { finalResult: 'done' },
+ id: 'call1',
+ },
+ ]);
+
+ await executor.run({ goal: 'test' }, signal);
+
+ expect(getSessionMemorySpy).toHaveBeenCalledWith({
+ includeExtensionContext: false,
+ });
+ const { message } = getMockMessageParams(0);
+ const parts = message as Part[];
+ const memoryPart = parts.find((p) =>
+ p.text?.includes(''),
+ );
+
+ expect(memoryPart?.text).toContain('Project memory rule');
+ expect(memoryPart?.text).not.toContain('');
+ expect(memoryPart?.text).not.toContain('Extension memory rule');
+ });
});
});
});
diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts
index 8780325ab8..4630ced5e5 100644
--- a/packages/core/src/agents/local-executor.ts
+++ b/packages/core/src/agents/local-executor.ts
@@ -640,10 +640,19 @@ export class LocalAgentExecutor {
);
const formattedInitialHints = formatUserHintsForModel(initialHints);
- // Inject loaded memory files (JIT + extension/project memory)
- const environmentMemory = this.context.config.isJitContextEnabled?.()
- ? this.context.config.getSessionMemory()
- : this.context.config.getEnvironmentMemory();
+ // Inject loaded memory files. Some background agents opt out of
+ // extension memory while still retaining project session context.
+ let environmentMemory: string;
+ if (this.context.config.isJitContextEnabled?.()) {
+ environmentMemory =
+ this.definition.includeExtensionContext === false
+ ? this.context.config.getSessionMemory({
+ includeExtensionContext: false,
+ })
+ : this.context.config.getSessionMemory();
+ } else {
+ environmentMemory = this.context.config.getEnvironmentMemory();
+ }
const initialParts: Part[] = [];
if (environmentMemory) {
diff --git a/packages/core/src/agents/skill-extraction-agent.test.ts b/packages/core/src/agents/skill-extraction-agent.test.ts
index 7e5251d053..fa9fc81caa 100644
--- a/packages/core/src/agents/skill-extraction-agent.test.ts
+++ b/packages/core/src/agents/skill-extraction-agent.test.ts
@@ -37,6 +37,7 @@ describe('SkillExtractionAgent', () => {
expect(agent.modelConfig.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
expect(agent.memoryInboxAccess).toBe(true);
expect(agent.autoMemoryExtractionWriteAccess).toBe(true);
+ expect(agent.includeExtensionContext).toBe(false);
expect(agent.toolConfig?.tools).toEqual(
expect.arrayContaining([
READ_FILE_TOOL_NAME,
diff --git a/packages/core/src/agents/skill-extraction-agent.ts b/packages/core/src/agents/skill-extraction-agent.ts
index b84a46ba17..943626500a 100644
--- a/packages/core/src/agents/skill-extraction-agent.ts
+++ b/packages/core/src/agents/skill-extraction-agent.ts
@@ -415,6 +415,7 @@ export const SkillExtractionAgent = (
},
memoryInboxAccess: true,
autoMemoryExtractionWriteAccess: true,
+ includeExtensionContext: false,
toolConfig: {
tools: [
ACTIVATE_SKILL_TOOL_NAME,
diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts
index bfca8b81d6..86c9bec63b 100644
--- a/packages/core/src/agents/types.ts
+++ b/packages/core/src/agents/types.ts
@@ -244,6 +244,12 @@ export interface LocalAgentDefinition<
*/
autoMemoryExtractionWriteAccess?: boolean;
+ /**
+ * Controls whether extension memory is injected into this agent's initial
+ * session context when JIT context is enabled. Defaults to true.
+ */
+ includeExtensionContext?: boolean;
+
/**
* Optional inline MCP servers for this agent.
*/
diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts
index 440cde681b..6696541107 100644
--- a/packages/core/src/config/config.test.ts
+++ b/packages/core/src/config/config.test.ts
@@ -3525,6 +3525,16 @@ describe('Config JIT Initialization', () => {
expect(sessionMemory).toContain('');
expect(sessionMemory).toContain('');
+ const sessionMemoryWithoutExtension = config.getSessionMemory({
+ includeExtensionContext: false,
+ });
+ expect(sessionMemoryWithoutExtension).toContain('');
+ expect(sessionMemoryWithoutExtension).not.toContain('');
+ expect(sessionMemoryWithoutExtension).not.toContain('Extension Memory');
+ expect(sessionMemoryWithoutExtension).toContain('');
+ expect(sessionMemoryWithoutExtension).toContain('Environment Memory');
+ expect(sessionMemoryWithoutExtension).toContain('');
+
// Verify state update (delegated to MemoryContextManager)
expect(config.getGeminiMdFileCount()).toBe(1);
expect(config.getGeminiMdFilePaths()).toEqual(['/path/to/GEMINI.md']);
@@ -3746,6 +3756,8 @@ describe('Config JIT Initialization', () => {
expect(config.isPathAllowed(privateExtractionPatch)).toBe(true);
expect(config.validatePathAccess(privateExtractionPatch)).toBeNull();
expect(config.isPathAllowed(globalExtractionPatch)).toBe(true);
+ // Writes (the default checkType for isPathAllowed) remain restricted
+ // to the canonical extraction.patch filenames.
expect(
config.isPathAllowed(path.join(inboxRoot, 'private', 'other.patch')),
).toBe(false);
@@ -3754,9 +3766,49 @@ describe('Config JIT Initialization', () => {
path.join(inboxRoot, 'private', 'nested', 'extraction.patch'),
),
).toBe(false);
+
+ // Reads are broadened to the .inbox/{private,global}/ subtree so the
+ // extractor can list and inspect prior patches before consolidating.
+ const privateOtherPatch = path.join(
+ inboxRoot,
+ 'private',
+ 'other.patch',
+ );
+ const globalLeftover = path.join(inboxRoot, 'global', 'topic-a.patch');
+ const nestedReadPath = path.join(
+ inboxRoot,
+ 'private',
+ 'nested',
+ 'extraction.patch',
+ );
+ expect(config.validatePathAccess(privateOtherPatch, 'read')).toBeNull();
+ expect(config.validatePathAccess(globalLeftover, 'read')).toBeNull();
+ expect(config.validatePathAccess(nestedReadPath, 'read')).toBeNull();
+ expect(config.validatePathAccess(inboxRoot, 'read')).toBeNull();
+ expect(
+ config.validatePathAccess(path.join(inboxRoot, 'private'), 'read'),
+ ).toBeNull();
+ expect(
+ config.validatePathAccess(path.join(inboxRoot, 'global'), 'read'),
+ ).toBeNull();
+
+ // Writes to the same broadened paths are still rejected.
+ expect(config.validatePathAccess(privateOtherPatch)).toContain(
+ 'Path not in workspace',
+ );
+ expect(config.validatePathAccess(nestedReadPath)).toContain(
+ 'Path not in workspace',
+ );
});
expect(config.isPathAllowed(privateExtractionPatch)).toBe(false);
+ // Outside the scope, reads of inbox files are denied again.
+ expect(
+ config.validatePathAccess(
+ path.join(inboxRoot, 'private', 'other.patch'),
+ 'read',
+ ),
+ ).toContain('Path not in workspace');
});
it('should restrict scoped auto-memory extraction writes to generated artifacts', () => {
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index f74ae4d7f5..b81737b0ea 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -2511,12 +2511,15 @@ export class Config implements McpContext, AgentLoopContext {
* user message when JIT is enabled. Returns empty string when JIT is
* disabled (Tier 2 memory is already in the system instruction).
*/
- getSessionMemory(): string {
+ getSessionMemory(options?: { includeExtensionContext?: boolean }): string {
if (!this.experimentalJitContext || !this.memoryContextManager) {
return '';
}
const sections: string[] = [];
- const extension = this.memoryContextManager.getExtensionMemory();
+ const includeExtensionContext = options?.includeExtensionContext ?? true;
+ const extension = includeExtensionContext
+ ? this.memoryContextManager.getExtensionMemory()
+ : '';
const project = this.memoryContextManager.getEnvironmentMemory();
if (extension?.trim()) {
sections.push(
@@ -3088,12 +3091,49 @@ export class Config implements McpContext, AgentLoopContext {
absolutePath: string,
resolvedPath: string,
inboxRoot: string,
+ checkType: 'read' | 'write' = 'write',
): boolean {
if (!hasScopedMemoryInboxAccess()) {
return false;
}
const normalizedPath = path.resolve(absolutePath);
+ const resolvedMemoryRoot = resolveToRealPath(
+ this.storage.getProjectMemoryTempDir(),
+ );
+
+ // Reads: allow the inbox root and the per-kind subtrees so the extraction
+ // agent can list/inspect prior patches (including non-canonical filenames
+ // left over from older runs) before deciding how to rewrite the canonical
+ // extraction.patch. Writes still flow through the strict canonical-path
+ // check below so the inbox cannot be backdoored with arbitrary files.
+ if (checkType === 'read') {
+ const resolvedInboxRoot = resolveToRealPath(inboxRoot);
+ const normalizedInboxRoot = path.resolve(inboxRoot);
+ if (
+ resolvedPath === resolvedInboxRoot ||
+ normalizedPath === normalizedInboxRoot
+ ) {
+ return isSubpath(resolvedMemoryRoot, resolvedPath);
+ }
+
+ for (const kind of ['private', 'global'] as const) {
+ const kindRoot = path.join(inboxRoot, kind);
+ const resolvedKindRoot = resolveToRealPath(kindRoot);
+ const normalizedKindRoot = path.resolve(kindRoot);
+ if (
+ resolvedPath === resolvedKindRoot ||
+ normalizedPath === normalizedKindRoot ||
+ isSubpath(resolvedKindRoot, resolvedPath) ||
+ isSubpath(normalizedKindRoot, normalizedPath)
+ ) {
+ return isSubpath(resolvedMemoryRoot, resolvedPath);
+ }
+ }
+
+ return false;
+ }
+
const isCanonicalPatchPath = (['private', 'global'] as const).some(
(kind) =>
normalizedPath === path.resolve(inboxRoot, kind, 'extraction.patch'),
@@ -3102,9 +3142,6 @@ export class Config implements McpContext, AgentLoopContext {
return false;
}
- const resolvedMemoryRoot = resolveToRealPath(
- this.storage.getProjectMemoryTempDir(),
- );
return isSubpath(resolvedMemoryRoot, resolvedPath);
}
@@ -3148,7 +3185,9 @@ export class Config implements McpContext, AgentLoopContext {
* 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`.
+ * for *writes* to `.inbox/{private,global}/extraction.patch`. Scoped *read*
+ * access to the wider `.inbox/{private,global}/` subtree is granted in
+ * `validatePathAccess` so the extractor can enumerate prior patches.
*
* @param absolutePath The absolute path to check.
* @returns true if the path is allowed, false otherwise.
@@ -3243,6 +3282,28 @@ export class Config implements McpContext, AgentLoopContext {
if (this.getWorkspaceContext().isPathReadable(absolutePath)) {
return null;
}
+
+ // The memory inbox is carved out of the standard temp-dir allowlist by
+ // `isPathAllowed`. The extraction agent is granted a scoped read
+ // exception so it can enumerate prior patches (including non-canonical
+ // filenames) before consolidating them into the canonical
+ // extraction.patch. Writes remain restricted to canonical paths.
+ if (hasScopedMemoryInboxAccess()) {
+ const inboxRoot = path.join(
+ this.storage.getProjectMemoryTempDir(),
+ '.inbox',
+ );
+ if (
+ this.isScopedMemoryInboxPatchPathAllowed(
+ absolutePath,
+ resolveToRealPath(absolutePath),
+ inboxRoot,
+ 'read',
+ )
+ ) {
+ return null;
+ }
+ }
}
// Then check standard allowed paths (Workspace + Temp)