497faae3bf
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.
161 lines
6.0 KiB
JavaScript
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);
|
|
});
|
|
});
|