From 40e26c54220816abd4c48e0f69c9d2b74163e331 Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 29 Jun 2026 11:38:51 +0530 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU --- src/cron/cron-service.ts | 35 ++++++++++++++++++++++-- test/cron-service.test.ts | 56 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index 690b5dfc..2e387091 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -9,12 +9,14 @@ import { v4 as uuidv4 } from 'uuid'; import { readFile } from 'node:fs/promises'; -import { statSync } from 'node:fs'; +import { statSync, realpathSync } from 'node:fs'; import { Session } from '../session.js'; import { SseEvent } from '../web/sse-events.js'; import { getErrorMessage } from '../types/api.js'; import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js'; import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js'; +import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../config/attachment-guard.js'; +import { validateSessionFilePath } from '../web/route-helpers.js'; import { computeNextRunAt, dueKeyFor } from './cron-time.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.js'; import type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js'; @@ -288,11 +290,40 @@ export class CronService { private async resolvePrompt(job: CronJob): Promise { if (job.promptMode === 'prompt_file_path') { if (!job.promptFilePath) throw new Error('prompt file path is empty'); - return readFile(job.promptFilePath, 'utf-8'); + const safePath = await this.resolveSafePromptPath(job.promptFilePath, job.workingDir); + return readFile(safePath, 'utf-8'); } return job.promptText ?? ''; } + /** + * Guards a prompt-file path before it is read. The path is user-supplied via + * the API, so without this an attacker (or a hostile job config) could read + * arbitrary host files (e.g. /etc/passwd, SSH keys) into a Claude session. + * + * Mirrors the attachment-serving guard (`resolveServableAttachmentPath` in + * file-routes): realpath-resolve, then reject via the shared blocklist + * (`/etc`, `/root`, secret locations) plus optional workspace confinement. + * Returns the symlink-resolved path to read. + */ + private async resolveSafePromptPath(rawPath: string, workingDir: string): Promise { + let resolved: string; + try { + resolved = realpathSync(rawPath); + } catch { + throw new Error('prompt file path could not be resolved'); + } + + const guard = await loadAttachmentGuardConfig(); + const blocked = + isBlockedAttachmentPath(resolved, guard.blockedTrees) || + isBlockedAttachmentPath(rawPath, guard.blockedTrees) || + (guard.confineToWorkspace && !validateSessionFilePath(workingDir, resolved)); + + if (blocked) throw new Error('prompt file path is blocked'); + return resolved; + } + private sendPromptWhenReady(sessionId: string, prompt: string, job: CronJob, run: CronJobRun): void { setImmediate(() => { const poll = async (): Promise => { diff --git a/test/cron-service.test.ts b/test/cron-service.test.ts index 1c4c6c57..c529488c 100644 --- a/test/cron-service.test.ts +++ b/test/cron-service.test.ts @@ -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 }));