Skip to content

Commit b79c141

Browse files
fix(cli): bound exec approvals --file JSON read size (#110755)
* fix(cli): bound exec approvals --file JSON read size Replace raw fs.readFile with the shared readRegularFile helper from @openclaw/fs-safe/advanced, which enforces regular-file validation and a max-bytes limit. The --stdin path already had a 1 MB bound via readStdin; --file now uses the same EXEC_APPROVALS_STDIN_MAX_BYTES limit. * test(cli): add --file read bounds regression tests Covers normal (under limit), oversized (> 1 MiB), and non-regular path (directory) --file inputs to the approvals set command. * fix: import readRegularFile from ../infra/fs-safe.js for boundary compliance * fix(cli): preserve approvals file path behavior --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 28ce6b8 commit b79c141

2 files changed

Lines changed: 104 additions & 1 deletion

File tree

src/cli/exec-approvals-cli.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,24 @@ describe("exec approvals CLI", () => {
233233
await program.parseAsync(args, { from: "user" });
234234
};
235235

236+
const runNativeApprovalsFileCommand = async (filePath: string) => {
237+
callGatewayFromCli.mockResolvedValue({
238+
enabled: true,
239+
hash: "sha256:current",
240+
defaultAction: "deny",
241+
rules: [],
242+
} as never);
243+
await runApprovalsCommand([
244+
"approvals",
245+
"set",
246+
"--node",
247+
"windows",
248+
"--file",
249+
filePath,
250+
"--json",
251+
]);
252+
};
253+
236254
beforeEach(() => {
237255
resetLocalSnapshot();
238256
runtimeErrors.length = 0;
@@ -857,4 +875,75 @@ describe("exec approvals CLI", () => {
857875
"Exec approvals stdin exceeds 5 bytes.",
858876
);
859877
});
878+
879+
it("reads approvals JSON from a regular file", async () => {
880+
const dir = tempDirs.make("openclaw-approvals-file-bound-");
881+
const filePath = path.join(dir, "approvals.json");
882+
fs.writeFileSync(filePath, JSON.stringify({ defaultAction: "deny", rules: [] }));
883+
884+
await runNativeApprovalsFileCommand(filePath);
885+
886+
expect(callGatewayFromCli.mock.calls.map(([method]) => method)).toEqual([
887+
"exec.approvals.node.get",
888+
"exec.approvals.node.set",
889+
"exec.approvals.node.get",
890+
]);
891+
expect(runtimeErrors).toHaveLength(0);
892+
});
893+
894+
it("rejects an oversized approvals file", async () => {
895+
const dir = tempDirs.make("openclaw-approvals-file-bound-");
896+
const filePath = path.join(dir, "oversized.json");
897+
fs.writeFileSync(filePath, Buffer.alloc(1024 * 1024 + 1, "x"));
898+
899+
await expect(runNativeApprovalsFileCommand(filePath)).rejects.toThrow("__exit__:1");
900+
901+
expect(runtimeErrors[0]).toContain("File exceeds 1048576 bytes");
902+
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
903+
});
904+
905+
it("preserves the directory read error", async () => {
906+
const dir = tempDirs.make("openclaw-approvals-file-directory-");
907+
908+
await expect(runNativeApprovalsFileCommand(dir)).rejects.toThrow("__exit__:1");
909+
910+
expect(runtimeErrors[0]).toMatch(/EISDIR|directory/i);
911+
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
912+
});
913+
914+
it("follows a symlinked approvals file", async () => {
915+
const dir = tempDirs.make("openclaw-approvals-file-symlink-");
916+
const targetPath = path.join(dir, "target.json");
917+
const symlinkPath = path.join(dir, "approvals.json");
918+
fs.writeFileSync(targetPath, JSON.stringify({ defaultAction: "deny", rules: [] }));
919+
fs.symlinkSync(targetPath, symlinkPath);
920+
921+
await runNativeApprovalsFileCommand(symlinkPath);
922+
923+
expect(callGatewayFromCli.mock.calls.map(([method]) => method)).toContain(
924+
"exec.approvals.node.set",
925+
);
926+
expect(runtimeErrors).toHaveLength(0);
927+
});
928+
929+
it("rejects a file that grows past the limit after opening", async () => {
930+
const dir = tempDirs.make("openclaw-approvals-file-growth-");
931+
const filePath = path.join(dir, "growing.json");
932+
fs.writeFileSync(filePath, Buffer.alloc(1024 * 1024, "x"));
933+
const open = fs.promises.open.bind(fs.promises);
934+
const openSpy = vi.spyOn(fs.promises, "open").mockImplementation(async (...args) => {
935+
const handle = await open(...args);
936+
fs.appendFileSync(filePath, "x");
937+
return handle;
938+
});
939+
940+
try {
941+
await expect(runNativeApprovalsFileCommand(filePath)).rejects.toThrow("__exit__:1");
942+
} finally {
943+
openSpy.mockRestore();
944+
}
945+
946+
expect(runtimeErrors[0]).toContain("File exceeds 1048576 bytes");
947+
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
948+
});
860949
});

src/cli/exec-approvals-cli.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
type ExecApprovalsDefaults,
2727
type ExecApprovalsFile,
2828
} from "../infra/exec-approvals.js";
29+
import { readFileDescriptorBounded } from "../infra/file-descriptor-read.js";
2930
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
3031
import { defaultRuntime } from "../runtime.js";
3132
import { callGatewayFromCli } from "./gateway-rpc.js";
@@ -99,6 +100,19 @@ async function readStdin(
99100
return bytes.toString("utf8");
100101
}
101102

103+
async function readApprovalsFile(filePath: string): Promise<string> {
104+
// Explicit CLI file inputs have historically followed symlinks and readable
105+
// special files. Pin that opened target while bounding the bytes consumed.
106+
const handle = await fs.open(filePath, "r");
107+
try {
108+
return (await readFileDescriptorBounded(handle.fd, EXEC_APPROVALS_STDIN_MAX_BYTES)).toString(
109+
"utf8",
110+
);
111+
} finally {
112+
await handle.close();
113+
}
114+
}
115+
102116
async function resolveTargetNodeId(opts: ExecApprovalsCliOpts): Promise<string | null> {
103117
if (opts.gateway) {
104118
return null;
@@ -799,7 +813,7 @@ export function registerExecApprovalsCli(program: Command) {
799813
}
800814
const { source, nodeId, targetLabel, baseHash, kind } =
801815
await loadWritableSnapshotTarget(opts);
802-
const raw = opts.stdin ? await readStdin() : await fs.readFile(String(opts.file), "utf8");
816+
const raw = opts.stdin ? await readStdin() : await readApprovalsFile(String(opts.file));
803817
let input: unknown;
804818
try {
805819
input = JSON5.parse(raw);

0 commit comments

Comments
 (0)