History

tool policies: per-tool and per-namespace permission overrides (#437)

* add tool policies for per-tool and per-namespace permission overrides Users can now mark tools as auto-approve, require approval, or block on a per-tool (vercel.dns.create) or per-subtree (vercel.dns.*, vercel.*) basis. Resolution: ordered list per scope, first match wins. No matching policy falls through to the plugin's existing resolveAnnotations output, so plugin defaults still drive behavior for unscoped tools. Block hides tools from search/list and hard-fails at invoke with ToolBlockedError carrying the matched pattern so agents can adapt. require_approval forces the elicitation prompt; approve skips it. Mid-invocation elicitations (mayElicit) are unaffected. - new core_schema tool_policy table; drizzle migration 0006 - policies.ts: matchPattern, isValidPattern, resolveToolPolicy - ToolBlockedError; ToolListFilter.includeBlocked opt-out for settings UI - executor.policies.{list,create,update,remove,resolve} CRUD - HTTP PoliciesApi + handlers under /scopes/:scopeId/policies - cloud /policies route + sidebar entry; PoliciesPage in @executor/react * show effective policy on each tool in source detail view Source detail is a management view, so include policy-blocked tools in the per-source list (`includeBlocked: true`) and render the effective policy on each row: - Color-coded dot per row in ToolTree (green=auto-approve, amber=require approval, red=blocked) with the matched pattern in the title attribute - Blocked rows render at reduced opacity so they read as inactive - ToolDetail header shows a badge with action + matched pattern when a policy is in effect Resolution mirrors the server: walk the policy list (already pre-sorted by the API in evaluation order) and stop at the first match. * also surface plugin-default approval state on each tool The dot only fired for user-authored policies, leaving tools whose plugin default is `requiresApproval: true` (e.g. openapi POST/DELETE) looking unannotated. Users would think "no rule applies" when actually the plugin's default still gates the prompt. - sources.tools handler now resolves annotations (was previously disabled) and returns requiresApproval / approvalDescription - ToolSummary gains defaultRequiresApproval - ToolTree row renders a hollow amber ring when no user policy matches but the plugin default would prompt; user policies still render as filled dots so they're visually distinct - ToolDetail header shows a muted "Default: Require approval" badge when no user policy matches; auto-approve-by-default stays silent (it's the safe state, no point cluttering every row) * unify user policy and plugin default into single EffectivePolicy resolver The UI was branching on "user policy vs plugin default" and rendering different things from each path. There's only ONE effective policy per tool — combining the layers should be the resolver's job, not the caller's. - new EffectivePolicy type: { action, source: 'user' | 'plugin-default', pattern? } - resolveEffectivePolicy / effectivePolicyFromSorted in @executor/sdk return the unified answer; callers ask once and render one thing - ToolSummary.policy is now always present (always EffectivePolicy); ToolTree and ToolDetail render once, parameterized by source - tests for the new helper (4 cases: user wins, default require, default approve, user-overrides-default) * allow bare * pattern as universal match Patterns like "*" should "just work" for trust-everything or deny-everything use cases. The matcher and validator both rejected it because the existing rule was "no leading *" — but bare * is the one case where leading * is unambiguous. - matchPattern: bare "*" matches every tool id; existing leading-* rejection still applies to mixed forms ("*foo", "*.foo") - isValidPattern: accept "*" as a complete pattern - updated tests, schema comment, error message, and the AddPolicyForm hint to mention the universal pattern * Trace MCP JSON-RPC request ids (#440) * optimistic policy mutations via Atom.optimistic + UI polish - Wire policies through Atom.optimistic / Atom.optimisticFn instead of a custom pending-state layer. Reducer reads get(self) so racing edits stack correctly and the post-commit refresh pulls authoritative state. - Action selector on each policy row is now a clickable badge dropdown (chevron + ring), backed by Select primitives so the selected item shows a check on the right. Kebab still owns Remove. - Drop the custom usePoliciesWithPending / usePendingPolicies helpers; policies.tsx reads policiesOptimisticAtom and writes through create/update/removePolicyOptimistic directly. - Codify the pattern as a project skill under .skills/effect-atom-optimistic-updates so future work reaches for the effect-atom primitives instead of rebuilding the layer. * add wrdn-effect-atom-optimistic warden skill Codifies the "use Atom.optimistic, not hand-rolled pending state" rule as a Warden skill. Local skill resolved from .agents/skills/. Scoped to the React UI package since the rule is React-specific. Detects: new useState/useRef/Map of pending values alongside an effect-atom mutation; new entries in PendingResource or new use<X>WithPending hooks in optimistic.tsx; reads of <thing>Atom paired with writes through <thing>OptimisticAtom; missing Atom.family wrapper around Atom.optimistic. Grandfathers existing legacy helpers (sources, connections); only flags new consumers. * add Move to top/bottom on policy rows Adds reorder affordances in the kebab menu. Computes min/max position across the list and writes through updatePolicyOptimistic so the row moves immediately. Page sorts by position so the optimistic write reorders the list visually and converges with the server order on refresh. * swap Move to top/bottom for Move up/down on policy rows Single-update reorder: target position is the midpoint between the row's new neighbors, with -1 / +1 fallback at the ends. One mutation call per click, no row swap needed. * drop Position N label from policy rows; visual order conveys it * tool_policy.position: bigint → double precision The reorder UI uses float midpoints (e.g. 2.5 between 2 and 3) so a single update places the row between its new neighbors without touching the neighbors' positions. bigint rejected the float; switch to double precision. * tool_policy.position: switch to fractional-indexing strings (lexorank) Drops the float-midpoint scheme for fractional-indexing keys (Jira / Notion / Linear style). Strings store lexicographically and can always be subdivided by lengthening — no precision ceiling on repeated inserts between the same two rows. - column: text, lex compare in resolver / list / page sort - create default: generateKeyBetween(null, currentMin) — top of list - reorder: generateKeyBetween(neighborA, neighborB) — single update - migration 0008: TRUNCATE + ALTER (feature unshipped, dev-only data) Adds fractional-indexing dep to @executor/sdk and @executor/react. * address review feedback on tool_policies - Consolidate tool_policy migrations into a single 0006 with the final shape (text position, composite (scope_id, position) index). Drops the bigint→double→text intermediates that never ran anywhere meaningful. Anyone with an existing dev DB needs to wipe apps/cloud/.dev-db before restart. - Fix a crash where Move up/down on a row neighboring an optimistic placeholder (position: "") would call generateKeyBetween with an invalid key. Reorder math now runs against committed rows only; pending placeholders aren't reorderable until the server confirms. - Stable sort tiebreak on id when two rows share a fractional-indexing key. Same fix in the React page, the executor's policiesList, and resolveToolPolicy via a shared comparePolicyRow helper. - source-detail.tsx now reads policiesOptimisticAtom so action changes on the policies page reflect immediately in the source tool tree. - Correct the Atom.optimisticFn reducer description in both project and Warden skills — the runtime feeds `current` to the reducer; the reducer doesn't call `get(self)`. * add backend tests for policy reorder and tiebreak - resolveToolPolicy: identical positions sort deterministically by id, regardless of input array order. Catches the racing-insert collision where two clients pick the same generateKeyBetween(null, min) key. - update without position preserves the existing position (regression for the case where partial updates would otherwise blank out the field). - update with a new position reorders the list — exercises the end-to-end reorder path that the UI's Move up/down feeds. - Consecutive creates produce strictly increasing-precedence keys with no collisions; list order is insertion-reverse. 29 → 33 tests. * add tool_policy table to apps/local Mirrors the cloud sqlite-side schema added in this PR. Uses the same fractional-indexing position column and composite (scope_id, position) index. Dev runs read drizzle/ directly; compiled binaries inline the SQL via embedded-migrations.gen.ts at build time. * Surface OpenAPI tool error messages in execute (#442)

Rhys Sullivan committed 6 months ago

Commit 3fa23591 parent ab0945746 files · +2935 −36Browse files

Unified · Split

46 files
  1. .agents/skills/wrdn-effect-atom-optimistic/SKILL.md +172 −0
  2. .skills/effect-atom-optimistic-updates/SKILL.md +252 −0
  3. apps/cloud/drizzle/0006_add_tool_policy.sql +17 −0
  4. apps/cloud/drizzle/meta/_journal.json +8 −1
  5. apps/cloud/src/mcp-miniflare.e2e.node.test.ts +45 −0
  6. apps/cloud/src/mcp.ts +7 −11
  7. apps/cloud/src/routeTree.gen.ts +21 −0
  8. apps/cloud/src/routes/policies.tsx +6 −0
  9. apps/cloud/src/services/executor-schema.ts +21 −0
  10. apps/cloud/src/web/shell.tsx +2 −0
  11. apps/local/drizzle/0004_add_tool_policy.sql +17 −0
  12. apps/local/drizzle/meta/_journal.json +7 −0
  13. apps/local/src/server/executor-schema.ts +19 −0
  14. bun.lock +4 −0
  15. packages/core/api/src/api.ts +2 −0
  16. packages/core/api/src/handlers/index.ts +3 −0
  17. packages/core/api/src/handlers/policies.ts +67 −0
  18. packages/core/api/src/handlers/sources.ts +9 −1
  19. packages/core/api/src/index.ts +1 −0
  20. packages/core/api/src/policies/api.ts +65 −0
  21. packages/core/api/src/sources/api.ts +4 −0
  22. packages/core/execution/src/tool-invoker.test.ts +49 −1
  23. packages/core/execution/src/tool-invoker.ts +32 −1
  24. packages/core/sdk/package.json +2 −1
  25. packages/core/sdk/src/core-schema.ts +60 −0
  26. packages/core/sdk/src/errors.ts +13 −0
  27. packages/core/sdk/src/executor.ts +276 −6
  28. packages/core/sdk/src/index.ts +22 −0
  29. packages/core/sdk/src/policies.test.ts +614 −0
  30. packages/core/sdk/src/policies.ts +252 −0
  31. packages/core/sdk/src/types.ts +5 −0
  32. packages/kernel/runtime-dynamic-worker/src/executor.ts +9 −0
  33. packages/kernel/runtime-dynamic-worker/src/invocation.test.ts +18 −0
  34. packages/kernel/runtime-quickjs/src/index.ts +6 −5
  35. packages/plugins/keychain/src/index.test.ts +11 −3
  36. packages/plugins/openapi/src/sdk/plugin.test.ts +63 −2
  37. packages/react/package.json +1 −0
  38. packages/react/src/api/atoms.tsx +127 −2
  39. packages/react/src/api/optimistic.tsx +1 −0
  40. packages/react/src/api/reactivity-keys.tsx +9 −0
  41. packages/react/src/components/select.tsx +7 −0
  42. packages/react/src/components/tool-detail.tsx +63 −1
  43. packages/react/src/components/tool-tree.tsx +59 −0
  44. packages/react/src/pages/policies.tsx +463 −0
  45. packages/react/src/pages/source-detail.tsx +14 −1
  46. warden.toml +10 −0
