mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): filter system-injected XML tags from rendering as raw text (#387)
* fix(web): filter system-injected XML tags from rendering as raw text Claude Code injects internal messages (<task-notification>, <system-reminder>, <command-name>, <local-command-caveat>) as user-role messages. The web UI was rendering these as raw XML text visible to users. - Parse <task-notification> and display as agent-event with summary text - Silently drop <system-reminder>, <command-name>, <local-command-caveat> - Add tests covering all injection prefixes and edge cases * fix(web): scope system injection filtering to Claude sessions only Address review feedback: the XML tag filtering was applied at the generic timeline layer, which could incorrectly hide legitimate user messages in Codex/Gemini sessions. - Add isClaudeSession flag threaded from Session.metadata.claudeSessionId - Only filter system-injected tags when isClaudeSession is true - Add tests verifying non-Claude sessions pass through all messages * fix(web): treat all string user output as sidechain to prevent prompt leaks Restores the fix from 3cf96ab that was accidentally reverted in 2205e04. In normalizeUserOutput(), string-content user messages arriving through the agent output path are never real user input (real user text goes through normalizeUserRecord). Previously, non-sidechain string messages were emitted as role:'user', causing subagent prompts and system-injected messages to render as user text in the web UI. Now all string-content user messages in this path are: - <task-notification> with summary → converted to role:'event' - Everything else → marked as sidechain (matched to parent Task tool call by the tracer, or harmlessly skipped by the reducer) This provides a root-level fix that prevents ANY string user message from the agent output path from leaking as visible user text. * ci: retrigger CI * fix(web): remove superseded return-null filter from upstream PR #372 The upstream `return null` filter for <task-notification> and <system-reminder> (from PR #372) is now superseded by the comprehensive sidechain upgrade logic. Remove it to avoid short-circuiting the new task-notification → event conversion. * refactor(web): remove reducer-side system injection filtering System-injected messages are now fully handled in normalizeUserOutput() (normalize layer), so the redundant filtering in reduceTimeline() is no longer needed. Removing it also eliminates the risk of accidentally hiding legitimate user messages that happen to start with XML tags. - Remove SYSTEM_INJECTION_PREFIXES, isSystemInjectedMessage, parseTaskNotificationSummary from reducerTimeline.ts - Remove isClaudeSession plumbing from reducer.ts and SessionChat.tsx - Simplify reducerTimeline.test.ts to only test pass-through behavior
This commit is contained in:
@@ -104,4 +104,95 @@ describe('normalizeDecryptedMessage', () => {
|
||||
}
|
||||
expect(firstBlock.text).toContain('"foo": "bar"')
|
||||
})
|
||||
|
||||
it('converts <task-notification> user output to event', () => {
|
||||
const message = makeMessage({
|
||||
role: 'agent',
|
||||
content: {
|
||||
type: 'output',
|
||||
data: {
|
||||
type: 'user',
|
||||
message: { content: '<task-notification> <summary>Background command stopped</summary> </task-notification>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const normalized = normalizeDecryptedMessage(message)
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
id: 'msg-1',
|
||||
role: 'event',
|
||||
isSidechain: false,
|
||||
content: { type: 'message', message: 'Background command stopped' }
|
||||
})
|
||||
})
|
||||
|
||||
it('treats <task-notification> without summary as sidechain (dropped by reducer)', () => {
|
||||
const message = makeMessage({
|
||||
role: 'agent',
|
||||
content: {
|
||||
type: 'output',
|
||||
data: {
|
||||
type: 'user',
|
||||
uuid: 'u3',
|
||||
message: { content: '<task-notification> <status>killed</status> </task-notification>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const normalized = normalizeDecryptedMessage(message)
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
role: 'agent',
|
||||
isSidechain: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('treats non-sidechain string user output as sidechain', () => {
|
||||
const message = makeMessage({
|
||||
role: 'agent',
|
||||
content: {
|
||||
type: 'output',
|
||||
data: {
|
||||
type: 'user',
|
||||
isSidechain: false,
|
||||
uuid: 'u1',
|
||||
message: { content: 'This is a subagent prompt' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const normalized = normalizeDecryptedMessage(message)
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
role: 'agent',
|
||||
isSidechain: true,
|
||||
})
|
||||
if (normalized?.role !== 'agent') throw new Error('Expected agent')
|
||||
expect(normalized.content[0]).toMatchObject({
|
||||
type: 'sidechain',
|
||||
prompt: 'This is a subagent prompt'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats <system-reminder> user output as sidechain (dropped by reducer)', () => {
|
||||
const message = makeMessage({
|
||||
role: 'agent',
|
||||
content: {
|
||||
type: 'output',
|
||||
data: {
|
||||
type: 'user',
|
||||
uuid: 'u2',
|
||||
message: { content: '<system-reminder>Some internal reminder</system-reminder>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const normalized = normalizeDecryptedMessage(message)
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
role: 'agent',
|
||||
isSidechain: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,18 +109,6 @@ function normalizeUserOutput(
|
||||
|
||||
const messageContent = message.content
|
||||
|
||||
// Skip system-injected messages that were logged as type:'user' but are
|
||||
// not text the human actually typed (task notifications, command caveats, etc.)
|
||||
if (typeof messageContent === 'string') {
|
||||
const trimmed = messageContent.trimStart()
|
||||
if (
|
||||
trimmed.startsWith('<task-notification>') ||
|
||||
trimmed.startsWith('<system-reminder>')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (isSidechain && typeof messageContent === 'string') {
|
||||
return {
|
||||
id: messageId,
|
||||
@@ -132,15 +120,37 @@ function normalizeUserOutput(
|
||||
}
|
||||
}
|
||||
|
||||
// Handle system-injected messages that arrive as type:'user' through
|
||||
// the agent output path. Real user text goes through normalizeUserRecord.
|
||||
if (typeof messageContent === 'string') {
|
||||
// Convert <task-notification> to a visible event
|
||||
const trimmed = messageContent.trimStart()
|
||||
if (trimmed.startsWith('<task-notification>')) {
|
||||
const summary = trimmed.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]?.trim()
|
||||
if (summary) {
|
||||
return {
|
||||
id: messageId,
|
||||
localId,
|
||||
createdAt,
|
||||
role: 'event',
|
||||
content: { type: 'message', message: summary },
|
||||
isSidechain: false,
|
||||
meta
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All other string-content user messages in this path are
|
||||
// system-injected (subagent prompts, system reminders, etc.).
|
||||
// Treat as sidechain so the tracer can match it to a parent Task
|
||||
// tool call; unmatched ones are harmlessly skipped by the reducer.
|
||||
return {
|
||||
id: messageId,
|
||||
localId,
|
||||
createdAt,
|
||||
role: 'user',
|
||||
isSidechain: false,
|
||||
content: { type: 'text', text: messageContent },
|
||||
meta
|
||||
role: 'agent',
|
||||
isSidechain: true,
|
||||
content: [{ type: 'sidechain', uuid, prompt: messageContent }]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { reduceTimeline } from './reducerTimeline'
|
||||
import type { TracedMessage } from './tracer'
|
||||
|
||||
function makeContext() {
|
||||
return {
|
||||
permissionsById: new Map(),
|
||||
groups: new Map(),
|
||||
consumedGroupIds: new Set<string>(),
|
||||
titleChangesByToolUseId: new Map(),
|
||||
emittedTitleChangeToolUseIds: new Set<string>()
|
||||
}
|
||||
}
|
||||
|
||||
function makeUserMessage(text: string, overrides?: Partial<TracedMessage>): TracedMessage {
|
||||
return {
|
||||
id: 'msg-1',
|
||||
localId: null,
|
||||
createdAt: 1_700_000_000_000,
|
||||
role: 'user',
|
||||
content: { type: 'text', text },
|
||||
isSidechain: false,
|
||||
...overrides
|
||||
} as TracedMessage
|
||||
}
|
||||
|
||||
describe('reduceTimeline', () => {
|
||||
it('renders user text as user-text block', () => {
|
||||
const text = 'Hello, this is a normal message'
|
||||
const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext())
|
||||
|
||||
expect(blocks).toHaveLength(1)
|
||||
expect(blocks[0].kind).toBe('user-text')
|
||||
})
|
||||
|
||||
it('does not filter XML-like user text (filtering is in normalize layer)', () => {
|
||||
const text = '<task-notification> <summary>Some task</summary> </task-notification>'
|
||||
const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext())
|
||||
|
||||
expect(blocks).toHaveLength(1)
|
||||
expect(blocks[0].kind).toBe('user-text')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user