From fa5006ed9859742cb484654fe807b2edebeb58cc Mon Sep 17 00:00:00 2001 From: artale Date: Mon, 15 Jun 2026 17:15:18 +0200 Subject: [PATCH] feat: add orchestrator policy inheritance --- src/fable5/index.ts | 3 +++ src/fable5/orchestrator-policy.test.ts | 20 ++++++++++++++++ src/fable5/orchestrator-policy.ts | 33 ++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 src/fable5/orchestrator-policy.test.ts create mode 100644 src/fable5/orchestrator-policy.ts diff --git a/src/fable5/index.ts b/src/fable5/index.ts index 5c68aa3..6a375c8 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -37,3 +37,6 @@ export type { SubAgentSpec, MetaAgentConfig, MetaAgentResult } from "./meta-agen export { AgentTeams } from "./agent-teams.js"; export type { TeamRole, TeamAgent, TeamConfig, TeamResult } from "./agent-teams.js"; + +export { isActionDenied, mergeToolPolicies } from "./orchestrator-policy.js"; +export type { ToolPolicy, EffectiveToolPolicy } from "./orchestrator-policy.js"; diff --git a/src/fable5/orchestrator-policy.test.ts b/src/fable5/orchestrator-policy.test.ts new file mode 100644 index 0000000..0bb8832 --- /dev/null +++ b/src/fable5/orchestrator-policy.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { isActionDenied, mergeToolPolicies } from "./orchestrator-policy.js"; + +describe("orchestrator tool policy", () => { + it("cascades parent denies over child allows", () => { + const effective = mergeToolPolicies( + { deny: ["git push --force"] }, + { allow: ["shell", "git push --force"] }, + ); + + expect(effective.allow).toContain("git push --force"); + expect(effective.deny).toContain("git push --force"); + expect(isActionDenied("git push origin main --force", effective)).toBe(true); + expect(isActionDenied("git push origin main --force-with-lease", effective)).toBe(true); + }); + + it("does not block normal push when only force push is denied", () => { + expect(isActionDenied("git push origin main", { deny: ["git push --force"] })).toBe(false); + }); +}); diff --git a/src/fable5/orchestrator-policy.ts b/src/fable5/orchestrator-policy.ts new file mode 100644 index 0000000..70c569b --- /dev/null +++ b/src/fable5/orchestrator-policy.ts @@ -0,0 +1,33 @@ +export interface ToolPolicy { + allow?: string[]; + deny?: string[]; +} + +export interface EffectiveToolPolicy { + allow: string[]; + deny: string[]; +} + +const uniq = (items: string[] = []): string[] => [...new Set(items.map((s) => s.trim()).filter(Boolean))]; + +export function mergeToolPolicies(parent?: ToolPolicy, child?: ToolPolicy): EffectiveToolPolicy { + return { + allow: uniq([...(parent?.allow ?? []), ...(child?.allow ?? [])]), + // ponytail: deny wins; add richer policy language when real agent configs need it. + deny: uniq([...(parent?.deny ?? []), ...(child?.deny ?? [])]), + }; +} + +export function isActionDenied(action: string, policy?: ToolPolicy): boolean { + const normalized = action.toLowerCase().replace(/\s+/g, " ").trim(); + return mergeToolPolicies(policy).deny.some((rule) => matchesRule(normalized, rule)); +} + +function matchesRule(action: string, rule: string): boolean { + const normalizedRule = rule.toLowerCase().replace(/\s+/g, " ").trim(); + if (!normalizedRule) return false; + if (normalizedRule === "git push --force") { + return /\bgit\s+push\b/.test(action) && /(?:\s|^)(?:--force(?:\s|=|$)|-f(?:\s|$)|--force-with-lease(?:\s|=|$))/.test(action); + } + return action.includes(normalizedRule); +}