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
+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.