@lvy-jsd/dsh-tool-manager 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,317 @@
1
+ // Per-agent visibility: an allowlist installed with `agent.ctx.tools.restrict`.
2
+ //
3
+ // allow(agent) = (pinned ∪ loaded tools ∪ tools of subscribed groups) − excluded
4
+ // ∩ names the host currently lets this agent's scope restrict
5
+ //
6
+ // An allowlist (not a denylist) means a tool that appears later — an MCP server
7
+ // reconnecting, a plugin installed, a tool renamed — stays hidden until loaded.
8
+ // Restrictions from other plugins (connector governance deny) intersect with
9
+ // ours, so a hard deny always wins.
10
+
11
+ import type { Agent, AgentContext, Catalog, Disposer, HostContext, LoadState, Logger, SerializedState, StateProvider } from './types.js'
12
+ import type { RegisteredFilter } from './service.js'
13
+
14
+ const STATE_KEYS = ['loaded', 'groups', 'excluded', 'handoffGroups', 'handoffTools'] as const
15
+
16
+ function emptyState(): LoadState {
17
+ return {
18
+ loaded: new Set(), // individual tool names
19
+ groups: new Set(), // subscribed group names (future tools included)
20
+ excluded: new Set(), // tools unloaded from inside a subscribed group
21
+ handoffGroups: new Set(),
22
+ handoffTools: new Set(),
23
+ }
24
+ }
25
+
26
+ /** Plain JSON form of one agent's state (sorted arrays). */
27
+ export function serializeState(state: LoadState): SerializedState {
28
+ const out = {} as SerializedState
29
+ for (const key of STATE_KEYS) out[key] = [...state[key]].sort()
30
+ return out
31
+ }
32
+
33
+ /** Inverse of serializeState; unknown keys are ignored, values coerced to strings. */
34
+ export function deserializeState(raw: Partial<Record<keyof LoadState, Iterable<unknown>>> | null | undefined): LoadState {
35
+ const state = emptyState()
36
+ for (const key of STATE_KEYS) for (const value of raw?.[key] ?? []) state[key].add(String(value))
37
+ return state
38
+ }
39
+
40
+ export interface VisibilityOptions {
41
+ pinned: () => Iterable<string>
42
+ entryTools?: readonly string[]
43
+ getFilters?: () => readonly RegisteredFilter[]
44
+ getCatalog: (agent?: Agent) => Catalog
45
+ invalidate?: () => void
46
+ onStateChange?: (agent: Agent, state: SerializedState) => void
47
+ isExempt?: (agent: Agent) => boolean
48
+ logger?: Logger
49
+ }
50
+
51
+ interface RegisteredProvider {
52
+ name: string
53
+ load: NonNullable<StateProvider['load']>
54
+ save: NonNullable<StateProvider['save']>
55
+ }
56
+
57
+ export type VisibilityController = ReturnType<typeof createVisibilityController>
58
+
59
+ export function createVisibilityController(ctx: Pick<HostContext, 'tools'>, { pinned, entryTools = [], getFilters = () => [], getCatalog, invalidate, onStateChange, isExempt = () => false, logger }: VisibilityOptions) {
60
+ const states = new Map<Agent, LoadState>()
61
+ const installed = new Map<Agent, { key: string, dispose: Disposer }>()
62
+ const providers: RegisteredProvider[] = [] // state providers (persistence plugins)
63
+ const touched = new Set<Agent>() // agents mutated by a load/unload
64
+ let agentsApi: AgentContext['agents'] | null = null
65
+ let syncing = false
66
+ let disposed = false
67
+
68
+ // --- names the host will accept in restrict() for this agent ------------
69
+ function restrictableNames(agent: Agent): Set<string> {
70
+ try {
71
+ if (typeof ctx.tools.view === 'function') return new Set(ctx.tools.view(agent).restrictableNames)
72
+ } catch (error) {
73
+ logger?.debug?.(`tool-manager: view() unavailable: ${(error as Error).message}`)
74
+ }
75
+ return new Set(ctx.tools.schemas().map((s) => s.name))
76
+ }
77
+
78
+ function allowFor(agent: Agent): string[] {
79
+ const state = states.get(agent)!
80
+ const catalog = getCatalog(agent)
81
+ const wanted = new Set(pinned())
82
+ for (const name of state.loaded) wanted.add(name)
83
+ for (const group of state.groups) {
84
+ for (const tool of catalog.groups.get(group)?.tools ?? []) {
85
+ if (!tool.highRisk && !state.excluded.has(tool.name)) wanted.add(tool.name)
86
+ }
87
+ }
88
+ const known = restrictableNames(agent)
89
+ let allow = [...wanted].filter((name) => known.has(name))
90
+ // Extension filters may only narrow, and never hide the entry tools (the
91
+ // model's way back to the directory).
92
+ for (const { name, filter } of getFilters()) {
93
+ try {
94
+ const kept = new Set(filter(agent, [...allow]) ?? allow)
95
+ allow = allow.filter((n) => kept.has(n) || entryTools.includes(n))
96
+ } catch (error) {
97
+ logger?.warn?.(`tool-manager: visibility filter ${name} failed: ${(error as Error).message}`)
98
+ }
99
+ }
100
+ return allow.sort()
101
+ }
102
+
103
+ // restrict() emits tools/change synchronously, which re-enters install()
104
+ // for the same agent before the outer call has recorded its disposer. A
105
+ // nested install must not stack a second restriction (restrictions
106
+ // intersect, and an orphan would hide every later load), so it only marks
107
+ // the agent dirty and the outer call loops.
108
+ const busy = new Set<Agent>()
109
+ const dirty = new Set<Agent>()
110
+
111
+ function install(agent: Agent) {
112
+ if (!states.has(agent) || agent?.ctx?.tools?.restrict === undefined) return
113
+ if (busy.has(agent)) {
114
+ dirty.add(agent)
115
+ return
116
+ }
117
+ busy.add(agent)
118
+ try {
119
+ do {
120
+ dirty.delete(agent)
121
+ installOnce(agent)
122
+ } while (dirty.has(agent))
123
+ } finally {
124
+ busy.delete(agent)
125
+ }
126
+ }
127
+
128
+ function installOnce(agent: Agent) {
129
+ // An exempt agent (e.g. a Claude Code route, whose tool list is handed over
130
+ // once at session start and never refreshed) gets no restriction at all; a
131
+ // restriction installed before it switched route is lifted.
132
+ if (isExempt(agent)) {
133
+ installed.get(agent)?.dispose?.()
134
+ installed.delete(agent)
135
+ return
136
+ }
137
+ const allow = allowFor(agent)
138
+ const key = allow.join('\n')
139
+ const current = installed.get(agent)
140
+ if (current?.key === key) return
141
+ try {
142
+ // New restriction first, then release the old one, so there is never a
143
+ // moment with no restriction (which would expose every tool).
144
+ const dispose = agent.ctx!.tools!.restrict!({ allow })
145
+ installed.set(agent, { key, dispose })
146
+ current?.dispose?.()
147
+ } catch (error) {
148
+ logger?.warn?.(`tool-manager: restrict agent ${String(agent?.id)} failed: ${(error as Error).message}`)
149
+ }
150
+ }
151
+
152
+ function syncAll() {
153
+ if (disposed || !agentsApi || syncing) return
154
+ // restrict() emits tools/change synchronously; the nested event would only
155
+ // rescan the same state.
156
+ syncing = true
157
+ try {
158
+ for (const agent of agentsApi.list()) {
159
+ if (!states.has(agent)) adopt(agent)
160
+ install(agent)
161
+ }
162
+ const live = agentsApi.list()
163
+ for (const agent of [...states.keys()]) if (!live.includes(agent)) forget(agent)
164
+ } finally {
165
+ syncing = false
166
+ }
167
+ }
168
+
169
+ function parentOf(agent: Agent): Agent | undefined {
170
+ const api = agentsApi
171
+ if (typeof api?.isOwnedBy !== 'function') return undefined
172
+ return api.list().find((candidate) => candidate !== agent && api.isOwnedBy!(agent.id, candidate))
173
+ }
174
+
175
+ /** State handed back by a provider, or null when none has any. */
176
+ function stateFromProviders(agent: Agent): LoadState | null {
177
+ for (const { name, load } of providers) {
178
+ try {
179
+ const raw = load(agent)
180
+ if (raw) return deserializeState(raw)
181
+ } catch (error) {
182
+ logger?.warn?.(`tool-manager: state provider ${name} failed to load: ${(error as Error).message}`)
183
+ }
184
+ }
185
+ return null
186
+ }
187
+
188
+ /** What an agent starts from: pinned tools plus the parent's handoff. */
189
+ function fallbackState(agent: Agent): LoadState {
190
+ const state = emptyState()
191
+ const parent = parentOf(agent)
192
+ const parentState = parent && states.get(parent)
193
+ if (parentState) {
194
+ for (const group of parentState.handoffGroups) state.groups.add(group)
195
+ for (const tool of parentState.handoffTools) state.loaded.add(tool)
196
+ }
197
+ return state
198
+ }
199
+
200
+ function adopt(agent: Agent) {
201
+ states.set(agent, stateFromProviders(agent) ?? fallbackState(agent))
202
+ }
203
+
204
+ /** Save a state change through every provider (fire and forget). */
205
+ function broadcast(agent: Agent, state: SerializedState) {
206
+ for (const { name, save } of providers) {
207
+ try {
208
+ save(agent, state)
209
+ } catch (error) {
210
+ logger?.warn?.(`tool-manager: state provider ${name} failed to save: ${(error as Error).message}`)
211
+ }
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Register a state provider so loaded state can outlive the process (DSH
217
+ * restart). `load(agent)` returns a serialized state or null; `save(agent,
218
+ * state)` receives plain JSON. Returns a disposer.
219
+ */
220
+ function registerStateProvider(provider: StateProvider = {}): Disposer {
221
+ const entry: RegisteredProvider = { name: provider.name ?? 'anonymous', load: provider.load ?? (() => null), save: provider.save ?? (() => {}) }
222
+ providers.push(entry)
223
+ // A persistence plugin normally mounts after this controller's first sync,
224
+ // so agents already adopted from the fallback state (nothing loaded yet)
225
+ // get one more chance to restore. An agent the user already changed is
226
+ // never overwritten.
227
+ for (const agent of [...states.keys()]) {
228
+ if (touched.has(agent)) continue
229
+ const restored = stateFromProviders(agent)
230
+ if (restored) {
231
+ states.set(agent, restored)
232
+ install(agent)
233
+ }
234
+ }
235
+ return () => {
236
+ const i = providers.indexOf(entry)
237
+ if (i >= 0) providers.splice(i, 1)
238
+ }
239
+ }
240
+
241
+ function forget(agent: Agent) {
242
+ states.delete(agent)
243
+ touched.delete(agent)
244
+ installed.get(agent)?.dispose?.()
245
+ installed.delete(agent)
246
+ }
247
+
248
+ function mount(agentCtx: AgentContext): Disposer {
249
+ agentsApi = agentCtx.agents
250
+ const stops = [
251
+ agentCtx.on('agent/created', ({ agent }: { agent: Agent }) => {
252
+ invalidate?.()
253
+ if (!states.has(agent)) adopt(agent)
254
+ install(agent)
255
+ }),
256
+ agentCtx.on('agent/disposed', ({ agent }: { agent: Agent }) => forget(agent)),
257
+ agentCtx.on('tools/change', () => { invalidate?.(); syncAll() }),
258
+ // A route switch (model/selection) can make an agent exempt or not; it is
259
+ // appended before the next request, so re-evaluate right away.
260
+ agentCtx.on('session/event', (session: { id?: unknown } | undefined, event: { type?: string } | undefined) => {
261
+ if (event?.type !== 'model/selection' && event?.type !== 'request/context') return
262
+ const agent = agentsApi?.get?.(session?.id)
263
+ if (agent && states.has(agent)) install(agent)
264
+ }),
265
+ ]
266
+ // Agents that already exist (resumed before this plugin mounted).
267
+ syncAll()
268
+ return () => {
269
+ for (const stop of stops) stop?.()
270
+ for (const agent of [...installed.keys()]) forget(agent)
271
+ agentsApi = null
272
+ }
273
+ }
274
+
275
+ function stateOf(agent: Agent): LoadState {
276
+ if (!states.has(agent)) adopt(agent)
277
+ return states.get(agent)!
278
+ }
279
+
280
+ /** Apply a mutation to one agent's state, reinstall, and notify listeners. */
281
+ function update<R>(agent: Agent, mutate: (state: LoadState) => R): R {
282
+ const state = stateOf(agent)
283
+ touched.add(agent)
284
+ const result = mutate(state)
285
+ install(agent)
286
+ const plain = serializeState(state)
287
+ broadcast(agent, plain)
288
+ onStateChange?.(agent, plain)
289
+ return result
290
+ }
291
+
292
+ /** Replace one agent's state (e.g. restored by a persistence plugin). */
293
+ function importState(agent: Agent, raw: Parameters<typeof deserializeState>[0]) {
294
+ states.set(agent, deserializeState(raw))
295
+ install(agent)
296
+ }
297
+
298
+ return {
299
+ mount,
300
+ update,
301
+ agents: (): Agent[] => agentsApi?.list() ?? [],
302
+ parentOf,
303
+ stateOf,
304
+ registerStateProvider,
305
+ stateProviders: () => providers.map((p) => p.name),
306
+ exportState: (agent: Agent) => serializeState(stateOf(agent)),
307
+ importState,
308
+ // null for an agent this plugin does not restrict (untracked or exempt).
309
+ allowFor: (agent: Agent): string[] | null => (states.has(agent) && !isExempt(agent) ? allowFor(agent) : null),
310
+ isExempt: (agent: Agent) => isExempt(agent),
311
+ refresh: syncAll,
312
+ dispose() {
313
+ disposed = true
314
+ for (const agent of [...installed.keys()]) forget(agent)
315
+ },
316
+ }
317
+ }