From 19c99172c7572320a739278952ac36860badb19f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 17:40:36 +0000 Subject: [PATCH 01/13] feat(core): add sandbox target configuration for observed-IO opt-out Adds a `sandbox` property to target configuration with `enabled`, `ignoredReads`, and `ignoredWrites`. The task orchestrator registers each task's sandbox configuration with the TaskIOService, which suppresses PID reporting for tasks whose target sets `sandbox.enabled: false`, so no IO tracing signal (and therefore no sandbox report) is produced for them. checkFilesAreInputs/checkFilesAreOutputs treat paths matching the ignored globs as reconciled so sandbox-violation validation skips them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YVrVQUAtU1aSQPuUxLkJsn --- packages/nx/schemas/nx-schema.json | 29 +++++++ packages/nx/schemas/project-schema.json | 26 +++++++ .../src/config/workspace-json-project-json.ts | 29 +++++++ .../nx/src/hasher/check-task-files.spec.ts | 78 +++++++++++++++++++ packages/nx/src/hasher/check-task-files.ts | 45 ++++++++--- .../src/tasks-runner/task-io-service.spec.ts | 59 ++++++++++++++ .../nx/src/tasks-runner/task-io-service.ts | 33 ++++++++ .../nx/src/tasks-runner/task-orchestrator.ts | 20 +++++ 8 files changed, 310 insertions(+), 9 deletions(-) create mode 100644 packages/nx/src/tasks-runner/task-io-service.spec.ts diff --git a/packages/nx/schemas/nx-schema.json b/packages/nx/schemas/nx-schema.json index 78fd574277f..804d49a117f 100644 --- a/packages/nx/schemas/nx-schema.json +++ b/packages/nx/schemas/nx-schema.json @@ -920,6 +920,9 @@ "cache": { "$ref": "#/definitions/targetDefaultsConfig/properties/cache" }, + "sandbox": { + "$ref": "#/definitions/targetDefaultsConfig/properties/sandbox" + }, "syncGenerators": { "$ref": "#/definitions/targetDefaultsConfig/properties/syncGenerators" } @@ -1046,6 +1049,32 @@ "type": "boolean", "description": "Specifies if the given target should be cacheable" }, + "sandbox": { + "type": "object", + "description": "Configures observed-IO sandboxing for tasks of this target", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Whether tasks for this target are tracked by the sandbox" + }, + "ignoredReads": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workspace-relative glob patterns for reads excluded from sandboxing reports" + }, + "ignoredWrites": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workspace-relative glob patterns for writes excluded from sandboxing reports" + } + }, + "additionalProperties": false + }, "syncGenerators": { "type": "array", "items": { diff --git a/packages/nx/schemas/project-schema.json b/packages/nx/schemas/project-schema.json index 07daea57dd9..048d7eccf4a 100644 --- a/packages/nx/schemas/project-schema.json +++ b/packages/nx/schemas/project-schema.json @@ -138,6 +138,32 @@ "type": "boolean", "description": "Specifies if the given target should be cacheable" }, + "sandbox": { + "type": "object", + "description": "Configures observed-IO sandboxing for tasks of this target", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Whether tasks for this target are tracked by the sandbox" + }, + "ignoredReads": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workspace-relative glob patterns for reads excluded from sandboxing reports" + }, + "ignoredWrites": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workspace-relative glob patterns for writes excluded from sandboxing reports" + } + }, + "additionalProperties": false + }, "continuous": { "type": "boolean", "default": false, diff --git a/packages/nx/src/config/workspace-json-project-json.ts b/packages/nx/src/config/workspace-json-project-json.ts index 39e93a429d2..96e7a3cdcb4 100644 --- a/packages/nx/src/config/workspace-json-project-json.ts +++ b/packages/nx/src/config/workspace-json-project-json.ts @@ -174,6 +174,30 @@ export interface TargetMetadata { }; } +/** + * Configuration for observed-IO sandboxing of a target's tasks. + */ +export interface TargetSandboxConfiguration { + /** + * Whether tasks for this target are tracked by the sandbox. + * Defaults to true. When false, no IO tracing is reported for the + * task, so no sandbox report is produced. + */ + enabled?: boolean; + + /** + * Workspace-relative glob patterns for reads that should be excluded + * from sandboxing reports. + */ + ignoredReads?: string[]; + + /** + * Workspace-relative glob patterns for writes that should be excluded + * from sandboxing reports. + */ + ignoredWrites?: string[]; +} + export interface TargetDependencyConfig { /** * A list of projects that have `target`. @@ -273,6 +297,11 @@ export interface TargetConfiguration { */ cache?: boolean; + /** + * Configures observed-IO sandboxing for tasks of this target. + */ + sandbox?: TargetSandboxConfiguration; + /** * Metadata about the target */ diff --git a/packages/nx/src/hasher/check-task-files.spec.ts b/packages/nx/src/hasher/check-task-files.spec.ts index 1e0297311ba..7ec3424b0f3 100644 --- a/packages/nx/src/hasher/check-task-files.spec.ts +++ b/packages/nx/src/hasher/check-task-files.spec.ts @@ -1136,6 +1136,84 @@ describe('checkFilesAreInputs / checkFilesAreOutputs', () => { // ── caching ────────────────────────────────────────────────────────────── + describe('sandbox ignored globs', () => { + function buildGraphWithSandbox(): ProjectGraph { + const graph = buildGraph(); + (graph.nodes['myproj'].data.targets['build'] as any).sandbox = { + ignoredReads: ['tmp/reads/**'], + ignoredWrites: ['tmp/writes/**'], + }; + return graph; + } + + beforeEach(() => { + mockCreateProjectGraphAsync.mockResolvedValue(buildGraphWithSandbox()); + }); + + it('matches an input against sandbox.ignoredReads with the sandboxIgnored category', async () => { + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs(['libs/myproj/src/index.ts']), + }); + + const result = await checkFilesAreInputs('myproj:build', [ + 'tmp/reads/cache.json', + 'tmp/other/cache.json', + ]); + + expect(result.matched).toEqual(['tmp/reads/cache.json']); + expect(result.unmatched).toEqual(['tmp/other/cache.json']); + expect(result.categories.get('tmp/reads/cache.json')).toBe( + 'sandboxIgnored' + ); + }); + + it('prefers the declared input category over sandboxIgnored', async () => { + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs(['tmp/reads/declared.json']), + }); + + const result = await checkFilesAreInputs('myproj:build', [ + 'tmp/reads/declared.json', + ]); + + expect(result.matched).toEqual(['tmp/reads/declared.json']); + expect(result.categories.get('tmp/reads/declared.json')).toBe('files'); + }); + + it('matches an output against sandbox.ignoredWrites', async () => { + mockGetOutputs.mockReturnValue(['dist/**']); + + const result = await checkFilesAreOutputs('myproj:build', [ + 'dist/main.js', + 'tmp/writes/scratch.log', + 'tmp/other/scratch.log', + ]); + + expect(result.matched).toEqual([ + 'dist/main.js', + 'tmp/writes/scratch.log', + ]); + expect(result.unmatched).toEqual(['tmp/other/scratch.log']); + }); + + it('does not match ignored globs of a different kind', async () => { + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs([]), + }); + mockGetOutputs.mockReturnValue([]); + + const inputs = await checkFilesAreInputs('myproj:build', [ + 'tmp/writes/scratch.log', + ]); + expect(inputs.unmatched).toEqual(['tmp/writes/scratch.log']); + + const outputs = await checkFilesAreOutputs('myproj:build', [ + 'tmp/reads/cache.json', + ]); + expect(outputs.unmatched).toEqual(['tmp/reads/cache.json']); + }); + }); + describe('caching across calls', () => { it('does not call underlying APIs more than once per taskId', async () => { mockInspectTaskInputs.mockReturnValue({ diff --git a/packages/nx/src/hasher/check-task-files.ts b/packages/nx/src/hasher/check-task-files.ts index 8b5a0d65efc..baf33333d3c 100644 --- a/packages/nx/src/hasher/check-task-files.ts +++ b/packages/nx/src/hasher/check-task-files.ts @@ -392,12 +392,33 @@ function classifyInput( const path = toWorkspaceRelativePath(candidate.path); if (raw.files.includes(path)) return 'files'; if (raw.depOutputs.includes(path)) return 'depOutputs'; + if (matchesDependentTaskOutputs(taskId, path, ctx)) { + return 'dependentTasksOutputFiles'; + } - return matchesDependentTaskOutputs(taskId, path, ctx) - ? 'dependentTasksOutputFiles' + return matchesSandboxIgnoredGlobs(taskId, path, 'ignoredReads', ctx) + ? 'sandboxIgnored' : null; } +/** + * Matches a path against the `sandbox.ignoredReads` / `sandbox.ignoredWrites` + * globs of the task's target. A match means the file access is deliberately + * excluded from sandboxing reports, so a violation consumer treats it as + * reconciled rather than unexpected. + */ +function matchesSandboxIgnoredGlobs( + taskId: string, + path: string, + kind: 'ignoredReads' | 'ignoredWrites', + ctx: LoadedContext +): boolean { + const { target, projectNode } = resolveIdentity(taskId, ctx.projectGraph); + const globs = projectNode.data.targets?.[target]?.sandbox?.[kind]; + if (!globs?.length) return false; + return matchGlobPaths(globs.map(normalizePath), [path])[0]; +} + // ── API (exported from devkit-internals) ───────────────────────────────────── // // Paths may be given workspace-relative or absolute, in either separator style; @@ -418,7 +439,8 @@ export type InputCategory = | 'dependentTasksOutputFiles' | 'runtime' | 'environment' - | 'external'; + | 'external' + | 'sandboxIgnored'; export interface InputCandidate { /** The value as supplied — matched verbatim against environment/runtime/external. */ @@ -435,7 +457,9 @@ export interface InputCandidate { * - a file in the task's materialized `depOutputs` (upstream has run); * - a file matching a `dependentTasksOutputFiles` glob declared on the task * that lies inside the declared outputs of an upstream task in the task - * graph (static — works even when upstream tasks have not yet run). + * graph (static — works even when upstream tasks have not yet run); + * - a file matching a `sandbox.ignoredReads` glob of the task's target + * (deliberately excluded from sandboxing reports). * * `categories` records the rule each matched value satisfied. Paths may be * workspace-relative or absolute; absolute ones are relativized against the @@ -496,6 +520,8 @@ export async function checkFilesAreInputs( * Uses the same path-matching logic as the task runner (directory containment * + glob matching through the native `globset` engine), including negated * (`!`-prefixed) patterns acting as exclusions over the whole pattern set. + * A file matching a `sandbox.ignoredWrites` glob of the task's target also + * counts as matched — it is deliberately excluded from sandboxing reports. * * Paths may be workspace-relative or absolute; absolute ones are relativized * against the workspace root. An output pattern whose `{options.*}` token has no @@ -517,14 +543,15 @@ export async function checkFilesAreOutputs( // malformed task, even when the file list is empty. resolveIdentity(taskId, ctx.projectGraph); const patterns = getOutputs(taskId, ctx.projectGraph); - const results = matchOutputPaths( - patterns, - files.map(toWorkspaceRelativePath) - ); + const relativePaths = files.map(toWorkspaceRelativePath); + const results = matchOutputPaths(patterns, relativePaths); const matched: string[] = []; const unmatched: string[] = []; files.forEach((file, i) => { - if (results[i]) { + if ( + results[i] || + matchesSandboxIgnoredGlobs(taskId, relativePaths[i], 'ignoredWrites', ctx) + ) { matched.push(file); } else { unmatched.push(file); diff --git a/packages/nx/src/tasks-runner/task-io-service.spec.ts b/packages/nx/src/tasks-runner/task-io-service.spec.ts new file mode 100644 index 00000000000..a8d143a063c --- /dev/null +++ b/packages/nx/src/tasks-runner/task-io-service.spec.ts @@ -0,0 +1,59 @@ +import { getTaskIOService, TaskPidUpdate } from './task-io-service'; + +describe('TaskIOService sandbox configuration', () => { + it('notifies PID subscribers for tasks without a sandbox configuration', () => { + const service = getTaskIOService(); + const updates: TaskPidUpdate[] = []; + service.subscribeToTaskPids((update) => updates.push(update)); + + service.notifyPidUpdate({ taskId: 'proj:tracked', pid: 100 }); + + expect(updates).toEqual([{ taskId: 'proj:tracked', pid: 100 }]); + }); + + it('suppresses PID updates for tasks whose sandbox is disabled', () => { + const service = getTaskIOService(); + const updates: TaskPidUpdate[] = []; + service.subscribeToTaskPids((update) => updates.push(update)); + + service.registerTaskSandboxConfiguration('proj:disabled', { + enabled: false, + }); + service.notifyPidUpdate({ taskId: 'proj:disabled', pid: 200 }); + service.notifyPidUpdate({ taskId: 'proj:other', pid: 201 }); + + expect(updates).toEqual([{ taskId: 'proj:other', pid: 201 }]); + expect(service.isTaskSandboxDisabled('proj:disabled')).toBe(true); + }); + + it('keeps PID updates for a sandbox configuration without enabled: false', () => { + const service = getTaskIOService(); + const updates: TaskPidUpdate[] = []; + service.subscribeToTaskPids((update) => updates.push(update)); + + service.registerTaskSandboxConfiguration('proj:ignores-only', { + ignoredReads: ['tmp/**'], + }); + service.notifyPidUpdate({ taskId: 'proj:ignores-only', pid: 300 }); + + expect(updates).toEqual([{ taskId: 'proj:ignores-only', pid: 300 }]); + expect(service.isTaskSandboxDisabled('proj:ignores-only')).toBe(false); + }); + + it('re-enables PID updates when a task is re-registered as enabled', () => { + const service = getTaskIOService(); + const updates: TaskPidUpdate[] = []; + service.subscribeToTaskPids((update) => updates.push(update)); + + service.registerTaskSandboxConfiguration('proj:reenabled', { + enabled: false, + }); + service.notifyPidUpdate({ taskId: 'proj:reenabled', pid: 400 }); + service.registerTaskSandboxConfiguration('proj:reenabled', { + enabled: true, + }); + service.notifyPidUpdate({ taskId: 'proj:reenabled', pid: 401 }); + + expect(updates).toEqual([{ taskId: 'proj:reenabled', pid: 401 }]); + }); +}); diff --git a/packages/nx/src/tasks-runner/task-io-service.ts b/packages/nx/src/tasks-runner/task-io-service.ts index 5ead8ed2409..3d68aff8748 100644 --- a/packages/nx/src/tasks-runner/task-io-service.ts +++ b/packages/nx/src/tasks-runner/task-io-service.ts @@ -1,3 +1,4 @@ +import type { TargetSandboxConfiguration } from '../config/workspace-json-project-json'; import { getProcessMetricsService } from './process-metrics-service'; /** @@ -50,6 +51,34 @@ class TaskIOService { private taskInputCallbacks: TaskInputCallback[] = []; private taskOutputsCallbacks: TaskOutputsCallback[] = []; + /** + * Task IDs whose target opted out of sandboxing via + * `sandbox: { enabled: false }`. PID updates for these tasks are + * suppressed so no IO tracing (and therefore no sandbox report) is + * produced for them. + */ + private sandboxDisabledTaskIds = new Set(); + + /** + * Register the sandbox configuration of a task before it runs. + * Only the disabled state is retained; a task without a registered + * config is treated as sandbox-enabled. + */ + registerTaskSandboxConfiguration( + taskId: string, + config: TargetSandboxConfiguration | undefined + ): void { + if (config?.enabled === false) { + this.sandboxDisabledTaskIds.add(taskId); + } else { + this.sandboxDisabledTaskIds.delete(taskId); + } + } + + isTaskSandboxDisabled(taskId: string): boolean { + return this.sandboxDisabledTaskIds.has(taskId); + } + /** * Subscribe to task PID updates. * Receives notifications when processes are added/removed from tasks. @@ -131,9 +160,13 @@ class TaskIOService { /** * Registers a PID to a task and notifies subscribers. + * No-op for tasks whose target disabled sandboxing. * @param update The TaskPidUpdate containing taskId and pid. */ notifyPidUpdate(update: TaskPidUpdate): void { + if (this.sandboxDisabledTaskIds.has(update.taskId)) { + return; + } for (const cb of this.pidCallbacks) { try { cb(update); diff --git a/packages/nx/src/tasks-runner/task-orchestrator.ts b/packages/nx/src/tasks-runner/task-orchestrator.ts index 34ebe9bbb20..e5edbc49d45 100644 --- a/packages/nx/src/tasks-runner/task-orchestrator.ts +++ b/packages/nx/src/tasks-runner/task-orchestrator.ts @@ -59,6 +59,7 @@ import { getTaskSpecificEnv, } from './task-env'; import { TaskStatus } from './tasks-runner'; +import { getTaskIOService } from './task-io-service'; import { Batch, TasksSchedule } from './tasks-schedule'; import { calculateReverseDeps, @@ -201,6 +202,7 @@ export class TaskOrchestrator { async init() { this.setupSignalHandlers(); this.taskInvocationTracker?.cleanupStale(); + this.registerTaskSandboxConfigurations(); // Init the ForkedProcessTaskRunner, TasksSchedule, and Cache await Promise.all([ @@ -218,6 +220,24 @@ export class TaskOrchestrator { } } + /** + * Registers each task's `sandbox` target configuration with the + * TaskIOService so PID reporting is suppressed for tasks whose target + * opted out of sandboxing. + */ + private registerTaskSandboxConfigurations(): void { + const ioService = getTaskIOService(); + for (const task of Object.values(this.taskGraph.tasks)) { + const sandbox = getTargetConfigurationForTask( + task, + this.projectGraph + )?.sandbox; + if (sandbox) { + ioService.registerTaskSandboxConfiguration(task.id, sandbox); + } + } + } + async run() { await this.init(); From 453d7d3083f34df144ff84e5dc5e999656adcb63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 02:59:15 +0000 Subject: [PATCH 02/13] feat(core): carry the sandbox configuration on task instances Review feedback: the target's sandbox configuration is copied onto each Task in createTaskGraph (Task['sandbox'] equals TargetConfiguration['sandbox']), so consumers read it off the task instead of resolving it through the project graph. The orchestrator registers it with the TaskIOService just-in-time in processTask, which both run paths await before spawning, replacing the upfront iteration over the task graph. The check-task-files sandbox matching is reverted: those functions answer whether a file is an input/output, and with ignored accesses excluded from reports at record time there is nothing left for them to reconcile. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YVrVQUAtU1aSQPuUxLkJsn --- packages/nx/src/config/task-graph.ts | 8 +- .../src/config/workspace-json-project-json.ts | 26 ++----- .../nx/src/hasher/check-task-files.spec.ts | 78 ------------------- packages/nx/src/hasher/check-task-files.ts | 45 +++-------- packages/nx/src/native/index.d.ts | 22 ++++++ packages/nx/src/native/tasks/types.rs | 18 +++++ .../tasks-runner/create-task-graph.spec.ts | 24 ++++++ .../nx/src/tasks-runner/create-task-graph.ts | 1 + .../nx/src/tasks-runner/task-orchestrator.ts | 28 +++---- 9 files changed, 95 insertions(+), 155 deletions(-) diff --git a/packages/nx/src/config/task-graph.ts b/packages/nx/src/config/task-graph.ts index edc108c77f2..0ffd03f74fd 100644 --- a/packages/nx/src/config/task-graph.ts +++ b/packages/nx/src/config/task-graph.ts @@ -3,4 +3,10 @@ * `packages/nx/src/native/tasks/types.rs` and exposed to TypeScript via NAPI. * This file re-exports them so existing imports keep working. */ -export type { Task, TaskGraph, TaskTarget, TaskHashDetails } from '../native'; +export type { + Task, + TaskGraph, + TaskTarget, + TaskHashDetails, + TaskSandboxConfiguration, +} from '../native'; diff --git a/packages/nx/src/config/workspace-json-project-json.ts b/packages/nx/src/config/workspace-json-project-json.ts index 96e7a3cdcb4..11bb76395ee 100644 --- a/packages/nx/src/config/workspace-json-project-json.ts +++ b/packages/nx/src/config/workspace-json-project-json.ts @@ -1,4 +1,4 @@ -import type { JsonInput } from '../native'; +import type { JsonInput, TaskSandboxConfiguration } from '../native'; import type { PackageJson } from '../utils/package-json'; import type { NxJsonConfiguration, @@ -176,27 +176,11 @@ export interface TargetMetadata { /** * Configuration for observed-IO sandboxing of a target's tasks. + * + * The same shape rides on each Task instance (`Task['sandbox']`), so the + * type is shared with the native task definition. */ -export interface TargetSandboxConfiguration { - /** - * Whether tasks for this target are tracked by the sandbox. - * Defaults to true. When false, no IO tracing is reported for the - * task, so no sandbox report is produced. - */ - enabled?: boolean; - - /** - * Workspace-relative glob patterns for reads that should be excluded - * from sandboxing reports. - */ - ignoredReads?: string[]; - - /** - * Workspace-relative glob patterns for writes that should be excluded - * from sandboxing reports. - */ - ignoredWrites?: string[]; -} +export type TargetSandboxConfiguration = TaskSandboxConfiguration; export interface TargetDependencyConfig { /** diff --git a/packages/nx/src/hasher/check-task-files.spec.ts b/packages/nx/src/hasher/check-task-files.spec.ts index 7ec3424b0f3..1e0297311ba 100644 --- a/packages/nx/src/hasher/check-task-files.spec.ts +++ b/packages/nx/src/hasher/check-task-files.spec.ts @@ -1136,84 +1136,6 @@ describe('checkFilesAreInputs / checkFilesAreOutputs', () => { // ── caching ────────────────────────────────────────────────────────────── - describe('sandbox ignored globs', () => { - function buildGraphWithSandbox(): ProjectGraph { - const graph = buildGraph(); - (graph.nodes['myproj'].data.targets['build'] as any).sandbox = { - ignoredReads: ['tmp/reads/**'], - ignoredWrites: ['tmp/writes/**'], - }; - return graph; - } - - beforeEach(() => { - mockCreateProjectGraphAsync.mockResolvedValue(buildGraphWithSandbox()); - }); - - it('matches an input against sandbox.ignoredReads with the sandboxIgnored category', async () => { - mockInspectTaskInputs.mockReturnValue({ - 'myproj:build': makeHashInputs(['libs/myproj/src/index.ts']), - }); - - const result = await checkFilesAreInputs('myproj:build', [ - 'tmp/reads/cache.json', - 'tmp/other/cache.json', - ]); - - expect(result.matched).toEqual(['tmp/reads/cache.json']); - expect(result.unmatched).toEqual(['tmp/other/cache.json']); - expect(result.categories.get('tmp/reads/cache.json')).toBe( - 'sandboxIgnored' - ); - }); - - it('prefers the declared input category over sandboxIgnored', async () => { - mockInspectTaskInputs.mockReturnValue({ - 'myproj:build': makeHashInputs(['tmp/reads/declared.json']), - }); - - const result = await checkFilesAreInputs('myproj:build', [ - 'tmp/reads/declared.json', - ]); - - expect(result.matched).toEqual(['tmp/reads/declared.json']); - expect(result.categories.get('tmp/reads/declared.json')).toBe('files'); - }); - - it('matches an output against sandbox.ignoredWrites', async () => { - mockGetOutputs.mockReturnValue(['dist/**']); - - const result = await checkFilesAreOutputs('myproj:build', [ - 'dist/main.js', - 'tmp/writes/scratch.log', - 'tmp/other/scratch.log', - ]); - - expect(result.matched).toEqual([ - 'dist/main.js', - 'tmp/writes/scratch.log', - ]); - expect(result.unmatched).toEqual(['tmp/other/scratch.log']); - }); - - it('does not match ignored globs of a different kind', async () => { - mockInspectTaskInputs.mockReturnValue({ - 'myproj:build': makeHashInputs([]), - }); - mockGetOutputs.mockReturnValue([]); - - const inputs = await checkFilesAreInputs('myproj:build', [ - 'tmp/writes/scratch.log', - ]); - expect(inputs.unmatched).toEqual(['tmp/writes/scratch.log']); - - const outputs = await checkFilesAreOutputs('myproj:build', [ - 'tmp/reads/cache.json', - ]); - expect(outputs.unmatched).toEqual(['tmp/reads/cache.json']); - }); - }); - describe('caching across calls', () => { it('does not call underlying APIs more than once per taskId', async () => { mockInspectTaskInputs.mockReturnValue({ diff --git a/packages/nx/src/hasher/check-task-files.ts b/packages/nx/src/hasher/check-task-files.ts index baf33333d3c..8b5a0d65efc 100644 --- a/packages/nx/src/hasher/check-task-files.ts +++ b/packages/nx/src/hasher/check-task-files.ts @@ -392,33 +392,12 @@ function classifyInput( const path = toWorkspaceRelativePath(candidate.path); if (raw.files.includes(path)) return 'files'; if (raw.depOutputs.includes(path)) return 'depOutputs'; - if (matchesDependentTaskOutputs(taskId, path, ctx)) { - return 'dependentTasksOutputFiles'; - } - return matchesSandboxIgnoredGlobs(taskId, path, 'ignoredReads', ctx) - ? 'sandboxIgnored' + return matchesDependentTaskOutputs(taskId, path, ctx) + ? 'dependentTasksOutputFiles' : null; } -/** - * Matches a path against the `sandbox.ignoredReads` / `sandbox.ignoredWrites` - * globs of the task's target. A match means the file access is deliberately - * excluded from sandboxing reports, so a violation consumer treats it as - * reconciled rather than unexpected. - */ -function matchesSandboxIgnoredGlobs( - taskId: string, - path: string, - kind: 'ignoredReads' | 'ignoredWrites', - ctx: LoadedContext -): boolean { - const { target, projectNode } = resolveIdentity(taskId, ctx.projectGraph); - const globs = projectNode.data.targets?.[target]?.sandbox?.[kind]; - if (!globs?.length) return false; - return matchGlobPaths(globs.map(normalizePath), [path])[0]; -} - // ── API (exported from devkit-internals) ───────────────────────────────────── // // Paths may be given workspace-relative or absolute, in either separator style; @@ -439,8 +418,7 @@ export type InputCategory = | 'dependentTasksOutputFiles' | 'runtime' | 'environment' - | 'external' - | 'sandboxIgnored'; + | 'external'; export interface InputCandidate { /** The value as supplied — matched verbatim against environment/runtime/external. */ @@ -457,9 +435,7 @@ export interface InputCandidate { * - a file in the task's materialized `depOutputs` (upstream has run); * - a file matching a `dependentTasksOutputFiles` glob declared on the task * that lies inside the declared outputs of an upstream task in the task - * graph (static — works even when upstream tasks have not yet run); - * - a file matching a `sandbox.ignoredReads` glob of the task's target - * (deliberately excluded from sandboxing reports). + * graph (static — works even when upstream tasks have not yet run). * * `categories` records the rule each matched value satisfied. Paths may be * workspace-relative or absolute; absolute ones are relativized against the @@ -520,8 +496,6 @@ export async function checkFilesAreInputs( * Uses the same path-matching logic as the task runner (directory containment * + glob matching through the native `globset` engine), including negated * (`!`-prefixed) patterns acting as exclusions over the whole pattern set. - * A file matching a `sandbox.ignoredWrites` glob of the task's target also - * counts as matched — it is deliberately excluded from sandboxing reports. * * Paths may be workspace-relative or absolute; absolute ones are relativized * against the workspace root. An output pattern whose `{options.*}` token has no @@ -543,15 +517,14 @@ export async function checkFilesAreOutputs( // malformed task, even when the file list is empty. resolveIdentity(taskId, ctx.projectGraph); const patterns = getOutputs(taskId, ctx.projectGraph); - const relativePaths = files.map(toWorkspaceRelativePath); - const results = matchOutputPaths(patterns, relativePaths); + const results = matchOutputPaths( + patterns, + files.map(toWorkspaceRelativePath) + ); const matched: string[] = []; const unmatched: string[] = []; files.forEach((file, i) => { - if ( - results[i] || - matchesSandboxIgnoredGlobs(taskId, relativePaths[i], 'ignoredWrites', ctx) - ) { + if (results[i]) { matched.push(file); } else { unmatched.push(file); diff --git a/packages/nx/src/native/index.d.ts b/packages/nx/src/native/index.d.ts index d5c47373b91..fac22970f00 100644 --- a/packages/nx/src/native/index.d.ts +++ b/packages/nx/src/native/index.d.ts @@ -743,6 +743,8 @@ export interface Task { parallelism?: boolean /** This denotes if the task runs continuously */ continuous?: boolean + /** The target's observed-IO sandbox configuration, if declared */ + sandbox?: TaskSandboxConfiguration } /** Graph of Tasks to be executed */ @@ -789,6 +791,26 @@ export interface TaskRun { end: number } +/** Observed-IO sandbox configuration of a task's target */ +export interface TaskSandboxConfiguration { + /** + * Whether tasks for this target are tracked by the sandbox. + * Defaults to true. When false, no IO tracing is reported for the + * task, so no sandbox report is produced. + */ + enabled?: boolean + /** + * Workspace-relative glob patterns for reads that should be excluded + * from sandboxing reports. + */ + ignoredReads?: Array + /** + * Workspace-relative glob patterns for writes that should be excluded + * from sandboxing reports. + */ + ignoredWrites?: Array +} + export declare const enum TaskStatus { Success = 0, Failure = 1, diff --git a/packages/nx/src/native/tasks/types.rs b/packages/nx/src/native/tasks/types.rs index 85648e62514..1fb4c68d657 100644 --- a/packages/nx/src/native/tasks/types.rs +++ b/packages/nx/src/native/tasks/types.rs @@ -38,6 +38,24 @@ pub struct Task { pub parallelism: Option, /// This denotes if the task runs continuously pub continuous: Option, + /// The target's observed-IO sandbox configuration, if declared + pub sandbox: Option, +} + +/// Observed-IO sandbox configuration of a task's target +#[napi(object)] +#[derive(Default, Clone, Debug, PartialEq, Eq)] +pub struct TaskSandboxConfiguration { + /// Whether tasks for this target are tracked by the sandbox. + /// Defaults to true. When false, no IO tracing is reported for the + /// task, so no sandbox report is produced. + pub enabled: Option, + /// Workspace-relative glob patterns for reads that should be excluded + /// from sandboxing reports. + pub ignored_reads: Option>, + /// Workspace-relative glob patterns for writes that should be excluded + /// from sandboxing reports. + pub ignored_writes: Option>, } impl Task { diff --git a/packages/nx/src/tasks-runner/create-task-graph.spec.ts b/packages/nx/src/tasks-runner/create-task-graph.spec.ts index 5f4d12267a5..ab2bb58d3db 100644 --- a/packages/nx/src/tasks-runner/create-task-graph.spec.ts +++ b/packages/nx/src/tasks-runner/create-task-graph.spec.ts @@ -183,6 +183,30 @@ describe('createTaskGraph', () => { }); }); + it('should copy the target sandbox configuration onto the task', () => { + projectGraph.nodes['app1'].data.targets['test'].sandbox = { + enabled: false, + ignoredReads: ['tmp/**'], + ignoredWrites: ['scratch/**'], + }; + + const taskGraph = createTaskGraph( + projectGraph, + {}, + ['app1', 'lib1'], + ['test'], + undefined, + {} + ); + + expect(taskGraph.tasks['app1:test'].sandbox).toEqual({ + enabled: false, + ignoredReads: ['tmp/**'], + ignoredWrites: ['scratch/**'], + }); + expect(taskGraph.tasks['lib1:test'].sandbox).toBeUndefined(); + }); + it('should return tasks with outputs', () => { projectGraph.nodes.app1.data.targets.test.outputs = [ '{workspaceRoot}/dist/app1', diff --git a/packages/nx/src/tasks-runner/create-task-graph.ts b/packages/nx/src/tasks-runner/create-task-graph.ts index fe116baa209..1fa98b29c5d 100644 --- a/packages/nx/src/tasks-runner/create-task-graph.ts +++ b/packages/nx/src/tasks-runner/create-task-graph.ts @@ -407,6 +407,7 @@ export class ProcessTasks { cache: project.data.targets[target].cache ?? false, parallelism: project.data.targets[target].parallelism ?? true, continuous: project.data.targets[target].continuous ?? false, + sandbox: project.data.targets[target].sandbox, }; } diff --git a/packages/nx/src/tasks-runner/task-orchestrator.ts b/packages/nx/src/tasks-runner/task-orchestrator.ts index e5edbc49d45..c5d3307d683 100644 --- a/packages/nx/src/tasks-runner/task-orchestrator.ts +++ b/packages/nx/src/tasks-runner/task-orchestrator.ts @@ -202,7 +202,6 @@ export class TaskOrchestrator { async init() { this.setupSignalHandlers(); this.taskInvocationTracker?.cleanupStale(); - this.registerTaskSandboxConfigurations(); // Init the ForkedProcessTaskRunner, TasksSchedule, and Cache await Promise.all([ @@ -220,24 +219,6 @@ export class TaskOrchestrator { } } - /** - * Registers each task's `sandbox` target configuration with the - * TaskIOService so PID reporting is suppressed for tasks whose target - * opted out of sandboxing. - */ - private registerTaskSandboxConfigurations(): void { - const ioService = getTaskIOService(); - for (const task of Object.values(this.taskGraph.tasks)) { - const sandbox = getTargetConfigurationForTask( - task, - this.projectGraph - )?.sandbox; - if (sandbox) { - ioService.registerTaskSandboxConfiguration(task.id, sandbox); - } - } - } - async run() { await this.init(); @@ -434,6 +415,15 @@ export class TaskOrchestrator { // region Processing Scheduled Tasks private async processTask(taskId: string): Promise { const task = this.taskGraph.tasks[taskId]; + if (task.sandbox) { + // Suppresses PID reporting for tasks whose target opted out of + // sandboxing (`sandbox: { enabled: false }`), so no sandbox report is + // produced for them. Both run paths await processTask before spawning. + getTaskIOService().registerTaskSandboxConfiguration( + task.id, + task.sandbox + ); + } const taskSpecificEnv = getTaskSpecificEnv(task, this.projectGraph); if (!task.hash) { From a125e4a098e8efdff1c37fe008eb28472ac5a9a6 Mon Sep 17 00:00:00 2001 From: Craigory Coppola Date: Mon, 31 Aug 2026 21:39:54 -0400 Subject: [PATCH 03/13] chore(core): pin sandbox target configuration merge semantics Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YVrVQUAtU1aSQPuUxLkJsn --- .../target-merging.spec.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts b/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts index 092c1da2303..0f4c0109c14 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts @@ -441,6 +441,65 @@ describe('target merging', () => { expect(result.cache).not.toBeDefined(); }); }); + + describe('sandbox', () => { + it('should take the base sandbox when the target does not define one', () => { + const result = mergeTargetConfigurations( + { executor: 'nx:run-commands' }, + { + executor: 'nx:run-commands', + sandbox: { enabled: false, ignoredReads: ['tmp/**'] }, + } + ); + expect(result.sandbox).toEqual({ + enabled: false, + ignoredReads: ['tmp/**'], + }); + }); + + it('should replace the base sandbox wholesale when the target defines one', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + sandbox: { ignoredWrites: ['scratch/**'] }, + }, + { + executor: 'nx:run-commands', + sandbox: { enabled: false, ignoredReads: ['tmp/**'] }, + } + ); + expect(result.sandbox).toEqual({ ignoredWrites: ['scratch/**'] }); + }); + + it('should shallow-merge with the base sandbox via the spread token', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + sandbox: { + '...': true, + ignoredWrites: ['scratch/**'], + } as any, + }, + { + executor: 'nx:run-commands', + sandbox: { enabled: false, ignoredReads: ['tmp/**'] }, + } + ); + expect(result.sandbox).toEqual({ + enabled: false, + ignoredReads: ['tmp/**'], + ignoredWrites: ['scratch/**'], + }); + }); + + it('should not be merged for incompatible targets', () => { + const result = mergeTargetConfigurations( + { executor: 'foo' }, + { executor: 'bar', sandbox: { enabled: false } } + ); + expect(result.sandbox).not.toBeDefined(); + }); + }); }); describe('spread syntax in mergeTargetConfigurations', () => { From 513f916977d791222c6fc6d8227de5483e3f856f Mon Sep 17 00:00:00 2001 From: Craigory Coppola Date: Mon, 31 Aug 2026 21:43:32 -0400 Subject: [PATCH 04/13] feat(core): permit the spread token in the sandbox schema Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YVrVQUAtU1aSQPuUxLkJsn --- packages/nx/schemas/nx-schema.json | 5 +++++ packages/nx/schemas/project-schema.json | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/packages/nx/schemas/nx-schema.json b/packages/nx/schemas/nx-schema.json index 804d49a117f..312469538fd 100644 --- a/packages/nx/schemas/nx-schema.json +++ b/packages/nx/schemas/nx-schema.json @@ -1053,6 +1053,11 @@ "type": "object", "description": "Configures observed-IO sandboxing for tasks of this target", "properties": { + "...": { + "type": "boolean", + "const": true, + "description": "Merges with the inherited sandbox configuration instead of replacing it. Keys before this token defer to the inherited value; keys after it win." + }, "enabled": { "type": "boolean", "default": true, diff --git a/packages/nx/schemas/project-schema.json b/packages/nx/schemas/project-schema.json index 048d7eccf4a..9f893bddb8a 100644 --- a/packages/nx/schemas/project-schema.json +++ b/packages/nx/schemas/project-schema.json @@ -142,6 +142,11 @@ "type": "object", "description": "Configures observed-IO sandboxing for tasks of this target", "properties": { + "...": { + "type": "boolean", + "const": true, + "description": "Merges with the inherited sandbox configuration instead of replacing it. Keys before this token defer to the inherited value; keys after it win." + }, "enabled": { "type": "boolean", "default": true, From f603eac40dab9d247f8b56d00e3b58bc087aaaaf Mon Sep 17 00:00:00 2001 From: Craigory Coppola Date: Tue, 1 Sep 2026 22:44:40 -0400 Subject: [PATCH 05/13] feat(core): permit the spread token on the sandbox authoring type TargetSandboxConfiguration aliased the resolved napi shape, which has no '...' member, so authoring a sandbox spread in TypeScript failed to compile on a form the schema, the merge implementation and the PR's own test all accept. The test needed an `as any` to construct it. Adds Spreadable and applies it to TargetConfiguration['sandbox']. Task['sandbox'] keeps the resolved type, since merging resolves the token away before a task is built. --- .../nx/src/config/workspace-json-project-json.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/nx/src/config/workspace-json-project-json.ts b/packages/nx/src/config/workspace-json-project-json.ts index 11bb76395ee..ec7c7646794 100644 --- a/packages/nx/src/config/workspace-json-project-json.ts +++ b/packages/nx/src/config/workspace-json-project-json.ts @@ -174,13 +174,21 @@ export interface TargetMetadata { }; } +/** + * Authoring form of a property merged key-by-key: permits the `'...'` spread + * token alongside the resolved keys. Merging resolves the token, so it never + * appears on the resolved shape. + */ +export type Spreadable = T & { '...'?: true }; + /** * Configuration for observed-IO sandboxing of a target's tasks. * - * The same shape rides on each Task instance (`Task['sandbox']`), so the - * type is shared with the native task definition. + * The resolved shape rides on each Task instance (`Task['sandbox']`), which is + * why it is shared with the native task definition. Authoring additionally + * permits `'...'`, which target merging resolves away. */ -export type TargetSandboxConfiguration = TaskSandboxConfiguration; +export type TargetSandboxConfiguration = Spreadable; export interface TargetDependencyConfig { /** From d391eb2fb0af24f833358552cf2bb6ae26bbb2af Mon Sep 17 00:00:00 2001 From: Craigory Coppola Date: Tue, 1 Sep 2026 22:44:48 -0400 Subject: [PATCH 06/13] fix(core): resolve the spread token inside sandbox glob arrays sandbox rode the generic top-level merge, which assigns the object without recursing into its values. mergeObjectWithSpread does a raw per-key copy for the same reason, so a nested `'...'` was unresolved in BOTH the wholesale and the object-spread branches: an inherited ignoredReads was silently dropped and the literal '...' reached the task graph as a glob matching nothing. Nothing rejects it, since the schema types the items as plain strings. Gives sandbox its own merge, next to options and configurations. The object level keeps replace-unless-'...' semantics, which the schema documents and the existing specs pin; only the values now merge per key, so a nested spread expands against the inherited value. The merge copies before resolving. Without an object-level spread getMergeValueResult returns the incoming object itself, so writing through it would edit the caller's target in place and corrupt later merge layers. --- .../target-merging.spec.ts | 72 +++++++++++++++- .../project-configuration/target-merging.ts | 83 +++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts b/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts index 0f4c0109c14..719b88df772 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts @@ -478,7 +478,7 @@ describe('target merging', () => { sandbox: { '...': true, ignoredWrites: ['scratch/**'], - } as any, + }, }, { executor: 'nx:run-commands', @@ -499,6 +499,76 @@ describe('target merging', () => { ); expect(result.sandbox).not.toBeDefined(); }); + + it('should resolve the spread token inside ignoredReads', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + sandbox: { ignoredReads: ['...', 'tmp/**'] }, + }, + { + executor: 'nx:run-commands', + sandbox: { ignoredReads: ['dist/**'] }, + } + ); + expect(result.sandbox).toEqual({ + ignoredReads: ['dist/**', 'tmp/**'], + }); + }); + + it('should resolve the spread token inside ignoredWrites under an object spread', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + sandbox: { + '...': true, + ignoredWrites: ['...', 'scratch/**'], + }, + }, + { + executor: 'nx:run-commands', + sandbox: { enabled: false, ignoredWrites: ['dist/**'] }, + } + ); + expect(result.sandbox).toEqual({ + enabled: false, + ignoredWrites: ['dist/**', 'scratch/**'], + }); + }); + + it('should not mutate the target it was given', () => { + const target = { + executor: 'nx:run-commands', + sandbox: { ignoredReads: ['...', 'tmp/**'] }, + }; + const before = JSON.stringify(target); + + mergeTargetConfigurations(target, { + executor: 'nx:run-commands', + sandbox: { ignoredReads: ['dist/**'] }, + }); + + expect(JSON.stringify(target)).toEqual(before); + }); + + it('should never leave a literal spread token on the merged globs', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + sandbox: { ignoredReads: ['...'], ignoredWrites: ['...'] }, + }, + { + executor: 'nx:run-commands', + sandbox: { ignoredReads: ['dist/**'], ignoredWrites: ['out/**'] }, + } + ); + expect(result.sandbox.ignoredReads).not.toContain('...'); + expect(result.sandbox.ignoredWrites).not.toContain('...'); + expect(result.sandbox).toEqual({ + ignoredReads: ['dist/**'], + ignoredWrites: ['out/**'], + }); + }); }); }); diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts b/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts index 1c16bfe5b58..30693ce07ba 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts @@ -4,6 +4,7 @@ import { ProjectMetadata, TargetConfiguration, TargetMetadata, + TargetSandboxConfiguration, } from '../../../config/workspace-json-project-json'; import { recordSourceMapKeysByIndex, @@ -231,6 +232,71 @@ function mergeConfigurationValue( return merged; } +// `sandbox` keeps replace-unless-`'...'` semantics at the object level, but its +// values still have to be merged per key: neither the wholesale replace nor +// `mergeObjectWithSpread` recurses, so an `ignoredReads: ['...', 'tmp/**']` +// would otherwise reach the task graph with `'...'` intact — a glob matching +// nothing, silently dropping the inherited patterns. +function mergeSandbox( + newSandbox: TargetSandboxConfiguration | undefined, + baseSandbox: TargetSandboxConfiguration | undefined, + projectConfigSourceMap?: Record, + sourceInformation?: SourceInformation, + targetIdentifier?: string, + deferSpreadsWithoutBase?: boolean +): TargetSandboxConfiguration | undefined { + if (newSandbox === undefined) { + return baseSandbox; + } + + const sourceMapContext = projectConfigSourceMap + ? { + sourceMap: projectConfigSourceMap, + key: `${targetIdentifier}.sandbox`, + sourceInformation, + } + : undefined; + + // Object level first: this settles which keys survive, their order, and + // their source-map attribution. + const merged = getMergeValueResult( + baseSandbox, + newSandbox, + sourceMapContext, + deferSpreadsWithoutBase + ); + + if (!merged || typeof merged !== 'object') { + return merged; + } + + // Copy before resolving: with no object-level spread `getMergeValueResult` + // returns `newSandbox` itself, and writing through it would edit the + // caller's target in place, corrupting later merge layers. + const resolved = { ...merged }; + + // Then re-resolve any key the target authored as an array, so a nested + // `'...'` expands against the inherited value rather than surviving as a + // literal element. + for (const key of Object.keys(resolved)) { + if (!Array.isArray(newSandbox[key])) continue; + resolved[key] = getMergeValueResult( + baseSandbox?.[key], + newSandbox[key], + projectConfigSourceMap + ? { + sourceMap: projectConfigSourceMap, + key: `${targetIdentifier}.sandbox.${key}`, + sourceInformation, + } + : undefined, + deferSpreadsWithoutBase + ); + } + + return resolved; +} + function mergeConfigurations( newConfigurations: Record | undefined, baseConfigurations: Record | undefined, @@ -455,6 +521,7 @@ export function mergeTargetConfigurations( const skipForOwnMerge = new Set([ 'options', 'configurations', + 'sandbox', NX_SPREAD_TOKEN, ]); @@ -611,6 +678,22 @@ export function mergeTargetConfigurations( } } + // merge sandbox if either side declares one + // as with options, an incompatible target discards the base + if (target.sandbox || (isCompatible && baseTarget?.sandbox)) { + const mergedSandbox = mergeSandbox( + target.sandbox, + isCompatible ? baseTarget?.sandbox : undefined, + projectConfigSourceMap, + sourceInformation, + targetIdentifier, + deferSpreadsWithoutBase + ); + if (mergedSandbox !== undefined) { + result.sandbox = mergedSandbox; + } + } + if (target.metadata) { result.metadata = mergeMetadata( projectConfigSourceMap, From 8bb79d329dcac796770ff0445133f95f94a10d55 Mon Sep 17 00:00:00 2001 From: Craigory Coppola Date: Tue, 1 Sep 2026 22:44:58 -0400 Subject: [PATCH 07/13] feat(core): validate the sandbox target configuration The JSON schema is editor-only and nothing validated sandbox at runtime, so a malformed value reached the task graph verbatim. Downstream consumers are strict: a wrong-typed ignoredReads is rejected by the cloud runner's request validation, failing the run rather than the target. Validates the effective value during normalization, where the project, target and source file are all still in hand, and throws naming all three. This runs after every createNodes result is merged, so plugin-inferred targets are covered too. Checks shape only. A syntactically invalid glob is a well-formed string and is deliberately out of scope. --- .../target-normalization.spec.ts | 76 ++++++++++++++++ .../target-normalization.ts | 91 +++++++++++++++++++ .../utils/project-configuration/utils.ts | 2 +- 3 files changed, 168 insertions(+), 1 deletion(-) diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-normalization.spec.ts b/packages/nx/src/project-graph/utils/project-configuration/target-normalization.spec.ts index 57b5d8159c1..168011683d7 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/target-normalization.spec.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/target-normalization.spec.ts @@ -119,6 +119,82 @@ describe('validateAndNormalizeProjectRootMap', () => { expect(projectRootMap['libs/a/ui'].name).toEqual('ui'); }); + describe('sandbox validation', () => { + const projectRootMapWithSandbox = (sandbox: unknown) => ({ + 'libs/a/ui': { + root: 'libs/a/ui', + name: 'a-ui', + targets: { build: { executor: 'nx:run-commands', sandbox } }, + }, + }); + + it('should reject a non-object sandbox', () => { + expect(() => + validateAndNormalizeProjectRootMap( + tempFs.tempDir, + projectRootMapWithSandbox(false) as any, + {} + ) + ).toThrow(/"sandbox" configuration for target "build" in project "a-ui"/); + }); + + it('should reject a string where a glob array is required', () => { + expect(() => + validateAndNormalizeProjectRootMap( + tempFs.tempDir, + projectRootMapWithSandbox({ ignoredReads: 'tmp/**' }) as any, + {} + ) + ).toThrow( + /"sandbox.ignoredReads" for target "build" in project "a-ui" must be an array of glob patterns, but it is a string/ + ); + }); + + it('should reject a non-string element inside a glob array', () => { + expect(() => + validateAndNormalizeProjectRootMap( + tempFs.tempDir, + projectRootMapWithSandbox({ ignoredWrites: ['ok/**', 7] }) as any, + {} + ) + ).toThrow(/"sandbox.ignoredWrites\[1\]".*must be a glob pattern string/); + }); + + it('should reject a non-boolean enabled', () => { + expect(() => + validateAndNormalizeProjectRootMap( + tempFs.tempDir, + projectRootMapWithSandbox({ enabled: 'false' }) as any, + {} + ) + ).toThrow(/"sandbox.enabled".*must be a boolean, but it is a string/); + }); + + it('should accept a well-formed sandbox', () => { + expect(() => + validateAndNormalizeProjectRootMap( + tempFs.tempDir, + projectRootMapWithSandbox({ + enabled: false, + ignoredReads: ['tmp/**'], + ignoredWrites: ['scratch/**'], + }) as any, + {} + ) + ).not.toThrow(); + }); + + it('should accept a target with no sandbox', () => { + expect(() => + validateAndNormalizeProjectRootMap( + tempFs.tempDir, + projectRootMapWithSandbox(undefined) as any, + {} + ) + ).not.toThrow(); + }); + }); + it('should fall back to the folder name when project.json cannot be parsed', () => { tempFs.createFilesSync({ 'libs/a/ui/project.json': 'not json', diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-normalization.ts b/packages/nx/src/project-graph/utils/project-configuration/target-normalization.ts index 15158acc3b9..eef3f7515db 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/target-normalization.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/target-normalization.ts @@ -28,6 +28,7 @@ import { resolveCommandSyntacticSugar, resolveNxTokensInOptions, } from './target-merging'; +import { isObject } from './utils'; import type { ConfigurationSourceMaps } from './source-maps'; @@ -254,6 +255,88 @@ function warnAboutLegacyCachedTargets( }); } +export class InvalidTargetSandboxError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidTargetSandboxError'; + } +} + +function describeSandboxValue(value: unknown): string { + if (Array.isArray(value)) return 'an array'; + if (value === null) return 'null'; + return `a ${typeof value}`; +} + +/** + * Rejects a `sandbox` whose shape the schema forbids. + * + * The schema is editor-only, and everything downstream — the Rust task hasher, + * the cloud runner's Go and Kotlin deserializers — is strict. A bad value that + * gets this far is reported far from its source, or silently drops the task's + * tracking, so it is worth failing here where the project, target and file are + * all still in hand. + */ +function validateTargetSandbox( + sandbox: unknown, + projectName: string, + projectRoot: string, + targetName: string, + sourceMaps: ConfigurationSourceMaps +): void { + if (sandbox === undefined) { + return; + } + + const targetSourceMaps = sourceMaps?.[projectRoot]; + const [file, plugin] = + targetSourceMaps?.[`targets.${targetName}.sandbox`] ?? + targetSourceMaps?.[`targets.${targetName}`] ?? + []; + const origin = file + ? ` (defined in ${file})` + : plugin + ? ` (defined by ${plugin})` + : ''; + const where = `"${targetName}" in project "${projectName}"${origin}`; + + if (!isObject(sandbox)) { + throw new InvalidTargetSandboxError( + `The "sandbox" configuration for target ${where} must be an object, but it is ${describeSandboxValue( + sandbox + )}.` + ); + } + + if (sandbox.enabled !== undefined && typeof sandbox.enabled !== 'boolean') { + throw new InvalidTargetSandboxError( + `"sandbox.enabled" for target ${where} must be a boolean, but it is ${describeSandboxValue( + sandbox.enabled + )}. Use \`false\` to opt the target out of observed-IO tracking.` + ); + } + + for (const key of ['ignoredReads', 'ignoredWrites'] as const) { + const value = sandbox[key]; + if (value === undefined) continue; + if (!Array.isArray(value)) { + throw new InvalidTargetSandboxError( + `"sandbox.${key}" for target ${where} must be an array of glob patterns, but it is ${describeSandboxValue( + value + )}.` + ); + } + const badIndex = value.findIndex((glob) => typeof glob !== 'string'); + if (badIndex !== -1) { + throw new InvalidTargetSandboxError( + `"sandbox.${key}[${badIndex}]" for target ${where} must be a glob pattern string, but it is ${describeSandboxValue( + value[badIndex] + )}.` + ); + } + } +} + function normalizeTargets( project: ProjectConfiguration, sourceMaps: ConfigurationSourceMaps, @@ -282,6 +365,14 @@ function normalizeTargets( const target = project.targets[targetName]; + validateTargetSandbox( + target.sandbox, + project.name ?? project.root, + project.root, + targetName, + sourceMaps + ); + const targetDefaults = nxJsonConfiguration.targetDefaults; if (isLegacyCachedTarget(targetName, targetDefaults, target)) { target.cache = true; diff --git a/packages/nx/src/project-graph/utils/project-configuration/utils.ts b/packages/nx/src/project-graph/utils/project-configuration/utils.ts index 88464bc71a0..6af665aec42 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/utils.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/utils.ts @@ -308,6 +308,6 @@ function writeTopLevelSourceMap(ctx: SourceMapContext | undefined): void { } } -function isObject(value: unknown): value is Record { +export function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } From 5d52d26028249a59f1cc6f3e16c2d3ea2cbf4102 Mon Sep 17 00:00:00 2001 From: Craigory Coppola Date: Tue, 1 Sep 2026 22:44:58 -0400 Subject: [PATCH 08/13] docs(core): document the sandbox target property sandbox had no section in the project configuration reference, while every sibling target property does. The violation-fixing guide routed readers to .nx/workflows/sandboxing-config.yaml with no mention that a per-target property exists, so anyone following the official remediation path was steered to the workspace-wide mechanism. Adds the reference section, cross-links both directions, and states that the two mechanisms combine rather than override. Also corrects the spread token level table, which was presented as exhaustive and did not list sandbox. --- .../docs/features/CI Features/sandboxing.mdoc | 4 ++ .../docs/kb/fix-sandbox-violations.mdoc | 20 ++++++- .../docs/reference/project-configuration.mdoc | 56 ++++++++++++++++++- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/astro-docs/src/content/docs/features/CI Features/sandboxing.mdoc b/astro-docs/src/content/docs/features/CI Features/sandboxing.mdoc index d7a7d3fa209..38225495262 100644 --- a/astro-docs/src/content/docs/features/CI Features/sandboxing.mdoc +++ b/astro-docs/src/content/docs/features/CI Features/sandboxing.mdoc @@ -278,6 +278,10 @@ Use `task-exclusions` to scope exclusions to a specific project, target, or comb Patterns use glob syntax relative to the workspace root. +To exclude paths for a single target, or to stop tracking it altogether, use the +[`sandbox` target property](/docs/reference/project-configuration#sandbox). +Its patterns add to the ones here rather than replacing them. + ## Cloud settings Once sandboxing is enabled, configure the enforcement mode in the Nx Cloud workspace settings under diff --git a/astro-docs/src/content/docs/kb/fix-sandbox-violations.mdoc b/astro-docs/src/content/docs/kb/fix-sandbox-violations.mdoc index a8e65d87281..b4e736bb2e3 100644 --- a/astro-docs/src/content/docs/kb/fix-sandbox-violations.mdoc +++ b/astro-docs/src/content/docs/kb/fix-sandbox-violations.mdoc @@ -21,7 +21,7 @@ Follow the steps from this guide exactly: {pageUrl} - (a) Nx config issue - the task legitimately needs the access. Inspect resolved config with `npx nx show target : --json`, then extend `inputs`/`outputs` in `project.json`, declare a `dependsOn`, or use `dependentTasksOutputFiles`. On Nx 23+ prefer the spread token `"..."` to append to plugin-inferred config without re-listing it. - (b) Task or app bug - the unexpected read/write reflects a problem in the script, build tool, or application code itself. Fix the offending code, not the Nx config. - - (c) Benign access, last resort - exclude the path in `.nx/workflows/sandboxing-config.yaml` (`exclude-reads`/`exclude-writes` or `task-exclusions`). Use sparingly, like a `ts-ignore` - excluded paths are not tracked... + - (c) Benign access, last resort - exclude the path. For a single target use `sandbox.ignoredReads`/`sandbox.ignoredWrites` in `project.json`; for the whole workspace use `.nx/workflows/sandboxing-config.yaml` (`exclude-reads`/`exclude-writes` or `task-exclusions`). Use sparingly, like a `ts-ignore` - excluded paths are not tracked... 5. After editing, run `npx nx reset` then re-validate the same per-task report. Once it reports `ok: true`, go back to step 2 to pick the next task. Loop until every task in `index.json` is clean. 6. Commit and push. After CI runs, re-download with `--since 1h` and re-validate to catch tasks that did not run locally. Print the **Sandbox violations dashboard** URL for the branch to the user (you cannot open it yourself) so they can confirm fixed tasks no longer appear - cache hits will not regenerate reports, so a clean local validate is not enough on its own. @@ -110,7 +110,23 @@ Fix the offending code or script, not the Nx config. ### Benign access, last resort The access is harmless and not worth a per-task fix. -Exclude the path in `.nx/workflows/sandboxing-config.yaml` (see [Excluding paths](/docs/features/ci-features/sandboxing#excluding-paths) for the full reference): + +When the access belongs to one target, exclude it on that target with [`sandbox`](/docs/reference/project-configuration#sandbox): + +```json +// apps/myapp/project.json +{ + "targets": { + "build": { + "sandbox": { + "ignoredReads": [".next/cache/**"] + } + } + } +} +``` + +When the access spans the workspace, exclude the path in `.nx/workflows/sandboxing-config.yaml` (see [Excluding paths](/docs/features/ci-features/sandboxing#excluding-paths) for the full reference): ```yaml # .nx/workflows/sandboxing-config.yaml diff --git a/astro-docs/src/content/docs/reference/project-configuration.mdoc b/astro-docs/src/content/docs/reference/project-configuration.mdoc index bbebb9d4d99..1ea67c43b7b 100644 --- a/astro-docs/src/content/docs/reference/project-configuration.mdoc +++ b/astro-docs/src/content/docs/reference/project-configuration.mdoc @@ -595,6 +595,58 @@ And the E2E project's `e2e` task has a dependency on the `serve` task, which ens } ``` +### Sandbox + +When [sandboxing](/docs/features/ci-features/sandboxing) is enabled, Nx tracks the files each task reads and writes. The `sandbox` property adjusts that tracking for a single target. + +Set `"enabled": false` to stop tracking a target's tasks: + +```json +{ + "targets": { + "build": { + "sandbox": { + "enabled": false + } + } + } +} +``` + +Use `ignoredReads` and `ignoredWrites` to keep tracking the target while excluding paths from its report. The patterns are globs relative to the workspace root. + +```json +{ + "targets": { + "build": { + "sandbox": { + "ignoredReads": ["tmp/**"], + "ignoredWrites": ["**/.cache/**"] + } + } + } +} +``` + +A target that defines `sandbox` replaces the inherited configuration rather than adding to it. Use the [spread token](#spread-token) to merge instead, either at the object level to keep inherited keys, or inside `ignoredReads` and `ignoredWrites` to keep inherited patterns. + +```json +{ + "targets": { + "build": { + "sandbox": { + "...": true, + "ignoredReads": ["...", "tmp/**"] + } + } + } +} +``` + +{% aside type="note" title="Workspace-wide exclusions live elsewhere" %} +`sandbox` applies to one target. To exclude paths across every task in the workspace, use `.nx/workflows/sandboxing-config.yaml`. See [Excluding paths](/docs/features/ci-features/sandboxing#excluding-paths). The two combine: a task is checked against its own `sandbox` patterns and the workspace-wide ones together, so neither overrides the other. +{% /aside %} + ### Sync generators In the same way that `dependsOn` tells Nx to run another task before running this task, the `syncGenerator` property tells Nx to run a generator to ensure that your files are in the correct state before this task is run. [Sync generators](/docs/concepts/sync-generators) are especially useful for keeping configuration files up to date with the project graph. @@ -680,7 +732,7 @@ If the inferred target has `inputs: ["default", "^production"]`, the result is ` } ``` -Nx processes target configuration down to `options[x]` and `configurations[x][y]`; values below that are opaque to the merge pipeline. Spread is therefore resolved at these levels: +Nx processes target configuration down to `options[x]`, `configurations[x][y]`, and the keys of `sandbox`; values below that are opaque to the merge pipeline. Spread is therefore resolved at these levels: | Level | Example | | -------------------------------------------------- | --------------------------------------------------------------------------- | @@ -691,6 +743,8 @@ Nx processes target configuration down to `options[x]` and `configurations[x][y] | `configurations` | `"configurations": { "my-config": { ... }, "...": true }` | | `configurations[x]` | `"configurations": { "prod": { "...": true, "sourceMap": false } }` | | `configurations[x][y]` | `"configurations": { "prod": { "env": { "MY_VAR": "val", "...": true } } }` | +| `sandbox` | `"sandbox": { "...": true, "enabled": false }` | +| `sandbox.ignoredReads`, `sandbox.ignoredWrites` | `"sandbox": { "ignoredReads": ["...", "tmp/**"] }` | {% aside type="caution" title="Spread does not apply to deeply nested options" %} Because `options[x]` and `configurations[x][y]` are the innermost levels Nx inspects, a spread token nested any deeper has no effect. For example, `options.webpack = { "...": true }` works (the spread is at `options[x]`), but `options.webpack.plugins = { "...": true }` is ignored because the spread sits inside the opaque value Nx assigns to `options.webpack`. From 98bd78b33003dc1aa9c1f6cc49184c4a0793dbeb Mon Sep 17 00:00:00 2001 From: Craigory Coppola Date: Tue, 1 Sep 2026 22:52:32 -0400 Subject: [PATCH 09/13] cleanup(core): drop the unused sandbox state accessor isTaskSandboxDisabled had no production caller. Suppression happens inside notifyPidUpdate, and the only reads were two assertions in its own spec that sat alongside assertions on the observable behaviour they duplicated. Confirmed unused by Nx Cloud before removing, rather than inferred from nx alone: it consumes TaskIOService through getTaskIOService and the three subscribeTo* methods only, and reads the opt-out off task.sandbox rather than through the service. --- packages/nx/src/tasks-runner/task-io-service.spec.ts | 2 -- packages/nx/src/tasks-runner/task-io-service.ts | 4 ---- 2 files changed, 6 deletions(-) diff --git a/packages/nx/src/tasks-runner/task-io-service.spec.ts b/packages/nx/src/tasks-runner/task-io-service.spec.ts index a8d143a063c..cf41af77e21 100644 --- a/packages/nx/src/tasks-runner/task-io-service.spec.ts +++ b/packages/nx/src/tasks-runner/task-io-service.spec.ts @@ -23,7 +23,6 @@ describe('TaskIOService sandbox configuration', () => { service.notifyPidUpdate({ taskId: 'proj:other', pid: 201 }); expect(updates).toEqual([{ taskId: 'proj:other', pid: 201 }]); - expect(service.isTaskSandboxDisabled('proj:disabled')).toBe(true); }); it('keeps PID updates for a sandbox configuration without enabled: false', () => { @@ -37,7 +36,6 @@ describe('TaskIOService sandbox configuration', () => { service.notifyPidUpdate({ taskId: 'proj:ignores-only', pid: 300 }); expect(updates).toEqual([{ taskId: 'proj:ignores-only', pid: 300 }]); - expect(service.isTaskSandboxDisabled('proj:ignores-only')).toBe(false); }); it('re-enables PID updates when a task is re-registered as enabled', () => { diff --git a/packages/nx/src/tasks-runner/task-io-service.ts b/packages/nx/src/tasks-runner/task-io-service.ts index 3d68aff8748..bac244f388b 100644 --- a/packages/nx/src/tasks-runner/task-io-service.ts +++ b/packages/nx/src/tasks-runner/task-io-service.ts @@ -75,10 +75,6 @@ class TaskIOService { } } - isTaskSandboxDisabled(taskId: string): boolean { - return this.sandboxDisabledTaskIds.has(taskId); - } - /** * Subscribe to task PID updates. * Receives notifications when processes are added/removed from tasks. From ac4fde86ab2ef081bef9ff3d14baed95b8a4586f Mon Sep 17 00:00:00 2001 From: Craigory Coppola Date: Tue, 1 Sep 2026 23:30:32 -0400 Subject: [PATCH 10/13] fix(core): keep a falsy sandbox and honour authored position Two regressions from the previous commits, both found by re-review. The merge gated on `target.sandbox` being truthy while 'sandbox' was also in skipForOwnMerge, so `"sandbox": false` was dropped from the merged target entirely and validation never saw the exact input it exists to reject. Before these commits that value survived to the napi boundary and threw there, so the fix had turned a loud error into a silent one: the opt-out quietly did nothing. Gating on key presence restores it. An array sandbox was also object-ified into a valid empty config by the copy, so the guard now excludes arrays. The per-key pass also keyed off Array.isArray alone and overwrote the object-level merge's decision even where that decision was base-wins, so the authored position of a glob array had no effect while `enabled` in the same object still honoured it. It now skips keys authored before the spread that the base already provides, matching how the target level computes the same set. The validation specs built root maps the pipeline cannot produce, which is why neither escaped there. They now also run through mergeCreateNodesResults. --- .../target-merging.spec.ts | 51 +++++++++++++++++++ .../project-configuration/target-merging.ts | 21 +++++++- .../target-normalization.spec.ts | 51 +++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts b/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts index 719b88df772..f3fe8c210d2 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/target-merging.spec.ts @@ -536,6 +536,57 @@ describe('target merging', () => { }); }); + it('should keep a falsy sandbox so validation can reject it', () => { + const result = mergeTargetConfigurations( + { executor: 'nx:run-commands', sandbox: false as any }, + { executor: 'nx:run-commands' } + ); + expect('sandbox' in result).toBe(true); + expect(result.sandbox).toBe(false); + }); + + it('should not turn an array sandbox into an empty object', () => { + const result = mergeTargetConfigurations( + { executor: 'nx:run-commands', sandbox: [] as any }, + { executor: 'nx:run-commands' } + ); + expect(result.sandbox).toEqual([]); + }); + + it('should let the base win for a glob array authored before the spread', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + sandbox: { ignoredReads: ['tmp/**'], '...': true }, + }, + { + executor: 'nx:run-commands', + sandbox: { ignoredReads: ['dist/**'], enabled: false }, + } + ); + expect(result.sandbox).toEqual({ + ignoredReads: ['dist/**'], + enabled: false, + }); + }); + + it('should let the target win for a glob array authored after the spread', () => { + const result = mergeTargetConfigurations( + { + executor: 'nx:run-commands', + sandbox: { '...': true, ignoredReads: ['tmp/**'] }, + }, + { + executor: 'nx:run-commands', + sandbox: { ignoredReads: ['dist/**'], enabled: false }, + } + ); + expect(result.sandbox).toEqual({ + ignoredReads: ['tmp/**'], + enabled: false, + }); + }); + it('should not mutate the target it was given', () => { const target = { executor: 'nx:run-commands', diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts b/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts index 30693ce07ba..59dbf3e3ace 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/target-merging.ts @@ -266,7 +266,7 @@ function mergeSandbox( deferSpreadsWithoutBase ); - if (!merged || typeof merged !== 'object') { + if (!merged || typeof merged !== 'object' || Array.isArray(merged)) { return merged; } @@ -275,11 +275,25 @@ function mergeSandbox( // caller's target in place, corrupting later merge layers. const resolved = { ...merged }; + // Keys authored before `'...'` let the base win, exactly as they do at the + // target level. Re-resolving those would overwrite the object-level merge's + // decision and make authored position meaningless for array keys while + // still honouring it for scalar ones. + const authoredKeys = Object.keys(newSandbox); + const spreadPosition = authoredKeys.indexOf(NX_SPREAD_TOKEN); + const keysBeforeSpread = + spreadPosition >= 0 + ? new Set(authoredKeys.slice(0, spreadPosition)) + : new Set(); + // Then re-resolve any key the target authored as an array, so a nested // `'...'` expands against the inherited value rather than surviving as a // literal element. for (const key of Object.keys(resolved)) { if (!Array.isArray(newSandbox[key])) continue; + if (keysBeforeSpread.has(key) && baseSandbox && key in baseSandbox) { + continue; + } resolved[key] = getMergeValueResult( baseSandbox?.[key], newSandbox[key], @@ -680,7 +694,10 @@ export function mergeTargetConfigurations( // merge sandbox if either side declares one // as with options, an incompatible target discards the base - if (target.sandbox || (isCompatible && baseTarget?.sandbox)) { + if ( + 'sandbox' in target || + (isCompatible && baseTarget && 'sandbox' in baseTarget) + ) { const mergedSandbox = mergeSandbox( target.sandbox, isCompatible ? baseTarget?.sandbox : undefined, diff --git a/packages/nx/src/project-graph/utils/project-configuration/target-normalization.spec.ts b/packages/nx/src/project-graph/utils/project-configuration/target-normalization.spec.ts index 168011683d7..5825c75cb25 100644 --- a/packages/nx/src/project-graph/utils/project-configuration/target-normalization.spec.ts +++ b/packages/nx/src/project-graph/utils/project-configuration/target-normalization.spec.ts @@ -119,6 +119,57 @@ describe('validateAndNormalizeProjectRootMap', () => { expect(projectRootMap['libs/a/ui'].name).toEqual('ui'); }); + describe('sandbox validation through the real merge pipeline', () => { + // The root-map tests below construct shapes the pipeline cannot produce. + // This one goes through mergeCreateNodesResults so a falsy sandbox is + // proven to reach validation the way an authored project.json would. + const resultsFor = (sandbox: unknown) => [ + [ + [ + 'nx/core/project-json', + 'libs/a/ui/project.json', + { + projects: { + 'libs/a/ui': { + name: 'a-ui', + root: 'libs/a/ui', + targets: { build: { executor: 'nx:run-commands', sandbox } }, + }, + }, + }, + ], + ], + ]; + + it('rejects sandbox: false authored on a project', async () => { + const { mergeCreateNodesResults } = + await import('../project-configuration-utils'); + expect(() => + mergeCreateNodesResults( + resultsFor(false) as any, + [], + {} as any, + tempFs.tempDir, + [] + ) + ).toThrow(/"sandbox" configuration for target "build"/); + }); + + it('accepts a well-formed sandbox authored on a project', async () => { + const { mergeCreateNodesResults } = + await import('../project-configuration-utils'); + expect(() => + mergeCreateNodesResults( + resultsFor({ enabled: false, ignoredReads: ['tmp/**'] }) as any, + [], + {} as any, + tempFs.tempDir, + [] + ) + ).not.toThrow(); + }); + }); + describe('sandbox validation', () => { const projectRootMapWithSandbox = (sandbox: unknown) => ({ 'libs/a/ui': { From 687649b2d8f75159082393a388937005a43bceae Mon Sep 17 00:00:00 2001 From: Craigory Coppola Date: Tue, 1 Sep 2026 23:35:28 -0400 Subject: [PATCH 11/13] docs(core): document the unsupported sandbox glob forms isSupportedGlob rejects any pattern whose first segment contains '*', plus '?', '!', '[', ']' and extglobs, so '**/generated/**' and '**/*.log' are honoured while a task runs and then dropped from its snapshot. Those are the first forms a user reaches for, and neither the schema description nor the reference page said anything about it. Documents the constraint in both schemas and the reference page. The silent drop itself is a cloud-side fix; this is about the promise the schema makes. --- .../src/content/docs/reference/project-configuration.mdoc | 4 ++++ packages/nx/schemas/nx-schema.json | 4 ++-- packages/nx/schemas/project-schema.json | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/astro-docs/src/content/docs/reference/project-configuration.mdoc b/astro-docs/src/content/docs/reference/project-configuration.mdoc index 1ea67c43b7b..b37dd4a655f 100644 --- a/astro-docs/src/content/docs/reference/project-configuration.mdoc +++ b/astro-docs/src/content/docs/reference/project-configuration.mdoc @@ -628,6 +628,10 @@ Use `ignoredReads` and `ignoredWrites` to keep tracking the target while excludi } ``` +{% aside type="caution" title="Not every glob is supported" %} +The first path segment cannot contain `*`, so `**/generated/**` is rejected. `?`, `!`, `[`, `]` and extglobs are also unsupported. Anchor the pattern to a directory instead, such as `libs/*/generated/**`. +{% /aside %} + A target that defines `sandbox` replaces the inherited configuration rather than adding to it. Use the [spread token](#spread-token) to merge instead, either at the object level to keep inherited keys, or inside `ignoredReads` and `ignoredWrites` to keep inherited patterns. ```json diff --git a/packages/nx/schemas/nx-schema.json b/packages/nx/schemas/nx-schema.json index 312469538fd..fa2125b31e9 100644 --- a/packages/nx/schemas/nx-schema.json +++ b/packages/nx/schemas/nx-schema.json @@ -1068,14 +1068,14 @@ "items": { "type": "string" }, - "description": "Workspace-relative glob patterns for reads excluded from sandboxing reports" + "description": "Workspace-relative glob patterns for reads excluded from sandboxing reports. The first path segment cannot contain '*', and '?', '!', '[', ']' and extglobs are not supported; anchor the pattern to a directory instead of leading with '**'." }, "ignoredWrites": { "type": "array", "items": { "type": "string" }, - "description": "Workspace-relative glob patterns for writes excluded from sandboxing reports" + "description": "Workspace-relative glob patterns for writes excluded from sandboxing reports. The first path segment cannot contain '*', and '?', '!', '[', ']' and extglobs are not supported; anchor the pattern to a directory instead of leading with '**'." } }, "additionalProperties": false diff --git a/packages/nx/schemas/project-schema.json b/packages/nx/schemas/project-schema.json index 9f893bddb8a..10934f7726a 100644 --- a/packages/nx/schemas/project-schema.json +++ b/packages/nx/schemas/project-schema.json @@ -157,14 +157,14 @@ "items": { "type": "string" }, - "description": "Workspace-relative glob patterns for reads excluded from sandboxing reports" + "description": "Workspace-relative glob patterns for reads excluded from sandboxing reports. The first path segment cannot contain '*', and '?', '!', '[', ']' and extglobs are not supported; anchor the pattern to a directory instead of leading with '**'." }, "ignoredWrites": { "type": "array", "items": { "type": "string" }, - "description": "Workspace-relative glob patterns for writes excluded from sandboxing reports" + "description": "Workspace-relative glob patterns for writes excluded from sandboxing reports. The first path segment cannot contain '*', and '?', '!', '[', ']' and extglobs are not supported; anchor the pattern to a directory instead of leading with '**'." } }, "additionalProperties": false From b26a4a6c821a0f8691f5253add0337ce8a5c29cd Mon Sep 17 00:00:00 2001 From: Craigory Coppola Date: Tue, 1 Sep 2026 23:41:12 -0400 Subject: [PATCH 12/13] docs(core): state the glob constraint at all four sites The previous commit updated the two JSON schemas and missed the Rust doc comment on TaskSandboxConfiguration, which is what a plugin author sees on hover writing createNodes in TypeScript. That audience is the one most likely to build a pattern programmatically and never open a schema. index.d.ts is generated from the Rust and was regenerated. The reference page also said an unsupported pattern is 'rejected'. Nothing performs that action: nx validates shape only and never inspects glob syntax, so there is no error at authoring time, at graph construction, or at run. A reader who believes 'rejected' waits for a failure that never comes. It now matches the schemas' 'not supported' and says plainly that the pattern has no effect. --- .../src/content/docs/reference/project-configuration.mdoc | 2 +- packages/nx/src/native/index.d.ts | 8 ++++++-- packages/nx/src/native/tasks/types.rs | 8 ++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/astro-docs/src/content/docs/reference/project-configuration.mdoc b/astro-docs/src/content/docs/reference/project-configuration.mdoc index b37dd4a655f..94df2bc72ab 100644 --- a/astro-docs/src/content/docs/reference/project-configuration.mdoc +++ b/astro-docs/src/content/docs/reference/project-configuration.mdoc @@ -629,7 +629,7 @@ Use `ignoredReads` and `ignoredWrites` to keep tracking the target while excludi ``` {% aside type="caution" title="Not every glob is supported" %} -The first path segment cannot contain `*`, so `**/generated/**` is rejected. `?`, `!`, `[`, `]` and extglobs are also unsupported. Anchor the pattern to a directory instead, such as `libs/*/generated/**`. +The first path segment cannot contain `*`, so `**/generated/**` is not supported. `?`, `!`, `[`, `]` and extglobs are not supported either. Nothing reports these at authoring time, so the pattern has no effect. Anchor it to a directory instead, such as `libs/*/generated/**`. {% /aside %} A target that defines `sandbox` replaces the inherited configuration rather than adding to it. Use the [spread token](#spread-token) to merge instead, either at the object level to keep inherited keys, or inside `ignoredReads` and `ignoredWrites` to keep inherited patterns. diff --git a/packages/nx/src/native/index.d.ts b/packages/nx/src/native/index.d.ts index fac22970f00..3bce00cc3a9 100644 --- a/packages/nx/src/native/index.d.ts +++ b/packages/nx/src/native/index.d.ts @@ -801,12 +801,16 @@ export interface TaskSandboxConfiguration { enabled?: boolean /** * Workspace-relative glob patterns for reads that should be excluded - * from sandboxing reports. + * from sandboxing reports. The first path segment cannot contain `*`, + * and `?`, `!`, `[`, `]` and extglobs are not supported; anchor the + * pattern to a directory instead of leading with `**`. */ ignoredReads?: Array /** * Workspace-relative glob patterns for writes that should be excluded - * from sandboxing reports. + * from sandboxing reports. The first path segment cannot contain `*`, + * and `?`, `!`, `[`, `]` and extglobs are not supported; anchor the + * pattern to a directory instead of leading with `**`. */ ignoredWrites?: Array } diff --git a/packages/nx/src/native/tasks/types.rs b/packages/nx/src/native/tasks/types.rs index 1fb4c68d657..e4bd9242116 100644 --- a/packages/nx/src/native/tasks/types.rs +++ b/packages/nx/src/native/tasks/types.rs @@ -51,10 +51,14 @@ pub struct TaskSandboxConfiguration { /// task, so no sandbox report is produced. pub enabled: Option, /// Workspace-relative glob patterns for reads that should be excluded - /// from sandboxing reports. + /// from sandboxing reports. The first path segment cannot contain `*`, + /// and `?`, `!`, `[`, `]` and extglobs are not supported; anchor the + /// pattern to a directory instead of leading with `**`. pub ignored_reads: Option>, /// Workspace-relative glob patterns for writes that should be excluded - /// from sandboxing reports. + /// from sandboxing reports. The first path segment cannot contain `*`, + /// and `?`, `!`, `[`, `]` and extglobs are not supported; anchor the + /// pattern to a directory instead of leading with `**`. pub ignored_writes: Option>, } From 9f1297c6663e0c75aa89b019f9d8fac040defcbd Mon Sep 17 00:00:00 2001 From: "nx-cloud[bot]" <71083854+nx-cloud[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:40:32 +0000 Subject: [PATCH 13/13] fix(core): keep a falsy sandbox and honour authored position [Self-Healing CI Rerun]