Bound read-only shell diagnostics

This commit is contained in:
Mikei386
2026-08-24 15:51:07 +02:00
parent 88643de738
commit 6876e495d6
7 changed files with 39 additions and 13 deletions
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mua", "name": "mua",
"version": "2026.08.24.r022", "version": "2026.08.24.r023",
"description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid", "description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+8 -4
View File
@@ -2,13 +2,13 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "mua"> <!ENTITY name "mua">
<!ENTITY author "Michael"> <!ENTITY author "Michael">
<!ENTITY version "2026.08.24.r022"> <!ENTITY version "2026.08.24.r023">
<!ENTITY launch "Settings/mua"> <!ENTITY launch "Settings/mua">
<!ENTITY pluginURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/plugin/mua.plg"> <!ENTITY pluginURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/plugin/mua.plg">
<!ENTITY pluginLOC "/boot/config/plugins/&name;"> <!ENTITY pluginLOC "/boot/config/plugins/&name;">
<!ENTITY emhttpLOC "/usr/local/emhttp/plugins/&name;"> <!ENTITY emhttpLOC "/usr/local/emhttp/plugins/&name;">
<!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.24.r022-x86_64-1.txz"> <!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.24.r023-x86_64-1.txz">
<!ENTITY txzSHA256 "ac981953fe30d468b22f3febc65c7bdae473393824d4dd4e5a015f2fe18e4f86"> <!ENTITY txzSHA256 "acc41140d7fdc9d720843a50051e7a018767225f4165525f70fe83ff297a73a7">
]> ]>
<PLUGIN name="&name;" <PLUGIN name="&name;"
@@ -23,6 +23,10 @@
> >
<CHANGES> <CHANGES>
### 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 ### 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. - 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. - 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) install/doinst.sh (läuft nach Installation)
=========================================== ===========================================
--> -->
<FILE Name="/boot/config/plugins/&name;/mua-2026.08.24.r022-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0"> <FILE Name="/boot/config/plugins/&name;/mua-2026.08.24.r023-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
<URL>&txzURL;</URL> <URL>&txzURL;</URL>
<SHA256>&txzSHA256;</SHA256> <SHA256>&txzSHA256;</SHA256>
</FILE> </FILE>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mua", "name": "mua",
"author": "Michael", "author": "Michael",
"version": "2026.08.24.r020", "version": "2026.08.24.r023",
"minver": "7.0.0", "minver": "7.0.0",
"pluginDirectory": "/usr/local/emhttp/plugins/mua", "pluginDirectory": "/usr/local/emhttp/plugins/mua",
"configDirectory": "/boot/config/plugins/mua", "configDirectory": "/boot/config/plugins/mua",
+9 -5
View File
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync }
// ── Konstanten ────────────────────────────────────────────────────────── // ── Konstanten ──────────────────────────────────────────────────────────
export const MUA_SERVER_NAME = "mua"; 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 MUA_PROTOCOL_VERSION = "2025-03-26";
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php"; export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
export const STATUS_HELPER = "/usr/local/bin/unraid-mcp-status-helper.php"; export const STATUS_HELPER = "/usr/local/bin/unraid-mcp-status-helper.php";
@@ -233,6 +233,7 @@ export async function runReadOnlyCommand(
program: string, program: string,
args: string[], args: string[],
timeoutSec = 30, timeoutSec = 30,
maxOutputChars = 12_000,
): Promise<string> { ): Promise<string> {
if (!READ_ONLY_PROGRAMS.has(program)) { if (!READ_ONLY_PROGRAMS.has(program)) {
throw new Error(`Program is not allowed in read-only mode: ${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)) { if (args.length > 64 || args.some((arg) => typeof arg !== "string" || arg.length > 4096)) {
throw new Error("Invalid or excessive arguments"); 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 lowered = args.map((arg) => arg.toLowerCase());
const reject = (message: string) => { throw new Error(message); }; 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 proc = spawn([program, ...args], { stdout: "pipe", stderr: "pipe", cwd: "/" });
const timeout = setTimeout(() => proc.kill(), timeoutSec * 1000); const timeout = setTimeout(() => proc.kill(), timeoutSec * 1000);
const [stdout, stderr, code] = await Promise.all([ const [stdout, stderr, code] = await Promise.all([
readStreamLimited(proc.stdout, 100_000), readStreamLimited(proc.stdout, maxOutputChars),
readStreamLimited(proc.stderr, 20_000), readStreamLimited(proc.stderr, Math.min(8_000, maxOutputChars)),
proc.exited, proc.exited,
]); ]);
clearTimeout(timeout); clearTimeout(timeout);
return JSON.stringify({ return JSON.stringify({
exit_code: code, exit_code: code,
stdout: sanitizeLogOutput(stdout.text.trim(), 100_000), stdout: sanitizeLogOutput(stdout.text.trim(), maxOutputChars),
stderr: sanitizeLogOutput(stderr.text.trim(), 20_000), stderr: sanitizeLogOutput(stderr.text.trim(), Math.min(8_000, maxOutputChars)),
truncated: stdout.truncated || stderr.truncated, truncated: stdout.truncated || stderr.truncated,
mode: "read-only", mode: "read-only",
}); });
+13
View File
@@ -94,6 +94,19 @@ describe("read-only shell", () => {
await expect(runReadOnlyCommand("find", ["/tmp", "-delete"], 5)).rejects.toThrow(); await expect(runReadOnlyCommand("find", ["/tmp", "-delete"], 5)).rejects.toThrow();
await expect(runReadOnlyCommand("ss", ["-K", "dst", "127.0.0.1"], 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", () => { describe("Community Applications approval", () => {
+7 -2
View File
@@ -577,7 +577,7 @@ export const TOOLS: ToolDef[] = [
{ {
name: "unraid_system_shell_readonly", name: "unraid_system_shell_readonly",
description: 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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -598,6 +598,7 @@ export const TOOLS: ToolDef[] = [
description: "Argument vector; passed directly without /bin/sh", description: "Argument vector; passed directly without /bin/sh",
}, },
timeout_seconds: int("Timeout in seconds (1-120, default 30)"), 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"], required: ["program"],
additionalProperties: false, additionalProperties: false,
@@ -612,7 +613,11 @@ export const TOOLS: ToolDef[] = [
if (timeout < 1 || timeout > 120) { if (timeout < 1 || timeout > 120) {
throw new Error("timeout_seconds must be between 1 and 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);
}, },
}, },
{ {