diff --git a/bun.lock b/bun.lock index a52b426..1ffc2e3 100644 --- a/bun.lock +++ b/bun.lock @@ -34,7 +34,7 @@ "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", "@wgtechlabs/config-engine": "^0.1.0", - "zod": "^3.24.0", + "zod": "^4.4.3", }, }, "packages/core": { @@ -282,7 +282,7 @@ "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", "dompurify": "^3.2.6", - "marked": "^17.0.3", + "marked": "^18.0.0", "qrcode": "^1.5.4", "svelte": "^5.20.1", }, @@ -715,7 +715,7 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "marked": ["marked@17.0.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A=="], + "marked": ["marked@18.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -795,7 +795,7 @@ "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], @@ -825,6 +825,8 @@ "@types/ws/@types/node": ["@types/node@22.19.8", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA=="], + "@wgtechlabs/config-engine/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "@sveltejs/vite-plugin-svelte-inspector/vite/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], diff --git a/packages/core/src/loop.ts b/packages/core/src/loop.ts index cb804c0..85fef95 100644 --- a/packages/core/src/loop.ts +++ b/packages/core/src/loop.ts @@ -130,6 +130,18 @@ function sanitizeMessage(text: string, userId: string, ownerId: string | undefin return text; } +/** + * Strip characters from a userId that could break the sender-identity marker + * format or serve as a prompt-injection vector when embedded in a system prompt. + * + * Backticks (`) are stripped to prevent escaping the marker's backticks. + * Square brackets [] are stripped to prevent breaking the marker format. + * Newlines (\n\r) are stripped to prevent multi-line injection. + */ +function sanitizeUserIdForPrompt(userId: string): string { + return userId.replace(/[`\\[\\]\\n\\r]/g, ''); +} + // --------------------------------------------------------------------------- // Shield — in-memory pending approvals (conversational flow) // --------------------------------------------------------------------------- @@ -873,8 +885,14 @@ export async function agentLoop( const sanitizedMessage = sanitizeMessage(message, userId, context.ownerId); // Build messages + // Embed sender identity directly in the system prompt so the LLM can + // correctly apply owner-vs-friend rules for this entire turn (including any + // follow-up tool-result messages). Placing it in a separate system message + // would leave subsequent tool-follow-up user messages without a sender marker + // and would shift message indices expected by tests. + const senderIdentityPrompt = `\n\n[Current message sender: userId = \`${sanitizeUserIdForPrompt(userId)}\`]`; const messages: Message[] = [ - { role: 'system', content: systemPrompt }, + { role: 'system', content: systemPrompt + senderIdentityPrompt }, ...history, { role: 'user', content: sanitizedMessage }, ]; diff --git a/packages/core/tests/loop.test.ts b/packages/core/tests/loop.test.ts index 60ccb48..3c1d4e7 100644 --- a/packages/core/tests/loop.test.ts +++ b/packages/core/tests/loop.test.ts @@ -52,6 +52,11 @@ describe('agentLoop', () => { expect(systemPrompt).toContain('## Plugin Setup Guidance'); expect(systemPrompt).toContain('For Discord, explain that they need to create an application'); expect(systemPrompt).toContain('do not pretend the plugin is configured'); + // Sender-identity must be embedded in the system prompt so the LLM always + // knows who sent the message — verify the marker is present and the very + // next message is the user turn (no separate system message in between). + expect(systemPrompt).toContain('[Current message sender: userId = `web:test`]'); + expect(firstPrompt.at(-1)?.role).toBe('user'); }); test('turns structured write tool calls into a natural final reply', async () => { @@ -113,6 +118,11 @@ describe('agentLoop', () => { expect(result).toBe('I refreshed the configuration. Please restart Tiny Claw when convenient.'); expect(prompts).toHaveLength(2); + // Sender-identity is embedded in the system prompt (prompts[0][0]) and + // immediately followed by the user message — no separate system entry. + expect(prompts[0]?.[0]?.role).toBe('system'); + expect(prompts[0]?.[0]?.content).toContain('[Current message sender: userId = `web:test`]'); + expect(prompts[0]?.at(-1)?.role).toBe('user'); expect(prompts[1]?.at(-2)?.role).toBe('assistant'); expect(prompts[1]?.at(-2)?.content).toContain('I used these tools and the results were:'); expect(prompts[1]?.at(-2)?.content).toContain('Restart required: refresh config'); @@ -285,5 +295,17 @@ describe('agentLoop', () => { ); expect(prompts[1]?.at(-1)?.role).toBe('user'); expect(prompts[1]?.at(-1)?.content).toContain('respond naturally to my original message'); - }); -}); + }); + }); + + describe('sanitizeUserIdForPrompt', () => { + const { sanitizeUserIdForPrompt } = require('../src/loop.js'); + + test('strips backticks, brackets, and newlines', () => { + expect(sanitizeUserIdForPrompt('test`[id]\n')).toBe('testid'); + expect(sanitizeUserIdForPrompt('normal-id')).toBe('normal-id'); + expect(sanitizeUserIdForPrompt('')).toBe(''); + expect(sanitizeUserIdForPrompt('`[\\n\\r]')).toBe(''); + }); + }); + };// This is to close the outer describe block that was truncated in the initial read