diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 66062621..1ed883cf 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -72,6 +72,13 @@ When a workspace opens, DevSpace loads root-level instruction files: Nested instruction files are returned as `availableAgentsFiles`. The model should read the relevant nested file before working under that directory. +Set `DEVSPACE_CONTEXT_IGNORE_PATHS` to a comma-separated list of literal, +workspace-relative directory paths that should be excluded from nested +instruction discovery. For example, `external-resources,vendor/generated` +prunes those subtrees before DevSpace scans for project instructions. The +default is empty, and normal workspace file tools can still access ignored +content. + This keeps instructions explicit and inspectable instead of silently injecting new context during later tool calls. diff --git a/docs/configuration.md b/docs/configuration.md index 4c02eb1a..5e808f49 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,6 +38,36 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com | `DEVSPACE_OAUTH_OWNER_TOKEN` | Owner password for OAuth approval. Must be at least 16 characters. | | `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. | | `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. | +| `DEVSPACE_CONTEXT_IGNORE_PATHS` | Optional comma-separated workspace-relative directories to exclude from nested instruction discovery. | + +## Instruction Discovery + +`open_workspace` discovers nested `AGENTS.md` and `CLAUDE.md` files. Use +`DEVSPACE_CONTEXT_IGNORE_PATHS` to prune mounted, external, or unusually large +resource trees from that discovery walk. The default is empty, so DevSpace does +not exclude any additional directories unless you configure them: + +```bash +DEVSPACE_CONTEXT_IGNORE_PATHS="external-resources,vendor/generated" \ +npx @waishnav/devspace serve +``` + +Entries are literal workspace-relative directory paths, not globs. Absolute +paths and parent traversal are rejected. Matching is exact: ignoring +`external-resources` does not ignore `external-resources-old` or +`nested/external-resources`. Because the environment variable is +comma-separated, use the JSON form for a directory name containing a comma. The +equivalent persisted `config.json` field is an array: + +```json +{ + "contextIgnorePaths": ["external-resources", "vendor/generated"] +} +``` + +Ignored subtrees remain available to normal workspace file and shell tools; +only nested instruction discovery skips them. This setting does not enforce +read-only access and is not a filesystem security boundary. ## OAuth @@ -158,6 +188,7 @@ DEVSPACE_OAUTH_OWNER_TOKEN="$(openssl rand -base64 32)" \ DEVSPACE_ALLOWED_ROOTS="$HOME/personal,$HOME/work" \ DEVSPACE_PUBLIC_BASE_URL="https://devspace.example.com" \ DEVSPACE_WORKTREE_ROOT="$HOME/.devspace/worktrees" \ +DEVSPACE_CONTEXT_IGNORE_PATHS="external-resources,vendor/generated" \ DEVSPACE_TOOL_MODE="minimal" \ DEVSPACE_WIDGETS="full" \ npx @waishnav/devspace serve diff --git a/src/cli.ts b/src/cli.ts index bbae1ae0..aa226e5a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -160,6 +160,7 @@ async function runInit({ force }: { force: boolean }): Promise { port, allowedRoots, publicBaseUrl, + contextIgnorePaths: files.config.contextIgnorePaths, subagents: resolveSubagentsFlag(files.config), }; const auth = { @@ -218,6 +219,7 @@ async function serve(): Promise { console.log(`public base url: ${config.publicBaseUrl}`); console.log(`allowed roots: ${config.allowedRoots.join(", ")}`); console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`); + console.log(`context ignore paths: ${config.contextIgnorePaths.join(", ") || "(none)"}`); if (config.allowedHosts.includes("*")) { console.warn("warning: Host header allowlist is disabled because DEVSPACE_ALLOWED_HOSTS=*"); } @@ -256,6 +258,7 @@ async function runDoctor(): Promise { console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`); console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`); console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`); + console.log(`Context ignore paths: ${config.contextIgnorePaths.join(", ") || "(none)"}`); } catch (error) { console.log(`Config status: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/config.test.ts b/src/config.test.ts index 0b4f99a8..ea97275f 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -26,6 +26,7 @@ assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); assert.equal(loadConfig(baseEnv).subagents, false); +assert.deepEqual(loadConfig(baseEnv).contextIgnorePaths, []); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); assert.equal( @@ -36,6 +37,14 @@ assert.equal(resolveSubagentsFlag({}, {}), undefined); assert.equal(resolveSubagentsFlag({ subagents: true }, {}), true); assert.equal(resolveSubagentsFlag({ subagents: true }, { DEVSPACE_SUBAGENTS: "0" }), false); assert.equal(resolveSubagentsFlag({}, { DEVSPACE_SUBAGENTS: "1" }), true); +assert.deepEqual( + loadConfig({ + ...baseEnv, + DEVSPACE_CONTEXT_IGNORE_PATHS: + "external-resources, vendor/generated, ./cache/data/,external-resources", + }).contextIgnorePaths, + ["external-resources", "vendor/generated", "cache/data"], +); const seededConfigDir = mkdtempSync(join(tmpdir(), "devspace-seeded-skills-test-")); const seededSkillPaths = ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }); @@ -60,6 +69,33 @@ assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }), /Invalid DEVSPACE_TOOL_MODE: invalid/, ); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_CONTEXT_IGNORE_PATHS: "../outside" }), + /Invalid DEVSPACE_CONTEXT_IGNORE_PATHS entry/, +); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_CONTEXT_IGNORE_PATHS: "/absolute" }), + /Invalid DEVSPACE_CONTEXT_IGNORE_PATHS entry/, +); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_CONTEXT_IGNORE_PATHS: "C:\\outside" }), + /Invalid DEVSPACE_CONTEXT_IGNORE_PATHS entry/, +); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_CONTEXT_IGNORE_PATHS: "." }), + /Invalid DEVSPACE_CONTEXT_IGNORE_PATHS entry/, +); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_CONTEXT_IGNORE_PATHS: "ignored\0path" }), + /Invalid DEVSPACE_CONTEXT_IGNORE_PATHS entry/, +); +assert.throws( + () => loadConfig({ + ...baseEnv, + DEVSPACE_CONTEXT_IGNORE_PATHS: ["external-resources", 42] as unknown as string, + }), + /must be a comma-separated string or a config array of strings/, +); assert.deepEqual(loadConfig(baseEnv).logging, { level: "info", @@ -162,6 +198,12 @@ writeFileSync( port: 8787, allowedRoots: [process.cwd()], publicBaseUrl: "https://devspace.example.com", + contextIgnorePaths: [ + "external-resources", + " vendor/generated ", + "./cache/data/", + "external-resources", + ], subagents: true, }), ); @@ -177,6 +219,25 @@ assert.equal(fileConfig.port, 8787); assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); assert.equal(fileConfig.subagents, true); +assert.deepEqual(fileConfig.contextIgnorePaths, [ + "external-resources", + "vendor/generated", + "cache/data", +]); +assert.deepEqual( + loadConfig({ + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_CONTEXT_IGNORE_PATHS: "environment/path", + }).contextIgnorePaths, + ["environment/path"], +); +assert.deepEqual( + loadConfig({ + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_CONTEXT_IGNORE_PATHS: "", + }).contextIgnorePaths, + [], +); assert.deepEqual(fileConfig.allowedHosts, [ "localhost", "127.0.0.1", diff --git a/src/config.ts b/src/config.ts index 4fc1bcbb..e9950b70 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,7 @@ export interface ServerConfig { widgets: WidgetMode; stateDir: string; worktreeRoot: string; + contextIgnorePaths: string[]; skillsEnabled: boolean; skillPaths: string[]; devspaceSkillsDir: string; @@ -115,6 +116,49 @@ function parsePathList(value: string | undefined): string[] { ); } +function parseContextIgnorePaths(value: unknown): string[] { + if (value === undefined) return []; + const entries = typeof value === "string" ? value.split(",") : value; + if (!Array.isArray(entries) || !entries.every((entry) => typeof entry === "string")) { + throw new Error( + "DEVSPACE_CONTEXT_IGNORE_PATHS must be a comma-separated string or a config array of strings.", + ); + } + + const normalized = entries + .map((entry) => entry.trim()) + .filter(Boolean) + .map(normalizeContextIgnorePath); + + return Array.from(new Set(normalized)); +} + +function normalizeContextIgnorePath(value: string): string { + const slashPath = value.replaceAll("\\", "/"); + const segments = slashPath.split("/"); + if ( + slashPath.startsWith("/") || + /^[A-Za-z]:/.test(slashPath) || + slashPath.includes("\0") || + segments.includes("..") + ) { + throw new Error( + `Invalid DEVSPACE_CONTEXT_IGNORE_PATHS entry: ${value}. Use workspace-relative directory paths.`, + ); + } + + const normalized = segments + .filter((segment) => segment !== "" && segment !== ".") + .join("/"); + if (!normalized) { + throw new Error( + `Invalid DEVSPACE_CONTEXT_IGNORE_PATHS entry: ${value}. Use workspace-relative directory paths.`, + ); + } + + return normalized; +} + function parseStringList(value: string | undefined, fallback: string[]): string[] { const entries = value ?.split(",") @@ -226,6 +270,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), + contextIgnorePaths: parseContextIgnorePaths( + env.DEVSPACE_CONTEXT_IGNORE_PATHS ?? files.config.contextIgnorePaths, + ), skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), devspaceSkillsDir: devspaceSkillsDir(env), diff --git a/src/user-config.ts b/src/user-config.ts index c8da90b5..5c0f0d22 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -17,6 +17,7 @@ export interface DevspaceUserConfig { allowedHosts?: string[]; stateDir?: string; worktreeRoot?: string; + contextIgnorePaths?: string[]; agentDir?: string; subagents?: boolean; } diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 4f3eb769..1277cbf5 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtemp, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, opendir, rm, stat, symlink, writeFile } from "node:fs/promises"; import { platform, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -41,17 +41,41 @@ try { await mkdir(join(root, "nested")); await writeFile(join(root, "nested", "AGENTS.md"), "nested instructions\n"); await writeFile(join(root, "nested", "file.txt"), "hello\n"); + await mkdir(join(root, "nested", "external-resources")); + await writeFile( + join(root, "nested", "external-resources", "AGENTS.md"), + "nested visible instructions\n", + ); + await mkdir(join(root, "external-resources", "mounted-content"), { recursive: true }); + await writeFile( + join(root, "external-resources", "mounted-content", "AGENTS.md"), + "mounted instructions\n", + ); + await mkdir(join(root, "external-resources-old")); + await writeFile( + join(root, "external-resources-old", "AGENTS.md"), + "prefix visible instructions\n", + ); + await mkdir(join(root, "node_modules")); + await writeFile(join(root, "node_modules", "AGENTS.md"), "built-in ignored instructions\n"); const config = loadConfig({ DEVSPACE_CONFIG_DIR: join(root, ".devspace-home"), DEVSPACE_ALLOWED_ROOTS: root, DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "worktrees"), DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_CONTEXT_IGNORE_PATHS: "external-resources", DEVSPACE_SUBAGENTS: "1", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); - const registry = new WorkspaceRegistry(config); + const openedDirectories: string[] = []; + const registry = new WorkspaceRegistry(config, undefined, { + opendir: async (path, options) => { + openedDirectories.push(path.toString()); + return await opendir(path, options); + }, + }); const { workspace, agentsFiles, availableAgentsFiles } = await registry.openWorkspace(root); assert.equal(workspace.mode, "checkout"); @@ -61,7 +85,41 @@ try { ); assert.deepEqual( availableAgentsFiles.map((file) => file.path), - [join(root, "nested", "AGENTS.md")], + [ + join(root, "external-resources-old", "AGENTS.md"), + join(root, "nested", "AGENTS.md"), + join(root, "nested", "external-resources", "AGENTS.md"), + ], + ); + assert.equal(openedDirectories.includes(join(root, "external-resources")), false); + assert.equal(openedDirectories.includes(join(root, "node_modules")), false); + assert.equal(openedDirectories.includes(join(root, "nested", "external-resources")), true); + assert.equal( + registry.resolvePath( + workspace, + join("external-resources", "mounted-content", "AGENTS.md"), + ), + join(root, "external-resources", "mounted-content", "AGENTS.md"), + ); + + const unignoredRoot = join(root, "unignored-workspace"); + await mkdir(join(unignoredRoot, "external-resources"), { recursive: true }); + await writeFile( + join(unignoredRoot, "external-resources", "AGENTS.md"), + "visible instructions\n", + ); + const unignoredConfig = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".devspace-unignored-home"), + DEVSPACE_ALLOWED_ROOTS: unignoredRoot, + DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "unignored-worktrees"), + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const unignoredWorkspace = await new WorkspaceRegistry(unignoredConfig).openWorkspace(unignoredRoot); + assert.deepEqual( + unignoredWorkspace.availableAgentsFiles.map((file) => file.path), + [join(unignoredRoot, "external-resources", "AGENTS.md")], ); assert.deepEqual( workspace.agentProfiles.map((profile) => ({ diff --git a/src/workspaces.ts b/src/workspaces.ts index e3c252f4..32dd5343 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -72,6 +72,9 @@ type DirectoryOps = { stat: (path: string) => Promise; mkdir: (path: string, options: { recursive: true }) => Promise; }; +type WorkspaceTraversalOps = { + opendir: typeof opendir; +}; export class WorkspaceRegistry { private readonly workspaces = new Map(); @@ -79,6 +82,7 @@ export class WorkspaceRegistry { constructor( private readonly config: ServerConfig, private readonly store?: WorkspaceStore, + private readonly traversalOps: WorkspaceTraversalOps = { opendir }, ) {} async openWorkspace(input: string | OpenWorkspaceInput): Promise { @@ -289,7 +293,7 @@ export class WorkspaceRegistry { } const discovered: AvailableAgentsFile[] = []; - await walkWorkspace(root, async (path, entry) => { + await walkWorkspace(root, new Set(this.config.contextIgnorePaths), this.traversalOps, async (path, entry) => { if (!entry.isFile()) return; if (!CONTEXT_FILE_NAMES.has(entry.name)) return; if (loadedPaths.has(path)) return; @@ -379,20 +383,24 @@ async function tryRealpath(path: string): Promise { async function walkWorkspace( directory: string, + contextIgnorePaths: ReadonlySet, + ops: WorkspaceTraversalOps, visit: (path: string, entry: { name: string; isFile(): boolean; isDirectory(): boolean }) => Promise | void, + relativeDirectory = "", ): Promise { let entries; try { - entries = await opendir(directory); + entries = await ops.opendir(directory); } catch { return; } for await (const entry of entries) { const path = join(directory, entry.name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name; if (entry.isDirectory()) { - if (!SKIPPED_CONTEXT_DIRS.has(entry.name)) { - await walkWorkspace(path, visit); + if (!SKIPPED_CONTEXT_DIRS.has(entry.name) && !contextIgnorePaths.has(relativePath)) { + await walkWorkspace(path, contextIgnorePaths, ops, visit, relativePath); } continue; }