fix(write): evaluate function-valued xd:// device approvals

The write approval gate discarded a mounted tool's function-valued
approval and never decoded the device JSON payload, defaulting the tier
to exec. Read/write xd:// operations then prompted in non-yolo modes
that permit them.

Now decode valid object payloads and resolve the mounted tool's normal
approval decision via resolveToolTier; malformed JSON, non-object
payloads, and unknown devices still fall back to exec and prompt.

Fixes #5727
This commit is contained in:
roboomp
2026-07-16 17:24:06 +00:00
parent c0d0ad7629
commit 5445beb6f5
4 changed files with 76 additions and 7 deletions
@@ -59,12 +59,12 @@ describe("read and write route xd:// device URLs", () => {
paths: [filePath],
});
// Approval resolves a tier instead of throwing. A mounted tool whose own
// approval is a function (unresolvable statically) falls back to exec.
// The write gate decodes the device payload and evaluates the mounted
// tool's own approval. ast_edit is write-tier for a filesystem path.
const approval = write!.approval;
expect(typeof approval).toBe("function");
if (typeof approval === "function") {
expect(approval({ path: "xd://ast_edit", content })).toBe("exec");
expect(approval({ path: "xd://ast_edit", content })).toBe("write");
}
// Execute dispatches through the xdev registry to the mounted ast_edit,
@@ -86,6 +86,47 @@ describe("read and write route xd:// device URLs", () => {
}
});
it("resolves function-valued device approvals per payload and fails closed on bad content", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-approval-"));
try {
const filePath = path.join(tempDir, "target.ts");
await Bun.write(filePath, "legacyWrap(x, value)\n");
const tools = await createTools(xdevSession(tempDir));
const write = tools.find(entry => entry.name === "write");
expect(write).toBeDefined();
const approval = write!.approval;
expect(typeof approval).toBe("function");
if (typeof approval !== "function") throw new Error("expected a function approval");
const tier = (path: string, content: string) => approval({ path, content });
// ast_edit on a filesystem path → write; on internal URLs only → read.
const astFsPath = JSON.stringify({
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
paths: [filePath],
});
const astInternalPath = JSON.stringify({
ops: [{ pat: "a", out: "b" }],
paths: ["artifact://abc"],
});
expect(tier("xd://ast_edit", astFsPath)).toBe("write");
expect(tier("xd://ast_edit", astInternalPath)).toBe("read");
// debug: inspection action → read; a real launch → exec (control).
expect(tier("xd://debug", JSON.stringify({ action: "sessions" }))).toBe("read");
expect(tier("xd://debug", JSON.stringify({ action: "launch", program: "./app" }))).toBe("exec");
// Fail closed: malformed JSON, non-object payloads, missing content,
// and unknown devices all stay exec so the gate never under-prompts.
expect(tier("xd://ast_edit", "{ not json")).toBe("exec");
expect(tier("xd://ast_edit", "[1,2,3]")).toBe("exec");
expect(tier("xd://ast_edit", '"a string"')).toBe("exec");
expect(approval({ path: "xd://ast_edit" })).toBe("exec");
expect(tier("xd://no_such_device", "{}")).toBe("exec");
} finally {
await removeWithRetries(tempDir);
}
});
it("renderCall withholds a partial xd:// URL, then delegates once settled", async () => {
await themeModule.initTheme();
const uiTheme = (await themeModule.getThemeByName("dark")) ?? (await themeModule.getThemeByName("light"));