Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion stations/notify/cmd/agent-notify/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,11 @@ func buildMessage(hook string, posArgs []string, stdin io.Reader) (canonical.Mes
case "", "custom":
// Prefer positional arg; otherwise read stdin.
if len(posArgs) > 0 {
return adapter.FromString(strings.Join(posArgs, " ")), nil
joined := strings.Join(posArgs, " ")
if err := adapter.CheckSize([]byte(joined)); err != nil {
return canonical.Message{}, err
}
return adapter.FromString(joined), nil
}
return adapter.AutoDetect(stdin)
default:
Expand Down
96 changes: 96 additions & 0 deletions stations/notify/cmd/agent-notify/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -775,3 +775,99 @@ func TestRun_CodexNotifyResolvesModelIdentity(t *testing.T) {
t.Errorf("embed title = %#v, want OpenAI · gpt-5.6-sol", embed["title"])
}
}

func TestRun_OversizedStdinRejected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("provider must not be called for oversized input")
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()

oversized := strings.Repeat("x", adapterMaxInputBytes()+1)
code, _, stderr := runMain(t,
[]string{"agent-notify"},
oversized,
map[string]string{"DISCORD_WEBHOOK_URL": srv.URL},
)
if code != exitConfig {
t.Fatalf("exit = %d, want %d (stderr=%s)", code, exitConfig, stderr)
}
if !strings.Contains(stderr, "input exceeds") {
t.Fatalf("stderr should mention input limit, got %q", stderr)
}
if strings.Contains(stderr, oversized[:32]) {
t.Fatalf("stderr must not echo oversized payload: %q", stderr)
}
}

func TestRun_PartialFailurePayloadLimitVisible(t *testing.T) {
// Discord truncates titles safely; Telegram MarkdownV2 escaping doubles
// "." so an oversize title cannot fit. One channel succeeds, one fails
// with a bounded payload_limit diagnostic and exitFailures.
var discordHits int
discordSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
discordHits++
w.WriteHeader(http.StatusNoContent)
}))
defer discordSrv.Close()

var telegramHits int
telegramSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
telegramHits++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer telegramSrv.Close()

cfgPath := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(cfgPath, []byte(`
[channels.discord-main]
type = "discord"
webhook_url_env = "DISCORD_WEBHOOK_URL"

[channels.telegram-personal]
type = "telegram"
bot_token_env = "TELEGRAM_BOT_TOKEN"
chat_id_env = "TELEGRAM_CHAT_ID"
`), 0o600); err != nil {
t.Fatal(err)
}

body, _ := json.Marshal(map[string]string{
"title": strings.Repeat(".", channels.TelegramTextMax+10),
"body": "done",
})
code, _, stderr := runMain(t,
[]string{"agent-notify", "--config", cfgPath, "--to", "discord-main,telegram-personal"},
string(body),
map[string]string{
"DISCORD_WEBHOOK_URL": discordSrv.URL,
"TELEGRAM_BOT_TOKEN": "SECRET-BOT-TOKEN",
"TELEGRAM_CHAT_ID": "12345",
},
)
if code != exitFailures {
t.Fatalf("exit = %d, want %d (stderr=%s)", code, exitFailures, stderr)
}
if discordHits != 1 {
t.Fatalf("discord hits = %d, want 1", discordHits)
}
if telegramHits != 0 {
t.Fatalf("telegram hits = %d, want 0 (payload rejected before send)", telegramHits)
}
if !strings.Contains(stderr, "FAIL channel=telegram-personal") {
t.Fatalf("stderr missing telegram FAIL line: %q", stderr)
}
if !strings.Contains(stderr, "payload_limit") {
t.Fatalf("stderr missing payload_limit cause: %q", stderr)
}
if strings.Contains(stderr, "SECRET-BOT-TOKEN") {
t.Fatalf("stderr leaked bot token: %q", stderr)
}
}

