* 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)
Unified · Split
| @@ -0,0 +1,172 @@ | |||
| 1 | --- | ||
| 2 | name: wrdn-effect-atom-optimistic | ||
| 3 | description: Detects hand-rolled optimistic-update plumbing in React code that should be using effect-atom's Atom.optimistic and Atom.optimisticFn instead. Run on diffs touching packages/react/src/api/atoms.tsx, packages/react/src/pages/**/*.tsx, or any file that imports an effect-atom mutation atom from ./api/atoms. The hand-rolled patterns race on concurrent mutations and the codebase has chosen the effect-atom primitives as the canonical answer. | ||
| 4 | allowed-tools: Read Grep Glob Bash | ||
| 5 | --- | ||
| 6 | |||
| 7 | You audit React code in this repo for one thing: did the author roll their own optimistic-update layer on top of an effect-atom query atom instead of using `Atom.optimistic` / `Atom.optimisticFn`? | ||
| 8 | |||
| 9 | This is not a security skill. It is a correctness skill. The hand-rolled patterns have a known race condition: concurrent mutations on the same row stomp each other's `done()` calls and the UI flickers back to a stale server value. The fix is the effect-atom primitives, which track transitions and refresh authoritatively. The repo already migrated `policies` and codified the pattern at `.skills/effect-atom-optimistic-updates/SKILL.md`. | ||
| 10 | |||
| 11 | Trace. Do not pattern-match a `useState` and call it a day. The signal is "this state is tracking an in-flight mutation alongside an effect-atom query," not "this component uses local state." | ||
| 12 | |||
| 13 | ## Trace before reporting | ||
| 14 | |||
| 15 | 1. **Find the mutation.** Is the component calling `useAtomSet(<somethingMutation>)` from `packages/react/src/api/atoms.tsx`? If not, this skill does not apply. | ||
| 16 | 2. **Find the read.** Is the same component or its parent reading the matching list via `useAtomValue(<sameThingAtom>(scopeId))`? If yes, the optimistic substitute exists or should exist. | ||
| 17 | 3. **Find the bookkeeping.** Look for any of these alongside the mutation call: | ||
| 18 | - `useState`, `useReducer`, `useRef` holding "pending" / "placeholder" / "in-flight" / "optimistic" values keyed by row id | ||
| 19 | - `Atom.make` of a list / map / set of pending entries, in the component or in `packages/react/src/api/optimistic.tsx` | ||
| 20 | - Calls into `usePendingResource`, `usePoliciesWithPending`, `usePendingPolicies`, `mergePending`, or any helper named like that | ||
| 21 | - `try { await doMutate(...) } finally { placeholder.done() }` shapes | ||
| 22 | - Manual id minting (`pending-${...}`, `crypto.randomUUID`) in the page-level handler rather than inside an `optimisticFn` reducer | ||
| 23 | 4. **Confirm the optimistic atom exists or is missing.** Open `packages/react/src/api/atoms.tsx` and check for `<thing>OptimisticAtom = Atom.family(scopeId => Atom.optimistic(<thing>Atom(scopeId)))`. If a sibling resource (sources, secrets, policies) has the optimistic wrapper and this one doesn't, the bug is the missing wrapper plus the hand-rolled substitute. | ||
| 24 | 5. **Check for grandfathered code.** `usePendingSources`, `useSourcesWithPending`, `useConnectionsWithPendingRemovals`, and `usePendingConnectionRemovals` in `packages/react/src/api/optimistic.tsx` are legacy. New code should not extend them. Their continued existence is not a finding by itself; **new** consumers or **new** entries in `PendingResource` are. | ||
| 25 | |||
| 26 | When the trace cannot resolve with the files at hand, drop the finding. | ||
| 27 | |||
| 28 | ## What to Report | ||
| 29 | |||
| 30 | - **Hand-rolled pending state next to an effect-atom mutation.** Component imports a mutation from `./api/atoms` (e.g. `updatePolicy`, `createSecret`, `removeConnection`) and tracks the in-flight value in `useState` / `useRef` / a custom atom for the purpose of immediate UI feedback. Severity: medium. | ||
| 31 | - **New entries in `PendingResource` or new helpers in `packages/react/src/api/optimistic.tsx`.** The file is closed for new patterns. New rows, new `usePending<X>` hooks, new `use<X>WithPending` hooks should instead be `Atom.optimistic` + `Atom.optimisticFn` families in `atoms.tsx`. Severity: medium. | ||
| 32 | - **`try/finally` cleanup of a placeholder around `await doMutate(...)`.** This shape is the tell. `optimisticFn` clears its own transition; manual cleanup means the author is reimplementing it. Severity: medium. | ||
| 33 | - **Reading `<thing>Atom(scopeId)` in a component that also writes through `<thing>OptimisticAtom`'s mutations.** The reads and writes must both go through the optimistic family or both bypass it; mixing them produces visual jumps. Severity: medium. | ||
| 34 | - **`Atom.optimisticFn` reducer that derives next state from a captured snapshot of the parent atom instead of from the `current` argument.** The reducer signature is `(current, update) => W` — the runtime reads the optimistic state itself and passes it as `current`, which already reflects in-flight transitions. Code that closes over a `useAtomValue(...)` snapshot or a captured `policies` variable instead of using `current` will see stale state under racing edits. A placeholder row id derived from `Date.now()` or random is fine; the bug is reducer state that ignores `current`. Severity: low. | ||
| 35 | - **`Atom.optimistic` wrapped via `Atom.optimistic(policiesAtom(scopeId))` outside an `Atom.family`.** Without `Atom.family`, every render builds a new optimistic atom and transitions don't share state. Severity: medium. | ||
| 36 | |||
| 37 | ## What NOT to Report | ||
| 38 | |||
| 39 | - `useState` / `useReducer` for UI-local state that has nothing to do with mutation lifecycle: form input values, modal open flags, hover state, derived display values. Read the surrounding code; if there is no `useAtomSet` or `await doMutate` near the state, it is not in scope. | ||
| 40 | - The legacy hooks themselves in `packages/react/src/api/optimistic.tsx` (`useSourcesWithPending`, `useConnectionsWithPendingRemovals`, `usePendingSources`, `usePendingConnectionRemovals`). Existing call sites are grandfathered. Only flag **new** call sites or **new** helpers added to that file. | ||
| 41 | - `useState` for a "busy" / "submitting" boolean used to disable a button while the mutation runs. That is not optimistic state. | ||
| 42 | - `setTimeout` / `setInterval` based debouncing or rate-limiting around a mutation. Different concern. | ||
| 43 | - Toast / error-message state. UI feedback, not optimistic data. | ||
| 44 | - Server-only code (`apps/cloud`, `apps/local`, `packages/core/**`). This skill is React-specific; do not flag backend handlers, plugin storage, or test helpers. | ||
| 45 | - Storybook files, test files, and example-only code. The pattern matters in shipped UI; not in fixtures. | ||
| 46 | |||
| 47 | ## Severity ladder | ||
| 48 | |||
| 49 | | Level | Criteria | | ||
| 50 | |-------|----------| | ||
| 51 | | **medium** | New optimistic-mutation code that bypasses `Atom.optimistic` / `Atom.optimisticFn` and rolls its own pending state. Or a mixed read/write where the read goes through the plain query atom and the write goes through the optimistic mutation. | | ||
| 52 | | **low** | Subtle defects in an `Atom.optimisticFn` reducer that work today but degrade under racing (clock-based identity, missing `Atom.family` wrapper, computed-once captures of `scopeId`). | | ||
| 53 | |||
| 54 | Do not invent `high`. Pick `low` when in doubt and explain why. | ||
| 55 | |||
| 56 | ## Reference patterns (TypeScript) | ||
| 57 | |||
| 58 | The repo's reference implementation lives in `packages/react/src/api/atoms.tsx` (search for `policiesOptimisticAtom`, `updatePolicyOptimistic`). | ||
| 59 | |||
| 60 | ### Bad: hand-rolled pending state | ||
| 61 | |||
| 62 | ```tsx | ||
| 63 | // packages/react/src/pages/secrets.tsx (hypothetical) | ||
| 64 | import { useState } from "react"; | ||
| 65 | import { useAtomSet, useAtomValue } from "@effect-atom/atom-react"; | ||
| 66 | |||
| 67 | import { secretsAtom, updateSecret } from "../api/atoms"; | ||
| 68 | |||
| 69 | export function SecretsPage() { | ||
| 70 | const scopeId = useScope(); | ||
| 71 | const secrets = useAtomValue(secretsAtom(scopeId)); | ||
| 72 | const doUpdate = useAtomSet(updateSecret, { mode: "promise" }); | ||
| 73 | const [pendingValue, setPendingValue] = useState<Map<string, string>>(new Map()); | ||
| 74 | |||
| 75 | const handleEdit = async (id: string, value: string) => { | ||
| 76 | setPendingValue((m) => new Map(m).set(id, value)); | ||
| 77 | try { | ||
| 78 | await doUpdate({ path: { scopeId, secretId: id }, payload: { value } }); | ||
| 79 | } finally { | ||
| 80 | setPendingValue((m) => { | ||
| 81 | const next = new Map(m); | ||
| 82 | next.delete(id); | ||
| 83 | return next; | ||
| 84 | }); | ||
| 85 | } | ||
| 86 | }; | ||
| 87 | // ... | ||
| 88 | } | ||
| 89 | ``` | ||
| 90 | |||
| 91 | The `Map` keyed by id, the `try/finally`, the manual cleanup. All three signal a hand-rolled optimistic layer. Two fast edits to the same secret race: A's `finally` deletes B's pending entry, UI flickers to A's server value before B settles. | ||
| 92 | |||
| 93 | ### Safe: effect-atom primitives | ||
| 94 | |||
| 95 | ```tsx | ||
| 96 | // packages/react/src/api/atoms.tsx | ||
| 97 | export const secretsOptimisticAtom = Atom.family((scopeId: ScopeId) => | ||
| 98 | Atom.optimistic(secretsAtom(scopeId)), | ||
| 99 | ); | ||
| 100 | |||
| 101 | export const updateSecretOptimistic = Atom.family((scopeId: ScopeId) => | ||
| 102 | secretsOptimisticAtom(scopeId).pipe( | ||
| 103 | Atom.optimisticFn({ | ||
| 104 | reducer: (current, arg: { path: { secretId: SecretId }; payload: { value: string } }) => | ||
| 105 | Result.map(current, (rows) => | ||
| 106 | rows.map((r) => | ||
| 107 | r.id === arg.path.secretId ? { ...r, value: arg.payload.value } : r, | ||
| 108 | ), | ||
| 109 | ), | ||
| 110 | fn: updateSecret, | ||
| 111 | }), | ||
| 112 | ), | ||
| 113 | ); | ||
| 114 | |||
| 115 | // packages/react/src/pages/secrets.tsx | ||
| 116 | const secrets = useAtomValue(secretsOptimisticAtom(scopeId)); | ||
| 117 | const doUpdate = useAtomSet(updateSecretOptimistic(scopeId), { mode: "promise" }); | ||
| 118 | |||
| 119 | const handleEdit = (id: SecretId, value: string) => | ||
| 120 | doUpdate({ path: { scopeId, secretId: id }, payload: { value } }); | ||
| 121 | ``` | ||
| 122 | |||
| 123 | No local state, no try/finally, no manual cleanup. Multiple edits stack: the runtime feeds each reducer call `current` reflecting prior in-flight transitions, so the second edit sees the optimistic value of the first. | ||
| 124 | |||
| 125 | ### Bad: extending the legacy layer | ||
| 126 | |||
| 127 | ```tsx | ||
| 128 | // packages/react/src/api/optimistic.tsx (hypothetical addition) | ||
| 129 | export const PendingResource = { | ||
| 130 | sources: "sources", | ||
| 131 | connectionRemovals: "connection-removals", | ||
| 132 | secrets: "secrets", // <-- new | ||
| 133 | } as const; | ||
| 134 | |||
| 135 | export interface PendingSecret { | ||
| 136 | readonly value: string; | ||
| 137 | } | ||
| 138 | |||
| 139 | export const useSecretsWithPending = (scopeId: ScopeId) => { /* ... */ }; | ||
| 140 | export const usePendingSecrets = () => { /* ... */ }; | ||
| 141 | ``` | ||
| 142 | |||
| 143 | Flag any new entry in `PendingResource` and any new `use<X>WithPending` / `usePending<X>` hook. The file is closed for new patterns. | ||
| 144 | |||
| 145 | ### Subtle: missing Atom.family wrapper | ||
| 146 | |||
| 147 | ```tsx | ||
| 148 | // Bad: builds a fresh optimistic atom every render. No transition state survives. | ||
| 149 | const optimistic = Atom.optimistic(secretsAtom(scopeId)); | ||
| 150 | const value = useAtomValue(optimistic); | ||
| 151 | ``` | ||
| 152 | |||
| 153 | ```tsx | ||
| 154 | // Safe: Atom.family memoizes per scopeId so transitions persist. | ||
| 155 | export const secretsOptimisticAtom = Atom.family((scopeId: ScopeId) => | ||
| 156 | Atom.optimistic(secretsAtom(scopeId)), | ||
| 157 | ); | ||
| 158 | |||
| 159 | const value = useAtomValue(secretsOptimisticAtom(scopeId)); | ||
| 160 | ``` | ||
| 161 | |||
| 162 | ## Output Requirements | ||
| 163 | |||
| 164 | For each finding: | ||
| 165 | |||
| 166 | - **File and line** of the offending code. | ||
| 167 | - **Severity** from the ladder above. | ||
| 168 | - **What is wrong**, in one sentence. | ||
| 169 | - **Trace**: which mutation atom, which read atom, which symptom (race / stale flicker / unmemoized atom). | ||
| 170 | - **Fix**: name the optimistic family that should exist, or the change that lifts the page's hand-rolled state into `atoms.tsx`. Point to `packages/react/src/api/atoms.tsx` (`policiesOptimisticAtom`) as the reference shape. | ||
| 171 | |||
| 172 | Group findings by severity. Lead with `medium`. | ||
| @@ -0,0 +1,252 @@ | |||
| 1 | --- | ||
| 2 | name: effect-atom-optimistic-updates | ||
| 3 | description: Pattern for implementing optimistic UI updates with effect-atom in this codebase. Use when adding optimistic behavior to a query atom + its mutations (action toggles, list adds/removes, inline edits). DO NOT roll your own pending-state with React state, Maps, or custom merge helpers — `Atom.optimistic` + `Atom.optimisticFn` already handle racing, refresh, and waiting correctly. | ||
| 4 | --- | ||
| 5 | |||
| 6 | # effect-atom Optimistic Updates | ||
| 7 | |||
| 8 | The `@effect-atom/atom-react` library ships first-class optimistic support via | ||
| 9 | `Atom.optimistic` and `Atom.optimisticFn`. Use them directly. Do not write a | ||
| 10 | custom "pending entries" layer (Maps, useState, useRef) on top of `useAtomValue` | ||
| 11 | — it will not handle racing updates correctly, and the existing helpers in | ||
| 12 | `packages/react/src/api/optimistic.tsx` are legacy patterns kept only for | ||
| 13 | sources/connections. | ||
| 14 | |||
| 15 | ## When to use | ||
| 16 | |||
| 17 | - A list query atom (`Atom.optimistic` wraps the query) | ||
| 18 | - Plus one or more mutation atoms that change rows in that list (create / update / delete) | ||
| 19 | - And you want the UI to reflect the change *immediately* on click, before the server roundtrip | ||
| 20 | |||
| 21 | If you only need the UI to be "eventually consistent" (i.e. you're fine waiting | ||
| 22 | ~200ms for the server response and the existing reactivity refetch), skip | ||
| 23 | optimistic — just use the mutation directly with `reactivityKeys`. | ||
| 24 | |||
| 25 | ## Why not roll your own | ||
| 26 | |||
| 27 | The naive approach — track `pending: Array<{id, value}>` in a separate atom and | ||
| 28 | merge it with the server result — has a subtle race that bites on rapid edits: | ||
| 29 | |||
| 30 | 1. User clicks "set action = block" → mutation A fires, pending entry written | ||
| 31 | 2. User clicks "set action = approve" → mutation B fires, pending entry overwritten with B | ||
| 32 | 3. **A's response returns first** → finally-block clears the pending entry → UI flickers back to the server's "block" value | ||
| 33 | 4. B's response returns → UI shows "approve" | ||
| 34 | |||
| 35 | Step 3 is the bug. Fixing it correctly requires per-call entry ids and "last | ||
| 36 | entry per row id wins" merging — at which point you've reimplemented a worse | ||
| 37 | version of `Atom.optimistic`'s transition tracking. | ||
| 38 | |||
| 39 | `Atom.optimistic` solves this because the runtime reads the current optimistic | ||
| 40 | state (including any in-flight transitions) and passes it to your reducer as | ||
| 41 | `current`, so B stacks on top of A correctly. When all transitions settle, it | ||
| 42 | calls `refresh(self)` to pull the server's authoritative state. | ||
| 43 | |||
| 44 | ## Pattern | ||
| 45 | |||
| 46 | Define optimistic atoms next to the underlying query and mutations in the | ||
| 47 | `atoms.tsx` (or equivalent) module. Each scope-keyed list gets a family that | ||
| 48 | wraps the query with `Atom.optimistic`, and each mutation gets a family that | ||
| 49 | pipes the optimistic atom through `Atom.optimisticFn` with a reducer. | ||
| 50 | |||
| 51 | ```typescript | ||
| 52 | import { Atom, Result } from "@effect-atom/atom-react"; | ||
| 53 | import type { ScopeId, PolicyId, ToolPolicyAction } from "@executor/sdk"; | ||
| 54 | |||
| 55 | import { ExecutorApiClient } from "./client"; | ||
| 56 | |||
| 57 | // 1. The plain query atom — same as before. | ||
| 58 | export const policiesAtom = (scopeId: ScopeId) => | ||
| 59 | ExecutorApiClient.query("policies", "list", { | ||
| 60 | path: { scopeId }, | ||
| 61 | timeToLive: "30 seconds", | ||
| 62 | reactivityKeys: [ReactivityKey.policies], | ||
| 63 | }); | ||
| 64 | |||
| 65 | // 2. Plain mutations — same as before. These are the underlying `fn` for | ||
| 66 | // the optimistic wrappers below. | ||
| 67 | export const createPolicy = ExecutorApiClient.mutation("policies", "create"); | ||
| 68 | export const updatePolicy = ExecutorApiClient.mutation("policies", "update"); | ||
| 69 | export const removePolicy = ExecutorApiClient.mutation("policies", "remove"); | ||
| 70 | |||
| 71 | // 3. Optimistic read atom. `Atom.family` memoizes per-scope so every consumer | ||
| 72 | // references the same optimistic atom instance and shares transition state. | ||
| 73 | export const policiesOptimisticAtom = Atom.family((scopeId: ScopeId) => | ||
| 74 | Atom.optimistic(policiesAtom(scopeId)), | ||
| 75 | ); | ||
| 76 | |||
| 77 | // 4. Optimistic mutation. The reducer takes the same arg as the underlying | ||
| 78 | // mutation and returns the next list state. `Result.map` keeps the | ||
| 79 | // Result wrapper intact. | ||
| 80 | export const updatePolicyOptimistic = Atom.family((scopeId: ScopeId) => | ||
| 81 | policiesOptimisticAtom(scopeId).pipe( | ||
| 82 | Atom.optimisticFn({ | ||
| 83 | reducer: ( | ||
| 84 | current, | ||
| 85 | arg: { | ||
| 86 | path: { scopeId: ScopeId; policyId: PolicyId }; | ||
| 87 | payload: { action?: ToolPolicyAction }; | ||
| 88 | reactivityKeys?: ReadonlyArray<unknown>; | ||
| 89 | }, | ||
| 90 | ) => | ||
| 91 | Result.map(current, (rows) => | ||
| 92 | rows.map((r) => | ||
| 93 | r.id === arg.path.policyId && arg.payload.action !== undefined | ||
| 94 | ? { ...r, action: arg.payload.action } | ||
| 95 | : r, | ||
| 96 | ), | ||
| 97 | ), | ||
| 98 | fn: updatePolicy, | ||
| 99 | }), | ||
| 100 | ), | ||
| 101 | ); | ||
| 102 | ``` | ||
| 103 | |||
| 104 | ## Consuming in components | ||
| 105 | |||
| 106 | Read from the optimistic atom; write through the optimistic mutation. The | ||
| 107 | existing `reactivityKeys` plumbing still applies — pass them in the call. | ||
| 108 | |||
| 109 | ```typescript | ||
| 110 | import { useAtomValue, useAtomSet } from "@effect-atom/atom-react"; | ||
| 111 | |||
| 112 | import { | ||
| 113 | policiesOptimisticAtom, | ||
| 114 | updatePolicyOptimistic, | ||
| 115 | } from "../api/atoms"; | ||
| 116 | import { policyWriteKeys } from "../api/reactivity-keys"; | ||
| 117 | |||
| 118 | export function PoliciesPage() { | ||
| 119 | const scopeId = useScope(); | ||
| 120 | |||
| 121 | // Read: this Result reflects in-flight optimistic state on top of server data. | ||
| 122 | const policies = useAtomValue(policiesOptimisticAtom(scopeId)); | ||
| 123 | |||
| 124 | // Write: same call signature as the underlying mutation. | ||
| 125 | const doUpdate = useAtomSet(updatePolicyOptimistic(scopeId), { mode: "promise" }); | ||
| 126 | |||
| 127 | const handleUpdate = async (id: string, action: ToolPolicyAction) => { | ||
| 128 | await doUpdate({ | ||
| 129 | path: { scopeId, policyId: PolicyId.make(id) }, | ||
| 130 | payload: { action }, | ||
| 131 | reactivityKeys: policyWriteKeys, | ||
| 132 | }); | ||
| 133 | }; | ||
| 134 | |||
| 135 | // ... | ||
| 136 | } | ||
| 137 | ``` | ||
| 138 | |||
| 139 | ## Reducer rules | ||
| 140 | |||
| 141 | 1. **`current` is the FULL Result, not the unwrapped value.** Use `Result.map` | ||
| 142 | to update inside the success case — the wrapper preserves Initial/Failure | ||
| 143 | states correctly. | ||
| 144 | 2. **The reducer is called for every transition, including ones that stack on | ||
| 145 | top of in-flight ones.** Read `current` and produce `next` — don't track | ||
| 146 | "the previous optimistic value" yourself. | ||
| 147 | 3. **The reducer signature must match the mutation's arg shape.** Effect-atom | ||
| 148 | passes the raw mutation arg (e.g. `{ path, payload, reactivityKeys }`) to | ||
| 149 | both the reducer and the underlying `fn`. Don't try to build a "nicer" arg | ||
| 150 | shape unless you also wrap the underlying mutation. | ||
| 151 | 4. **Be pure.** No side effects, no calls to `Date.now()` for stable values, | ||
| 152 | no random ids unless you need a placeholder row id (see "Adds" below). | ||
| 153 | |||
| 154 | ## Adds (server mints the id) | ||
| 155 | |||
| 156 | For create flows the server assigns the canonical id. The reducer inserts a | ||
| 157 | placeholder with a temp id — the post-commit refresh replaces it with the | ||
| 158 | canonical row. | ||
| 159 | |||
| 160 | ```typescript | ||
| 161 | export const createPolicyOptimistic = Atom.family((scopeId: ScopeId) => | ||
| 162 | policiesOptimisticAtom(scopeId).pipe( | ||
| 163 | Atom.optimisticFn({ | ||
| 164 | reducer: ( | ||
| 165 | current, | ||
| 166 | arg: { | ||
| 167 | path: { scopeId: ScopeId }; | ||
| 168 | payload: { pattern: string; action: ToolPolicyAction }; | ||
| 169 | reactivityKeys?: ReadonlyArray<unknown>; | ||
| 170 | }, | ||
| 171 | ) => | ||
| 172 | Result.map(current, (rows) => [ | ||
| 173 | { | ||
| 174 | id: PolicyId.make(`pending-${Math.random().toString(36).slice(2)}`), | ||
| 175 | scopeId, | ||
| 176 | pattern: arg.payload.pattern, | ||
| 177 | action: arg.payload.action, | ||
| 178 | position: -Number.MAX_SAFE_INTEGER, // sort to top | ||
| 179 | createdAt: Date.now(), | ||
| 180 | updatedAt: Date.now(), | ||
| 181 | }, | ||
| 182 | ...rows, | ||
| 183 | ]), | ||
| 184 | fn: createPolicy, | ||
| 185 | }), | ||
| 186 | ), | ||
| 187 | ); | ||
| 188 | ``` | ||
| 189 | |||
| 190 | The placeholder doesn't need to roundtrip through the `id` field unless your | ||
| 191 | list rendering keys on it (it usually does — `<Row key={p.id}>`). A unique | ||
| 192 | prefix like `pending-` is fine. | ||
| 193 | |||
| 194 | ## Removes | ||
| 195 | |||
| 196 | ```typescript | ||
| 197 | export const removePolicyOptimistic = Atom.family((scopeId: ScopeId) => | ||
| 198 | policiesOptimisticAtom(scopeId).pipe( | ||
| 199 | Atom.optimisticFn({ | ||
| 200 | reducer: ( | ||
| 201 | current, | ||
| 202 | arg: { | ||
| 203 | path: { scopeId: ScopeId; policyId: PolicyId }; | ||
| 204 | reactivityKeys?: ReadonlyArray<unknown>; | ||
| 205 | }, | ||
| 206 | ) => | ||
| 207 | Result.map(current, (rows) => | ||
| 208 | rows.filter((r) => r.id !== arg.path.policyId), | ||
| 209 | ), | ||
| 210 | fn: removePolicy, | ||
| 211 | }), | ||
| 212 | ), | ||
| 213 | ); | ||
| 214 | ``` | ||
| 215 | |||
| 216 | ## How racing is handled (mental model) | ||
| 217 | |||
| 218 | You don't have to think about this — it works — but understanding helps: | ||
| 219 | |||
| 220 | - `Atom.optimistic(self)` wraps the underlying atom and tracks a `transitions` set | ||
| 221 | - Each `Atom.optimisticFn` call creates one shared transition state per (scope, mutation) | ||
| 222 | - A call: runtime reads the current optimistic state (including in-flight transitions), invokes `reducer(current, arg) → value`, sets transition to `Success(value, waiting=true)`, calls the underlying mutation `fn` with `arg` | ||
| 223 | - The next call to the same optimisticFn sees the prior call's optimistic value as `current` — so it stacks on top | ||
| 224 | - When `fn` settles, both calls' subscribers fire, the transition flips to non-waiting, and `refresh(self)` pulls the server state | ||
| 225 | - The server's authoritative response replaces the optimistic state via the | ||
| 226 | underlying atom's normal subscribe path | ||
| 227 | |||
| 228 | Net result: rapid edits look smooth, the last edit wins both visually and on | ||
| 229 | the server, no flickers, no manual cleanup. | ||
| 230 | |||
| 231 | ## Things to avoid | ||
| 232 | |||
| 233 | - ❌ `useState`/`useRef` to hold pending values | ||
| 234 | - ❌ `Map` / `Set` of in-flight ids | ||
| 235 | - ❌ Custom `mergePending` helpers | ||
| 236 | - ❌ `try/finally` blocks that "clear the placeholder" — `optimistic` clears for you | ||
| 237 | - ❌ Reading `policiesAtom` directly in the component while writing through `updatePolicyOptimistic` — they have different transition state, you'll see jumps | ||
| 238 | |||
| 239 | ## Reference implementation | ||
| 240 | |||
| 241 | `packages/react/src/api/atoms.tsx` (search for `policiesOptimisticAtom`) and | ||
| 242 | `packages/react/src/pages/policies.tsx` show the full pattern: optimistic | ||
| 243 | read, optimistic create/update/remove, no custom state. | ||
| 244 | |||
| 245 | ## When you must NOT use this | ||
| 246 | |||
| 247 | - The mutation has cross-cutting effects on data that *isn't* in the same list | ||
| 248 | (e.g. a single mutation invalidates `tools` AND `policies`). Reactivity keys | ||
| 249 | still handle that — optimistic only paints the list-local change. | ||
| 250 | - You need to show transient UI state that isn't a row property (toasts, | ||
| 251 | dirty indicators per field, pending counts). Those belong in component state, | ||
| 252 | not in the atom layer. | ||
| @@ -0,0 +1,17 @@ | |||
| 1 | CREATE TABLE "tool_policy" ( | ||
| 2 | "id" text NOT NULL, | ||
| 3 | "scope_id" text NOT NULL, | ||
| 4 | "pattern" text NOT NULL, | ||
| 5 | "action" text NOT NULL, | ||
| 6 | -- Fractional-indexing key (Jira lexorank style). Lex-ordered text; | ||
| 7 | -- always subdivisible by lengthening, so reorders never run out of | ||
| 8 | -- room. | ||
| 9 | "position" text NOT NULL, | ||
| 10 | "created_at" timestamp NOT NULL, | ||
| 11 | "updated_at" timestamp NOT NULL, | ||
| 12 | CONSTRAINT "tool_policy_scope_id_id_pk" PRIMARY KEY("scope_id","id") | ||
| 13 | ); | ||
| 14 | --> statement-breakpoint | ||
| 15 | -- List queries are always `WHERE scope_id = ? ORDER BY position`, so the | ||
| 16 | -- composite index serves both the filter and the sort from one btree. | ||
| 17 | CREATE INDEX "tool_policy_scope_id_position_idx" ON "tool_policy" USING btree ("scope_id", "position"); | ||
| @@ -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": true | 45 | "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 | } |
| @@ -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 | |||
| 493 | layer(TestEnv, { timeout: 60_000 })("cloud MCP request-id telemetry", (it) => { | ||
| 494 | it.effect("exports MCP JSON-RPC request ids for request-shaped ids", () => | ||
| 495 | Effect.gen(function* () { | ||
| 496 | const { baseUrl, seedOrg } = yield* Worker; | ||
| 497 | const receiver = yield* TelemetryReceiver; | ||
| 498 | const orgId = nextOrgId(); | ||
| 499 | const accountId = nextAccountId(); | ||
| 500 | const requestId = `req_${crypto.randomUUID().replace(/-/g, "")}`; | ||
| 501 | yield* Effect.promise(() => seedOrg(orgId, "Request Id Org")); | ||
| 502 | |||
| 503 | const response = yield* Effect.promise(() => | ||
| 504 | fetch(new URL("/mcp", baseUrl), { | ||
| 505 | method: "POST", | ||
| 506 | headers: { | ||
| 507 | accept: "application/json, text/event-stream", | ||
| 508 | authorization: `Bearer ${makeTestBearer(accountId, orgId)}`, | ||
| 509 | "content-type": "application/json", | ||
| 510 | }, | ||
| 511 | body: JSON.stringify({ | ||
| 512 | jsonrpc: "2.0", | ||
| 513 | id: requestId, | ||
| 514 | method: "initialize", | ||
| 515 | params: { | ||
| 516 | protocolVersion: "2025-06-18", | ||
| 517 | capabilities: {}, | ||
| 518 | clientInfo: { name: "mcp-request-id-e2e", version: "0" }, | ||
| 519 | }, | ||
| 520 | }), | ||
| 521 | }), | ||
| 522 | ); | ||
| 523 | expect(response.status).toBe(200); | ||
| 524 | yield* Effect.promise(() => response.text()); | ||
| 525 | |||
| 526 | const annotateSpan = yield* Effect.promise(() => | ||
| 527 | receiver.waitForSpan( | ||
| 528 | (s) => | ||
| 529 | s.name === "mcp.request.annotate" && | ||
| 530 | s.attributes["mcp.rpc.id"] === requestId, | ||
| 531 | ), | ||
| 532 | ); | ||
| 533 | expect(annotateSpan.attributes["mcp.rpc.method"]).toBe("initialize"); | ||
| 534 | }), 30_000, | ||
| 535 | ); | ||
| 536 | }); | ||
| @@ -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 endpoints | 376 | // OAuth metadata endpoints |
| @@ -11,6 +11,7 @@ · 10 unmodified lines | |||
| 11 | import { Route as rootRouteImport } from './routes/__root' | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | import { Route as ToolsRouteImport } from './routes/tools' | 12 | import { Route as ToolsRouteImport } from './routes/tools' |
| 13 | import { Route as SecretsRouteImport } from './routes/secrets' | 13 | import { Route as SecretsRouteImport } from './routes/secrets' |
| 14 | import { Route as PoliciesRouteImport } from './routes/policies' | ||
| 14 | import { Route as OrgRouteImport } from './routes/org' | 15 | import { Route as OrgRouteImport } from './routes/org' |
| 15 | import { Route as ConnectionsRouteImport } from './routes/connections' | 16 | import { Route as ConnectionsRouteImport } from './routes/connections' |
| 16 | import { Route as BillingRouteImport } from './routes/billing' | 17 | import { 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) |
| 33 | const PoliciesRoute = PoliciesRouteImport.update({ | ||
| 34 | id: '/policies', | ||
| 35 | path: '/policies', | ||
| 36 | getParentRoute: () => rootRouteImport, | ||
| 37 | } as any) | ||
| 32 | const OrgRoute = OrgRouteImport.update({ | 38 | const 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 BillingRoute | 76 | '/billing': typeof BillingRoute |
| 71 | '/connections': typeof ConnectionsRoute | 77 | '/connections': typeof ConnectionsRoute |
| 72 | '/org': typeof OrgRoute | 78 | '/org': typeof OrgRoute |
| 79 | '/policies': typeof PoliciesRoute | ||
| 73 | '/secrets': typeof SecretsRoute | 80 | '/secrets': typeof SecretsRoute |
| 74 | '/tools': typeof ToolsRoute | 81 | '/tools': typeof ToolsRoute |
| 75 | '/billing/plans': typeof BillingPlansRoute | 82 | '/billing/plans': typeof BillingPlansRoute |
| @@ -81,6 +88,7 @@ export interface FileRoutesByTo { · 5 unmodified lines | |||
| 81 | '/billing': typeof BillingRoute | 88 | '/billing': typeof BillingRoute |
| 82 | '/connections': typeof ConnectionsRoute | 89 | '/connections': typeof ConnectionsRoute |
| 83 | '/org': typeof OrgRoute | 90 | '/org': typeof OrgRoute |
| 91 | '/policies': typeof PoliciesRoute | ||
| 84 | '/secrets': typeof SecretsRoute | 92 | '/secrets': typeof SecretsRoute |
| 85 | '/tools': typeof ToolsRoute | 93 | '/tools': typeof ToolsRoute |
| 86 | '/billing/plans': typeof BillingPlansRoute | 94 | '/billing/plans': typeof BillingPlansRoute |
| @@ -93,6 +101,7 @@ export interface FileRoutesById { · 6 unmodified lines | |||
| 93 | '/billing': typeof BillingRoute | 101 | '/billing': typeof BillingRoute |
| 94 | '/connections': typeof ConnectionsRoute | 102 | '/connections': typeof ConnectionsRoute |
| 95 | '/org': typeof OrgRoute | 103 | '/org': typeof OrgRoute |
| 104 | '/policies': typeof PoliciesRoute | ||
| 96 | '/secrets': typeof SecretsRoute | 105 | '/secrets': typeof SecretsRoute |
| 97 | '/tools': typeof ToolsRoute | 106 | '/tools': typeof ToolsRoute |
| 98 | '/billing_/plans': typeof BillingPlansRoute | 107 | '/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 BillingRoute | 152 | BillingRoute: typeof BillingRoute |
| 141 | ConnectionsRoute: typeof ConnectionsRoute | 153 | ConnectionsRoute: typeof ConnectionsRoute |
| 142 | OrgRoute: typeof OrgRoute | 154 | OrgRoute: typeof OrgRoute |
| 155 | PoliciesRoute: typeof PoliciesRoute | ||
| 143 | SecretsRoute: typeof SecretsRoute | 156 | SecretsRoute: typeof SecretsRoute |
| 144 | ToolsRoute: typeof ToolsRoute | 157 | ToolsRoute: typeof ToolsRoute |
| 145 | BillingPlansRoute: typeof BillingPlansRoute | 158 | BillingPlansRoute: typeof BillingPlansRoute |
| @@ -163,6 +176,13 @@ declare module '@tanstack/react-router' { · 17 unmodified lines | |||
| 163 | preLoaderRoute: typeof SecretsRouteImport | 176 | preLoaderRoute: typeof SecretsRouteImport |
| 164 | parentRoute: typeof rootRouteImport | 177 | 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, |
| @@ -0,0 +1,6 @@ | |||
| 1 | import { createFileRoute } from "@tanstack/react-router"; | ||
| 2 | import { PoliciesPage } from "@executor/react/pages/policies"; | ||
| 3 | |||
| 4 | export const Route = createFileRoute("/policies")({ | ||
| 5 | component: () => <PoliciesPage />, | ||
| 6 | }); | ||
| @@ -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 | ||
| 86 | export const tool_policy = pgTable("tool_policy", { | ||
| 87 | id: text('id').notNull(), | ||
| 88 | scope_id: text('scope_id').notNull(), | ||
| 89 | pattern: text('pattern').notNull(), | ||
| 90 | action: text('action').notNull(), | ||
| 91 | // Fractional-indexing key (Jira lexorank style). Stored as text and | ||
| 92 | // compared lexicographically. Sortable like a number, but you can | ||
| 93 | // always lengthen the key to insert between two adjacent rows | ||
| 94 | // without precision loss. | ||
| 95 | position: text('position').notNull(), | ||
| 96 | created_at: timestamp('created_at').notNull(), | ||
| 97 | updated_at: timestamp('updated_at').notNull() | ||
| 98 | }, (table) => [ | ||
| 99 | primaryKey({ columns: [table.scope_id, table.id] }), | ||
| 100 | // Composite (scope_id, position) — list reads are always | ||
| 101 | // `WHERE scope_id = ? ORDER BY position` so the prefix lets postgres | ||
| 102 | // serve both the filter and the sort from one btree. The earlier | ||
| 103 | // standalone scope_id and position indexes were redundant. | ||
| 104 | index("tool_policy_scope_id_position_idx").on(table.scope_id, table.position), | ||
| 105 | ]); | ||
| 106 | |||
| 86 | export const openapi_source = pgTable("openapi_source", { | 107 | export 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(), |
| @@ -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 | ||
| @@ -0,0 +1,17 @@ | |||
| 1 | CREATE TABLE `tool_policy` ( | ||
| 2 | `id` text NOT NULL, | ||
| 3 | `scope_id` text NOT NULL, | ||
| 4 | `pattern` text NOT NULL, | ||
| 5 | `action` text NOT NULL, | ||
| 6 | -- Fractional-indexing key (Jira lexorank style). Lex-ordered text; | ||
| 7 | -- always subdivisible by lengthening, so reorders never run out of | ||
| 8 | -- room. | ||
| 9 | `position` text NOT NULL, | ||
| 10 | `created_at` integer NOT NULL, | ||
| 11 | `updated_at` integer NOT NULL, | ||
| 12 | PRIMARY KEY(`scope_id`, `id`) | ||
| 13 | ); | ||
| 14 | --> statement-breakpoint | ||
| 15 | -- List queries are always `WHERE scope_id = ? ORDER BY position`, so the | ||
| 16 | -- composite index serves both the filter and the sort from one btree. | ||
| 17 | CREATE INDEX `tool_policy_scope_id_position_idx` ON `tool_policy` (`scope_id`, `position`); | ||
| @@ -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": true | 31 | "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 | } |
| @@ -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 | ||
| 86 | export const tool_policy = sqliteTable("tool_policy", { | ||
| 87 | id: text('id').notNull(), | ||
| 88 | scope_id: text('scope_id').notNull(), | ||
| 89 | pattern: text('pattern').notNull(), | ||
| 90 | action: text('action').notNull(), | ||
| 91 | // Fractional-indexing key (Jira lexorank style). Lex-ordered text; | ||
| 92 | // always subdivisible by lengthening, so reorders never run out of | ||
| 93 | // room. | ||
| 94 | position: text('position').notNull(), | ||
| 95 | created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull(), | ||
| 96 | updated_at: integer('updated_at', { mode: 'timestamp_ms' }).notNull() | ||
| 97 | }, (table) => [ | ||
| 98 | primaryKey({ columns: [table.scope_id, table.id] }), | ||
| 99 | // List queries are always WHERE scope_id = ? ORDER BY position, so | ||
| 100 | // the composite index serves both the filter and the sort from one | ||
| 101 | // btree. | ||
| 102 | index("tool_policy_scope_id_position_idx").on(table.scope_id, table.position), | ||
| 103 | ]); | ||
| 104 | |||
| 86 | export const openapi_source = sqliteTable("openapi_source", { | 105 | export 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(), |
| @@ -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=="], |
| @@ -7,6 +7,7 @@ import { SecretsApi } from "./secrets/api"; · 6 unmodified lines | |||
| 7 | import { ConnectionsApi } from "./connections/api"; | 7 | import { ConnectionsApi } from "./connections/api"; |
| 8 | import { ExecutionsApi } from "./executions/api"; | 8 | import { ExecutionsApi } from "./executions/api"; |
| 9 | import { ScopeApi } from "./scope/api"; | 9 | import { ScopeApi } from "./scope/api"; |
| 10 | import { PoliciesApi } from "./policies/api"; | ||
| 10 | 11 | ||
| 11 | export const CoreExecutorApi = HttpApi.make("executor") | 12 | export 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", |
| @@ -6,6 +6,7 @@ import { SecretsHandlers } from "./secrets"; · 5 unmodified lines | |||
| 6 | import { ConnectionsHandlers } from "./connections"; | 6 | import { ConnectionsHandlers } from "./connections"; |
| 7 | import { ScopeHandlers } from "./scope"; | 7 | import { ScopeHandlers } from "./scope"; |
| 8 | import { ExecutionsHandlers } from "./executions"; | 8 | import { ExecutionsHandlers } from "./executions"; |
| 9 | import { PoliciesHandlers } from "./policies"; | ||
| 9 | 10 | ||
| 10 | export { ToolsHandlers } from "./tools"; | 11 | export { ToolsHandlers } from "./tools"; |
| 11 | export { SourcesHandlers } from "./sources"; | 12 | export { SourcesHandlers } from "./sources"; |
| @@ -13,6 +14,7 @@ export { SecretsHandlers } from "./secrets"; · 1 unmodified line | |||
| 13 | export { ConnectionsHandlers } from "./connections"; | 14 | export { ConnectionsHandlers } from "./connections"; |
| 14 | export { ScopeHandlers } from "./scope"; | 15 | export { ScopeHandlers } from "./scope"; |
| 15 | export { ExecutionsHandlers } from "./executions"; | 16 | export { ExecutionsHandlers } from "./executions"; |
| 17 | export { PoliciesHandlers } from "./policies"; | ||
| 16 | 18 | ||
| 17 | export const CoreHandlers = Layer.mergeAll( | 19 | export 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 | ); |
| @@ -0,0 +1,67 @@ | |||
| 1 | import { HttpApiBuilder } from "@effect/platform"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import type { ToolPolicy } from "@executor/sdk"; | ||
| 4 | |||
| 5 | import { ExecutorApi } from "../api"; | ||
| 6 | import { ExecutorService } from "../services"; | ||
| 7 | import { capture } from "@executor/api"; | ||
| 8 | |||
| 9 | const policyToResponse = (p: ToolPolicy) => ({ | ||
| 10 | id: p.id, | ||
| 11 | scopeId: p.scopeId, | ||
| 12 | pattern: p.pattern, | ||
| 13 | action: p.action, | ||
| 14 | position: p.position, | ||
| 15 | createdAt: p.createdAt.getTime(), | ||
| 16 | updatedAt: p.updatedAt.getTime(), | ||
| 17 | }); | ||
| 18 | |||
| 19 | export const PoliciesHandlers = HttpApiBuilder.group(ExecutorApi, "policies", (handlers) => | ||
| 20 | handlers | ||
| 21 | .handle("list", () => | ||
| 22 | capture( | ||
| 23 | Effect.gen(function* () { | ||
| 24 | const executor = yield* ExecutorService; | ||
| 25 | const policies = yield* executor.policies.list(); | ||
| 26 | return policies.map(policyToResponse); | ||
| 27 | }), | ||
| 28 | ), | ||
| 29 | ) | ||
| 30 | .handle("create", ({ path, payload }) => | ||
| 31 | capture( | ||
| 32 | Effect.gen(function* () { | ||
| 33 | const executor = yield* ExecutorService; | ||
| 34 | const created = yield* executor.policies.create({ | ||
| 35 | scope: path.scopeId, | ||
| 36 | pattern: payload.pattern, | ||
| 37 | action: payload.action, | ||
| 38 | position: payload.position, | ||
| 39 | }); | ||
| 40 | return policyToResponse(created); | ||
| 41 | }), | ||
| 42 | ), | ||
| 43 | ) | ||
| 44 | .handle("update", ({ path, payload }) => | ||
| 45 | capture( | ||
| 46 | Effect.gen(function* () { | ||
| 47 | const executor = yield* ExecutorService; | ||
| 48 | const updated = yield* executor.policies.update({ | ||
| 49 | id: path.policyId, | ||
| 50 | pattern: payload.pattern, | ||
| 51 | action: payload.action, | ||
| 52 | position: payload.position, | ||
| 53 | }); | ||
| 54 | return policyToResponse(updated); | ||
| 55 | }), | ||
| 56 | ), | ||
| 57 | ) | ||
| 58 | .handle("remove", ({ path }) => | ||
| 59 | capture( | ||
| 60 | Effect.gen(function* () { | ||
| 61 | const executor = yield* ExecutorService; | ||
| 62 | yield* executor.policies.remove(path.policyId); | ||
| 63 | return { removed: true }; | ||
| 64 | }), | ||
| 65 | ), | ||
| 66 | ), | ||
| 67 | ); | ||
| @@ -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 | ) |
| @@ -5,6 +5,7 @@ export { SecretsApi } from "./secrets/api"; · 4 unmodified lines | |||
| 5 | export { ConnectionsApi } from "./connections/api"; | 5 | export { ConnectionsApi } from "./connections/api"; |
| 6 | export { ExecutionsApi } from "./executions/api"; | 6 | export { ExecutionsApi } from "./executions/api"; |
| 7 | export { ScopeApi } from "./scope/api"; | 7 | export { ScopeApi } from "./scope/api"; |
| 8 | export { PoliciesApi } from "./policies/api"; | ||
| 8 | export { | 9 | export { |
| 9 | InternalError, | 10 | InternalError, |
| 10 | ErrorCapture, | 11 | ErrorCapture, |
| @@ -0,0 +1,65 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | ||
| 2 | import { Schema } from "effect"; | ||
| 3 | import { PolicyId, ScopeId, ToolPolicyActionSchema } from "@executor/sdk"; | ||
| 4 | |||
| 5 | import { InternalError } from "../observability"; | ||
| 6 | |||
| 7 | // --------------------------------------------------------------------------- | ||
| 8 | // Params | ||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | |||
| 11 | const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId); | ||
| 12 | const policyIdParam = HttpApiSchema.param("policyId", PolicyId); | ||
| 13 | |||
| 14 | // --------------------------------------------------------------------------- | ||
| 15 | // Response / payload schemas | ||
| 16 | // --------------------------------------------------------------------------- | ||
| 17 | |||
| 18 | const ToolPolicyResponse = Schema.Struct({ | ||
| 19 | id: PolicyId, | ||
| 20 | scopeId: ScopeId, | ||
| 21 | pattern: Schema.String, | ||
| 22 | action: ToolPolicyActionSchema, | ||
| 23 | position: Schema.String, | ||
| 24 | createdAt: Schema.Number, | ||
| 25 | updatedAt: Schema.Number, | ||
| 26 | }); | ||
| 27 | |||
| 28 | const CreateToolPolicyPayload = Schema.Struct({ | ||
| 29 | pattern: Schema.String, | ||
| 30 | action: ToolPolicyActionSchema, | ||
| 31 | position: Schema.optional(Schema.String), | ||
| 32 | }); | ||
| 33 | |||
| 34 | const UpdateToolPolicyPayload = Schema.Struct({ | ||
| 35 | pattern: Schema.optional(Schema.String), | ||
| 36 | action: Schema.optional(ToolPolicyActionSchema), | ||
| 37 | position: Schema.optional(Schema.String), | ||
| 38 | }); | ||
| 39 | |||
| 40 | // --------------------------------------------------------------------------- | ||
| 41 | // Group | ||
| 42 | // --------------------------------------------------------------------------- | ||
| 43 | |||
| 44 | export class PoliciesApi extends HttpApiGroup.make("policies") | ||
| 45 | .add( | ||
| 46 | HttpApiEndpoint.get("list")`/scopes/${scopeIdParam}/policies`.addSuccess( | ||
| 47 | Schema.Array(ToolPolicyResponse), | ||
| 48 | ), | ||
| 49 | ) | ||
| 50 | .add( | ||
| 51 | HttpApiEndpoint.post("create")`/scopes/${scopeIdParam}/policies` | ||
| 52 | .setPayload(CreateToolPolicyPayload) | ||
| 53 | .addSuccess(ToolPolicyResponse), | ||
| 54 | ) | ||
| 55 | .add( | ||
| 56 | HttpApiEndpoint.patch("update")`/scopes/${scopeIdParam}/policies/${policyIdParam}` | ||
| 57 | .setPayload(UpdateToolPolicyPayload) | ||
| 58 | .addSuccess(ToolPolicyResponse), | ||
| 59 | ) | ||
| 60 | .add( | ||
| 61 | HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/policies/${policyIdParam}`.addSuccess( | ||
| 62 | Schema.Struct({ removed: Schema.Boolean }), | ||
| 63 | ), | ||
| 64 | ) | ||
| 65 | .addError(InternalError) {} | ||
| @@ -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 | ||
| 51 | const DetectRequest = Schema.Struct({ | 55 | const DetectRequest = Schema.Struct({ |
| @@ -10,7 +10,7 @@ import { · 9 unmodified lines | |||
| 10 | } from "@executor/sdk"; | 10 | } from "@executor/sdk"; |
| 11 | import { makeQuickJsExecutor } from "@executor/runtime-quickjs"; | 11 | import { makeQuickJsExecutor } from "@executor/runtime-quickjs"; |
| 12 | import { createExecutionEngine } from "./engine"; | 12 | import { createExecutionEngine } from "./engine"; |
| 13 | import { describeTool, searchTools } from "./tool-invoker"; | 13 | import { describeTool, makeExecutorToolInvoker, searchTools } from "./tool-invoker"; |
| 14 | 14 | ||
| 15 | const codeExecutor = makeQuickJsExecutor(); | 15 | const codeExecutor = makeQuickJsExecutor(); |
| 16 | 16 | ||
| @@ -105,6 +105,33 @@ const crmPlugin = definePlugin(() => ({ · 88 unmodified lines | |||
| 105 | ], | 105 | ], |
| 106 | })); | 106 | })); |
| 107 | 107 | ||
| 108 | const errorPlugin = definePlugin(() => ({ | ||
| 109 | id: "error-test" as const, | ||
| 110 | storage: () => ({}), | ||
| 111 | staticSources: () => [ | ||
| 112 | { | ||
| 113 | id: "records", | ||
| 114 | kind: "in-memory", | ||
| 115 | name: "Records", | ||
| 116 | tools: [ | ||
| 117 | { | ||
| 118 | name: "queryRows", | ||
| 119 | description: "Query rows", | ||
| 120 | inputSchema: EmptyInputSchema, | ||
| 121 | handler: () => | ||
| 122 | Effect.succeed({ | ||
| 123 | data: null, | ||
| 124 | error: { | ||
| 125 | message: 'Field with name "DisplayName" does not exist', | ||
| 126 | code: "invalid_query", | ||
| 127 | }, | ||
| 128 | }), | ||
| 129 | }, | ||
| 130 | ], | ||
| 131 | }, | ||
| 132 | ], | ||
| 133 | })); | ||
| 134 | |||
| 108 | const makeSearchExecutor = () => | 135 | const 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 | // --------------------------------------------------------------------------- |
| @@ -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 | ||
| 25 | const stringifyUnknown = (value: unknown): string => { | ||
| 26 | try { | ||
| 27 | return JSON.stringify(value) ?? String(value); | ||
| 28 | } catch { | ||
| 29 | return String(value); | ||
| 30 | } | ||
| 31 | }; | ||
| 32 | |||
| 33 | const hasStringMessage = (value: unknown): value is { readonly message: string } => | ||
| 34 | value !== null && | ||
| 35 | typeof value === "object" && | ||
| 36 | "message" in value && | ||
| 37 | typeof value.message === "string"; | ||
| 38 | |||
| 39 | const messageFromErrorLike = (value: unknown): string | undefined => { | ||
| 40 | if (value instanceof Error || hasStringMessage(value)) { | ||
| 41 | return value.message; | ||
| 42 | } | ||
| 43 | return undefined; | ||
| 44 | }; | ||
| 45 | |||
| 46 | const renderToolErrorMessage = (error: unknown): string => | ||
| 47 | messageFromErrorLike(error) ?? | ||
| 48 | (typeof error === "undefined" ? "Tool execution failed" : stringifyUnknown(error)); | ||
| 49 | |||
| 25 | /** | 50 | /** |
| 26 | * Bridges QuickJS `tools.someSource.someOp(args)` calls into | 51 | * 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 !== undefined | 89 | (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; |
| @@ -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:", |
| @@ -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 | ||
| 153 | export type CoreSchema = typeof coreSchema; | 184 | export 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 | ||
| 210 | export type ToolPolicyRow = InferDBFieldsOutput< | ||
| 211 | CoreSchema["tool_policy"]["fields"] | ||
| 212 | > & | ||
| 213 | Record<string, unknown>; | ||
| 214 | |||
| 215 | // --------------------------------------------------------------------------- | ||
| 216 | // Tool policy — user-authored override of the default approval behavior. | ||
| 217 | // `action` tells the executor what to do at invoke time and at search / | ||
| 218 | // list time: | ||
| 219 | // - approve : skip the upfront approval prompt, just run. | ||
| 220 | // - require_approval : force an approval prompt even if the plugin's | ||
| 221 | // annotations would have skipped it. | ||
| 222 | // - block : invisible to search / list, hard-fail at invoke | ||
| 223 | // with `ToolBlockedError`. | ||
| 224 | // Mid-invocation elicitations (`mayElicit`) are NOT affected by policies. | ||
| 225 | // --------------------------------------------------------------------------- | ||
| 226 | |||
| 227 | export type ToolPolicyAction = "approve" | "require_approval" | "block"; | ||
| 228 | |||
| 229 | export const TOOL_POLICY_ACTIONS = [ | ||
| 230 | "approve", | ||
| 231 | "require_approval", | ||
| 232 | "block", | ||
| 233 | ] as const satisfies readonly ToolPolicyAction[]; | ||
| 234 | |||
| 235 | export const isToolPolicyAction = (value: unknown): value is ToolPolicyAction => | ||
| 236 | typeof value === "string" && | ||
| 237 | (TOOL_POLICY_ACTIONS as readonly string[]).includes(value); | ||
| 238 | |||
| 179 | // --------------------------------------------------------------------------- | 239 | // --------------------------------------------------------------------------- |
| 180 | // Tool annotations — default-policy metadata the executor consults | 240 | // Tool annotations — default-policy metadata the executor consults |
| 181 | // before invocation. Returned by `plugin.resolveAnnotations` (dynamic | 241 | // before invocation. Returned by `plugin.resolveAnnotations` (dynamic |
| @@ -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. */ | ||
| 47 | export class ToolBlockedError extends Schema.TaggedError<ToolBlockedError>()( | ||
| 48 | "ToolBlockedError", | ||
| 49 | { | ||
| 50 | toolId: ToolId, | ||
| 51 | pattern: Schema.String, | ||
| 52 | }, | ||
| 53 | ) {} | ||
| 54 | |||
| 43 | // --------------------------------------------------------------------------- | 55 | // --------------------------------------------------------------------------- |
| 44 | // Source lifecycle | 56 | // Source lifecycle |
| 45 | // --------------------------------------------------------------------------- | 57 | // --------------------------------------------------------------------------- |
| @@ -138,6 +150,7 @@ export type ExecutorError = · 92 unmodified lines | |||
| 138 | | ToolInvocationError | 150 | | ToolInvocationError |
| 139 | | PluginNotLoadedError | 151 | | PluginNotLoadedError |
| 140 | | NoHandlerError | 152 | | NoHandlerError |
| 153 | | ToolBlockedError | ||
| 141 | | SourceNotFoundError | 154 | | SourceNotFoundError |
| 142 | | SourceRemovalNotAllowedError | 155 | | SourceRemovalNotAllowedError |
| 143 | | SecretNotFoundError | 156 | | SecretNotFoundError |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { Deferred, Effect, FiberRef, Option, Schema } from "effect"; | 1 | import { Deferred, Effect, FiberRef, Option, Schema } from "effect"; |
| 2 | import { generateKeyBetween } from "fractional-indexing"; | ||
| 2 | import { | 3 | import { |
| 3 | StorageError, | 4 | StorageError, |
| 4 | typedAdapter, | 5 | typedAdapter, |
| @@ -23,12 +24,14 @@ import { · 18 unmodified lines | |||
| 23 | } from "./connections"; | 24 | } from "./connections"; |
| 24 | import { | 25 | import { |
| 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"; |
| 34 | import { | 37 | import { |
| @@ -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"; |
| 53 | import { ConnectionId, ScopeId, SecretId, ToolId } from "./ids"; | 57 | import { ConnectionId, ScopeId, SecretId, ToolId } from "./ids"; |
| 58 | import { | ||
| 59 | comparePolicyRow, | ||
| 60 | isValidPattern, | ||
| 61 | resolveToolPolicy, | ||
| 62 | rowToToolPolicy, | ||
| 63 | type CreateToolPolicyInput, | ||
| 64 | type PolicyMatch, | ||
| 65 | type ToolPolicy, | ||
| 66 | type UpdateToolPolicyInput, | ||
| 67 | } from "./policies"; | ||
| 54 | import type { | 68 | import 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 | | ToolNotFoundError | 158 | | ToolNotFoundError |
| 159 | | ToolBlockedError | ||
| 145 | | PluginNotLoadedError | 160 | | PluginNotLoadedError |
| 146 | | NoHandlerError | 161 | | NoHandlerError |
| 147 | | ToolInvocationError | 162 | | 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 already | 2348 | // 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 + invokeTool | 2350 | // 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.plugin | 2356 | 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 | ||
| @@ -32,6 +32,7 @@ export { Scope } from "./scope"; · 31 unmodified lines | |||
| 32 | export { | 32 | export { |
| 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 schema | 59 | // Core schema |
| 59 | export { | 60 | export { |
| 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 | ||
| 79 | export { | ||
| 80 | matchPattern, | ||
| 81 | isValidPattern, | ||
| 82 | resolveToolPolicy, | ||
| 83 | resolveEffectivePolicy, | ||
| 84 | effectivePolicyFromSorted, | ||
| 85 | rowToToolPolicy, | ||
| 86 | ToolPolicyActionSchema, | ||
| 87 | type ToolPolicy, | ||
| 88 | type CreateToolPolicyInput, | ||
| 89 | type UpdateToolPolicyInput, | ||
| 90 | type PolicyMatch, | ||
| 91 | type EffectivePolicy, | ||
| 92 | type PolicySource, | ||
| 93 | } from "./policies"; | ||
| 94 | |||
| 73 | // Secrets | 95 | // Secrets |
| 74 | export { | 96 | export { |
| 75 | SecretRef, | 97 | SecretRef, |
| @@ -0,0 +1,614 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import { generateKeyBetween } from "fractional-indexing"; | ||
| 4 | |||
| 5 | import type { ToolPolicyRow } from "./core-schema"; | ||
| 6 | import { PolicyId } from "./ids"; | ||
| 7 | import { createExecutor } from "./executor"; | ||
| 8 | import { | ||
| 9 | ElicitationResponse, | ||
| 10 | type ElicitationHandler, | ||
| 11 | } from "./elicitation"; | ||
| 12 | import { | ||
| 13 | effectivePolicyFromSorted, | ||
| 14 | isValidPattern, | ||
| 15 | matchPattern, | ||
| 16 | resolveToolPolicy, | ||
| 17 | } from "./policies"; | ||
| 18 | import { definePlugin, defineSchema } from "./plugin"; | ||
| 19 | import { makeTestConfig } from "./testing"; | ||
| 20 | |||
| 21 | // --------------------------------------------------------------------------- | ||
| 22 | // Pure unit tests — pattern matcher + resolution. No executor required. | ||
| 23 | // --------------------------------------------------------------------------- | ||
| 24 | |||
| 25 | describe("matchPattern", () => { | ||
| 26 | it("matches exact tool ids", () => { | ||
| 27 | expect(matchPattern("vercel.dns.create", "vercel.dns.create")).toBe(true); | ||
| 28 | expect(matchPattern("vercel.dns.create", "vercel.dns.delete")).toBe(false); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("matches subtree wildcards", () => { | ||
| 32 | expect(matchPattern("vercel.dns.*", "vercel.dns.create")).toBe(true); | ||
| 33 | expect(matchPattern("vercel.dns.*", "vercel.dns.delete")).toBe(true); | ||
| 34 | expect(matchPattern("vercel.dns.*", "vercel.dns.zones.list")).toBe(true); | ||
| 35 | expect(matchPattern("vercel.dns.*", "vercel.dnstool")).toBe(false); | ||
| 36 | expect(matchPattern("vercel.dns.*", "vercel.deploy")).toBe(false); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("matches plugin-wide wildcards", () => { | ||
| 40 | expect(matchPattern("vercel.*", "vercel.dns.create")).toBe(true); | ||
| 41 | expect(matchPattern("vercel.*", "vercel.deploy")).toBe(true); | ||
| 42 | expect(matchPattern("vercel.*", "vercelapp.deploy")).toBe(false); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("does not collapse the dot boundary", () => { | ||
| 46 | // The tool id must continue with a dot after the wildcard prefix — | ||
| 47 | // otherwise `vercel.dns.*` would silently capture `vercel.dnstool`. | ||
| 48 | expect(matchPattern("vercel.dns.*", "vercel.dnstool")).toBe(false); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("matches every tool id when the pattern is bare *", () => { | ||
| 52 | expect(matchPattern("*", "vercel.dns.create")).toBe(true); | ||
| 53 | expect(matchPattern("*", "github.repos.list")).toBe(true); | ||
| 54 | expect(matchPattern("*", "x")).toBe(true); | ||
| 55 | }); | ||
| 56 | }); | ||
| 57 | |||
| 58 | describe("isValidPattern", () => { | ||
| 59 | it("accepts exact ids and trailing wildcards", () => { | ||
| 60 | expect(isValidPattern("a")).toBe(true); | ||
| 61 | expect(isValidPattern("a.b")).toBe(true); | ||
| 62 | expect(isValidPattern("a.b.c")).toBe(true); | ||
| 63 | expect(isValidPattern("a.*")).toBe(true); | ||
| 64 | expect(isValidPattern("a.b.*")).toBe(true); | ||
| 65 | }); | ||
| 66 | |||
| 67 | it("accepts the universal pattern", () => { | ||
| 68 | expect(isValidPattern("*")).toBe(true); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("rejects malformed shapes", () => { | ||
| 72 | expect(isValidPattern("")).toBe(false); | ||
| 73 | expect(isValidPattern(".a")).toBe(false); | ||
| 74 | expect(isValidPattern("a.")).toBe(false); | ||
| 75 | expect(isValidPattern("a..b")).toBe(false); | ||
| 76 | expect(isValidPattern("*.a")).toBe(false); | ||
| 77 | expect(isValidPattern("a.*.b")).toBe(false); | ||
| 78 | expect(isValidPattern("a*")).toBe(false); | ||
| 79 | expect(isValidPattern("a.b*")).toBe(false); | ||
| 80 | }); | ||
| 81 | }); | ||
| 82 | |||
| 83 | describe("resolveToolPolicy", () => { | ||
| 84 | const ROW = ( | ||
| 85 | id: string, | ||
| 86 | pattern: string, | ||
| 87 | action: "approve" | "require_approval" | "block", | ||
| 88 | position: string, | ||
| 89 | scope_id = "test-scope", | ||
| 90 | ): ToolPolicyRow => | ||
| 91 | ({ | ||
| 92 | id, | ||
| 93 | scope_id, | ||
| 94 | pattern, | ||
| 95 | action, | ||
| 96 | position, | ||
| 97 | created_at: new Date(0), | ||
| 98 | updated_at: new Date(0), | ||
| 99 | }) as ToolPolicyRow; | ||
| 100 | |||
| 101 | const flatRank = () => 0; // single-scope tests | ||
| 102 | |||
| 103 | it("returns undefined when no policies match", () => { | ||
| 104 | const result = resolveToolPolicy( | ||
| 105 | "vercel.dns.create", | ||
| 106 | [ROW("a", "github.*", "block", "a0")], | ||
| 107 | flatRank, | ||
| 108 | ); | ||
| 109 | expect(result).toBeUndefined(); | ||
| 110 | }); | ||
| 111 | |||
| 112 | it("returns the first matching rule by position", () => { | ||
| 113 | // Lowest position = highest precedence. Specific exception listed | ||
| 114 | // above the broad rule wins for the specific tool id. | ||
| 115 | const result = resolveToolPolicy( | ||
| 116 | "vercel.dns.create", | ||
| 117 | [ | ||
| 118 | ROW("a", "vercel.dns.create", "approve", "a0"), | ||
| 119 | ROW("b", "vercel.dns.*", "require_approval", "a1"), | ||
| 120 | ], | ||
| 121 | flatRank, | ||
| 122 | ); | ||
| 123 | expect(result?.action).toBe("approve"); | ||
| 124 | expect(result?.pattern).toBe("vercel.dns.create"); | ||
| 125 | expect(result?.policyId).toBe("a"); | ||
| 126 | }); | ||
| 127 | |||
| 128 | it("falls through to the broader rule when the specific rule is below it", () => { | ||
| 129 | // First-match-wins is purely positional — if the user puts the | ||
| 130 | // broad rule above the specific exception, the broad rule wins. | ||
| 131 | // The system trusts the user's ordering. | ||
| 132 | const result = resolveToolPolicy( | ||
| 133 | "vercel.dns.create", | ||
| 134 | [ | ||
| 135 | ROW("b", "vercel.dns.*", "require_approval", "a0"), | ||
| 136 | ROW("a", "vercel.dns.create", "approve", "a1"), | ||
| 137 | ], | ||
| 138 | flatRank, | ||
| 139 | ); | ||
| 140 | expect(result?.action).toBe("require_approval"); | ||
| 141 | expect(result?.pattern).toBe("vercel.dns.*"); | ||
| 142 | }); | ||
| 143 | |||
| 144 | it("walks innermost scope first", () => { | ||
| 145 | // Scope rank comes from the executor's scope stack: 0 = innermost, | ||
| 146 | // 1 = next, etc. An inner-scope rule wins even when its position | ||
| 147 | // would otherwise put it after the outer rule. | ||
| 148 | const policies = [ | ||
| 149 | ROW("outer", "vercel.*", "block", "a0", "org"), | ||
| 150 | ROW("inner", "vercel.dns.create", "approve", "a0", "user"), | ||
| 151 | ]; | ||
| 152 | const rank = (row: { scope_id: unknown }) => | ||
| 153 | row.scope_id === "user" ? 0 : 1; | ||
| 154 | const result = resolveToolPolicy( | ||
| 155 | "vercel.dns.create", | ||
| 156 | policies, | ||
| 157 | rank, | ||
| 158 | ); | ||
| 159 | expect(result?.action).toBe("approve"); | ||
| 160 | expect(result?.policyId).toBe("inner"); | ||
| 161 | }); | ||
| 162 | |||
| 163 | it("tiebreaks identical positions by id so order is deterministic", () => { | ||
| 164 | // Two rows with the same `position` (racing inserts that picked the | ||
| 165 | // same fractional-indexing key from independent clients) must sort | ||
| 166 | // deterministically — otherwise the rendered order flips on every | ||
| 167 | // refetch and reorder math sees colliding neighbor keys. | ||
| 168 | const a = resolveToolPolicy( | ||
| 169 | "vercel.dns.create", | ||
| 170 | [ | ||
| 171 | ROW("z", "vercel.dns.*", "block", "a0"), | ||
| 172 | ROW("a", "vercel.dns.*", "approve", "a0"), | ||
| 173 | ], | ||
| 174 | flatRank, | ||
| 175 | ); | ||
| 176 | const b = resolveToolPolicy( | ||
| 177 | "vercel.dns.create", | ||
| 178 | [ | ||
| 179 | ROW("a", "vercel.dns.*", "approve", "a0"), | ||
| 180 | ROW("z", "vercel.dns.*", "block", "a0"), | ||
| 181 | ], | ||
| 182 | flatRank, | ||
| 183 | ); | ||
| 184 | // Same input rows in different order, same winner — id "a" sorts | ||
| 185 | // before "z" so it wins regardless of array order. | ||
| 186 | expect(a?.policyId).toBe("a"); | ||
| 187 | expect(b?.policyId).toBe("a"); | ||
| 188 | }); | ||
| 189 | }); | ||
| 190 | |||
| 191 | describe("effectivePolicyFromSorted", () => { | ||
| 192 | const POL = ( | ||
| 193 | id: string, | ||
| 194 | pattern: string, | ||
| 195 | action: "approve" | "require_approval" | "block", | ||
| 196 | ) => ({ id: PolicyId.make(id), pattern, action }); | ||
| 197 | |||
| 198 | it("returns user policy when one matches", () => { | ||
| 199 | const result = effectivePolicyFromSorted( | ||
| 200 | "vercel.dns.create", | ||
| 201 | [POL("a", "vercel.dns.*", "block")], | ||
| 202 | true, | ||
| 203 | ); | ||
| 204 | expect(result).toEqual({ | ||
| 205 | action: "block", | ||
| 206 | source: "user", | ||
| 207 | pattern: "vercel.dns.*", | ||
| 208 | policyId: PolicyId.make("a"), | ||
| 209 | }); | ||
| 210 | }); | ||
| 211 | |||
| 212 | it("falls back to plugin default require_approval", () => { | ||
| 213 | const result = effectivePolicyFromSorted("vercel.dns.create", [], true); | ||
| 214 | expect(result).toEqual({ | ||
| 215 | action: "require_approval", | ||
| 216 | source: "plugin-default", | ||
| 217 | }); | ||
| 218 | }); | ||
| 219 | |||
| 220 | it("falls back to plugin default approve when annotation is false/undefined", () => { | ||
| 221 | expect( | ||
| 222 | effectivePolicyFromSorted("vercel.dns.create", [], false), | ||
| 223 | ).toEqual({ action: "approve", source: "plugin-default" }); | ||
| 224 | expect(effectivePolicyFromSorted("vercel.dns.create", [])).toEqual({ | ||
| 225 | action: "approve", | ||
| 226 | source: "plugin-default", | ||
| 227 | }); | ||
| 228 | }); | ||
| 229 | |||
| 230 | it("user policy wins over plugin default", () => { | ||
| 231 | // Plugin default would be require_approval; user explicitly approves. | ||
| 232 | const result = effectivePolicyFromSorted( | ||
| 233 | "vercel.dns.create", | ||
| 234 | [POL("a", "vercel.dns.create", "approve")], | ||
| 235 | true, | ||
| 236 | ); | ||
| 237 | expect(result.action).toBe("approve"); | ||
| 238 | expect(result.source).toBe("user"); | ||
| 239 | }); | ||
| 240 | }); | ||
| 241 | |||
| 242 | // --------------------------------------------------------------------------- | ||
| 243 | // Executor integration — exercises invoke + list + CRUD with a real | ||
| 244 | // in-memory adapter and a tiny test plugin. Mirrors the design choices: | ||
| 245 | // - block → invisible to list; ToolBlockedError at invoke | ||
| 246 | // - approve → invoke skips approval prompt | ||
| 247 | // - require_approval → invoke fires elicitation, declined => fails | ||
| 248 | // - undefined → falls through to plugin annotation | ||
| 249 | // --------------------------------------------------------------------------- | ||
| 250 | |||
| 251 | const recordingHandler = (calls: { count: number }): ElicitationHandler => | ||
| 252 | (() => { | ||
| 253 | calls.count++; | ||
| 254 | return Effect.succeed(new ElicitationResponse({ action: "accept" })); | ||
| 255 | }) as ElicitationHandler; | ||
| 256 | |||
| 257 | const decliningHandler: ElicitationHandler = () => | ||
| 258 | Effect.succeed(new ElicitationResponse({ action: "decline" })); | ||
| 259 | |||
| 260 | const policyTestSchema = defineSchema({ | ||
| 261 | ptest_marker: { | ||
| 262 | fields: { | ||
| 263 | id: { type: "string", required: true }, | ||
| 264 | }, | ||
| 265 | }, | ||
| 266 | }); | ||
| 267 | |||
| 268 | const policyTestPlugin = definePlugin(() => ({ | ||
| 269 | id: "ptest" as const, | ||
| 270 | schema: policyTestSchema, | ||
| 271 | storage: () => ({}), | ||
| 272 | resolveAnnotations: ({ toolRows }) => { | ||
| 273 | const out: Record<string, { requiresApproval?: boolean }> = {}; | ||
| 274 | for (const row of toolRows) { | ||
| 275 | // Make tools whose name contains "delete" require approval by | ||
| 276 | // default — mirrors openapi's HTTP-method heuristic in spirit. | ||
| 277 | out[row.id as string] = { | ||
| 278 | requiresApproval: (row.name as string).toLowerCase().includes("delete"), | ||
| 279 | }; | ||
| 280 | } | ||
| 281 | return Effect.succeed(out); | ||
| 282 | }, | ||
| 283 | extension: (ctx) => ({ | ||
| 284 | seed: () => | ||
| 285 | ctx.transaction( | ||
| 286 | Effect.gen(function* () { | ||
| 287 | yield* ctx.core.sources.register({ | ||
| 288 | id: "vercel", | ||
| 289 | scope: "test-scope", | ||
| 290 | kind: "test", | ||
| 291 | name: "Vercel", | ||
| 292 | tools: [ | ||
| 293 | { name: "deploy", description: "deploy" }, | ||
| 294 | { name: "delete", description: "delete a deployment" }, | ||
| 295 | ], | ||
| 296 | }); | ||
| 297 | yield* ctx.core.sources.register({ | ||
| 298 | id: "github", | ||
| 299 | scope: "test-scope", | ||
| 300 | kind: "test", | ||
| 301 | name: "GitHub", | ||
| 302 | tools: [{ name: "list", description: "list repos" }], | ||
| 303 | }); | ||
| 304 | }), | ||
| 305 | ), | ||
| 306 | }), | ||
| 307 | invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.id }), | ||
| 308 | })); | ||
| 309 | |||
| 310 | const setupExecutor = () => | ||
| 311 | Effect.gen(function* () { | ||
| 312 | const config = makeTestConfig({ plugins: [policyTestPlugin()] as const }); | ||
| 313 | const executor = yield* createExecutor(config); | ||
| 314 | yield* executor.ptest.seed(); | ||
| 315 | return executor; | ||
| 316 | }); | ||
| 317 | |||
| 318 | describe("executor.policies", () => { | ||
| 319 | it.effect("list is empty when no rules exist", () => | ||
| 320 | Effect.gen(function* () { | ||
| 321 | const executor = yield* setupExecutor(); | ||
| 322 | const rules = yield* executor.policies.list(); | ||
| 323 | expect(rules).toEqual([]); | ||
| 324 | }), | ||
| 325 | ); | ||
| 326 | |||
| 327 | it.effect("create defaults new rules to the top of the list", () => | ||
| 328 | Effect.gen(function* () { | ||
| 329 | const executor = yield* setupExecutor(); | ||
| 330 | const first = yield* executor.policies.create({ | ||
| 331 | scope: "test-scope", | ||
| 332 | pattern: "vercel.*", | ||
| 333 | action: "require_approval", | ||
| 334 | }); | ||
| 335 | const second = yield* executor.policies.create({ | ||
| 336 | scope: "test-scope", | ||
| 337 | pattern: "vercel.delete", | ||
| 338 | action: "block", | ||
| 339 | }); | ||
| 340 | // second was created later but should sit above first (lower lex | ||
| 341 | // order on the fractional-indexing key). | ||
| 342 | expect(second.position < first.position).toBe(true); | ||
| 343 | |||
| 344 | const rules = yield* executor.policies.list(); | ||
| 345 | expect(rules.map((r) => r.pattern)).toEqual([ | ||
| 346 | "vercel.delete", | ||
| 347 | "vercel.*", | ||
| 348 | ]); | ||
| 349 | }), | ||
| 350 | ); | ||
| 351 | |||
| 352 | it.effect("rejects malformed patterns", () => | ||
| 353 | Effect.gen(function* () { | ||
| 354 | const executor = yield* setupExecutor(); | ||
| 355 | const result = yield* Effect.either( | ||
| 356 | executor.policies.create({ | ||
| 357 | scope: "test-scope", | ||
| 358 | pattern: "vercel..bad", | ||
| 359 | action: "block", | ||
| 360 | }), | ||
| 361 | ); | ||
| 362 | expect(result._tag).toBe("Left"); | ||
| 363 | }), | ||
| 364 | ); | ||
| 365 | |||
| 366 | it.effect("update mutates the row in place", () => | ||
| 367 | Effect.gen(function* () { | ||
| 368 | const executor = yield* setupExecutor(); | ||
| 369 | const created = yield* executor.policies.create({ | ||
| 370 | scope: "test-scope", | ||
| 371 | pattern: "vercel.*", | ||
| 372 | action: "require_approval", | ||
| 373 | }); | ||
| 374 | yield* executor.policies.update({ | ||
| 375 | id: created.id, | ||
| 376 | action: "block", | ||
| 377 | }); | ||
| 378 | const rules = yield* executor.policies.list(); | ||
| 379 | expect(rules[0]?.action).toBe("block"); | ||
| 380 | }), | ||
| 381 | ); | ||
| 382 | |||
| 383 | it.effect("update without position preserves the existing position", () => | ||
| 384 | Effect.gen(function* () { | ||
| 385 | const executor = yield* setupExecutor(); | ||
| 386 | const created = yield* executor.policies.create({ | ||
| 387 | scope: "test-scope", | ||
| 388 | pattern: "vercel.*", | ||
| 389 | action: "require_approval", | ||
| 390 | }); | ||
| 391 | yield* executor.policies.update({ | ||
| 392 | id: created.id, | ||
| 393 | action: "block", | ||
| 394 | }); | ||
| 395 | const rules = yield* executor.policies.list(); | ||
| 396 | expect(rules[0]?.position).toBe(created.position); | ||
| 397 | }), | ||
| 398 | ); | ||
| 399 | |||
| 400 | it.effect("update with a new position reorders the list", () => | ||
| 401 | Effect.gen(function* () { | ||
| 402 | const executor = yield* setupExecutor(); | ||
| 403 | const a = yield* executor.policies.create({ | ||
| 404 | scope: "test-scope", | ||
| 405 | pattern: "a.*", | ||
| 406 | action: "approve", | ||
| 407 | }); | ||
| 408 | const b = yield* executor.policies.create({ | ||
| 409 | scope: "test-scope", | ||
| 410 | pattern: "b.*", | ||
| 411 | action: "approve", | ||
| 412 | }); | ||
| 413 | // After two creates: b above a (newer = higher precedence). | ||
| 414 | const before = yield* executor.policies.list(); | ||
| 415 | expect(before.map((r) => r.pattern)).toEqual(["b.*", "a.*"]); | ||
| 416 | |||
| 417 | // Move `a` above `b` by setting a position lex-less-than b's. | ||
| 418 | // generateKeyBetween(null, b.position) is what the UI would do. | ||
| 419 | yield* executor.policies.update({ | ||
| 420 | id: a.id, | ||
| 421 | position: generateKeyBetween(null, b.position), | ||
| 422 | }); | ||
| 423 | const after = yield* executor.policies.list(); | ||
| 424 | expect(after.map((r) => r.pattern)).toEqual(["a.*", "b.*"]); | ||
| 425 | }), | ||
| 426 | ); | ||
| 427 | |||
| 428 | it.effect( | ||
| 429 | "consecutive creates produce strictly increasing-precedence keys", | ||
| 430 | () => | ||
| 431 | Effect.gen(function* () { | ||
| 432 | const executor = yield* setupExecutor(); | ||
| 433 | const created: string[] = []; | ||
| 434 | for (const pattern of ["a.*", "b.*", "c.*", "d.*"]) { | ||
| 435 | const row = yield* executor.policies.create({ | ||
| 436 | scope: "test-scope", | ||
| 437 | pattern, | ||
| 438 | action: "approve", | ||
| 439 | }); | ||
| 440 | created.push(row.position); | ||
| 441 | } | ||
| 442 | // Each new key sorts strictly above the previous (lower lex | ||
| 443 | // order = higher precedence). No collisions. | ||
| 444 | for (let i = 1; i < created.length; i++) { | ||
| 445 | expect(created[i]! < created[i - 1]!).toBe(true); | ||
| 446 | } | ||
| 447 | // List order matches insertion-reverse. | ||
| 448 | const rules = yield* executor.policies.list(); | ||
| 449 | expect(rules.map((r) => r.pattern)).toEqual([ | ||
| 450 | "d.*", | ||
| 451 | "c.*", | ||
| 452 | "b.*", | ||
| 453 | "a.*", | ||
| 454 | ]); | ||
| 455 | }), | ||
| 456 | ); | ||
| 457 | |||
| 458 | it.effect("remove deletes the row", () => | ||
| 459 | Effect.gen(function* () { | ||
| 460 | const executor = yield* setupExecutor(); | ||
| 461 | const created = yield* executor.policies.create({ | ||
| 462 | scope: "test-scope", | ||
| 463 | pattern: "vercel.*", | ||
| 464 | action: "block", | ||
| 465 | }); | ||
| 466 | yield* executor.policies.remove(created.id); | ||
| 467 | const rules = yield* executor.policies.list(); | ||
| 468 | expect(rules).toEqual([]); | ||
| 469 | }), | ||
| 470 | ); | ||
| 471 | |||
| 472 | it.effect("resolve returns the matching rule with provenance", () => | ||
| 473 | Effect.gen(function* () { | ||
| 474 | const executor = yield* setupExecutor(); | ||
| 475 | yield* executor.policies.create({ | ||
| 476 | scope: "test-scope", | ||
| 477 | pattern: "vercel.*", | ||
| 478 | action: "block", | ||
| 479 | }); | ||
| 480 | const result = yield* executor.policies.resolve("vercel.deploy"); | ||
| 481 | expect(result?.action).toBe("block"); | ||
| 482 | expect(result?.pattern).toBe("vercel.*"); | ||
| 483 | }), | ||
| 484 | ); | ||
| 485 | }); | ||
| 486 | |||
| 487 | describe("blocked tools", () => { | ||
| 488 | it.effect("are filtered from tools.list by default", () => | ||
| 489 | Effect.gen(function* () { | ||
| 490 | const executor = yield* setupExecutor(); | ||
| 491 | yield* executor.policies.create({ | ||
| 492 | scope: "test-scope", | ||
| 493 | pattern: "vercel.delete", | ||
| 494 | action: "block", | ||
| 495 | }); | ||
| 496 | const tools = yield* executor.tools.list(); | ||
| 497 | const ids = tools.map((t) => t.id).sort(); | ||
| 498 | expect(ids).toEqual(["github.list", "vercel.deploy"]); | ||
| 499 | }), | ||
| 500 | ); | ||
| 501 | |||
| 502 | it.effect("are visible when includeBlocked is true", () => | ||
| 503 | Effect.gen(function* () { | ||
| 504 | const executor = yield* setupExecutor(); | ||
| 505 | yield* executor.policies.create({ | ||
| 506 | scope: "test-scope", | ||
| 507 | pattern: "vercel.delete", | ||
| 508 | action: "block", | ||
| 509 | }); | ||
| 510 | const tools = yield* executor.tools.list({ includeBlocked: true }); | ||
| 511 | const ids = tools.map((t) => t.id).sort(); | ||
| 512 | expect(ids).toEqual(["github.list", "vercel.delete", "vercel.deploy"]); | ||
| 513 | }), | ||
| 514 | ); | ||
| 515 | |||
| 516 | it.effect("invoke fails with ToolBlockedError carrying the matched pattern", () => | ||
| 517 | Effect.gen(function* () { | ||
| 518 | const executor = yield* setupExecutor(); | ||
| 519 | yield* executor.policies.create({ | ||
| 520 | scope: "test-scope", | ||
| 521 | pattern: "vercel.*", | ||
| 522 | action: "block", | ||
| 523 | }); | ||
| 524 | const result = yield* Effect.either( | ||
| 525 | executor.tools.invoke("vercel.delete", {}), | ||
| 526 | ); | ||
| 527 | expect(result._tag).toBe("Left"); | ||
| 528 | if (result._tag === "Left") { | ||
| 529 | expect((result.left as { _tag?: string })._tag).toBe( | ||
| 530 | "ToolBlockedError", | ||
| 531 | ); | ||
| 532 | expect((result.left as { pattern?: string }).pattern).toBe("vercel.*"); | ||
| 533 | } | ||
| 534 | }), | ||
| 535 | ); | ||
| 536 | }); | ||
| 537 | |||
| 538 | describe("approve / require_approval interaction with annotations", () => { | ||
| 539 | it.effect("approve skips the elicitation prompt even when plugin requires approval", () => | ||
| 540 | Effect.gen(function* () { | ||
| 541 | const executor = yield* setupExecutor(); | ||
| 542 | yield* executor.policies.create({ | ||
| 543 | scope: "test-scope", | ||
| 544 | pattern: "vercel.delete", | ||
| 545 | action: "approve", | ||
| 546 | }); | ||
| 547 | const calls = { count: 0 }; | ||
| 548 | const result = yield* executor.tools.invoke("vercel.delete", {}, { | ||
| 549 | onElicitation: recordingHandler(calls), | ||
| 550 | }); | ||
| 551 | expect(calls.count).toBe(0); | ||
| 552 | expect(result).toEqual({ ran: "vercel.delete" }); | ||
| 553 | }), | ||
| 554 | ); | ||
| 555 | |||
| 556 | it.effect("require_approval forces the prompt for tools the plugin would auto-approve", () => | ||
| 557 | Effect.gen(function* () { | ||
| 558 | const executor = yield* setupExecutor(); | ||
| 559 | yield* executor.policies.create({ | ||
| 560 | scope: "test-scope", | ||
| 561 | pattern: "vercel.deploy", | ||
| 562 | action: "require_approval", | ||
| 563 | }); | ||
| 564 | const calls = { count: 0 }; | ||
| 565 | yield* executor.tools.invoke("vercel.deploy", {}, { | ||
| 566 | onElicitation: recordingHandler(calls), | ||
| 567 | }); | ||
| 568 | expect(calls.count).toBe(1); | ||
| 569 | }), | ||
| 570 | ); | ||
| 571 | |||
| 572 | it.effect("require_approval surfaces ElicitationDeclined when user declines", () => | ||
| 573 | Effect.gen(function* () { | ||
| 574 | const executor = yield* setupExecutor(); | ||
| 575 | yield* executor.policies.create({ | ||
| 576 | scope: "test-scope", | ||
| 577 | pattern: "vercel.deploy", | ||
| 578 | action: "require_approval", | ||
| 579 | }); | ||
| 580 | const result = yield* Effect.either( | ||
| 581 | executor.tools.invoke("vercel.deploy", {}, { | ||
| 582 | onElicitation: decliningHandler, | ||
| 583 | }), | ||
| 584 | ); | ||
| 585 | expect(result._tag).toBe("Left"); | ||
| 586 | if (result._tag === "Left") { | ||
| 587 | expect((result.left as { _tag?: string })._tag).toBe( | ||
| 588 | "ElicitationDeclinedError", | ||
| 589 | ); | ||
| 590 | } | ||
| 591 | }), | ||
| 592 | ); | ||
| 593 | |||
| 594 | it.effect("absence of policy falls through to plugin annotation", () => | ||
| 595 | Effect.gen(function* () { | ||
| 596 | const executor = yield* setupExecutor(); | ||
| 597 | // No policy for vercel.delete — the plugin's resolveAnnotations | ||
| 598 | // marks any tool whose name contains "delete" as requiring | ||
| 599 | // approval, so the prompt should fire. | ||
| 600 | const calls = { count: 0 }; | ||
| 601 | yield* executor.tools.invoke("vercel.delete", {}, { | ||
| 602 | onElicitation: recordingHandler(calls), | ||
| 603 | }); | ||
| 604 | expect(calls.count).toBe(1); | ||
| 605 | |||
| 606 | // vercel.deploy has no plugin-required approval and no policy, | ||
| 607 | // so no prompt. | ||
| 608 | yield* executor.tools.invoke("vercel.deploy", {}, { | ||
| 609 | onElicitation: recordingHandler(calls), | ||
| 610 | }); | ||
| 611 | expect(calls.count).toBe(1); | ||
| 612 | }), | ||
| 613 | ); | ||
| 614 | }); | ||
| @@ -0,0 +1,252 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Tool policies — pattern matcher + policy resolution. Pure functions; the | ||
| 3 | // executor stitches them into `tools.list`, `tools.invoke`, and the public | ||
| 4 | // `executor.policies` CRUD surface. Plugins consume the same surface. | ||
| 5 | // --------------------------------------------------------------------------- | ||
| 6 | |||
| 7 | import { Schema } from "effect"; | ||
| 8 | |||
| 9 | import type { ToolPolicyAction, ToolPolicyRow } from "./core-schema"; | ||
| 10 | import { PolicyId, ScopeId } from "./ids"; | ||
| 11 | |||
| 12 | // --------------------------------------------------------------------------- | ||
| 13 | // Public projection — what callers see when they list policies. Strips the | ||
| 14 | // raw `scope_id` to a readable `scopeId`, hides `created_at` typing | ||
| 15 | // inconsistencies between adapters, and re-tags `id` as a `PolicyId`. | ||
| 16 | // --------------------------------------------------------------------------- | ||
| 17 | |||
| 18 | export interface ToolPolicy { | ||
| 19 | readonly id: PolicyId; | ||
| 20 | readonly scopeId: ScopeId; | ||
| 21 | readonly pattern: string; | ||
| 22 | readonly action: ToolPolicyAction; | ||
| 23 | /** Fractional-indexing key. Lower lex order = higher precedence. | ||
| 24 | * Use `generateKeyBetween(a, b)` from the `fractional-indexing` | ||
| 25 | * package to produce a key that sits between two existing rows. */ | ||
| 26 | readonly position: string; | ||
| 27 | readonly createdAt: Date; | ||
| 28 | readonly updatedAt: Date; | ||
| 29 | } | ||
| 30 | |||
| 31 | export interface CreateToolPolicyInput { | ||
| 32 | readonly scope: string; | ||
| 33 | readonly pattern: string; | ||
| 34 | readonly action: ToolPolicyAction; | ||
| 35 | /** Optional explicit position. Defaults to a key above the current | ||
| 36 | * minimum (top of the scope's list; highest precedence). */ | ||
| 37 | readonly position?: string; | ||
| 38 | } | ||
| 39 | |||
| 40 | export interface UpdateToolPolicyInput { | ||
| 41 | readonly id: string; | ||
| 42 | readonly pattern?: string; | ||
| 43 | readonly action?: ToolPolicyAction; | ||
| 44 | readonly position?: string; | ||
| 45 | } | ||
| 46 | |||
| 47 | // --------------------------------------------------------------------------- | ||
| 48 | // Match result — what `resolveToolPolicy` returns when a rule fires. Carries | ||
| 49 | // the matched pattern so error messages and approval prompts can show the | ||
| 50 | // user *which* rule produced the gate ("matched policy: vercel.dns.*"). | ||
| 51 | // --------------------------------------------------------------------------- | ||
| 52 | |||
| 53 | export interface PolicyMatch { | ||
| 54 | readonly action: ToolPolicyAction; | ||
| 55 | readonly pattern: string; | ||
| 56 | readonly policyId: string; | ||
| 57 | } | ||
| 58 | |||
| 59 | // --------------------------------------------------------------------------- | ||
| 60 | // Effective policy — the single answer to "what happens when this tool is | ||
| 61 | // invoked?". Combines the user policy layer with the plugin's default | ||
| 62 | // `requiresApproval` annotation. Callers (UI, agents, telemetry) shouldn't | ||
| 63 | // need to know the layering — they ask once and render one thing. | ||
| 64 | // | ||
| 65 | // `source` distinguishes user-authored rules from plugin-derived defaults | ||
| 66 | // purely for display ("Matched: vercel.*" vs "Plugin default"). The | ||
| 67 | // `action` is what actually drives behavior at invoke time. | ||
| 68 | // --------------------------------------------------------------------------- | ||
| 69 | |||
| 70 | export type PolicySource = "user" | "plugin-default"; | ||
| 71 | |||
| 72 | export interface EffectivePolicy { | ||
| 73 | readonly action: ToolPolicyAction; | ||
| 74 | readonly source: PolicySource; | ||
| 75 | /** Matched pattern; populated only when `source === "user"`. */ | ||
| 76 | readonly pattern?: string; | ||
| 77 | /** Policy row id; populated only when `source === "user"`. */ | ||
| 78 | readonly policyId?: string; | ||
| 79 | } | ||
| 80 | |||
| 81 | // --------------------------------------------------------------------------- | ||
| 82 | // Pattern matching. v1 grammar: | ||
| 83 | // - universal: `*` matches every tool id | ||
| 84 | // - exact: `vercel.dns.create` matches only that id | ||
| 85 | // - subtree: `vercel.dns.*` matches anything starting with `vercel.dns.` | ||
| 86 | // - plugin-wide: `vercel.*` matches anything starting with `vercel.` | ||
| 87 | // `*` is only meaningful as a complete trailing segment (or as the | ||
| 88 | // entire pattern). Patterns without a wildcard are exact-id matches. | ||
| 89 | // --------------------------------------------------------------------------- | ||
| 90 | |||
| 91 | export const matchPattern = (pattern: string, toolId: string): boolean => { | ||
| 92 | if (pattern === "*") return true; | ||
| 93 | if (pattern === toolId) return true; | ||
| 94 | if (pattern.endsWith(".*")) { | ||
| 95 | const prefix = pattern.slice(0, -2); | ||
| 96 | if (prefix.length === 0) return false; | ||
| 97 | return toolId === prefix || toolId.startsWith(`${prefix}.`); | ||
| 98 | } | ||
| 99 | return false; | ||
| 100 | }; | ||
| 101 | |||
| 102 | // --------------------------------------------------------------------------- | ||
| 103 | // Pattern validation — rejects shapes the matcher can't handle. Used by the | ||
| 104 | // CRUD path so a malformed rule never lands in the table. | ||
| 105 | // --------------------------------------------------------------------------- | ||
| 106 | |||
| 107 | export const isValidPattern = (pattern: string): boolean => { | ||
| 108 | if (pattern.length === 0) return false; | ||
| 109 | if (pattern === "*") return true; | ||
| 110 | if (pattern.startsWith(".") || pattern.endsWith(".")) return false; | ||
| 111 | if (pattern.includes("..")) return false; | ||
| 112 | if (pattern.startsWith("*")) return false; | ||
| 113 | // `*` is only valid as the entire trailing segment. | ||
| 114 | const segments = pattern.split("."); | ||
| 115 | for (let i = 0; i < segments.length; i++) { | ||
| 116 | const seg = segments[i]!; | ||
| 117 | if (seg.length === 0) return false; | ||
| 118 | if (seg.includes("*") && seg !== "*") return false; | ||
| 119 | if (seg === "*" && i !== segments.length - 1) return false; | ||
| 120 | } | ||
| 121 | return true; | ||
| 122 | }; | ||
| 123 | |||
| 124 | // --------------------------------------------------------------------------- | ||
| 125 | // Resolution — given a tool id and the policy rows visible across the | ||
| 126 | // executor's scope stack, return the first matching rule under the | ||
| 127 | // (innermost-scope-first, position-ascending) ordering. Caller passes a | ||
| 128 | // `scopeRank` function so the resolver doesn't need to know the executor's | ||
| 129 | // scope stack shape. | ||
| 130 | // --------------------------------------------------------------------------- | ||
| 131 | |||
| 132 | // Lex compare on fractional-indexing key, then id as a stable tiebreak. | ||
| 133 | // Two rows with identical `position` (racing inserts that picked the same | ||
| 134 | // `generateKeyBetween(null, min)` from independent clients) would otherwise | ||
| 135 | // flip on every refetch. | ||
| 136 | export const comparePolicyRow = ( | ||
| 137 | a: { position: unknown; id: unknown }, | ||
| 138 | b: { position: unknown; id: unknown }, | ||
| 139 | ): number => { | ||
| 140 | const pa = a.position as string; | ||
| 141 | const pb = b.position as string; | ||
| 142 | if (pa < pb) return -1; | ||
| 143 | if (pa > pb) return 1; | ||
| 144 | const ia = a.id as string; | ||
| 145 | const ib = b.id as string; | ||
| 146 | return ia < ib ? -1 : ia > ib ? 1 : 0; | ||
| 147 | }; | ||
| 148 | |||
| 149 | export const resolveToolPolicy = ( | ||
| 150 | toolId: string, | ||
| 151 | policies: readonly ToolPolicyRow[], | ||
| 152 | scopeRank: (row: { scope_id: unknown }) => number, | ||
| 153 | ): PolicyMatch | undefined => { | ||
| 154 | if (policies.length === 0) return undefined; | ||
| 155 | const sorted = [...policies].sort((a, b) => { | ||
| 156 | const sa = scopeRank(a); | ||
| 157 | const sb = scopeRank(b); | ||
| 158 | if (sa !== sb) return sa - sb; | ||
| 159 | return comparePolicyRow(a, b); | ||
| 160 | }); | ||
| 161 | for (const row of sorted) { | ||
| 162 | if (matchPattern(row.pattern as string, toolId)) { | ||
| 163 | return { | ||
| 164 | action: row.action as ToolPolicyAction, | ||
| 165 | pattern: row.pattern as string, | ||
| 166 | policyId: row.id as string, | ||
| 167 | }; | ||
| 168 | } | ||
| 169 | } | ||
| 170 | return undefined; | ||
| 171 | }; | ||
| 172 | |||
| 173 | // --------------------------------------------------------------------------- | ||
| 174 | // Layered resolution — one call returns the effective policy combining | ||
| 175 | // user-authored rules and the plugin's default `requiresApproval` | ||
| 176 | // annotation. Use this anywhere a UI / agent / log needs the final answer | ||
| 177 | // without knowing about the layering. | ||
| 178 | // | ||
| 179 | // Two flavors: | ||
| 180 | // - `resolveEffectivePolicy` takes raw rows + a scopeRank, mirrors | ||
| 181 | // `resolveToolPolicy`. Used server-side. | ||
| 182 | // - `effectivePolicyFromSorted` takes a pre-sorted list of public | ||
| 183 | // `ToolPolicy` projections; for clients that already received | ||
| 184 | // policies in evaluation order from the API. | ||
| 185 | // --------------------------------------------------------------------------- | ||
| 186 | |||
| 187 | const liftPlugin = ( | ||
| 188 | defaultRequiresApproval: boolean | undefined, | ||
| 189 | ): EffectivePolicy => | ||
| 190 | defaultRequiresApproval | ||
| 191 | ? { action: "require_approval", source: "plugin-default" } | ||
| 192 | : { action: "approve", source: "plugin-default" }; | ||
| 193 | |||
| 194 | const liftUser = (match: PolicyMatch): EffectivePolicy => ({ | ||
| 195 | action: match.action, | ||
| 196 | source: "user", | ||
| 197 | pattern: match.pattern, | ||
| 198 | policyId: match.policyId, | ||
| 199 | }); | ||
| 200 | |||
| 201 | export const resolveEffectivePolicy = ( | ||
| 202 | toolId: string, | ||
| 203 | policies: readonly ToolPolicyRow[], | ||
| 204 | scopeRank: (row: { scope_id: unknown }) => number, | ||
| 205 | defaultRequiresApproval?: boolean, | ||
| 206 | ): EffectivePolicy => { | ||
| 207 | const match = resolveToolPolicy(toolId, policies, scopeRank); | ||
| 208 | return match ? liftUser(match) : liftPlugin(defaultRequiresApproval); | ||
| 209 | }; | ||
| 210 | |||
| 211 | export const effectivePolicyFromSorted = ( | ||
| 212 | toolId: string, | ||
| 213 | sortedPolicies: readonly Pick<ToolPolicy, "pattern" | "action" | "id">[], | ||
| 214 | defaultRequiresApproval?: boolean, | ||
| 215 | ): EffectivePolicy => { | ||
| 216 | for (const p of sortedPolicies) { | ||
| 217 | if (matchPattern(p.pattern, toolId)) { | ||
| 218 | return { | ||
| 219 | action: p.action, | ||
| 220 | source: "user", | ||
| 221 | pattern: p.pattern, | ||
| 222 | policyId: p.id, | ||
| 223 | }; | ||
| 224 | } | ||
| 225 | } | ||
| 226 | return liftPlugin(defaultRequiresApproval); | ||
| 227 | }; | ||
| 228 | |||
| 229 | // --------------------------------------------------------------------------- | ||
| 230 | // Row → public projection. | ||
| 231 | // --------------------------------------------------------------------------- | ||
| 232 | |||
| 233 | export const rowToToolPolicy = (row: ToolPolicyRow): ToolPolicy => ({ | ||
| 234 | id: PolicyId.make(row.id as string), | ||
| 235 | scopeId: ScopeId.make(row.scope_id as string), | ||
| 236 | pattern: row.pattern as string, | ||
| 237 | action: row.action as ToolPolicyAction, | ||
| 238 | position: row.position as string, | ||
| 239 | createdAt: row.created_at as Date, | ||
| 240 | updatedAt: row.updated_at as Date, | ||
| 241 | }); | ||
| 242 | |||
| 243 | // --------------------------------------------------------------------------- | ||
| 244 | // Schema for the action enum — useful for HTTP edges that want to validate | ||
| 245 | // inputs with effect/Schema. | ||
| 246 | // --------------------------------------------------------------------------- | ||
| 247 | |||
| 248 | export const ToolPolicyActionSchema = Schema.Literal( | ||
| 249 | "approve", | ||
| 250 | "require_approval", | ||
| 251 | "block", | ||
| 252 | ); | ||
| @@ -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 | } |
| @@ -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); |
| @@ -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 }) => { |
| @@ -45,17 +45,18 @@ const toError = (cause: unknown): Error => · 44 unmodified lines | |||
| 45 | 45 | ||
| 46 | const toErrorMessage = (cause: unknown): string => { | 46 | const 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); |
| @@ -10,7 +10,7 @@ import { · 9 unmodified lines | |||
| 10 | import { keychainPlugin } from "./index"; | 10 | import { keychainPlugin } from "./index"; |
| 11 | 11 | ||
| 12 | describe("keychain plugin", () => { | 12 | describe("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 provider | 46 | // 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); |
| @@ -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 | ||
| 108 | class QueryValidationError extends Schema.TaggedError<QueryValidationError>()( | ||
| 109 | "QueryValidationError", | ||
| 110 | { | ||
| 111 | message: Schema.String, | ||
| 112 | }, | ||
| 113 | ) {} | ||
| 114 | |||
| 108 | const ItemsGroup = HttpApiGroup.make("items") | 115 | const 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 | ||
| 117 | const TestApi = HttpApi.make("testApi").add(ItemsGroup); | 130 | const 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; |
| @@ -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", |
| @@ -1,5 +1,11 @@ | |||
| 1 | import type { ScopeId, ToolId, SecretId } from "@executor/sdk"; | 1 | import { |
| 2 | import { Atom } from "@effect-atom/atom-react"; | 2 | PolicyId, |
| 3 | type ScopeId, | ||
| 4 | type ToolId, | ||
| 5 | type SecretId, | ||
| 6 | type ToolPolicyAction, | ||
| 7 | } from "@executor/sdk"; | ||
| 8 | import { Atom, Result } from "@effect-atom/atom-react"; | ||
| 3 | 9 | ||
| 4 | import { ExecutorApiClient } from "./client"; | 10 | import { ExecutorApiClient } from "./client"; |
| 5 | import { ReactivityKey } from "./reactivity-keys"; | 11 | import { 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 | ||
| 80 | export 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-atom | 88 | // Mutation atoms — reactivityKeys must be passed at call site (effect-atom |
| 76 | // does not accept them at definition time). See `reactivity-keys.tsx` for the | 89 | // 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 | |||
| 91 | export const refreshSource = ExecutorApiClient.mutation("sources", "refresh"); | 104 | export const refreshSource = ExecutorApiClient.mutation("sources", "refresh"); |
| 92 | 105 | ||
| 93 | export const detectSource = ExecutorApiClient.mutation("sources", "detect"); | 106 | export const detectSource = ExecutorApiClient.mutation("sources", "detect"); |
| 107 | |||
| 108 | export const createPolicy = ExecutorApiClient.mutation("policies", "create"); | ||
| 109 | |||
| 110 | export const updatePolicy = ExecutorApiClient.mutation("policies", "update"); | ||
| 111 | |||
| 112 | export const removePolicy = ExecutorApiClient.mutation("policies", "remove"); | ||
| 113 | |||
| 114 | // --------------------------------------------------------------------------- | ||
| 115 | // Policies — optimistic surface. Reads go through `policiesOptimisticAtom` | ||
| 116 | // (which layers in-flight transitions on top of `policiesAtom`), and writes | ||
| 117 | // go through the matching `*PolicyOptimistic` mutation atoms. Each mutation | ||
| 118 | // declares a reducer that produces the next array of rows; effect-atom's | ||
| 119 | // `Atom.optimisticFn` handles transition tracking, waiting state, and the | ||
| 120 | // post-commit refresh — including racing calls (latest reducer wins). | ||
| 121 | // --------------------------------------------------------------------------- | ||
| 122 | |||
| 123 | export const policiesOptimisticAtom = Atom.family((scopeId: ScopeId) => | ||
| 124 | Atom.optimistic(policiesAtom(scopeId)), | ||
| 125 | ); | ||
| 126 | |||
| 127 | export const createPolicyOptimistic = Atom.family((scopeId: ScopeId) => | ||
| 128 | policiesOptimisticAtom(scopeId).pipe( | ||
| 129 | Atom.optimisticFn({ | ||
| 130 | reducer: ( | ||
| 131 | current, | ||
| 132 | arg: { | ||
| 133 | path: { scopeId: ScopeId }; | ||
| 134 | payload: { | ||
| 135 | pattern: string; | ||
| 136 | action: ToolPolicyAction; | ||
| 137 | position?: string; | ||
| 138 | }; | ||
| 139 | reactivityKeys?: ReadonlyArray<unknown>; | ||
| 140 | }, | ||
| 141 | ) => | ||
| 142 | Result.map(current, (rows) => [ | ||
| 143 | { | ||
| 144 | id: PolicyId.make( | ||
| 145 | `pending-${Math.random().toString(36).slice(2)}`, | ||
| 146 | ), | ||
| 147 | scopeId, | ||
| 148 | pattern: arg.payload.pattern, | ||
| 149 | action: arg.payload.action, | ||
| 150 | // Empty string sorts before any fractional-indexing key, so | ||
| 151 | // the placeholder lands at the top until the server returns | ||
| 152 | // the canonical key. | ||
| 153 | position: "", | ||
| 154 | createdAt: Date.now(), | ||
| 155 | updatedAt: Date.now(), | ||
| 156 | }, | ||
| 157 | ...rows, | ||
| 158 | ]), | ||
| 159 | fn: createPolicy, | ||
| 160 | }), | ||
| 161 | ), | ||
| 162 | ); | ||
| 163 | |||
| 164 | export const updatePolicyOptimistic = Atom.family((_scopeId: ScopeId) => | ||
| 165 | policiesOptimisticAtom(_scopeId).pipe( | ||
| 166 | Atom.optimisticFn({ | ||
| 167 | reducer: ( | ||
| 168 | current, | ||
| 169 | arg: { | ||
| 170 | path: { scopeId: ScopeId; policyId: PolicyId }; | ||
| 171 | payload: { | ||
| 172 | pattern?: string; | ||
| 173 | action?: ToolPolicyAction; | ||
| 174 | position?: string; | ||
| 175 | }; | ||
| 176 | reactivityKeys?: ReadonlyArray<unknown>; | ||
| 177 | }, | ||
| 178 | ) => | ||
| 179 | Result.map(current, (rows) => | ||
| 180 | rows.map((r) => | ||
| 181 | r.id === arg.path.policyId | ||
| 182 | ? { | ||
| 183 | ...r, | ||
| 184 | ...(arg.payload.action !== undefined | ||
| 185 | ? { action: arg.payload.action } | ||
| 186 | : {}), | ||
| 187 | ...(arg.payload.pattern !== undefined | ||
| 188 | ? { pattern: arg.payload.pattern } | ||
| 189 | : {}), | ||
| 190 | ...(arg.payload.position !== undefined | ||
| 191 | ? { position: arg.payload.position } | ||
| 192 | : {}), | ||
| 193 | } | ||
| 194 | : r, | ||
| 195 | ), | ||
| 196 | ), | ||
| 197 | fn: updatePolicy, | ||
| 198 | }), | ||
| 199 | ), | ||
| 200 | ); | ||
| 201 | |||
| 202 | export const removePolicyOptimistic = Atom.family((scopeId: ScopeId) => | ||
| 203 | policiesOptimisticAtom(scopeId).pipe( | ||
| 204 | Atom.optimisticFn({ | ||
| 205 | reducer: ( | ||
| 206 | current, | ||
| 207 | arg: { | ||
| 208 | path: { scopeId: ScopeId; policyId: PolicyId }; | ||
| 209 | reactivityKeys?: ReadonlyArray<unknown>; | ||
| 210 | }, | ||
| 211 | ) => | ||
| 212 | Result.map(current, (rows) => | ||
| 213 | rows.filter((r) => r.id !== arg.path.policyId), | ||
| 214 | ), | ||
| 215 | fn: removePolicy, | ||
| 216 | }), | ||
| 217 | ), | ||
| 218 | ); | ||
| @@ -203,3 +203,4 @@ export const usePendingConnectionRemovals = () => { · 202 unmodified lines | |||
| 203 | [add, remove], | 203 | [add, remove], |
| 204 | ); | 204 | ); |
| 205 | }; | 205 | }; |
| 206 | |||
| @@ -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 resources | 27 | // 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. */ |
| 49 | export const scopeWriteKeys = [ReactivityKey.scope] as const; | 50 | export 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. */ | ||
| 55 | export const policyWriteKeys = [ | ||
| 56 | ReactivityKey.policies, | ||
| 57 | ReactivityKey.tools, | ||
| 58 | ] as const; | ||
| 59 | |||
| 51 | /** Cloud-only: org membership mutations. */ | 60 | /** Cloud-only: org membership mutations. */ |
| 52 | export const orgMemberWriteKeys = [ReactivityKey.orgMembers] as const; | 61 | export const orgMemberWriteKeys = [ReactivityKey.orgMembers] as const; |
| 53 | 62 | ||
| @@ -4,6 +4,12 @@ import * as React from "react"; · 3 unmodified lines | |||
| 4 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; | 4 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; |
| 5 | import { Select as SelectPrimitive } from "radix-ui"; | 5 | import { 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. | ||
| 11 | const SelectPrimitiveTrigger = SelectPrimitive.Trigger; | ||
| 12 | |||
| 7 | import { cn } from "../lib/utils"; | 13 | import { cn } from "../lib/utils"; |
| 8 | 14 | ||
| 9 | function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) { | 15 | function 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, |
| @@ -1,7 +1,13 @@ | |||
| 1 | import { useMemo, useState } from "react"; | 1 | import { useMemo, useState } from "react"; |
| 2 | import { useAtomValue, Result } from "@effect-atom/atom-react"; | 2 | import { useAtomValue, Result } from "@effect-atom/atom-react"; |
| 3 | import { toolSchemaAtom } from "../api/atoms"; | 3 | import { toolSchemaAtom } from "../api/atoms"; |
| 4 | import { ScopeId, ToolId } from "@executor/sdk"; | 4 | import { |
| 5 | ScopeId, | ||
| 6 | ToolId, | ||
| 7 | type EffectivePolicy, | ||
| 8 | type ToolPolicyAction, | ||
| 9 | } from "@executor/sdk"; | ||
| 10 | import { Badge } from "./badge"; | ||
| 5 | import { Button } from "./button"; | 11 | import { Button } from "./button"; |
| 6 | import { Markdown } from "./markdown"; | 12 | import { Markdown } from "./markdown"; |
| 7 | import { SchemaExplorer } from "./schema-explorer"; | 13 | import { SchemaExplorer } from "./schema-explorer"; |
| @@ -10,6 +16,45 @@ import { CardStack, CardStackHeader, CardStackContent } from "./card-stack"; · 2 unmodified lines | |||
| 10 | import { CopyButton } from "./copy-button"; | 16 | import { CopyButton } from "./copy-button"; |
| 11 | import { ChevronRight } from "lucide-react"; | 17 | import { ChevronRight } from "lucide-react"; |
| 12 | 18 | ||
| 19 | const POLICY_LABEL: Record<ToolPolicyAction, string> = { | ||
| 20 | approve: "Auto-approve", | ||
| 21 | require_approval: "Require approval", | ||
| 22 | block: "Blocked", | ||
| 23 | }; | ||
| 24 | |||
| 25 | const POLICY_VARIANT: Record< | ||
| 26 | ToolPolicyAction, | ||
| 27 | "default" | "secondary" | "outline" | "destructive" | ||
| 28 | > = { | ||
| 29 | approve: "secondary", | ||
| 30 | require_approval: "outline", | ||
| 31 | block: "destructive", | ||
| 32 | }; | ||
| 33 | |||
| 34 | // Render the effective policy as a badge. User policies show the | ||
| 35 | // matched pattern; plugin defaults read "Default: <action>". Silent for | ||
| 36 | // the auto-approve plugin default — that's the safe state and the | ||
| 37 | // header would just be noise. | ||
| 38 | const policyBadgeFor = (policy: EffectivePolicy) => { | ||
| 39 | if (policy.source === "plugin-default" && policy.action === "approve") { | ||
| 40 | return null; | ||
| 41 | } | ||
| 42 | if (policy.source === "user") { | ||
| 43 | return { | ||
| 44 | variant: POLICY_VARIANT[policy.action], | ||
| 45 | title: `Matched policy: ${policy.pattern}`, | ||
| 46 | text: `${POLICY_LABEL[policy.action]} · ${policy.pattern}`, | ||
| 47 | className: "font-mono text-[10px]", | ||
| 48 | }; | ||
| 49 | } | ||
| 50 | return { | ||
| 51 | variant: "outline" as const, | ||
| 52 | title: "No matching policy — plugin default applies", | ||
| 53 | text: `Default: ${POLICY_LABEL[policy.action]}`, | ||
| 54 | className: "text-[10px] text-muted-foreground", | ||
| 55 | }; | ||
| 56 | }; | ||
| 57 | |||
| 13 | function EmptySection(props: { title: string; message: string }) { | 58 | function 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"> |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { useEffect, useMemo, useRef, useState } from "react"; | 1 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { ChevronRightIcon, SearchIcon, XIcon } from "lucide-react"; | 2 | import { ChevronRightIcon, SearchIcon, XIcon } from "lucide-react"; |
| 3 | import type { EffectivePolicy, ToolPolicyAction } from "@executor/sdk"; | ||
| 3 | import { Button } from "./button"; | 4 | import { Button } from "./button"; |
| 4 | import { Input } from "./input"; | 5 | import { Input } from "./input"; |
| 5 | import { cn } from "../lib/utils"; | 6 | import { 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. | ||
| 25 | const POLICY_INDICATOR: Record< | ||
| 26 | ToolPolicyAction, | ||
| 27 | { readonly label: string; readonly dot: string; readonly ring: string } | ||
| 28 | > = { | ||
| 29 | approve: { | ||
| 30 | label: "Auto-approve", | ||
| 31 | dot: "bg-emerald-500", | ||
| 32 | ring: "ring-emerald-500/70", | ||
| 33 | }, | ||
| 34 | require_approval: { | ||
| 35 | label: "Require approval", | ||
| 36 | dot: "bg-amber-500", | ||
| 37 | ring: "ring-amber-500/70", | ||
| 38 | }, | ||
| 39 | block: { | ||
| 40 | label: "Blocked", | ||
| 41 | dot: "bg-destructive", | ||
| 42 | ring: "ring-destructive/70", | ||
| 43 | }, | ||
| 44 | }; | ||
| 45 | |||
| 46 | // What the dot looks like for a given effective policy. Auto-approve as | ||
| 47 | // a plugin default is silent (the safe state — no point cluttering every | ||
| 48 | // row); everything else gets a dot. User policies are filled, plugin | ||
| 49 | // defaults are hollow rings. | ||
| 50 | const indicatorFor = (policy: EffectivePolicy) => { | ||
| 51 | if (policy.source === "plugin-default" && policy.action === "approve") { | ||
| 52 | return null; | ||
| 53 | } | ||
| 54 | const ind = POLICY_INDICATOR[policy.action]; | ||
| 55 | const filled = policy.source === "user"; | ||
| 56 | const label = | ||
| 57 | policy.source === "user" | ||
| 58 | ? `${ind.label} (matched ${policy.pattern})` | ||
| 59 | : `Plugin default: ${ind.label}`; | ||
| 60 | return { | ||
| 61 | label, | ||
| 62 | className: filled | ||
| 63 | ? ind.dot | ||
| 64 | : cn("bg-transparent ring-1", ind.ring), | ||
| 65 | }; | ||
| 66 | }; | ||
| 67 | |||
| 18 | type TreeNode = { | 68 | type 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 | <Button | 415 | <Button |
| 365 | ref={props.buttonRef} | 416 | ref={props.buttonRef} |
| @@ -370,12 +421,20 @@ function ToolLeafRow(props: { · 4 unmodified lines | |||
| 370 | props.active | 421 | 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 | } |
| @@ -0,0 +1,463 @@ | |||
| 1 | import { useState } from "react"; | ||
| 2 | import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; | ||
| 3 | import { generateKeyBetween } from "fractional-indexing"; | ||
| 4 | import { ChevronDownIcon } from "lucide-react"; | ||
| 5 | import { PolicyId, type ToolPolicyAction } from "@executor/sdk"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | createPolicyOptimistic, | ||
| 9 | policiesOptimisticAtom, | ||
| 10 | removePolicyOptimistic, | ||
| 11 | updatePolicyOptimistic, | ||
| 12 | } from "../api/atoms"; | ||
| 13 | import { policyWriteKeys } from "../api/reactivity-keys"; | ||
| 14 | import { useScope } from "../hooks/use-scope"; | ||
| 15 | import { badgeVariants } from "../components/badge"; | ||
| 16 | import { cn } from "../lib/utils"; | ||
| 17 | import { Button } from "../components/button"; | ||
| 18 | import { | ||
| 19 | CardStack, | ||
| 20 | CardStackContent, | ||
| 21 | CardStackEntry, | ||
| 22 | CardStackEntryActions, | ||
| 23 | CardStackEntryContent, | ||
| 24 | CardStackEntryDescription, | ||
| 25 | CardStackEntryTitle, | ||
| 26 | CardStackHeader, | ||
| 27 | } from "../components/card-stack"; | ||
| 28 | import { | ||
| 29 | DropdownMenu, | ||
| 30 | DropdownMenuContent, | ||
| 31 | DropdownMenuItem, | ||
| 32 | DropdownMenuTrigger, | ||
| 33 | } from "../components/dropdown-menu"; | ||
| 34 | import { Input } from "../components/input"; | ||
| 35 | import { | ||
| 36 | Select, | ||
| 37 | SelectContent, | ||
| 38 | SelectItem, | ||
| 39 | SelectPrimitiveTrigger, | ||
| 40 | SelectTrigger, | ||
| 41 | SelectValue, | ||
| 42 | } from "../components/select"; | ||
| 43 | import { Label } from "../components/label"; | ||
| 44 | |||
| 45 | // --------------------------------------------------------------------------- | ||
| 46 | // Sort comparator — fractional-indexing key, then id as a stable tiebreak. | ||
| 47 | // Identical positions can briefly happen across racing inserts; without the | ||
| 48 | // tiebreak the rendered order flips between refetches, and `generateKeyBetween` | ||
| 49 | // would also throw if asked to insert "between" two equal keys. | ||
| 50 | // --------------------------------------------------------------------------- | ||
| 51 | |||
| 52 | const comparePolicy = ( | ||
| 53 | posA: string, | ||
| 54 | idA: string, | ||
| 55 | posB: string, | ||
| 56 | idB: string, | ||
| 57 | ): number => { | ||
| 58 | if (posA < posB) return -1; | ||
| 59 | if (posA > posB) return 1; | ||
| 60 | if (idA < idB) return -1; | ||
| 61 | if (idA > idB) return 1; | ||
| 62 | return 0; | ||
| 63 | }; | ||
| 64 | |||
| 65 | // --------------------------------------------------------------------------- | ||
| 66 | // Action display | ||
| 67 | // --------------------------------------------------------------------------- | ||
| 68 | |||
| 69 | const actionLabels: Record<ToolPolicyAction, string> = { | ||
| 70 | approve: "Auto-approve", | ||
| 71 | require_approval: "Require approval", | ||
| 72 | block: "Block", | ||
| 73 | }; | ||
| 74 | |||
| 75 | const actionVariants: Record< | ||
| 76 | ToolPolicyAction, | ||
| 77 | "default" | "secondary" | "outline" | "destructive" | ||
| 78 | > = { | ||
| 79 | approve: "secondary", | ||
| 80 | require_approval: "outline", | ||
| 81 | block: "destructive", | ||
| 82 | }; | ||
| 83 | |||
| 84 | // --------------------------------------------------------------------------- | ||
| 85 | // Pattern matcher (mirrors `matchPattern` in @executor/sdk) — used for the | ||
| 86 | // live "this rule matches N tools" preview without a server round-trip. | ||
| 87 | // Kept inline so the React package doesn't take a runtime dep on the SDK | ||
| 88 | // for one tiny pure function. If they drift, only the preview is stale. | ||
| 89 | // --------------------------------------------------------------------------- | ||
| 90 | |||
| 91 | const matchesPattern = (pattern: string, toolId: string): boolean => { | ||
| 92 | if (pattern === "*") return true; | ||
| 93 | if (pattern === toolId) return true; | ||
| 94 | if (pattern.endsWith(".*")) { | ||
| 95 | const prefix = pattern.slice(0, -2); | ||
| 96 | if (prefix.length === 0) return false; | ||
| 97 | return toolId === prefix || toolId.startsWith(`${prefix}.`); | ||
| 98 | } | ||
| 99 | return false; | ||
| 100 | }; | ||
| 101 | |||
| 102 | const isValidPattern = (pattern: string): boolean => { | ||
| 103 | if (pattern.length === 0) return false; | ||
| 104 | if (pattern === "*") return true; | ||
| 105 | if (pattern.startsWith(".") || pattern.endsWith(".")) return false; | ||
| 106 | if (pattern.includes("..")) return false; | ||
| 107 | if (pattern.startsWith("*")) return false; | ||
| 108 | const segments = pattern.split("."); | ||
| 109 | for (let i = 0; i < segments.length; i++) { | ||
| 110 | const seg = segments[i]!; | ||
| 111 | if (seg.length === 0) return false; | ||
| 112 | if (seg.includes("*") && seg !== "*") return false; | ||
| 113 | if (seg === "*" && i !== segments.length - 1) return false; | ||
| 114 | } | ||
| 115 | return true; | ||
| 116 | }; | ||
| 117 | |||
| 118 | // --------------------------------------------------------------------------- | ||
| 119 | // Add-policy form | ||
| 120 | // --------------------------------------------------------------------------- | ||
| 121 | |||
| 122 | function AddPolicyForm(props: { | ||
| 123 | onSubmit: (input: { pattern: string; action: ToolPolicyAction }) => void; | ||
| 124 | busy: boolean; | ||
| 125 | }) { | ||
| 126 | const [pattern, setPattern] = useState(""); | ||
| 127 | const [action, setAction] = useState<ToolPolicyAction>("require_approval"); | ||
| 128 | const valid = isValidPattern(pattern); | ||
| 129 | |||
| 130 | return ( | ||
| 131 | <form | ||
| 132 | className="flex flex-col gap-3 rounded-xl border border-border bg-card px-5 py-4" | ||
| 133 | onSubmit={(e) => { | ||
| 134 | e.preventDefault(); | ||
| 135 | if (!valid) return; | ||
| 136 | props.onSubmit({ pattern, action }); | ||
| 137 | setPattern(""); | ||
| 138 | setAction("require_approval"); | ||
| 139 | }} | ||
| 140 | > | ||
| 141 | <div className="flex flex-col gap-1"> | ||
| 142 | <Label htmlFor="policy-pattern" className="text-xs font-medium text-foreground/80"> | ||
| 143 | Pattern | ||
| 144 | </Label> | ||
| 145 | <Input | ||
| 146 | id="policy-pattern" | ||
| 147 | placeholder="vercel.dns.* or *" | ||
| 148 | value={pattern} | ||
| 149 | onChange={(e) => setPattern(e.target.value)} | ||
| 150 | className="font-mono text-sm" | ||
| 151 | /> | ||
| 152 | <p className="text-xs text-muted-foreground"> | ||
| 153 | Exact tool id, trailing wildcard, or{" "} | ||
| 154 | <code className="font-mono">*</code> for every tool. Examples:{" "} | ||
| 155 | <code className="font-mono">*</code>,{" "} | ||
| 156 | <code className="font-mono">vercel.*</code>,{" "} | ||
| 157 | <code className="font-mono">vercel.dns.*</code>,{" "} | ||
| 158 | <code className="font-mono">vercel.dns.create</code>. | ||
| 159 | </p> | ||
| 160 | </div> | ||
| 161 | <div className="flex flex-col gap-1"> | ||
| 162 | <Label className="text-xs font-medium text-foreground/80">Action</Label> | ||
| 163 | <Select | ||
| 164 | value={action} | ||
| 165 | onValueChange={(v) => setAction(v as ToolPolicyAction)} | ||
| 166 | > | ||
| 167 | <SelectTrigger className="w-full"> | ||
| 168 | <SelectValue /> | ||
| 169 | </SelectTrigger> | ||
| 170 | <SelectContent> | ||
| 171 | <SelectItem value="approve">{actionLabels.approve}</SelectItem> | ||
| 172 | <SelectItem value="require_approval"> | ||
| 173 | {actionLabels.require_approval} | ||
| 174 | </SelectItem> | ||
| 175 | <SelectItem value="block">{actionLabels.block}</SelectItem> | ||
| 176 | </SelectContent> | ||
| 177 | </Select> | ||
| 178 | </div> | ||
| 179 | <div className="flex items-center justify-end"> | ||
| 180 | <Button type="submit" disabled={!valid || props.busy} size="sm"> | ||
| 181 | Add policy | ||
| 182 | </Button> | ||
| 183 | </div> | ||
| 184 | </form> | ||
| 185 | ); | ||
| 186 | } | ||
| 187 | |||
| 188 | // --------------------------------------------------------------------------- | ||
| 189 | // Policy row | ||
| 190 | // --------------------------------------------------------------------------- | ||
| 191 | |||
| 192 | function PolicyRow(props: { | ||
| 193 | policy: { | ||
| 194 | id: string; | ||
| 195 | pattern: string; | ||
| 196 | action: ToolPolicyAction; | ||
| 197 | }; | ||
| 198 | isFirst: boolean; | ||
| 199 | isLast: boolean; | ||
| 200 | onRemove: () => void; | ||
| 201 | onChangeAction: (action: ToolPolicyAction) => void; | ||
| 202 | onMoveUp: () => void; | ||
| 203 | onMoveDown: () => void; | ||
| 204 | }) { | ||
| 205 | return ( | ||
| 206 | <CardStackEntry> | ||
| 207 | <CardStackEntryContent> | ||
| 208 | <CardStackEntryTitle className="flex items-center gap-2 font-mono text-sm"> | ||
| 209 | <span className="truncate">{props.policy.pattern}</span> | ||
| 210 | </CardStackEntryTitle> | ||
| 211 | </CardStackEntryContent> | ||
| 212 | <CardStackEntryActions> | ||
| 213 | <Select | ||
| 214 | value={props.policy.action} | ||
| 215 | onValueChange={(v) => props.onChangeAction(v as ToolPolicyAction)} | ||
| 216 | > | ||
| 217 | <SelectPrimitiveTrigger | ||
| 218 | className={cn( | ||
| 219 | badgeVariants({ | ||
| 220 | variant: actionVariants[props.policy.action], | ||
| 221 | }), | ||
| 222 | "cursor-pointer pr-1.5 gap-1 transition-[opacity,box-shadow] hover:opacity-80 focus-visible:outline-none data-[state=open]:ring-2 data-[state=open]:ring-ring/50", | ||
| 223 | )} | ||
| 224 | > | ||
| 225 | {actionLabels[props.policy.action]} | ||
| 226 | <ChevronDownIcon className="size-3 opacity-70" /> | ||
| 227 | </SelectPrimitiveTrigger> | ||
| 228 | <SelectContent position="popper" align="end"> | ||
| 229 | <SelectItem value="approve">{actionLabels.approve}</SelectItem> | ||
| 230 | <SelectItem value="require_approval"> | ||
| 231 | {actionLabels.require_approval} | ||
| 232 | </SelectItem> | ||
| 233 | <SelectItem value="block">{actionLabels.block}</SelectItem> | ||
| 234 | </SelectContent> | ||
| 235 | </Select> | ||
| 236 | <DropdownMenu> | ||
| 237 | <DropdownMenuTrigger asChild> | ||
| 238 | <Button | ||
| 239 | variant="ghost" | ||
| 240 | size="icon" | ||
| 241 | className="size-7 opacity-0 transition-opacity group-hover/card-stack-entry:opacity-100 group-focus-within/card-stack-entry:opacity-100 data-[state=open]:opacity-100" | ||
| 242 | > | ||
| 243 | <svg viewBox="0 0 16 16" className="size-3"> | ||
| 244 | <circle cx="8" cy="3" r="1.2" fill="currentColor" /> | ||
| 245 | <circle cx="8" cy="8" r="1.2" fill="currentColor" /> | ||
| 246 | <circle cx="8" cy="13" r="1.2" fill="currentColor" /> | ||
| 247 | </svg> | ||
| 248 | </Button> | ||
| 249 | </DropdownMenuTrigger> | ||
| 250 | <DropdownMenuContent align="end" className="w-40"> | ||
| 251 | <DropdownMenuItem | ||
| 252 | disabled={props.isFirst} | ||
| 253 | onClick={props.onMoveUp} | ||
| 254 | > | ||
| 255 | Move up | ||
| 256 | </DropdownMenuItem> | ||
| 257 | <DropdownMenuItem | ||
| 258 | disabled={props.isLast} | ||
| 259 | onClick={props.onMoveDown} | ||
| 260 | > | ||
| 261 | Move down | ||
| 262 | </DropdownMenuItem> | ||
| 263 | <DropdownMenuItem | ||
| 264 | className="text-destructive focus:text-destructive text-sm" | ||
| 265 | onClick={props.onRemove} | ||
| 266 | > | ||
| 267 | Remove | ||
| 268 | </DropdownMenuItem> | ||
| 269 | </DropdownMenuContent> | ||
| 270 | </DropdownMenu> | ||
| 271 | </CardStackEntryActions> | ||
| 272 | </CardStackEntry> | ||
| 273 | ); | ||
| 274 | } | ||
| 275 | |||
| 276 | // --------------------------------------------------------------------------- | ||
| 277 | // Page | ||
| 278 | // --------------------------------------------------------------------------- | ||
| 279 | |||
| 280 | export function PoliciesPage() { | ||
| 281 | const scopeId = useScope(); | ||
| 282 | const policies = useAtomValue(policiesOptimisticAtom(scopeId)); | ||
| 283 | const doCreate = useAtomSet(createPolicyOptimistic(scopeId), { | ||
| 284 | mode: "promise", | ||
| 285 | }); | ||
| 286 | const doUpdate = useAtomSet(updatePolicyOptimistic(scopeId), { | ||
| 287 | mode: "promise", | ||
| 288 | }); | ||
| 289 | const doRemove = useAtomSet(removePolicyOptimistic(scopeId), { | ||
| 290 | mode: "promise", | ||
| 291 | }); | ||
| 292 | const [busy, setBusy] = useState(false); | ||
| 293 | |||
| 294 | const handleCreate = async (input: { | ||
| 295 | pattern: string; | ||
| 296 | action: ToolPolicyAction; | ||
| 297 | }) => { | ||
| 298 | setBusy(true); | ||
| 299 | try { | ||
| 300 | await doCreate({ | ||
| 301 | path: { scopeId }, | ||
| 302 | payload: { pattern: input.pattern, action: input.action }, | ||
| 303 | reactivityKeys: policyWriteKeys, | ||
| 304 | }); | ||
| 305 | } finally { | ||
| 306 | setBusy(false); | ||
| 307 | } | ||
| 308 | }; | ||
| 309 | |||
| 310 | const handleUpdate = async (id: string, action: ToolPolicyAction) => { | ||
| 311 | await doUpdate({ | ||
| 312 | path: { scopeId, policyId: PolicyId.make(id) }, | ||
| 313 | payload: { action }, | ||
| 314 | reactivityKeys: policyWriteKeys, | ||
| 315 | }); | ||
| 316 | }; | ||
| 317 | |||
| 318 | const handleRemove = async (id: string) => { | ||
| 319 | await doRemove({ | ||
| 320 | path: { scopeId, policyId: PolicyId.make(id) }, | ||
| 321 | reactivityKeys: policyWriteKeys, | ||
| 322 | }); | ||
| 323 | }; | ||
| 324 | |||
| 325 | const handleMove = async (id: string, position: string) => { | ||
| 326 | await doUpdate({ | ||
| 327 | path: { scopeId, policyId: PolicyId.make(id) }, | ||
| 328 | payload: { position }, | ||
| 329 | reactivityKeys: policyWriteKeys, | ||
| 330 | }); | ||
| 331 | }; | ||
| 332 | |||
| 333 | return ( | ||
| 334 | <div className="min-h-0 flex-1 overflow-y-auto"> | ||
| 335 | <div className="mx-auto max-w-3xl px-6 py-10 lg:px-8 lg:py-14"> | ||
| 336 | <div className="flex items-end justify-between mb-10"> | ||
| 337 | <div> | ||
| 338 | <h1 className="font-display text-[2rem] tracking-tight text-foreground leading-none"> | ||
| 339 | Policies | ||
| 340 | </h1> | ||
| 341 | <p className="mt-2 text-sm text-muted-foreground leading-relaxed"> | ||
| 342 | Override default approval behavior for tools. Rules are | ||
| 343 | evaluated top-to-bottom; the first match wins. Blocked tools | ||
| 344 | are hidden from agent search and fail at invoke. | ||
| 345 | </p> | ||
| 346 | </div> | ||
| 347 | </div> | ||
| 348 | |||
| 349 | <div className="mb-8"> | ||
| 350 | <AddPolicyForm onSubmit={handleCreate} busy={busy} /> | ||
| 351 | </div> | ||
| 352 | |||
| 353 | {Result.match(policies, { | ||
| 354 | onInitial: () => ( | ||
| 355 | <div className="flex items-center gap-2 py-8"> | ||
| 356 | <div className="size-1.5 rounded-full bg-muted-foreground/30 animate-pulse" /> | ||
| 357 | <p className="text-sm text-muted-foreground"> | ||
| 358 | Loading policies… | ||
| 359 | </p> | ||
| 360 | </div> | ||
| 361 | ), | ||
| 362 | onFailure: () => ( | ||
| 363 | <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3"> | ||
| 364 | <p className="text-sm text-destructive">Failed to load policies</p> | ||
| 365 | </div> | ||
| 366 | ), | ||
| 367 | onSuccess: ({ value }) => { | ||
| 368 | // Sort by position (lex order on fractional-indexing keys), | ||
| 369 | // tiebreaking on id so identical positions don't swap on refetch | ||
| 370 | // and `generateKeyBetween` never sees duplicate neighbor keys | ||
| 371 | // (which would throw). Optimistic placeholders carry | ||
| 372 | // `position: ""` so they sort to the top. | ||
| 373 | const sorted = [...value].sort((a, b) => | ||
| 374 | comparePolicy(a.position, a.id, b.position, b.id), | ||
| 375 | ); | ||
| 376 | // Reorder math runs against committed rows only — placeholder | ||
| 377 | // rows (empty `position`) aren't valid keys for | ||
| 378 | // `generateKeyBetween` and aren't reorderable until the server | ||
| 379 | // confirms. | ||
| 380 | const committed = sorted.filter((p) => p.position !== ""); | ||
| 381 | const committedIndex = (id: string): number => | ||
| 382 | committed.findIndex((p) => p.id === id); | ||
| 383 | const positionAbove = (id: string): string => { | ||
| 384 | const j = committedIndex(id); | ||
| 385 | if (j <= 0) return generateKeyBetween(null, committed[0]!.position); | ||
| 386 | return j === 1 | ||
| 387 | ? generateKeyBetween(null, committed[0]!.position) | ||
| 388 | : generateKeyBetween( | ||
| 389 | committed[j - 2]!.position, | ||
| 390 | committed[j - 1]!.position, | ||
| 391 | ); | ||
| 392 | }; | ||
| 393 | const positionBelow = (id: string): string => { | ||
| 394 | const j = committedIndex(id); | ||
| 395 | if (j === -1 || j >= committed.length - 1) | ||
| 396 | return generateKeyBetween( | ||
| 397 | committed[committed.length - 1]!.position, | ||
| 398 | null, | ||
| 399 | ); | ||
| 400 | return j === committed.length - 2 | ||
| 401 | ? generateKeyBetween( | ||
| 402 | committed[committed.length - 1]!.position, | ||
| 403 | null, | ||
| 404 | ) | ||
| 405 | : generateKeyBetween( | ||
| 406 | committed[j + 1]!.position, | ||
| 407 | committed[j + 2]!.position, | ||
| 408 | ); | ||
| 409 | }; | ||
| 410 | return ( | ||
| 411 | <CardStack> | ||
| 412 | <CardStackHeader>Active policies</CardStackHeader> | ||
| 413 | <CardStackContent> | ||
| 414 | {sorted.length === 0 ? ( | ||
| 415 | <CardStackEntry> | ||
| 416 | <CardStackEntryContent> | ||
| 417 | <CardStackEntryDescription> | ||
| 418 | No policies yet. Tools fall back to their plugin's | ||
| 419 | default approval behavior. | ||
| 420 | </CardStackEntryDescription> | ||
| 421 | </CardStackEntryContent> | ||
| 422 | </CardStackEntry> | ||
| 423 | ) : ( | ||
| 424 | sorted.map((p) => { | ||
| 425 | const j = committedIndex(p.id); | ||
| 426 | // Pending placeholder or only one committed row → no | ||
| 427 | // reorder affordance. | ||
| 428 | const reorderable = j !== -1 && committed.length > 1; | ||
| 429 | return ( | ||
| 430 | <PolicyRow | ||
| 431 | key={p.id} | ||
| 432 | policy={{ | ||
| 433 | id: p.id, | ||
| 434 | pattern: p.pattern, | ||
| 435 | action: p.action, | ||
| 436 | }} | ||
| 437 | isFirst={!reorderable || j === 0} | ||
| 438 | isLast={!reorderable || j === committed.length - 1} | ||
| 439 | onRemove={() => handleRemove(p.id)} | ||
| 440 | onChangeAction={(action) => | ||
| 441 | handleUpdate(p.id, action) | ||
| 442 | } | ||
| 443 | onMoveUp={() => handleMove(p.id, positionAbove(p.id))} | ||
| 444 | onMoveDown={() => | ||
| 445 | handleMove(p.id, positionBelow(p.id)) | ||
| 446 | } | ||
| 447 | /> | ||
| 448 | ); | ||
| 449 | }) | ||
| 450 | )} | ||
| 451 | </CardStackContent> | ||
| 452 | </CardStack> | ||
| 453 | ); | ||
| 454 | }, | ||
| 455 | })} | ||
| 456 | </div> | ||
| 457 | </div> | ||
| 458 | ); | ||
| 459 | } | ||
| 460 | |||
| 461 | // Exported for tests / direct consumers that don't want the matcher | ||
| 462 | // duplicated in two places. Cloud's UI uses these for live preview. | ||
| 463 | export { matchesPattern, isValidPattern }; | ||
| @@ -1,7 +1,9 @@ | |||
| 1 | import { Suspense, useEffect, useMemo, useState } from "react"; | 1 | import { Suspense, useEffect, useMemo, useState } from "react"; |
| 2 | import { useNavigate } from "@tanstack/react-router"; | 2 | import { useNavigate } from "@tanstack/react-router"; |
| 3 | import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; | 3 | import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; |
| 4 | import { effectivePolicyFromSorted } from "@executor/sdk"; | ||
| 4 | import { | 5 | import { |
| 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} /> |
| @@ -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]] | ||
| 71 | name = "wrdn-effect-atom-optimistic" | ||
| 72 | paths = [ | ||
| 73 | "packages/react/src/api/atoms.tsx", | ||
| 74 | "packages/react/src/api/optimistic.tsx", | ||
| 75 | "packages/react/src/pages/**/*.tsx", | ||
| 76 | "packages/react/src/components/**/*.tsx", | ||
| 77 | ] | ||