Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/job-sidecar-0600.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'nexus-agents': patch
---

security(jobs): write async job sidecar files (`<NEXUS_DATA_DIR>/jobs/result-<jobId>.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.
28 changes: 27 additions & 1 deletion packages/nexus-agents/src/mcp/jobs/job-result-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
*/

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';

import {
writeJobPending,
writeJobComplete,
writeJobFailed,
writeJobCancelled,
readJobResult,
} from './job-result-store.js';
import { resetNexusDataDirCache, nexusDataPath } from '../../config/nexus-data-dir.js';
Expand All @@ -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');
Expand Down
21 changes: 16 additions & 5 deletions packages/nexus-agents/src/mcp/jobs/job-result-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand All @@ -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 });
}

Expand All @@ -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 });
}

Expand All @@ -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 });
}

Expand Down Expand Up @@ -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 });
}

Expand Down
Loading