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.
|
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
|
```bash
|
||||||
fn message inbox
|
fn message inbox
|
||||||
fn message outbox
|
fn message outbox
|
||||||
|
|||||||
@@ -496,6 +496,114 @@ describe("worktree path boundary helpers", () => {
|
|||||||
expect(result).toEqual({ ok: true, content: [{ type: "text", text: "attachment content" }] });
|
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 () => {
|
it("does not wrap tools when cwd is not a worktree", async () => {
|
||||||
const mockTool = {
|
const mockTool = {
|
||||||
name: "read",
|
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 {
|
function isSameOrInsidePath(parentPath: string, childPath: string): boolean {
|
||||||
const rel = relative(parentPath, childPath);
|
const rel = relative(parentPath, childPath);
|
||||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
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)
|
* - 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
|
* - 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.
|
* 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
|
* - All other paths outside the worktree are rejected
|
||||||
*
|
*
|
||||||
* @param worktreePath - Absolute path to the worktree directory
|
* @param worktreePath - Absolute path to the worktree directory
|
||||||
* @param projectRoot - Absolute path to the project root (derived from worktree)
|
* @param projectRoot - Absolute path to the project root (derived from worktree)
|
||||||
* @param requestedPath - The path being accessed
|
* @param requestedPath - The path being accessed
|
||||||
* @param toolName - Tool making the request (controls read-only exceptions)
|
* @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
|
* @returns true if allowed, false if rejected
|
||||||
*/
|
*/
|
||||||
function isWorktreeAllowedPath(
|
function isWorktreeAllowedPath(
|
||||||
@@ -1666,6 +1691,7 @@ function isWorktreeAllowedPath(
|
|||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
requestedPath: string,
|
requestedPath: string,
|
||||||
toolName?: string,
|
toolName?: string,
|
||||||
|
readOnlyExtraRoots: readonly string[] = [],
|
||||||
): boolean {
|
): boolean {
|
||||||
// Normalize paths
|
// Normalize paths
|
||||||
const worktreeResolved = resolve(worktreePath);
|
const worktreeResolved = resolve(worktreePath);
|
||||||
@@ -1707,12 +1733,25 @@ function isWorktreeAllowedPath(
|
|||||||
// into the worktree. `glob`/`grep` are narrow enough to allow as well so
|
// into the worktree. `glob`/`grep` are narrow enough to allow as well so
|
||||||
// the agent can discover them; writes and bash remain restricted.
|
// the agent can discover them; writes and bash remain restricted.
|
||||||
const readOnlyTools = new Set(["read", "glob", "grep"]);
|
const readOnlyTools = new Set(["read", "glob", "grep"]);
|
||||||
if (
|
if (toolName && readOnlyTools.has(toolName)) {
|
||||||
toolName &&
|
if (projectRelativePaths.some((relPath) => /^\.fusion\/tasks\/[^/]+\/(PROMPT\.md|task\.json)$/.test(relPath))) {
|
||||||
readOnlyTools.has(toolName) &&
|
return true;
|
||||||
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
|
// All other paths outside the worktree are rejected
|
||||||
@@ -1726,6 +1765,7 @@ function isWorktreeAllowedPath(
|
|||||||
* @param tools - Array of tool definitions to wrap
|
* @param tools - Array of tool definitions to wrap
|
||||||
* @param worktreePath - Absolute path to the worktree directory (if applicable)
|
* @param worktreePath - Absolute path to the worktree directory (if applicable)
|
||||||
* @param projectRoot - Absolute path to the project root (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
|
* @returns Wrapped tools with boundary validation
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
@@ -1778,11 +1818,14 @@ export function wrapToolsWithBoundary(
|
|||||||
tools: ToolDefinition[],
|
tools: ToolDefinition[],
|
||||||
worktreePath: string | null,
|
worktreePath: string | null,
|
||||||
projectRoot: string | null,
|
projectRoot: string | null,
|
||||||
|
readOnlyExtraRoots: readonly string[] = [],
|
||||||
): ToolDefinition[] {
|
): ToolDefinition[] {
|
||||||
if (!worktreePath || !projectRoot) {
|
if (!worktreePath || !projectRoot) {
|
||||||
return tools; // Not a worktree session, no wrapping needed
|
return tools; // Not a worktree session, no wrapping needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedReadOnlyExtraRoots = normalizeAdditionalSkillPaths(readOnlyExtraRoots);
|
||||||
|
|
||||||
return tools.map((tool) => {
|
return tools.map((tool) => {
|
||||||
// Only wrap tools that access the filesystem
|
// Only wrap tools that access the filesystem
|
||||||
const fileToolNames = new Set(["read", "write", "edit", "glob", "grep", "bash"]);
|
const fileToolNames = new Set(["read", "write", "edit", "glob", "grep", "bash"]);
|
||||||
@@ -1804,13 +1847,13 @@ export function wrapToolsWithBoundary(
|
|||||||
|
|
||||||
// Check path argument for file operations
|
// Check path argument for file operations
|
||||||
const pathArg = params.path as string | undefined;
|
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);
|
const relToProject = relative(projectRoot, pathArg);
|
||||||
return boundaryRejection(
|
return boundaryRejection(
|
||||||
`Path "${relToProject}" is outside the worktree boundary. ` +
|
`Path "${relToProject}" is outside the worktree boundary. ` +
|
||||||
`Coding agents can only modify files inside the current worktree. ` +
|
`Coding agents can only modify files inside the current worktree. ` +
|
||||||
`Exceptions (read-only): .fusion/memory/, .fusion/tasks/*/attachments/, ` +
|
`Existing exceptions include .fusion/memory/ and task attachments; ` +
|
||||||
`and .fusion/tasks/*/{PROMPT.md,task.json} for dependency context.`,
|
`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
|
// `tools: "readonly"` MUST mean a hermetically sealed read-only session with
|
||||||
// respect to host extension injection. Host extensions (`@runfusion/fusion`)
|
// respect to host extension injection. Host extensions (`@runfusion/fusion`)
|
||||||
// can register write tools like `fn_task_create`, so they are deliberately
|
// 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]
|
? [options.systemPromptLayers.dynamic]
|
||||||
: [],
|
: [],
|
||||||
...(effectiveExtensionPaths.length > 0 ? { additionalExtensionPaths: [...effectiveExtensionPaths] } : {}),
|
...(effectiveExtensionPaths.length > 0 ? { additionalExtensionPaths: [...effectiveExtensionPaths] } : {}),
|
||||||
...(options.additionalSkillPaths && options.additionalSkillPaths.length > 0
|
...(normalizedAdditionalSkillPaths.length > 0
|
||||||
? { additionalSkillPaths: [...options.additionalSkillPaths] }
|
? { additionalSkillPaths: normalizedAdditionalSkillPaths }
|
||||||
: {}),
|
: {}),
|
||||||
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
|
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
|
||||||
});
|
});
|
||||||
@@ -2454,6 +2506,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
toolsWithActionGate,
|
toolsWithActionGate,
|
||||||
boundaryContext.worktreePath,
|
boundaryContext.worktreePath,
|
||||||
boundaryContext.worktreeProjectRoot,
|
boundaryContext.worktreeProjectRoot,
|
||||||
|
normalizedAdditionalSkillPaths,
|
||||||
);
|
);
|
||||||
// Sort tools alphabetically by name for deterministic ordering.
|
// Sort tools alphabetically by name for deterministic ordering.
|
||||||
// Prompt caching requires the tool list to be byte-identical across
|
// Prompt caching requires the tool list to be byte-identical across
|
||||||
|
|||||||
Reference in New Issue
Block a user