Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/gittensory-miner/lib/deny-hooks.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
};

export const DEFAULT_DENY_RULES: DenyRule[];

export function evaluateDenyHooks(toolCall: ProposedToolCall, rules?: DenyRule[]): DenyVerdict;
100 changes: 100 additions & 0 deletions packages/gittensory-miner/lib/deny-hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// 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;
}

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) => pattern.test(value))) 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 };
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
88 changes: 88 additions & 0 deletions test/unit/miner-deny-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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 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);
});
});
Loading