// adapterMaxInputBytes mirrors adapter.MaxInputBytes without importing the
// adapter package into every CLI assertion helper.
func adapterMaxInputBytes() int {
return 256 * 1024
}
Comment thread
solomonneas marked this conversation as resolved.
Outdated
4 changes: 2 additions & 2 deletions stations/notify/internal/adapter/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ func FromString(s string) canonical.Message {
// - JSON arrays, scalars, and other non-object inputs are treated as
// plain string bodies.
func AutoDetect(r io.Reader) (canonical.Message, error) {
raw, err := io.ReadAll(r)
raw, err := ReadBounded(r)
if err != nil {
return canonical.Message{}, fmt.Errorf("read input: %w", err)
return canonical.Message{}, err
}
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" {
Expand Down
37 changes: 37 additions & 0 deletions stations/notify/internal/adapter/bound.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package adapter

import (
"fmt"
"io"
)

// MaxInputBytes is the hard ceiling for hook and stdin payloads. Reading stops
// one byte past this limit so oversized input is detected without unbounded
// allocation.
const MaxInputBytes = 256 * 1024

// ErrInputTooLarge is returned when hook or stdin input exceeds MaxInputBytes.
var ErrInputTooLarge = fmt.Errorf("input exceeds %d-byte limit", MaxInputBytes)

// ReadBounded reads from r up to MaxInputBytes. If more data is available it
// returns ErrInputTooLarge without retaining the excess.
func ReadBounded(r io.Reader) ([]byte, error) {
limited := io.LimitReader(r, int64(MaxInputBytes)+1)
raw, err := io.ReadAll(limited)
if err != nil {
return nil, fmt.Errorf("read input: %w", err)
}
if len(raw) > MaxInputBytes {
return nil, ErrInputTooLarge
}
return raw, nil
}

// CheckSize refuses a pre-buffered payload (for example a Codex argv JSON
// blob) that exceeds MaxInputBytes.
func CheckSize(raw []byte) error {
if len(raw) > MaxInputBytes {
return ErrInputTooLarge
}
return nil
}
92 changes: 92 additions & 0 deletions stations/notify/internal/adapter/bound_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package adapter

import (
"bytes"
"errors"
"io"
"strings"
"testing"
)

func TestReadBounded_BelowLimit(t *testing.T) {
in := bytes.Repeat([]byte("a"), MaxInputBytes-1)
got, err := ReadBounded(bytes.NewReader(in))
if err != nil {
t.Fatalf("ReadBounded: %v", err)
}
if len(got) != MaxInputBytes-1 {
t.Fatalf("len = %d, want %d", len(got), MaxInputBytes-1)
}
}

func TestReadBounded_AtLimit(t *testing.T) {
in := bytes.Repeat([]byte("b"), MaxInputBytes)
got, err := ReadBounded(bytes.NewReader(in))
if err != nil {
t.Fatalf("ReadBounded: %v", err)
}
if len(got) != MaxInputBytes {
t.Fatalf("len = %d, want %d", len(got), MaxInputBytes)
}
}

func TestReadBounded_AboveLimit(t *testing.T) {
in := bytes.Repeat([]byte("c"), MaxInputBytes+1)
got, err := ReadBounded(bytes.NewReader(in))
if !errors.Is(err, ErrInputTooLarge) {
t.Fatalf("err = %v, want ErrInputTooLarge", err)
}
if got != nil {
t.Fatalf("got %d bytes, want nil on oversized input", len(got))
}
}

func TestReadBounded_AboveLimitDoesNotDrainUnbounded(t *testing.T) {
// A reader that can produce far more than the limit must still stop after
// MaxInputBytes+1 so memory stays bounded.
r := &countingReader{limit: MaxInputBytes * 4}
_, err := ReadBounded(r)
if !errors.Is(err, ErrInputTooLarge) {
t.Fatalf("err = %v, want ErrInputTooLarge", err)
}
if r.read > MaxInputBytes+1 {
t.Fatalf("read %d bytes, want at most %d", r.read, MaxInputBytes+1)
}
}

func TestAutoDetect_AboveLimit(t *testing.T) {
in := strings.Repeat("x", MaxInputBytes+1)
_, err := AutoDetect(strings.NewReader(in))
if !errors.Is(err, ErrInputTooLarge) {
t.Fatalf("err = %v, want ErrInputTooLarge", err)
}
}

func TestCheckSize_Boundaries(t *testing.T) {
if err := CheckSize(bytes.Repeat([]byte("d"), MaxInputBytes)); err != nil {
t.Fatalf("at limit: %v", err)
}
if err := CheckSize(bytes.Repeat([]byte("e"), MaxInputBytes+1)); !errors.Is(err, ErrInputTooLarge) {
t.Fatalf("above limit: %v", err)
}
}

type countingReader struct {
limit int
read int
}

func (c *countingReader) Read(p []byte) (int, error) {
if c.read >= c.limit {
return 0, io.EOF
}
n := len(p)
if c.read+n > c.limit {
n = c.limit - c.read
}
for i := 0; i < n; i++ {
p[i] = 'z'
}
c.read += n
return n, nil
}
4 changes: 2 additions & 2 deletions stations/notify/internal/adapter/claude_code_notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import (
// ClaudeCodeNotification reads a Claude Code Notification hook event JSON
// and produces a canonical message.
func ClaudeCodeNotification(r io.Reader) (canonical.Message, error) {
raw, err := io.ReadAll(r)
raw, err := ReadBounded(r)
if err != nil {
return canonical.Message{}, fmt.Errorf("read input: %w", err)
return canonical.Message{}, err
}
var ev map[string]interface{}
if err := json.Unmarshal(raw, &ev); err != nil {
Expand Down
4 changes: 2 additions & 2 deletions stations/notify/internal/adapter/claude_code_stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import (
// to sensible defaults when fields are missing. Survives most schema
// additions and aliased renames without changes.
func ClaudeCodeStop(r io.Reader) (canonical.Message, error) {
raw, err := io.ReadAll(r)
raw, err := ReadBounded(r)
if err != nil {
return canonical.Message{}, fmt.Errorf("read input: %w", err)
return canonical.Message{}, err
}
var ev map[string]interface{}
if err := json.Unmarshal(raw, &ev); err != nil {
Expand Down
7 changes: 5 additions & 2 deletions stations/notify/internal/adapter/codex_notify.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import (
// message. Codex's notify schema is younger than Claude Code's, so this
// adapter is especially defensive about field name variations.
func CodexNotify(r io.Reader) (canonical.Message, error) {
raw, err := io.ReadAll(r)
raw, err := ReadBounded(r)
if err != nil {
return canonical.Message{}, fmt.Errorf("read input: %w", err)
return canonical.Message{}, err
}
return CodexNotifyFromBytes(raw)
}
Expand All @@ -23,6 +23,9 @@ func CodexNotify(r io.Reader) (canonical.Message, error) {
// Codex passes the event JSON as the last positional argv argument rather than
// on stdin, so the command layer can reach this directly with the arg payload.
func CodexNotifyFromBytes(raw []byte) (canonical.Message, error) {
if err := CheckSize(raw); err != nil {
return canonical.Message{}, err
}
var ev map[string]interface{}
if err := json.Unmarshal(raw, &ev); err != nil {
return canonical.Message{}, fmt.Errorf("parse codex event: %w", err)
Expand Down
79 changes: 65 additions & 14 deletions stations/notify/internal/channels/discord.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,9 @@ type discordRequest struct {
}

func (d *Discord) Send(ctx context.Context, m canonical.Message) error {
embed := discordEmbed{
Title: titleFor(m),
Description: m.Body,
Color: colorFor(m.Level),
}
if m.Source != "" {
embed.Footer = &discordFooter{Text: m.Source}
}
if len(m.Tags) > 0 {
embed.Fields = []discordField{{
Name: "tags",
Value: strings.Join(m.Tags, ", "),
Inline: true,
}}
embed, err := fitDiscordEmbed(m)
if err != nil {
return err
}

payload := discordRequest{Embeds: []discordEmbed{embed}}
Expand Down Expand Up @@ -114,3 +103,65 @@ func colorFor(level string) int {
return colorInfo
}
}

func fitDiscordEmbed(m canonical.Message) (discordEmbed, error) {
title, ok := truncateRunes(titleFor(m), DiscordTitleMax)
if !ok {
return discordEmbed{}, payloadLimitError("discord")
}
desc, ok := truncateRunes(m.Body, DiscordDescriptionMax)
if !ok {
return discordEmbed{}, payloadLimitError("discord")
}
embed := discordEmbed{
Title: title,
Description: desc,
Color: colorFor(m.Level),
}
if m.Source != "" {
footer, ok := truncateRunes(m.Source, DiscordFooterMax)
if !ok {
return discordEmbed{}, payloadLimitError("discord")
}
embed.Footer = &discordFooter{Text: footer}
}
if len(m.Tags) > 0 {
tags, ok := truncateRunes(strings.Join(m.Tags, ", "), DiscordFieldValueMax)
if !ok {
return discordEmbed{}, payloadLimitError("discord")
}
embed.Fields = []discordField{{
Name: "tags",
Value: tags,
Inline: true,
}}
}
if embedCharCount(embed) > DiscordEmbedTotalMax {
// Shrink description until the documented total embed ceiling fits.
overhead := embedCharCount(embed) - len(embed.Description)
budget := DiscordEmbedTotalMax - overhead
if budget < len(truncateSuffix) {
return discordEmbed{}, payloadLimitError("discord")
}
desc, ok = truncateRunes(m.Body, budget)
if !ok {
return discordEmbed{}, payloadLimitError("discord")
}
embed.Description = desc
if embedCharCount(embed) > DiscordEmbedTotalMax {
return discordEmbed{}, payloadLimitError("discord")
}
}
return embed, nil
}

func embedCharCount(e discordEmbed) int {
n := len(e.Title) + len(e.Description)
if e.Footer != nil {
n += len(e.Footer.Text)
}
for _, f := range e.Fields {
n += len(f.Name) + len(f.Value)
}
return n
}
Loading
Loading