mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a36a906292 | ||
|
|
05a773b426 |
No files matched your search
@@ -450,6 +450,13 @@ new child to an opened parent directory without a path race.
|
||||
`readDirectory()` likewise keeps its stable SDK/RPC method but fails closed
|
||||
unless the main process supplies a native adapter whose inode checks and entry
|
||||
enumeration are bound to the same directory handle.
|
||||
The host advertises `filesystem.directory-enumeration` only when it supplies that
|
||||
adapter. Declare the feature as required, or optional and check
|
||||
`context.enabledFeatures.has(PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE)`.
|
||||
The default host supplies no adapter and does not enable this feature. Both the
|
||||
SDK and direct RPC return `unsupported` before a permission request or path probe.
|
||||
An enabled feature still requires the declared `filesystem.read` directory
|
||||
resource and the normal permission, root, symlink and same-handle checks.
|
||||
|
||||
Permission names already use the phase-3 enforcement boundaries: clipboard
|
||||
read/write, terminal metadata/output/input and input/output interception, Vault
|
||||
|
||||
@@ -207,6 +207,12 @@ class PluginFilesystemBroker {
|
||||
};
|
||||
}
|
||||
validatePath(params) { return assertPathParams(params); }
|
||||
validateDirectory(params) {
|
||||
if (typeof this.openDirectoryHandle !== "function") {
|
||||
throw new PluginRpcError(RPC_ERRORS.unsupported, "Secure plugin directory enumeration is unavailable on this runtime");
|
||||
}
|
||||
return assertPathParams(params);
|
||||
}
|
||||
|
||||
describeReadAuthorization(params, resourceKind = "exact") {
|
||||
const value = assertPathParams(params);
|
||||
@@ -329,17 +335,11 @@ class PluginFilesystemBroker {
|
||||
}
|
||||
|
||||
async readDirectory(params, context) {
|
||||
const value = assertPathParams(params);
|
||||
const value = this.validateDirectory(params);
|
||||
assertAuthorizedPath(context, "filesystem.read", value.path);
|
||||
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
|
||||
assertAuthorizedPath(context, "filesystem.read", canonical);
|
||||
if (!stats.isDirectory()) throw invalidArgument("Plugin filesystem directory target is not a directory");
|
||||
if (!this.openDirectoryHandle) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unsupported,
|
||||
"Secure plugin directory enumeration is unavailable on this runtime",
|
||||
);
|
||||
}
|
||||
const directory = await this.openDirectoryHandle(canonical, { signal: context.signal });
|
||||
if (
|
||||
!directory
|
||||
|
||||
@@ -7158,6 +7158,9 @@
|
||||
"TerminalInputDraftFeature": {
|
||||
"const": "terminal.input-drafts"
|
||||
},
|
||||
"FilesystemDirectoryEnumerationFeature": {
|
||||
"const": "filesystem.directory-enumeration"
|
||||
},
|
||||
"HostTerminalInputProposalRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -36,6 +36,7 @@ const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./ho
|
||||
const { PluginHostVaultService } = require("./hostVaultService.cjs");
|
||||
const { PluginAISourceBinding } = require("./ai/sourceBinding.cjs");
|
||||
const HOST_VAULT_FEATURE = require("./generated/plugin-contract.schema.json").$defs.HostVaultFeature.const;
|
||||
const DIRECTORY_ENUMERATION_FEATURE = require("./generated/plugin-contract.schema.json").$defs.FilesystemDirectoryEnumerationFeature.const;
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const { PluginProtocol } = require("./pluginProtocol.cjs");
|
||||
const { PluginViewHost } = require("./pluginViewHost.cjs");
|
||||
@@ -99,7 +100,9 @@ function createPluginHostService(options) {
|
||||
const paths = createPluginPaths(options.app.getPath("userData"));
|
||||
const appRoot = options.appRoot ?? options.app.getAppPath();
|
||||
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
|
||||
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
|
||||
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])]
|
||||
.filter((feature) => feature !== DIRECTORY_ENUMERATION_FEATURE);
|
||||
if (typeof options.openPluginDirectoryHandle === "function") supportedFeatures.push(DIRECTORY_ENUMERATION_FEATURE);
|
||||
const database = new PluginDatabase(paths.database);
|
||||
let interactionService;
|
||||
let byteStreamService;
|
||||
|
||||
@@ -335,6 +335,8 @@ test("host service wires a handle-bound directory adapter without changing the R
|
||||
},
|
||||
});
|
||||
context.after(() => service.manager.shutdown());
|
||||
assert.ok(service.runtimeSupervisor.supportedFeatures.includes("filesystem.directory-enumeration"));
|
||||
assert.ok(service.packageStore.supportedFeatures.includes("filesystem.directory-enumeration"));
|
||||
const pluginManifest = {
|
||||
id: "com.example.directory",
|
||||
name: "directory",
|
||||
@@ -481,3 +483,27 @@ test("Deny for the privileged development runtime stops its activation through t
|
||||
assert.equal(stops, 1); assert.equal(current, identity); assert.equal(service.database.getActivePlugin(manifest.id).enabled, true);
|
||||
service.database.privateOwner = privateOwner;
|
||||
});
|
||||
|
||||
test("production host composition does not advertise or prompt for unavailable directory enumeration", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-directory-availability-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let prompts = 0;
|
||||
const feature = "filesystem.directory-enumeration";
|
||||
// Same adapter-free composition as main/registerBridges; even an explicit extra
|
||||
// supportedFeatures entry cannot claim an adapter that was never supplied.
|
||||
const service = createPluginHostService({ ...createOptions(root), supportedFeatures: [feature],
|
||||
requestPermissionDecision: async () => { prompts++; throw new Error("must not prompt"); } });
|
||||
context.after(() => service.manager.shutdown());
|
||||
assert.equal(service.runtimeSupervisor.supportedFeatures.includes(feature), false);
|
||||
assert.equal(service.packageStore.supportedFeatures.includes(feature), false);
|
||||
let authorizations = 0, probes = 0;
|
||||
service.permissionEngine.authorize = async () => { authorizations++; throw new Error("must not authorize"); };
|
||||
service.filesystemBroker.fileSystem = new Proxy({}, { get() { probes++; throw new Error("must not probe"); } });
|
||||
const manifest = { id: "com.example.unavailable-directory", version: "1.0.0",
|
||||
permissions: { optional: [{ permission: "filesystem.read", resources: [root] }] } };
|
||||
const routes = service.rpcRegistry.createRoutes({ pluginId: manifest.id, pluginVersion: manifest.version,
|
||||
runtimeId: "runtime-unavailable", runtimeKind: "browser", manifest });
|
||||
await assert.rejects(routes.requestHandlers["filesystem.readDirectory"]({ path: root }, transportContext()),
|
||||
(error) => error.code === -32012 && /unavailable/.test(error.message));
|
||||
assert.equal(authorizations, 0); assert.equal(prompts, 0); assert.equal(probes, 0);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
PLUGIN_ERROR_WIRE_CODES,
|
||||
pluginErrorToRpcError,
|
||||
} from "@netcatty/plugin-sdk";
|
||||
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS } from "@netcatty/plugin-contract";
|
||||
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS, PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE } from "@netcatty/plugin-contract";
|
||||
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
|
||||
import { connectByteStream } from "./byteStreamClient.mjs";
|
||||
|
||||
@@ -412,8 +412,12 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
...options,
|
||||
}).then(() => undefined),
|
||||
stat: (filePath) => client.request("filesystem.stat", { path: filePath }),
|
||||
readDirectory: (directoryPath) => client.request("filesystem.readDirectory", { path: directoryPath })
|
||||
.then((result) => result.entries),
|
||||
readDirectory: async (directoryPath) => {
|
||||
if (!config.enabledFeatures?.includes(PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE)) {
|
||||
throw new PluginError("unsupported", "Secure plugin directory enumeration is unavailable on this runtime");
|
||||
}
|
||||
return (await client.request("filesystem.readDirectory", { path: directoryPath })).entries;
|
||||
},
|
||||
};
|
||||
const companions = {
|
||||
start: async (companionId) => {
|
||||
|
||||
@@ -78,7 +78,7 @@ test("runtime peer exposes secure host capabilities over the canonical RPC trans
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
enabledFeatures: [],
|
||||
enabledFeatures: ["filesystem.directory-enumeration"],
|
||||
},
|
||||
async loadPlugin() {
|
||||
return {
|
||||
@@ -128,7 +128,7 @@ test("runtime peer exposes secure host capabilities over the canonical RPC trans
|
||||
const initialized = await host.request("plugin.initialize", {
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
supportedFeatures: [],
|
||||
supportedFeatures: ["filesystem.directory-enumeration"],
|
||||
});
|
||||
assert.equal(initialized.pluginId, "com.example.peer");
|
||||
const unhandledStream = await host.streams.openOutgoing("unhandled", 1024);
|
||||
@@ -616,3 +616,23 @@ test("runtime peer routes connection and importer Provider operation maps", asyn
|
||||
await peer.dispose();
|
||||
host.close();
|
||||
});
|
||||
|
||||
test("actual SDK directory call rejects an unnegotiated feature without sending host RPC", async (context) => {
|
||||
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
let requests = 0;
|
||||
const host = new PluginRpcRouter({ pluginId: "com.example.no-directory", send: (message) => port1.postMessage(message),
|
||||
handlers: { "filesystem.readDirectory": async () => { requests++; return { entries: [] }; }, "log.write": async () => null } });
|
||||
port1.on("message", (message) => host.accept(message));
|
||||
const peer = await startPluginRuntime({ port: port2,
|
||||
config: { pluginId: "com.example.no-directory", pluginVersion: "1.0.0", netcattyVersion: "1.0.0", apiVersion: "0.1.0-internal", enabledFeatures: [] },
|
||||
async loadPlugin() { return { default: { async activate(pluginContext) {
|
||||
assert.equal(pluginContext.enabledFeatures.has("filesystem.directory-enumeration"), false);
|
||||
await assert.rejects(pluginContext.filesystem.readDirectory("/not-probed"),
|
||||
(error) => error.code === "unsupported" && /unavailable/.test(error.message));
|
||||
} } }; } });
|
||||
context.after(async () => { await peer.dispose(); host.close(); port1.close(); port2.close(); });
|
||||
await host.request("plugin.initialize", { netcattyVersion: "1.0.0", apiVersion: "0.1.0-internal", supportedFeatures: [] });
|
||||
await host.request("plugin.activate", {});
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
@@ -127,7 +127,7 @@ function registerSecurePluginCapabilities(registry, options) {
|
||||
options.filesystemBroker.readDirectory(params, context)
|
||||
), {
|
||||
metadata: { capability: "filesystem", mutating: false, permission: "filesystem.read" },
|
||||
validateParams: (params) => options.filesystemBroker.validatePath(params),
|
||||
validateParams: (params) => options.filesystemBroker.validateDirectory(params),
|
||||
authorization: (params) => options.filesystemBroker.describeReadAuthorization(params, "directory"),
|
||||
});
|
||||
registry.registerRequest("filesystem.writeFile", (params, context) => (
|
||||
|
||||
@@ -4976,6 +4976,9 @@
|
||||
"TerminalInputDraftFeature": {
|
||||
"const": "terminal.input-drafts"
|
||||
},
|
||||
"FilesystemDirectoryEnumerationFeature": {
|
||||
"const": "filesystem.directory-enumeration"
|
||||
},
|
||||
"HostTerminalInputProposalRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -194,6 +194,8 @@ export type CredentialRef = {
|
||||
|
||||
export type FeatureId = string;
|
||||
|
||||
export type FilesystemDirectoryEnumerationFeature = "filesystem.directory-enumeration";
|
||||
|
||||
export type HostClipboardReadRequest = {
|
||||
kind: "clipboard.read";
|
||||
title: string;
|
||||
|
||||
@@ -69,3 +69,4 @@ export const PLUGIN_THEME_PACK_FEATURE = "appearance.theme-packs" as const satis
|
||||
export const PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE = "terminal.selection-sharing" as const satisfies import("./generated/plugin-contract.js").TerminalSelectionSharingFeature;
|
||||
|
||||
export const PLUGIN_TERMINAL_INPUT_DRAFT_FEATURE = "terminal.input-drafts" as const satisfies import("./generated/plugin-contract.js").TerminalInputDraftFeature;
|
||||
export const PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE = "filesystem.directory-enumeration" as const satisfies import("./generated/plugin-contract.js").FilesystemDirectoryEnumerationFeature;
|
||||
@@ -711,6 +711,7 @@ export interface PluginFilesystemClient {
|
||||
overwrite: true;
|
||||
}>): Promise<void>;
|
||||
stat(path: string): Promise<PluginFilesystemStat>;
|
||||
/** Requires filesystem.directory-enumeration and an authorized filesystem.read directory. */
|
||||
readDirectory(path: string): Promise<readonly PluginFilesystemEntry[]>;
|
||||
}
|
||||
|
||||
@@ -981,3 +982,4 @@ export { PLUGIN_THEME_PACK_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export { PLUGIN_TERMINAL_INPUT_DRAFT_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE } from "@netcatty/plugin-contract";
|
||||
Reference in new issue
Block a user