Skip to content
Open
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 .changeset/tool-name-policy-wildcards.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions apps/docs/concepts/policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
63 changes: 55 additions & 8 deletions e2e/scenarios/policies-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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" },
Expand All @@ -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" } },
},
},
},
});

Expand All @@ -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.
Expand Down Expand Up @@ -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));
}),
);
74 changes: 72 additions & 2 deletions packages/core/sdk/src/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
effectivePolicyFromSorted,
isValidPattern,
matchPattern,
patternSpecificity,
positionForNewPattern,
resolveToolPolicy,
} from "./policies";
import { definePlugin, tool } from "./plugin";
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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);
});
Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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",
Expand Down
Loading