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---
2name: wrdn-effect-atom-optimistic
3description: 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.
4allowed-tools: Read Grep Glob Bash
5---
6
7You 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
9This 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
11Trace. 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
151. **Find the mutation.** Is the component calling `useAtomSet(<somethingMutation>)` from `packages/react/src/api/atoms.tsx`? If not, this skill does not apply.
162. **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.
173. **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
234. **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.
245. **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
26When 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
54Do not invent `high`. Pick `low` when in doubt and explain why.
55
56## Reference patterns (TypeScript)
57
58The 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)
64import { useState } from "react";
65import { useAtomSet, useAtomValue } from "@effect-atom/atom-react";
66
67import { secretsAtom, updateSecret } from "../api/atoms";
68
69export 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
91The `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
97export const secretsOptimisticAtom = Atom.family((scopeId: ScopeId) =>
98 Atom.optimistic(secretsAtom(scopeId)),
99);
100
101export 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
116const secrets = useAtomValue(secretsOptimisticAtom(scopeId));
117const doUpdate = useAtomSet(updateSecretOptimistic(scopeId), { mode: "promise" });
118
119const handleEdit = (id: SecretId, value: string) =>
120 doUpdate({ path: { scopeId, secretId: id }, payload: { value } });
121```
122
123No 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)
129export const PendingResource = {
130 sources: "sources",
131 connectionRemovals: "connection-removals",
132 secrets: "secrets", // <-- new
133} as const;
134
135export interface PendingSecret {
136 readonly value: string;
137}
138
139export const useSecretsWithPending = (scopeId: ScopeId) => { /* ... */ };
140export const usePendingSecrets = () => { /* ... */ };
141```
142
143Flag 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.
149const optimistic = Atom.optimistic(secretsAtom(scopeId));
150const value = useAtomValue(optimistic);
151```
152
153```tsx
154// Safe: Atom.family memoizes per scopeId so transitions persist.
155export const secretsOptimisticAtom = Atom.family((scopeId: ScopeId) =>
156 Atom.optimistic(secretsAtom(scopeId)),
157);
158
159const value = useAtomValue(secretsOptimisticAtom(scopeId));
160```
161
162## Output Requirements
163
164For 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
172Group findings by severity. Lead with `medium`.
.skills/effect-atom-optimistic-updates/SKILL.md+252−0
@@ -0,0 +1,252 @@
1---
2name: effect-atom-optimistic-updates
3description: 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
8The `@effect-atom/atom-react` library ships first-class optimistic support via
9`Atom.optimistic` and `Atom.optimisticFn`. Use them directly. Do not write a
10custom "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
13sources/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
21If 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
23optimistic — just use the mutation directly with `reactivityKeys`.
24
25## Why not roll your own
26
27The naive approach — track `pending: Array<{id, value}>` in a separate atom and
28merge it with the server result — has a subtle race that bites on rapid edits:
29
301. User clicks "set action = block" → mutation A fires, pending entry written
312. User clicks "set action = approve" → mutation B fires, pending entry overwritten with B
323. **A's response returns first** → finally-block clears the pending entry → UI flickers back to the server's "block" value
334. B's response returns → UI shows "approve"
34
35Step 3 is the bug. Fixing it correctly requires per-call entry ids and "last
36entry per row id wins" merging — at which point you've reimplemented a worse
37version of `Atom.optimistic`'s transition tracking.
38
39`Atom.optimistic` solves this because the runtime reads the current optimistic
40state (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
42calls `refresh(self)` to pull the server's authoritative state.
43
44## Pattern
45
46Define 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
48wraps the query with `Atom.optimistic`, and each mutation gets a family that
49pipes the optimistic atom through `Atom.optimisticFn` with a reducer.
50
51```typescript
52import { Atom, Result } from "@effect-atom/atom-react";
53import type { ScopeId, PolicyId, ToolPolicyAction } from "@executor/sdk";
54
55import { ExecutorApiClient } from "./client";
56
57// 1. The plain query atom — same as before.
58export 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.
67export const createPolicy = ExecutorApiClient.mutation("policies", "create");
68export const updatePolicy = ExecutorApiClient.mutation("policies", "update");
69export 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.
73export 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.
80export 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
106Read from the optimistic atom; write through the optimistic mutation. The
107existing `reactivityKeys` plumbing still applies — pass them in the call.
108
109```typescript
110import { useAtomValue, useAtomSet } from "@effect-atom/atom-react";
111
112import {
113 policiesOptimisticAtom,
114 updatePolicyOptimistic,
115} from "../api/atoms";
116import { policyWriteKeys } from "../api/reactivity-keys";
117
118export 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
1411. **`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.
1442. **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.
1473. **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.
1514. **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
156For create flows the server assigns the canonical id. The reducer inserts a
157placeholder with a temp id — the post-commit refresh replaces it with the
158canonical row.
159
160```typescript
161export 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
190The placeholder doesn't need to roundtrip through the `id` field unless your
191list rendering keys on it (it usually does — `<Row key={p.id}>`). A unique
192prefix like `pending-` is fine.
193
194## Removes
195
196```typescript
197export 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
218You 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
228Net result: rapid edits look smooth, the last edit wins both visually and on
229the 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
243read, 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 @@
1CREATE 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.
17CREATE 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
43 "when": 1777000000000,43 "when": 1777000000000,
44 "tag": "0005_drop_connection_kind",44 "tag": "0005_drop_connection_kind",
45 "breakpoints": true45 "breakpoints": true
46 },
47 {
48 "idx": 6,
49 "version": "7",
50 "when": 1777444003590,
51 "tag": "0006_add_tool_policy",
52 "breakpoints": true
46 }53 }
47 ]54 ]
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
489 );489 );
490 490
491});491});
492
493layer(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
362 };362 };
363 363
364 const envelope = opts.parseBody ? yield* readJsonRpcEnvelope(request) : Option.none();364 const envelope = opts.parseBody ? yield* readJsonRpcEnvelope(request) : Option.none();
365 365 const attrs = {
366 yield* Effect.annotateCurrentSpan({
367 ...baseAttrs,366 ...baseAttrs,
368 ...rpcAttrs(envelope),367 ...rpcAttrs(envelope),
369 });368 "mcp.request.parse_body": opts.parseBody,
370 }).pipe(369 };
371 Effect.withSpan("mcp.request.annotate", {370
372 attributes: {371 yield* Effect.annotateCurrentSpan(attrs);
373 "mcp.request.method": request.method,372 yield* Effect.annotateCurrentSpan(attrs).pipe(Effect.withSpan("mcp.request.annotate"));
374 "mcp.request.parse_body": opts.parseBody,373 });
375 },
376 }),
377 );
378 374
379// ---------------------------------------------------------------------------375// ---------------------------------------------------------------------------
380// OAuth metadata endpoints376// OAuth metadata endpoints
apps/cloud/src/routeTree.gen.ts+21−0
@@ -11,6 +11,7 @@ · 10 unmodified lines
11import { Route as rootRouteImport } from './routes/__root'11import { Route as rootRouteImport } from './routes/__root'
12import { Route as ToolsRouteImport } from './routes/tools'12import { Route as ToolsRouteImport } from './routes/tools'
13import { Route as SecretsRouteImport } from './routes/secrets'13import { Route as SecretsRouteImport } from './routes/secrets'
14import { Route as PoliciesRouteImport } from './routes/policies'
14import { Route as OrgRouteImport } from './routes/org'15import { Route as OrgRouteImport } from './routes/org'
15import { Route as ConnectionsRouteImport } from './routes/connections'16import { Route as ConnectionsRouteImport } from './routes/connections'
16import { Route as BillingRouteImport } from './routes/billing'17import { Route as BillingRouteImport } from './routes/billing'
@@ -29,6 +30,11 @@ const SecretsRoute = SecretsRouteImport.update({ · 12 unmodified lines
29 path: '/secrets',30 path: '/secrets',
30 getParentRoute: () => rootRouteImport,31 getParentRoute: () => rootRouteImport,
31} as any)32} as any)
33const PoliciesRoute = PoliciesRouteImport.update({
34 id: '/policies',
35 path: '/policies',
36 getParentRoute: () => rootRouteImport,
37} as any)
32const OrgRoute = OrgRouteImport.update({38const OrgRoute = OrgRouteImport.update({
33 id: '/org',39 id: '/org',
34 path: '/org',40 path: '/org',
@@ -70,6 +76,7 @@ export interface FileRoutesByFullPath { · 35 unmodified lines
70 '/billing': typeof BillingRoute76 '/billing': typeof BillingRoute
71 '/connections': typeof ConnectionsRoute77 '/connections': typeof ConnectionsRoute
72 '/org': typeof OrgRoute78 '/org': typeof OrgRoute
79 '/policies': typeof PoliciesRoute
73 '/secrets': typeof SecretsRoute80 '/secrets': typeof SecretsRoute
74 '/tools': typeof ToolsRoute81 '/tools': typeof ToolsRoute
75 '/billing/plans': typeof BillingPlansRoute82 '/billing/plans': typeof BillingPlansRoute
@@ -81,6 +88,7 @@ export interface FileRoutesByTo { · 5 unmodified lines
81 '/billing': typeof BillingRoute88 '/billing': typeof BillingRoute
82 '/connections': typeof ConnectionsRoute89 '/connections': typeof ConnectionsRoute
83 '/org': typeof OrgRoute90 '/org': typeof OrgRoute
91 '/policies': typeof PoliciesRoute
84 '/secrets': typeof SecretsRoute92 '/secrets': typeof SecretsRoute
85 '/tools': typeof ToolsRoute93 '/tools': typeof ToolsRoute
86 '/billing/plans': typeof BillingPlansRoute94 '/billing/plans': typeof BillingPlansRoute
@@ -93,6 +101,7 @@ export interface FileRoutesById { · 6 unmodified lines
93 '/billing': typeof BillingRoute101 '/billing': typeof BillingRoute
94 '/connections': typeof ConnectionsRoute102 '/connections': typeof ConnectionsRoute
95 '/org': typeof OrgRoute103 '/org': typeof OrgRoute
104 '/policies': typeof PoliciesRoute
96 '/secrets': typeof SecretsRoute105 '/secrets': typeof SecretsRoute
97 '/tools': typeof ToolsRoute106 '/tools': typeof ToolsRoute
98 '/billing_/plans': typeof BillingPlansRoute107 '/billing_/plans': typeof BillingPlansRoute
@@ -106,6 +115,7 @@ export interface FileRouteTypes { · 7 unmodified lines
106 | '/billing'115 | '/billing'
107 | '/connections'116 | '/connections'
108 | '/org'117 | '/org'
118 | '/policies'
109 | '/secrets'119 | '/secrets'
110 | '/tools'120 | '/tools'
111 | '/billing/plans'121 | '/billing/plans'
@@ -117,6 +127,7 @@ export interface FileRouteTypes { · 5 unmodified lines
117 | '/billing'127 | '/billing'
118 | '/connections'128 | '/connections'
119 | '/org'129 | '/org'
130 | '/policies'
120 | '/secrets'131 | '/secrets'
121 | '/tools'132 | '/tools'
122 | '/billing/plans'133 | '/billing/plans'
@@ -128,6 +139,7 @@ export interface FileRouteTypes { · 5 unmodified lines
128 | '/billing'139 | '/billing'
129 | '/connections'140 | '/connections'
130 | '/org'141 | '/org'
142 | '/policies'
131 | '/secrets'143 | '/secrets'
132 | '/tools'144 | '/tools'
133 | '/billing_/plans'145 | '/billing_/plans'
@@ -140,6 +152,7 @@ export interface RootRouteChildren { · 6 unmodified lines
140 BillingRoute: typeof BillingRoute152 BillingRoute: typeof BillingRoute
141 ConnectionsRoute: typeof ConnectionsRoute153 ConnectionsRoute: typeof ConnectionsRoute
142 OrgRoute: typeof OrgRoute154 OrgRoute: typeof OrgRoute
155 PoliciesRoute: typeof PoliciesRoute
143 SecretsRoute: typeof SecretsRoute156 SecretsRoute: typeof SecretsRoute
144 ToolsRoute: typeof ToolsRoute157 ToolsRoute: typeof ToolsRoute
145 BillingPlansRoute: typeof BillingPlansRoute158 BillingPlansRoute: typeof BillingPlansRoute
@@ -163,6 +176,13 @@ declare module '@tanstack/react-router' { · 17 unmodified lines
163 preLoaderRoute: typeof SecretsRouteImport176 preLoaderRoute: typeof SecretsRouteImport
164 parentRoute: typeof rootRouteImport177 parentRoute: typeof rootRouteImport
165 }178 }
179 '/policies': {
180 id: '/policies'
181 path: '/policies'
182 fullPath: '/policies'
183 preLoaderRoute: typeof PoliciesRouteImport
184 parentRoute: typeof rootRouteImport
185 }
166 '/org': {186 '/org': {
167 id: '/org'187 id: '/org'
168 path: '/org'188 path: '/org'
@@ -220,6 +240,7 @@ const rootRouteChildren: RootRouteChildren = { · 51 unmodified lines
220 BillingRoute: BillingRoute,240 BillingRoute: BillingRoute,
221 ConnectionsRoute: ConnectionsRoute,241 ConnectionsRoute: ConnectionsRoute,
222 OrgRoute: OrgRoute,242 OrgRoute: OrgRoute,
243 PoliciesRoute: PoliciesRoute,
223 SecretsRoute: SecretsRoute,244 SecretsRoute: SecretsRoute,
224 ToolsRoute: ToolsRoute,245 ToolsRoute: ToolsRoute,
225 BillingPlansRoute: BillingPlansRoute,246 BillingPlansRoute: BillingPlansRoute,
apps/cloud/src/routes/policies.tsx+6−0
@@ -0,0 +1,6 @@
1import { createFileRoute } from "@tanstack/react-router";
2import { PoliciesPage } from "@executor/react/pages/policies";
3
4export 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
83 index("connection_provider_idx").on(table.provider),83 index("connection_provider_idx").on(table.provider),
84]);84]);
85 85
86export 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
86export const openapi_source = pgTable("openapi_source", {107export const openapi_source = pgTable("openapi_source", {
87 id: text('id').notNull(),108 id: text('id').notNull(),
88 scope_id: text('scope_id').notNull(),109 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
361 const isHome = props.pathname === "/";361 const isHome = props.pathname === "/";
362 const isSecrets = props.pathname === "/secrets";362 const isSecrets = props.pathname === "/secrets";
363 const isConnections = props.pathname === "/connections";363 const isConnections = props.pathname === "/connections";
364 const isPolicies = props.pathname === "/policies";
364 const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/");365 const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/");
365 const isOrg = props.pathname === "/org";366 const isOrg = props.pathname === "/org";
366 367
@@ -378,6 +379,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 11 unmodified lines
378 <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} />379 <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} />
379 <NavItem to="/connections" label="Connections" active={isConnections} onNavigate={props.onNavigate} />380 <NavItem to="/connections" label="Connections" active={isConnections} onNavigate={props.onNavigate} />
380 <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} />381 <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} />
382 <NavItem to="/policies" label="Policies" active={isPolicies} onNavigate={props.onNavigate} />
381 <NavItem to="/org" label="Organization" active={isOrg} onNavigate={props.onNavigate} />383 <NavItem to="/org" label="Organization" active={isOrg} onNavigate={props.onNavigate} />
382 <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} />384 <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} />
383 385
apps/local/drizzle/0004_add_tool_policy.sql+17−0
@@ -0,0 +1,17 @@
1CREATE 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.
17CREATE 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
29 "when": 1776976132767,29 "when": 1776976132767,
30 "tag": "0003_little_silk_fever",30 "tag": "0003_little_silk_fever",
31 "breakpoints": true31 "breakpoints": true
32 },
33 {
34 "idx": 4,
35 "version": "6",
36 "when": 1777800000000,
37 "tag": "0004_add_tool_policy",
38 "breakpoints": true
32 }39 }
33 ]40 ]
34}41}
apps/local/src/server/executor-schema.ts+19−0
@@ -83,6 +83,25 @@ export const connection = sqliteTable("connection", { · 82 unmodified lines
83 index("connection_provider_idx").on(table.provider),83 index("connection_provider_idx").on(table.provider),
84]);84]);
85 85
86export 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
86export const openapi_source = sqliteTable("openapi_source", {105export const openapi_source = sqliteTable("openapi_source", {
87 id: text('id').notNull(),106 id: text('id').notNull(),
88 scope_id: text('scope_id').notNull(),107 scope_id: text('scope_id').notNull(),
bun.lock+4−0
@@ -321,6 +321,7 @@ · 320 unmodified lines
321 "@effect/platform": "catalog:",321 "@effect/platform": "catalog:",
322 "@executor/storage-core": "workspace:*",322 "@executor/storage-core": "workspace:*",
323 "effect": "catalog:",323 "effect": "catalog:",
324 "fractional-indexing": "^3.2.0",
324 },325 },
325 "devDependencies": {326 "devDependencies": {
326 "@effect/vitest": "catalog:",327 "@effect/vitest": "catalog:",
@@ -788,6 +789,7 @@ · 461 unmodified lines
788 "cmdk": "^1.1.1",789 "cmdk": "^1.1.1",
789 "effect": "catalog:",790 "effect": "catalog:",
790 "embla-carousel-react": "^8.6.0",791 "embla-carousel-react": "^8.6.0",
792 "fractional-indexing": "^3.2.0",
791 "input-otp": "^1.4.2",793 "input-otp": "^1.4.2",
792 "lucide-react": "^1.7.0",794 "lucide-react": "^1.7.0",
793 "radix-ui": "^1.4.3",795 "radix-ui": "^1.4.3",
@@ -3143,6 +3145,8 @@ · 2349 unmodified lines
3143 3145
3144 "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],3146 "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
3145 3147
3148 "fractional-indexing": ["fractional-indexing@3.2.0", "", {}, "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ=="],
3149
3146 "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=="],3150 "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=="],
3147 3151
3148 "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],3152 "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
7import { ConnectionsApi } from "./connections/api";7import { ConnectionsApi } from "./connections/api";
8import { ExecutionsApi } from "./executions/api";8import { ExecutionsApi } from "./executions/api";
9import { ScopeApi } from "./scope/api";9import { ScopeApi } from "./scope/api";
10import { PoliciesApi } from "./policies/api";
10 11
11export const CoreExecutorApi = HttpApi.make("executor")12export const CoreExecutorApi = HttpApi.make("executor")
12 .add(ToolsApi)13 .add(ToolsApi)
@@ -15,6 +16,7 @@ export const CoreExecutorApi = HttpApi.make("executor") · 2 unmodified lines
15 .add(ConnectionsApi)16 .add(ConnectionsApi)
16 .add(ExecutionsApi)17 .add(ExecutionsApi)
17 .add(ScopeApi)18 .add(ScopeApi)
19 .add(PoliciesApi)
18 .annotateContext(20 .annotateContext(
19 OpenApi.annotations({21 OpenApi.annotations({
20 title: "Executor API",22 title: "Executor API",
packages/core/api/src/handlers/index.ts+3−0
@@ -6,6 +6,7 @@ import { SecretsHandlers } from "./secrets"; · 5 unmodified lines
6import { ConnectionsHandlers } from "./connections";6import { ConnectionsHandlers } from "./connections";
7import { ScopeHandlers } from "./scope";7import { ScopeHandlers } from "./scope";
8import { ExecutionsHandlers } from "./executions";8import { ExecutionsHandlers } from "./executions";
9import { PoliciesHandlers } from "./policies";
9 10
10export { ToolsHandlers } from "./tools";11export { ToolsHandlers } from "./tools";
11export { SourcesHandlers } from "./sources";12export { SourcesHandlers } from "./sources";
@@ -13,6 +14,7 @@ export { SecretsHandlers } from "./secrets"; · 1 unmodified line
13export { ConnectionsHandlers } from "./connections";14export { ConnectionsHandlers } from "./connections";
14export { ScopeHandlers } from "./scope";15export { ScopeHandlers } from "./scope";
15export { ExecutionsHandlers } from "./executions";16export { ExecutionsHandlers } from "./executions";
17export { PoliciesHandlers } from "./policies";
16 18
17export const CoreHandlers = Layer.mergeAll(19export const CoreHandlers = Layer.mergeAll(
18 ToolsHandlers,20 ToolsHandlers,
@@ -21,4 +23,5 @@ export const CoreHandlers = Layer.mergeAll( · 2 unmodified lines
21 ConnectionsHandlers,23 ConnectionsHandlers,
22 ScopeHandlers,24 ScopeHandlers,
23 ExecutionsHandlers,25 ExecutionsHandlers,
26 PoliciesHandlers,
24);27);
packages/core/api/src/handlers/policies.ts+67−0
@@ -0,0 +1,67 @@
1import { HttpApiBuilder } from "@effect/platform";
2import { Effect } from "effect";
3import type { ToolPolicy } from "@executor/sdk";
4
5import { ExecutorApi } from "../api";
6import { ExecutorService } from "../services";
7import { capture } from "@executor/api";
8
9const 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
19export 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
42 .handle("tools", ({ path }) =>42 .handle("tools", ({ path }) =>
43 capture(Effect.gen(function* () {43 capture(Effect.gen(function* () {
44 const executor = yield* ExecutorService;44 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.
45 const tools = yield* executor.tools.list({50 const tools = yield* executor.tools.list({
46 sourceId: path.sourceId,51 sourceId: path.sourceId,
47 includeAnnotations: false,52 includeAnnotations: true,
53 includeBlocked: true,
48 });54 });
49 return tools.map((t) => ({55 return tools.map((t) => ({
50 id: ToolId.make(t.id),56 id: ToolId.make(t.id),
@@ -53,6 +59,8 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 2 unmodified lines
53 name: t.name,59 name: t.name,
54 description: t.description,60 description: t.description,
55 mayElicit: t.annotations?.mayElicit,61 mayElicit: t.annotations?.mayElicit,
62 requiresApproval: t.annotations?.requiresApproval,
63 approvalDescription: t.annotations?.approvalDescription,
56 }));64 }));
57 })),65 })),
58 )66 )
packages/core/api/src/index.ts+1−0
@@ -5,6 +5,7 @@ export { SecretsApi } from "./secrets/api"; · 4 unmodified lines
5export { ConnectionsApi } from "./connections/api";5export { ConnectionsApi } from "./connections/api";
6export { ExecutionsApi } from "./executions/api";6export { ExecutionsApi } from "./executions/api";
7export { ScopeApi } from "./scope/api";7export { ScopeApi } from "./scope/api";
8export { PoliciesApi } from "./policies/api";
8export {9export {
9 InternalError,10 InternalError,
10 ErrorCapture,11 ErrorCapture,
packages/core/api/src/policies/api.ts+65−0
@@ -0,0 +1,65 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";
3import { PolicyId, ScopeId, ToolPolicyActionSchema } from "@executor/sdk";
4
5import { InternalError } from "../observability";
6
7// ---------------------------------------------------------------------------
8// Params
9// ---------------------------------------------------------------------------
10
11const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
12const policyIdParam = HttpApiSchema.param("policyId", PolicyId);
13
14// ---------------------------------------------------------------------------
15// Response / payload schemas
16// ---------------------------------------------------------------------------
17
18const 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
28const CreateToolPolicyPayload = Schema.Struct({
29 pattern: Schema.String,
30 action: ToolPolicyActionSchema,
31 position: Schema.optional(Schema.String),
32});
33
34const 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
44export 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
46 name: Schema.String,46 name: Schema.String,
47 description: Schema.optional(Schema.String),47 description: Schema.optional(Schema.String),
48 mayElicit: Schema.optional(Schema.Boolean),48 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),
49});53});
50 54
51const DetectRequest = Schema.Struct({55const DetectRequest = Schema.Struct({
packages/core/execution/src/tool-invoker.test.ts+49−1
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
10} from "@executor/sdk";10} from "@executor/sdk";
11import { makeQuickJsExecutor } from "@executor/runtime-quickjs";11import { makeQuickJsExecutor } from "@executor/runtime-quickjs";
12import { createExecutionEngine } from "./engine";12import { createExecutionEngine } from "./engine";
13import { describeTool, searchTools } from "./tool-invoker";13import { describeTool, makeExecutorToolInvoker, searchTools } from "./tool-invoker";
14 14
15const codeExecutor = makeQuickJsExecutor();15const codeExecutor = makeQuickJsExecutor();
16 16
@@ -105,6 +105,33 @@ const crmPlugin = definePlugin(() => ({ · 88 unmodified lines
105 ],105 ],
106}));106}));
107 107
108const 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
108const makeSearchExecutor = () =>135const makeSearchExecutor = () =>
109 createExecutor(136 createExecutor(
110 makeTestConfig({ plugins: [githubPlugin(), crmPlugin()] as const }),137 makeTestConfig({ plugins: [githubPlugin(), crmPlugin()] as const }),
@@ -252,6 +279,27 @@ describe("tool discovery", () => { · 141 unmodified lines
252 expect(String(invalidSearch.result)).toContain("tools.search expects an object");279 expect(String(invalidSearch.result)).toContain("tools.search expects an object");
253 }),280 }),
254 );281 );
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 );
255});303});
256 304
257// ---------------------------------------------------------------------------305// ---------------------------------------------------------------------------
packages/core/execution/src/tool-invoker.ts+32−1
@@ -22,6 +22,31 @@ const extractSourceNamespace = (path: string): string => { · 21 unmodified lines
22 return idx === -1 ? path : path.slice(0, idx);22 return idx === -1 ? path : path.slice(0, idx);
23};23};
24 24
25const stringifyUnknown = (value: unknown): string => {
26 try {
27 return JSON.stringify(value) ?? String(value);
28 } catch {
29 return String(value);
30 }
31};
32
33const 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
39const messageFromErrorLike = (value: unknown): string | undefined => {
40 if (value instanceof Error || hasStringMessage(value)) {
41 return value.message;
42 }
43 return undefined;
44};
45
46const renderToolErrorMessage = (error: unknown): string =>
47 messageFromErrorLike(error) ??
48 (typeof error === "undefined" ? "Tool execution failed" : stringifyUnknown(error));
49
25/**50/**
26 * Bridges QuickJS `tools.someSource.someOp(args)` calls into51 * Bridges QuickJS `tools.someSource.someOp(args)` calls into
27 * `executor.tools.invoke(toolId, args)`.52 * `executor.tools.invoke(toolId, args)`.
@@ -63,7 +88,13 @@ export const makeExecutorToolInvoker = ( · 35 unmodified lines
63 (r as { error?: unknown }).error !== null &&88 (r as { error?: unknown }).error !== null &&
64 (r as { error?: unknown }).error !== undefined89 (r as { error?: unknown }).error !== undefined
65 ) {90 ) {
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 );
67 }98 }
68 if (r !== null && typeof r === "object" && "data" in r) {99 if (r !== null && typeof r === "object" && "data" in r) {
69 return (r as { data: unknown }).data;100 return (r as { data: unknown }).data;
packages/core/sdk/package.json+2−1
@@ -46,7 +46,8 @@ · 45 unmodified lines
46 "dependencies": {46 "dependencies": {
47 "@effect/platform": "catalog:",47 "@effect/platform": "catalog:",
48 "@executor/storage-core": "workspace:*",48 "@executor/storage-core": "workspace:*",
49 "effect": "catalog:"49 "effect": "catalog:",
50 "fractional-indexing": "^3.2.0"
50 },51 },
51 "devDependencies": {52 "devDependencies": {
52 "@effect/vitest": "catalog:",53 "@effect/vitest": "catalog:",
packages/core/sdk/src/core-schema.ts+60−0
@@ -148,6 +148,37 @@ export const coreSchema = { · 147 unmodified lines
148 updated_at: { type: "date", required: true },148 updated_at: { type: "date", required: true },
149 },149 },
150 },150 },
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 },
151} as const satisfies DBSchema;182} as const satisfies DBSchema;
152 183
153export type CoreSchema = typeof coreSchema;184export type CoreSchema = typeof coreSchema;
@@ -176,6 +207,35 @@ export type ConnectionRow = InferDBFieldsOutput< · 22 unmodified lines
176> &207> &
177 Record<string, unknown>;208 Record<string, unknown>;
178 209
210export 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
227export type ToolPolicyAction = "approve" | "require_approval" | "block";
228
229export const TOOL_POLICY_ACTIONS = [
230 "approve",
231 "require_approval",
232 "block",
233] as const satisfies readonly ToolPolicyAction[];
234
235export const isToolPolicyAction = (value: unknown): value is ToolPolicyAction =>
236 typeof value === "string" &&
237 (TOOL_POLICY_ACTIONS as readonly string[]).includes(value);
238
179// ---------------------------------------------------------------------------239// ---------------------------------------------------------------------------
180// Tool annotations — default-policy metadata the executor consults240// Tool annotations — default-policy metadata the executor consults
181// before invocation. Returned by `plugin.resolveAnnotations` (dynamic241// 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
40 },40 },
41) {}41) {}
42 42
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. */
47export class ToolBlockedError extends Schema.TaggedError<ToolBlockedError>()(
48 "ToolBlockedError",
49 {
50 toolId: ToolId,
51 pattern: Schema.String,
52 },
53) {}
54
43// ---------------------------------------------------------------------------55// ---------------------------------------------------------------------------
44// Source lifecycle56// Source lifecycle
45// ---------------------------------------------------------------------------57// ---------------------------------------------------------------------------
@@ -138,6 +150,7 @@ export type ExecutorError = · 92 unmodified lines
138 | ToolInvocationError150 | ToolInvocationError
139 | PluginNotLoadedError151 | PluginNotLoadedError
140 | NoHandlerError152 | NoHandlerError
153 | ToolBlockedError
141 | SourceNotFoundError154 | SourceNotFoundError
142 | SourceRemovalNotAllowedError155 | SourceRemovalNotAllowedError
143 | SecretNotFoundError156 | SecretNotFoundError
packages/core/sdk/src/executor.ts+276−6
@@ -1,4 +1,5 @@
1import { Deferred, Effect, FiberRef, Option, Schema } from "effect";1import { Deferred, Effect, FiberRef, Option, Schema } from "effect";
2import { generateKeyBetween } from "fractional-indexing";
2import {3import {
3 StorageError,4 StorageError,
4 typedAdapter,5 typedAdapter,
@@ -23,12 +24,14 @@ import { · 18 unmodified lines
23} from "./connections";24} from "./connections";
24import {25import {
25 coreSchema,26 coreSchema,
27 isToolPolicyAction,
26 type ConnectionRow,28 type ConnectionRow,
27 type CoreSchema,29 type CoreSchema,
28 type DefinitionsInput,30 type DefinitionsInput,
29 type SourceInput,31 type SourceInput,
30 type SourceRow,32 type SourceRow,
31 type ToolAnnotations,33 type ToolAnnotations,
34 type ToolPolicyRow,
32 type ToolRow,35 type ToolRow,
33} from "./core-schema";36} from "./core-schema";
34import {37import {
@@ -47,10 +50,21 @@ import { · 12 unmodified lines
47 PluginNotLoadedError,50 PluginNotLoadedError,
48 SecretOwnedByConnectionError,51 SecretOwnedByConnectionError,
49 SourceRemovalNotAllowedError,52 SourceRemovalNotAllowedError,
53 ToolBlockedError,
50 ToolInvocationError,54 ToolInvocationError,
51 ToolNotFoundError,55 ToolNotFoundError,
52} from "./errors";56} from "./errors";
53import { ConnectionId, ScopeId, SecretId, ToolId } from "./ids";57import { ConnectionId, ScopeId, SecretId, ToolId } from "./ids";
58import {
59 comparePolicyRow,
60 isValidPattern,
61 resolveToolPolicy,
62 rowToToolPolicy,
63 type CreateToolPolicyInput,
64 type PolicyMatch,
65 type ToolPolicy,
66 type UpdateToolPolicyInput,
67} from "./policies";
54import type {68import type {
55 AnyPlugin,69 AnyPlugin,
56 Elicit,70 Elicit,
@@ -142,6 +156,7 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 85 unmodified lines
142 ) => Effect.Effect<156 ) => Effect.Effect<
143 unknown,157 unknown,
144 | ToolNotFoundError158 | ToolNotFoundError
159 | ToolBlockedError
145 | PluginNotLoadedError160 | PluginNotLoadedError
146 | NoHandlerError161 | NoHandlerError
147 | ToolInvocationError162 | ToolInvocationError
@@ -228,6 +243,29 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 80 unmodified lines
228 readonly providers: () => Effect.Effect<readonly string[]>;243 readonly providers: () => Effect.Effect<readonly string[]>;
229 };244 };
230 245
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
231 readonly close: () => Effect.Effect<void, StorageFailure>;269 readonly close: () => Effect.Effect<void, StorageFailure>;
232} & PluginExtensions<TPlugins>;270} & PluginExtensions<TPlugins>;
233 271
@@ -1944,11 +1982,37 @@ export const createExecutor = < · 1710 unmodified lines
1944 for (const row of dynamicDeduped) {1982 for (const row of dynamicDeduped) {
1945 out.push(rowToTool(row, annotations.get(row.id)));1983 out.push(rowToTool(row, annotations.get(row.id)));
1946 }1984 }
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
1948 yield* Effect.annotateCurrentSpan({2011 yield* Effect.annotateCurrentSpan({
1949 "executor.tools.static_count": staticTools.size,2012 "executor.tools.static_count": staticTools.size,
1950 "executor.tools.dynamic_count": dynamicDeduped.length,2013 "executor.tools.dynamic_count": dynamicDeduped.length,
1951 "executor.tools.result_count": result.length,2014 "executor.tools.result_count": result.length,
2015 "executor.tools.blocked_count": blockedCount,
1952 });2016 });
1953 return result;2017 return result;
1954 }).pipe(Effect.withSpan("executor.tools.list"));2018 }).pipe(Effect.withSpan("executor.tools.list"));
@@ -2130,18 +2194,51 @@ export const createExecutor = < · 175 unmodified lines
2130 });2194 });
2131 };2195 };
2132 2196
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
2133 const enforceApproval = (2216 const enforceApproval = (
2134 annotations: ToolAnnotations | undefined,2217 annotations: ToolAnnotations | undefined,
2135 toolId: string,2218 toolId: string,
2136 args: unknown,2219 args: unknown,
2137 options: InvokeOptions | undefined,2220 options: InvokeOptions | undefined,
2221 policy: PolicyMatch | undefined,
2138 ) =>2222 ) =>
2139 Effect.gen(function* () {2223 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
2141 const handler = resolveElicitationHandler(options);2233 const handler = resolveElicitationHandler(options);
2142 const tid = ToolId.make(toolId);2234 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}?`;
2143 const request = new FormElicitation({2240 const request = new FormElicitation({
2144 message: annotations.approvalDescription ?? `Approve ${toolId}?`,2241 message,
2145 requestedSchema: {},2242 requestedSchema: {},
2146 });2243 });
2147 const response = yield* handler({ toolId: tid, args, request });2244 const response = yield* handler({ toolId: tid, args, request });
@@ -2174,6 +2271,19 @@ export const createExecutor = < · 26 unmodified lines
2174 ),2271 ),
2175 );2272 );
2176 2273
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
2177 // Static path — O(1) map lookup, no DB hit.2287 // Static path — O(1) map lookup, no DB hit.
2178 const staticEntry = staticTools.get(toolId);2288 const staticEntry = staticTools.get(toolId);
2179 if (staticEntry) {2289 if (staticEntry) {
@@ -2188,6 +2298,7 @@ export const createExecutor = < · 8 unmodified lines
2188 toolId,2298 toolId,
2189 args,2299 args,
2190 options,2300 options,
2301 policy,
2191 ).pipe(Effect.withSpan("executor.tool.enforce_approval"));2302 ).pipe(Effect.withSpan("executor.tool.enforce_approval"));
2192 return yield* wrapInvocationError(2303 return yield* wrapInvocationError(
2193 staticEntry.tool.handler({2304 staticEntry.tool.handler({
@@ -2237,9 +2348,11 @@ export const createExecutor = < · 43 unmodified lines
2237 // has a resolver. Cheap because the plugin typically already2348 // has a resolver. Cheap because the plugin typically already
2238 // needs to load its enrichment data to invoke the tool —2349 // needs to load its enrichment data to invoke the tool —
2239 // implementations should structure their resolver + invokeTool2350 // 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.
2241 let annotations: ToolAnnotations | undefined;2354 let annotations: ToolAnnotations | undefined;
2242 if (runtime.plugin.resolveAnnotations) {2355 if (policy?.action !== "approve" && runtime.plugin.resolveAnnotations) {
2243 const map = yield* runtime.plugin2356 const map = yield* runtime.plugin
2244 .resolveAnnotations({2357 .resolveAnnotations({
2245 ctx: runtime.ctx,2358 ctx: runtime.ctx,
@@ -2249,7 +2362,7 @@ export const createExecutor = < · 3 unmodified lines
2249 .pipe(Effect.withSpan("executor.tool.resolve_annotations"));2362 .pipe(Effect.withSpan("executor.tool.resolve_annotations"));
2250 annotations = map[toolId];2363 annotations = map[toolId];
2251 }2364 }
2252 yield* enforceApproval(annotations, toolId, args, options).pipe(2365 yield* enforceApproval(annotations, toolId, args, options, policy).pipe(
2253 Effect.withSpan("executor.tool.enforce_approval"),2366 Effect.withSpan("executor.tool.enforce_approval"),
2254 );2367 );
2255 2368
@@ -2376,6 +2489,156 @@ export const createExecutor = < · 120 unmodified lines
2376 return "missing";2489 return "missing";
2377 });2490 });
2378 2491
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
2379 const close = () =>2642 const close = () =>
2380 Effect.gen(function* () {2643 Effect.gen(function* () {
2381 for (const runtime of runtimes.values()) {2644 for (const runtime of runtimes.values()) {
@@ -2430,6 +2693,13 @@ export const createExecutor = < · 48 unmodified lines
2430 Array.from(connectionProviders.keys()) as readonly string[],2693 Array.from(connectionProviders.keys()) as readonly string[],
2431 ),2694 ),
2432 },2695 },
2696 policies: {
2697 list: policiesList,
2698 create: policiesCreate,
2699 update: policiesUpdate,
2700 remove: policiesRemove,
2701 resolve: policiesResolve,
2702 },
2433 close,2703 close,
2434 };2704 };
2435 2705
packages/core/sdk/src/index.ts+22−0
@@ -32,6 +32,7 @@ export { Scope } from "./scope"; · 31 unmodified lines
32export {32export {
33 ToolNotFoundError,33 ToolNotFoundError,
34 ToolInvocationError,34 ToolInvocationError,
35 ToolBlockedError,
35 NoHandlerError,36 NoHandlerError,
36 SourceNotFoundError,37 SourceNotFoundError,
37 SourceRemovalNotAllowedError,38 SourceRemovalNotAllowedError,
@@ -58,6 +59,8 @@ export { · 20 unmodified lines
58// Core schema59// Core schema
59export {60export {
60 coreSchema,61 coreSchema,
62 isToolPolicyAction,
63 TOOL_POLICY_ACTIONS,
61 type CoreSchema,64 type CoreSchema,
62 type SourceInput,65 type SourceInput,
63 type SourceInputTool,66 type SourceInputTool,
@@ -66,10 +69,29 @@ export { · 2 unmodified lines
66 type DefinitionRow,69 type DefinitionRow,
67 type SecretRow,70 type SecretRow,
68 type ConnectionRow,71 type ConnectionRow,
72 type ToolPolicyRow,
73 type ToolPolicyAction,
69 type DefinitionsInput,74 type DefinitionsInput,
70 type ToolAnnotations,75 type ToolAnnotations,
71} from "./core-schema";76} from "./core-schema";
72 77
78// Tool policies
79export {
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
73// Secrets95// Secrets
74export {96export {
75 SecretRef,97 SecretRef,
packages/core/sdk/src/policies.test.ts+614−0
@@ -0,0 +1,614 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import { generateKeyBetween } from "fractional-indexing";
4
5import type { ToolPolicyRow } from "./core-schema";
6import { PolicyId } from "./ids";
7import { createExecutor } from "./executor";
8import {
9 ElicitationResponse,
10 type ElicitationHandler,
11} from "./elicitation";
12import {
13 effectivePolicyFromSorted,
14 isValidPattern,
15 matchPattern,
16 resolveToolPolicy,
17} from "./policies";
18import { definePlugin, defineSchema } from "./plugin";
19import { makeTestConfig } from "./testing";
20
21// ---------------------------------------------------------------------------
22// Pure unit tests — pattern matcher + resolution. No executor required.
23// ---------------------------------------------------------------------------
24
25describe("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
58describe("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
83describe("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
191describe("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
251const recordingHandler = (calls: { count: number }): ElicitationHandler =>
252 (() => {
253 calls.count++;
254 return Effect.succeed(new ElicitationResponse({ action: "accept" }));
255 }) as ElicitationHandler;
256
257const decliningHandler: ElicitationHandler = () =>
258 Effect.succeed(new ElicitationResponse({ action: "decline" }));
259
260const policyTestSchema = defineSchema({
261 ptest_marker: {
262 fields: {
263 id: { type: "string", required: true },
264 },
265 },
266});
267
268const 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
310const 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
318describe("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
487describe("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
538describe("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
7import { Schema } from "effect";
8
9import type { ToolPolicyAction, ToolPolicyRow } from "./core-schema";
10import { 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
18export 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
31export 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
40export 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
53export 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
70export type PolicySource = "user" | "plugin-default";
71
72export 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
91export 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
107export 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.
136export 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
149export 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
187const liftPlugin = (
188 defaultRequiresApproval: boolean | undefined,
189): EffectivePolicy =>
190 defaultRequiresApproval
191 ? { action: "require_approval", source: "plugin-default" }
192 : { action: "approve", source: "plugin-default" };
193
194const liftUser = (match: PolicyMatch): EffectivePolicy => ({
195 action: match.action,
196 source: "user",
197 pattern: match.pattern,
198 policyId: match.policyId,
199});
200
201export 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
211export 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
233export 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
248export 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
107 readonly query?: string;107 readonly query?: string;
108 /** Resolve plugin-derived annotations. Defaults to true. */108 /** Resolve plugin-derived annotations. Defaults to true. */
109 readonly includeAnnotations?: boolean;109 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;
110}115}
packages/kernel/runtime-dynamic-worker/src/executor.ts+9−0
@@ -196,6 +196,15 @@ export const renderWorkerError = (error: SerializedWorkerError): string => { · 195 unmodified lines
196 return error.primary;196 return error.primary;
197 }197 }
198 198
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
199 if (typeof error.primary === "object" && error.primary !== null) {208 if (typeof error.primary === "object" && error.primary !== null) {
200 try {209 try {
201 return JSON.stringify(error.primary);210 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
254 expect(result.result).toBeNull();254 expect(result.result).toBeNull();
255 });255 });
256 256
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
257 it("handles multiple tool calls in sequence", async () => {275 it("handles multiple tool calls in sequence", async () => {
258 const executor = makeDynamicWorkerExecutor({ loader });276 const executor = makeDynamicWorkerExecutor({ loader });
259 const invoker = makeInvoker(({ path }) => {277 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
45 45
46const toErrorMessage = (cause: unknown): string => {46const toErrorMessage = (cause: unknown): string => {
47 if (typeof cause === "object" && cause !== null) {47 if (typeof cause === "object" && cause !== null) {
48 const stack = "stack" in cause && typeof cause.stack === "string" ? cause.stack : undefined;
49 const message =48 const message =
50 "message" in cause && typeof cause.message === "string" ? cause.message : undefined;49 "message" in cause && typeof cause.message === "string" ? cause.message : undefined;
51 50
52 if (stack) {
53 return stack;
54 }
55
56 if (message) {51 if (message) {
57 return message;52 return message;
58 }53 }
54
55 const stack = "stack" in cause && typeof cause.stack === "string" ? cause.stack : undefined;
56
57 if (stack) {
58 return stack;
59 }
59 }60 }
60 61
61 const error = toError(cause);62 const error = toError(cause);
packages/plugins/keychain/src/index.test.ts+11−3
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
10import { keychainPlugin } from "./index";10import { keychainPlugin } from "./index";
11 11
12describe("keychain plugin", () => {12describe("keychain plugin", () => {
13 it.effect("registers keychain as a secret provider", () =>13 it.effect("exposes keychain metadata and registers a provider when reachable", () =>
14 Effect.gen(function* () {14 Effect.gen(function* () {
15 const executor = yield* createExecutor(15 const executor = yield* createExecutor(
16 makeTestConfig({16 makeTestConfig({
@@ -22,12 +22,12 @@ describe("keychain plugin", () => { · 5 unmodified lines
22 expect(executor.keychain.isSupported).toBeTypeOf("boolean");22 expect(executor.keychain.isSupported).toBeTypeOf("boolean");
23 23
24 const providers = yield* executor.secrets.providers();24 const providers = yield* executor.secrets.providers();
25 expect(providers).toContain("keychain");25 expect(providers.filter((provider) => provider === "keychain").length).toBeLessThanOrEqual(1);
26 }),26 }),
27 );27 );
28 28
29 // The tests below exercise the real system keychain.29 // 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.
31 31
32 it.effect.skipIf(!!process.env.CI)("stores and checks secret via system keychain", () =>32 it.effect.skipIf(!!process.env.CI)("stores and checks secret via system keychain", () =>
33 Effect.gen(function* () {33 Effect.gen(function* () {
@@ -37,6 +37,10 @@ describe("keychain plugin", () => { · 3 unmodified lines
37 plugins: [keychainPlugin({ serviceName: "executor-test" })] as const,37 plugins: [keychainPlugin({ serviceName: "executor-test" })] as const,
38 }),38 }),
39 );39 );
40 const providers = yield* executor.secrets.providers();
41 if (!providers.includes("keychain")) {
42 return;
43 }
40 44
41 try {45 try {
42 // Store through SDK, pinned to keychain provider46 // Store through SDK, pinned to keychain provider
@@ -70,6 +74,10 @@ describe("keychain plugin", () => { · 27 unmodified lines
70 plugins: [keychainPlugin({ serviceName: "executor-test" })] as const,74 plugins: [keychainPlugin({ serviceName: "executor-test" })] as const,
71 }),75 }),
72 );76 );
77 const providers = yield* executor.secrets.providers();
78 if (!providers.includes("keychain")) {
79 return;
80 }
73 81
74 const exists = yield* executor.keychain.has("nonexistent-secret");82 const exists = yield* executor.keychain.has("nonexistent-secret");
75 expect(exists).toBe(false);83 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
105 "x-static": Schema.optional(Schema.String),105 "x-static": Schema.optional(Schema.String),
106}) {}106}) {}
107 107
108class QueryValidationError extends Schema.TaggedError<QueryValidationError>()(
109 "QueryValidationError",
110 {
111 message: Schema.String,
112 },
113) {}
114
108const ItemsGroup = HttpApiGroup.make("items")115const ItemsGroup = HttpApiGroup.make("items")
109 .add(HttpApiEndpoint.get("listItems", "/items").addSuccess(Schema.Array(Item)))116 .add(HttpApiEndpoint.get("listItems", "/items").addSuccess(Schema.Array(Item)))
110 .add(117 .add(
@@ -112,7 +119,13 @@ const ItemsGroup = HttpApiGroup.make("items") · 1 unmodified line
112 .setPath(Schema.Struct({ itemId: Schema.NumberFromString }))119 .setPath(Schema.Struct({ itemId: Schema.NumberFromString }))
113 .addSuccess(Item),120 .addSuccess(Item),
114 )121 )
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 );
116 129
117const TestApi = HttpApi.make("testApi").add(ItemsGroup);130const TestApi = HttpApi.make("testApi").add(ItemsGroup);
118 131
@@ -148,6 +161,13 @@ const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => · 29 unmodified lines
148 "x-static": req.headers["x-static"],161 "x-static": req.headers["x-static"],
149 });162 });
150 }),163 }),
164 )
165 .handle("queryRows", () =>
166 Effect.fail(
167 new QueryValidationError({
168 message: 'Field with name "DisplayName" does not exist',
169 }),
170 ),
151 ),171 ),
152);172);
153 173
@@ -275,7 +295,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 121 unmodified lines
275 autoApprove,295 autoApprove,
276 )) as { sourceId: string; toolCount: number };296 )) as { sourceId: string; toolCount: number };
277 297
278 expect(result).toEqual({ sourceId: "runtime", toolCount: 3 });298 expect(result).toEqual({ sourceId: "runtime", toolCount: 4 });
279 expect((yield* executor.tools.list()).map((t) => t.id)).toContain(299 expect((yield* executor.tools.list()).map((t) => t.id)).toContain(
280 "runtime.items.listItems",300 "runtime.items.listItems",
281 );301 );
@@ -454,6 +474,47 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 172 unmodified lines
454 }),474 }),
455 );475 );
456 476
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
457 it.effect("removeSpec cleans up registered tools", () =>518 it.effect("removeSpec cleans up registered tools", () =>
458 Effect.gen(function* () {519 Effect.gen(function* () {
459 const httpClient = yield* HttpClient.HttpClient;520 const httpClient = yield* HttpClient.HttpClient;
packages/react/package.json+1−0
@@ -33,6 +33,7 @@ · 32 unmodified lines
33 "cmdk": "^1.1.1",33 "cmdk": "^1.1.1",
34 "effect": "catalog:",34 "effect": "catalog:",
35 "embla-carousel-react": "^8.6.0",35 "embla-carousel-react": "^8.6.0",
36 "fractional-indexing": "^3.2.0",
36 "input-otp": "^1.4.2",37 "input-otp": "^1.4.2",
37 "lucide-react": "^1.7.0",38 "lucide-react": "^1.7.0",
38 "radix-ui": "^1.4.3",39 "radix-ui": "^1.4.3",
packages/react/src/api/atoms.tsx+127−2
@@ -1,5 +1,11 @@
1import type { ScopeId, ToolId, SecretId } from "@executor/sdk";1import {
2import { Atom } from "@effect-atom/atom-react";2 PolicyId,
3 type ScopeId,
4 type ToolId,
5 type SecretId,
6 type ToolPolicyAction,
7} from "@executor/sdk";
8import { Atom, Result } from "@effect-atom/atom-react";
3 9
4import { ExecutorApiClient } from "./client";10import { ExecutorApiClient } from "./client";
5import { ReactivityKey } from "./reactivity-keys";11import { ReactivityKey } from "./reactivity-keys";
@@ -71,6 +77,13 @@ export const connectionsAtom = (scopeId: ScopeId) => · 65 unmodified lines
71 reactivityKeys: [ReactivityKey.connections],77 reactivityKeys: [ReactivityKey.connections],
72 });78 });
73 79
80export const policiesAtom = (scopeId: ScopeId) =>
81 ExecutorApiClient.query("policies", "list", {
82 path: { scopeId },
83 timeToLive: "30 seconds",
84 reactivityKeys: [ReactivityKey.policies],
85 });
86
74// ---------------------------------------------------------------------------87// ---------------------------------------------------------------------------
75// Mutation atoms — reactivityKeys must be passed at call site (effect-atom88// Mutation atoms — reactivityKeys must be passed at call site (effect-atom
76// does not accept them at definition time). See `reactivity-keys.tsx` for the89// 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
91export const refreshSource = ExecutorApiClient.mutation("sources", "refresh");104export const refreshSource = ExecutorApiClient.mutation("sources", "refresh");
92 105
93export const detectSource = ExecutorApiClient.mutation("sources", "detect");106export const detectSource = ExecutorApiClient.mutation("sources", "detect");
107
108export const createPolicy = ExecutorApiClient.mutation("policies", "create");
109
110export const updatePolicy = ExecutorApiClient.mutation("policies", "update");
111
112export 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
123export const policiesOptimisticAtom = Atom.family((scopeId: ScopeId) =>
124 Atom.optimistic(policiesAtom(scopeId)),
125);
126
127export 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
164export 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
202export 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
203 [add, remove],203 [add, remove],
204 );204 );
205};205};
206
packages/react/src/api/reactivity-keys.tsx+9−0
@@ -23,6 +23,7 @@ export const ReactivityKey = { · 22 unmodified lines
23 secrets: "secrets",23 secrets: "secrets",
24 connections: "connections",24 connections: "connections",
25 scope: "scope",25 scope: "scope",
26 policies: "policies",
26 // cloud-only resources27 // cloud-only resources
27 orgMembers: "org:members",28 orgMembers: "org:members",
28 orgDomains: "org:domains",29 orgDomains: "org:domains",
@@ -48,6 +49,14 @@ export const connectionWriteKeys = [ · 19 unmodified lines
48/** Mutations that change scope membership/info. */49/** Mutations that change scope membership/info. */
49export const scopeWriteKeys = [ReactivityKey.scope] as const;50export const scopeWriteKeys = [ReactivityKey.scope] as const;
50 51
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. */
55export const policyWriteKeys = [
56 ReactivityKey.policies,
57 ReactivityKey.tools,
58] as const;
59
51/** Cloud-only: org membership mutations. */60/** Cloud-only: org membership mutations. */
52export const orgMemberWriteKeys = [ReactivityKey.orgMembers] as const;61export const orgMemberWriteKeys = [ReactivityKey.orgMembers] as const;
53 62
packages/react/src/components/select.tsx+7−0
@@ -4,6 +4,12 @@ import * as React from "react"; · 3 unmodified lines
4import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";4import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
5import { Select as SelectPrimitive } from "radix-ui";5import { Select as SelectPrimitive } from "radix-ui";
6 6
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.
11const SelectPrimitiveTrigger = SelectPrimitive.Trigger;
12
7import { cn } from "../lib/utils";13import { cn } from "../lib/utils";
8 14
9function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {15function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
@@ -167,6 +173,7 @@ export { · 157 unmodified lines
167 SelectGroup,173 SelectGroup,
168 SelectItem,174 SelectItem,
169 SelectLabel,175 SelectLabel,
176 SelectPrimitiveTrigger,
170 SelectScrollDownButton,177 SelectScrollDownButton,
171 SelectScrollUpButton,178 SelectScrollUpButton,
172 SelectSeparator,179 SelectSeparator,
packages/react/src/components/tool-detail.tsx+63−1
@@ -1,7 +1,13 @@
1import { useMemo, useState } from "react";1import { useMemo, useState } from "react";
2import { useAtomValue, Result } from "@effect-atom/atom-react";2import { useAtomValue, Result } from "@effect-atom/atom-react";
3import { toolSchemaAtom } from "../api/atoms";3import { toolSchemaAtom } from "../api/atoms";
4import { ScopeId, ToolId } from "@executor/sdk";4import {
5 ScopeId,
6 ToolId,
7 type EffectivePolicy,
8 type ToolPolicyAction,
9} from "@executor/sdk";
10import { Badge } from "./badge";
5import { Button } from "./button";11import { Button } from "./button";
6import { Markdown } from "./markdown";12import { Markdown } from "./markdown";
7import { SchemaExplorer } from "./schema-explorer";13import { SchemaExplorer } from "./schema-explorer";
@@ -10,6 +16,45 @@ import { CardStack, CardStackHeader, CardStackContent } from "./card-stack"; · 2 unmodified lines
10import { CopyButton } from "./copy-button";16import { CopyButton } from "./copy-button";
11import { ChevronRight } from "lucide-react";17import { ChevronRight } from "lucide-react";
12 18
19const POLICY_LABEL: Record<ToolPolicyAction, string> = {
20 approve: "Auto-approve",
21 require_approval: "Require approval",
22 block: "Blocked",
23};
24
25const 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.
38const 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
13function EmptySection(props: { title: string; message: string }) {58function EmptySection(props: { title: string; message: string }) {
14 return (59 return (
15 <CardStack>60 <CardStack>
@@ -50,6 +95,9 @@ export function ToolDetail(props: { · 34 unmodified lines
50 toolName: string;95 toolName: string;
51 toolDescription?: string;96 toolDescription?: string;
52 scopeId: ScopeId;97 scopeId: ScopeId;
98 /** Resolved effective policy — user-authored or plugin-default,
99 * unified into one shape. Surfaces in the header. */
100 policy?: EffectivePolicy;
53}) {101}) {
54 const toolContract = useAtomValue(toolSchemaAtom(props.scopeId, props.toolId as ToolId));102 const toolContract = useAtomValue(toolSchemaAtom(props.scopeId, props.toolId as ToolId));
55 const [tab, setTab] = useState<"schema" | "typescript">("schema");103 const [tab, setTab] = useState<"schema" | "typescript">("schema");
@@ -92,6 +140,20 @@ export function ToolDetail(props: { · 36 unmodified lines
92 <div className="mt-1 flex items-center gap-2">140 <div className="mt-1 flex items-center gap-2">
93 <h3 className="text-base font-semibold text-foreground truncate">{displayName}</h3>141 <h3 className="text-base font-semibold text-foreground truncate">{displayName}</h3>
94 <CopyButton value={props.toolId} label="Copy tool ID" />142 <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 })()}
95 </div>157 </div>
96 {props.toolDescription && (158 {props.toolDescription && (
97 <div className="mt-1.5 max-w-lg text-sm text-muted-foreground line-clamp-2">159 <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 @@
1import { useEffect, useMemo, useRef, useState } from "react";1import { useEffect, useMemo, useRef, useState } from "react";
2import { ChevronRightIcon, SearchIcon, XIcon } from "lucide-react";2import { ChevronRightIcon, SearchIcon, XIcon } from "lucide-react";
3import type { EffectivePolicy, ToolPolicyAction } from "@executor/sdk";
3import { Button } from "./button";4import { Button } from "./button";
4import { Input } from "./input";5import { Input } from "./input";
5import { cn } from "../lib/utils";6import { cn } from "../lib/utils";
@@ -13,8 +14,57 @@ export interface ToolSummary { · 7 unmodified lines
13 readonly name: string;14 readonly name: string;
14 readonly pluginKey: string;15 readonly pluginKey: string;
15 readonly description?: string;16 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;
16}21}
17 22
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.
25const 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.
50const 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
18type TreeNode = {68type TreeNode = {
19 segment: string;69 segment: string;
20 path: string;70 path: string;
@@ -360,6 +410,7 @@ function ToolLeafRow(props: { · 339 unmodified lines
360 search: string;410 search: string;
361}) {411}) {
362 const label = props.tool.name.split(".").pop() ?? props.tool.name;412 const label = props.tool.name.split(".").pop() ?? props.tool.name;
413 const indicator = indicatorFor(props.tool.policy);
363 return (414 return (
364 <Button415 <Button
365 ref={props.buttonRef}416 ref={props.buttonRef}
@@ -370,12 +421,20 @@ function ToolLeafRow(props: { · 4 unmodified lines
370 props.active421 props.active
371 ? "bg-primary/15 text-foreground ring-1 ring-inset ring-primary/40 hover:bg-primary/20"422 ? "bg-primary/15 text-foreground ring-1 ring-inset ring-primary/40 hover:bg-primary/20"
372 : "text-foreground/80 hover:bg-accent/60 hover:text-foreground",423 : "text-foreground/80 hover:bg-accent/60 hover:text-foreground",
424 props.tool.policy.action === "block" && !props.active && "opacity-60",
373 )}425 )}
374 style={{ paddingLeft: rowIndent(props.depth) + 20, paddingRight: 12 }}426 style={{ paddingLeft: rowIndent(props.depth) + 20, paddingRight: 12 }}
375 >427 >
376 <span className="flex-1 truncate text-left font-mono">428 <span className="flex-1 truncate text-left font-mono">
377 {highlightMatch(label, props.search)}429 {highlightMatch(label, props.search)}
378 </span>430 </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 )}
379 </Button>438 </Button>
380 );439 );
381}440}
packages/react/src/pages/policies.tsx+463−0
@@ -0,0 +1,463 @@
1import { useState } from "react";
2import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
3import { generateKeyBetween } from "fractional-indexing";
4import { ChevronDownIcon } from "lucide-react";
5import { PolicyId, type ToolPolicyAction } from "@executor/sdk";
6
7import {
8 createPolicyOptimistic,
9 policiesOptimisticAtom,
10 removePolicyOptimistic,
11 updatePolicyOptimistic,
12} from "../api/atoms";
13import { policyWriteKeys } from "../api/reactivity-keys";
14import { useScope } from "../hooks/use-scope";
15import { badgeVariants } from "../components/badge";
16import { cn } from "../lib/utils";
17import { Button } from "../components/button";
18import {
19 CardStack,
20 CardStackContent,
21 CardStackEntry,
22 CardStackEntryActions,
23 CardStackEntryContent,
24 CardStackEntryDescription,
25 CardStackEntryTitle,
26 CardStackHeader,
27} from "../components/card-stack";
28import {
29 DropdownMenu,
30 DropdownMenuContent,
31 DropdownMenuItem,
32 DropdownMenuTrigger,
33} from "../components/dropdown-menu";
34import { Input } from "../components/input";
35import {
36 Select,
37 SelectContent,
38 SelectItem,
39 SelectPrimitiveTrigger,
40 SelectTrigger,
41 SelectValue,
42} from "../components/select";
43import { 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
52const 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
69const actionLabels: Record<ToolPolicyAction, string> = {
70 approve: "Auto-approve",
71 require_approval: "Require approval",
72 block: "Block",
73};
74
75const 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
91const 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
102const 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
122function 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
192function 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
280export 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.
463export { matchesPattern, isValidPattern };
packages/react/src/pages/source-detail.tsx+14−1
@@ -1,7 +1,9 @@
1import { Suspense, useEffect, useMemo, useState } from "react";1import { Suspense, useEffect, useMemo, useState } from "react";
2import { useNavigate } from "@tanstack/react-router";2import { useNavigate } from "@tanstack/react-router";
3import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";3import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";
4import { effectivePolicyFromSorted } from "@executor/sdk";
4import {5import {
6 policiesOptimisticAtom,
5 sourceToolsAtom,7 sourceToolsAtom,
6 sourcesAtom,8 sourcesAtom,
7 sourceAtom,9 sourceAtom,
@@ -26,6 +28,7 @@ export function SourceDetailPage(props: { · 18 unmodified lines
26 const scopeId = useScope();28 const scopeId = useScope();
27 const source = useAtomValue(sourceAtom(namespace, scopeId));29 const source = useAtomValue(sourceAtom(namespace, scopeId));
28 const tools = useAtomValue(sourceToolsAtom(namespace, scopeId));30 const tools = useAtomValue(sourceToolsAtom(namespace, scopeId));
31 const policies = useAtomValue(policiesOptimisticAtom(scopeId));
29 const refreshSources = useAtomRefresh(sourcesAtom(scopeId));32 const refreshSources = useAtomRefresh(sourcesAtom(scopeId));
30 const refreshTools = useAtomRefresh(sourceToolsAtom(namespace, scopeId));33 const refreshTools = useAtomRefresh(sourceToolsAtom(namespace, scopeId));
31 const doRemove = useAtomSet(removeSource, { mode: "promise" });34 const doRemove = useAtomSet(removeSource, { mode: "promise" });
@@ -62,6 +65,14 @@ export function SourceDetailPage(props: { · 30 unmodified lines
62 return sourcePlugins.find((p) => p.key === sourceData.kind) ?? null;65 return sourcePlugins.find((p) => p.key === sourceData.kind) ?? null;
63 }, [sourceData, sourcePlugins]);66 }, [sourceData, sourcePlugins]);
64 67
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
65 const sourceTools: ToolSummary[] = useMemo(() => {76 const sourceTools: ToolSummary[] = useMemo(() => {
66 if (!Result.isSuccess(tools)) return [];77 if (!Result.isSuccess(tools)) return [];
67 return tools.value.map((t) => ({78 return tools.value.map((t) => ({
@@ -69,8 +80,9 @@ export function SourceDetailPage(props: { · 1 unmodified line
69 name: t.name,80 name: t.name,
70 pluginKey: t.pluginId,81 pluginKey: t.pluginId,
71 description: t.description,82 description: t.description,
83 policy: effectivePolicyFromSorted(t.id, policyList, t.requiresApproval),
72 }));84 }));
73 }, [tools]);85 }, [tools, policyList]);
74 86
75 const selectedTool = useMemo(87 const selectedTool = useMemo(
76 () => sourceTools.find((t) => t.id === selectedToolId) ?? null,88 () => sourceTools.find((t) => t.id === selectedToolId) ?? null,
@@ -235,6 +247,7 @@ export function SourceDetailPage(props: { · 158 unmodified lines
235 toolName={selectedTool.name}247 toolName={selectedTool.name}
236 toolDescription={selectedTool.description}248 toolDescription={selectedTool.description}
237 scopeId={scopeId}249 scopeId={scopeId}
250 policy={selectedTool.policy}
238 />251 />
239 ) : (252 ) : (
240 <ToolDetailEmpty hasTools={sourceTools.length > 0} />253 <ToolDetailEmpty hasTools={sourceTools.length > 0} />
warden.toml+10−0
@@ -65,3 +65,13 @@ paths = [ · 64 unmodified lines
65 ".github/workflows/**/*.yml",65 ".github/workflows/**/*.yml",
66 ".github/workflows/**/*.yaml",66 ".github/workflows/**/*.yaml",
67]67]
68
69# Local skill — no `remote =`, resolved from `.agents/skills/<name>/SKILL.md`.
70[[skills]]
71name = "wrdn-effect-atom-optimistic"
72paths = [
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