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:
@@ -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