From 217d5ed29a3076516ddd0a30f9d18f3757e8bca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 6 Aug 2026 01:23:54 +0000 Subject: [PATCH] headscale: describe an acl change in english, read the diff, then save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy editor now opens read-only behind an Edit button, and edit mode brings up an assistant: describe the change, get a complete revised document back, read a line diff of it, and only then press Save. Nobody writes an ACL from memory — it is HuJSON in Tailscale's grammar keyed to names only the server knows — and that, not typing speed, is what made this screen unusable. The model never touches Headscale. It proposes text, the text lands in the editor's draft, and the existing Save button is still the only thing that leaves the browser. A model that could write the ACL directly is one that can partition the network the owner is connected through. The sidecar calls officer-anthropic-proxy on loopback for one request with a timeout — no session, no agent, nothing persisted. It sends the draft on screen plus the tailnet's vocabulary (user, node and tag names) and no credentials of any kind. It does not validate the reply either: Headscale owns the only parser that counts, same argument as policy.ts. --- src/servers/sidecar/headscale/assist.ts | 153 +++++++++++++++ src/servers/sidecar/headscale/claude-proxy.ts | 97 ++++++++++ src/servers/sidecar/headscale/policy.ts | 5 +- .../src/apps/Headscale/PolicyAssistant.tsx | 177 ++++++++++++++++++ .../src/apps/Headscale/PolicyView.tsx | 70 +++++-- .../officerdev/src/apps/Headscale/diff.ts | 77 ++++++++ .../src/apps/Headscale/useHeadscalePolicy.ts | 26 +++ 7 files changed, 591 insertions(+), 14 deletions(-) create mode 100644 src/servers/sidecar/headscale/assist.ts create mode 100644 src/servers/sidecar/headscale/claude-proxy.ts create mode 100644 src/workspaces/officerdev/src/apps/Headscale/PolicyAssistant.tsx create mode 100644 src/workspaces/officerdev/src/apps/Headscale/diff.ts diff --git a/src/servers/sidecar/headscale/assist.ts b/src/servers/sidecar/headscale/assist.ts new file mode 100644 index 00000000..c2d220fc --- /dev/null +++ b/src/servers/sidecar/headscale/assist.ts @@ -0,0 +1,153 @@ +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; + } +} diff --git a/src/servers/sidecar/headscale/claude-proxy.ts b/src/servers/sidecar/headscale/claude-proxy.ts new file mode 100644 index 00000000..5219dd55 --- /dev/null +++ b/src/servers/sidecar/headscale/claude-proxy.ts @@ -0,0 +1,97 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent. +// +// The target is `officer-anthropic-proxy` on loopback — the same process Claude Code itself talks to. It +// holds the owner's OAuth credential and refreshes it; callers hold nothing. Its `x-api-key` is a locally +// generated secret it writes to its own state file, so authenticating is a file read, not a credential this +// sidecar is given. That file is written by the proxy and read by everyone else; see claude/state.ts +// (`readProxySecretFromDisk`), which does the same thing for the agent. +// +// Not imported from claude/state.ts on purpose: that module initialises paths and a lock for a sidecar this +// one is not. Twenty lines of file read is a better dependency than another sidecar's lifecycle. +// +// This is a REQUEST-SCOPED call with a timeout, not a session. Anything conversational belongs in the chat +// surface, which already exists and already persists. + +const PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051'; +const DATA_PATH = process.env.DATA_PATH ?? ''; +const DEFAULT_TIMEOUT_MS = 120_000; + +/** The proxy is not running, has no token, or refused us. Distinct from the model declining to answer. */ +export class ProxyUnavailable extends Error {} + +/** + * The proxy's own generated secret. Empty means "not on disk yet" — it persists on a debounce, so a + * freshly installed machine has a window where the file exists without it. + */ +function readProxySecret(): string { + try { + const file = join(DATA_PATH, 'sidecar', 'claude-state.json'); + if (!existsSync(file)) return ''; + const parsed = JSON.parse(readFileSync(file, 'utf-8')) as { proxySecret?: unknown }; + return typeof parsed.proxySecret === 'string' ? parsed.proxySecret : ''; + } catch { + return ''; + } +} + +type AskParams = { model: string; system: string; prompt: string; maxTokens: number; timeoutMs?: number }; + +type MessagesResponse = { content?: { type?: string; text?: string }[]; error?: { message?: string } }; + +/** + * One user turn, one reply, as plain text. + * + * The system prompt is sent as two blocks with Claude Code's own identity first. The proxy authenticates + * with a Claude Pro/Max OAuth token, and that credential is issued to the CLI — asking it to be something + * else is a request the upstream is entitled to refuse. (Measured 2026-08-06: a plain assistant prompt is + * currently accepted too. Keeping the block costs ~14 tokens and removes the question.) + */ +export async function askClaude({ model, system, prompt, maxTokens, timeoutMs }: AskParams): Promise { + const secret = readProxySecret(); + if (!secret) throw new ProxyUnavailable('the Claude proxy has not started yet — try again in a moment'); + + let res: Response; + try { + res = await fetch(`http://127.0.0.1:${PROXY_PORT}/v1/messages`, { + method: 'POST', + headers: { 'x-api-key': secret, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + system: [ + { type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." }, + { type: 'text', text: system }, + ], + messages: [{ role: 'user', content: prompt }], + }), + signal: AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS), + }); + } catch (err) { + if (err instanceof Error && err.name === 'TimeoutError') throw new ProxyUnavailable('the model took too long'); + throw new ProxyUnavailable('the Claude proxy is not reachable'); + } + + const body = (await res.json().catch(() => null)) as MessagesResponse | null; + + if (!res.ok) { + const detail = body?.error?.message; + // 401/429 are the proxy's own credential problems and read as "unavailable"; anything else is upstream + // saying something specific about this request, which is worth passing through. + if (res.status === 401 || res.status === 429) { + throw new ProxyUnavailable(detail ?? `the Claude proxy returned ${res.status}`); + } + throw new Error(detail ?? `the model returned ${res.status}`); + } + + const text = (body?.content ?? []) + .filter((block) => block.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) + .join('') + .trim(); + + if (!text) throw new Error('the model returned an empty reply'); + return text; +} diff --git a/src/servers/sidecar/headscale/policy.ts b/src/servers/sidecar/headscale/policy.ts index 38dde6da..8a5a3bd2 100644 --- a/src/servers/sidecar/headscale/policy.ts +++ b/src/servers/sidecar/headscale/policy.ts @@ -2,6 +2,7 @@ import type { OfficerContext } from './routes'; import { badRequest, methodNotAllowed, readJson } from './routes'; import { HeadscaleError } from './client'; import { activeClient } from './active'; +import { handlePolicyAssistRoute } from './assist'; // The ACL policy — /_officer/policy. One HuJSON document that decides which node may reach which, so it is // the highest-consequence thing this app can write and the only place a typo silently partitions a network. @@ -103,8 +104,10 @@ async function putPolicy(ctx: OfficerContext): Promise { } } -/** Dispatch `/_officer/policy`. No sub-paths: there is exactly one policy per server. */ +/** Dispatch `/_officer/policy`. One policy per server, plus the drafting assistant beside it. */ export async function handlePolicyRoute(ctx: OfficerContext, rest: string[]): Promise { + // `/policy/assist` proposes a document; it never writes one. See assist.ts. + if (rest[0] === 'assist') return handlePolicyAssistRoute(ctx, rest.slice(1)); if (rest.length > 0) return badRequest('unexpected path'); if (ctx.req.method === 'GET') return getPolicy(ctx); if (ctx.req.method === 'PUT') return putPolicy(ctx); diff --git a/src/workspaces/officerdev/src/apps/Headscale/PolicyAssistant.tsx b/src/workspaces/officerdev/src/apps/Headscale/PolicyAssistant.tsx new file mode 100644 index 00000000..4bfed3f9 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/PolicyAssistant.tsx @@ -0,0 +1,177 @@ +import { useMemo, useState } from 'react'; +import { Check, Loader2, Sparkles, Wand2, X } from 'lucide-react'; +import { useHeadscalePolicyAssist, assistFailure } from './useHeadscalePolicy'; +import { collapseUnchanged, diffCounts, diffLines } from './diff'; +import { Button, Card, ErrorNote } from './Cards'; + +// Ask for a policy change in English; read the diff; decide. +// +// The whole point of this panel is the middle step. The model is good at the grammar — HuJSON, tagOwners, +// the src/dst shapes — and has no idea which of the owner's machines matter, so its proposal is a draft to +// be read, not an answer to be trusted. Nothing here writes to Headscale: Apply puts the text in the editor +// above and the existing Save button is still the only thing that leaves the browser. +// +// The diff is against what is CURRENTLY in the editor, which is also what was sent up, so it always shows +// exactly what accepting would change on screen — including edits the owner made and hasn't saved. + +const EXAMPLES = [ + 'let everyone reach the machines tagged tag:server on port 22', + 'stop the phones from reaching anything except the DNS server', + 'add a group for family with just my own user in it', +]; + +const DiffBody = ({ before, after }: { before: string; after: string }) => { + const lines = useMemo(() => diffLines(before, after), [before, after]); + const rows = useMemo(() => collapseUnchanged(lines), [lines]); + const { added, removed } = useMemo(() => diffCounts(lines), [lines]); + + if (!added && !removed) { + return

