Skip to content

analyze: emit request prints manifest app/participant/tasks with SafeText only, leaving inline-Markdown (image beacon / active link) unescaped that report.md neutralizes #69

Description

@REPPL

Summary

analyze.EmitRequest writes the manifest's app, participant, and tasks fields into the emitted analysis request through session.SafeText only. SafeText strips control/format/bidi bytes but deliberately passes the inline-Markdown metacharacters \ ` * _ [ ] ( ) ! < > ~ through unchanged. These three fields are rendered as plain Markdown list items outside any code fence, so an attacker-authored manifest value survives into the request as live Markdown — a tracking/exfil image beacon or an active link — the exact construct report.md already neutralizes for the identical fields via mdInline.

Wrong implicit assumption

emit.go's own comment (lines 82–96) asserts these strings "go through session.SafeText, matching report and review," and reasons only about block-structure forgery (a newline-led ## heading, which SafeText's newline stripping does prevent). It never considers inline Markdown, which needs no newline. The parity claim is false: report.go renders the same fields through mdOrDash → mdInline, which applies SafeText and then backslash-escapes each inline trigger (report.go:324-336). emit.go omits that second step.

Exact trigger (input/state → observed vs correct)

  1. An exchanged/downloaded session ships an attacker-authored manifest.json (session.SaveManifest/LoadManifest validate only total size, never field content):

    {"session":"2026-08-18_101500","app":"[x](http://attacker.example/beacon.png)","participant":"P1","t0_epoch_ms":1,"tasks":["Explore [y](http://attacker.example/t.png)"]}
  2. Operator runs testimony analyze -session DIR -out sessions/DIR/request.md (the -out path cli.go:371 itself gives as the canonical destination).

  3. The request's ## Session block contains, verbatim:

    - App: [x](http://attacker.example/beacon.png)
    
  • Observed: when request.md is opened in any Markdown viewer (VS Code preview, GitHub, Obsidian) or handed to a colleague who previews it, the image URL is fetched → tracking/exfil beacon fires; an [label](http://attacker.example) value becomes an active link embedded in the instructions the model is asked to obey.
  • Correct: the inline-Markdown metacharacters are backslash-escaped (as mdInline does for the same fields), rendering as inert literal text. An ordinary app/participant/task name carries none of these bytes and is byte-for-byte unchanged.

Location

  • internal/analyze/emit.go:98-99 — App and Participant via safeOrNone
  • internal/analyze/emit.go:109 and :116 — Tasks via bare session.SafeText(t)
  • internal/analyze/emit.go:153-159safeOrNone applies session.SafeText only, no escaping
  • Contrast the hardened sibling: internal/report/report.go:101-113 renders the identical fields via mdOrDash/mdInline (report.go:324-336), which escapes the triggers.

CWE anchor

CWE-116 (Improper Encoding or Escaping of Output); the beacon/active-link effect is markup injection in the same family report.go's mdInline comment names for report.md.

Sibling-sweep result

  • App, Participant, Tasks (emit.go) — all three share the gap (all rendered outside any fence with SafeText only). All affected.
  • ## Timeline block (emit.go:125-136) — NOT a sibling: each entry is one json.Marshal object per physical line inside a ```jsonl fence, where inline Markdown does not render; SafeText there is sufficient.
  • report.md (report.go) and review terminal output (review.go) — already hardened (mdInline/SafeText-for-terminal). The emitted request is the one remaining Markdown artifact built from untrusted manifest text that does not escape inline Markdown — i.e. the lone unswept sink.

Scope caveat (for the maintainer)

The beacon's image fetch requires the request to reach a Markdown renderer — saving -out request.md and previewing it, or handing the .md file to a colleague. The documented pure stdin | assistant path (an LLM API that does not fetch URLs) does not fire the beacon; the terminal path does not render Markdown. The unescaped-injection reach holds regardless, and the repo already ruled the identical construct in-scope for the report.md sibling.

Validator confirmations (both lenses, adversarial, independent)

  • Reachability lens — CONFIRMED: "manifest app/participant/tasks reach the emitted request through safeOrNone/SafeText only (emit.go:98-99, 109, 153-159), outside any code fence, with the inline-Markdown escaping the sibling report.md path applies via mdInline (report.go:324-337) omitted; the beacon/active-link payload the repo already treats as in-scope for report.md (report.go:312-323) survives byte-for-byte."
  • Correctness lens — CONFIRMED: "SafeText demonstrably preserves the inline-Markdown metacharacters (session.go:375-394, default: return r); App/Participant/Tasks sit in raw Markdown context with no escaping and no fence; report.go renders the identical fields via mdInline which escapes them — a genuine divergence, and emit.go's own 'matching report' parity claim is false at the safety layer."

Fix direction (one line, not prescriptive)

Route the three fields through the same inline-Markdown escaping report.go uses (or a shared helper), so the request neutralizes inline constructs as report.md already does.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions