refactor(x): workspace-context loading gets one trait-gated chokepoint

The workspaceContext trait was consulted separately at every assembly
site before loading agent notes / work dir — a convention each caller
had to re-remember, where a forgotten check either leaks the user's
agent-memory into non-copilot prompts or silently omits it for the
copilot, and neither fails loudly. The loaders move out of the legacy
runtime file into agents/workspace-context.ts, and
loadWorkspaceContext() applies the trait gate INSIDE: both engines now
call it (the resolver keeps its loader test-seams by injecting them
through), so a future assembly site structurally cannot skip the gate.
Tests pin the gate: non-workspace agents get nulls even when the
loaders would yield values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-10 08:45:58 +05:30
parent d0018daad3
commit efeaae1e19
4 changed files with 169 additions and 82 deletions

View file

@ -12,6 +12,7 @@ import { TOOL_ADDITIONS_KEY } from "../application/lib/tool-additions.js";
import { AskHumanRequestEvent, RunEvent, ToolPermissionMetadata, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
import { BuiltinTools } from "../application/lib/builtin-tools.js";
import { hasWorkspaceContext, loadAgent } from "./registry.js";
import { loadWorkspaceContext } from "./workspace-context.js";
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
import { resolveFilePathForPermission } from "../filesystem/files.js";
@ -31,14 +32,6 @@ import { captureLlmUsage } from "../analytics/usage.js";
import { enterUseCase, withUseCase, type UseCase } from "../analytics/use_case.js";
import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js";
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
// Work directory is scoped per run (per chat). Each run gets its own sidecar
// config file so setting it in one chat does not leak into others.
function workDirConfigFile(runId: string): string {
return path.join(WorkDir, 'config', `workdir-${runId}.json`);
}
type ToolPermissionMetadataValue = z.infer<typeof ToolPermissionMetadata>;
function isPathInside(parent: string, child: string): boolean {
@ -164,71 +157,6 @@ export async function getToolPermissionMetadata(
};
}
export function loadUserWorkDir(runId: string): string | null {
try {
const file = workDirConfigFile(runId);
if (!fs.existsSync(file)) return null;
const raw = fs.readFileSync(file, 'utf-8');
const parsed = JSON.parse(raw) as { path?: unknown };
const value = typeof parsed.path === 'string' ? parsed.path.trim() : '';
return value || null;
} catch {
return null;
}
}
export function loadAgentNotesContext(): string | null {
const sections: string[] = [];
const userFile = path.join(AGENT_NOTES_DIR, 'user.md');
const prefsFile = path.join(AGENT_NOTES_DIR, 'preferences.md');
try {
if (fs.existsSync(userFile)) {
const content = fs.readFileSync(userFile, 'utf-8').trim();
if (content) {
sections.push(`## About the User\nThese are notes you took about the user in previous chats.\n\n${content}`);
}
}
} catch { /* ignore */ }
try {
if (fs.existsSync(prefsFile)) {
const content = fs.readFileSync(prefsFile, 'utf-8').trim();
if (content) {
sections.push(`## User Preferences\nThese are notes you took on their general preferences.\n\n${content}`);
}
}
} catch { /* ignore */ }
// List other Agent Notes files for on-demand access
const otherFiles: string[] = [];
const skipFiles = new Set(['user.md', 'preferences.md', 'inbox.md']);
try {
if (fs.existsSync(AGENT_NOTES_DIR)) {
function listMdFiles(dir: string, prefix: string) {
for (const entry of fs.readdirSync(dir)) {
const fullPath = path.join(dir, entry);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
listMdFiles(fullPath, `${prefix}${entry}/`);
} else if (entry.endsWith('.md') && !skipFiles.has(`${prefix}${entry}`)) {
otherFiles.push(`${prefix}${entry}`);
}
}
}
listMdFiles(AGENT_NOTES_DIR, '');
}
} catch { /* ignore */ }
if (otherFiles.length > 0) {
sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using file-readText. Only read them when relevant to the current task.\n\n${otherFiles.map(f => `- knowledge/Agent Notes/${f}`).join('\n')}`);
}
if (sections.length === 0) return null;
return `# Agent Memory\n\n${sections.join('\n\n')}`;
}
function formatCurrentDateTime(now: Date): string {
return now.toLocaleString('en-US', {
weekday: 'long',
@ -1312,11 +1240,11 @@ export async function* streamAgent({
loopLogger.log('running llm turn');
// stream agent response and build message
const messageBuilder = new StreamStepMessageBuilder();
const composeCopilotContext = hasWorkspaceContext(state.agentName);
const workspace = loadWorkspaceContext(state.agentName, runId);
const instructionsWithDateTime = composeSystemInstructions({
instructions: agent.instructions,
agentNotesContext: composeCopilotContext ? loadAgentNotesContext() : null,
userWorkDir: composeCopilotContext ? loadUserWorkDir(runId) : null,
agentNotesContext: workspace.agentNotesContext,
userWorkDir: workspace.userWorkDir,
voiceInput,
voiceOutput,
searchEnabled,

View file

@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { loadWorkspaceContext } from "./workspace-context.js";
const loaders = {
loadNotes: () => "# Agent Memory\n\nnotes",
loadWorkDir: (key: string) => (key === "chat-1" ? "/Users/me/work" : null),
};
describe("loadWorkspaceContext", () => {
it("loads notes and work dir for workspaceContext-trait agents", () => {
expect(loadWorkspaceContext("copilot", "chat-1", loaders)).toEqual({
agentNotesContext: "# Agent Memory\n\nnotes",
userWorkDir: "/Users/me/work",
});
// rowboatx is the copilot alias.
expect(loadWorkspaceContext("rowboatx", "chat-1", loaders).userWorkDir).toBe(
"/Users/me/work",
);
});
it("returns nulls for non-workspace agents even when the loaders would yield values", () => {
// THE gate this module exists for: a caller cannot leak the user's
// agent-memory into a background/knowledge agent's prompt by
// forgetting a trait check — the check lives here.
for (const agentId of ["background-task-agent", "note_creation", "some-user-agent"]) {
expect(loadWorkspaceContext(agentId, "chat-1", loaders)).toEqual({
agentNotesContext: null,
userWorkDir: null,
});
}
expect(loadWorkspaceContext(null, "chat-1", loaders).agentNotesContext).toBeNull();
expect(loadWorkspaceContext(undefined, "chat-1", loaders).agentNotesContext).toBeNull();
});
it("skips the work-dir lookup without a key", () => {
expect(loadWorkspaceContext("copilot", null, loaders)).toEqual({
agentNotesContext: "# Agent Memory\n\nnotes",
userWorkDir: null,
});
});
});

View file

@ -0,0 +1,115 @@
import fs from "fs";
import path from "path";
import { WorkDir } from "../config/config.js";
import { hasWorkspaceContext } from "./registry.js";
// Workspace context for agent assembly: agent notes (global) and the user's
// per-chat work directory. loadWorkspaceContext is THE chokepoint — the
// workspaceContext trait gate lives inside it, so no assembly site can
// forget the gate and leak the user's agent-memory into a non-workspace
// agent's prompt (or silently omit it for the copilot). Loaders extracted
// verbatim from the legacy runtime file.
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
// Work directory is scoped per run (per chat). Each run gets its own sidecar
// config file so setting it in one chat does not leak into others.
function workDirConfigFile(runId: string): string {
return path.join(WorkDir, 'config', `workdir-${runId}.json`);
}
export function loadUserWorkDir(runId: string): string | null {
try {
const file = workDirConfigFile(runId);
if (!fs.existsSync(file)) return null;
const raw = fs.readFileSync(file, 'utf-8');
const parsed = JSON.parse(raw) as { path?: unknown };
const value = typeof parsed.path === 'string' ? parsed.path.trim() : '';
return value || null;
} catch {
return null;
}
}
export function loadAgentNotesContext(): string | null {
const sections: string[] = [];
const userFile = path.join(AGENT_NOTES_DIR, 'user.md');
const prefsFile = path.join(AGENT_NOTES_DIR, 'preferences.md');
try {
if (fs.existsSync(userFile)) {
const content = fs.readFileSync(userFile, 'utf-8').trim();
if (content) {
sections.push(`## About the User\nThese are notes you took about the user in previous chats.\n\n${content}`);
}
}
} catch { /* ignore */ }
try {
if (fs.existsSync(prefsFile)) {
const content = fs.readFileSync(prefsFile, 'utf-8').trim();
if (content) {
sections.push(`## User Preferences\nThese are notes you took on their general preferences.\n\n${content}`);
}
}
} catch { /* ignore */ }
// List other Agent Notes files for on-demand access
const otherFiles: string[] = [];
const skipFiles = new Set(['user.md', 'preferences.md', 'inbox.md']);
try {
if (fs.existsSync(AGENT_NOTES_DIR)) {
function listMdFiles(dir: string, prefix: string) {
for (const entry of fs.readdirSync(dir)) {
const fullPath = path.join(dir, entry);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
listMdFiles(fullPath, `${prefix}${entry}/`);
} else if (entry.endsWith('.md') && !skipFiles.has(`${prefix}${entry}`)) {
otherFiles.push(`${prefix}${entry}`);
}
}
}
listMdFiles(AGENT_NOTES_DIR, '');
}
} catch { /* ignore */ }
if (otherFiles.length > 0) {
sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using file-readText. Only read them when relevant to the current task.\n\n${otherFiles.map(f => `- knowledge/Agent Notes/${f}`).join('\n')}`);
}
if (sections.length === 0) return null;
return `# Agent Memory\n\n${sections.join('\n\n')}`;
}
export interface WorkspaceContext {
agentNotesContext: string | null;
userWorkDir: string | null;
}
const NO_WORKSPACE: WorkspaceContext = {
agentNotesContext: null,
userWorkDir: null,
};
// workDirKey is the per-chat work-directory sidecar key: the composition's
// workDirId on the turn runtime, the runId on the legacy engine.
export function loadWorkspaceContext(
agentId: string | null | undefined,
workDirKey: string | null | undefined,
loaders: {
loadNotes?: typeof loadAgentNotesContext;
loadWorkDir?: typeof loadUserWorkDir;
} = {},
): WorkspaceContext {
if (!hasWorkspaceContext(agentId)) {
return NO_WORKSPACE;
}
const loadNotes = loaders.loadNotes ?? loadAgentNotesContext;
const loadWorkDir = loaders.loadWorkDir ?? loadUserWorkDir;
return {
agentNotesContext: loadNotes(),
userWorkDir: workDirKey ? loadWorkDir(workDirKey) : null,
};
}

View file

@ -10,7 +10,8 @@ import { composeSystemInstructions } from "../../agents/compose-instructions.js"
import {
loadAgentNotesContext,
loadUserWorkDir,
} from "../../agents/runtime.js";
loadWorkspaceContext,
} from "../../agents/workspace-context.js";
import { hasWorkspaceContext, loadAgent } from "../../agents/registry.js";
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
import { skillToolNames } from "../../application/assistant/skills/index.js";
@ -126,14 +127,16 @@ export class RealAgentResolver {
? parsed.data
: CompositionOverrides.parse({});
const { workDirId, subagent, activeSkills, ...modeFlags } = composition;
// Agent notes and work-dir context are scoped to agents with the
// workspaceContext trait (the copilot), per the agent registry.
// The workspaceContext trait gate lives INSIDE loadWorkspaceContext
// (agents/workspace-context.ts) — no assembly site can forget it.
const workspace = loadWorkspaceContext(requested.agentId, workDirId, {
loadNotes: this.loadNotes,
loadWorkDir: this.loadWorkDir,
});
const copilotContext = hasWorkspaceContext(requested.agentId);
const systemPrompt = composeSystemInstructions({
instructions: agent.instructions,
agentNotesContext: copilotContext ? this.loadNotes() : null,
userWorkDir:
copilotContext && workDirId ? this.loadWorkDir(workDirId) : null,
...workspace,
...modeFlags,
});