mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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 }));
|
||||
|
||||
Reference in New Issue
Block a user