feat(#1053): permissions tab — capabilities UI + move tool-groups
Add a new P3RM1SS10NS tab to the dashboard that consolidates all
per-agent permission configuration:
Backend:
- GET /api/capabilities returns { caps: [...], assignments: {...} }
driven by Capability::ALL variants (manage_root_agent,
read_host_journal, query_agent_state)
- POST /api/capabilities/{agent} writes capabilities.json and queues
a rebuild so HIVE_CAPABILITIES takes effect
Frontend:
- New 'permissions' entry in TABS, placed after 'system'
- P3RM1SS10NS tab pane with two sections:
C4P4B1L1T13S — agents × capabilities checkbox matrix (.cap-*)
T00L GR0UPS — agents × tool-groups checkbox matrix (.tg-*) moved
from SYST3M tab
- activateTab('permissions') fetches both tables; neither has an SSE
channel so they re-fetch on each activation to stay fresh
- CSS for .cap-* mirrors the .tg-* layout (scrollable, Catppuccin)
This commit is contained in:
parent
9d11e5b6d6
commit
013e8740bd
4 changed files with 252 additions and 7 deletions
|
|
@ -2047,8 +2047,57 @@ body.logs-shell {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── capabilities (permissions) table ─────────────────────────────
|
||||||
|
Agents × capabilities matrix in the P3RM1SS10NS tab. Same layout
|
||||||
|
as the tool-groups table below. Horizontally scrollable on narrow
|
||||||
|
viewports. */
|
||||||
|
.cap-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
.cap-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.82em;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
.cap-table th,
|
||||||
|
.cap-table td {
|
||||||
|
padding: 0.35em 0.6em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.cap-table thead th {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cap-agent-col {
|
||||||
|
text-align: left !important;
|
||||||
|
min-width: 8em;
|
||||||
|
}
|
||||||
|
.cap-agent-name {
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cap-cb {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
accent-color: var(--purple);
|
||||||
|
}
|
||||||
|
.cap-save-btn {
|
||||||
|
font-size: 0.78em;
|
||||||
|
padding: 0.2em 0.6em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cap-row:hover td {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── tool-groups (permissions) table ──────────────────────────────
|
/* ─── tool-groups (permissions) table ──────────────────────────────
|
||||||
Agents × tool-groups matrix in the SYST3M tab. Horizontally
|
Agents × tool-groups matrix in the P3RM1SS10NS tab. Horizontally
|
||||||
scrollable on narrow viewports. */
|
scrollable on narrow viewports. */
|
||||||
.tg-table-wrap {
|
.tg-table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,14 @@
|
||||||
<span class="tab-label">◆ SYST3M ◆</span>
|
<span class="tab-label">◆ SYST3M ◆</span>
|
||||||
<span class="tab-count" id="tab-count-system" hidden></span>
|
<span class="tab-count" id="tab-count-system" hidden></span>
|
||||||
</a>
|
</a>
|
||||||
|
<!-- P3RM1SS10NS: per-agent capability grants and tool-group
|
||||||
|
assignments. Both tables are fetched on tab activation. -->
|
||||||
|
<a class="tab" id="tab-permissions" href="#permissions" role="tab"
|
||||||
|
aria-controls="tab-pane-permissions"
|
||||||
|
data-tab="permissions">
|
||||||
|
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- SCH3DUL3S: scheduled-prompts surface. List of queued
|
<!-- SCH3DUL3S: scheduled-prompts surface. List of queued
|
||||||
schedules + an operator-direct creation form. Count pill
|
schedules + an operator-direct creation form. Count pill
|
||||||
mirrors the active (non-cancelled) schedule count; hidden
|
mirrors the active (non-cancelled) schedule count; hidden
|
||||||
|
|
@ -172,6 +180,22 @@
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P3RM1SS10NS: per-agent capability grants + tool-group
|
||||||
|
assignments. Both tables are column-driven from the backend
|
||||||
|
(GET /api/capabilities, GET /api/tool-groups) so new entries
|
||||||
|
require no UI change. Saving POSTs to the respective
|
||||||
|
/{agent} endpoint and queues a rebuild. -->
|
||||||
|
<section class="tab-pane" id="tab-pane-permissions"
|
||||||
|
role="tabpanel" aria-labelledby="tab-permissions">
|
||||||
|
<h2>◆ C4P4B1L1T13S ◆</h2>
|
||||||
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
|
<p class="meta">per-agent capability grants. capabilities unlock gated MCP tools and system access. saving queues a rebuild for the affected agent.</p>
|
||||||
|
<div id="capabilities-section">
|
||||||
|
<p class="meta">loading…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- T00L GR0UPS: per-agent tool-group permission matrix. Rows = agents,
|
<!-- T00L GR0UPS: per-agent tool-group permission matrix. Rows = agents,
|
||||||
cols = tool groups fetched from GET /api/tool-groups.
|
cols = tool groups fetched from GET /api/tool-groups.
|
||||||
Checking / unchecking and saving POSTs to
|
Checking / unchecking and saving POSTs to
|
||||||
|
|
|
||||||
|
|
@ -1178,6 +1178,121 @@ window.marked = marked;
|
||||||
// each save. Groups (columns) come from the backend so the UI doesn't
|
// each save. Groups (columns) come from the backend so the UI doesn't
|
||||||
// need updating when a new group is added.
|
// need updating when a new group is added.
|
||||||
|
|
||||||
|
async function fetchAndRenderCapabilities() {
|
||||||
|
const root = $('capabilities-section');
|
||||||
|
if (!root) return;
|
||||||
|
root.replaceChildren();
|
||||||
|
root.append(el('p', { class: 'meta' }, 'loading…'));
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/capabilities');
|
||||||
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||||
|
const data = await resp.json();
|
||||||
|
renderCapabilities(root, data);
|
||||||
|
} catch (err) {
|
||||||
|
root.replaceChildren();
|
||||||
|
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCapabilities(root, data) {
|
||||||
|
root.replaceChildren();
|
||||||
|
const { caps, assignments } = data;
|
||||||
|
if (!caps || !caps.length) {
|
||||||
|
root.append(el('p', { class: 'meta' }, '(no capabilities defined)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent names: union of live containers + keys already in assignments.
|
||||||
|
const agentNames = [...new Set([
|
||||||
|
...Array.from(containersState.keys()),
|
||||||
|
...Object.keys(assignments),
|
||||||
|
])].sort();
|
||||||
|
|
||||||
|
if (!agentNames.length) {
|
||||||
|
root.append(el('p', { class: 'meta' }, '(no agents)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrap = el('div', { class: 'cap-table-wrap' });
|
||||||
|
const table = el('table', { class: 'cap-table' });
|
||||||
|
|
||||||
|
// Header row.
|
||||||
|
const thead = el('thead');
|
||||||
|
const hrow = el('tr');
|
||||||
|
hrow.append(el('th', { class: 'cap-agent-col' }, 'agent'));
|
||||||
|
for (const c of caps) {
|
||||||
|
hrow.append(el('th', { class: 'cap-cap-col', title: c }, c.replace(/_/g, '_')));
|
||||||
|
}
|
||||||
|
hrow.append(el('th', { class: 'cap-save-col' }, ''));
|
||||||
|
thead.append(hrow);
|
||||||
|
table.append(thead);
|
||||||
|
|
||||||
|
const tbody = el('tbody');
|
||||||
|
for (const name of agentNames) {
|
||||||
|
const assigned = assignments[name] || [];
|
||||||
|
const tr = el('tr', { class: 'cap-row' });
|
||||||
|
|
||||||
|
// Agent name cell.
|
||||||
|
tr.append(el('td', { class: 'cap-agent-col' },
|
||||||
|
el('span', { class: 'cap-agent-name' }, name)));
|
||||||
|
|
||||||
|
// One checkbox per capability.
|
||||||
|
const checkboxes = [];
|
||||||
|
for (const c of caps) {
|
||||||
|
const checked = assigned.includes(c);
|
||||||
|
const td = el('td', { class: 'cap-cap-col' });
|
||||||
|
const cb = el('input', {
|
||||||
|
type: 'checkbox',
|
||||||
|
class: 'cap-cb',
|
||||||
|
'data-cap': c,
|
||||||
|
'aria-label': c,
|
||||||
|
});
|
||||||
|
cb.checked = checked;
|
||||||
|
td.append(cb);
|
||||||
|
tr.append(td);
|
||||||
|
checkboxes.push(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save button cell.
|
||||||
|
const saveTd = el('td', { class: 'cap-save-col' });
|
||||||
|
const saveBtn = el('button', { type: 'button', class: 'btn cap-save-btn' }, 'save');
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
const selectedCaps = checkboxes
|
||||||
|
.filter((cb) => cb.checked)
|
||||||
|
.map((cb) => cb.dataset.cap);
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = '…';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/capabilities/' + encodeURIComponent(name), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ caps: selectedCaps }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const txt = await r.text();
|
||||||
|
saveBtn.textContent = 'err';
|
||||||
|
saveBtn.title = txt;
|
||||||
|
} else {
|
||||||
|
saveBtn.textContent = '✓';
|
||||||
|
setTimeout(fetchAndRenderCapabilities, 800);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
saveBtn.textContent = 'err';
|
||||||
|
saveBtn.title = String(err);
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
saveTd.append(saveBtn);
|
||||||
|
tr.append(saveTd);
|
||||||
|
|
||||||
|
tbody.append(tr);
|
||||||
|
}
|
||||||
|
table.append(tbody);
|
||||||
|
wrap.append(table);
|
||||||
|
root.append(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAndRenderToolGroups() {
|
async function fetchAndRenderToolGroups() {
|
||||||
const root = $('tool-groups-section');
|
const root = $('tool-groups-section');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
|
@ -3463,7 +3578,7 @@ window.marked = marked;
|
||||||
// (`/flow.html`) reached via the tab-strip link. Tab routing only
|
// (`/flow.html`) reached via the tab-strip link. Tab routing only
|
||||||
// applies when the tab DOM is present (e.g. not on the flow page
|
// applies when the tab DOM is present (e.g. not on the flow page
|
||||||
// itself, where these elements don't exist and the loop no-ops).
|
// itself, where these elements don't exist and the loop no-ops).
|
||||||
const TABS = ['swarm', 'call', 'system', 'schedules', 'peers', 'settings'];
|
const TABS = ['swarm', 'call', 'system', 'permissions', 'schedules', 'peers', 'settings'];
|
||||||
function activateTab(name) {
|
function activateTab(name) {
|
||||||
const target = TABS.includes(name) ? name : TABS[0];
|
const target = TABS.includes(name) ? name : TABS[0];
|
||||||
for (const t of TABS) {
|
for (const t of TABS) {
|
||||||
|
|
@ -3483,9 +3598,12 @@ window.marked = marked;
|
||||||
// Schedules pane has no SSE channel for mutations, so re-fetch
|
// Schedules pane has no SSE channel for mutations, so re-fetch
|
||||||
// on activation so the operator never lands on stale data.
|
// on activation so the operator never lands on stale data.
|
||||||
if (target === 'schedules') refreshSchedules();
|
if (target === 'schedules') refreshSchedules();
|
||||||
// Capabilities table is on the system pane; fetch on each activation
|
// Permissions tables (capabilities + tool-groups) have no SSE channel;
|
||||||
// so it stays fresh without an SSE channel.
|
// fetch both on each activation so the operator sees fresh data.
|
||||||
if (target === 'system') fetchAndRenderToolGroups();
|
if (target === 'permissions') {
|
||||||
|
fetchAndRenderCapabilities();
|
||||||
|
fetchAndRenderToolGroups();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// ─── tabbar overflow menu ────────────────────────────────────────────────
|
// ─── tabbar overflow menu ────────────────────────────────────────────────
|
||||||
// Tabs with `data-overflow="default"` (LOGS, SETTINGS) always live in
|
// Tabs with `data-overflow="default"` (LOGS, SETTINGS) always live in
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/api/topology/set-parent", post(post_set_parent))
|
.route("/api/topology/set-parent", post(post_set_parent))
|
||||||
.route("/api/tool-groups", get(get_tool_groups))
|
.route("/api/tool-groups", get(get_tool_groups))
|
||||||
.route("/api/tool-groups/{agent}", post(post_tool_groups))
|
.route("/api/tool-groups/{agent}", post(post_tool_groups))
|
||||||
|
.route("/api/capabilities", get(get_capabilities))
|
||||||
|
.route("/api/capabilities/{agent}", post(post_capabilities))
|
||||||
.route("/op-send", post(post_op_send))
|
.route("/op-send", post(post_op_send))
|
||||||
.route("/meta-update", post(post_meta_update))
|
.route("/meta-update", post(post_meta_update))
|
||||||
.route("/api/schedules", get(api_schedules).post(post_schedule_new))
|
.route("/api/schedules", get(api_schedules).post(post_schedule_new))
|
||||||
|
|
@ -2503,7 +2505,7 @@ async fn post_set_parent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── tool-group (capabilities) endpoints ──────────────────────────────────
|
// ── tool-group endpoints ──────────────────────────────────
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct ToolGroupsSnapshot {
|
struct ToolGroupsSnapshot {
|
||||||
|
|
@ -2549,7 +2551,7 @@ async fn post_tool_groups(
|
||||||
crate::rebuild_queue::QueueKind::Rebuild,
|
crate::rebuild_queue::QueueKind::Rebuild,
|
||||||
logical.clone(),
|
logical.clone(),
|
||||||
crate::rebuild_queue::QueueSource::Manual,
|
crate::rebuild_queue::QueueSource::Manual,
|
||||||
"tool-group change via capabilities UI".to_owned(),
|
"tool-group change via permissions UI".to_owned(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
state.coord.emit_rebuild_queue_snapshot();
|
state.coord.emit_rebuild_queue_snapshot();
|
||||||
|
|
@ -2557,6 +2559,58 @@ async fn post_tool_groups(
|
||||||
(StatusCode::OK, "ok").into_response()
|
(StatusCode::OK, "ok").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── capability endpoints ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CapabilitiesSnapshot {
|
||||||
|
/// Ordered list of all known capability names. Drives the column
|
||||||
|
/// headers in the capabilities table — the UI does not hard-code them.
|
||||||
|
caps: Vec<&'static str>,
|
||||||
|
/// Per-agent capability grant map. Absent agents have no extra caps.
|
||||||
|
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_capabilities(State(_state): State<AppState>) -> axum::Json<CapabilitiesSnapshot> {
|
||||||
|
use hive_sh4re::Capability;
|
||||||
|
let caps = vec![
|
||||||
|
Capability::ManageRootAgent.as_str(),
|
||||||
|
Capability::ReadHostJournal.as_str(),
|
||||||
|
Capability::QueryAgentState.as_str(),
|
||||||
|
];
|
||||||
|
let assignments = crate::capabilities::read();
|
||||||
|
axum::Json(CapabilitiesSnapshot { caps, assignments })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SetCapabilitiesBody {
|
||||||
|
caps: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_capabilities(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
axum::Json(body): axum::Json<SetCapabilitiesBody>,
|
||||||
|
) -> Response {
|
||||||
|
let logical = strip_container_prefix(&name);
|
||||||
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::capabilities::set_caps(&logical, &body.caps) {
|
||||||
|
return error_response(&format!("set capabilities for {logical}: {e}"));
|
||||||
|
}
|
||||||
|
// Trigger a rebuild so the new HIVE_CAPABILITIES env var takes effect.
|
||||||
|
state.coord.rebuild_queue.enqueue(
|
||||||
|
crate::rebuild_queue::QueueKind::Rebuild,
|
||||||
|
logical.clone(),
|
||||||
|
crate::rebuild_queue::QueueSource::Manual,
|
||||||
|
"capability change via dashboard".to_owned(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
state.coord.emit_rebuild_queue_snapshot();
|
||||||
|
tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities via dashboard");
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
||||||
let logical = strip_container_prefix(&name);
|
let logical = strip_container_prefix(&name);
|
||||||
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue