From 62872d80faf611895f1abf604ccdf3009e43ce7f Mon Sep 17 00:00:00 2001 From: admin <33443359+miuiadmin@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:02:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20HAPI=5FDISABLE=5FPEER=5FTOOLS=20?= =?UTF-8?q?=E2=80=94=20hide=20peer-session=20tools=20from=20wrapped=20agen?= =?UTF-8?q?ts=20(#2052)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add HAPI_DISABLE_PEER_TOOLS to hide peer-session tools from agents Wrapped agents with peer tools can browse the session list and message other sessions, polluting peer contexts on busy runners. Setting HAPI_DISABLE_PEER_TOOLS=1 (true/yes also accepted) in the runner environment now removes the peer surface entirely instead of advertising it and refusing: - the session MCP server does not register list_peers / inspect_peer / ping_peer, and they are dropped from the toolNames consumed by Claude --allowedTools and the codex/generic bridge --tools lists - the stdio bridge filters peer names even when a stale --tools value lists them explicitly - flavor system prompts (claude/codex/opencode) swap the peer steer block for a short notice that keeps the hub-path-is-not-a-file guard while pointing citations at the user - the peer core (pingPeer/inspectPeer/listPeerSessions) refuses with a new 'disabled' error code before any network call, so shell `hapi ping-peer` / `inspect-peer` from inside a wrapped agent fails loudly as defense in depth Operators scope the variable to the runner (systemd/launchd/guard env); an interactive terminal without it keeps full CLI access. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Code * test(cli): cover peer-tool prompt steer and default bridge tool list Adds regression tests for the two surfaces the kill switch mutates beyond the explicit --tools path: - happyMcpStdioBridge: default tool list (no --tools) keeps the peer tools as a positive control, then HAPI_DISABLE_PEER_TOOLS=1 filters them out of the same default seed. - New cross-flavor prompt test: the citation steer baked into the claude/codex/opencode prompt consts swaps to the disabled notice under the env flag and keeps the flavor-specific "Call \"\"" instruction by default (module consts evaluate env at import, so each mode is imported fresh via vi.resetModules + dynamic import). via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Code * test(cli): keep peer-tools env set through prompt assertions Keeps HAPI_DISABLE_PEER_TOOLS applied until after the assertions and restores it at test end. Some upstream shapes resolve the citation steer at call time (getSystemPrompt(env)) rather than baking it into the import-time const, so restoring the env inside the import helper made those shapes observe the enabled steer and fail. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Code --------- Co-authored-by: HAPI Co-authored-by: Claude Code --- cli/src/claude/utils/startHappyServer.test.ts | 53 ++++ cli/src/claude/utils/startHappyServer.ts | 250 +++++++++--------- .../utils/systemPrompt.peerTools.test.ts | 61 +++++ cli/src/claude/utils/systemPrompt.ts | 5 +- cli/src/codex/happyMcpStdioBridge.test.ts | 52 +++- cli/src/codex/happyMcpStdioBridge.ts | 8 + cli/src/codex/utils/buildHapiMcpBridge.ts | 10 +- cli/src/codex/utils/systemPrompt.ts | 5 +- cli/src/modules/common/peerTools.test.ts | 34 +++ cli/src/modules/common/peerTools.ts | 19 ++ cli/src/modules/pingPeer/pingPeer.test.ts | 39 ++- cli/src/modules/pingPeer/pingPeer.ts | 20 ++ cli/src/opencode/utils/systemPrompt.ts | 7 +- docs/guide/how-it-works.md | 2 +- docs/guide/installation.md | 4 + shared/src/sessionCitation.ts | 14 + 16 files changed, 451 insertions(+), 132 deletions(-) create mode 100644 cli/src/claude/utils/systemPrompt.peerTools.test.ts create mode 100644 cli/src/modules/common/peerTools.test.ts create mode 100644 cli/src/modules/common/peerTools.ts diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index 8abb273f..fda93291 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -328,3 +328,56 @@ describe('toClaudeAllowedHapiMcpTools', () => { expect(toClaudeAllowedHapiMcpTools(['display_media'])).not.toContain('mcp__hapi__display_media') }) }) + +describe('startHappyServer peer tools kill switch', () => { + const originalFlag = process.env.HAPI_DISABLE_PEER_TOOLS + let client: Client | null + let stopServer: (() => void) | null + + afterEach(async () => { + await client?.close() + stopServer?.() + if (originalFlag === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = originalFlag + } + }) + + async function startServer(): Promise<{ url: string; toolNames: string[] }> { + const sessionClient = { + updateMetadata: vi.fn(), + sendAgentMessage: vi.fn(), + sendClaudeSessionMessage: vi.fn() + } as unknown as ApiSessionClient + const server = await startHappyServer(sessionClient, {}) + stopServer = server.stop + client = new Client( + { name: 'hapi-peer-tools-test', version: '1.0.0' }, + { capabilities: {} } + ) + await client.connect(new StreamableHTTPClientTransport(new URL(server.url))) + return { url: server.url, toolNames: server.toolNames } + } + + it('registers peer tools by default', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS + const { toolNames } = await startServer() + expect(toolNames).toEqual(expect.arrayContaining(['list_peers', 'ping_peer', 'inspect_peer'])) + const tools = await client!.listTools() + const names = tools.tools.map((tool) => tool.name) + expect(names).toEqual(expect.arrayContaining(['list_peers', 'ping_peer', 'inspect_peer'])) + }) + + it('omits peer tools from registration and toolNames when HAPI_DISABLE_PEER_TOOLS is set', async () => { + process.env.HAPI_DISABLE_PEER_TOOLS = '1' + const { toolNames } = await startServer() + expect(toolNames).toEqual(['change_title', 'display_image', 'display_video', 'display_media']) + const tools = await client!.listTools() + const names = tools.tools.map((tool) => tool.name) + expect(names).not.toContain('list_peers') + expect(names).not.toContain('ping_peer') + expect(names).not.toContain('inspect_peer') + expect(names).toEqual(expect.arrayContaining(['display_image', 'display_video', 'display_media'])) + }) +}) diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 10a2a02d..e3814f77 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -21,6 +21,7 @@ import { import type { InlineMediaSource } from "@/modules/common/inlineMediaSource"; import { DISPLAY_IMAGE_PROMPT_CURSOR, DISPLAY_MEDIA_PROMPT_CURSOR, DISPLAY_VIDEO_PROMPT_CURSOR } from "@/modules/common/displayImagePrompt"; import { resolveSkill } from "@/modules/common/skills"; +import { isPeerToolsDisabled } from "@/modules/common/peerTools"; import { INSPECT_PEER_TOOL_DESCRIPTION, PING_PEER_TOOL_DESCRIPTION, @@ -121,6 +122,10 @@ function createHapiMcpServer( const maxInlineMediaBytes = 25 * 1024 * 1024; + // HAPI_DISABLE_PEER_TOOLS=1: peer tools are neither registered nor + // advertised, so wrapped agents cannot browse or message other sessions. + const peerToolsDisabled = isPeerToolsDisabled(); + const inspectPeerInputSchema: z.ZodTypeAny = z.object({ sessionIdPrefix: z.string().trim().min(1).describe(SESSION_ID_PREFIX_PARAM_DESCRIPTION), messageLimit: z.number().int().min(1).max(100).optional().describe( @@ -298,127 +303,129 @@ function createHapiMcpServer( } }); - mcp.registerTool('ping_peer', { - description: PING_PEER_TOOL_DESCRIPTION, - title: 'Ping Peer Session', - inputSchema: pingPeerInputSchema, - }, async (args: { sessionIdPrefix: string; message: string }) => { - logger.debug('[hapiMCP] ping_peer:', args.sessionIdPrefix); - try { - const result = await pingPeer({ - sessionIdPrefix: args.sessionIdPrefix, - message: args.message, - }); - return { - content: [ - { - type: 'text' as const, - text: `Delivered to ${result.sessionId}${result.resumed ? ' (resumed)' : ''} (${result.name})`, - }, - ], - isError: false, - }; - } catch (error) { - const message = error instanceof PingPeerError - ? error.message - : error instanceof Error + if (!peerToolsDisabled) { + mcp.registerTool('ping_peer', { + description: PING_PEER_TOOL_DESCRIPTION, + title: 'Ping Peer Session', + inputSchema: pingPeerInputSchema, + }, async (args: { sessionIdPrefix: string; message: string }) => { + logger.debug('[hapiMCP] ping_peer:', args.sessionIdPrefix); + try { + const result = await pingPeer({ + sessionIdPrefix: args.sessionIdPrefix, + message: args.message, + }); + return { + content: [ + { + type: 'text' as const, + text: `Delivered to ${result.sessionId}${result.resumed ? ' (resumed)' : ''} (${result.name})`, + }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof PingPeerError ? error.message - : String(error); - logger.debug('[hapiMCP] ping_peer failed:', message); - return { - content: [ - { - type: 'text' as const, - text: `Failed to ping peer: ${message}`, - }, - ], - isError: true, - }; - } - }); + : error instanceof Error + ? error.message + : String(error); + logger.debug('[hapiMCP] ping_peer failed:', message); + return { + content: [ + { + type: 'text' as const, + text: `Failed to ping peer: ${message}`, + }, + ], + isError: true, + }; + } + }); - mcp.registerTool('inspect_peer', { - description: INSPECT_PEER_TOOL_DESCRIPTION, - title: 'Inspect Peer Session', - inputSchema: inspectPeerInputSchema, - }, async (args: { sessionIdPrefix: string; messageLimit?: number }) => { - logger.debug('[hapiMCP] inspect_peer:', args.sessionIdPrefix); - try { - const result = await inspectPeer({ - sessionIdPrefix: args.sessionIdPrefix, - messageLimit: args.messageLimit, - }); - return { - content: [ - { - type: 'text' as const, - text: formatInspectPeerReport(result), - }, - ], - isError: false, - }; - } catch (error) { - const message = error instanceof PingPeerError - ? error.message - : error instanceof Error + mcp.registerTool('inspect_peer', { + description: INSPECT_PEER_TOOL_DESCRIPTION, + title: 'Inspect Peer Session', + inputSchema: inspectPeerInputSchema, + }, async (args: { sessionIdPrefix: string; messageLimit?: number }) => { + logger.debug('[hapiMCP] inspect_peer:', args.sessionIdPrefix); + try { + const result = await inspectPeer({ + sessionIdPrefix: args.sessionIdPrefix, + messageLimit: args.messageLimit, + }); + return { + content: [ + { + type: 'text' as const, + text: formatInspectPeerReport(result), + }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof PingPeerError ? error.message - : String(error); - logger.debug('[hapiMCP] inspect_peer failed:', message); - return { - content: [ - { - type: 'text' as const, - text: `Failed to inspect peer: ${message}`, - }, - ], - isError: true, - }; - } - }); + : error instanceof Error + ? error.message + : String(error); + logger.debug('[hapiMCP] inspect_peer failed:', message); + return { + content: [ + { + type: 'text' as const, + text: `Failed to inspect peer: ${message}`, + }, + ], + isError: true, + }; + } + }); - mcp.registerTool('list_peers', { - description: 'List peer HAPI sessions on the same hub/namespace (id prefix, active, flavor, name). Uses this session\'s hub credentials - works from runner-spawned agents without being on the hub host. Prefer this over shelling `hapi ping-peer --list`. Then call inspect_peer / ping_peer with a listed id.', - title: 'List Peer Sessions', - inputSchema: listPeersInputSchema, - }, async (args: { limit?: number }) => { - logger.debug('[hapiMCP] list_peers'); - try { - const limit = args.limit ?? 30; - const sessions = await listPeerSessions({ - limit: peerListFetchLimit(limit, { excludeCaller: true }), - }); - const peers = sessions.filter((session) => session.id !== client.sessionId); - const hasMore = peers.length > limit; - return { - content: [ - { - type: 'text' as const, - text: formatPeerSessionsList(peers, { - maxRows: limit, - hasMore, - }), - }, - ], - isError: false, - }; - } catch (error) { - const message = error instanceof PingPeerError - ? error.message - : error instanceof Error + mcp.registerTool('list_peers', { + description: 'List peer HAPI sessions on the same hub/namespace (id prefix, active, flavor, name). Uses this session\'s hub credentials - works from runner-spawned agents without being on the hub host. Prefer this over shelling `hapi ping-peer --list`. Then call inspect_peer / ping_peer with a listed id.', + title: 'List Peer Sessions', + inputSchema: listPeersInputSchema, + }, async (args: { limit?: number }) => { + logger.debug('[hapiMCP] list_peers'); + try { + const limit = args.limit ?? 30; + const sessions = await listPeerSessions({ + limit: peerListFetchLimit(limit, { excludeCaller: true }), + }); + const peers = sessions.filter((session) => session.id !== client.sessionId); + const hasMore = peers.length > limit; + return { + content: [ + { + type: 'text' as const, + text: formatPeerSessionsList(peers, { + maxRows: limit, + hasMore, + }), + }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof PingPeerError ? error.message - : String(error); - logger.debug('[hapiMCP] list_peers failed:', message); - return { - content: [ - { - type: 'text' as const, - text: `Failed to list peers: ${message}`, - }, - ], - isError: true, - }; - } - }); + : error instanceof Error + ? error.message + : String(error); + logger.debug('[hapiMCP] list_peers failed:', message); + return { + content: [ + { + type: 'text' as const, + text: `Failed to list peers: ${message}`, + }, + ], + isError: true, + }; + } + }); + } if (skillLookup) { @@ -540,9 +547,14 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH hapiMcpUrl: mcpUrl, })); - const toolNames = enableChangeTitle - ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer'] - : ['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']; + const peerToolsDisabled = isPeerToolsDisabled(); + const toolNames = [ + ...(enableChangeTitle ? ['change_title'] : []), + 'display_image', + 'display_video', + 'display_media', + ...(peerToolsDisabled ? [] : (['list_peers', 'ping_peer', 'inspect_peer'] as const)), + ]; if (options.skillLookup) { toolNames.push('skill_lookup'); } diff --git a/cli/src/claude/utils/systemPrompt.peerTools.test.ts b/cli/src/claude/utils/systemPrompt.peerTools.test.ts new file mode 100644 index 00000000..a7027c6c --- /dev/null +++ b/cli/src/claude/utils/systemPrompt.peerTools.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' + +/** + * Peer-tools citation steer across all three flavors. + * + * The gate is resolved either at import time (citation block baked into + * module-level prompt consts, feature shape) or at call time + * (getSystemPrompt(env) resolving per call, stack shape). Setting the env + * before import AND keeping it set through the assertions covers both + * shapes; each mode re-imports the modules fresh via vi.resetModules(). + */ +async function importFlavorPrompts(disabled: boolean) { + vi.resetModules() + const prev = process.env.HAPI_DISABLE_PEER_TOOLS + if (disabled) process.env.HAPI_DISABLE_PEER_TOOLS = '1' + else delete process.env.HAPI_DISABLE_PEER_TOOLS + const [claude, codex, opencode] = await Promise.all([ + import('./systemPrompt'), + import('../../codex/utils/systemPrompt'), + import('../../opencode/utils/systemPrompt'), + ]) + const restore = () => { + if (prev === undefined) delete process.env.HAPI_DISABLE_PEER_TOOLS + else process.env.HAPI_DISABLE_PEER_TOOLS = prev + } + return { claude, codex, opencode, restore } +} + +describe('peer-tools citation steer across flavors', () => { + it('replaces the citation tool steer with the disabled notice when HAPI_DISABLE_PEER_TOOLS is set', async () => { + const { claude, codex, opencode, restore } = await importFlavorPrompts(true) + try { + const claudePrompt = claude.getSystemPrompt() + expect(claudePrompt).toContain('are disabled on this machine') + expect(claudePrompt).not.toContain('Call "mcp__hapi__inspect_peer"') + + expect(codex.TITLE_INSTRUCTION).toContain('are disabled on this machine') + expect(codex.TITLE_INSTRUCTION).not.toContain('Call "functions.hapi__inspect_peer"') + + expect(opencode.TITLE_INSTRUCTION).toContain('are disabled on this machine') + expect(opencode.TITLE_INSTRUCTION).not.toContain('Call "hapi_inspect_peer"') + + expect(opencode.OPENCODE_NATIVE_TOOL_INSTRUCTION).toContain('are disabled on this machine') + expect(opencode.OPENCODE_NATIVE_TOOL_INSTRUCTION).not.toContain('Call "hapi_inspect_peer"') + } finally { + restore() + } + }) + + it('keeps the citation tool steer by default', async () => { + const { claude, codex, opencode, restore } = await importFlavorPrompts(false) + try { + expect(claude.getSystemPrompt()).toContain('Call "mcp__hapi__inspect_peer"') + expect(codex.TITLE_INSTRUCTION).toContain('Call "functions.hapi__inspect_peer"') + expect(opencode.TITLE_INSTRUCTION).toContain('Call "hapi_inspect_peer"') + expect(opencode.OPENCODE_NATIVE_TOOL_INSTRUCTION).toContain('Call "hapi_inspect_peer"') + } finally { + restore() + } + }) +}) diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 3174ac6e..1ed7241e 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -1,5 +1,6 @@ import { trimIdent } from "@/utils/trimIdent"; -import { buildSessionCitationSteerInstruction } from "@hapi/protocol/sessionCitation"; +import { buildSessionCitationSteerInstruction, SESSION_CITATION_DISABLED_STEER } from "@hapi/protocol/sessionCitation"; +import { isPeerToolsDisabled } from "@/modules/common/peerTools"; import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; import { DISPLAY_IMAGE_PROMPT_CLAUDE, DISPLAY_MEDIA_PROMPT_CLAUDE, DISPLAY_VIDEO_PROMPT_CLAUDE } from "@/modules/common/displayImagePrompt"; import { withSessionSummaryInstruction } from "@/modules/common/sessionSummaryInstruction"; @@ -12,7 +13,7 @@ const BASE_SYSTEM_PROMPT = (() => trimIdent(` ${DISPLAY_IMAGE_PROMPT_CLAUDE} ${DISPLAY_VIDEO_PROMPT_CLAUDE} ${DISPLAY_MEDIA_PROMPT_CLAUDE} - ${buildSessionCitationSteerInstruction({ + ${isPeerToolsDisabled() ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ inspectTool: 'mcp__hapi__inspect_peer', pingTool: 'mcp__hapi__ping_peer', listPeersTool: 'mcp__hapi__list_peers', diff --git a/cli/src/codex/happyMcpStdioBridge.test.ts b/cli/src/codex/happyMcpStdioBridge.test.ts index c940d0bc..628ec4dc 100644 --- a/cli/src/codex/happyMcpStdioBridge.test.ts +++ b/cli/src/codex/happyMcpStdioBridge.test.ts @@ -176,4 +176,54 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { ]) }) -}) + + it('hides peer tools under HAPI_DISABLE_PEER_TOOLS even when listed in --tools', async () => { + process.env.HAPI_DISABLE_PEER_TOOLS = '1' + try { + await runHappyMcpStdioBridge([ + '--url', + 'http://127.0.0.1:43006', + '--tools', + 'change_title,list_peers,ping_peer,inspect_peer' + ]) + + expect([...harness.tools.keys()]).toEqual(['change_title']) + } finally { + delete process.env.HAPI_DISABLE_PEER_TOOLS + } + }) + + it('drops peer tools from the default tool list under HAPI_DISABLE_PEER_TOOLS', async () => { + // No --tools: the bridge seeds its default list, which includes the + // peer tools. Positive control first so the disabled run below proves + // filtering rather than an empty seed. + await runHappyMcpStdioBridge(['--url', 'http://127.0.0.1:43006']) + + expect([...harness.tools.keys()]).toEqual([ + 'change_title', + 'display_image', + 'display_video', + 'display_media', + 'ping_peer', + 'inspect_peer', + 'list_peers' + ]) + + harness.tools.clear() + harness.configs.clear() + + process.env.HAPI_DISABLE_PEER_TOOLS = '1' + try { + await runHappyMcpStdioBridge(['--url', 'http://127.0.0.1:43006']) + + expect([...harness.tools.keys()]).toEqual([ + 'change_title', + 'display_image', + 'display_video', + 'display_media' + ]) + } finally { + delete process.env.HAPI_DISABLE_PEER_TOOLS + } + }) +}) \ No newline at end of file diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index e1bcd979..80e2c07c 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -22,6 +22,7 @@ import { PING_PEER_TOOL_DESCRIPTION, SESSION_ID_PREFIX_PARAM_DESCRIPTION, } from '@hapi/protocol/sessionCitation'; +import { isPeerToolsDisabled, PEER_TOOL_NAMES } from '@/modules/common/peerTools'; const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']; @@ -50,6 +51,13 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { try { // Resolve target HTTP MCP URL const { url: urlFromArgs, toolNames } = parseArgs(argv); + + // HAPI_DISABLE_PEER_TOOLS=1: drop peer tools even when explicitly listed, + // so a wrapped agent cannot re-enable them via a stale --tools value. + if (isPeerToolsDisabled()) { + for (const name of PEER_TOOL_NAMES) toolNames.delete(name); + } + const baseUrl = urlFromArgs || process.env.HAPI_HTTP_MCP_URL || ''; if (!baseUrl) { diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 8091db55..712f48b8 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -9,6 +9,7 @@ import { startHappyServer } from '@/claude/utils/startHappyServer'; import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; import type { ApiSessionClient } from '@/api/apiSession'; import { exportHapiSessionEnv } from '@/agent/hapiSessionEnv'; +import { isPeerToolsDisabled } from '@/modules/common/peerTools'; import type { CodexMcpServerConfig } from './codexMcpServers'; /** @@ -112,9 +113,12 @@ export async function buildHapiMcpBridge( }; } // Discovery shortlist only - same trust as skill_lookup / change_title. - tools.list_peers = { - approval_mode: 'approve' - }; + // Skipped under HAPI_DISABLE_PEER_TOOLS (tool is not registered then). + if (!isPeerToolsDisabled()) { + tools.list_peers = { + approval_mode: 'approve' + }; + } // ping_peer / inspect_peer are registered on the HTTP MCP server / stdio // bridge, but are not auto-approved: they target another session (resume + // inject, or read peer histories). diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index bd85efc3..71d74583 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -6,8 +6,9 @@ */ import { trimIdent } from '@/utils/trimIdent'; -import { buildSessionCitationSteerInstruction } from '@hapi/protocol/sessionCitation'; +import { buildSessionCitationSteerInstruction, SESSION_CITATION_DISABLED_STEER } from '@hapi/protocol/sessionCitation'; import { DISPLAY_IMAGE_PROMPT_CODEX, DISPLAY_MEDIA_PROMPT_CODEX, DISPLAY_VIDEO_PROMPT_CODEX } from '@/modules/common/displayImagePrompt'; +import { isPeerToolsDisabled } from '@/modules/common/peerTools'; import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction'; /** @@ -24,7 +25,7 @@ export const TITLE_INSTRUCTION = trimIdent(` ${DISPLAY_IMAGE_PROMPT_CODEX} ${DISPLAY_VIDEO_PROMPT_CODEX} ${DISPLAY_MEDIA_PROMPT_CODEX} - ${buildSessionCitationSteerInstruction({ + ${isPeerToolsDisabled() ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ inspectTool: 'functions.hapi__inspect_peer', pingTool: 'functions.hapi__ping_peer', listPeersTool: 'functions.hapi__list_peers', diff --git a/cli/src/modules/common/peerTools.test.ts b/cli/src/modules/common/peerTools.test.ts new file mode 100644 index 00000000..ed4fea37 --- /dev/null +++ b/cli/src/modules/common/peerTools.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { isPeerToolsDisabled } from './peerTools' + +describe('isPeerToolsDisabled', () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS + + afterEach(() => { + if (original === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = original + } + }) + + it('defaults to enabled (peer tools visible)', () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS + expect(isPeerToolsDisabled()).toBe(false) + }) + + it.each(['1', 'true', 'TRUE', 'yes', 'Yes'])('disables peer tools for %s', (value) => { + process.env.HAPI_DISABLE_PEER_TOOLS = value + expect(isPeerToolsDisabled()).toBe(true) + }) + + it('stays enabled for unrelated values', () => { + process.env.HAPI_DISABLE_PEER_TOOLS = '0' + expect(isPeerToolsDisabled()).toBe(false) + }) + + it('reads from an explicit env object', () => { + expect(isPeerToolsDisabled({ HAPI_DISABLE_PEER_TOOLS: '1' } as NodeJS.ProcessEnv)).toBe(true) + expect(isPeerToolsDisabled({} as NodeJS.ProcessEnv)).toBe(false) + }) +}) diff --git a/cli/src/modules/common/peerTools.ts b/cli/src/modules/common/peerTools.ts new file mode 100644 index 00000000..18c15888 --- /dev/null +++ b/cli/src/modules/common/peerTools.ts @@ -0,0 +1,19 @@ +/** + * Kill switch for the peer-session MCP tools (`list_peers` / `inspect_peer` / + * `ping_peer`). + * + * Set `HAPI_DISABLE_PEER_TOOLS=1` in the runner environment to stop wrapped + * agents from browsing or messaging other HAPI sessions (peer pollution): + * the tools are not registered on the session MCP server, not advertised via + * `--allowedTools` / `--tools`, and the peer steer block is dropped from + * flavor system prompts. The core module refuses as defense in depth, so + * `hapi ping-peer` / `inspect-peer` from inside a wrapped agent shell fails + * loudly instead of silently bypassing the switch. Operators who keep the + * variable unset in their interactive shell keep full CLI access. + */ + +export const PEER_TOOL_NAMES = ['list_peers', 'ping_peer', 'inspect_peer'] as const; + +export function isPeerToolsDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + return ['true', '1', 'yes'].includes((env.HAPI_DISABLE_PEER_TOOLS ?? '').toLowerCase()); +} diff --git a/cli/src/modules/pingPeer/pingPeer.test.ts b/cli/src/modules/pingPeer/pingPeer.test.ts index a442b0cd..8c5e2ff3 100644 --- a/cli/src/modules/pingPeer/pingPeer.test.ts +++ b/cli/src/modules/pingPeer/pingPeer.test.ts @@ -1,7 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { PingPeerError, exitCodeForPingPeerError, + inspectPeer, + listPeerSessions, pingPeer, resolveSessionByPrefix, type PingPeerSessionSummary @@ -672,3 +674,38 @@ describe('listSessions query params', () => { expect(pingParams[0]).toBeUndefined() }) }) + + +describe('HAPI_DISABLE_PEER_TOOLS kill switch', () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS + + beforeEach(() => { + process.env.HAPI_DISABLE_PEER_TOOLS = '1' + }) + + afterEach(() => { + if (original === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = original + } + }) + + it('pingPeer refuses before any network call', async () => { + await expect(pingPeer({ sessionIdPrefix: 'abc', message: 'hi' })) + .rejects.toMatchObject({ code: 'disabled' }) + }) + + it('inspectPeer refuses before any network call', async () => { + await expect(inspectPeer({ sessionIdPrefix: 'abc' })) + .rejects.toMatchObject({ code: 'disabled' }) + }) + + it('listPeerSessions refuses before any network call', async () => { + await expect(listPeerSessions()).rejects.toMatchObject({ code: 'disabled' }) + }) + + it('maps the disabled code to the usage exit code', () => { + expect(exitCodeForPingPeerError(new PingPeerError('disabled', 'disabled on this machine'))).toBe(2) + }) +}) diff --git a/cli/src/modules/pingPeer/pingPeer.ts b/cli/src/modules/pingPeer/pingPeer.ts index 139daf68..309b5804 100644 --- a/cli/src/modules/pingPeer/pingPeer.ts +++ b/cli/src/modules/pingPeer/pingPeer.ts @@ -13,11 +13,13 @@ import axios, { type AxiosInstance } from 'axios' import { extractAssistantPlainText, getPermissionModesForFlavor, isObject } from '@hapi/protocol' import { normalizeSessionIdPrefix } from '@hapi/protocol/sessionCitation' import { configuration } from '@/configuration' +import { isPeerToolsDisabled } from '@/modules/common/peerTools' import { getAuthToken } from '@/api/auth' import { buildHubRequestHeaders } from '@/api/hubExtraHeaders' export type PingPeerErrorCode = | 'bad_args' + | 'disabled' | 'auth_failed' | 'not_found' | 'ambiguous' @@ -36,6 +38,20 @@ export class PingPeerError extends Error { } } +/** + * Defense-in-depth gate for HAPI_DISABLE_PEER_TOOLS: the MCP server and CLI + * surface refuse earlier, but any other caller (stale --tools bridge, shell + * `hapi ping-peer` from a wrapped agent) lands here and fails loudly. + */ +function assertPeerToolsEnabled(): void { + if (isPeerToolsDisabled()) { + throw new PingPeerError( + 'disabled', + 'peer session tools are disabled on this machine (HAPI_DISABLE_PEER_TOOLS is set)' + ) + } +} + export type PingPeerSessionSummary = { id: string active: boolean @@ -373,6 +389,7 @@ async function sendMessage( export async function listPeerSessions( options: ListPeerSessionsOptions = {} ): Promise { + assertPeerToolsEnabled() const apiUrl = resolveApiUrl(options.apiUrl) const accessToken = resolveAccessToken(options.accessToken) const http = options.http ?? axios @@ -459,6 +476,7 @@ export function formatPeerSessionsList( } export async function pingPeer(options: PingPeerOptions): Promise { + assertPeerToolsEnabled() const prefix = normalizeSessionIdPrefix(options.sessionIdPrefix ?? '') const message = options.message ?? '' if (!prefix) { @@ -531,6 +549,7 @@ export async function pingPeer(options: PingPeerOptions): Promise { + assertPeerToolsEnabled() const prefix = normalizeSessionIdPrefix(options.sessionIdPrefix ?? '') if (!prefix) { throw new PingPeerError('bad_args', 'session id prefix is required') diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index b1838d33..6b805d99 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -6,7 +6,8 @@ */ import { trimIdent } from '@/utils/trimIdent'; -import { buildSessionCitationSteerInstruction } from '@hapi/protocol/sessionCitation'; +import { buildSessionCitationSteerInstruction, SESSION_CITATION_DISABLED_STEER } from '@hapi/protocol/sessionCitation'; +import { isPeerToolsDisabled } from '@/modules/common/peerTools'; import { HAPI_MCP_BRIDGE_PROMPT } from '@/modules/common/hapiMcpBridgePrompt'; import { DISPLAY_IMAGE_PROMPT_HAPI_MCP, @@ -21,7 +22,7 @@ import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryIn */ export const TITLE_INSTRUCTION = trimIdent(` ${HAPI_MCP_BRIDGE_PROMPT} - ${buildSessionCitationSteerInstruction({ + ${isPeerToolsDisabled() ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ inspectTool: 'hapi_inspect_peer', pingTool: 'hapi_ping_peer', listPeersTool: 'hapi_list_peers', @@ -41,7 +42,7 @@ export const OPENCODE_NATIVE_TOOL_INSTRUCTION = trimIdent(` ${DISPLAY_IMAGE_PROMPT_HAPI_MCP} ${DISPLAY_VIDEO_PROMPT_HAPI_MCP} ${DISPLAY_MEDIA_PROMPT_HAPI_MCP} - ${buildSessionCitationSteerInstruction({ + ${isPeerToolsDisabled() ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ inspectTool: 'hapi_inspect_peer', pingTool: 'hapi_ping_peer', listPeersTool: 'hapi_list_peers', diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index 210036b7..06e1d395 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -36,7 +36,7 @@ hapi runner start # Run background service for remote session spawning hapi ping-peer --list # Shell peer shortlist (prefer MCP list_peers in-session) ``` -MCP peer tools (same hub/namespace as the session): `list_peers` (discover), `inspect_peer` (read), `ping_peer` (message). These work from runner-spawned sessions even when the hub is on another host - see [Installation → Split hub + remote runner](./installation.md#split-hub-remote-runner-peer-discovery). +MCP peer tools (same hub/namespace as the session): `list_peers` (discover), `inspect_peer` (read), `ping_peer` (message). These work from runner-spawned sessions even when the hub is on another host - see [Installation → Split hub + remote runner](./installation.md#split-hub-remote-runner-peer-discovery). Runners that must keep agents out of other sessions' contexts can hide all three with `HAPI_DISABLE_PEER_TOOLS=1` - see [Installation → Disabling peer tools](./installation.md#disabling-peer-tools-on-a-runner). ### HAPI Hub diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 70c36e5f..e9d75f3b 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -320,6 +320,10 @@ hapi runner start Session CLI may export an **explicit** non-default `HAPI_API_URL` (from env or settings) into child env so shell helpers hit the same remote hub. It does **not** mirror `CLI_API_TOKEN` into wrapped agents (settings/prompt-backed secrets stay out of agent env; a fresh `hapi` re-reads `~/.hapi/settings.json`, and systemd/env tokens already inherit). Prefer MCP `list_peers` inside a session. Web terminal PTYs still strip hub secrets. If `--list` fails with an auth/URL error, the message points at `hapi auth login` and the configured hub URL. +##### Disabling peer tools on a runner + +Set `HAPI_DISABLE_PEER_TOOLS=1` (also `true`/`yes`) in the runner environment to stop wrapped agents from browsing or messaging other HAPI sessions — for example when one agent digging through the session list and pinging peers pollutes other sessions' contexts. The switch is not advertised-then-refused: the `list_peers` / `inspect_peer` / `ping_peer` MCP tools are not registered on the session MCP server and not passed to `--allowedTools` / `--tools`, the peer steer block is dropped from agent system prompts (a pasted `/sessions/` citation only reminds the agent it is a hub path, not a local file), and the peer core refuses with an explicit `disabled` error as defense in depth — so `hapi ping-peer` / `hapi inspect-peer` from inside a wrapped agent shell fails loudly too. Scope the env to the runner (systemd unit, launchd plist, or runner guard script); operators who leave it unset in their interactive terminal keep full `hapi ping-peer` CLI access. + Additional runner commands: ```bash diff --git a/shared/src/sessionCitation.ts b/shared/src/sessionCitation.ts index bca4efe5..e13e0c39 100644 --- a/shared/src/sessionCitation.ts +++ b/shared/src/sessionCitation.ts @@ -133,6 +133,20 @@ export type SessionCitationSteerTools = { listPeersTool?: string } +/** + * System-prompt citation block used when peer tools are hidden by + * `HAPI_DISABLE_PEER_TOOLS`. Keeps the hub-path-is-not-a-file guard (pasted + * citations still arrive) but points the agent at the user instead of the + * disabled peer tools. + */ +export const SESSION_CITATION_DISABLED_STEER = + 'When the user cites another HAPI session as [title](/sessions/), ' + + 'Copy-reference prose See session "…" (/sessions/) for context, ' + + 'or a bare /sessions/, extract that . ' + + '/sessions/ is a HAPI hub path, not a local filesystem path - do not Grep, Glob, or Read it as a file. ' + + 'Peer session tools (inspect_peer / ping_peer / list_peers) are disabled on this machine; ' + + 'ask the user to paste the relevant context from that session instead.' + /** * Always-on system-prompt line: both citation forms + hub-not-FS + tool names. */