Watch
0
0
Fork
You've already forked hyperhive
0

agent permissions: one set, tool groups included, set at agent creation

mara on #5170: "dont split caps and perms. its all perms. / dont include
the now empty tool groups / nothing set -> default / also add perms to
agent creation screen".

- hive-sh4re: one `Permission` enum (messaging, meta, inbox, execution,
  web_tools, manage_root_agent) replaces `ToolGroup` and `Capability`;
  `lifecycle`/`forge` are gone. `Permission::default_for` is the default
  for an agent the swarm sets nothing for.
- The record is `{ "permissions": [...] }`. Missing key, unset field or
  no successful read yet = default; `[]` = none; a failed read after a
  good one keeps the last value.
- hive-agent reads its own key every 10s and builds each turn's --tools,
  --allowedTools and MCP config (rewritten before every turn) from it;
  the subagent daemon reads it on each start/continue. Agents get a
  read-only grant on their own key.
- hive-c0re restarts an agent only when its manage_root_agent changes.
  The import now merges meta/capabilities.json and meta/tool-groups.json
  into one list per agent and deletes both; the "import pending, don't
  read" decision is `ImportOutcome::read_allowed`, table-tested.
- swarm-controller: `GET /api/permissions` (names + default) and an
  optional `permissions` on `POST /api/agents`, stored before the job is
  queued. swarm-ui's create-agent form gets a permissions field.
- Deleted: HIVE_TOOL_GROUPS and its forward to the subagent daemon,
  tool_groups.rs, ruth's tool-group seed, the permissions rebuild job
  (PermPayload/WritePermFile/perm_change), the dashboard permissions tab
  and its stale-entries panel.

Part of #5170.
This commit is contained in:
atlas 2026-10-15 04:09:53 +02:00 • committed by mara
commit f0844f6da3
73 changed files with 1578 additions and 2742 deletions

View file

@ -25,6 +25,9 @@
// Its name field, hive select, and submit button all come from the
// shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than
// page-scoped input/button chrome — see that kit's own comments for why.
//
// Permissions: checkboxes from `GET /api/permissions`, on the default set;
// sent only when changed, so an untouched agent follows the default.
import { useEffect, useState } from "preact/hooks";
import { Link } from "wouter-preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
@ -44,6 +47,12 @@ import {
InferenceFields,
type InferenceDraft,
} from "../agent/detail/AgentInference.js";
import { FormField } from "../ui/form-field/FormField.js";
import { MultiselectFilterOptions } from "../ui/multiselect-filter/MultiselectFilter.js";
import {
requestPermissions,
togglePermission,
} from "./CreateAgentPermissions.js";
import "./CreateAgentForm.css";
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
@ -59,6 +68,12 @@ interface CreateAgentResponse {
node_id: number;
}
// Mirrors swarm-controller's `Catalogue` (`GET /api/permissions`).
interface PermissionCatalogue {
all: string[];
default: string[];
}
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
@ -103,6 +118,12 @@ export function CreateAgentForm({
EMPTY_INFERENCE_DRAFT,
);
const inferenceProblem = draftProblem(inference);
// `null` until `/api/permissions` answers; `selected` starts on its default.
const [catalogue, setCatalogue] = useState<PermissionCatalogue | null>(null);
const [catalogueError, setCatalogueError] = useState<ProblemDetails | null>(
null,
);
const [selected, setSelected] = useState<string[]>([]);
// Loaded once, not re-fetched on submit: the roster changes rarely
// enough (a nix-level swarm config change) that staleness within one
@ -124,6 +145,19 @@ export function CreateAgentForm({
})().catch((e: unknown) => setHivesError({ detail: String(e) }));
}, []);
useEffect(() => {
(async () => {
const r = await fetch("/api/permissions");
if (!r.ok) {
setCatalogueError(await readApiError(r));
return;
}
const data = (await r.json()) as PermissionCatalogue;
setCatalogue(data);
setSelected(data.default);
})().catch((e: unknown) => setCatalogueError({ detail: String(e) }));
}, []);
const hiveOptions: SelectOption[] = (hives ?? []).map((h) => ({
value: h.name,
label: h.name,
@ -146,6 +180,9 @@ export function CreateAgentForm({
hive,
...(Object.keys(limits).length > 0 ? { limits } : {}),
...(Object.keys(settings).length > 0 ? { inference: settings } : {}),
permissions: catalogue
? requestPermissions(selected, catalogue.default)
: undefined,
}),
});
if (!r.ok) {
@ -155,6 +192,7 @@ export function CreateAgentForm({
const data = (await r.json()) as CreateAgentResponse;
setResult({ status: "done", nodeId: data.node_id });
setName("");
if (catalogue) setSelected(catalogue.default);
} catch (err) {
setResult({ status: "error", problem: { detail: String(err) } });
}
@ -176,6 +214,12 @@ export function CreateAgentForm({
problem={hivesError}
/>
)}
{catalogueError && (
<ApiErrorPanel
context="failed to load the permission list"
problem={catalogueError}
/>
)}
<form class="create-agent-form" onSubmit={submit}>
<TextField
id="agent-name"
@ -202,6 +246,19 @@ export function CreateAgentForm({
: "select a hive"
}
/>
<FormField label="permissions" htmlFor="agent-permissions">
<div id="agent-permissions">
{catalogue ? (
<MultiselectFilterOptions
options={catalogue.all}
filter={{ values: selected, negate: false }}
onToggle={(v) => setSelected((s) => togglePermission(s, v))}
/>
) : (
<span>loading…</span>
)}
</div>
</FormField>
<details class="create-agent-section">
<summary>resource caps</summary>
<div class="create-agent-section-body">
@ -230,6 +287,7 @@ export function CreateAgentForm({
disabled={
result.status === "submitting" ||
!hivesReady ||
!catalogue ||
inferenceProblem !== null
}
>

View file

@ -0,0 +1,24 @@
// The create-agent form's permissions logic, kept out of the component so
// `npm test` can run it.
/** `selected` with `value` added, or removed when it is there. */
export function togglePermission(selected: string[], value: string): string[] {
return selected.includes(value)
? selected.filter((p) => p !== value)
: [...selected, value];
}
/**
* The `permissions` to send with a create: `undefined` while `selected` is
* the default set (in any order), so the agent's key stays unset and follows
* the default; otherwise the list, which may be empty.
*/
export function requestPermissions(
selected: string[],
defaults: string[],
): string[] | undefined {
const same =
selected.length === defaults.length &&
selected.every((p) => defaults.includes(p));
return same ? undefined : selected;
}

View file

@ -0,0 +1,31 @@
// The create-agent form's permissions field: what a toggle does and what the
// create request carries. Run by `npm test` with Node's type stripping.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
requestPermissions,
togglePermission,
} from "../src/pages/CreateAgentPermissions.ts";
const defaults = ["messaging", "meta", "inbox", "execution"];
test("a toggle adds a missing permission and removes a present one", () => {
assert.deepEqual(togglePermission(["meta"], "inbox"), ["meta", "inbox"]);
assert.deepEqual(togglePermission(["meta", "inbox"], "meta"), ["inbox"]);
});
test("the default set, in any order, sends no permissions", () => {
assert.equal(
requestPermissions([...defaults].reverse(), defaults),
undefined,
);
});
test("a changed set is sent, an empty one included", () => {
assert.deepEqual(requestPermissions(["messaging"], defaults), ["messaging"]);
assert.deepEqual(requestPermissions([], defaults), []);
assert.deepEqual(requestPermissions([...defaults, "web_tools"], defaults), [
...defaults,
"web_tools",
]);
});