Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 47 additions & 4 deletions packages/protocol/src/cloud-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,21 +119,64 @@ export const CLOUD_NODE_ALLOWLIST: readonly string[] = [
];

/**
* Node types removed even though their namespace is allowed. Two groups:
* Nodes that read or write a path on the server's own filesystem and sit
* inside an otherwise-allowed namespace, so only naming them keeps them out.
*
* Two shapes end up here. Most declare a path property the editor renders as a
* native file/folder picker (`json_schema_extra: { type: "file_path" }`) —
* `nodetool.input.DocumentFileInput` is the one users hit first. The rest take
* a plain string path and call `fs` in `process()`; nothing in their metadata
* distinguishes them, which is why this list is written out rather than
* derived.
*
* Split out from {@link CLOUD_NODE_DENYLIST} so the "no host filesystem in the
* cloud" rule is one reviewable list instead of entries scattered by namespace.
*/
export const CLOUD_HOST_FILE_NODES: readonly string[] = [
// Path pickers — a local path typed into a browser tab points at the
// container's disk, not the user's machine.
"nodetool.input.DocumentFileInput",
"nodetool.input.FilePathInput",
"nodetool.input.FolderPathInput",
// Media loaders/savers that go to disk. Their asset-store siblings
// (LoadImageAssets, SaveImage, SaveAudio, SaveVideo, …) stay.
"nodetool.audio.LoadAudioFile",
"nodetool.audio.LoadAudioFolder",
"nodetool.audio.SaveAudioFile",
"nodetool.image.LoadImageFile",
"nodetool.image.LoadImageFolder",
"nodetool.image.SaveImageFile",
"nodetool.video.LoadVideoFile",
"nodetool.video.SaveVideoFile",
"nodetool.model3d.LoadModel3DFile",
"nodetool.model3d.SaveModel3DFile"
];

/**
* Node types removed even though their namespace is allowed. Three groups:
*
* - Host-filesystem nodes — {@link CLOUD_HOST_FILE_NODES} above, which this
* list embeds. A node that reads or writes a path on the server's own disk
* has no meaning in a managed multi-tenant cloud: the path belongs to the
* container, not to the user sitting in the browser. The matching HTTP
* surfaces are already refused in production (`/api/files/local`, the
* workspace routes, tRPC `files.list`), so leaving the nodes in the palette
* only offers a picker that cannot pick and a run that cannot resolve.
* Assets are the cloud's file story: the `*Assets` loaders and the plain
* `SaveImage`/`SaveAudio`/`SaveVideo`/`SaveDataframe` savers go through the
* asset store and stay.
* - `nodetool.text.*` file I/O — `nodetool.text` is whole-listed for its
* creative-text toolkit and ASR, but the folder/asset loaders and the two
* filesystem writers (`SaveText`, `SaveTextFile` — both call fs.writeFile on
* an unsandboxed host path) are dropped: arbitrary host-filesystem access
* isn't appropriate for a managed multi-tenant cloud (same reason the
* shell/Docker code runners stay out).
* an unsandboxed host path) are dropped, for the same reason.
* - `nodetool.agents.*` — the developer/automation-flavored agents that wrap
* the very integrations the cloud profile drops (shell, git, sqlite,
* supabase, http, filesystem, browser, office docs). Kept agents: Agent,
* Classifier, Extractor, Summarizer, CreateThread, ImageAgent, MediaAgent,
* FfmpegAgent, DocumentAgent.
*/
export const CLOUD_NODE_DENYLIST: readonly string[] = [
...CLOUD_HOST_FILE_NODES,
"nodetool.text.LoadTextFolder",
"nodetool.text.LoadTextAssets",
"nodetool.text.SaveText",
Expand Down
26 changes: 26 additions & 0 deletions packages/protocol/tests/cloud-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CLOUD_NODE_NAMESPACES,
CLOUD_NODE_ALLOWLIST,
CLOUD_NODE_DENYLIST,
CLOUD_HOST_FILE_NODES,
CLOUD_PROVIDER_IDS,
CLOUD_BUILTIN_PACK_IDS,
isCloudNodeType,
Expand Down Expand Up @@ -136,6 +137,31 @@ describe("code is node-level trimmed; text is whole-listed minus file I/O", () =
}
});

