/** * 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();