feat(settings): admin switch to allow or block members' test runs
Settings → AI tools → Tests on this server (admin, S3 only). Stored in /opt/agenthub/app/member-policy.json, which member-guard.js reads as the member at each Bash call — no restart; no file = blocked. GET/PUT /api/ai-tools/member-policy, audited, 409 ENOS3 outside S3.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file MemberPolicySettings — Settings → AI tools → Tests on this server:
|
||||
* the admin's server-wide switch that allows or blocks members' Claude from
|
||||
* running test runners and container commands on this box
|
||||
* (`GET/PUT /api/ai-tools/member-policy`, `server/lib/member-policy.js`,
|
||||
* enforced by `scripts/member-guard.js`). Renders nothing outside server mode
|
||||
* with per-user accounts (S3), or for anyone but an admin.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type MemberPolicyState } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function MemberPolicySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isAdmin, local } = usePermissions();
|
||||
const [state, setState] = useState<MemberPolicyState | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (local || !isAdmin) return;
|
||||
api.aiTools
|
||||
.memberPolicy()
|
||||
.then(setState)
|
||||
.catch(() => setState(null));
|
||||
}, [local, isAdmin]);
|
||||
|
||||
if (local || !isAdmin || !state?.available) return null;
|
||||
return (
|
||||
<SettingsSection id="member-policy" title={t("memberPolicy.title")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("memberPolicy.switch")}
|
||||
description={t("memberPolicy.hint")}
|
||||
checked={state.allowTests}
|
||||
disabled={busy}
|
||||
onChange={async (on) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setState(await api.aiTools.setMemberPolicy(on));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file Tests for MemberPolicySettings — the admin's "allow members to run
|
||||
* tests" switch in Settings → AI tools: hidden in local mode, for a non-admin
|
||||
* and outside S3, and a toggle saves through PUT /api/ai-tools/member-policy.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { MemberPolicySettings } from "../MemberPolicySettings";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
const perms = vi.hoisted(() => ({ isAdmin: true, local: false }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { aiTools: { memberPolicy: vi.fn(), setMemberPolicy: vi.fn() } },
|
||||
}));
|
||||
|
||||
const get = vi.mocked(api.aiTools.memberPolicy);
|
||||
const set = vi.mocked(api.aiTools.setMemberPolicy);
|
||||
|
||||
describe("MemberPolicySettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
perms.isAdmin = true;
|
||||
perms.local = false;
|
||||
});
|
||||
|
||||
it("lets an admin allow tests, off by default", async () => {
|
||||
get.mockResolvedValue({ available: true, allowTests: false });
|
||||
set.mockResolvedValue({ available: true, allowTests: true });
|
||||
render(<MemberPolicySettings />);
|
||||
const sw = await screen.findByRole("switch", { name: "Allow members to run tests" });
|
||||
expect(sw).toHaveAttribute("aria-checked", "false");
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => expect(set).toHaveBeenCalledWith(true));
|
||||
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "true"));
|
||||
});
|
||||
|
||||
it("hides outside S3", async () => {
|
||||
get.mockResolvedValue({ available: false, allowTests: false });
|
||||
const { container } = render(<MemberPolicySettings />);
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("asks nothing for a non-admin or in local mode", () => {
|
||||
perms.isAdmin = false;
|
||||
const { container } = render(<MemberPolicySettings />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
perms.isAdmin = true;
|
||||
perms.local = true;
|
||||
render(<MemberPolicySettings />);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1169,6 +1169,11 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"memberPolicy": {
|
||||
"title": "Tests on this server",
|
||||
"switch": "Allow members to run tests",
|
||||
"hint": "Off: members' Claude may not run test runners or container commands here (vitest, jest, phpunit, pytest, npm test, docker, …) — a few at once freeze this shared server, so they test on their own machine. On: they may. Commands typed by hand in a terminal and Codex are not checked either way."
|
||||
},
|
||||
"ompSharing": {
|
||||
"title": "Oh My Pi login sharing",
|
||||
"switch": "Share this server's Oh My Pi login",
|
||||
|
||||
@@ -1157,6 +1157,11 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"memberPolicy": {
|
||||
"title": "Chạy test trên server này",
|
||||
"switch": "Cho phép thành viên chạy test",
|
||||
"hint": "Tắt: Claude của thành viên không được chạy test hay lệnh container ở đây (vitest, jest, phpunit, pytest, npm test, docker, …) — vài lượt chạy cùng lúc là server dùng chung bị đứng, nên họ test trên máy của mình. Bật: được phép chạy. Lệnh tự gõ trong terminal và Codex thì không bị kiểm tra trong cả hai trường hợp."
|
||||
},
|
||||
"ompSharing": {
|
||||
"title": "Dùng chung đăng nhập Oh My Pi",
|
||||
"switch": "Dùng chung đăng nhập Oh My Pi của máy chủ",
|
||||
|
||||
@@ -3782,6 +3782,14 @@ export const api = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
/** GET /api/ai-tools/member-policy - what members' Claude may run (server mode, S3). */
|
||||
memberPolicy: () => request<MemberPolicyState>("/ai-tools/member-policy"),
|
||||
/** PUT /api/ai-tools/member-policy - an admin's switch. */
|
||||
setMemberPolicy: (allowTests: boolean) =>
|
||||
request<MemberPolicyState>("/ai-tools/member-policy", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ allowTests }),
|
||||
}),
|
||||
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
|
||||
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
|
||||
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
|
||||
@@ -5274,6 +5282,13 @@ export interface ArtifactCandidate {
|
||||
}
|
||||
|
||||
/** `GET /api/ai-tools/omp-sharing` — never a token. */
|
||||
export interface MemberPolicyState {
|
||||
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
||||
available: boolean;
|
||||
/** May members' Claude run test runners and container commands? Off by default. */
|
||||
allowTests: boolean;
|
||||
}
|
||||
|
||||
export interface OmpSharingState {
|
||||
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
||||
available: boolean;
|
||||
|
||||
@@ -72,6 +72,7 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||
import { MemberPolicySettings } from "../components/settings/MemberPolicySettings";
|
||||
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
||||
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
|
||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||
@@ -317,6 +318,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<ArtifactsHubSettings />
|
||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||
<OmpSharingSettings />
|
||||
{/* Members' test runs on this box: server mode + S3, an admin's. */}
|
||||
<MemberPolicySettings />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. Under one Linux user per account, a member already running `MAX_AI_SESSIONS` AI sessions (default 4) gets `429 ESESSIONLIMIT` from the same two routes. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. Under one Linux user per account, a member already running `MAX_AI_SESSIONS` AI sessions (default 4) gets `429 ESESSIONLIMIT` from the same two routes. An admin allows or blocks members' test runs with `GET`/`PUT /api/ai-tools/member-policy` (`{available, allowTests}`, `409 ENOS3` outside it). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups and System Health — the one `/api/users` read EVERY signed-in account may make, a developer included, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
|
||||
|
||||
@@ -1179,6 +1179,10 @@ tests run on each member's own machine:
|
||||
`npm run test:*`, `php artisan test`, `go test`, `cargo test`, `docker`,
|
||||
`podman`, … and tells Claude to leave testing to the member's machine.
|
||||
A person typing a command in a Terminal tab, and Codex, are not covered.
|
||||
An admin can allow them again with **Settings → AI tools → Tests on this
|
||||
server** (`PUT /api/ai-tools/member-policy` `{allowTests}`, kept in
|
||||
`/opt/agenthub/app/member-policy.json`, no file = blocked); it takes effect
|
||||
at members' next Bash call, no restart.
|
||||
- **At most 4 AI sessions per member at once** (`MAX_AI_SESSIONS=<n>` in
|
||||
`/etc/agenthub/server.conf`, `0` = no limit). Counted from the account's
|
||||
running processes (`server/lib/ai-procs.js`: `claude`, `codex`, `agy`,
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
*
|
||||
* - `PreToolUse` (matcher Bash): denies a test run or a container command
|
||||
* (`isHeavy`) — the member commits, pulls the branch on their own machine
|
||||
* and runs it there. Claude gets the reason and stops trying.
|
||||
* and runs it there. Claude gets the reason and stops trying. An admin
|
||||
* can allow them again (Settings → AI tools, server/lib/member-policy.js).
|
||||
* - `UserPromptSubmit`: a member runs at most `MAX_AI_SESSIONS` AI sessions
|
||||
* at once (server/lib/ai-procs.js). The dashboard refuses a pane past the
|
||||
* limit itself (ESESSIONLIMIT); this catches a `claude` typed into a
|
||||
@@ -25,6 +26,7 @@ const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const procs = require(path.join(__dirname, "..", "server", "lib", "ai-procs.js"));
|
||||
const policy = require(path.join(__dirname, "..", "server", "lib", "member-policy.js"));
|
||||
|
||||
/** Test runners and container tools, by the program a command segment runs. */
|
||||
const HEAVY_BINS = new Set([
|
||||
@@ -139,8 +141,12 @@ const DENY_REASON =
|
||||
"run them. Finish the change, tell the user which tests to run, and let them " +
|
||||
"commit, pull the branch on their own machine and test there.";
|
||||
|
||||
function guardTool(input) {
|
||||
if (input.tool_name !== "Bash" || !isHeavy(input.tool_input?.command)) return;
|
||||
/** The admin's switch (Settings → AI tools), beside this script in the app root. */
|
||||
const testsAllowed = () =>
|
||||
policy.read(path.join(__dirname, "..", policy.FILE)).allowTests === true;
|
||||
|
||||
function guardTool(input, allowed = testsAllowed()) {
|
||||
if (allowed || input.tool_name !== "Bash" || !isHeavy(input.tool_input?.command)) return;
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
|
||||
@@ -77,6 +77,18 @@ describe("member-guard: a test run or a container command", () => {
|
||||
assert.equal(o.permissionDecision, "deny");
|
||||
assert.match(o.permissionDecisionReason, /own machine/);
|
||||
});
|
||||
|
||||
it("lets everything through once an admin allows tests", () => {
|
||||
const out = [];
|
||||
const write = process.stdout.write;
|
||||
process.stdout.write = (s) => out.push(String(s));
|
||||
try {
|
||||
guardTool({ tool_name: "Bash", tool_input: { command: "npx vitest run" } }, true);
|
||||
} finally {
|
||||
process.stdout.write = write;
|
||||
}
|
||||
assert.equal(out.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
/** A fake /proc: one dir per pid with stat, cmdline; owner is this process (uid). */
|
||||
@@ -93,6 +105,28 @@ function fakeProc(list) {
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("member-policy: the admin's switch, read by the guard", () => {
|
||||
it("blocks tests without a file, and writes a world-readable one", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ah-app-"));
|
||||
const prev = process.env.AGENTHUB_APP_ROOT;
|
||||
process.env.AGENTHUB_APP_ROOT = dir;
|
||||
try {
|
||||
const policy = require("../lib/member-policy.js");
|
||||
assert.equal(policy.read().allowTests, false);
|
||||
fs.writeFileSync(path.join(dir, policy.FILE), "not json");
|
||||
assert.equal(policy.read().allowTests, false);
|
||||
assert.equal(policy.setAllowTests(true).allowTests, true);
|
||||
const f = path.join(dir, policy.FILE);
|
||||
assert.equal(fs.statSync(f).mode & 0o777, 0o644);
|
||||
assert.equal(policy.read(f).allowTests, true);
|
||||
assert.equal(policy.setAllowTests(false).allowTests, false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.AGENTHUB_APP_ROOT;
|
||||
else process.env.AGENTHUB_APP_ROOT = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-procs: a member's top-level AI sessions", () => {
|
||||
const uid = process.getuid();
|
||||
const root = fakeProc([
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file member-policy.js
|
||||
* @description Server mode, one Linux user per account (S3): the admin's
|
||||
* switch for what members' Claude may run on this box — today whether test
|
||||
* runners and container commands are allowed (`allowTests`, off by default).
|
||||
* Kept in `<app root>/member-policy.json` (/opt/agenthub/app, host-owned,
|
||||
* world-readable), because `scripts/member-guard.js` runs AS the member and
|
||||
* reads it from there; a missing or unreadable file means the default, so
|
||||
* tests stay blocked unless an admin turned them on.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const FILE = "member-policy.json";
|
||||
const DEFAULTS = Object.freeze({ allowTests: false });
|
||||
|
||||
const appRoot = () => process.env.AGENTHUB_APP_ROOT || "/opt/agenthub/app";
|
||||
const file = () => path.join(appRoot(), FILE);
|
||||
|
||||
/** The policy in force (defaults for anything missing or unreadable). */
|
||||
function read(p = file()) {
|
||||
try {
|
||||
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
return { allowTests: j?.allowTests === true };
|
||||
} catch {
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
/** Does it apply here? Only S3 runs members' panes as their own accounts. */
|
||||
function available() {
|
||||
try {
|
||||
return require("./linux-accounts").s3Enabled();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function state() {
|
||||
return { available: available(), ...read() };
|
||||
}
|
||||
|
||||
/** Writes the switch (atomically, 0644: members' hooks read it). */
|
||||
function setAllowTests(on) {
|
||||
const next = { ...read(), allowTests: on === true };
|
||||
fs.mkdirSync(appRoot(), { recursive: true });
|
||||
const tmp = `${file()}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o644 });
|
||||
fs.chmodSync(tmp, 0o644);
|
||||
fs.renameSync(tmp, file());
|
||||
return state();
|
||||
}
|
||||
|
||||
module.exports = { FILE, DEFAULTS, read, available, state, setAllowTests };
|
||||
@@ -173,6 +173,43 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/member-policy": {
|
||||
get: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "What members' Claude may run on this server",
|
||||
description:
|
||||
"Server mode with per-user accounts (S3): whether members' Claude may run test runners and container commands (`allowTests`, off by default). Kept in `/opt/agenthub/app/member-policy.json` and enforced by the members' synchronous PreToolUse hook (scripts/member-guard.js).",
|
||||
operationId: "aiToolsMemberPolicy",
|
||||
responses: {
|
||||
200: { description: "`{available, allowTests}`." },
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "Allow or block members' test runs",
|
||||
description:
|
||||
"An admin's switch. Takes effect at the next Bash call of every member's Claude, no restart. Same-origin only; audited.",
|
||||
operationId: "aiToolsSetMemberPolicy",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["allowTests"],
|
||||
properties: { allowTests: { type: "boolean" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "The new state, as GET." },
|
||||
400: { description: "EBADFIELD" },
|
||||
403: { description: "Not an admin" },
|
||||
409: { description: "ENOS3 — not server mode with per-user accounts" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/{id}": {
|
||||
put: {
|
||||
tags: ["AI Tools"],
|
||||
|
||||
@@ -66,6 +66,35 @@ router.put("/omp-sharing", sameOriginGuard, async (req, res) => {
|
||||
res.json(state);
|
||||
});
|
||||
|
||||
// What members' Claude may run on this box (server/lib/member-policy.js,
|
||||
// read by scripts/member-guard.js): S3 only, an admin's switch.
|
||||
router.get("/member-policy", (_req, res) => {
|
||||
res.json(require("../lib/member-policy").state());
|
||||
});
|
||||
|
||||
router.put("/member-policy", sameOriginGuard, (req, res) => {
|
||||
const on = req.body?.allowTests;
|
||||
if (typeof on !== "boolean") {
|
||||
return res.status(400).json({ error: { code: "EBADFIELD", message: "allowTests: boolean" } });
|
||||
}
|
||||
const policy = require("../lib/member-policy");
|
||||
if (!policy.available()) {
|
||||
return res.status(409).json({
|
||||
error: { code: "ENOS3", message: "this needs server mode with per-user accounts" },
|
||||
});
|
||||
}
|
||||
const state = policy.setAllowTests(on);
|
||||
if (req.user) {
|
||||
require("../lib/audit").recordAccountChange(
|
||||
req.user.id,
|
||||
null,
|
||||
"PUT",
|
||||
`members' test runs ${on ? "allowed" : "blocked"}`
|
||||
);
|
||||
}
|
||||
res.json(state);
|
||||
});
|
||||
|
||||
router.put("/:id", sameOriginGuard, (req, res) => {
|
||||
const { enabled, default: makeDefault, defaultModel } = req.body || {};
|
||||
// Every field checked before anything is written: a bad one saves nothing.
|
||||
|
||||
Reference in New Issue
Block a user