.agents/skills/wrdn-effect-atom-optimistic/SKILL.md+172−0
@@ -0,0 +1,172 @@
1+---
2+name: wrdn-effect-atom-optimistic
3+description: Detects hand-rolled optimistic-update plumbing in React code that should be using effect-atom's Atom.optimistic and Atom.optimisticFn instead. Run on diffs touching packages/react/src/api/atoms.tsx, packages/react/src/pages/**/*.tsx, or any file that imports an effect-atom mutation atom from ./api/atoms. The hand-rolled patterns race on concurrent mutations and the codebase has chosen the effect-atom primitives as the canonical answer.
4+allowed-tools: Read Grep Glob Bash
5+---
6+
7+You audit React code in this repo for one thing: did the author roll their own optimistic-update layer on top of an effect-atom query atom instead of using `Atom.optimistic` / `Atom.optimisticFn`?
8+
9+This is not a security skill. It is a correctness skill. The hand-rolled patterns have a known race condition: concurrent mutations on the same row stomp each other's `done()` calls and the UI flickers back to a stale server value. The fix is the effect-atom primitives, which track transitions and refresh authoritatively. The repo already migrated `policies` and codified the pattern at `.skills/effect-atom-optimistic-updates/SKILL.md`.
10+
11+Trace. Do not pattern-match a `useState` and call it a day. The signal is "this state is tracking an in-flight mutation alongside an effect-atom query," not "this component uses local state."
12+
13+## Trace before reporting
14+
15+1. **Find the mutation.** Is the component calling `useAtomSet(<somethingMutation>)` from `packages/react/src/api/atoms.tsx`? If not, this skill does not apply.
16+2. **Find the read.** Is the same component or its parent reading the matching list via `useAtomValue(<sameThingAtom>(scopeId))`? If yes, the optimistic substitute exists or should exist.
17+3. **Find the bookkeeping.** Look for any of these alongside the mutation call:
18+ - `useState`, `useReducer`, `useRef` holding "pending" / "placeholder" / "in-flight" / "optimistic" values keyed by row id
19+ - `Atom.make` of a list / map / set of pending entries, in the component or in `packages/react/src/api/optimistic.tsx`
20+ - Calls into `usePendingResource`, `usePoliciesWithPending`, `usePendingPolicies`, `mergePending`, or any helper named like that
21+ - `try { await doMutate(...) } finally { placeholder.done() }` shapes
22+ - Manual id minting (`pending-${...}`, `crypto.randomUUID`) in the page-level handler rather than inside an `optimisticFn` reducer
23+4. **Confirm the optimistic atom exists or is missing.** Open `packages/react/src/api/atoms.tsx` and check for `<thing>OptimisticAtom = Atom.family(scopeId => Atom.optimistic(<thing>Atom(scopeId)))`. If a sibling resource (sources, secrets, policies) has the optimistic wrapper and this one doesn't, the bug is the missing wrapper plus the hand-rolled substitute.
24+5. **Check for grandfathered code.** `usePendingSources`, `useSourcesWithPending`, `useConnectionsWithPendingRemovals`, and `usePendingConnectionRemovals` in `packages/react/src/api/optimistic.tsx` are legacy. New code should not extend them. Their continued existence is not a finding by itself; **new** consumers or **new** entries in `PendingResource` are.
25+
26+When the trace cannot resolve with the files at hand, drop the finding.
27+
28+## What to Report
29+
30+- **Hand-rolled pending state next to an effect-atom mutation.** Component imports a mutation from `./api/atoms` (e.g. `updatePolicy`, `createSecret`, `removeConnection`) and tracks the in-flight value in `useState` / `useRef` / a custom atom for the purpose of immediate UI feedback. Severity: medium.
31+- **New entries in `PendingResource` or new helpers in `packages/react/src/api/optimistic.tsx`.** The file is closed for new patterns. New rows, new `usePending<X>` hooks, new `use<X>WithPending` hooks should instead be `Atom.optimistic` + `Atom.optimisticFn` families in `atoms.tsx`. Severity: medium.
32+- **`try/finally` cleanup of a placeholder around `await doMutate(...)`.** This shape is the tell. `optimisticFn` clears its own transition; manual cleanup means the author is reimplementing it. Severity: medium.
33+- **Reading `<thing>Atom(scopeId)` in a component that also writes through `<thing>OptimisticAtom`'s mutations.** The reads and writes must both go through the optimistic family or both bypass it; mixing them produces visual jumps. Severity: medium.
34+- **`Atom.optimisticFn` reducer that derives next state from a captured snapshot of the parent atom instead of from the `current` argument.** The reducer signature is `(current, update) => W` — the runtime reads the optimistic state itself and passes it as `current`, which already reflects in-flight transitions. Code that closes over a `useAtomValue(...)` snapshot or a captured `policies` variable instead of using `current` will see stale state under racing edits. A placeholder row id derived from `Date.now()` or random is fine; the bug is reducer state that ignores `current`. Severity: low.
35+- **`Atom.optimistic` wrapped via `Atom.optimistic(policiesAtom(scopeId))` outside an `Atom.family`.** Without `Atom.family`, every render builds a new optimistic atom and transitions don't share state. Severity: medium.
36+
37+## What NOT to Report
38+
39+- `useState` / `useReducer` for UI-local state that has nothing to do with mutation lifecycle: form input values, modal open flags, hover state, derived display values. Read the surrounding code; if there is no `useAtomSet` or `await doMutate` near the state, it is not in scope.
40+- The legacy hooks themselves in `packages/react/src/api/optimistic.tsx` (`useSourcesWithPending`, `useConnectionsWithPendingRemovals`, `usePendingSources`, `usePendingConnectionRemovals`). Existing call sites are grandfathered. Only flag **new** call sites or **new** helpers added to that file.
41+- `useState` for a "busy" / "submitting" boolean used to disable a button while the mutation runs. That is not optimistic state.
42+- `setTimeout` / `setInterval` based debouncing or rate-limiting around a mutation. Different concern.
43+- Toast / error-message state. UI feedback, not optimistic data.
44+- Server-only code (`apps/cloud`, `apps/local`, `packages/core/**`). This skill is React-specific; do not flag backend handlers, plugin storage, or test helpers.
45+- Storybook files, test files, and example-only code. The pattern matters in shipped UI; not in fixtures.
46+
47+## Severity ladder
48+
49+| Level | Criteria |
50+|-------|----------|
51+| **medium** | New optimistic-mutation code that bypasses `Atom.optimistic` / `Atom.optimisticFn` and rolls its own pending state. Or a mixed read/write where the read goes through the plain query atom and the write goes through the optimistic mutation. |
52+| **low** | Subtle defects in an `Atom.optimisticFn` reducer that work today but degrade under racing (clock-based identity, missing `Atom.family` wrapper, computed-once captures of `scopeId`). |
53+
54+Do not invent `high`. Pick `low` when in doubt and explain why.
55+
56+## Reference patterns (TypeScript)
57+
58+The repo's reference implementation lives in `packages/react/src/api/atoms.tsx` (search for `policiesOptimisticAtom`, `updatePolicyOptimistic`).
59+
60+### Bad: hand-rolled pending state
61+
62+```tsx
63+// packages/react/src/pages/secrets.tsx (hypothetical)
64+import { useState } from "react";
65+import { useAtomSet, useAtomValue } from "@effect-atom/atom-react";
66+
67+import { secretsAtom, updateSecret } from "../api/atoms";
68+
69+export function SecretsPage() {
70+ const scopeId = useScope();
71+ const secrets = useAtomValue(secretsAtom(scopeId));
72+ const doUpdate = useAtomSet(updateSecret, { mode: "promise" });
73+ const [pendingValue, setPendingValue] = useState<Map<string, string>>(new Map());
74+
75+ const handleEdit = async (id: string, value: string) => {
76+ setPendingValue((m) => new Map(m).set(id, value));
77+ try {
78+ await doUpdate({ path: { scopeId, secretId: id }, payload: { value } });
79+ } finally {
80+ setPendingValue((m) => {
81+ const next = new Map(m);
82+ next.delete(id);
83+ return next;
84+ });
85+ }
86+ };
87+ // ...
88+}
89+```
90+
91+The `Map` keyed by id, the `try/finally`, the manual cleanup. All three signal a hand-rolled optimistic layer. Two fast edits to the same secret race: A's `finally` deletes B's pending entry, UI flickers to A's server value before B settles.
92+
93+### Safe: effect-atom primitives
94+
95+```tsx
96+// packages/react/src/api/atoms.tsx
97+export const secretsOptimisticAtom = Atom.family((scopeId: ScopeId) =>
98+ Atom.optimistic(secretsAtom(scopeId)),
99+);
100+
101+export const updateSecretOptimistic = Atom.family((scopeId: ScopeId) =>
102+ secretsOptimisticAtom(scopeId).pipe(
103+ Atom.optimisticFn({
104+ reducer: (current, arg: { path: { secretId: SecretId }; payload: { value: string } }) =>
105+ Result.map(current, (rows) =>
106+ rows.map((r) =>
107+ r.id === arg.path.secretId ? { ...r, value: arg.payload.value } : r,
108+ ),
109+ ),
110+ fn: updateSecret,
111+ }),
112+ ),
113+);
114+
115+// packages/react/src/pages/secrets.tsx
116+const secrets = useAtomValue(secretsOptimisticAtom(scopeId));
117+const doUpdate = useAtomSet(updateSecretOptimistic(scopeId), { mode: "promise" });
118+
119+const handleEdit = (id: SecretId, value: string) =>
120+ doUpdate({ path: { scopeId, secretId: id }, payload: { value } });
121+```
122+
123+No local state, no try/finally, no manual cleanup. Multiple edits stack: the runtime feeds each reducer call `current` reflecting prior in-flight transitions, so the second edit sees the optimistic value of the first.
124+
125+### Bad: extending the legacy layer
126+
127+```tsx
128+// packages/react/src/api/optimistic.tsx (hypothetical addition)
129+export const PendingResource = {
130+ sources: "sources",
131+ connectionRemovals: "connection-removals",
132+ secrets: "secrets", // <-- new
133+} as const;
134+
135+export interface PendingSecret {
136+ readonly value: string;
137+}
138+
139+export const useSecretsWithPending = (scopeId: ScopeId) => { /* ... */ };
140+export const usePendingSecrets = () => { /* ... */ };
141+```
142+
143+Flag any new entry in `PendingResource` and any new `use<X>WithPending` / `usePending<X>` hook. The file is closed for new patterns.
144+
145+### Subtle: missing Atom.family wrapper
146+
147+```tsx
148+// Bad: builds a fresh optimistic atom every render. No transition state survives.
149+const optimistic = Atom.optimistic(secretsAtom(scopeId));
150+const value = useAtomValue(optimistic);
151+```
152+
153+```tsx
154+// Safe: Atom.family memoizes per scopeId so transitions persist.
155+export const secretsOptimisticAtom = Atom.family((scopeId: ScopeId) =>
156+ Atom.optimistic(secretsAtom(scopeId)),
157+);
158+
159+const value = useAtomValue(secretsOptimisticAtom(scopeId));
160+```
161+
162+## Output Requirements
163+
164+For each finding:
165+
166+- **File and line** of the offending code.
167+- **Severity** from the ladder above.
168+- **What is wrong**, in one sentence.
169+- **Trace**: which mutation atom, which read atom, which symptom (race / stale flicker / unmemoized atom).
170+- **Fix**: name the optimistic family that should exist, or the change that lifts the page's hand-rolled state into `atoms.tsx`. Point to `packages/react/src/api/atoms.tsx` (`policiesOptimisticAtom`) as the reference shape.
171+
172+Group findings by severity. Lead with `medium`.
.skills/effect-atom-optimistic-updates/SKILL.md+252−0
@@ -0,0 +1,252 @@
1+---
2+name: effect-atom-optimistic-updates
3+description: Pattern for implementing optimistic UI updates with effect-atom in this codebase. Use when adding optimistic behavior to a query atom + its mutations (action toggles, list adds/removes, inline edits). DO NOT roll your own pending-state with React state, Maps, or custom merge helpers — `Atom.optimistic` + `Atom.optimisticFn` already handle racing, refresh, and waiting correctly.
4+---
5+
6+# effect-atom Optimistic Updates
7+
8+The `@effect-atom/atom-react` library ships first-class optimistic support via
9+`Atom.optimistic` and `Atom.optimisticFn`. Use them directly. Do not write a
10+custom "pending entries" layer (Maps, useState, useRef) on top of `useAtomValue`
11+— it will not handle racing updates correctly, and the existing helpers in
12+`packages/react/src/api/optimistic.tsx` are legacy patterns kept only for
13+sources/connections.
14+
15+## When to use
16+
17+- A list query atom (`Atom.optimistic` wraps the query)
18+- Plus one or more mutation atoms that change rows in that list (create / update / delete)
19+- And you want the UI to reflect the change *immediately* on click, before the server roundtrip
20+
21+If you only need the UI to be "eventually consistent" (i.e. you're fine waiting
22+~200ms for the server response and the existing reactivity refetch), skip
23+optimistic — just use the mutation directly with `reactivityKeys`.
24+
25+## Why not roll your own
26+
27+The naive approach — track `pending: Array<{id, value}>` in a separate atom and
28+merge it with the server result — has a subtle race that bites on rapid edits:
29+
30+1. User clicks "set action = block" → mutation A fires, pending entry written
31+2. User clicks "set action = approve" → mutation B fires, pending entry overwritten with B
32+3. **A's response returns first** → finally-block clears the pending entry → UI flickers back to the server's "block" value
33+4. B's response returns → UI shows "approve"
34+
35+Step 3 is the bug. Fixing it correctly requires per-call entry ids and "last
36+entry per row id wins" merging — at which point you've reimplemented a worse
37+version of `Atom.optimistic`'s transition tracking.
38+
39+`Atom.optimistic` solves this because the runtime reads the current optimistic
40+state (including any in-flight transitions) and passes it to your reducer as
41+`current`, so B stacks on top of A correctly. When all transitions settle, it
42+calls `refresh(self)` to pull the server's authoritative state.
43+
44+## Pattern
45+
46+Define optimistic atoms next to the underlying query and mutations in the
47+`atoms.tsx` (or equivalent) module. Each scope-keyed list gets a family that
48+wraps the query with `Atom.optimistic`, and each mutation gets a family that
49+pipes the optimistic atom through `Atom.optimisticFn` with a reducer.
50+
51+```typescript
52+import { Atom, Result } from "@effect-atom/atom-react";
53+import type { ScopeId, PolicyId, ToolPolicyAction } from "@executor/sdk";
54+
55+import { ExecutorApiClient } from "./client";
56+
57+// 1. The plain query atom — same as before.
58+export const policiesAtom = (scopeId: ScopeId) =>
59+ ExecutorApiClient.query("policies", "list", {
60+ path: { scopeId },
61+ timeToLive: "30 seconds",
62+ reactivityKeys: [ReactivityKey.policies],
63+ });
64+
65+// 2. Plain mutations — same as before. These are the underlying `fn` for
66+// the optimistic wrappers below.
67+export const createPolicy = ExecutorApiClient.mutation("policies", "create");
68+export const updatePolicy = ExecutorApiClient.mutation("policies", "update");
69+export const removePolicy = ExecutorApiClient.mutation("policies", "remove");
70+
71+// 3. Optimistic read atom. `Atom.family` memoizes per-scope so every consumer
72+// references the same optimistic atom instance and shares transition state.
73+export const policiesOptimisticAtom = Atom.family((scopeId: ScopeId) =>
74+ Atom.optimistic(policiesAtom(scopeId)),
75+);
76+
77+// 4. Optimistic mutation. The reducer takes the same arg as the underlying
78+// mutation and returns the next list state. `Result.map` keeps the
79+// Result wrapper intact.
80+export const updatePolicyOptimistic = Atom.family((scopeId: ScopeId) =>
81+ policiesOptimisticAtom(scopeId).pipe(
82+ Atom.optimisticFn({
83+ reducer: (
84+ current,
85+ arg: {
86+ path: { scopeId: ScopeId; policyId: PolicyId };
87+ payload: { action?: ToolPolicyAction };
88+ reactivityKeys?: ReadonlyArray<unknown>;
89+ },
90+ ) =>
91+ Result.map(current, (rows) =>
92+ rows.map((r) =>
93+ r.id === arg.path.policyId && arg.payload.action !== undefined
94+ ? { ...r, action: arg.payload.action }
95+ : r,
96+ ),
97+ ),
98+ fn: updatePolicy,
99+ }),
100+ ),
101+);
102+```
103+
104+## Consuming in components
105+
106+Read from the optimistic atom; write through the optimistic mutation. The
107+existing `reactivityKeys` plumbing still applies — pass them in the call.
108+
109+```typescript
110+import { useAtomValue, useAtomSet } from "@effect-atom/atom-react";
111+
112+import {
113+ policiesOptimisticAtom,
114+ updatePolicyOptimistic,
115+} from "../api/atoms";
116+import { policyWriteKeys } from "../api/reactivity-keys";
117+
118+export function PoliciesPage() {
119+ const scopeId = useScope();
120+
121+ // Read: this Result reflects in-flight optimistic state on top of server data.
122+ const policies = useAtomValue(policiesOptimisticAtom(scopeId));
123+
124+ // Write: same call signature as the underlying mutation.
125+ const doUpdate = useAtomSet(updatePolicyOptimistic(scopeId), { mode: "promise" });
126+
127+ const handleUpdate = async (id: string, action: ToolPolicyAction) => {
128+ await doUpdate({
129+ path: { scopeId, policyId: PolicyId.make(id) },
130+ payload: { action },
131+ reactivityKeys: policyWriteKeys,
132+ });
133+ };
134+
135+ // ...
136+}
137+```
138+
139+## Reducer rules
140+
141+1. **`current` is the FULL Result, not the unwrapped value.** Use `Result.map`
142+ to update inside the success case — the wrapper preserves Initial/Failure
143+ states correctly.
144+2. **The reducer is called for every transition, including ones that stack on
145+ top of in-flight ones.** Read `current` and produce `next` — don't track
146+ "the previous optimistic value" yourself.
147+3. **The reducer signature must match the mutation's arg shape.** Effect-atom
148+ passes the raw mutation arg (e.g. `{ path, payload, reactivityKeys }`) to
149+ both the reducer and the underlying `fn`. Don't try to build a "nicer" arg
150+ shape unless you also wrap the underlying mutation.
151+4. **Be pure.** No side effects, no calls to `Date.now()` for stable values,
152+ no random ids unless you need a placeholder row id (see "Adds" below).
153+
154+## Adds (server mints the id)
155+
156+For create flows the server assigns the canonical id. The reducer inserts a
157+placeholder with a temp id — the post-commit refresh replaces it with the
158+canonical row.
159+
160+```typescript
161+export const createPolicyOptimistic = Atom.family((scopeId: ScopeId) =>
162+ policiesOptimisticAtom(scopeId).pipe(
163+ Atom.optimisticFn({
164+ reducer: (
165+ current,
166+ arg: {
167+ path: { scopeId: ScopeId };
168+ payload: { pattern: string; action: ToolPolicyAction };
169+ reactivityKeys?: ReadonlyArray<unknown>;
170+ },
171+ ) =>
172+ Result.map(current, (rows) => [
173+ {
174+ id: PolicyId.make(`pending-${Math.random().toString(36).slice(2)}`),
175+ scopeId,
176+ pattern: arg.payload.pattern,
177+ action: arg.payload.action,
178+ position: -Number.MAX_SAFE_INTEGER, // sort to top
179+ createdAt: Date.now(),
180+ updatedAt: Date.now(),
181+ },
182+ ...rows,
183+ ]),
184+ fn: createPolicy,
185+ }),
186+ ),
187+);
188+```
189+
190+The placeholder doesn't need to roundtrip through the `id` field unless your
191+list rendering keys on it (it usually does — `<Row key={p.id}>`). A unique
192+prefix like `pending-` is fine.
193+
194+## Removes
195+
196+```typescript
197+export const removePolicyOptimistic = Atom.family((scopeId: ScopeId) =>
198+ policiesOptimisticAtom(scopeId).pipe(
199+ Atom.optimisticFn({
200+ reducer: (
201+ current,
202+ arg: {
203+ path: { scopeId: ScopeId; policyId: PolicyId };
204+ reactivityKeys?: ReadonlyArray<unknown>;
205+ },
206+ ) =>
207+ Result.map(current, (rows) =>
208+ rows.filter((r) => r.id !== arg.path.policyId),
209+ ),
210+ fn: removePolicy,
211+ }),
212+ ),
213+);
214+```
215+
216+## How racing is handled (mental model)
217+
218+You don't have to think about this — it works — but understanding helps:
219+
220+- `Atom.optimistic(self)` wraps the underlying atom and tracks a `transitions` set
221+- Each `Atom.optimisticFn` call creates one shared transition state per (scope, mutation)
222+- A call: runtime reads the current optimistic state (including in-flight transitions), invokes `reducer(current, arg) → value`, sets transition to `Success(value, waiting=true)`, calls the underlying mutation `fn` with `arg`
223+- The next call to the same optimisticFn sees the prior call's optimistic value as `current` — so it stacks on top
224+- When `fn` settles, both calls' subscribers fire, the transition flips to non-waiting, and `refresh(self)` pulls the server state
225+- The server's authoritative response replaces the optimistic state via the
226+ underlying atom's normal subscribe path
227+
228+Net result: rapid edits look smooth, the last edit wins both visually and on
229+the server, no flickers, no manual cleanup.
230+
231+## Things to avoid
232+
233+- ❌ `useState`/`useRef` to hold pending values
234+- ❌ `Map` / `Set` of in-flight ids
235+- ❌ Custom `mergePending` helpers
236+- ❌ `try/finally` blocks that "clear the placeholder" — `optimistic` clears for you
237+- ❌ Reading `policiesAtom` directly in the component while writing through `updatePolicyOptimistic` — they have different transition state, you'll see jumps
238+
239+## Reference implementation
240+
241+`packages/react/src/api/atoms.tsx` (search for `policiesOptimisticAtom`) and
242+`packages/react/src/pages/policies.tsx` show the full pattern: optimistic
243+read, optimistic create/update/remove, no custom state.
244+
245+## When you must NOT use this
246+
247+- The mutation has cross-cutting effects on data that *isn't* in the same list
248+ (e.g. a single mutation invalidates `tools` AND `policies`). Reactivity keys
249+ still handle that — optimistic only paints the list-local change.
250+- You need to show transient UI state that isn't a row property (toasts,
251+ dirty indicators per field, pending counts). Those belong in component state,
252+ not in the atom layer.
apps/cloud/drizzle/0006_add_tool_policy.sql+17−0
@@ -0,0 +1,17 @@
1+CREATE TABLE "tool_policy" (
2+ "id" text NOT NULL,
3+ "scope_id" text NOT NULL,
4+ "pattern" text NOT NULL,
5+ "action" text NOT NULL,
6+ -- Fractional-indexing key (Jira lexorank style). Lex-ordered text;
7+ -- always subdivisible by lengthening, so reorders never run out of
8+ -- room.
9+ "position" text NOT NULL,
10+ "created_at" timestamp NOT NULL,
11+ "updated_at" timestamp NOT NULL,
12+ CONSTRAINT "tool_policy_scope_id_id_pk" PRIMARY KEY("scope_id","id")
13+);
14+--> statement-breakpoint
15+-- List queries are always `WHERE scope_id = ? ORDER BY position`, so the
16+-- composite index serves both the filter and the sort from one btree.
17+CREATE INDEX "tool_policy_scope_id_position_idx" ON "tool_policy" USING btree ("scope_id", "position");
apps/cloud/drizzle/meta/_journal.json+8−1
@@ -43,6 +43,13 @@ · 42 unmodified lines
4343 "when": 1777000000000,
4444 "tag": "0005_drop_connection_kind",
4545 "breakpoints": true
46+ },
47+ {
48+ "idx": 6,
49+ "version": "7",
50+ "when": 1777444003590,
51+ "tag": "0006_add_tool_policy",
52+ "breakpoints": true
4653 }
4754 ]
48−}
55+}
apps/cloud/src/mcp-miniflare.e2e.node.test.ts+45−0
@@ -489,3 +489,48 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) · 488 unmodified lines
489489 );
490490
491491 });
492+
493+layer(TestEnv, { timeout: 60_000 })("cloud MCP request-id telemetry", (it) => {
494+ it.effect("exports MCP JSON-RPC request ids for request-shaped ids", () =>
495+ Effect.gen(function* () {
496+ const { baseUrl, seedOrg } = yield* Worker;
497+ const receiver = yield* TelemetryReceiver;
498+ const orgId = nextOrgId();
499+ const accountId = nextAccountId();
500+ const requestId = `req_${crypto.randomUUID().replace(/-/g, "")}`;
501+ yield* Effect.promise(() => seedOrg(orgId, "Request Id Org"));
502+
503+ const response = yield* Effect.promise(() =>
504+ fetch(new URL("/mcp", baseUrl), {
505+ method: "POST",
506+ headers: {
507+ accept: "application/json, text/event-stream",
508+ authorization: `Bearer ${makeTestBearer(accountId, orgId)}`,
509+ "content-type": "application/json",
510+ },
511+ body: JSON.stringify({
512+ jsonrpc: "2.0",
513+ id: requestId,
514+ method: "initialize",
515+ params: {
516+ protocolVersion: "2025-06-18",
517+ capabilities: {},
518+ clientInfo: { name: "mcp-request-id-e2e", version: "0" },
519+ },
520+ }),
521+ }),
522+ );
523+ expect(response.status).toBe(200);
524+ yield* Effect.promise(() => response.text());
525+
526+ const annotateSpan = yield* Effect.promise(() =>
527+ receiver.waitForSpan(
528+ (s) =>
529+ s.name === "mcp.request.annotate" &&
530+ s.attributes["mcp.rpc.id"] === requestId,
531+ ),
532+ );
533+ expect(annotateSpan.attributes["mcp.rpc.method"]).toBe("initialize");
534+ }), 30_000,
535+ );
536+});
apps/cloud/src/mcp.ts+7−11
@@ -362,19 +362,15 @@ const annotateMcpRequest = ( · 361 unmodified lines
362362 };
363363
364364 const envelope = opts.parseBody ? yield* readJsonRpcEnvelope(request) : Option.none();
365−
366− yield* Effect.annotateCurrentSpan({
365+ const attrs = {
367366 ...baseAttrs,
368367 ...rpcAttrs(envelope),
369− });
370− }).pipe(
371− Effect.withSpan("mcp.request.annotate", {
372− attributes: {
373− "mcp.request.method": request.method,
374− "mcp.request.parse_body": opts.parseBody,
375− },
376− }),
377− );
368+ "mcp.request.parse_body": opts.parseBody,
369+ };
370+
371+ yield* Effect.annotateCurrentSpan(attrs);
372+ yield* Effect.annotateCurrentSpan(attrs).pipe(Effect.withSpan("mcp.request.annotate"));
373+ });
378374
379375 // ---------------------------------------------------------------------------
380376 // OAuth metadata endpoints
apps/cloud/src/routeTree.gen.ts+21−0
@@ -11,6 +11,7 @@ · 10 unmodified lines
1111 import { Route as rootRouteImport } from './routes/__root'
1212 import { Route as ToolsRouteImport } from './routes/tools'
1313 import { Route as SecretsRouteImport } from './routes/secrets'
14+import { Route as PoliciesRouteImport } from './routes/policies'
1415 import { Route as OrgRouteImport } from './routes/org'
1516 import { Route as ConnectionsRouteImport } from './routes/connections'
1617 import { Route as BillingRouteImport } from './routes/billing'
@@ -29,6 +30,11 @@ const SecretsRoute = SecretsRouteImport.update({ · 12 unmodified lines
2930 path: '/secrets',
3031 getParentRoute: () => rootRouteImport,
3132 } as any)
33+const PoliciesRoute = PoliciesRouteImport.update({
34+ id: '/policies',
35+ path: '/policies',
36+ getParentRoute: () => rootRouteImport,
37+} as any)
3238 const OrgRoute = OrgRouteImport.update({
3339 id: '/org',
3440 path: '/org',
@@ -70,6 +76,7 @@ export interface FileRoutesByFullPath { · 35 unmodified lines
7076 '/billing': typeof BillingRoute
7177 '/connections': typeof ConnectionsRoute
7278 '/org': typeof OrgRoute
79+ '/policies': typeof PoliciesRoute
7380 '/secrets': typeof SecretsRoute
7481 '/tools': typeof ToolsRoute
7582 '/billing/plans': typeof BillingPlansRoute
@@ -81,6 +88,7 @@ export interface FileRoutesByTo { · 5 unmodified lines
8188 '/billing': typeof BillingRoute
8289 '/connections': typeof ConnectionsRoute
8390 '/org': typeof OrgRoute
91+ '/policies': typeof PoliciesRoute
8492 '/secrets': typeof SecretsRoute
8593 '/tools': typeof ToolsRoute
8694 '/billing/plans': typeof BillingPlansRoute
@@ -93,6 +101,7 @@ export interface FileRoutesById { · 6 unmodified lines
93101 '/billing': typeof BillingRoute
94102 '/connections': typeof ConnectionsRoute
95103 '/org': typeof OrgRoute
104+ '/policies': typeof PoliciesRoute
96105 '/secrets': typeof SecretsRoute
97106 '/tools': typeof ToolsRoute
98107 '/billing_/plans': typeof BillingPlansRoute
@@ -106,6 +115,7 @@ export interface FileRouteTypes { · 7 unmodified lines
106115 | '/billing'
107116 | '/connections'
108117 | '/org'
118+ | '/policies'
109119 | '/secrets'
110120 | '/tools'
111121 | '/billing/plans'
@@ -117,6 +127,7 @@ export interface FileRouteTypes { · 5 unmodified lines
117127 | '/billing'
118128 | '/connections'
119129 | '/org'
130+ | '/policies'
120131 | '/secrets'
121132 | '/tools'
122133 | '/billing/plans'
@@ -128,6 +139,7 @@ export interface FileRouteTypes { · 5 unmodified lines
128139 | '/billing'
129140 | '/connections'
130141 | '/org'
142+ | '/policies'
131143 | '/secrets'
132144 | '/tools'
133145 | '/billing_/plans'
@@ -140,6 +152,7 @@ export interface RootRouteChildren { · 6 unmodified lines
140152 BillingRoute: typeof BillingRoute
141153 ConnectionsRoute: typeof ConnectionsRoute
142154 OrgRoute: typeof OrgRoute
155+ PoliciesRoute: typeof PoliciesRoute
143156 SecretsRoute: typeof SecretsRoute
144157 ToolsRoute: typeof ToolsRoute
145158 BillingPlansRoute: typeof BillingPlansRoute
@@ -163,6 +176,13 @@ declare module '@tanstack/react-router' { · 17 unmodified lines
163176 preLoaderRoute: typeof SecretsRouteImport
164177 parentRoute: typeof rootRouteImport
165178 }
179+ '/policies': {
180+ id: '/policies'
181+ path: '/policies'
182+ fullPath: '/policies'
183+ preLoaderRoute: typeof PoliciesRouteImport
184+ parentRoute: typeof rootRouteImport
185+ }
166186 '/org': {
167187 id: '/org'
168188 path: '/org'
@@ -220,6 +240,7 @@ const rootRouteChildren: RootRouteChildren = { · 51 unmodified lines
220240 BillingRoute: BillingRoute,
221241 ConnectionsRoute: ConnectionsRoute,
222242 OrgRoute: OrgRoute,
243+ PoliciesRoute: PoliciesRoute,
223244 SecretsRoute: SecretsRoute,
224245 ToolsRoute: ToolsRoute,
225246 BillingPlansRoute: BillingPlansRoute,
apps/cloud/src/routes/policies.tsx+6−0
@@ -0,0 +1,6 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+import { PoliciesPage } from "@executor/react/pages/policies";
3+
4+export const Route = createFileRoute("/policies")({
5+ component: () => <PoliciesPage />,
6+});
apps/cloud/src/services/executor-schema.ts+21−0
@@ -83,6 +83,27 @@ export const connection = pgTable("connection", { · 82 unmodified lines
8383 index("connection_provider_idx").on(table.provider),
8484 ]);
8585
86+export const tool_policy = pgTable("tool_policy", {
87+ id: text('id').notNull(),
88+ scope_id: text('scope_id').notNull(),
89+ pattern: text('pattern').notNull(),
90+ action: text('action').notNull(),
91+ // Fractional-indexing key (Jira lexorank style). Stored as text and
92+ // compared lexicographically. Sortable like a number, but you can
93+ // always lengthen the key to insert between two adjacent rows
94+ // without precision loss.
95+ position: text('position').notNull(),
96+ created_at: timestamp('created_at').notNull(),
97+ updated_at: timestamp('updated_at').notNull()
98+}, (table) => [
99+ primaryKey({ columns: [table.scope_id, table.id] }),
100+ // Composite (scope_id, position) — list reads are always
101+ // `WHERE scope_id = ? ORDER BY position` so the prefix lets postgres
102+ // serve both the filter and the sort from one btree. The earlier
103+ // standalone scope_id and position indexes were redundant.
104+ index("tool_policy_scope_id_position_idx").on(table.scope_id, table.position),
105+]);
106+
86107 export const openapi_source = pgTable("openapi_source", {
87108 id: text('id').notNull(),
88109 scope_id: text('scope_id').notNull(),
apps/cloud/src/web/shell.tsx+2−0
@@ -361,6 +361,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 360 unmodified lines
361361 const isHome = props.pathname === "/";
362362 const isSecrets = props.pathname === "/secrets";
363363 const isConnections = props.pathname === "/connections";
364+ const isPolicies = props.pathname === "/policies";
364365 const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/");
365366 const isOrg = props.pathname === "/org";
366367
@@ -378,6 +379,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 11 unmodified lines
378379 <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} />
379380 <NavItem to="/connections" label="Connections" active={isConnections} onNavigate={props.onNavigate} />
380381 <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} />
382+ <NavItem to="/policies" label="Policies" active={isPolicies} onNavigate={props.onNavigate} />
381383 <NavItem to="/org" label="Organization" active={isOrg} onNavigate={props.onNavigate} />
382384 <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} />
383385
apps/local/drizzle/0004_add_tool_policy.sql+17−0
@@ -0,0 +1,17 @@
1+CREATE TABLE `tool_policy` (
2+ `id` text NOT NULL,
3+ `scope_id` text NOT NULL,
4+ `pattern` text NOT NULL,
5+ `action` text NOT NULL,
6+ -- Fractional-indexing key (Jira lexorank style). Lex-ordered text;
7+ -- always subdivisible by lengthening, so reorders never run out of
8+ -- room.
9+ `position` text NOT NULL,
10+ `created_at` integer NOT NULL,
11+ `updated_at` integer NOT NULL,
12+ PRIMARY KEY(`scope_id`, `id`)
13+);
14+--> statement-breakpoint
15+-- List queries are always `WHERE scope_id = ? ORDER BY position`, so the
16+-- composite index serves both the filter and the sort from one btree.
17+CREATE INDEX `tool_policy_scope_id_position_idx` ON `tool_policy` (`scope_id`, `position`);
apps/local/drizzle/meta/_journal.json+7−0
@@ -29,6 +29,13 @@ · 28 unmodified lines
2929 "when": 1776976132767,
3030 "tag": "0003_little_silk_fever",
3131 "breakpoints": true
32+ },
33+ {
34+ "idx": 4,
35+ "version": "6",
36+ "when": 1777800000000,
37+ "tag": "0004_add_tool_policy",
38+ "breakpoints": true
3239 }
3340 ]
3441 }
apps/local/src/server/executor-schema.ts+19−0
@@ -83,6 +83,25 @@ export const connection = sqliteTable("connection", { · 82 unmodified lines
8383 index("connection_provider_idx").on(table.provider),
8484 ]);
8585
86+export const tool_policy = sqliteTable("tool_policy", {
87+ id: text('id').notNull(),
88+ scope_id: text('scope_id').notNull(),
89+ pattern: text('pattern').notNull(),
90+ action: text('action').notNull(),
91+ // Fractional-indexing key (Jira lexorank style). Lex-ordered text;
92+ // always subdivisible by lengthening, so reorders never run out of
93+ // room.
94+ position: text('position').notNull(),
95+ created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
96+ updated_at: integer('updated_at', { mode: 'timestamp_ms' }).notNull()
97+}, (table) => [
98+ primaryKey({ columns: [table.scope_id, table.id] }),
99+ // List queries are always WHERE scope_id = ? ORDER BY position, so
100+ // the composite index serves both the filter and the sort from one
101+ // btree.
102+ index("tool_policy_scope_id_position_idx").on(table.scope_id, table.position),
103+]);
104+
86105 export const openapi_source = sqliteTable("openapi_source", {
87106 id: text('id').notNull(),
88107 scope_id: text('scope_id').notNull(),
bun.lock+4−0
@@ -321,6 +321,7 @@ · 320 unmodified lines
321321 "@effect/platform": "catalog:",
322322 "@executor/storage-core": "workspace:*",
323323 "effect": "catalog:",
324+ "fractional-indexing": "^3.2.0",
324325 },
325326 "devDependencies": {
326327 "@effect/vitest": "catalog:",
@@ -788,6 +789,7 @@ · 461 unmodified lines
788789 "cmdk": "^1.1.1",
789790 "effect": "catalog:",
790791 "embla-carousel-react": "^8.6.0",
792+ "fractional-indexing": "^3.2.0",
791793 "input-otp": "^1.4.2",
792794 "lucide-react": "^1.7.0",
793795 "radix-ui": "^1.4.3",
@@ -3143,6 +3145,8 @@ · 2349 unmodified lines
31433145
31443146 "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
31453147
3148+ "fractional-indexing": ["fractional-indexing@3.2.0", "", {}, "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ=="],
3149+
31463150 "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
31473151
31483152 "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
packages/core/api/src/api.ts+2−0
@@ -7,6 +7,7 @@ import { SecretsApi } from "./secrets/api"; · 6 unmodified lines
77 import { ConnectionsApi } from "./connections/api";
88 import { ExecutionsApi } from "./executions/api";
99 import { ScopeApi } from "./scope/api";
10+import { PoliciesApi } from "./policies/api";
1011
1112 export const CoreExecutorApi = HttpApi.make("executor")
1213 .add(ToolsApi)
@@ -15,6 +16,7 @@ export const CoreExecutorApi = HttpApi.make("executor") · 2 unmodified lines
1516 .add(ConnectionsApi)
1617 .add(ExecutionsApi)
1718 .add(ScopeApi)
19+ .add(PoliciesApi)
1820 .annotateContext(
1921 OpenApi.annotations({
2022 title: "Executor API",
packages/core/api/src/handlers/index.ts+3−0
@@ -6,6 +6,7 @@ import { SecretsHandlers } from "./secrets"; · 5 unmodified lines
66 import { ConnectionsHandlers } from "./connections";
77 import { ScopeHandlers } from "./scope";
88 import { ExecutionsHandlers } from "./executions";
9+import { PoliciesHandlers } from "./policies";
910
1011 export { ToolsHandlers } from "./tools";
1112 export { SourcesHandlers } from "./sources";
@@ -13,6 +14,7 @@ export { SecretsHandlers } from "./secrets"; · 1 unmodified line
1314 export { ConnectionsHandlers } from "./connections";
1415 export { ScopeHandlers } from "./scope";
1516 export { ExecutionsHandlers } from "./executions";
17+export { PoliciesHandlers } from "./policies";
1618
1719 export const CoreHandlers = Layer.mergeAll(
1820 ToolsHandlers,
@@ -21,4 +23,5 @@ export const CoreHandlers = Layer.mergeAll( · 2 unmodified lines
2123 ConnectionsHandlers,
2224 ScopeHandlers,
2325 ExecutionsHandlers,
26+ PoliciesHandlers,
2427 );
packages/core/api/src/handlers/policies.ts+67−0
@@ -0,0 +1,67 @@
1+import { HttpApiBuilder } from "@effect/platform";
2+import { Effect } from "effect";
3+import type { ToolPolicy } from "@executor/sdk";
4+
5+import { ExecutorApi } from "../api";
6+import { ExecutorService } from "../services";
7+import { capture } from "@executor/api";
8+
9+const policyToResponse = (p: ToolPolicy) => ({
10+ id: p.id,
11+ scopeId: p.scopeId,
12+ pattern: p.pattern,
13+ action: p.action,
14+ position: p.position,
15+ createdAt: p.createdAt.getTime(),
16+ updatedAt: p.updatedAt.getTime(),
17+});
18+
19+export const PoliciesHandlers = HttpApiBuilder.group(ExecutorApi, "policies", (handlers) =>
20+ handlers
21+ .handle("list", () =>
22+ capture(
23+ Effect.gen(function* () {
24+ const executor = yield* ExecutorService;
25+ const policies = yield* executor.policies.list();
26+ return policies.map(policyToResponse);
27+ }),
28+ ),
29+ )
30+ .handle("create", ({ path, payload }) =>
31+ capture(
32+ Effect.gen(function* () {
33+ const executor = yield* ExecutorService;
34+ const created = yield* executor.policies.create({
35+ scope: path.scopeId,
36+ pattern: payload.pattern,
37+ action: payload.action,
38+ position: payload.position,
39+ });
40+ return policyToResponse(created);
41+ }),
42+ ),
43+ )
44+ .handle("update", ({ path, payload }) =>
45+ capture(
46+ Effect.gen(function* () {
47+ const executor = yield* ExecutorService;
48+ const updated = yield* executor.policies.update({
49+ id: path.policyId,
50+ pattern: payload.pattern,
51+ action: payload.action,
52+ position: payload.position,
53+ });
54+ return policyToResponse(updated);
55+ }),
56+ ),
57+ )
58+ .handle("remove", ({ path }) =>
59+ capture(
60+ Effect.gen(function* () {
61+ const executor = yield* ExecutorService;
62+ yield* executor.policies.remove(path.policyId);
63+ return { removed: true };
64+ }),
65+ ),
66+ ),
67+);
packages/core/api/src/handlers/sources.ts+9−1
@@ -42,9 +42,15 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 41 unmodified lines
4242 .handle("tools", ({ path }) =>
4343 capture(Effect.gen(function* () {
4444 const executor = yield* ExecutorService;
45+ // Source detail is a management view — include policy-blocked
46+ // tools so users can see and unblock them from the same place
47+ // they review the source's other tools. Annotations are loaded
48+ // so the UI can show the plugin's default approval state for
49+ // tools that have no user policy override.
4550 const tools = yield* executor.tools.list({
4651 sourceId: path.sourceId,
47− includeAnnotations: false,
52+ includeAnnotations: true,
53+ includeBlocked: true,
4854 });
4955 return tools.map((t) => ({
5056 id: ToolId.make(t.id),
@@ -53,6 +59,8 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 2 unmodified lines
5359 name: t.name,
5460 description: t.description,
5561 mayElicit: t.annotations?.mayElicit,
62+ requiresApproval: t.annotations?.requiresApproval,
63+ approvalDescription: t.annotations?.approvalDescription,
5664 }));
5765 })),
5866 )
packages/core/api/src/index.ts+1−0
@@ -5,6 +5,7 @@ export { SecretsApi } from "./secrets/api"; · 4 unmodified lines
55 export { ConnectionsApi } from "./connections/api";
66 export { ExecutionsApi } from "./executions/api";
77 export { ScopeApi } from "./scope/api";
8+export { PoliciesApi } from "./policies/api";
89 export {
910 InternalError,
1011 ErrorCapture,
packages/core/api/src/policies/api.ts+65−0
@@ -0,0 +1,65 @@
1+import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2+import { Schema } from "effect";
3+import { PolicyId, ScopeId, ToolPolicyActionSchema } from "@executor/sdk";
4+
5+import { InternalError } from "../observability";
6+
7+// ---------------------------------------------------------------------------
8+// Params
9+// ---------------------------------------------------------------------------
10+
11+const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
12+const policyIdParam = HttpApiSchema.param("policyId", PolicyId);
13+
14+// ---------------------------------------------------------------------------
15+// Response / payload schemas
16+// ---------------------------------------------------------------------------
17+
18+const ToolPolicyResponse = Schema.Struct({
19+ id: PolicyId,
20+ scopeId: ScopeId,
21+ pattern: Schema.String,
22+ action: ToolPolicyActionSchema,
23+ position: Schema.String,
24+ createdAt: Schema.Number,
25+ updatedAt: Schema.Number,
26+});
27+
28+const CreateToolPolicyPayload = Schema.Struct({
29+ pattern: Schema.String,
30+ action: ToolPolicyActionSchema,
31+ position: Schema.optional(Schema.String),
32+});
33+
34+const UpdateToolPolicyPayload = Schema.Struct({
35+ pattern: Schema.optional(Schema.String),
36+ action: Schema.optional(ToolPolicyActionSchema),
37+ position: Schema.optional(Schema.String),
38+});
39+
40+// ---------------------------------------------------------------------------
41+// Group
42+// ---------------------------------------------------------------------------
43+
44+export class PoliciesApi extends HttpApiGroup.make("policies")
45+ .add(
46+ HttpApiEndpoint.get("list")`/scopes/${scopeIdParam}/policies`.addSuccess(
47+ Schema.Array(ToolPolicyResponse),
48+ ),
49+ )
50+ .add(
51+ HttpApiEndpoint.post("create")`/scopes/${scopeIdParam}/policies`
52+ .setPayload(CreateToolPolicyPayload)
53+ .addSuccess(ToolPolicyResponse),
54+ )
55+ .add(
56+ HttpApiEndpoint.patch("update")`/scopes/${scopeIdParam}/policies/${policyIdParam}`
57+ .setPayload(UpdateToolPolicyPayload)
58+ .addSuccess(ToolPolicyResponse),
59+ )
60+ .add(
61+ HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/policies/${policyIdParam}`.addSuccess(
62+ Schema.Struct({ removed: Schema.Boolean }),
63+ ),
64+ )
65+ .addError(InternalError) {}
packages/core/api/src/sources/api.ts+4−0
@@ -46,6 +46,10 @@ const ToolMetadataResponse = Schema.Struct({ · 45 unmodified lines
4646 name: Schema.String,
4747 description: Schema.optional(Schema.String),
4848 mayElicit: Schema.optional(Schema.Boolean),
49+ /** Plugin-derived default approval annotation. Surfaces in the UI as
50+ * the "default" policy when no user `tool_policy` rule matches. */
51+ requiresApproval: Schema.optional(Schema.Boolean),
52+ approvalDescription: Schema.optional(Schema.String),
4953 });
5054
5155 const DetectRequest = Schema.Struct({
packages/core/execution/src/tool-invoker.test.ts+49−1
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
1010 } from "@executor/sdk";
1111 import { makeQuickJsExecutor } from "@executor/runtime-quickjs";
1212 import { createExecutionEngine } from "./engine";
13−import { describeTool, searchTools } from "./tool-invoker";
13+import { describeTool, makeExecutorToolInvoker, searchTools } from "./tool-invoker";
1414
1515 const codeExecutor = makeQuickJsExecutor();
1616
@@ -105,6 +105,33 @@ const crmPlugin = definePlugin(() => ({ · 88 unmodified lines
105105 ],
106106 }));
107107
108+const errorPlugin = definePlugin(() => ({
109+ id: "error-test" as const,
110+ storage: () => ({}),
111+ staticSources: () => [
112+ {
113+ id: "records",
114+ kind: "in-memory",
115+ name: "Records",
116+ tools: [
117+ {
118+ name: "queryRows",
119+ description: "Query rows",
120+ inputSchema: EmptyInputSchema,
121+ handler: () =>
122+ Effect.succeed({
123+ data: null,
124+ error: {
125+ message: 'Field with name "DisplayName" does not exist',
126+ code: "invalid_query",
127+ },
128+ }),
129+ },
130+ ],
131+ },
132+ ],
133+}));
134+
108135 const makeSearchExecutor = () =>
109136 createExecutor(
110137 makeTestConfig({ plugins: [githubPlugin(), crmPlugin()] as const }),
@@ -252,6 +279,27 @@ describe("tool discovery", () => { · 141 unmodified lines
252279 expect(String(invalidSearch.result)).toContain("tools.search expects an object");
253280 }),
254281 );
282+
283+ it.effect("converts message-bearing tool error results into execution errors", () =>
284+ Effect.gen(function* () {
285+ const executor = yield* createExecutor(
286+ makeTestConfig({ plugins: [errorPlugin()] as const }),
287+ );
288+ const invoker = makeExecutorToolInvoker(executor, {
289+ invokeOptions: { onElicitation: acceptAll },
290+ });
291+
292+ const error = yield* Effect.flip(
293+ invoker.invoke({ path: "records.queryRows", args: {} }),
294+ );
295+
296+ expect(error).toEqual(
297+ expect.objectContaining({
298+ message: 'Field with name "DisplayName" does not exist',
299+ }),
300+ );
301+ }),
302+ );
255303 });
256304
257305 // ---------------------------------------------------------------------------
packages/core/execution/src/tool-invoker.ts+32−1
@@ -22,6 +22,31 @@ const extractSourceNamespace = (path: string): string => { · 21 unmodified lines
2222 return idx === -1 ? path : path.slice(0, idx);
2323 };
2424
25+const stringifyUnknown = (value: unknown): string => {
26+ try {
27+ return JSON.stringify(value) ?? String(value);
28+ } catch {
29+ return String(value);
30+ }
31+};
32+
33+const hasStringMessage = (value: unknown): value is { readonly message: string } =>
34+ value !== null &&
35+ typeof value === "object" &&
36+ "message" in value &&
37+ typeof value.message === "string";
38+
39+const messageFromErrorLike = (value: unknown): string | undefined => {
40+ if (value instanceof Error || hasStringMessage(value)) {
41+ return value.message;
42+ }
43+ return undefined;
44+};
45+
46+const renderToolErrorMessage = (error: unknown): string =>
47+ messageFromErrorLike(error) ??
48+ (typeof error === "undefined" ? "Tool execution failed" : stringifyUnknown(error));
49+
2550 /**
2651 * Bridges QuickJS `tools.someSource.someOp(args)` calls into
2752 * `executor.tools.invoke(toolId, args)`.
@@ -63,7 +88,13 @@ export const makeExecutorToolInvoker = ( · 35 unmodified lines
6388 (r as { error?: unknown }).error !== null &&
6489 (r as { error?: unknown }).error !== undefined
6590 ) {
66− return yield* Effect.fail((r as { error: unknown }).error);
91+ const error = (r as { error: unknown }).error;
92+ return yield* Effect.fail(
93+ new ExecutionToolError({
94+ message: renderToolErrorMessage(error),
95+ cause: error,
96+ }),
97+ );
6798 }
6899 if (r !== null && typeof r === "object" && "data" in r) {
69100 return (r as { data: unknown }).data;
packages/core/sdk/package.json+2−1
@@ -46,7 +46,8 @@ · 45 unmodified lines
4646 "dependencies": {
4747 "@effect/platform": "catalog:",
4848 "@executor/storage-core": "workspace:*",
49− "effect": "catalog:"
49+ "effect": "catalog:",
50+ "fractional-indexing": "^3.2.0"
5051 },
5152 "devDependencies": {
5253 "@effect/vitest": "catalog:",
packages/core/sdk/src/core-schema.ts+60−0
@@ -148,6 +148,37 @@ export const coreSchema = { · 147 unmodified lines
148148 updated_at: { type: "date", required: true },
149149 },
150150 },
151+ // User-authored overrides for tool permissions. Each row is one rule:
152+ // a glob-ish pattern + an action (approve / require_approval / block).
153+ // Resolution walks the scope stack innermost-first, then `position`
154+ // ascending within each scope; first match wins. Plugin-derived
155+ // annotations from `resolveAnnotations` apply only when no rule
156+ // matches.
157+ //
158+ // Pattern grammar (v1):
159+ // - `*` every tool id (universal)
160+ // - `vercel.dns.create` exact tool id
161+ // - `vercel.dns.*` any tool whose id starts with `vercel.dns.`
162+ // - `vercel.*` plugin-wide
163+ // No `**`, no brace expansion, no leading-`*` prefixes (`*foo`, `*.foo`).
164+ tool_policy: {
165+ fields: {
166+ id: { type: "string", required: true },
167+ scope_id: { type: "string", required: true, index: true },
168+ pattern: { type: "string", required: true },
169+ /** "approve" | "require_approval" | "block". */
170+ action: { type: "string", required: true },
171+ /** Fractional-indexing key (Jira lexorank style). Lower lex order =
172+ * higher precedence. New rules default to a key generated above
173+ * the current minimum. Strings instead of numbers so we can
174+ * always lengthen the key to insert between two adjacent rows
175+ * without precision loss; see `fractional-indexing` in
176+ * `policies.ts`. */
177+ position: { type: "string", required: true, index: true },
178+ created_at: { type: "date", required: true },
179+ updated_at: { type: "date", required: true },
180+ },
181+ },
151182 } as const satisfies DBSchema;
152183
153184 export type CoreSchema = typeof coreSchema;
@@ -176,6 +207,35 @@ export type ConnectionRow = InferDBFieldsOutput< · 22 unmodified lines
176207 > &
177208 Record<string, unknown>;
178209
210+export type ToolPolicyRow = InferDBFieldsOutput<
211+ CoreSchema["tool_policy"]["fields"]
212+> &
213+ Record<string, unknown>;
214+
215+// ---------------------------------------------------------------------------
216+// Tool policy — user-authored override of the default approval behavior.
217+// `action` tells the executor what to do at invoke time and at search /
218+// list time:
219+// - approve : skip the upfront approval prompt, just run.
220+// - require_approval : force an approval prompt even if the plugin's
221+// annotations would have skipped it.
222+// - block : invisible to search / list, hard-fail at invoke
223+// with `ToolBlockedError`.
224+// Mid-invocation elicitations (`mayElicit`) are NOT affected by policies.
225+// ---------------------------------------------------------------------------
226+
227+export type ToolPolicyAction = "approve" | "require_approval" | "block";
228+
229+export const TOOL_POLICY_ACTIONS = [
230+ "approve",
231+ "require_approval",
232+ "block",
233+] as const satisfies readonly ToolPolicyAction[];
234+
235+export const isToolPolicyAction = (value: unknown): value is ToolPolicyAction =>
236+ typeof value === "string" &&
237+ (TOOL_POLICY_ACTIONS as readonly string[]).includes(value);
238+
179239 // ---------------------------------------------------------------------------
180240 // Tool annotations — default-policy metadata the executor consults
181241 // before invocation. Returned by `plugin.resolveAnnotations` (dynamic
packages/core/sdk/src/errors.ts+13−0
@@ -40,6 +40,18 @@ export class NoHandlerError extends Schema.TaggedError<NoHandlerError>()( · 39 unmodified lines
4040 },
4141 ) {}
4242
43+/** Tool invocation was rejected because a workspace `tool_policy` rule
44+ * with `action: "block"` matched. `pattern` is the matched policy
45+ * pattern so callers / agents can render a useful "this is blocked
46+ * by your `vercel.dns.*` rule" message. */
47+export class ToolBlockedError extends Schema.TaggedError<ToolBlockedError>()(
48+ "ToolBlockedError",
49+ {
50+ toolId: ToolId,
51+ pattern: Schema.String,
52+ },
53+) {}
54+
4355 // ---------------------------------------------------------------------------
4456 // Source lifecycle
4557 // ---------------------------------------------------------------------------
@@ -138,6 +150,7 @@ export type ExecutorError = · 92 unmodified lines
138150 | ToolInvocationError
139151 | PluginNotLoadedError
140152 | NoHandlerError
153+ | ToolBlockedError
141154 | SourceNotFoundError
142155 | SourceRemovalNotAllowedError
143156 | SecretNotFoundError
packages/core/sdk/src/executor.ts+276−6
@@ -1,4 +1,5 @@
11 import { Deferred, Effect, FiberRef, Option, Schema } from "effect";
2+import { generateKeyBetween } from "fractional-indexing";
23 import {
34 StorageError,
45 typedAdapter,
@@ -23,12 +24,14 @@ import { · 18 unmodified lines
2324 } from "./connections";
2425 import {
2526 coreSchema,
27+ isToolPolicyAction,
2628 type ConnectionRow,
2729 type CoreSchema,
2830 type DefinitionsInput,
2931 type SourceInput,
3032 type SourceRow,
3133 type ToolAnnotations,
34+ type ToolPolicyRow,
3235 type ToolRow,
3336 } from "./core-schema";
3437 import {
@@ -47,10 +50,21 @@ import { · 12 unmodified lines
4750 PluginNotLoadedError,
4851 SecretOwnedByConnectionError,
4952 SourceRemovalNotAllowedError,
53+ ToolBlockedError,
5054 ToolInvocationError,
5155 ToolNotFoundError,
5256 } from "./errors";
5357 import { ConnectionId, ScopeId, SecretId, ToolId } from "./ids";
58+import {
59+ comparePolicyRow,
60+ isValidPattern,
61+ resolveToolPolicy,
62+ rowToToolPolicy,
63+ type CreateToolPolicyInput,
64+ type PolicyMatch,
65+ type ToolPolicy,
66+ type UpdateToolPolicyInput,
67+} from "./policies";
5468 import type {
5569 AnyPlugin,
5670 Elicit,
@@ -142,6 +156,7 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 85 unmodified lines
142156 ) => Effect.Effect<
143157 unknown,
144158 | ToolNotFoundError
159+ | ToolBlockedError
145160 | PluginNotLoadedError
146161 | NoHandlerError
147162 | ToolInvocationError
@@ -228,6 +243,29 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 80 unmodified lines
228243 readonly providers: () => Effect.Effect<readonly string[]>;
229244 };
230245
246+ readonly policies: {
247+ /** All policies visible across the executor's scope stack, sorted
248+ * by (innermost-scope-first, position ascending) — i.e. the order
249+ * in which they're evaluated by first-match-wins. */
250+ readonly list: () => Effect.Effect<readonly ToolPolicy[], StorageFailure>;
251+ /** Create a new policy. Defaults to the top of the target scope's
252+ * list (highest precedence) when `position` is omitted. */
253+ readonly create: (
254+ input: CreateToolPolicyInput,
255+ ) => Effect.Effect<ToolPolicy, StorageFailure>;
256+ readonly update: (
257+ input: UpdateToolPolicyInput,
258+ ) => Effect.Effect<ToolPolicy, StorageFailure>;
259+ readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;
260+ /** Resolve the effective policy for a tool id by walking the scope-
261+ * stacked policy list with first-match-wins semantics. Returns
262+ * `undefined` when no rule matches (caller falls back to the
263+ * plugin's `resolveAnnotations` output). */
264+ readonly resolve: (
265+ toolId: string,
266+ ) => Effect.Effect<PolicyMatch | undefined, StorageFailure>;
267+ };
268+
231269 readonly close: () => Effect.Effect<void, StorageFailure>;
232270 } & PluginExtensions<TPlugins>;
233271
@@ -1944,11 +1982,37 @@ export const createExecutor = < · 1710 unmodified lines
19441982 for (const row of dynamicDeduped) {
19451983 out.push(rowToTool(row, annotations.get(row.id)));
19461984 }
1947− const result = filter ? out.filter((t) => toolMatchesFilter(t, filter)) : out;
1985+ const filtered = filter
1986+ ? out.filter((t) => toolMatchesFilter(t, filter))
1987+ : out;
1988+
1989+ // Drop tools blocked by user policy unless the caller explicitly
1990+ // asked to see them (the settings UI does, agent surfaces don't).
1991+ // One findMany covers the entire scope stack; resolution per
1992+ // tool is in-memory.
1993+ let result = filtered;
1994+ let blockedCount = 0;
1995+ if (filter?.includeBlocked !== true) {
1996+ const policies = yield* loadAllPolicies();
1997+ if (policies.length > 0) {
1998+ const kept: Tool[] = [];
1999+ for (const tool of filtered) {
2000+ const match = resolveToolPolicy(tool.id, policies, scopeRank);
2001+ if (match?.action === "block") {
2002+ blockedCount++;
2003+ continue;
2004+ }
2005+ kept.push(tool);
2006+ }
2007+ result = kept;
2008+ }
2009+ }
2010+
19482011 yield* Effect.annotateCurrentSpan({
19492012 "executor.tools.static_count": staticTools.size,
19502013 "executor.tools.dynamic_count": dynamicDeduped.length,
19512014 "executor.tools.result_count": result.length,
2015+ "executor.tools.blocked_count": blockedCount,
19522016 });
19532017 return result;
19542018 }).pipe(Effect.withSpan("executor.tools.list"));
@@ -2130,18 +2194,51 @@ export const createExecutor = < · 175 unmodified lines
21302194 });
21312195 };
21322196
2197+ // ------------------------------------------------------------------
2198+ // Tool policies — user-authored overrides of the plugin-derived
2199+ // approval annotations. Resolution walks the scope-stacked policy
2200+ // table with first-match-wins ordering (innermost scope first, then
2201+ // `position` ascending). The result either short-circuits invoke
2202+ // (`block`), forces approval (`require_approval`), skips approval
2203+ // (`approve`), or returns `undefined` so the plugin annotation is
2204+ // used as today.
2205+ // ------------------------------------------------------------------
2206+
2207+ const loadAllPolicies = () =>
2208+ core.findMany({ model: "tool_policy" });
2209+
2210+ const resolveToolPolicyForId = (toolId: string) =>
2211+ Effect.gen(function* () {
2212+ const policies = yield* loadAllPolicies();
2213+ return resolveToolPolicy(toolId, policies, scopeRank);
2214+ });
2215+
21332216 const enforceApproval = (
21342217 annotations: ToolAnnotations | undefined,
21352218 toolId: string,
21362219 args: unknown,
21372220 options: InvokeOptions | undefined,
2221+ policy: PolicyMatch | undefined,
21382222 ) =>
21392223 Effect.gen(function* () {
2140− if (!annotations?.requiresApproval) return;
2224+ // approve → never prompt regardless of plugin annotation.
2225+ if (policy?.action === "approve") return;
2226+
2227+ // require_approval → always prompt. If the plugin already had a
2228+ // description, prefer it; otherwise show the matched pattern so
2229+ // the user can see *why* the prompt fired.
2230+ const policyForcesApproval = policy?.action === "require_approval";
2231+ if (!policyForcesApproval && !annotations?.requiresApproval) return;
2232+
21412233 const handler = resolveElicitationHandler(options);
21422234 const tid = ToolId.make(toolId);
2235+ const message = annotations?.approvalDescription
2236+ ? annotations.approvalDescription
2237+ : policyForcesApproval && policy
2238+ ? `Approve ${toolId}? (matched policy: ${policy.pattern})`
2239+ : `Approve ${toolId}?`;
21432240 const request = new FormElicitation({
2144− message: annotations.approvalDescription ?? `Approve ${toolId}?`,
2241+ message,
21452242 requestedSchema: {},
21462243 });
21472244 const response = yield* handler({ toolId: tid, args, request });
@@ -2174,6 +2271,19 @@ export const createExecutor = < · 26 unmodified lines
21742271 ),
21752272 );
21762273
2274+ // Resolve the user-authored policy first. A `block` rule
2275+ // short-circuits both the static and dynamic paths before any
2276+ // plugin code runs.
2277+ const policy = yield* resolveToolPolicyForId(toolId).pipe(
2278+ Effect.withSpan("executor.tool.resolve_policy"),
2279+ );
2280+ if (policy?.action === "block") {
2281+ return yield* new ToolBlockedError({
2282+ toolId: ToolId.make(toolId),
2283+ pattern: policy.pattern,
2284+ });
2285+ }
2286+
21772287 // Static path — O(1) map lookup, no DB hit.
21782288 const staticEntry = staticTools.get(toolId);
21792289 if (staticEntry) {
@@ -2188,6 +2298,7 @@ export const createExecutor = < · 8 unmodified lines
21882298 toolId,
21892299 args,
21902300 options,
2301+ policy,
21912302 ).pipe(Effect.withSpan("executor.tool.enforce_approval"));
21922303 return yield* wrapInvocationError(
21932304 staticEntry.tool.handler({
@@ -2237,9 +2348,11 @@ export const createExecutor = < · 43 unmodified lines
22372348 // has a resolver. Cheap because the plugin typically already
22382349 // needs to load its enrichment data to invoke the tool —
22392350 // implementations should structure their resolver + invokeTool
2240− // around a single storage read.
2351+ // around a single storage read. Skipped entirely when the user
2352+ // policy is `approve` — the prompt is going to be skipped no
2353+ // matter what the plugin says, so don't pay for the lookup.
22412354 let annotations: ToolAnnotations | undefined;
2242− if (runtime.plugin.resolveAnnotations) {
2355+ if (policy?.action !== "approve" && runtime.plugin.resolveAnnotations) {
22432356 const map = yield* runtime.plugin
22442357 .resolveAnnotations({
22452358 ctx: runtime.ctx,
@@ -2249,7 +2362,7 @@ export const createExecutor = < · 3 unmodified lines
22492362 .pipe(Effect.withSpan("executor.tool.resolve_annotations"));
22502363 annotations = map[toolId];
22512364 }
2252− yield* enforceApproval(annotations, toolId, args, options).pipe(
2365+ yield* enforceApproval(annotations, toolId, args, options, policy).pipe(
22532366 Effect.withSpan("executor.tool.enforce_approval"),
22542367 );
22552368
@@ -2376,6 +2489,156 @@ export const createExecutor = < · 120 unmodified lines
23762489 return "missing";
23772490 });
23782491
2492+ // ------------------------------------------------------------------
2493+ // Policies — CRUD surface backed by the `tool_policy` core table.
2494+ // The cloud settings UI is one consumer; plugins call the same API
2495+ // when they programmatically manage policies.
2496+ //
2497+ // `list` orders rows the same way resolution does — innermost scope
2498+ // first, then position ascending — so the UI can render the
2499+ // evaluation order without re-sorting.
2500+ // ------------------------------------------------------------------
2501+ const policiesList = () =>
2502+ Effect.gen(function* () {
2503+ const rows = yield* loadAllPolicies();
2504+ const sorted = [...rows].sort((a, b) => {
2505+ const sa = scopeRank(a);
2506+ const sb = scopeRank(b);
2507+ if (sa !== sb) return sa - sb;
2508+ return comparePolicyRow(a, b);
2509+ });
2510+ return sorted.map((row) => rowToToolPolicy(row));
2511+ }).pipe(Effect.withSpan("executor.policies.list"));
2512+
2513+ const policiesCreate = (input: CreateToolPolicyInput) =>
2514+ Effect.gen(function* () {
2515+ if (!isValidPattern(input.pattern)) {
2516+ return yield* new StorageError({
2517+ message:
2518+ `Invalid tool policy pattern "${input.pattern}". ` +
2519+ `Patterns must be "*" (every tool), an exact tool id ("a.b.c"), ` +
2520+ `or a trailing wildcard ("a.b.*"). Leading "*" prefixes ` +
2521+ `("*foo", "*.foo") and "**" are not supported.`,
2522+ cause: undefined,
2523+ });
2524+ }
2525+ if (!isToolPolicyAction(input.action)) {
2526+ return yield* new StorageError({
2527+ message:
2528+ `Invalid tool policy action "${String(input.action)}". ` +
2529+ `Expected "approve" | "require_approval" | "block".`,
2530+ cause: undefined,
2531+ });
2532+ }
2533+
2534+ // Default position: a fractional-indexing key above the
2535+ // current minimum. Lets newly-created rules win against
2536+ // existing ones, which matches the v1 design — users typically
2537+ // add a rule to override behavior they're seeing right now,
2538+ // not as a background fallback.
2539+ let position = input.position;
2540+ if (position === undefined) {
2541+ const existing = yield* core.findMany({
2542+ model: "tool_policy",
2543+ where: [{ field: "scope_id", value: input.scope }],
2544+ });
2545+ let min: string | null = null;
2546+ for (const row of existing) {
2547+ const p = row.position as string;
2548+ if (min === null || p < min) min = p;
2549+ }
2550+ position = generateKeyBetween(null, min);
2551+ }
2552+
2553+ const id = `pol_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
2554+ const now = new Date();
2555+ yield* core.create({
2556+ model: "tool_policy",
2557+ data: {
2558+ id,
2559+ scope_id: input.scope,
2560+ pattern: input.pattern,
2561+ action: input.action,
2562+ position,
2563+ created_at: now,
2564+ updated_at: now,
2565+ },
2566+ forceAllowId: true,
2567+ });
2568+ return rowToToolPolicy({
2569+ id,
2570+ scope_id: input.scope,
2571+ pattern: input.pattern,
2572+ action: input.action,
2573+ position,
2574+ created_at: now,
2575+ updated_at: now,
2576+ } as ToolPolicyRow);
2577+ }).pipe(Effect.withSpan("executor.policies.create"));
2578+
2579+ const policiesUpdate = (input: UpdateToolPolicyInput) =>
2580+ Effect.gen(function* () {
2581+ if (input.pattern !== undefined && !isValidPattern(input.pattern)) {
2582+ return yield* new StorageError({
2583+ message: `Invalid tool policy pattern "${input.pattern}".`,
2584+ cause: undefined,
2585+ });
2586+ }
2587+ if (input.action !== undefined && !isToolPolicyAction(input.action)) {
2588+ return yield* new StorageError({
2589+ message: `Invalid tool policy action "${String(input.action)}".`,
2590+ cause: undefined,
2591+ });
2592+ }
2593+
2594+ const rows = yield* core.findMany({
2595+ model: "tool_policy",
2596+ where: [{ field: "id", value: input.id }],
2597+ });
2598+ const row = findInnermost(rows);
2599+ if (!row) {
2600+ return yield* new StorageError({
2601+ message: `Tool policy "${input.id}" not found.`,
2602+ cause: undefined,
2603+ });
2604+ }
2605+
2606+ const updated: ToolPolicyRow = {
2607+ ...row,
2608+ pattern: input.pattern ?? row.pattern,
2609+ action: input.action ?? row.action,
2610+ position: input.position ?? row.position,
2611+ updated_at: new Date(),
2612+ };
2613+ yield* core.update({
2614+ model: "tool_policy",
2615+ where: [
2616+ { field: "id", value: input.id },
2617+ { field: "scope_id", value: row.scope_id as string },
2618+ ],
2619+ update: {
2620+ pattern: updated.pattern as string,
2621+ action: updated.action as string,
2622+ position: updated.position as string,
2623+ updated_at: updated.updated_at as Date,
2624+ },
2625+ });
2626+ return rowToToolPolicy(updated);
2627+ }).pipe(Effect.withSpan("executor.policies.update"));
2628+
2629+ const policiesRemove = (id: string) =>
2630+ core
2631+ .deleteMany({
2632+ model: "tool_policy",
2633+ where: [{ field: "id", value: id }],
2634+ })
2635+ .pipe(Effect.asVoid, Effect.withSpan("executor.policies.remove"));
2636+
2637+ const policiesResolve = (toolId: string) =>
2638+ resolveToolPolicyForId(toolId).pipe(
2639+ Effect.withSpan("executor.policies.resolve"),
2640+ );
2641+
23792642 const close = () =>
23802643 Effect.gen(function* () {
23812644 for (const runtime of runtimes.values()) {
@@ -2430,6 +2693,13 @@ export const createExecutor = < · 48 unmodified lines
24302693 Array.from(connectionProviders.keys()) as readonly string[],
24312694 ),
24322695 },
2696+ policies: {
2697+ list: policiesList,
2698+ create: policiesCreate,
2699+ update: policiesUpdate,
2700+ remove: policiesRemove,
2701+ resolve: policiesResolve,
2702+ },
24332703 close,
24342704 };
24352705
packages/core/sdk/src/index.ts+22−0
@@ -32,6 +32,7 @@ export { Scope } from "./scope"; · 31 unmodified lines
3232 export {
3333 ToolNotFoundError,
3434 ToolInvocationError,
35+ ToolBlockedError,
3536 NoHandlerError,
3637 SourceNotFoundError,
3738 SourceRemovalNotAllowedError,
@@ -58,6 +59,8 @@ export { · 20 unmodified lines
5859 // Core schema
5960 export {
6061 coreSchema,
62+ isToolPolicyAction,
63+ TOOL_POLICY_ACTIONS,
6164 type CoreSchema,
6265 type SourceInput,
6366 type SourceInputTool,
@@ -66,10 +69,29 @@ export { · 2 unmodified lines
6669 type DefinitionRow,
6770 type SecretRow,
6871 type ConnectionRow,
72+ type ToolPolicyRow,
73+ type ToolPolicyAction,
6974 type DefinitionsInput,
7075 type ToolAnnotations,
7176 } from "./core-schema";
7277
78+// Tool policies
79+export {
80+ matchPattern,
81+ isValidPattern,
82+ resolveToolPolicy,
83+ resolveEffectivePolicy,
84+ effectivePolicyFromSorted,
85+ rowToToolPolicy,
86+ ToolPolicyActionSchema,
87+ type ToolPolicy,
88+ type CreateToolPolicyInput,
89+ type UpdateToolPolicyInput,
90+ type PolicyMatch,
91+ type EffectivePolicy,
92+ type PolicySource,
93+} from "./policies";
94+
7395 // Secrets
7496 export {
7597 SecretRef,
packages/core/sdk/src/policies.test.ts+614−0
@@ -0,0 +1,614 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+import { generateKeyBetween } from "fractional-indexing";
4+
5+import type { ToolPolicyRow } from "./core-schema";
6+import { PolicyId } from "./ids";
7+import { createExecutor } from "./executor";
8+import {
9+ ElicitationResponse,
10+ type ElicitationHandler,
11+} from "./elicitation";
12+import {
13+ effectivePolicyFromSorted,
14+ isValidPattern,
15+ matchPattern,
16+ resolveToolPolicy,
17+} from "./policies";
18+import { definePlugin, defineSchema } from "./plugin";
19+import { makeTestConfig } from "./testing";
20+
21+// ---------------------------------------------------------------------------
22+// Pure unit tests — pattern matcher + resolution. No executor required.
23+// ---------------------------------------------------------------------------
24+
25+describe("matchPattern", () => {
26+ it("matches exact tool ids", () => {
27+ expect(matchPattern("vercel.dns.create", "vercel.dns.create")).toBe(true);
28+ expect(matchPattern("vercel.dns.create", "vercel.dns.delete")).toBe(false);
29+ });
30+
31+ it("matches subtree wildcards", () => {
32+ expect(matchPattern("vercel.dns.*", "vercel.dns.create")).toBe(true);
33+ expect(matchPattern("vercel.dns.*", "vercel.dns.delete")).toBe(true);
34+ expect(matchPattern("vercel.dns.*", "vercel.dns.zones.list")).toBe(true);
35+ expect(matchPattern("vercel.dns.*", "vercel.dnstool")).toBe(false);
36+ expect(matchPattern("vercel.dns.*", "vercel.deploy")).toBe(false);
37+ });
38+
39+ it("matches plugin-wide wildcards", () => {
40+ expect(matchPattern("vercel.*", "vercel.dns.create")).toBe(true);
41+ expect(matchPattern("vercel.*", "vercel.deploy")).toBe(true);
42+ expect(matchPattern("vercel.*", "vercelapp.deploy")).toBe(false);
43+ });
44+
45+ it("does not collapse the dot boundary", () => {
46+ // The tool id must continue with a dot after the wildcard prefix —
47+ // otherwise `vercel.dns.*` would silently capture `vercel.dnstool`.
48+ expect(matchPattern("vercel.dns.*", "vercel.dnstool")).toBe(false);
49+ });
50+
51+ it("matches every tool id when the pattern is bare *", () => {
52+ expect(matchPattern("*", "vercel.dns.create")).toBe(true);
53+ expect(matchPattern("*", "github.repos.list")).toBe(true);
54+ expect(matchPattern("*", "x")).toBe(true);
55+ });
56+});
57+
58+describe("isValidPattern", () => {
59+ it("accepts exact ids and trailing wildcards", () => {
60+ expect(isValidPattern("a")).toBe(true);
61+ expect(isValidPattern("a.b")).toBe(true);
62+ expect(isValidPattern("a.b.c")).toBe(true);
63+ expect(isValidPattern("a.*")).toBe(true);
64+ expect(isValidPattern("a.b.*")).toBe(true);
65+ });
66+
67+ it("accepts the universal pattern", () => {
68+ expect(isValidPattern("*")).toBe(true);
69+ });
70+
71+ it("rejects malformed shapes", () => {
72+ expect(isValidPattern("")).toBe(false);
73+ expect(isValidPattern(".a")).toBe(false);
74+ expect(isValidPattern("a.")).toBe(false);
75+ expect(isValidPattern("a..b")).toBe(false);
76+ expect(isValidPattern("*.a")).toBe(false);
77+ expect(isValidPattern("a.*.b")).toBe(false);
78+ expect(isValidPattern("a*")).toBe(false);
79+ expect(isValidPattern("a.b*")).toBe(false);
80+ });
81+});
82+
83+describe("resolveToolPolicy", () => {
84+ const ROW = (
85+ id: string,
86+ pattern: string,
87+ action: "approve" | "require_approval" | "block",
88+ position: string,
89+ scope_id = "test-scope",
90+ ): ToolPolicyRow =>
91+ ({
92+ id,
93+ scope_id,
94+ pattern,
95+ action,
96+ position,
97+ created_at: new Date(0),
98+ updated_at: new Date(0),
99+ }) as ToolPolicyRow;
100+
101+ const flatRank = () => 0; // single-scope tests
102+
103+ it("returns undefined when no policies match", () => {
104+ const result = resolveToolPolicy(
105+ "vercel.dns.create",
106+ [ROW("a", "github.*", "block", "a0")],
107+ flatRank,
108+ );
109+ expect(result).toBeUndefined();
110+ });
111+
112+ it("returns the first matching rule by position", () => {
113+ // Lowest position = highest precedence. Specific exception listed
114+ // above the broad rule wins for the specific tool id.
115+ const result = resolveToolPolicy(
116+ "vercel.dns.create",
117+ [
118+ ROW("a", "vercel.dns.create", "approve", "a0"),
119+ ROW("b", "vercel.dns.*", "require_approval", "a1"),
120+ ],
121+ flatRank,
122+ );
123+ expect(result?.action).toBe("approve");
124+ expect(result?.pattern).toBe("vercel.dns.create");
125+ expect(result?.policyId).toBe("a");
126+ });
127+
128+ it("falls through to the broader rule when the specific rule is below it", () => {
129+ // First-match-wins is purely positional — if the user puts the
130+ // broad rule above the specific exception, the broad rule wins.
131+ // The system trusts the user's ordering.
132+ const result = resolveToolPolicy(
133+ "vercel.dns.create",
134+ [
135+ ROW("b", "vercel.dns.*", "require_approval", "a0"),
136+ ROW("a", "vercel.dns.create", "approve", "a1"),
137+ ],
138+ flatRank,
139+ );
140+ expect(result?.action).toBe("require_approval");
141+ expect(result?.pattern).toBe("vercel.dns.*");
142+ });
143+
144+ it("walks innermost scope first", () => {
145+ // Scope rank comes from the executor's scope stack: 0 = innermost,
146+ // 1 = next, etc. An inner-scope rule wins even when its position
147+ // would otherwise put it after the outer rule.
148+ const policies = [
149+ ROW("outer", "vercel.*", "block", "a0", "org"),
150+ ROW("inner", "vercel.dns.create", "approve", "a0", "user"),
151+ ];
152+ const rank = (row: { scope_id: unknown }) =>
153+ row.scope_id === "user" ? 0 : 1;
154+ const result = resolveToolPolicy(
155+ "vercel.dns.create",
156+ policies,
157+ rank,
158+ );
159+ expect(result?.action).toBe("approve");
160+ expect(result?.policyId).toBe("inner");
161+ });
162+
163+ it("tiebreaks identical positions by id so order is deterministic", () => {
164+ // Two rows with the same `position` (racing inserts that picked the
165+ // same fractional-indexing key from independent clients) must sort
166+ // deterministically — otherwise the rendered order flips on every
167+ // refetch and reorder math sees colliding neighbor keys.
168+ const a = resolveToolPolicy(
169+ "vercel.dns.create",
170+ [
171+ ROW("z", "vercel.dns.*", "block", "a0"),
172+ ROW("a", "vercel.dns.*", "approve", "a0"),
173+ ],
174+ flatRank,
175+ );
176+ const b = resolveToolPolicy(
177+ "vercel.dns.create",
178+ [
179+ ROW("a", "vercel.dns.*", "approve", "a0"),
180+ ROW("z", "vercel.dns.*", "block", "a0"),
181+ ],
182+ flatRank,
183+ );
184+ // Same input rows in different order, same winner — id "a" sorts
185+ // before "z" so it wins regardless of array order.
186+ expect(a?.policyId).toBe("a");
187+ expect(b?.policyId).toBe("a");
188+ });
189+});
190+
191+describe("effectivePolicyFromSorted", () => {
192+ const POL = (
193+ id: string,
194+ pattern: string,
195+ action: "approve" | "require_approval" | "block",
196+ ) => ({ id: PolicyId.make(id), pattern, action });
197+
198+ it("returns user policy when one matches", () => {
199+ const result = effectivePolicyFromSorted(
200+ "vercel.dns.create",
201+ [POL("a", "vercel.dns.*", "block")],
202+ true,
203+ );
204+ expect(result).toEqual({
205+ action: "block",
206+ source: "user",
207+ pattern: "vercel.dns.*",
208+ policyId: PolicyId.make("a"),
209+ });
210+ });
211+
212+ it("falls back to plugin default require_approval", () => {
213+ const result = effectivePolicyFromSorted("vercel.dns.create", [], true);
214+ expect(result).toEqual({
215+ action: "require_approval",
216+ source: "plugin-default",
217+ });
218+ });
219+
220+ it("falls back to plugin default approve when annotation is false/undefined", () => {
221+ expect(
222+ effectivePolicyFromSorted("vercel.dns.create", [], false),
223+ ).toEqual({ action: "approve", source: "plugin-default" });
224+ expect(effectivePolicyFromSorted("vercel.dns.create", [])).toEqual({
225+ action: "approve",
226+ source: "plugin-default",
227+ });
228+ });
229+
230+ it("user policy wins over plugin default", () => {
231+ // Plugin default would be require_approval; user explicitly approves.
232+ const result = effectivePolicyFromSorted(
233+ "vercel.dns.create",
234+ [POL("a", "vercel.dns.create", "approve")],
235+ true,
236+ );
237+ expect(result.action).toBe("approve");
238+ expect(result.source).toBe("user");
239+ });
240+});
241+
242+// ---------------------------------------------------------------------------
243+// Executor integration — exercises invoke + list + CRUD with a real
244+// in-memory adapter and a tiny test plugin. Mirrors the design choices:
245+// - block → invisible to list; ToolBlockedError at invoke
246+// - approve → invoke skips approval prompt
247+// - require_approval → invoke fires elicitation, declined => fails
248+// - undefined → falls through to plugin annotation
249+// ---------------------------------------------------------------------------
250+
251+const recordingHandler = (calls: { count: number }): ElicitationHandler =>
252+ (() => {
253+ calls.count++;
254+ return Effect.succeed(new ElicitationResponse({ action: "accept" }));
255+ }) as ElicitationHandler;
256+
257+const decliningHandler: ElicitationHandler = () =>
258+ Effect.succeed(new ElicitationResponse({ action: "decline" }));
259+
260+const policyTestSchema = defineSchema({
261+ ptest_marker: {
262+ fields: {
263+ id: { type: "string", required: true },
264+ },
265+ },
266+});
267+
268+const policyTestPlugin = definePlugin(() => ({
269+ id: "ptest" as const,
270+ schema: policyTestSchema,
271+ storage: () => ({}),
272+ resolveAnnotations: ({ toolRows }) => {
273+ const out: Record<string, { requiresApproval?: boolean }> = {};
274+ for (const row of toolRows) {
275+ // Make tools whose name contains "delete" require approval by
276+ // default — mirrors openapi's HTTP-method heuristic in spirit.
277+ out[row.id as string] = {
278+ requiresApproval: (row.name as string).toLowerCase().includes("delete"),
279+ };
280+ }
281+ return Effect.succeed(out);
282+ },
283+ extension: (ctx) => ({
284+ seed: () =>
285+ ctx.transaction(
286+ Effect.gen(function* () {
287+ yield* ctx.core.sources.register({
288+ id: "vercel",
289+ scope: "test-scope",
290+ kind: "test",
291+ name: "Vercel",
292+ tools: [
293+ { name: "deploy", description: "deploy" },
294+ { name: "delete", description: "delete a deployment" },
295+ ],
296+ });
297+ yield* ctx.core.sources.register({
298+ id: "github",
299+ scope: "test-scope",
300+ kind: "test",
301+ name: "GitHub",
302+ tools: [{ name: "list", description: "list repos" }],
303+ });
304+ }),
305+ ),
306+ }),
307+ invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.id }),
308+}));
309+
310+const setupExecutor = () =>
311+ Effect.gen(function* () {
312+ const config = makeTestConfig({ plugins: [policyTestPlugin()] as const });
313+ const executor = yield* createExecutor(config);
314+ yield* executor.ptest.seed();
315+ return executor;
316+ });
317+
318+describe("executor.policies", () => {
319+ it.effect("list is empty when no rules exist", () =>
320+ Effect.gen(function* () {
321+ const executor = yield* setupExecutor();
322+ const rules = yield* executor.policies.list();
323+ expect(rules).toEqual([]);
324+ }),
325+ );
326+
327+ it.effect("create defaults new rules to the top of the list", () =>
328+ Effect.gen(function* () {
329+ const executor = yield* setupExecutor();
330+ const first = yield* executor.policies.create({
331+ scope: "test-scope",
332+ pattern: "vercel.*",
333+ action: "require_approval",
334+ });
335+ const second = yield* executor.policies.create({
336+ scope: "test-scope",
337+ pattern: "vercel.delete",
338+ action: "block",
339+ });
340+ // second was created later but should sit above first (lower lex
341+ // order on the fractional-indexing key).
342+ expect(second.position < first.position).toBe(true);
343+
344+ const rules = yield* executor.policies.list();
345+ expect(rules.map((r) => r.pattern)).toEqual([
346+ "vercel.delete",
347+ "vercel.*",
348+ ]);
349+ }),
350+ );
351+
352+ it.effect("rejects malformed patterns", () =>
353+ Effect.gen(function* () {
354+ const executor = yield* setupExecutor();
355+ const result = yield* Effect.either(
356+ executor.policies.create({
357+ scope: "test-scope",
358+ pattern: "vercel..bad",
359+ action: "block",
360+ }),
361+ );
362+ expect(result._tag).toBe("Left");
363+ }),
364+ );
365+
366+ it.effect("update mutates the row in place", () =>
367+ Effect.gen(function* () {
368+ const executor = yield* setupExecutor();
369+ const created = yield* executor.policies.create({
370+ scope: "test-scope",
371+ pattern: "vercel.*",
372+ action: "require_approval",
373+ });
374+ yield* executor.policies.update({
375+ id: created.id,
376+ action: "block",
377+ });
378+ const rules = yield* executor.policies.list();
379+ expect(rules[0]?.action).toBe("block");
380+ }),
381+ );
382+
383+ it.effect("update without position preserves the existing position", () =>
384+ Effect.gen(function* () {
385+ const executor = yield* setupExecutor();
386+ const created = yield* executor.policies.create({
387+ scope: "test-scope",
388+ pattern: "vercel.*",
389+ action: "require_approval",
390+ });
391+ yield* executor.policies.update({
392+ id: created.id,
393+ action: "block",
394+ });
395+ const rules = yield* executor.policies.list();
396+ expect(rules[0]?.position).toBe(created.position);
397+ }),
398+ );
399+
400+ it.effect("update with a new position reorders the list", () =>
401+ Effect.gen(function* () {
402+ const executor = yield* setupExecutor();
403+ const a = yield* executor.policies.create({
404+ scope: "test-scope",
405+ pattern: "a.*",
406+ action: "approve",
407+ });
408+ const b = yield* executor.policies.create({
409+ scope: "test-scope",
410+ pattern: "b.*",
411+ action: "approve",
412+ });
413+ // After two creates: b above a (newer = higher precedence).
414+ const before = yield* executor.policies.list();
415+ expect(before.map((r) => r.pattern)).toEqual(["b.*", "a.*"]);
416+
417+ // Move `a` above `b` by setting a position lex-less-than b's.
418+ // generateKeyBetween(null, b.position) is what the UI would do.
419+ yield* executor.policies.update({
420+ id: a.id,
421+ position: generateKeyBetween(null, b.position),
422+ });
423+ const after = yield* executor.policies.list();
424+ expect(after.map((r) => r.pattern)).toEqual(["a.*", "b.*"]);
425+ }),
426+ );
427+
428+ it.effect(
429+ "consecutive creates produce strictly increasing-precedence keys",
430+ () =>
431+ Effect.gen(function* () {
432+ const executor = yield* setupExecutor();
433+ const created: string[] = [];
434+ for (const pattern of ["a.*", "b.*", "c.*", "d.*"]) {
435+ const row = yield* executor.policies.create({
436+ scope: "test-scope",
437+ pattern,
438+ action: "approve",
439+ });
440+ created.push(row.position);
441+ }
442+ // Each new key sorts strictly above the previous (lower lex
443+ // order = higher precedence). No collisions.
444+ for (let i = 1; i < created.length; i++) {
445+ expect(created[i]! < created[i - 1]!).toBe(true);
446+ }
447+ // List order matches insertion-reverse.
448+ const rules = yield* executor.policies.list();
449+ expect(rules.map((r) => r.pattern)).toEqual([
450+ "d.*",
451+ "c.*",
452+ "b.*",
453+ "a.*",
454+ ]);
455+ }),
456+ );
457+
458+ it.effect("remove deletes the row", () =>
459+ Effect.gen(function* () {
460+ const executor = yield* setupExecutor();
461+ const created = yield* executor.policies.create({
462+ scope: "test-scope",
463+ pattern: "vercel.*",
464+ action: "block",
465+ });
466+ yield* executor.policies.remove(created.id);
467+ const rules = yield* executor.policies.list();
468+ expect(rules).toEqual([]);
469+ }),
470+ );
471+
472+ it.effect("resolve returns the matching rule with provenance", () =>
473+ Effect.gen(function* () {
474+ const executor = yield* setupExecutor();
475+ yield* executor.policies.create({
476+ scope: "test-scope",
477+ pattern: "vercel.*",
478+ action: "block",
479+ });
480+ const result = yield* executor.policies.resolve("vercel.deploy");
481+ expect(result?.action).toBe("block");
482+ expect(result?.pattern).toBe("vercel.*");
483+ }),
484+ );
485+});
486+
487+describe("blocked tools", () => {
488+ it.effect("are filtered from tools.list by default", () =>
489+ Effect.gen(function* () {
490+ const executor = yield* setupExecutor();
491+ yield* executor.policies.create({
492+ scope: "test-scope",
493+ pattern: "vercel.delete",
494+ action: "block",
495+ });
496+ const tools = yield* executor.tools.list();
497+ const ids = tools.map((t) => t.id).sort();
498+ expect(ids).toEqual(["github.list", "vercel.deploy"]);
499+ }),
500+ );
501+
502+ it.effect("are visible when includeBlocked is true", () =>
503+ Effect.gen(function* () {
504+ const executor = yield* setupExecutor();
505+ yield* executor.policies.create({
506+ scope: "test-scope",
507+ pattern: "vercel.delete",
508+ action: "block",
509+ });
510+ const tools = yield* executor.tools.list({ includeBlocked: true });
511+ const ids = tools.map((t) => t.id).sort();
512+ expect(ids).toEqual(["github.list", "vercel.delete", "vercel.deploy"]);
513+ }),
514+ );
515+
516+ it.effect("invoke fails with ToolBlockedError carrying the matched pattern", () =>
517+ Effect.gen(function* () {
518+ const executor = yield* setupExecutor();
519+ yield* executor.policies.create({
520+ scope: "test-scope",
521+ pattern: "vercel.*",
522+ action: "block",
523+ });
524+ const result = yield* Effect.either(
525+ executor.tools.invoke("vercel.delete", {}),
526+ );
527+ expect(result._tag).toBe("Left");
528+ if (result._tag === "Left") {
529+ expect((result.left as { _tag?: string })._tag).toBe(
530+ "ToolBlockedError",
531+ );
532+ expect((result.left as { pattern?: string }).pattern).toBe("vercel.*");
533+ }
534+ }),
535+ );
536+});
537+
538+describe("approve / require_approval interaction with annotations", () => {
539+ it.effect("approve skips the elicitation prompt even when plugin requires approval", () =>
540+ Effect.gen(function* () {
541+ const executor = yield* setupExecutor();
542+ yield* executor.policies.create({
543+ scope: "test-scope",
544+ pattern: "vercel.delete",
545+ action: "approve",
546+ });
547+ const calls = { count: 0 };
548+ const result = yield* executor.tools.invoke("vercel.delete", {}, {
549+ onElicitation: recordingHandler(calls),
550+ });
551+ expect(calls.count).toBe(0);
552+ expect(result).toEqual({ ran: "vercel.delete" });
553+ }),
554+ );
555+
556+ it.effect("require_approval forces the prompt for tools the plugin would auto-approve", () =>
557+ Effect.gen(function* () {
558+ const executor = yield* setupExecutor();
559+ yield* executor.policies.create({
560+ scope: "test-scope",
561+ pattern: "vercel.deploy",
562+ action: "require_approval",
563+ });
564+ const calls = { count: 0 };
565+ yield* executor.tools.invoke("vercel.deploy", {}, {
566+ onElicitation: recordingHandler(calls),
567+ });
568+ expect(calls.count).toBe(1);
569+ }),
570+ );
571+
572+ it.effect("require_approval surfaces ElicitationDeclined when user declines", () =>
573+ Effect.gen(function* () {
574+ const executor = yield* setupExecutor();
575+ yield* executor.policies.create({
576+ scope: "test-scope",
577+ pattern: "vercel.deploy",
578+ action: "require_approval",
579+ });
580+ const result = yield* Effect.either(
581+ executor.tools.invoke("vercel.deploy", {}, {
582+ onElicitation: decliningHandler,
583+ }),
584+ );
585+ expect(result._tag).toBe("Left");
586+ if (result._tag === "Left") {
587+ expect((result.left as { _tag?: string })._tag).toBe(
588+ "ElicitationDeclinedError",
589+ );
590+ }
591+ }),
592+ );
593+
594+ it.effect("absence of policy falls through to plugin annotation", () =>
595+ Effect.gen(function* () {
596+ const executor = yield* setupExecutor();
597+ // No policy for vercel.delete — the plugin's resolveAnnotations
598+ // marks any tool whose name contains "delete" as requiring
599+ // approval, so the prompt should fire.
600+ const calls = { count: 0 };
601+ yield* executor.tools.invoke("vercel.delete", {}, {
602+ onElicitation: recordingHandler(calls),
603+ });
604+ expect(calls.count).toBe(1);
605+
606+ // vercel.deploy has no plugin-required approval and no policy,
607+ // so no prompt.
608+ yield* executor.tools.invoke("vercel.deploy", {}, {
609+ onElicitation: recordingHandler(calls),
610+ });
611+ expect(calls.count).toBe(1);
612+ }),
613+ );
614+});
packages/core/sdk/src/policies.ts+252−0
@@ -0,0 +1,252 @@
1+// ---------------------------------------------------------------------------
2+// Tool policies — pattern matcher + policy resolution. Pure functions; the
3+// executor stitches them into `tools.list`, `tools.invoke`, and the public
4+// `executor.policies` CRUD surface. Plugins consume the same surface.
5+// ---------------------------------------------------------------------------
6+
7+import { Schema } from "effect";
8+
9+import type { ToolPolicyAction, ToolPolicyRow } from "./core-schema";
10+import { PolicyId, ScopeId } from "./ids";
11+
12+// ---------------------------------------------------------------------------
13+// Public projection — what callers see when they list policies. Strips the
14+// raw `scope_id` to a readable `scopeId`, hides `created_at` typing
15+// inconsistencies between adapters, and re-tags `id` as a `PolicyId`.
16+// ---------------------------------------------------------------------------
17+
18+export interface ToolPolicy {
19+ readonly id: PolicyId;
20+ readonly scopeId: ScopeId;
21+ readonly pattern: string;
22+ readonly action: ToolPolicyAction;
23+ /** Fractional-indexing key. Lower lex order = higher precedence.
24+ * Use `generateKeyBetween(a, b)` from the `fractional-indexing`
25+ * package to produce a key that sits between two existing rows. */
26+ readonly position: string;
27+ readonly createdAt: Date;
28+ readonly updatedAt: Date;
29+}
30+
31+export interface CreateToolPolicyInput {
32+ readonly scope: string;
33+ readonly pattern: string;
34+ readonly action: ToolPolicyAction;
35+ /** Optional explicit position. Defaults to a key above the current
36+ * minimum (top of the scope's list; highest precedence). */
37+ readonly position?: string;
38+}
39+
40+export interface UpdateToolPolicyInput {
41+ readonly id: string;
42+ readonly pattern?: string;
43+ readonly action?: ToolPolicyAction;
44+ readonly position?: string;
45+}
46+
47+// ---------------------------------------------------------------------------
48+// Match result — what `resolveToolPolicy` returns when a rule fires. Carries
49+// the matched pattern so error messages and approval prompts can show the
50+// user *which* rule produced the gate ("matched policy: vercel.dns.*").
51+// ---------------------------------------------------------------------------
52+
53+export interface PolicyMatch {
54+ readonly action: ToolPolicyAction;
55+ readonly pattern: string;
56+ readonly policyId: string;
57+}
58+
59+// ---------------------------------------------------------------------------
60+// Effective policy — the single answer to "what happens when this tool is
61+// invoked?". Combines the user policy layer with the plugin's default
62+// `requiresApproval` annotation. Callers (UI, agents, telemetry) shouldn't
63+// need to know the layering — they ask once and render one thing.
64+//
65+// `source` distinguishes user-authored rules from plugin-derived defaults
66+// purely for display ("Matched: vercel.*" vs "Plugin default"). The
67+// `action` is what actually drives behavior at invoke time.
68+// ---------------------------------------------------------------------------
69+
70+export type PolicySource = "user" | "plugin-default";
71+
72+export interface EffectivePolicy {
73+ readonly action: ToolPolicyAction;
74+ readonly source: PolicySource;
75+ /** Matched pattern; populated only when `source === "user"`. */
76+ readonly pattern?: string;
77+ /** Policy row id; populated only when `source === "user"`. */
78+ readonly policyId?: string;
79+}
80+
81+// ---------------------------------------------------------------------------
82+// Pattern matching. v1 grammar:
83+// - universal: `*` matches every tool id
84+// - exact: `vercel.dns.create` matches only that id
85+// - subtree: `vercel.dns.*` matches anything starting with `vercel.dns.`
86+// - plugin-wide: `vercel.*` matches anything starting with `vercel.`
87+// `*` is only meaningful as a complete trailing segment (or as the
88+// entire pattern). Patterns without a wildcard are exact-id matches.
89+// ---------------------------------------------------------------------------
90+
91+export const matchPattern = (pattern: string, toolId: string): boolean => {
92+ if (pattern === "*") return true;
93+ if (pattern === toolId) return true;
94+ if (pattern.endsWith(".*")) {
95+ const prefix = pattern.slice(0, -2);
96+ if (prefix.length === 0) return false;
97+ return toolId === prefix || toolId.startsWith(`${prefix}.`);
98+ }
99+ return false;
100+};
101+
102+// ---------------------------------------------------------------------------
103+// Pattern validation — rejects shapes the matcher can't handle. Used by the
104+// CRUD path so a malformed rule never lands in the table.
105+// ---------------------------------------------------------------------------
106+
107+export const isValidPattern = (pattern: string): boolean => {
108+ if (pattern.length === 0) return false;
109+ if (pattern === "*") return true;
110+ if (pattern.startsWith(".") || pattern.endsWith(".")) return false;
111+ if (pattern.includes("..")) return false;
112+ if (pattern.startsWith("*")) return false;
113+ // `*` is only valid as the entire trailing segment.
114+ const segments = pattern.split(".");
115+ for (let i = 0; i < segments.length; i++) {
116+ const seg = segments[i]!;
117+ if (seg.length === 0) return false;
118+ if (seg.includes("*") && seg !== "*") return false;
119+ if (seg === "*" && i !== segments.length - 1) return false;
120+ }
121+ return true;
122+};
123+
124+// ---------------------------------------------------------------------------
125+// Resolution — given a tool id and the policy rows visible across the
126+// executor's scope stack, return the first matching rule under the
127+// (innermost-scope-first, position-ascending) ordering. Caller passes a
128+// `scopeRank` function so the resolver doesn't need to know the executor's
129+// scope stack shape.
130+// ---------------------------------------------------------------------------
131+
132+// Lex compare on fractional-indexing key, then id as a stable tiebreak.
133+// Two rows with identical `position` (racing inserts that picked the same
134+// `generateKeyBetween(null, min)` from independent clients) would otherwise
135+// flip on every refetch.
136+export const comparePolicyRow = (
137+ a: { position: unknown; id: unknown },
138+ b: { position: unknown; id: unknown },
139+): number => {
140+ const pa = a.position as string;
141+ const pb = b.position as string;
142+ if (pa < pb) return -1;
143+ if (pa > pb) return 1;
144+ const ia = a.id as string;
145+ const ib = b.id as string;
146+ return ia < ib ? -1 : ia > ib ? 1 : 0;
147+};
148+
149+export const resolveToolPolicy = (
150+ toolId: string,
151+ policies: readonly ToolPolicyRow[],
152+ scopeRank: (row: { scope_id: unknown }) => number,
153+): PolicyMatch | undefined => {
154+ if (policies.length === 0) return undefined;
155+ const sorted = [...policies].sort((a, b) => {
156+ const sa = scopeRank(a);
157+ const sb = scopeRank(b);
158+ if (sa !== sb) return sa - sb;
159+ return comparePolicyRow(a, b);
160+ });
161+ for (const row of sorted) {
162+ if (matchPattern(row.pattern as string, toolId)) {
163+ return {
164+ action: row.action as ToolPolicyAction,
165+ pattern: row.pattern as string,
166+ policyId: row.id as string,
167+ };
168+ }
169+ }
170+ return undefined;
171+};
172+
173+// ---------------------------------------------------------------------------
174+// Layered resolution — one call returns the effective policy combining
175+// user-authored rules and the plugin's default `requiresApproval`
176+// annotation. Use this anywhere a UI / agent / log needs the final answer
177+// without knowing about the layering.
178+//
179+// Two flavors:
180+// - `resolveEffectivePolicy` takes raw rows + a scopeRank, mirrors
181+// `resolveToolPolicy`. Used server-side.
182+// - `effectivePolicyFromSorted` takes a pre-sorted list of public
183+// `ToolPolicy` projections; for clients that already received
184+// policies in evaluation order from the API.
185+// ---------------------------------------------------------------------------
186+
187+const liftPlugin = (
188+ defaultRequiresApproval: boolean | undefined,
189+): EffectivePolicy =>
190+ defaultRequiresApproval
191+ ? { action: "require_approval", source: "plugin-default" }
192+ : { action: "approve", source: "plugin-default" };
193+
194+const liftUser = (match: PolicyMatch): EffectivePolicy => ({
195+ action: match.action,
196+ source: "user",
197+ pattern: match.pattern,
198+ policyId: match.policyId,
199+});
200+
201+export const resolveEffectivePolicy = (
202+ toolId: string,
203+ policies: readonly ToolPolicyRow[],
204+ scopeRank: (row: { scope_id: unknown }) => number,
205+ defaultRequiresApproval?: boolean,
206+): EffectivePolicy => {
207+ const match = resolveToolPolicy(toolId, policies, scopeRank);
208+ return match ? liftUser(match) : liftPlugin(defaultRequiresApproval);
209+};
210+
211+export const effectivePolicyFromSorted = (
212+ toolId: string,
213+ sortedPolicies: readonly Pick<ToolPolicy, "pattern" | "action" | "id">[],
214+ defaultRequiresApproval?: boolean,
215+): EffectivePolicy => {
216+ for (const p of sortedPolicies) {
217+ if (matchPattern(p.pattern, toolId)) {
218+ return {
219+ action: p.action,
220+ source: "user",
221+ pattern: p.pattern,
222+ policyId: p.id,
223+ };
224+ }
225+ }
226+ return liftPlugin(defaultRequiresApproval);
227+};
228+
229+// ---------------------------------------------------------------------------
230+// Row → public projection.
231+// ---------------------------------------------------------------------------
232+
233+export const rowToToolPolicy = (row: ToolPolicyRow): ToolPolicy => ({
234+ id: PolicyId.make(row.id as string),
235+ scopeId: ScopeId.make(row.scope_id as string),
236+ pattern: row.pattern as string,
237+ action: row.action as ToolPolicyAction,
238+ position: row.position as string,
239+ createdAt: row.created_at as Date,
240+ updatedAt: row.updated_at as Date,
241+});
242+
243+// ---------------------------------------------------------------------------
244+// Schema for the action enum — useful for HTTP edges that want to validate
245+// inputs with effect/Schema.
246+// ---------------------------------------------------------------------------
247+
248+export const ToolPolicyActionSchema = Schema.Literal(
249+ "approve",
250+ "require_approval",
251+ "block",
252+);
packages/core/sdk/src/types.ts+5−0
@@ -107,4 +107,9 @@ export interface ToolListFilter { · 106 unmodified lines
107107 readonly query?: string;
108108 /** Resolve plugin-derived annotations. Defaults to true. */
109109 readonly includeAnnotations?: boolean;
110+ /** Include tools whose effective `tool_policy` is `block`. Defaults to
111+ * `false` so the agent-facing surfaces (`searchTools`, sandbox `tools.list`)
112+ * silently omit blocked tools. The settings UI for managing policies
113+ * should pass `true` so users can author rules against blocked tools. */
114+ readonly includeBlocked?: boolean;
110115 }
packages/kernel/runtime-dynamic-worker/src/executor.ts+9−0
@@ -196,6 +196,15 @@ export const renderWorkerError = (error: SerializedWorkerError): string => { · 195 unmodified lines
196196 return error.primary;
197197 }
198198
199+ if (
200+ typeof error.primary === "object" &&
201+ error.primary !== null &&
202+ "message" in error.primary &&
203+ typeof error.primary.message === "string"
204+ ) {
205+ return error.primary.message;
206+ }
207+
199208 if (typeof error.primary === "object" && error.primary !== null) {
200209 try {
201210 return JSON.stringify(error.primary);
packages/kernel/runtime-dynamic-worker/src/invocation.test.ts+18−0
@@ -254,6 +254,24 @@ describe("makeDynamicWorkerExecutor", () => { · 253 unmodified lines
254254 expect(result.result).toBeNull();
255255 });
256256
257+ it("surfaces message-bearing object tool errors in execution result", async () => {
258+ const executor = makeDynamicWorkerExecutor({ loader });
259+ const invoker = {
260+ invoke: () =>
261+ Effect.fail({
262+ code: "invalid_query",
263+ message: 'Field with name "DisplayName" does not exist',
264+ }),
265+ } satisfies SandboxToolInvoker;
266+
267+ const result = await Effect.runPromise(
268+ executor.execute("async () => { return await tools.records.query({}); }", invoker),
269+ );
270+
271+ expect(result.error).toBe('Field with name "DisplayName" does not exist');
272+ expect(result.result).toBeNull();
273+ });
274+
257275 it("handles multiple tool calls in sequence", async () => {
258276 const executor = makeDynamicWorkerExecutor({ loader });
259277 const invoker = makeInvoker(({ path }) => {
packages/kernel/runtime-quickjs/src/index.ts+6−5
@@ -45,17 +45,18 @@ const toError = (cause: unknown): Error => · 44 unmodified lines
4545
4646 const toErrorMessage = (cause: unknown): string => {
4747 if (typeof cause === "object" && cause !== null) {
48− const stack = "stack" in cause && typeof cause.stack === "string" ? cause.stack : undefined;
4948 const message =
5049 "message" in cause && typeof cause.message === "string" ? cause.message : undefined;
5150
52− if (stack) {
53− return stack;
54− }
55−
5651 if (message) {
5752 return message;
5853 }
54+
55+ const stack = "stack" in cause && typeof cause.stack === "string" ? cause.stack : undefined;
56+
57+ if (stack) {
58+ return stack;
59+ }
5960 }
6061
6162 const error = toError(cause);
packages/plugins/keychain/src/index.test.ts+11−3
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
1010 import { keychainPlugin } from "./index";
1111
1212 describe("keychain plugin", () => {
13− it.effect("registers keychain as a secret provider", () =>
13+ it.effect("exposes keychain metadata and registers a provider when reachable", () =>
1414 Effect.gen(function* () {
1515 const executor = yield* createExecutor(
1616 makeTestConfig({
@@ -22,12 +22,12 @@ describe("keychain plugin", () => { · 5 unmodified lines
2222 expect(executor.keychain.isSupported).toBeTypeOf("boolean");
2323
2424 const providers = yield* executor.secrets.providers();
25− expect(providers).toContain("keychain");
25+ expect(providers.filter((provider) => provider === "keychain").length).toBeLessThanOrEqual(1);
2626 }),
2727 );
2828
2929 // The tests below exercise the real system keychain.
30− // They are skipped in CI because there is no keychain service available.
30+ // They no-op when the platform package loads but no keychain service is reachable.
3131
3232 it.effect.skipIf(!!process.env.CI)("stores and checks secret via system keychain", () =>
3333 Effect.gen(function* () {
@@ -37,6 +37,10 @@ describe("keychain plugin", () => { · 3 unmodified lines
3737 plugins: [keychainPlugin({ serviceName: "executor-test" })] as const,
3838 }),
3939 );
40+ const providers = yield* executor.secrets.providers();
41+ if (!providers.includes("keychain")) {
42+ return;
43+ }
4044
4145 try {
4246 // Store through SDK, pinned to keychain provider
@@ -70,6 +74,10 @@ describe("keychain plugin", () => { · 27 unmodified lines
7074 plugins: [keychainPlugin({ serviceName: "executor-test" })] as const,
7175 }),
7276 );
77+ const providers = yield* executor.secrets.providers();
78+ if (!providers.includes("keychain")) {
79+ return;
80+ }
7381
7482 const exists = yield* executor.keychain.has("nonexistent-secret");
7583 expect(exists).toBe(false);
packages/plugins/openapi/src/sdk/plugin.test.ts+63−2
@@ -105,6 +105,13 @@ class EchoHeaders extends Schema.Class<EchoHeaders>("EchoHeaders")({ · 104 unmodified lines
105105 "x-static": Schema.optional(Schema.String),
106106 }) {}
107107
108+class QueryValidationError extends Schema.TaggedError<QueryValidationError>()(
109+ "QueryValidationError",
110+ {
111+ message: Schema.String,
112+ },
113+) {}
114+
108115 const ItemsGroup = HttpApiGroup.make("items")
109116 .add(HttpApiEndpoint.get("listItems", "/items").addSuccess(Schema.Array(Item)))
110117 .add(
@@ -112,7 +119,13 @@ const ItemsGroup = HttpApiGroup.make("items") · 1 unmodified line
112119 .setPath(Schema.Struct({ itemId: Schema.NumberFromString }))
113120 .addSuccess(Item),
114121 )
115− .add(HttpApiEndpoint.get("echoHeaders", "/echo-headers").addSuccess(EchoHeaders));
122+ .add(HttpApiEndpoint.get("echoHeaders", "/echo-headers").addSuccess(EchoHeaders))
123+ .add(
124+ HttpApiEndpoint.get("queryRows", "/records/rows/:entryTypeId")
125+ .setPath(Schema.Struct({ entryTypeId: Schema.String }))
126+ .addSuccess(Schema.Unknown)
127+ .addError(QueryValidationError, { status: 400 }),
128+ );
116129
117130 const TestApi = HttpApi.make("testApi").add(ItemsGroup);
118131
@@ -148,6 +161,13 @@ const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => · 29 unmodified lines
148161 "x-static": req.headers["x-static"],
149162 });
150163 }),
164+ )
165+ .handle("queryRows", () =>
166+ Effect.fail(
167+ new QueryValidationError({
168+ message: 'Field with name "DisplayName" does not exist',
169+ }),
170+ ),
151171 ),
152172 );
153173
@@ -275,7 +295,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 121 unmodified lines
275295 autoApprove,
276296 )) as { sourceId: string; toolCount: number };
277297
278− expect(result).toEqual({ sourceId: "runtime", toolCount: 3 });
298+ expect(result).toEqual({ sourceId: "runtime", toolCount: 4 });
279299 expect((yield* executor.tools.list()).map((t) => t.id)).toContain(
280300 "runtime.items.listItems",
281301 );
@@ -454,6 +474,47 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 172 unmodified lines
454474 }),
455475 );
456476
477+ it.effect("surfaces structured validation errors from OpenAPI tool calls", () =>
478+ Effect.gen(function* () {
479+ const httpClient = yield* HttpClient.HttpClient;
480+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
481+
482+ const executor = yield* createExecutor(
483+ makeTestConfig({
484+ plugins: [
485+ openApiPlugin({ httpClientLayer: clientLayer }),
486+ memorySecretsPlugin(),
487+ ] as const,
488+ }),
489+ );
490+
491+ yield* executor.openapi.addSpec({
492+ spec: specJson,
493+ scope: TEST_SCOPE,
494+ namespace: "records",
495+ baseUrl: "",
496+ });
497+
498+ const result = (yield* executor.tools.invoke(
499+ "records.items.queryRows",
500+ {
501+ entryTypeId: "18538",
502+ query: JSON.stringify([{ DisplayName: "Example" }]),
503+ limit: 10,
504+ skip: 0,
505+ },
506+ autoApprove,
507+ )) as { data: unknown; error: unknown };
508+
509+ expect(result.data).toBeNull();
510+ expect(result.error).toEqual(
511+ expect.objectContaining({
512+ message: 'Field with name "DisplayName" does not exist',
513+ }),
514+ );
515+ }),
516+ );
517+
457518 it.effect("removeSpec cleans up registered tools", () =>
458519 Effect.gen(function* () {
459520 const httpClient = yield* HttpClient.HttpClient;
packages/react/package.json+1−0
@@ -33,6 +33,7 @@ · 32 unmodified lines
3333 "cmdk": "^1.1.1",
3434 "effect": "catalog:",
3535 "embla-carousel-react": "^8.6.0",
36+ "fractional-indexing": "^3.2.0",
3637 "input-otp": "^1.4.2",
3738 "lucide-react": "^1.7.0",
3839 "radix-ui": "^1.4.3",
packages/react/src/api/atoms.tsx+127−2
@@ -1,5 +1,11 @@
1−import type { ScopeId, ToolId, SecretId } from "@executor/sdk";
2−import { Atom } from "@effect-atom/atom-react";
1+import {
2+ PolicyId,
3+ type ScopeId,
4+ type ToolId,
5+ type SecretId,
6+ type ToolPolicyAction,
7+} from "@executor/sdk";
8+import { Atom, Result } from "@effect-atom/atom-react";
39
410 import { ExecutorApiClient } from "./client";
511 import { ReactivityKey } from "./reactivity-keys";
@@ -71,6 +77,13 @@ export const connectionsAtom = (scopeId: ScopeId) => · 65 unmodified lines
7177 reactivityKeys: [ReactivityKey.connections],
7278 });
7379
80+export const policiesAtom = (scopeId: ScopeId) =>
81+ ExecutorApiClient.query("policies", "list", {
82+ path: { scopeId },
83+ timeToLive: "30 seconds",
84+ reactivityKeys: [ReactivityKey.policies],
85+ });
86+
7487 // ---------------------------------------------------------------------------
7588 // Mutation atoms — reactivityKeys must be passed at call site (effect-atom
7689 // does not accept them at definition time). See `reactivity-keys.tsx` for the
@@ -91,3 +104,115 @@ export const removeSource = ExecutorApiClient.mutation("sources", "remove"); · 14 unmodified lines
91104 export const refreshSource = ExecutorApiClient.mutation("sources", "refresh");
92105
93106 export const detectSource = ExecutorApiClient.mutation("sources", "detect");
107+
108+export const createPolicy = ExecutorApiClient.mutation("policies", "create");
109+
110+export const updatePolicy = ExecutorApiClient.mutation("policies", "update");
111+
112+export const removePolicy = ExecutorApiClient.mutation("policies", "remove");
113+
114+// ---------------------------------------------------------------------------
115+// Policies — optimistic surface. Reads go through `policiesOptimisticAtom`
116+// (which layers in-flight transitions on top of `policiesAtom`), and writes
117+// go through the matching `*PolicyOptimistic` mutation atoms. Each mutation
118+// declares a reducer that produces the next array of rows; effect-atom's
119+// `Atom.optimisticFn` handles transition tracking, waiting state, and the
120+// post-commit refresh — including racing calls (latest reducer wins).
121+// ---------------------------------------------------------------------------
122+
123+export const policiesOptimisticAtom = Atom.family((scopeId: ScopeId) =>
124+ Atom.optimistic(policiesAtom(scopeId)),
125+);
126+
127+export const createPolicyOptimistic = Atom.family((scopeId: ScopeId) =>
128+ policiesOptimisticAtom(scopeId).pipe(
129+ Atom.optimisticFn({
130+ reducer: (
131+ current,
132+ arg: {
133+ path: { scopeId: ScopeId };
134+ payload: {
135+ pattern: string;
136+ action: ToolPolicyAction;
137+ position?: string;
138+ };
139+ reactivityKeys?: ReadonlyArray<unknown>;
140+ },
141+ ) =>
142+ Result.map(current, (rows) => [
143+ {
144+ id: PolicyId.make(
145+ `pending-${Math.random().toString(36).slice(2)}`,
146+ ),
147+ scopeId,
148+ pattern: arg.payload.pattern,
149+ action: arg.payload.action,
150+ // Empty string sorts before any fractional-indexing key, so
151+ // the placeholder lands at the top until the server returns
152+ // the canonical key.
153+ position: "",
154+ createdAt: Date.now(),
155+ updatedAt: Date.now(),
156+ },
157+ ...rows,
158+ ]),
159+ fn: createPolicy,
160+ }),
161+ ),
162+);
163+
164+export const updatePolicyOptimistic = Atom.family((_scopeId: ScopeId) =>
165+ policiesOptimisticAtom(_scopeId).pipe(
166+ Atom.optimisticFn({
167+ reducer: (
168+ current,
169+ arg: {
170+ path: { scopeId: ScopeId; policyId: PolicyId };
171+ payload: {
172+ pattern?: string;
173+ action?: ToolPolicyAction;
174+ position?: string;
175+ };
176+ reactivityKeys?: ReadonlyArray<unknown>;
177+ },
178+ ) =>
179+ Result.map(current, (rows) =>
180+ rows.map((r) =>
181+ r.id === arg.path.policyId
182+ ? {
183+ ...r,
184+ ...(arg.payload.action !== undefined
185+ ? { action: arg.payload.action }
186+ : {}),
187+ ...(arg.payload.pattern !== undefined
188+ ? { pattern: arg.payload.pattern }
189+ : {}),
190+ ...(arg.payload.position !== undefined
191+ ? { position: arg.payload.position }
192+ : {}),
193+ }
194+ : r,
195+ ),
196+ ),
197+ fn: updatePolicy,
198+ }),
199+ ),
200+);
201+
202+export const removePolicyOptimistic = Atom.family((scopeId: ScopeId) =>
203+ policiesOptimisticAtom(scopeId).pipe(
204+ Atom.optimisticFn({
205+ reducer: (
206+ current,
207+ arg: {
208+ path: { scopeId: ScopeId; policyId: PolicyId };
209+ reactivityKeys?: ReadonlyArray<unknown>;
210+ },
211+ ) =>
212+ Result.map(current, (rows) =>
213+ rows.filter((r) => r.id !== arg.path.policyId),
214+ ),
215+ fn: removePolicy,
216+ }),
217+ ),
218+);
packages/react/src/api/optimistic.tsx+1−0
@@ -203,3 +203,4 @@ export const usePendingConnectionRemovals = () => { · 202 unmodified lines
203203 [add, remove],
204204 );
205205 };
206+
packages/react/src/api/reactivity-keys.tsx+9−0
@@ -23,6 +23,7 @@ export const ReactivityKey = { · 22 unmodified lines
2323 secrets: "secrets",
2424 connections: "connections",
2525 scope: "scope",
26+ policies: "policies",
2627 // cloud-only resources
2728 orgMembers: "org:members",
2829 orgDomains: "org:domains",
@@ -48,6 +49,14 @@ export const connectionWriteKeys = [ · 19 unmodified lines
4849 /** Mutations that change scope membership/info. */
4950 export const scopeWriteKeys = [ReactivityKey.scope] as const;
5051
52+/** Mutations that mutate tool policies. Also touches `tools` because
53+ * `tools.list` filters blocked tools — adding/removing a `block`
54+ * policy changes what the tools page shows. */
55+export const policyWriteKeys = [
56+ ReactivityKey.policies,
57+ ReactivityKey.tools,
58+] as const;
59+
5160 /** Cloud-only: org membership mutations. */
5261 export const orgMemberWriteKeys = [ReactivityKey.orgMembers] as const;
5362
packages/react/src/components/select.tsx+7−0
@@ -4,6 +4,12 @@ import * as React from "react"; · 3 unmodified lines
44 import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
55 import { Select as SelectPrimitive } from "radix-ui";
66
7+// Re-export the unstyled Trigger so callers can wrap it around their own
8+// trigger element via `asChild`. The styled `SelectTrigger` below is
9+// opinionated (border, chevron, height); use this when you need a custom
10+// trigger like a Badge.
11+const SelectPrimitiveTrigger = SelectPrimitive.Trigger;
12+
713 import { cn } from "../lib/utils";
814
915 function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
@@ -167,6 +173,7 @@ export { · 157 unmodified lines
167173 SelectGroup,
168174 SelectItem,
169175 SelectLabel,
176+ SelectPrimitiveTrigger,
170177 SelectScrollDownButton,
171178 SelectScrollUpButton,
172179 SelectSeparator,
packages/react/src/components/tool-detail.tsx+63−1
@@ -1,7 +1,13 @@
11 import { useMemo, useState } from "react";
22 import { useAtomValue, Result } from "@effect-atom/atom-react";
33 import { toolSchemaAtom } from "../api/atoms";
4−import { ScopeId, ToolId } from "@executor/sdk";
4+import {
5+ ScopeId,
6+ ToolId,
7+ type EffectivePolicy,
8+ type ToolPolicyAction,
9+} from "@executor/sdk";
10+import { Badge } from "./badge";
511 import { Button } from "./button";
612 import { Markdown } from "./markdown";
713 import { SchemaExplorer } from "./schema-explorer";
@@ -10,6 +16,45 @@ import { CardStack, CardStackHeader, CardStackContent } from "./card-stack"; · 2 unmodified lines
1016 import { CopyButton } from "./copy-button";
1117 import { ChevronRight } from "lucide-react";
1218
19+const POLICY_LABEL: Record<ToolPolicyAction, string> = {
20+ approve: "Auto-approve",
21+ require_approval: "Require approval",
22+ block: "Blocked",
23+};
24+
25+const POLICY_VARIANT: Record<
26+ ToolPolicyAction,
27+ "default" | "secondary" | "outline" | "destructive"
28+> = {
29+ approve: "secondary",
30+ require_approval: "outline",
31+ block: "destructive",
32+};
33+
34+// Render the effective policy as a badge. User policies show the
35+// matched pattern; plugin defaults read "Default: <action>". Silent for
36+// the auto-approve plugin default — that's the safe state and the
37+// header would just be noise.
38+const policyBadgeFor = (policy: EffectivePolicy) => {
39+ if (policy.source === "plugin-default" && policy.action === "approve") {
40+ return null;
41+ }
42+ if (policy.source === "user") {
43+ return {
44+ variant: POLICY_VARIANT[policy.action],
45+ title: `Matched policy: ${policy.pattern}`,
46+ text: `${POLICY_LABEL[policy.action]} · ${policy.pattern}`,
47+ className: "font-mono text-[10px]",
48+ };
49+ }
50+ return {
51+ variant: "outline" as const,
52+ title: "No matching policy — plugin default applies",
53+ text: `Default: ${POLICY_LABEL[policy.action]}`,
54+ className: "text-[10px] text-muted-foreground",
55+ };
56+};
57+
1358 function EmptySection(props: { title: string; message: string }) {
1459 return (
1560 <CardStack>
@@ -50,6 +95,9 @@ export function ToolDetail(props: { · 34 unmodified lines
5095 toolName: string;
5196 toolDescription?: string;
5297 scopeId: ScopeId;
98+ /** Resolved effective policy — user-authored or plugin-default,
99+ * unified into one shape. Surfaces in the header. */
100+ policy?: EffectivePolicy;
53101 }) {
54102 const toolContract = useAtomValue(toolSchemaAtom(props.scopeId, props.toolId as ToolId));
55103 const [tab, setTab] = useState<"schema" | "typescript">("schema");
@@ -92,6 +140,20 @@ export function ToolDetail(props: { · 36 unmodified lines
92140 <div className="mt-1 flex items-center gap-2">
93141 <h3 className="text-base font-semibold text-foreground truncate">{displayName}</h3>
94142 <CopyButton value={props.toolId} label="Copy tool ID" />
143+ {(() => {
144+ if (!props.policy) return null;
145+ const badge = policyBadgeFor(props.policy);
146+ if (!badge) return null;
147+ return (
148+ <Badge
149+ variant={badge.variant}
150+ title={badge.title}
151+ className={badge.className}
152+ >
153+ {badge.text}
154+ </Badge>
155+ );
156+ })()}
95157 </div>
96158 {props.toolDescription && (
97159 <div className="mt-1.5 max-w-lg text-sm text-muted-foreground line-clamp-2">
packages/react/src/components/tool-tree.tsx+59−0
@@ -1,5 +1,6 @@
11 import { useEffect, useMemo, useRef, useState } from "react";
22 import { ChevronRightIcon, SearchIcon, XIcon } from "lucide-react";
3+import type { EffectivePolicy, ToolPolicyAction } from "@executor/sdk";
34 import { Button } from "./button";
45 import { Input } from "./input";
56 import { cn } from "../lib/utils";
@@ -13,8 +14,57 @@ export interface ToolSummary { · 7 unmodified lines
1314 readonly name: string;
1415 readonly pluginKey: string;
1516 readonly description?: string;
17+ /** Resolved policy for this tool — combines user-authored rules and
18+ * plugin defaults into one answer. Always present. UI distinguishes
19+ * user vs default purely via `policy.source`. */
20+ readonly policy: EffectivePolicy;
1621 }
1722
23+// Color + label for the per-row policy indicator. Mirrors the badges on
24+// the /policies page so the same action looks the same everywhere.
25+const POLICY_INDICATOR: Record<
26+ ToolPolicyAction,
27+ { readonly label: string; readonly dot: string; readonly ring: string }
28+> = {
29+ approve: {
30+ label: "Auto-approve",
31+ dot: "bg-emerald-500",
32+ ring: "ring-emerald-500/70",
33+ },
34+ require_approval: {
35+ label: "Require approval",
36+ dot: "bg-amber-500",
37+ ring: "ring-amber-500/70",
38+ },
39+ block: {
40+ label: "Blocked",
41+ dot: "bg-destructive",
42+ ring: "ring-destructive/70",
43+ },
44+};
45+
46+// What the dot looks like for a given effective policy. Auto-approve as
47+// a plugin default is silent (the safe state — no point cluttering every
48+// row); everything else gets a dot. User policies are filled, plugin
49+// defaults are hollow rings.
50+const indicatorFor = (policy: EffectivePolicy) => {
51+ if (policy.source === "plugin-default" && policy.action === "approve") {
52+ return null;
53+ }
54+ const ind = POLICY_INDICATOR[policy.action];
55+ const filled = policy.source === "user";
56+ const label =
57+ policy.source === "user"
58+ ? `${ind.label} (matched ${policy.pattern})`
59+ : `Plugin default: ${ind.label}`;
60+ return {
61+ label,
62+ className: filled
63+ ? ind.dot
64+ : cn("bg-transparent ring-1", ind.ring),
65+ };
66+};
67+
1868 type TreeNode = {
1969 segment: string;
2070 path: string;
@@ -360,6 +410,7 @@ function ToolLeafRow(props: { · 339 unmodified lines
360410 search: string;
361411 }) {
362412 const label = props.tool.name.split(".").pop() ?? props.tool.name;
413+ const indicator = indicatorFor(props.tool.policy);
363414 return (
364415 <Button
365416 ref={props.buttonRef}
@@ -370,12 +421,20 @@ function ToolLeafRow(props: { · 4 unmodified lines
370421 props.active
371422 ? "bg-primary/15 text-foreground ring-1 ring-inset ring-primary/40 hover:bg-primary/20"
372423 : "text-foreground/80 hover:bg-accent/60 hover:text-foreground",
424+ props.tool.policy.action === "block" && !props.active && "opacity-60",
373425 )}
374426 style={{ paddingLeft: rowIndent(props.depth) + 20, paddingRight: 12 }}
375427 >
376428 <span className="flex-1 truncate text-left font-mono">
377429 {highlightMatch(label, props.search)}
378430 </span>
431+ {indicator && (
432+ <span
433+ aria-label={indicator.label}
434+ title={indicator.label}
435+ className={cn("shrink-0 size-1.5 rounded-full", indicator.className)}
436+ />
437+ )}
379438 </Button>
380439 );
381440 }
packages/react/src/pages/policies.tsx+463−0
@@ -0,0 +1,463 @@
1+import { useState } from "react";
2+import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
3+import { generateKeyBetween } from "fractional-indexing";
4+import { ChevronDownIcon } from "lucide-react";
5+import { PolicyId, type ToolPolicyAction } from "@executor/sdk";
6+
7+import {
8+ createPolicyOptimistic,
9+ policiesOptimisticAtom,
10+ removePolicyOptimistic,
11+ updatePolicyOptimistic,
12+} from "../api/atoms";
13+import { policyWriteKeys } from "../api/reactivity-keys";
14+import { useScope } from "../hooks/use-scope";
15+import { badgeVariants } from "../components/badge";
16+import { cn } from "../lib/utils";
17+import { Button } from "../components/button";
18+import {
19+ CardStack,
20+ CardStackContent,
21+ CardStackEntry,
22+ CardStackEntryActions,
23+ CardStackEntryContent,
24+ CardStackEntryDescription,
25+ CardStackEntryTitle,
26+ CardStackHeader,
27+} from "../components/card-stack";
28+import {
29+ DropdownMenu,
30+ DropdownMenuContent,
31+ DropdownMenuItem,
32+ DropdownMenuTrigger,
33+} from "../components/dropdown-menu";
34+import { Input } from "../components/input";
35+import {
36+ Select,
37+ SelectContent,
38+ SelectItem,
39+ SelectPrimitiveTrigger,
40+ SelectTrigger,
41+ SelectValue,
42+} from "../components/select";
43+import { Label } from "../components/label";
44+
45+// ---------------------------------------------------------------------------
46+// Sort comparator — fractional-indexing key, then id as a stable tiebreak.
47+// Identical positions can briefly happen across racing inserts; without the
48+// tiebreak the rendered order flips between refetches, and `generateKeyBetween`
49+// would also throw if asked to insert "between" two equal keys.
50+// ---------------------------------------------------------------------------
51+
52+const comparePolicy = (
53+ posA: string,
54+ idA: string,
55+ posB: string,
56+ idB: string,
57+): number => {
58+ if (posA < posB) return -1;
59+ if (posA > posB) return 1;
60+ if (idA < idB) return -1;
61+ if (idA > idB) return 1;
62+ return 0;
63+};
64+
65+// ---------------------------------------------------------------------------
66+// Action display
67+// ---------------------------------------------------------------------------
68+
69+const actionLabels: Record<ToolPolicyAction, string> = {
70+ approve: "Auto-approve",
71+ require_approval: "Require approval",
72+ block: "Block",
73+};
74+
75+const actionVariants: Record<
76+ ToolPolicyAction,
77+ "default" | "secondary" | "outline" | "destructive"
78+> = {
79+ approve: "secondary",
80+ require_approval: "outline",
81+ block: "destructive",
82+};
83+
84+// ---------------------------------------------------------------------------
85+// Pattern matcher (mirrors `matchPattern` in @executor/sdk) — used for the
86+// live "this rule matches N tools" preview without a server round-trip.
87+// Kept inline so the React package doesn't take a runtime dep on the SDK
88+// for one tiny pure function. If they drift, only the preview is stale.
89+// ---------------------------------------------------------------------------
90+
91+const matchesPattern = (pattern: string, toolId: string): boolean => {
92+ if (pattern === "*") return true;
93+ if (pattern === toolId) return true;
94+ if (pattern.endsWith(".*")) {
95+ const prefix = pattern.slice(0, -2);
96+ if (prefix.length === 0) return false;
97+ return toolId === prefix || toolId.startsWith(`${prefix}.`);
98+ }
99+ return false;
100+};
101+
102+const isValidPattern = (pattern: string): boolean => {
103+ if (pattern.length === 0) return false;
104+ if (pattern === "*") return true;
105+ if (pattern.startsWith(".") || pattern.endsWith(".")) return false;
106+ if (pattern.includes("..")) return false;
107+ if (pattern.startsWith("*")) return false;
108+ const segments = pattern.split(".");
109+ for (let i = 0; i < segments.length; i++) {
110+ const seg = segments[i]!;
111+ if (seg.length === 0) return false;
112+ if (seg.includes("*") && seg !== "*") return false;
113+ if (seg === "*" && i !== segments.length - 1) return false;
114+ }
115+ return true;
116+};
117+
118+// ---------------------------------------------------------------------------
119+// Add-policy form
120+// ---------------------------------------------------------------------------
121+
122+function AddPolicyForm(props: {
123+ onSubmit: (input: { pattern: string; action: ToolPolicyAction }) => void;
124+ busy: boolean;
125+}) {
126+ const [pattern, setPattern] = useState("");
127+ const [action, setAction] = useState<ToolPolicyAction>("require_approval");
128+ const valid = isValidPattern(pattern);
129+
130+ return (
131+ <form
132+ className="flex flex-col gap-3 rounded-xl border border-border bg-card px-5 py-4"
133+ onSubmit={(e) => {
134+ e.preventDefault();
135+ if (!valid) return;
136+ props.onSubmit({ pattern, action });
137+ setPattern("");
138+ setAction("require_approval");
139+ }}
140+ >
141+ <div className="flex flex-col gap-1">
142+ <Label htmlFor="policy-pattern" className="text-xs font-medium text-foreground/80">
143+ Pattern
144+ </Label>
145+ <Input
146+ id="policy-pattern"
147+ placeholder="vercel.dns.* or *"
148+ value={pattern}
149+ onChange={(e) => setPattern(e.target.value)}
150+ className="font-mono text-sm"
151+ />
152+ <p className="text-xs text-muted-foreground">
153+ Exact tool id, trailing wildcard, or{" "}
154+ <code className="font-mono">*</code> for every tool. Examples:{" "}
155+ <code className="font-mono">*</code>,{" "}
156+ <code className="font-mono">vercel.*</code>,{" "}
157+ <code className="font-mono">vercel.dns.*</code>,{" "}
158+ <code className="font-mono">vercel.dns.create</code>.
159+ </p>
160+ </div>
161+ <div className="flex flex-col gap-1">
162+ <Label className="text-xs font-medium text-foreground/80">Action</Label>
163+ <Select
164+ value={action}
165+ onValueChange={(v) => setAction(v as ToolPolicyAction)}
166+ >
167+ <SelectTrigger className="w-full">
168+ <SelectValue />
169+ </SelectTrigger>
170+ <SelectContent>
171+ <SelectItem value="approve">{actionLabels.approve}</SelectItem>
172+ <SelectItem value="require_approval">
173+ {actionLabels.require_approval}
174+ </SelectItem>
175+ <SelectItem value="block">{actionLabels.block}</SelectItem>
176+ </SelectContent>
177+ </Select>
178+ </div>
179+ <div className="flex items-center justify-end">
180+ <Button type="submit" disabled={!valid || props.busy} size="sm">
181+ Add policy
182+ </Button>
183+ </div>
184+ </form>
185+ );
186+}
187+
188+// ---------------------------------------------------------------------------
189+// Policy row
190+// ---------------------------------------------------------------------------
191+
192+function PolicyRow(props: {
193+ policy: {
194+ id: string;
195+ pattern: string;
196+ action: ToolPolicyAction;
197+ };
198+ isFirst: boolean;
199+ isLast: boolean;
200+ onRemove: () => void;
201+ onChangeAction: (action: ToolPolicyAction) => void;
202+ onMoveUp: () => void;
203+ onMoveDown: () => void;
204+}) {
205+ return (
206+ <CardStackEntry>
207+ <CardStackEntryContent>
208+ <CardStackEntryTitle className="flex items-center gap-2 font-mono text-sm">
209+ <span className="truncate">{props.policy.pattern}</span>
210+ </CardStackEntryTitle>
211+ </CardStackEntryContent>
212+ <CardStackEntryActions>
213+ <Select
214+ value={props.policy.action}
215+ onValueChange={(v) => props.onChangeAction(v as ToolPolicyAction)}
216+ >
217+ <SelectPrimitiveTrigger
218+ className={cn(
219+ badgeVariants({
220+ variant: actionVariants[props.policy.action],
221+ }),
222+ "cursor-pointer pr-1.5 gap-1 transition-[opacity,box-shadow] hover:opacity-80 focus-visible:outline-none data-[state=open]:ring-2 data-[state=open]:ring-ring/50",
223+ )}
224+ >
225+ {actionLabels[props.policy.action]}
226+ <ChevronDownIcon className="size-3 opacity-70" />
227+ </SelectPrimitiveTrigger>
228+ <SelectContent position="popper" align="end">
229+ <SelectItem value="approve">{actionLabels.approve}</SelectItem>
230+ <SelectItem value="require_approval">
231+ {actionLabels.require_approval}
232+ </SelectItem>
233+ <SelectItem value="block">{actionLabels.block}</SelectItem>
234+ </SelectContent>
235+ </Select>
236+ <DropdownMenu>
237+ <DropdownMenuTrigger asChild>
238+ <Button
239+ variant="ghost"
240+ size="icon"
241+ className="size-7 opacity-0 transition-opacity group-hover/card-stack-entry:opacity-100 group-focus-within/card-stack-entry:opacity-100 data-[state=open]:opacity-100"
242+ >
243+ <svg viewBox="0 0 16 16" className="size-3">
244+ <circle cx="8" cy="3" r="1.2" fill="currentColor" />
245+ <circle cx="8" cy="8" r="1.2" fill="currentColor" />
246+ <circle cx="8" cy="13" r="1.2" fill="currentColor" />
247+ </svg>
248+ </Button>
249+ </DropdownMenuTrigger>
250+ <DropdownMenuContent align="end" className="w-40">
251+ <DropdownMenuItem
252+ disabled={props.isFirst}
253+ onClick={props.onMoveUp}
254+ >
255+ Move up
256+ </DropdownMenuItem>
257+ <DropdownMenuItem
258+ disabled={props.isLast}
259+ onClick={props.onMoveDown}
260+ >
261+ Move down
262+ </DropdownMenuItem>
263+ <DropdownMenuItem
264+ className="text-destructive focus:text-destructive text-sm"
265+ onClick={props.onRemove}
266+ >
267+ Remove
268+ </DropdownMenuItem>
269+ </DropdownMenuContent>
270+ </DropdownMenu>
271+ </CardStackEntryActions>
272+ </CardStackEntry>
273+ );
274+}
275+
276+// ---------------------------------------------------------------------------
277+// Page
278+// ---------------------------------------------------------------------------
279+
280+export function PoliciesPage() {
281+ const scopeId = useScope();
282+ const policies = useAtomValue(policiesOptimisticAtom(scopeId));
283+ const doCreate = useAtomSet(createPolicyOptimistic(scopeId), {
284+ mode: "promise",
285+ });
286+ const doUpdate = useAtomSet(updatePolicyOptimistic(scopeId), {
287+ mode: "promise",
288+ });
289+ const doRemove = useAtomSet(removePolicyOptimistic(scopeId), {
290+ mode: "promise",
291+ });
292+ const [busy, setBusy] = useState(false);
293+
294+ const handleCreate = async (input: {
295+ pattern: string;
296+ action: ToolPolicyAction;
297+ }) => {
298+ setBusy(true);
299+ try {
300+ await doCreate({
301+ path: { scopeId },
302+ payload: { pattern: input.pattern, action: input.action },
303+ reactivityKeys: policyWriteKeys,
304+ });
305+ } finally {
306+ setBusy(false);
307+ }
308+ };
309+
310+ const handleUpdate = async (id: string, action: ToolPolicyAction) => {
311+ await doUpdate({
312+ path: { scopeId, policyId: PolicyId.make(id) },
313+ payload: { action },
314+ reactivityKeys: policyWriteKeys,
315+ });
316+ };
317+
318+ const handleRemove = async (id: string) => {
319+ await doRemove({
320+ path: { scopeId, policyId: PolicyId.make(id) },
321+ reactivityKeys: policyWriteKeys,
322+ });
323+ };
324+
325+ const handleMove = async (id: string, position: string) => {
326+ await doUpdate({
327+ path: { scopeId, policyId: PolicyId.make(id) },
328+ payload: { position },
329+ reactivityKeys: policyWriteKeys,
330+ });
331+ };
332+
333+ return (
334+ <div className="min-h-0 flex-1 overflow-y-auto">
335+ <div className="mx-auto max-w-3xl px-6 py-10 lg:px-8 lg:py-14">
336+ <div className="flex items-end justify-between mb-10">
337+ <div>
338+ <h1 className="font-display text-[2rem] tracking-tight text-foreground leading-none">
339+ Policies
340+ </h1>
341+ <p className="mt-2 text-sm text-muted-foreground leading-relaxed">
342+ Override default approval behavior for tools. Rules are
343+ evaluated top-to-bottom; the first match wins. Blocked tools
344+ are hidden from agent search and fail at invoke.
345+ </p>
346+ </div>
347+ </div>
348+
349+ <div className="mb-8">
350+ <AddPolicyForm onSubmit={handleCreate} busy={busy} />
351+ </div>
352+
353+ {Result.match(policies, {
354+ onInitial: () => (
355+ <div className="flex items-center gap-2 py-8">
356+ <div className="size-1.5 rounded-full bg-muted-foreground/30 animate-pulse" />
357+ <p className="text-sm text-muted-foreground">
358+ Loading policies…
359+ </p>
360+ </div>
361+ ),
362+ onFailure: () => (
363+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
364+ <p className="text-sm text-destructive">Failed to load policies</p>
365+ </div>
366+ ),
367+ onSuccess: ({ value }) => {
368+ // Sort by position (lex order on fractional-indexing keys),
369+ // tiebreaking on id so identical positions don't swap on refetch
370+ // and `generateKeyBetween` never sees duplicate neighbor keys
371+ // (which would throw). Optimistic placeholders carry
372+ // `position: ""` so they sort to the top.
373+ const sorted = [...value].sort((a, b) =>
374+ comparePolicy(a.position, a.id, b.position, b.id),
375+ );
376+ // Reorder math runs against committed rows only — placeholder
377+ // rows (empty `position`) aren't valid keys for
378+ // `generateKeyBetween` and aren't reorderable until the server
379+ // confirms.
380+ const committed = sorted.filter((p) => p.position !== "");
381+ const committedIndex = (id: string): number =>
382+ committed.findIndex((p) => p.id === id);
383+ const positionAbove = (id: string): string => {
384+ const j = committedIndex(id);
385+ if (j <= 0) return generateKeyBetween(null, committed[0]!.position);
386+ return j === 1
387+ ? generateKeyBetween(null, committed[0]!.position)
388+ : generateKeyBetween(
389+ committed[j - 2]!.position,
390+ committed[j - 1]!.position,
391+ );
392+ };
393+ const positionBelow = (id: string): string => {
394+ const j = committedIndex(id);
395+ if (j === -1 || j >= committed.length - 1)
396+ return generateKeyBetween(
397+ committed[committed.length - 1]!.position,
398+ null,
399+ );
400+ return j === committed.length - 2
401+ ? generateKeyBetween(
402+ committed[committed.length - 1]!.position,
403+ null,
404+ )
405+ : generateKeyBetween(
406+ committed[j + 1]!.position,
407+ committed[j + 2]!.position,
408+ );
409+ };
410+ return (
411+ <CardStack>
412+ <CardStackHeader>Active policies</CardStackHeader>
413+ <CardStackContent>
414+ {sorted.length === 0 ? (
415+ <CardStackEntry>
416+ <CardStackEntryContent>
417+ <CardStackEntryDescription>
418+ No policies yet. Tools fall back to their plugin's
419+ default approval behavior.
420+ </CardStackEntryDescription>
421+ </CardStackEntryContent>
422+ </CardStackEntry>
423+ ) : (
424+ sorted.map((p) => {
425+ const j = committedIndex(p.id);
426+ // Pending placeholder or only one committed row → no
427+ // reorder affordance.
428+ const reorderable = j !== -1 && committed.length > 1;
429+ return (
430+ <PolicyRow
431+ key={p.id}
432+ policy={{
433+ id: p.id,
434+ pattern: p.pattern,
435+ action: p.action,
436+ }}
437+ isFirst={!reorderable || j === 0}
438+ isLast={!reorderable || j === committed.length - 1}
439+ onRemove={() => handleRemove(p.id)}
440+ onChangeAction={(action) =>
441+ handleUpdate(p.id, action)
442+ }
443+ onMoveUp={() => handleMove(p.id, positionAbove(p.id))}
444+ onMoveDown={() =>
445+ handleMove(p.id, positionBelow(p.id))
446+ }
447+ />
448+ );
449+ })
450+ )}
451+ </CardStackContent>
452+ </CardStack>
453+ );
454+ },
455+ })}
456+ </div>
457+ </div>
458+ );
459+}
460+
461+// Exported for tests / direct consumers that don't want the matcher
462+// duplicated in two places. Cloud's UI uses these for live preview.
463+export { matchesPattern, isValidPattern };
packages/react/src/pages/source-detail.tsx+14−1
@@ -1,7 +1,9 @@
11 import { Suspense, useEffect, useMemo, useState } from "react";
22 import { useNavigate } from "@tanstack/react-router";
33 import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";
4+import { effectivePolicyFromSorted } from "@executor/sdk";
45 import {
6+ policiesOptimisticAtom,
57 sourceToolsAtom,
68 sourcesAtom,
79 sourceAtom,
@@ -26,6 +28,7 @@ export function SourceDetailPage(props: { · 18 unmodified lines
2628 const scopeId = useScope();
2729 const source = useAtomValue(sourceAtom(namespace, scopeId));
2830 const tools = useAtomValue(sourceToolsAtom(namespace, scopeId));
31+ const policies = useAtomValue(policiesOptimisticAtom(scopeId));
2932 const refreshSources = useAtomRefresh(sourcesAtom(scopeId));
3033 const refreshTools = useAtomRefresh(sourceToolsAtom(namespace, scopeId));
3134 const doRemove = useAtomSet(removeSource, { mode: "promise" });
@@ -62,6 +65,14 @@ export function SourceDetailPage(props: { · 30 unmodified lines
6265 return sourcePlugins.find((p) => p.key === sourceData.kind) ?? null;
6366 }, [sourceData, sourcePlugins]);
6467
68+ // Policies are pre-sorted by the server in evaluation order
69+ // (innermost scope first, then position ASC). The matcher walks the
70+ // list and stops at the first hit, mirroring server-side resolution.
71+ const policyList = useMemo(
72+ () => (Result.isSuccess(policies) ? policies.value : []),
73+ [policies],
74+ );
75+
6576 const sourceTools: ToolSummary[] = useMemo(() => {
6677 if (!Result.isSuccess(tools)) return [];
6778 return tools.value.map((t) => ({
@@ -69,8 +80,9 @@ export function SourceDetailPage(props: { · 1 unmodified line
6980 name: t.name,
7081 pluginKey: t.pluginId,
7182 description: t.description,
83+ policy: effectivePolicyFromSorted(t.id, policyList, t.requiresApproval),
7284 }));
73− }, [tools]);
85+ }, [tools, policyList]);
7486
7587 const selectedTool = useMemo(
7688 () => sourceTools.find((t) => t.id === selectedToolId) ?? null,
@@ -235,6 +247,7 @@ export function SourceDetailPage(props: { · 158 unmodified lines
235247 toolName={selectedTool.name}
236248 toolDescription={selectedTool.description}
237249 scopeId={scopeId}
250+ policy={selectedTool.policy}
238251 />
239252 ) : (
240253 <ToolDetailEmpty hasTools={sourceTools.length > 0} />
warden.toml+10−0
@@ -65,3 +65,13 @@ paths = [ · 64 unmodified lines
6565 ".github/workflows/**/*.yml",
6666 ".github/workflows/**/*.yaml",
6767 ]
68+
69+# Local skill — no `remote =`, resolved from `.agents/skills/<name>/SKILL.md`.
70+[[skills]]
71+name = "wrdn-effect-atom-optimistic"
72+paths = [
73+ "packages/react/src/api/atoms.tsx",
74+ "packages/react/src/api/optimistic.tsx",
75+ "packages/react/src/pages/**/*.tsx",
76+ "packages/react/src/components/**/*.tsx",
77+]

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