diff --git a/packages/gittensory-miner/lib/deny-hooks.d.ts b/packages/gittensory-miner/lib/deny-hooks.d.ts new file mode 100644 index 0000000000..9729232682 --- /dev/null +++ b/packages/gittensory-miner/lib/deny-hooks.d.ts @@ -0,0 +1,24 @@ +export type DenyRule = { + /** Tool-name glob (`*` = any within a segment, `**` across segments) or an exact tool name. */ + matcher: string; + /** Optional glob tested against every path-shaped string in the tool-call input. */ + pathPattern?: string; + /** Optional substrings that must ALL appear in one string-shaped input field (e.g. a shell command). */ + inputIncludesAll?: string[]; + /** Human-readable reason surfaced when this rule blocks a call. */ + reason: string; +}; + +export type DenyVerdict = { + allowed: boolean; + blockedBy?: DenyRule; +}; + +export type ProposedToolCall = { + name: string; + input: Record; +}; + +export const DEFAULT_DENY_RULES: DenyRule[]; + +export function evaluateDenyHooks(toolCall: ProposedToolCall, rules?: DenyRule[]): DenyVerdict; diff --git a/packages/gittensory-miner/lib/deny-hooks.js b/packages/gittensory-miner/lib/deny-hooks.js new file mode 100644 index 0000000000..f7c46b5059 --- /dev/null +++ b/packages/gittensory-miner/lib/deny-hooks.js @@ -0,0 +1,118 @@ +// PreToolUse-style deny-hook primitives (#2295). A pure, deterministic rule evaluator modeled on Claude Code's +// PreToolUse deny-hook shape: given a proposed tool call and a set of deny rules, it decides allow/block WITHOUT +// executing, intercepting, or mutating anything. There is NO live tool-call interception in this phase — a later +// phase's real coding-agent driver plugs an event source into `evaluateDenyHooks`; this module is only the +// decision function. No IO, no globals, no Date/random: identical inputs always yield the identical verdict. +// +// A rule fires when its tool-name `matcher` matches AND every constraint it declares also matches: +// - `pathPattern` (a glob) must match some path-shaped string in the tool-call input, and/or +// - `inputIncludesAll` (substrings) must ALL appear in a single string-shaped input field (e.g. a command). +// A rule with neither constraint fires on the matcher alone. The built-in DEFAULT_DENY_RULES mirror the +// forbidden-path patterns enforced in `scripts/check-mcp-package.mjs` plus a conservative git force-push guard. + +/** + * Compile a glob to an anchored RegExp. `**` matches across path segments (any char incl. `/`); a leading `**​/` + * also matches zero directories; `*` matches within a single segment (no `/`); every other char is literal. Kept + * intentionally small — it only needs the shapes the built-in rules use (`.github/workflows/**`, `**​/.env*`, + * `**​/secret*​/**`, `**​/*private*key*`). + */ +function globToRegExp(glob) { + let source = ""; + for (let i = 0; i < glob.length; i += 1) { + const char = glob[i]; + if (char === "*") { + if (glob[i + 1] === "*") { + i += 1; + if (glob[i + 1] === "/") { + i += 1; + source += "(?:.*/)?"; // '**/' — any (or zero) leading directories + } else { + source += ".*"; // '**' — any char, including '/' + } + } else { + source += "[^/]*"; // '*' — any char except '/' + } + } else { + source += char.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + } + } + return new RegExp(`^${source}$`); +} + +/** Collect the string values in a tool-call input (top-level strings and string elements of top-level arrays) so a + * rule can test them without hard-coding field names. Non-object input yields no strings (rule can't match). */ +function collectInputStrings(input) { + const strings = []; + if (!input || typeof input !== "object" || Array.isArray(input)) return strings; + for (const value of Object.values(input)) { + if (typeof value === "string") strings.push(value); + else if (Array.isArray(value)) { + for (const element of value) if (typeof element === "string") strings.push(element); + } + } + return strings; +} + +/** + * The candidate strings a path glob is tested against for one input value: the whole value AND each + * whitespace-separated token (surrounding quotes stripped). A protected path is frequently embedded as one + * argument of a command-shaped string (`git add .github/workflows/ci.yml`), so the evaluator tokenizes here + * rather than relying on a later caller to split the command first — a bare path-valued field still matches via + * the whole-value candidate. + */ +function pathCandidates(value) { + const candidates = [value]; + for (const token of value.split(/\s+/)) { + const trimmed = token.replace(/^["']+|["']+$/g, ""); + if (trimmed && trimmed !== value) candidates.push(trimmed); + } + return candidates; +} + +function matcherMatches(matcher, toolName) { + if (typeof matcher !== "string") return false; + return globToRegExp(matcher).test(typeof toolName === "string" ? toolName : ""); +} + +function ruleMatches(rule, toolName, inputStrings) { + if (!rule || typeof rule !== "object") return false; + if (!matcherMatches(rule.matcher, toolName)) return false; + if (typeof rule.pathPattern === "string") { + const pattern = globToRegExp(rule.pathPattern); + if (!inputStrings.some((value) => pathCandidates(value).some((candidate) => pattern.test(candidate)))) { + return false; + } + } + if (Array.isArray(rule.inputIncludesAll)) { + const needles = rule.inputIncludesAll.filter((needle) => typeof needle === "string"); + if (!inputStrings.some((value) => needles.every((needle) => value.includes(needle)))) return false; + } + return true; +} + +/** + * The built-in house-rule deny set — a non-empty starting example a later phase can extend or replace. Mirrors the + * forbidden-path regex in `scripts/check-mcp-package.mjs` (CI workflows, env files, secret-bearing paths, private + * key material) and adds a conservative git force-push guard (a command carrying both `push` and `--force`). + */ +export const DEFAULT_DENY_RULES = [ + { matcher: "*", pathPattern: ".github/workflows/**", reason: "Never modify CI workflows (.github/workflows/**)." }, + { matcher: "*", pathPattern: "**/.env*", reason: "Never read or write environment files (.env*)." }, + { matcher: "*", pathPattern: "**/secret*/**", reason: "Never touch secret-bearing paths (**/secret*/**)." }, + { matcher: "*", pathPattern: "**/*private*key*", reason: "Never touch private key material (**/*private*key*)." }, + { matcher: "*", inputIncludesAll: ["push", "--force"], reason: "Never force-push (git push --force)." }, +]; + +/** + * Evaluate a proposed tool call against deny rules and return the first block, or allow. Pure and side-effect-free + * — it NEVER runs or intercepts the tool call; a later phase's real hook wiring acts on the verdict. An empty rule + * set (or a call matching no rule) always allows. Defaults to {@link DEFAULT_DENY_RULES} when no rules are given. + */ +export function evaluateDenyHooks(toolCall, rules = DEFAULT_DENY_RULES) { + const toolName = toolCall && typeof toolCall === "object" ? toolCall.name : undefined; + const inputStrings = collectInputStrings(toolCall && typeof toolCall === "object" ? toolCall.input : undefined); + for (const rule of Array.isArray(rules) ? rules : []) { + if (ruleMatches(rule, toolName, inputStrings)) return { allowed: false, blockedBy: rule }; + } + return { allowed: true }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 3630b0a0c8..894ac211ec 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -31,7 +31,7 @@ "lib" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-deny-hooks.test.ts b/test/unit/miner-deny-hooks.test.ts new file mode 100644 index 0000000000..38c56a8694 --- /dev/null +++ b/test/unit/miner-deny-hooks.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_DENY_RULES, + evaluateDenyHooks, +} from "../../packages/gittensory-miner/lib/deny-hooks.js"; + +// PreToolUse-style deny-hook primitives (#2295). Pure decision function — no live interception. Each built-in +// rule is checked on BOTH sides (fires on a crafted matching call, does NOT fire on a benign one), plus the +// empty-rule-set / no-match allow paths and the matcher/pathPattern/inputIncludesAll composition. + +describe("evaluateDenyHooks — built-in DEFAULT_DENY_RULES", () => { + it("has a non-empty default rule set (the safety primitive exists and is wired at least this far)", () => { + expect(DEFAULT_DENY_RULES.length).toBeGreaterThan(0); + }); + + it("blocks writing a CI workflow, allows an ordinary source path", () => { + const blocked = evaluateDenyHooks({ name: "Write", input: { file_path: ".github/workflows/ci.yml" } }); + expect(blocked.allowed).toBe(false); + expect(blocked.blockedBy?.reason).toContain("CI workflows"); + expect(evaluateDenyHooks({ name: "Write", input: { file_path: "src/review/rag.ts" } }).allowed).toBe(true); + }); + + it("blocks touching an env file (at any depth), allows a normal config file", () => { + expect(evaluateDenyHooks({ name: "Read", input: { file_path: ".env" } }).allowed).toBe(false); + expect(evaluateDenyHooks({ name: "Read", input: { file_path: "app/.env.local" } }).allowed).toBe(false); + expect(evaluateDenyHooks({ name: "Read", input: { file_path: "app/config.ts" } }).allowed).toBe(true); + }); + + it("blocks secret-bearing paths, allows an unrelated directory", () => { + expect(evaluateDenyHooks({ name: "Read", input: { file_path: "config/secrets/prod.json" } }).allowed).toBe(false); + expect(evaluateDenyHooks({ name: "Read", input: { file_path: "config/public/prod.json" } }).allowed).toBe(true); + }); + + it("blocks private key material, allows a lookalike that is not a key", () => { + expect(evaluateDenyHooks({ name: "Read", input: { file_path: "keys/id_private_key.pem" } }).allowed).toBe(false); + expect(evaluateDenyHooks({ name: "Read", input: { file_path: "docs/public-notes.md" } }).allowed).toBe(true); + }); + + it("blocks a protected path EMBEDDED in a command string (tokenizes; no pre-split path field required)", () => { + // A command carrying a protected path as one argument must be caught, not just a bare path-valued field. + expect(evaluateDenyHooks({ name: "Bash", input: { command: "git add .github/workflows/ci.yml && git commit" } }).allowed).toBe(false); + expect(evaluateDenyHooks({ name: "Bash", input: { command: "cat config/secrets/prod.json" } }).allowed).toBe(false); + // A benign command carrying no protected token still passes. + expect(evaluateDenyHooks({ name: "Bash", input: { command: "git status --short" } }).allowed).toBe(true); + }); + + it("blocks a git force-push regardless of flag/subcommand order, allows a normal push", () => { + expect(evaluateDenyHooks({ name: "Bash", input: { command: "git push --force origin main" } }).allowed).toBe(false); + // Order-independent: both substrings present in either order. + expect(evaluateDenyHooks({ name: "Bash", input: { command: "git --force-with-lease push" } }).allowed).toBe(false); + expect(evaluateDenyHooks({ name: "Bash", input: { command: "git push origin main" } }).allowed).toBe(true); + expect(evaluateDenyHooks({ name: "Bash", input: { command: "git commit --amend" } }).allowed).toBe(true); + }); +}); + +describe("evaluateDenyHooks — rule composition and allow paths", () => { + it("an empty rule set always allows", () => { + expect(evaluateDenyHooks({ name: "Bash", input: { command: "rm -rf /" } }, []).allowed).toBe(true); + }); + + it("a call matching zero rules is allowed", () => { + const rules = [{ matcher: "Write", pathPattern: "dist/**", reason: "no dist edits" }]; + expect(evaluateDenyHooks({ name: "Read", input: { file_path: "src/a.ts" } }, rules).allowed).toBe(true); + }); + + it("respects the tool-name matcher: an exact matcher only fires for that tool", () => { + const rules = [{ matcher: "Write", pathPattern: "**/*.lock", reason: "no lockfile writes" }]; + expect(evaluateDenyHooks({ name: "Write", input: { file_path: "pnpm.lock" } }, rules).allowed).toBe(false); + // Same path, different tool → matcher does not match → allowed. + expect(evaluateDenyHooks({ name: "Read", input: { file_path: "pnpm.lock" } }, rules).allowed).toBe(true); + }); + + it("a matcher-only rule (no pathPattern/inputIncludesAll) fires on the tool name alone", () => { + const rules = [{ matcher: "Delete", reason: "deletes are never allowed" }]; + const verdict = evaluateDenyHooks({ name: "Delete", input: {} }, rules); + expect(verdict.allowed).toBe(false); + expect(verdict.blockedBy?.reason).toBe("deletes are never allowed"); + }); + + it("matches pathPattern against a path-shaped array field", () => { + const rules = [{ matcher: "*", pathPattern: ".github/workflows/**", reason: "no workflows" }]; + expect(evaluateDenyHooks({ name: "MultiEdit", input: { paths: ["README.md", ".github/workflows/x.yml"] } }, rules).allowed).toBe(false); + expect(evaluateDenyHooks({ name: "MultiEdit", input: { paths: ["README.md", "src/x.ts"] } }, rules).allowed).toBe(true); + }); + + it("defaults to the built-in rule set when no rules argument is passed", () => { + expect(evaluateDenyHooks({ name: "Write", input: { file_path: ".github/workflows/ci.yml" } }).allowed).toBe(false); + }); + + it("degrades to allow on a malformed tool call (missing/empty input) rather than throwing", () => { + expect(evaluateDenyHooks({ name: "Bash", input: {} }).allowed).toBe(true); + // A path constraint with no string input fields present cannot match. + const rules = [{ matcher: "*", pathPattern: "**/*", reason: "everything" }]; + expect(evaluateDenyHooks({ name: "Bash", input: { count: 3, nested: { file: "x" } } }, rules).allowed).toBe(true); + }); +});