import type { OfficerContext } from './routes'; import { badRequest, methodNotAllowed, readJson } from './routes'; import { activeClient } from './active'; import { arrayField, toNode, toUser } from './normalize'; import { askClaude, ProxyUnavailable } from './claude-proxy'; // `POST /_officer/policy/assist` — describe a change in English, get a complete revised policy back. // // The ACL is the one document in this app that nobody can write from memory: HuJSON, Tailscale's grammar, // and every rule keyed to user, tag and host names that only this server knows. The gap this closes is not // "typing is slow", it is "I do not know what the file is supposed to look like". // // THREE THINGS THIS DELIBERATELY DOES NOT DO. // // 1. **It never saves.** The proposal comes back as text and lands in the editor as a draft. Headscale is // written to by exactly one thing, the Save button, and it is pressed by a person who has read the diff. // A model that could write the ACL directly is a model that can partition the network the owner is // connected through — including the SSH route back in to fix it. // 2. **It never validates.** Same argument as policy.ts: Headscale owns the only parser that counts, and a // proposal that looks fine here and is refused on save is a normal, visible outcome. // 3. **It sends no credentials.** The prompt carries user names, node names and tags — the vocabulary the // rules must reference — and nothing else. No API keys, no pre-auth keys, no node addresses. // // The current document is sent in full and the reply must be the full replacement, not a patch. Patches // against a hand-formatted HuJSON file are where comments and alignment get silently destroyed, and this // file's whole premise is that those are worth keeping. const MODEL = 'claude-sonnet-5'; const MAX_TOKENS = 8_000; /** A prompt long enough to be an essay is a prompt that should be a conversation. Cheap guard, not a limit. */ const MAX_PROMPT_CHARS = 2_000; /** Enough context to write rules against without pasting an entire large tailnet into the request. */ const MAX_NODES = 60; const SYSTEM = `You are helping the owner of a self-hosted Headscale server edit their tailnet's ACL policy. The policy is a HuJSON document (JSON with // comments and trailing commas) in Tailscale's ACL format: groups, tagOwners, hosts, acls, ssh, autoApprovers. Headscale implements a subset — it has no Tailscale SaaS features such as nodeAttrs postures, and grants are supported only in recent versions, so prefer classic "acls" entries unless the existing document already uses grants. Rules for your reply, in this order: 1. First, one short paragraph of plain English: what you changed and, where it matters, what it now allows or denies. No preamble, no restating the request. 2. Then the COMPLETE new policy document inside a single fenced code block tagged hujson. Not a patch, not an excerpt — the whole file, ready to replace what is there. Preserve the existing document's comments, key order and indentation wherever your change does not touch them; they are hand-maintained and the owner reads this file. Only reference users, tags and hosts that exist in the context given to you, or that you also define in the same document. If the request is ambiguous enough that you would have to guess at something consequential, say so in the paragraph and make the narrower, safer choice rather than asking a question — the owner reviews a diff before anything is saved. If the request cannot be expressed in this policy at all, say why in the paragraph and return the document unchanged in the code block.`; type TailnetContext = { users: string[]; tags: string[]; nodes: string[] }; /** * The vocabulary a usable rule has to be written in: who exists, what tags are in use, what the machines are * called. Best-effort — a server that will not answer these still gets an assistant, just a less informed * one, which beats failing the request over context that is an optimisation. */ async function readContext(userId: number): Promise { const client = await activeClient(userId); if (client instanceof Response) return { users: [], tags: [], nodes: [] }; try { const [userBody, nodeBody] = await Promise.all([client.call('/api/v1/user'), client.call('/api/v1/node')]); const users = arrayField(userBody, 'users') .map((raw) => toUser(raw)?.name) .filter((name): name is string => !!name); const nodes = arrayField(nodeBody, 'nodes').map(toNode); const tags = [...new Set(nodes.flatMap((node) => node.tags))].sort(); const named = nodes.slice(0, MAX_NODES).map((node) => { const owner = node.user?.name ?? 'unknown'; const tagged = node.tags.length ? ` [${node.tags.join(' ')}]` : ''; return `${node.name} (user: ${owner})${tagged}`; }); return { users, tags, nodes: named }; } catch { return { users: [], tags: [], nodes: [] }; } } function buildPrompt(policy: string, request: string, context: TailnetContext): string { const lines = [ 'Current policy document:', '```hujson', policy.trim() || '// (this server has no policy yet)', '```', '', 'This tailnet:', `- users: ${context.users.length ? context.users.join(', ') : '(none)'}`, `- tags in use: ${context.tags.length ? context.tags.join(', ') : '(none)'}`, `- machines: ${context.nodes.length ? context.nodes.join('; ') : '(none)'}`, ]; if (context.nodes.length === MAX_NODES) lines.push(` (first ${MAX_NODES} shown)`); lines.push('', 'Requested change:', request.trim()); return lines.join('\n'); } /** * Split the reply into the explanation and the document. * * The fence is the contract, so a reply without one is a failure to report rather than something to salvage: * feeding half an answer into the editor as if it were a policy is worse than saying the model didn't comply. */ function splitReply(text: string): { explanation: string; policy: string } | null { const match = text.match(/```(?:hujson|json|jsonc)?\s*\n([\s\S]*?)```/); if (!match || !match[1]?.trim()) return null; return { explanation: text.slice(0, match.index).trim(), policy: match[1].replace(/\s+$/, '') }; } /** `POST /_officer/policy/assist {prompt, policy}` → `{explanation, policy}`. Nothing is written upstream. */ export async function handlePolicyAssistRoute(ctx: OfficerContext, rest: string[]): Promise { if (rest.length > 0) return badRequest('unexpected path'); if (ctx.req.method !== 'POST') return methodNotAllowed(); const body = await readJson(ctx.req); const request = typeof body?.prompt === 'string' ? body.prompt.trim() : ''; if (!request) return badRequest('prompt is required'); if (request.length > MAX_PROMPT_CHARS) return badRequest(`prompt must be under ${MAX_PROMPT_CHARS} characters`); // The draft on screen, not the saved document: the owner may have edited it, and a proposal built against // a version they cannot see would come back as a diff full of changes they never asked for. const policy = typeof body?.policy === 'string' ? body.policy : ''; const context = await readContext(ctx.userId); try { const reply = await askClaude({ model: MODEL, maxTokens: MAX_TOKENS, system: SYSTEM, prompt: buildPrompt(policy, request, context), }); const split = splitReply(reply); if (!split) { return Response.json({ error: 'the model did not return a policy document — try rephrasing' }, { status: 502 }); } return Response.json({ explanation: split.explanation, policy: split.policy }); } catch (err) { if (err instanceof ProxyUnavailable) { return Response.json({ error: err.message, code: 'assistant_unavailable' }, { status: 503 }); } throw err; } }