mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
Moves src/backend/ai and its routes into plugins/ai, mounted through a small /ai dispatcher instead of its own port. Moves the ai-enabled and ai-private-endpoints settings routes out of user-settings-routes.ts into the plugin too, so it has no leftover core dependents.
137 lines
3.4 KiB
TypeScript
137 lines
3.4 KiB
TypeScript
/**
|
|
* Which commands the assistant may run without a per-command approval click,
|
|
* for users who opted into read-only execution.
|
|
*
|
|
* The check parses the command into arguments and matches the resolved binary
|
|
* against the allowlist. Substring matching would be trivially defeated
|
|
* ("df; rm -rf /" contains "df"), so any shell metacharacter that could chain,
|
|
* redirect or substitute a second command rejects the whole string outright.
|
|
*/
|
|
|
|
export const READ_ONLY_COMMANDS = new Set([
|
|
"df",
|
|
"du",
|
|
"free",
|
|
"uptime",
|
|
"uname",
|
|
"whoami",
|
|
"hostname",
|
|
"id",
|
|
"ps",
|
|
"top",
|
|
"systemctl",
|
|
"journalctl",
|
|
"docker",
|
|
"ip",
|
|
"ss",
|
|
"netstat",
|
|
"lsblk",
|
|
"cat",
|
|
"ls",
|
|
"stat",
|
|
"which",
|
|
"date",
|
|
"lscpu",
|
|
"vmstat",
|
|
"iostat",
|
|
]);
|
|
|
|
/** Characters that let one command become several. */
|
|
const SHELL_METACHARACTERS = /[;&|`$><\n\r\\]/;
|
|
|
|
/** Subcommands that are safe for otherwise-powerful binaries. */
|
|
const SUBCOMMAND_ALLOWLIST: Record<string, Set<string>> = {
|
|
systemctl: new Set([
|
|
"status",
|
|
"list-units",
|
|
"list-unit-files",
|
|
"is-active",
|
|
"is-enabled",
|
|
"show",
|
|
]),
|
|
docker: new Set([
|
|
"ps",
|
|
"stats",
|
|
"images",
|
|
"logs",
|
|
"inspect",
|
|
"version",
|
|
"info",
|
|
]),
|
|
ip: new Set(["a", "addr", "link", "route", "neigh"]),
|
|
};
|
|
|
|
/** Paths `cat` may read. Anything else could disclose credentials. */
|
|
const CAT_ALLOWED_PREFIXES = ["/proc/", "/sys/", "/etc/os-release"];
|
|
|
|
export interface CommandCheck {
|
|
allowed: boolean;
|
|
reason?: string;
|
|
}
|
|
|
|
export function isReadOnlyCommand(raw: string): CommandCheck {
|
|
const command = raw.trim();
|
|
if (!command) return { allowed: false, reason: "Empty command" };
|
|
|
|
if (SHELL_METACHARACTERS.test(command)) {
|
|
return {
|
|
allowed: false,
|
|
reason: "Command chaining, redirection and substitution are not allowed",
|
|
};
|
|
}
|
|
|
|
const parts = command.split(/\s+/).filter(Boolean);
|
|
if (!parts.length) return { allowed: false, reason: "Empty command" };
|
|
|
|
// Reject env-prefixed and privilege-escalating forms outright.
|
|
const head = parts[0];
|
|
if (head === "sudo" || head === "su" || head === "doas" || head === "env") {
|
|
return { allowed: false, reason: `${head} is not allowed` };
|
|
}
|
|
|
|
// A path like /usr/bin/df resolves to its basename.
|
|
const binary = head.includes("/")
|
|
? head.slice(head.lastIndexOf("/") + 1)
|
|
: head;
|
|
|
|
if (!READ_ONLY_COMMANDS.has(binary)) {
|
|
return {
|
|
allowed: false,
|
|
reason: `${binary} is not on the read-only allowlist`,
|
|
};
|
|
}
|
|
|
|
const allowedSubcommands = SUBCOMMAND_ALLOWLIST[binary];
|
|
if (allowedSubcommands) {
|
|
const subcommand = parts.slice(1).find((part) => !part.startsWith("-"));
|
|
if (!subcommand || !allowedSubcommands.has(subcommand)) {
|
|
return {
|
|
allowed: false,
|
|
reason:
|
|
`${binary} ${subcommand ?? ""}`.trim() +
|
|
" is not on the read-only allowlist",
|
|
};
|
|
}
|
|
}
|
|
|
|
if (binary === "cat") {
|
|
const targets = parts.slice(1).filter((part) => !part.startsWith("-"));
|
|
if (!targets.length) {
|
|
return { allowed: false, reason: "cat needs a file path" };
|
|
}
|
|
for (const target of targets) {
|
|
const permitted = CAT_ALLOWED_PREFIXES.some((prefix) =>
|
|
prefix.endsWith("/") ? target.startsWith(prefix) : target === prefix,
|
|
);
|
|
if (!permitted) {
|
|
return {
|
|
allowed: false,
|
|
reason: `cat is limited to ${CAT_ALLOWED_PREFIXES.join(", ")}`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|