mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: ship the AI assistant as a first-party in-process plugin
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.
This commit is contained in:
1 parent
c58ccbfc3f
commit
504710b065
43 files changed
+568
-262
No files matched your search
@@ -0,0 +1,68 @@
|
||||
import { proposeTools } from "./propose-tools.js";
|
||||
import { readTools } from "./read-tools.js";
|
||||
import type { AiTool, ToolDefinitionShape } from "./types.js";
|
||||
|
||||
/**
|
||||
* The allowlist, and the security boundary for the whole feature.
|
||||
*
|
||||
* A model can only ever invoke what appears here. Route-level RBAC gates the
|
||||
* HTTP API, but tools run in-process with the calling user's identity and never
|
||||
* pass through a router, so "the assistant cannot reach credentials or user
|
||||
* administration" is a property of this list, not of the permission system.
|
||||
*
|
||||
* Anything touching credentials, vaults, RBAC, users, identity, certificates,
|
||||
* SSO or instance settings is deliberately absent and must stay absent.
|
||||
*/
|
||||
export const AI_TOOLS: AiTool[] = [...readTools, ...proposeTools];
|
||||
|
||||
const BY_NAME = new Map(AI_TOOLS.map((tool) => [tool.name, tool]));
|
||||
|
||||
export function getTool(name: string): AiTool | undefined {
|
||||
return BY_NAME.get(name);
|
||||
}
|
||||
|
||||
export function listToolNames(): string[] {
|
||||
return AI_TOOLS.map((tool) => tool.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domains the assistant must never be able to reach, in any tool, ever.
|
||||
* The catalog test asserts no tool name references these.
|
||||
*/
|
||||
export const FORBIDDEN_DOMAINS = [
|
||||
"credential",
|
||||
"vault",
|
||||
"rbac",
|
||||
"role",
|
||||
"permission",
|
||||
"user_admin",
|
||||
"password",
|
||||
"totp",
|
||||
"webauthn",
|
||||
"passkey",
|
||||
"api_key",
|
||||
"session",
|
||||
"oidc",
|
||||
"sso",
|
||||
"ldap",
|
||||
"termix_id",
|
||||
"identity",
|
||||
"certificate",
|
||||
"opkssh",
|
||||
"stepca",
|
||||
"step_ca",
|
||||
"acme",
|
||||
"ssl",
|
||||
"audit",
|
||||
"sync",
|
||||
"settings",
|
||||
];
|
||||
|
||||
/** Tool definitions in the shape the provider adapters expect. */
|
||||
export function toolDefinitions(): ToolDefinitionShape[] {
|
||||
return AI_TOOLS.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
createCurrentAlertRepository,
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentFleetRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentSnippetRepository,
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { validateDefinition } from "../../../../src/backend/database/routes/automations.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
createFleetSshFactory,
|
||||
getFleetPoolKey,
|
||||
} from "../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { getTool } from "./catalog.js";
|
||||
|
||||
/** Approved commands get a bounded window rather than hanging the request. */
|
||||
const COMMAND_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Applies an approved proposal.
|
||||
*
|
||||
* The stored payload is treated as untrusted input even though the server wrote
|
||||
* it: the proposal could have sat in the table across a release, and defending
|
||||
* the apply path rather than the create path means one place to get right.
|
||||
* Everything goes through the same repositories a human action uses, scoped to
|
||||
* the approving user.
|
||||
*/
|
||||
|
||||
export interface ApplyResult {
|
||||
ok: boolean;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
function requireNumber(value: unknown, field: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${field} must be a positive integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`${field} is required`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export async function applyProposal(
|
||||
kind: string,
|
||||
payload: Record<string, unknown>,
|
||||
userId: string,
|
||||
): Promise<ApplyResult> {
|
||||
// A payload whose tool no longer exists is refused rather than guessed at.
|
||||
if (!getTool(kind)) {
|
||||
throw new Error(`Unknown proposal kind: ${kind}`);
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case "propose_create_host": {
|
||||
const created = await createCurrentHostRepository().create({
|
||||
userId,
|
||||
name: requireString(payload.name, "name"),
|
||||
ip: requireString(payload.ip, "ip"),
|
||||
port: Number(payload.port) || 22,
|
||||
username: optionalString(payload.username) ?? "",
|
||||
folder: optionalString(payload.folder) ?? "",
|
||||
tags: JSON.stringify(Array.isArray(payload.tags) ? payload.tags : []),
|
||||
} as any);
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created host ${(created as any).name ?? ""}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_update_host": {
|
||||
const hostId = requireNumber(payload.hostId, "hostId");
|
||||
const changes = (payload.changes ?? {}) as Record<string, unknown>;
|
||||
|
||||
const existing = await createCurrentHostRepository().findByIdForUser(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!existing) throw new Error("Host not found");
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (changes.name !== undefined)
|
||||
updates.name = requireString(changes.name, "name");
|
||||
if (changes.ip !== undefined)
|
||||
updates.ip = requireString(changes.ip, "ip");
|
||||
if (changes.port !== undefined) updates.port = Number(changes.port);
|
||||
if (changes.username !== undefined)
|
||||
updates.username = optionalString(changes.username) ?? "";
|
||||
if (changes.folder !== undefined)
|
||||
updates.folder = optionalString(changes.folder) ?? "";
|
||||
if (changes.tags !== undefined) {
|
||||
updates.tags = JSON.stringify(
|
||||
Array.isArray(changes.tags) ? changes.tags : [],
|
||||
);
|
||||
}
|
||||
|
||||
if (!Object.keys(updates).length) {
|
||||
return { ok: false, summary: "Nothing to change" };
|
||||
}
|
||||
|
||||
await createCurrentHostRepository().updateForUser(
|
||||
userId,
|
||||
hostId,
|
||||
updates as any,
|
||||
);
|
||||
return { ok: true, summary: `Updated host ${hostId}` };
|
||||
}
|
||||
|
||||
case "propose_delete_host": {
|
||||
const hostId = requireNumber(payload.hostId, "hostId");
|
||||
const deleted = await createCurrentHostRepository().deleteForUser(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!deleted) throw new Error("Host not found");
|
||||
return { ok: true, summary: `Deleted host ${hostId}` };
|
||||
}
|
||||
|
||||
case "propose_create_snippet": {
|
||||
const created = await createCurrentSnippetRepository().createSnippet(
|
||||
userId,
|
||||
{
|
||||
name: requireString(payload.name, "name"),
|
||||
content: requireString(payload.content, "content"),
|
||||
description: optionalString(payload.description),
|
||||
folder: optionalString(payload.folder),
|
||||
} as any,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created snippet ${(created as any)?.name ?? ""}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_update_snippet": {
|
||||
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
||||
const changes = (payload.changes ?? {}) as Record<string, unknown>;
|
||||
|
||||
const existing = await createCurrentSnippetRepository().findOwnedById(
|
||||
userId,
|
||||
snippetId,
|
||||
);
|
||||
if (!existing) throw new Error("Snippet not found");
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (changes.name !== undefined)
|
||||
updates.name = requireString(changes.name, "name");
|
||||
if (changes.content !== undefined)
|
||||
updates.content = requireString(changes.content, "content");
|
||||
if (changes.description !== undefined)
|
||||
updates.description = optionalString(changes.description);
|
||||
if (changes.folder !== undefined)
|
||||
updates.folder = optionalString(changes.folder);
|
||||
|
||||
if (!Object.keys(updates).length) {
|
||||
return { ok: false, summary: "Nothing to change" };
|
||||
}
|
||||
|
||||
await createCurrentSnippetRepository().updateSnippet(
|
||||
userId,
|
||||
snippetId,
|
||||
updates as any,
|
||||
);
|
||||
return { ok: true, summary: `Updated snippet ${snippetId}` };
|
||||
}
|
||||
|
||||
case "propose_delete_snippet": {
|
||||
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
||||
const deleted = await createCurrentSnippetRepository().deleteSnippet(
|
||||
userId,
|
||||
snippetId,
|
||||
);
|
||||
if (!deleted) throw new Error("Snippet not found");
|
||||
return { ok: true, summary: `Deleted snippet ${snippetId}` };
|
||||
}
|
||||
|
||||
case "propose_create_fleet": {
|
||||
const fleet = await createCurrentFleetRepository().create(userId, {
|
||||
name: requireString(payload.name, "name"),
|
||||
description: optionalString(payload.description),
|
||||
} as any);
|
||||
|
||||
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
|
||||
let added = 0;
|
||||
for (const raw of hostIds) {
|
||||
const hostId = Number(raw);
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) continue;
|
||||
// Only hosts the approving user owns can join their fleet.
|
||||
const host = await createCurrentHostRepository().findByIdForUser(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!host) continue;
|
||||
await createCurrentFleetRepository().addMember(
|
||||
(fleet as any).id,
|
||||
hostId,
|
||||
);
|
||||
added += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created fleet ${(fleet as any).name} with ${added} host${added === 1 ? "" : "s"}`,
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_create_alert_rule": {
|
||||
const created = await createCurrentAlertRepository().createAlertRule({
|
||||
userId,
|
||||
name: requireString(payload.name, "name"),
|
||||
hostId:
|
||||
payload.hostId === null || payload.hostId === undefined
|
||||
? null
|
||||
: requireNumber(payload.hostId, "hostId"),
|
||||
enabled: true,
|
||||
triggerType: requireString(payload.triggerType, "triggerType"),
|
||||
thresholdValue:
|
||||
payload.thresholdValue === null ||
|
||||
payload.thresholdValue === undefined
|
||||
? null
|
||||
: Number(payload.thresholdValue),
|
||||
thresholdDurationSeconds:
|
||||
payload.thresholdDurationSeconds === null ||
|
||||
payload.thresholdDurationSeconds === undefined
|
||||
? null
|
||||
: Number(payload.thresholdDurationSeconds),
|
||||
cooldownMinutes: Number(payload.cooldownMinutes) || 15,
|
||||
channelIds: [],
|
||||
} as any);
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created alert rule ${(created as any)?.name ?? ""}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_create_automation": {
|
||||
// Reuses the same validator the automations route runs, so an
|
||||
// LLM-authored definition is held to exactly the human standard.
|
||||
const validation = validateDefinition(payload.definition);
|
||||
if (!validation.ok || !validation.definition) {
|
||||
throw new Error(
|
||||
validation.error ?? "The automation definition is invalid",
|
||||
);
|
||||
}
|
||||
|
||||
const created = await createCurrentAutomationRepository().create({
|
||||
userId,
|
||||
name: requireString(payload.name, "name"),
|
||||
description: optionalString(payload.description),
|
||||
definition: JSON.stringify(validation.definition),
|
||||
// Starts disabled: an automation the user has not watched run once
|
||||
// should not begin firing against their servers on approval.
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created automation ${(created as any).name} (disabled until you enable it)`,
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_run_command": {
|
||||
const hostId = requireNumber(payload.hostId, "hostId");
|
||||
const command = requireString(payload.command, "command");
|
||||
|
||||
// resolveHostById, not the raw repository row: it runs the connect-level
|
||||
// permission check, decrypts auth under the owner's key, and resolves the
|
||||
// jump host chain. A plain row has none of that, so the SSH factory saw
|
||||
// an unresolved jumpHosts field and failed the connection.
|
||||
const host = await resolveHostById(hostId, userId);
|
||||
if (!host) throw new Error("Host not found");
|
||||
|
||||
const result = await runCommandOnHost(host, command);
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
summary: result.output?.slice(0, 2000) ?? "(no output)",
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Proposal kind ${kind} cannot be applied automatically`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one approved command over the shared SSH pool, mirroring how an
|
||||
* automation run_command step executes.
|
||||
*/
|
||||
async function runCommandOnHost(
|
||||
host: Record<string, any>,
|
||||
command: string,
|
||||
): Promise<{ output?: string; error?: string }> {
|
||||
try {
|
||||
const result = await withConnection(
|
||||
getFleetPoolKey(host as any),
|
||||
createFleetSshFactory(host as any),
|
||||
async (client) => execCommand(client, command, COMMAND_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
if (result.code === 0 || result.code === null) {
|
||||
return { output: output || "(no output)" };
|
||||
}
|
||||
return { error: `Exited with code ${result.code}: ${output}`.trim() };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kinds the route handles itself rather than through applyProposal.
|
||||
* Empty: everything the assistant can propose can now be applied.
|
||||
*/
|
||||
export const ROUTE_APPLIED_KINDS = new Set<string>();
|
||||
@@ -0,0 +1,279 @@
|
||||
import { num, objectSchema, proposal, str, type AiTool } from "./types.js";
|
||||
|
||||
/**
|
||||
* Propose tools never mutate anything. They return a draft that is stored as a
|
||||
* pending proposal and rendered as a card; the change happens only when the
|
||||
* user approves it, and the payload is re-validated at that point.
|
||||
*/
|
||||
|
||||
export const proposeTools: AiTool[] = [
|
||||
{
|
||||
name: "propose_create_host",
|
||||
description:
|
||||
"Propose adding a new SSH host. Never include credentials: the user attaches those themselves after approving.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Display name for the host"),
|
||||
ip: str("Hostname or IP address"),
|
||||
port: num("SSH port (defaults to 22)"),
|
||||
username: str("SSH username"),
|
||||
folder: str("Folder to file the host under"),
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags to apply",
|
||||
},
|
||||
},
|
||||
["name", "ip"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal("propose_create_host", `Add host ${String(args.name)}`, {
|
||||
name: args.name,
|
||||
ip: args.ip,
|
||||
port: args.port ?? 22,
|
||||
username: args.username ?? null,
|
||||
folder: args.folder ?? null,
|
||||
tags: args.tags ?? [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "propose_update_host",
|
||||
description:
|
||||
"Propose changing an existing host's non-secret settings. Only include the fields that should change.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
hostId: num("The host id to update"),
|
||||
name: str("New display name"),
|
||||
ip: str("New hostname or IP address"),
|
||||
port: num("New SSH port"),
|
||||
username: str("New SSH username"),
|
||||
folder: str("New folder"),
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Replacement tag list",
|
||||
},
|
||||
},
|
||||
["hostId"],
|
||||
),
|
||||
handler: async (args) => {
|
||||
const changes: Record<string, unknown> = {};
|
||||
for (const field of [
|
||||
"name",
|
||||
"ip",
|
||||
"port",
|
||||
"username",
|
||||
"folder",
|
||||
"tags",
|
||||
]) {
|
||||
if (args[field] !== undefined) changes[field] = args[field];
|
||||
}
|
||||
return proposal(
|
||||
"propose_update_host",
|
||||
`Update host ${String(args.hostId)}`,
|
||||
{ hostId: args.hostId, changes },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "propose_delete_host",
|
||||
description:
|
||||
"Propose removing a host. Use sparingly and explain why in the reason.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
hostId: num("The host id to delete"),
|
||||
reason: str("Why this host should be removed"),
|
||||
},
|
||||
["hostId", "reason"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal("propose_delete_host", `Delete host ${String(args.hostId)}`, {
|
||||
hostId: args.hostId,
|
||||
reason: args.reason,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "propose_create_snippet",
|
||||
description:
|
||||
"Propose saving a new command snippet the user can run against their hosts.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Snippet name"),
|
||||
content: str("The command text"),
|
||||
description: str("What the snippet does"),
|
||||
folder: str("Folder to file it under"),
|
||||
},
|
||||
["name", "content"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_create_snippet",
|
||||
`Create snippet ${String(args.name)}`,
|
||||
{
|
||||
name: args.name,
|
||||
content: args.content,
|
||||
description: args.description ?? null,
|
||||
folder: args.folder ?? null,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "propose_update_snippet",
|
||||
description: "Propose editing an existing snippet.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
snippetId: num("The snippet id to update"),
|
||||
name: str("New name"),
|
||||
content: str("New command text"),
|
||||
description: str("New description"),
|
||||
folder: str("New folder"),
|
||||
},
|
||||
["snippetId"],
|
||||
),
|
||||
handler: async (args) => {
|
||||
const changes: Record<string, unknown> = {};
|
||||
for (const field of ["name", "content", "description", "folder"]) {
|
||||
if (args[field] !== undefined) changes[field] = args[field];
|
||||
}
|
||||
return proposal(
|
||||
"propose_update_snippet",
|
||||
`Update snippet ${String(args.snippetId)}`,
|
||||
{ snippetId: args.snippetId, changes },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "propose_delete_snippet",
|
||||
description: "Propose deleting a snippet.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
snippetId: num("The snippet id to delete"),
|
||||
reason: str("Why this snippet should be removed"),
|
||||
},
|
||||
["snippetId", "reason"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_delete_snippet",
|
||||
`Delete snippet ${String(args.snippetId)}`,
|
||||
{ snippetId: args.snippetId, reason: args.reason },
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "propose_create_automation",
|
||||
description:
|
||||
"Propose a new automation. The definition must be a valid AutomationDefinition object with a trigger and an ordered list of steps. It is validated server-side and previewed with a dry run before anything happens.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Automation name"),
|
||||
description: str("What the automation does"),
|
||||
definition: {
|
||||
type: "object",
|
||||
description:
|
||||
"The AutomationDefinition: { trigger: {...}, steps: [...] }",
|
||||
},
|
||||
},
|
||||
["name", "definition"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_create_automation",
|
||||
`Create automation ${String(args.name)}`,
|
||||
{
|
||||
name: args.name,
|
||||
description: args.description ?? null,
|
||||
definition: args.definition,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "propose_create_fleet",
|
||||
description: "Propose grouping hosts into a new fleet.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Fleet name"),
|
||||
description: str("What this fleet is for"),
|
||||
hostIds: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
description: "Host ids to add as members",
|
||||
},
|
||||
},
|
||||
["name"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal("propose_create_fleet", `Create fleet ${String(args.name)}`, {
|
||||
name: args.name,
|
||||
description: args.description ?? null,
|
||||
hostIds: args.hostIds ?? [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "propose_create_alert_rule",
|
||||
description:
|
||||
"Propose a new alert rule that fires when a host metric crosses a threshold.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Rule name"),
|
||||
hostId: num("Host id to watch, or omit to watch all hosts"),
|
||||
triggerType: str(
|
||||
"What to watch, for example cpu, memory, disk or host_status",
|
||||
),
|
||||
thresholdValue: num("Threshold to compare against"),
|
||||
thresholdDurationSeconds: num(
|
||||
"How long the breach must persist before firing",
|
||||
),
|
||||
cooldownMinutes: num("Minimum minutes between repeat firings"),
|
||||
},
|
||||
["name", "triggerType"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_create_alert_rule",
|
||||
`Create alert rule ${String(args.name)}`,
|
||||
{
|
||||
name: args.name,
|
||||
hostId: args.hostId ?? null,
|
||||
triggerType: args.triggerType,
|
||||
thresholdValue: args.thresholdValue ?? null,
|
||||
thresholdDurationSeconds: args.thresholdDurationSeconds ?? null,
|
||||
cooldownMinutes: args.cooldownMinutes ?? 15,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "propose_run_command",
|
||||
description:
|
||||
"Propose running a command on a host. The user reviews and approves it before it runs. Use this for anything that changes state; read-only diagnostics may run directly if the user has enabled that.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
hostId: num("The host id to run on"),
|
||||
command: str("The exact command to run"),
|
||||
explanation: str("What the command does and why it is needed"),
|
||||
},
|
||||
["hostId", "command", "explanation"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_run_command",
|
||||
// Deliberately short: the card renders the command in its own block,
|
||||
// so repeating it here made every card twice as tall as it needed.
|
||||
`Run a command on host ${String(args.hostId)}`,
|
||||
{
|
||||
hostId: args.hostId,
|
||||
command: args.command,
|
||||
explanation: args.explanation,
|
||||
},
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
createCurrentAlertRepository,
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentFleetRepository,
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentNetworkTopologyRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentWorkspaceRepository,
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { num, objectSchema, type AiTool } from "./types.js";
|
||||
|
||||
/**
|
||||
* Read tools project explicit fields rather than spreading rows. Redaction runs
|
||||
* afterwards as a second line of defense, but the projection here is the
|
||||
* primary control: a field that is never selected cannot leak.
|
||||
*/
|
||||
|
||||
interface HostSummary {
|
||||
id: number;
|
||||
name: string | null;
|
||||
ip: string | null;
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
folder: string | null;
|
||||
tags: unknown;
|
||||
protocol: string | null;
|
||||
enableTerminal: boolean | null;
|
||||
enableFileManager: boolean | null;
|
||||
enableTunnel: boolean | null;
|
||||
enableDocker: boolean | null;
|
||||
}
|
||||
|
||||
function toHostSummary(host: Record<string, any>): HostSummary {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name ?? null,
|
||||
ip: host.ip ?? null,
|
||||
port: host.port ?? null,
|
||||
username: host.username ?? null,
|
||||
folder: host.folder ?? null,
|
||||
tags: host.tags ?? null,
|
||||
protocol: host.protocol ?? null,
|
||||
enableTerminal: host.enableTerminal ?? null,
|
||||
enableFileManager: host.enableFileManager ?? null,
|
||||
enableTunnel: host.enableTunnel ?? null,
|
||||
enableDocker: host.enableDocker ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const readTools: AiTool[] = [
|
||||
{
|
||||
name: "list_hosts",
|
||||
description:
|
||||
"List the user's SSH hosts with their names, addresses, folders and tags. Never returns passwords or keys. Call this before proposing anything that references a host.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const hosts = await createCurrentHostRepository().listByUserId(
|
||||
context.userId,
|
||||
);
|
||||
return { hosts: hosts.map((host) => toHostSummary(host as any)) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_host",
|
||||
description:
|
||||
"Get one host's non-secret configuration by id. Use it to inspect settings before proposing an update.",
|
||||
category: "read",
|
||||
parameters: objectSchema(
|
||||
{ hostId: num("The host id, as returned by list_hosts") },
|
||||
["hostId"],
|
||||
),
|
||||
handler: async (args, context) => {
|
||||
const hostId = Number(args.hostId);
|
||||
const host = await createCurrentHostRepository().findByIdForUser(
|
||||
context.userId,
|
||||
hostId,
|
||||
);
|
||||
if (!host) return { error: "Host not found" };
|
||||
return { host: toHostSummary(host as any) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_fleets",
|
||||
description:
|
||||
"List the user's fleets. Fleets group hosts for bulk operations and inventory.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const fleets = await createCurrentFleetRepository().listByUser(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
fleets: fleets.map((fleet: any) => ({
|
||||
id: fleet.id,
|
||||
name: fleet.name,
|
||||
description: fleet.description ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_snippets",
|
||||
description:
|
||||
"List the user's saved command snippets, including their folder and the command text.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const snippets = await createCurrentSnippetRepository().listOwnedSnippets(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
snippets: snippets.map((snippet: any) => ({
|
||||
id: snippet.id,
|
||||
name: snippet.name,
|
||||
content: snippet.content,
|
||||
folder: snippet.folder ?? null,
|
||||
description: snippet.description ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_automations",
|
||||
description:
|
||||
"List the user's automations with their trigger kind and enabled state. Read this before proposing a change to an existing automation.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const automations = await createCurrentAutomationRepository().list(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
automations: automations.map((automation: any) => ({
|
||||
id: automation.id,
|
||||
name: automation.name,
|
||||
description: automation.description ?? null,
|
||||
enabled: automation.enabled,
|
||||
lastRunAt: automation.lastRunAt ?? null,
|
||||
lastRunStatus: automation.lastRunStatus ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_automation",
|
||||
description:
|
||||
"Get one automation's full definition (trigger and steps) by id.",
|
||||
category: "read",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
automationId: num("The automation id, as returned by list_automations"),
|
||||
},
|
||||
["automationId"],
|
||||
),
|
||||
handler: async (args, context) => {
|
||||
const automation = await createCurrentAutomationRepository().findForUser(
|
||||
Number(args.automationId),
|
||||
context.userId,
|
||||
);
|
||||
if (!automation) return { error: "Automation not found" };
|
||||
return {
|
||||
automation: {
|
||||
id: (automation as any).id,
|
||||
name: (automation as any).name,
|
||||
enabled: (automation as any).enabled,
|
||||
definition: (automation as any).definition,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_workspaces",
|
||||
description:
|
||||
"List the user's saved workspace layouts (named sets of open tabs and splits).",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const workspaces = await createCurrentWorkspaceRepository().listByUser(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
workspaces: workspaces.map((workspace: any) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
isDefault: workspace.isDefault ?? false,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_alert_rules",
|
||||
description:
|
||||
"List the user's alert rules with their thresholds and enabled state.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const rules = await createCurrentAlertRepository().listAlertRules(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
rules: rules.map((rule) => ({
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
hostId: rule.host_id,
|
||||
enabled: rule.enabled === 1,
|
||||
triggerType: rule.trigger_type,
|
||||
thresholdValue: rule.threshold_value,
|
||||
thresholdDurationSeconds: rule.threshold_duration_seconds,
|
||||
cooldownMinutes: rule.cooldown_minutes,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_notification_channels",
|
||||
description:
|
||||
"List the user's notification channels by id, name and type. Channel configuration is never returned because it holds tokens.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const channels =
|
||||
await createCurrentAlertRepository().listNotificationChannels(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
channels: channels.map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
enabled: channel.enabled === 1,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_alert_firings",
|
||||
description:
|
||||
"Recent alert firings, newest first. Use this to answer questions about what has been alerting.",
|
||||
category: "read",
|
||||
parameters: objectSchema({
|
||||
limit: num("How many firings to return (default 25, max 100)"),
|
||||
}),
|
||||
handler: async (args, context) => {
|
||||
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
|
||||
const result = await createCurrentAlertRepository().listAlertFirings({
|
||||
userId: context.userId,
|
||||
limit,
|
||||
offset: 0,
|
||||
});
|
||||
return {
|
||||
firings: (result.firings ?? []).map((firing) => ({
|
||||
id: firing.id,
|
||||
ruleName: firing.rule_name,
|
||||
hostName: firing.host_name,
|
||||
firedAt: firing.fired_at,
|
||||
resolvedAt: firing.resolved_at,
|
||||
severity: firing.severity,
|
||||
message: firing.message,
|
||||
acknowledged: firing.acknowledged === 1,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_homepage_items",
|
||||
description: "List the user's homepage service-link tiles.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const items = await createCurrentHomepageItemRepository().listByUserId(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
items: (items as any[]).map((item) => ({
|
||||
id: item.id,
|
||||
typeId: item.typeId,
|
||||
title: item.title ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_command_history",
|
||||
description:
|
||||
"Recent commands the user has run on one host, newest first. Useful for understanding what they have been working on.",
|
||||
category: "read",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
hostId: num("The host id, as returned by list_hosts"),
|
||||
limit: num("How many entries to return (default 25, max 100)"),
|
||||
},
|
||||
["hostId"],
|
||||
),
|
||||
handler: async (args, context) => {
|
||||
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
|
||||
const hostId = Number(args.hostId);
|
||||
|
||||
// Ownership is enforced here rather than trusted from the model.
|
||||
const host = await createCurrentHostRepository().findByIdForUser(
|
||||
context.userId,
|
||||
hostId,
|
||||
);
|
||||
if (!host) return { error: "Host not found" };
|
||||
|
||||
const commands =
|
||||
await createCurrentCommandHistoryRepository().listCommandsForHost(
|
||||
context.userId,
|
||||
hostId,
|
||||
limit,
|
||||
);
|
||||
return { commands };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_network_topology",
|
||||
description: "The user's saved network topology graph, if they have one.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const topology =
|
||||
await createCurrentNetworkTopologyRepository().findByUserId(
|
||||
context.userId,
|
||||
);
|
||||
if (!topology) return { topology: null };
|
||||
return { topology: (topology as any).data ?? null };
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,72 @@
|
||||
export type ToolCategory = "read" | "propose";
|
||||
|
||||
export interface ToolContext {
|
||||
/** Always taken from the verified JWT, never from model input. */
|
||||
userId: string;
|
||||
conversationId: number;
|
||||
/** Per-user opt-in for running allowlisted read-only commands. */
|
||||
allowReadOnlyCommands: boolean;
|
||||
}
|
||||
|
||||
export interface AiTool {
|
||||
name: string;
|
||||
description: string;
|
||||
category: ToolCategory;
|
||||
/** JSON Schema for the arguments, sent to the provider verbatim. */
|
||||
parameters: Record<string, unknown>;
|
||||
/**
|
||||
* Read tools return data to feed back to the model. Propose tools return a
|
||||
* ProposalDraft and must not mutate anything.
|
||||
*/
|
||||
handler: (
|
||||
args: Record<string, unknown>,
|
||||
context: ToolContext,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** What a provider adapter needs to describe a tool to its model. */
|
||||
export interface ToolDefinitionShape {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProposalDraft {
|
||||
__proposal: true;
|
||||
kind: string;
|
||||
summary: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function isProposalDraft(value: unknown): value is ProposalDraft {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
(value as ProposalDraft).__proposal === true
|
||||
);
|
||||
}
|
||||
|
||||
export function proposal(
|
||||
kind: string,
|
||||
summary: string,
|
||||
payload: Record<string, unknown>,
|
||||
): ProposalDraft {
|
||||
return { __proposal: true, kind, summary, payload };
|
||||
}
|
||||
|
||||
/** Small helper so tool schemas stay readable. */
|
||||
export function objectSchema(
|
||||
properties: Record<string, unknown>,
|
||||
required: string[] = [],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
type: "object",
|
||||
properties,
|
||||
required,
|
||||
additionalProperties: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const str = (description: string) => ({ type: "string", description });
|
||||
export const num = (description: string) => ({ type: "number", description });
|
||||
export const bool = (description: string) => ({ type: "boolean", description });
|
||||
Reference in new issue
Block a user