fix(cron): confine cron prompt-file reads to block sensitive paths

A cron job's promptFilePath is user-supplied via the API and was read with an
unconfined readFile of any absolute path, so a hostile job config could exfil
arbitrary host files (e.g. /etc/passwd, SSH keys) into a Claude session.

Guard the read in resolvePrompt by mirroring the attachment-serving guard
(resolveServableAttachmentPath in file-routes): realpath-resolve the path, then
reject via the shared blocklist (/etc, /root, secret locations) plus the
optional workspace-confinement toggle before reading.

Regression tests in cron-service.test.ts: blocks /etc/passwd (the live repro)
and /root/*, fails cleanly on a missing file, and still allows an ordinary
prompt file outside the blocklist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
This commit is contained in:
Kris
2026-06-29 11:38:51 +05:30
co-authored by Claude Opus 4.8
parent 9feaa0d6e5
commit 40e26c5422
2 changed files with 89 additions and 2 deletions
+56
View File
@@ -12,6 +12,9 @@
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CronService, type CronDeps } from '../src/cron/cron-service.js';
import type { CronJob, CronJobRun } from '../src/types/cron.js';
import type { CronJobInput } from '../src/cron/cron-input.js';
@@ -245,6 +248,59 @@ describe('CronService', () => {
});
});
describe('resolvePrompt path guard', () => {
it('blocks a prompt_file_path pointing at a sensitive system file', async () => {
const job = svc.service.createJob(
mkInput({ promptMode: 'prompt_file_path', promptFilePath: '/etc/passwd', promptText: undefined })
);
const run = await svc.service.runNow(job.id);
expect(run).not.toBeNull();
expect(run!.status).toBe('failed');
// Must fail at prompt resolution (blocked), NOT later at the missing workingDir —
// i.e. the file content must never be read.
expect(run!.errorMessage).toMatch(/Prompt error/i);
expect(run!.errorMessage).toMatch(/block/i);
// No session was created for a blocked job.
expect(svc.sessions.size).toBe(0);
});
it('blocks a prompt_file_path under a default-blocked tree (/root)', async () => {
const job = svc.service.createJob(
mkInput({ promptMode: 'prompt_file_path', promptFilePath: '/root/.bashrc', promptText: undefined })
);
const run = await svc.service.runNow(job.id);
expect(run!.status).toBe('failed');
expect(run!.errorMessage).toMatch(/Prompt error/i);
});
it('fails cleanly (no throw) when the prompt file does not exist', async () => {
const job = svc.service.createJob(
mkInput({
promptMode: 'prompt_file_path',
promptFilePath: '/tmp/codeman-cron-no-such-prompt-file.md',
promptText: undefined,
})
);
const run = await svc.service.runNow(job.id);
expect(run!.status).toBe('failed');
expect(run!.errorMessage).toMatch(/Prompt error/i);
});
it('allows an ordinary prompt file outside the blocklist (passes resolution)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'codeman-cron-prompt-'));
const file = join(dir, 'prompt.md');
writeFileSync(file, 'do the thing');
const job = svc.service.createJob(
mkInput({ promptMode: 'prompt_file_path', promptFilePath: file, promptText: undefined })
);
const run = await svc.service.runNow(job.id);
expect(run!.status).toBe('failed'); // still fails — workingDir (MISSING_DIR) does not exist
// ...but it got PAST prompt resolution: the failure is the workingDir, not a Prompt error.
expect(run!.errorMessage).not.toMatch(/Prompt error/i);
expect(run!.errorMessage).toMatch(/workingDir/i);
});
});
describe('runNow', () => {
it('launches regardless of enabled/schedule state', async () => {
const job = svc.service.createJob(mkInput({ enabled: false }));