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)
sendOnce → m.messenger.Send → runtimeMessenger.Send → zellij.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:
- Promote
stripUnsafeControlChars into a small shared helper (e.g. internal/domain or a text util) so both the controller and lifecycle use one definition.
- Apply it to
ch.LogTail and the joined reviewContent bodies before building msg in reactions.go (keep \n/\t; drop other control chars).
- 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
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.
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}/sendendpoint 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
sendhandler sanitizes before delivery:The lifecycle nudge path delivers through the same zellij paste mechanism but with no sanitization:
sendOnce→m.messenger.Send→runtimeMessenger.Send→zellij.Runtime.SendMessage, whichpastes the bytes into the live pane and presses Enter:The content is attacker-influenced and unsanitized
FetchFailedCheckLogTail(observer_provider.go:139) fetches the raw GitHub job log and runs it throughtailLines, 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.reviewContent(reactions.go:400) joins rawc.Bodystrings with no filtering. Anyone who can comment on the PR controls this text.Both reach the PTY verbatim.
Impact
stripUnsafeControlCharsexists 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:stripUnsafeControlCharsinto a small shared helper (e.g.internal/domainor atextutil) so both the controller and lifecycle use one definition.ch.LogTailand the joinedreviewContentbodies before buildingmsginreactions.go(keep\n/\t; drop other control chars).zellij.SendMessageso 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
\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.