diff --git a/dist/mua-2026.08.24.r023-x86_64-1.txz b/dist/mua-2026.08.24.r023-x86_64-1.txz new file mode 100644 index 0000000..c5d7396 Binary files /dev/null and b/dist/mua-2026.08.24.r023-x86_64-1.txz differ diff --git a/package.json b/package.json index 3e5f71f..f88cec8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mua", - "version": "2026.08.24.r022", + "version": "2026.08.24.r023", "description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid", "type": "module", "main": "src/index.ts", diff --git a/plugin/mua.plg b/plugin/mua.plg index e1c3de7..a1cbe48 100644 --- a/plugin/mua.plg +++ b/plugin/mua.plg @@ -2,13 +2,13 @@ - + - - + + ]> +### 2026.08.24.r023 +- Begrenzt die Ausgabe der Nur-Lese-Shell bereits serverseitig auf standardmäßig 12.000 Zeichen und erlaubt ein explizites Limit von 1.000 bis 30.000 Zeichen. +- Präzisiert die Werkzeugbeschreibung für eine zielgerichtete Diagnosekette statt breiter Konfigurations- und Verzeichnisabfragen. + ### 2026.08.24.r022 - Allgemeine asynchrone Root-Jobs mit getrennten Werkzeugen für Start, kompakten Status und Aufräumen verhindern HTTP-Timeouts bei langen autorisierten Arbeiten. - Bestehende Freigaben der kritischen Root-Shell erhalten dieselbe Berechtigungsklasse automatisch; Nur-Lese-Profile bleiben unverändert. @@ -145,7 +149,7 @@ Das .txz enthält: install/doinst.sh (läuft nach Installation) =========================================== --> - + &txzURL; &txzSHA256; diff --git a/plugin/plugin.json b/plugin/plugin.json index 277eb19..a2a5e50 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "mua", "author": "Michael", - "version": "2026.08.24.r020", + "version": "2026.08.24.r023", "minver": "7.0.0", "pluginDirectory": "/usr/local/emhttp/plugins/mua", "configDirectory": "/boot/config/plugins/mua", diff --git a/src/helpers.ts b/src/helpers.ts index a133fdd..8f270d6 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -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 { 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", }); diff --git a/src/security.test.ts b/src/security.test.ts index 3f81e2c..827faef 100644 --- a/src/security.test.ts +++ b/src/security.test.ts @@ -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", () => { diff --git a/src/tools.ts b/src/tools.ts index 169d11d..8cf8188 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -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); }, }, {