Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions apps/cli/ai/agent.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import fs from 'fs';
import path from 'path';
import { query, type HookCallback, type Query } from '@anthropic-ai/claude-agent-sdk';
import {
query,
type HookCallback,
type HookCallbackMatcher,
type Query,
} from '@anthropic-ai/claude-agent-sdk';
import { AI_MODELS, DEFAULT_MODEL, type AiModelId } from '@studio/common/ai/models';
import { buildSystemPrompt } from 'cli/ai/system-prompt';
import { createRemoteSiteTools, createStudioTools } from 'cli/ai/tools';
import {
createRemoteSiteTools,
createStudioTools,
STUDIO_MCP_SERVER_NAME,
STUDIO_MCP_TOOL_PREFIX,
} from 'cli/ai/tools';
import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths';
import type { SiteInfo } from 'cli/ai/ui';

Expand Down Expand Up @@ -76,7 +86,7 @@ export function startAiAgent( config: AiAgentConfig ): Query {
// Configure MCP servers based on site type:
// Remote sites get WP.com REST API tools + screenshot; local sites get the full Studio toolset.
const mcpServers = {
studio: isRemoteSite
[ STUDIO_MCP_SERVER_NAME ]: isRemoteSite
? createRemoteSiteTools( wpcomAccessToken, activeSite.wpcomSiteId! )
: createStudioTools( { enablePreviewSteering: isForkedByDesktop } ),
};
Expand Down Expand Up @@ -131,6 +141,30 @@ export function startAiAgent( config: AiAgentConfig ): Query {
}
: undefined;

// Pre-approve our own MCP server's tools so they bypass the SDK's auto
// permission classifier. The classifier is a Sonnet call: when it has an
// outage every Studio tool call fails with "claude-sonnet-4-6 is
// temporarily unavailable, so auto mode cannot determine the safety…",
// which leaves the agent unable to do anything until Sonnet recovers.
// Studio's own tools are scoped to local sites or the user's own
// WordPress.com sites via OAuth, and the system prompt instructs the
// agent to confirm destructive actions, so they don't need an external
// safety check. Built-in tools (Bash, Write, Edit, …) still go through
// the classifier.
const allowStudioToolsHook: HookCallback = async () => ( {
hookSpecificOutput: {
hookEventName: 'PreToolUse' as const,
permissionDecision: 'allow' as const,
},
} );

const preToolUseHooks: HookCallbackMatcher[] = [
{ matcher: `^${ STUDIO_MCP_TOOL_PREFIX }`, hooks: [ allowStudioToolsHook ] },
];
if ( askUserQuestionHook ) {
preToolUseHooks.push( { matcher: 'AskUserQuestion', hooks: [ askUserQuestionHook ] } );
}

return query( {
prompt,
options: {
Expand All @@ -144,11 +178,7 @@ export function startAiAgent( config: AiAgentConfig ): Query {
cwd: STUDIO_SITES_ROOT,
tools: { type: 'preset', preset: 'claude_code' },
permissionMode: 'auto',
...( askUserQuestionHook && {
hooks: {
PreToolUse: [ { matcher: 'AskUserQuestion', hooks: [ askUserQuestionHook ] } ],
},
} ),
hooks: { PreToolUse: preToolUseHooks },
plugins: [ { type: 'local' as const, path: path.resolve( import.meta.dirname, 'plugin' ) } ],
model,
resume,
Expand Down
59 changes: 58 additions & 1 deletion apps/cli/ai/tests/agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import fs from 'fs';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { query, type PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
import { startAiAgent } from 'cli/ai/agent';
import { STUDIO_MCP_TOOL_PREFIX } from 'cli/ai/tools';
import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths';

vi.mock( '@anthropic-ai/claude-agent-sdk', () => ( {
Expand All @@ -15,6 +16,8 @@ vi.mock( 'cli/ai/system-prompt', () => ( {
vi.mock( 'cli/ai/tools', () => ( {
createRemoteSiteTools: vi.fn().mockReturnValue( { type: 'remote-tools' } ),
createStudioTools: vi.fn().mockReturnValue( { type: 'local-tools' } ),
STUDIO_MCP_SERVER_NAME: 'studio',
STUDIO_MCP_TOOL_PREFIX: 'mcp__studio__',
} ) );

describe( 'AI agent startup', () => {
Expand Down Expand Up @@ -76,4 +79,58 @@ describe( 'AI agent startup', () => {
);
expect( result ).toBe( mockQuery );
} );

describe( 'permission hooks', () => {
const studioMatcherPattern = `^${ STUDIO_MCP_TOOL_PREFIX }`;

function getPreToolUseHooks() {
const callArgs = vi.mocked( query ).mock.calls[ 0 ][ 0 ];
return callArgs.options?.hooks?.PreToolUse ?? [];
}

it( 'pre-approves mcp__studio__ tools so a classifier outage cannot block them', async () => {
startAiAgent( { prompt: 'noop' } );

const studioMatcher = getPreToolUseHooks().find(
( m ) => m.matcher === studioMatcherPattern
);
expect( studioMatcher ).toBeDefined();

const input: PreToolUseHookInput = {
hook_event_name: 'PreToolUse',
tool_name: `${ STUDIO_MCP_TOOL_PREFIX }site_info`,
tool_input: {},
tool_use_id: 'use-1',
cwd: STUDIO_SITES_ROOT,
session_id: 'session',
transcript_path: '',
permission_mode: 'auto',
};
const result = await studioMatcher!.hooks[ 0 ]( input, 'use-1', {
signal: new AbortController().signal,
} );
expect( result ).toEqual( {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow',
},
} );
} );

it( 'registers the studio pre-approval hook even when onAskUser is not provided', () => {
startAiAgent( { prompt: 'noop' } );

const matchers = getPreToolUseHooks().map( ( m ) => m.matcher );
expect( matchers ).toContain( studioMatcherPattern );
expect( matchers ).not.toContain( 'AskUserQuestion' );
} );

it( 'keeps the AskUserQuestion hook alongside the studio pre-approval', () => {
startAiAgent( { prompt: 'noop', onAskUser: async () => ( {} ) } );

const matchers = getPreToolUseHooks().map( ( m ) => m.matcher );
expect( matchers ).toContain( studioMatcherPattern );
expect( matchers ).toContain( 'AskUserQuestion' );
} );
} );
} );
11 changes: 9 additions & 2 deletions apps/cli/ai/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ import { normalizeHostname } from 'cli/lib/utils';
import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager';
import { getProgressCallback, setProgressCallback, emitProgress } from 'cli/logger';

// The MCP server name that prefixes every Studio tool the agent sees as
// `mcp__studio__<tool>`. The agent's PreToolUse permission hook matches against
// this prefix to pre-approve our own tools — keep `STUDIO_MCP_TOOL_PREFIX` in
// sync if you ever rename the server.
export const STUDIO_MCP_SERVER_NAME = 'studio';
export const STUDIO_MCP_TOOL_PREFIX = `mcp__${ STUDIO_MCP_SERVER_NAME }__`;

/**
* Splits a command string into arguments, respecting quoted strings.
* Handles both single and double quotes, e.g.:
Expand Down Expand Up @@ -1309,7 +1316,7 @@ export function resolveStudioToolDefinitions( options: CreateStudioToolsOptions

export function createStudioTools( options: CreateStudioToolsOptions = {} ) {
return createSdkMcpServer( {
name: 'studio',
name: STUDIO_MCP_SERVER_NAME,
version: '1.0.0',
tools: resolveStudioToolDefinitions( options ),
} );
Expand All @@ -1325,7 +1332,7 @@ export function createRemoteSiteTools( token: string, siteId: number ) {
? [ takeScreenshotTool, shareScreenshotTool ]
: [ takeScreenshotTool ];
return createSdkMcpServer( {
name: 'studio',
name: STUDIO_MCP_SERVER_NAME,
version: '1.0.0',
tools: [ ...wpcomTools, ...screenshotTools, createSiteTool, pullSiteTool ],
} );
Expand Down
Loading