diff --git a/.changeset/job-sidecar-0600.md b/.changeset/job-sidecar-0600.md new file mode 100644 index 00000000000..3cb7d0f8cdc --- /dev/null +++ b/.changeset/job-sidecar-0600.md @@ -0,0 +1,5 @@ +--- +'nexus-agents': patch +--- + +security(jobs): write async job sidecar files (`/jobs/result-.json`) with `0600` permissions (#3753, defense-in-depth). The payload may carry job-result data; restricting to the owner matters if `NEXUS_DATA_DIR` is ever shared across users. Extracted a `persistJobRecord` helper (DRY over the four writers) that sets the mode on write and `chmod`s after, so the permission holds even when a terminal status overwrites a pre-existing pending file. Not exploitable today (per-user stdio MCP, randomUUID jobIds) — pure hardening. diff --git a/packages/nexus-agents/src/mcp/jobs/job-result-store.test.ts b/packages/nexus-agents/src/mcp/jobs/job-result-store.test.ts index 2c748fae2ec..07c12aa94fd 100644 --- a/packages/nexus-agents/src/mcp/jobs/job-result-store.test.ts +++ b/packages/nexus-agents/src/mcp/jobs/job-result-store.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -11,6 +11,7 @@ import { writeJobPending, writeJobComplete, writeJobFailed, + writeJobCancelled, readJobResult, } from './job-result-store.js'; import { resetNexusDataDirCache, nexusDataPath } from '../../config/nexus-data-dir.js'; @@ -32,6 +33,31 @@ describe('job-result-store', () => { rmSync(tmpDir, { recursive: true, force: true }); }); + describe('sidecar file permissions (#3753 defense-in-depth)', () => { + const mode600 = (jobId: string): number => + statSync(nexusDataPath('jobs', `result-${jobId}.json`)).mode & 0o777; + + it('writes each terminal record with 0600 mode', () => { + writeJobComplete('mode-complete', 'orchestrate', { ok: true }); + writeJobFailed('mode-failed', 'orchestrate', 'boom'); + writeJobCancelled('mode-cancelled', 'orchestrate', 'stop'); + expect(mode600('mode-complete')).toBe(0o600); + expect(mode600('mode-failed')).toBe(0o600); + expect(mode600('mode-cancelled')).toBe(0o600); + }); + + it('writeJobPending creates the file 0600', () => { + writeJobPending('mode-pending', 'orchestrate'); + expect(mode600('mode-pending')).toBe(0o600); + }); + + it('a complete that OVERWRITES a pending stays 0600', () => { + writeJobPending('mode-overwrite', 'orchestrate'); + writeJobComplete('mode-overwrite', 'orchestrate', { ok: true }); + expect(mode600('mode-overwrite')).toBe(0o600); + }); + }); + it('writeJobPending creates a pending record', () => { writeJobPending('job-test-1', 'orchestrate'); const record = readJobResult('job-test-1'); diff --git a/packages/nexus-agents/src/mcp/jobs/job-result-store.ts b/packages/nexus-agents/src/mcp/jobs/job-result-store.ts index f3258b880f9..055a2370055 100644 --- a/packages/nexus-agents/src/mcp/jobs/job-result-store.ts +++ b/packages/nexus-agents/src/mcp/jobs/job-result-store.ts @@ -27,7 +27,7 @@ * @module mcp/jobs/job-result-store */ -import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, writeFileSync, chmodSync } from 'node:fs'; import { z } from 'zod'; @@ -76,6 +76,17 @@ function jobResultPath(jobId: string): string { return nexusDataPathEnsure('jobs', `result-${jobId}.json`); } +/** + * Write a job sidecar record with 0600 perms (#3753 defense-in-depth — the + * payload may carry job result data; restrict to the owner if NEXUS_DATA_DIR is + * ever shared). `chmodSync` after write guarantees the mode even when overwriting + * a pre-existing (default-umask) file, which the `writeFileSync` mode option skips. + */ +function persistJobRecord(path: string, record: JobResult): void { + writeFileSync(path, JSON.stringify(record, null, 2), { mode: 0o600 }); + chmodSync(path, 0o600); +} + /** * Write the initial `pending` record for a new job. Idempotent: if a * record for `jobId` already exists (e.g. operator restart re-runs the @@ -97,7 +108,7 @@ export function writeJobPending(jobId: string, toolName: string): void { status: 'pending', createdAt: new Date().toISOString(), }; - writeFileSync(path, JSON.stringify(record, null, 2)); + persistJobRecord(path, record); logger.debug('Wrote pending job record', { jobId, toolName }); } @@ -116,7 +127,7 @@ export function writeJobComplete(jobId: string, toolName: string, result: unknow completedAt: new Date().toISOString(), result, }; - writeFileSync(jobResultPath(jobId), JSON.stringify(record, null, 2)); + persistJobRecord(jobResultPath(jobId), record); logger.debug('Wrote complete job record', { jobId, toolName }); } @@ -131,7 +142,7 @@ export function writeJobFailed(jobId: string, toolName: string, error: string): completedAt: new Date().toISOString(), error, }; - writeFileSync(jobResultPath(jobId), JSON.stringify(record, null, 2)); + persistJobRecord(jobResultPath(jobId), record); logger.debug('Wrote failed job record', { jobId, toolName, error }); } @@ -159,7 +170,7 @@ export function writeJobCancelled(jobId: string, toolName: string, reason?: stri completedAt: new Date().toISOString(), ...(reason !== undefined ? { error: reason } : {}), }; - writeFileSync(jobResultPath(jobId), JSON.stringify(record, null, 2)); + persistJobRecord(jobResultPath(jobId), record); logger.debug('Wrote cancelled job record', { jobId, toolName, reason }); }