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:
2026-11-03 11:37:13 +07:00
parent e6ce8009b1
commit 497faae3bf
14 changed files with 309 additions and 5 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -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();
});
});
+5
View File
@@ -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",
+5
View File
@@ -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ủ",
+15
View File
@@ -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;
+3
View File
@@ -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
View File
@@ -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.
+4
View File
@@ -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`,
+9 -3
View File
@@ -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: {
+34
View File
@@ -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([
+56
View File
@@ -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 };
+37
View File
@@ -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"],
+29
View File
@@ -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.