diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index e2de8881b8..2b779dae18 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -193,6 +193,36 @@ export { type AcceptanceCriteria, type AcceptanceCriteriaInput, } from "./miner/acceptance-criteria.js"; +// Pure deny-hook evaluator + rule-proposal synthesis moved out of gittensory-miner (#5667). The miner-lib +// `deny-hooks.js`/`deny-hook-synthesis.js` are now thin wrappers over these (the SQLite proposal store stays in +// the miner). `synthesizeDenyRuleProposals` takes an injected `nowMs` clock so synthesis is deterministic/pure. +export { + DEFAULT_DENY_RULES, + evaluateDenyHooks, + type DenyRule, + type DenyVerdict, + type ProposedToolCall, +} from "./miner/deny-hooks.js"; +export { + DEFAULT_SYNTHESIS_CONFIG, + PROPOSAL_STATUSES, + aggregateBlockerHistory, + canonicalizeChangedPath, + changedPathToDenyGlob, + isCoveredByDefaultDenyRules, + normalizeBlockerHistory, + normalizeBlockerHistoryRecord, + normalizeRepoFullName, + proposalStatusSet, + resolveEffectiveDenyRules, + setProposalStatuses, + synthesizeDenyRuleProposals, + type BlockerHistoryRecord, + type DenyRuleProposal, + type DenyRuleProposalAudit, + type DenyRuleProposalStatus, + type SynthesisConfig, +} from "./miner/deny-hook-synthesis.js"; // The subset of types/predicted-gate-types.ts's hand-kept mirrors (see that file's own header comment) that // the self-review adapter's public signature (SelfReviewContext, SelfReviewSlopAssessment) references. Not // previously part of the public barrel; exported now so those types are actually nameable by consumers. diff --git a/packages/gittensory-engine/src/miner/deny-hook-synthesis.ts b/packages/gittensory-engine/src/miner/deny-hook-synthesis.ts new file mode 100644 index 0000000000..efb6fe544d --- /dev/null +++ b/packages/gittensory-engine/src/miner/deny-hook-synthesis.ts @@ -0,0 +1,274 @@ +// Synthesize PreToolUse deny-hook rule proposals from per-repo blocker/path history (#4522, pure logic moved into +// the engine by #5667). Pure synthesis only: the optional local SQLite store for refresh + maintainer review lives +// in `packages/gittensory-miner/lib/deny-hook-synthesis.js`, which imports these pure functions from the engine. +// Approved rules merge with {@link DEFAULT_DENY_RULES}; unapproved proposals never block tool calls. Feeds the +// consumption surface #2343 will wire into evaluateDenyHooks — this module owns derivation + audit, not live hook +// interception. The clock is injected (nowMs) so the synthesis stays pure and deterministic. +import { createHash } from "node:crypto"; +import { DEFAULT_DENY_RULES, evaluateDenyHooks, type DenyRule } from "./deny-hooks.js"; + +export type BlockerHistoryRecord = { + repoFullName?: string | null; + blockerCodes: string[]; + changedPaths?: string[]; + guardrailMatches?: string[]; + pullNumber?: number | null; + recordedAt?: string | null; +}; + +export type DenyRuleProposalStatus = "proposed" | "approved" | "rejected"; + +export type DenyRuleProposalAudit = { + kind: string; + path?: string; + pathPattern?: string; + occurrenceCount?: number; + blockerCodes?: string[]; + synthesizedAt: string; +}; + +export type DenyRuleProposal = { + id: string; + status: DenyRuleProposalStatus; + rule: DenyRule; + audit: DenyRuleProposalAudit; +}; + +export type SynthesisConfig = { + minPathOccurrences?: number; + maxProposals?: number; +}; + +export const PROPOSAL_STATUSES: readonly DenyRuleProposalStatus[] = Object.freeze(["proposed", "approved", "rejected"]); +export const proposalStatusSet: ReadonlySet = new Set(PROPOSAL_STATUSES); + +export const DEFAULT_SYNTHESIS_CONFIG: Readonly> = Object.freeze({ + minPathOccurrences: 2, + maxProposals: 20, +}); + +export function normalizeRepoFullName(repoFullName: unknown): string { + if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); + const [owner, repo, extra] = repoFullName.trim().split("/"); + if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); + return `${owner}/${repo}`; +} + +function normalizeOptionalStringArray(value: unknown): string[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) return []; + return value.filter((entry) => typeof entry === "string" && entry.trim()).map((entry) => entry.trim()); +} + +/** Validate one blocker-history row from the review stack (gate block/close audit). */ +export function normalizeBlockerHistoryRecord(record: unknown): BlockerHistoryRecord | null { + if (!record || typeof record !== "object" || Array.isArray(record)) return null; + const source = record as Record; + const blockerCodes = normalizeOptionalStringArray(source.blockerCodes); + if (blockerCodes.length === 0) return null; + const changedPaths = normalizeOptionalStringArray(source.changedPaths); + const guardrailMatches = normalizeOptionalStringArray(source.guardrailMatches); + const repoFullName = typeof source.repoFullName === "string" && source.repoFullName.trim() + ? normalizeRepoFullName(source.repoFullName) + : null; + return { + repoFullName, + blockerCodes, + changedPaths, + guardrailMatches, + pullNumber: Number.isInteger(source.pullNumber) && (source.pullNumber as number) > 0 ? (source.pullNumber as number) : null, + recordedAt: typeof source.recordedAt === "string" && source.recordedAt.trim() ? source.recordedAt.trim() : null, + }; +} + +export function normalizeBlockerHistory(records: unknown): BlockerHistoryRecord[] { + if (!Array.isArray(records)) return []; + const normalized: BlockerHistoryRecord[] = []; + for (const record of records) { + const entry = normalizeBlockerHistoryRecord(record); + if (entry) normalized.push(entry); + } + return normalized; +} + +/** Canonicalize a changed path the same way guardrail matching does (case/separator insensitive). */ +export function canonicalizeChangedPath(path: unknown): string | null { + if (typeof path !== "string") return null; + const trimmed = path.trim().replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, ""); + if (!trimmed || trimmed.includes("..")) return null; + return trimmed.toLowerCase(); +} + +/** Convert a repo-relative changed path into a deny-hook glob matching DEFAULT_DENY_RULES shape. */ +export function changedPathToDenyGlob(path: string): string | null { + const canonical = canonicalizeChangedPath(path); + if (!canonical) return null; + return `**/${canonical}`; +} + +function ruleSignature(rule: DenyRule): string { + return JSON.stringify({ + matcher: rule.matcher, + pathPattern: rule.pathPattern ?? null, + inputIncludesAll: rule.inputIncludesAll ?? null, + reason: rule.reason, + }); +} + +/** True when a synthesized glob is already enforced by a built-in default deny rule. */ +export function isCoveredByDefaultDenyRules(pathPattern: string): boolean { + if (typeof pathPattern !== "string" || !pathPattern.trim()) return false; + const samplePath = pathPattern.replace(/^\*\*\//, ""); + if (!samplePath) return false; + return !evaluateDenyHooks({ name: "Write", input: { file_path: samplePath } }, DEFAULT_DENY_RULES).allowed; +} + +function collectPathsFromRecord(record: BlockerHistoryRecord): Set { + const paths = new Set(); + /* v8 ignore next -- records reaching here are pre-normalized, so changedPaths/guardrailMatches are always arrays */ + for (const path of [...(record.changedPaths ?? []), ...(record.guardrailMatches ?? [])]) { + const canonical = canonicalizeChangedPath(path); + if (canonical) paths.add(canonical); + } + return paths; +} + +/** Aggregate path and blocker-code frequencies from normalized history. Pure. */ +export function aggregateBlockerHistory(records: unknown): { + pathCounts: Map; + pathBlockers: Map>; + blockerCounts: Map; + recordCount: number; +} { + const normalized = normalizeBlockerHistory(records); + const pathCounts = new Map(); + const pathBlockers = new Map>(); + const blockerCounts = new Map(); + + for (const record of normalized) { + for (const code of record.blockerCodes) { + blockerCounts.set(code, (blockerCounts.get(code) ?? 0) + 1); + } + for (const path of collectPathsFromRecord(record)) { + pathCounts.set(path, (pathCounts.get(path) ?? 0) + 1); + const blockers = pathBlockers.get(path) ?? new Set(); + for (const code of record.blockerCodes) blockers.add(code); + pathBlockers.set(path, blockers); + } + } + + return { + pathCounts, + pathBlockers, + blockerCounts, + recordCount: normalized.length, + }; +} + +function stableProposalId(kind: string, key: string): string { + const digest = createHash("sha256").update(`${kind}:${key}`).digest("hex").slice(0, 16); + return `${kind}:${digest}`; +} + +// Clock injected (nowMs) so the stamped `synthesizedAt` is deterministic and this stays pure (#5667). The miner +// wrapper defaults nowMs to Date.now(), preserving the pre-#5667 wall-clock behavior for existing callers. +function buildPathProposal( + path: string, + occurrenceCount: number, + blockerCodes: Set, + nowMs: number, +): DenyRuleProposal | null { + const pathPattern = changedPathToDenyGlob(path); + /* v8 ignore next -- path is a canonical pathCounts key, so changedPathToDenyGlob never returns null here */ + if (!pathPattern) return null; + if (isCoveredByDefaultDenyRules(pathPattern)) return null; + const sortedBlockers = [...blockerCodes].sort(); + /* v8 ignore next -- every aggregated path carries >=1 blocker code, so the "path history" fallback is unreachable */ + const reason = `Synthesized deny rule: ${occurrenceCount} gate block(s) touched ${path} (${sortedBlockers.join(", ") || "path history"}). Review before enabling.`; + const rule: DenyRule = { matcher: "*", pathPattern, reason }; + return { + id: stableProposalId("path", pathPattern), + status: "proposed", + rule, + audit: { + kind: "path_history", + path, + pathPattern, + occurrenceCount, + blockerCodes: sortedBlockers, + synthesizedAt: new Date(nowMs).toISOString(), + }, + }; +} + +/** + * Derive candidate deny-hook rules from blocker/path history. Returns proposal objects only — nothing is active + * until a maintainer approves them (see resolveEffectiveDenyRules). `nowMs` is a required injected clock: the + * emitted `audit.synthesizedAt` is `new Date(nowMs).toISOString()`, so identical inputs yield identical output. + */ +export function synthesizeDenyRuleProposals(records: unknown, config: SynthesisConfig, nowMs: number): DenyRuleProposal[] { + const minPathOccurrences = Number.isInteger(config.minPathOccurrences) + ? Math.max(1, config.minPathOccurrences as number) + : DEFAULT_SYNTHESIS_CONFIG.minPathOccurrences; + const maxProposals = Number.isInteger(config.maxProposals) + ? Math.max(1, config.maxProposals as number) + : DEFAULT_SYNTHESIS_CONFIG.maxProposals; + + const { pathCounts, pathBlockers, recordCount } = aggregateBlockerHistory(records); + if (recordCount === 0) return []; + + const rankedPaths = [...pathCounts.entries()] + .filter(([, count]) => count >= minPathOccurrences) + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])); + + const proposals: DenyRuleProposal[] = []; + const seenSignatures = new Set(DEFAULT_DENY_RULES.map(ruleSignature)); + for (const [path, count] of rankedPaths) { + /* v8 ignore next -- pathBlockers has an entry for every pathCounts key (both are populated together) */ + const proposal = buildPathProposal(path, count, pathBlockers.get(path) ?? new Set(), nowMs); + if (!proposal) continue; + const signature = ruleSignature(proposal.rule); + /* v8 ignore next -- distinct canonical paths yield distinct signatures, so this dedup guard never fires */ + if (seenSignatures.has(signature)) continue; + seenSignatures.add(signature); + proposals.push(proposal); + if (proposals.length >= maxProposals) break; + } + return proposals; +} + +/** Merge built-in defaults with maintainer-approved synthesized rules (deduped, defaults first). */ +export function resolveEffectiveDenyRules( + options: { includeDefaults?: boolean; approvedProposals?: DenyRuleProposal[] } = {}, +): DenyRule[] { + const includeDefaults = options.includeDefaults !== false; + const approvedProposals = Array.isArray(options.approvedProposals) ? options.approvedProposals : []; + const merged: DenyRule[] = includeDefaults ? [...DEFAULT_DENY_RULES] : []; + const seen = new Set(merged.map(ruleSignature)); + for (const proposal of approvedProposals) { + if (proposal?.status !== "approved") continue; + const rule = proposal.rule; + if (!rule || typeof rule !== "object") continue; + const signature = ruleSignature(rule); + if (seen.has(signature)) continue; + seen.add(signature); + merged.push(rule); + } + return merged; +} + +/** Apply maintainer approval/rejection to in-memory proposals. Pure. */ +export function setProposalStatuses( + proposals: DenyRuleProposal[], + updates: Record | Map, +): DenyRuleProposal[] { + if (!Array.isArray(proposals)) throw new Error("invalid_proposals"); + const updateMap: Map = updates instanceof Map + ? updates + : new Map(Object.entries(updates ?? {}).filter(([id]) => typeof id === "string")); + return proposals.map((proposal) => { + const nextStatus = updateMap.get(proposal.id); + if (!nextStatus || !proposalStatusSet.has(nextStatus)) return proposal; + return { ...proposal, status: nextStatus }; + }); +} diff --git a/packages/gittensory-engine/src/miner/deny-hooks.ts b/packages/gittensory-engine/src/miner/deny-hooks.ts new file mode 100644 index 0000000000..cc273e66e6 --- /dev/null +++ b/packages/gittensory-engine/src/miner/deny-hooks.ts @@ -0,0 +1,183 @@ +// PreToolUse-style deny-hook primitives (#2295, moved into the engine by #5667). 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. `packages/gittensory-miner/lib/deny-hooks.js` is now a thin +// re-export of this engine module. +// +// 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), and/or +// - `inputTokenPattern` (a RegExp) must match a whole whitespace-separated token (quotes stripped) of a single +// string-shaped input field — for flag-shaped needles like `-f`, where a substring test would also fire on +// `--follow-tags`. +// A rule with none of these constraints 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. + +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[]; + /** Optional pattern that must match a whole whitespace-separated token (quotes stripped) of one + * string-shaped input field — for flag-shaped needles where a substring test would false-positive + * on an unrelated longer flag (e.g. `-f` vs. `--follow-tags`). */ + inputTokenPattern?: RegExp; + /** 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; +}; + +/** + * Compile a glob to an anchored, case-insensitive 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. Inputs are normalized before matching so `./`, nested, and Windows-style variants + * cannot bypass the built-in path rules. + */ +function globToRegExp(glob: string): RegExp { + let source = ""; + for (let i = 0; i < glob.length; i += 1) { + const char = glob[i]!; // in-bounds by the loop guard; `!` satisfies noUncheckedIndexedAccess + 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}$`, "i"); +} + +function normalizePathCandidate(value: string): string { + return value + .replace(/\\/g, "/") + .replace(/^\.\/+/, "") + .replace(/\/\.\//g, "/") + .replace(/\/+$/, ""); +} + +/** Collect string values anywhere in a tool-call input so rules can test nested tool arguments without + * hard-coding field names. Non-object input yields no strings (rule can't match). */ +function collectInputStrings(input: unknown, seen: WeakSet = new WeakSet()): string[] { + const strings: string[] = []; + if (!input || typeof input !== "object") return strings; + if (seen.has(input)) return strings; + seen.add(input); + const values: unknown[] = Array.isArray(input) ? input : Object.values(input); + for (const value of values) { + if (typeof value === "string") strings.push(value); + else if (value && typeof value === "object") strings.push(...collectInputStrings(value, seen)); + } + return strings; +} + +/** Split a string-shaped input field into whitespace-separated tokens with surrounding quotes stripped — + * shared by path-candidate expansion and flag-token matching below. */ +function splitTokens(value: string): string[] { + return value + .split(/\s+/) + .map((token) => token.replace(/^["']+|["']+$/g, "")) + .filter(Boolean); +} + +/** + * 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: string): string[] { + const candidates = new Set([value, normalizePathCandidate(value)]); + for (const trimmed of splitTokens(value)) { + candidates.add(trimmed); + candidates.add(normalizePathCandidate(trimmed)); + } + return [...candidates].filter(Boolean); +} + +function matcherMatches(matcher: unknown, toolName: unknown): boolean { + if (typeof matcher !== "string") return false; + return globToRegExp(matcher).test(typeof toolName === "string" ? toolName : ""); +} + +function ruleMatches(rule: DenyRule, toolName: unknown, inputStrings: string[]): boolean { + 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; + } + if (rule.inputTokenPattern instanceof RegExp) { + const tokenPattern = rule.inputTokenPattern; + if (!inputStrings.some((value) => splitTokens(value).some((token) => tokenPattern.test(token)))) { + 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 conservative git force-push guards (a command carrying `push` plus a force flag). + */ +export const DEFAULT_DENY_RULES: DenyRule[] = [ + { matcher: "*", pathPattern: "**/.github/workflows/**", reason: "Never modify CI workflows (.github/workflows/**)." }, + { matcher: "*", pathPattern: "**/.env*", reason: "Never read or write environment files (.env*)." }, + { matcher: "*", pathPattern: "**/.dev.vars", reason: "Never read or write local Worker secrets (.dev.vars)." }, + { matcher: "*", pathPattern: "**/.npmrc", reason: "Never read or write npm credential files (.npmrc)." }, + { matcher: "*", pathPattern: "**/*secret*/**", reason: "Never touch secret-bearing directories (**/*secret*/**)." }, + { matcher: "*", pathPattern: "**/*secret*", reason: "Never touch secret-bearing paths (**/*secret*)." }, + // Ordered before **/*.pem below: a file like id_private_key.pem matches both patterns, and + // evaluateDenyHooks returns the first matching rule's reason — this one is more specific + // (#2942, keeps the "private key material" reason for *private*key*.pem files). + { matcher: "*", pathPattern: "**/*private*key*", reason: "Never touch private key material (**/*private*key*)." }, + { matcher: "*", pathPattern: "**/*.pem", reason: "Never touch PEM key material (*.pem)." }, + { matcher: "*", inputIncludesAll: ["push", "--force"], reason: "Never force-push (git push --force)." }, + // Token-matched rather than substring-matched: a substring test for "-f" would also fire on an + // unrelated long flag like --follow-tags. Matches a whole short-option token (bundled or not) + // whose letters include "f", e.g. -f, -uf, -fu, but not a "--"-prefixed long flag. + { matcher: "*", inputIncludesAll: ["push"], inputTokenPattern: /^-[a-z]*f[a-z]*$/i, reason: "Never force-push (git push -f)." }, +]; + +/** + * 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: ProposedToolCall, rules: DenyRule[] = DEFAULT_DENY_RULES): DenyVerdict { + 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/lib/deny-hook-synthesis.js b/packages/gittensory-miner/lib/deny-hook-synthesis.js index 0cc3b6f1e7..127205f42e 100644 --- a/packages/gittensory-miner/lib/deny-hook-synthesis.js +++ b/packages/gittensory-miner/lib/deny-hook-synthesis.js @@ -1,30 +1,53 @@ -// Synthesize PreToolUse deny-hook rule proposals from per-repo blocker/path history (#4522). Pure synthesis -// plus an optional local SQLite store for refresh + maintainer review before any synthesized rule takes effect. -// Approved rules merge with {@link DEFAULT_DENY_RULES}; unapproved proposals never block tool calls. Feeds the -// consumption surface #2343 will wire into evaluateDenyHooks — this issue owns derivation + audit, not live hook -// interception. +// Synthesize PreToolUse deny-hook rule proposals from per-repo blocker/path history (#4522). The pure synthesis +// logic moved into `@loopover/engine` (packages/gittensory-engine/src/miner/deny-hook-synthesis.ts) by #5667; +// this module is now a thin wrapper that re-exports those pure helpers and keeps the local SQLite store for +// refresh + maintainer review before any synthesized rule takes effect. Approved rules merge with +// {@link DEFAULT_DENY_RULES}; unapproved proposals never block tool calls. No behavior change. import { chmodSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; -import { createHash } from "node:crypto"; import { DatabaseSync } from "node:sqlite"; -import { DEFAULT_DENY_RULES, evaluateDenyHooks } from "./deny-hooks.js"; +import { + aggregateBlockerHistory, + canonicalizeChangedPath, + changedPathToDenyGlob, + DEFAULT_SYNTHESIS_CONFIG, + isCoveredByDefaultDenyRules, + normalizeBlockerHistory, + normalizeBlockerHistoryRecord, + normalizeRepoFullName, + proposalStatusSet, + resolveEffectiveDenyRules, + setProposalStatuses, + synthesizeDenyRuleProposals as engineSynthesizeDenyRuleProposals, +} from "@loopover/engine"; import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; -const defaultDbFileName = "deny-hook-synthesis.sqlite3"; -const PROPOSAL_STATUSES = Object.freeze(["proposed", "approved", "rejected"]); -const proposalStatusSet = new Set(PROPOSAL_STATUSES); +// Re-export the pure synthesis helpers from the engine so this module's public API is unchanged after #5667 +// moved derivation/audit into @loopover/engine. Only the SQLite store below (and its forge/db-path helpers) is +// miner-local, because it depends on node:sqlite/node:fs and this package's forge-config default. +export { + aggregateBlockerHistory, + canonicalizeChangedPath, + changedPathToDenyGlob, + DEFAULT_SYNTHESIS_CONFIG, + isCoveredByDefaultDenyRules, + normalizeBlockerHistory, + normalizeBlockerHistoryRecord, + resolveEffectiveDenyRules, + setProposalStatuses, +}; -export const DEFAULT_SYNTHESIS_CONFIG = Object.freeze({ - minPathOccurrences: 2, - maxProposals: 20, -}); +const defaultDbFileName = "deny-hook-synthesis.sqlite3"; -function normalizeRepoFullName(repoFullName) { - if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); - const [owner, repo, extra] = repoFullName.trim().split("/"); - if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); - return `${owner}/${repo}`; +/** + * Derive candidate deny-hook rules from blocker/path history. Miner-facing wrapper over the engine's pure + * `synthesizeDenyRuleProposals`, defaulting the injected clock to `Date.now()` so this keeps the pre-#5667 2-arg + * signature (and wall-clock `audit.synthesizedAt`) every existing caller and test relies on. Returns proposal + * objects only — nothing is active until a maintainer approves them (see resolveEffectiveDenyRules). + */ +export function synthesizeDenyRuleProposals(records, config = {}) { + return engineSynthesizeDenyRuleProposals(records, config, Date.now()); } /** Optional forge host, scoping rows so two hosts serving the same owner/repo name never collide (#5563). @@ -35,203 +58,6 @@ function normalizeApiBaseUrl(apiBaseUrl) { return apiBaseUrl.trim(); } -function normalizeOptionalStringArray(value) { - if (value === undefined || value === null) return []; - if (!Array.isArray(value)) return []; - return value.filter((entry) => typeof entry === "string" && entry.trim()).map((entry) => entry.trim()); -} - -/** Validate one blocker-history row from the review stack (gate block/close audit). */ -export function normalizeBlockerHistoryRecord(record) { - if (!record || typeof record !== "object" || Array.isArray(record)) return null; - const blockerCodes = normalizeOptionalStringArray(record.blockerCodes); - if (blockerCodes.length === 0) return null; - const changedPaths = normalizeOptionalStringArray(record.changedPaths); - const guardrailMatches = normalizeOptionalStringArray(record.guardrailMatches); - const repoFullName = typeof record.repoFullName === "string" && record.repoFullName.trim() - ? normalizeRepoFullName(record.repoFullName) - : null; - return { - repoFullName, - blockerCodes, - changedPaths, - guardrailMatches, - pullNumber: Number.isInteger(record.pullNumber) && record.pullNumber > 0 ? record.pullNumber : null, - recordedAt: typeof record.recordedAt === "string" && record.recordedAt.trim() ? record.recordedAt.trim() : null, - }; -} - -export function normalizeBlockerHistory(records) { - if (!Array.isArray(records)) return []; - const normalized = []; - for (const record of records) { - const entry = normalizeBlockerHistoryRecord(record); - if (entry) normalized.push(entry); - } - return normalized; -} - -/** Canonicalize a changed path the same way guardrail matching does (case/separator insensitive). */ -export function canonicalizeChangedPath(path) { - if (typeof path !== "string") return null; - const trimmed = path.trim().replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, ""); - if (!trimmed || trimmed.includes("..")) return null; - return trimmed.toLowerCase(); -} - -/** Convert a repo-relative changed path into a deny-hook glob matching DEFAULT_DENY_RULES shape. */ -export function changedPathToDenyGlob(path) { - const canonical = canonicalizeChangedPath(path); - if (!canonical) return null; - return `**/${canonical}`; -} - -function ruleSignature(rule) { - return JSON.stringify({ - matcher: rule.matcher, - pathPattern: rule.pathPattern ?? null, - inputIncludesAll: rule.inputIncludesAll ?? null, - reason: rule.reason, - }); -} - -/** True when a synthesized glob is already enforced by a built-in default deny rule. */ -export function isCoveredByDefaultDenyRules(pathPattern) { - if (typeof pathPattern !== "string" || !pathPattern.trim()) return false; - const samplePath = pathPattern.replace(/^\*\*\//, ""); - if (!samplePath) return false; - return !evaluateDenyHooks({ name: "Write", input: { file_path: samplePath } }, DEFAULT_DENY_RULES).allowed; -} - -function collectPathsFromRecord(record) { - const paths = new Set(); - for (const path of [...record.changedPaths, ...record.guardrailMatches]) { - const canonical = canonicalizeChangedPath(path); - if (canonical) paths.add(canonical); - } - return paths; -} - -/** Aggregate path and blocker-code frequencies from normalized history. Pure. */ -export function aggregateBlockerHistory(records) { - const normalized = normalizeBlockerHistory(records); - const pathCounts = new Map(); - const pathBlockers = new Map(); - const blockerCounts = new Map(); - - for (const record of normalized) { - for (const code of record.blockerCodes) { - blockerCounts.set(code, (blockerCounts.get(code) ?? 0) + 1); - } - for (const path of collectPathsFromRecord(record)) { - pathCounts.set(path, (pathCounts.get(path) ?? 0) + 1); - const blockers = pathBlockers.get(path) ?? new Set(); - for (const code of record.blockerCodes) blockers.add(code); - pathBlockers.set(path, blockers); - } - } - - return { - pathCounts, - pathBlockers, - blockerCounts, - recordCount: normalized.length, - }; -} - -function stableProposalId(kind, key) { - const digest = createHash("sha256").update(`${kind}:${key}`).digest("hex").slice(0, 16); - return `${kind}:${digest}`; -} - -function buildPathProposal(path, occurrenceCount, blockerCodes) { - const pathPattern = changedPathToDenyGlob(path); - if (!pathPattern || isCoveredByDefaultDenyRules(pathPattern)) return null; - const sortedBlockers = [...blockerCodes].sort(); - const reason = `Synthesized deny rule: ${occurrenceCount} gate block(s) touched ${path} (${sortedBlockers.join(", ") || "path history"}). Review before enabling.`; - const rule = { matcher: "*", pathPattern, reason }; - return { - id: stableProposalId("path", pathPattern), - status: "proposed", - rule, - audit: { - kind: "path_history", - path, - pathPattern, - occurrenceCount, - blockerCodes: sortedBlockers, - synthesizedAt: new Date(0).toISOString(), - }, - }; -} - -/** - * Derive candidate deny-hook rules from blocker/path history. Returns proposal objects only — nothing is active - * until a maintainer approves them (see resolveEffectiveDenyRules). - */ -export function synthesizeDenyRuleProposals(records, config = {}) { - const minPathOccurrences = Number.isInteger(config.minPathOccurrences) - ? Math.max(1, config.minPathOccurrences) - : DEFAULT_SYNTHESIS_CONFIG.minPathOccurrences; - const maxProposals = Number.isInteger(config.maxProposals) - ? Math.max(1, config.maxProposals) - : DEFAULT_SYNTHESIS_CONFIG.maxProposals; - - const { pathCounts, pathBlockers, recordCount } = aggregateBlockerHistory(records); - if (recordCount === 0) return []; - - const rankedPaths = [...pathCounts.entries()] - .filter(([, count]) => count >= minPathOccurrences) - .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])); - - const proposals = []; - const seenSignatures = new Set(DEFAULT_DENY_RULES.map(ruleSignature)); - for (const [path, count] of rankedPaths) { - const proposal = buildPathProposal(path, count, pathBlockers.get(path) ?? new Set()); - if (!proposal) continue; - const signature = ruleSignature(proposal.rule); - if (seenSignatures.has(signature)) continue; - seenSignatures.add(signature); - proposals.push({ - ...proposal, - audit: { ...proposal.audit, synthesizedAt: new Date().toISOString() }, - }); - if (proposals.length >= maxProposals) break; - } - return proposals; -} - -/** Merge built-in defaults with maintainer-approved synthesized rules (deduped, defaults first). */ -export function resolveEffectiveDenyRules(options = {}) { - const includeDefaults = options.includeDefaults !== false; - const approvedProposals = Array.isArray(options.approvedProposals) ? options.approvedProposals : []; - const merged = includeDefaults ? [...DEFAULT_DENY_RULES] : []; - const seen = new Set(merged.map(ruleSignature)); - for (const proposal of approvedProposals) { - if (proposal?.status !== "approved") continue; - const rule = proposal.rule; - if (!rule || typeof rule !== "object") continue; - const signature = ruleSignature(rule); - if (seen.has(signature)) continue; - seen.add(signature); - merged.push(rule); - } - return merged; -} - -/** Apply maintainer approval/rejection to in-memory proposals. Pure. */ -export function setProposalStatuses(proposals, updates) { - if (!Array.isArray(proposals)) throw new Error("invalid_proposals"); - const updateMap = updates instanceof Map - ? updates - : new Map(Object.entries(updates ?? {}).filter(([id]) => typeof id === "string")); - return proposals.map((proposal) => { - const nextStatus = updateMap.get(proposal.id); - if (!nextStatus || !proposalStatusSet.has(nextStatus)) return proposal; - return { ...proposal, status: nextStatus }; - }); -} - export function resolveDenyHookSynthesisDbPath(env = process.env) { const explicitPath = typeof env.GITTENSORY_MINER_DENY_HOOK_SYNTHESIS_DB === "string" ? env.GITTENSORY_MINER_DENY_HOOK_SYNTHESIS_DB.trim() diff --git a/packages/gittensory-miner/lib/deny-hooks.js b/packages/gittensory-miner/lib/deny-hooks.js index 9d5184e767..b8fbecd6b6 100644 --- a/packages/gittensory-miner/lib/deny-hooks.js +++ b/packages/gittensory-miner/lib/deny-hooks.js @@ -1,155 +1,6 @@ -// 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), and/or -// - `inputTokenPattern` (a RegExp) must match a whole whitespace-separated token (quotes stripped) of a single -// string-shaped input field — for flag-shaped needles like `-f`, where a substring test would also fire on -// `--follow-tags`. -// A rule with none of these constraints 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, case-insensitive 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. Inputs are normalized before matching so `./`, nested, and Windows-style variants - * cannot bypass the built-in path rules. - */ -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}$`, "i"); -} - -function normalizePathCandidate(value) { - return value - .replace(/\\/g, "/") - .replace(/^\.\/+/, "") - .replace(/\/\.\//g, "/") - .replace(/\/+$/, ""); -} - -/** Collect string values anywhere in a tool-call input so rules can test nested tool arguments without - * hard-coding field names. Non-object input yields no strings (rule can't match). */ -function collectInputStrings(input, seen = new WeakSet()) { - const strings = []; - if (!input || typeof input !== "object") return strings; - if (seen.has(input)) return strings; - seen.add(input); - const values = Array.isArray(input) ? input : Object.values(input); - for (const value of values) { - if (typeof value === "string") strings.push(value); - else if (value && typeof value === "object") strings.push(...collectInputStrings(value, seen)); - } - return strings; -} - -/** Split a string-shaped input field into whitespace-separated tokens with surrounding quotes stripped — - * shared by path-candidate expansion and flag-token matching below. */ -function splitTokens(value) { - return value - .split(/\s+/) - .map((token) => token.replace(/^["']+|["']+$/g, "")) - .filter(Boolean); -} - -/** - * 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 = new Set([value, normalizePathCandidate(value)]); - for (const trimmed of splitTokens(value)) { - candidates.add(trimmed); - candidates.add(normalizePathCandidate(trimmed)); - } - return [...candidates].filter(Boolean); -} - -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; - } - if (rule.inputTokenPattern instanceof RegExp) { - if (!inputStrings.some((value) => splitTokens(value).some((token) => rule.inputTokenPattern.test(token)))) { - 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 conservative git force-push guards (a command carrying `push` plus a force flag). - */ -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: "**/.dev.vars", reason: "Never read or write local Worker secrets (.dev.vars)." }, - { matcher: "*", pathPattern: "**/.npmrc", reason: "Never read or write npm credential files (.npmrc)." }, - { matcher: "*", pathPattern: "**/*secret*/**", reason: "Never touch secret-bearing directories (**/*secret*/**)." }, - { matcher: "*", pathPattern: "**/*secret*", reason: "Never touch secret-bearing paths (**/*secret*)." }, - // Ordered before **/*.pem below: a file like id_private_key.pem matches both patterns, and - // evaluateDenyHooks returns the first matching rule's reason — this one is more specific - // (#2942, keeps the "private key material" reason for *private*key*.pem files). - { matcher: "*", pathPattern: "**/*private*key*", reason: "Never touch private key material (**/*private*key*)." }, - { matcher: "*", pathPattern: "**/*.pem", reason: "Never touch PEM key material (*.pem)." }, - { matcher: "*", inputIncludesAll: ["push", "--force"], reason: "Never force-push (git push --force)." }, - // Token-matched rather than substring-matched: a substring test for "-f" would also fire on an - // unrelated long flag like --follow-tags. Matches a whole short-option token (bundled or not) - // whose letters include "f", e.g. -f, -uf, -fu, but not a "--"-prefixed long flag. - { matcher: "*", inputIncludesAll: ["push"], inputTokenPattern: /^-[a-z]*f[a-z]*$/i, reason: "Never force-push (git push -f)." }, -]; - -/** - * 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 }; -} +// PreToolUse-style deny-hook primitives (#2295). Now a thin re-export of the engine's pure, deterministic deny +// evaluator: the whole implementation moved into `@loopover/engine` (packages/gittensory-engine/src/miner/ +// deny-hooks.ts) by #5667 so the review stack and the miner share one copy. No behavior change — the evaluator is +// pure (no IO, no globals, no Date/random). See deny-hooks.d.ts for the type contract (DenyRule/DenyVerdict/ +// ProposedToolCall), which still declares the same shapes the engine module now implements. +export { DEFAULT_DENY_RULES, evaluateDenyHooks } from "@loopover/engine"; diff --git a/test/unit/deny-hook-engine-extraction.test.ts b/test/unit/deny-hook-engine-extraction.test.ts new file mode 100644 index 0000000000..645f124c1d --- /dev/null +++ b/test/unit/deny-hook-engine-extraction.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +// #5667: the deny-hook pure logic now lives in gittensory-engine. This suite drives it through the ENGINE's +// public barrel ALONE (no @loopover/miner, no SQLite in the import graph), proving portability, and exercises +// every branch of the extracted evaluator + synthesizer so the moved code carries its own coverage. +import { + DEFAULT_DENY_RULES, + DEFAULT_SYNTHESIS_CONFIG, + PROPOSAL_STATUSES, + aggregateBlockerHistory, + canonicalizeChangedPath, + changedPathToDenyGlob, + evaluateDenyHooks, + isCoveredByDefaultDenyRules, + normalizeBlockerHistory, + normalizeBlockerHistoryRecord, + normalizeRepoFullName, + proposalStatusSet, + resolveEffectiveDenyRules, + setProposalStatuses, + synthesizeDenyRuleProposals, + type DenyRuleProposal, +} from "../../packages/gittensory-engine/src/index"; +import { denyHookFixtures } from "../fixtures/deny-hooks/cases.js"; + +// A fixed injected clock so the synthesizer is deterministic given its inputs (the #5667 requirement). +const NOW = 1_700_000_000_000; + +describe("deny-hook engine extraction — evaluator, via @loopover/engine alone (#5667)", () => { + it.each(denyHookFixtures)("fixture: $name", (fixture) => { + const verdict = fixture.rules ? evaluateDenyHooks(fixture.toolCall, fixture.rules) : evaluateDenyHooks(fixture.toolCall); + expect(verdict.allowed).toBe(fixture.expected.allowed); + if (fixture.expected.allowed) { + expect(verdict.blockedBy).toBeUndefined(); + } else if (fixture.expected.blockedByIncludes !== undefined) { + expect(verdict.blockedBy?.reason).toContain(fixture.expected.blockedByIncludes); + } + }); + + it("allows a call with no matching rule, an empty rule set, a non-array rule set, and a non-object tool call", () => { + expect(evaluateDenyHooks({ name: "Write", input: { file_path: "src/ok.ts" } }, []).allowed).toBe(true); + expect(evaluateDenyHooks({ name: "Write", input: { file_path: "src/ok.ts" } }, null as never).allowed).toBe(true); + expect(evaluateDenyHooks(null as never).allowed).toBe(true); + expect(evaluateDenyHooks({ name: "Write", input: {} as never }).allowed).toBe(true); + }); + + it("ignores a malformed rule and a non-string matcher, and force-push token/substring rules fire", () => { + // A non-object rule + a rule whose matcher is not a string are both skipped without throwing. + expect(evaluateDenyHooks({ name: "Bash", input: { command: "ls" } }, [null as never, { matcher: 42 as never, reason: "x" }]).allowed).toBe(true); + // inputIncludesAll (push + --force) and inputTokenPattern (-f bundled) both block. + expect(evaluateDenyHooks({ name: "Bash", input: { command: "git push --force origin main" } }).allowed).toBe(false); + expect(evaluateDenyHooks({ name: "Bash", input: { command: "git push -uf origin main" } }).allowed).toBe(false); + // A "push" without any force flag is allowed (inputTokenPattern must not match --follow-tags). + expect(evaluateDenyHooks({ name: "Bash", input: { command: "git push --follow-tags origin main" } }).allowed).toBe(true); + }); + + it("collects strings from nested/array inputs, skips falsy values, and tolerates a cyclic input object", () => { + const cyclic: Record = { nested: { file_path: "config/secrets/x.json" } }; + cyclic.self = cyclic; // exercises the WeakSet cycle guard in collectInputStrings + expect(evaluateDenyHooks({ name: "Write", input: cyclic }).allowed).toBe(false); + expect(evaluateDenyHooks({ name: "Edit", input: { edits: ["src/a.ts", ".env.production"] } }).allowed).toBe(false); + // Falsy, non-string nested values (0/null/false) are skipped without recursing — the `value && ...` guard. + expect(evaluateDenyHooks({ name: "Write", input: { count: 0, flag: null, ok: false, file_path: ".env" } }).allowed).toBe(false); + }); +}); + +describe("deny-hook engine extraction — synthesizer, via @loopover/engine alone (#5667)", () => { + it("normalizes blocker history, dropping malformed rows and shaping valid ones", () => { + expect(normalizeBlockerHistory("not-an-array" as never)).toEqual([]); + expect(normalizeBlockerHistory([null, 42, [], { blockerCodes: [] }])).toEqual([]); + const [record] = normalizeBlockerHistory([ + { + repoFullName: "acme/widgets", + blockerCodes: ["guardrail_hold", " ", 7 as never], + changedPaths: ["src/a.ts"], + guardrailMatches: ["src/b.ts"], + pullNumber: 12, + recordedAt: "2026-01-01T00:00:00.000Z", + }, + ]); + expect(record).toMatchObject({ repoFullName: "acme/widgets", blockerCodes: ["guardrail_hold"], pullNumber: 12 }); + // repoFullName absent, pullNumber invalid, recordedAt absent → all null/[]; a blockerCodes-only row still normalizes. + expect(normalizeBlockerHistoryRecord({ blockerCodes: ["x"], pullNumber: 0 })).toMatchObject({ + repoFullName: null, + pullNumber: null, + recordedAt: null, + changedPaths: [], + guardrailMatches: [], + }); + expect(normalizeBlockerHistoryRecord(null)).toBeNull(); + expect(normalizeBlockerHistoryRecord([] as never)).toBeNull(); + expect(normalizeBlockerHistoryRecord({ blockerCodes: [] })).toBeNull(); + expect(normalizeBlockerHistoryRecord({ blockerCodes: "nope" as never })).toBeNull(); + }); + + it("normalizeRepoFullName validates owner/repo and rejects malformed values", () => { + expect(normalizeRepoFullName(" acme/widgets ")).toBe("acme/widgets"); + expect(() => normalizeRepoFullName(42 as never)).toThrow("invalid_repo_full_name"); + expect(() => normalizeRepoFullName("no-slash")).toThrow("invalid_repo_full_name"); + expect(() => normalizeRepoFullName("a/b/c")).toThrow("invalid_repo_full_name"); + }); + + it("canonicalizes and globs changed paths, rejecting traversal and non-strings", () => { + expect(canonicalizeChangedPath("./Src/Foo.ts")).toBe("src/foo.ts"); + expect(canonicalizeChangedPath("a\\b\\C.TS")).toBe("a/b/c.ts"); + expect(canonicalizeChangedPath(42 as never)).toBeNull(); + expect(canonicalizeChangedPath("../escape")).toBeNull(); + expect(canonicalizeChangedPath(" ")).toBeNull(); + expect(changedPathToDenyGlob("src/Foo.ts")).toBe("**/src/foo.ts"); + expect(changedPathToDenyGlob("../nope")).toBeNull(); + }); + + it("isCoveredByDefaultDenyRules recognizes built-in coverage and handles blank input", () => { + expect(isCoveredByDefaultDenyRules("**/.github/workflows/deploy.yml")).toBe(true); + expect(isCoveredByDefaultDenyRules("**/docs/CHANGELOG.md")).toBe(false); + expect(isCoveredByDefaultDenyRules(" ")).toBe(false); + expect(isCoveredByDefaultDenyRules(42 as never)).toBe(false); + expect(isCoveredByDefaultDenyRules("**/")).toBe(false); // samplePath collapses to empty + }); + + it("aggregates blocker/path frequencies", () => { + expect(aggregateBlockerHistory([]).recordCount).toBe(0); + const agg = aggregateBlockerHistory([ + // "../escape.md" canonicalizes to null (traversal) and is skipped — the `if (canonical)` false branch. + { blockerCodes: ["a"], changedPaths: ["CHANGELOG.md", "../escape.md"] }, + { blockerCodes: ["a", "b"], changedPaths: ["./CHANGELOG.md"], guardrailMatches: ["CHANGELOG.md"] }, + ]); + expect(agg.pathCounts.has("../escape.md")).toBe(false); + expect(agg.recordCount).toBe(2); + expect(agg.pathCounts.get("changelog.md")).toBe(2); + expect(agg.blockerCounts.get("a")).toBe(2); + expect([...(agg.pathBlockers.get("changelog.md") ?? [])].sort()).toEqual(["a", "b"]); + }); + + it("synthesizes deterministic, clock-stamped proposals and honors config + thresholds + caps", () => { + expect(synthesizeDenyRuleProposals([], {}, NOW)).toEqual([]); + expect(DEFAULT_SYNTHESIS_CONFIG).toMatchObject({ minPathOccurrences: 2, maxProposals: 20 }); + const history = [ + { blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] }, + { blockerCodes: ["guardrail_hold"], changedPaths: ["./CHANGELOG.md"] }, + { blockerCodes: ["guardrail_hold"], guardrailMatches: ["CHANGELOG.md"] }, + ]; + const proposals = synthesizeDenyRuleProposals(history, { minPathOccurrences: 2, maxProposals: 5 }, NOW); + expect(proposals).toHaveLength(1); + expect(proposals[0]?.rule).toMatchObject({ matcher: "*", pathPattern: "**/changelog.md" }); + expect(proposals[0]?.status).toBe("proposed"); + expect(proposals[0]?.audit.occurrenceCount).toBe(3); + // The injected clock makes synthesizedAt deterministic — the whole point of #5667's nowMs injection. + expect(proposals[0]?.audit.synthesizedAt).toBe(new Date(NOW).toISOString()); + + // Non-integer config falls back to defaults; below-threshold + default-covered paths yield nothing. + expect(synthesizeDenyRuleProposals(history, { minPathOccurrences: 1.5 as never }, NOW)).toHaveLength(1); + expect(synthesizeDenyRuleProposals([{ blockerCodes: ["x"], changedPaths: ["docs/ONE.md"] }], { minPathOccurrences: 2 }, NOW)).toEqual([]); + expect( + synthesizeDenyRuleProposals( + [ + { blockerCodes: ["x"], changedPaths: [".github/workflows/ci.yml"] }, + { blockerCodes: ["x"], changedPaths: [".github/workflows/ci.yml"] }, + ], + {}, + NOW, + ), + ).toEqual([]); + // maxProposals cap: two distinct repeated paths, cap of 1 → only one proposal. + const capped = synthesizeDenyRuleProposals( + [ + { blockerCodes: ["x"], changedPaths: ["docs/AAA.md"] }, + { blockerCodes: ["x"], changedPaths: ["docs/AAA.md"] }, + { blockerCodes: ["x"], changedPaths: ["docs/BBB.md"] }, + { blockerCodes: ["x"], changedPaths: ["docs/BBB.md"] }, + ], + { minPathOccurrences: 2, maxProposals: 1 }, + NOW, + ); + expect(capped).toHaveLength(1); + }); + + it("resolves effective rules from defaults + approved proposals, and setProposalStatuses applies decisions", () => { + expect(resolveEffectiveDenyRules()).toEqual(DEFAULT_DENY_RULES); + expect(resolveEffectiveDenyRules({ includeDefaults: false })).toEqual([]); + const proposals = synthesizeDenyRuleProposals( + [ + { blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] }, + { blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] }, + ], + {}, + NOW, + ); + // Not yet approved → still just the defaults. + expect(resolveEffectiveDenyRules({ approvedProposals: proposals })).toEqual(DEFAULT_DENY_RULES); + // setProposalStatuses: object form, a Map form, an unknown id (no-op), an invalid status (no-op), undefined updates. + const approved = setProposalStatuses(proposals, { [proposals[0]!.id]: "approved" }); + expect(approved[0]?.status).toBe("approved"); + expect(setProposalStatuses(proposals, new Map([[proposals[0]!.id, "rejected" as const]]))[0]?.status).toBe("rejected"); + expect(setProposalStatuses(proposals, { unknown: "approved" })[0]?.status).toBe("proposed"); + expect(setProposalStatuses(proposals, { [proposals[0]!.id]: "bogus" as never })[0]?.status).toBe("proposed"); + expect(setProposalStatuses(proposals, undefined as never)[0]?.status).toBe("proposed"); + expect(() => setProposalStatuses("nope" as never, {})).toThrow("invalid_proposals"); + + const effective = resolveEffectiveDenyRules({ approvedProposals: approved }); + expect(effective.length).toBe(DEFAULT_DENY_RULES.length + 1); + // A non-approved / malformed / duplicate proposal in the approved list is skipped (status + rule-shape + dedupe guards). + const noisy: DenyRuleProposal[] = [ + ...approved, + { id: "x", status: "proposed", rule: approved[0]!.rule, audit: approved[0]!.audit }, + { id: "y", status: "approved", rule: null as never, audit: approved[0]!.audit }, + { id: "z", status: "approved", rule: approved[0]!.rule, audit: approved[0]!.audit }, // duplicate signature + ]; + expect(resolveEffectiveDenyRules({ approvedProposals: noisy }).length).toBe(DEFAULT_DENY_RULES.length + 1); + expect(evaluateDenyHooks({ name: "Write", input: { file_path: "CHANGELOG.md" } }, effective).allowed).toBe(false); + }); + + it("exposes the frozen proposal-status vocabulary", () => { + expect(PROPOSAL_STATUSES).toEqual(["proposed", "approved", "rejected"]); + expect(proposalStatusSet.has("approved")).toBe(true); + expect(proposalStatusSet.has("nope")).toBe(false); + }); +});