Bound read-only shell diagnostics
This commit is contained in:
1 parent
88643de738
commit
6876e495d6
7 files changed
+39
-13
No files matched your search
+9
-5
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync }
|
||||
|
||||
// ── Konstanten ──────────────────────────────────────────────────────────
|
||||
export const MUA_SERVER_NAME = "mua";
|
||||
export const MUA_VERSION = "2026.08.24.r022";
|
||||
export const MUA_VERSION = "2026.08.24.r023";
|
||||
export const MUA_PROTOCOL_VERSION = "2025-03-26";
|
||||
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
|
||||
export const STATUS_HELPER = "/usr/local/bin/unraid-mcp-status-helper.php";
|
||||
@@ -233,6 +233,7 @@ export async function runReadOnlyCommand(
|
||||
program: string,
|
||||
args: string[],
|
||||
timeoutSec = 30,
|
||||
maxOutputChars = 12_000,
|
||||
): Promise<string> {
|
||||
if (!READ_ONLY_PROGRAMS.has(program)) {
|
||||
throw new Error(`Program is not allowed in read-only mode: ${program}`);
|
||||
@@ -240,6 +241,9 @@ export async function runReadOnlyCommand(
|
||||
if (args.length > 64 || args.some((arg) => typeof arg !== "string" || arg.length > 4096)) {
|
||||
throw new Error("Invalid or excessive arguments");
|
||||
}
|
||||
if (!Number.isInteger(maxOutputChars) || maxOutputChars < 1_000 || maxOutputChars > 30_000) {
|
||||
throw new Error("maxOutputChars must be between 1000 and 30000");
|
||||
}
|
||||
|
||||
const lowered = args.map((arg) => arg.toLowerCase());
|
||||
const reject = (message: string) => { throw new Error(message); };
|
||||
@@ -286,15 +290,15 @@ export async function runReadOnlyCommand(
|
||||
const proc = spawn([program, ...args], { stdout: "pipe", stderr: "pipe", cwd: "/" });
|
||||
const timeout = setTimeout(() => proc.kill(), timeoutSec * 1000);
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
readStreamLimited(proc.stdout, 100_000),
|
||||
readStreamLimited(proc.stderr, 20_000),
|
||||
readStreamLimited(proc.stdout, maxOutputChars),
|
||||
readStreamLimited(proc.stderr, Math.min(8_000, maxOutputChars)),
|
||||
proc.exited,
|
||||
]);
|
||||
clearTimeout(timeout);
|
||||
return JSON.stringify({
|
||||
exit_code: code,
|
||||
stdout: sanitizeLogOutput(stdout.text.trim(), 100_000),
|
||||
stderr: sanitizeLogOutput(stderr.text.trim(), 20_000),
|
||||
stdout: sanitizeLogOutput(stdout.text.trim(), maxOutputChars),
|
||||
stderr: sanitizeLogOutput(stderr.text.trim(), Math.min(8_000, maxOutputChars)),
|
||||
truncated: stdout.truncated || stderr.truncated,
|
||||
mode: "read-only",
|
||||
});
|
||||
|
||||
@@ -94,6 +94,19 @@ describe("read-only shell", () => {
|
||||
await expect(runReadOnlyCommand("find", ["/tmp", "-delete"], 5)).rejects.toThrow();
|
||||
await expect(runReadOnlyCommand("ss", ["-K", "dst", "127.0.0.1"], 5)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("bounds read-only output server-side and reports truncation", async () => {
|
||||
const result = JSON.parse(
|
||||
await runReadOnlyCommand("cat", ["/dev/zero"], 1, 1000),
|
||||
);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.stdout.length).toBeLessThanOrEqual(1000);
|
||||
});
|
||||
|
||||
test("rejects excessive read-only output budgets", async () => {
|
||||
await expect(runReadOnlyCommand("ls", ["/"], 5, 999)).rejects.toThrow();
|
||||
await expect(runReadOnlyCommand("ls", ["/"], 5, 30001)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Community Applications approval", () => {
|
||||
|
||||
+7
-2
@@ -577,7 +577,7 @@ export const TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "unraid_system_shell_readonly",
|
||||
description:
|
||||
"Run a strictly allowlisted read-only command without a shell interpreter. Supports diagnostics such as ls, tail, head, cat, grep, stat, find, ps, df, du, ss and read-only ip show/list operations. Pipes, redirects, command chaining and mutating options are impossible or rejected.",
|
||||
"Run one strictly allowlisted read-only command without a shell interpreter. Use it as a bounded diagnostic fallback, not for broad inventories: start from a concrete notification or failing component, locate the newest exact artifact, then use grep/tail/stat on that file. Prefer tail/grep over cat, and targeted paths over recursive find or large ls output. Never read a complete configuration or directory tree unless the user explicitly needs it. Output defaults to 12000 characters and is always server-side bounded. Pipes, redirects, command chaining and mutating options are impossible or rejected.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -598,6 +598,7 @@ export const TOOLS: ToolDef[] = [
|
||||
description: "Argument vector; passed directly without /bin/sh",
|
||||
},
|
||||
timeout_seconds: int("Timeout in seconds (1-120, default 30)"),
|
||||
max_output_chars: int("Maximum combined diagnostic output retained server-side (1000-30000, default 12000). Keep the default or lower it for routine diagnosis; raise it only when the user explicitly needs a larger bounded result."),
|
||||
},
|
||||
required: ["program"],
|
||||
additionalProperties: false,
|
||||
@@ -612,7 +613,11 @@ export const TOOLS: ToolDef[] = [
|
||||
if (timeout < 1 || timeout > 120) {
|
||||
throw new Error("timeout_seconds must be between 1 and 120");
|
||||
}
|
||||
return runReadOnlyCommand(program, rawArgs as string[], timeout);
|
||||
const maxOutputChars = Number(a["max_output_chars"] ?? 12_000);
|
||||
if (!Number.isInteger(maxOutputChars) || maxOutputChars < 1_000 || maxOutputChars > 30_000) {
|
||||
throw new Error("max_output_chars must be between 1000 and 30000");
|
||||
}
|
||||
return runReadOnlyCommand(program, rawArgs as string[], timeout, maxOutputChars);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user