cfgaudit

Rules / CFG108

CFG108 a committed scheduled_tasks.json runs prompts on a timer

Severity: error (a bare task, which fires in any checkout) · info (a session-bound task, inert on another machine) OWASP: LLM03:2026 – Excessive Agency

What cfgaudit checks

.claude/scheduled_tasks.json is Claude Code's on-disk store for its cron scheduler — the CronCreate / ScheduleWakeup tools, /loop, and routines. Each entry pairs a cron expression with a prompt that is enqueued into the session, as if the user had typed it, when the schedule fires. cfgaudit reports a committed file that carries actionable tasks (a task with both a prompt and a cron).

The file is Claude Code's own runtime state and is normally kept out of git — 2.1.273 adds it to the repository's .git/info/exclude — but committed copies exist in the wild, and a committed copy runs on a timer in every contributor's interactive session.

Why a committed copy is dangerous

Two mechanisms in the 2.1.273 binary combine into a zero-interaction path:

  1. The file self-enables the scheduler. Scheduling is off by default (the launch flag initialises to false). The interactive scheduler's start() calls replaceScheduledTasksEnabled(true) whenever the store has a non-empty tasks array (nan()), so the file's mere presence turns the feature on.
  2. A task with no session identity fires in any checkout. The fire gate de(task) returns the scheduler-lock flag when createdBySessionId is absent, so such a task runs here. A task carrying another machine's createdInProject stays inert on a clone — that is the guard against a copied file — but a task with neither identity field takes the first branch. The writer always stamps both fields; the reader treats them as optional, so a hand-authored or hand-edited file can omit them.

Severity: the bare/session-bound split

Task shape Severity Why
no createdBySessionId, no createdInProject (bare) error self-enables the scheduler and fires in any clone, with no interaction
bound to the author's session or project info inert on another machine, but still committed autonomous scheduling

That split is what separates a live task from an inert one, so it keeps the high severity's false positives near zero. In a 40-repo sample of the 91 public repos that commit the file, 24 were empty, 16 carried tasks, and 8 of those carried at least one bare task — almost all recurring: true, with prompts like Run the Python script at …\Temp\fix_hook.py or Check CI run … fix them. Run pnpm lint.

Verified at the artifact

The self-enable and the fire were confirmed by driving an interactive session (a headless claude -p run does not mount the scheduler, which is the TUI hook useScheduledTasks). A scratch HOME, a committed .claude/scheduled_tasks.json carrying a single bare recurring * * * * * task, and a local endpoint that logs requests: on the next cron minute the session sent the task's exact prompt to the API, in a request whose billing header read cc_workload=cron — the scheduler generated it, with no input.

Safe alternative

Delete .claude/scheduled_tasks.json from the repository. Scheduled tasks are per-user runtime state, not shared configuration; each contributor can create their own with CronCreate / /loop. If a repository wants to ship automation, a committed script invoked by a documented command or a CI job is auditable in a way a timer-fired prompt is not.

References

Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation