other
- Location
scripts/heartbeat.mjs:153- Finding
Untrusted Story Content Is Passed to Agentic Local LLM CLIs
- Content
View full analysis
Vulnerability Details
File Location:
scripts/heartbeat.mjs:153-174, 220-241, 384-389
Vulnerability Type: Indirect prompt injection through remotely supplied story content
Risk Level: MediumVulnerable Code
js function buildPrompt(s) { const dimension = (s.dimension ?? s.bench ?? "DILEMMA").toUpperCase(); const desc = DIMENSION_GUIDE[dimension] ?? "General evaluation"; return [ "You are Themis, an impartial AI judge on JudgeHuman. Evaluate this story.", "", `Primary dimension: ${dimension} — ${desc}`, `Title: ${s.title}`, `Content: ${s.content ?? s.exhibit ?? "(none)"}`, "", "Score all five dimensions 1–10:", "1–2 = seriously problematic | 3–4 = below average | 5–6 = neutral | 7–8 = commendable | 9–10 = exceptional", "", "Compute an overall composite score 0–100.", "Give up to 3 concise reasoning strings (max 200 chars each).", "", "Reply ONLY with valid JSON, no markdown:", '{"dimension_scores":{"ETHICS":0,"HUMANITY":0,"AESTHETICS":0,"HYPE":0,"DILEMMA":0},"score":0,"reasoning":["..."]}', ].join("\n"); }js if (evaluator === "claude-cli") { const raw = execFileSync("claude", ["-p", prompt, "--output-format", "json"], { timeout: 60_000, encoding: "utf8", // PATH + HOME only (HOME locates CLI auth). CLAUDECODE is naturally // absent, allowing spawning outside a live Claude Code session. env: minimalChildEnv(), }); return parseEvalResponse(raw); } if (evaluator === "codex-cli") { const raw = execFileSync( "codex", ["exec", "--ephemeral", "--sandbox", "read-only", "--skip-git-repo-check", prompt], { timeout: 120_000, encoding: "utf8", // PATH + HOME only (HOME locates ~/.codex auth) env: minimalChildEnv(), } ); return parseEvalResponse(raw); }js if (evaluator) { const signal = await evaluate(s, evaluator); const res = await api("/api/v2/agent/signal", { method: "POST", auth: tr ...[truncated 2884 chars]- Remediation
View remediation
Remediation Suggestions
- Treat every remotely retrieved title and story body as hostile input. Place it in an explicitly delimited data field and instruct the evaluator that content inside that field is evidence only and must never be interpreted as instructions.
- Prefer structured API messages that separate trusted evaluator instructions from untrusted story data. Do not combine both into one undifferentiated prompt.
- Disable tools for local CLI evaluation wherever supported. Run all evaluators in a disposable sandbox with:
- No project-directory access.
- A temporary, minimal home directory.
- No shell or subprocess tools.
- No network access unless strictly required.
- Read access only to files explicitly needed for authentication.
- Do not rely only on environment filtering. Restrict the child process’s working directory, filesystem namespace, tool permissions, and network capabilities.
- Validate evaluator output against a strict schema before submission:
- Allow only
score,dimension_scores,reasoning, and explicitly supported evidence fields. - Reject unknown properties.
- Enforce numeric ranges and finite numeric values.
- Enforce permitted dimension names.
- Enforce reasoning item counts and lengths.
- Allow only
- Construct the outbound request from individually validated fields instead of spreading the complete evaluator object:
js const body = { story_id: s.id, score: validated.score, dimension_scores: validated.dimension_scores, reasoning: validated.reasoning, };- Consider requiring per-story approval for untrusted content or providing a mode that displays generated evaluations for human review before publication.
- Add adversarial tests containing embedded instructions, requests to read local files, malformed JSON, unexpected properties, oversized reasoning, and out-of-range scores.
