feat(daemon): sense-generator workflow — shell injection safe role execution #79

Closed
opened 2026-04-24 08:27:47 +00:00 by xiaoju · 1 comment
Owner

Background

Workflow roles that invoke external CLI tools (e.g. cursor-agent) via role.execute() need to pass user-authored prompt content as CLI arguments. Naively concatenating prompt strings into shell commands opens the door to shell injection — any $(), backticks, &&, |, etc. in the prompt would be interpreted by the shell.

Problem

  1. cursor-agent has no --prompt-file or stdin prompt input — the only way to pass a prompt is via CLI args.
  2. If execute() uses child_process.exec() or spawn(..., { shell: true }), prompt content is shell-interpreted.
  3. Long prompts with special characters (quotes, backticks, $, newlines) break or inject.

Solution

1. Safe process spawning utility

Create a shared utility in packages/core (or packages/daemon) that wraps child_process.spawn with shell: false — prompt goes as a separate argv element, never shell-interpolated:

import { spawn } from "node:child_process";

type SpawnResult = { stdout: string; stderr: string; exitCode: number };

function spawnSafe(cmd: string, args: string[], opts?: { cwd?: string; timeout?: number }): Promise<SpawnResult> {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, {
      shell: false,  // ← KEY: no shell interpretation
      cwd: opts?.cwd,
      timeout: opts?.timeout,
      stdio: ["ignore", "pipe", "pipe"],
    });
    // collect stdout/stderr, resolve on close
  });
}

2. Prompt validation before execution

In role execute functions, validate prompt is a string before passing to CLI:

function assertStringPrompt(prompt: unknown): string {
  if (typeof prompt !== "string") {
    throw new Error(`Expected string prompt, got ${typeof prompt}`);
  }
  return prompt;
}

3. Short prompt + .cursor/rules pattern

Keep the -p prompt short (one sentence instruction). Put coding conventions, scope constraints, and context into .cursor/rules files that cursor-agent reads automatically from the workspace.

Acceptance Criteria

  • spawnSafe() utility exists with shell: false enforced
  • Prompt type validation (must be string) before CLI invocation
  • sense-generator workflow uses spawnSafe() for cursor-agent calls
  • Unit test: prompt containing $(rm -rf /), backticks, pipes, etc. is passed literally as argv without shell interpretation
  • No exec() or spawn(..., { shell: true }) used anywhere for user prompt content

小橘 🍊(NEKO Team)

## Background Workflow roles that invoke external CLI tools (e.g. `cursor-agent`) via `role.execute()` need to pass user-authored prompt content as CLI arguments. Naively concatenating prompt strings into shell commands opens the door to **shell injection** — any `$()`, backticks, `&&`, `|`, etc. in the prompt would be interpreted by the shell. ## Problem 1. `cursor-agent` has no `--prompt-file` or stdin prompt input — the only way to pass a prompt is via CLI args. 2. If `execute()` uses `child_process.exec()` or `spawn(..., { shell: true })`, prompt content is shell-interpreted. 3. Long prompts with special characters (quotes, backticks, `$`, newlines) break or inject. ## Solution ### 1. Safe process spawning utility Create a shared utility in `packages/core` (or `packages/daemon`) that wraps `child_process.spawn` with **`shell: false`** — prompt goes as a separate argv element, never shell-interpolated: ```typescript import { spawn } from "node:child_process"; type SpawnResult = { stdout: string; stderr: string; exitCode: number }; function spawnSafe(cmd: string, args: string[], opts?: { cwd?: string; timeout?: number }): Promise<SpawnResult> { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { shell: false, // ← KEY: no shell interpretation cwd: opts?.cwd, timeout: opts?.timeout, stdio: ["ignore", "pipe", "pipe"], }); // collect stdout/stderr, resolve on close }); } ``` ### 2. Prompt validation before execution In role execute functions, validate prompt is a string before passing to CLI: ```typescript function assertStringPrompt(prompt: unknown): string { if (typeof prompt !== "string") { throw new Error(`Expected string prompt, got ${typeof prompt}`); } return prompt; } ``` ### 3. Short prompt + `.cursor/rules` pattern Keep the `-p` prompt short (one sentence instruction). Put coding conventions, scope constraints, and context into `.cursor/rules` files that cursor-agent reads automatically from the workspace. ## Acceptance Criteria - [ ] `spawnSafe()` utility exists with `shell: false` enforced - [ ] Prompt type validation (must be string) before CLI invocation - [ ] sense-generator workflow uses `spawnSafe()` for cursor-agent calls - [ ] Unit test: prompt containing `$(rm -rf /)`, backticks, pipes, etc. is passed literally as argv without shell interpretation - [ ] No `exec()` or `spawn(..., { shell: true })` used anywhere for user prompt content --- 小橘 🍊(NEKO Team)
Author
Owner

Resolved by PR #98 (packages/workflow-utils).

All acceptance criteria met:

  • spawnSafe() with shell: false
  • Prompt type validation (TypeScript strong typing)
  • cursorAgent() wrapper uses spawnSafe()
  • Injection test ($(echo BAD) passed literally as argv)
  • No exec() or spawn({ shell: true }) anywhere

— 小橘 🍊(NEKO Team)

Resolved by PR #98 (`packages/workflow-utils`). All acceptance criteria met: - `spawnSafe()` with `shell: false` ✅ - Prompt type validation (TypeScript strong typing) ✅ - `cursorAgent()` wrapper uses `spawnSafe()` ✅ - Injection test (`$(echo BAD)` passed literally as argv) ✅ - No `exec()` or `spawn({ shell: true })` anywhere ✅ — 小橘 🍊(NEKO Team)
This repo is archived. You cannot comment on issues.
No Label
1 Participants
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: uncaged/nerve#79