History

Scope tool policies to the account they are set under (#2062)

Rhys Sullivan committed 1 month ago

Commit 29413d81 parent 4a08d8d11 files · +257 −67Browse files

Unified · Split

11 files
  1. .changeset/account-scoped-tool-policies.md +5 −0
  2. e2e/scenarios/policies-ui.test.ts +68 −21
  3. e2e/src/integration-creation-permissions.ts +12 −0
  4. packages/react/src/api/error-reporting.test.ts +14 −0
  5. packages/react/src/api/error-reporting.tsx +11 −2
  6. packages/react/src/components/tool-tree.tsx +53 −22
  7. packages/react/src/lib/integration-add.tsx +3 −9
  8. packages/react/src/lib/policy-pattern.test.ts +21 −0
  9. packages/react/src/lib/policy-pattern.ts +27 −1
  10. packages/react/src/pages/integration-detail.tsx +24 −5
  11. packages/react/src/pages/tools.tsx +19 −7
.changeset/account-scoped-tool-policies.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"@executor-js/react": patch
3+---
4+
5+Tool policies set from an account section of the integration Tools tab now apply to that connection only, and each account header gets a menu to set a policy for the whole connection. Members no longer see policy controls they cannot use, and a refused policy write shows the server's reason.
e2e/scenarios/policies-ui.test.ts+68−21
@@ -4,18 +4,23 @@ · 3 unmodified lines
44 // the category (group) row menu writes a subtree rule. The product promises
55 // under test:
66 //
7−// 1. Both menus surface the REAL stored pattern (connection-wildcarded
8−// `integration.*.*.tool`) before anything is written.
7+// 1. Both menus surface the REAL stored pattern (pinned to the account the
8+// row sits under, `integration.<owner>.<connection>.tool`) before
9+// anything is written.
910 // 2. A leaf rule and a category rule coexist: the more specific leaf rule
1011 // keeps precedence over the later category rule, which covers the rest
1112 // of its group.
12−// 3. Rules are connection-agnostic: set from one account's section, they
13−// govern the other account's rows too, and the menu there shows the
14−// active rule with a Clear option.
15−// 4. The tool detail header's policy badge is the same authoring surface:
13+// 3. Rules are account-scoped: set from one account's section, they leave
14+// the other account's rows untouched. Two connections of one
15+// integration are different credentials (a bot token and a user token),
16+// so blocking a tool on one must not block it on the other.
17+// 4. The account header has its own menu that rules the whole connection
18+// (`integration.<owner>.<connection>.*`), recognizes its rule, and
19+// clears it.
20+// 5. The tool detail header's policy badge is the same authoring surface:
1621 // it writes the same stored pattern, recognizes its own rule afterward
1722 // (the Clear affordance), and Clear really removes the rule.
18−// 5. The rules materialize as manageable rows on /policies and persist
23+// 6. The rules materialize as manageable rows on /policies and persist
1924 // server-side with exactly the owner/pattern/action the UI promised.
2025 import { randomBytes } from "node:crypto";
2126
@@ -83,11 +88,12 @@ scenario( · 61 unmodified lines
8388 const beta = ConnectionName.make(`beta${suffix}`);
8489 const accounts = [alpha, beta] as const;
8590
86− // The UI hides owner/connection segments; a rule authored on a node is
87− // stored connection-wildcarded so it spans every account.
88− const leafPattern = `${integration}.*.*.records.create`;
89− const categoryPattern = `${integration}.*.*.records.*`;
90− const listLeafPattern = `${integration}.*.*.records.list`;
91+ // The UI hides owner/connection segments in the row labels, but a rule
92+ // authored under an account section is stored pinned to that account.
93+ const leafPattern = `${integration}.org.${alpha}.records.create`;
94+ const categoryPattern = `${integration}.org.${alpha}.records.*`;
95+ const listLeafPattern = `${integration}.org.${alpha}.records.list`;
96+ const betaAccountPattern = `${integration}.org.${beta}.*`;
9197
9298 // Selfhost scenarios share one workspace — remove everything this one
9399 // made (policies, connections, the integration) even on failure.
@@ -159,6 +165,14 @@ scenario( · 65 unmodified lines
159165 .getByRole("button")
160166 .filter({ hasText: leaf })
161167 .getByLabel(label, { exact: true });
168+ // Wait until a leaf's indicator with this label is gone (after a clear).
169+ const expectNoIndicator = async (connection: string, leaf: string, label: string) => {
170+ await expect
171+ .poll(() => leafIndicator(connection, leaf, label).count(), {
172+ message: `${connection} ${leaf} still shows "${label}"`,
173+ })
174+ .toBe(0);
175+ };
162176 const internalError = JSON.stringify({ _tag: "InternalError", traceId: "policy-write" });
163177
164178 await step("Open the integration's Tools tab", async () => {
@@ -254,25 +268,58 @@ scenario( · 89 unmodified lines
254268 },
255269 );
256270
257− await step("The same rules govern the second account's rows", async () => {
271+ await step("The second account's rows are untouched", async () => {
258272 await closedGroup(beta, integration).click();
259273 await closedGroup(beta, "records").click();
260− await leafIndicator(beta, "create", `Blocked (matched ${leafPattern})`).waitFor();
261− await leafIndicator(
262− beta,
263− "list",
264− `Require approval (matched ${categoryPattern})`,
265− ).waitFor();
274+ await sectionFor(beta).getByRole("button").filter({ hasText: "create" }).waitFor();
275+ expect(
276+ await leafIndicator(beta, "create", `Blocked (matched ${leafPattern})`).count(),
277+ "a rule set under one account does not block the same tool on another",
278+ ).toBe(0);
279+ expect(
280+ await leafIndicator(
281+ beta,
282+ "list",
283+ `Require approval (matched ${categoryPattern})`,
284+ ).count(),
285+ "a category rule set under one account does not reach another account",
286+ ).toBe(0);
266287 });
267288
268289 await step("Reopening the menu offers to clear the active rule", async () => {
269− await policyMenuFor(beta, `${integration}.records.create`).click();
290+ await policyMenuFor(alpha, `${integration}.records.create`).click();
270291 await page.getByRole("menuitem", { name: "Clear" }).waitFor();
271292 await page.keyboard.press("Escape");
272293 });
273294
295+ await step("The account header blocks the whole second connection", async () => {
296+ const headerMenu = sectionFor(beta).getByRole("button", {
297+ name: `Set policy for ${integration} / ${beta}`,
298+ exact: true,
299+ });
300+ await headerMenu.click();
301+ // The header menu is headed by the whole-account pattern it will store.
302+ await page.getByText(betaAccountPattern, { exact: true }).waitFor();
303+ await page.getByRole("menuitem", { name: "Block" }).click();
304+ await leafIndicator(beta, "create", `Blocked (matched ${betaAccountPattern})`).waitFor();
305+ await leafIndicator(beta, "list", `Blocked (matched ${betaAccountPattern})`).waitFor();
306+ // The first account is not affected by the second account's rule.
307+ await leafIndicator(alpha, "create", `Blocked (matched ${leafPattern})`).waitFor();
308+ });
309+
310+ await step("The account header recognizes its rule and Clear removes it", async () => {
311+ const headerMenu = sectionFor(beta).getByRole("button", {
312+ name: `Set policy for ${integration} / ${beta}`,
313+ exact: true,
314+ });
315+ await headerMenu.click();
316+ await page.getByRole("menuitem", { name: "Clear" }).click();
317+ await sectionFor(beta).getByRole("button").filter({ hasText: "create" }).waitFor();
318+ await expectNoIndicator(beta, "create", `Blocked (matched ${betaAccountPattern})`);
319+ });
320+
274321 await step("Open the tool detail for records.list", async () => {
275− await sectionFor(beta).getByRole("button").filter({ hasText: "list" }).click();
322+ await sectionFor(alpha).getByRole("button").filter({ hasText: "list" }).click();
276323 // The header badge reflects the inherited category rule.
277324 await page.getByRole("button", { name: `Matched policy: ${categoryPattern}` }).waitFor();
278325 });
e2e/src/integration-creation-permissions.ts+12−0
@@ -108,6 +108,18 @@ export const integrationCreationPermissions = (admin: Identity, member: Identity · 107 unmodified lines
108108 expect(await action.isDisabled()).toBe(true);
109109 }
110110 });
111+ await step("Member sees tool policies without a way to change them", async () => {
112+ // Policies on the Tools page are workspace rules the server refuses
113+ // for members, so the row menus and the detail badge menu stay off.
114+ await visit(page, "/tools");
115+ await page.getByRole("button").filter({ hasText: "executor" }).first().waitFor();
116+ expect(
117+ await page.getByRole("button", { name: /^Set policy/ }).count(),
118+ "members get no policy menus on tool rows",
119+ ).toBe(0);
120+ await visit(page, `/integrations/${slug}`);
121+ await page.getByRole("button", { name: "Add connection", exact: true }).waitFor();
122+ });
111123 await step("Member can still add a personal connection", async () => {
112124 await page.getByRole("button", { name: "Add connection", exact: true }).click();
113125 const dialog = page.getByRole("dialog");
packages/react/src/api/error-reporting.test.ts+14−0
@@ -53,6 +53,20 @@ describe("frontend error reporting", () => { · 52 unmodified lines
5353 expect(messageFromExit(Exit.fail({ reason: "unknown" }), "Fallback")).toBe("Fallback");
5454 });
5555
56+ it("reads a message the error exposes as a prototype getter", () => {
57+ // Schema-tagged API errors (e.g. OrgWriteDeniedError) declare no `message`
58+ // field; the sentence lives on a class getter, which a struct decode misses.
59+ class GetterError extends Data.TaggedError("GetterError")<{}> {
60+ override get message(): string {
61+ return "Requires a workspace admin.";
62+ }
63+ }
64+ const exit = Exit.fail(new GetterError());
65+
66+ expect(messageFromExit(exit, "Fallback")).toBe("Requires a workspace admin.");
67+ expect(messageFromUnknown(new GetterError(), "Fallback")).toBe("Requires a workspace admin.");
68+ });
69+
5670 it("reports failed exits with the provided context", () => {
5771 const exit = Exit.fail({ message: "Could not update integration" });
5872 const { calls, report } = captureReports();
packages/react/src/api/error-reporting.tsx+11−2
@@ -21,8 +21,17 @@ class FrontendHandledError extends Data.TaggedError("FrontendHandledError")<{ · 20 unmodified lines
2121 readonly context: FrontendErrorContext;
2222 }> {}
2323
24−const ErrorMessage = Schema.Struct({ message: Schema.String });
25−const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
24+// Effect's tagged error classes (`Schema.TaggedErrorClass`) often declare no
25+// `message` field and expose it as a prototype getter instead, so a struct
26+// decode — which only sees own properties — would miss the very sentence the
27+// server wrote for the user. Read the property directly.
28+const decodeErrorMessage = (value: unknown): Option.Option<{ readonly message: string }> => {
29+ if (typeof value !== "object" || value === null) return Option.none();
30+ const message: unknown = Reflect.get(value, "message");
31+ return typeof message === "string" && message.length > 0
32+ ? Option.some({ message })
33+ : Option.none();
34+};
2635
2736 const TaggedValue = Schema.Struct({ _tag: Schema.String });
2837 const decodeTaggedValue = Schema.decodeUnknownOption(TaggedValue);
packages/react/src/components/tool-tree.tsx+53−22
@@ -3,7 +3,7 @@ import { ChevronRightIcon, MoreHorizontalIcon, SearchIcon, XIcon } from "lucide- · 2 unmodified lines
33 import type { EffectivePolicy, Owner, ToolPolicyAction } from "@executor-js/sdk/shared";
44 import { ownerLabel, useOwnerDisplay } from "../api/owner-display";
55 import { trackEvent } from "../api/analytics";
6−import { toPolicyPattern } from "../lib/policy-pattern";
6+import { accountPolicyPattern, toPolicyPattern } from "../lib/policy-pattern";
77 import { Badge } from "./badge";
88 import { Button } from "./button";
99 import { Input } from "./input";
@@ -297,7 +297,9 @@ export function ToolTree(props: { · 287 unmodified lines
297297 * emit the tool's full dotted id; group rows emit `prefix.*`. */
298298 onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
299299 onClearPolicy?: (pattern: string) => void;
300− /** Maps the displayed row path into the persisted policy pattern. */
300+ /** Maps the displayed row path into the persisted policy pattern for the
301+ * flat tree. Ignored in the account-grouped view, where every row writes a
302+ * pattern pinned to its own account (`integration.<owner>.<conn>.<tool>`). */
301303 patternForDisplay?: (displayPattern: string) => string;
302304 /** Sorted user-authored policies (most-precedent first). Used to
303305 * decide whether a node has its own exact-pattern user rule today
@@ -429,26 +431,55 @@ export function ToolTree(props: { · 125 unmodified lines
429431 : (props.emptyLabel ?? "No tools available")}
430432 </div>
431433 ) : groupByConnection ? (
432− accountGroups.map((group) => (
433− <section key={group.key}>
434− <header className="sticky top-0 z-10 flex items-center gap-2 border-b border-border/30 bg-muted/40 px-3 py-1.5 backdrop-blur-sm">
435− {ownerDisplay.showOwnerLabels ? (
436− <Badge variant="outline" className="shrink-0 text-[10px]">
437− {ownerLabel(group.owner)}
438− </Badge>
439− ) : null}
440− <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">
441− {group.integration && group.connection
442− ? `${group.integration} / ${group.connection}`
443− : group.connection || ownerDisplay.label(group.owner)}
444− </span>
445− <span className="shrink-0 tabular-nums text-xs text-muted-foreground">
446− {group.tools.length}
447− </span>
448− </header>
449− <ToolTreeBody key={group.key} tools={group.tools} {...bodyProps} />
450− </section>
451− ))
434+ accountGroups.map((group) => {
435+ // Rows inside an account section author rules for THAT account
436+ // only: two connections of one integration are different
437+ // credentials, so a rule set under one must not govern the other.
438+ const accountPattern = accountPolicyPattern(group.owner, group.connection);
439+ const wholeAccountPattern = accountPattern(`${group.integration}.*`);
440+ const wholeAccountRule = exactPatterns.get(wholeAccountPattern);
441+ const accountLabel =
442+ group.integration && group.connection
443+ ? `${group.integration} / ${group.connection}`
444+ : group.connection || ownerDisplay.label(group.owner);
445+ return (
446+ <section key={group.key}>
447+ <header className="group/tt-row sticky top-0 z-10 flex items-center gap-2 border-b border-border/30 bg-muted/40 px-3 py-1.5 backdrop-blur-sm">
448+ {ownerDisplay.showOwnerLabels ? (
449+ <Badge variant="outline" className="shrink-0 text-[10px]">
450+ {ownerLabel(group.owner)}
451+ </Badge>
452+ ) : null}
453+ <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">
454+ {accountLabel}
455+ </span>
456+ <span className="shrink-0 tabular-nums text-xs text-muted-foreground">
457+ {group.tools.length}
458+ </span>
459+ {onSetPolicy && group.integration && group.connection ? (
460+ <PolicyActionMenu
461+ pattern={wholeAccountPattern}
462+ current={wholeAccountRule}
463+ onSet={onSetPolicy}
464+ onClear={onClearPolicy}
465+ triggerLabel={`Set policy for ${accountLabel}`}
466+ triggerClassName={
467+ wholeAccountRule
468+ ? undefined
469+ : "opacity-0 group-hover/tt-row:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100"
470+ }
471+ />
472+ ) : null}
473+ </header>
474+ <ToolTreeBody
475+ key={group.key}
476+ tools={group.tools}
477+ {...bodyProps}
478+ patternForDisplay={accountPattern}
479+ />
480+ </section>
481+ );
482+ })
452483 ) : (
453484 <ToolTreeBody tools={filteredTools} {...bodyProps} />
454485 )}
packages/react/src/lib/integration-add.tsx+3−9
@@ -11,20 +11,14 @@ import { Link } from "@tanstack/react-router"; · 10 unmodified lines
1111 import * as Exit from "effect/Exit";
1212 import * as Option from "effect/Option";
1313 import * as Predicate from "effect/Predicate";
14−import * as Schema from "effect/Schema";
1514 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
1615
1716 import { integrationsOptimisticAtom } from "../api/atoms";
18−
19−const ErrorMessage = Schema.Struct({ message: Schema.String });
20−const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
17+import { messageFromExit } from "../api/error-reporting";
2118
2219 /** The failed Exit's `message`, or `fallback` when the error carries none. */
23−export const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
24− Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
25− onNone: () => fallback,
26− onSome: ({ message }) => message,
27− });
20+export const errorMessageFromExit: (exit: Exit.Exit<unknown, unknown>, fallback: string) => string =
21+ messageFromExit;
2822
2923 export const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean =>
3024 Option.match(Exit.findErrorOption(exit), {
packages/react/src/lib/policy-pattern.test.ts+21−0
@@ -0,0 +1,21 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { accountPolicyPattern, toPolicyPattern } from "./policy-pattern";
4+
5+describe("policy pattern bridges", () => {
6+ it("wildcards owner and connection for the connection-agnostic tree", () => {
7+ expect(toPolicyPattern("slack.conversations.history")).toBe("slack.*.*.conversations.history");
8+ expect(toPolicyPattern("slack.conversations.*")).toBe("slack.*.*.conversations.*");
9+ expect(toPolicyPattern("slack.*")).toBe("slack.*");
10+ expect(toPolicyPattern("*")).toBe("*");
11+ });
12+
13+ it("pins owner and connection for a row inside an account section", () => {
14+ const forBot = accountPolicyPattern("org", "bot");
15+ expect(forBot("slack.conversations.history")).toBe("slack.org.bot.conversations.history");
16+ expect(forBot("slack.conversations.*")).toBe("slack.org.bot.conversations.*");
17+ expect(forBot("slack.*")).toBe("slack.org.bot.*");
18+ expect(forBot("slack")).toBe("slack.org.bot.*");
19+ expect(forBot("*")).toBe("*");
20+ });
21+});
packages/react/src/lib/policy-pattern.ts+27−1
@@ -1,4 +1,4 @@
1−import { matchPattern } from "@executor-js/sdk/shared";
1+import { matchPattern, type Owner } from "@executor-js/sdk/shared";
22
33 // ---------------------------------------------------------------------------
44 // Policy pattern bridge.
@@ -29,3 +29,29 @@ export const toPolicyPattern = (displayPattern: string): string => { · 24 unmodified lines
2929 };
3030
3131 export { matchPattern };
32+
33+// ---------------------------------------------------------------------------
34+// Account-scoped bridge.
35+//
36+// The account-grouped Tools tab shows the same tool once per connection. A
37+// rule authored from a row inside one account's section must govern THAT
38+// account only — a Slack bot connection and a Slack user connection are
39+// different credentials with different capabilities, and blocking a tool on
40+// one must not silently block it on the other. So instead of wildcarding the
41+// owner + connection segments, fill them in: `integration.<owner>.<conn>.<tool>`.
42+// `integration.*` becomes the whole-account subtree
43+// `integration.<owner>.<conn>.*`. Apply the returned mapper at both the site
44+// that BUILDS a pattern and the site that LOOKS UP the exact rule, exactly as
45+// with `toPolicyPattern`.
46+// ---------------------------------------------------------------------------
47+
48+export const accountPolicyPattern =
49+ (owner: Owner, connection: string) =>
50+ (displayPattern: string): string => {
51+ if (displayPattern === "*") return "*";
52+ const firstDot = displayPattern.indexOf(".");
53+ if (firstDot === -1) return `${displayPattern}.${owner}.${connection}.*`;
54+ const integration = displayPattern.slice(0, firstDot);
55+ const rest = displayPattern.slice(firstDot + 1);
56+ return `${integration}.${owner}.${connection}.${rest}`;
57+ };
packages/react/src/pages/integration-detail.tsx+24−5
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 effectivePolicyFromSorted,
1414 type Connection,
1515 type Owner,
16+ type ToolPolicyAction,
1617 } from "@executor-js/sdk/shared";
1718 import {
1819 checkConnectionHealth,
@@ -46,6 +47,7 @@ import { useExecutorDocumentTitle } from "../lib/document-title"; · 27 unmodified lines
4647 import { ErrorState } from "../components/error-state";
4748 import { isAsyncResultLoading } from "../lib/async-result";
4849 import { useConnectionsHealth } from "../lib/use-connection-health";
50+import { accountPolicyPattern } from "../lib/policy-pattern";
4951 import {
5052 integrationDetailInternalTabFromSearch,
5153 type IntegrationDetailInternalTab,
@@ -142,6 +144,16 @@ export function IntegrationDetailPage(props: { · 90 unmodified lines
142144 // Integrations are workspace-owned; the server refuses catalog mutations
143145 // (update/remove) from non-admin members, so disable the controls for them.
144146 const canMutateIntegration = useCanCreateWorkspaceConnections();
147+ // Tool policies on this tab are workspace rules (`usePolicyActions("org")`),
148+ // which the server refuses for non-admin members. Offer the menus only to
149+ // those who can actually write them.
150+ const canSetPolicy = canMutateIntegration;
151+ const onSetPolicy = canSetPolicy
152+ ? (pattern: string, action: ToolPolicyAction) => void policyActions.set(pattern, action)
153+ : undefined;
154+ const onClearPolicy = canSetPolicy
155+ ? (pattern: string, policyId?: string) => void policyActions.clear(pattern, policyId)
156+ : undefined;
145157 const canEdit = !isBuiltInIntegration && integrationData !== null;
146158 const canRefresh = integrationData?.canRefresh ?? false;
147159 const canRemove = integrationData?.canRemove ?? false;
@@ -301,6 +313,7 @@ export function IntegrationDetailPage(props: { · 153 unmodified lines
301313 policy: effectivePolicyFromSorted(matchId, policyList, t.requiresApproval),
302314 owner: t.owner,
303315 connection: t.connection,
316+ integration: t.integration,
304317 };
305318 });
306319 }, [tools, policyList]);
@@ -620,8 +633,8 @@ export function IntegrationDetailPage(props: { · 313 unmodified lines
620633 tools={integrationTools}
621634 selectedToolId={selectedToolId}
622635 onSelect={setSelectedToolId}
623− onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)}
624− onClearPolicy={(pattern) => void policyActions.clear(pattern)}
636+ onSetPolicy={onSetPolicy}
637+ onClearPolicy={onClearPolicy}
625638 policies={sortedPolicies}
626639 groupByConnection={!isBuiltInIntegration}
627640 emptyLabel={hasToolSyncIssue ? emptyToolsTitle : undefined}
@@ -636,9 +649,15 @@ export function IntegrationDetailPage(props: { · 8 unmodified lines
636649 toolName={selectedTool.name}
637650 staticTool={selection?.static}
638651 policy={selectedTool.policy}
639− onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)}
640− onClearPolicy={(pattern, policyId) =>
641− void policyActions.clear(pattern, policyId)
652+ onSetPolicy={onSetPolicy}
653+ onClearPolicy={onClearPolicy}
654+ // The header badge must write and look up the SAME
655+ // account-pinned pattern the tree row under this
656+ // account uses, or it cannot recognize its own rule.
657+ patternForDisplay={
658+ selection && !selection.static
659+ ? accountPolicyPattern(selection.owner, selection.connection)
660+ : undefined
642661 }
643662 {...(!selection?.static && selectedBareName
644663 ? {
packages/react/src/pages/tools.tsx+19−7
@@ -2,7 +2,11 @@ import { useMemo, useState } from "react"; · 1 unmodified line
22 import { Link } from "@tanstack/react-router";
33 import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5−import { ToolAddress, effectivePolicyFromSorted } from "@executor-js/sdk/shared";
5+import {
6+ ToolAddress,
7+ effectivePolicyFromSorted,
8+ type ToolPolicyAction,
9+} from "@executor-js/sdk/shared";
610
711 import { policiesOptimisticAtom, toolsAllAtom } from "../api/atoms";
812 import { usePolicyActions } from "../hooks/use-policy-actions";
@@ -13,6 +17,7 @@ import { Skeleton } from "../components/skeleton"; · 4 unmodified lines
1317 import { useExecutorDocumentTitle } from "../lib/document-title";
1418 import { ErrorState } from "../components/error-state";
1519 import { isAsyncResultLoading } from "../lib/async-result";
20+import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
1621
1722 // Dynamic tool policy patterns are derived from the connection-aware address.
1823 // Static tools (for example Executor's own tools) use their address directly.
@@ -38,6 +43,15 @@ export function ToolsPage() { · 19 unmodified lines
3843 const refreshTools = useAtomRefresh(toolsAllAtom);
3944 const policies = useAtomValue(policiesOptimisticAtom);
4045 const policyActions = usePolicyActions("org");
46+ // Policies here are workspace rules, which the server refuses for non-admin
47+ // members. Offer the menus only to those who can actually write them.
48+ const canSetPolicy = useCanCreateWorkspaceConnections();
49+ const onSetPolicy = canSetPolicy
50+ ? (pattern: string, action: ToolPolicyAction) => void policyActions.set(pattern, action)
51+ : undefined;
52+ const onClearPolicy = canSetPolicy
53+ ? (pattern: string, policyId?: string) => void policyActions.clear(pattern, policyId)
54+ : undefined;
4155
4256 const [selectedToolId, setSelectedToolId] = useState<string | null>(null);
4357
@@ -144,8 +158,8 @@ export function ToolsPage() { · 100 unmodified lines
144158 tools={summaries}
145159 selectedToolId={selectedToolId}
146160 onSelect={setSelectedToolId}
147− onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)}
148− onClearPolicy={(pattern) => void policyActions.clear(pattern)}
161+ onSetPolicy={onSetPolicy}
162+ onClearPolicy={onClearPolicy}
149163 policies={sortedPolicies}
150164 />
151165 </div>
@@ -156,10 +170,8 @@ export function ToolsPage() { · 4 unmodified lines
156170 toolName={selectedTool.name}
157171 staticTool={selection?.static}
158172 policy={selectedTool.policy}
159− onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)}
160− onClearPolicy={(pattern, policyId) =>
161− void policyActions.clear(pattern, policyId)
162− }
173+ onSetPolicy={onSetPolicy}
174+ onClearPolicy={onClearPolicy}
163175 />
164176 ) : (
165177 <ToolDetailEmpty hasTools={summaries.length > 0} />

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t