Skip to content

Commit ab14f6e

Browse files
committed
feat: add pure context mode to disable prompt injection
1 parent 1ed407d commit ab14f6e

33 files changed

Lines changed: 467 additions & 75 deletions

cli/src/agent/utils/haqiAgentInstructions.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,25 @@ describe('haqiAgentInstructions', () => {
107107

108108
expect(prompt).toContain('[truncated: MEMORY.md exceeded 65536 bytes]')
109109
})
110+
111+
it('skips all automatic prompt injections when pure context mode is enabled', () => {
112+
const startDir = join(tempRoot, 'workspace')
113+
const hapiHome = join(tempRoot, 'hapi-home')
114+
mkdirSync(startDir, { recursive: true })
115+
mkdirSync(hapiHome, { recursive: true })
116+
process.env.HAPI_HOME = hapiHome
117+
writeFileSync(join(startDir, 'HAQI-Agent.md'), 'repo-rules')
118+
writeFileSync(
119+
join(hapiHome, 'settings.json'),
120+
JSON.stringify({
121+
memoryInjectionEnabled: true,
122+
pureContextMode: true
123+
}, null, 2)
124+
)
125+
126+
const prompt = buildPromptWithHaqiAgentInstructions('BASE PROMPT', startDir)
127+
128+
expect(prompt).toBe('BASE PROMPT')
129+
expect(existsSync(join(hapiHome, 'MEMORY.md'))).toBe(false)
130+
})
110131
})

