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
5 changes: 5 additions & 0 deletions packages/loopover-engine/src/miner/deny-hook-synthesis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ function ruleSignature(rule: DenyRule): string {
matcher: rule.matcher,
pathPattern: rule.pathPattern ?? null,
inputIncludesAll: rule.inputIncludesAll ?? null,
// RegExp doesn't survive JSON.stringify (it serializes to `{}`), so two rules differing only by
// inputTokenPattern would otherwise collapse to the same signature. .toString() (e.g. "/^-[a-z]*f[a-z]*$/i")
// captures both source and flags, matching this function's own "identical = same effective match behavior"
// contract for the other optional fields.
inputTokenPattern: rule.inputTokenPattern ? rule.inputTokenPattern.toString() : null,
reason: rule.reason,
});
}
Expand Down
25 changes: 25 additions & 0 deletions test/unit/miner-deny-hook-synthesis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,31 @@ describe("resolveEffectiveDenyRules() (#4522)", () => {
expect(verdict.allowed).toBe(false);
expect(verdict.blockedBy?.pathPattern).toBe("**/changelog.md");
});

// #8013: ruleSignature (the identity function resolveEffectiveDenyRules dedupes with) used to omit
// inputTokenPattern entirely, so two rules identical in every OTHER field but a different inputTokenPattern
// collapsed onto the same signature and the second was silently dropped as a "duplicate".
it("does NOT dedupe two approved rules that differ only by inputTokenPattern (#8013)", () => {
const now = new Date(0).toISOString();
const baseProposal = (inputTokenPattern: RegExp, id: string) => ({
id,
status: "approved" as const,
rule: { matcher: "*", inputIncludesAll: ["push"], inputTokenPattern, reason: "custom force-push guard" },
audit: { kind: "manual", synthesizedAt: now },
});
const approved = [
baseProposal(/^-[a-z]*f[a-z]*$/i, "custom:1"),
baseProposal(/^--force$/i, "custom:2"),
];
const effective = resolveEffectiveDenyRules({ approvedProposals: approved });
// Both survive: a real bug here collapses the second one away, leaving only DEFAULT_DENY_RULES.length + 1.
expect(effective.length).toBe(DEFAULT_DENY_RULES.length + 2);
// And each one's OWN pattern is still the one that's actually enforced (not silently replaced by the other).
const longFlagVerdict = evaluateDenyHooks({ name: "Bash", input: { command: "git push --force" } }, effective);
expect(longFlagVerdict.allowed).toBe(false);
const shortFlagVerdict = evaluateDenyHooks({ name: "Bash", input: { command: "git push -f" } }, effective);
expect(shortFlagVerdict.allowed).toBe(false);
});
});

describe("initDenyHookSynthesisStore() (#4522)", () => {
Expand Down