Bound read-only shell diagnostics
This commit is contained in:
BIN
Binary file not shown.
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user