fix(cli): stop prepending skill_lookup $name instruction onto user turns (#1096)

Cursor ACP (and other remotes) flagged the glued-on SKILL_LOOKUP_INSTRUCTION
as prompt injection. Keep discovery on the skill_lookup MCP tool description
and on system prompts (OpenCode/Grok); do not taint user messages.

Fixes #1095

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-20 18:29:29 +01:00
committed by GitHub
co-authored by Debian Cursor
parent b74a11ecc3
commit af962fc61f
7 changed files with 30 additions and 40 deletions
@@ -180,8 +180,10 @@ describe('runAgentSession', () => {
const firstPrompt = JSON.stringify(harness.prompts[0])
const secondPrompt = JSON.stringify(harness.prompts[1])
expect(firstPrompt).toContain('$name')
expect(firstPrompt).toContain('skill_lookup')
expect(firstPrompt).toContain('first')
expect(firstPrompt).not.toContain('skill_lookup')
expect(firstPrompt).not.toContain('$name')
expect(secondPrompt).toContain('second')
expect(secondPrompt).not.toContain('skill_lookup')
})
})
+3 -11
View File
@@ -16,8 +16,6 @@ import { PermissionModeSchema } from '@hapi/protocol/schemas';
import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
import type { SessionEndReason } from '@hapi/protocol';
import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstruction';
function emitReadyIfIdle(props: {
queueSize: () => number;
shouldExit: boolean;
@@ -101,8 +99,6 @@ export async function runAgentSession(opts: {
let thinking = false;
let shouldExit = false;
let waitAbortController: AbortController | null = null;
let skillLookupInstructionSent = false;
const syncKeepAlive = () => {
session.keepAlive(thinking, 'remote', {
permissionMode: currentPermissionMode
@@ -180,15 +176,11 @@ export async function runAgentSession(opts: {
continue;
}
let messageText = batch.message;
if (!skillLookupInstructionSent && !messageText.trimStart().startsWith('/')) {
messageText = `${SKILL_LOOKUP_INSTRUCTION}\n\n${messageText}`;
skillLookupInstructionSent = true;
}
// skill_lookup discovery lives on the MCP tool description — do not
// prepend instructions onto user turns (prompt-injection false positive).
const promptContent: PromptContent[] = [{
type: 'text',
text: messageText
text: batch.message
}];
thinking = true;
@@ -768,8 +768,10 @@ describe('cursorAcpRemoteLauncher', () => {
await cursorAcpRemoteLauncher(session);
expect(harness.promptCalls).toBe(2);
expect(JSON.stringify(harness.prompts[0])).toContain('$name');
expect(JSON.stringify(harness.prompts[0])).toContain('skill_lookup');
expect(JSON.stringify(harness.prompts[0])).toContain('first');
expect(JSON.stringify(harness.prompts[0])).not.toContain('skill_lookup');
expect(JSON.stringify(harness.prompts[0])).not.toContain('$name');
expect(JSON.stringify(harness.prompts[1])).toContain('second');
expect(JSON.stringify(harness.prompts[1])).not.toContain('skill_lookup');
});
});
+3 -11
View File
@@ -30,8 +30,6 @@ import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cur
import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels';
import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache';
import type { AcpSdkBackend } from '@/agent/backends/acp';
import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstruction';
class CursorAcpRemoteLauncher extends RemoteLauncherBase {
private readonly session: CursorSession;
private backend: ReturnType<typeof createCursorAcpBackend> | null = null;
@@ -48,8 +46,6 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
private spawnedWithAutoReview = false;
/** Avoid re-queueing `/auto-review` on every mid-session mode sync. */
private autoReviewSlashQueued = false;
private skillLookupInstructionSent = false;
constructor(session: CursorSession) {
super(process.env.DEBUG ? session.logPath : undefined);
this.session = session;
@@ -243,15 +239,11 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
}
messageBuffer.addMessage(batch.message, 'user');
let messageText = batch.message;
if (!this.skillLookupInstructionSent && !messageText.trimStart().startsWith('/')) {
messageText = `${SKILL_LOOKUP_INSTRUCTION}\n\n${messageText}`;
this.skillLookupInstructionSent = true;
}
// skill_lookup discovery lives on the MCP tool description — do not
// prepend instructions onto user turns (prompt-injection false positive).
const promptContent: PromptContent[] = [{
type: 'text',
text: messageText
text: batch.message
}];
session.onThinkingChange(true);
+4 -3
View File
@@ -73,12 +73,13 @@ describe('kimiRemoteLauncher skill lookup instruction', () => {
harness.prompts = []
})
it('injects the instruction only on the first prompt', async () => {
it('does not prepend skill_lookup instructions onto user turns', async () => {
await kimiRemoteLauncher(createSession() as never, { model: 'kimi-k2' })
expect(harness.prompts).toHaveLength(2)
expect(JSON.stringify(harness.prompts[0])).toContain('$name')
expect(JSON.stringify(harness.prompts[0])).toContain('skill_lookup')
expect(JSON.stringify(harness.prompts[0])).toContain('first')
expect(JSON.stringify(harness.prompts[0])).not.toContain('skill_lookup')
expect(JSON.stringify(harness.prompts[0])).not.toContain('$name')
expect(JSON.stringify(harness.prompts[1])).not.toContain('skill_lookup')
})
})
+3 -11
View File
@@ -10,8 +10,6 @@ import type { PermissionMode } from './types';
import { createKimiBackend } from './utils/kimiBackend';
import { KimiPermissionHandler } from './utils/permissionHandler';
import { resolveKimiRuntimeConfig } from './utils/config';
import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstruction';
class KimiRemoteLauncher extends RemoteLauncherBase {
private readonly session: KimiSession;
private readonly model?: string;
@@ -24,8 +22,6 @@ class KimiRemoteLauncher extends RemoteLauncherBase {
private currentBackendModel: string | null = null;
private setModelSupported: boolean | undefined = undefined;
private lastDisplayedToolCall = new Map<string, string>();
private skillLookupInstructionSent = false;
constructor(session: KimiSession, opts: { model?: string }) {
super(process.env.DEBUG ? session.logPath : undefined);
this.session = session;
@@ -171,15 +167,11 @@ class KimiRemoteLauncher extends RemoteLauncherBase {
this.applyDisplayMode(batch.mode.permissionMode, batch.mode.model);
messageBuffer.addMessage(batch.message, 'user');
let messageText = batch.message;
if (!this.skillLookupInstructionSent && !messageText.trimStart().startsWith('/')) {
messageText = `${SKILL_LOOKUP_INSTRUCTION}\n\n${messageText}`;
this.skillLookupInstructionSent = true;
}
// skill_lookup discovery lives on the MCP tool description — do not
// prepend instructions onto user turns (prompt-injection false positive).
const promptContent: PromptContent[] = [{
type: 'text',
text: messageText
text: batch.message
}];
session.onThinkingChange(true);
@@ -1,2 +1,11 @@
/**
* Discovery copy for agents that can host a durable system / instructions block
* (OpenCode, Grok). Do **not** prepend this to user turns — that path looks like
* prompt injection on Cursor ACP and similar remotes (tiann/hapi#1095).
*
* Cursor / Kimi / generic ACP rely on the `skill_lookup` MCP tool description
* (and Cursor's native `.cursor/mcp.json` overlay where session/new mcpServers
* are ignored) instead of a user-message prepend.
*/
export const SKILL_LOOKUP_INSTRUCTION =
'When a user message starts with "$name", call HAPI\'s skill_lookup tool with "name" (without "$") before acting.'