Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
763
extensions/file-transfer/src/shared/node-invoke-policy.test.ts
Normal file
763
extensions/file-transfer/src/shared/node-invoke-policy.test.ts
Normal file
@@ -0,0 +1,763 @@
|
||||
// File Transfer tests cover node invoke policy plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import type { OpenClawPluginNodeInvokePolicyContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFileTransferNodeInvokePolicy } from "./node-invoke-policy.js";
|
||||
|
||||
vi.mock("./audit.js", () => ({
|
||||
appendFileTransferAudit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./policy.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./policy.js")>();
|
||||
return {
|
||||
...actual,
|
||||
persistAllowAlways: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
const tmpRoots: string[] = [];
|
||||
const testUnlessWindows = process.platform === "win32" ? it.skip : it;
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tmpRoots.map((tmpRoot) => fs.rm(tmpRoot, { recursive: true, force: true })));
|
||||
tmpRoots.length = 0;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./audit.js");
|
||||
vi.doUnmock("./policy.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
function tarEntries(entries: Record<string, string>): string {
|
||||
const blocks: Buffer[] = [];
|
||||
for (const [relPath, contents] of Object.entries(entries)) {
|
||||
const payload = Buffer.from(contents);
|
||||
blocks.push(createTarFileHeader(relPath, payload.byteLength), payload);
|
||||
const padding = (512 - (payload.byteLength % 512)) % 512;
|
||||
if (padding > 0) {
|
||||
blocks.push(Buffer.alloc(padding));
|
||||
}
|
||||
}
|
||||
blocks.push(Buffer.alloc(1024));
|
||||
return gzipSync(Buffer.concat(blocks)).toString("base64");
|
||||
}
|
||||
|
||||
function writeTarString(header: Buffer, offset: number, length: number, value: string): void {
|
||||
header.write(value.slice(0, length), offset, length, "utf8");
|
||||
}
|
||||
|
||||
function writeTarOctal(header: Buffer, offset: number, length: number, value: number): void {
|
||||
const text = value.toString(8).padStart(length - 1, "0");
|
||||
header.write(`${text}\0`.slice(-length), offset, length, "ascii");
|
||||
}
|
||||
|
||||
function createTarFileHeader(name: string, size: number): Buffer {
|
||||
const header = Buffer.alloc(512);
|
||||
writeTarString(header, 0, 100, name);
|
||||
writeTarOctal(header, 100, 8, 0o644);
|
||||
writeTarOctal(header, 108, 8, 0);
|
||||
writeTarOctal(header, 116, 8, 0);
|
||||
writeTarOctal(header, 124, 12, size);
|
||||
writeTarOctal(header, 136, 12, 0);
|
||||
header.fill(" ", 148, 156);
|
||||
header.write("0", 156, 1, "ascii");
|
||||
header.write("ustar\0", 257, 6, "ascii");
|
||||
header.write("00", 263, 2, "ascii");
|
||||
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
||||
header.write(checksum.toString(8).padStart(6, "0"), 148, 6, "ascii");
|
||||
header[154] = 0;
|
||||
header[155] = 0x20;
|
||||
return header;
|
||||
}
|
||||
|
||||
function createCtx(overrides: {
|
||||
command?: string;
|
||||
params?: Record<string, unknown>;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
approvals?: OpenClawPluginNodeInvokePolicyContext["approvals"];
|
||||
}) {
|
||||
const invokeNode = vi.fn<OpenClawPluginNodeInvokePolicyContext["invokeNode"]>(
|
||||
async ({
|
||||
params,
|
||||
}: Parameters<OpenClawPluginNodeInvokePolicyContext["invokeNode"]>[0] = {}) => ({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path:
|
||||
typeof (params as { path?: unknown } | undefined)?.path === "string"
|
||||
? (params as { path: string }).path
|
||||
: "/tmp/file.txt",
|
||||
size: 1,
|
||||
sha256: "a".repeat(64),
|
||||
},
|
||||
}),
|
||||
);
|
||||
return {
|
||||
ctx: {
|
||||
nodeId: "node-1",
|
||||
command: overrides.command ?? "file.fetch",
|
||||
params: overrides.params ?? { path: "/tmp/file.txt", maxBytes: 1024 },
|
||||
config: {},
|
||||
pluginConfig: overrides.pluginConfig ?? {
|
||||
nodes: {
|
||||
"node-1": {
|
||||
allowReadPaths: ["/tmp/**"],
|
||||
allowWritePaths: ["/tmp/**"],
|
||||
maxBytes: 512,
|
||||
},
|
||||
},
|
||||
},
|
||||
node: { nodeId: "node-1", displayName: "Node One" },
|
||||
...(overrides.approvals ? { approvals: overrides.approvals } : {}),
|
||||
invokeNode,
|
||||
},
|
||||
invokeNode,
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
throw new Error(`${label} was not an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function expectRecordFields(record: Record<string, unknown>, fields: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
expect(record[key]).toEqual(value);
|
||||
}
|
||||
}
|
||||
|
||||
function expectResultFields(result: unknown, fields: Record<string, unknown>) {
|
||||
expectRecordFields(requireRecord(result, "policy result"), fields);
|
||||
}
|
||||
|
||||
function requireInvokeParams(
|
||||
invokeNode: ReturnType<typeof vi.fn<OpenClawPluginNodeInvokePolicyContext["invokeNode"]>>,
|
||||
callIndex: number,
|
||||
) {
|
||||
const call = (invokeNode.mock.calls as unknown[][])[callIndex]?.[0];
|
||||
const request = requireRecord(call, `invoke call ${callIndex + 1}`);
|
||||
return requireRecord(request.params, `invoke call ${callIndex + 1} params`);
|
||||
}
|
||||
|
||||
describe("file-transfer node invoke policy", () => {
|
||||
it("injects policy-owned limits before invoking the node", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
command: "file.fetch",
|
||||
params: { path: "/tmp/file.txt", maxBytes: 4096, followSymlinks: true },
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(invokeNode).toHaveBeenNthCalledWith(1, {
|
||||
params: {
|
||||
path: "/tmp/file.txt",
|
||||
maxBytes: 512,
|
||||
followSymlinks: false,
|
||||
preflightOnly: true,
|
||||
},
|
||||
});
|
||||
expect(invokeNode).toHaveBeenNthCalledWith(2, {
|
||||
params: {
|
||||
path: "/tmp/file.txt",
|
||||
maxBytes: 512,
|
||||
followSymlinks: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes string maxBytes before invoking the node", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
params: { path: "/tmp/file.txt", maxBytes: "1024" },
|
||||
pluginConfig: {
|
||||
nodes: {
|
||||
"node-1": {
|
||||
allowReadPaths: ["/tmp/**"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(invokeNode).toHaveBeenNthCalledWith(1, {
|
||||
params: {
|
||||
path: "/tmp/file.txt",
|
||||
maxBytes: 1024,
|
||||
followSymlinks: false,
|
||||
preflightOnly: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed maxBytes before invoking the node", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
params: { path: "/tmp/file.txt", maxBytes: "1024.5" },
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, {
|
||||
ok: false,
|
||||
code: "INVALID_PARAMS",
|
||||
message: "maxBytes must be a positive integer",
|
||||
});
|
||||
expect(invokeNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed maxBytes before requesting approval", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const approvals = {
|
||||
request: vi.fn(async () => ({ id: "approval-1", decision: "allow-always" as const })),
|
||||
};
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
params: { path: "/tmp/new.txt", maxBytes: "1024.5" },
|
||||
pluginConfig: {
|
||||
nodes: {
|
||||
"node-1": {
|
||||
ask: "on-miss",
|
||||
allowReadPaths: ["/allowed/**"],
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals,
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, {
|
||||
ok: false,
|
||||
code: "INVALID_PARAMS",
|
||||
message: "maxBytes must be a positive integer",
|
||||
});
|
||||
expect(approvals.request).not.toHaveBeenCalled();
|
||||
expect(invokeNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies raw node.invoke before the node when plugin policy is missing", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const { ctx, invokeNode } = createCtx({ pluginConfig: {} });
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, { ok: false, code: "NO_POLICY" });
|
||||
expect(invokeNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses plugin approvals for ask-on-miss before invoking the node", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const approvals = {
|
||||
request: vi.fn(async () => ({ id: "approval-1", decision: "allow-once" as const })),
|
||||
};
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
params: { path: "/tmp/new.txt" },
|
||||
pluginConfig: {
|
||||
nodes: {
|
||||
"node-1": {
|
||||
ask: "on-miss",
|
||||
allowReadPaths: ["/allowed/**"],
|
||||
maxBytes: 256,
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals,
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const approvalCalls = approvals.request.mock.calls as unknown[][];
|
||||
const approvalRequest = requireRecord(approvalCalls[0]?.[0], "approval request");
|
||||
expectRecordFields(approvalRequest, {
|
||||
title: "Read file: /tmp/new.txt",
|
||||
severity: "info",
|
||||
toolName: "file.fetch",
|
||||
});
|
||||
expect(invokeNode).toHaveBeenNthCalledWith(1, {
|
||||
params: {
|
||||
path: "/tmp/new.txt",
|
||||
followSymlinks: false,
|
||||
maxBytes: 256,
|
||||
preflightOnly: true,
|
||||
},
|
||||
});
|
||||
expect(invokeNode).toHaveBeenNthCalledWith(2, {
|
||||
params: {
|
||||
path: "/tmp/new.txt",
|
||||
followSymlinks: false,
|
||||
maxBytes: 256,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("marks node transport failures as unavailable", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
params: { path: "/tmp/file.txt" },
|
||||
});
|
||||
invokeNode.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
code: "TIMEOUT",
|
||||
message: "node timed out",
|
||||
details: { nodeError: { code: "TIMEOUT" } },
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, {
|
||||
ok: false,
|
||||
code: "TIMEOUT",
|
||||
unavailable: true,
|
||||
details: { nodeError: { code: "TIMEOUT" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("checks file.fetch canonical policy before requesting bytes", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
params: { path: "/tmp/link.txt" },
|
||||
});
|
||||
invokeNode.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/etc/passwd",
|
||||
size: 1,
|
||||
sha256: "a".repeat(64),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, { ok: false, code: "SYMLINK_TARGET_DENIED" });
|
||||
expect(invokeNode).toHaveBeenCalledTimes(1);
|
||||
expectRecordFields(requireInvokeParams(invokeNode, 0), {
|
||||
path: "/tmp/link.txt",
|
||||
followSymlinks: false,
|
||||
preflightOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("continues file.fetch after preflight without forwarding caller preflightOnly", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
params: { path: "/tmp/file.txt", preflightOnly: true },
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, { ok: true });
|
||||
expect(invokeNode).toHaveBeenCalledTimes(2);
|
||||
expectRecordFields(requireInvokeParams(invokeNode, 0), {
|
||||
path: "/tmp/file.txt",
|
||||
preflightOnly: true,
|
||||
});
|
||||
expect(requireInvokeParams(invokeNode, 1).preflightOnly).toBeUndefined();
|
||||
});
|
||||
|
||||
it("checks file.write canonical policy before the mutating node call", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
command: "file.write",
|
||||
params: {
|
||||
path: "/tmp/link/out.txt",
|
||||
contentBase64: Buffer.from("payload").toString("base64"),
|
||||
createParents: true,
|
||||
},
|
||||
pluginConfig: {
|
||||
nodes: {
|
||||
"node-1": {
|
||||
allowWritePaths: ["/tmp/**"],
|
||||
followSymlinks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
invokeNode.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/etc/out.txt",
|
||||
size: 7,
|
||||
sha256: "b".repeat(64),
|
||||
overwritten: false,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, { ok: false, code: "SYMLINK_TARGET_DENIED" });
|
||||
expect(invokeNode).toHaveBeenCalledTimes(1);
|
||||
expectRecordFields(requireInvokeParams(invokeNode, 0), {
|
||||
path: "/tmp/link/out.txt",
|
||||
followSymlinks: true,
|
||||
preflightOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("continues file.write after preflight without forwarding caller preflightOnly", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
command: "file.write",
|
||||
params: {
|
||||
path: "/tmp/link/out.txt",
|
||||
contentBase64: Buffer.from("payload").toString("base64"),
|
||||
createParents: true,
|
||||
preflightOnly: true,
|
||||
},
|
||||
pluginConfig: {
|
||||
nodes: {
|
||||
"node-1": {
|
||||
allowWritePaths: ["/tmp/**", "/private/tmp/**"],
|
||||
followSymlinks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
invokeNode
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/private/tmp/out.txt",
|
||||
size: 7,
|
||||
sha256: "b".repeat(64),
|
||||
overwritten: false,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/private/tmp/out.txt",
|
||||
size: 7,
|
||||
sha256: "b".repeat(64),
|
||||
overwritten: false,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, { ok: true });
|
||||
expect(invokeNode).toHaveBeenCalledTimes(2);
|
||||
expect(requireInvokeParams(invokeNode, 0).preflightOnly).toBe(true);
|
||||
expect(requireInvokeParams(invokeNode, 1).preflightOnly).toBeUndefined();
|
||||
});
|
||||
|
||||
it("checks every dir.fetch preflight entry before requesting the archive", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
command: "dir.fetch",
|
||||
params: { path: "/home/me" },
|
||||
pluginConfig: {
|
||||
nodes: {
|
||||
"node-1": {
|
||||
allowReadPaths: ["/home/me", "/home/me/**"],
|
||||
denyPaths: ["**/.ssh/**"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
invokeNode.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/home/me",
|
||||
entries: ["ok.txt", ".ssh/id_rsa"],
|
||||
fileCount: 2,
|
||||
preflightOnly: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, { ok: false, code: "PATH_POLICY_DENIED" });
|
||||
expect(
|
||||
requireRecord(requireRecord(result, "policy result").details, "result details").path,
|
||||
).toBe("/home/me/.ssh/id_rsa");
|
||||
expect(invokeNode).toHaveBeenCalledTimes(1);
|
||||
expectRecordFields(requireInvokeParams(invokeNode, 0), {
|
||||
path: "/home/me",
|
||||
preflightOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects dir.fetch preflight responses without an entry list", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
command: "dir.fetch",
|
||||
params: { path: "/home/me" },
|
||||
pluginConfig: {
|
||||
nodes: {
|
||||
"node-1": {
|
||||
allowReadPaths: ["/home/me", "/home/me/**"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
invokeNode.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/home/me",
|
||||
fileCount: 2,
|
||||
preflightOnly: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, { ok: false, code: "PREFLIGHT_ENTRIES_MISSING" });
|
||||
expect(invokeNode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects invalid dir.fetch preflight entries before requesting the archive", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
command: "dir.fetch",
|
||||
params: { path: "/home/me" },
|
||||
pluginConfig: {
|
||||
nodes: {
|
||||
"node-1": {
|
||||
allowReadPaths: ["/home/me", "/home/me/**"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
invokeNode.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/home/me",
|
||||
entries: ["ok.txt", "/etc/passwd"],
|
||||
fileCount: 2,
|
||||
preflightOnly: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, { ok: false, code: "PREFLIGHT_ENTRY_INVALID" });
|
||||
expect(invokeNode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects oversized dir.fetch preflight entry lists before requesting the archive", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const entries = Array.from({ length: 5001 }, (_, index) => `file-${index}.txt`);
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
command: "dir.fetch",
|
||||
params: { path: "/home/me" },
|
||||
pluginConfig: {
|
||||
nodes: {
|
||||
"node-1": {
|
||||
allowReadPaths: ["/home/me", "/home/me/**"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
invokeNode.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/home/me",
|
||||
entries,
|
||||
fileCount: entries.length,
|
||||
preflightOnly: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, { ok: false, code: "PREFLIGHT_ENTRIES_TOO_MANY" });
|
||||
expect(invokeNode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
testUnlessWindows(
|
||||
"continues dir.fetch after preflight without forwarding caller preflightOnly",
|
||||
async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const tarBase64 = tarEntries({
|
||||
"a.txt": "a",
|
||||
"sub/b.txt": "b",
|
||||
});
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
command: "dir.fetch",
|
||||
params: { path: "/tmp/project", preflightOnly: true },
|
||||
});
|
||||
invokeNode
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/tmp/project",
|
||||
entries: ["a.txt", "sub/b.txt"],
|
||||
fileCount: 2,
|
||||
preflightOnly: true,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/tmp/project",
|
||||
tarBase64,
|
||||
tarBytes: 7,
|
||||
sha256: "c".repeat(64),
|
||||
fileCount: 2,
|
||||
entries: ["a.txt", "sub/b.txt"],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, { ok: true });
|
||||
expect(invokeNode).toHaveBeenCalledTimes(2);
|
||||
expectRecordFields(requireInvokeParams(invokeNode, 0), {
|
||||
path: "/tmp/project",
|
||||
preflightOnly: true,
|
||||
});
|
||||
expect(requireInvokeParams(invokeNode, 1).preflightOnly).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
testUnlessWindows(
|
||||
"checks final dir.fetch archive entries before returning the archive",
|
||||
async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const tarBase64 = tarEntries({
|
||||
"ok.txt": "ok",
|
||||
".ssh/id_rsa": "secret",
|
||||
});
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
command: "dir.fetch",
|
||||
params: { path: "/home/me" },
|
||||
pluginConfig: {
|
||||
nodes: {
|
||||
"node-1": {
|
||||
allowReadPaths: ["/home/me", "/home/me/**"],
|
||||
denyPaths: ["**/.ssh/**"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
invokeNode
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/home/me",
|
||||
entries: ["ok.txt"],
|
||||
fileCount: 1,
|
||||
preflightOnly: true,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/home/me",
|
||||
tarBase64,
|
||||
tarBytes: 7,
|
||||
sha256: "c".repeat(64),
|
||||
fileCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, { ok: false, code: "PATH_POLICY_DENIED" });
|
||||
expect(
|
||||
requireRecord(requireRecord(result, "policy result").details, "result details").path,
|
||||
).toBe("/home/me/.ssh/id_rsa");
|
||||
expect(invokeNode).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
testUnlessWindows("rejects oversized final dir.fetch archive entry lists", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const tarBase64 = tarEntries(
|
||||
Object.fromEntries(Array.from({ length: 5001 }, (_, index) => [`file-${index}.txt`, "x"])),
|
||||
);
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
command: "dir.fetch",
|
||||
params: { path: "/tmp/project" },
|
||||
pluginConfig: {
|
||||
nodes: {
|
||||
"node-1": {
|
||||
allowReadPaths: ["/tmp/project", "/tmp/project/**"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
invokeNode
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/tmp/project",
|
||||
entries: ["file-0.txt"],
|
||||
fileCount: 1,
|
||||
preflightOnly: true,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/tmp/project",
|
||||
tarBase64,
|
||||
tarBytes: 7,
|
||||
sha256: "c".repeat(64),
|
||||
fileCount: 5001,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, { ok: false, code: "ARCHIVE_ENTRIES_TOO_MANY" });
|
||||
expect(invokeNode).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects final dir.fetch archive responses without readable archive entries", async () => {
|
||||
const policy = createFileTransferNodeInvokePolicy();
|
||||
const { ctx, invokeNode } = createCtx({
|
||||
command: "dir.fetch",
|
||||
params: { path: "/tmp/project" },
|
||||
});
|
||||
invokeNode
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/tmp/project",
|
||||
entries: ["a.txt"],
|
||||
fileCount: 1,
|
||||
preflightOnly: true,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
ok: true,
|
||||
path: "/tmp/project",
|
||||
tarBytes: 7,
|
||||
sha256: "c".repeat(64),
|
||||
fileCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await policy.handle(ctx);
|
||||
|
||||
expectResultFields(result, { ok: false, code: "ARCHIVE_ENTRIES_MISSING" });
|
||||
expect(invokeNode).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user