FN-8466: allow reads of advertised plugin skills
Allow isolated worktree sessions to read only host-advertised plugin skill roots. - Normalize additional skill paths once for resource loading and boundary checks - Permit read, glob, and grep while keeping write, edit, and Bash worktree-bound - Document the boundary and cover root normalization and access restrictions Files changed: .changeset/fn-8466-skill-path-read-boundary.md | 7 ++ docs/agents.md | 4 + .../src/__tests__/pi-create-fn-agent.test.ts | 108 +++++++++++++++++++++ packages/engine/src/pi.ts | 75 +++++++++++--- 4 files changed, 183 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-8466 Fusion-Task-Lineage: ff6566be-803d-48b8-8550-aa1bfe080fed Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8466-skill-path-read-boundary.md
Normal file
7
.changeset/fn-8466-skill-path-read-boundary.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Allow session Read tool to open host-advertised plugin skill body paths under worktree boundary.
|
||||
category: fix
|
||||
dev: Worktree-bound pi sessions treat one normalized AgentOptions.additionalSkillPaths list as a read-only boundary exception for read/glob/grep and as DefaultResourceLoader skill roots (GitHub #2384 / FN-8466); skill-root write/edit remain blocked.
|
||||
@@ -932,6 +932,10 @@ Dashboard Chat, Chat Room responders, and task-detail Planner Chat run at the in
|
||||
|
||||
Task-detail Planner Chat is included because it is a `task-planner:<taskId>` ChatManager session. This does not change the readonly planning/mission interview lanes or WhatsApp plugin chat. Chat verification remains limited to its existing allowlisted profiles rather than accepting arbitrary shell commands.
|
||||
|
||||
### Worktree session file boundary
|
||||
|
||||
Pi sessions started in an isolated task worktree reject filesystem paths outside that worktree. The established project-memory and task-attachment exceptions remain unchanged. Separately, when Fusion advertises skill bodies through `AgentOptions.additionalSkillPaths` (including enabled plugin skill roots), it allows only `read`, `glob`, and `grep` to access those exact normalized roots. `write`, `edit`, and Bash working directories remain worktree-bound for skill roots; this is not a general `~/.fusion/plugins` exception.
|
||||
|
||||
```bash
|
||||
fn message inbox
|
||||
fn message outbox
|
||||
|
||||
@@ -496,6 +496,114 @@ describe("worktree path boundary helpers", () => {
|
||||
expect(result).toEqual({ ok: true, content: [{ type: "text", text: "attachment content" }] });
|
||||
});
|
||||
|
||||
it("allows only read/glob/grep under host-advertised skill roots", async () => {
|
||||
const makeTool = (name: string) => ({
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} a skill file`,
|
||||
parameters: {},
|
||||
execute: vi.fn().mockResolvedValue({ ok: true, content: [] }),
|
||||
});
|
||||
const skillRoot = "/Users/agent/.fusion/plugins/de-sloppify/skills";
|
||||
const skillPath = `${skillRoot}/de-sloppify/references/style.md`;
|
||||
const [readTool, globTool, grepTool, writeTool, editTool, bashTool] = [
|
||||
makeTool("read"),
|
||||
makeTool("glob"),
|
||||
makeTool("grep"),
|
||||
makeTool("write"),
|
||||
makeTool("edit"),
|
||||
makeTool("bash"),
|
||||
];
|
||||
const { wrapToolsWithBoundary } = await import("../pi.js");
|
||||
const wrapped = wrapToolsWithBoundary(
|
||||
[readTool, globTool, grepTool, writeTool, editTool, bashTool] as any,
|
||||
"/project/.worktrees/fn-8466",
|
||||
"/project",
|
||||
[skillRoot],
|
||||
);
|
||||
|
||||
for (const tool of wrapped.slice(0, 3) as any[]) {
|
||||
await tool.execute(`call-${tool.name}`, { path: skillPath });
|
||||
}
|
||||
expect(readTool.execute).toHaveBeenCalledOnce();
|
||||
expect(globTool.execute).toHaveBeenCalledOnce();
|
||||
expect(grepTool.execute).toHaveBeenCalledOnce();
|
||||
|
||||
for (const tool of wrapped.slice(3, 5) as any[]) {
|
||||
const result = await tool.execute(`call-${tool.name}`, { path: skillPath });
|
||||
expect(result).toMatchObject({ ok: false, error: expect.stringContaining("outside the worktree boundary") });
|
||||
}
|
||||
expect(writeTool.execute).not.toHaveBeenCalled();
|
||||
expect(editTool.execute).not.toHaveBeenCalled();
|
||||
|
||||
const bashResult = await (wrapped[5] as any).execute("call-bash", { command: "pwd", cwd: skillRoot });
|
||||
expect(bashResult).toMatchObject({ ok: false, error: expect.stringContaining("outside the worktree boundary") });
|
||||
expect(bashTool.execute).not.toHaveBeenCalled();
|
||||
|
||||
const outsideResult = await (wrapped[0] as any).execute("call-outside", { path: "/other/project/secret" });
|
||||
expect(outsideResult).toMatchObject({ ok: false, error: expect.stringContaining("outside the worktree boundary") });
|
||||
expect(readTool.execute).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects host skill paths when no read-only extra roots are provided", async () => {
|
||||
const mockReadTool = {
|
||||
name: "read",
|
||||
label: "Read",
|
||||
description: "Read a file",
|
||||
parameters: {},
|
||||
execute: vi.fn().mockResolvedValue({ ok: true, content: [] }),
|
||||
};
|
||||
const { wrapToolsWithBoundary } = await import("../pi.js");
|
||||
const wrapped = wrapToolsWithBoundary([mockReadTool as any], "/project/.worktrees/fn-8466", "/project");
|
||||
|
||||
const result = await (wrapped[0] as any).execute("call-1", {
|
||||
path: "/Users/agent/.fusion/plugins/de-sloppify/skills/de-sloppify/SKILL.md",
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, error: expect.stringContaining("outside the worktree boundary") });
|
||||
expect(mockReadTool.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("canonicalizes macOS-style skill roots before allowing reads", async () => {
|
||||
const skillRoot = "/var/folders/fn-8466/plugin/skills";
|
||||
const canonicalSkillPath = "/private/var/folders/fn-8466/plugin/skills/de-sloppify/SKILL.md";
|
||||
const mockReadTool = {
|
||||
name: "read",
|
||||
label: "Read",
|
||||
description: "Read a file",
|
||||
parameters: {},
|
||||
execute: vi.fn().mockResolvedValue({ ok: true, content: [] }),
|
||||
};
|
||||
realpathSyncNativeMock.mockImplementation((path: PathLike) => {
|
||||
const text = String(path);
|
||||
return text.startsWith("/var/") ? `/private${text}` : text;
|
||||
});
|
||||
const { wrapToolsWithBoundary } = await import("../pi.js");
|
||||
const wrapped = wrapToolsWithBoundary(
|
||||
[mockReadTool as any],
|
||||
"/project/.worktrees/fn-8466",
|
||||
"/project",
|
||||
[skillRoot],
|
||||
);
|
||||
|
||||
const result = await (wrapped[0] as any).execute("call-1", { path: canonicalSkillPath });
|
||||
expect(result).toEqual({ ok: true, content: [] });
|
||||
expect(mockReadTool.execute).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("normalizes one stable skill-root list for resource loading and boundary wiring", async () => {
|
||||
const { normalizeAdditionalSkillPaths } = await import("../pi.js");
|
||||
expect(normalizeAdditionalSkillPaths(["/skills/plugin", "", "/skills/plugin/", "/skills/ce"])).toEqual([
|
||||
"/skills/plugin",
|
||||
"/skills/ce",
|
||||
]);
|
||||
|
||||
const fs = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
const source = fs.readFileSync(`${process.cwd()}/src/pi.ts`, "utf8");
|
||||
expect(source).toContain("const normalizedAdditionalSkillPaths = normalizeAdditionalSkillPaths(options.additionalSkillPaths);");
|
||||
expect(source).toContain("additionalSkillPaths: normalizedAdditionalSkillPaths");
|
||||
expect(source).toMatch(/wrapToolsWithBoundary\(\s*toolsWithActionGate,\s*boundaryContext\.worktreePath,\s*boundaryContext\.worktreeProjectRoot,\s*normalizedAdditionalSkillPaths,/);
|
||||
});
|
||||
|
||||
it("does not wrap tools when cwd is not a worktree", async () => {
|
||||
const mockTool = {
|
||||
name: "read",
|
||||
|
||||
@@ -1616,6 +1616,29 @@ function normalizeExistingPathForGitComparison(path: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:SkillReadBoundary 2026-07-21-12:00:
|
||||
* GitHub #2384 / FN-8466 requires the exact host-advertised additional skill
|
||||
* roots to drive both pi discovery and the worktree Read boundary. Resolve and
|
||||
* deduplicate once so the manifest cannot advertise a skill body the boundary
|
||||
* rejects through a divergent raw-path list.
|
||||
*/
|
||||
export function normalizeAdditionalSkillPaths(paths?: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalizedPaths: string[] = [];
|
||||
|
||||
for (const path of paths ?? []) {
|
||||
if (!path?.trim()) continue;
|
||||
const normalizedPath = normalizeExistingPathForGitComparison(resolve(path));
|
||||
if (!seen.has(normalizedPath)) {
|
||||
seen.add(normalizedPath);
|
||||
normalizedPaths.push(normalizedPath);
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedPaths;
|
||||
}
|
||||
|
||||
function isSameOrInsidePath(parentPath: string, childPath: string): boolean {
|
||||
const rel = relative(parentPath, childPath);
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
||||
@@ -1653,12 +1676,14 @@ async function assertValidWorktreeSession(cwd: string, projectRoot: string): Pro
|
||||
* - 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.
|
||||
* - Host-advertised additional skill roots are allowed for read-only tools only.
|
||||
* - 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)
|
||||
* @param readOnlyExtraRoots - Host-advertised roots readable by read-only tools
|
||||
* @returns true if allowed, false if rejected
|
||||
*/
|
||||
function isWorktreeAllowedPath(
|
||||
@@ -1666,6 +1691,7 @@ function isWorktreeAllowedPath(
|
||||
projectRoot: string,
|
||||
requestedPath: string,
|
||||
toolName?: string,
|
||||
readOnlyExtraRoots: readonly string[] = [],
|
||||
): boolean {
|
||||
// Normalize paths
|
||||
const worktreeResolved = resolve(worktreePath);
|
||||
@@ -1707,12 +1733,25 @@ function isWorktreeAllowedPath(
|
||||
// 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) &&
|
||||
projectRelativePaths.some((relPath) => /^\.fusion\/tasks\/[^/]+\/(PROMPT\.md|task\.json)$/.test(relPath))
|
||||
) {
|
||||
return true;
|
||||
if (toolName && readOnlyTools.has(toolName)) {
|
||||
if (projectRelativePaths.some((relPath) => /^\.fusion\/tasks\/[^/]+\/(PROMPT\.md|task\.json)$/.test(relPath))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SkillReadBoundary 2026-07-21-12:00:
|
||||
GitHub #2384 / FN-8466 lets agents Read only the specific additional skill
|
||||
roots advertised by this session. Do not extend this exception to write,
|
||||
edit, or bash: plugin skill bodies remain host-owned read-only context.
|
||||
*/
|
||||
if (readOnlyExtraRoots.some((root) => {
|
||||
const rootResolved = resolve(root);
|
||||
const rootCanonical = normalizeExistingPathForGitComparison(rootResolved);
|
||||
return isSameOrInsidePath(rootResolved, requestedResolved)
|
||||
|| isSameOrInsidePath(rootCanonical, requestedCanonical);
|
||||
})) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// All other paths outside the worktree are rejected
|
||||
@@ -1726,6 +1765,7 @@ function isWorktreeAllowedPath(
|
||||
* @param tools - Array of tool definitions to wrap
|
||||
* @param worktreePath - Absolute path to the worktree directory (if applicable)
|
||||
* @param projectRoot - Absolute path to the project root (if applicable)
|
||||
* @param readOnlyExtraRoots - Host-advertised roots readable by read/glob/grep only
|
||||
* @returns Wrapped tools with boundary validation
|
||||
*/
|
||||
/**
|
||||
@@ -1778,11 +1818,14 @@ export function wrapToolsWithBoundary(
|
||||
tools: ToolDefinition[],
|
||||
worktreePath: string | null,
|
||||
projectRoot: string | null,
|
||||
readOnlyExtraRoots: readonly string[] = [],
|
||||
): ToolDefinition[] {
|
||||
if (!worktreePath || !projectRoot) {
|
||||
return tools; // Not a worktree session, no wrapping needed
|
||||
}
|
||||
|
||||
const normalizedReadOnlyExtraRoots = normalizeAdditionalSkillPaths(readOnlyExtraRoots);
|
||||
|
||||
return tools.map((tool) => {
|
||||
// Only wrap tools that access the filesystem
|
||||
const fileToolNames = new Set(["read", "write", "edit", "glob", "grep", "bash"]);
|
||||
@@ -1804,13 +1847,13 @@ export function wrapToolsWithBoundary(
|
||||
|
||||
// Check path argument for file operations
|
||||
const pathArg = params.path as string | undefined;
|
||||
if (pathArg && !isWorktreeAllowedPath(worktreePath, projectRoot, pathArg, tool.name)) {
|
||||
if (pathArg && !isWorktreeAllowedPath(worktreePath, projectRoot, pathArg, tool.name, normalizedReadOnlyExtraRoots)) {
|
||||
const relToProject = relative(projectRoot, pathArg);
|
||||
return boundaryRejection(
|
||||
`Path "${relToProject}" is outside the worktree boundary. ` +
|
||||
`Coding agents can only modify files inside the current worktree. ` +
|
||||
`Exceptions (read-only): .fusion/memory/, .fusion/tasks/*/attachments/, ` +
|
||||
`and .fusion/tasks/*/{PROMPT.md,task.json} for dependency context.`,
|
||||
`Existing exceptions include .fusion/memory/ and task attachments; ` +
|
||||
`read-only tools may also access sibling task specs and host-advertised skill roots.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2314,6 +2357,15 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:SkillReadBoundary 2026-07-21-12:00:
|
||||
GitHub #2384 / FN-8466 requires one canonical additional-skill list for both
|
||||
DefaultResourceLoader's manifest and the worktree boundary. A skill location
|
||||
the loader advertises must be readable, while the boundary grants it only to
|
||||
read/glob/grep rather than write/edit/bash.
|
||||
*/
|
||||
const normalizedAdditionalSkillPaths = normalizeAdditionalSkillPaths(options.additionalSkillPaths);
|
||||
|
||||
// `tools: "readonly"` MUST mean a hermetically sealed read-only session with
|
||||
// respect to host extension injection. Host extensions (`@runfusion/fusion`)
|
||||
// can register write tools like `fn_task_create`, so they are deliberately
|
||||
@@ -2348,8 +2400,8 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
? [options.systemPromptLayers.dynamic]
|
||||
: [],
|
||||
...(effectiveExtensionPaths.length > 0 ? { additionalExtensionPaths: [...effectiveExtensionPaths] } : {}),
|
||||
...(options.additionalSkillPaths && options.additionalSkillPaths.length > 0
|
||||
? { additionalSkillPaths: [...options.additionalSkillPaths] }
|
||||
...(normalizedAdditionalSkillPaths.length > 0
|
||||
? { additionalSkillPaths: normalizedAdditionalSkillPaths }
|
||||
: {}),
|
||||
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
|
||||
});
|
||||
@@ -2454,6 +2506,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
toolsWithActionGate,
|
||||
boundaryContext.worktreePath,
|
||||
boundaryContext.worktreeProjectRoot,
|
||||
normalizedAdditionalSkillPaths,
|
||||
);
|
||||
// Sort tools alphabetically by name for deterministic ordering.
|
||||
// Prompt caching requires the tool list to be byte-identical across
|
||||
|
||||
Reference in New Issue
Block a user