feat(engine): allow read of sibling task PROMPT.md/task.json for deps

Agents working on a task that depends on other tasks (e.g. documentation
alignment tasks needing the sibling tasks' specs) were repeatedly
rejected by the worktree boundary when reading .fusion/tasks/FN-NNNN/PROMPT.md,
which also contributed to the malformed-tool-result crash we just fixed.

Add a read-only exception to isWorktreeAllowedPath: the read/glob/grep
tools may access .fusion/tasks/*/PROMPT.md and .fusion/tasks/*/task.json
at the project root. Writes and bash cwd remain restricted.

Update the system-prompt boundary docs (executor.ts) so agents know the
exception exists and stop burning turns re-trying rejected reads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-24 14:07:00 -07:00
committed by gsxdsm
parent 097902d922
commit b7b10a6284
3 changed files with 33 additions and 8 deletions

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { AgentDetailView } from "../AgentDetailView";
@@ -825,11 +825,11 @@ describe("AgentDetailView", () => {
});
it("shows loading state while fetching chain of command", async () => {
let resolveChain: ((agents: AgentDetail[]) => void) | undefined;
const resolveChainCalls: Array<(agents: AgentDetail[]) => void> = [];
mockFetchChainOfCommand.mockImplementation(
() =>
new Promise((resolve) => {
resolveChain = resolve;
resolveChainCalls.push(resolve as (agents: AgentDetail[]) => void);
}) as any,
);
@@ -845,7 +845,11 @@ describe("AgentDetailView", () => {
expect(screen.getByText("Loading reporting chain...")).toBeInTheDocument();
});
resolveChain?.([{ id: "agent-001", name: "Test Agent" } as AgentDetail]);
await act(async () => {
for (const resolve of resolveChainCalls) {
resolve([{ id: "agent-001", name: "Test Agent" } as AgentDetail]);
}
});
await waitFor(() => {
expect(screen.queryByText("Loading reporting chain...")).not.toBeInTheDocument();

View File

@@ -331,6 +331,7 @@ You are running in an **isolated git worktree**. This means:
- **All code changes must be made inside the current worktree directory.** Do not modify files outside the worktree — the worktree is your isolated execution environment.
- **Exception — Project memory:** You MAY read and write to files under .fusion/memory/ at the project root to save durable project learnings (architecture patterns, conventions, pitfalls).
- **Exception — Task attachments:** You MAY read files under .fusion/tasks/{taskId}/attachments/ at the project root for context screenshots and documents attached to this task.
- **Exception — Sibling task specs:** You MAY read .fusion/tasks/{taskId}/PROMPT.md and .fusion/tasks/{taskId}/task.json at the project root (read-only) to consult dependency tasks' specifications.
- **Shell commands** run inside the worktree by default. Avoid using cd to navigate outside the worktree.
If you attempt to write to a path outside the worktree, the file tools will reject the operation with an error explaining the boundary.
@@ -4929,6 +4930,7 @@ You are running in an **isolated git worktree**. This means:
- **All code changes must be made inside the current worktree directory.** Do not modify files outside the worktree.
- **Exception — Project memory:** You MAY read and write to files under \`.fusion/memory/\` at the project root to save durable project learnings.
- **Exception — Task attachments:** You MAY read files under \`.fusion/tasks/{taskId}/attachments/\` at the project root for context.
- **Exception — Sibling task specs:** You MAY read \`.fusion/tasks/{taskId}/PROMPT.md\` and \`.fusion/tasks/{taskId}/task.json\` at the project root (read-only) to consult dependency tasks' specifications.
- **Shell commands** run inside the worktree by default. Avoid using \`cd\` to navigate outside the worktree.
## Begin

View File

@@ -781,14 +781,22 @@ async function assertValidWorktreeSession(cwd: string, projectRoot: string): Pro
* - Paths inside the worktree are always allowed
* - Project root .fusion/memory/ files are allowed (for durable project learnings)
* - Task attachments under .fusion/tasks/N/attachments/ are allowed (for reading context files)
* - Sibling task specs (.fusion/tasks/N/PROMPT.md and task.json) are allowed for
* read-only tools (read/glob/grep) so agents can consult dependency specs.
* - All other paths outside the worktree are rejected
*
* @param worktreePath - Absolute path to the worktree directory
* @param projectRoot - Absolute path to the project root (derived from worktree)
* @param requestedPath - The path being accessed
* @param toolName - Tool making the request (controls read-only exceptions)
* @returns true if allowed, false if rejected
*/
function isWorktreeAllowedPath(worktreePath: string, projectRoot: string, requestedPath: string): boolean {
function isWorktreeAllowedPath(
worktreePath: string,
projectRoot: string,
requestedPath: string,
toolName?: string,
): boolean {
// Normalize paths
const worktreeResolved = resolve(worktreePath);
const projectRootResolved = resolve(projectRoot);
@@ -815,6 +823,16 @@ function isWorktreeAllowedPath(worktreePath: string, projectRoot: string, reques
return true;
}
// Exception (read-only): sibling task specs so the agent can consult the
// PROMPT.md / task.json of dependency tasks without needing them copied
// into the worktree. `glob`/`grep` are narrow enough to allow as well so
// the agent can discover them; writes and bash remain restricted.
const readOnlyTools = new Set(["read", "glob", "grep"]);
if (toolName && readOnlyTools.has(toolName) &&
/^\.fusion\/tasks\/[^/]+\/(PROMPT\.md|task\.json)$/.test(relToProjectRoot)) {
return true;
}
// All other paths outside the worktree are rejected
return false;
}
@@ -874,18 +892,19 @@ export function wrapToolsWithBoundary(
// Check path argument for file operations
const pathArg = params.path as string | undefined;
if (pathArg && !isWorktreeAllowedPath(worktreePath, projectRoot, pathArg)) {
if (pathArg && !isWorktreeAllowedPath(worktreePath, projectRoot, pathArg, tool.name)) {
const relToProject = relative(projectRoot, pathArg);
return boundaryRejection(
`Path "${relToProject}" is outside the worktree boundary. ` +
`Coding agents can only modify files inside the current worktree. ` +
`Exception: .fusion/memory/ (project root) and .fusion/tasks/*/attachments/* are permitted for reading.`,
`Exceptions (read-only): .fusion/memory/, .fusion/tasks/*/attachments/, ` +
`and .fusion/tasks/*/{PROMPT.md,task.json} for dependency context.`,
);
}
// For bash, also check the working directory if specified
const cwdArg = params.cwd as string | undefined;
if (tool.name === "bash" && cwdArg && !isWorktreeAllowedPath(worktreePath, projectRoot, cwdArg)) {
if (tool.name === "bash" && cwdArg && !isWorktreeAllowedPath(worktreePath, projectRoot, cwdArg, tool.name)) {
return boundaryRejection(
`Working directory is outside the worktree boundary. ` +
`Commands must run inside the worktree.`,