fix(engine,core): seal readonly agent sessions; sanitize summarizer output

The title summarizer ran with `tools: "readonly"` but host extensions
(`@runfusion/fusion`) were still injected, exposing `fn_task_create` and
the rest of the `fn_*` mutation surface. A summarizer model called
`fn_task_create` mid-summary, spawning an unintended sibling task and
leaving its chat-style reply ("Created **FN-xxxx** with the full spec…")
sliced as the original task's title.

- pi.ts: in `tools: "readonly"` mode, skip host extension paths and drop
  caller-supplied customTools so the session truly only has read/grep/
  find/ls.
- ai-summarize.ts: harden all four system prompts (title, merge summary,
  commit body, commit subject) with explicit no-tool / treat-input-as-
  content framing; wrap the title prompt's user content in a
  `<description>` delimiter; route the AI response through new
  `sanitizeTitle` that strips chatty preambles, markdown emphasis,
  surrounding quotes, and trailing punctuation before truncation.
- Tests: add a regression covering the exact incident shape plus
  unit coverage for `sanitizeTitle` edge cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-01 07:52:12 -07:00
parent 4c4ca04a60
commit 1c5f60d3b6
3 changed files with 244 additions and 35 deletions

View File

@@ -1114,16 +1114,25 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
});
}
// `tools: "readonly"` MUST mean a hermetically sealed read-only session — no
// way for the model to mutate state. Host extensions (`@runfusion/fusion`)
// register write tools like `fn_task_create`, so they are deliberately
// EXCLUDED in readonly mode. Caller-supplied `customTools` are also dropped
// for the same reason. Without this, summarizer/compaction sessions could
// call write tools and mutate the task board (see FN-3057/FN-3058 incident).
const isReadonly = options.tools === "readonly";
const effectiveExtensionPaths = isReadonly ? [] : hostExtensionPaths;
if (isReadonly && hostExtensionPaths.length > 0) {
piLog.log(`readonly session — host extensions (${hostExtensionPaths.length}) skipped`);
}
const resourceLoader = new DefaultResourceLoader({
cwd: options.cwd,
agentDir: getFusionAgentDir(),
settingsManager,
systemPromptOverride: () => options.systemPrompt,
appendSystemPromptOverride: () => [],
// Inject host-supplied extension paths (e.g. cli's own `@runfusion/fusion`
// extension that registers `fn_*` tools) so they're loaded inside every
// agent session, including chat sessions that don't pass `customTools`.
...(hostExtensionPaths.length > 0 ? { additionalExtensionPaths: [...hostExtensionPaths] } : {}),
...(effectiveExtensionPaths.length > 0 ? { additionalExtensionPaths: [...effectiveExtensionPaths] } : {}),
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
});
await resourceLoader.reload();
@@ -1137,10 +1146,15 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
// suppress the defaults with `noTools: "builtin"` and register our wrapped
// tools through `customTools` instead. The wrapped tools preserve the same
// names (`read`, `bash`, ...) as the built-ins they replace.
// Readonly sessions drop caller-supplied customTools — see comment above
// about hermetic isolation. Only the wrapped read-only built-ins survive.
const customToolList: ToolDefinition[] = [
...(wrappedTools as ToolDefinition[]),
...(options.customTools ?? []),
...(isReadonly ? [] : (options.customTools ?? [])),
];
if (isReadonly && (options.customTools?.length ?? 0) > 0) {
piLog.log(`readonly session — customTools (${options.customTools!.length}) skipped`);
}
// Last-chance abort hook. Fires *here* — after every awaited setup step
// in createFnAgent (provider registration, worktree validation, resource
// loader reload) and immediately before the actual LLM session spawn.