it("drops the host-filesystem nodes from the managed cloud", () => {
for (const nodeType of CLOUD_HOST_FILE_NODES) {
expect(isCloudNodeType(nodeType)).toBe(false);
}
// The node users reach for first — a local path picker in the browser.
expect(CLOUD_HOST_FILE_NODES).toContain(
"nodetool.input.DocumentFileInput"
);
});

it("keeps the asset-store counterparts of the dropped file nodes", () => {
// Assets, not host paths, are how the cloud moves files around. Dropping
// the disk nodes must not take these with them.
for (const nodeType of [
"nodetool.input.AssetFolderInput",
"nodetool.input.DocumentInput",
"nodetool.image.LoadImageAssets",
"nodetool.image.SaveImage",
"nodetool.audio.SaveAudio",
"nodetool.video.SaveVideo"
]) {
expect(isCloudNodeType(nodeType)).toBe(true);
}
});

it("admits every explicit allowlist entry", () => {
for (const nodeType of CLOUD_NODE_ALLOWLIST) {
expect(isCloudNodeType(nodeType)).toBe(true);
Expand Down
40 changes: 40 additions & 0 deletions packages/websocket/tests/node-registry-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
import { NodeRegistry } from "@nodetool-ai/node-sdk";
import {
BUILTIN_NODE_PACKS,
CLOUD_HOST_FILE_NODES,
CLOUD_PROFILE_ENV,
NODE_ENV_VAR,
isCloudNodeType
Expand Down Expand Up @@ -261,11 +262,39 @@ describe("applyCloudNodePolicy", () => {
expect(remaining).not.toContain("nodetool.text.LoadTextFolder");
expect(remaining).not.toContain("nodetool.text.SaveText");
expect(remaining).not.toContain("nodetool.text.SaveTextFile");
// …the same goes for every node that reads or writes a host path,
// including the file/folder pickers in the otherwise-allowed input
// namespace…
for (const nodeType of CLOUD_HOST_FILE_NODES) {
expect(remaining).not.toContain(nodeType);
}
// …while the asset-store equivalents stay, since assets are how the cloud
// moves files…
expect(remaining).toContain("nodetool.input.AssetFolderInput");
expect(remaining).toContain("nodetool.image.LoadImageAssets");
expect(remaining).toContain("nodetool.image.SaveImage");
// …while the creative media core stays.
expect(remaining.some((t) => t.startsWith("nodetool.image."))).toBe(true);
expect(remaining.some((t) => t.startsWith("nodetool.audio."))).toBe(true);
}

/**
* Drift guard. A path property the editor renders as a native picker is the
* one host-filesystem shape that is visible in metadata, so a node added
* later inside an allowed namespace is caught here rather than in
* production. Nodes taking a plain string path look like any other string
* node and can only be caught by naming them in CLOUD_HOST_FILE_NODES.
*/
function expectNoNativePathPickers(registry: NodeRegistry): void {
const offenders = registry.list().filter((nodeType) =>
(registry.getMetadata(nodeType)?.properties ?? []).some((property) => {
const kind = property.json_schema_extra?.["type"];
return kind === "file_path" || kind === "folder_path";
})
);
expect(offenders).toEqual([]);
}

it("is a no-op when the cloud profile is off", () => {
delete process.env[CLOUD_PROFILE_ENV];
delete process.env[NODE_ENV_VAR];
Expand Down Expand Up @@ -294,4 +323,15 @@ describe("applyCloudNodePolicy", () => {
applyCloudNodePolicy(registry);
expectCuratedSurface(registry);
});

it("leaves no native file/folder picker in the cloud surface", () => {
delete process.env[CLOUD_PROFILE_ENV];
process.env[NODE_ENV_VAR] = "production";
const registry = fullRegistry();
// The pickers exist before the policy runs — otherwise this guard would
// pass for the wrong reason.
expect(registry.list()).toContain("nodetool.input.DocumentFileInput");
applyCloudNodePolicy(registry);
expectNoNativePathPickers(registry);
});
});
Loading