Files
pi-kit/test/guardrails.test.ts

174 lines
9.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Behavioral tests for the guardrail extensions. No test framework — run with:
* node --experimental-strip-types test/guardrails.test.ts
* (or `npm test`). Exits non-zero on any failure.
*
* Extensions only `import type` the Pi API, so they run here with node's
* type-stripping and mocked pi/ctx objects.
*
* NOTE: type stripping requires Node.js >= 22.6 (install.sh's >= 20 applies to
* employees installing pi, not to this test harness).
*/
const [NODE_MAJOR, NODE_MINOR] = process.versions.node.split(".").map(Number);
if (NODE_MAJOR < 22 || (NODE_MAJOR === 22 && NODE_MINOR < 6)) {
console.error(`npm test требует Node.js >= 22.6 (type stripping), у тебя ${process.version}.`);
console.error("Это ограничение тест-харнеса, а не install.sh (для сотрудников достаточно >= 20).");
process.exit(1);
}
process.env.PI_KIT_AUDIT_OFF = "1"; // don't touch the real audit log during tests
import protectedPaths from "../extensions/protected-paths.ts";
import permissionGate from "../extensions/permission-gate.ts";
import secretScanner from "../extensions/secret-scanner.ts";
import commitGuard from "../extensions/commit-guard.ts";
import llmRedaction from "../extensions/llm-redaction.ts";
import companyContext from "../extensions/company-context.ts";
import { redact, scanSecrets } from "../extensions/lib/secrets.ts";
import { normalize, renderContext, fetchRemoteConfig, MAX_CONTEXT_LINES } from "../extensions/lib/company-config.ts";
let fails = 0;
function check(name: string, cond: boolean) {
console.log(`${cond ? "PASS" : "FAIL"} ${name}`);
if (!cond) fails++;
}
/** Capture the handler an extension registers for a given event. */
function grab(ext: (pi: any) => void, evName: string): any {
let handler: any;
ext({ on: (ev: string, fn: any) => { if (ev === evName) handler = fn; }, registerCommand() {} });
return handler;
}
/** Collect registerCommand calls (for extensions that register commands). */
function grabCommands(ext: (pi: any) => void): string[] {
const names: string[] = [];
ext({ on() {}, registerCommand: (name: string) => names.push(name) });
return names;
}
const noUI: any = { hasUI: false };
const AKIA = "AKIA" + "ABCDEFGHIJKLMNOP";
const GLPAT = "glpat-" + "aBcDeF1234567890ghXY12"; // 22 chars after prefix (>=20)
const PRIVKEY = "-----BEGIN RSA PRIVATE KEY-----";
const JWT =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U";
async function run() {
// --- protected-paths ---
const pp = grab(protectedPaths, "tool_call");
check("protected: block .env", !!(await pp({ toolName: "write", input: { path: ".env" } }, noUI))?.block);
check("protected: block secrets/ file", !!(await pp({ toolName: "write", input: { path: "deploy/secrets/db.sh" } }, noUI))?.block);
check("protected: block .git internals", !!(await pp({ toolName: "edit", input: { path: ".git/config" } }, noUI))?.block);
check("protected: allow src/app.go", !(await pp({ toolName: "write", input: { path: "src/app.go" } }, noUI))?.block);
// --- permission-gate (table: command -> should block?) ---
const pg = grab(permissionGate, "tool_call");
const permCases: Array<[string, string, boolean]> = [
["perm: block push v1.2.3 tag", "git push origin v1.2.3", true],
["perm: block push tags/v1.2.3", "git push origin tags/v1.2.3", true],
["perm: block push refs/tags/v1.2.3", "git push origin refs/tags/v1.2.3", true],
["perm: block push main", "git push origin main", true],
["perm: block push HEAD:refs/heads/main", "git push origin HEAD:refs/heads/main", true],
["perm: block push release branch", "git push origin release-42", true],
["perm: block force push", "git push --force origin feature/x", true],
["perm: block push --tags", "git push origin --tags", true],
["perm: block rm -rf", "rm -rf /tmp/x", true],
["perm: block rm -r -f separated", "rm -r -f /tmp/x", true],
["perm: block rm -f -r separated", "rm -f -r -- /tmp/x", true],
["perm: block rm -fr", "rm -fr /tmp/x", true],
["perm: block git reset --hard", "git reset --hard HEAD~1", true],
["perm: block git clean -fdx", "git clean -fdx", true],
["perm: allow rm -i single", "rm -i /tmp/notes.txt", false],
["perm: allow ls", "ls -la", false],
["perm: allow push feature branch", "git push origin feature/PROJ-1", false],
];
for (const [name, cmd, blocked] of permCases) {
const res = await pg({ toolName: "bash", input: { command: cmd } }, noUI);
check(name, blocked ? !!res?.block : !res?.block);
}
// --- secret-scanner ---
const ss = grab(secretScanner, "tool_call");
check("secret: block write AKIA", !!(await ss({ toolName: "write", input: { path: "cfg.txt", content: `key=${AKIA}` } }, noUI))?.block);
check("secret: block edit private key", !!(await ss({ toolName: "edit", input: { path: "id_rsa", new_string: PRIVKEY } }, noUI))?.block);
check("secret: allow clean content", !(await ss({ toolName: "write", input: { path: "a.go", content: "package main" } }, noUI))?.block);
check("secret: ignore non-write tool", (await ss({ toolName: "bash", input: { command: "echo hi" } }, noUI)) === undefined);
// --- commit-guard ---
const cg = grab(commitGuard, "tool_call");
check("commit: block non-conventional", !!(await cg({ toolName: "bash", input: { command: 'git commit -m "fixed stuff"' } }, noUI))?.block);
check("commit: allow conventional", !(await cg({ toolName: "bash", input: { command: 'git commit -m "feat(auth): add login"' } }, noUI))?.block);
check("commit: block secret in message", !!(await cg({ toolName: "bash", input: { command: `git commit -m "chore: token ${GLPAT}"` } }, noUI))?.block);
check("commit: ignore editor commit", (await cg({ toolName: "bash", input: { command: "git commit" } }, noUI)) === undefined);
// --- llm-redaction ---
const lr = grab(llmRedaction, "before_provider_request");
const payload = { model: "x", messages: [{ role: "user", content: `here is my token ${GLPAT}` }] };
const out: any = await lr({ payload });
const redacted = JSON.stringify(out?.messages ?? []);
check("redact: token removed from payload", !redacted.includes(GLPAT) && redacted.includes("REDACTED"));
check("redact: no-op when clean", (await lr({ payload: { messages: [{ role: "user", content: "hello" }] } })) === undefined);
{
const deep = { messages: [{ content: [{ type: "text", text: `k ${AKIA}` }] }] };
const r: any = await lr({ payload: deep });
check("redact: deep object arrays", !!r && !JSON.stringify(r.messages).includes(AKIA));
}
// --- lib units: secrets ---
check("scanSecrets finds jwt", scanSecrets(JWT).length > 0);
check("redact keeps clean text", redact("just normal text") === "just normal text");
// --- lib units: company-config ---
check("normalize: junk input falls back to defaults", normalize({ foo: 1, gitHost: 42, rules: "nope" }).gitHost === "TODO");
check("normalize: drops non-string rules", JSON.stringify(normalize({ rules: [1, "keep", null] }).rules) === '["keep"]');
check("normalize: keeps known fields", normalize({ gitHost: "h", trackerUrl: "t", docsUrl: "d", repoMap: "m" }).gitHost === "h");
check("renderContext: replaces placeholders", renderContext("host={{gitHost}}", normalize({ gitHost: "gh" })).text === "host=gh");
check("renderContext: rules block", renderContext("{{rules}}", normalize({ rules: ["a", "b"] })).text.includes("- a\n- b"));
check("renderContext: no truncation for short template", !renderContext("# t\n{{rules}}", normalize({})).truncated);
{
const longTemplate = Array.from({ length: MAX_CONTEXT_LINES + 10 }, (_, i) => `line ${i}`).join("\n");
const r = renderContext(longTemplate, normalize({}));
check("renderContext: truncates long template", r.truncated && r.text.split("\n").length === MAX_CONTEXT_LINES);
}
{
const okFetch: any = async () => ({ ok: true, json: async () => ({ gitHost: "remote", rules: [1, "a"] }) });
const remote = await fetchRemoteConfig("https://x", okFetch);
check("fetchRemoteConfig: success normalizes", remote?.gitHost === "remote" && JSON.stringify(remote.rules) === '["a"]');
}
{
const badFetch: any = async () => ({ ok: false });
check("fetchRemoteConfig: !ok returns null", (await fetchRemoteConfig("https://x", badFetch)) === null);
}
{
const throwFetch: any = async () => { throw new Error("net"); };
check("fetchRemoteConfig: throw returns null", (await fetchRemoteConfig("https://x", throwFetch)) === null);
}
// --- company-context extension (no remote configured) ---
// Must use ONE extension instance: the rendered context is cached per-instance.
{
let sessionHandler: any;
let beforeAgentHandler: any;
companyContext({
on(ev: string, fn: any) {
if (ev === "session_start") sessionHandler = fn;
if (ev === "before_agent_start") beforeAgentHandler = fn;
},
registerCommand() {},
} as any);
await sessionHandler({ reason: "test" }, noUI);
const inj = await beforeAgentHandler({ systemPrompt: "base" });
check("company-context: injects corporate block", typeof inj?.systemPrompt === "string" && inj.systemPrompt.includes("Корпоративный контекст"));
check("company-context: preserves base prompt", inj?.systemPrompt.startsWith("base"));
check("company-context: registers /kit-config", grabCommands(companyContext).includes("kit-config"));
}
console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILED`);
process.exit(fails === 0 ? 0 : 1);
}
run();