cli/src/agent/utils/haqiAgentInstructions.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const WORKSPACE_INSTRUCTIONS_FILE = 'HAQI-Agent.md'
88
const GLOBAL_MEMORY_FILE = 'MEMORY.md'
99
const GLOBAL_SETTINGS_FILE = 'settings.json'
1010
const DEFAULT_MEMORY_INJECTION_ENABLED = false
11+
const DEFAULT_PURE_CONTEXT_MODE = false
1112
const MAX_FILE_BYTES = 64 * 1024
1213
const DEFAULT_MEMORY_TEMPLATE = trimIdent(`
1314
# MEMORY.md
@@ -61,27 +62,54 @@ function resolveGlobalSettingsPath(): string {
6162
return join(resolveHapiHomeDir(), GLOBAL_SETTINGS_FILE)
6263
}
6364

64-
function isGlobalMemoryInjectionEnabled(): boolean {
65+
type GlobalPromptSettings = {
66+
memoryInjectionEnabled: boolean
67+
pureContextMode: boolean
68+
}
69+
70+
function readGlobalPromptSettings(): GlobalPromptSettings {
6571
try {
6672
const filepath = resolveGlobalSettingsPath()
6773
if (!existsSync(filepath)) {
68-
return DEFAULT_MEMORY_INJECTION_ENABLED
74+
return {
75+
memoryInjectionEnabled: DEFAULT_MEMORY_INJECTION_ENABLED,
76+
pureContextMode: DEFAULT_PURE_CONTEXT_MODE
77+
}
6978
}
7079
const raw = readFileSync(filepath, 'utf-8').trim()
7180
if (!raw) {
72-
return DEFAULT_MEMORY_INJECTION_ENABLED
81+
return {
82+
memoryInjectionEnabled: DEFAULT_MEMORY_INJECTION_ENABLED,
83+
pureContextMode: DEFAULT_PURE_CONTEXT_MODE
84+
}
85+
}
86+
const parsed = JSON.parse(raw) as {
87+
memoryInjectionEnabled?: unknown
88+
pureContextMode?: unknown
7389
}
74-
const parsed = JSON.parse(raw) as { memoryInjectionEnabled?: unknown }
75-
if (typeof parsed.memoryInjectionEnabled === 'boolean') {
76-
return parsed.memoryInjectionEnabled
90+
const memoryInjectionEnabled = typeof parsed.memoryInjectionEnabled === 'boolean'
91+
? parsed.memoryInjectionEnabled
92+
: DEFAULT_MEMORY_INJECTION_ENABLED
93+
const pureContextMode = typeof parsed.pureContextMode === 'boolean'
94+
? parsed.pureContextMode
95+
: DEFAULT_PURE_CONTEXT_MODE
96+
return {
97+
memoryInjectionEnabled,
98+
pureContextMode
7799
}
78-
return DEFAULT_MEMORY_INJECTION_ENABLED
79100
} catch (error) {
80101
logger.debug('[haqi-agent-instructions] failed to load global settings', error)
81-
return DEFAULT_MEMORY_INJECTION_ENABLED
102+
return {
103+
memoryInjectionEnabled: DEFAULT_MEMORY_INJECTION_ENABLED,
104+
pureContextMode: DEFAULT_PURE_CONTEXT_MODE
105+
}
82106
}
83107
}
84108

109+
export function isPureContextModeEnabled(): boolean {
110+
return readGlobalPromptSettings().pureContextMode
111+
}
112+
85113
function ensureGlobalMemoryFile(filepath: string): void {
86114
if (existsSync(filepath)) {
87115
return
@@ -140,9 +168,13 @@ export function loadGlobalMemory(): { path: string; content: string } | null {
140168
}
141169

142170
export function buildPromptWithHaqiAgentInstructions(basePrompt: string, startDir: string): string {
171+
const promptSettings = readGlobalPromptSettings()
172+
if (promptSettings.pureContextMode) {
173+
return basePrompt
174+
}
175+
143176
const instructions = loadHaqiAgentInstructions(startDir)
144-
const memoryInjectionEnabled = isGlobalMemoryInjectionEnabled()
145-
const globalMemory = memoryInjectionEnabled ? loadGlobalMemory() : null
177+
const globalMemory = promptSettings.memoryInjectionEnabled ? loadGlobalMemory() : null
146178
if (!instructions && !globalMemory) {
147179
return basePrompt
148180
}

cli/src/claude/claudeLocal.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ export async function claudeLocal(opts: {
5353
}
5454

5555
const resolvedSystemPrompt = buildClaudeSystemPrompt(opts.path);
56-
args.push('--append-system-prompt', stripNewlinesForWindowsShellArg(resolvedSystemPrompt));
56+
if (resolvedSystemPrompt.trim()) {
57+
args.push('--append-system-prompt', stripNewlinesForWindowsShellArg(resolvedSystemPrompt));
58+
}
5759

5860
const cleanupMcpConfig = appendMcpConfigArg(args, opts.mcpServers, {
5961
baseDir: projectDir

cli/src/claude/claudeRemote.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { awaitFileExist } from "@/modules/watcher/awaitFileExist";
1010
import { buildClaudeSystemPrompt } from "./utils/systemPrompt";
1111
import { PermissionResult } from "./sdk/types";
1212
import { getHapiBlobsDir } from "@/constants/uploadPaths";
13+
import { isPureContextModeEnabled } from "@/agent/utils/haqiAgentInstructions";
1314

1415
export async function claudeRemote(opts: {
1516

@@ -108,6 +109,23 @@ export async function claudeRemote(opts: {
108109
}
109110

110111
const resolvedSystemPrompt = buildClaudeSystemPrompt(opts.path);
112+
const pureContextMode = isPureContextModeEnabled();
113+
const normalizedSystemPrompt = resolvedSystemPrompt.trim();
114+
115+
const customSystemPrompt = pureContextMode
116+
? undefined
117+
: (initial.mode.customSystemPrompt
118+
? (normalizedSystemPrompt
119+
? `${initial.mode.customSystemPrompt}\n\n${normalizedSystemPrompt}`
120+
: initial.mode.customSystemPrompt)
121+
: undefined);
122+
const appendSystemPrompt = pureContextMode
123+
? undefined
124+
: (initial.mode.appendSystemPrompt
125+
? (normalizedSystemPrompt
126+
? `${initial.mode.appendSystemPrompt}\n\n${normalizedSystemPrompt}`
127+
: initial.mode.appendSystemPrompt)
128+
: (normalizedSystemPrompt || undefined));
111129

112130
// Prepare SDK options
113131
let mode = initial.mode;
@@ -119,8 +137,8 @@ export async function claudeRemote(opts: {
119137
model: initial.mode.model,
120138
effort: initial.mode.thinkEffort,
121139
fallbackModel: initial.mode.fallbackModel,
122-
customSystemPrompt: initial.mode.customSystemPrompt ? initial.mode.customSystemPrompt + '\n\n' + resolvedSystemPrompt : undefined,
123-
appendSystemPrompt: initial.mode.appendSystemPrompt ? initial.mode.appendSystemPrompt + '\n\n' + resolvedSystemPrompt : resolvedSystemPrompt,
140+
customSystemPrompt,
141+
appendSystemPrompt,
124142
allowedTools: initial.mode.allowedTools ? initial.mode.allowedTools.concat(opts.allowedTools) : opts.allowedTools,
125143
disallowedTools: initial.mode.disallowedTools,
126144
canCallTool: (toolName: string, input: unknown, options: { signal: AbortSignal }) => opts.canCallTool(toolName, input, mode, options),

cli/src/claude/runClaude.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { isModelModeAllowedForFlavor, isPermissionModeAllowedForFlavor } from '@
1818
import { ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas';
1919
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
2020
import { findClaudeThinkEffortFromArgs, type ClaudeThinkEffort, resolveClaudeModelSelection } from './modelMode';
21+
import { isPureContextModeEnabled } from '@/agent/utils/haqiAgentInstructions';
2122

2223
export interface StartOptions {
2324
model?: string
@@ -427,11 +428,15 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
427428
}
428429
const messagePermissionMode = currentPermissionMode;
429430
const messageModel = currentModel;
431+
const pureContextMode = isPureContextModeEnabled();
430432
logger.debug(`[loop] User message received with permission mode: ${currentPermissionMode}, modelMode: ${currentModelMode}, model: ${currentModel ?? 'default'}`);
431433

432434
// Resolve custom system prompt - use message.meta.customSystemPrompt if provided, otherwise use current
433435
let messageCustomSystemPrompt = currentCustomSystemPrompt;
434-
if (message.meta?.hasOwnProperty('customSystemPrompt')) {
436+
if (pureContextMode) {
437+
messageCustomSystemPrompt = undefined;
438+
currentCustomSystemPrompt = undefined;
439+
} else if (message.meta?.hasOwnProperty('customSystemPrompt')) {
435440
messageCustomSystemPrompt = message.meta.customSystemPrompt || undefined; // null becomes undefined
436441
currentCustomSystemPrompt = messageCustomSystemPrompt;
437442
logger.debug(`[loop] Custom system prompt updated from user message: ${messageCustomSystemPrompt ? 'set' : 'reset to none'}`);
@@ -451,7 +456,10 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
451456

452457
// Resolve append system prompt - use message.meta.appendSystemPrompt if provided, otherwise use current
453458
let messageAppendSystemPrompt = currentAppendSystemPrompt;
454-
if (message.meta?.hasOwnProperty('appendSystemPrompt')) {
459+
if (pureContextMode) {
460+
messageAppendSystemPrompt = undefined;
461+
currentAppendSystemPrompt = undefined;
462+
} else if (message.meta?.hasOwnProperty('appendSystemPrompt')) {
455463
messageAppendSystemPrompt = message.meta.appendSystemPrompt || undefined; // null becomes undefined
456464
currentAppendSystemPrompt = messageAppendSystemPrompt;
457465
logger.debug(`[loop] Append system prompt updated from user message: ${messageAppendSystemPrompt ? 'set' : 'reset to none'}`);
@@ -632,13 +640,14 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
632640
try {
633641
const parsed = resolveEnqueuePayload(payload);
634642
const formattedText = formatMessageWithAttachments(parsed.text, parsed.attachments);
643+
const pureContextMode = isPureContextModeEnabled();
635644
const enhancedMode: EnhancedMode = {
636645
permissionMode: currentPermissionMode ?? 'default',
637646
model: currentModel,
638647
thinkEffort: currentThinkEffort,
639648
fallbackModel: currentFallbackModel,
640-
customSystemPrompt: currentCustomSystemPrompt,
641-
appendSystemPrompt: currentAppendSystemPrompt,
649+
customSystemPrompt: pureContextMode ? undefined : currentCustomSystemPrompt,
650+
appendSystemPrompt: pureContextMode ? undefined : currentAppendSystemPrompt,
642651
allowedTools: currentAllowedTools,
643652
disallowedTools: currentDisallowedTools,
644653
routeContext: parsed.routeContext
@@ -727,12 +736,13 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
727736
try {
728737
const parsed = resolveEnqueuePayload(payload);
729738
const formattedText = formatMessageWithAttachments(parsed.text, parsed.attachments);
739+
const pureContextMode = isPureContextModeEnabled();
730740
const enhancedMode: EnhancedMode = {
731741
permissionMode: currentPermissionMode ?? 'default',
732742
model: currentModelMode === 'default' ? undefined : currentModelMode,
733743
fallbackModel: currentFallbackModel,
734-
customSystemPrompt: currentCustomSystemPrompt,
735-
appendSystemPrompt: currentAppendSystemPrompt,
744+
customSystemPrompt: pureContextMode ? undefined : currentCustomSystemPrompt,
745+
appendSystemPrompt: pureContextMode ? undefined : currentAppendSystemPrompt,
736746
allowedTools: currentAllowedTools,
737747
disallowedTools: currentDisallowedTools,
738748
routeContext: parsed.routeContext

cli/src/claude/utils/systemPrompt.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { trimIdent } from "@/utils/trimIdent";
22
import { shouldIncludeCoAuthoredBy } from "./claudeSettings";
3-
import { buildPromptWithHaqiAgentInstructions } from "@/agent/utils/haqiAgentInstructions";
3+
import { buildPromptWithHaqiAgentInstructions, isPureContextModeEnabled } from "@/agent/utils/haqiAgentInstructions";
44

55
/**
66
* Base system prompt shared across all configurations
@@ -43,5 +43,8 @@ export const systemPrompt = (() => {
4343
})();
4444

4545
export function buildClaudeSystemPrompt(startDir: string): string {
46+
if (isPureContextModeEnabled()) {
47+
return '';
48+
}
4649
return buildPromptWithHaqiAgentInstructions(systemPrompt, startDir);
4750
}

cli/src/codex/utils/appServerConfig.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { mkdtempSync, rmSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
25
import { buildThreadStartParams, buildTurnStartParams } from './appServerConfig';
36
import { codexSystemPrompt } from './systemPrompt';
47

58
describe('appServerConfig', () => {
9+
let tempRoot: string;
10+
let previousHapiHome: string | undefined;
611
const mcpServers = { hapi: { command: 'node', args: ['mcp'] } };
712

13+
beforeEach(() => {
14+
tempRoot = mkdtempSync(join(tmpdir(), 'app-server-config-'));
15+
previousHapiHome = process.env.HAPI_HOME;
16+
process.env.HAPI_HOME = tempRoot;
17+
});
18+
19+
afterEach(() => {
20+
if (previousHapiHome === undefined) {
21+
delete process.env.HAPI_HOME;
22+
} else {
23+
process.env.HAPI_HOME = previousHapiHome;
24+
}
25+
rmSync(tempRoot, { recursive: true, force: true });
26+
});
27+
828
it('applies CLI overrides when permission mode is default', () => {
929
const params = buildThreadStartParams({
1030
mode: { permissionMode: 'default' },
@@ -47,6 +67,16 @@ describe('appServerConfig', () => {
4767
expect(params.approvalPolicy).toBe('never');
4868
});
4969

70+
it('omits base instructions when empty', () => {
71+
const params = buildThreadStartParams({
72+
mode: { permissionMode: 'default' },
73+
mcpServers,
74+
baseInstructions: ''
75+
});
76+
77+
expect(params.baseInstructions).toBeUndefined();
78+
});
79+
5080
it('builds turn params with mode defaults', () => {
5181
const params = buildTurnStartParams({
5282
threadId: 'thread-1',

cli/src/codex/utils/appServerConfig.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { EnhancedMode } from '../loop';
22
import type { CodexCliOverrides } from './codexCliOverrides';
33
import type { McpServersConfig } from './buildHapiMcpBridge';
44
import { codexSystemPrompt } from './systemPrompt';
5+
import { isPureContextModeEnabled } from '@/agent/utils/haqiAgentInstructions';
56
import type {
67
ApprovalPolicy,
78
SandboxMode,
@@ -91,12 +92,13 @@ export function buildThreadStartParams(args: {
9192
const resolvedSandbox = cliOverrides?.sandbox ?? sandbox;
9293

9394
const config = buildMcpServerConfig(args.mcpServers);
94-
const baseInstructions = args.baseInstructions ?? codexSystemPrompt;
95+
const defaultBaseInstructions = isPureContextModeEnabled() ? '' : codexSystemPrompt;
96+
const baseInstructions = args.baseInstructions ?? defaultBaseInstructions;
9597

9698
const params: ThreadStartParams = {
9799
approvalPolicy: resolvedApprovalPolicy,
98100
sandbox: resolvedSandbox,
99-
baseInstructions,
101+
...(baseInstructions.trim() ? { baseInstructions } : {}),
100102
...(args.developerInstructions ? { developerInstructions: args.developerInstructions } : {}),
101103
...(Object.keys(config).length > 0 ? { config } : {})
102104
};

cli/src/codex/utils/codexMcpConfig.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,5 +89,10 @@ describe('codexMcpConfig', () => {
8989

9090
expect(args[1]).toContain('\\\\');
9191
});
92+
93+
it('returns no args for empty instructions', () => {
94+
const args = buildDeveloperInstructionsArg(' ');
95+
expect(args).toEqual([]);
96+
});
9297
});
9398
});

cli/src/codex/utils/codexMcpConfig.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ export function buildMcpServerConfigArgs(
7171
* @returns Array of CLI arguments to pass to codex
7272
*/
7373
export function buildDeveloperInstructionsArg(instructions: string): string[] {
74+
if (!instructions.trim()) {
75+
return [];
76+
}
7477
const escaped = escapeTomlString(instructions);
7578
return ['-c', `developer_instructions="${escaped}"`];
7679
}

0 commit comments

Comments
 (0)