Skip to content

bug(lifecycle): CI logs and reviewer comments are pasted into the agent PTY unsanitized — terminal escape-sequence injection (the /send endpoint sanitizes, the nudge path does not) #322

@areycruzer

Description

@areycruzer

TL;DR

Lifecycle nudges (CI-failing, review-feedback) build a message that embeds externally-controlled text — raw CI job-log tails and GitHub reviewer comment bodies — and paste it straight into the agent's live terminal via zellij. That paste path does not strip control/escape characters, even though the user-facing POST /sessions/{id}/send endpoint deliberately does (stripUnsafeControlChars). So a malicious PR comment or crafted CI log output can inject ANSI/terminal escape sequences into the agent's TTY.


The inconsistency

The HTTP send handler sanitizes before delivery:

// controllers/sessions.go:277
message := stripUnsafeControlChars(in.Message)   // drops control chars except \n \r \t
...
c.Svc.Send(r.Context(), sessionID(r), message)
// controllers/sessions.go:406
func stripUnsafeControlChars(message string) string {
    return strings.Map(func(r rune) rune {
        if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
            return -1
        }
        return r
    }, message)
}

The lifecycle nudge path delivers through the same zellij paste mechanism but with no sanitization:

// lifecycle/reactions.go:72
if ch.LogTail != "" {
    msg += "\n\nFailing output:\n" + ch.LogTail        // raw CI log
}
return m.sendOnce(ctx, id, o.URL, ..., msg, 0)

// lifecycle/reactions.go:79
comments, sig := reviewContent(o.Comments)             // raw reviewer comment bodies
msg := "A reviewer left feedback on your PR. Address it and push."
if comments != "" { msg += "\n\n" + comments }
return m.sendOnce(ctx, id, o.URL, ..., msg, reviewMaxNudge)

sendOncem.messenger.SendruntimeMessenger.Sendzellij.Runtime.SendMessage, which pastes the bytes into the live pane and presses Enter:

// adapters/runtime/zellij/zellij.go:182
for _, chunk := range chunks(message, r.chunkSize) {
    r.run(ctx, pasteArgs(id, paneID, chunk))    // pastes raw bytes into the PTY
}
r.run(ctx, sendEnterArgs(id, paneID))

The content is attacker-influenced and unsanitized

  • CI log tail: FetchFailedCheckLogTail (observer_provider.go:139) fetches the raw GitHub job log and runs it through tailLines, which only normalizes \r\n\n — no control-char stripping (provider.go:713). CI logs routinely contain ANSI color codes, and a test/build step can emit arbitrary escape sequences.
  • Reviewer comments: reviewContent (reactions.go:400) joins raw c.Body strings with no filtering. Anyone who can comment on the PR controls this text.

Both reach the PTY verbatim.

Impact

  • Terminal escape-sequence injection into the agent's interactive TTY: cursor movement, screen clears, scrollback manipulation, and (depending on terminal) OSC sequences. At minimum this can corrupt/spoof what the agent and a human watching the pane see; escape sequences can misrepresent the nudge's framing.
  • It's the exact threat stripUnsafeControlChars exists to prevent — the protection is just missing on the higher-trust-looking (but actually external) path. The SCM doc even calls out "comment-injection-into-session-context" as a real concern (adapters/scm/github/doc.go:124).

Suggested fix (small + surgical)

Sanitize externally-sourced content on the nudge path with the same policy as /send:

  1. Promote stripUnsafeControlChars into a small shared helper (e.g. internal/domain or a text util) so both the controller and lifecycle use one definition.
  2. Apply it to ch.LogTail and the joined reviewContent bodies before building msg in reactions.go (keep \n/\t; drop other control chars).
  3. Alternatively (defense in depth) sanitize inside zellij.SendMessage so every paste is safe regardless of caller — but the agent's own static nudge text is trusted, so sanitizing the external fields is the minimal, intent-preserving fix.

Acceptance criteria

  • A CI log tail or reviewer comment containing escape/control sequences is stripped before being pasted into the agent pane.
  • The static nudge wording (newlines/formatting) is preserved.
  • Test: a nudge built from a LogTail/comment with embedded \x1b[... and other control bytes delivers sanitized text.

Filed after reviewing all open/closed issues and PRs — not tracked elsewhere. Distinct from #295 (waiting_input suppression of the merge-conflict nudge), which is about whether a nudge fires, not what bytes it delivers.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingcoding-agentsRuntime + Workspace + Agent adapters lanelcm-smLifecycle + Session Manager lanepriority: highFix soon

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions