summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-03 16:33:50 +0900
committerAdam Malczewski <[email protected]>2026-06-03 16:33:50 +0900
commit24bdaa6ca0333b91369ac50b23e929f83af01c3a (patch)
tree110f8a32c16daa01bfce8b412e687cf241cd8b6f
parentebd68da7dfd6d4f2ef6c6b29a62ec848bbf15cef (diff)
downloaddispatch-24bdaa6ca0333b91369ac50b23e929f83af01c3a.tar.gz
dispatch-24bdaa6ca0333b91369ac50b23e929f83af01c3a.zip
fix(config): emit local permission patterns after global ones in merge
Gemini review caught a precedence-inversion bug in mergePermissions: when a nested permission group exists in BOTH global and local configs, the previous `{ ...existing, ...value }` spread updated an overridden pattern IN PLACE, leaving its original (global) insertion slot. Since configToRuleset flattens patterns in iteration order and evaluate() uses findLast (last match wins), a more-general global pattern declared lower (e.g. "*") would sit AFTER the local override and silently shadow it. Example: global bash { "npm test"=allow, "*"=ask } + local bash { "npm test"=deny } resolved "npm test" to "ask" instead of "deny". Fix: drop global patterns the local block also defines, keep remaining global patterns in order, then append ALL local patterns last — reproducing a clean "global rules then local rules" concatenation so local always wins. Adds a regression test asserting order and evaluation outcome.
-rw-r--r--packages/core/src/config/loader.ts37
-rw-r--r--packages/core/tests/config/merge.test.ts17
2 files changed, 46 insertions, 8 deletions
diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts
index 1e5f151..66f798b 100644
--- a/packages/core/src/config/loader.ts
+++ b/packages/core/src/config/loader.ts
@@ -105,13 +105,17 @@ export function loadConfig(dir: string): DispatchConfig {
* - A key present only in one side is carried over verbatim.
* - A key whose value is a string on either side: local replaces global.
* - A key that is a nested `{ pattern -> action }` object on BOTH sides is
- * merged pattern-by-pattern (local patterns override global patterns of the
- * same name; non-conflicting patterns from both survive).
+ * merged pattern-by-pattern: global patterns the local block does NOT also
+ * define come first (original order), then EVERY local pattern is appended
+ * last (overriding any same-named global pattern).
*
- * Global groups are emitted before local-only groups, and global patterns
- * before local patterns within a shared group. This ordering matters because
- * `configToRuleset` flattens in iteration order and `evaluate` uses `findLast`
- * (last match wins) — so local rules naturally win.
+ * Emitting all local patterns after the global ones is essential, not
+ * cosmetic: `configToRuleset` flattens patterns in iteration order and
+ * `evaluate` uses `findLast` (last match wins). If an overridden pattern were
+ * updated in place, a more-general global pattern (e.g. "*") could remain AFTER
+ * it and silently shadow the local override. Appending local patterns last
+ * reproduces a clean "global rules then local rules" concatenation so local
+ * always wins.
*/
function mergePermissions(
global: DispatchConfig["permissions"],
@@ -124,8 +128,25 @@ function mergePermissions(
for (const [key, value] of Object.entries(local)) {
const existing = result[key];
if (existing !== undefined && typeof existing !== "string" && typeof value !== "string") {
- // Both nested objects — merge patterns, local wins on conflicts.
- result[key] = { ...existing, ...value };
+ // Both nested objects — merge patterns so that ALL local patterns
+ // are emitted AFTER the global ones. This matters because
+ // `configToRuleset` flattens patterns in insertion order and
+ // `evaluate` uses `findLast` (last match wins): a naive
+ // `{ ...existing, ...value }` would update an overridden pattern
+ // IN PLACE, leaving a more-general global pattern (e.g. "*") sitting
+ // AFTER it and silently shadowing the local override. We therefore
+ // drop any global pattern that the local block also defines, keep the
+ // remaining global patterns in their original order, then append every
+ // local pattern last — reproducing a clean "global rules then local
+ // rules" concatenation where local always wins.
+ const merged: Record<string, string> = {};
+ for (const [pattern, action] of Object.entries(existing)) {
+ if (!(pattern in value)) merged[pattern] = action;
+ }
+ for (const [pattern, action] of Object.entries(value)) {
+ merged[pattern] = action;
+ }
+ result[key] = merged;
} else {
// Local string, brand-new key, or a string/object type mismatch:
// local replaces global wholesale.
diff --git a/packages/core/tests/config/merge.test.ts b/packages/core/tests/config/merge.test.ts
index b362628..b9e4bbb 100644
--- a/packages/core/tests/config/merge.test.ts
+++ b/packages/core/tests/config/merge.test.ts
@@ -157,6 +157,23 @@ describe("mergeConfigs — permissions", () => {
const ruleset = configToRuleset(merged);
expect(evaluate("bash", "anything", ruleset).action).toBe("allow");
});
+
+ // Regression: a SPECIFIC local override must not be shadowed by a more
+ // GENERAL global pattern (e.g. "*") that happened to be declared lower in
+ // the global block. `evaluate` uses findLast, so every local pattern must be
+ // emitted AFTER all global patterns of the same group.
+ it("specific local override beats a general global wildcard regardless of declaration order", () => {
+ const merged = mergeConfigs(
+ { permissions: { bash: { "npm test": "allow", "*": "ask" } } },
+ { permissions: { bash: { "npm test": "deny" } } },
+ );
+ // Local "npm test" must be emitted after global "*".
+ expect(Object.keys(merged.permissions.bash as object)).toEqual(["*", "npm test"]);
+ const ruleset = configToRuleset(merged);
+ expect(evaluate("bash", "npm test", ruleset).action).toBe("deny");
+ // And the inherited global wildcard still applies to other commands.
+ expect(evaluate("bash", "rm -rf /", ruleset).action).toBe("ask");
+ });
});
// ─── loadConfig (filesystem integration) ─────────────────────────