It's a Plan: Code Execution via Chat Message Parsed as CLI Option

A project member on the default role loads a JavaScript module on the machine that hosts the agent runner through a chat message that starts with a hyphen.

Advisory ID: TP-2026-122
Product: It's a Plan (self-hosted project management and issue tracking with AI agents)
Vulnerability type: Argument injection (CWE-88)
CVE: not requested
CVSS 3.1: 8.5 (High) · CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H
Affected versions: @itsaplan/runner >= 0.2.0, < 0.7.0
Vendor advisory: GHSA-x99f-4j7w-gvh9
Fixed in: @itsaplan/runner 0.7.0 (shipped with It's a Plan 1.7.0)
Reported: 11 October 2026

Summary

It's a Plan is a self-hosted project management tool in which external coding agents work on tasks from the project chat through a runner on the operator's machine. The runner passes a project member's chat message as the last argv element to the coding CLI, and for three of the seven shipped presets (opencode, pi, omp) nothing separates that text from the preset's own options. A message that starts with a hyphen is therefore parsed by the CLI as an option instead of as the task. With the omp preset, --extension=<path> loads a JavaScript module at startup, before the model is resolved and under every approval mode. A project member on the lowest shipped role runs its own code as the operator account on the runner host, once the module file is there.

Root cause

presetArgv appends the message after the preset's tail as the last argv element (packages/runner/src/presets.ts:168-182). The omp preset sets promptVia: 'arg' with an empty tail: [] (packages/runner/src/presets.ts:136-149), although the type comment describes the field as a stdin marker or the option the prompt follows (packages/runner/src/presets.ts:25). On a thread already bound to a CLI session, the API forwards the message without framing (apps/api/src/modules/agents/chat/service.ts:507). The chat endpoint requires only ['ai_agents', 'read'] (apps/api/src/modules/agents/chat/index.ts:85), which defaultMemberPermissions() grants to every member (packages/db/src/permissions.ts:92), and runnerScope defaults to team (apps/api/src/modules/agents/core/service.ts:631). The own parser of omp accepts --flag=value in one element (src/cli/args.ts:201 in @oh-my-pi/pi-coding-agent) and loads --extension=<path> before it resolves a model.

Proof of Concept

Message 1 (binds the thread to a CLI session):  hello
Message 2:                                      --extension=/tmp/rce3/work/notes.mjs

-> runner spawns: omp ... --auto-approve --resume OMP-SESSION-1 --extension=/tmp/rce3/work/notes.mjs
$ cat /tmp/rce3/PWNED.txt
RCE-3: code executed on the operator machine as uid=1000 cwd=/tmp/rce3/work

The second message arrives without a -- separator as its own argv element behind the preset's options, so omp evaluates it as an option and loads the module. The code also runs under --approval-mode always-ask and without an API key, because the option takes effect before the model call.

Impact

  • JavaScript of the member's choosing runs as the operator account, outside any container.
  • Model refusal and the approval mode do not apply, because the option is evaluated before the model call.
  • Reachable by the lowest shipped role with two chat messages and no owner action.
  • Requires a runner on the omp preset and a module file on the runner host; the claude, codex, antigravity and copilot presets are not affected.

References

Is Something Like This in Your Software?

Our team found this vulnerability in the course of its work. Have your applications tested by the same specialists, with a penetration test from turingpoint.