From 5c2b43eda803d893dbc7141fb6bcae38759a9e67 Mon Sep 17 00:00:00 2001 From: Fran Zekan Date: Thu, 13 Aug 2026 14:52:18 +0200 Subject: [PATCH] feat: support tool-name wildcards in policies --- .changeset/tool-name-policy-wildcards.md | 5 + apps/docs/concepts/policies.mdx | 19 ++++ e2e/scenarios/policies-ui.test.ts | 63 +++++++++-- packages/core/sdk/src/policies.test.ts | 74 ++++++++++++- packages/core/sdk/src/policies.ts | 127 ++++++++++++++++++----- packages/react/src/pages/policies.tsx | 11 +- 6 files changed, 257 insertions(+), 42 deletions(-) create mode 100644 .changeset/tool-name-policy-wildcards.md diff --git a/.changeset/tool-name-policy-wildcards.md b/.changeset/tool-name-policy-wildcards.md new file mode 100644 index 0000000000..a89aee74cf --- /dev/null +++ b/.changeset/tool-name-policy-wildcards.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Match policy rules against generated tool-name prefixes, with `**` support for tool names nested under any number of groups. Policies can now cover families such as `get*` or `delete*` without enumerating every imported API operation. diff --git a/apps/docs/concepts/policies.mdx b/apps/docs/concepts/policies.mdx index 85318b6116..0dbb58e811 100644 --- a/apps/docs/concepts/policies.mdx +++ b/apps/docs/concepts/policies.mdx @@ -12,3 +12,22 @@ A **policy** controls what an agent can do with each tool: Policies start from a sensible default derived from the integration's spec. For example, read-only `GET` operations on an OpenAPI spec are allowed by default, while writes can be set to require approval. You can tune the policy for any tool at any time. + +## Matching tools with patterns + +Policy patterns match a tool's dotted address. You can target one tool, an integration, or +tool names that share a generated prefix: + +- `*` matches every tool. +- `github.*` matches the whole GitHub integration. +- `github.*.*.repos.*` matches every tool in the `repos` group across connections. +- `github.*.*.**.get*` matches tools whose final name segment starts with `get`, at any + depth in the integration's generated tool groups. + +A complete `*` in the middle of a pattern matches one address segment. A trailing complete +`*` matches the remaining subtree. `**` matches zero or more segments, and a suffix wildcard +such as `get*` matches characters within one segment without crossing a dot. + +Name-prefix patterns are case-sensitive and match the generated tool name, not the underlying +HTTP method. An OpenAPI operation can use an `operationId` such as `listUsers` even when its +method is `GET`. OpenAPI defaults still derive approval behavior from the actual HTTP method. diff --git a/e2e/scenarios/policies-ui.test.ts b/e2e/scenarios/policies-ui.test.ts index cc400cd0ab..48b2f20f31 100644 --- a/e2e/scenarios/policies-ui.test.ts +++ b/e2e/scenarios/policies-ui.test.ts @@ -17,6 +17,8 @@ // (the Clear affordance), and Clear really removes the rule. // 5. The rules materialize as manageable rows on /policies and persist // server-side with exactly the owner/pattern/action the UI promised. +// 6. A manually-authored `**.get*` rule matches generated tool names across +// groups without catching a sibling `delete*` tool. import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; @@ -32,10 +34,10 @@ const api = composePluginApi([openApiHttpPlugin()] as const); const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); -/** Two tagged groups so the tree renders a `records` category (two leaves) - * next to an unrelated `checks` category the rules must not touch. Tag → - * group segment, operationId → leaf segment: `records.list`, - * `records.create`, `checks.ping`. Never contacted over the network. */ +/** Tagged groups render `records` category rules alongside generated + * `users.getV1User` / `users.deleteV1User` names for wildcard coverage and + * an unrelated `checks` category the rules must not touch. Tag → group + * segment and operationId → leaf segment. Never contacted over the network. */ const recordsSpec = JSON.stringify({ openapi: "3.0.3", info: { title: "Records API", version: "1.0.0" }, @@ -62,6 +64,20 @@ const recordsSpec = JSON.stringify({ responses: { "200": { description: "ok" } }, }, }, + "/users/{id}": { + get: { + operationId: "getV1User", + tags: ["users"], + summary: "Get a user", + responses: { "200": { description: "ok" } }, + }, + delete: { + operationId: "deleteV1User", + tags: ["users"], + summary: "Delete a user", + responses: { "204": { description: "deleted" } }, + }, + }, }, }); @@ -87,6 +103,7 @@ scenario( const leafPattern = `${integration}.*.*.records.create`; const categoryPattern = `${integration}.*.*.records.*`; const listLeafPattern = `${integration}.*.*.records.list`; + const getNamePattern = `${integration}.*.*.**.get*`; // Selfhost scenarios share one workspace — remove everything this one // made (policies, connections, the integration) even on failure. @@ -292,19 +309,49 @@ scenario( await page.getByText(leafPattern, { exact: true }).waitFor(); await page.getByText(categoryPattern, { exact: true }).waitFor(); }); + + await step("Require approval for every generated get-prefixed tool", async () => { + await page.getByLabel("Pattern").fill(getNamePattern); + await page.getByRole("button", { name: "Add policy" }).click(); + await page.getByText(getNamePattern, { exact: true }).waitFor(); + }); + + await step( + "The name wildcard matches get tools without catching delete tools", + async () => { + await page.goto(`/integrations/${integration}`, { waitUntil: "networkidle" }); + await page.getByRole("tab", { name: "Tools" }).click(); + await closedGroup(alpha, integration).click(); + await closedGroup(alpha, "users").click(); + await leafIndicator( + alpha, + "getV1User", + `Require approval (matched ${getNamePattern})`, + ).waitFor(); + await leafIndicator( + alpha, + "deleteV1User", + "Plugin default: Require approval", + ).waitFor(); + }, + ); }); - // Server-side truth, on a fresh read: exactly the two authored rules, + // Server-side truth, on a fresh read: exactly the three authored rules, // org-owned, with the more specific leaf rule placed above the later - // category rule so it keeps precedence. + // category and tool-name wildcard rules so it keeps precedence. const policies = yield* client.policies.list(); const mine = policies .filter((p) => p.pattern.startsWith(`${integration}.`)) .sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0)); expect( mine.map((p) => `${p.owner} ${p.pattern} ${p.action}`), - "the UI-authored rules persisted with the leaf rule above the category rule", - ).toEqual([`org ${leafPattern} block`, `org ${categoryPattern} require_approval`]); + "the UI-authored rules persisted in specificity order", + ).toEqual([ + `org ${leafPattern} block`, + `org ${categoryPattern} require_approval`, + `org ${getNamePattern} require_approval`, + ]); }).pipe(Effect.ensuring(cleanup)); }), ); diff --git a/packages/core/sdk/src/policies.test.ts b/packages/core/sdk/src/policies.test.ts index beb9703c49..ddc668e8da 100644 --- a/packages/core/sdk/src/policies.test.ts +++ b/packages/core/sdk/src/policies.test.ts @@ -17,6 +17,8 @@ import { effectivePolicyFromSorted, isValidPattern, matchPattern, + patternSpecificity, + positionForNewPattern, resolveToolPolicy, } from "./policies"; import { definePlugin, tool } from "./plugin"; @@ -73,6 +75,25 @@ describe("matchPattern", () => { expect(matchPattern("github.user.alice.repos.*", "github.user.alice.repos.list")).toBe(true); expect(matchPattern("github.user.alice.repos.*", "github.user.bob.repos.list")).toBe(false); }); + + it("matches trailing wildcards within a tool-name segment", () => { + expect(matchPattern("github.*.*.users.get*", "github.org.acme.users.getV1User")).toBe(true); + expect(matchPattern("github.*.*.users.get*", "github.org.acme.users.get")).toBe(true); + expect(matchPattern("github.*.*.users.get*", "github.org.acme.users.listV1Users")).toBe(false); + // A tool-name wildcard never consumes a dot. Use `**` when the number of + // structured tool-name segments is intentionally variable. + expect(matchPattern("github.*.*.get*", "github.org.acme.users.getV1User")).toBe(false); + }); + + it("matches globstars across zero or more structured tool-name segments", () => { + expect(matchPattern("github.*.*.**.get*", "github.org.acme.getV1User")).toBe(true); + expect(matchPattern("github.*.*.**.get*", "github.org.acme.users.getV1User")).toBe(true); + expect(matchPattern("github.*.*.**.get*", "github.org.acme.v1.users.getV1User")).toBe(true); + expect(matchPattern("github.*.*.**.get*", "github.org.acme.v1.users.deleteV1User")).toBe(false); + expect(matchPattern("github.*.*.users.**.get*", "github.org.acme.users.v1.getV1User")).toBe( + true, + ); + }); }); describe("isValidPattern", () => { @@ -91,6 +112,12 @@ describe("isValidPattern", () => { expect(isValidPattern("github.user.alice.repos.*")).toBe(true); }); + it("accepts tool-name prefixes and globstars", () => { + expect(isValidPattern("github.*.*.users.get*")).toBe(true); + expect(isValidPattern("github.*.*.**.get*")).toBe(true); + expect(isValidPattern("github.*.*.users.**.delete*")).toBe(true); + }); + it("accepts the universal pattern", () => { expect(isValidPattern("*")).toBe(true); }); @@ -101,8 +128,38 @@ describe("isValidPattern", () => { expect(isValidPattern("a.")).toBe(false); expect(isValidPattern("a..b")).toBe(false); expect(isValidPattern("*.a")).toBe(false); // leading * still rejected - expect(isValidPattern("a*")).toBe(false); // partial wildcard - expect(isValidPattern("a.b*")).toBe(false); // partial wildcard + expect(isValidPattern("**")).toBe(false); // globstars must remain integration-scoped + expect(isValidPattern("**.get*")).toBe(false); + expect(isValidPattern("a*")).toBe(false); // integration wildcards must remain segment-wide + expect(isValidPattern("*a")).toBe(false); // wildcard prefixes are ambiguous + expect(isValidPattern("a.b*c")).toBe(false); // only a trailing wildcard is supported + expect(isValidPattern("a.get*.b")).toBe(false); // only the final tool-name segment is partial + expect(isValidPattern("a.b**")).toBe(false); // globstar must be a complete segment + expect(isValidPattern("a.***.b")).toBe(false); + }); +}); + +describe("pattern specificity", () => { + it("orders exact tools above name wildcards above broad integration rules", () => { + expect(patternSpecificity("github.*.*.users.getV1User")).toBeGreaterThan( + patternSpecificity("github.*.*.**.get*"), + ); + expect(patternSpecificity("github.*.*.**.get*")).toBeGreaterThan( + patternSpecificity("github.*"), + ); + expect(patternSpecificity("github.*")).toBeGreaterThan(patternSpecificity("*")); + }); + + it("places a name wildcard below an existing exact tool rule", () => { + const exactPosition = "a0"; + const wildcardPosition = positionForNewPattern("github.*.*.**.get*", [ + { + id: "exact", + pattern: "github.*.*.users.getV1User", + position: exactPosition, + }, + ]); + expect(wildcardPosition > exactPosition).toBe(true); }); }); @@ -153,6 +210,19 @@ describe("resolveToolPolicy", () => { expect(result?.policyId).toBe("a"); }); + it("resolves a generated tool-name prefix rule", () => { + const result = resolveToolPolicy( + "github.org.acme.users.getV1User", + [ + ROW("get-tools", "github.*.*.**.get*", "require_approval", "a0"), + ROW("all-github", "github.*", "approve", "a1"), + ], + flatRank, + ); + expect(result?.action).toBe("require_approval"); + expect(result?.pattern).toBe("github.*.*.**.get*"); + }); + it("falls through to the broader rule when the specific rule is below it", () => { const result = resolveToolPolicy( "vercel.dns.create", diff --git a/packages/core/sdk/src/policies.ts b/packages/core/sdk/src/policies.ts index 8620d9c6d3..31f996ea7b 100644 --- a/packages/core/sdk/src/policies.ts +++ b/packages/core/sdk/src/policies.ts @@ -78,29 +78,85 @@ export interface EffectivePolicy { // - mid-segment `*`: `vercel.*.*.dns.create` — each NON-trailing `*` matches // EXACTLY ONE segment (e.g. wildcard the owner/connection // segments to target a tool across every connection). -// A `*` is always a complete segment: mid-pattern it consumes one segment, -// trailing it is a subtree. Partial wildcards (`me*`) and a leading `*` (other -// than the universal `*`) are rejected by `isValidPattern`. +// - name prefix: `vercel.*.*.dns.get*` — a trailing `*` inside a segment +// matches the rest of that ONE tool-name segment +// - globstar: `vercel.*.*.**.get*` — `**` matches zero or more +// structured tool-name segments +// A complete trailing `*` keeps its legacy subtree meaning. Leading wildcards +// (other than the universal `*`) and non-trailing partial wildcards (`g*t`) are +// rejected by `isValidPattern`. // --------------------------------------------------------------------------- export const matchPattern = (pattern: string, toolId: string): boolean => { if (pattern === "*") return true; const patternSegments = pattern.split("."); const toolSegments = toolId.split("."); - for (let i = 0; i < patternSegments.length; i++) { - const seg = patternSegments[i]!; - if (seg === "*") { - // Trailing `*` is a subtree: the literal prefix already matched, so the - // address matches at this position and anything deeper (or nothing). - if (i === patternSegments.length - 1) return toolSegments.length >= i; - // A non-trailing `*` consumes EXACTLY ONE segment; one must exist here. + + // Keep the common path allocation-light: existing exact / segment-wildcard + // rules and new name-prefix rules do not need globstar backtracking. + if (!patternSegments.includes("**")) { + for (let i = 0; i < patternSegments.length; i++) { + const segment = patternSegments[i]!; + if (segment === "*") { + if (i === patternSegments.length - 1) return toolSegments.length >= i; + if (i >= toolSegments.length) return false; + continue; + } if (i >= toolSegments.length) return false; - continue; + if (segment.endsWith("*")) { + if (!toolSegments[i]!.startsWith(segment.slice(0, -1))) return false; + } else if (toolSegments[i] !== segment) { + return false; + } } - if (i >= toolSegments.length || toolSegments[i] !== seg) return false; + return patternSegments.length === toolSegments.length; } - // Pattern exhausted with no trailing `*`: an exact match requires equal length. - return patternSegments.length === toolSegments.length; + + const memo = new Map(); + + const matchesFrom = (patternIndex: number, toolIndex: number): boolean => { + const memoKey = `${patternIndex}:${toolIndex}`; + const cached = memo.get(memoKey); + if (cached !== undefined) return cached; + + if (patternIndex === patternSegments.length) { + const result = toolIndex === toolSegments.length; + memo.set(memoKey, result); + return result; + } + + const segment = patternSegments[patternIndex]!; + let result: boolean; + if (segment === "**") { + // Globstar may consume no segment, or consume one and remain active. + result = + matchesFrom(patternIndex + 1, toolIndex) || + (toolIndex < toolSegments.length && matchesFrom(patternIndex, toolIndex + 1)); + } else if (segment === "*") { + // Preserve the original grammar: a complete trailing `*` is a subtree, + // while a complete mid-pattern `*` consumes exactly one segment. + result = + patternIndex === patternSegments.length - 1 + ? toolSegments.length >= toolIndex + : toolIndex < toolSegments.length && matchesFrom(patternIndex + 1, toolIndex + 1); + } else if (segment.endsWith("*")) { + const prefix = segment.slice(0, -1); + result = + toolIndex < toolSegments.length && + toolSegments[toolIndex]!.startsWith(prefix) && + matchesFrom(patternIndex + 1, toolIndex + 1); + } else { + result = + toolIndex < toolSegments.length && + toolSegments[toolIndex] === segment && + matchesFrom(patternIndex + 1, toolIndex + 1); + } + + memo.set(memoKey, result); + return result; + }; + + return matchesFrom(0, 0); }; export const isValidPattern = (pattern: string): boolean => { @@ -113,9 +169,15 @@ export const isValidPattern = (pattern: string): boolean => { for (let i = 0; i < segments.length; i++) { const seg = segments[i]!; if (seg.length === 0) return false; - // A `*` segment must be the WHOLE segment — no partial wildcards (`me*`). - // A `*` is valid mid-pattern (one segment) or trailing (subtree). - if (seg.includes("*") && seg !== "*") return false; + if (!seg.includes("*")) continue; + // Complete wildcard segments retain their existing meaning; `**` adds an + // explicit zero-or-more form that can be followed by a tool-name prefix. + if (seg === "*" || seg === "**") continue; + // Partial wildcards apply only to the final tool-name segment and only as + // a suffix (`get*`, never an owner/connection wildcard, `g*t`, or `*get`). + if (i === 0 || i !== segments.length - 1 || !seg.endsWith("*")) return false; + const prefix = seg.slice(0, -1); + if (prefix.length === 0 || prefix.includes("*")) return false; } return true; }; @@ -144,18 +206,31 @@ export const comparePolicyRow = ( // lower position-key (higher precedence). New rules are auto-placed below // any more-specific existing rules so a freshly-added group rule never // silently shadows an existing leaf rule. -// `*` → 0 -// `vercel.*` → 2 (1 literal segment, wildcard) -// `vercel.dns.*` → 4 (2 literal segments, wildcard) -// `vercel.dns` → 5 (2 literal segments, exact — beats same-prefix wildcard) -// `vercel.dns.create` → 7 (3 literal segments, exact) +// Literal segments contribute 2, name-prefix segments contribute 1, complete +// wildcards contribute 0, and a pattern with no wildcard receives an exact +// bonus. This preserves the original examples while placing a rule such as +// `vercel.*.*.**.get*` below an exact tool and above `vercel.*`. +// `*` → 0 +// `vercel.*` → 2 +// `vercel.dns.*` → 4 +// `vercel.dns` → 5 +// `vercel.*.*.**.get*` → 3 +// `vercel.dns.create` → 7 export const patternSpecificity = (pattern: string): number => { if (pattern === "*") return 0; - if (pattern.endsWith(".*")) { - const prefix = pattern.slice(0, -2); - return prefix.split(".").length * 2; + let score = 0; + let hasWildcard = false; + for (const segment of pattern.split(".")) { + if (segment === "*" || segment === "**") { + hasWildcard = true; + } else if (segment.endsWith("*")) { + score += 1; + hasWildcard = true; + } else { + score += 2; + } } - return pattern.split(".").length * 2 + 1; + return score + (hasWildcard ? 0 : 1); }; /** diff --git a/packages/react/src/pages/policies.tsx b/packages/react/src/pages/policies.tsx index f97df9fe37..bf2a3f5ebe 100644 --- a/packages/react/src/pages/policies.tsx +++ b/packages/react/src/pages/policies.tsx @@ -133,17 +133,16 @@ function AddPolicyForm(props: { setPattern(e.target.value)} className="font-mono text-sm" />

- Exact tool id, trailing wildcard, or * for every tool. - Examples: *,{" "} - vercel.*,{" "} - vercel.dns.*,{" "} - vercel.dns.create. + Use get* for a tool-name prefix and{" "} + ** across generated tool groups. Examples:{" "} + *, vercel.*,{" "} + my-api.*.*.**.get*.