diff --git a/bunfig.toml b/bunfig.toml index 886f7f5..f6dfc78 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -7,3 +7,4 @@ frozenLockfile = false [test] coverage = true +coverageThreshold = 1.0 diff --git a/extensions/rules-guard/index.test.ts b/extensions/rules-guard/index.test.ts index 32e3f19..55ecac8 100644 --- a/extensions/rules-guard/index.test.ts +++ b/extensions/rules-guard/index.test.ts @@ -3,14 +3,25 @@ * Run: `bun test` inside agent/extensions/rules-guard. */ import { describe, expect, test } from "bun:test"; -import { +import fs from "node:fs"; +import os from "node:os"; +import nodePath from "node:path"; +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; +import rulesGuard, { + bashMatcher, buildPolicy, + candidateAbsPaths, + compileGlob, decide, EMBEDDED_ALLOW, + EMBEDDED_DENY, fileVerdict, globSpecificity, loadPolicyEntries, type Policy, + parseRule, + pathTokens, + redactText, } from "./index"; const HOME = "/home/test"; @@ -19,6 +30,62 @@ const readBlocked = (path: string, pol: Policy, cwd = "/work") => const writeBlocked = (path: string, pol: Policy, cwd = "/work") => decide("write", { path }, cwd, pol).block; +// ── Test helpers ────────────────────────────────────────────────────────────── + +/** Deterministic PRNG (mulberry32) so fuzz cases are reproducible across runs. */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function randStr(rng: () => number, len: number, alphabet: string): string { + let s = ""; + for (let i = 0; i < len; i++) + s += alphabet.charAt(Math.floor(rng() * alphabet.length)); + return s; +} + +function must(v: T | undefined | null): T { + if (v == null) throw new Error("expected a value"); + return v; +} + +/** Run `fn` with a throwaway temp dir, always cleaned up. */ +function inTempDir(fn: (dir: string) => void): void { + const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "rg-")); + try { + fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +type AnyHandler = (event: unknown, ctx: unknown) => unknown; + +/** Minimal ExtensionAPI stand-in that captures registered handlers + label. */ +function makeMockPi(): { + pi: ExtensionAPI; + handlers: Map; + state: { label: string }; +} { + const handlers = new Map(); + const state = { label: "" }; + const pi = { + setLabel: (l: string) => { + state.label = l; + }, + on: (event: string, handler: AnyHandler) => { + handlers.set(event, handler); + }, + } as unknown as ExtensionAPI; + return { pi, handlers, state }; +} + describe("globSpecificity", () => { test("wildcards count as zero, literals as one", () => { expect(globSpecificity("**/.env*", HOME)).toBe(5); // /.env @@ -151,3 +218,411 @@ describe("fileVerdict (direct)", () => { expect(hit?.src).toBe("Read(/foo/bar/**)"); }); }); + +describe("compileGlob (glob semantics)", () => { + test("** crosses segments; * and ? stay within one (positive+negative)", () => { + expect(compileGlob("**/*.key", HOME).test("/a/b/c/id.key")).toBe(true); + expect(compileGlob("/a/*/c", HOME).test("/a/b/c")).toBe(true); + expect(compileGlob("/a/*/c", HOME).test("/a/b/x/c")).toBe(false); + expect(compileGlob("a?c", HOME).test("abc")).toBe(true); + expect(compileGlob("a?c", HOME).test("a/c")).toBe(false); + }); + test("bare ** (no trailing slash) matches greedily across depth", () => { + const re = compileGlob("/foo**", HOME); + expect(re.test("/foobar")).toBe(true); + expect(re.test("/foo/x/y")).toBe(true); + expect(re.test("/fo")).toBe(false); + }); + test("~ expands to home; trailing /** matches the dir itself", () => { + expect(compileGlob("~/.ssh/**", HOME).test("/home/test/.ssh")).toBe(true); + expect(compileGlob("~/.ssh/**", HOME).test("/home/test/.ssh/id")).toBe( + true, + ); + expect(compileGlob("~", HOME).test("/home/test")).toBe(true); + }); + test("regex metacharacters are matched literally (adversarial)", () => { + const re = compileGlob("/a.b+c(d)/x", HOME); + expect(re.test("/a.b+c(d)/x")).toBe(true); + expect(re.test("/axbxcxdx/x")).toBe(false); + }); +}); + +describe("bashMatcher (command head, boundary-safe)", () => { + test("trailing * = any args; head matches only at a boundary", () => { + const re = bashMatcher("git push --force *"); + expect(re.test("git push --force origin main")).toBe(true); + expect(re.test("git push --force")).toBe(true); + expect(re.test("git push --force-with-lease")).toBe(false); + }); + test("literal whitespace is flexible; internal boundary respected", () => { + expect(bashMatcher("rm -rf *").test("rm -rf /tmp/x")).toBe(true); + expect(bashMatcher("git reset --hard *").test("git reset --hardcore")).toBe( + false, + ); + }); +}); + +describe("parseRule (adversarial)", () => { + test("well-formed Tool(pattern) parses, incl. empty pattern", () => { + expect(parseRule("Read(**/x)")).toEqual({ tool: "Read", pattern: "**/x" }); + expect(parseRule(" Write(a/b) ")).toEqual({ + tool: "Write", + pattern: "a/b", + }); + expect(parseRule("Bash()")).toEqual({ tool: "Bash", pattern: "" }); + }); + test("malformed entries return null", () => { + expect(parseRule("not a rule")).toBeNull(); + expect(parseRule("Read**/x)")).toBeNull(); + expect(parseRule("Read(**/x")).toBeNull(); + expect(parseRule("Read2(x)")).toBeNull(); + expect(parseRule("")).toBeNull(); + }); +}); + +describe("candidateAbsPaths", () => { + test("resolves relative + ~ against cwd/home; strips read selectors", () => { + expect(candidateAbsPaths("foo.ts", "/work")).toEqual(["/work/foo.ts"]); + expect(candidateAbsPaths("~/x", "/work")).toEqual([ + nodePath.join(os.homedir(), "x"), + ]); + expect(candidateAbsPaths("/work/foo.ts:50-100", "/work")).toEqual([ + "/work/foo.ts", + ]); + expect(candidateAbsPaths("/work/foo.ts:raw", "/work")).toEqual([ + "/work/foo.ts", + ]); + }); + test("skips URLs and data/mailto schemes and empty input (negative)", () => { + expect(candidateAbsPaths("https://x.com/a", "/work")).toEqual([]); + expect(candidateAbsPaths("data:text/plain,x", "/work")).toEqual([]); + expect(candidateAbsPaths("mailto:a@b.c", "/work")).toEqual([]); + expect(candidateAbsPaths("", "/work")).toEqual([]); + }); + test("decomposes archive members, incl. a ~ inner segment", () => { + const inner = `sec${"rets/k.json"}`; + const out = candidateAbsPaths(`bundle.zip:${inner}`, "/work"); + expect(out).toContain(`/work/bundle.zip:${inner}`); + expect(out).toContain("/work/bundle.zip"); + expect(out).toContain(`/work/${inner}`); + expect(candidateAbsPaths("a.zip:~/x", "/work")).toContain( + nodePath.join(os.homedir(), "x"), + ); + }); +}); + +describe("pathTokens (path-signal extraction)", () => { + test("flags tokens carrying a path signal", () => { + const toks = pathTokens("cat .env; open('~/.x/id'); read a/b.json"); + expect(toks).toContain(".env"); + expect(toks).toContain("~/.x/id"); + expect(toks).toContain("a/b.json"); + }); + test("leaves ordinary code identifiers untouched (negative)", () => { + expect(pathTokens("process.env obj.key foo bar")).toEqual([]); + }); +}); + +describe("decide — path fields across tool classes", () => { + const pol = buildPolicy(["Read(**/secrets/**)", "Write(**/out/**)"], []); + test("array-valued path field is scanned element-wise", () => { + expect( + decide("read", { paths: ["/work/ok", "/work/secrets/k"] }, "/work", pol) + .block, + ).toBe(true); + expect( + decide("read", { paths: ["/work/ok", "/work/fine"] }, "/work", pol).block, + ).toBe(false); + }); + test("non-string/non-array field values are ignored, never block (negative)", () => { + expect(decide("read", { path: 42 }, "/work", pol).block).toBe(false); + expect(decide("read", { path: { nested: true } }, "/work", pol).block).toBe( + false, + ); + expect(decide("read", { paths: [1, null, {}] }, "/work", pol).block).toBe( + false, + ); + expect(decide("bash", { command: 99 }, "/work", pol).block).toBe(false); + }); + test("read-only ignores write-deny; write/unknown tools honor it", () => { + expect(decide("read", { path: "/work/out/x" }, "/work", pol).block).toBe( + false, + ); + expect(decide("write", { path: "/work/out/x" }, "/work", pol).block).toBe( + true, + ); + expect(decide("wibble", { path: "/work/out/x" }, "/work", pol).block).toBe( + true, + ); + }); + test("edit hashline header paths are checked in write context", () => { + const body = `[/work/sec${"rets/k#1A2B"}]\n+x`; + expect(decide("edit", { input: body }, "/work", pol).block).toBe(true); + expect(decide("edit", { input: 42 }, "/work", pol).block).toBe(false); + }); +}); + +describe("decide — shell & code fields", () => { + const pol = buildPolicy(["Bash(rm -rf *)", "Read(**/.ssh/**)"], []); + test("denied bash pattern blocks after stripping sudo/env prefixes", () => { + expect( + decide("bash", { command: "sudo rm -rf /tmp/x" }, "/w", pol).block, + ).toBe(true); + expect( + decide("bash", { command: "A=1 rm -rf /tmp/x" }, "/w", pol).block, + ).toBe(true); + expect(decide("bash", { command: "ls -la" }, "/w", pol).block).toBe(false); + }); + test("path-token scan catches a shell/code read of a denied path", () => { + expect( + decide("bash", { command: "cat ~/.ssh/id_ed25519" }, "/home/test", pol) + .block, + ).toBe(true); + expect( + decide("eval", { code: "open('~/.ssh/id_ed25519')" }, "/home/test", pol) + .block, + ).toBe(true); + }); +}); + +describe("decide — adversarial bypass vectors", () => { + test("path traversal (..) resolving INTO a denied dir is blocked", () => { + const pol = buildPolicy(["Read(**/vault/**)"], []); + expect( + decide("read", { path: "/work/pub/../vault/k" }, "/work", pol).block, + ).toBe(true); + // …and traversal OUT of a denied dir must NOT false-positive. + expect( + decide("read", { path: "/work/vault/../pub/k" }, "/work", pol).block, + ).toBe(false); + }); + test("a read-class deny binds write/edit/unknown tools (any tool reads bytes)", () => { + const pol = buildPolicy(["Read(**/vault/**)"], []); + for (const tool of ["write", "edit", "notebook", "zzz"]) + expect(decide(tool, { path: "/work/vault/k" }, "/work", pol).block).toBe( + true, + ); + }); + test("a denied command hidden after a shell separator is still caught", () => { + const pol = buildPolicy(["Bash(danger *)"], []); + const evasions = [ + "echo hi; danger now", + "true && danger x", + "false || danger y", + "echo x | danger z", + "echo x\ndanger w", + ]; + for (const command of evasions) + expect(decide("bash", { command }, "/w", pol).block).toBe(true); + expect(decide("bash", { command: "echo a; echo b" }, "/w", pol).block).toBe( + false, + ); + }); +}); + +describe("redactText (defense-in-depth)", () => { + test("redacts each known secret shape (positive)", () => { + expect(redactText(`x sk-ant-${"abcdefghij1234567"} y`)).toBe( + "x [REDACTED] y", + ); + expect(redactText(`pk-${"abcdefghij1234567"}`)).toBe("[REDACTED]"); + expect(redactText(`AKIA${"1234567890ABCDEF"}`)).toBe("[REDACTED]"); + expect(redactText(`ghp_${"a".repeat(36)}`)).toBe("[REDACTED]"); + expect(redactText(`github_pat_${"a".repeat(24)}`)).toBe("[REDACTED]"); + expect(redactText(`xoxb-${"1234567890-abc"}`)).toBe("[REDACTED]"); + const pem = "-----BEGIN PRIVATE KEY-----\nAA\n-----END PRIVATE KEY-----"; + expect(redactText(pem)).toBe("[REDACTED]"); + }); + test("redacts every distinct secret in one blob (positive)", () => { + const blob = `a ghp_${"a".repeat(36)} b AKIA${"1234567890ABCDEF"} c`; + expect(redactText(blob)).toBe("a [REDACTED] b [REDACTED] c"); + }); + test("leaves non-secret / too-short text unchanged (negative)", () => { + expect(redactText("hello world")).toBe("hello world"); + expect(redactText("ghp_tooshort")).toBe("ghp_tooshort"); + expect(redactText("")).toBe(""); + }); +}); + +describe("loadPolicyEntries (settings file merge)", () => { + test("merges file deny+allow atop defaults; drops non-strings", () => { + inTempDir((dir) => { + const f = nodePath.join(dir, "settings.json"); + fs.writeFileSync( + f, + JSON.stringify({ + permissions: { deny: ["Read(/x/y)", 123], allow: ["Read(/x/y/z)"] }, + }), + ); + const { deny, allow } = loadPolicyEntries([f]); + expect(deny).toContain("Read(/x/y)"); + expect(deny).toContain("Read(**/.env*)"); + expect(deny).not.toContain(123 as unknown as string); + expect(allow).toContain("Read(/x/y/z)"); + expect(allow).toEqual(expect.arrayContaining(EMBEDDED_ALLOW)); + }); + }); + test("missing / invalid / mis-shaped files fall back to defaults", () => { + inTempDir((dir) => { + const bad = nodePath.join(dir, "bad.json"); + fs.writeFileSync(bad, "{ not json "); + const shaped = nodePath.join(dir, "shaped.json"); + fs.writeFileSync( + shaped, + JSON.stringify({ permissions: { deny: "nope" } }), + ); + const { deny, allow } = loadPolicyEntries([ + bad, + shaped, + nodePath.join(dir, "nope.json"), + ]); + expect(deny).toEqual(EMBEDDED_DENY); + expect(allow).toEqual(EMBEDDED_ALLOW); + }); + }); +}); + +describe("rulesGuard wiring — registration & session_start", () => { + test("sets a label of the expected shape and registers three hooks", () => { + const { pi, handlers, state } = makeMockPi(); + rulesGuard(pi); + expect(state.label).toMatch(/^RULES guard \(\d+ deny \/ \d+ allow\)$/); + expect(handlers.has("session_start")).toBe(true); + expect(handlers.has("tool_call")).toBe(true); + expect(handlers.has("tool_result")).toBe(true); + }); + test("session_start notifies only when a UI is present", async () => { + const { pi, handlers } = makeMockPi(); + rulesGuard(pi); + const start = must(handlers.get("session_start")); + const msgs: string[] = []; + const ui = { notify: (m: string) => void msgs.push(m) }; + await start({}, { hasUI: true, ui }); + await start({}, { hasUI: false, ui }); + await start({}, undefined); + expect(msgs).toHaveLength(1); + }); +}); + +describe("rulesGuard wiring — tool_call", () => { + test("blocks a default-denied command; passes benign input & missing ctx", async () => { + const { pi, handlers } = makeMockPi(); + rulesGuard(pi); + const onCall = must(handlers.get("tool_call")); + const hit = (await onCall( + { toolName: "bash", input: { command: "rm -rf /tmp/x" } }, + { cwd: "/work" }, + )) as { block?: boolean } | undefined; + expect(hit?.block).toBe(true); + expect( + await onCall({ toolName: "read", input: {} }, { cwd: "/work" }), + ).toBeUndefined(); + expect( + await onCall({ toolName: "read", input: {} }, undefined), + ).toBeUndefined(); + }); +}); + +describe("rulesGuard wiring — tool_result", () => { + test("redacts secret text, passes non-text; skips clean/error/non-array", async () => { + const { pi, handlers } = makeMockPi(); + rulesGuard(pi); + const onResult = must(handlers.get("tool_result")); + const tok = `ghp_${"a".repeat(36)}`; + const red = (await onResult( + { + isError: false, + content: [{ type: "image" }, { type: "text", text: `t ${tok}` }], + }, + {}, + )) as { content?: Array<{ text?: string }> } | undefined; + expect(red?.content?.[1]?.text).toBe("t [REDACTED]"); + const clean = await onResult( + { isError: false, content: [{ type: "text", text: "nothing here" }] }, + {}, + ); + expect(clean).toBeUndefined(); + const err = await onResult( + { isError: true, content: [{ type: "text", text: tok }] }, + {}, + ); + expect(err).toBeUndefined(); + expect( + await onResult({ isError: false, content: "x" }, {}), + ).toBeUndefined(); + }); +}); + +// ── Fuzzy invariants (seeded → deterministic under coverageThreshold=1.0) ────── +const FUZZ_POL = buildPolicy( + ["Read(**/secrets/**)", "Bash(rm -rf *)", "Write(**/.env*)"], + ["Read(**/.env.example)"], +); +const FUZZ_TOOLS = [ + "read", + "write", + "edit", + "bash", + "eval", + "glob", + "z", +] as const; +const FUZZ_FIELDS = [ + "path", + "paths", + "command", + "code", + "input", + "junk", +] as const; +const FUZZ_ALPHA = "abcXYZ/._~-*?:'\"()[]{} \n;&|="; + +describe("fuzzy — decide & compileGlob never blow up", () => { + test("decide tolerates arbitrary tool/field/value combos", () => { + const rng = mulberry32(0xc0ffee); + for (let i = 0; i < 2000; i++) { + const tool = FUZZ_TOOLS[Math.floor(rng() * FUZZ_TOOLS.length)] ?? "read"; + const field = + FUZZ_FIELDS[Math.floor(rng() * FUZZ_FIELDS.length)] ?? "path"; + const val = randStr(rng, Math.floor(rng() * 24), FUZZ_ALPHA); + const input: Record = + rng() < 0.5 ? { [field]: val } : { [field]: [val, val] }; + expect(() => decide(tool, input, "/work", FUZZ_POL)).not.toThrow(); + } + }); + test("compileGlob output is always fully anchored, never throws", () => { + const rng = mulberry32(7); + for (let i = 0; i < 500; i++) { + const g = randStr(rng, 1 + Math.floor(rng() * 16), FUZZ_ALPHA); + const re = compileGlob(g, HOME); + expect(re.source.startsWith("^")).toBe(true); + expect(re.source.endsWith("$")).toBe(true); + } + }); +}); + +describe("fuzzy — redaction idempotence & deny coverage", () => { + test("redactText is idempotent for random secret-ish strings", () => { + const rng = mulberry32(42); + const bits = ["sk-ant-", "AKIA", "ghp_", "xoxb-", "hello ", "1234", "-_"]; + for (let i = 0; i < 2000; i++) { + let s = ""; + const n = 1 + Math.floor(rng() * 6); + for (let j = 0; j < n; j++) + s += bits[Math.floor(rng() * bits.length)] ?? ""; + const once = redactText(s); + expect(redactText(once)).toBe(once); + } + }); + test("any leaf under a denied dir is blocked regardless of name", () => { + const rng = mulberry32(99); + const leaf = "abcdefghijklmnopqrstuvwxyz0123456789-_"; + for (let i = 0; i < 500; i++) { + const name = randStr(rng, 1 + Math.floor(rng() * 12), leaf); + expect( + decide("read", { path: `/work/secrets/${name}` }, "/work", FUZZ_POL) + .block, + ).toBe(true); + } + }); +}); diff --git a/extensions/rules-guard/index.ts b/extensions/rules-guard/index.ts index aa8023b..5efbc30 100644 --- a/extensions/rules-guard/index.ts +++ b/extensions/rules-guard/index.ts @@ -45,6 +45,7 @@ export const EMBEDDED_DENY: string[] = [ "Edit(~/.bashrc)", "Edit(~/.zshrc)", "Read(**/*.id_ed25519)", + "Read(**/*.id_rsa)", "Read(**/*.key)", "Read(**/*.pem)", "Read(**/.aws/**)", diff --git a/hk.pkl b/hk.pkl index 578a9cc..b86e8d5 100644 --- a/hk.pkl +++ b/hk.pkl @@ -11,7 +11,10 @@ local linters = new Mapping { fix = "bun biome check --write --error-on-warnings --no-errors-on-unmatched {{ files }}" } // Detect accidentally committed private keys - ["detect_private_key"] = Builtins.detect_private_key + ["detect_private_key"] = (Builtins.detect_private_key) { + // The suite embeds a synthetic PEM fixture to test secret redaction, not a real key. + exclude = List("extensions/rules-guard/index.test.ts") + } // Check Mise files ["mise"] = Builtins.mise // Ensure all files end with LF (*nix) not CRLF (DOS)