fix: enforce read-only Pascal agent requests

This commit is contained in:
134 2026-07-13 18:43:10 +08:00
parent 42bd2827a6
commit beb6ff4f04
4 changed files with 89 additions and 4 deletions

View File

@ -256,6 +256,7 @@ export async function POST(request: Request) {
hasMutation: session.hasMutation(),
mutationRequired,
mutationToolNames: session.mutationToolNames,
readOnlyToolNames: session.readOnlyToolNames,
stepNumber,
}),
}),

View File

@ -24,6 +24,42 @@ describe('Pascal agent intent', () => {
).toBe(false)
})
test('does not treat a negated mutation as permission to write', () => {
expect(
requiresPascalMutation([
{
role: 'user',
parts: [
{
type: 'text',
text: '请只读取当前场景并汇总元素数量,不要修改任何模型。',
},
],
},
]),
).toBe(false)
expect(
requiresPascalMutation([
{
role: 'user',
parts: [{ type: 'text', text: 'Inspect this scene, but do not modify the model.' }],
},
]),
).toBe(false)
})
test('keeps other requested mutations after removing a negated action', () => {
expect(
requiresPascalMutation([
{
role: 'user',
parts: [{ type: 'text', text: '不要删除现有墙体,只添加一扇窗。' }],
},
]),
).toBe(true)
})
test('uses the latest user message', () => {
expect(
requiresPascalMutation([
@ -40,6 +76,7 @@ describe('Pascal agent intent', () => {
hasMutation: false,
mutationRequired: true,
mutationToolNames: ['create_room', 'add_door'],
readOnlyToolNames: ['read_scene'],
stepNumber: 1,
}),
).toEqual({
@ -54,8 +91,24 @@ describe('Pascal agent intent', () => {
hasMutation: true,
mutationRequired: true,
mutationToolNames: ['create_room'],
readOnlyToolNames: ['read_scene'],
stepNumber: 3,
}),
).toEqual({ toolChoice: 'auto' })
})
test('only exposes inspection tools for read-only requests', () => {
expect(
pascalAgentStepPolicy({
hasMutation: false,
mutationRequired: false,
mutationToolNames: ['apply_patch', 'save_scene'],
readOnlyToolNames: ['read_scene', 'list_levels'],
stepNumber: 0,
}),
).toEqual({
activeTools: ['read_scene', 'list_levels'],
toolChoice: 'auto',
})
})
})

View File

@ -1,5 +1,25 @@
const MUTATION_INTENT =
/(?:设计|创建|生成|添加|增加|新增|修改|调整|编辑|删除|移动|旋转|缩放|布置|装修|建造|搭建|修复|design|create|generate|add|modify|update|edit|delete|remove|move|rotate|scale|furnish|build|repair|fix)/i
const CHINESE_MUTATION_ACTIONS =
'设计|创建|生成|添加|增加|新增|修改|调整|编辑|删除|移动|旋转|缩放|布置|装修|建造|搭建|修复'
const ENGLISH_MUTATION_ACTIONS =
'design|create|generate|add|modify|change|update|edit|delete|remove|move|rotate|scale|furnish|build|repair|fix'
const MUTATION_INTENT = new RegExp(
`(?:${CHINESE_MUTATION_ACTIONS}|${ENGLISH_MUTATION_ACTIONS})`,
'i',
)
const NEGATED_CHINESE_MUTATION = new RegExp(
`(?:不要|请勿|勿|无需|不需要|不用|禁止|避免|不(?:再)?)[^,。;;,.!?!?]{0,32}(?:${CHINESE_MUTATION_ACTIONS})`,
'gi',
)
const NEGATED_ENGLISH_MUTATION = new RegExp(
`(?:do\\s+not|don't|dont|please\\s+do\\s+not|please\\s+don't|without|avoid)\\s+[^,.!?;]{0,48}(?:${ENGLISH_MUTATION_ACTIONS})`,
'gi',
)
function actionableIntentText(text: string): string {
return text.replace(NEGATED_CHINESE_MUTATION, ' ').replace(NEGATED_ENGLISH_MUTATION, ' ')
}
export function requiresPascalMutation(messages: unknown[]): boolean {
const latestUserMessage = [...messages]
@ -24,15 +44,19 @@ export function requiresPascalMutation(messages: unknown[]): boolean {
.map((part) => part.text)
.join(' ')
return MUTATION_INTENT.test(text)
return MUTATION_INTENT.test(actionableIntentText(text))
}
export function pascalAgentStepPolicy(params: {
hasMutation: boolean
mutationRequired: boolean
mutationToolNames: string[]
readOnlyToolNames: string[]
stepNumber: number
}): { activeTools?: string[]; toolChoice: 'auto' | 'required' } {
if (!params.mutationRequired) {
return { activeTools: params.readOnlyToolNames, toolChoice: 'auto' }
}
if (!(params.mutationRequired && !params.hasMutation)) return { toolChoice: 'auto' }
if (params.stepNumber < 1) return { toolChoice: 'required' }
return {

View File

@ -45,6 +45,7 @@ export interface PascalAgentMcpSession {
close: () => Promise<void>
hasMutation: () => boolean
mutationToolNames: string[]
readOnlyToolNames: string[]
saveDraft: (abortSignal?: AbortSignal) => Promise<void>
sceneName: string
tools: ToolSet
@ -117,6 +118,10 @@ export function isPascalAgentMutationTool(name: string): boolean {
return MUTATION_TOOL_NAMES.has(name)
}
function isPascalAgentWriteTool(name: string): boolean {
return name === 'save_scene' || isPascalAgentMutationTool(name)
}
function readSceneName(result: unknown, fallback: string): string {
const structured = (result as { structuredContent?: { name?: unknown } })?.structuredContent
return typeof structured?.name === 'string' && structured.name.trim()
@ -228,10 +233,12 @@ export async function createPascalAgentMcpSession(params: {
await client?.close()
}
const toolNames = Object.keys(tools)
return {
close,
hasMutation: () => mutationCount > 0,
mutationToolNames: Object.keys(tools).filter(isPascalAgentMutationTool),
mutationToolNames: toolNames.filter(isPascalAgentMutationTool),
readOnlyToolNames: toolNames.filter((name) => !isPascalAgentWriteTool(name)),
sceneName,
tools,
saveDraft: async (abortSignal) => {