Files
AgentHub/server/__tests__/member-guard.test.js
T
nntrivi2001 497faae3bf 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.
2026-11-03 11:37:13 +07:00

161 lines
6.0 KiB
JavaScript

/**
* @file Tests for the S3 members' synchronous hook (scripts/member-guard.js)
* and the AI-session counter it shares with spawnRun (server/lib/ai-procs.js):
* which Bash commands count as a test run or a container command, and which
* processes count as one top-level AI session, read from a fake /proc.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { isHeavy, guardTool } = require("../../scripts/member-guard.js");
const procs = require("../lib/ai-procs.js");
describe("member-guard: a test run or a container command", () => {
const heavy = [
"npx vitest run",
"vitest",
"npm test",
"npm t",
"npm run test:unit",
"pnpm test",
"yarn test --watch=false",
"bun test",
"cd client && NODE_ENV=test npx vitest related src/a.ts --run",
"timeout 300 npx jest",
"./vendor/bin/phpunit --filter Foo",
"php artisan test",
"vendor/bin/pest",
"python -m pytest -q",
"pytest tests/",
"go test ./...",
"cargo test",
"docker compose up -d",
"docker-compose up",
"podman run alpine",
"node_modules/.bin/playwright test",
'bash -c "npm run e2e"',
"make test",
"npm run build && npm test 2>&1 | tail -20",
];
const fine = [
"npm run build",
"npm install -D vitest",
"git commit -m 'add vitest tests'",
"rg -n docker src/",
"cat tests/a.test.js",
"ls node_modules/.bin/vitest",
"echo run npm test later",
"npx tsc -b",
"npm run lint",
"php artisan migrate:status",
"go build ./...",
"git log --grep test",
"composer install",
];
for (const c of heavy) it(`refuses: ${c}`, () => assert.equal(isHeavy(c), true));
for (const c of fine) it(`allows: ${c}`, () => assert.equal(isHeavy(c), false));
it("answers Claude Code with a PreToolUse deny and its reason, and nothing for the rest", () => {
const out = [];
const write = process.stdout.write;
process.stdout.write = (s) => out.push(String(s));
try {
guardTool({ tool_name: "Bash", tool_input: { command: "npm test" } });
guardTool({ tool_name: "Bash", tool_input: { command: "npm run build" } });
guardTool({ tool_name: "Read", tool_input: { file_path: "/x" } });
} finally {
process.stdout.write = write;
}
assert.equal(out.length, 1);
const o = JSON.parse(out[0]).hookSpecificOutput;
assert.equal(o.hookEventName, "PreToolUse");
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). */
function fakeProc(list) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ah-proc-"));
for (const p of list) {
const d = path.join(root, String(p.pid));
fs.mkdirSync(d);
// pid (comm) state ppid ... field 22 = starttime (index 19 after the ')')
const fields = ["S", String(p.ppid), ...Array(17).fill("0"), String(p.start)];
fs.writeFileSync(path.join(d, "stat"), `${p.pid} (x y) ${fields.join(" ")}`);
fs.writeFileSync(path.join(d, "cmdline"), p.argv.join("\0") + "\0");
}
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([
{ pid: 10, ppid: 1, start: 1, argv: ["tmux"] },
{ pid: 11, ppid: 10, start: 5, argv: ["/opt/agenthub/tools/bin/claude", "--resume", "x"] },
{ pid: 12, ppid: 11, start: 6, argv: ["/bin/bash", "-c", "claude -p hi"] },
{ pid: 13, ppid: 12, start: 7, argv: ["claude", "-p", "hi"] }, // nested: not counted
{ pid: 20, ppid: 10, start: 3, argv: ["bash"] }, // a Terminal tab
{ pid: 21, ppid: 20, start: 4, argv: ["node", "/x/@openai/codex/bin/codex.js"] },
{ pid: 22, ppid: 21, start: 4, argv: ["/x/codex", "--yolo"] }, // Codex's native child
{ pid: 30, ppid: 10, start: 9, argv: ["omp", "auth-gateway", "serve"] }, // a server
{ pid: 31, ppid: 10, start: 8, argv: ["vitest"] },
]);
it("counts each session once, oldest first, and no tool server", () => {
assert.deepEqual(
procs.sessionsOf(uid, root).map((s) => s.pid),
[21, 11]
);
});
it("finds the session a hook process runs under", () => {
assert.equal(procs.sessionOf(13, root), 11);
assert.equal(procs.sessionOf(31, root), null);
});
it("reads the limit from server.conf, 4 without it, 0 = off", () => {
const conf = path.join(root, "server.conf");
assert.equal(procs.sessionLimit(conf), 4);
fs.writeFileSync(conf, "HOST_USER=x\nMAX_AI_SESSIONS=0\n");
assert.equal(procs.sessionLimit(conf), 0);
});
});