Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
9 changes: 9 additions & 0 deletions internal/telegram/approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,15 @@ func (tc *TelegramChannel) handleMessage(msg *tgbotapi.Message) {
}

reply := tgbotapi.NewMessage(tc.chatID, response)
// Responses that contain <code> or <b> are pre-rendered HTML (see
// policyShow / policyShowFromStore). Send them with HTML parse mode so
// the tags render and Telegram does not auto-link URLs inside <code>.
// Plain-text responses never contain these tags because dynamic input
// gets HTML-escaped before insertion.
if strings.Contains(response, "<code>") || strings.Contains(response, "<b>") {
reply.ParseMode = tgbotapi.ModeHTML
reply.DisableWebPagePreview = true
}
if _, err := tc.api.Send(reply); err != nil {
log.Printf("telegram send error: %s", sanitizeError(err))
}
Expand Down
13 changes: 7 additions & 6 deletions internal/telegram/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ var protoDisplayName = map[string]string{
// Callers must set ParseMode to HTML when sending the message.
func FormatApprovalMessage(req channel.ApprovalRequest) string {
if req.Protocol == "mcp" {
msg := "OpenClaw wants to call tool:\n\n" + htmlEscape(req.Destination)
msg := "OpenClaw wants to call tool:\n\n" + htmlCode(req.Destination)
if req.ToolArgs != "" {
pretty := prettyJSONOrRaw(req.ToolArgs)
msg += "\n\nArguments:\n<pre><code class=\"language-json\">" + htmlEscape(pretty) + "</code></pre>"
Expand All @@ -69,20 +69,21 @@ func FormatApprovalMessage(req channel.ApprovalRequest) string {
if display == "" {
display = req.Protocol
}
destPort := fmt.Sprintf("%s:%d", req.Destination, req.Port)
if req.Method != "" {
ver := ""
if req.HTTPVersion != "" {
ver = " (" + htmlEscape(req.HTTPVersion) + ")"
}
return fmt.Sprintf(
"OpenClaw wants to connect to:\n\n%s %s:%d\n%s %s%s\n\nAllow this request?",
htmlEscape(display), htmlEscape(req.Destination), req.Port,
htmlEscape(req.Method), htmlEscape(buildRequestURL(req)), ver,
"OpenClaw wants to connect to:\n\n%s %s\n%s %s%s\n\nAllow this request?",
htmlEscape(display), htmlCode(destPort),
htmlEscape(req.Method), htmlCode(buildRequestURL(req)), ver,
)
}
return fmt.Sprintf(
"OpenClaw wants to connect to:\n\n%s %s:%d\n\nAllow this connection?",
htmlEscape(display), htmlEscape(req.Destination), req.Port,
"OpenClaw wants to connect to:\n\n%s %s\n\nAllow this connection?",
htmlEscape(display), htmlCode(destPort),
)
}

Expand Down
12 changes: 6 additions & 6 deletions internal/telegram/bot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ func TestFormatApprovalMessage(t *testing.T) {
Protocol: "http",
}
msg := FormatApprovalMessage(req)
if !strings.Contains(msg, "HTTP example.com:8080") {
t.Errorf("expected 'HTTP example.com:8080' in message, got: %s", msg)
if !strings.Contains(msg, "HTTP <code>example.com:8080</code>") {
t.Errorf("expected 'HTTP <code>example.com:8080</code>' in message, got: %s", msg)
}
})

Expand Down Expand Up @@ -167,10 +167,10 @@ func TestFormatApprovalMessage(t *testing.T) {
Path: "/users/me",
}
msg := FormatApprovalMessage(req)
if !strings.Contains(msg, "HTTPS api.example.com:443") {
if !strings.Contains(msg, "HTTPS <code>api.example.com:443</code>") {
t.Errorf("expected destination line in message, got: %s", msg)
}
if !strings.Contains(msg, "GET https://api.example.com/users/me") {
if !strings.Contains(msg, "GET <code>https://api.example.com/users/me</code>") {
t.Errorf("expected request line in message, got: %s", msg)
}
if !strings.Contains(msg, "Allow this request?") {
Expand All @@ -187,7 +187,7 @@ func TestFormatApprovalMessage(t *testing.T) {
Path: "/api/submit",
}
msg := FormatApprovalMessage(req)
if !strings.Contains(msg, "POST http://localhost:8080/api/submit") {
if !strings.Contains(msg, "POST <code>http://localhost:8080/api/submit</code>") {
t.Errorf("expected URL with explicit port, got: %s", msg)
}
})
Expand All @@ -201,7 +201,7 @@ func TestFormatApprovalMessage(t *testing.T) {
Path: "",
}
msg := FormatApprovalMessage(req)
if !strings.Contains(msg, "HEAD https://example.com/") {
if !strings.Contains(msg, "HEAD <code>https://example.com/</code>") {
t.Errorf("expected URL with default path '/', got: %s", msg)
}
})
Expand Down
40 changes: 22 additions & 18 deletions internal/telegram/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,7 @@ func (h *CommandHandler) policyShow() string {
// Fallback to engine snapshot when store is not configured.
snap := h.engine.Load().Snapshot()
var b strings.Builder
b.WriteString("Current policy (default: ")
b.WriteString(snap.Default.String())
b.WriteString(")\n\n")
fmt.Fprintf(&b, "Current policy (default: %s)\n\n", htmlCode(snap.Default.String()))

for _, section := range []struct {
label string
Expand All @@ -235,14 +233,14 @@ func (h *CommandHandler) policyShow() string {
b.WriteString(":\n")
for _, r := range section.rules {
b.WriteString(" ")
b.WriteString(r.Destination)
b.WriteString(htmlCode(r.Destination))
if len(r.Ports) > 0 {
b.WriteString(" ports=")
b.WriteString(formatPorts(r.Ports))
}
if len(r.Protocols) > 0 {
b.WriteString(" protocols=")
b.WriteString(strings.Join(r.Protocols, ","))
b.WriteString(htmlCode(strings.Join(r.Protocols, ",")))
}
b.WriteString("\n")
}
Expand All @@ -255,6 +253,13 @@ func (h *CommandHandler) policyShow() string {
return b.String()
}

// htmlCode wraps s in <code>...</code> after HTML-escaping it. Inside <code>
// Telegram will not auto-detect URLs, so destinations like api.github.com
// render as monospace text rather than clickable blue links.
func htmlCode(s string) string {
return "<code>" + htmlEscape(s) + "</code>"
}

func (h *CommandHandler) policyShowFromStore() string {
cfg, err := h.store.GetConfig()
if err != nil {
Expand All @@ -271,9 +276,7 @@ func (h *CommandHandler) policyShowFromStore() string {
}

var b strings.Builder
b.WriteString("Current policy (default: ")
b.WriteString(dv)
b.WriteString(")\n\n")
fmt.Fprintf(&b, "Current policy (default: %s)\n\n", htmlCode(dv))

for _, section := range []struct {
label string
Expand All @@ -293,32 +296,33 @@ func (h *CommandHandler) policyShowFromStore() string {
if len(sectionRules) == 0 {
continue
}
b.WriteString("<b>")
b.WriteString(section.label)
b.WriteString(":\n")
b.WriteString("</b>:\n")
for _, r := range sectionRules {
target := r.Destination
if r.Tool != "" {
target = "tool:" + r.Tool
} else if r.Pattern != "" {
target = "pattern:" + r.Pattern
}
fmt.Fprintf(&b, " [%d] %s", r.ID, target)
fmt.Fprintf(&b, " [%d] %s", r.ID, htmlCode(target))
if len(r.Ports) > 0 {
b.WriteString(" ports=")
b.WriteString(formatPorts(r.Ports))
}
if len(r.Protocols) > 0 {
b.WriteString(" protocols=")
b.WriteString(strings.Join(r.Protocols, ","))
b.WriteString(htmlCode(strings.Join(r.Protocols, ",")))
}
if r.Replacement != "" {
fmt.Fprintf(&b, " -> %q", r.Replacement)
fmt.Fprintf(&b, " -> %s", htmlCode(r.Replacement))
}
if r.Name != "" {
fmt.Fprintf(&b, " (%s)", r.Name)
fmt.Fprintf(&b, " (%s)", htmlEscape(r.Name))
}
if r.Source != "" {
fmt.Fprintf(&b, " [%s]", r.Source)
fmt.Fprintf(&b, " [%s]", htmlEscape(r.Source))
}
b.WriteString("\n")
}
Expand Down Expand Up @@ -350,14 +354,14 @@ func (h *CommandHandler) policyAllow(dest string) string {
if err := h.recompileAndSwap(); err != nil {
return fmt.Sprintf("Added allow rule but failed to recompile: %v", err)
}
return fmt.Sprintf("Added allow rule: %s", dest)
return "Added allow rule: " + htmlCode(dest)
}

// Fallback to in-memory mutation when store is not configured.
if err := h.engine.Load().AddAllowRule(dest); err != nil { //nolint:staticcheck // backward compat fallback when no store
return fmt.Sprintf("Failed to add allow rule: %v", err)
}
return fmt.Sprintf("Added allow rule: %s%s", dest, inMemoryWarning)
return "Added allow rule: " + htmlCode(dest) + inMemoryWarning
}

func (h *CommandHandler) policyDeny(dest string) string {
Expand All @@ -375,13 +379,13 @@ func (h *CommandHandler) policyDeny(dest string) string {
if err := h.recompileAndSwap(); err != nil {
return fmt.Sprintf("Added deny rule but failed to recompile: %v", err)
}
return fmt.Sprintf("Added deny rule: %s", dest)
return "Added deny rule: " + htmlCode(dest)
}

if err := h.engine.Load().AddDenyRule(dest); err != nil { //nolint:staticcheck // backward compat fallback when no store
return fmt.Sprintf("Failed to add deny rule: %v", err)
}
return fmt.Sprintf("Added deny rule: %s%s", dest, inMemoryWarning)
return "Added deny rule: " + htmlCode(dest) + inMemoryWarning
}

func (h *CommandHandler) policyRemove(idStr string) string {
Expand Down
45 changes: 41 additions & 4 deletions internal/telegram/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -469,20 +469,57 @@ func TestPolicyShowIncludesAllFields(t *testing.T) {
out := handler.Handle(&Command{Name: "policy", Args: []string{"show"}})

mustContain := []string{
"example.com",
"<code>example.com</code>",
"ports=443",
"protocols=quic",
"protocols=<code>quic</code>",
"(test rule)",
"[manual]",
"pattern:sk-[A-Za-z0-9]+",
`-> "sk-REDACTED"`,
"<code>pattern:sk-[A-Za-z0-9]+</code>",
"-> <code>sk-REDACTED</code>",
"[seed]",
}
for _, want := range mustContain {
if !strings.Contains(out, want) {
t.Errorf("policy show output missing %q\nfull output:\n%s", want, out)
}
}

// Section headers are bolded so the sender picks HTML parse mode,
// which also disables Telegram's URL auto-linking inside <code>.
if !strings.Contains(out, "<b>ALLOW</b>") {
t.Errorf("policy show output must bold section headers: %q", out)
}
}

func TestPolicyShowEscapesHTML(t *testing.T) {
s := newTestStore(t)
if _, err := s.AddRule("deny", store.RuleOpts{
Pattern: "<script>",
Source: "manual",
}); err != nil {
t.Fatal(err)
}
if _, err := s.AddRule("redact", store.RuleOpts{
Pattern: "a&b",
Replacement: "<x>",
}); err != nil {
t.Fatal(err)
}

handler := newTestHandlerWithStore(t, s, nil, "")
out := handler.Handle(&Command{Name: "policy", Args: []string{"show"}})

// Raw "<script>" must not survive the <code> wrapper. It should be
// rendered as "<code>pattern:&lt;script&gt;</code>".
if strings.Contains(out, "pattern:<script>") {
t.Errorf("raw <script> must be HTML-escaped: %s", out)
}
if !strings.Contains(out, "&lt;script&gt;") {
t.Errorf("expected &lt;script&gt; in output: %s", out)
}
if !strings.Contains(out, "a&amp;b") {
t.Errorf("expected a&amp;b in output: %s", out)
}
}

func TestPolicyRemoveThenRecompile(t *testing.T) {
Expand Down
Loading