release r008 add safe read-only shell
This commit is contained in:
@@ -49,6 +49,7 @@ export const SAFE_DEFAULT_TOOLS = [
|
||||
"unraid_network_inspect",
|
||||
"unraid_network_host_state",
|
||||
"unraid_system_connection_test",
|
||||
"unraid_system_shell_readonly",
|
||||
];
|
||||
|
||||
// ── Config laden ────────────────────────────────────────────────────────
|
||||
|
||||
+114
-1
@@ -13,7 +13,7 @@ import { createConnection, type Socket } from "net";
|
||||
|
||||
// ── Konstanten ──────────────────────────────────────────────────────────
|
||||
export const MUA_SERVER_NAME = "mua";
|
||||
export const MUA_VERSION = "2026.08.21.r007";
|
||||
export const MUA_VERSION = "2026.08.21.r008";
|
||||
export const MUA_PROTOCOL_VERSION = "2025-03-26";
|
||||
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
|
||||
|
||||
@@ -81,6 +81,119 @@ export async function runShell(cmd: string, timeoutSec = 60): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
const READ_ONLY_PROGRAMS = new Set([
|
||||
"cat", "date", "df", "dmesg", "du", "file", "find", "free", "grep",
|
||||
"head", "hostname", "id", "ip", "lsof", "ls", "lsblk", "lspci", "mount",
|
||||
"ps", "readlink", "realpath", "sha256sum", "ss", "stat", "tail", "uname",
|
||||
"uptime", "wc", "whoami",
|
||||
]);
|
||||
|
||||
async function readStreamLimited(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
maxBytes: number,
|
||||
): Promise<{ text: string; truncated: boolean }> {
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let kept = 0;
|
||||
let truncated = false;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const available = Math.max(0, maxBytes - kept);
|
||||
if (kept < maxBytes) {
|
||||
const slice = value.subarray(0, available);
|
||||
if (slice.length > 0) chunks.push(slice);
|
||||
kept += slice.length;
|
||||
}
|
||||
if (value.length > available) truncated = true;
|
||||
}
|
||||
const combined = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return { text: new TextDecoder().decode(combined), truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt ausschließlich freigegebene Leseprogramme direkt als argv aus.
|
||||
* Kein /bin/sh, keine Pipes, Umleitungen, Substitutionen oder Verkettungen.
|
||||
*/
|
||||
export async function runReadOnlyCommand(
|
||||
program: string,
|
||||
args: string[],
|
||||
timeoutSec = 30,
|
||||
): Promise<string> {
|
||||
if (!READ_ONLY_PROGRAMS.has(program)) {
|
||||
throw new Error(`Program is not allowed in read-only mode: ${program}`);
|
||||
}
|
||||
if (args.length > 64 || args.some((arg) => typeof arg !== "string" || arg.length > 4096)) {
|
||||
throw new Error("Invalid or excessive arguments");
|
||||
}
|
||||
|
||||
const lowered = args.map((arg) => arg.toLowerCase());
|
||||
const reject = (message: string) => { throw new Error(message); };
|
||||
if (["hostname", "whoami", "uptime"].includes(program) && args.length > 0) {
|
||||
reject(`${program} does not accept arguments in read-only mode`);
|
||||
}
|
||||
if (program === "date") {
|
||||
const safeDateFlags = new Set(["-u", "--utc", "-r", "--reference", "--rfc-email", "-d", "--date"]);
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = lowered[i];
|
||||
if (i > 0 && ["-d", "--date", "-r", "--reference"].includes(lowered[i - 1])) continue;
|
||||
if (arg.startsWith("+") || safeDateFlags.has(arg) || arg.startsWith("--date=") ||
|
||||
arg.startsWith("--iso-8601") || arg.startsWith("--rfc-3339")) continue;
|
||||
reject("Only date display and parsing options are allowed");
|
||||
}
|
||||
}
|
||||
if (program === "dmesg" && args.some((a) =>
|
||||
["-C", "-D", "-E", "-n"].includes(a) ||
|
||||
["--clear", "--read-clear", "--console-off", "--console-on", "--console-level"].includes(a.toLowerCase())
|
||||
)) {
|
||||
reject("Changing or clearing the kernel log is not allowed");
|
||||
}
|
||||
if (program === "ss" && lowered.some((a) => a === "-k" || a === "--kill")) {
|
||||
reject("Killing sockets is not allowed");
|
||||
}
|
||||
if (program === "mount" && args.length > 0) {
|
||||
reject("mount is display-only and accepts no arguments in read-only mode");
|
||||
}
|
||||
if (program === "ip") {
|
||||
const safeObjects = new Set(["address", "addr", "route", "link", "neigh", "neighbor"]);
|
||||
const mutating = new Set(["add", "append", "change", "delete", "del", "flush", "replace", "set"]);
|
||||
if (args.length === 0 || !safeObjects.has(lowered[0]) || lowered.some((a) => mutating.has(a))) {
|
||||
reject("Only read-only ip objects and show/list operations are allowed");
|
||||
}
|
||||
}
|
||||
if (program === "find") {
|
||||
const mutatingFind = ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf", "-fls"];
|
||||
if (lowered.some((a) => mutatingFind.some((blocked) => a === blocked || a.startsWith(blocked)))) {
|
||||
reject("Mutating find actions are not allowed");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
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),
|
||||
proc.exited,
|
||||
]);
|
||||
clearTimeout(timeout);
|
||||
return JSON.stringify({
|
||||
exit_code: code,
|
||||
stdout: sanitizeLogOutput(stdout.text.trim(), 100_000),
|
||||
stderr: sanitizeLogOutput(stderr.text.trim(), 20_000),
|
||||
truncated: stdout.truncated || stderr.truncated,
|
||||
mode: "read-only",
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`read-only command failed: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker-Befehl ausführen (kompakt).
|
||||
*/
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseConfig, SAFE_DEFAULT_TOOLS } from "./auth";
|
||||
import { sanitizeLogOutput } from "./helpers";
|
||||
import { runReadOnlyCommand, sanitizeLogOutput } from "./helpers";
|
||||
import { getToolRisk } from "./tools";
|
||||
|
||||
describe("secure tool configuration", () => {
|
||||
@@ -43,5 +43,21 @@ describe("risk classification", () => {
|
||||
expect(getToolRisk("unraid_docker_restart")).toBe("write");
|
||||
expect(getToolRisk("unraid_network_lan_probe")).toBe("active");
|
||||
expect(getToolRisk("unraid_docker_list")).toBe("read");
|
||||
expect(getToolRisk("unraid_system_shell_readonly")).toBe("read");
|
||||
});
|
||||
});
|
||||
|
||||
describe("read-only shell", () => {
|
||||
test("executes an allowlisted program without a shell", async () => {
|
||||
const result = JSON.parse(await runReadOnlyCommand("ls", ["-ld", "/"], 5));
|
||||
expect(result.exit_code).toBe(0);
|
||||
expect(result.mode).toBe("read-only");
|
||||
});
|
||||
|
||||
test("rejects arbitrary programs and mutating subcommands", async () => {
|
||||
await expect(runReadOnlyCommand("sh", ["-c", "id"], 5)).rejects.toThrow();
|
||||
await expect(runReadOnlyCommand("ip", ["link", "set", "lo", "down"], 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();
|
||||
});
|
||||
});
|
||||
|
||||
+44
-2
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* MUA — Mikes Unraid Agent
|
||||
* tools.ts — MCP Tool-Definitionen (22 Tools)
|
||||
* tools.ts — MCP Tool-Definitionen (23 Tools)
|
||||
*
|
||||
* Portiert von mcp/tools.php. Schema: unraid_<kategorie>_<aktion>
|
||||
* Kategorien: docker (14), network (6), system (2).
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
connectionTest,
|
||||
validateName,
|
||||
runShell,
|
||||
runReadOnlyCommand,
|
||||
} from "./helpers";
|
||||
|
||||
export interface ToolDef {
|
||||
@@ -321,7 +322,7 @@ export const TOOLS: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// ── System (2) ────────────────────────────────────────────────────────
|
||||
// ── System (3) ────────────────────────────────────────────────────────
|
||||
{
|
||||
name: "unraid_system_connection_test",
|
||||
description:
|
||||
@@ -329,6 +330,47 @@ export const TOOLS: ToolDef[] = [
|
||||
inputSchema: empty,
|
||||
handler: () => connectionTest(),
|
||||
},
|
||||
{
|
||||
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.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
program: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"cat", "date", "df", "dmesg", "du", "file", "find", "free", "grep",
|
||||
"head", "hostname", "id", "ip", "lsof", "ls", "lsblk", "lspci", "mount",
|
||||
"ps", "readlink", "realpath", "sha256sum", "ss", "stat", "tail", "uname",
|
||||
"uptime", "wc", "whoami",
|
||||
],
|
||||
description: "Allowlisted read-only program",
|
||||
},
|
||||
args: {
|
||||
type: "array",
|
||||
items: { type: "string", maxLength: 4096 },
|
||||
maxItems: 64,
|
||||
description: "Argument vector; passed directly without /bin/sh",
|
||||
},
|
||||
timeout_seconds: int("Timeout in seconds (1-120, default 30)"),
|
||||
},
|
||||
required: ["program"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: (a) => {
|
||||
const program = (a["program"] as string) ?? "";
|
||||
const rawArgs = a["args"] ?? [];
|
||||
if (!Array.isArray(rawArgs) || rawArgs.some((arg) => typeof arg !== "string")) {
|
||||
throw new Error("args must be an array of strings");
|
||||
}
|
||||
const timeout = Number(a["timeout_seconds"] ?? 30);
|
||||
if (timeout < 1 || timeout > 120) {
|
||||
throw new Error("timeout_seconds must be between 1 and 120");
|
||||
}
|
||||
return runReadOnlyCommand(program, rawArgs as string[], timeout);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unraid_system_shell",
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user