No change — the proposal matches what you have.

; + } + + return ( + <> +
+ +{added} + −{removed} + unchanged lines collapsed +
+
+ {rows.map((row, index) => + row === null ? ( +
+ ⋯ +
+ ) : ( +
+ {row.kind === 'add' ? '+' : row.kind === 'remove' ? '−' : ' '} {row.text} +
+ ), + )} +
+ + ); +}; + +type PolicyAssistantProps = { + /** The text on screen right now. Sent up as the base, and diffed against. */ + policy: string; + /** Accepting a proposal — puts it in the editor's draft. Never saves. */ + onApply: (policy: string) => void; + disabled?: boolean; +}; + +export const PolicyAssistant = ({ policy, onApply, disabled }: PolicyAssistantProps) => { + const assist = useHeadscalePolicyAssist(); + const [prompt, setPrompt] = useState(''); + // The proposal lives here, not in the mutation's `data`, so that discarding it is a real state change and + // a second ask doesn't briefly show the previous answer against the new base. + const [proposal, setProposal] = useState<{ explanation: string; policy: string } | null>(null); + + const ask = async () => { + const request = prompt.trim(); + if (!request || assist.isPending) return; + setProposal(null); + try { + setProposal(await assist.mutateAsync({ prompt: request, policy })); + } catch { + // Rendered from `assist.error` below — mutateAsync rejecting is the same failure twice. + } + }; + + const apply = () => { + if (!proposal) return; + onApply(proposal.policy); + setProposal(null); + setPrompt(''); + assist.reset(); + }; + + return ( + +
+ + Describe the change + Proposes a document — never saves it +
+ +
+