Skip to content

Commit 3fa2359

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

46 files changed

Lines changed: 2935 additions & 36 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)